@helping-ai-workflow/md2doc 3.17.2 → 3.17.4
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/lib/editor/blockmap.js +65 -0
- package/lib/editor/client.js +1861 -154
- package/lib/editor/code-edit.js +6 -5
- package/lib/editor/convert-md.js +9 -2
- package/lib/editor/dot-preview.js +65 -0
- package/lib/editor/dot-worker.js +14 -0
- package/lib/editor/inline-md.js +40 -5
- package/lib/editor/list-md.js +143 -47
- package/lib/editor/server.js +52 -3
- package/lib/editor/wave-ui.js +9 -0
- package/lib/md2doc.js +46 -6
- package/lib/theme/tokens.js +5 -1
- package/package.json +1 -1
package/lib/editor/code-edit.js
CHANGED
|
@@ -123,8 +123,8 @@ function newlineWithIndent(text, pos) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// D3 Tab / Shift+Tab over a selection (R42). Touched lines are those that
|
|
126
|
-
// intersect [selStart, selEnd]; when the selection is not collapsed
|
|
127
|
-
//
|
|
126
|
+
// intersect [selStart, selEnd]; when the selection is not collapsed, a line it
|
|
127
|
+
// only touches at an edge (it starts at the line's end, or ends at column 0) is not touched. Indent adds two
|
|
128
128
|
// spaces; dedent removes up to two leading spaces. A position on a touched
|
|
129
129
|
// line keeps its column relative to the line's text (indent: a column-0
|
|
130
130
|
// position moves with the text, unlike VS Code; dedent: clamps at the line
|
|
@@ -136,9 +136,10 @@ function indentLines(text, selStart, selEnd, dedent) {
|
|
|
136
136
|
for (const l of lines) { starts.push(off); off += l.length + 1; }
|
|
137
137
|
const touched = lines.map((l, i) => {
|
|
138
138
|
const s = starts[i], e = s + l.length;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return
|
|
139
|
+
// v3.17.3 C3: a selection that starts exactly at a line's end, or ends at
|
|
140
|
+
// its start, does not touch that line; a collapsed caret touches its own.
|
|
141
|
+
if (selEnd === selStart) return s <= selStart && e >= selStart;
|
|
142
|
+
return s < selEnd && e > selStart;
|
|
142
143
|
});
|
|
143
144
|
const delta = lines.map((l, i) => {
|
|
144
145
|
if (!touched[i]) return 0;
|
package/lib/editor/convert-md.js
CHANGED
|
@@ -154,6 +154,13 @@ function listAttrsFor(target) {
|
|
|
154
154
|
return null;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// A run of hash characters at the very end of a heading's text, set off by a
|
|
158
|
+
// space (or the whole text), reads as the ATX closing sequence and is dropped
|
|
159
|
+
// on the next parse; its first character is written escaped to keep it text.
|
|
160
|
+
function escapeClosingHashes(md) {
|
|
161
|
+
return md.replace(/(^|[ \t])(#+)([ \t]*)$/, '$1\\$2$3');
|
|
162
|
+
}
|
|
163
|
+
|
|
157
164
|
function emitAs(content, target, opts) {
|
|
158
165
|
const o = opts || {};
|
|
159
166
|
const prefix = o.indentPrefix || '';
|
|
@@ -170,7 +177,7 @@ function emitAs(content, target, opts) {
|
|
|
170
177
|
const text = body.length > 1 ? body.map((l) => l.trim()).join(' ') : body.join(' ');
|
|
171
178
|
// An EMPTY heading emits no trailing space, or converting '##' to a
|
|
172
179
|
// heading of the same level would rewrite the line to '## '.
|
|
173
|
-
return [text === '' ? prefix + hashes : prefix + hashes + ' ' + text];
|
|
180
|
+
return [text === '' ? prefix + hashes : prefix + hashes + ' ' + escapeClosingHashes(text)];
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
if (target === 'quote') return body.map((l) => prefix + '> ' + l);
|
|
@@ -203,5 +210,5 @@ function emitAs(content, target, opts) {
|
|
|
203
210
|
return body.slice();
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
return { CONVERT_TARGETS, stripMarker, emitAs, targetIsList, listAttrsFor };
|
|
213
|
+
return { CONVERT_TARGETS, stripMarker, emitAs, targetIsList, listAttrsFor, escapeClosingHashes };
|
|
207
214
|
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A kept-alive worker thread for the Graphviz preview. Created on the first
|
|
3
|
+
// preview, reused for every later one. An engine error comes back as an
|
|
4
|
+
// ordinary Error (the server answers 422 with its first line); a worker that
|
|
5
|
+
// dies takes its in-flight requests down with an Error flagged `crashed`
|
|
6
|
+
// (the server answers 500), and the next request starts a fresh worker.
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { Worker } = require('worker_threads');
|
|
9
|
+
|
|
10
|
+
function createDotPreview() {
|
|
11
|
+
let worker = null;
|
|
12
|
+
let seq = 0;
|
|
13
|
+
const pending = new Map();
|
|
14
|
+
|
|
15
|
+
// Only the requests that belong to `w` die with it: a worker that was
|
|
16
|
+
// closed or replaced reports its exit late, after a new one took over.
|
|
17
|
+
function failAll(err, w) {
|
|
18
|
+
for (const [id, p] of pending) {
|
|
19
|
+
if (w !== undefined && p.worker !== w) continue;
|
|
20
|
+
p.reject(err);
|
|
21
|
+
pending.delete(id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function start() {
|
|
25
|
+
const w = new Worker(path.join(__dirname, 'dot-worker.js'));
|
|
26
|
+
// An idle worker must not keep the process (or a test) alive.
|
|
27
|
+
w.unref();
|
|
28
|
+
w.on('message', (m) => {
|
|
29
|
+
const p = pending.get(m.id);
|
|
30
|
+
if (!p) return;
|
|
31
|
+
pending.delete(m.id);
|
|
32
|
+
if (m.error !== undefined) p.reject(new Error(m.error));
|
|
33
|
+
else p.resolve(m.svg);
|
|
34
|
+
});
|
|
35
|
+
const dead = (err) => {
|
|
36
|
+
if (worker === w) worker = null;
|
|
37
|
+
const e = new Error(String((err && err.message) || 'graphviz worker exited'));
|
|
38
|
+
e.crashed = true;
|
|
39
|
+
failAll(e, w);
|
|
40
|
+
};
|
|
41
|
+
w.on('error', dead);
|
|
42
|
+
w.on('exit', () => dead(null));
|
|
43
|
+
return w;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
render(source) {
|
|
47
|
+
if (worker === null) worker = start();
|
|
48
|
+
const id = ++seq;
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
pending.set(id, { resolve, reject, worker });
|
|
51
|
+
// A request in flight keeps the loop alive until it answers.
|
|
52
|
+
worker.ref();
|
|
53
|
+
worker.postMessage({ id, source });
|
|
54
|
+
}).finally(() => { if (pending.size === 0 && worker !== null) worker.unref(); });
|
|
55
|
+
},
|
|
56
|
+
close() {
|
|
57
|
+
const w = worker;
|
|
58
|
+
worker = null;
|
|
59
|
+
if (w !== null) w.terminate();
|
|
60
|
+
failAll(Object.assign(new Error('graphviz worker closed'), { crashed: true }));
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { createDotPreview };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Worker-thread entry for the stateless /api/preview: lays a dot source out
|
|
3
|
+
// with the same renderDotSvg the bake uses, so a large graph does not block
|
|
4
|
+
// the editor server's event loop (and with it /api/ping and every save).
|
|
5
|
+
const { parentPort } = require('worker_threads');
|
|
6
|
+
const { renderDotSvg } = require('../md2doc.js');
|
|
7
|
+
|
|
8
|
+
parentPort.on('message', async (m) => {
|
|
9
|
+
try {
|
|
10
|
+
parentPort.postMessage({ id: m.id, svg: await renderDotSvg(m.source) });
|
|
11
|
+
} catch (e) {
|
|
12
|
+
parentPort.postMessage({ id: m.id, error: String((e && e.message) || e) });
|
|
13
|
+
}
|
|
14
|
+
});
|
package/lib/editor/inline-md.js
CHANGED
|
@@ -517,13 +517,48 @@
|
|
|
517
517
|
return { s, altStart, altEnd, destStart, destEnd, angle, title };
|
|
518
518
|
}
|
|
519
519
|
|
|
520
|
+
// v3.17.3 A3: marked keeps a backslash before anything but [ and ] in an
|
|
521
|
+
// image's alt (measured, marked 14: `a\`b` renders alt a\`b, `x\\y` renders
|
|
522
|
+
// x\\y), and an unbalanced backtick in the brackets turns the whole image
|
|
523
|
+
// into a code span. So only [ and ] are backslash-escaped; the backtick,
|
|
524
|
+
// the ampersand and the backslash are written as the character references
|
|
525
|
+
// the browser reads back as the same character. The reader side below
|
|
526
|
+
// undoes those, plus the backslash escapes older files hold.
|
|
527
|
+
// Final review M2 (R25 / R7), widened by v3.17.4 E1 (R10): every character
|
|
528
|
+
// reference the reader decodes — marked passes them into the alt attribute
|
|
529
|
+
// untouched and the browser decodes them — so the field shows what the
|
|
530
|
+
// reader shows. A decimal or hex number needs no table. A name goes through
|
|
531
|
+
// the installed decoder (the client installs one that asks the page itself,
|
|
532
|
+
// so every name the browser knows decodes); with none installed, the six
|
|
533
|
+
// names amp, lt, gt, quot, apos and nbsp. A name the decoder does not know
|
|
534
|
+
// stays as written in the field, and is written so that it renders as that
|
|
535
|
+
// text. An edited alt is written as the decoded character itself.
|
|
536
|
+
const ALT_ESCAPE = { '[': '\\[', ']': '\\]', '&': '&', '`': '`', '\\': '\' };
|
|
537
|
+
const ALT_NAMED_CHAR = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
|
|
538
|
+
const defaultNamedReference = (name) => ALT_NAMED_CHAR[name];
|
|
539
|
+
let namedReference = defaultNamedReference;
|
|
540
|
+
function setNamedReferenceDecoder(fn) { namedReference = typeof fn === 'function' ? fn : defaultNamedReference; }
|
|
541
|
+
const ALT_UNESCAPE = /\\([!-\/:-@\[-`{-~])|&(?:#([0-9]+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));/g;
|
|
542
|
+
// A number the HTML parser would not give as itself (zero, a surrogate,
|
|
543
|
+
// past U+10FFFF) is U+FFFD there, so it is here too.
|
|
544
|
+
function altCodePoint(n) {
|
|
545
|
+
return n === 0 || n > 0x10ffff || (n >= 0xd800 && n <= 0xdfff) ? '\ufffd' : String.fromCodePoint(n);
|
|
546
|
+
}
|
|
547
|
+
function altUnescape(m, c, dec, hex, name) {
|
|
548
|
+
if (c !== undefined) return c;
|
|
549
|
+
if (dec !== undefined) return altCodePoint(parseInt(dec, 10));
|
|
550
|
+
if (hex !== undefined) return altCodePoint(parseInt(hex, 16));
|
|
551
|
+
const r = namedReference(name);
|
|
552
|
+
return typeof r === 'string' ? r : m;
|
|
553
|
+
}
|
|
554
|
+
|
|
520
555
|
function parseImageParagraph(text) {
|
|
521
556
|
const f = scanImage(text);
|
|
522
557
|
if (!f) return null;
|
|
523
558
|
const rawAlt = f.s.slice(f.altStart, f.altEnd);
|
|
524
559
|
const rawDest = f.s.slice(f.destStart, f.destEnd);
|
|
525
560
|
return {
|
|
526
|
-
alt: rawAlt.replace(
|
|
561
|
+
alt: rawAlt.replace(ALT_UNESCAPE, altUnescape),
|
|
527
562
|
dest: f.angle ? rawDest.slice(1, -1) : rawDest,
|
|
528
563
|
title: f.title,
|
|
529
564
|
angle: f.angle,
|
|
@@ -531,20 +566,20 @@
|
|
|
531
566
|
}
|
|
532
567
|
|
|
533
568
|
// Alt and/or destination replaced, everything else kept. `alt` is plain text
|
|
534
|
-
// (the
|
|
535
|
-
//
|
|
569
|
+
// (the characters that would end or bend the brackets are escaped, see
|
|
570
|
+
// ALT_ESCAPE); `dest` is written as given, so the caller makes it
|
|
536
571
|
// markdown-ready — an angle-bracketed original loses its brackets.
|
|
537
572
|
function rewriteImage(text, patch) {
|
|
538
573
|
const f = scanImage(text);
|
|
539
574
|
if (!f) return String(text == null ? '' : text);
|
|
540
575
|
const p = patch || {};
|
|
541
576
|
let out = f.s.slice(0, f.altStart);
|
|
542
|
-
out += typeof p.alt === 'string' ? p.alt.replace(/[\\\[\]]/g,
|
|
577
|
+
out += typeof p.alt === 'string' ? p.alt.replace(/[\\\[\]&`]/g, (c) => ALT_ESCAPE[c]) : f.s.slice(f.altStart, f.altEnd);
|
|
543
578
|
out += f.s.slice(f.altEnd, f.destStart);
|
|
544
579
|
out += typeof p.dest === 'string' ? p.dest : f.s.slice(f.destStart, f.destEnd);
|
|
545
580
|
out += f.s.slice(f.destEnd);
|
|
546
581
|
return out;
|
|
547
582
|
}
|
|
548
583
|
|
|
549
|
-
return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage };
|
|
584
|
+
return { serializeInline, canWysiwyg, escapeText, escapeBlockStarts, normalizeHref, isPastedUrl, parseImageParagraph, rewriteImage, setNamedReferenceDecoder };
|
|
550
585
|
});
|
package/lib/editor/list-md.js
CHANGED
|
@@ -304,18 +304,9 @@
|
|
|
304
304
|
// per block is what let the per-li degrade path address a different item's
|
|
305
305
|
// line. See the continuation-column note in the emission below.
|
|
306
306
|
//
|
|
307
|
-
// RUN (spec §3.8):
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
// `counters`, indexed by depth, with everything deeper than the current
|
|
311
|
-
// depth cleared whenever the walk comes back up.
|
|
312
|
-
//
|
|
313
|
-
// Rule (b) compares against the last block seen AT THAT DEPTH (`types`),
|
|
314
|
-
// NOT against the immediately previous block: with a deeper item sitting
|
|
315
|
-
// between two same-depth blocks, comparing against `prev` makes the
|
|
316
|
-
// list-type change invisible and the ordinal never restarts. The md then
|
|
317
|
-
// re-lexes as <ol start="3">, §3.8 discards `start`, and the next commit
|
|
318
|
-
// renumbers to '1.' — the document oscillates between two states forever.
|
|
307
|
+
// RUN (spec §3.8): the rules and their bookkeeping are planRuns()'s, below
|
|
308
|
+
// (v3.17.3 moved them there so a run's marker style is known before its
|
|
309
|
+
// first item is written).
|
|
319
310
|
//
|
|
320
311
|
// Same DOM-API constraint as the rest of this module, plus
|
|
321
312
|
// `classList.contains` (state classes are token-matched, never compared as
|
|
@@ -363,16 +354,115 @@
|
|
|
363
354
|
// `prefix` is what §3.4's colDelta measures the OLD side against, so it comes
|
|
364
355
|
// back as literal text rather than a count — a caller that finds a TAB in it
|
|
365
356
|
// knows the column arithmetic is not exact and can decline.
|
|
366
|
-
const SRC_MARKER_RE = /^([ \t]*)(
|
|
357
|
+
const SRC_MARKER_RE = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)(?:\[[ xX]\][ \t]+)?/;
|
|
358
|
+
// `style` is the LAST marker's bullet character or ordinal delimiter ('-',
|
|
359
|
+
// '*', '+', '.', ')') — the item's own, on a same-line nest — or null.
|
|
367
360
|
function splitSourceMarkers(line) {
|
|
368
361
|
const text = typeof line === 'string' ? line : '';
|
|
369
362
|
let at = 0;
|
|
363
|
+
let style = null;
|
|
370
364
|
for (;;) {
|
|
371
365
|
const m = SRC_MARKER_RE.exec(text.slice(at));
|
|
372
366
|
if (!m) break;
|
|
373
367
|
at += m[0].length;
|
|
368
|
+
style = m[2].charAt(m[2].length - 1);
|
|
374
369
|
}
|
|
375
|
-
return { prefix: text.slice(0, at), content: text.slice(at) };
|
|
370
|
+
return { prefix: text.slice(0, at), content: text.slice(at), style: style };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── v3.17.3 A9 (R5): a run is written with ITS OWN marker ───────────────
|
|
374
|
+
// A bullet list may use '-', '*' or '+', an ordered one '.' or ')', and
|
|
375
|
+
// CommonMark starts a NEW list at a change of either. Writing the canonical
|
|
376
|
+
// '- ' / 'n. ' for every item turned `* a\n* b` into `- a\n- bX` on an edit
|
|
377
|
+
// to b — a in the same commit — which rewrote lines nobody touched (README
|
|
378
|
+
// promises only touched lines change).
|
|
379
|
+
//
|
|
380
|
+
// So every run (the RUN rule below: one list at one depth, as marked sees
|
|
381
|
+
// it — `- a` and `* c` side by side are two lists, data-list-start keeps
|
|
382
|
+
// them apart) is written with one style, chosen by its items' SOURCE
|
|
383
|
+
// markers in three tiers, the first tier with any vote deciding (within a
|
|
384
|
+
// tier the majority, ties going to the earliest):
|
|
385
|
+
// 1. the run's own CARRIED items (opts.carryOver: bytes replayed, so the
|
|
386
|
+
// marker on their first line is the list's own);
|
|
387
|
+
// 2. its re-serialised items, by what opts.sourceMarker(blockEl) answers
|
|
388
|
+
// (client.js reads it off the file — the edited item, say);
|
|
389
|
+
// 3. items opts.foreign(blockEl) names — moved in from another list by
|
|
390
|
+
// the gesture (client.js's moveSpanAcrossLists()). Their old marker is
|
|
391
|
+
// another list's, so it counts only for a run made of nothing else (a
|
|
392
|
+
// list moved whole keeps its style); otherwise a moved item takes the
|
|
393
|
+
// run's. Without the tiers a `* moved` dropped into `- alpha` won the
|
|
394
|
+
// 1:1 tie and rewrote the untouched item (review I1, measured).
|
|
395
|
+
// An item with no source at all (one Enter just made) votes for nothing and
|
|
396
|
+
// takes the run's style; a run where no item has one is written
|
|
397
|
+
// canonically. A style the run's type cannot carry (a '*' source on an item
|
|
398
|
+
// converted to ordered) is not a vote.
|
|
399
|
+
const UL_STYLES = '-*+';
|
|
400
|
+
const OL_STYLES = '.)';
|
|
401
|
+
function styleFits(style, listType) {
|
|
402
|
+
return typeof style === 'string' && style.length === 1 &&
|
|
403
|
+
(listType === 'ol' ? OL_STYLES : UL_STYLES).indexOf(style) !== -1;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// The run bookkeeping, done once ahead of the emission so a run's style is
|
|
407
|
+
// known before its first item is written (that item may be the one being
|
|
408
|
+
// re-serialised, with no source of its own). Returns, per block, null (not
|
|
409
|
+
// a li) or { run, ordinal }. `firstOrdinal` is the number the FIRST run
|
|
410
|
+
// starts at (default 1; client.js's convert passes the number a joined list
|
|
411
|
+
// continues from, v3.17.3 A7) — every later run restarts at 1.
|
|
412
|
+
//
|
|
413
|
+
// RUN (spec §3.8): a run breaks at a shallower li, at a same-depth li whose
|
|
414
|
+
// data-list-type differs, or at a non-li block. Deeper items never break
|
|
415
|
+
// the run they are nested under. Every run's ordinal restarts at 1 — hence
|
|
416
|
+
// `counters`, indexed by depth, with everything deeper than the current
|
|
417
|
+
// depth cleared whenever the walk comes back up.
|
|
418
|
+
//
|
|
419
|
+
// Rule (b) compares against the last block seen AT THAT DEPTH (`types`),
|
|
420
|
+
// NOT against the immediately previous block: with a deeper item sitting
|
|
421
|
+
// between two same-depth blocks, comparing against `prev` makes the
|
|
422
|
+
// list-type change invisible and the ordinal never restarts. The md then
|
|
423
|
+
// re-lexes as <ol start="3">, §3.8 discards `start`, and the next commit
|
|
424
|
+
// renumbers to '1.' — the document oscillates between two states forever.
|
|
425
|
+
//
|
|
426
|
+
// Rule (d) is the fourth reset, and rules (a)-(c) cannot derive it:
|
|
427
|
+
// ' 1. x' followed by ' 1) y' is TWO nested list tokens (a delimiter
|
|
428
|
+
// change starts a new list) at the SAME depth with the SAME type, so
|
|
429
|
+
// without this the second list is renumbered as a continuation of the
|
|
430
|
+
// first ('2. y') and a commit rewrites a list the user never touched.
|
|
431
|
+
// lib/md2doc.js stamps the attribute; only the renderer still knows
|
|
432
|
+
// where marked's token boundaries were.
|
|
433
|
+
function planRuns(els, firstOrdinal) {
|
|
434
|
+
const plan = [];
|
|
435
|
+
const counters = [];
|
|
436
|
+
const types = [];
|
|
437
|
+
const runAt = [];
|
|
438
|
+
let nextRun = 0;
|
|
439
|
+
let prev = null;
|
|
440
|
+
let first = true;
|
|
441
|
+
els.forEach((blockEl) => {
|
|
442
|
+
if (blockEl.getAttribute('data-block-type') !== 'li') { plan.push(null); return; }
|
|
443
|
+
const indent = Number(blockEl.getAttribute('data-indent')) || 0;
|
|
444
|
+
const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
|
|
445
|
+
const isListStart = blockEl.getAttribute('data-list-start') === '1';
|
|
446
|
+
// Going DEEPER opens a brand-new run at that depth (even when the type
|
|
447
|
+
// matches); a list-type change against the last block AT THIS DEPTH
|
|
448
|
+
// closes that run and opens another; coming back UP leaves this depth's
|
|
449
|
+
// run open (deeper items did not break it) but clears every deeper
|
|
450
|
+
// depth so the next descent restarts at 1.
|
|
451
|
+
if (isListStart || !prev || indent > prev.indent || types[indent] !== listType) {
|
|
452
|
+
counters[indent] = first ? firstOrdinal - 1 : 0;
|
|
453
|
+
runAt[indent] = nextRun++;
|
|
454
|
+
}
|
|
455
|
+
first = false;
|
|
456
|
+
for (let k = counters.length - 1; k > indent; k--) {
|
|
457
|
+
counters[k] = 0;
|
|
458
|
+
types[k] = undefined;
|
|
459
|
+
}
|
|
460
|
+
types[indent] = listType;
|
|
461
|
+
counters[indent] = (counters[indent] || 0) + 1;
|
|
462
|
+
plan.push({ run: runAt[indent], ordinal: counters[indent] });
|
|
463
|
+
prev = { indent: indent };
|
|
464
|
+
});
|
|
465
|
+
return plan;
|
|
376
466
|
}
|
|
377
467
|
|
|
378
468
|
function spaces(n) {
|
|
@@ -460,6 +550,36 @@
|
|
|
460
550
|
// `blank: true`, so
|
|
461
551
|
// `lineMeta.length === md.split('\n').length` still holds.
|
|
462
552
|
const blankBefore = (opts && typeof opts.blankBefore === 'function') ? opts.blankBefore : null;
|
|
553
|
+
const sourceMarker = (opts && typeof opts.sourceMarker === 'function') ? opts.sourceMarker : null;
|
|
554
|
+
const foreign = (opts && typeof opts.foreign === 'function') ? opts.foreign : null;
|
|
555
|
+
const firstOrdinal = (opts && Number(opts.firstOrdinal) >= 1) ? Math.floor(Number(opts.firstOrdinal)) : 1;
|
|
556
|
+
const els = toArray(blockEls);
|
|
557
|
+
const plan = planRuns(els, firstOrdinal);
|
|
558
|
+
// v3.17.3 A9: each run's style, by the tiered vote described at UL_STYLES.
|
|
559
|
+
// votes[run][tier] = [{ style, n }] in first-seen order.
|
|
560
|
+
const votes = {};
|
|
561
|
+
els.forEach((blockEl, i) => {
|
|
562
|
+
if (!plan[i]) return;
|
|
563
|
+
const id = blockEl.getAttribute('data-block-id');
|
|
564
|
+
const carried = carryOver && id !== null && carryOver[id];
|
|
565
|
+
const style = carried && carried.length ? splitSourceMarkers(carried[0]).style
|
|
566
|
+
: (sourceMarker ? sourceMarker(blockEl) : null);
|
|
567
|
+
const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
|
|
568
|
+
if (!styleFits(style, listType)) return;
|
|
569
|
+
const tier = foreign && foreign(blockEl) ? 2 : (carried && carried.length ? 0 : 1);
|
|
570
|
+
const byTier = votes[plan[i].run] || (votes[plan[i].run] = [[], [], []]);
|
|
571
|
+
const v = byTier[tier];
|
|
572
|
+
const hit = v.find((x) => x.style === style);
|
|
573
|
+
if (hit) hit.n++;
|
|
574
|
+
else v.push({ style: style, n: 1 });
|
|
575
|
+
});
|
|
576
|
+
const runStyle = {};
|
|
577
|
+
Object.keys(votes).forEach((r) => {
|
|
578
|
+
const v = votes[r].find((t) => t.length > 0);
|
|
579
|
+
let best = null;
|
|
580
|
+
v.forEach((x) => { if (!best || x.n > best.n) best = x; });
|
|
581
|
+
runStyle[r] = best.style;
|
|
582
|
+
});
|
|
463
583
|
let prevItemEl = null;
|
|
464
584
|
const unsupported = [];
|
|
465
585
|
const unsupportedByLi = [];
|
|
@@ -467,13 +587,8 @@
|
|
|
467
587
|
const lineMeta = [];
|
|
468
588
|
const lines = [];
|
|
469
589
|
// widths[k] = the marker width (as a literal run of spaces) of the
|
|
470
|
-
// innermost item seen at depth k
|
|
471
|
-
// the run currently open at depth k.
|
|
590
|
+
// innermost item seen at depth k. The ordinals are planRuns()'s.
|
|
472
591
|
const widths = [];
|
|
473
|
-
const counters = [];
|
|
474
|
-
// types[k] = the list type of the last block seen at depth k, so rule (b)
|
|
475
|
-
// survives a deeper item sitting between two same-depth blocks.
|
|
476
|
-
const types = [];
|
|
477
592
|
let prev = null; // { indent, listType }
|
|
478
593
|
|
|
479
594
|
// ── Round 6: a CONTENT-FREE TASK item cannot be given a line of its own ──
|
|
@@ -522,7 +637,7 @@
|
|
|
522
637
|
return line.slice(k);
|
|
523
638
|
}
|
|
524
639
|
|
|
525
|
-
|
|
640
|
+
els.forEach((blockEl, blockIdx) => {
|
|
526
641
|
const blockId = blockEl.getAttribute('data-block-id');
|
|
527
642
|
|
|
528
643
|
// §3.8 rule (c): a non-li block terminates a run. It has no marker and
|
|
@@ -546,40 +661,21 @@
|
|
|
546
661
|
if (pending && indent <= pending.indent) flushPending();
|
|
547
662
|
const listType = blockEl.getAttribute('data-list-type') === 'ol' ? 'ol' : 'ul';
|
|
548
663
|
const isTask = blockEl.getAttribute('data-task') === '1';
|
|
549
|
-
// §3.8
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
// Run bookkeeping. Going DEEPER opens a brand-new run at that depth
|
|
553
|
-
// (even when the type matches); a list-type change against the last
|
|
554
|
-
// block AT THIS DEPTH closes that run and opens another; coming back
|
|
555
|
-
// UP leaves this depth's run open (deeper items did not break it) but
|
|
556
|
-
// clears every deeper depth so the next descent restarts at 1.
|
|
557
|
-
//
|
|
558
|
-
// Rule (d) is the fourth reset, and rules (a)-(c) cannot derive it:
|
|
559
|
-
// ' 1. x' followed by ' 1) y' is TWO nested list tokens (a delimiter
|
|
560
|
-
// change starts a new list) at the SAME depth with the SAME type, so
|
|
561
|
-
// without this the second list is renumbered as a continuation of the
|
|
562
|
-
// first ('2. y') and a commit rewrites a list the user never touched.
|
|
563
|
-
// lib/md2doc.js stamps the attribute; only the renderer still knows
|
|
564
|
-
// where marked's token boundaries were.
|
|
565
|
-
if (isListStart || !prev || indent > prev.indent || types[indent] !== listType) {
|
|
566
|
-
counters[indent] = 0;
|
|
567
|
-
}
|
|
568
|
-
for (let k = counters.length - 1; k > indent; k--) {
|
|
569
|
-
counters[k] = 0;
|
|
570
|
-
types[k] = undefined;
|
|
571
|
-
}
|
|
572
|
-
types[indent] = listType;
|
|
664
|
+
// §3.8's run bookkeeping (rules (a)-(d)) is planRuns()'s, above.
|
|
665
|
+
const slot = plan[blockIdx];
|
|
573
666
|
|
|
574
667
|
const indentPrefix = widths.slice(0, indent).join('');
|
|
575
|
-
counters[indent] = (counters[indent] || 0) + 1;
|
|
576
668
|
|
|
577
669
|
const checkEl = firstChildWithClass(blockEl, 'ed-li-check');
|
|
578
670
|
const isChecked = checkEl ? checkEl.getAttribute('data-checked') === '1' : false;
|
|
579
671
|
// Two-part marker: the bullet (ordered ordinal or '- ') and an
|
|
580
672
|
// optional checkbox, kept independent so an ordered task list gets
|
|
581
673
|
// BOTH ('1. [ ] todo') — RULING F-N, same as serializeList above.
|
|
582
|
-
|
|
674
|
+
// v3.17.3 A9: in the run's own style ('* ', '1) '); every style is one
|
|
675
|
+
// character, so the bullet's width — which `widths` and the child
|
|
676
|
+
// indent are computed from — is the canonical one's.
|
|
677
|
+
const style = runStyle[slot.run];
|
|
678
|
+
const bullet = listType === 'ol' ? (slot.ordinal + (style || '.') + ' ') : ((style || '-') + ' ');
|
|
583
679
|
const marker = isTask ? bullet + (isChecked ? '[x] ' : '[ ] ') : bullet;
|
|
584
680
|
|
|
585
681
|
const textEl = firstChildWithClass(blockEl, 'ed-li-text');
|
package/lib/editor/server.js
CHANGED
|
@@ -4,7 +4,8 @@ const path = require('path');
|
|
|
4
4
|
const http = require('http');
|
|
5
5
|
const crypto = require('crypto');
|
|
6
6
|
const drawioLib = require('../drawio.js');
|
|
7
|
-
const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths
|
|
7
|
+
const { renderMarkdown, buildKatexStyleTag, diagramEnginePaths } = require('../md2doc.js');
|
|
8
|
+
const { createDotPreview } = require('./dot-preview.js');
|
|
8
9
|
|
|
9
10
|
const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
|
|
10
11
|
const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
|
|
@@ -353,6 +354,8 @@ async function createEditorServer({
|
|
|
353
354
|
// has ever been registered" — see registerAliveConnection() and
|
|
354
355
|
// DEFAULT_NEVER_OPENED_GRACE_MS's own comment on why that boundary moved
|
|
355
356
|
// off GET /edit/:id.
|
|
357
|
+
// v3.17.3 A4: real paths of the files POST /api/asset wrote in this process.
|
|
358
|
+
const writtenAssets = new Set();
|
|
356
359
|
const aliveConnections = new Set();
|
|
357
360
|
let shutdownGraceTimer = null;
|
|
358
361
|
// Guards against the (already-benign, but worth making impossible on
|
|
@@ -677,6 +680,8 @@ async function createEditorServer({
|
|
|
677
680
|
});
|
|
678
681
|
}
|
|
679
682
|
|
|
683
|
+
// Graphviz preview runs in a worker thread, created on the first request.
|
|
684
|
+
const dotPreview = createDotPreview();
|
|
680
685
|
const server = http.createServer(async (req, res) => {
|
|
681
686
|
try {
|
|
682
687
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
@@ -707,7 +712,10 @@ async function createEditorServer({
|
|
|
707
712
|
// is running could fire a cross-site "simple" POST at every candidate
|
|
708
713
|
// localhost port and write to disk.
|
|
709
714
|
const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping', '/api/asset', '/api/preview']);
|
|
710
|
-
|
|
715
|
+
// v3.17.3 A4: DELETE /api/asset is state-changing too; it gets the same
|
|
716
|
+
// content-type gate (the client sends it with the fetch).
|
|
717
|
+
if ((req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) ||
|
|
718
|
+
(req.method === 'DELETE' && url.pathname === '/api/asset')) {
|
|
711
719
|
const contentType = String(req.headers['content-type'] || '');
|
|
712
720
|
if (!/^application\/json\b/i.test(contentType)) {
|
|
713
721
|
return send(res, 415, { error: 'content-type must be application/json' });
|
|
@@ -847,8 +855,11 @@ async function createEditorServer({
|
|
|
847
855
|
if (body.lang !== 'dot') return send(res, 400, { error: 'lang must be dot' });
|
|
848
856
|
if (typeof body.source !== 'string') return send(res, 400, { error: 'source must be a string' });
|
|
849
857
|
try {
|
|
850
|
-
return send(res, 200, { svg: await
|
|
858
|
+
return send(res, 200, { svg: await dotPreview.render(body.source) });
|
|
851
859
|
} catch (e) {
|
|
860
|
+
// A dead worker is the server's fault, not the source's: 500, and
|
|
861
|
+
// the next request starts a new one.
|
|
862
|
+
if (e && e.crashed === true) return send(res, 500, { error: 'graphviz worker failed' });
|
|
852
863
|
const first = String((e && e.message) || e).split('\n')[0];
|
|
853
864
|
return send(res, 422, { error: first });
|
|
854
865
|
}
|
|
@@ -1005,9 +1016,45 @@ async function createEditorServer({
|
|
|
1005
1016
|
}
|
|
1006
1017
|
throw e;
|
|
1007
1018
|
}
|
|
1019
|
+
writtenAssets.add(path.join(realAssets, name));
|
|
1008
1020
|
return send(res, 200, { name, path: assetLib.relPath(name) });
|
|
1009
1021
|
}
|
|
1010
1022
|
|
|
1023
|
+
// v3.17.3 A4: takes back an upload the page made and then could not use
|
|
1024
|
+
// (a replace refused while its file was still on the way). Only a file
|
|
1025
|
+
// this server instance wrote through POST /api/asset can go: the path is
|
|
1026
|
+
// looked up in `writtenAssets`, never trusted. Anything else, a file that
|
|
1027
|
+
// exists included, is 404 and untouched. `fileId` (optional) names the
|
|
1028
|
+
// document the path is relative to; without it every open document's
|
|
1029
|
+
// directory is tried.
|
|
1030
|
+
if (req.method === 'DELETE' && url.pathname === '/api/asset') {
|
|
1031
|
+
const rel = url.searchParams.get('path');
|
|
1032
|
+
const fidRaw = url.searchParams.get('fileId');
|
|
1033
|
+
const fid = fidRaw === null ? null : Number(fidRaw);
|
|
1034
|
+
if (typeof rel !== 'string' || rel === '' || rel.indexOf('\0') !== -1 ||
|
|
1035
|
+
(fid !== null && (!Number.isInteger(fid) || fid < 0 || fid >= absFiles.length))) {
|
|
1036
|
+
return send(res, 404, { error: 'not an uploaded asset' });
|
|
1037
|
+
}
|
|
1038
|
+
const candidates = fid === null ? absFiles : [absFiles[fid]];
|
|
1039
|
+
for (const file of candidates) {
|
|
1040
|
+
let target;
|
|
1041
|
+
try {
|
|
1042
|
+
const dir = fs.realpathSync(path.dirname(file));
|
|
1043
|
+
const abs = path.resolve(dir, rel);
|
|
1044
|
+
target = path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs));
|
|
1045
|
+
} catch (e) { continue; }
|
|
1046
|
+
if (!writtenAssets.has(target)) continue;
|
|
1047
|
+
try { fs.unlinkSync(target); } catch (e) {
|
|
1048
|
+
if (e && e.code === 'ENOENT') { writtenAssets.delete(target); continue; }
|
|
1049
|
+
throw e;
|
|
1050
|
+
}
|
|
1051
|
+
writtenAssets.delete(target);
|
|
1052
|
+
res.writeHead(204);
|
|
1053
|
+
return res.end();
|
|
1054
|
+
}
|
|
1055
|
+
return send(res, 404, { error: 'not an uploaded asset' });
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1011
1058
|
// v3.6.0: the liveness stream. A GET (EventSource cannot send any other
|
|
1012
1059
|
// method), held open for as long as the tab exists. fileId/clientId are
|
|
1013
1060
|
// read here purely so a future diagnostic has them to hand — see
|
|
@@ -1128,6 +1175,8 @@ async function createEditorServer({
|
|
|
1128
1175
|
}, neverOpenedGraceMs);
|
|
1129
1176
|
if (neverOpenedTimer.unref) neverOpenedTimer.unref();
|
|
1130
1177
|
|
|
1178
|
+
server.on('close', () => dotPreview.close());
|
|
1179
|
+
|
|
1131
1180
|
return {
|
|
1132
1181
|
server,
|
|
1133
1182
|
port,
|
package/lib/editor/wave-ui.js
CHANGED
|
@@ -1020,6 +1020,15 @@
|
|
|
1020
1020
|
// moves (the ⠿ menu's 上移 / 下移 say Alt+↑ / Alt+↓); from the
|
|
1021
1021
|
// canvas, the selected one, as before. A group's ⠿ (or its menu)
|
|
1022
1022
|
// moves the group, like that menu's 上移 / 下移.
|
|
1023
|
+
// v3.17.3 (C14, R14): 週期與相位…'s fields are text the keyboard is
|
|
1024
|
+
// typing into. Alt+↑/↓ there writes what is typed to the signal
|
|
1025
|
+
// whose panel this is (closePopover flushes to its own index) and
|
|
1026
|
+
// moves no lane; before, the selected lane moved under the open
|
|
1027
|
+
// panel and the value landed on whoever then sat at its index.
|
|
1028
|
+
if (popover !== null && popover.kind === 'period' && popover.el.contains(ev.target)) {
|
|
1029
|
+
closePopover(true);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1023
1032
|
const named = keyboardLane(ev.target);
|
|
1024
1033
|
const group = named === null ? keyboardGroup(ev.target) : null;
|
|
1025
1034
|
if (named !== null) {
|