From e5908c01ec2786724cfd945f25126e7fcf117973 Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 8 Jul 2026 10:30:52 +0100 Subject: [PATCH] feat(react): add attach file slash command --- .../react/src/components/prosekit-editor.tsx | 7 +- .../react/src/components/slash-menu.test.tsx | 25 ++++++ packages/react/src/components/slash-menu.tsx | 77 ++++++++++++++++++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx index 1091eaf0..1d1514dc 100644 --- a/packages/react/src/components/prosekit-editor.tsx +++ b/packages/react/src/components/prosekit-editor.tsx @@ -363,7 +363,12 @@ export function ProseKitEditor({ {blockHandle && !readOnly && } {!readOnly && } {blockHandle && !readOnly && } - + {!readOnly && } {onTagSearch && } {onWikilinkSearch && } diff --git a/packages/react/src/components/slash-menu.test.tsx b/packages/react/src/components/slash-menu.test.tsx index 5981e715..195f5503 100644 --- a/packages/react/src/components/slash-menu.test.tsx +++ b/packages/react/src/components/slash-menu.test.tsx @@ -111,6 +111,31 @@ describe('SlashMenu', () => { expect(ref.current?.getMarkdown()).toMatch(/^\d{2}:\d{2}\n$/) }) + it('attaches a selected file through the slash menu', async () => { + const ref = createRef() + const onFilePaste = vi.fn((file: File) => `assets/${file.name}`) + await render() + + const input = page.getByTestId('slash-menu-file-input').element() as HTMLInputElement + const inputClick = vi.spyOn(input, 'click').mockImplementation(() => {}) + await pmRoot.click() + await userEvent.keyboard('Hello /attach') + await menu.getByText('Attach file', { exact: true }).click() + + expect(inputClick).toHaveBeenCalledOnce() + const files = new DataTransfer() + files.items.add(new File(['%PDF'], 'report.pdf', { type: 'application/pdf' })) + Object.defineProperty(input, 'files', { value: files.files, configurable: true }) + input.dispatchEvent(new Event('change', { bubbles: true })) + + await vi.waitFor(() => { + expect(onFilePaste).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: 'report.pdf' }), + ) + expect(ref.current?.getMarkdown()).toBe('Hello [report.pdf](assets/report.pdf)\n') + }) + }) + it('shows host items after the built-in ones', async () => { const onSlashMenuSearch = (): SlashMenuItem[] => [ { label: 'Meeting note', detail: 'Template', onSelect: () => {} }, diff --git a/packages/react/src/components/slash-menu.tsx b/packages/react/src/components/slash-menu.tsx index 82d7ca5b..a53c86c2 100644 --- a/packages/react/src/components/slash-menu.tsx +++ b/packages/react/src/components/slash-menu.tsx @@ -1,4 +1,11 @@ -import { isSelectionInTableCell, type EditorExtension, type TypedEditor } from '@meowdown/core' +import { + buildFileMarkdown, + isSelectionInTableCell, + type EditorExtension, + type FilePasteOptions, + type FileSaveErrorHandler, + type TypedEditor, +} from '@meowdown/core' import { canUseRegexLookbehind } from '@prosekit/core' import { useEditor, useEditorDerivedValue } from '@prosekit/react' import { @@ -8,7 +15,7 @@ import { AutocompletePositioner, AutocompleteRoot, } from '@prosekit/react/autocomplete' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react' import { formatNowTime, type TimeFormat } from '../utils/date-format.ts' @@ -18,6 +25,10 @@ import type { SlashMenuItem, SlashMenuSearchHandler } from './types.ts' // Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading". const regex = canUseRegexLookbehind() ? /(? { + console.error('[meowdown] failed to save attached file:', error) +} + interface SlashMenuItemProps { label: string keywords?: string[] @@ -46,6 +57,12 @@ interface SlashMenuProps { /** Adds host items after the built-in ones. See `EditorProps.onSlashMenuSearch`. */ onSlashMenuSearch?: SlashMenuSearchHandler + + /** Persists files selected from the "Attach file" item. See `EditorProps.onFilePaste`. */ + onFilePaste?: FilePasteOptions['onFilePaste'] + + /** Called when an attached file fails to persist. See `EditorProps.onFileSaveError`. */ + onFileSaveError?: FilePasteOptions['onFileSaveError'] } // Hoisted so its identity is stable across renders, as useEditorDerivedValue @@ -54,11 +71,17 @@ function selectionInTableCell(editor: TypedEditor): boolean { return isSelectionInTableCell(editor.state) } -export function SlashMenu({ timeFormat = '12', onSlashMenuSearch }: SlashMenuProps) { +export function SlashMenu({ + timeFormat = '12', + onSlashMenuSearch, + onFilePaste, + onFileSaveError, +}: SlashMenuProps) { const editor = useEditor() + const fileInputRef = useRef(null) // A table cell holds inline content only, so the block-creating items below - // are no-ops there. Hide them and offer only the inline "Now" item. + // are no-ops there. Hide them and offer only inline items. const inTableCell = useEditorDerivedValue(selectionInTableCell) const [open, setOpen] = useState(false) @@ -87,12 +110,51 @@ export function SlashMenu({ timeFormat = '12', onSlashMenuSearch }: SlashMenuPro } }, [open, query, fetchHostItems]) + const openFilePicker = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const handleFileInputChange = useCallback( + async (event: ChangeEvent): Promise => { + const input = event.currentTarget + const files = Array.from(input.files ?? []) + input.value = '' + if (!onFilePaste || files.length === 0) return + + const onSaveError = onFileSaveError ?? defaultOnFileSaveError + const markdown: string[] = [] + for (const file of files) { + try { + const destination = await onFilePaste(file) + if (destination) markdown.push(buildFileMarkdown(file, destination)) + } catch (error) { + onSaveError(error, file) + } + } + + if (markdown.length === 0) return + editor.focus() + editor.commands.insertText({ text: markdown.join('\n') }) + }, + [editor, onFilePaste, onFileSaveError], + ) + return ( setOpen(event.detail)} onQueryChange={(event) => setQuery(event.detail)} > + {onFilePaste ? ( + + ) : null} {!inTableCell && ( @@ -166,6 +228,13 @@ export function SlashMenu({ timeFormat = '12', onSlashMenuSearch }: SlashMenuPro label="Now" onSelect={() => editor.commands.insertText({ text: formatNowTime(timeFormat) })} /> + {onFilePaste ? ( + + ) : null} {hostItems.map((item) => (