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 必须用 ` + ); + })} + + ); +} diff --git a/src/components/editor/NoteEditor.tsx b/src/components/editor/NoteEditor.tsx index c5d6900..23f33f7 100644 --- a/src/components/editor/NoteEditor.tsx +++ b/src/components/editor/NoteEditor.tsx @@ -6,6 +6,9 @@ import { EditorEventType, type EditorPlugin, type EditorSettings, + type SelectionToolbarMatch, + type SlashTriggerMatch, + type WikilinkTriggerMatch, } from "@swarmnote/editor-core"; import { admonitionPlugin } from "@swarmnote/editor-core/plugins/admonition"; import { @@ -13,6 +16,9 @@ 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"; import { mermaidPlugin } from "@swarmnote/editor-core/plugins/mermaid"; import { rawHtmlPlugin } from "@swarmnote/editor-core/plugins/rawHtml"; @@ -26,11 +32,20 @@ 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, + getWikilinkItems, + resolveInternalLink, +} from "@/components/editor/interactionProviders"; +import { SelectionToolbar } from "@/components/editor/SelectionToolbar"; +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"; @@ -59,6 +74,14 @@ 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({ + onItemConfirmed: (id) => bumpSlashMru(id), + }), + ); + if (enabled.has("wikilink")) plugins.push(wikilinkPlugin()); + if (enabled.has("selectionToolbar")) plugins.push(selectionToolbarPlugin()); return plugins; } @@ -164,6 +187,15 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr const [tableMenuState, setTableMenuState] = useState( initialTableContextMenuState, ); + + // Slash command popover state — driven by `SlashTriggerChange` events. + const [slashMatch, setSlashMatch] = useState(null); + // Wikilink popover state — driven by `WikilinkTriggerChange` events. + const [wikilinkMatch, setWikilinkMatch] = useState(null); + // Selection toolbar state — driven by `SelectionToolbarChange` events. + const [selectionToolbarMatch, setSelectionToolbarMatch] = useState( + null, + ); const handleTableMenuOpenChange = useCallback((open: boolean) => { setTableMenuState((prev) => ({ ...prev, open })); }, []); @@ -261,10 +293,19 @@ 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. }); }, + getSlashItems, + getWikilinkItems, }, plugins, autofocus: true, @@ -284,12 +325,23 @@ 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) { + setWikilinkMatch(event.match.active ? event.match : null); + } else if (event.kind === EditorEventType.SelectionToolbarChange) { + setSelectionToolbarMatch(event.match.active ? event.match : null); } }, }); @@ -500,6 +552,9 @@ function NoteEditorInner({ ydoc, provider }: { ydoc: Y.Doc; provider: TauriYjsPr onChange={handleFileInputChange} /> + + + ); } 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 = { + 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 ( + + +
+ + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + {actions.map((action) => { + const Icon = ICON_REGISTRY[action.icon]; + return ( + + ); + })} + + + ); +} diff --git a/src/components/editor/SlashCommandPopover.tsx b/src/components/editor/SlashCommandPopover.tsx new file mode 100644 index 0000000..f253bbc --- /dev/null +++ b/src/components/editor/SlashCommandPopover.tsx @@ -0,0 +1,20 @@ +import { useLingui } from "@lingui/react/macro"; +import type { EditorControl, SlashTriggerMatch } from "@swarmnote/editor-core"; +import { CharTriggerPopover } from "@/components/editor/CharTriggerPopover"; + +interface SlashCommandPopoverProps { + match: SlashTriggerMatch | null; + control: EditorControl | null; +} + +export function SlashCommandPopover({ match, control }: SlashCommandPopoverProps) { + const { t } = useLingui(); + return ( + + ); +} diff --git a/src/components/editor/WikilinkPopover.tsx b/src/components/editor/WikilinkPopover.tsx new file mode 100644 index 0000000..34af2d9 --- /dev/null +++ b/src/components/editor/WikilinkPopover.tsx @@ -0,0 +1,21 @@ +import { useLingui } from "@lingui/react/macro"; +import type { EditorControl, WikilinkTriggerMatch } from "@swarmnote/editor-core"; +import { CharTriggerPopover } from "@/components/editor/CharTriggerPopover"; + +interface WikilinkPopoverProps { + match: WikilinkTriggerMatch | null; + control: EditorControl | null; +} + +export function WikilinkPopover({ match, control }: WikilinkPopoverProps) { + const { t } = useLingui(); + return ( + + ); +} diff --git a/src/components/editor/interactionProviders.ts b/src/components/editor/interactionProviders.ts new file mode 100644 index 0000000..e27568f --- /dev/null +++ b/src/components/editor/interactionProviders.ts @@ -0,0 +1,252 @@ +import type { SlashItem, WikilinkItem } 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; +const MRU_STORAGE_KEY = "swarmnote.slash.mru"; +const MRU_LIMIT = 20; +const MRU_PRIORITY_BASE = 300; + +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, ""); +} + +/** 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); + 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)]); +} + +// --------------------------------------------------------------------------- +// 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", 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 `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 + "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 { + if (signal.aborted) return []; + + const items: SlashItem[] = [...BASIC_BLOCK_ITEMS]; + + for (const note of matchNotes(query)) { + 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); + }, + }); + } + + 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); + item.section = "Recent"; + } + } + } + + if (signal.aborted) return []; + return items; +} + +/** Host `getWikilinkItems`. Returns matching note titles for `[[query` trigger. */ +export async function getWikilinkItems( + query: string, + signal: AbortSignal, +): Promise { + if (signal.aborted) return []; + + const items: WikilinkItem[] = matchNotes(query).map((note) => ({ + id: note.id, + title: basename(note.id), + description: note.id, + icon: "📄", + 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; +} diff --git a/src/routes/settings/general.tsx b/src/routes/settings/general.tsx index 9ac2ae1..37ec49f 100644 --- a/src/routes/settings/general.tsx +++ b/src/routes/settings/general.tsx @@ -4,9 +4,12 @@ import type { LucideIcon } from "lucide-react"; import { AlertCircle, Code2, + Command, FolderOpen, Globe, Image as ImageIcon, + Link as LinkIcon, + MousePointer, Palette, Puzzle, Sigma, @@ -109,6 +112,24 @@ function GeneralSettingsPage() { label: t`智能粘贴`, description: t`粘贴 URL 转链接,拖放 / 粘贴文件上传为图片`, }, + { + id: "slash", + icon: Command, + label: t`Slash 命令`, + description: t`输入 / 触发候选菜单,快速插入或跳转笔记`, + }, + { + id: "wikilink", + icon: LinkIcon, + 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 51e11d4..669aa5b 100644 --- a/src/stores/preferencesStore.ts +++ b/src/stores/preferencesStore.ts @@ -16,6 +16,9 @@ export const EDITOR_PLUGIN_IDS = [ "blockImage", "rawHtml", "smartPaste", + "slash", + "wikilink", + "selectionToolbar", ] as const; export type EditorPluginId = (typeof EDITOR_PLUGIN_IDS)[number];