diff --git a/packages/lexical-playground/__tests__/e2e/Attachments.spec.mjs b/packages/lexical-playground/__tests__/e2e/Attachments.spec.mjs new file mode 100644 index 00000000000..a4b281cfcc3 --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/Attachments.spec.mjs @@ -0,0 +1,536 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {expect} from '@playwright/test'; + +import {moveLeft, selectAll} from '../keyboardShortcuts/index.mjs'; +import { + assertHTML, + assertSelection, + click, + copyToClipboard, + focusEditor, + getPageOrFrame, + html, + initialize, + pasteFromClipboard, + selectFromInsertDropdown, + test, + waitForSelector, + withExclusiveClipboardAccess, +} from '../utils/index.mjs'; + +const SAMPLE_ATTACHMENT_PATH = + 'packages/lexical-playground/__tests__/e2e/fixtures/sample.txt'; + +async function insertAttachment(page, filePath) { + await selectFromInsertDropdown(page, '.attachment'); + + // Wait for the modal to fully mount before interacting + await waitForSelector( + page, + 'input[data-test-id="attachment-modal-file-upload"]', + ); + + const frame = getPageOrFrame(page); + await frame.setInputFiles( + 'input[data-test-id="attachment-modal-file-upload"]', + filePath, + ); + + await click(page, 'button[data-test-id="attachment-modal-file-upload-btn"]'); +} + +test.describe('Attachments', () => { + test.use({acceptDownloads: true}); + test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); + + test('Can insert attachment via toolbar and see it rendered', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + await assertHTML( + page, + html` +

+ +

+
πŸ“„
+
+
+ sample.txt +
+
40 Bytes
+
+
+ +
+

+ `, + ); + + await assertSelection(page, { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0], + }); + }); + + test('Can select attachment by clicking and shows selected class', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + // Skip collab for selection/focus tests due to frame issues + test.skip(isCollab); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Click on the attachment to select it + await click(page, '.AttachmentNode__container'); + + // Verify the selected class is applied + await waitForSelector(page, '.AttachmentNode__container.selected'); + + await assertHTML( + page, + html` +

+ +

+
πŸ“„
+
+
+ sample.txt +
+
40 Bytes
+
+
+ +
+

+ `, + ); + }); + + test('Floating toolbar appears when attachment is selected', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + // Skip collab for selection/focus tests due to frame issues + test.skip(isCollab); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Click on the attachment to select it + await click(page, '.AttachmentNode__container'); + + // Wait for the floating toolbar to appear + await waitForSelector(page, '.AttachmentNode__floatingToolbar'); + + // Verify toolbar buttons are present + const frame = getPageOrFrame(page); + const downloadButton = frame.locator( + '.AttachmentNode__toolbarButton[aria-label="download attachment"]', + ); + const deleteButton = frame.locator( + '.AttachmentNode__toolbarButton--delete[aria-label="delete attachment"]', + ); + + await expect(downloadButton).toBeVisible(); + await expect(deleteButton).toBeVisible(); + }); + + test('Can download attachment via floating toolbar', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + // Skip collab for selection/focus tests due to frame issues + test.skip(isCollab); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Click on the attachment to select it + await click(page, '.AttachmentNode__container'); + + // Wait for the floating toolbar to appear + await waitForSelector(page, '.AttachmentNode__floatingToolbar'); + + // Set up download listener before clicking download button + const downloadPromise = page.waitForEvent('download'); + + // Click the download button + await click( + page, + '.AttachmentNode__toolbarButton[aria-label="download attachment"]', + ); + + // Wait for the download to trigger + const download = await downloadPromise; + + // Verify the download has the correct filename + expect(download.suggestedFilename()).toBe('sample.txt'); + }); + + test('Can navigate around attachment with arrow keys', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await page.keyboard.type('Before'); + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + await waitForSelector(page, '.AttachmentNode__container'); + await page.keyboard.type('After'); + + await assertHTML( + page, + html` +

+ Before + +

+
πŸ“„
+
+
+ sample.txt +
+
40 Bytes
+
+
+ + After +

+ `, + ); + + // Navigate backwards with ArrowLeft + await moveLeft(page, 7); // 5 for "After" + 2 (to cross attachment) + + await assertSelection(page, { + anchorOffset: 6, + anchorPath: [0, 0, 0], + focusOffset: 6, + focusPath: [0, 0, 0], + }); + + // Navigate forward again + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + + // Should be at the beginning of "After" + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0, 2, 0], + focusOffset: 0, + focusPath: [0, 2, 0], + }); + }); + + test('Can delete attachment with Delete key when cursor is before it', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Move cursor to before the attachment + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 0, + focusPath: [0], + }); + + // Delete the attachment + await page.keyboard.press('Delete'); + + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can delete attachment with Backspace when cursor is after it', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Cursor should be after the attachment + await assertSelection(page, { + anchorOffset: 1, + anchorPath: [0], + focusOffset: 1, + focusPath: [0], + }); + + // Delete the attachment with Backspace + await page.keyboard.press('Backspace'); + + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can delete attachment via floating toolbar delete button', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText); + // Skip collab for selection/focus tests due to frame issues + test.skip(isCollab); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Click on the attachment to select it and show toolbar + await click(page, '.AttachmentNode__container'); + + // Wait for the floating toolbar to appear + await waitForSelector(page, '.AttachmentNode__floatingToolbar'); + + // Click the delete button + await click( + page, + '.AttachmentNode__toolbarButton--delete[aria-label="delete attachment"]', + ); + + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can add multiple attachments and delete them individually', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + // Insert first attachment + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + await waitForSelector(page, '.AttachmentNode__container'); + + // Insert second attachment + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + // Verify we have two attachments + const frame = getPageOrFrame(page); + const attachments = frame.locator('.AttachmentNode__container'); + await expect(attachments).toHaveCount(2); + + // Delete the second attachment using backspace (cursor is after it) + await page.keyboard.press('Backspace'); + + // Should have one attachment remaining + await expect(attachments).toHaveCount(1); + + // Delete the remaining attachment + await page.keyboard.press('Backspace'); + + await assertHTML( + page, + html` +


+ `, + ); + }); + + test('Can copy and paste attachment', async ({page, isPlainText}) => { + test.skip(isPlainText); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Select all content + await selectAll(page); + + await withExclusiveClipboardAccess(async () => { + // Copy + const clipboard = await copyToClipboard(page); + + // Clear editor + await page.keyboard.press('Backspace'); + + await assertHTML( + page, + html` +


+ `, + ); + + // Paste + await pasteFromClipboard(page, clipboard); + }); + + // Verify attachment is restored + await waitForSelector(page, '.AttachmentNode__container'); + + await assertHTML( + page, + html` +

+ +

+
πŸ“„
+
+
+ sample.txt +
+
40 Bytes
+
+
+ +
+

+ `, + ); + }); + + test('Can insert attachment after text and before text', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await page.keyboard.type('Hello'); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + await page.keyboard.type('World'); + + await assertHTML( + page, + html` +

+ Hello + +

+
πŸ“„
+
+
+ sample.txt +
+
40 Bytes
+
+
+ + World +

+ `, + ); + }); + + test('Attachment has correct data-lexical-decorator attribute', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container'); + + // Check the decorator attribute exists + const frame = getPageOrFrame(page); + const decorator = frame.locator('[data-lexical-decorator="true"]'); + await expect(decorator).toHaveCount(1); + + // Verify the attachment container is inside the decorator span + const attachmentInDecorator = frame.locator( + '[data-lexical-decorator="true"] .AttachmentNode__container', + ); + await expect(attachmentInDecorator).toHaveCount(1); + }); + + test('Attachment shows draggable class when editable', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await insertAttachment(page, SAMPLE_ATTACHMENT_PATH); + + await waitForSelector(page, '.AttachmentNode__container.draggable'); + + const frame = getPageOrFrame(page); + const draggableAttachment = frame.locator( + '.AttachmentNode__container.draggable', + ); + await expect(draggableAttachment).toHaveCount(1); + }); +}); diff --git a/packages/lexical-playground/__tests__/e2e/fixtures/sample.txt b/packages/lexical-playground/__tests__/e2e/fixtures/sample.txt new file mode 100644 index 00000000000..d97aeb9efb2 --- /dev/null +++ b/packages/lexical-playground/__tests__/e2e/fixtures/sample.txt @@ -0,0 +1 @@ +cSample attachment content for testing. diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 7d7d1a9c7dd..2a46575a70a 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -22,6 +22,7 @@ import {type JSX, useMemo} from 'react'; import {isDevPlayground} from './appSettings'; import {buildHTMLConfig} from './buildHTMLConfig'; +import {AttachmentStoreProvider} from './context/AttachmentStoreContext'; import {FlashMessageContext} from './context/FlashMessageContext'; import {SettingsContext, useSettings} from './context/SettingsContext'; import {SharedHistoryContext} from './context/SharedHistoryContext'; @@ -176,7 +177,9 @@ export default function PlaygroundApp(): JSX.Element { return ( - + + + + diff --git a/packages/lexical-playground/src/context/AttachmentStoreContext.tsx b/packages/lexical-playground/src/context/AttachmentStoreContext.tsx new file mode 100644 index 00000000000..edf6698dfde --- /dev/null +++ b/packages/lexical-playground/src/context/AttachmentStoreContext.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {AttachmentStore} from '../stores/AttachmentStore'; +import type {JSX, ReactNode} from 'react'; + +import {createContext, useContext, useEffect, useMemo} from 'react'; + +import {DemoAttachmentStore} from '../stores/DemoAttachmentStore'; + +export interface AttachmentStoreContextValue { + store: AttachmentStore; + /** Flag to show UI warning about demo mode limitations */ + showDemoWarning: boolean; +} + +const Context = createContext(null); + +interface AttachmentStoreProviderProps { + children: ReactNode; + /** Custom store implementation. Defaults to DemoAttachmentStore */ + store?: AttachmentStore; +} + +export function AttachmentStoreProvider({ + children, + store, +}: AttachmentStoreProviderProps): JSX.Element { + const contextValue = useMemo(() => { + const resolvedStore = store ?? new DemoAttachmentStore(); + return { + showDemoWarning: + !resolvedStore.isPersistent() && + resolvedStore.getConfig().demoWarningLevel === 'ui', + store: resolvedStore, + }; + }, [store]); + + // Cleanup on unmount + useEffect(() => { + return () => { + const currentStore = contextValue.store; + if (currentStore instanceof DemoAttachmentStore) { + currentStore.cleanup(); + } + }; + }, [contextValue.store]); + + return {children}; +} + +export function useAttachmentStore(): AttachmentStoreContextValue { + const ctx = useContext(Context); + if (!ctx) { + throw new Error( + 'useAttachmentStore must be used within AttachmentStoreProvider', + ); + } + return ctx; +} diff --git a/packages/lexical-playground/src/images/icons/paperclip.svg b/packages/lexical-playground/src/images/icons/paperclip.svg new file mode 100644 index 00000000000..c02950b5319 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/paperclip.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index baa0b2e7681..58f436f4122 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -621,6 +621,11 @@ i.page-break, background-image: url(images/icons/scissors.svg); } +i.attachment, +.icon.attachment { + background-image: url(images/icons/paperclip.svg); +} + .link-editor .button.active, .toolbar .button.active { background-color: rgb(223, 232, 250); diff --git a/packages/lexical-playground/src/nodes/AttachmentComponent.tsx b/packages/lexical-playground/src/nodes/AttachmentComponent.tsx new file mode 100644 index 00000000000..d860109223c --- /dev/null +++ b/packages/lexical-playground/src/nodes/AttachmentComponent.tsx @@ -0,0 +1,465 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalCommand, LexicalEditor, NodeKey} from 'lexical'; +import type {JSX} from 'react'; + +import './AttachmentNode.css'; + +import { + autoUpdate, + flip, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, +} from '@floating-ui/react'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; +import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; +import {mergeRegister} from '@lexical/utils'; +import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGSTART_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Suspense, useCallback, useEffect, useRef, useState} from 'react'; + +import joinClasses from '../utils/joinClasses'; +import {$isAttachmentNode} from './AttachmentNode'; + +// file type to icon mapping +// use the object key as acceptable mime type +const getFileIcon = (fileType: string, fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + + switch (extension) { + case 'pdf': + return 'πŸ“„'; + case 'doc': + case 'docx': + return 'πŸ“'; + case 'xls': + case 'xlsx': + return 'πŸ“Š'; + case 'ppt': + case 'pptx': + return 'πŸ“‹'; + case 'zip': + case 'rar': + return 'πŸ“¦'; + case 'txt': + return 'πŸ“„'; + case 'csv': + return 'πŸ“Š'; + case 'jpg': + case 'jpeg': + case 'png': + case 'gif': + case 'webp': + return 'πŸ–ΌοΈ'; + case 'mp4': + case 'avi': + case 'mov': + return 'πŸŽ₯'; + case 'mp3': + case 'wav': + case 'flac': + return '🎡'; + default: + // if no match, use the default icon + return 'πŸ“Ž'; + } +}; + +const formatFileSize = (bytes: number): string => { + if (bytes === 0) { + return '0 Bytes'; + } + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +export const DELETE_ATTACHMENT_COMMAND: LexicalCommand< + MouseEvent | KeyboardEvent +> = createCommand('DELETE_ATTACHMENT_COMMAND'); + +export default function AttachmentComponent({ + fileName, + fileSize, + fileType, + fileUrl, + nodeKey, +}: { + fileName: string; + fileSize: number; + fileType: string; + fileUrl: string; + nodeKey: NodeKey; +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey); + const [isHovered, setIsHovered] = useState(false); + const [showFloatingToolbar, setShowFloatingToolbar] = useState(true); + const [isDragging, setIsDragging] = useState(false); + const isEditable = useLexicalEditable(); + const attachmentRef = useRef(null); + const activeEditorRef = useRef(null); + + // Floating UI setup + const {refs, floatingStyles, context} = useFloating({ + middleware: [offset(8), flip(), shift({padding: 8})], + onOpenChange: (open) => { + if (!open) { + setShowFloatingToolbar(false); + } + }, + open: isSelected, + placement: 'bottom', + whileElementsMounted: autoUpdate, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + click, + dismiss, + ]); + + const handleDownload = useCallback(() => { + const link = document.createElement('a'); + link.href = fileUrl; + link.download = fileName; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setShowFloatingToolbar(false); + }, [fileName, fileUrl]); + + const $onEnter = useCallback( + (event: KeyboardEvent) => { + const latestSelection = $getSelection(); + if ( + isSelected && + $isNodeSelection(latestSelection) && + latestSelection.getNodes().length === 1 + ) { + event.preventDefault(); + handleDownload(); + return true; + } + return false; + }, + [isSelected, handleDownload], + ); + + const $onEscape = useCallback( + (event: KeyboardEvent) => { + if ( + activeEditorRef.current === attachmentRef.current || + event.target === attachmentRef.current + ) { + $setSelection(null); + editor.update(() => { + setSelected(false); + const parentRootElement = editor.getRootElement(); + if (parentRootElement !== null) { + parentRootElement.focus(); + } + }); + return true; + } + return false; + }, + [editor, setSelected], + ); + + const $onDelete = useCallback( + (event: KeyboardEvent) => { + if (isSelected) { + event.preventDefault(); + editor.dispatchCommand(DELETE_ATTACHMENT_COMMAND, event); + return true; + } + return false; + }, + [isSelected, editor], + ); + + const onClick = useCallback( + (payload: MouseEvent) => { + const event = payload; + + if ( + event.target === attachmentRef.current || + attachmentRef.current?.contains(event.target as Node) + ) { + if (event.shiftKey) { + setSelected(!isSelected); + } else { + clearSelection(); + setSelected(true); + } + + // Show floating toolbar when selecting + if (isEditable) { + setShowFloatingToolbar(true); + } + return true; + } + + return false; + }, + [isSelected, setSelected, clearSelection, isEditable], + ); + + const handleDelete = useCallback(() => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isAttachmentNode(node)) { + node.remove(); + } + }); + setShowFloatingToolbar(false); + }, [editor, nodeKey]); + + const handleToolbarClose = useCallback(() => { + setShowFloatingToolbar(false); + }, []); + + // Drag handlers + const handleDragStart = useCallback((event: DragEvent) => { + if (event.target === attachmentRef.current) { + setIsDragging(true); + const dragImage = new Image(); + dragImage.src = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + event.dataTransfer?.setDragImage(dragImage, 0, 0); + event.dataTransfer!.effectAllowed = 'move'; + return true; + } + return false; + }, []); + + const handleDragEnd = useCallback(() => { + setIsDragging(false); + }, []); + + useEffect(() => { + const unregister = mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_, activeEditor) => { + activeEditorRef.current = activeEditor; + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CLICK_COMMAND, + onClick, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand(KEY_ENTER_COMMAND, $onEnter, COMMAND_PRIORITY_LOW), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + $onEscape, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + $onDelete, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + $onDelete, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DELETE_ATTACHMENT_COMMAND, + () => { + handleDelete(); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + return handleDragStart(event); + }, + COMMAND_PRIORITY_LOW, + ), + ); + + return unregister; + }, [ + editor, + isSelected, + nodeKey, + $onEnter, + $onEscape, + $onDelete, + onClick, + handleDelete, + handleDragStart, + setSelected, + ]); + + // Set floating UI reference + useEffect(() => { + if (attachmentRef.current) { + refs.setReference(attachmentRef.current); + } + }, [refs]); + + // Handle drag events + useEffect(() => { + const element = attachmentRef.current; + if (!element) { + return; + } + + const onDragEnd = () => handleDragEnd(); + + element.addEventListener('dragend', onDragEnd); + + return () => { + element.removeEventListener('dragend', onDragEnd); + }; + }, [handleDragEnd]); + + const fileIcon = getFileIcon(fileType, fileName); + const formattedFileSize = formatFileSize(fileSize); + + return ( + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + draggable={isEditable && isSelected} + {...getReferenceProps()}> +
{fileIcon}
+ +
+
+ {fileName} +
+
{formattedFileSize}
+
+
+ + {/* Floating Toolbar */} + {isSelected && showFloatingToolbar && ( + +
+ +
+
+ )} +
+ ); +} + +interface AttachmentFloatingToolbarProps { + editor: LexicalEditor; + nodeKey: string; + fileName: string; + fileUrl: string; + onDelete: () => void; +} + +function AttachmentFloatingToolbar({ + editor, + nodeKey, + fileName, + fileUrl, + onDelete, +}: AttachmentFloatingToolbarProps): JSX.Element { + const isEditable = useLexicalEditable(); + const handleDownload = useCallback(() => { + const link = document.createElement('a'); + link.href = fileUrl; + link.download = fileName; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [fileName, fileUrl]); + + const handleDelete = useCallback(() => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isAttachmentNode(node)) { + node.remove(); + } + }); + onDelete(); + }, [editor, nodeKey, onDelete]); + + return ( +
+ + {isEditable && ( + + )} +
+ ); +} diff --git a/packages/lexical-playground/src/nodes/AttachmentNode.css b/packages/lexical-playground/src/nodes/AttachmentNode.css new file mode 100644 index 00000000000..2c481a057c4 --- /dev/null +++ b/packages/lexical-playground/src/nodes/AttachmentNode.css @@ -0,0 +1,168 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +.AttachmentNode__contentEditable { + min-height: 20px; + border: 0px; + resize: none; + cursor: text; + caret-color: rgb(5, 5, 5); + display: block; + position: relative; + outline: 0px; + padding: 10px; + user-select: text; + font-size: 12px; + width: calc(100% - 20px); + white-space: pre-wrap; + word-break: break-word; +} + +.AttachmentNode__placeholder { + font-size: 12px; + color: #888; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 10px; + left: 10px; + user-select: none; + white-space: nowrap; + display: inline-block; + pointer-events: none; +} + +.AttachmentNode__container { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + margin: 8px 4px; + padding: 12px; + min-width: 200px; + max-width: 400px; + border: 1px solid #e1e5e9; + border-radius: 8px; + background-color: #f8f9fa; + cursor: pointer; + transition: all 0.2s ease; +} + +.AttachmentNode__container.selected { + border-color: #1976d2; +} + +.AttachmentNode__container:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.AttachmentNode__container.draggable { + cursor: grab; +} + +.AttachmentNode__container.draggable:active { + cursor: grabbing; +} + +.AttachmentNode__container.dragging { + opacity: 0.6; + transform: rotate(2deg); + cursor: grabbing; +} + +.AttachmentNode__icon { + font-size: 24px; + min-width: 24px; + text-align: center; +} + +.AttachmentNode__content { + flex: 1; + min-width: 0; +} + +.AttachmentNode__filename { + font-weight: 500; + font-size: 14px; + color: #1c1e21; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.AttachmentNode__filesize { + font-size: 12px; + color: #65676b; + margin-top: 2px; +} + +.AttachmentNode__floatingToolbar { + display: flex; + position: absolute; + top: 0; + left: 0; + z-index: 10; + background-color: #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border-radius: 8px; + padding: 4px; + gap: 2px; + opacity: 1; + transition: opacity 0.2s ease; + will-change: transform; + border: 1px solid #e1e5e9; + transform: translateX(-50%); +} + +.AttachmentNode__toolbarButton { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + color: #65676b; + transition: all 0.2s ease; + padding: 0; +} + +.AttachmentNode__toolbarButton:hover { + background-color: #f2f3f5; + color: #1c1e21; +} + +.AttachmentNode__toolbarButton:active { + background-color: #e4e6ea; + transform: scale(0.95); +} + +.AttachmentNode__toolbarButton--delete:hover { + background-color: #ffebee; + color: #d32f2f; +} + +.AttachmentNode__toolbarButton--delete:active { + background-color: #ffcdd2; +} + +.AttachmentNode__toolbarButton i { + display: block; + width: 16px; + height: 16px; + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.AttachmentNode__container--resizing { + touch-action: none; +} diff --git a/packages/lexical-playground/src/nodes/AttachmentNode.tsx b/packages/lexical-playground/src/nodes/AttachmentNode.tsx new file mode 100644 index 00000000000..edaddb61298 --- /dev/null +++ b/packages/lexical-playground/src/nodes/AttachmentNode.tsx @@ -0,0 +1,408 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import type {JSX} from 'react'; + +import { + $applyNodeReplacement, + COMMAND_PRIORITY_HIGH, + DecoratorNode, +} from 'lexical'; +import * as React from 'react'; + +const AttachmentComponent = React.lazy( + () => import('../nodes/AttachmentComponent'), +); + +// convert base64 to Blob and create object URL +function convertBase64ToObjectURL( + base64Data: string, + mimeType: string, +): string { + try { + // Remove data URL prefix if present (e.g., "data:image/png;base64,") + const base64String = base64Data.includes(',') + ? base64Data.split(',')[1] + : base64Data; + + // Convert base64 to binary + const binaryString = atob(base64String); + const bytes = new Uint8Array(binaryString.length); + + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + // Create blob and object URL + const blob = new Blob([bytes], {type: mimeType}); + return URL.createObjectURL(blob); + } catch (error) { + console.warn('Failed to convert base64 to object URL:', error); + return base64Data; // Return original data if conversion fails + } +} + +// convert object URL to base64 +async function convertObjectURLToBase64( + objectUrl: string, + mimeType: string, +): Promise { + try { + // If it's already a data URL, return as is + if (objectUrl.startsWith('data:')) { + return objectUrl; + } + + // If it's not a blob URL, return as is + if (!objectUrl.startsWith('blob:')) { + return objectUrl; + } + + // Fetch the blob data + const response = await fetch(objectUrl); + const blob = await response.blob(); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Ensure the data URL has the correct MIME type + if (result.startsWith('data:') && mimeType) { + const base64Data = result.split(',')[1]; + resolve(`data:${mimeType};base64,${base64Data}`); + } else { + resolve(result); + } + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + } catch (error) { + console.warn('Failed to convert object URL to base64:', error); + return objectUrl; // Return original URL if conversion fails + } +} + +export interface AttachmentPayload { + fileName: string; + fileSize: number; + fileType: string; + fileUrl: string; + key?: NodeKey; + base64Data?: string; + attachmentId?: string; +} + +export type SerializedAttachmentNode = Spread< + { + fileName: string; + fileSize: number; + fileType: string; + fileUrl: string; + attachmentId?: string; + }, + SerializedLexicalNode +>; + +function $convertAttachmentElement(domNode: Node): DOMConversionOutput | null { + const element = domNode as HTMLDivElement; + + // Check AttachmentNode's unique identifier + if ( + !element.hasAttribute('data-file-name') || + !element.classList.contains('AttachmentNode__container') + ) { + return null; + } + + const fileName = element.getAttribute('data-file-name') || ''; + const fileSize = parseInt(element.getAttribute('data-file-size') || '0', 10); + const fileType = element.getAttribute('data-file-type') || ''; + let fileUrl = element.getAttribute('data-file-url') || ''; + + // check if all required properties are present + if (!fileName || !fileUrl) { + console.warn( + 'AttachmentNode conversion failed - required properties missing:', + { + fileName, + fileSize, + fileType, + fileUrl, + }, + ); + return null; + } + + // Convert base64 data to object URL if it's not already a blob URL + if (fileUrl.startsWith('data:') && !fileUrl.startsWith('blob:')) { + fileUrl = convertBase64ToObjectURL(fileUrl, fileType); + } + + const attachmentNode = $createAttachmentNode({ + fileName, + fileSize, + fileType, + fileUrl, + }); + + return {node: attachmentNode}; +} + +export class AttachmentNode extends DecoratorNode { + __fileName: string; + __fileSize: number; + __fileType: string; + __fileUrl: string; + __base64Data?: string; // Store base64 data for serialization + __attachmentId?: string; // ID from AttachmentStore + + static getType(): string { + return 'attachment'; + } + + static clone(node: AttachmentNode): AttachmentNode { + const cloned = new AttachmentNode( + node.__fileName, + node.__fileSize, + node.__fileType, + node.__fileUrl, + node.__key, + ); + cloned.__base64Data = node.__base64Data; + cloned.__attachmentId = node.__attachmentId; + return cloned; + } + + static importJSON(serializedNode: SerializedAttachmentNode): AttachmentNode { + const {fileName, fileSize, fileType, fileUrl, attachmentId} = + serializedNode; + + // Convert base64 data to object URL for editor use + const convertedFileUrl = fileUrl.startsWith('data:') + ? convertBase64ToObjectURL(fileUrl, fileType) + : fileUrl; + + const node = $createAttachmentNode({ + attachmentId, + fileName, + fileSize, + fileType, + fileUrl: convertedFileUrl, + }); + + // Store original base64 data for serialization + if (fileUrl.startsWith('data:')) { + node.__base64Data = fileUrl; + } + + return node; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('div'); + element.className = 'AttachmentNode__container'; + element.setAttribute('data-file-name', this.__fileName); + element.setAttribute('data-file-size', this.__fileSize.toString()); + element.setAttribute('data-file-type', this.__fileType); + + // Use base64 data for DOM export if available, otherwise use current fileUrl + const exportFileUrl = this.__base64Data || this.__fileUrl; + element.setAttribute('data-file-url', exportFileUrl); + + return {element}; + } + + static importDOM(): DOMConversionMap | null { + return { + div: (node: Node) => { + const element = node as HTMLDivElement; + // Check AttachmentNode's unique identifier and set high priority + if ( + element.hasAttribute('data-file-name') && + element.classList.contains('AttachmentNode__container') + ) { + return { + conversion: $convertAttachmentElement, + // Higher priority than LayoutContainerNode(2), LayoutItemNode(2) + priority: COMMAND_PRIORITY_HIGH, + }; + } + return null; + }, + }; + } + + constructor( + fileName: string, + fileSize: number, + fileType: string, + fileUrl: string, + key?: NodeKey, + ) { + super(key); + this.__fileName = fileName; + this.__fileSize = fileSize; + this.__fileType = fileType; + this.__fileUrl = fileUrl; + this.__base64Data = undefined; + } + + exportJSON(): SerializedAttachmentNode { + // Use base64 data for serialization if available, otherwise use current fileUrl + const exportFileUrl = this.__base64Data || this.getFileUrl(); + + return { + ...super.exportJSON(), + attachmentId: this.__attachmentId, + fileName: this.getFileName(), + fileSize: this.getFileSize(), + fileType: this.getFileType(), + fileUrl: exportFileUrl, + }; + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement('span'); + const theme = config.theme; + const className = theme.attachment; + if (className !== undefined) { + span.className = className; + } + return span; + } + + updateDOM(): false { + return false; + } + + getFileName(): string { + return this.__fileName; + } + + getFileSize(): number { + return this.__fileSize; + } + + getFileType(): string { + return this.__fileType; + } + + getFileUrl(): string { + return this.__fileUrl; + } + + setFileName(fileName: string): void { + const writable = this.getWritable(); + writable.__fileName = fileName; + } + + setFileSize(fileSize: number): void { + const writable = this.getWritable(); + writable.__fileSize = fileSize; + } + + setFileType(fileType: string): void { + const writable = this.getWritable(); + writable.__fileType = fileType; + } + + setFileUrl(fileUrl: string): void { + const writable = this.getWritable(); + writable.__fileUrl = fileUrl; + } + + getBase64Data(): string | undefined { + return this.__base64Data; + } + + setBase64Data(base64Data: string | undefined): void { + const writable = this.getWritable(); + writable.__base64Data = base64Data; + } + + getAttachmentId(): string | undefined { + return this.__attachmentId; + } + + setAttachmentId(attachmentId: string | undefined): void { + const writable = this.getWritable(); + writable.__attachmentId = attachmentId; + } + + // Convert current object URL to base64 and store it + async convertToBase64(): Promise { + if (this.__fileUrl.startsWith('blob:')) { + try { + const base64Data = await convertObjectURLToBase64( + this.__fileUrl, + this.__fileType, + ); + const writable = this.getWritable(); + writable.__base64Data = base64Data; + } catch (error) { + console.warn('Failed to convert attachment to base64:', error); + } + } + } + + decorate(): JSX.Element { + return ( + + ); + } +} + +export function $createAttachmentNode({ + fileName, + fileSize, + fileType, + fileUrl, + base64Data, + attachmentId, + key, +}: AttachmentPayload): AttachmentNode { + const node = $applyNodeReplacement( + new AttachmentNode(fileName, fileSize, fileType, fileUrl, key), + ); + + // Set base64 data if provided + if (base64Data) { + node.__base64Data = base64Data; + } + + // Set attachment ID if provided + if (attachmentId) { + node.__attachmentId = attachmentId; + } + + return node; +} + +export function $isAttachmentNode( + node: LexicalNode | null | undefined, +): node is AttachmentNode { + return node instanceof AttachmentNode; +} diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts index 6b0d2aee7cc..b7cac8ca19d 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts @@ -21,6 +21,7 @@ import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import {CollapsibleContainerNode} from '../plugins/CollapsiblePlugin/CollapsibleContainerNode'; import {CollapsibleContentNode} from '../plugins/CollapsiblePlugin/CollapsibleContentNode'; import {CollapsibleTitleNode} from '../plugins/CollapsiblePlugin/CollapsibleTitleNode'; +import {AttachmentNode} from './AttachmentNode'; import {AutocompleteNode} from './AutocompleteNode'; import {DateTimeNode} from './DateTimeNode/DateTimeNode'; import {EmojiNode} from './EmojiNode'; @@ -75,6 +76,7 @@ const PlaygroundNodes: Array> = [ LayoutItemNode, SpecialTextNode, DateTimeNode, + AttachmentNode, ]; export default PlaygroundNodes; diff --git a/packages/lexical-playground/src/plugins/AttachmentPlugin/index.tsx b/packages/lexical-playground/src/plugins/AttachmentPlugin/index.tsx new file mode 100644 index 00000000000..d5949ecc9a0 --- /dev/null +++ b/packages/lexical-playground/src/plugins/AttachmentPlugin/index.tsx @@ -0,0 +1,496 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {JSX} from 'react'; + +import { + $isAutoLinkNode, + $isLinkNode, + LinkNode, + TOGGLE_LINK_COMMAND, +} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import { + $findMatchingParent, + $wrapNodeInElement, + mergeRegister, +} from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelection, + $getRoot, + $getSelection, + $insertNodes, + $isNodeSelection, + $isRootOrShadowRoot, + $setSelection, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + ElementNode, + getDOMSelectionFromTarget, + isHTMLElement, + LexicalCommand, + LexicalEditor, + LexicalNode, +} from 'lexical'; +import {useEffect, useRef, useState} from 'react'; + +import {useAttachmentStore} from '../../context/AttachmentStoreContext'; +import { + $createAttachmentNode, + $isAttachmentNode, + AttachmentNode, + AttachmentPayload, +} from '../../nodes/AttachmentNode'; +import Button from '../../ui/Button'; +import {DialogActions} from '../../ui/Dialog'; +import FileInput from '../../ui/FileInput'; +import FilePreview from '../../ui/FilePreview'; + +export type InsertAttachmentPayload = Readonly; + +export const INSERT_ATTACHMENT_COMMAND: LexicalCommand = + createCommand('INSERT_ATTACHMENT_COMMAND'); + +const MAX_SIZE_MB = 3; +const ACCEPTABLE_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip', + 'application/x-rar-compressed', + 'text/plain', + 'text/csv', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'video/mp4', + 'video/x-msvideo', + 'video/quicktime', + 'audio/mpeg', + 'audio/wav', + 'audio/flac', +]; + +export function InsertAttachmentUploadedDialogBody({ + onClick, +}: { + onClick: (payload: InsertAttachmentPayload) => void; +}) { + const {store, showDemoWarning} = useAttachmentStore(); + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + + const loadFile = (files: FileList | null) => { + if (!files || !files[0]) { + setSelectedFile(null); + return; + } + + const file = files[0]; + + // Check file size + if (file.size > MAX_SIZE_MB * 1024 * 1024) { + console.warn(`File size exceeds the maximum limit of ${MAX_SIZE_MB}MB`); + setSelectedFile(null); + return; + } + + // Check file type + const fileType = file.type || getFileTypeFromName(file.name); + if (!ACCEPTABLE_TYPES.includes(fileType)) { + console.warn('File type is not supported'); + setSelectedFile(null); + return; + } + + setSelectedFile(file); + }; + + const handleSubmit = async () => { + if (selectedFile) { + setIsUploading(true); + try { + const fileType = + selectedFile.type || getFileTypeFromName(selectedFile.name); + + // Upload file using the store + const stored = await store.upload({ + file: selectedFile, + fileName: selectedFile.name, + fileSize: selectedFile.size, + fileType, + }); + + // Get base64 data if the store supports it (for serialization) + let base64Data: string | undefined; + if (store.getConfig().serializeAsBase64) { + const base64 = await store.toBase64(stored.id); + if (base64) { + base64Data = base64; + } + } + + onClick({ + attachmentId: stored.id, + base64Data, + fileName: stored.fileName, + fileSize: stored.fileSize, + fileType: stored.fileType, + fileUrl: stored.url, + }); + } catch (error) { + console.error('Failed to upload attachment:', error); + } finally { + setIsUploading(false); + } + } + }; + + const isDisabled = !selectedFile || isUploading; + + return ( + <> + {showDemoWarning && ( +
+ Demo mode: Files will be lost on page reload +
+ )} + + {selectedFile && ( + + )} + + + + + ); +} + +export function InsertAttachmentDialog({ + activeEditor, + onClose, +}: { + activeEditor: LexicalEditor; + onClose: () => void; +}): JSX.Element { + const hasModifier = useRef(false); + + useEffect(() => { + hasModifier.current = false; + const handler = (e: KeyboardEvent) => { + hasModifier.current = e.altKey; + }; + document.addEventListener('keydown', handler); + return () => { + document.removeEventListener('keydown', handler); + }; + }, [activeEditor]); + + const onClick = (payload: InsertAttachmentPayload) => { + activeEditor.dispatchCommand(INSERT_ATTACHMENT_COMMAND, payload); + onClose(); + }; + + return ; +} + +const formatFileSize = (bytes: number): string => { + if (bytes === 0) { + return '0 Bytes'; + } + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +const getFileTypeFromName = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + + const mimeTypes: Record = { + avi: 'video/x-msvideo', + csv: 'text/csv', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + flac: 'audio/flac', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + mov: 'video/quicktime', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + pdf: 'application/pdf', + png: 'image/png', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + rar: 'application/x-rar-compressed', + txt: 'text/plain', + wav: 'audio/wav', + webp: 'image/webp', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + zip: 'application/zip', + }; + + return mimeTypes[extension || ''] || 'application/octet-stream'; +}; + +export const extractAttachmentNodes = (root: ElementNode): AttachmentNode[] => { + const attachmentNodes: AttachmentNode[] = []; + + function traverse(node: LexicalNode) { + if ($isAttachmentNode(node)) { + attachmentNodes.push(node); + } + + // If node is an ElementNode, traverse its children + if (node instanceof ElementNode) { + const children = node.getChildren(); + children.forEach(traverse); + } + } + + traverse(root); + return attachmentNodes; +}; + +export default function AttachmentPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([AttachmentNode])) { + throw new Error( + 'AttachmentPlugin: AttachmentNode not registered on editor', + ); + } + + return mergeRegister( + editor.registerCommand( + INSERT_ATTACHMENT_COMMAND, + (payload) => { + const attachmentNode = $createAttachmentNode(payload); + $insertNodes([attachmentNode]); + if ($isRootOrShadowRoot(attachmentNode.getParentOrThrow())) { + $wrapNodeInElement( + attachmentNode, + $createParagraphNode, + ).selectEnd(); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + return $onDragStart(event); + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + return $onDragover(event); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + return $onDrop(event, editor); + }, + COMMAND_PRIORITY_HIGH, + ), + // 에디터가 정리될 λ•Œ μ²¨λΆ€νŒŒμΌ objectURL듀을 정리 + () => { + return editor.read(() => { + try { + const root = $getRoot(); + const attachmentNodes = extractAttachmentNodes(root); + attachmentNodes.forEach((node) => { + URL.revokeObjectURL(node.getFileUrl()); + }); + } catch (error) { + console.warn( + 'Failed to cleanup attachment URLs during editor cleanup:', + error, + ); + } + }); + }, + ); + }, [editor]); + + return null; +} + +const TRANSPARENT_IMAGE = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +let img: HTMLImageElement; +function getDragImage(): HTMLImageElement { + if (!img) { + img = document.createElement('img'); + img.src = TRANSPARENT_IMAGE; + } + return img; +} + +function $onDragStart(event: DragEvent): boolean { + const node = $getAttachmentNodeInSelection(); + if (!node) { + return false; + } + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return false; + } + dataTransfer.setData('text/plain', '_'); + dataTransfer.setDragImage(getDragImage(), 0, 0); + dataTransfer.setData( + 'application/x-lexical-drag', + JSON.stringify({ + data: { + fileName: node.getFileName(), + fileSize: node.getFileSize(), + fileType: node.getFileType(), + fileUrl: node.getFileUrl(), + key: node.getKey(), + }, + type: 'attachment', + }), + ); + + return true; +} + +function $onDragover(event: DragEvent): boolean { + const node = $getAttachmentNodeInSelection(); + if (!node) { + return false; + } + if (!canDropAttachment(event)) { + event.preventDefault(); + } + return true; +} + +function $onDrop(event: DragEvent, editor: LexicalEditor): boolean { + const node = $getAttachmentNodeInSelection(); + if (!node) { + return false; + } + const data = getDragAttachmentData(event); + if (!data) { + return false; + } + const existingLink = $findMatchingParent( + node, + (parent): parent is LinkNode => + !$isAutoLinkNode(parent) && $isLinkNode(parent), + ); + event.preventDefault(); + if (canDropAttachment(event)) { + const range = getDragSelection(event); + node.remove(); + const rangeSelection = $createRangeSelection(); + if (range !== null && range !== undefined) { + rangeSelection.applyDOMRange(range); + } + $setSelection(rangeSelection); + editor.dispatchCommand(INSERT_ATTACHMENT_COMMAND, data); + if (existingLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, existingLink.getURL()); + } + } + return true; +} + +function $getAttachmentNodeInSelection(): AttachmentNode | null { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) { + return null; + } + const nodes = selection.getNodes(); + const node = nodes[0]; + return $isAttachmentNode(node) ? node : null; +} + +function getDragAttachmentData( + event: DragEvent, +): null | InsertAttachmentPayload { + const dragData = event.dataTransfer?.getData('application/x-lexical-drag'); + if (!dragData) { + return null; + } + const {type, data} = JSON.parse(dragData); + if (type !== 'attachment') { + return null; + } + + return data; +} + +declare global { + interface DragEvent { + rangeOffset?: number; + rangeParent?: Node; + } +} + +function canDropAttachment(event: DragEvent): boolean { + const target = event.target; + return !!( + isHTMLElement(target) && + !target.closest('code, span.attachment-node') && + isHTMLElement(target.parentElement) && + target.parentElement.closest('div.ContentEditable__root') + ); +} + +function getDragSelection(event: DragEvent): Range | null | undefined { + let range; + const domSelection = getDOMSelectionFromTarget(event.target); + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(event.clientX, event.clientY); + } else if (event.rangeParent && domSelection !== null) { + domSelection.collapse(event.rangeParent, event.rangeOffset || 0); + range = domSelection.getRangeAt(0); + } else { + throw Error(`Cannot get the selection when dragging`); + } + + return range; +} diff --git a/packages/lexical-playground/src/plugins/DragDropPastePlugin/index.ts b/packages/lexical-playground/src/plugins/DragDropPastePlugin/index.ts index b38a10ce4b3..2647dde855c 100644 --- a/packages/lexical-playground/src/plugins/DragDropPastePlugin/index.ts +++ b/packages/lexical-playground/src/plugins/DragDropPastePlugin/index.ts @@ -12,6 +12,8 @@ import {isMimeType, mediaFileReader} from '@lexical/utils'; import {COMMAND_PRIORITY_LOW} from 'lexical'; import {useEffect} from 'react'; +import {useAttachmentStore} from '../../context/AttachmentStoreContext'; +import {INSERT_ATTACHMENT_COMMAND} from '../AttachmentPlugin'; import {INSERT_IMAGE_COMMAND} from '../ImagesPlugin'; const ACCEPTABLE_IMAGE_TYPES = [ @@ -22,23 +24,87 @@ const ACCEPTABLE_IMAGE_TYPES = [ 'image/webp', ]; +// Acceptable attachment types (keep same as AttachmentPlugin) +const ACCEPTABLE_ATTACHMENT_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/zip', + 'application/x-rar-compressed', + 'text/plain', + 'text/csv', + 'video/mp4', + 'video/x-msvideo', + 'video/quicktime', + 'audio/mpeg', + 'audio/wav', + 'audio/flac', +]; + +// Maximum attachment size (3MB) +const MAX_ATTACHMENT_SIZE_MB = 3; + export default function DragDropPaste(): null { const [editor] = useLexicalComposerContext(); + const {store} = useAttachmentStore(); + useEffect(() => { return editor.registerCommand( DRAG_DROP_PASTE, (files) => { (async () => { - const filesResult = await mediaFileReader( - files, - [ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x), - ); + const filesResult = await mediaFileReader(files, [ + ...ACCEPTABLE_IMAGE_TYPES, + ...ACCEPTABLE_ATTACHMENT_TYPES, + ]); for (const {file, result} of filesResult) { if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) { editor.dispatchCommand(INSERT_IMAGE_COMMAND, { altText: file.name, src: result, }); + } else if (isMimeType(file, ACCEPTABLE_ATTACHMENT_TYPES)) { + // Check file size + if (file.size > MAX_ATTACHMENT_SIZE_MB * 1024 * 1024) { + console.warn( + `File size exceeds ${MAX_ATTACHMENT_SIZE_MB}MB limit`, + ); + continue; + } + + // Upload file using the store + try { + const stored = await store.upload({ + file, + fileName: file.name, + fileSize: file.size, + fileType: file.type, + }); + + // Get base64 data if the store supports it (for serialization) + let base64Data: string | undefined; + if (store.getConfig().serializeAsBase64) { + const base64 = await store.toBase64(stored.id); + if (base64) { + base64Data = base64; + } + } + + editor.dispatchCommand(INSERT_ATTACHMENT_COMMAND, { + attachmentId: stored.id, + base64Data, + fileName: stored.fileName, + fileSize: stored.fileSize, + fileType: stored.fileType, + fileUrl: stored.url, + }); + } catch (error) { + console.error('Failed to upload attachment:', error); + } } } })(); @@ -46,6 +112,6 @@ export default function DragDropPaste(): null { }, COMMAND_PRIORITY_LOW, ); - }, [editor]); + }, [editor, store]); return null; } diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx index ecb8fbeb33c..9175ff604bc 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx @@ -81,6 +81,7 @@ import DropdownColorPicker from '../../ui/DropdownColorPicker'; import {isKeyboardInput} from '../../utils/focusUtils'; import {getSelectedNode} from '../../utils/getSelectedNode'; import {sanitizeUrl} from '../../utils/url'; +import {InsertAttachmentDialog} from '../AttachmentPlugin'; import {EmbedConfigs} from '../AutoEmbedPlugin'; import {INSERT_COLLAPSIBLE_COMMAND} from '../CollapsiblePlugin'; import {INSERT_DATETIME_COMMAND} from '../DateTimePlugin'; @@ -1281,6 +1282,19 @@ export default function ToolbarPlugin({ Page Break + { + showModal('Insert Attachment', (onClose) => ( + + )); + }} + className="item"> + + Attachment + { showModal('Insert Image', (onClose) => ( diff --git a/packages/lexical-playground/src/stores/AttachmentStore.ts b/packages/lexical-playground/src/stores/AttachmentStore.ts new file mode 100644 index 00000000000..0c5c7b9af74 --- /dev/null +++ b/packages/lexical-playground/src/stores/AttachmentStore.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * AttachmentStore Interface + * + * A pluggable storage abstraction for attachment files that supports: + * - Demo mode: Uses blob URLs (session-scoped, no persistence) + * - Production: External storage (S3, R2) with just URLs in documents + * - Optional: Base64 for self-contained export + */ + +export interface AttachmentStoreConfig { + /** Whether to serialize attachments as base64 in exportJSON/exportDOM */ + serializeAsBase64?: boolean; + /** Warning level for demo mode: 'none' | 'console' | 'ui' */ + demoWarningLevel?: 'none' | 'console' | 'ui'; +} + +export interface AttachmentFile { + file: File; + fileName: string; + fileSize: number; + fileType: string; +} + +export interface StoredAttachment { + /** The URL to access the file (blob:, https://, or data:) */ + url: string; + /** Unique identifier for the stored file */ + id: string; + /** Original file metadata */ + fileName: string; + fileSize: number; + fileType: string; + /** Whether this is a persistent URL (survives page reload) */ + isPersistent: boolean; +} + +export interface AttachmentStore { + /** + * Upload a file and return storage information + * For demo mode: creates blob URL + * For production: uploads to external storage and returns permanent URL + */ + upload(file: AttachmentFile): Promise; + + /** + * Get the current URL for a stored attachment + * Useful when URLs might change or need refreshing (signed URLs, etc.) + */ + getUrl(id: string): Promise; + + /** + * Delete a stored attachment and clean up resources + */ + delete(id: string): Promise; + + /** + * Convert an attachment to base64 data URL (for self-contained export) + * Returns null if conversion is not possible + */ + toBase64(id: string): Promise; + + /** + * Check if the store supports persistent storage + */ + isPersistent(): boolean; + + /** + * Get configuration for serialization behavior + */ + getConfig(): AttachmentStoreConfig; +} diff --git a/packages/lexical-playground/src/stores/DemoAttachmentStore.ts b/packages/lexical-playground/src/stores/DemoAttachmentStore.ts new file mode 100644 index 00000000000..076c5c1bc64 --- /dev/null +++ b/packages/lexical-playground/src/stores/DemoAttachmentStore.ts @@ -0,0 +1,183 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + AttachmentFile, + AttachmentStore, + AttachmentStoreConfig, + StoredAttachment, +} from './AttachmentStore'; + +/** + * DemoAttachmentStore + * + * Default implementation using blob URLs with console warnings. + * Files are stored in memory and will be lost on page reload. + * + * For production use, implement a custom AttachmentStore that uploads + * files to external storage (S3, Cloudflare R2, etc.) and returns + * permanent URLs. + */ +export class DemoAttachmentStore implements AttachmentStore { + private urlMap = new Map< + string, + {url: string; metadata: StoredAttachment; blob?: Blob} + >(); + private config: AttachmentStoreConfig; + private warningShown = false; + + constructor(config: AttachmentStoreConfig = {}) { + this.config = { + // Default true for playground compatibility + demoWarningLevel: 'console', + serializeAsBase64: true, + ...config, + }; + } + + private showWarningOnce(): void { + if (this.warningShown || this.config.demoWarningLevel === 'none') { + return; + } + + if (this.config.demoWarningLevel === 'console') { + console.warn( + '[Lexical Attachment] Using demo attachment store. ' + + 'Files are stored as blob URLs and will be lost on page reload. ' + + 'For production use, provide a custom AttachmentStore implementation ' + + 'that uploads files to external storage (S3, Cloudflare R2, etc.).', + ); + this.warningShown = true; + } + } + + async upload(file: AttachmentFile): Promise { + this.showWarningOnce(); + + const id = crypto.randomUUID(); + const url = URL.createObjectURL(file.file); + + const stored: StoredAttachment = { + fileName: file.fileName, + fileSize: file.fileSize, + fileType: file.fileType, + id, + isPersistent: false, + url, + }; + + this.urlMap.set(id, {blob: file.file, metadata: stored, url}); + return stored; + } + + async getUrl(id: string): Promise { + const entry = this.urlMap.get(id); + if (!entry) { + throw new Error(`Attachment not found: ${id}`); + } + return entry.url; + } + + async delete(id: string): Promise { + const entry = this.urlMap.get(id); + if (entry && entry.url.startsWith('blob:')) { + URL.revokeObjectURL(entry.url); + } + this.urlMap.delete(id); + } + + async toBase64(id: string): Promise { + const entry = this.urlMap.get(id); + if (!entry) { + return null; + } + + try { + // If we have the original blob, use it directly + const blob = entry.blob; + if (blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + if (result.startsWith('data:') && entry.metadata.fileType) { + const base64Data = result.split(',')[1]; + resolve(`data:${entry.metadata.fileType};base64,${base64Data}`); + } else { + resolve(result); + } + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + } + + // Fallback: fetch from blob URL + if (entry.url.startsWith('blob:')) { + const response = await fetch(entry.url); + const fetchedBlob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + if (result.startsWith('data:') && entry.metadata.fileType) { + const base64Data = result.split(',')[1]; + resolve(`data:${entry.metadata.fileType};base64,${base64Data}`); + } else { + resolve(result); + } + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(fetchedBlob); + }); + } + + return null; + } catch (error) { + console.warn('Failed to convert attachment to base64:', error); + return null; + } + } + + isPersistent(): boolean { + return false; + } + + getConfig(): AttachmentStoreConfig { + return this.config; + } + + /** + * Register an existing attachment (e.g., from imported JSON) + * This is useful for tracking attachments that were loaded from serialized state + */ + registerExisting( + id: string, + url: string, + metadata: Omit, + ): void { + const stored: StoredAttachment = { + ...metadata, + id, + url, + }; + this.urlMap.set(id, {metadata: stored, url}); + } + + /** + * Clean up all stored attachments + */ + cleanup(): void { + for (const [, entry] of this.urlMap) { + if (entry.url.startsWith('blob:')) { + URL.revokeObjectURL(entry.url); + } + } + this.urlMap.clear(); + } +} diff --git a/packages/lexical-playground/src/ui/FilePreview.css b/packages/lexical-playground/src/ui/FilePreview.css new file mode 100644 index 00000000000..326957eac81 --- /dev/null +++ b/packages/lexical-playground/src/ui/FilePreview.css @@ -0,0 +1,28 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +.FilePreview__container { + background-color: #f5f5f5; + border-radius: 4px; + margin-top: 10px; + padding: 10px; +} + +.FilePreview__item { + margin-bottom: 4px; +} + +.FilePreview__item:last-child { + margin-bottom: 0; +} + +.FilePreview__item strong { + color: #333; + margin-right: 8px; +} diff --git a/packages/lexical-playground/src/ui/FilePreview.tsx b/packages/lexical-playground/src/ui/FilePreview.tsx new file mode 100644 index 00000000000..0abb05862e7 --- /dev/null +++ b/packages/lexical-playground/src/ui/FilePreview.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {JSX} from 'react'; + +import './FilePreview.css'; + +import * as React from 'react'; + +export interface FilePreviewProps { + fileName: string; + fileSize: string; + fileType: string; +} + +export default function FilePreview({ + fileName, + fileSize, + fileType, +}: FilePreviewProps): JSX.Element { + return ( +
+
+ File: {fileName} +
+
+ Size: {fileSize} +
+
+ Type: {fileType} +
+
+ ); +}