From 3709f7265540d4756072e8baf2bf4864a65353f7 Mon Sep 17 00:00:00 2001 From: yexiyue Date: Wed, 13 May 2026 14:35:22 +0800 Subject: [PATCH 1/7] feat(editor): wire slash command runtime + Tauri popover (v0.3 phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land host side of Phase A — consume sibling editor-core slash runtime. - preferencesStore: add `slash` to `EDITOR_PLUGIN_IDS` (enabled by default) - NoteEditor: - opt into `slashCommandPlugin()` when `enabledPlugins` contains 'slash' - subscribe to `EditorEventType.SlashTriggerChange`, mirror match into component state, drop on `match.active: false` - wire `host.getSlashItems` through new `interactionProviders.ts` - SlashCommandPopover.tsx (new): Radix Popover anchored at `match.screenRect`, renders grouped items with `activeIndex` highlighted; captures keydown on editor's contentDOM and routes ArrowDown/ArrowUp/Enter/Escape to `slash.{next,prev,confirm,dismiss}` via `editorControl.execCommand` - interactionProviders.ts (new): `getSlashItems(query, signal)` returns "Jump to: " items from `fileTreeStore` notes (fuzzy match); empty query returns first 8 notes - Settings general.tsx: add Slash 命令 row in 编辑器插件 section Depends on sibling commit swarm-apps/swarmnote-editor@feat/interaction-trio-phase-a-slash · 0a23111 Frontend typecheck + lint:ci + build pass. Manual `pnpm tauri dev` smoke test deferred to user. --- src/components/editor/NoteEditor.tsx | 12 ++ src/components/editor/SlashCommandPopover.tsx | 150 ++++++++++++++++++ src/components/editor/interactionProviders.ts | 63 ++++++++ src/routes/settings/general.tsx | 7 + src/stores/preferencesStore.ts | 1 + 5 files changed, 233 insertions(+) create mode 100644 src/components/editor/SlashCommandPopover.tsx create mode 100644 src/components/editor/interactionProviders.ts diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index c5d6900..ce9e031 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -6,6 +6,7 @@ import { EditorEventType, type EditorPlugin, type EditorSettings, + type SlashTriggerMatch, } from "@swarmnote/editor-core"; import { admonitionPlugin } from "@swarmnote/editor-core/plugins/admonition"; import { @@ -13,6 +14,7 @@ import { refreshBlockImagesEffect, } from "@swarmnote/editor-core/plugins/blockImage"; import { codeBlockPlugin } from "@swarmnote/editor-core/plugins/codeBlock"; +import { slashCommandPlugin } from "@swarmnote/editor-core/plugins/interactions/slash"; import { mathPlugin } from "@swarmnote/editor-core/plugins/math"; import { mermaidPlugin } from "@swarmnote/editor-core/plugins/mermaid"; import { rawHtmlPlugin } from "@swarmnote/editor-core/plugins/rawHtml"; @@ -26,6 +28,8 @@ import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "reac import * as Y from "yjs"; import { openYDoc, reloadYDocConfirmed, saveMedia } from "@/commands/document"; import { EditorContextMenu } from "@/components/editor/EditorContextMenu"; +import { getSlashItems } from "@/components/editor/interactionProviders"; +import { SlashCommandPopover } from "@/components/editor/SlashCommandPopover"; import { initialTableContextMenuState, TableContextMenu, @@ -59,6 +63,7 @@ function buildEditorPlugins( if (enabled.has("blockImage")) plugins.push(blockImagePlugin()); if (enabled.has("rawHtml")) plugins.push(rawHtmlPlugin()); if (enabled.has("smartPaste")) plugins.push(smartPastePlugin()); + if (enabled.has("slash")) plugins.push(slashCommandPlugin()); return plugins; } @@ -164,6 +169,9 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr const [tableMenuState, setTableMenuState] = useState<TableContextMenuState>( initialTableContextMenuState, ); + + // Slash command popover state — driven by `SlashTriggerChange` events. + const [slashMatch, setSlashMatch] = useState<SlashTriggerMatch | null>(null); const handleTableMenuOpenChange = useCallback((open: boolean) => { setTableMenuState((prev) => ({ ...prev, open })); }, []); @@ -265,6 +273,7 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr // URL may be malformed or blocked — silent. }); }, + getSlashItems, }, plugins, autofocus: true, @@ -290,6 +299,8 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr openUrl(event.url).catch(() => { // URL may be malformed or blocked — silent. }); + } else if (event.kind === EditorEventType.SlashTriggerChange) { + setSlashMatch(event.match.active ? event.match : null); } }, }); @@ -500,6 +511,7 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr onChange={handleFileInputChange} /> <TableContextMenu state={tableMenuState} onOpenChange={handleTableMenuOpenChange} /> + <SlashCommandPopover match={slashMatch} control={editorControl} /> </> ); } diff --git a/src/components/editor/SlashCommandPopover.tsx b/src/components/editor/SlashCommandPopover.tsx new file mode 100644 index 0000000..ab10c22 --- /dev/null +++ b/src/components/editor/SlashCommandPopover.tsx @@ -0,0 +1,150 @@ +import { useLingui } from "@lingui/react/macro"; +import type { EditorControl, SlashTriggerMatch } from "@swarmnote/editor-core"; +import { useEffect, useMemo, useRef } from "react"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface SlashCommandPopoverProps { + /** Current trigger match from `SlashTriggerChange` events. */ + match: SlashTriggerMatch | null; + /** Editor control instance used to route keyboard events to `slash.*` commands. */ + control: EditorControl | null; +} + +/** + * Renders a floating Radix Popover with slash candidate items. + * + * Subscribes to keyboard events on the editor's contentDOM while the trigger + * is active and routes ↑/↓/Enter/Escape to the SDK's slash.* commands. + */ +export function SlashCommandPopover({ match, control }: SlashCommandPopoverProps) { + const { t } = useLingui(); + const open = match?.active ?? false; + const items = useMemo(() => match?.items ?? [], [match]); + const activeIndex = match?.activeIndex ?? 0; + const screenRect = match?.screenRect; + + // Capture keyboard events on the editor's contentDOM while open, route to slash.* commands. + const controlRef = useRef(control); + controlRef.current = control; + + useEffect(() => { + if (!open || !control) return; + const contentDom = control.view.contentDOM; + const handler = (e: KeyboardEvent) => { + let cmd: string | null = null; + if (e.key === "ArrowDown") cmd = "slash.next"; + else if (e.key === "ArrowUp") cmd = "slash.prev"; + else if (e.key === "Enter") cmd = "slash.confirm"; + else if (e.key === "Escape") cmd = "slash.dismiss"; + if (!cmd) return; + e.preventDefault(); + e.stopPropagation(); + controlRef.current?.execCommand(cmd); + }; + contentDom.addEventListener("keydown", handler, true); + return () => { + contentDom.removeEventListener("keydown", handler, true); + }; + }, [open, control]); + + // Group items by section if any + const grouped = useMemo(() => { + const buckets = new Map<string, typeof items>(); + for (const it of items) { + const key = it.section ?? ""; + const arr = buckets.get(key) ?? []; + arr.push(it); + buckets.set(key, arr); + } + return Array.from(buckets.entries()); + }, [items]); + + if (!open || !screenRect) return null; + + return ( + <Popover open={open}> + <PopoverAnchor asChild> + <div + aria-hidden + style={{ + position: "fixed", + left: screenRect.x, + top: screenRect.y, + width: screenRect.width, + height: screenRect.height, + pointerEvents: "none", + }} + /> + </PopoverAnchor> + <PopoverContent + align="start" + side="bottom" + sideOffset={4} + className="w-72 p-1" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {items.length === 0 ? ( + <div className="px-2 py-1.5 text-sm text-muted-foreground">{t`No matching commands`}</div> + ) : ( + <div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto"> + {grouped.map(([section, sectionItems]) => ( + <SlashSection + key={section || "_default"} + label={section} + items={sectionItems} + activeIndex={activeIndex} + allItems={items} + /> + ))} + </div> + )} + </PopoverContent> + </Popover> + ); +} + +interface SlashSectionProps { + label: string; + items: SlashTriggerMatch["items"]; + activeIndex: number; + allItems: SlashTriggerMatch["items"]; +} + +function SlashSection({ label, items, activeIndex, allItems }: SlashSectionProps) { + return ( + <> + {label ? ( + <div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">{label}</div> + ) : null} + {items.map((item) => { + const absoluteIndex = allItems.indexOf(item); + const active = absoluteIndex === activeIndex; + return ( + <div + key={item.id} + data-active={active || undefined} + className={cn( + "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm", + "cursor-default select-none", + active ? "bg-accent text-accent-foreground" : "hover:bg-muted", + )} + > + {item.icon ? ( + <span className="text-base leading-5 flex-shrink-0" aria-hidden> + {item.icon} + </span> + ) : null} + <div className="flex flex-col min-w-0"> + <div className="truncate">{item.title}</div> + {item.description ? ( + <div className="truncate text-xs text-muted-foreground">{item.description}</div> + ) : null} + </div> + </div> + ); + })} + </> + ); +} diff --git a/src/components/editor/interactionProviders.ts b/src/components/editor/interactionProviders.ts new file mode 100644 index 0000000..3030cc7 --- /dev/null +++ b/src/components/editor/interactionProviders.ts @@ -0,0 +1,63 @@ +import type { SlashItem } from "@swarmnote/editor-core"; +import type { FileTreeNode } from "@/commands/fs"; +import { useEditorStore } from "@/stores/editorStore"; +import { useFileTreeStore } from "@/stores/fileTreeStore"; + +const MAX_NOTE_JUMP_ITEMS = 8; + +function flattenNotes(nodes: FileTreeNode[]): FileTreeNode[] { + const flat: FileTreeNode[] = []; + const walk = (xs: FileTreeNode[]) => { + for (const n of xs) { + if (n.children) walk(n.children); + else if (n.id.endsWith(".md")) flat.push(n); + } + }; + walk(nodes); + return flat; +} + +function basename(relPath: string): string { + const last = relPath.split("/").pop() ?? relPath; + return last.replace(/\.md$/i, ""); +} + +/** + * Host implementation of `EditorHostCapabilities.getSlashItems`. + * + * - Empty query: a small set of quick actions (currently jump-to-recent items). + * - Non-empty query: fuzzy-match note titles from `fileTreeStore` and return + * "Jump to: <title>" items. + * + * AbortSignal honored by an early `signal.aborted` check before returning. + */ +export async function getSlashItems(query: string, signal: AbortSignal): Promise<SlashItem[]> { + if (signal.aborted) return []; + + const items: SlashItem[] = []; + const trimmed = query.trim().toLowerCase(); + const tree = useFileTreeStore.getState().tree; + const allNotes = flattenNotes(tree); + + // Jump-to-note items + const matchedNotes = trimmed + ? allNotes.filter((n) => basename(n.id).toLowerCase().includes(trimmed)) + : allNotes.slice(0, MAX_NOTE_JUMP_ITEMS); + + for (const note of matchedNotes.slice(0, MAX_NOTE_JUMP_ITEMS)) { + const title = basename(note.id); + items.push({ + id: `jump:${note.id}`, + title: `Jump to: ${title}`, + description: note.id, + icon: "📄", + section: "Notes", + run: () => { + useEditorStore.getState().loadDocument(note.id, title, note.id); + }, + }); + } + + if (signal.aborted) return []; + return items; +} diff --git a/src/routes/settings/general.tsx b/src/routes/settings/general.tsx index 9ac2ae1..692f340 100644 --- a/src/routes/settings/general.tsx +++ b/src/routes/settings/general.tsx @@ -4,6 +4,7 @@ import type { LucideIcon } from "lucide-react"; import { AlertCircle, Code2, + Command, FolderOpen, Globe, Image as ImageIcon, @@ -109,6 +110,12 @@ function GeneralSettingsPage() { label: t`智能粘贴`, description: t`粘贴 URL 转链接,拖放 / 粘贴文件上传为图片`, }, + { + id: "slash", + icon: Command, + label: t`Slash 命令`, + description: t`输入 / 触发候选菜单,快速插入或跳转笔记`, + }, ]; return ( diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index 51e11d4..fe7f0b1 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -16,6 +16,7 @@ export const EDITOR_PLUGIN_IDS = [ "blockImage", "rawHtml", "smartPaste", + "slash", ] as const; export type EditorPluginId = (typeof EDITOR_PLUGIN_IDS)[number]; From ae6752c276010cfc172f822049e7ecf39a60dfe0 Mon Sep 17 00:00:00 2001 From: yexiyue <yexiyue666@qq.com> Date: Wed, 13 May 2026 14:57:02 +0800 Subject: [PATCH 2/7] feat(editor): add basic block items + MRU sort to slash menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on sibling's expanded slash surface (commandId path + per-item priority + onItemConfirmed) to deliver a Notion-style picker: - interactionProviders: add a basic block catalog (Heading 1/2/3, Bulleted / Numbered / Check list, Quote, Divider, Today's date). Most route through commandId references (toggleHeading via run for level arg; toggleList / toggleBlockquote / insertHorizontalRule via commandId metadata only). - MRU: persist recently confirmed item ids to localStorage (swarmnote.slash.mru, capped at 20). On each getSlashItems call, matching items get item.priority bumped to MRU_PRIORITY_BASE + recency rank and re-sectioned as "Recent" so the popover groups them at the top. Empty store → no behavior change. - NoteEditor: pass onItemConfirmed → bumpSlashMru into slashCommandPlugin({ ... }) so every Enter on the popover updates MRU. Frontend typecheck + lint:ci + build pass. Depends on sibling commit c33a10a (execCommandFacet + per-plugin slash items + SlashItem.priority). --- src/components/editor/NoteEditor.tsx | 9 +- src/components/editor/interactionProviders.ts | 165 +++++++++++++++++- 2 files changed, 166 insertions(+), 8 deletions(-) diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index ce9e031..3004153 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -28,7 +28,7 @@ import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "reac import * as Y from "yjs"; import { openYDoc, reloadYDocConfirmed, saveMedia } from "@/commands/document"; import { EditorContextMenu } from "@/components/editor/EditorContextMenu"; -import { getSlashItems } from "@/components/editor/interactionProviders"; +import { bumpSlashMru, getSlashItems } from "@/components/editor/interactionProviders"; import { SlashCommandPopover } from "@/components/editor/SlashCommandPopover"; import { initialTableContextMenuState, @@ -63,7 +63,12 @@ function buildEditorPlugins( if (enabled.has("blockImage")) plugins.push(blockImagePlugin()); if (enabled.has("rawHtml")) plugins.push(rawHtmlPlugin()); if (enabled.has("smartPaste")) plugins.push(smartPastePlugin()); - if (enabled.has("slash")) plugins.push(slashCommandPlugin()); + if (enabled.has("slash")) + plugins.push( + slashCommandPlugin({ + onItemConfirmed: (id) => bumpSlashMru(id), + }), + ); return plugins; } diff --git a/src/components/editor/interactionProviders.ts b/src/components/editor/interactionProviders.ts index 3030cc7..a38667e 100644 --- a/src/components/editor/interactionProviders.ts +++ b/src/components/editor/interactionProviders.ts @@ -4,6 +4,9 @@ import { useEditorStore } from "@/stores/editorStore"; import { useFileTreeStore } from "@/stores/fileTreeStore"; const MAX_NOTE_JUMP_ITEMS = 8; +const MRU_STORAGE_KEY = "swarmnote.slash.mru"; +const MRU_LIMIT = 20; +const MRU_PRIORITY_BASE = 300; function flattenNotes(nodes: FileTreeNode[]): FileTreeNode[] { const flat: FileTreeNode[] = []; @@ -22,24 +25,161 @@ function basename(relPath: string): string { return last.replace(/\.md$/i, ""); } +/** + * MRU registry of recently-used slash item ids. Persisted to localStorage so + * users see their favourites near the top across sessions (Notion-style). + * + * The list is most-recent-first; `bumpSlashMru(id)` lifts an id to the head. + */ +function readMru(): string[] { + try { + const raw = localStorage.getItem(MRU_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +} + +function writeMru(ids: string[]): void { + try { + localStorage.setItem(MRU_STORAGE_KEY, JSON.stringify(ids.slice(0, MRU_LIMIT))); + } catch { + // Quota exceeded or storage unavailable — silently skip + } +} + +export function bumpSlashMru(id: string): void { + const cur = readMru(); + writeMru([id, ...cur.filter((x) => x !== id)]); +} + +/** Build a static block-item catalog (Heading / List / Quote / Divider / Date). */ +function basicBlockItems(): SlashItem[] { + return [ + { + id: "heading.1", + title: "Heading 1", + description: "Top-level section heading", + icon: "H₁", + keywords: ["h1", "heading", "标题"], + section: "Basic", + run: () => { + useEditorStore.getState().editorControl?.execCommand("toggleHeading", 1); + }, + }, + { + id: "heading.2", + title: "Heading 2", + description: "Section heading", + icon: "H₂", + keywords: ["h2", "heading", "标题"], + section: "Basic", + run: () => { + useEditorStore.getState().editorControl?.execCommand("toggleHeading", 2); + }, + }, + { + id: "heading.3", + title: "Heading 3", + description: "Subsection heading", + icon: "H₃", + keywords: ["h3", "heading", "标题"], + section: "Basic", + run: () => { + useEditorStore.getState().editorControl?.execCommand("toggleHeading", 3); + }, + }, + { + id: "list.bulleted", + title: "Bulleted list", + description: "Insert an unordered list", + icon: "•", + keywords: ["list", "bullet", "unordered", "无序列表"], + section: "Basic", + commandId: "toggleUnorderedList", + }, + { + id: "list.numbered", + title: "Numbered list", + description: "Insert an ordered list", + icon: "1.", + keywords: ["list", "ordered", "numbered", "有序列表"], + section: "Basic", + commandId: "toggleOrderedList", + }, + { + id: "list.check", + title: "Check list", + description: "Insert a todo / checkbox list", + icon: "☐", + keywords: ["check", "todo", "task", "任务", "复选"], + section: "Basic", + commandId: "toggleCheckList", + }, + { + id: "quote", + title: "Quote", + description: "Insert a blockquote", + icon: "❝", + keywords: ["quote", "blockquote", "引用"], + section: "Basic", + commandId: "toggleBlockquote", + }, + { + id: "divider", + title: "Divider", + description: "Insert a horizontal rule", + icon: "—", + keywords: ["divider", "hr", "separator", "分割线"], + section: "Basic", + commandId: "insertHorizontalRule", + }, + { + id: "date.today", + title: "Today's date", + description: "Insert YYYY-MM-DD at cursor", + icon: "📅", + keywords: ["date", "today", "日期", "今天"], + section: "Basic", + run: ({ view, range }) => { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const insert = `${yyyy}-${mm}-${dd}`; + view.dispatch({ + changes: { from: range.from, insert }, + selection: { anchor: range.from + insert.length }, + }); + }, + }, + ]; +} + /** * Host implementation of `EditorHostCapabilities.getSlashItems`. * - * - Empty query: a small set of quick actions (currently jump-to-recent items). - * - Non-empty query: fuzzy-match note titles from `fileTreeStore` and return - * "Jump to: <title>" items. + * Returns three groups: + * 1. Basic blocks (Heading / List / Quote / Divider / Date) — via commandId or run + * 2. Notes (Jump to: <title>) — from `fileTreeStore`, fuzzy on query + * 3. (Plugin items are merged in by the SDK from `ctx.registerSlashItems`) * - * AbortSignal honored by an early `signal.aborted` check before returning. + * MRU items get a boosted priority so recently-used items appear near the top. */ export async function getSlashItems(query: string, signal: AbortSignal): Promise<SlashItem[]> { if (signal.aborted) return []; const items: SlashItem[] = []; const trimmed = query.trim().toLowerCase(); + + // 1. Basic block catalog + items.push(...basicBlockItems()); + + // 2. Note jumps const tree = useFileTreeStore.getState().tree; const allNotes = flattenNotes(tree); - - // Jump-to-note items const matchedNotes = trimmed ? allNotes.filter((n) => basename(n.id).toLowerCase().includes(trimmed)) : allNotes.slice(0, MAX_NOTE_JUMP_ITEMS); @@ -58,6 +198,19 @@ export async function getSlashItems(query: string, signal: AbortSignal): Promise }); } + // 3. Lift MRU items via per-item priority override (SDK reads item.priority) + const mru = readMru(); + if (mru.length > 0) { + for (const item of items) { + const idx = mru.indexOf(item.id); + if (idx >= 0) { + item.priority = MRU_PRIORITY_BASE + (MRU_LIMIT - idx); + // Re-section so the popover renders them in a "Recent" group + item.section = "Recent"; + } + } + } + if (signal.aborted) return []; return items; } From ff491ecf4e9f615eaed4bc2a90cb38cf43ba9fe4 Mon Sep 17 00:00:00 2001 From: yexiyue <yexiyue666@qq.com> Date: Wed, 13 May 2026 15:15:19 +0800 Subject: [PATCH 3/7] feat(editor): wire popover item click to slash.confirmAt Render each SlashItem as a <button> with onMouseDown (not onClick) + preventDefault. mousedown fires before the editor's blur event, so the trigger stays active long enough to dispatch; click would arrive after SlashTriggerChange { active: false } already unmounted the popover. On pick, call `editorControl.execCommand("slash.confirmAt", index)` to atomically jump to the clicked index and commit in one round-trip. --- src/components/editor/SlashCommandPopover.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/editor/SlashCommandPopover.tsx b/src/components/editor/SlashCommandPopover.tsx index ab10c22..de3c7d0 100644 --- a/src/components/editor/SlashCommandPopover.tsx +++ b/src/components/editor/SlashCommandPopover.tsx @@ -96,6 +96,9 @@ export function SlashCommandPopover({ match, control }: SlashCommandPopoverProps items={sectionItems} activeIndex={activeIndex} allItems={items} + onPick={(absoluteIndex) => { + controlRef.current?.execCommand("slash.confirmAt", absoluteIndex); + }} /> ))} </div> @@ -110,9 +113,10 @@ interface SlashSectionProps { items: SlashTriggerMatch["items"]; activeIndex: number; allItems: SlashTriggerMatch["items"]; + onPick: (absoluteIndex: number) => void; } -function SlashSection({ label, items, activeIndex, allItems }: SlashSectionProps) { +function SlashSection({ label, items, activeIndex, allItems, onPick }: SlashSectionProps) { return ( <> {label ? ( @@ -122,12 +126,19 @@ function SlashSection({ label, items, activeIndex, allItems }: SlashSectionProps const absoluteIndex = allItems.indexOf(item); const active = absoluteIndex === activeIndex; return ( - <div + <button + type="button" key={item.id} data-active={active || undefined} + // mousedown 而非 click:mousedown 在 blur 之前 fire,避免编辑器先失焦 + // 导致 trigger 在 click 到达前已被 dismiss。preventDefault 防失焦。 + onMouseDown={(e) => { + e.preventDefault(); + onPick(absoluteIndex); + }} className={cn( - "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm", - "cursor-default select-none", + "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm text-left w-full", + "cursor-pointer select-none", active ? "bg-accent text-accent-foreground" : "hover:bg-muted", )} > @@ -142,7 +153,7 @@ function SlashSection({ label, items, activeIndex, allItems }: SlashSectionProps <div className="truncate text-xs text-muted-foreground">{item.description}</div> ) : null} </div> - </div> + </button> ); })} </> From b83cb39388ab8bd09dc949f3ab413ff4327f9233 Mon Sep 17 00:00:00 2001 From: yexiyue <yexiyue666@qq.com> Date: Wed, 13 May 2026 15:20:20 +0800 Subject: [PATCH 4/7] feat(editor): wire wikilink runtime + Tauri popover (v0.3 phase B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land host side of Phase B — consume sibling editor-core wikilink runtime. - preferencesStore: add `wikilink` to EDITOR_PLUGIN_IDS (default enabled) - NoteEditor: - opt into `wikilinkPlugin()` when enabled - subscribe to `EditorEventType.WikilinkTriggerChange`, mirror match into component state, drop on `match.active: false` - wire `host.getWikilinkItems` from interactionProviders - render `<WikilinkPopover />` alongside existing slash popover - WikilinkPopover.tsx (new): Radix Popover anchored at `match.screenRect`, identical keyboard / click routing as slash but dispatches `wikilink.*` commands; static "Link to note" header - interactionProviders: `getWikilinkItems(query, signal)` returns matching note titles from `fileTreeStore` (empty query returns first 8) - Settings general.tsx: add Wikilink row in 编辑器插件 section Frontend typecheck + lint:ci + build pass. Depends on sibling commit 879cb2f (wikilink runtime + types stable). --- src/components/editor/NoteEditor.tsx | 16 ++- src/components/editor/WikilinkPopover.tsx | 120 ++++++++++++++++++ src/components/editor/interactionProviders.ts | 33 ++++- src/routes/settings/general.tsx | 7 + src/stores/preferencesStore.ts | 1 + 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/components/editor/WikilinkPopover.tsx diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index 3004153..3dd6849 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -7,6 +7,7 @@ import { type EditorPlugin, type EditorSettings, type SlashTriggerMatch, + type WikilinkTriggerMatch, } from "@swarmnote/editor-core"; import { admonitionPlugin } from "@swarmnote/editor-core/plugins/admonition"; import { @@ -15,6 +16,7 @@ import { } from "@swarmnote/editor-core/plugins/blockImage"; import { codeBlockPlugin } from "@swarmnote/editor-core/plugins/codeBlock"; import { slashCommandPlugin } from "@swarmnote/editor-core/plugins/interactions/slash"; +import { wikilinkPlugin } from "@swarmnote/editor-core/plugins/interactions/wikilink"; import { mathPlugin } from "@swarmnote/editor-core/plugins/math"; import { mermaidPlugin } from "@swarmnote/editor-core/plugins/mermaid"; import { rawHtmlPlugin } from "@swarmnote/editor-core/plugins/rawHtml"; @@ -28,13 +30,18 @@ import { type ChangeEvent, useCallback, useEffect, useRef, useState } from "reac import * as Y from "yjs"; import { openYDoc, reloadYDocConfirmed, saveMedia } from "@/commands/document"; import { EditorContextMenu } from "@/components/editor/EditorContextMenu"; -import { bumpSlashMru, getSlashItems } from "@/components/editor/interactionProviders"; +import { + bumpSlashMru, + getSlashItems, + getWikilinkItems, +} from "@/components/editor/interactionProviders"; import { SlashCommandPopover } from "@/components/editor/SlashCommandPopover"; import { initialTableContextMenuState, TableContextMenu, type TableContextMenuState, } from "@/components/editor/TableContextMenu"; +import { WikilinkPopover } from "@/components/editor/WikilinkPopover"; import { colorForDevice } from "@/lib/awareness-color"; import { TauriYjsProvider } from "@/lib/TauriYjsProvider"; import { useEditorStore } from "@/stores/editorStore"; @@ -69,6 +76,7 @@ function buildEditorPlugins( onItemConfirmed: (id) => bumpSlashMru(id), }), ); + if (enabled.has("wikilink")) plugins.push(wikilinkPlugin()); return plugins; } @@ -177,6 +185,8 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr // Slash command popover state — driven by `SlashTriggerChange` events. const [slashMatch, setSlashMatch] = useState<SlashTriggerMatch | null>(null); + // Wikilink popover state — driven by `WikilinkTriggerChange` events. + const [wikilinkMatch, setWikilinkMatch] = useState<WikilinkTriggerMatch | null>(null); const handleTableMenuOpenChange = useCallback((open: boolean) => { setTableMenuState((prev) => ({ ...prev, open })); }, []); @@ -279,6 +289,7 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr }); }, getSlashItems, + getWikilinkItems, }, plugins, autofocus: true, @@ -306,6 +317,8 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr }); } else if (event.kind === EditorEventType.SlashTriggerChange) { setSlashMatch(event.match.active ? event.match : null); + } else if (event.kind === EditorEventType.WikilinkTriggerChange) { + setWikilinkMatch(event.match.active ? event.match : null); } }, }); @@ -517,6 +530,7 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr /> <TableContextMenu state={tableMenuState} onOpenChange={handleTableMenuOpenChange} /> <SlashCommandPopover match={slashMatch} control={editorControl} /> + <WikilinkPopover match={wikilinkMatch} control={editorControl} /> </> ); } diff --git a/src/components/editor/WikilinkPopover.tsx b/src/components/editor/WikilinkPopover.tsx new file mode 100644 index 0000000..00667f6 --- /dev/null +++ b/src/components/editor/WikilinkPopover.tsx @@ -0,0 +1,120 @@ +import { useLingui } from "@lingui/react/macro"; +import type { EditorControl, WikilinkTriggerMatch } from "@swarmnote/editor-core"; +import { useEffect, useMemo, useRef } from "react"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface WikilinkPopoverProps { + match: WikilinkTriggerMatch | null; + control: EditorControl | null; +} + +/** + * Floating Radix Popover for wikilink note picker. Mirror of SlashCommandPopover + * but subscribes to `WikilinkTriggerChange` and dispatches `wikilink.*` commands. + */ +export function WikilinkPopover({ match, control }: WikilinkPopoverProps) { + const { t } = useLingui(); + const open = match?.active ?? false; + const items = useMemo(() => match?.items ?? [], [match]); + const activeIndex = match?.activeIndex ?? 0; + const screenRect = match?.screenRect; + + const controlRef = useRef(control); + controlRef.current = control; + + useEffect(() => { + if (!open || !control) return; + const contentDom = control.view.contentDOM; + const handler = (e: KeyboardEvent) => { + let cmd: string | null = null; + if (e.key === "ArrowDown") cmd = "wikilink.next"; + else if (e.key === "ArrowUp") cmd = "wikilink.prev"; + else if (e.key === "Enter") cmd = "wikilink.confirm"; + else if (e.key === "Escape") cmd = "wikilink.dismiss"; + if (!cmd) return; + e.preventDefault(); + e.stopPropagation(); + controlRef.current?.execCommand(cmd); + }; + contentDom.addEventListener("keydown", handler, true); + return () => { + contentDom.removeEventListener("keydown", handler, true); + }; + }, [open, control]); + + if (!open || !screenRect) return null; + + return ( + <Popover open={open}> + <PopoverAnchor asChild> + <div + aria-hidden + style={{ + position: "fixed", + left: screenRect.x, + top: screenRect.y, + width: screenRect.width, + height: screenRect.height, + pointerEvents: "none", + }} + /> + </PopoverAnchor> + <PopoverContent + align="start" + side="bottom" + sideOffset={4} + className="w-72 p-1" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + <div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground"> + {t`Link to note`} + </div> + {items.length === 0 ? ( + <div className="px-2 py-1.5 text-sm text-muted-foreground">{t`No matching notes`}</div> + ) : ( + <div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto"> + {items.map((item, idx) => { + const active = idx === activeIndex; + return ( + <button + type="button" + key={item.id} + data-active={active || undefined} + onMouseDown={(e) => { + e.preventDefault(); + controlRef.current?.execCommand("wikilink.confirmAt", idx); + }} + className={cn( + "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm text-left w-full", + "cursor-pointer select-none", + active ? "bg-accent text-accent-foreground" : "hover:bg-muted", + )} + > + {item.icon ? ( + <span className="text-base leading-5 flex-shrink-0" aria-hidden> + {item.icon} + </span> + ) : ( + <span className="text-base leading-5 flex-shrink-0" aria-hidden> + 📄 + </span> + )} + <div className="flex flex-col min-w-0"> + <div className="truncate">{item.title}</div> + {item.description ? ( + <div className="truncate text-xs text-muted-foreground"> + {item.description} + </div> + ) : null} + </div> + </button> + ); + })} + </div> + )} + </PopoverContent> + </Popover> + ); +} diff --git a/src/components/editor/interactionProviders.ts b/src/components/editor/interactionProviders.ts index a38667e..b167c3a 100644 --- a/src/components/editor/interactionProviders.ts +++ b/src/components/editor/interactionProviders.ts @@ -1,4 +1,4 @@ -import type { SlashItem } from "@swarmnote/editor-core"; +import type { SlashItem, WikilinkItem } from "@swarmnote/editor-core"; import type { FileTreeNode } from "@/commands/fs"; import { useEditorStore } from "@/stores/editorStore"; import { useFileTreeStore } from "@/stores/fileTreeStore"; @@ -214,3 +214,34 @@ export async function getSlashItems(query: string, signal: AbortSignal): Promise if (signal.aborted) return []; return items; } + +/** + * Host implementation of `EditorHostCapabilities.getWikilinkItems`. + * + * Returns matching note titles from `fileTreeStore`. Empty query returns + * the first 8 notes (lets users browse without typing). + */ +export async function getWikilinkItems( + query: string, + signal: AbortSignal, +): Promise<WikilinkItem[]> { + if (signal.aborted) return []; + + const trimmed = query.trim().toLowerCase(); + const tree = useFileTreeStore.getState().tree; + const allNotes = flattenNotes(tree); + const matched = trimmed + ? allNotes.filter((n) => basename(n.id).toLowerCase().includes(trimmed)) + : allNotes.slice(0, MAX_NOTE_JUMP_ITEMS); + + const items: WikilinkItem[] = matched.slice(0, MAX_NOTE_JUMP_ITEMS).map((note) => ({ + id: note.id, + title: basename(note.id), + description: note.id, + icon: "📄", + commit: "replaceWithLink" as const, + })); + + if (signal.aborted) return []; + return items; +} diff --git a/src/routes/settings/general.tsx b/src/routes/settings/general.tsx index 692f340..f328fa6 100644 --- a/src/routes/settings/general.tsx +++ b/src/routes/settings/general.tsx @@ -8,6 +8,7 @@ import { FolderOpen, Globe, Image as ImageIcon, + Link as LinkIcon, Palette, Puzzle, Sigma, @@ -116,6 +117,12 @@ function GeneralSettingsPage() { label: t`Slash 命令`, description: t`输入 / 触发候选菜单,快速插入或跳转笔记`, }, + { + id: "wikilink", + icon: LinkIcon, + label: t`Wikilink`, + description: t`输入 [[ 触发笔记选择,插入 [[note-title]] 链接`, + }, ]; return ( diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index fe7f0b1..2ba2cb7 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -17,6 +17,7 @@ export const EDITOR_PLUGIN_IDS = [ "rawHtml", "smartPaste", "slash", + "wikilink", ] as const; export type EditorPluginId = (typeof EDITOR_PLUGIN_IDS)[number]; From 7290735a827b60961d84bee3261c4cd35021c987 Mon Sep 17 00:00:00 2001 From: yexiyue <yexiyue666@qq.com> Date: Wed, 13 May 2026 15:24:35 +0800 Subject: [PATCH 5/7] feat(editor): wire selection toolbar + Tauri UI (v0.3 phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Land host side of Phase C — the trio is complete on desktop. - preferencesStore: add `selectionToolbar` to EDITOR_PLUGIN_IDS (default enabled) - NoteEditor: - opt into `selectionToolbarPlugin()` when enabled - subscribe to `EditorEventType.SelectionToolbarChange`, mirror match into component state, drop on `match.active: false` - render `<SelectionToolbar />` alongside the slash + wikilink popovers - SelectionToolbar.tsx (new): Radix Popover with `side="top"` floating above the selection. Renders the merged action buttons (bold / italic / strike / inline-code / link by default) as <button onMouseDown> so the editor selection is preserved through the click. Each button dispatches `action.commandId` via `editorControl.execCommand`. Uses lucide icons mapped from action.icon strings (extensible registry). - Settings general.tsx: add Selection 工具栏 row in 编辑器插件 section Frontend typecheck + lint:ci + build pass. Depends on sibling commit b9b2955 (selection toolbar runtime + types stable). This completes the v0.3 interaction trio (slash + wikilink + selectionToolbar). Plugin SDK surface is fully stable: all three register* / get* / event types previously marked @unstable in v0.1 are now load-bearing and locked. --- src/components/editor/NoteEditor.tsx | 11 +++ src/components/editor/SelectionToolbar.tsx | 85 ++++++++++++++++++++++ src/routes/settings/general.tsx | 7 ++ src/stores/preferencesStore.ts | 1 + 4 files changed, 104 insertions(+) create mode 100644 src/components/editor/SelectionToolbar.tsx diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index 3dd6849..3ace187 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -6,6 +6,7 @@ import { EditorEventType, type EditorPlugin, type EditorSettings, + type SelectionToolbarMatch, type SlashTriggerMatch, type WikilinkTriggerMatch, } from "@swarmnote/editor-core"; @@ -15,6 +16,7 @@ import { refreshBlockImagesEffect, } from "@swarmnote/editor-core/plugins/blockImage"; import { codeBlockPlugin } from "@swarmnote/editor-core/plugins/codeBlock"; +import { selectionToolbarPlugin } from "@swarmnote/editor-core/plugins/interactions/selectionToolbar"; import { slashCommandPlugin } from "@swarmnote/editor-core/plugins/interactions/slash"; import { wikilinkPlugin } from "@swarmnote/editor-core/plugins/interactions/wikilink"; import { mathPlugin } from "@swarmnote/editor-core/plugins/math"; @@ -35,6 +37,7 @@ import { getSlashItems, getWikilinkItems, } from "@/components/editor/interactionProviders"; +import { SelectionToolbar } from "@/components/editor/SelectionToolbar"; import { SlashCommandPopover } from "@/components/editor/SlashCommandPopover"; import { initialTableContextMenuState, @@ -77,6 +80,7 @@ function buildEditorPlugins( }), ); if (enabled.has("wikilink")) plugins.push(wikilinkPlugin()); + if (enabled.has("selectionToolbar")) plugins.push(selectionToolbarPlugin()); return plugins; } @@ -187,6 +191,10 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr const [slashMatch, setSlashMatch] = useState<SlashTriggerMatch | null>(null); // Wikilink popover state — driven by `WikilinkTriggerChange` events. const [wikilinkMatch, setWikilinkMatch] = useState<WikilinkTriggerMatch | null>(null); + // Selection toolbar state — driven by `SelectionToolbarChange` events. + const [selectionToolbarMatch, setSelectionToolbarMatch] = useState<SelectionToolbarMatch | null>( + null, + ); const handleTableMenuOpenChange = useCallback((open: boolean) => { setTableMenuState((prev) => ({ ...prev, open })); }, []); @@ -319,6 +327,8 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr setSlashMatch(event.match.active ? event.match : null); } else if (event.kind === EditorEventType.WikilinkTriggerChange) { setWikilinkMatch(event.match.active ? event.match : null); + } else if (event.kind === EditorEventType.SelectionToolbarChange) { + setSelectionToolbarMatch(event.match.active ? event.match : null); } }, }); @@ -531,6 +541,7 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr <TableContextMenu state={tableMenuState} onOpenChange={handleTableMenuOpenChange} /> <SlashCommandPopover match={slashMatch} control={editorControl} /> <WikilinkPopover match={wikilinkMatch} control={editorControl} /> + <SelectionToolbar match={selectionToolbarMatch} control={editorControl} /> </> ); } diff --git a/src/components/editor/SelectionToolbar.tsx b/src/components/editor/SelectionToolbar.tsx new file mode 100644 index 0000000..58ca152 --- /dev/null +++ b/src/components/editor/SelectionToolbar.tsx @@ -0,0 +1,85 @@ +import type { EditorControl, SelectionToolbarMatch } from "@swarmnote/editor-core"; +import { Bold, Code, Italic, Link as LinkIcon, type LucideIcon, Strikethrough } from "lucide-react"; +import { useMemo, useRef } from "react"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +interface SelectionToolbarProps { + match: SelectionToolbarMatch | null; + control: EditorControl | null; +} + +const ICON_REGISTRY: Record<string, LucideIcon> = { + bold: Bold, + italic: Italic, + strikethrough: Strikethrough, + code: Code, + link: LinkIcon, +}; + +/** + * Floating toolbar above the current text selection. Subscribes to + * `SelectionToolbarChange` and renders the merged action buttons; each + * button dispatches `action.commandId` via `editorControl.execCommand`. + * + * Uses onMouseDown + preventDefault so the editor selection isn't lost + * when the button is pressed. + */ +export function SelectionToolbar({ match, control }: SelectionToolbarProps) { + const open = match?.active ?? false; + const actions = useMemo(() => match?.actions ?? [], [match]); + const screenRect = match?.screenRect; + + const controlRef = useRef(control); + controlRef.current = control; + + if (!open || !screenRect || actions.length === 0) return null; + + return ( + <Popover open={open}> + <PopoverAnchor asChild> + <div + aria-hidden + style={{ + position: "fixed", + left: screenRect.x, + top: screenRect.y, + width: screenRect.width, + height: screenRect.height, + pointerEvents: "none", + }} + /> + </PopoverAnchor> + <PopoverContent + align="center" + side="top" + sideOffset={6} + className="flex flex-row items-center gap-0.5 p-1 w-auto" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {actions.map((action) => { + const Icon = ICON_REGISTRY[action.icon]; + return ( + <button + type="button" + key={action.id} + title={action.title} + onMouseDown={(e) => { + e.preventDefault(); + controlRef.current?.execCommand(action.commandId); + }} + className={cn( + "inline-flex h-8 w-8 items-center justify-center rounded-sm", + "cursor-pointer select-none text-sm", + "hover:bg-muted", + )} + > + {Icon ? <Icon className="h-4 w-4" /> : <span aria-hidden>{action.icon}</span>} + </button> + ); + })} + </PopoverContent> + </Popover> + ); +} diff --git a/src/routes/settings/general.tsx b/src/routes/settings/general.tsx index f328fa6..37ec49f 100644 --- a/src/routes/settings/general.tsx +++ b/src/routes/settings/general.tsx @@ -9,6 +9,7 @@ import { Globe, Image as ImageIcon, Link as LinkIcon, + MousePointer, Palette, Puzzle, Sigma, @@ -123,6 +124,12 @@ function GeneralSettingsPage() { label: t`Wikilink`, description: t`输入 [[ 触发笔记选择,插入 [[note-title]] 链接`, }, + { + id: "selectionToolbar", + icon: MousePointer, + label: t`Selection 工具栏`, + description: t`选中文字时浮出格式化工具栏(粗体 / 斜体 / 链接 等)`, + }, ]; return ( diff --git a/src/stores/preferencesStore.ts b/src/stores/preferencesStore.ts index 2ba2cb7..669aa5b 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -18,6 +18,7 @@ export const EDITOR_PLUGIN_IDS = [ "smartPaste", "slash", "wikilink", + "selectionToolbar", ] as const; export type EditorPluginId = (typeof EDITOR_PLUGIN_IDS)[number]; From 9fa057003e22b61705c51df538a5b27902d4802c Mon Sep 17 00:00:00 2001 From: yexiyue <yexiyue666@qq.com> Date: Wed, 13 May 2026 15:27:58 +0800 Subject: [PATCH 6/7] docs(editor): document interaction trigger trio (v0.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dev-notes/knowledge/editor.md: new "Interaction trigger 三类" section covering CharTrigger family abstraction, Selection family, payload DOM-agnostic + requestMeasure pattern, SDK surface (v0.3 stable), execCommandFacet, popover-click vs blur gotcha, Notion-style UX (built-in plugin slash items, basic block catalog, MRU), and third-party plugin example. - dev-notes/plans/editor-core-host-boundary.md §二: banner pointing at the v0.3 interaction-trio OpenSpec change. --- dev-notes/knowledge/editor.md | 82 ++++++++++++++++++++ dev-notes/plans/editor-core-host-boundary.md | 8 ++ 2 files changed, 90 insertions(+) diff --git a/dev-notes/knowledge/editor.md b/dev-notes/knowledge/editor.md index a010160..bfe1a13 100644 --- a/dev-notes/knowledge/editor.md +++ b/dev-notes/knowledge/editor.md @@ -429,3 +429,85 @@ CM6 默认配置 + 暖色品牌系统会导致 selection 与 activeLine 撞色 - 取舍:用户失去"光标在哪行"的视觉锚点,依赖 caret 自身。Live Preview 模式下用户感知主要靠光标本身,可接受 **相关文件**:`../swarmnote-editor/packages/editor-core/src/extensions/inlineRendering/addFormattingClasses.ts`、`../swarmnote-editor/packages/editor-core/src/extensions/inlineRendering/replaceFormatCharacters.ts`、`../swarmnote-editor/packages/editor-core/src/extensions/markdownDecorationExtension.ts`、`../swarmnote-editor/packages/editor-core/src/theme/createTheme.ts` + +## Interaction trigger 三类(v0.3 interaction trio) + +`add-editor-interaction-trio-v03`(v0.3)落地 slash / wikilink / selectionToolbar 三个内置 interaction plugin,把 v0.1 全部 `@unstable` SDK 表面提升 stable。 + +### 抽象分家:CharTrigger family vs Selection family + +- **CharTrigger family**(slash / wikilink)共用 SDK 内部 helper `src/internal/charTriggerStateMachine.ts`:trigger char 检测 / IME 排除 / syntaxTree 排除 code/math/frontmatter / debounce 150ms / AbortSignal / query token 防 stale 结果。两个 plugin 各自传不同 trigger 序列 + commit 逻辑 +- **Selection family**(selectionToolbar)独立 ViewPlugin:监听 selectionSet + focusChanged + docChanged。100ms debounce dismiss + immediate dismiss on blur + +### Payload DOM-agnostic + screenRect 异步 + +所有 `*TriggerMatch` payload 不含 `EditorView` / DOM 引用。anchor 走 CM document offset;`screenRect?` 由 web plugin 通过 `view.requestMeasure({ read, write })` 异步算(**不能** 在 update phase 直接调 `view.coordsAtPos`,否则 CM6 抛 "Reading the editor layout isn't allowed during an update")。 + +### SDK 表面 (stable since v0.3) + +```text +ctx.registerSlashItems(provider) ctx.on(event, listener) +ctx.registerWikilinkItems(provider) host.getSlashItems(query, signal) +ctx.registerSelectionToolbarActions(arr) host.getWikilinkItems(query, signal) + host.getSelectionToolbarActions?(selection) + +EditorEventType.SlashTriggerChange payload: SlashTriggerMatch +EditorEventType.WikilinkTriggerChange payload: WikilinkTriggerMatch +EditorEventType.SelectionToolbarChange payload: SelectionToolbarMatch + +9 commands: slash.{next,prev,confirm,confirmAt,dismiss} + wikilink.{...} + selectionToolbar.dismiss +``` + +`SlashItem` / `WikilinkItem` / `SelectionToolbarAction` 类型主入口 re-export,第三方 plugin 可自由 import 使用。 + +### execCommandFacet 接通 SlashItem.commandId + +createEditor 内部用 mutable ref pattern 把 `control.execCommand` 注入 `execCommandFacet`,plugin runtime 通过 `view.state.facet(execCommandFacet)` 调任意已注册命令。SlashItem 写 `{ commandId: 'toggleHeading' }` 即可在 popover 选中后调命令,避免每个 item 写 inline run。 + +### 点击 popover 不工作的坑 + +popover item 必须用 `<button onMouseDown>` 而**不**是 `<button onClick>`: + +- 编辑器 `blur` 在 `mouseup` 之前 fire +- blur → 触发 `*TriggerChange { active: false }` → popover 立即 unmount +- click 永远收不到 + +修复:`onMouseDown` + `e.preventDefault()` 阻止焦点转移;调 `*.confirmAt(index)` 命令(**不**是 dispatch 多次 `next` + 一次 `confirm`,那样会因 React 重渲染抖动)。 + +### Notion-style UX + +- 6 个内置 plugin(math/table/mermaid/codeBlock/blockImage/admonition)各自 `ctx.registerSlashItems` 注册自己的 `/math` `/table` `/code` 等 items +- Host 端 `interactionProviders.ts` 注册 basic block items(Heading 1/2/3 / List / Quote / Divider / Date)+ Jump-to-note items +- MRU localStorage(key `swarmnote.slash.mru`,上限 20):host 给最近用过的 items 赋 `priority = 300+` + section `"Recent"`,popover 自然顶置 + +### 第三方 plugin 注册示例 + +```ts +function myPlugin(): EditorPlugin { + return { + id: 'org.example.my', + setup(ctx) { + ctx.registerSlashItems({ + id: 'my.builtin', + provide: () => [{ + id: 'my.timestamp', + title: 'Timestamp', + icon: '🕒', + section: 'Insert', + keywords: ['time', '时间戳'], + // 二选一:commandId 引用已注册命令,或 run 自定义 commit + run: ({ view, range }) => { + view.dispatch({ changes: { from: range.from, insert: new Date().toISOString() } }); + }, + }], + }); + }, + }; +} +``` + +**相关文件**: +- sibling: `../swarmnote-editor/packages/editor-core/src/internal/charTriggerStateMachine.ts`、`src/plugins/interactions/{slash,wikilink,selectionToolbar}/index.ts`、`src/pluginHost.ts`(facets + register* runtime) +- host: `src/components/editor/{SlashCommandPopover,WikilinkPopover,SelectionToolbar,interactionProviders}.tsx`、`src/components/editor/NoteEditor.tsx`(onEvent 路由) diff --git a/dev-notes/plans/editor-core-host-boundary.md b/dev-notes/plans/editor-core-host-boundary.md index 0f75765..88f5e61 100644 --- a/dev-notes/plans/editor-core-host-boundary.md +++ b/dev-notes/plans/editor-core-host-boundary.md @@ -226,6 +226,14 @@ graph TD ## 二、建议抽成 interaction core 的内容 +> **已落实于 OpenSpec change `add-editor-interaction-trio-v03`**(v0.3, +> 2026-05-13):slash / wikilink / selectionToolbar 三个 interaction +> plugin 都已升级为真实 runtime(plugins/interactions/{slash,wikilink, +> selectionToolbar}),SDK 表面(registerSlashItems / registerWikilinkItems / +> registerSelectionToolbarActions / on / host.get*)全部 stable。 +> CharTrigger family 抽象(slash + wikilink 共用 helper)见 +> `../knowledge/editor.md` 的「Interaction trigger 三类」节。 + 这部分当前还没有完整抽出,但从后续目标看,应该独立收束。 ### 1. slash command trigger From fc2f31097144b63b0d3561701df7adf2ffbf8adb Mon Sep 17 00:00:00 2001 From: yexiyue <yexiyue666@qq.com> Date: Wed, 13 May 2026 16:47:12 +0800 Subject: [PATCH 7/7] refactor(editor): extract CharTriggerPopover + route internal links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-side companion to the editor-core link/wikilink polish. Extract shared CharTriggerPopover: - SlashCommandPopover (161) + WikilinkPopover (120) had ~85% duplication across keyboard routing, Radix popover anchor, section grouping, and item rendering - New CharTriggerPopover.tsx (211) owns the shared rendering, generic over CharTriggerItem (SlashItem / WikilinkItem both satisfy) - SlashCommandPopover (20) + WikilinkPopover (21) are now thin shells forwarding `commandPrefix` / `emptyLabel` / `headerLabel`. Future hashtag / mention popovers reuse the same component - Net code reduction ~29 lines, duplication eliminated Internal link routing: - onEvent LinkOpen handler now resolves via `resolveInternalLink(url)` first → loadDocument internal note; otherwise openUrl() external - Previously `openUrl` was called for every url, so wikilink targets and `.md` relative paths were never routed to in-app navigation interactionProviders simplification: - basicBlockItems() function call → BASIC_BLOCK_ITEMS module-level const (no per-call rebuild; closures look up store lazily) - Heading 1/2/3 three near-duplicate object literals → HEADING_LEVELS array + map - getSlashItems / getWikilinkItems duplicate "filter notes by query" → matchNotes(query) helper - Cleaned up doc blocks (resolveInternalLink doc was sandwiched between getSlashItems and getWikilinkItems) --- src/components/editor/CharTriggerPopover.tsx | 211 +++++++++++++ src/components/editor/NoteEditor.tsx | 25 +- src/components/editor/SlashCommandPopover.tsx | 155 +--------- src/components/editor/WikilinkPopover.tsx | 115 +------ src/components/editor/interactionProviders.ts | 287 +++++++++--------- 5 files changed, 391 insertions(+), 402 deletions(-) create mode 100644 src/components/editor/CharTriggerPopover.tsx diff --git a/src/components/editor/CharTriggerPopover.tsx b/src/components/editor/CharTriggerPopover.tsx new file mode 100644 index 0000000..266559c --- /dev/null +++ b/src/components/editor/CharTriggerPopover.tsx @@ -0,0 +1,211 @@ +import type { EditorControl } from "@swarmnote/editor-core"; +import { useEffect, useMemo, useRef } from "react"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +/** + * Minimum item shape required by the popover. `SlashItem` and `WikilinkItem` + * both satisfy this (their extra fields like `commandId` / `commit` / `run` + * are owned by the SDK, not relevant to rendering). + */ +export interface CharTriggerItem { + id: string; + title: string; + description?: string; + icon?: string; + section?: string; +} + +/** + * Match shape produced by `slash.*` / `wikilink.*` SDK runtime — keeps this + * popover decoupled from either specific match type. + */ +export interface CharTriggerMatchLike<TItem extends CharTriggerItem> { + active: boolean; + items: TItem[]; + activeIndex: number; + screenRect?: { x: number; y: number; width: number; height: number }; +} + +interface CharTriggerPopoverProps<TItem extends CharTriggerItem> { + match: CharTriggerMatchLike<TItem> | null; + control: EditorControl | null; + /** + * Command id prefix. Keyboard routes ArrowDown/Up/Enter/Escape to + * `<prefix>.next` / `.prev` / `.confirm` / `.dismiss`; clicks dispatch + * `<prefix>.confirmAt(index)`. + */ + commandPrefix: "slash" | "wikilink"; + /** Optional header label rendered above items (e.g. "Link to note"). */ + headerLabel?: string; + /** Empty-state label when items is empty. */ + emptyLabel: string; + /** Override side defaults — useful if anchor placement differs. */ + side?: "top" | "bottom"; +} + +/** + * Shared floating Radix Popover for char-trigger interactions (slash / wikilink). + * + * Subscribes to keyboard events on the editor's contentDOM while open and + * routes ↑/↓/Enter/Escape to `<commandPrefix>.*` commands. Items are grouped + * by `section` when any item declares one. Mouse picks go through + * `<commandPrefix>.confirmAt(index)` to atomically jump-and-commit. + */ +export function CharTriggerPopover<TItem extends CharTriggerItem>({ + match, + control, + commandPrefix, + headerLabel, + emptyLabel, + side = "bottom", +}: CharTriggerPopoverProps<TItem>) { + const open = match?.active ?? false; + const items = match?.items ?? []; + const activeIndex = match?.activeIndex ?? 0; + const screenRect = match?.screenRect; + + const controlRef = useRef(control); + controlRef.current = control; + + useEffect(() => { + if (!open || !control) return; + const contentDom = control.view.contentDOM; + const handler = (e: KeyboardEvent) => { + let suffix: string | null = null; + if (e.key === "ArrowDown") suffix = "next"; + else if (e.key === "ArrowUp") suffix = "prev"; + else if (e.key === "Enter") suffix = "confirm"; + else if (e.key === "Escape") suffix = "dismiss"; + if (!suffix) return; + e.preventDefault(); + e.stopPropagation(); + controlRef.current?.execCommand(`${commandPrefix}.${suffix}`); + }; + contentDom.addEventListener("keydown", handler, true); + return () => { + contentDom.removeEventListener("keydown", handler, true); + }; + }, [open, control, commandPrefix]); + + // Group items by section if any item declares one + const grouped = useMemo(() => { + const buckets = new Map<string, TItem[]>(); + for (const it of items) { + const key = it.section ?? ""; + const arr = buckets.get(key) ?? []; + arr.push(it); + buckets.set(key, arr); + } + return Array.from(buckets.entries()); + }, [items]); + + if (!open || !screenRect) return null; + + return ( + <Popover open={open}> + <PopoverAnchor asChild> + <div + aria-hidden + style={{ + position: "fixed", + left: screenRect.x, + top: screenRect.y, + width: screenRect.width, + height: screenRect.height, + pointerEvents: "none", + }} + /> + </PopoverAnchor> + <PopoverContent + align="start" + side={side} + sideOffset={4} + className="w-72 p-1" + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {headerLabel ? ( + <div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground"> + {headerLabel} + </div> + ) : null} + {items.length === 0 ? ( + <div className="px-2 py-1.5 text-sm text-muted-foreground">{emptyLabel}</div> + ) : ( + <div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto"> + {grouped.map(([section, sectionItems]) => ( + <Section + key={section || "_default"} + label={section} + items={sectionItems} + activeIndex={activeIndex} + allItems={items} + onPick={(absoluteIndex) => { + controlRef.current?.execCommand(`${commandPrefix}.confirmAt`, absoluteIndex); + }} + /> + ))} + </div> + )} + </PopoverContent> + </Popover> + ); +} + +interface SectionProps<TItem extends CharTriggerItem> { + label: string; + items: TItem[]; + activeIndex: number; + allItems: TItem[]; + onPick: (absoluteIndex: number) => void; +} + +function Section<TItem extends CharTriggerItem>({ + label, + items, + activeIndex, + allItems, + onPick, +}: SectionProps<TItem>) { + return ( + <> + {label ? ( + <div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">{label}</div> + ) : null} + {items.map((item) => { + const absoluteIndex = allItems.indexOf(item); + const active = absoluteIndex === activeIndex; + return ( + <button + type="button" + key={item.id} + data-active={active || undefined} + // mousedown 而非 click:blur 在 click 前 fire 会 dismiss popover + onMouseDown={(e) => { + e.preventDefault(); + onPick(absoluteIndex); + }} + className={cn( + "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm text-left w-full", + "cursor-pointer select-none", + active ? "bg-accent text-accent-foreground" : "hover:bg-muted", + )} + > + {item.icon ? ( + <span className="text-base leading-5 flex-shrink-0" aria-hidden> + {item.icon} + </span> + ) : null} + <div className="flex flex-col min-w-0"> + <div className="truncate">{item.title}</div> + {item.description ? ( + <div className="truncate text-xs text-muted-foreground">{item.description}</div> + ) : null} + </div> + </button> + ); + })} + </> + ); +} diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index 3ace187..23f33f7 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -36,6 +36,7 @@ import { bumpSlashMru, getSlashItems, getWikilinkItems, + resolveInternalLink, } from "@/components/editor/interactionProviders"; import { SelectionToolbar } from "@/components/editor/SelectionToolbar"; import { SlashCommandPopover } from "@/components/editor/SlashCommandPopover"; @@ -292,6 +293,13 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr resolveImage: imageResolver, uploadFile, openLink: (url) => { + // Resolve wikilink target / .md relative path → load note + const internal = resolveInternalLink(url); + if (internal) { + useEditorStore.getState().loadDocument(internal.id, internal.title, internal.id); + return; + } + // Fall back to system browser for external URLs openUrl(url).catch(() => { // URL may be malformed or blocked — silent. }); @@ -317,12 +325,17 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr actions: event.actions, }); } else if (event.kind === EditorEventType.LinkOpen) { - // Markdown link Ctrl/Cmd-click + image-link button click both - // route here. window.open is unreliable inside Tauri webview, so - // delegate to plugin-opener which uses the system default browser. - openUrl(event.url).catch(() => { - // URL may be malformed or blocked — silent. - }); + // Ctrl/Cmd-click on markdown link / wikilink / image link routes + // here. First try to resolve as an internal note (wikilink target + // or .md path); fall back to system browser for external URLs. + const internal = resolveInternalLink(event.url); + if (internal) { + useEditorStore.getState().loadDocument(internal.id, internal.title, internal.id); + } else { + openUrl(event.url).catch(() => { + // URL may be malformed or blocked — silent. + }); + } } else if (event.kind === EditorEventType.SlashTriggerChange) { setSlashMatch(event.match.active ? event.match : null); } else if (event.kind === EditorEventType.WikilinkTriggerChange) { diff --git a/src/components/editor/SlashCommandPopover.tsx b/src/components/editor/SlashCommandPopover.tsx index de3c7d0..f253bbc 100644 --- a/src/components/editor/SlashCommandPopover.tsx +++ b/src/components/editor/SlashCommandPopover.tsx @@ -1,161 +1,20 @@ import { useLingui } from "@lingui/react/macro"; import type { EditorControl, SlashTriggerMatch } from "@swarmnote/editor-core"; -import { useEffect, useMemo, useRef } from "react"; -import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; +import { CharTriggerPopover } from "@/components/editor/CharTriggerPopover"; interface SlashCommandPopoverProps { - /** Current trigger match from `SlashTriggerChange` events. */ match: SlashTriggerMatch | null; - /** Editor control instance used to route keyboard events to `slash.*` commands. */ control: EditorControl | null; } -/** - * Renders a floating Radix Popover with slash candidate items. - * - * Subscribes to keyboard events on the editor's contentDOM while the trigger - * is active and routes ↑/↓/Enter/Escape to the SDK's slash.* commands. - */ export function SlashCommandPopover({ match, control }: SlashCommandPopoverProps) { const { t } = useLingui(); - const open = match?.active ?? false; - const items = useMemo(() => match?.items ?? [], [match]); - const activeIndex = match?.activeIndex ?? 0; - const screenRect = match?.screenRect; - - // Capture keyboard events on the editor's contentDOM while open, route to slash.* commands. - const controlRef = useRef(control); - controlRef.current = control; - - useEffect(() => { - if (!open || !control) return; - const contentDom = control.view.contentDOM; - const handler = (e: KeyboardEvent) => { - let cmd: string | null = null; - if (e.key === "ArrowDown") cmd = "slash.next"; - else if (e.key === "ArrowUp") cmd = "slash.prev"; - else if (e.key === "Enter") cmd = "slash.confirm"; - else if (e.key === "Escape") cmd = "slash.dismiss"; - if (!cmd) return; - e.preventDefault(); - e.stopPropagation(); - controlRef.current?.execCommand(cmd); - }; - contentDom.addEventListener("keydown", handler, true); - return () => { - contentDom.removeEventListener("keydown", handler, true); - }; - }, [open, control]); - - // Group items by section if any - const grouped = useMemo(() => { - const buckets = new Map<string, typeof items>(); - for (const it of items) { - const key = it.section ?? ""; - const arr = buckets.get(key) ?? []; - arr.push(it); - buckets.set(key, arr); - } - return Array.from(buckets.entries()); - }, [items]); - - if (!open || !screenRect) return null; - - return ( - <Popover open={open}> - <PopoverAnchor asChild> - <div - aria-hidden - style={{ - position: "fixed", - left: screenRect.x, - top: screenRect.y, - width: screenRect.width, - height: screenRect.height, - pointerEvents: "none", - }} - /> - </PopoverAnchor> - <PopoverContent - align="start" - side="bottom" - sideOffset={4} - className="w-72 p-1" - onOpenAutoFocus={(e) => e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > - {items.length === 0 ? ( - <div className="px-2 py-1.5 text-sm text-muted-foreground">{t`No matching commands`}</div> - ) : ( - <div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto"> - {grouped.map(([section, sectionItems]) => ( - <SlashSection - key={section || "_default"} - label={section} - items={sectionItems} - activeIndex={activeIndex} - allItems={items} - onPick={(absoluteIndex) => { - controlRef.current?.execCommand("slash.confirmAt", absoluteIndex); - }} - /> - ))} - </div> - )} - </PopoverContent> - </Popover> - ); -} - -interface SlashSectionProps { - label: string; - items: SlashTriggerMatch["items"]; - activeIndex: number; - allItems: SlashTriggerMatch["items"]; - onPick: (absoluteIndex: number) => void; -} - -function SlashSection({ label, items, activeIndex, allItems, onPick }: SlashSectionProps) { return ( - <> - {label ? ( - <div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">{label}</div> - ) : null} - {items.map((item) => { - const absoluteIndex = allItems.indexOf(item); - const active = absoluteIndex === activeIndex; - return ( - <button - type="button" - key={item.id} - data-active={active || undefined} - // mousedown 而非 click:mousedown 在 blur 之前 fire,避免编辑器先失焦 - // 导致 trigger 在 click 到达前已被 dismiss。preventDefault 防失焦。 - onMouseDown={(e) => { - e.preventDefault(); - onPick(absoluteIndex); - }} - className={cn( - "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm text-left w-full", - "cursor-pointer select-none", - active ? "bg-accent text-accent-foreground" : "hover:bg-muted", - )} - > - {item.icon ? ( - <span className="text-base leading-5 flex-shrink-0" aria-hidden> - {item.icon} - </span> - ) : null} - <div className="flex flex-col min-w-0"> - <div className="truncate">{item.title}</div> - {item.description ? ( - <div className="truncate text-xs text-muted-foreground">{item.description}</div> - ) : null} - </div> - </button> - ); - })} - </> + <CharTriggerPopover + match={match} + control={control} + commandPrefix="slash" + emptyLabel={t`No matching commands`} + /> ); } diff --git a/src/components/editor/WikilinkPopover.tsx b/src/components/editor/WikilinkPopover.tsx index 00667f6..34af2d9 100644 --- a/src/components/editor/WikilinkPopover.tsx +++ b/src/components/editor/WikilinkPopover.tsx @@ -1,120 +1,21 @@ import { useLingui } from "@lingui/react/macro"; import type { EditorControl, WikilinkTriggerMatch } from "@swarmnote/editor-core"; -import { useEffect, useMemo, useRef } from "react"; -import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; +import { CharTriggerPopover } from "@/components/editor/CharTriggerPopover"; interface WikilinkPopoverProps { match: WikilinkTriggerMatch | null; control: EditorControl | null; } -/** - * Floating Radix Popover for wikilink note picker. Mirror of SlashCommandPopover - * but subscribes to `WikilinkTriggerChange` and dispatches `wikilink.*` commands. - */ export function WikilinkPopover({ match, control }: WikilinkPopoverProps) { const { t } = useLingui(); - const open = match?.active ?? false; - const items = useMemo(() => match?.items ?? [], [match]); - const activeIndex = match?.activeIndex ?? 0; - const screenRect = match?.screenRect; - - const controlRef = useRef(control); - controlRef.current = control; - - useEffect(() => { - if (!open || !control) return; - const contentDom = control.view.contentDOM; - const handler = (e: KeyboardEvent) => { - let cmd: string | null = null; - if (e.key === "ArrowDown") cmd = "wikilink.next"; - else if (e.key === "ArrowUp") cmd = "wikilink.prev"; - else if (e.key === "Enter") cmd = "wikilink.confirm"; - else if (e.key === "Escape") cmd = "wikilink.dismiss"; - if (!cmd) return; - e.preventDefault(); - e.stopPropagation(); - controlRef.current?.execCommand(cmd); - }; - contentDom.addEventListener("keydown", handler, true); - return () => { - contentDom.removeEventListener("keydown", handler, true); - }; - }, [open, control]); - - if (!open || !screenRect) return null; - return ( - <Popover open={open}> - <PopoverAnchor asChild> - <div - aria-hidden - style={{ - position: "fixed", - left: screenRect.x, - top: screenRect.y, - width: screenRect.width, - height: screenRect.height, - pointerEvents: "none", - }} - /> - </PopoverAnchor> - <PopoverContent - align="start" - side="bottom" - sideOffset={4} - className="w-72 p-1" - onOpenAutoFocus={(e) => e.preventDefault()} - onCloseAutoFocus={(e) => e.preventDefault()} - > - <div className="px-2 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground"> - {t`Link to note`} - </div> - {items.length === 0 ? ( - <div className="px-2 py-1.5 text-sm text-muted-foreground">{t`No matching notes`}</div> - ) : ( - <div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto"> - {items.map((item, idx) => { - const active = idx === activeIndex; - return ( - <button - type="button" - key={item.id} - data-active={active || undefined} - onMouseDown={(e) => { - e.preventDefault(); - controlRef.current?.execCommand("wikilink.confirmAt", idx); - }} - className={cn( - "flex items-start gap-2 rounded-sm px-2 py-1.5 text-sm text-left w-full", - "cursor-pointer select-none", - active ? "bg-accent text-accent-foreground" : "hover:bg-muted", - )} - > - {item.icon ? ( - <span className="text-base leading-5 flex-shrink-0" aria-hidden> - {item.icon} - </span> - ) : ( - <span className="text-base leading-5 flex-shrink-0" aria-hidden> - 📄 - </span> - )} - <div className="flex flex-col min-w-0"> - <div className="truncate">{item.title}</div> - {item.description ? ( - <div className="truncate text-xs text-muted-foreground"> - {item.description} - </div> - ) : null} - </div> - </button> - ); - })} - </div> - )} - </PopoverContent> - </Popover> + <CharTriggerPopover + match={match} + control={control} + commandPrefix="wikilink" + headerLabel={t`Link to note`} + emptyLabel={t`No matching notes`} + /> ); } diff --git a/src/components/editor/interactionProviders.ts b/src/components/editor/interactionProviders.ts index b167c3a..e27568f 100644 --- a/src/components/editor/interactionProviders.ts +++ b/src/components/editor/interactionProviders.ts @@ -25,12 +25,21 @@ function basename(relPath: string): string { return last.replace(/\.md$/i, ""); } -/** - * MRU registry of recently-used slash item ids. Persisted to localStorage so - * users see their favourites near the top across sessions (Notion-style). - * - * The list is most-recent-first; `bumpSlashMru(id)` lifts an id to the head. - */ +/** Snapshot notes from fileTreeStore matching `query` (empty → first N). */ +function matchNotes(query: string): FileTreeNode[] { + const trimmed = query.trim().toLowerCase(); + const notes = flattenNotes(useFileTreeStore.getState().tree); + if (!trimmed) return notes.slice(0, MAX_NOTE_JUMP_ITEMS); + return notes + .filter((n) => basename(n.id).toLowerCase().includes(trimmed)) + .slice(0, MAX_NOTE_JUMP_ITEMS); +} + +// --------------------------------------------------------------------------- +// MRU: persist recently-confirmed slash item ids to localStorage so favourites +// surface at the top across sessions (Notion-style). +// --------------------------------------------------------------------------- + function readMru(): string[] { try { const raw = localStorage.getItem(MRU_STORAGE_KEY); @@ -55,136 +64,115 @@ export function bumpSlashMru(id: string): void { writeMru([id, ...cur.filter((x) => x !== id)]); } -/** Build a static block-item catalog (Heading / List / Quote / Divider / Date). */ -function basicBlockItems(): SlashItem[] { - return [ - { - id: "heading.1", - title: "Heading 1", - description: "Top-level section heading", - icon: "H₁", - keywords: ["h1", "heading", "标题"], +// --------------------------------------------------------------------------- +// Basic block catalog — module-level since SlashItem.run closures only look up +// editorControl lazily through the store; no per-call captured state. +// --------------------------------------------------------------------------- + +const HEADING_LEVELS: ReadonlyArray<{ level: 1 | 2 | 3; icon: string; description: string }> = [ + { level: 1, icon: "H₁", description: "Top-level section heading" }, + { level: 2, icon: "H₂", description: "Section heading" }, + { level: 3, icon: "H₃", description: "Subsection heading" }, +]; + +const BASIC_BLOCK_ITEMS: readonly SlashItem[] = [ + ...HEADING_LEVELS.map( + ({ level, icon, description }): SlashItem => ({ + id: `heading.${level}`, + title: `Heading ${level}`, + description, + icon, + keywords: [`h${level}`, "heading", "标题"], section: "Basic", run: () => { - useEditorStore.getState().editorControl?.execCommand("toggleHeading", 1); - }, - }, - { - id: "heading.2", - title: "Heading 2", - description: "Section heading", - icon: "H₂", - keywords: ["h2", "heading", "标题"], - section: "Basic", - run: () => { - useEditorStore.getState().editorControl?.execCommand("toggleHeading", 2); - }, - }, - { - id: "heading.3", - title: "Heading 3", - description: "Subsection heading", - icon: "H₃", - keywords: ["h3", "heading", "标题"], - section: "Basic", - run: () => { - useEditorStore.getState().editorControl?.execCommand("toggleHeading", 3); - }, - }, - { - id: "list.bulleted", - title: "Bulleted list", - description: "Insert an unordered list", - icon: "•", - keywords: ["list", "bullet", "unordered", "无序列表"], - section: "Basic", - commandId: "toggleUnorderedList", - }, - { - id: "list.numbered", - title: "Numbered list", - description: "Insert an ordered list", - icon: "1.", - keywords: ["list", "ordered", "numbered", "有序列表"], - section: "Basic", - commandId: "toggleOrderedList", - }, - { - id: "list.check", - title: "Check list", - description: "Insert a todo / checkbox list", - icon: "☐", - keywords: ["check", "todo", "task", "任务", "复选"], - section: "Basic", - commandId: "toggleCheckList", - }, - { - id: "quote", - title: "Quote", - description: "Insert a blockquote", - icon: "❝", - keywords: ["quote", "blockquote", "引用"], - section: "Basic", - commandId: "toggleBlockquote", - }, - { - id: "divider", - title: "Divider", - description: "Insert a horizontal rule", - icon: "—", - keywords: ["divider", "hr", "separator", "分割线"], - section: "Basic", - commandId: "insertHorizontalRule", - }, - { - id: "date.today", - title: "Today's date", - description: "Insert YYYY-MM-DD at cursor", - icon: "📅", - keywords: ["date", "today", "日期", "今天"], - section: "Basic", - run: ({ view, range }) => { - const now = new Date(); - const yyyy = now.getFullYear(); - const mm = String(now.getMonth() + 1).padStart(2, "0"); - const dd = String(now.getDate()).padStart(2, "0"); - const insert = `${yyyy}-${mm}-${dd}`; - view.dispatch({ - changes: { from: range.from, insert }, - selection: { anchor: range.from + insert.length }, - }); + useEditorStore.getState().editorControl?.execCommand("toggleHeading", level); }, + }), + ), + { + id: "list.bulleted", + title: "Bulleted list", + description: "Insert an unordered list", + icon: "•", + keywords: ["list", "bullet", "unordered", "无序列表"], + section: "Basic", + commandId: "toggleUnorderedList", + }, + { + id: "list.numbered", + title: "Numbered list", + description: "Insert an ordered list", + icon: "1.", + keywords: ["list", "ordered", "numbered", "有序列表"], + section: "Basic", + commandId: "toggleOrderedList", + }, + { + id: "list.check", + title: "Check list", + description: "Insert a todo / checkbox list", + icon: "☐", + keywords: ["check", "todo", "task", "任务", "复选"], + section: "Basic", + commandId: "toggleCheckList", + }, + { + id: "quote", + title: "Quote", + description: "Insert a blockquote", + icon: "❝", + keywords: ["quote", "blockquote", "引用"], + section: "Basic", + commandId: "toggleBlockquote", + }, + { + id: "divider", + title: "Divider", + description: "Insert a horizontal rule", + icon: "—", + keywords: ["divider", "hr", "separator", "分割线"], + section: "Basic", + commandId: "insertHorizontalRule", + }, + { + id: "date.today", + title: "Today's date", + description: "Insert YYYY-MM-DD at cursor", + icon: "📅", + keywords: ["date", "today", "日期", "今天"], + section: "Basic", + run: ({ view, range }) => { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const insert = `${yyyy}-${mm}-${dd}`; + view.dispatch({ + changes: { from: range.from, insert }, + selection: { anchor: range.from + insert.length }, + }); }, - ]; -} + }, +]; + +// --------------------------------------------------------------------------- +// Host providers consumed by editor SDK +// --------------------------------------------------------------------------- /** - * Host implementation of `EditorHostCapabilities.getSlashItems`. - * - * Returns three groups: - * 1. Basic blocks (Heading / List / Quote / Divider / Date) — via commandId or run - * 2. Notes (Jump to: <title>) — from `fileTreeStore`, fuzzy on query - * 3. (Plugin items are merged in by the SDK from `ctx.registerSlashItems`) + * Host `getSlashItems`. Returns: + * 1. Basic block catalog (Heading / List / Quote / Divider / Date) + * 2. Jump-to-note items matching query (or first 8 when empty) * - * MRU items get a boosted priority so recently-used items appear near the top. + * MRU items get a boosted priority + "Recent" section so favourites surface + * at the top. Plugin items are merged in by the SDK from `ctx.registerSlashItems`. */ export async function getSlashItems(query: string, signal: AbortSignal): Promise<SlashItem[]> { if (signal.aborted) return []; - const items: SlashItem[] = []; - const trimmed = query.trim().toLowerCase(); + const items: SlashItem[] = [...BASIC_BLOCK_ITEMS]; - // 1. Basic block catalog - items.push(...basicBlockItems()); - - // 2. Note jumps - const tree = useFileTreeStore.getState().tree; - const allNotes = flattenNotes(tree); - const matchedNotes = trimmed - ? allNotes.filter((n) => basename(n.id).toLowerCase().includes(trimmed)) - : allNotes.slice(0, MAX_NOTE_JUMP_ITEMS); - - for (const note of matchedNotes.slice(0, MAX_NOTE_JUMP_ITEMS)) { + for (const note of matchNotes(query)) { const title = basename(note.id); items.push({ id: `jump:${note.id}`, @@ -198,14 +186,12 @@ export async function getSlashItems(query: string, signal: AbortSignal): Promise }); } - // 3. Lift MRU items via per-item priority override (SDK reads item.priority) const mru = readMru(); if (mru.length > 0) { for (const item of items) { const idx = mru.indexOf(item.id); if (idx >= 0) { item.priority = MRU_PRIORITY_BASE + (MRU_LIMIT - idx); - // Re-section so the popover renders them in a "Recent" group item.section = "Recent"; } } @@ -215,33 +201,52 @@ export async function getSlashItems(query: string, signal: AbortSignal): Promise return items; } -/** - * Host implementation of `EditorHostCapabilities.getWikilinkItems`. - * - * Returns matching note titles from `fileTreeStore`. Empty query returns - * the first 8 notes (lets users browse without typing). - */ +/** Host `getWikilinkItems`. Returns matching note titles for `[[query` trigger. */ export async function getWikilinkItems( query: string, signal: AbortSignal, ): Promise<WikilinkItem[]> { if (signal.aborted) return []; - const trimmed = query.trim().toLowerCase(); - const tree = useFileTreeStore.getState().tree; - const allNotes = flattenNotes(tree); - const matched = trimmed - ? allNotes.filter((n) => basename(n.id).toLowerCase().includes(trimmed)) - : allNotes.slice(0, MAX_NOTE_JUMP_ITEMS); - - const items: WikilinkItem[] = matched.slice(0, MAX_NOTE_JUMP_ITEMS).map((note) => ({ + const items: WikilinkItem[] = matchNotes(query).map((note) => ({ id: note.id, title: basename(note.id), description: note.id, icon: "📄", - commit: "replaceWithLink" as const, + commit: "replaceWithLink", })); if (signal.aborted) return []; return items; } + +/** + * Resolve a `LinkOpen` event's url to an internal note. Returns null when the + * url should be opened as an external URL by the host. + * + * Match order: + * 1. External scheme (`xxx://` / `mailto:` / `tel:` ...) → null + * 2. `.md` path → exact match + * 3. Wikilink target → basename case-insensitive, then fuzzy contains + */ +export function resolveInternalLink(url: string): { id: string; title: string } | null { + if (!url) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return null; + + const normalized = url.startsWith("./") ? url.slice(2) : url; + const lowered = normalized.toLowerCase(); + const notes = flattenNotes(useFileTreeStore.getState().tree); + + if (lowered.endsWith(".md")) { + const hit = notes.find((n) => n.id === normalized); + if (hit) return { id: hit.id, title: basename(hit.id) }; + } + + const exactTitle = notes.find((n) => basename(n.id).toLowerCase() === lowered); + if (exactTitle) return { id: exactTitle.id, title: basename(exactTitle.id) }; + + const fuzzy = notes.find((n) => basename(n.id).toLowerCase().includes(lowered)); + if (fuzzy) return { id: fuzzy.id, title: basename(fuzzy.id) }; + + return null; +}