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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions dev-notes/knowledge/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路由)
8 changes: 8 additions & 0 deletions dev-notes/plans/editor-core-host-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
211 changes: 211 additions & 0 deletions src/components/editor/CharTriggerPopover.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
})}
</>
);
}
Loading
Loading