@tangle-network/ui 11.33.0 โ 11.35.0
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/CHANGELOG.md +19 -0
- package/dist/editor/document-editor-pane.d.ts +7 -1
- package/dist/editor/document-editor-pane.js +27 -12
- package/dist/editor/markdown-document-editor.js +30 -12
- package/dist/editor/source-markdown-editor.js +256 -0
- package/dist/editor/source-markdown.js +495 -0
- package/dist/files/file-artifact-pane.d.ts +2 -0
- package/dist/files/file-artifact-pane.js +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/segmented-control.d.ts +6 -3
- package/dist/primitives/segmented-control.js +105 -56
- package/dist/run/inline-thinking-item.d.ts +7 -1
- package/dist/run/inline-thinking-item.js +6 -5
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +2 -2
- package/src/editor/document-editor-pane.tsx +54 -16
- package/src/editor/markdown-document-editor.tsx +33 -18
- package/src/editor/source-markdown-editor.test.tsx +156 -0
- package/src/editor/source-markdown-editor.ts +296 -0
- package/src/editor/source-markdown.fixtures.ts +90 -0
- package/src/editor/source-markdown.test.ts +235 -0
- package/src/editor/source-markdown.ts +627 -0
- package/src/files/file-artifact-pane.test.tsx +34 -1
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/segmented-control.stories.tsx +89 -0
- package/src/primitives/segmented-control.test.tsx +20 -7
- package/src/primitives/segmented-control.tsx +90 -29
- package/src/run/inline-thinking-item.test.tsx +29 -0
- package/src/run/inline-thinking-item.tsx +11 -4
- package/src/utils/format.test.ts +16 -1
- package/src/utils/format.ts +17 -0
- package/src/utils/index.ts +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
2
|
+
import type { Editor } from "@tiptap/react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { MarkdownDocumentEditor } from "./markdown-document-editor";
|
|
5
|
+
import { CALL_SHEET, KITCHEN_SINK } from "./source-markdown.fixtures";
|
|
6
|
+
|
|
7
|
+
// The first render imports tiptap for real; a cold import on a busy runner
|
|
8
|
+
// takes several seconds.
|
|
9
|
+
const PEERS_LOADED = { timeout: 15_000 };
|
|
10
|
+
const LOADS_PEERS = 30_000;
|
|
11
|
+
|
|
12
|
+
async function openEditor(value: string) {
|
|
13
|
+
const onChange = vi.fn<(markdown: string) => void>();
|
|
14
|
+
let editor: Editor | null = null;
|
|
15
|
+
const view = render(
|
|
16
|
+
<MarkdownDocumentEditor
|
|
17
|
+
value={value}
|
|
18
|
+
onChange={(markdown) => onChange(markdown)}
|
|
19
|
+
onReady={(ready) => {
|
|
20
|
+
editor = ready;
|
|
21
|
+
}}
|
|
22
|
+
/>,
|
|
23
|
+
);
|
|
24
|
+
// The first mount after the peers load can replace its editor instance, so
|
|
25
|
+
// wait for a live one and hand back the latest.
|
|
26
|
+
await waitFor(() => {
|
|
27
|
+
expect(editor).not.toBeNull();
|
|
28
|
+
expect((editor as Editor | null)?.isDestroyed).toBe(false);
|
|
29
|
+
}, PEERS_LOADED);
|
|
30
|
+
return {
|
|
31
|
+
view,
|
|
32
|
+
get editor() {
|
|
33
|
+
return editor! as Editor;
|
|
34
|
+
},
|
|
35
|
+
onChange,
|
|
36
|
+
/** The Markdown the editor last reported, or the value it opened. */
|
|
37
|
+
saved: () => onChange.mock.calls.at(-1)?.[0] ?? value,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Document position at the start or end of the first text node containing `needle`. */
|
|
42
|
+
function textPosition(editor: Editor, needle: string, edge: "start" | "end" = "end"): number {
|
|
43
|
+
let found = -1;
|
|
44
|
+
editor.state.doc.descendants((node, pos) => {
|
|
45
|
+
if (found >= 0) return false;
|
|
46
|
+
const index = node.isText ? (node.text ?? "").indexOf(needle) : -1;
|
|
47
|
+
if (index >= 0) found = pos + index + (edge === "end" ? needle.length : 0);
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
if (found < 0) throw new Error(`Not in the document: ${needle}`);
|
|
51
|
+
return found;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function insertAfter(editor: Editor, needle: string, text: string) {
|
|
55
|
+
act(() => {
|
|
56
|
+
editor.commands.insertContentAt(textPosition(editor, needle), text);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tableRows(markdown: string) {
|
|
61
|
+
return markdown.split("\n").filter((line) => line.startsWith("|"));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("MarkdownDocumentEditor source preservation", () => {
|
|
65
|
+
it("opens every Markdown construct without reporting a change", async () => {
|
|
66
|
+
const { editor, onChange, view } = await openEditor(KITCHEN_SINK);
|
|
67
|
+
act(() => {
|
|
68
|
+
editor.commands.setTextSelection(1);
|
|
69
|
+
editor.setEditable(false);
|
|
70
|
+
editor.setEditable(true);
|
|
71
|
+
});
|
|
72
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
73
|
+
// The table is shown as a table, read-only, instead of a run-on paragraph.
|
|
74
|
+
const table = view.container.querySelector('[data-md-kind="table"] table');
|
|
75
|
+
expect(table?.querySelectorAll("tbody tr")).toHaveLength(1);
|
|
76
|
+
expect(table?.textContent).toContain("Center");
|
|
77
|
+
}, LOADS_PEERS);
|
|
78
|
+
|
|
79
|
+
it.each([
|
|
80
|
+
["a paragraph with escapes", "snake_case_name", " (edited)"],
|
|
81
|
+
["a star bullet", "star bullet two", "!"],
|
|
82
|
+
["a nested bullet", "nested four-space", " item"],
|
|
83
|
+
["a paren-ordered item", "paren ordered", " list"],
|
|
84
|
+
["a setext heading", "Setext heading", " again"],
|
|
85
|
+
["a fenced code block", "const x = 1", "0"],
|
|
86
|
+
["a tilde fence", "tilde fence", "d"],
|
|
87
|
+
["a nested quote", "nested quote", "d"],
|
|
88
|
+
["the last paragraph", "next line", "."],
|
|
89
|
+
])("changes only the edited span when typing into %s", async (_name, needle, text) => {
|
|
90
|
+
const { editor, saved } = await openEditor(KITCHEN_SINK);
|
|
91
|
+
insertAfter(editor, needle, text);
|
|
92
|
+
expect(saved()).toBe(KITCHEN_SINK.replace(needle, needle + text));
|
|
93
|
+
}, LOADS_PEERS);
|
|
94
|
+
|
|
95
|
+
it("keeps every table row when the title is edited, the failure founders hit", async () => {
|
|
96
|
+
const { editor, saved } = await openEditor(CALL_SHEET);
|
|
97
|
+
act(() => {
|
|
98
|
+
editor.commands.insertContentAt(textPosition(editor, "Plausible Analytics:", "start"), "Draft: ");
|
|
99
|
+
});
|
|
100
|
+
expect(saved()).toBe(CALL_SHEET.replace("# Plausible", "# Draft: Plausible"));
|
|
101
|
+
expect(tableRows(saved())).toEqual(tableRows(CALL_SHEET));
|
|
102
|
+
expect(tableRows(saved())).toHaveLength(5);
|
|
103
|
+
}, LOADS_PEERS);
|
|
104
|
+
|
|
105
|
+
it("separates a paragraph added with Enter by a blank line", async () => {
|
|
106
|
+
const { editor, saved } = await openEditor(CALL_SHEET);
|
|
107
|
+
act(() => {
|
|
108
|
+
editor.chain().setTextSelection(textPosition(editor, "positioning Plausible.")).splitBlock().run();
|
|
109
|
+
editor.commands.insertContent("A new line of notes.");
|
|
110
|
+
});
|
|
111
|
+
expect(saved()).toBe(
|
|
112
|
+
CALL_SHEET.replace("positioning Plausible.\n", "positioning Plausible.\n\nA new line of notes.\n"),
|
|
113
|
+
);
|
|
114
|
+
}, LOADS_PEERS);
|
|
115
|
+
|
|
116
|
+
it("adds only the delimiters when a word is bolded", async () => {
|
|
117
|
+
const { editor, saved } = await openEditor(CALL_SHEET);
|
|
118
|
+
const end = textPosition(editor, "Who signs off");
|
|
119
|
+
act(() => {
|
|
120
|
+
editor.chain().setTextSelection({ from: end - "signs off".length, to: end }).toggleBold().run();
|
|
121
|
+
});
|
|
122
|
+
expect(saved()).toBe(CALL_SHEET.replace("Who signs off", "Who **signs off**"));
|
|
123
|
+
}, LOADS_PEERS);
|
|
124
|
+
|
|
125
|
+
it("adds an item in the list's own marker style", async () => {
|
|
126
|
+
const { editor, saved } = await openEditor(CALL_SHEET);
|
|
127
|
+
act(() => {
|
|
128
|
+
editor.chain().setTextSelection(textPosition(editor, "after the call")).splitListItem("listItem").run();
|
|
129
|
+
editor.commands.insertContent("Log the objections");
|
|
130
|
+
});
|
|
131
|
+
expect(saved()).toBe(CALL_SHEET.replace("after the call\n", "after the call\n* Log the objections\n"));
|
|
132
|
+
}, LOADS_PEERS);
|
|
133
|
+
|
|
134
|
+
it("removes only the table when its block is deleted", async () => {
|
|
135
|
+
const { editor, saved } = await openEditor(CALL_SHEET);
|
|
136
|
+
let tablePos = -1;
|
|
137
|
+
editor.state.doc.forEach((node, offset) => {
|
|
138
|
+
if (node.type.name === "markdownSource") tablePos = offset;
|
|
139
|
+
});
|
|
140
|
+
act(() => {
|
|
141
|
+
editor.chain().setNodeSelection(tablePos).deleteSelection().run();
|
|
142
|
+
});
|
|
143
|
+
const start = CALL_SHEET.indexOf("| Competitor");
|
|
144
|
+
const end = CALL_SHEET.indexOf("## Qualifying");
|
|
145
|
+
expect(saved()).toBe(CALL_SHEET.slice(0, start) + CALL_SHEET.slice(end));
|
|
146
|
+
}, LOADS_PEERS);
|
|
147
|
+
|
|
148
|
+
it("reports the original bytes again once an edit is undone", async () => {
|
|
149
|
+
const { editor, saved } = await openEditor(KITCHEN_SINK);
|
|
150
|
+
insertAfter(editor, "star bullet one", "?");
|
|
151
|
+
act(() => {
|
|
152
|
+
editor.commands.undo();
|
|
153
|
+
});
|
|
154
|
+
expect(saved()).toBe(KITCHEN_SINK);
|
|
155
|
+
}, LOADS_PEERS);
|
|
156
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tiptap half of source-preserving Markdown editing (see
|
|
3
|
+
* `source-markdown.ts`): a read-only node for blocks the editor cannot
|
|
4
|
+
* represent, a block-id attribute on every top-level node, and the session
|
|
5
|
+
* that maps an editor document back onto the file's original bytes.
|
|
6
|
+
*
|
|
7
|
+
* tiptap arrives as an argument, so this module reaches it through type-only
|
|
8
|
+
* imports, which a bundler erases (see `editor-peers.ts`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type * as TiptapReact from "@tiptap/react";
|
|
12
|
+
import { marked, type Token, type Tokens } from "marked";
|
|
13
|
+
import {
|
|
14
|
+
asReadOnlyDocument,
|
|
15
|
+
BLOCK_ID_ATTRIBUTE,
|
|
16
|
+
blockHtml,
|
|
17
|
+
blockMarkdown,
|
|
18
|
+
createBlockMerger,
|
|
19
|
+
parseSourceMarkdown,
|
|
20
|
+
serializeSourceMarkdown,
|
|
21
|
+
SOURCE_KIND_ATTRIBUTE,
|
|
22
|
+
SOURCE_RAW_ATTRIBUTE,
|
|
23
|
+
withReadOnlyBlocks,
|
|
24
|
+
type SourceBlockKind,
|
|
25
|
+
type SourceMarkdown,
|
|
26
|
+
type SourceNode,
|
|
27
|
+
} from "./source-markdown";
|
|
28
|
+
|
|
29
|
+
type Core = typeof TiptapReact;
|
|
30
|
+
type Editor = TiptapReact.Editor;
|
|
31
|
+
type PMNode = Editor["state"]["doc"];
|
|
32
|
+
type Schema = Editor["schema"];
|
|
33
|
+
type DOMOutputSpec = unknown[] | string;
|
|
34
|
+
|
|
35
|
+
/** Name of the read-only node that holds a block's raw Markdown. */
|
|
36
|
+
export const SOURCE_NODE = "markdownSource";
|
|
37
|
+
|
|
38
|
+
/** Top-level node types that can carry a block id. */
|
|
39
|
+
const BLOCK_TYPES = [
|
|
40
|
+
"paragraph",
|
|
41
|
+
"heading",
|
|
42
|
+
"bulletList",
|
|
43
|
+
"orderedList",
|
|
44
|
+
"blockquote",
|
|
45
|
+
"codeBlock",
|
|
46
|
+
"horizontalRule",
|
|
47
|
+
SOURCE_NODE,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const CAPTIONS: Record<SourceBlockKind, string> = {
|
|
51
|
+
table: "Table",
|
|
52
|
+
html: "HTML",
|
|
53
|
+
image: "Image",
|
|
54
|
+
checklist: "Checklist",
|
|
55
|
+
definition: "Link definition",
|
|
56
|
+
markdown: "Markdown",
|
|
57
|
+
paragraph: "Markdown",
|
|
58
|
+
heading: "Markdown",
|
|
59
|
+
code: "Markdown",
|
|
60
|
+
blockquote: "Markdown",
|
|
61
|
+
list: "Markdown",
|
|
62
|
+
hr: "Markdown",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function inlineText(tokens: Token[] | undefined): string {
|
|
66
|
+
return (tokens ?? [])
|
|
67
|
+
.map((token) => {
|
|
68
|
+
switch (token.type) {
|
|
69
|
+
case "image":
|
|
70
|
+
return (token as Tokens.Image).text;
|
|
71
|
+
case "br":
|
|
72
|
+
return " ";
|
|
73
|
+
case "html":
|
|
74
|
+
return "";
|
|
75
|
+
default: {
|
|
76
|
+
const children = (token as { tokens?: Token[] }).tokens;
|
|
77
|
+
return children ? inlineText(children) : (token as { text?: string }).text ?? "";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.join("");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tablePreview(raw: string): DOMOutputSpec | null {
|
|
85
|
+
const table = marked.lexer(raw, { gfm: true }).find((token) => token.type === "table") as
|
|
86
|
+
| Tokens.Table
|
|
87
|
+
| undefined;
|
|
88
|
+
if (!table) return null;
|
|
89
|
+
const cell = (tag: "th" | "td", value: Tokens.TableCell) => [tag, {}, inlineText(value.tokens)];
|
|
90
|
+
return [
|
|
91
|
+
"table",
|
|
92
|
+
{},
|
|
93
|
+
["thead", {}, ["tr", {}, ...table.header.map((value) => cell("th", value))]],
|
|
94
|
+
["tbody", {}, ...table.rows.map((row) => ["tr", {}, ...row.map((value) => cell("td", value))])],
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sourcePreview(raw: string, kind: SourceBlockKind): DOMOutputSpec[] {
|
|
99
|
+
const caption: DOMOutputSpec = [
|
|
100
|
+
"p",
|
|
101
|
+
{ class: "not-prose m-0 mb-2 text-sm text-muted-foreground" },
|
|
102
|
+
`${CAPTIONS[kind] ?? "Markdown"} kept exactly as written. Edit it in the Markdown source.`,
|
|
103
|
+
];
|
|
104
|
+
const table = kind === "table" ? tablePreview(raw) : null;
|
|
105
|
+
if (table) return [caption, ["div", { class: "overflow-x-auto" }, table]];
|
|
106
|
+
return [caption, ["pre", { class: "m-0 whitespace-pre-wrap" }, ["code", {}, raw]]];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The read-only source node and the block-id attribute, built from the loaded tiptap. */
|
|
110
|
+
export function createSourceMarkdownExtensions(core: Core) {
|
|
111
|
+
const SourceBlock = core.Node.create({
|
|
112
|
+
name: SOURCE_NODE,
|
|
113
|
+
group: "block",
|
|
114
|
+
atom: true,
|
|
115
|
+
selectable: true,
|
|
116
|
+
draggable: false,
|
|
117
|
+
addAttributes() {
|
|
118
|
+
return {
|
|
119
|
+
raw: {
|
|
120
|
+
default: "",
|
|
121
|
+
parseHTML: (element: HTMLElement) => element.getAttribute(SOURCE_RAW_ATTRIBUTE) ?? "",
|
|
122
|
+
renderHTML: (attributes: Record<string, unknown>) => ({ [SOURCE_RAW_ATTRIBUTE]: attributes.raw }),
|
|
123
|
+
},
|
|
124
|
+
kind: {
|
|
125
|
+
default: "markdown",
|
|
126
|
+
parseHTML: (element: HTMLElement) => element.getAttribute(SOURCE_KIND_ATTRIBUTE) ?? "markdown",
|
|
127
|
+
renderHTML: (attributes: Record<string, unknown>) => ({ [SOURCE_KIND_ATTRIBUTE]: attributes.kind }),
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
parseHTML() {
|
|
132
|
+
return [{ tag: `div[${SOURCE_RAW_ATTRIBUTE}]` }];
|
|
133
|
+
},
|
|
134
|
+
renderHTML({ node, HTMLAttributes }) {
|
|
135
|
+
return [
|
|
136
|
+
"div",
|
|
137
|
+
core.mergeAttributes(HTMLAttributes, {
|
|
138
|
+
class: "my-3 rounded-lg border border-dashed border-border bg-muted/40 p-3",
|
|
139
|
+
}),
|
|
140
|
+
...sourcePreview(String(node.attrs.raw ?? ""), node.attrs.kind as SourceBlockKind),
|
|
141
|
+
] as never;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const BlockIdentity = core.Extension.create({
|
|
146
|
+
name: "markdownSourceBlockId",
|
|
147
|
+
addGlobalAttributes() {
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
types: BLOCK_TYPES,
|
|
151
|
+
attributes: {
|
|
152
|
+
mdBlock: {
|
|
153
|
+
default: null,
|
|
154
|
+
// A split's new half is new text, not a copy of the source block.
|
|
155
|
+
keepOnSplit: false,
|
|
156
|
+
parseHTML: (element: HTMLElement) => element.getAttribute(BLOCK_ID_ATTRIBUTE),
|
|
157
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
158
|
+
attributes.mdBlock ? { [BLOCK_ID_ATTRIBUTE]: attributes.mdBlock } : {},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return [SourceBlock, BlockIdentity];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function blockIdOf(node: PMNode): string | null {
|
|
170
|
+
const id = (node.attrs as { mdBlock?: unknown }).mdBlock;
|
|
171
|
+
return typeof id === "string" && id ? id : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isEmptyParagraph(node: PMNode) {
|
|
175
|
+
return node.type.name === "paragraph" && node.content.size === 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function withoutBlockIds(value: unknown): unknown {
|
|
179
|
+
if (Array.isArray(value)) return value.map(withoutBlockIds);
|
|
180
|
+
if (!value || typeof value !== "object") return value;
|
|
181
|
+
const out: Record<string, unknown> = {};
|
|
182
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
183
|
+
if (key === "mdBlock") continue;
|
|
184
|
+
out[key] = withoutBlockIds(entry);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One open file in the rich editor: its source blocks, the nodes they were
|
|
191
|
+
* first parsed into, and the Markdown written back for the current document.
|
|
192
|
+
*/
|
|
193
|
+
export class SourceMarkdownSession {
|
|
194
|
+
source: SourceMarkdown;
|
|
195
|
+
private readonly core: Core;
|
|
196
|
+
private readonly markdown: string;
|
|
197
|
+
private readonly initial = new Map<string, PMNode>();
|
|
198
|
+
private readonly mergers = new Map<string, (edited: string) => string | null>();
|
|
199
|
+
|
|
200
|
+
constructor(core: Core, markdown: string) {
|
|
201
|
+
this.core = core;
|
|
202
|
+
this.markdown = markdown;
|
|
203
|
+
this.source = parseSourceMarkdown(markdown);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
get html() {
|
|
207
|
+
return this.source.html;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Records the nodes the editor parsed. A block the editor document does not
|
|
212
|
+
* hold as exactly one top-level node (dropped, split, or merged by the
|
|
213
|
+
* schema) is shown read-only instead, so no source byte can be lost.
|
|
214
|
+
*/
|
|
215
|
+
attach(editor: Editor) {
|
|
216
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
217
|
+
const unmapped = this.capture(editor.state.doc);
|
|
218
|
+
if (unmapped.size === 0) return;
|
|
219
|
+
this.source = attempt < 2 ? withReadOnlyBlocks(this.source, unmapped) : asReadOnlyDocument(this.markdown);
|
|
220
|
+
editor.commands.setContent(this.source.html, { emitUpdate: false });
|
|
221
|
+
}
|
|
222
|
+
this.capture(editor.state.doc);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private capture(doc: PMNode): Set<string> {
|
|
226
|
+
this.initial.clear();
|
|
227
|
+
this.mergers.clear();
|
|
228
|
+
const unmapped = new Set<string>();
|
|
229
|
+
let previous: string | null = null;
|
|
230
|
+
doc.forEach((node, _offset, index) => {
|
|
231
|
+
const id = blockIdOf(node);
|
|
232
|
+
if (id && this.source.byId.has(id)) {
|
|
233
|
+
if (this.initial.has(id)) unmapped.add(id);
|
|
234
|
+
else this.initial.set(id, node);
|
|
235
|
+
previous = id;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (index === doc.childCount - 1 && isEmptyParagraph(node)) return;
|
|
239
|
+
// A node with no id came out of the block before it (or the first block).
|
|
240
|
+
const owner = previous ?? this.source.blocks[0]?.id;
|
|
241
|
+
if (owner) unmapped.add(owner);
|
|
242
|
+
});
|
|
243
|
+
for (const block of this.source.blocks) {
|
|
244
|
+
if (!this.initial.has(block.id)) unmapped.add(block.id);
|
|
245
|
+
}
|
|
246
|
+
return unmapped;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private toMarkdown(node: PMNode, raw: string) {
|
|
250
|
+
const fragment = node.type.schema.topNodeType.create(null, node).content;
|
|
251
|
+
return blockMarkdown(this.core.getHTMLFromFragment(fragment, node.type.schema), raw);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private reparses(markdown: string, node: PMNode, schema: Schema) {
|
|
255
|
+
const html = blockHtml(markdown, this.source.links);
|
|
256
|
+
if (html === null) return false;
|
|
257
|
+
const parsed = this.core.createDocument(html, schema);
|
|
258
|
+
const nodes: PMNode[] = [];
|
|
259
|
+
parsed.forEach((child) => {
|
|
260
|
+
if (!isEmptyParagraph(child)) nodes.push(child);
|
|
261
|
+
});
|
|
262
|
+
return (
|
|
263
|
+
nodes.length === 1 &&
|
|
264
|
+
JSON.stringify(withoutBlockIds(nodes[0].toJSON())) === JSON.stringify(withoutBlockIds(node.toJSON()))
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private mergerFor(id: string, initial: PMNode, raw: string) {
|
|
269
|
+
let merge = this.mergers.get(id);
|
|
270
|
+
if (!merge) {
|
|
271
|
+
merge = createBlockMerger(raw, this.toMarkdown(initial, raw));
|
|
272
|
+
this.mergers.set(id, merge);
|
|
273
|
+
}
|
|
274
|
+
return merge;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The file's Markdown for `doc`: original bytes wherever the reader changed nothing. */
|
|
278
|
+
serialize(doc: PMNode): string {
|
|
279
|
+
const nodes: SourceNode[] = [];
|
|
280
|
+
doc.forEach((node) => {
|
|
281
|
+
const id = blockIdOf(node);
|
|
282
|
+
const block = id ? this.source.byId.get(id) : undefined;
|
|
283
|
+
const initial = id ? this.initial.get(id) : undefined;
|
|
284
|
+
const raw = node.type.name === SOURCE_NODE ? String(node.attrs.raw ?? "") : null;
|
|
285
|
+
nodes.push({
|
|
286
|
+
block: initial ? block : undefined,
|
|
287
|
+
unchanged: Boolean(initial?.eq(node)),
|
|
288
|
+
sameType: initial?.type === node.type,
|
|
289
|
+
markdown: () => raw ?? this.toMarkdown(node, block?.raw ?? ""),
|
|
290
|
+
merge: (edited) => (initial && block ? this.mergerFor(block.id, initial, block.raw)(edited) : null),
|
|
291
|
+
reparses: (markdown) => this.reparses(markdown, node, node.type.schema),
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
return serializeSourceMarkdown(this.source, nodes);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown fixtures for the source-preserving editor tests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One of every block and inline construct, in the less common spellings too.
|
|
7
|
+
* Trailing spaces are written as \u0020 so an editor cannot strip them.
|
|
8
|
+
*/
|
|
9
|
+
export const KITCHEN_SINK = `
|
|
10
|
+
|
|
11
|
+
# Title with \`code\` and **bold**
|
|
12
|
+
|
|
13
|
+
Setext heading
|
|
14
|
+
==============
|
|
15
|
+
|
|
16
|
+
Paragraph with *star emphasis*, _underscore emphasis_, __double under__, snake_case_name, a\\_b escaped,
|
|
17
|
+
a hard break here\u0020\u0020
|
|
18
|
+
and a [link](https://example.com "Title"), a [ref link][ref], <https://auto.link>, and ~~strike~~.
|
|
19
|
+
|
|
20
|
+
* star bullet one
|
|
21
|
+
* star bullet two
|
|
22
|
+
* nested four-space
|
|
23
|
+
|
|
24
|
+
+ plus list
|
|
25
|
+
|
|
26
|
+
1) paren ordered
|
|
27
|
+
2) second
|
|
28
|
+
|
|
29
|
+
3. starts at three
|
|
30
|
+
4. four
|
|
31
|
+
|
|
32
|
+
- [ ] task open
|
|
33
|
+
- [x] task done
|
|
34
|
+
|
|
35
|
+
> quote line one
|
|
36
|
+
> > nested quote
|
|
37
|
+
|
|
38
|
+
\`\`\`ts
|
|
39
|
+
const x = 1 // fenced
|
|
40
|
+
\`\`\`
|
|
41
|
+
|
|
42
|
+
~~~
|
|
43
|
+
tilde fence
|
|
44
|
+
~~~
|
|
45
|
+
|
|
46
|
+
indented code
|
|
47
|
+
|
|
48
|
+
***
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
___
|
|
52
|
+
|
|
53
|
+
| Left | Center | Right |
|
|
54
|
+
|:-----|:------:|------:|
|
|
55
|
+
| a | **b** | [c](https://c.example) |
|
|
56
|
+
|
|
57
|
+
<div align="center">raw html block</div>
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
Text with inline <sup>html</sup> and an emoji ๐ and trailing spaces\u0020\u0020\u0020
|
|
62
|
+
|
|
63
|
+
[ref]: https://ref.example "Ref Title"
|
|
64
|
+
|
|
65
|
+
Line with trailing backslash\\
|
|
66
|
+
next line
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
/** Shaped like an agent-written competitor call sheet: headings, prose and GFM tables. */
|
|
71
|
+
export const CALL_SHEET = `# Plausible Analytics: competitor call sheet
|
|
72
|
+
|
|
73
|
+
**Updated:** October 11, 2026 ยท **Use:** establish what the buyer is comparing before positioning Plausible.
|
|
74
|
+
|
|
75
|
+
## Three deal paths
|
|
76
|
+
|
|
77
|
+
| Competitor | Why a buyer may choose it | Call response |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Google Analytics 4 | Standard GA4 is free of charge. ([GA4](https://example.com/ga4)) | **Acknowledge:** the no-cost offer is real. |
|
|
80
|
+
| Fathom | Closest paid, cookie-free alternative. | Ask which reports they rely on today. |
|
|
81
|
+
| Simple Analytics | Stricter no-identifier model. | Show the dashboard and ask what is missing. |
|
|
82
|
+
|
|
83
|
+
## Qualifying questions
|
|
84
|
+
|
|
85
|
+
1. Which reports do you open every week?
|
|
86
|
+
2. Who signs off on analytics spend?
|
|
87
|
+
|
|
88
|
+
* Keep the call to 20 minutes
|
|
89
|
+
* Send the comparison after the call
|
|
90
|
+
`;
|