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) => (