@tangle-network/ui 11.32.2 → 11.34.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 +23 -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 +3 -2
- package/dist/index.js +3 -2
- package/dist/lib/control-presentation.js +2 -1
- package/dist/lib/focus.d.ts +5 -1
- package/dist/lib/focus.js +6 -1
- package/dist/lib/interaction.d.ts +41 -0
- package/dist/lib/interaction.js +42 -0
- package/dist/markdown/markdown-table.js +1 -1
- package/dist/primitives/button.js +19 -18
- package/dist/primitives/card.d.ts +14 -1
- package/dist/primitives/card.js +15 -5
- package/dist/primitives/dropdown-menu.js +7 -6
- package/dist/primitives/segmented-control.d.ts +5 -1
- package/dist/primitives/segmented-control.js +12 -5
- package/dist/primitives/select.js +4 -3
- package/dist/primitives/tabs.js +4 -3
- 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 +3 -2
- package/dist/utils.js +3 -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 +38 -3
- package/src/files/file-artifact-pane.tsx +3 -0
- package/src/lib/control-presentation.ts +10 -4
- package/src/lib/focus.ts +7 -1
- package/src/lib/interaction.ts +64 -0
- package/src/markdown/markdown-table.test.tsx +9 -0
- package/src/markdown/markdown-table.tsx +5 -1
- package/src/primitives/button.tsx +29 -10
- package/src/primitives/card.tsx +23 -6
- package/src/primitives/control-presentation.test.tsx +6 -3
- package/src/primitives/date-picker.test.tsx +1 -1
- package/src/primitives/dropdown-menu.tsx +15 -10
- package/src/primitives/interaction-states.test.tsx +285 -0
- package/src/primitives/segmented-control.test.tsx +4 -2
- package/src/primitives/segmented-control.tsx +13 -14
- package/src/primitives/select.tsx +11 -5
- package/src/primitives/tabs.tsx +7 -3
- 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 +10 -1
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-preserving Markdown for the rich editor.
|
|
3
|
+
*
|
|
4
|
+
* The rich editor holds a ProseMirror document, and a document can only be
|
|
5
|
+
* turned back into Markdown by re-serializing it. Re-serializing rewrites
|
|
6
|
+
* whatever the reader did not touch: list markers, emphasis delimiters,
|
|
7
|
+
* escapes, blank lines, and every construct the editor has no node for (a GFM
|
|
8
|
+
* table became one run-on paragraph). This module keeps the original bytes
|
|
9
|
+
* instead.
|
|
10
|
+
*
|
|
11
|
+
* The file is split into its top-level blocks with `marked`'s lexer, whose
|
|
12
|
+
* token `raw` fields concatenate back to the source. Each block the editor can
|
|
13
|
+
* represent is rendered to HTML carrying a block id; every other block (a
|
|
14
|
+
* table, raw HTML, an image, a task list, a link definition) becomes one atomic
|
|
15
|
+
* source node that holds its raw text. On save:
|
|
16
|
+
*
|
|
17
|
+
* - a block whose node still equals the node it was parsed into is written
|
|
18
|
+
* back as its original bytes, with its original separator;
|
|
19
|
+
* - a block the reader changed is re-serialized, and the change is merged
|
|
20
|
+
* into the original bytes (a three-way merge against the block's own
|
|
21
|
+
* re-serialization), so only the edited span differs; the merge is kept only
|
|
22
|
+
* when it parses back to exactly what the reader sees, otherwise the block's
|
|
23
|
+
* re-serialization is written;
|
|
24
|
+
* - a block the reader added is re-serialized.
|
|
25
|
+
*
|
|
26
|
+
* This module is pure: the editor-specific half (ProseMirror nodes, schema,
|
|
27
|
+
* HTML) arrives through `SourceNode` callbacks, so it runs without tiptap.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { marked, type Token, type Tokens, type TokensList } from "marked";
|
|
31
|
+
import TurndownService from "turndown";
|
|
32
|
+
|
|
33
|
+
/** One top-level block of the source file. */
|
|
34
|
+
export interface SourceBlock {
|
|
35
|
+
/** Stable id carried by the editor node parsed from this block. */
|
|
36
|
+
id: string;
|
|
37
|
+
/** Position in the source, used to tell whether two blocks were adjacent. */
|
|
38
|
+
index: number;
|
|
39
|
+
/** The block's bytes, without the line breaks that separate it from the next one. */
|
|
40
|
+
raw: string;
|
|
41
|
+
/** The bytes between this block and the next, or the file's trailing bytes for the last block. */
|
|
42
|
+
gap: string;
|
|
43
|
+
/** False for a block the editor cannot represent; it is shown read-only and kept verbatim. */
|
|
44
|
+
editable: boolean;
|
|
45
|
+
/** The lexer's token type, or the reason the block is read-only. */
|
|
46
|
+
kind: SourceBlockKind;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type SourceBlockKind =
|
|
50
|
+
| "paragraph"
|
|
51
|
+
| "heading"
|
|
52
|
+
| "code"
|
|
53
|
+
| "blockquote"
|
|
54
|
+
| "list"
|
|
55
|
+
| "hr"
|
|
56
|
+
| "table"
|
|
57
|
+
| "html"
|
|
58
|
+
| "image"
|
|
59
|
+
| "checklist"
|
|
60
|
+
| "definition"
|
|
61
|
+
| "markdown";
|
|
62
|
+
|
|
63
|
+
export interface SourceMarkdown {
|
|
64
|
+
/** Blank lines before the first block. */
|
|
65
|
+
prefix: string;
|
|
66
|
+
/** Bytes after the last block (normally its line break). */
|
|
67
|
+
trailer: string;
|
|
68
|
+
blocks: SourceBlock[];
|
|
69
|
+
byId: ReadonlyMap<string, SourceBlock>;
|
|
70
|
+
/** HTML for the editor: editable blocks tagged with `data-md-block`, the rest as source nodes. */
|
|
71
|
+
html: string;
|
|
72
|
+
/** Link reference definitions of the whole file, so a block re-read alone keeps `[text][ref]` links. */
|
|
73
|
+
links: Readonly<TokensList["links"]>;
|
|
74
|
+
/** Lexer token of each editable block. */
|
|
75
|
+
tokens: ReadonlyMap<string, Token>;
|
|
76
|
+
/** The file's line ending, used for text the editor writes. */
|
|
77
|
+
lineEnding: "\n" | "\r\n";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** HTML attribute that carries a block id from the source HTML into the editor document. */
|
|
81
|
+
export const BLOCK_ID_ATTRIBUTE = "data-md-block";
|
|
82
|
+
/** HTML attribute that carries a read-only block's raw Markdown. */
|
|
83
|
+
export const SOURCE_RAW_ATTRIBUTE = "data-md-raw";
|
|
84
|
+
/** HTML attribute that carries a read-only block's kind, which picks its caption. */
|
|
85
|
+
export const SOURCE_KIND_ATTRIBUTE = "data-md-kind";
|
|
86
|
+
|
|
87
|
+
const INLINE_TYPES = new Set(["text", "strong", "em", "codespan", "del", "link", "br", "escape"]);
|
|
88
|
+
|
|
89
|
+
let documentCounter = 0;
|
|
90
|
+
|
|
91
|
+
function readOnlyKind(token: Token): SourceBlockKind | null {
|
|
92
|
+
switch (token.type) {
|
|
93
|
+
case "paragraph":
|
|
94
|
+
case "heading":
|
|
95
|
+
return inlineReadOnlyKind((token as Tokens.Paragraph | Tokens.Heading).tokens);
|
|
96
|
+
case "code":
|
|
97
|
+
case "hr":
|
|
98
|
+
return null;
|
|
99
|
+
case "blockquote":
|
|
100
|
+
return blocksReadOnlyKind((token as Tokens.Blockquote).tokens);
|
|
101
|
+
case "list": {
|
|
102
|
+
for (const item of (token as Tokens.List).items) {
|
|
103
|
+
if (item.task) return "checklist";
|
|
104
|
+
const kind = blocksReadOnlyKind(item.tokens);
|
|
105
|
+
if (kind) return kind;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
case "table":
|
|
110
|
+
return "table";
|
|
111
|
+
case "html":
|
|
112
|
+
return "html";
|
|
113
|
+
case "def":
|
|
114
|
+
return "definition";
|
|
115
|
+
default:
|
|
116
|
+
return "markdown";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function blocksReadOnlyKind(tokens: Token[]): SourceBlockKind | null {
|
|
121
|
+
for (const token of tokens) {
|
|
122
|
+
if (token.type === "space") continue;
|
|
123
|
+
if (token.type === "text") {
|
|
124
|
+
const kind = inlineReadOnlyKind((token as Tokens.Text).tokens ?? []);
|
|
125
|
+
if (kind) return kind;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const kind = readOnlyKind(token);
|
|
129
|
+
if (kind) return kind;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function inlineReadOnlyKind(tokens: Token[] | undefined): SourceBlockKind | null {
|
|
135
|
+
for (const token of tokens ?? []) {
|
|
136
|
+
if (token.type === "image") return "image";
|
|
137
|
+
if (token.type === "html") return "html";
|
|
138
|
+
if (!INLINE_TYPES.has(token.type)) return "markdown";
|
|
139
|
+
const children = (token as { tokens?: Token[] }).tokens;
|
|
140
|
+
if (children) {
|
|
141
|
+
const kind = inlineReadOnlyKind(children);
|
|
142
|
+
if (kind) return kind;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function escapeAttribute(value: string) {
|
|
149
|
+
return value
|
|
150
|
+
.replace(/&/g, "&")
|
|
151
|
+
.replace(/"/g, """)
|
|
152
|
+
.replace(/</g, "<")
|
|
153
|
+
.replace(/>/g, ">");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function sourceNodeHtml(block: Pick<SourceBlock, "id" | "raw" | "kind">) {
|
|
157
|
+
return `<div ${BLOCK_ID_ATTRIBUTE}="${block.id}" ${SOURCE_KIND_ATTRIBUTE}="${block.kind}" ${SOURCE_RAW_ATTRIBUTE}="${escapeAttribute(block.raw)}"></div>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Renders one editable block, tagging its root element with the block id. */
|
|
161
|
+
function editableBlockHtml(token: Token, id: string): string | null {
|
|
162
|
+
const html = String(marked.parser([token], { async: false, gfm: true })).trim();
|
|
163
|
+
if (!/^<[a-z][a-z0-9]*/i.test(html)) return null;
|
|
164
|
+
return html.replace(/^<([a-z][a-z0-9]*)/i, `<$1 ${BLOCK_ID_ATTRIBUTE}="${id}"`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Maps offsets in the line-feed-normalized text back to the original text, so
|
|
169
|
+
* a file with CRLF line endings keeps them.
|
|
170
|
+
*/
|
|
171
|
+
function originalOffsets(original: string, normalized: string): (offset: number) => number {
|
|
172
|
+
if (original === normalized) return (offset) => offset;
|
|
173
|
+
const map = new Int32Array(normalized.length + 1);
|
|
174
|
+
let o = 0;
|
|
175
|
+
for (let n = 0; n < normalized.length; n++) {
|
|
176
|
+
map[n] = o;
|
|
177
|
+
o += original[o] === "\r" && original[o + 1] === "\n" ? 2 : 1;
|
|
178
|
+
}
|
|
179
|
+
map[normalized.length] = original.length;
|
|
180
|
+
return (offset) => map[offset];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildHtml(blocks: SourceBlock[], tokens: ReadonlyMap<string, Token>) {
|
|
184
|
+
return blocks
|
|
185
|
+
.map((block) => {
|
|
186
|
+
if (!block.editable) return sourceNodeHtml(block);
|
|
187
|
+
const token = tokens.get(block.id);
|
|
188
|
+
return (token && editableBlockHtml(token, block.id)) ?? sourceNodeHtml(block);
|
|
189
|
+
})
|
|
190
|
+
.join("\n");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function finish(
|
|
194
|
+
prefix: string,
|
|
195
|
+
trailer: string,
|
|
196
|
+
blocks: SourceBlock[],
|
|
197
|
+
tokens: ReadonlyMap<string, Token>,
|
|
198
|
+
links: SourceMarkdown["links"],
|
|
199
|
+
): SourceMarkdown {
|
|
200
|
+
return {
|
|
201
|
+
prefix,
|
|
202
|
+
trailer,
|
|
203
|
+
blocks,
|
|
204
|
+
byId: new Map(blocks.map((block) => [block.id, block])),
|
|
205
|
+
html: buildHtml(blocks, tokens),
|
|
206
|
+
links,
|
|
207
|
+
tokens,
|
|
208
|
+
lineEnding: [prefix, trailer, ...blocks.map((block) => block.raw + block.gap)].some((text) => text.includes("\r\n"))
|
|
209
|
+
? "\r\n"
|
|
210
|
+
: "\n",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The whole file as one read-only block: the fallback when it cannot be split faithfully. */
|
|
215
|
+
function wholeFileSource(markdown: string, nonce: string): SourceMarkdown {
|
|
216
|
+
const blocks: SourceBlock[] = markdown.trim()
|
|
217
|
+
? [{ id: `${nonce}:0`, index: 0, raw: markdown, gap: "", editable: false, kind: "markdown" }]
|
|
218
|
+
: [];
|
|
219
|
+
return finish(blocks.length ? "" : markdown, "", blocks, new Map(), {});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Splits a Markdown file into source blocks and the editor HTML that carries them. */
|
|
223
|
+
export function parseSourceMarkdown(markdown: string): SourceMarkdown {
|
|
224
|
+
const nonce = `m${(documentCounter += 1).toString(36)}`;
|
|
225
|
+
const normalized = markdown.replace(/\r\n?/g, "\n");
|
|
226
|
+
// A lone CR is not a line ending this module can map back, so such a file
|
|
227
|
+
// stays read-only rather than gaining line feeds.
|
|
228
|
+
if (/\r(?!\n)/.test(markdown)) return wholeFileSource(markdown, nonce);
|
|
229
|
+
const tokens = marked.lexer(normalized, { gfm: true });
|
|
230
|
+
if (tokens.map((token) => token.raw).join("") !== normalized) {
|
|
231
|
+
return wholeFileSource(markdown, nonce);
|
|
232
|
+
}
|
|
233
|
+
const toOriginal = originalOffsets(markdown, normalized);
|
|
234
|
+
const slice = (start: number, end: number) => markdown.slice(toOriginal(start), toOriginal(end));
|
|
235
|
+
|
|
236
|
+
const blocks: SourceBlock[] = [];
|
|
237
|
+
const blockTokens = new Map<string, Token>();
|
|
238
|
+
let prefix = "";
|
|
239
|
+
let offset = 0;
|
|
240
|
+
for (const token of tokens) {
|
|
241
|
+
const start = offset;
|
|
242
|
+
offset += token.raw.length;
|
|
243
|
+
if (token.type === "space") {
|
|
244
|
+
if (blocks.length === 0) prefix += slice(start, offset);
|
|
245
|
+
else blocks[blocks.length - 1].gap += slice(start, offset);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const body = token.raw.replace(/\n+$/, "");
|
|
249
|
+
const id = `${nonce}:${blocks.length}`;
|
|
250
|
+
const kind = readOnlyKind(token);
|
|
251
|
+
blocks.push({
|
|
252
|
+
id,
|
|
253
|
+
index: blocks.length,
|
|
254
|
+
raw: slice(start, start + body.length),
|
|
255
|
+
gap: slice(start + body.length, offset),
|
|
256
|
+
editable: kind === null,
|
|
257
|
+
kind: kind ?? (token.type as SourceBlockKind),
|
|
258
|
+
});
|
|
259
|
+
blockTokens.set(id, token);
|
|
260
|
+
}
|
|
261
|
+
const last = blocks[blocks.length - 1];
|
|
262
|
+
const trailer = last ? last.gap : "";
|
|
263
|
+
return finish(prefix, trailer, blocks, blockTokens, { ...tokens.links });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Returns a copy of `source` with the given blocks shown read-only. The editor
|
|
268
|
+
* calls this when its document could not hold a block faithfully.
|
|
269
|
+
*/
|
|
270
|
+
export function withReadOnlyBlocks(source: SourceMarkdown, ids: ReadonlySet<string>): SourceMarkdown {
|
|
271
|
+
const blocks = source.blocks.map((block) => (
|
|
272
|
+
block.editable && ids.has(block.id) ? { ...block, editable: false, kind: "markdown" as const } : block
|
|
273
|
+
));
|
|
274
|
+
return finish(source.prefix, source.trailer, blocks, source.tokens, source.links);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The whole document read-only: the last fallback when the editor cannot map it. */
|
|
278
|
+
export function asReadOnlyDocument(markdown: string): SourceMarkdown {
|
|
279
|
+
return wholeFileSource(markdown, `m${(documentCounter += 1).toString(36)}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* HTML for one block of Markdown, with no block ids, or null when the text is
|
|
284
|
+
* not a single editable block. `links` resolves reference links defined
|
|
285
|
+
* elsewhere in the file. Used to check that a merged block reads back as the
|
|
286
|
+
* reader's edit.
|
|
287
|
+
*/
|
|
288
|
+
export function blockHtml(markdown: string, links: SourceMarkdown["links"] = {}): string | null {
|
|
289
|
+
const lexer = new marked.Lexer({ gfm: true });
|
|
290
|
+
Object.assign(lexer.tokens.links, links);
|
|
291
|
+
const tokens = lexer.lex(markdown.replace(/\r\n?/g, "\n")).filter((t) => t.type !== "space");
|
|
292
|
+
if (tokens.length !== 1 || readOnlyKind(tokens[0]) !== null) return null;
|
|
293
|
+
return String(marked.parser(tokens, { async: false, gfm: true }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ------------------------------------------------------------------------ */
|
|
297
|
+
/* Turndown that writes blocks in their source's own style */
|
|
298
|
+
/* ------------------------------------------------------------------------ */
|
|
299
|
+
|
|
300
|
+
interface MarkdownStyle {
|
|
301
|
+
bullet: "-" | "*" | "+";
|
|
302
|
+
orderedDelimiter: "." | ")";
|
|
303
|
+
em: "*" | "_";
|
|
304
|
+
strong: "**" | "__";
|
|
305
|
+
fence: "```" | "~~~";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function styleOf(raw: string): MarkdownStyle {
|
|
309
|
+
const bullet = /^[ \t]*([*+-])[ \t]+\S/m.exec(raw)?.[1] as MarkdownStyle["bullet"] | undefined;
|
|
310
|
+
const ordered = /^[ \t]*\d{1,9}([.)])[ \t]+\S/m.exec(raw)?.[1] as MarkdownStyle["orderedDelimiter"] | undefined;
|
|
311
|
+
return {
|
|
312
|
+
bullet: bullet ?? "-",
|
|
313
|
+
orderedDelimiter: ordered ?? ".",
|
|
314
|
+
em: /(^|[^\w*_\\])_[^_\s]/.test(raw) && !/(^|[^\w*\\])\*[^*\s]/.test(raw) ? "_" : "*",
|
|
315
|
+
strong: /(^|[^\w\\])__[^_\s]/.test(raw) && !/\*\*[^*\s]/.test(raw) ? "__" : "**",
|
|
316
|
+
fence: /^[ \t]*~~~/m.test(raw) && !/^[ \t]*```/m.test(raw) ? "~~~" : "```",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const turndownCache = new Map<string, TurndownService>();
|
|
321
|
+
|
|
322
|
+
function turndownFor(style: MarkdownStyle): TurndownService {
|
|
323
|
+
const key = `${style.bullet}${style.orderedDelimiter}${style.em}${style.strong}${style.fence}`;
|
|
324
|
+
const cached = turndownCache.get(key);
|
|
325
|
+
if (cached) return cached;
|
|
326
|
+
const service = new TurndownService({
|
|
327
|
+
bulletListMarker: style.bullet,
|
|
328
|
+
codeBlockStyle: "fenced",
|
|
329
|
+
emDelimiter: style.em,
|
|
330
|
+
strongDelimiter: style.strong,
|
|
331
|
+
fence: style.fence,
|
|
332
|
+
headingStyle: "atx",
|
|
333
|
+
hr: "---",
|
|
334
|
+
});
|
|
335
|
+
// The editor wraps every list item's text in a paragraph. An item with one
|
|
336
|
+
// paragraph is a tight item, so its text sits on the marker's line with no
|
|
337
|
+
// blank line before a nested list.
|
|
338
|
+
service.addRule("listParagraph", {
|
|
339
|
+
filter: (node) =>
|
|
340
|
+
node.nodeName === "P" &&
|
|
341
|
+
node.parentNode?.nodeName === "LI" &&
|
|
342
|
+
Array.from((node.parentNode as HTMLElement).children).filter((child) => child.nodeName === "P").length === 1,
|
|
343
|
+
replacement: (content) => `\n${content}\n`,
|
|
344
|
+
});
|
|
345
|
+
// Turndown pads list markers to four columns ("- item"); a single space is
|
|
346
|
+
// what people and agents write, and it is what an unedited list keeps.
|
|
347
|
+
service.addRule("listItem", {
|
|
348
|
+
filter: "li",
|
|
349
|
+
replacement(content, node) {
|
|
350
|
+
const element = node as HTMLElement;
|
|
351
|
+
const parent = element.parentNode as HTMLElement | null;
|
|
352
|
+
let prefix = `${style.bullet} `;
|
|
353
|
+
if (parent?.nodeName === "OL") {
|
|
354
|
+
const start = parent.getAttribute("start");
|
|
355
|
+
const index = Array.prototype.indexOf.call(parent.children, element);
|
|
356
|
+
prefix = `${(start ? Number(start) : 1) + index}${style.orderedDelimiter} `;
|
|
357
|
+
}
|
|
358
|
+
const body = content.replace(/^\n+|\n+$/g, "");
|
|
359
|
+
// Continuation lines align under the text; blank lines stay empty.
|
|
360
|
+
const indented = body.replace(/\n(?=[^\n])/g, `\n${" ".repeat(prefix.length)}`);
|
|
361
|
+
return prefix + indented + (element.nextSibling ? "\n" : "");
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
service.addRule("strikethrough", {
|
|
365
|
+
filter: ["del", "s", "strike"] as Array<keyof HTMLElementTagNameMap>,
|
|
366
|
+
replacement: (content) => `~~${content}~~`,
|
|
367
|
+
});
|
|
368
|
+
turndownCache.set(key, service);
|
|
369
|
+
return service;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Markdown for one block of editor HTML, written in the style of `sourceRaw`. */
|
|
373
|
+
export function blockMarkdown(html: string, sourceRaw = ""): string {
|
|
374
|
+
return turndownFor(styleOf(sourceRaw)).turndown(html).replace(/^\n+|\n+$/g, "");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* ------------------------------------------------------------------------ */
|
|
378
|
+
/* Character diff and the three-way block merge */
|
|
379
|
+
/* ------------------------------------------------------------------------ */
|
|
380
|
+
|
|
381
|
+
/** -1: only in the first text; 0: in both; 1: only in the second text. */
|
|
382
|
+
export type DiffOp = [-1 | 0 | 1, string];
|
|
383
|
+
|
|
384
|
+
/** Edit cost past which a diff is abandoned, bounding the trace to a few MB. */
|
|
385
|
+
const MAX_DIFF_COST = 1200;
|
|
386
|
+
|
|
387
|
+
function pushOp(ops: DiffOp[], op: -1 | 0 | 1, text: string) {
|
|
388
|
+
if (!text) return;
|
|
389
|
+
const last = ops[ops.length - 1];
|
|
390
|
+
if (last && last[0] === op) last[1] += text;
|
|
391
|
+
else ops.push([op, text]);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Myers' O((N+M)D) diff of two strings with a common prefix and suffix removed. */
|
|
395
|
+
function myers(a: string, b: string, maxCost: number): DiffOp[] | null {
|
|
396
|
+
const n = a.length;
|
|
397
|
+
const m = b.length;
|
|
398
|
+
if (n === 0) return m ? [[1, b]] : [];
|
|
399
|
+
if (m === 0) return [[-1, a]];
|
|
400
|
+
const limit = Math.min(n + m, maxCost);
|
|
401
|
+
const offset = limit + 1;
|
|
402
|
+
const v = new Int32Array(2 * limit + 3);
|
|
403
|
+
v[offset + 1] = 0;
|
|
404
|
+
const trace: Int32Array[] = [];
|
|
405
|
+
let found = -1;
|
|
406
|
+
for (let d = 0; d <= limit && found < 0; d++) {
|
|
407
|
+
trace.push(v.slice(offset - d - 1, offset + d + 2));
|
|
408
|
+
for (let k = -d; k <= d; k += 2) {
|
|
409
|
+
let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
|
|
410
|
+
? v[offset + k + 1]
|
|
411
|
+
: v[offset + k - 1] + 1;
|
|
412
|
+
let y = x - k;
|
|
413
|
+
while (x < n && y < m && a.charCodeAt(x) === b.charCodeAt(y)) {
|
|
414
|
+
x++;
|
|
415
|
+
y++;
|
|
416
|
+
}
|
|
417
|
+
v[offset + k] = x;
|
|
418
|
+
if (x >= n && y >= m) {
|
|
419
|
+
found = d;
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (found < 0) return null;
|
|
425
|
+
|
|
426
|
+
// Walk the trace back from the end, collecting runs as index ranges.
|
|
427
|
+
const runs: Array<[-1 | 0 | 1, number, number]> = [];
|
|
428
|
+
let x = n;
|
|
429
|
+
let y = m;
|
|
430
|
+
for (let d = found; d > 0; d--) {
|
|
431
|
+
const snapshot = trace[d];
|
|
432
|
+
const at = (k: number) => snapshot[k + d + 1];
|
|
433
|
+
const k = x - y;
|
|
434
|
+
const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
|
|
435
|
+
const prevX = at(prevK);
|
|
436
|
+
const prevY = prevX - prevK;
|
|
437
|
+
// The snake runs from the move's end back to (x, y).
|
|
438
|
+
const moveEndX = prevK === k + 1 ? prevX : prevX + 1;
|
|
439
|
+
if (x > moveEndX) runs.push([0, moveEndX, x]);
|
|
440
|
+
if (prevK === k + 1) runs.push([1, prevY, prevY + 1]);
|
|
441
|
+
else runs.push([-1, prevX, prevX + 1]);
|
|
442
|
+
x = prevX;
|
|
443
|
+
y = prevY;
|
|
444
|
+
}
|
|
445
|
+
if (x > 0) runs.push([0, 0, x]);
|
|
446
|
+
|
|
447
|
+
const ops: DiffOp[] = [];
|
|
448
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
449
|
+
const [op, from, to] = runs[i];
|
|
450
|
+
pushOp(ops, op, op === 1 ? b.slice(from, to) : a.slice(from, to));
|
|
451
|
+
}
|
|
452
|
+
return ops;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Character diff from `a` to `b`, or null when it would cost more than the bound. */
|
|
456
|
+
export function diffText(a: string, b: string, maxCost = MAX_DIFF_COST): DiffOp[] | null {
|
|
457
|
+
let start = 0;
|
|
458
|
+
const shorter = Math.min(a.length, b.length);
|
|
459
|
+
while (start < shorter && a.charCodeAt(start) === b.charCodeAt(start)) start++;
|
|
460
|
+
let endA = a.length;
|
|
461
|
+
let endB = b.length;
|
|
462
|
+
while (endA > start && endB > start && a.charCodeAt(endA - 1) === b.charCodeAt(endB - 1)) {
|
|
463
|
+
endA--;
|
|
464
|
+
endB--;
|
|
465
|
+
}
|
|
466
|
+
const middle = myers(a.slice(start, endA), b.slice(start, endB), maxCost);
|
|
467
|
+
if (!middle) return null;
|
|
468
|
+
const ops: DiffOp[] = [];
|
|
469
|
+
pushOp(ops, 0, a.slice(0, start));
|
|
470
|
+
for (const [op, text] of middle) pushOp(ops, op, text);
|
|
471
|
+
pushOp(ops, 0, a.slice(endA));
|
|
472
|
+
return ops;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* For each boundary between characters of `canonical`, the matching boundary
|
|
477
|
+
* in `raw`, or -1 where the two texts disagree around it. A boundary that two
|
|
478
|
+
* matched runs both claim keeps the earlier one, so text typed at the end of a
|
|
479
|
+
* word lands before any syntax that only the source spells out.
|
|
480
|
+
*/
|
|
481
|
+
function boundaryMap(ops: DiffOp[], canonicalLength: number, rawLength: number): Int32Array {
|
|
482
|
+
const map = new Int32Array(canonicalLength + 1).fill(-1);
|
|
483
|
+
let c = 0;
|
|
484
|
+
let r = 0;
|
|
485
|
+
for (const [op, text] of ops) {
|
|
486
|
+
if (op === 0) {
|
|
487
|
+
for (let j = 0; j <= text.length; j++) {
|
|
488
|
+
if (map[c + j] < 0) map[c + j] = r + j;
|
|
489
|
+
}
|
|
490
|
+
c += text.length;
|
|
491
|
+
r += text.length;
|
|
492
|
+
} else if (op === -1) {
|
|
493
|
+
c += text.length;
|
|
494
|
+
} else {
|
|
495
|
+
r += text.length;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (map[0] < 0) map[0] = 0;
|
|
499
|
+
if (map[canonicalLength] < 0) map[canonicalLength] = rawLength;
|
|
500
|
+
return map;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Merges edits of one block into its original bytes. `raw` is the block as
|
|
505
|
+
* written; `canonical` is the Markdown of the node first parsed from it. The
|
|
506
|
+
* alignment of the two is computed once and reused for every later edit.
|
|
507
|
+
*/
|
|
508
|
+
export function createBlockMerger(raw: string, canonical: string): (edited: string) => string | null {
|
|
509
|
+
let map: Int32Array | null | undefined;
|
|
510
|
+
const alignment = () => {
|
|
511
|
+
if (map === undefined) {
|
|
512
|
+
const ops = diffText(canonical, raw);
|
|
513
|
+
map = ops ? boundaryMap(ops, canonical.length, raw.length) : null;
|
|
514
|
+
}
|
|
515
|
+
return map;
|
|
516
|
+
};
|
|
517
|
+
return (edited) => {
|
|
518
|
+
if (edited === canonical) return raw;
|
|
519
|
+
if (raw === canonical) return edited;
|
|
520
|
+
const edit = diffText(canonical, edited);
|
|
521
|
+
const boundaries = alignment();
|
|
522
|
+
if (!edit || !boundaries) return null;
|
|
523
|
+
|
|
524
|
+
let out = "";
|
|
525
|
+
let rawCursor = 0;
|
|
526
|
+
let c = 0;
|
|
527
|
+
let hunkStart = -1;
|
|
528
|
+
let inserted = "";
|
|
529
|
+
const flush = () => {
|
|
530
|
+
if (hunkStart < 0) return true;
|
|
531
|
+
const from = boundaries[hunkStart];
|
|
532
|
+
const to = boundaries[c];
|
|
533
|
+
if (from < 0 || to < 0 || to < from || from < rawCursor) return false;
|
|
534
|
+
out += raw.slice(rawCursor, from) + inserted;
|
|
535
|
+
rawCursor = to;
|
|
536
|
+
hunkStart = -1;
|
|
537
|
+
inserted = "";
|
|
538
|
+
return true;
|
|
539
|
+
};
|
|
540
|
+
for (const [op, text] of edit) {
|
|
541
|
+
if (op === 0) {
|
|
542
|
+
if (!flush()) return null;
|
|
543
|
+
c += text.length;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (hunkStart < 0) hunkStart = c;
|
|
547
|
+
if (op === -1) c += text.length;
|
|
548
|
+
else inserted += text;
|
|
549
|
+
}
|
|
550
|
+
if (!flush()) return null;
|
|
551
|
+
return out + raw.slice(rawCursor);
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Applies the reader's edit (`canonical` to `edited`) to the block's original
|
|
557
|
+
* bytes. Returns null when an edited span cannot be located in the original.
|
|
558
|
+
*/
|
|
559
|
+
export function mergeBlockEdit(raw: string, canonical: string, edited: string): string | null {
|
|
560
|
+
return createBlockMerger(raw, canonical)(edited);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/* ------------------------------------------------------------------------ */
|
|
564
|
+
/* Serialization */
|
|
565
|
+
/* ------------------------------------------------------------------------ */
|
|
566
|
+
|
|
567
|
+
/** One top-level node of the editor document, as the serializer needs it. */
|
|
568
|
+
export interface SourceNode {
|
|
569
|
+
/** The source block this node was parsed from; undefined for a node the reader added. */
|
|
570
|
+
block?: SourceBlock;
|
|
571
|
+
/** True while the node equals the node first parsed from its block. */
|
|
572
|
+
unchanged: boolean;
|
|
573
|
+
/** True while the node keeps the type first parsed from its block. */
|
|
574
|
+
sameType: boolean;
|
|
575
|
+
/** The node's Markdown as it stands. A read-only source node returns its raw text. */
|
|
576
|
+
markdown(): string;
|
|
577
|
+
/** Merges `edited` into the block's original bytes, or null when it cannot be located. */
|
|
578
|
+
merge(edited: string): string | null;
|
|
579
|
+
/** Whether `markdown` parses back into exactly this node. */
|
|
580
|
+
reparses(markdown: string): boolean;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
interface Piece {
|
|
584
|
+
text: string;
|
|
585
|
+
block?: SourceBlock;
|
|
586
|
+
keepsSeparator: boolean;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function withLineEnding(text: string, lineEnding: SourceMarkdown["lineEnding"]) {
|
|
590
|
+
return lineEnding === "\n" ? text : text.replace(/\r?\n/g, lineEnding);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function editedBlockText(node: SourceNode): string {
|
|
594
|
+
const edited = node.markdown();
|
|
595
|
+
const merged = node.merge(edited);
|
|
596
|
+
if (merged !== null && (merged === edited || node.reparses(merged))) return merged;
|
|
597
|
+
return edited;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Writes the editor document back as Markdown, keeping every untouched byte of `source`. */
|
|
601
|
+
export function serializeSourceMarkdown(source: SourceMarkdown, nodes: readonly SourceNode[]): string {
|
|
602
|
+
const pieces: Piece[] = [];
|
|
603
|
+
for (const node of nodes) {
|
|
604
|
+
const block = node.block;
|
|
605
|
+
let text: string;
|
|
606
|
+
if (block && (node.unchanged || !block.editable)) text = block.raw;
|
|
607
|
+
else if (block) text = withLineEnding(editedBlockText(node), source.lineEnding);
|
|
608
|
+
else text = withLineEnding(node.markdown(), source.lineEnding);
|
|
609
|
+
if (!text.trim()) continue;
|
|
610
|
+
pieces.push({ text, block, keepsSeparator: Boolean(block) && (node.unchanged || node.sameType) });
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
let out = source.prefix;
|
|
614
|
+
pieces.forEach((piece, i) => {
|
|
615
|
+
out += piece.text;
|
|
616
|
+
const next = pieces[i + 1];
|
|
617
|
+
if (!next) {
|
|
618
|
+
out += source.blocks.length ? source.trailer : source.lineEnding;
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const adjacent = piece.block && next.block && next.block.index === piece.block.index + 1;
|
|
622
|
+
out += adjacent && piece.keepsSeparator && next.keepsSeparator
|
|
623
|
+
? piece.block!.gap
|
|
624
|
+
: source.lineEnding + source.lineEnding;
|
|
625
|
+
});
|
|
626
|
+
return out;
|
|
627
|
+
}
|
|
@@ -153,7 +153,9 @@ describe("FileArtifactPane previews", () => {
|
|
|
153
153
|
/>,
|
|
154
154
|
);
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
// The rich editor lazy-loads its editor packages; under a loaded host that takes
|
|
157
|
+
// longer than findBy's 1s default.
|
|
158
|
+
expect(await screen.findByRole("link", { name: "Sibling" }, { timeout: 10_000 })).toHaveAttribute(
|
|
157
159
|
"href",
|
|
158
160
|
"/app/ws-1/vault?file=docs%2Fsibling.md",
|
|
159
161
|
);
|
|
@@ -252,7 +254,7 @@ describe("FileArtifactPane controlled drafts", () => {
|
|
|
252
254
|
editor: { enabled: true, savedContent: "# Saved notes", onSave },
|
|
253
255
|
};
|
|
254
256
|
const view = render(<FileArtifactPane {...props} />);
|
|
255
|
-
const save = await screen.findByRole("button", { name: "Save changes" });
|
|
257
|
+
const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
|
|
256
258
|
expect(screen.getByRole("heading", { name: "Unsaved source draft" })).toBeVisible();
|
|
257
259
|
expect(screen.getByText("Unsaved changes")).toBeVisible();
|
|
258
260
|
expect(save).toBeEnabled();
|
|
@@ -260,7 +262,40 @@ describe("FileArtifactPane controlled drafts", () => {
|
|
|
260
262
|
expect(onSave).toHaveBeenCalledWith(draft);
|
|
261
263
|
|
|
262
264
|
view.rerender(<FileArtifactPane {...props} editor={{ ...props.editor, savedContent: draft }} />);
|
|
263
|
-
|
|
265
|
+
// One save state: "Saved", with no Save button left to press.
|
|
266
|
+
expect(screen.getByRole("status")).toHaveTextContent("Saved");
|
|
267
|
+
expect(screen.queryByRole("button", { name: "Save changes" })).toBeNull();
|
|
268
|
+
expect(screen.queryByText("Local draft")).toBeNull();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("follows the host's dirty flag over its own comparison, and saves on Ctrl+S", async () => {
|
|
272
|
+
const onSave = vi.fn();
|
|
273
|
+
const content = "# Same text the host saved";
|
|
274
|
+
const props = { filename: "notes.md", content, editor: { enabled: true, dirty: true, onSave } };
|
|
275
|
+
const view = render(<FileArtifactPane {...props} />);
|
|
276
|
+
const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
|
|
277
|
+
expect(save).toBeEnabled();
|
|
278
|
+
expect(screen.getByRole("status")).toHaveTextContent("Unsaved changes");
|
|
279
|
+
|
|
280
|
+
fireEvent.keyDown(save, { key: "s", ctrlKey: true });
|
|
281
|
+
expect(onSave).toHaveBeenCalledWith(content);
|
|
282
|
+
|
|
283
|
+
view.rerender(<FileArtifactPane {...props} editor={{ ...props.editor, dirty: false }} />);
|
|
284
|
+
expect(screen.getByRole("status")).toHaveTextContent("Saved");
|
|
285
|
+
fireEvent.keyDown(screen.getByRole("status"), { key: "s", metaKey: true });
|
|
286
|
+
expect(onSave).toHaveBeenCalledTimes(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("shows Saving while the host saves", async () => {
|
|
290
|
+
render(
|
|
291
|
+
<FileArtifactPane
|
|
292
|
+
filename="notes.md"
|
|
293
|
+
content="# Draft"
|
|
294
|
+
editor={{ enabled: true, dirty: true, saving: true, onSave: vi.fn() }}
|
|
295
|
+
/>,
|
|
296
|
+
);
|
|
297
|
+
const save = await screen.findByRole("button", { name: "Save changes" }, { timeout: 10_000 });
|
|
264
298
|
expect(save).toBeDisabled();
|
|
299
|
+
expect(screen.getByRole("status")).toHaveTextContent("Saving…");
|
|
265
300
|
});
|
|
266
301
|
});
|