From 4faeb91446cd3f3b0952cc4795c0a6085cd0d627 Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 2 Jul 2026 21:47:16 +1000 Subject: [PATCH 1/2] feat(website): expose `handleRef` on `DemoEditor` --- website/src/components/demo-editor.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/website/src/components/demo-editor.tsx b/website/src/components/demo-editor.tsx index fcacffac..bfec0e8c 100644 --- a/website/src/components/demo-editor.tsx +++ b/website/src/components/demo-editor.tsx @@ -4,7 +4,7 @@ import { type EditorMode, type EditorProps, } from '@meowdown/react' -import { useRef } from 'react' +import { useRef, type RefObject } from 'react' import { CodeMirrorEditor } from './codemirror-editor.tsx' @@ -18,6 +18,12 @@ export interface DemoEditorProps extends Omit * 'focus'. */ mode?: DemoMode + + /** + * Handle of whichever editor is currently mounted. In source mode the + * selection and pending-replacement methods are no-ops. + */ + handleRef?: RefObject } export function DemoEditor({ @@ -26,12 +32,14 @@ export function DemoEditor({ onDocChange, readOnly, children, + handleRef, ...richProps }: DemoEditorProps) { // Handle of whichever editor is currently mounted. Reading it during render // seeds the next editor with the previous one's content when the mode flips // between the rich and source families. - const childRef = useRef(null) + const fallbackRef = useRef(null) + const childRef = handleRef ?? fallbackRef const seedMarkdown = childRef.current?.getMarkdown() ?? initialMarkdown ?? '' if (mode === 'source') { From 100c2f2e7f943f86198fca9787039a1b9606b34e Mon Sep 17 00:00:00 2001 From: ocavue Date: Thu, 2 Jul 2026 21:47:17 +1000 Subject: [PATCH 2/2] feat(website): demo the selection menu and pending-replacement preview --- website/src/app.tsx | 25 +++- website/src/selection-demo.tsx | 224 +++++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 website/src/selection-demo.tsx diff --git a/website/src/app.tsx b/website/src/app.tsx index 14a65b23..1a80247c 100644 --- a/website/src/app.tsx +++ b/website/src/app.tsx @@ -1,10 +1,18 @@ import type { ExitBoundaryHandler } from '@meowdown/core' -import type { TagItem, WikilinkItem } from '@meowdown/react' +import type { EditorHandle, TagItem, WikilinkItem } from '@meowdown/react' import { getId } from '@ocavue/utils' import { clsx } from 'clsx/lite' -import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useState } from 'react' +import { + type CSSProperties, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react' import { DemoEditor } from './components/demo-editor.tsx' +import { SelectionMenuShortcut, useSelectionDemo } from './selection-demo.tsx' import { uploadFile } from './upload-file.ts' import { MODES, useEditorMode } from './use-editor-mode.ts' @@ -45,6 +53,8 @@ Label your notes with tags like #meow and #markdown. Type \`#\` followed by a le Connect notes with wikilinks like [[Daily journal]] and [[Reading list]]. Type \`[[\` to link another note. +Select some text and click the sparkle button (or press \`Mod-Shift-J\`) to run a command on it. The result streams into a preview, and nothing changes until you accept it. + Track things two ways. Type \`+ \` for a circle checkbox task, or \`[] \` for a square checkbox task: + [ ] Ship the circle task @@ -205,6 +215,9 @@ function Brand() { export function App() { const { mode, setMode, activeMode } = useEditorMode() + const editorRef = useRef(null) + const selectionDemo = useSelectionDemo(editorRef) + const [spellCheck, setSpellCheck] = useState(undefined) useEffect(() => { @@ -289,15 +302,21 @@ export function App() { mode={mode} spellCheck={spellCheck} initialMarkdown={INITIAL_CONTENT} + handleRef={editorRef} onTagSearch={searchTags} onWikilinkSearch={searchNotes} + onSelectionMenuSearch={selectionDemo.onSelectionMenuSearch} + pendingReplacementActions={selectionDemo.pendingReplacementActions} + onPendingReplacementResolve={selectionDemo.onPendingReplacementResolve} onFilePaste={uploadFile} onImageClick={handleImageClick} onLinkClick={handleLinkClick} onTagClick={handleTagClick} onWikilinkClick={handleWikilinkClick} onExitBoundary={handleExitBoundary} - /> + > + + {edgeFlash && ( diff --git a/website/src/selection-demo.tsx b/website/src/selection-demo.tsx new file mode 100644 index 00000000..6c68e9ad --- /dev/null +++ b/website/src/selection-demo.tsx @@ -0,0 +1,224 @@ +import { getPendingReplacement, type PendingReplacementMode } from '@meowdown/core' +import { + useEditor, + useKeymap, + type EditorHandle, + type PendingReplacementResolveHandler, + type SelectionMenuContext, + type SelectionMenuSearchHandler, +} from '@meowdown/react' +import { useCallback, useMemo, useRef, type ReactNode, type RefObject } from 'react' + +/** + * A selection-menu command for the demo: the result is computed locally from + * the selected text and streamed into the pending-replacement preview in small + * chunks, standing in for an AI provider. + */ +interface DemoCommand { + id: string + label: string + detail: string + mode: PendingReplacementMode + transform: (selectedText: string) => string +} + +/** Leading Markdown block markers: heading, list, blockquote, task box. */ +const LEADING_BLOCK_MARKERS = /^\s*(?:(?:[-*+]|\d+[.)]|#{1,6}|>)\s+)*(?:\[[ xX]\]\s+)?/ + +const DEMO_COMMANDS: DemoCommand[] = [ + { + id: 'meowify', + label: 'Meowify', + detail: 'Every word becomes meow', + mode: 'replace', + transform: (selectedText) => + selectedText.replaceAll(/\p{L}+/gu, (word) => (/^\p{Lu}/u.test(word) ? 'Meow' : 'meow')), + }, + { + id: 'uppercase', + label: 'Uppercase', + detail: 'Shout the selection', + mode: 'replace', + transform: (selectedText) => selectedText.toUpperCase(), + }, + { + id: 'bullets', + label: 'Turn into bullet list', + detail: 'One bullet per line', + mode: 'replace', + transform: (selectedText) => + selectedText + .split('\n') + .map((line) => line.replace(LEADING_BLOCK_MARKERS, '').trim()) + .filter((line) => line.length > 0) + .map((line) => `- ${line}`) + .join('\n'), + }, + { + id: 'summarize', + label: 'Write a short summary', + detail: 'Appends below the selection', + mode: 'append', + transform: (selectedText) => { + const wordCount = selectedText.split(/\s+/).filter(Boolean).length + return `**Summary:** the selection has ${wordCount} ${wordCount === 1 ? 'word' : 'words'} and, in essence, says: meow.` + }, + }, +] + +const SEARCH_LATENCY_MS = 150 +const STREAM_CHUNK_SIZE = 4 +const STREAM_INTERVAL_MS = 30 + +/** One in-flight (or previewed) demo run, kept for retry. */ +interface DemoRun { + command: DemoCommand + context: SelectionMenuContext + cancelled: boolean +} + +const ACTION_BUTTON_CLASS = + 'inline-flex cursor-pointer items-center rounded-lg px-2.5 py-1 text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800' + +function DemoReplacementActions({ + onRetry, + onInsertBelow, +}: { + onRetry: () => void + onInsertBelow: () => void +}) { + return ( + <> + + + + ) +} + +export interface SelectionDemoValue { + onSelectionMenuSearch: SelectionMenuSearchHandler + pendingReplacementActions: ReactNode + onPendingReplacementResolve: PendingReplacementResolveHandler + /** Opens the selection menu; bound to `Mod-Shift-j` by `SelectionMenuShortcut`. */ + openMenu: () => void +} + +/** + * Wires the selection menu and the pending-replacement preview to a set of + * locally computed demo commands, mirroring how a real host (prompt list, + * streaming provider call, retry control) would use the editor API. + */ +export function useSelectionDemo(handleRef: RefObject): SelectionDemoValue { + const runRef = useRef(undefined) + + const beginStream = useCallback( + (command: DemoCommand, context: SelectionMenuContext) => { + if (runRef.current) runRef.current.cancelled = true + const run: DemoRun = { command, context, cancelled: false } + runRef.current = run + + const result = command.transform(context.selectedText) + let offset = 0 + const timer = setInterval(() => { + if (run.cancelled || offset >= result.length) { + clearInterval(timer) + return + } + handleRef.current?.appendPendingReplacementText( + result.slice(offset, offset + STREAM_CHUNK_SIZE), + ) + offset += STREAM_CHUNK_SIZE + }, STREAM_INTERVAL_MS) + }, + [handleRef], + ) + + const runCommand = useCallback( + (command: DemoCommand, context: SelectionMenuContext) => { + const handle = handleRef.current + if (!handle) return + const { from, to } = context + if (!handle.startPendingReplacement({ from, to, mode: command.mode })) return + beginStream(command, context) + }, + [handleRef, beginStream], + ) + + const onSelectionMenuSearch = useCallback( + async (query) => { + // Simulate network latency so the menu's loading state shows up. + await new Promise((resolve) => setTimeout(resolve, SEARCH_LATENCY_MS)) + const needle = query.trim().toLowerCase() + const commands = needle + ? DEMO_COMMANDS.filter((command) => command.label.toLowerCase().includes(needle)) + : DEMO_COMMANDS + return commands.map((command) => ({ + id: command.id, + label: command.label, + detail: command.detail, + onSelect: (context: SelectionMenuContext) => runCommand(command, context), + })) + }, + [runCommand], + ) + + const retry = useCallback(() => { + const run = runRef.current + const handle = handleRef.current + if (!run || !handle) return + // Restage over the current staged range: it tracks edits made while the + // text streamed, so the offsets captured at menu-open time may be stale. + const staged = handle.editor ? getPendingReplacement(handle.editor.state) : null + const range = staged ?? run.context + if ( + !handle.startPendingReplacement({ from: range.from, to: range.to, mode: run.command.mode }) + ) { + return + } + beginStream(run.command, run.context) + }, [handleRef, beginStream]) + + const insertBelow = useCallback(() => { + handleRef.current?.acceptPendingReplacement({ mode: 'append' }) + }, [handleRef]) + + const pendingReplacementActions = useMemo( + () => , + [retry, insertBelow], + ) + + const onPendingReplacementResolve = useCallback(() => { + if (runRef.current) runRef.current.cancelled = true + runRef.current = undefined + }, []) + + const openMenu = useCallback(() => { + handleRef.current?.openSelectionMenu() + }, [handleRef]) + + return { onSelectionMenuSearch, pendingReplacementActions, onPendingReplacementResolve, openMenu } +} + +/** + * Binds `Mod-Shift-j` inside the editor to open the selection menu. An empty + * selection lets the key fall through. + */ +export function SelectionMenuShortcut({ onTrigger }: { onTrigger: () => void }) { + const editor = useEditor() + const keymap = useMemo( + () => ({ + 'Mod-Shift-j': () => { + if (editor.state.selection.empty) return false + onTrigger() + return true + }, + }), + [editor, onTrigger], + ) + useKeymap(keymap) + return null +}