Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,30 @@ const myPlugin: NexusPlugin = {

</details>

<details>
<summary><b>Plugin Options</b> — configurable plugin behavior</summary>

#### `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 })],
});
```

</details>

---

## 🛠️ Development
Expand Down
24 changes: 24 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,30 @@ const myPlugin: NexusPlugin = {

</details>

<details>
<summary><b>插件配置</b> —— 可配置的插件行为</summary>

#### `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 })],
});
```

</details>

---

## 🛠️ 开发
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/controlled-document.ts
Original file line number Diff line number Diff line change
@@ -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;
}
8 changes: 8 additions & 0 deletions packages/core/src/event-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ export class EventEmitter<EventMap extends { [K in keyof EventMap]: (...args: an
clear(): void {
this.listeners.clear();
}

once<K extends keyof EventMap>(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);
}
}
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,9 @@ export type {
WidgetDefinition,
WidgetRenderContext
} from "./types";

export {
isControlledDocument,
resolveInitialDocument,
shouldSyncControlledDocument,
}from "./controlled-document";
42 changes: 41 additions & 1 deletion packages/core/test/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";

import { createEditor } from "../src/index";

describe("event system", () => {
Expand Down Expand Up @@ -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();
});
});
21 changes: 18 additions & 3 deletions packages/plugin-history/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof history>[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)],
};
}
}
36 changes: 36 additions & 0 deletions packages/plugin-history/test/plugin-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
103 changes: 103 additions & 0 deletions packages/plugin-toolbar/test/plugin-toolbar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
toggleUnorderedList,
createToolbarPlugin,
createToolbarUI,
toggleBlockquote,
insertCodeBlock,
insertImage,
insertHorizontalRule
} from "../src/index";

describe("toggleBold", () => {
Expand Down Expand Up @@ -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();
});
});

28 changes: 10 additions & 18 deletions packages/react/src/controlled-document.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Loading