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.
- package/package/editor/blocks/step.d.ts +5 -0
- package/package/editor/blocks/step.js +273 -213
- package/package/editor/blocks/stepHorizontalView.d.ts +1 -0
- package/package/editor/blocks/stepHorizontalView.js +2 -2
- package/package/editor/blocks/useDeferredMount.d.ts +26 -0
- package/package/editor/blocks/useDeferredMount.js +54 -0
- package/package/editor/createMarkdownPasteHandler.js +56 -9
- package/package/styles.css +14 -0
- package/package.json +1 -1
- package/src/App.tsx +33 -13
- package/src/editor/blocks/step.tsx +198 -47
- package/src/editor/blocks/stepHorizontalView.tsx +3 -0
- package/src/editor/blocks/stepNumber.test.ts +39 -0
- package/src/editor/blocks/useDeferredMount.ts +66 -0
- package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
- package/src/editor/createMarkdownPasteHandler.ts +60 -8
- package/src/editor/renderingPerf.test.ts +59 -0
- package/src/editor/styles.css +14 -0
|
@@ -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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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],
|
|
64
|
-
}
|
|
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(
|
|
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
|
+
});
|
package/src/editor/styles.css
CHANGED
|
@@ -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;
|