testomatio-editor-blocks 0.4.66 → 0.4.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { createMarkdownPasteHandler } from "./createMarkdownPasteHandler";
3
+
4
+ // These tests lock in the rendering-performance contract of the paste handler:
5
+ // a large paste must NOT build the whole document in one synchronous shot
6
+ // (which froze the editor for ~1.6s on a 1000-block document). Only a small
7
+ // first chunk is inserted synchronously; the rest is streamed in deferred
8
+ // (idle/timeout) batches. They assert behaviour, not wall-clock, so they are
9
+ // deterministic and CI-safe.
10
+
11
+ type Recorded = { content: { text: string }[]; id?: string };
12
+
13
+ function makeBlocks(n: number) {
14
+ return Array.from({ length: n }, (_, i) => ({
15
+ type: "paragraph",
16
+ content: [{ type: "text", text: `line ${i}`, styles: {} }],
17
+ }));
18
+ }
19
+
20
+ function makeEditor() {
21
+ let idSeq = 0;
22
+ const inserted: Recorded[] = [];
23
+ const assignIds = (blocks: any[]) => blocks.map((b) => ({ ...b, id: `b${idSeq++}` }));
24
+
25
+ const editor: any = {
26
+ document: [{ id: "cursor", type: "paragraph", content: [] }],
27
+ getSelection: () => ({ blocks: [] }),
28
+ getTextCursorPosition: () => ({ block: editor.document[0] }),
29
+ replaceBlocks: vi.fn((_ids: string[], blocks: any[]) => {
30
+ const withIds = assignIds(blocks);
31
+ inserted.push(...withIds);
32
+ return { insertedBlocks: withIds, removedBlocks: [] };
33
+ }),
34
+ insertBlocks: vi.fn((blocks: any[]) => {
35
+ const withIds = assignIds(blocks);
36
+ inserted.push(...withIds);
37
+ return withIds;
38
+ }),
39
+ focus: vi.fn(),
40
+ };
41
+ return { editor, inserted };
42
+ }
43
+
44
+ function makeEvent(text: string): any {
45
+ return {
46
+ clipboardData: {
47
+ types: ["text/plain"],
48
+ getData: (type: string) => (type === "text/plain" ? text : ""),
49
+ },
50
+ };
51
+ }
52
+
53
+ const defaultPasteHandler = vi.fn(() => true);
54
+
55
+ afterEach(() => {
56
+ vi.useRealTimers();
57
+ vi.clearAllMocks();
58
+ });
59
+
60
+ describe("createMarkdownPasteHandler — chunked rendering", () => {
61
+ it("inserts a small paste in a single synchronous transaction", () => {
62
+ const N = 20;
63
+ const handler = createMarkdownPasteHandler(() => makeBlocks(N) as any);
64
+ const { editor, inserted } = makeEditor();
65
+
66
+ vi.useFakeTimers();
67
+ const result = handler({
68
+ event: makeEvent("a\nb\nc"),
69
+ editor,
70
+ defaultPasteHandler,
71
+ });
72
+
73
+ expect(result).toBe(true);
74
+ expect(inserted.length).toBe(N); // everything inserted up front
75
+ expect(editor.replaceBlocks).toHaveBeenCalledTimes(1);
76
+ expect(editor.insertBlocks).not.toHaveBeenCalled(); // nothing deferred
77
+ expect(vi.getTimerCount()).toBe(0); // no background work scheduled
78
+ });
79
+
80
+ it("does NOT render a large paste synchronously — only a bounded first chunk", () => {
81
+ const N = 1000;
82
+ const handler = createMarkdownPasteHandler(() => makeBlocks(N) as any);
83
+ const { editor, inserted } = makeEditor();
84
+
85
+ vi.useFakeTimers();
86
+ handler({ event: makeEvent("big\npaste"), editor, defaultPasteHandler });
87
+
88
+ const syncCount = inserted.length;
89
+ expect(syncCount).toBeGreaterThan(0);
90
+ expect(syncCount).toBeLessThan(N); // the whole doc was NOT built synchronously
91
+ expect(syncCount).toBeLessThanOrEqual(100); // first chunk stays small
92
+ expect(editor.replaceBlocks).toHaveBeenCalledTimes(1);
93
+ expect(vi.getTimerCount()).toBeGreaterThan(0); // remainder is scheduled, not run
94
+ });
95
+
96
+ it("eventually streams in every block exactly once and in order", () => {
97
+ const N = 1000;
98
+ const handler = createMarkdownPasteHandler(() => makeBlocks(N) as any);
99
+ const { editor, inserted } = makeEditor();
100
+
101
+ vi.useFakeTimers();
102
+ handler({ event: makeEvent("big\npaste"), editor, defaultPasteHandler });
103
+ vi.runAllTimers(); // flush all deferred batches
104
+
105
+ expect(inserted.length).toBe(N);
106
+ inserted.forEach((block, i) => {
107
+ expect(block.content[0].text).toBe(`line ${i}`);
108
+ });
109
+
110
+ // Every background batch is bounded — no single batch rebuilds the doc.
111
+ for (const call of editor.insertBlocks.mock.calls) {
112
+ expect((call[0] as any[]).length).toBeLessThanOrEqual(100);
113
+ }
114
+ });
115
+
116
+ it("delegates to the default handler when the converter yields nothing", () => {
117
+ const handler = createMarkdownPasteHandler(() => [] as any);
118
+ const { editor } = makeEditor();
119
+
120
+ const result = handler({ event: makeEvent("x"), editor, defaultPasteHandler });
121
+
122
+ expect(result).toBe(true);
123
+ expect(defaultPasteHandler).toHaveBeenCalled();
124
+ expect(editor.replaceBlocks).not.toHaveBeenCalled();
125
+ });
126
+ });
@@ -12,6 +12,25 @@ type PasteHandlerContext = {
12
12
 
13
13
  const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
14
14
 
15
+ // For large pastes, only the first chunk is inserted synchronously (enough to
16
+ // fill the viewport); the remaining blocks are streamed in during idle time so
17
+ // the editor stays responsive and the user sees content immediately instead of
18
+ // the main thread freezing while a thousand-block document is built at once.
19
+ const CHUNK_THRESHOLD = 150;
20
+ const FIRST_CHUNK = 50;
21
+ const REST_CHUNK = 40;
22
+
23
+ type ScheduleFn = (cb: () => void) => void;
24
+
25
+ const scheduleIdle: ScheduleFn =
26
+ typeof window !== "undefined" && typeof (window as any).requestIdleCallback === "function"
27
+ ? (cb) => (window as any).requestIdleCallback(() => cb(), { timeout: 200 })
28
+ : (cb) => setTimeout(cb, 0);
29
+
30
+ function lastBlockId(blocks: Array<{ id?: string }>): string | undefined {
31
+ return blocks.length ? blocks[blocks.length - 1]?.id : undefined;
32
+ }
33
+
15
34
  function isInlineOnlyPaste(plainText: string, parsedBlocks: CustomPartialBlock[]): boolean {
16
35
  if (parsedBlocks.length !== 1) return false;
17
36
  const [block] = parsedBlocks;
@@ -55,18 +74,51 @@ export function createMarkdownPasteHandler(
55
74
  ?.map((block: any) => block.id)
56
75
  .filter((id: unknown): id is string => Boolean(id)) ?? [];
57
76
 
58
- if (selectedIds.length > 0) {
59
- editor.replaceBlocks(selectedIds, parsedBlocks);
60
- } else {
77
+ // Insert the initial set of blocks at the paste location, returning the
78
+ // inserted blocks so subsequent chunks can be appended after the last one.
79
+ const insertInitial = (
80
+ blocksToInsert: CustomPartialBlock[],
81
+ ): Array<{ id?: string }> | null => {
82
+ if (selectedIds.length > 0) {
83
+ return editor.replaceBlocks(selectedIds, blocksToInsert).insertedBlocks;
84
+ }
61
85
  const cursorBlock = editor.getTextCursorPosition().block;
62
86
  if (cursorBlock) {
63
- editor.replaceBlocks([cursorBlock.id], parsedBlocks);
64
- } else if (editor.document.length > 0) {
87
+ return editor.replaceBlocks([cursorBlock.id], blocksToInsert).insertedBlocks;
88
+ }
89
+ if (editor.document.length > 0) {
65
90
  const reference = editor.document[editor.document.length - 1];
66
- editor.insertBlocks(parsedBlocks, reference.id, "after");
67
- } else {
68
- return defaultPasteHandler();
91
+ return editor.insertBlocks(blocksToInsert, reference.id, "after");
69
92
  }
93
+ return null;
94
+ };
95
+
96
+ if (parsedBlocks.length <= CHUNK_THRESHOLD) {
97
+ // Small paste: insert everything in one transaction (original behaviour).
98
+ if (insertInitial(parsedBlocks) === null) return defaultPasteHandler();
99
+ } else {
100
+ // Large paste: render the first screenful now, stream the rest in idle
101
+ // time so the main thread is never blocked building the whole document.
102
+ const firstChunk = parsedBlocks.slice(0, FIRST_CHUNK);
103
+ const rest = parsedBlocks.slice(FIRST_CHUNK);
104
+ const inserted = insertInitial(firstChunk);
105
+ if (inserted === null) return defaultPasteHandler();
106
+
107
+ let anchorId = lastBlockId(inserted);
108
+ let cursor = 0;
109
+ const pump = () => {
110
+ if (!anchorId || cursor >= rest.length) return;
111
+ const batch = rest.slice(cursor, cursor + REST_CHUNK);
112
+ cursor += REST_CHUNK;
113
+ try {
114
+ const insertedBatch = editor.insertBlocks(batch, anchorId, "after");
115
+ anchorId = lastBlockId(insertedBatch) ?? anchorId;
116
+ } catch {
117
+ return; // stop streaming on any structural error
118
+ }
119
+ if (cursor < rest.length) scheduleIdle(pump);
120
+ };
121
+ scheduleIdle(pump);
70
122
  }
71
123
 
72
124
  editor.focus();
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { markdownToBlocks } from "./customMarkdownConverter";
3
+
4
+ // Coarse regression guard for parse cost. Parsing was never the bottleneck
5
+ // (~5-15ms for a 1000-block document) — rendering was — but a future change
6
+ // could accidentally make the parser quadratic. These use best-of-N timing
7
+ // (noise only ever adds time, so the minimum approximates true compute cost)
8
+ // with generous bounds, so they only fail on a real algorithmic regression.
9
+
10
+ function generateMarkdown(testCases: number): string {
11
+ const parts: string[] = ["<!-- suite -->", "# Generated Suite", ""];
12
+ for (let t = 0; t < testCases; t++) {
13
+ parts.push("<!-- test\nid: @T" + t + "\n-->");
14
+ parts.push("# Test case number " + t);
15
+ parts.push("## Steps", "");
16
+ for (let s = 0; s < 6; s++) {
17
+ parts.push("* Perform action " + s + " in test " + t);
18
+ parts.push(" *Expected:* Result " + s + " is observed in test " + t);
19
+ }
20
+ parts.push("");
21
+ }
22
+ return parts.join("\n");
23
+ }
24
+
25
+ function bestOfMs(runs: number, fn: () => void): number {
26
+ // warm up the JIT first
27
+ for (let i = 0; i < 3; i++) fn();
28
+ let min = Infinity;
29
+ for (let i = 0; i < runs; i++) {
30
+ const t0 = performance.now();
31
+ fn();
32
+ min = Math.min(min, performance.now() - t0);
33
+ }
34
+ return min;
35
+ }
36
+
37
+ describe("rendering perf — markdown parsing stays fast", () => {
38
+ it("parses a large (1000+ block) document well within budget", () => {
39
+ const md = generateMarkdown(150);
40
+ const blocks = markdownToBlocks(md);
41
+ expect(blocks.length).toBeGreaterThan(500);
42
+
43
+ const ms = bestOfMs(8, () => markdownToBlocks(md));
44
+ // ~20-50x headroom over the real ~5-15ms cost.
45
+ expect(ms).toBeLessThan(250);
46
+ });
47
+
48
+ it("scales sub-quadratically with document size", () => {
49
+ const small = generateMarkdown(100);
50
+ const large = generateMarkdown(400); // 4x the content
51
+
52
+ const tSmall = bestOfMs(8, () => markdownToBlocks(small));
53
+ const tLarge = bestOfMs(8, () => markdownToBlocks(large));
54
+
55
+ // 4x the input: linear parsing ≈ 4x time, quadratic ≈ 16x. Allow a generous
56
+ // 8x (plus a small cushion for tiny-time measurement noise).
57
+ expect(tLarge).toBeLessThan(tSmall * 8 + 5);
58
+ });
59
+ });
@@ -1240,6 +1240,20 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
1240
1240
  min-height: 4rem;
1241
1241
  }
1242
1242
 
1243
+ /* Static stand-in shown before a step's interactive editor is lazily mounted.
1244
+ Mirrors the OverType inner padding/typography so document height stays stable. */
1245
+ .bn-step-editor--preview {
1246
+ padding: 10px 12px;
1247
+ white-space: pre-wrap;
1248
+ word-break: break-word;
1249
+ color: #262626;
1250
+ cursor: text;
1251
+ }
1252
+
1253
+ html.dark .bn-step-editor--preview {
1254
+ color: #e5e5e5;
1255
+ }
1256
+
1243
1257
  .bn-step-editor.bn-step-editor--focused {
1244
1258
  outline: none;
1245
1259
  box-shadow: none;