diff --git a/README.md b/README.md index f3e5678c..d5f3111e 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,30 @@ const myPlugin: NexusPlugin = { +
+Plugin Options — configurable plugin behavior + +#### `createHistoryPlugin(options?)` + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `minDepth` | `number` | 200 | Minimum number of undo entries to keep in history | +| `newGroupDelay` | `number` | 500 | Delay (ms) before consecutive edits are grouped into one undo step | + +```ts +import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; + +// Default — CM6 defaults +const editor = createEditor({ plugins: [createHistoryPlugin()] }); + +// Custom — limit undo depth, faster grouping +const editor = createEditor({ + plugins: [createHistoryPlugin({ minDepth: 100, newGroupDelay: 300 })], +}); +``` + +
+ --- ## 🛠️ Development diff --git a/README.zh.md b/README.zh.md index bed00206..bc20e45b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -253,6 +253,30 @@ const myPlugin: NexusPlugin = { +
+插件配置 —— 可配置的插件行为 + +#### `createHistoryPlugin(options?)` + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `minDepth` | `number` | 200 | 撤销历史保留的最少条目数 | +| `newGroupDelay` | `number` | 500 | 连续编辑合并为同一撤销组的间隔(毫秒) | + +```ts +import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; + +// 默认 — CM6 默认值 +const editor = createEditor({ plugins: [createHistoryPlugin()] }); + +// 自定义 — 限制撤销深度,加快分组 +const editor = createEditor({ + plugins: [createHistoryPlugin({ minDepth: 100, newGroupDelay: 300 })], +}); +``` + +
+ --- ## 🛠️ 开发 diff --git a/packages/core/src/controlled-document.ts b/packages/core/src/controlled-document.ts new file mode 100644 index 00000000..f1aaef94 --- /dev/null +++ b/packages/core/src/controlled-document.ts @@ -0,0 +1,17 @@ +export function isControlledDocument(modelValue: string | undefined): boolean { + return modelValue !== undefined; +} + +export function resolveInitialDocument( + modelValue: string | undefined, + initialValue: string | undefined +): string { + return modelValue !== undefined ? modelValue : (initialValue ?? ""); +} + +export function shouldSyncControlledDocument( + next: string, + lastSynced: string | null +): boolean { + return lastSynced !== next; +} diff --git a/packages/core/src/event-emitter.ts b/packages/core/src/event-emitter.ts index fd75fe53..3d2a2719 100644 --- a/packages/core/src/event-emitter.ts +++ b/packages/core/src/event-emitter.ts @@ -26,4 +26,12 @@ export class EventEmitter(event: K, handler: EventMap[K]): void { + const wrapper = ((...args: any[]) => { + this.off(event, wrapper); + (handler as (...a: any[]) => void)(...args); + }) as EventMap[K]; + this.on(event, wrapper); +} } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 768852f0..94c0e258 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,3 +56,9 @@ export type { WidgetDefinition, WidgetRenderContext } from "./types"; + +export { + isControlledDocument, + resolveInitialDocument, + shouldSyncControlledDocument, +}from "./controlled-document"; \ No newline at end of file diff --git a/packages/core/test/events.test.ts b/packages/core/test/events.test.ts index eff559ca..28488f85 100644 --- a/packages/core/test/events.test.ts +++ b/packages/core/test/events.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from "vitest"; - import { createEditor } from "../src/index"; describe("event system", () => { @@ -238,3 +237,44 @@ describe("slashMenuChange", () => { editor.destroy(); }); }); + +describe("once", () => { + it("fires handler only once", () => { + const container = document.createElement("div"); + const handler = vi.fn(); + const editor = createEditor({ container, initialValue: "" }); + + editor.once("change", handler); + editor.setDocument("first"); + editor.setDocument("second"); + + expect(handler).toHaveBeenCalledOnce(); + editor.destroy(); + }); + + it("auto-removes handler after first invocation", () => { + const container = document.createElement("div"); + const handler = vi.fn(); + const editor = createEditor({ container, initialValue: "" }); + + editor.once("change", handler); + editor.setDocument("test"); + + // Trigger change again — handler should not fire + editor.setDocument("test again"); + expect(handler).toHaveBeenCalledTimes(1); + editor.destroy(); + }); + + it("receives correct arguments on first call", () => { + const container = document.createElement("div"); + const handler = vi.fn(); + const editor = createEditor({ container, initialValue: "" }); + + editor.once("change", handler); + editor.setDocument("hello"); + + expect(handler).toHaveBeenCalledWith("hello", expect.objectContaining({ type: "root" })); + editor.destroy(); + }); +}); \ No newline at end of file diff --git a/packages/plugin-history/src/index.ts b/packages/plugin-history/src/index.ts index 4f65b373..4d16a890 100644 --- a/packages/plugin-history/src/index.ts +++ b/packages/plugin-history/src/index.ts @@ -3,9 +3,24 @@ import { keymap } from "@codemirror/view"; import type { NexusPlugin } from "@floatboat/nexus-core"; -export function createHistoryPlugin(): NexusPlugin { +export interface HistoryPluginOptions { + /** Minimum depth of undo entries to keep. Maps to CM6 `minDepth`. */ + minDepth?: number; + /** Delay in ms before consecutive edits are grouped. Maps to CM6 `newGroupDelay`. */ + newGroupDelay?: number; +} + +export function createHistoryPlugin(options?: HistoryPluginOptions): NexusPlugin { + const exts: Parameters[0] | undefined = + options !== undefined + ? { + ...(options.minDepth !== undefined && { minDepth: options.minDepth }), + ...(options.newGroupDelay !== undefined && { newGroupDelay: options.newGroupDelay }), + } + : undefined; + return { name: "plugin-history", - cmExtensions: [history(), keymap.of(historyKeymap)] + cmExtensions: [history(exts), keymap.of(historyKeymap)], }; -} +} \ No newline at end of file diff --git a/packages/plugin-history/test/plugin-history.test.ts b/packages/plugin-history/test/plugin-history.test.ts index 11d8d0d0..121ef49e 100644 --- a/packages/plugin-history/test/plugin-history.test.ts +++ b/packages/plugin-history/test/plugin-history.test.ts @@ -62,3 +62,39 @@ describe("@floatboat/nexus-plugin-history", () => { editor.destroy(); }); }); + + + +describe("@floatboat/nexus-plugin-history — options", () => { + + it("newGroupDelay groups rapid edits into one undo step", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "hello", + plugins: [createHistoryPlugin({ newGroupDelay: 100_000 })], + }); + + editor.setDocument("hello world"); + editor.setDocument("hello world!"); + + // Both changes should be in one undo group + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("hello"); + editor.destroy(); + }); + + it("default options behave identically to the original plugin", () => { + const container = document.createElement("div"); + const editor = createEditor({ + container, + initialValue: "start", + plugins: [createHistoryPlugin()], + }); + + editor.setDocument("end"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("start"); + editor.destroy(); + }); +}); diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts index 3fc97f53..edc6a115 100644 --- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts +++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts @@ -12,6 +12,10 @@ import { toggleUnorderedList, createToolbarPlugin, createToolbarUI, + toggleBlockquote, + insertCodeBlock, + insertImage, + insertHorizontalRule } from "../src/index"; describe("toggleBold", () => { @@ -606,3 +610,102 @@ describe("toggleUnorderedList — atomic undo", () => { editor.destroy(); }); }); + + +describe("toggleBlockquote", () => { + it("adds blockquote prefix to current line", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world" }); + + editor.setSelection(2); + toggleBlockquote(editor); + + expect(editor.getDocument()).toBe("> hello world"); + editor.destroy(); + }); + + it("removes blockquote prefix when already present", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "> hello" }); + + editor.setSelection(2); + toggleBlockquote(editor); + + expect(editor.getDocument()).toBe("hello"); + editor.destroy(); + }); + + it("works with multi-line blockquote", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "> line1\n> line2" }); + + editor.setSelection(2); + toggleBlockquote(editor); + + expect(editor.getDocument()).toBe("line1\n> line2"); + editor.destroy(); + }); +}); + +describe("insertCodeBlock", () => { + it("wraps selected text in triple backticks", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world" }); + + editor.setSelection(6, 11); + insertCodeBlock(editor); + + expect(editor.getDocument()).toContain("```"); + expect(editor.getDocument()).toContain("world"); + editor.destroy(); + }); + + it("inserts empty code block when nothing is selected", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "text " }); + + editor.setSelection(5, 5); + insertCodeBlock(editor); + +expect(editor.getDocument()).toContain("```\n\n```"); + editor.destroy(); + }); +}); + +describe("insertImage", () => { + it("inserts markdown image syntax with selected text as alt", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "my photo" }); + + editor.setSelection(3, 8); + insertImage(editor); + + expect(editor.getDocument()).toBe("my ![photo](url)"); + editor.destroy(); + }); + + it("uses default alt text when nothing is selected", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "text " }); + + editor.setSelection(5, 5); + insertImage(editor); + + expect(editor.getDocument()).toBe("text ![alt text](url)"); + editor.destroy(); + }); +}); + +describe("insertHorizontalRule", () => { + it("inserts horizontal rule at cursor position", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello" }); + + editor.setSelection(5); + insertHorizontalRule(editor); + + expect(editor.getDocument()).toContain("---"); + editor.destroy(); + }); +}); + diff --git a/packages/react/src/controlled-document.ts b/packages/react/src/controlled-document.ts index 6d422f09..791c0370 100644 --- a/packages/react/src/controlled-document.ts +++ b/packages/react/src/controlled-document.ts @@ -1,22 +1,14 @@ /** * Pure helpers for React controlled-document sync (tested in isolation). */ +import { + isControlledDocument, + resolveInitialDocument, + shouldSyncControlledDocument, +} from "@floatboat/nexus-core"; -export function isControlledDocument(value: string | undefined): boolean { - return value !== undefined; -} - -export function resolveInitialDocument( - value: string | undefined, - initialValue: string | undefined -): string { - return value !== undefined ? value : (initialValue ?? ""); -} - -/** Whether an external `value` prop should trigger silent setDocument. */ -export function shouldSyncControlledDocument( - next: string, - lastSynced: string | null -): boolean { - return lastSynced !== next; -} +export { + isControlledDocument, + resolveInitialDocument, + shouldSyncControlledDocument, +}; \ No newline at end of file diff --git a/packages/vue/src/controlled-document.ts b/packages/vue/src/controlled-document.ts index e1dd917f..82832ec8 100644 --- a/packages/vue/src/controlled-document.ts +++ b/packages/vue/src/controlled-document.ts @@ -2,20 +2,14 @@ * Pure helpers for Vue controlled-document sync (tested in isolation). */ -export function isControlledDocument(modelValue: string | undefined): boolean { - return modelValue !== undefined; -} +import { + isControlledDocument, + resolveInitialDocument, + shouldSyncControlledDocument, +} from "@floatboat/nexus-core"; -export function resolveInitialDocument( - modelValue: string | undefined, - initialValue: string | undefined -): string { - return modelValue !== undefined ? modelValue : (initialValue ?? ""); -} - -export function shouldSyncControlledDocument( - next: string, - lastSynced: string | null -): boolean { - return lastSynced !== next; -} +export { + isControlledDocument, + resolveInitialDocument, + shouldSyncControlledDocument, +}; \ No newline at end of file