Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/react/src/components/prosekit-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,12 @@ export function ProseKitEditor({
{blockHandle && !readOnly && <BlockHandle />}
{!readOnly && <TableHandle />}
{blockHandle && !readOnly && <DropIndicator />}
<SlashMenu timeFormat={timeFormat} onSlashMenuSearch={onSlashMenuSearch} />
<SlashMenu
timeFormat={timeFormat}
onSlashMenuSearch={onSlashMenuSearch}
onFilePaste={onFilePaste}
onFileSaveError={onFileSaveError}
/>
{!readOnly && <LinkMenu onLinkClick={onLinkClick} onLinkCopy={onLinkCopy} />}
{onTagSearch && <TagMenu onTagSearch={onTagSearch} />}
{onWikilinkSearch && <WikilinkMenu onWikilinkSearch={onWikilinkSearch} />}
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/components/slash-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EditorHandle>()
const onFilePaste = vi.fn((file: File) => `assets/${file.name}`)
await render(<ProseKitEditor ref={ref} onFilePaste={onFilePaste} />)

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: () => {} },
Expand Down
77 changes: 73 additions & 4 deletions packages/react/src/components/slash-menu.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'

Expand All @@ -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() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u

const defaultOnFileSaveError: FileSaveErrorHandler = (error) => {
console.error('[meowdown] failed to save attached file:', error)
}

interface SlashMenuItemProps {
label: string
keywords?: string[]
Expand Down Expand Up @@ -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
Expand All @@ -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<EditorExtension>()
const fileInputRef = useRef<HTMLInputElement>(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)
Expand Down Expand Up @@ -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<HTMLInputElement>): Promise<void> => {
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 (
<AutocompleteRoot
regex={regex}
onOpenChange={(event) => setOpen(event.detail)}
onQueryChange={(event) => setQuery(event.detail)}
>
{onFilePaste ? (
<input
ref={fileInputRef}
data-testid="slash-menu-file-input"
type="file"
multiple
hidden
onChange={handleFileInputChange}
/>
) : null}
<AutocompletePositioner className={styles.Positioner}>
<AutocompletePopup className={styles.Popup} data-testid="slash-menu">
{!inTableCell && (
Expand Down Expand Up @@ -166,6 +228,13 @@ export function SlashMenu({ timeFormat = '12', onSlashMenuSearch }: SlashMenuPro
label="Now"
onSelect={() => editor.commands.insertText({ text: formatNowTime(timeFormat) })}
/>
{onFilePaste ? (
<SlashMenuItem
label="Attach file"
keywords={['attachment', 'file', 'upload']}
onSelect={openFilePicker}
/>
) : null}
{hostItems.map((item) => (
<SlashMenuItem
key={item.id ?? item.label}
Expand Down
Loading