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 ");
+ 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 ");
+ 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