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}
+
+
+ );
+}