From aafd82cdc126f7ab96d0fdd26e3a583bb96159ee Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 1 Jul 2026 23:42:23 +0100 Subject: [PATCH 1/3] feat: selection command menu and pending-replacement preview Two host-agnostic editor primitives: - A selection command menu: a popover anchored to the current selection with a filter input and host-supplied rows (onSelectionMenuSearch), opened via EditorHandle.openSelectionMenu() or a small floating affordance shown on a non-empty text selection. - A pending replacement: Markdown staged over a source range (e.g. streamed from an AI provider) previewed without touching the document. Accept applies the result as a single transaction (inline for a single-paragraph result inside one textblock, as blocks otherwise); discard is a no-op. Other edits remap the staged range; deleting the source discards it. Mod-Enter accepts, Escape discards, and the preview popover offers Accept/Discard plus a host actions slot. Co-Authored-By: Claude Fable 5 --- packages/core/src/extensions/extension.ts | 2 + .../extensions/pending-replacement.test.ts | 207 +++++++++++++ .../src/extensions/pending-replacement.ts | 276 ++++++++++++++++++ packages/core/src/index.ts | 11 + packages/core/src/style.css | 8 + packages/core/src/utils/selected-text.ts | 11 + packages/react/src/components/editor.tsx | 66 +++++ .../pending-replacement-preview.module.css | 76 +++++ ...ending-replacement-preview.module.d.css.ts | 12 + .../pending-replacement-preview.test.tsx | 111 +++++++ .../pending-replacement-preview.tsx | 112 +++++++ .../react/src/components/prosekit-editor.tsx | 91 +++++- .../src/components/selection-menu.module.css | 109 +++++++ .../components/selection-menu.module.d.css.ts | 15 + .../src/components/selection-menu.test.tsx | 155 ++++++++++ .../react/src/components/selection-menu.tsx | 258 ++++++++++++++++ packages/react/src/components/types.ts | 74 ++++- packages/react/src/index.ts | 4 + website/src/components/codemirror-editor.tsx | 13 + 19 files changed, 1608 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/extensions/pending-replacement.test.ts create mode 100644 packages/core/src/extensions/pending-replacement.ts create mode 100644 packages/core/src/utils/selected-text.ts create mode 100644 packages/react/src/components/pending-replacement-preview.module.css create mode 100644 packages/react/src/components/pending-replacement-preview.module.d.css.ts create mode 100644 packages/react/src/components/pending-replacement-preview.test.tsx create mode 100644 packages/react/src/components/pending-replacement-preview.tsx create mode 100644 packages/react/src/components/selection-menu.module.css create mode 100644 packages/react/src/components/selection-menu.module.d.css.ts create mode 100644 packages/react/src/components/selection-menu.test.tsx create mode 100644 packages/react/src/components/selection-menu.tsx diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 22dfa15d..30f80e2e 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -28,6 +28,7 @@ import { defineLinkCommands } from './link-commands.ts' import { defineMeowdownList } from './list.ts' import { defineMoveBlock } from './move-block.ts' import { defineMeowdownParagraph } from './paragraph.ts' +import { definePendingReplacement } from './pending-replacement.ts' import { defineTable } from './table.ts' import { defineWikilink } from './wikilink.ts' @@ -72,6 +73,7 @@ function defineEditorExtensionImpl() { defineVirtualSelection(), defineModClickPrevention(), defineEditorCommands(), + definePendingReplacement(), ) } diff --git a/packages/core/src/extensions/pending-replacement.test.ts b/packages/core/src/extensions/pending-replacement.test.ts new file mode 100644 index 00000000..b3f7e332 --- /dev/null +++ b/packages/core/src/extensions/pending-replacement.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { userEvent } from 'vitest/browser' + +import { docToMarkdown } from '../converters/pm-to-md.ts' +import { setupFixture } from '../testing/index.ts' + +import { + definePendingReplacementHandler, + getPendingReplacement, + type PendingReplacementEvent, +} from './pending-replacement.ts' + +function selectionRange(fixture: { state: { selection: { from: number; to: number } } }) { + const { from, to } = fixture.state.selection + return { from, to } +} + +describe('pending replacement', () => { + it('stages and accumulates text without touching the document', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say hello end'))) + const before = docToMarkdown(fixture.doc) + + const { from, to } = selectionRange(fixture) + expect(editor.commands.startPendingReplacement({ from, to, mode: 'replace' })).toBe(true) + editor.commands.appendPendingReplacementText('good') + editor.commands.appendPendingReplacementText('bye') + + expect(getPendingReplacement(fixture.state)).toEqual({ + from, + to, + mode: 'replace', + text: 'goodbye', + }) + expect(docToMarkdown(fixture.doc)).toBe(before) + }) + + it('discard clears the stage and leaves the document byte-identical', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say hello end'))) + const before = docToMarkdown(fixture.doc) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('goodbye') + expect(editor.commands.discardPendingReplacement()).toBe(true) + + expect(getPendingReplacement(fixture.state)).toBeNull() + expect(docToMarkdown(fixture.doc)).toBe(before) + }) + + it('accepts a single-paragraph result inline, keeping the paragraph whole', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say hello end'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('goodbye **friend**') + expect(editor.commands.acceptPendingReplacement()).toBe(true) + + expect(fixture.doc.childCount).toBe(1) + expect(fixture.doc.child(0).textContent).toBe('say goodbye **friend** end') + expect(getPendingReplacement(fixture.state)).toBeNull() + }) + + it('accepts a multi-block result as blocks', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('hello'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('- one\n- two') + editor.commands.acceptPendingReplacement() + + expect(docToMarkdown(fixture.doc)).toBe('- one\n- two\n') + }) + + it('accepts in append mode after the source block', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('first'), n.paragraph('last'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'append' }) + editor.commands.appendPendingReplacementText('continued') + editor.commands.acceptPendingReplacement() + + expect(docToMarkdown(fixture.doc)).toBe('first\n\ncontinued\n\nlast\n') + }) + + it('refuses to accept an empty stage', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('hello'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + expect(editor.commands.acceptPendingReplacement()).toBe(false) + editor.commands.appendPendingReplacementText(' ') + expect(editor.commands.acceptPendingReplacement()).toBe(false) + }) + + it('remaps the staged range through other edits', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say hello end'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('goodbye') + editor.commands.selectText(1) + editor.commands.insertText({ text: 'X' }) + + const pending = getPendingReplacement(fixture.state) + expect(pending?.text).toBe('goodbye') + expect(pending && fixture.doc.textBetween(pending.from, pending.to)).toBe('hello') + + editor.commands.acceptPendingReplacement() + expect(fixture.doc.child(0).textContent).toBe('Xsay goodbye end') + }) + + it('discards a replace stage when its source range is deleted', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say hello end'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('goodbye') + editor.commands.insertText({ text: '!' }) + + expect(getPendingReplacement(fixture.state)).toBeNull() + expect(fixture.doc.child(0).textContent).toBe('say ! end') + }) + + it('restarting the stage resets the accumulated text (retry)', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('hello'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('first attempt') + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + + expect(getPendingReplacement(fixture.state)?.text).toBe('') + }) + + it('rejects an out-of-range or empty replace stage', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('hello'))) + + expect(editor.commands.startPendingReplacement({ from: 1, to: 999, mode: 'replace' })).toBe( + false, + ) + expect(editor.commands.startPendingReplacement({ from: 2, to: 2, mode: 'replace' })).toBe(false) + expect(editor.commands.startPendingReplacement({ from: 2, to: 2, mode: 'append' })).toBe(true) + }) + + it('reports updates and the outcome to a handler', () => { + using fixture = setupFixture() + const { editor, n } = fixture + const events: PendingReplacementEvent[] = [] + editor.use( + definePendingReplacementHandler((event) => { + events.push(event) + }), + ) + fixture.set(n.doc(n.paragraph('hello'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('goodbye') + editor.commands.acceptPendingReplacement() + + expect(events.map((event) => event.type)).toEqual(['update', 'update', 'ended']) + const last = events.at(-1) + expect(last?.type === 'ended' && last.outcome).toBe('accepted') + + editor.commands.startPendingReplacement({ from: 1, to: 2, mode: 'replace' }) + editor.commands.discardPendingReplacement() + const final = events.at(-1) + expect(final?.type === 'ended' && final.outcome).toBe('discarded') + }) + + it('Escape discards the stage', async () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say hello end'))) + const before = docToMarkdown(fixture.doc) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('goodbye') + + fixture.view.focus() + await userEvent.keyboard('{Escape}') + + expect(getPendingReplacement(fixture.state)).toBeNull() + expect(docToMarkdown(fixture.doc)).toBe(before) + }) +}) diff --git a/packages/core/src/extensions/pending-replacement.ts b/packages/core/src/extensions/pending-replacement.ts new file mode 100644 index 00000000..e2c43724 --- /dev/null +++ b/packages/core/src/extensions/pending-replacement.ts @@ -0,0 +1,276 @@ +import { + defineCommands, + defineKeymap, + definePlugin, + union, + type PlainExtension, +} from '@prosekit/core' +import { Slice } from '@prosekit/pm/model' +import { + Plugin, + PluginKey, + TextSelection, + type Command, + type EditorState, +} from '@prosekit/pm/state' +import { Decoration, DecorationSet } from '@prosekit/pm/view' + +import { markdownToDoc } from '../converters/md-to-pm.ts' +import type { PositionRange } from '../utils/range.ts' + +import { getNodeBuildersForSchema } from './schema.ts' + +/** Where an accepted replacement lands relative to the source range. */ +export type PendingReplacementMode = 'replace' | 'append' + +/** How a pending replacement ended. */ +export type PendingReplacementOutcome = 'accepted' | 'discarded' + +/** + * A staged replacement: Markdown text accumulating over `[from, to]` that is + * only written into the document when accepted. Until then the document is + * untouched; discarding is a no-op. + */ +export interface PendingReplacement { + /** Start of the source range the replacement targets. */ + from: number + /** End of the source range the replacement targets. */ + to: number + /** The Markdown accumulated so far (e.g. streamed from an AI provider). */ + text: string + /** Whether accepting replaces the source range or inserts after its block. */ + mode: PendingReplacementMode +} + +interface PendingReplacementPluginState { + pending: PendingReplacement | null + /** + * The replacement that just ended and how, present only on the state + * produced by the transaction that ended it. Lets watchers distinguish an + * accept from a discard without re-deriving it from the document. + */ + ended?: { pending: PendingReplacement; outcome: PendingReplacementOutcome } +} + +type PendingReplacementMeta = + | { type: 'start'; from: number; to: number; mode: PendingReplacementMode } + | { type: 'append'; text: string } + | { type: 'accept' } + | { type: 'discard' } + +const pendingReplacementKey = new PluginKey( + 'meowdownPendingReplacement', +) + +/** The active pending replacement, or null when there is none. */ +export function getPendingReplacement(state: EditorState): PendingReplacement | null { + return pendingReplacementKey.getState(state)?.pending ?? null +} + +function applyMeta( + meta: PendingReplacementMeta, + value: PendingReplacementPluginState, +): PendingReplacementPluginState { + switch (meta.type) { + case 'start': + return { pending: { from: meta.from, to: meta.to, mode: meta.mode, text: '' } } + case 'append': + if (!value.pending) return value + return { pending: { ...value.pending, text: value.pending.text + meta.text } } + case 'accept': + if (!value.pending) return value + return { pending: null, ended: { pending: value.pending, outcome: 'accepted' } } + case 'discard': + if (!value.pending) return value + return { pending: null, ended: { pending: value.pending, outcome: 'discarded' } } + } +} + +const pendingReplacementPlugin = new Plugin({ + key: pendingReplacementKey, + state: { + init: () => ({ pending: null }), + apply: (tr, value) => { + const meta = tr.getMeta(pendingReplacementKey) as PendingReplacementMeta | undefined + if (meta) return applyMeta(meta, value) + // Other document changes (typing, mark re-derivation) remap the staged + // range. Insertions at the edges stay outside the range; a replace stage + // whose source content is deleted or collapsed away is discarded. + if (tr.docChanged && value.pending) { + const fromResult = tr.mapping.mapResult(value.pending.from, 1) + const toResult = tr.mapping.mapResult(value.pending.to, -1) + const from = Math.min(fromResult.pos, toResult.pos) + const to = Math.max(fromResult.pos, toResult.pos) + const sourceGone = (fromResult.deletedAfter && toResult.deletedBefore) || from >= to + if (sourceGone && value.pending.mode === 'replace') { + return { pending: null, ended: { pending: value.pending, outcome: 'discarded' } } + } + return { pending: { ...value.pending, from, to } } + } + return value + }, + }, + props: { + decorations: (state) => { + const pending = getPendingReplacement(state) + if (!pending || pending.from >= pending.to) return null + return DecorationSet.create(state.doc, [ + Decoration.inline(pending.from, pending.to, { class: 'md-pending-replacement' }), + ]) + }, + }, +}) + +/** Options for the `startPendingReplacement` command. */ +export interface StartPendingReplacementOptions extends PositionRange { + mode: PendingReplacementMode +} + +function startPendingReplacement(options: StartPendingReplacementOptions): Command { + return (state, dispatch) => { + const { from, to, mode } = options + if (from < 0 || to > state.doc.content.size || from > to) return false + if (from === to && mode === 'replace') return false + dispatch?.( + state.tr.setMeta(pendingReplacementKey, { + type: 'start', + from, + to, + mode, + } satisfies PendingReplacementMeta), + ) + return true + } +} + +function appendPendingReplacementText(text: string): Command { + return (state, dispatch) => { + if (!getPendingReplacement(state)) return false + dispatch?.( + state.tr.setMeta(pendingReplacementKey, { + type: 'append', + text, + } satisfies PendingReplacementMeta), + ) + return true + } +} + +function discardPendingReplacement(): Command { + return (state, dispatch) => { + if (!getPendingReplacement(state)) return false + dispatch?.( + state.tr.setMeta(pendingReplacementKey, { type: 'discard' } satisfies PendingReplacementMeta), + ) + return true + } +} + +function acceptPendingReplacement(): Command { + return (state, dispatch) => { + const pending = getPendingReplacement(state) + if (!pending || !pending.text.trim()) return false + if (dispatch) { + const nodes = getNodeBuildersForSchema(state.schema) + const parsed = markdownToDoc(pending.text, { nodes }) + const tr = state.tr + tr.setMeta(pendingReplacementKey, { type: 'accept' } satisfies PendingReplacementMeta) + + if (pending.mode === 'append') { + // Insert the parsed blocks after the top-level block containing `to`. + const insertPos = state.doc.resolve(pending.to).after(1) + tr.insert(insertPos, parsed.content) + tr.setSelection(TextSelection.near(tr.doc.resolve(insertPos + parsed.content.size), -1)) + } else { + const $from = state.doc.resolve(pending.from) + const $to = state.doc.resolve(pending.to) + const paragraph = parsed.childCount === 1 ? parsed.firstChild : null + if ( + paragraph?.type.name === 'paragraph' && + $from.sameParent($to) && + $from.parent.isTextblock + ) { + // A single-paragraph result inside one textblock stays inline, so a + // sentence-level fix does not split the surrounding paragraph. + tr.replaceWith(pending.from, pending.to, paragraph.content) + tr.setSelection( + TextSelection.near(tr.doc.resolve(pending.from + paragraph.content.size), -1), + ) + } else { + tr.replaceRange(pending.from, pending.to, new Slice(parsed.content, 0, 0)) + tr.setSelection(TextSelection.near(tr.doc.resolve(tr.mapping.map(pending.to)), -1)) + } + } + dispatch(tr.scrollIntoView()) + } + return true + } +} + +function definePendingReplacementCommands() { + return defineCommands({ + startPendingReplacement, + appendPendingReplacementText, + acceptPendingReplacement, + discardPendingReplacement, + }) +} + +/** Accept on Mod-Enter and discard on Escape, only while a replacement is pending. */ +function definePendingReplacementKeymap(): PlainExtension { + return defineKeymap({ + 'Mod-Enter': acceptPendingReplacement(), + Escape: discardPendingReplacement(), + }) +} + +/** + * The pending-replacement primitive: staged Markdown over a source range, + * previewed without touching the document. `startPendingReplacement` stages a + * range (restarting resets the accumulated text, which is how a retry begins), + * `appendPendingReplacementText` accumulates streamed text, + * `acceptPendingReplacement` applies the result as one transaction — inline + * when a single-paragraph result lands inside one textblock, as blocks + * otherwise — and `discardPendingReplacement` clears the stage without a + * document change. Other edits remap the staged range; a replace stage whose + * source range is deleted is discarded. + */ +export function definePendingReplacement() { + return union( + definePlugin(pendingReplacementPlugin), + definePendingReplacementCommands(), + definePendingReplacementKeymap(), + ) +} + +/** A pending-replacement change: text/range updates, or how the stage ended. */ +export type PendingReplacementEvent = + | { type: 'update'; pending: PendingReplacement } + | { type: 'ended'; pending: PendingReplacement; outcome: PendingReplacementOutcome } + +export type PendingReplacementHandler = (event: PendingReplacementEvent) => void + +/** + * Watches pending-replacement state and reports changes, so a UI layer can + * render the preview and know whether the stage was accepted or discarded. + */ +export function definePendingReplacementHandler( + handler: PendingReplacementHandler, +): PlainExtension { + return definePlugin( + new Plugin({ + view: () => ({ + update: (view, prevState) => { + const prev = pendingReplacementKey.getState(prevState) + const next = pendingReplacementKey.getState(view.state) + if (!next || prev === next) return + if (next.pending) { + if (next.pending !== prev?.pending) handler({ type: 'update', pending: next.pending }) + } else if (next.ended && next.ended !== prev?.ended) { + handler({ type: 'ended', pending: next.ended.pending, outcome: next.ended.outcome }) + } + }, + }), + }), + ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ec2c8376..d89e4cbb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -69,6 +69,16 @@ export { defineMarkMode, type MarkMode } from './extensions/mark-mode.ts' export type { MarkName } from './extensions/mark-names.ts' export { defineMarkdownCopy } from './extensions/markdown-copy.ts' export type { NodeName } from './extensions/node-names.ts' +export { + definePendingReplacementHandler, + getPendingReplacement, + type PendingReplacement, + type PendingReplacementEvent, + type PendingReplacementHandler, + type PendingReplacementMode, + type PendingReplacementOutcome, + type StartPendingReplacementOptions, +} from './extensions/pending-replacement.ts' export { getMarkBuilders, type TypedMarkBuilders } from './extensions/schema.ts' export { isSelectionInTableCell } from './extensions/table.ts' export { @@ -83,5 +93,6 @@ export { } from './extensions/wikilink-click.ts' export { defineWikilinkTrigger } from './extensions/wikilink-trigger.ts' export type { PositionRange } from './utils/range.ts' +export { getSelectedText } from './utils/selected-text.ts' export { getVirtualElementFromRange, type VirtualElement } from './utils/virtual-element.ts' export { defineSpellCheckPlugin } from './extensions/spell-check.ts' diff --git a/packages/core/src/style.css b/packages/core/src/style.css index 17d6ea87..2a1fd7a4 100644 --- a/packages/core/src/style.css +++ b/packages/core/src/style.css @@ -179,6 +179,14 @@ -webkit-box-decoration-break: clone; } + /* Source range of a staged (pending) replacement. */ + .ProseMirror .md-pending-replacement { + background: var(--meowdown-selection); + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + } + .ProseMirror code { font-family: var(--meowdown-font-mono); font-size: 0.9em; diff --git a/packages/core/src/utils/selected-text.ts b/packages/core/src/utils/selected-text.ts new file mode 100644 index 00000000..b4c07fed --- /dev/null +++ b/packages/core/src/utils/selected-text.ts @@ -0,0 +1,11 @@ +import type { EditorState } from '@prosekit/pm/state' + +/** + * The plain text of the current selection, with block boundaries as blank + * lines. Inline Markdown syntax is literal text in the document, so the + * result reads as Markdown for inline content. + */ +export function getSelectedText(state: EditorState): string { + const { from, to } = state.selection + return state.doc.textBetween(from, to, '\n\n') +} diff --git a/packages/react/src/components/editor.tsx b/packages/react/src/components/editor.tsx index 67a6078f..e88407a4 100644 --- a/packages/react/src/components/editor.tsx +++ b/packages/react/src/components/editor.tsx @@ -7,6 +7,7 @@ import type { LinkCopyHandler, MarkMode, PlaceholderOptions, + StartPendingReplacementOptions, TagClickHandler, WikilinkClickHandler, } from '@meowdown/core' @@ -20,7 +21,9 @@ import { ProseKitEditor } from './prosekit-editor.tsx' import type { EditorHandle, EditorStateSnapshot, + PendingReplacementResolveHandler, SelectionHint, + SelectionMenuSearchHandler, SlashMenuSearchHandler, TagSearchHandler, WikilinkSearchHandler, @@ -75,6 +78,37 @@ export interface EditorProps { */ onWikilinkSearch?: WikilinkSearchHandler + /** + * Searches commands for the selection menu, which opens over a non-empty + * selection via `EditorHandle.openSelectionMenu()` or the selection + * affordance. Receives the filter text typed in the menu (may be empty) and + * the selection the menu was opened over, and returns the rows to show, + * synchronously or as a promise. The host ranks the rows; the menu does not + * re-sort. Pass a stable function (e.g. from `useCallback`). Omit to disable + * the selection menu. + */ + onSelectionMenuSearch?: SelectionMenuSearchHandler + + /** + * Shows a small floating button on a non-empty text selection that opens the + * selection menu. On by default; only relevant when `onSelectionMenuSearch` + * is set. Ignored when `readOnly` is set. + */ + selectionMenuAffordance?: boolean + + /** + * Extra controls rendered in the pending-replacement preview footer, next to + * the built-in Accept and Discard buttons (e.g. a retry button). + */ + pendingReplacementActions?: ReactNode + + /** + * Called when a pending replacement ends, with the outcome ('accepted' or + * 'discarded') and the final staged value. Use it to stop a stream that is + * still appending. Pass a stable function (e.g. from `useCallback`). + */ + onPendingReplacementResolve?: PendingReplacementResolveHandler + /** * Called with the link target on click of a rendered wiki link, or on * `Mod-Enter` with the caret on one. Pass a stable function (e.g. from @@ -203,6 +237,10 @@ export function MeowdownEditor({ onSlashMenuSearch, onTagSearch, onWikilinkSearch, + onSelectionMenuSearch, + selectionMenuAffordance = true, + pendingReplacementActions, + onPendingReplacementResolve, onWikilinkClick, onLinkClick, onLinkCopy, @@ -256,6 +294,24 @@ export function MeowdownEditor({ function scrollIntoView(): void { childRef.current?.scrollIntoView() } + function getSelectedText(): string { + return childRef.current?.getSelectedText() ?? '' + } + function openSelectionMenu(): void { + childRef.current?.openSelectionMenu() + } + function startPendingReplacement(options: StartPendingReplacementOptions): boolean { + return childRef.current?.startPendingReplacement(options) ?? false + } + function appendPendingReplacementText(text: string): void { + childRef.current?.appendPendingReplacementText(text) + } + function acceptPendingReplacement(): void { + childRef.current?.acceptPendingReplacement() + } + function discardPendingReplacement(): void { + childRef.current?.discardPendingReplacement() + } return { getMarkdown, setMarkdown, @@ -266,6 +322,12 @@ export function MeowdownEditor({ setSelection, focus, scrollIntoView, + getSelectedText, + openSelectionMenu, + startPendingReplacement, + appendPendingReplacementText, + acceptPendingReplacement, + discardPendingReplacement, get editor() { return childRef.current?.editor }, @@ -282,6 +344,10 @@ export function MeowdownEditor({ onSlashMenuSearch={onSlashMenuSearch} onTagSearch={onTagSearch} onWikilinkSearch={onWikilinkSearch} + onSelectionMenuSearch={onSelectionMenuSearch} + selectionMenuAffordance={selectionMenuAffordance} + pendingReplacementActions={pendingReplacementActions} + onPendingReplacementResolve={onPendingReplacementResolve} onWikilinkClick={onWikilinkClick} onLinkClick={onLinkClick} onLinkCopy={onLinkCopy} diff --git a/packages/react/src/components/pending-replacement-preview.module.css b/packages/react/src/components/pending-replacement-preview.module.css new file mode 100644 index 00000000..4416346b --- /dev/null +++ b/packages/react/src/components/pending-replacement-preview.module.css @@ -0,0 +1,76 @@ +.Positioner { + display: block; + z-index: 50; + width: min(24rem, calc(100vw - 1rem)); +} + +.Popup { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + font-size: 0.875rem; + border: 1px solid var(--meowdown-border); + border-radius: 0.75rem; + background: var(--meowdown-popover-bg); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +.Text { + max-height: 14rem; + padding: 0.625rem 0.75rem; + overflow-y: auto; + white-space: pre-wrap; + overflow-wrap: break-word; + color: var(--meowdown-text); +} + +.Waiting { + color: var(--meowdown-muted); +} + +.Footer { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem; + border-top: 1px solid var(--meowdown-border); +} + +.Spacer { + flex: 1; +} + +.Button { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + color: var(--meowdown-text); + border-radius: 0.5rem; + cursor: pointer; + + &:hover { + background: var(--meowdown-popover-hover-bg); + } +} + +.AcceptButton { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.625rem; + color: var(--meowdown-popover-bg); + background: var(--meowdown-accent); + border-radius: 0.5rem; + cursor: pointer; + + &:hover { + filter: brightness(1.05); + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} diff --git a/packages/react/src/components/pending-replacement-preview.module.d.css.ts b/packages/react/src/components/pending-replacement-preview.module.d.css.ts new file mode 100644 index 00000000..c8917860 --- /dev/null +++ b/packages/react/src/components/pending-replacement-preview.module.d.css.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +declare const styles = { + 'Positioner': '' as string, + 'Popup': '' as string, + 'Text': '' as string, + 'Waiting': '' as string, + 'Footer': '' as string, + 'Spacer': '' as string, + 'Button': '' as string, + 'AcceptButton': '' as string, +} as const; +export default styles; diff --git a/packages/react/src/components/pending-replacement-preview.test.tsx b/packages/react/src/components/pending-replacement-preview.test.tsx new file mode 100644 index 00000000..857cc157 --- /dev/null +++ b/packages/react/src/components/pending-replacement-preview.test.tsx @@ -0,0 +1,111 @@ +import '../testing/index.ts' + +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' +import { page } from 'vitest/browser' + +import { MeowdownEditor } from './editor.tsx' +import { ProseKitEditor } from './prosekit-editor.tsx' +import type { EditorHandle } from './types.ts' + +const preview = page.getByTestId('pending-replacement') +const previewText = page.getByTestId('pending-replacement-text') +const acceptButton = page.getByTestId('pending-replacement-accept') +const discardButton = page.getByTestId('pending-replacement-discard') + +// The doc is one paragraph 'say hello end'; 'hello' spans positions 5..10. +const HELLO_RANGE = { from: 5, to: 10 } as const + +describe('PendingReplacementPreview', () => { + it('shows streamed text without touching the document', async () => { + const ref = createRef() + await render() + + expect(ref.current?.startPendingReplacement({ ...HELLO_RANGE, mode: 'replace' })).toBe(true) + await expect.element(preview).toBeVisible() + await expect.element(previewText.getByText('Waiting for text...')).toBeVisible() + await expect.element(acceptButton).toBeDisabled() + + ref.current?.appendPendingReplacementText('good') + ref.current?.appendPendingReplacementText('bye') + await expect.element(previewText.getByText('goodbye')).toBeVisible() + await expect.element(acceptButton).not.toBeDisabled() + expect(ref.current?.getMarkdown()).toBe('say hello end\n') + }) + + it('Accept applies the text and reports the outcome', async () => { + const ref = createRef() + const onResolve = vi.fn() + await render( + , + ) + + ref.current?.startPendingReplacement({ ...HELLO_RANGE, mode: 'replace' }) + ref.current?.appendPendingReplacementText('goodbye') + await expect.element(acceptButton).not.toBeDisabled() + await acceptButton.click() + + await expect.element(preview).not.toBeInTheDocument() + expect(ref.current?.getMarkdown()).toBe('say goodbye end\n') + expect(onResolve).toHaveBeenCalledWith('accepted', expect.objectContaining({ text: 'goodbye' })) + }) + + it('Discard leaves the markdown byte-identical and reports the outcome', async () => { + const ref = createRef() + const onResolve = vi.fn() + await render( + , + ) + const before = ref.current?.getMarkdown() + + ref.current?.startPendingReplacement({ ...HELLO_RANGE, mode: 'replace' }) + ref.current?.appendPendingReplacementText('goodbye') + await expect.element(preview).toBeVisible() + await discardButton.click() + + await expect.element(preview).not.toBeInTheDocument() + expect(ref.current?.getMarkdown()).toBe(before) + expect(onResolve).toHaveBeenCalledWith( + 'discarded', + expect.objectContaining({ text: 'goodbye' }), + ) + }) + + it('renders host actions in the footer', async () => { + const ref = createRef() + await render( + Retry} + />, + ) + + ref.current?.startPendingReplacement({ ...HELLO_RANGE, mode: 'replace' }) + await expect.element(preview.getByRole('button', { name: 'Retry' })).toBeVisible() + }) + + it('restarting the stage resets the preview text (retry)', async () => { + const ref = createRef() + await render() + + ref.current?.startPendingReplacement({ ...HELLO_RANGE, mode: 'replace' }) + ref.current?.appendPendingReplacementText('first attempt') + await expect.element(previewText.getByText('first attempt')).toBeVisible() + + ref.current?.startPendingReplacement({ ...HELLO_RANGE, mode: 'replace' }) + await expect.element(previewText.getByText('Waiting for text...')).toBeVisible() + + ref.current?.appendPendingReplacementText('second attempt') + await expect.element(previewText.getByText('second attempt')).toBeVisible() + }) +}) diff --git a/packages/react/src/components/pending-replacement-preview.tsx b/packages/react/src/components/pending-replacement-preview.tsx new file mode 100644 index 00000000..6bf5254c --- /dev/null +++ b/packages/react/src/components/pending-replacement-preview.tsx @@ -0,0 +1,112 @@ +import { Popover } from '@base-ui/react/popover' +import { + definePendingReplacementHandler, + getVirtualElementFromRange, + type EditorExtension, + type PendingReplacement, + type VirtualElement, +} from '@meowdown/core' +import { useEditor, useExtension } from '@prosekit/react' +import { useMemo, useState, type ReactNode } from 'react' + +import styles from './pending-replacement-preview.module.css' +import type { PendingReplacementResolveHandler } from './types.ts' + +interface PendingReplacementPreviewProps { + /** Extra controls rendered in the preview footer (e.g. a retry button). */ + actions?: ReactNode + /** Called when the stage ends, with the outcome and the final staged value. */ + onResolve?: PendingReplacementResolveHandler +} + +/** + * The preview for a staged (pending) replacement: a popover anchored to the + * source range showing the accumulated text, with Accept and Discard controls + * plus a host-provided `actions` slot. Dismissing the popover (Escape or an + * outside press) discards the stage; the document is only touched on accept. + */ +export function PendingReplacementPreview({ actions, onResolve }: PendingReplacementPreviewProps) { + const editor = useEditor() + const [pending, setPending] = useState(null) + + useExtension( + useMemo(() => { + return definePendingReplacementHandler((event) => { + if (event.type === 'update') { + setPending(event.pending) + } else { + setPending(null) + onResolve?.(event.outcome, event.pending) + } + }) + }, [onResolve]), + ) + + const from = pending?.from + const to = pending?.to + const anchor: VirtualElement | undefined = useMemo(() => { + if (from == null || to == null) return + return getVirtualElementFromRange(editor.view, { from, to }) + }, [from, to, editor]) + + if (!pending) return null + + const discard = (): void => { + editor.commands.discardPendingReplacement() + editor.focus() + } + const accept = (): void => { + editor.commands.acceptPendingReplacement() + editor.focus() + } + + return ( + { + if (!next) discard() + }} + > + + + +
+ {pending.text || Waiting for text...} +
+
+ {actions} + + + +
+
+
+
+
+ ) +} diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx index b4668eb5..744666f3 100644 --- a/packages/react/src/components/prosekit-editor.tsx +++ b/packages/react/src/components/prosekit-editor.tsx @@ -1,6 +1,7 @@ import { defineEditorExtension, docToMarkdown, + getSelectedText, markdownToDoc, type EditorExtension, type ExitBoundaryHandler, @@ -11,6 +12,7 @@ import { type LinkCopyHandler, type MarkMode, type PlaceholderOptions, + type StartPendingReplacementOptions, type TagClickHandler, type TypedEditor, type WikilinkClickHandler, @@ -21,7 +23,15 @@ import type { EditorNode } from '@prosekit/pm/model' import { Selection, TextSelection } from '@prosekit/pm/state' import { ProseKit } from '@prosekit/react' import { clsx } from 'clsx/lite' -import { useImperativeHandle, useMemo, useRef, useState, type ReactNode, type Ref } from 'react' +import { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from 'react' import { defineCodeBlockView } from '../extensions/code-block-view.ts' import type { TimeFormat } from '../utils/date-format.ts' @@ -30,13 +40,18 @@ import { BlockHandle } from './block-handle.tsx' import { DropIndicator } from './drop-indicator.tsx' import { EditorExtensions } from './editor-extensions.tsx' import { LinkMenu } from './link-menu.tsx' +import { PendingReplacementPreview } from './pending-replacement-preview.tsx' +import { SelectionMenu } from './selection-menu.tsx' import { SlashMenu } from './slash-menu.tsx' import { TableHandle } from './table-handle.tsx' import { TagMenu } from './tag-menu.tsx' import type { EditorHandle, EditorStateSnapshot, + PendingReplacementResolveHandler, SelectionHint, + SelectionMenuContext, + SelectionMenuSearchHandler, SlashMenuSearchHandler, TagSearchHandler, WikilinkSearchHandler, @@ -80,6 +95,18 @@ export interface ProseKitEditorProps { /** Enables the wikilink menu. See `EditorProps.onWikilinkSearch`. */ onWikilinkSearch?: WikilinkSearchHandler + /** Enables the selection menu. See `EditorProps.onSelectionMenuSearch`. */ + onSelectionMenuSearch?: SelectionMenuSearchHandler + + /** Shows the selection affordance. See `EditorProps.selectionMenuAffordance`. */ + selectionMenuAffordance?: boolean + + /** Extra pending-replacement controls. See `EditorProps.pendingReplacementActions`. */ + pendingReplacementActions?: ReactNode + + /** Called when a pending replacement ends. See `EditorProps.onPendingReplacementResolve`. */ + onPendingReplacementResolve?: PendingReplacementResolveHandler + /** Called on click or Mod-Enter of a rendered wiki link. See `EditorProps.onWikilinkClick`. */ onWikilinkClick?: WikilinkClickHandler @@ -148,6 +175,10 @@ export function ProseKitEditor({ onSlashMenuSearch, onTagSearch, onWikilinkSearch, + onSelectionMenuSearch, + selectionMenuAffordance = true, + pendingReplacementActions, + onPendingReplacementResolve, onWikilinkClick, onLinkClick, onLinkCopy, @@ -183,6 +214,22 @@ export function ProseKitEditor({ // doc-change handler can ignore it: a host replacing content already knows. const suppressDocChangeRef = useRef(false) + // The selection the menu is open over, captured at open time so it survives + // focus moving into the menu's filter input. Undefined while closed. + const [selectionMenuContext, setSelectionMenuContext] = useState() + const hasSelectionMenu = !!onSelectionMenuSearch + + const openSelectionMenu = useCallback(() => { + const { state } = editor + const { from, to, empty } = state.selection + if (empty) return + setSelectionMenuContext({ selectedText: getSelectedText(state), from, to }) + }, [editor]) + + const closeSelectionMenu = useCallback(() => { + setSelectionMenuContext(undefined) + }, []) + useImperativeHandle(ref, () => { function getMarkdown(): string { return docToMarkdown(editor.state.doc, { frontmatter }) @@ -225,6 +272,25 @@ export function ProseKitEditor({ function scrollIntoView(): void { editor.view.dispatch(editor.state.tr.scrollIntoView()) } + function getSelectedTextFromState(): string { + return getSelectedText(editor.state) + } + function openSelectionMenuFromHandle(): void { + if (!hasSelectionMenu) return + openSelectionMenu() + } + function startPendingReplacement(options: StartPendingReplacementOptions): boolean { + return editor.commands.startPendingReplacement(options) + } + function appendPendingReplacementText(text: string): void { + editor.commands.appendPendingReplacementText(text) + } + function acceptPendingReplacement(): void { + editor.commands.acceptPendingReplacement() + } + function discardPendingReplacement(): void { + editor.commands.discardPendingReplacement() + } return { getMarkdown, setMarkdown, @@ -235,9 +301,15 @@ export function ProseKitEditor({ setSelection, focus, scrollIntoView, + getSelectedText: getSelectedTextFromState, + openSelectionMenu: openSelectionMenuFromHandle, + startPendingReplacement, + appendPendingReplacementText, + acceptPendingReplacement, + discardPendingReplacement, editor, } - }, [editor, frontmatter]) + }, [editor, frontmatter, hasSelectionMenu, openSelectionMenu]) // Guard the host callback so programmatic setState/setMarkdown stays silent. // Stable per `onDocChange` identity, so the extension is not rebuilt every render. @@ -277,6 +349,21 @@ export function ProseKitEditor({ {!readOnly && } {onTagSearch && } {onWikilinkSearch && } + {onSelectionMenuSearch && !readOnly && ( + + )} + {!readOnly && ( + + )} {children} ) diff --git a/packages/react/src/components/selection-menu.module.css b/packages/react/src/components/selection-menu.module.css new file mode 100644 index 00000000..e013de7b --- /dev/null +++ b/packages/react/src/components/selection-menu.module.css @@ -0,0 +1,109 @@ +.Positioner { + display: block; + z-index: 50; + width: min(18rem, calc(100vw - 1rem)); +} + +.Popup { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + padding: 0.25rem; + font-size: 0.875rem; + border: 1px solid var(--meowdown-border); + border-radius: 0.75rem; + background: var(--meowdown-popover-bg); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +.Input { + box-sizing: border-box; + width: 100%; + padding: 0.375rem 0.5rem; + margin-bottom: 0.25rem; + font: inherit; + color: var(--meowdown-text); + background: transparent; + border: 1px solid var(--meowdown-border); + border-radius: 0.5rem; + + &:focus { + outline: 2px solid var(--meowdown-accent); + outline-offset: -1px; + } +} + +.List { + display: flex; + flex-direction: column; + max-height: 16rem; + overflow-y: auto; +} + +.Item { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + text-align: left; + color: var(--meowdown-text); + border-radius: 0.5rem; + cursor: pointer; + + &[data-active] { + background: var(--meowdown-popover-hover-bg); + } +} + +.Label { + flex: none; +} + +.Detail { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 0.8125rem; + color: var(--meowdown-muted); +} + +.Empty { + padding: 0.375rem 0.5rem; + color: var(--meowdown-muted); +} + +.AffordancePositioner { + display: block; + z-index: 40; +} + +.AffordancePopup { + display: flex; +} + +.AffordanceButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + color: var(--meowdown-muted); + background: var(--meowdown-popover-bg); + border: 1px solid var(--meowdown-border); + border-radius: 0.5rem; + cursor: pointer; + + &:hover { + color: var(--meowdown-accent); + background: var(--meowdown-popover-hover-bg); + } + + & svg { + width: 0.875rem; + height: 0.875rem; + } +} diff --git a/packages/react/src/components/selection-menu.module.d.css.ts b/packages/react/src/components/selection-menu.module.d.css.ts new file mode 100644 index 00000000..c99e14bd --- /dev/null +++ b/packages/react/src/components/selection-menu.module.d.css.ts @@ -0,0 +1,15 @@ +// @ts-nocheck +declare const styles = { + 'Positioner': '' as string, + 'Popup': '' as string, + 'Input': '' as string, + 'List': '' as string, + 'Item': '' as string, + 'Label': '' as string, + 'Detail': '' as string, + 'Empty': '' as string, + 'AffordancePositioner': '' as string, + 'AffordancePopup': '' as string, + 'AffordanceButton': '' as string, +} as const; +export default styles; diff --git a/packages/react/src/components/selection-menu.test.tsx b/packages/react/src/components/selection-menu.test.tsx new file mode 100644 index 00000000..fb9fccf5 --- /dev/null +++ b/packages/react/src/components/selection-menu.test.tsx @@ -0,0 +1,155 @@ +import '../testing/index.ts' + +import { createRef } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { render } from 'vitest-browser-react' +import { page, userEvent } from 'vitest/browser' + +import { MeowdownEditor } from './editor.tsx' +import { ProseKitEditor } from './prosekit-editor.tsx' +import type { EditorHandle, SelectionMenuContext, SelectionMenuItem } from './types.ts' + +const pmRoot = page.locate('.ProseMirror') +const menu = page.getByTestId('selection-menu') +const affordance = page.getByTestId('selection-menu-affordance') + +const ITEMS: SelectionMenuItem[] = [ + { id: 'fix', label: 'Fix grammar', onSelect: () => {} }, + { id: 'summarize', label: 'Summarize', detail: 'Short summary', onSelect: () => {} }, + { id: 'rephrase', label: 'Rephrase', onSelect: () => {} }, +] + +function searchItems(query: string): SelectionMenuItem[] { + return ITEMS.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())) +} + +// The doc is one paragraph 'say hello end'; 'hello' spans positions 5..10. +const HELLO_SELECTION = { type: 'text', anchor: 5, head: 10 } as const + +describe('SelectionMenu', () => { + it('opens over a selection via the handle and lists the items', async () => { + const ref = createRef() + await render( + , + ) + ref.current?.setSelection(HELLO_SELECTION) + await expect.element(menu).not.toBeInTheDocument() + + ref.current?.openSelectionMenu() + await expect.element(menu).toBeVisible() + await expect.element(menu.getByText('Fix grammar')).toBeVisible() + await expect.element(menu.getByText('Short summary')).toBeVisible() + }) + + it('stays closed when the selection is empty', async () => { + const ref = createRef() + await render( + , + ) + ref.current?.setSelection({ type: 'text', anchor: 3, head: 3 }) + ref.current?.openSelectionMenu() + await expect.element(menu).not.toBeInTheDocument() + }) + + it('passes the filter text and the selection to the search handler', async () => { + const ref = createRef() + const search = vi.fn((query: string, _context: SelectionMenuContext) => searchItems(query)) + await render( + , + ) + ref.current?.setSelection(HELLO_SELECTION) + ref.current?.openSelectionMenu() + await expect.element(menu).toBeVisible() + + await userEvent.keyboard('sum') + await expect.element(menu.getByText('Summarize')).toBeVisible() + await expect.element(menu.getByText('Fix grammar')).not.toBeInTheDocument() + + const lastCall = search.mock.calls.at(-1) + expect(lastCall?.[0]).toBe('sum') + expect(lastCall?.[1]).toEqual({ selectedText: 'hello', from: 5, to: 10 }) + }) + + it('Enter picks the active item with the captured selection and closes', async () => { + const ref = createRef() + const onSelect = vi.fn() + const search = (): SelectionMenuItem[] => [{ id: 'fix', label: 'Fix grammar', onSelect }] + await render( + , + ) + ref.current?.setSelection(HELLO_SELECTION) + ref.current?.openSelectionMenu() + await expect.element(menu.getByText('Fix grammar')).toBeVisible() + + await userEvent.keyboard('{Enter}') + await expect.element(menu).not.toBeInTheDocument() + expect(onSelect).toHaveBeenCalledWith({ selectedText: 'hello', from: 5, to: 10 }) + }) + + it('shows the affordance on a selection and opens the menu from it', async () => { + const ref = createRef() + await render( + , + ) + await expect.element(affordance).not.toBeInTheDocument() + await pmRoot.click() + ref.current?.setSelection(HELLO_SELECTION) + await expect.element(affordance).toBeVisible() + + await affordance.getByRole('button', { name: 'Selection commands' }).click() + await expect.element(menu).toBeVisible() + await expect.element(affordance).not.toBeInTheDocument() + }) + + it('hides the affordance when selectionMenuAffordance is off', async () => { + const ref = createRef() + await render( + , + ) + await pmRoot.click() + ref.current?.setSelection(HELLO_SELECTION) + // Give the (absent) affordance delay a chance to elapse. + await new Promise((resolve) => setTimeout(resolve, 400)) + await expect.element(affordance).not.toBeInTheDocument() + }) + + it('renders nothing when onSelectionMenuSearch is not given', async () => { + const ref = createRef() + await render() + ref.current?.setSelection(HELLO_SELECTION) + ref.current?.openSelectionMenu() + await expect.element(menu).not.toBeInTheDocument() + }) + + it('passes onSelectionMenuSearch through ', async () => { + const ref = createRef() + await render( + , + ) + ref.current?.setSelection(HELLO_SELECTION) + ref.current?.openSelectionMenu() + await expect.element(menu.getByText('Fix grammar')).toBeVisible() + expect(ref.current?.getSelectedText()).toBe('hello') + }) +}) diff --git a/packages/react/src/components/selection-menu.tsx b/packages/react/src/components/selection-menu.tsx new file mode 100644 index 00000000..f3916c37 --- /dev/null +++ b/packages/react/src/components/selection-menu.tsx @@ -0,0 +1,258 @@ +import { Popover } from '@base-ui/react/popover' +import { + getPendingReplacement, + getVirtualElementFromRange, + type EditorExtension, + type VirtualElement, +} from '@meowdown/core' +import { defineUpdateHandler, isTextSelection } from '@prosekit/core' +import { useEditor, useExtension } from '@prosekit/react' +import { SparklesIcon } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from 'react' + +import { useDelayedFlag } from '../hooks/use-delayed-flag.ts' + +import styles from './selection-menu.module.css' +import type { + SelectionMenuContext, + SelectionMenuItem, + SelectionMenuSearchHandler, +} from './types.ts' + +interface SelectionMenuProps { + onSelectionMenuSearch: SelectionMenuSearchHandler + /** The selection the menu is open over; the menu is closed when undefined. */ + context: SelectionMenuContext | undefined + /** Requests opening over the current selection (from the affordance). */ + onOpen: () => void + /** Requests closing the menu. */ + onClose: () => void + /** Shows the floating button on a non-empty selection. On by default. */ + affordance?: boolean +} + +interface SelectionSnapshot { + from: number + to: number + /** Whether a floating affordance may anchor to this selection. */ + anchorable: boolean +} + +/** + * A command menu over the current selection: a popover with a filter input and + * host-supplied rows, anchored to the selected range. Opened imperatively (via + * `EditorHandle.openSelectionMenu`) or from the selection affordance, a small + * floating button that appears on a non-empty selection. + */ +export function SelectionMenu({ + onSelectionMenuSearch, + context, + onOpen, + onClose, + affordance = true, +}: SelectionMenuProps) { + const editor = useEditor() + const [selection, setSelection] = useState() + + // Tracks the live selection for the affordance. A pending replacement or a + // non-text selection (e.g. a selected image) never shows the button. + useExtension( + useMemo(() => { + return defineUpdateHandler((view) => { + const { from, to, empty } = view.state.selection + const anchorable = + !empty && isTextSelection(view.state.selection) && !getPendingReplacement(view.state) + setSelection((previous) => { + if ( + previous?.from === from && + previous?.to === to && + previous?.anchorable === anchorable + ) { + return previous + } + return { from, to, anchorable } + }) + }) + }, []), + ) + + const close = useCallback(() => { + onClose() + editor.focus() + }, [onClose, editor]) + + const menuAnchor: VirtualElement | undefined = useMemo(() => { + if (!context) return + return getVirtualElementFromRange(editor.view, { from: context.from, to: context.to }) + }, [context, editor]) + + const open = !!context + const showAffordance = affordance && !open && !!selection?.anchorable + const affordanceVisible = useDelayedFlag(showAffordance, 250, 0) + + const affordanceAnchor: VirtualElement | undefined = useMemo(() => { + if (!showAffordance || !selection) return + return getVirtualElementFromRange(editor.view, { from: selection.to, to: selection.to }) + }, [showAffordance, selection, editor]) + + if (context) { + return ( + { + if (!next) close() + }} + > + + + + + + + + + ) + } + + if (affordanceVisible && showAffordance) { + return ( + {}}> + + + + + + + + + ) + } + + return null +} + +/** The menu content. Mounted only while the menu is open, so its filter state + * resets naturally on close. */ +function SelectionMenuPopup({ + onSelectionMenuSearch, + context, + onClose, +}: { + onSelectionMenuSearch: SelectionMenuSearchHandler + context: SelectionMenuContext + onClose: () => void +}) { + const [query, setQuery] = useState('') + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + + const fetchItems = useCallback( + async (query: string, signal: AbortSignal): Promise => { + if (signal.aborted) return + setLoading(true) + const result = await onSelectionMenuSearch(query, context) + if (signal.aborted) return + setItems(result) + setActiveIndex(0) + setLoading(false) + }, + [onSelectionMenuSearch, context], + ) + + useEffect(() => { + const controller = new AbortController() + // Defer so the effect body doesn't call setState synchronously. + queueMicrotask(() => { + void fetchItems(query, controller.signal) + }) + return () => { + controller.abort() + } + }, [query, fetchItems]) + + const selectItem = useCallback( + (item: SelectionMenuItem) => { + onClose() + item.onSelect(context) + }, + [context, onClose], + ) + + function onInputKeyDown(event: KeyboardEvent): void { + if (event.key === 'ArrowDown') { + event.preventDefault() + setActiveIndex((index) => Math.min(index + 1, Math.max(items.length - 1, 0))) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + setActiveIndex((index) => Math.max(index - 1, 0)) + } else if (event.key === 'Enter') { + event.preventDefault() + const item = items[activeIndex] + if (item) selectItem(item) + } + } + + return ( + <> + setQuery(event.target.value)} + onKeyDown={onInputKeyDown} + /> +
+ {items.map((item, index) => ( + + ))} + {items.length === 0 ? ( +
{loading ? 'Loading...' : 'No commands'}
+ ) : null} +
+ + ) +} diff --git a/packages/react/src/components/types.ts b/packages/react/src/components/types.ts index 87362efb..936609ee 100644 --- a/packages/react/src/components/types.ts +++ b/packages/react/src/components/types.ts @@ -1,4 +1,9 @@ -import type { TypedEditor } from '@meowdown/core' +import type { + PendingReplacement, + PendingReplacementOutcome, + StartPendingReplacementOptions, + TypedEditor, +} from '@meowdown/core' import type { SelectionJSON } from '@prosekit/core' /** A selection to restore: an exact JSON selection, or a document edge. */ @@ -53,6 +58,35 @@ export interface EditorHandle { /** Scrolls the selection into view. */ scrollIntoView: () => void + /** + * The plain text of the current selection, with block boundaries as blank + * lines. Inline Markdown syntax is literal text, so the result reads as + * Markdown for inline content. + */ + getSelectedText: () => string + + /** + * Opens the selection menu over the current selection. A no-op when the + * selection is empty or `onSelectionMenuSearch` is not set. + */ + openSelectionMenu: () => void + + /** + * Stages a pending replacement over a document range. Returns `false` when + * the range is invalid. Calling it again resets the accumulated text, which + * is how a retry starts. + */ + startPendingReplacement: (options: StartPendingReplacementOptions) => boolean + + /** Appends streamed text to the staged replacement. */ + appendPendingReplacementText: (text: string) => void + + /** Applies the staged replacement to the document as a single edit. */ + acceptPendingReplacement: () => void + + /** Clears the staged replacement without touching the document. */ + discardPendingReplacement: () => void + /** * Escape hatch: the underlying ProseKit editor, or `undefined` when the * handle does not wrap one. @@ -117,3 +151,41 @@ export interface WikilinkItem { * a promise. */ export type WikilinkSearchHandler = (query: string) => WikilinkItem[] | Promise + +/** The selection the selection menu was opened over. */ +export interface SelectionMenuContext { + /** The selected text, with block boundaries as blank lines. */ + selectedText: string + /** Start of the selection. */ + from: number + /** End of the selection. */ + to: number +} + +/** One row in the selection menu. The host ranks the rows; the menu does not re-sort. */ +export interface SelectionMenuItem { + /** Stable identity for the row. */ + id: string + /** Display text. */ + label: string + /** Secondary text shown beside the label. */ + detail?: string + /** Runs when the row is picked, with the selection the menu was opened over. */ + onSelect: (context: SelectionMenuContext) => void +} + +/** + * Searches commands for the selection menu. Receives the filter text typed in + * the menu (may be empty) and the selection the menu was opened over, and + * returns the rows to show, either synchronously or as a promise. + */ +export type SelectionMenuSearchHandler = ( + query: string, + context: SelectionMenuContext, +) => SelectionMenuItem[] | Promise + +/** Reports how a pending replacement ended and its final staged value. */ +export type PendingReplacementResolveHandler = ( + outcome: PendingReplacementOutcome, + pending: PendingReplacement, +) => void diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index efa64f76..8e0f2c41 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,7 +4,11 @@ export type { TimeFormat } from './utils/date-format.ts' export type { EditorHandle, EditorStateSnapshot, + PendingReplacementResolveHandler, SelectionHint, + SelectionMenuContext, + SelectionMenuItem, + SelectionMenuSearchHandler, SlashMenuItem, SlashMenuSearchHandler, TagItem, diff --git a/website/src/components/codemirror-editor.tsx b/website/src/components/codemirror-editor.tsx index 2beda4c1..fe98d7f0 100644 --- a/website/src/components/codemirror-editor.tsx +++ b/website/src/components/codemirror-editor.tsx @@ -109,6 +109,12 @@ export function CodeMirrorEditor({ function scrollIntoView(): void { viewRef.current?.dispatch({ scrollIntoView: true }) } + function getSelectedText(): string { + const view = viewRef.current + if (!view) return '' + const main = view.state.selection.main + return view.state.sliceDoc(main.from, main.to) + } return { getMarkdown, setMarkdown, @@ -119,6 +125,13 @@ export function CodeMirrorEditor({ setSelection, focus, scrollIntoView, + getSelectedText, + // The raw-Markdown editor has no selection menu or staged replacements. + openSelectionMenu: () => {}, + startPendingReplacement: () => false, + appendPendingReplacementText: () => {}, + acceptPendingReplacement: () => {}, + discardPendingReplacement: () => {}, editor: undefined, } }, []) From 507390600da60f11b1394b3320be34f0c519dbdb Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Wed, 1 Jul 2026 23:52:37 +0100 Subject: [PATCH 2/3] fix: guard virtual-element measurement after view destroy Floating-UI re-measures asynchronously; a measurement landing after the editor unmounts hit view.docView.domFromPos on a destroyed view (unhandled rejection on WebKit). Return the last known rect instead. Co-Authored-By: Claude Fable 5 --- packages/core/src/utils/virtual-element.ts | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/core/src/utils/virtual-element.ts b/packages/core/src/utils/virtual-element.ts index c612ff50..4de656bd 100644 --- a/packages/core/src/utils/virtual-element.ts +++ b/packages/core/src/utils/virtual-element.ts @@ -7,16 +7,27 @@ export type { VirtualElement } /** * Returns a Floating-UI virtual element tracking a document range. + * + * Positioning libraries re-measure asynchronously (resize observers, animation + * frames), so a measurement can fire after the view is destroyed or the range + * no longer resolves — those return the last known rect instead of throwing. */ export function getVirtualElementFromRange(view: EditorView, range: PositionRange): VirtualElement { + let lastRect = new DOMRect(0, 0, 0, 0) const getBoundingClientRect = (): DOMRect => { - const start = view.coordsAtPos(range.from) - const end = view.coordsAtPos(range.to) - const left = Math.min(start.left, end.left) - const right = Math.max(start.right, end.right) - const top = Math.min(start.top, end.top) - const bottom = Math.max(start.bottom, end.bottom) - return new DOMRect(left, top, right - left, bottom - top) + if (view.isDestroyed) return lastRect + try { + const start = view.coordsAtPos(range.from) + const end = view.coordsAtPos(range.to) + const left = Math.min(start.left, end.left) + const right = Math.max(start.right, end.right) + const top = Math.min(start.top, end.top) + const bottom = Math.max(start.bottom, end.bottom) + lastRect = new DOMRect(left, top, right - left, bottom - top) + } catch { + // Out-of-range position (e.g. the document shrank mid-measure). + } + return lastRect } return { getBoundingClientRect, From ca6a7d4ba9ac8e9b48a291c36496b9ac6b5d4bc9 Mon Sep 17 00:00:00 2001 From: Alex MacCaw Date: Thu, 2 Jul 2026 12:03:38 +0100 Subject: [PATCH 3/3] feat: accept-mode override and Markdown-faithful selected text Old Reflect's prediction menu let the user pick Replace vs Insert at accept time and serialized the selection as Markdown; port both: - acceptPendingReplacement({ mode }) overrides the staged placement for one accept, so a host can offer the alternate action next to Accept. - getSelectedText serializes the selection slice through docToMarkdown, keeping list markers, headings, and blockquotes intact instead of flattening blocks to bare lines. Co-Authored-By: Claude Fable 5 --- .../extensions/pending-replacement.test.ts | 13 ++++++ .../src/extensions/pending-replacement.ts | 11 ++++- packages/core/src/index.ts | 1 + packages/core/src/utils/selected-text.test.ts | 46 +++++++++++++++++++ packages/core/src/utils/selected-text.ts | 23 ++++++++-- packages/react/src/components/editor.tsx | 5 +- .../react/src/components/prosekit-editor.tsx | 5 +- packages/react/src/components/types.ts | 15 ++++-- 8 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/utils/selected-text.test.ts diff --git a/packages/core/src/extensions/pending-replacement.test.ts b/packages/core/src/extensions/pending-replacement.test.ts index b3f7e332..8e967c40 100644 --- a/packages/core/src/extensions/pending-replacement.test.ts +++ b/packages/core/src/extensions/pending-replacement.test.ts @@ -79,6 +79,19 @@ describe('pending replacement', () => { expect(docToMarkdown(fixture.doc)).toBe('- one\n- two\n') }) + it('accepts with a mode override (insert-below on a replace stage)', () => { + using fixture = setupFixture() + const { editor, n } = fixture + fixture.set(n.doc(n.paragraph('say
hello end'))) + + const { from, to } = selectionRange(fixture) + editor.commands.startPendingReplacement({ from, to, mode: 'replace' }) + editor.commands.appendPendingReplacementText('a summary') + expect(editor.commands.acceptPendingReplacement({ mode: 'append' })).toBe(true) + + expect(docToMarkdown(fixture.doc)).toBe('say hello end\n\na summary\n') + }) + it('accepts in append mode after the source block', () => { using fixture = setupFixture() const { editor, n } = fixture diff --git a/packages/core/src/extensions/pending-replacement.ts b/packages/core/src/extensions/pending-replacement.ts index e2c43724..338fc38f 100644 --- a/packages/core/src/extensions/pending-replacement.ts +++ b/packages/core/src/extensions/pending-replacement.ts @@ -166,17 +166,24 @@ function discardPendingReplacement(): Command { } } -function acceptPendingReplacement(): Command { +/** Options for the `acceptPendingReplacement` command. */ +export interface AcceptPendingReplacementOptions { + /** Overrides the staged mode for this accept (e.g. "Insert below" on a replace stage). */ + mode?: PendingReplacementMode +} + +function acceptPendingReplacement(options: AcceptPendingReplacementOptions = {}): Command { return (state, dispatch) => { const pending = getPendingReplacement(state) if (!pending || !pending.text.trim()) return false if (dispatch) { + const mode = options.mode ?? pending.mode const nodes = getNodeBuildersForSchema(state.schema) const parsed = markdownToDoc(pending.text, { nodes }) const tr = state.tr tr.setMeta(pendingReplacementKey, { type: 'accept' } satisfies PendingReplacementMeta) - if (pending.mode === 'append') { + if (mode === 'append') { // Insert the parsed blocks after the top-level block containing `to`. const insertPos = state.doc.resolve(pending.to).after(1) tr.insert(insertPos, parsed.content) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d89e4cbb..96ac5b28 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -72,6 +72,7 @@ export type { NodeName } from './extensions/node-names.ts' export { definePendingReplacementHandler, getPendingReplacement, + type AcceptPendingReplacementOptions, type PendingReplacement, type PendingReplacementEvent, type PendingReplacementHandler, diff --git a/packages/core/src/utils/selected-text.test.ts b/packages/core/src/utils/selected-text.test.ts new file mode 100644 index 00000000..d82b8509 --- /dev/null +++ b/packages/core/src/utils/selected-text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { setupFixture } from '../testing/index.ts' + +import { getSelectedText } from './selected-text.ts' + +describe('getSelectedText', () => { + it('returns bare text for a selection inside one textblock', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('say hello **bold** end'))) + expect(getSelectedText(fixture.state)).toBe('hello **bold**') + }) + + it('returns an empty string for an empty selection', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('say hello'))) + expect(getSelectedText(fixture.state)).toBe('') + }) + + it('keeps block markers for a multi-block selection', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set( + n.doc( + n.heading({ level: 2 }, 'Title'), + n.list({ kind: 'bullet' }, n.paragraph('one')), + n.list({ kind: 'bullet' }, n.paragraph('two')), + ), + ) + expect(getSelectedText(fixture.state)).toBe('## Title\n\n- one\n- two') + }) + + it('keeps list markers when the selection spans list items partially', () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set( + n.doc( + n.list({ kind: 'bullet' }, n.paragraph('first item')), + n.list({ kind: 'bullet' }, n.paragraph('second item')), + ), + ) + expect(getSelectedText(fixture.state)).toBe('- item\n- second') + }) +}) diff --git a/packages/core/src/utils/selected-text.ts b/packages/core/src/utils/selected-text.ts index b4c07fed..18591d41 100644 --- a/packages/core/src/utils/selected-text.ts +++ b/packages/core/src/utils/selected-text.ts @@ -1,11 +1,24 @@ import type { EditorState } from '@prosekit/pm/state' +import { docToMarkdown } from '../converters/pm-to-md.ts' + /** - * The plain text of the current selection, with block boundaries as blank - * lines. Inline Markdown syntax is literal text in the document, so the - * result reads as Markdown for inline content. + * The current selection as Markdown: block structure (list markers, headings, + * blockquotes) is serialized, and inline Markdown syntax is already literal + * text in the document. A selection inside one textblock comes back as its + * bare text; a multi-block selection keeps its block markers, so downstream + * consumers (e.g. an AI prompt) see the same Markdown the user would. */ export function getSelectedText(state: EditorState): string { - const { from, to } = state.selection - return state.doc.textBetween(from, to, '\n\n') + const { selection, schema } = state + if (selection.empty) return '' + const fragment = selection.content().content + try { + const doc = schema.topNodeType.create(null, fragment) + return docToMarkdown(doc).replace(/\n+$/, '') + } catch { + // A fragment the doc type cannot hold (e.g. a bare table row) falls back + // to plain text with block boundaries as blank lines. + return state.doc.textBetween(selection.from, selection.to, '\n\n') + } } diff --git a/packages/react/src/components/editor.tsx b/packages/react/src/components/editor.tsx index e88407a4..4f5f2d9e 100644 --- a/packages/react/src/components/editor.tsx +++ b/packages/react/src/components/editor.tsx @@ -1,4 +1,5 @@ import type { + AcceptPendingReplacementOptions, ExitBoundaryHandler, FilePasteOptions, ImageClickHandler, @@ -306,8 +307,8 @@ export function MeowdownEditor({ function appendPendingReplacementText(text: string): void { childRef.current?.appendPendingReplacementText(text) } - function acceptPendingReplacement(): void { - childRef.current?.acceptPendingReplacement() + function acceptPendingReplacement(options?: AcceptPendingReplacementOptions): void { + childRef.current?.acceptPendingReplacement(options) } function discardPendingReplacement(): void { childRef.current?.discardPendingReplacement() diff --git a/packages/react/src/components/prosekit-editor.tsx b/packages/react/src/components/prosekit-editor.tsx index 744666f3..c6bc483d 100644 --- a/packages/react/src/components/prosekit-editor.tsx +++ b/packages/react/src/components/prosekit-editor.tsx @@ -3,6 +3,7 @@ import { docToMarkdown, getSelectedText, markdownToDoc, + type AcceptPendingReplacementOptions, type EditorExtension, type ExitBoundaryHandler, type FilePasteOptions, @@ -285,8 +286,8 @@ export function ProseKitEditor({ function appendPendingReplacementText(text: string): void { editor.commands.appendPendingReplacementText(text) } - function acceptPendingReplacement(): void { - editor.commands.acceptPendingReplacement() + function acceptPendingReplacement(options?: AcceptPendingReplacementOptions): void { + editor.commands.acceptPendingReplacement(options ?? {}) } function discardPendingReplacement(): void { editor.commands.discardPendingReplacement() diff --git a/packages/react/src/components/types.ts b/packages/react/src/components/types.ts index 936609ee..3937f2bf 100644 --- a/packages/react/src/components/types.ts +++ b/packages/react/src/components/types.ts @@ -1,4 +1,5 @@ import type { + AcceptPendingReplacementOptions, PendingReplacement, PendingReplacementOutcome, StartPendingReplacementOptions, @@ -59,9 +60,9 @@ export interface EditorHandle { scrollIntoView: () => void /** - * The plain text of the current selection, with block boundaries as blank - * lines. Inline Markdown syntax is literal text, so the result reads as - * Markdown for inline content. + * The current selection as Markdown: block structure (list markers, + * headings) is serialized, and inline syntax is already literal text. A + * selection inside one textblock comes back as its bare text. */ getSelectedText: () => string @@ -81,8 +82,12 @@ export interface EditorHandle { /** Appends streamed text to the staged replacement. */ appendPendingReplacementText: (text: string) => void - /** Applies the staged replacement to the document as a single edit. */ - acceptPendingReplacement: () => void + /** + * Applies the staged replacement to the document as a single edit. Pass a + * `mode` to override the staged placement for this accept (e.g. "Insert + * below" on a replace stage). + */ + acceptPendingReplacement: (options?: AcceptPendingReplacementOptions) => void /** Clears the staged replacement without touching the document. */ discardPendingReplacement: () => void