@helping-ai-workflow/md2doc 3.3.0 → 3.4.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/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +79 -6
- package/lib/editor/client.js +3212 -140
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/server.js +323 -3
- package/lib/editor/toolbar-model.js +51 -14
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +677 -14
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
package/lib/md2doc.js
CHANGED
|
@@ -20,11 +20,65 @@
|
|
|
20
20
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
|
+
const drawio = require('./drawio.js');
|
|
23
24
|
|
|
24
25
|
// How long to let client-side WaveDrom / Mermaid scripts render before we
|
|
25
26
|
// snapshot the DOM (PDF print, or --bake-svg inert-SVG bake).
|
|
26
27
|
const DIAGRAM_RENDER_WAIT_MS = 2500;
|
|
27
28
|
|
|
29
|
+
// v3.4.0 batch2 final review R9 — the deferred-diagram placeholders.
|
|
30
|
+
//
|
|
31
|
+
// A placeholder is written by the synchronous marked() pass and read back by
|
|
32
|
+
// an async post-pass roughly four thousand lines away in this same file
|
|
33
|
+
// (bakeDrawio / bakeGraphviz). The two sites used to agree only by
|
|
34
|
+
// character-for-character attribute ordering, and nothing failed if they
|
|
35
|
+
// drifted: a matcher that no longer matches collects zero jobs, the post-pass
|
|
36
|
+
// returns the string untouched, and the placeholder — which carries the
|
|
37
|
+
// base64 of the WHOLE source .drawio file or dot block — ships inside an
|
|
38
|
+
// empty div. Silent, and invisible to every test that only asserts on the
|
|
39
|
+
// baked shape.
|
|
40
|
+
//
|
|
41
|
+
// So the emitter and the matcher are both generated from one ordered field
|
|
42
|
+
// list. Adding, renaming or reordering an attribute changes both at once;
|
|
43
|
+
// there is no edit that can move one without the other.
|
|
44
|
+
//
|
|
45
|
+
// Two rules the field list relies on:
|
|
46
|
+
// - every value must already be safe inside a double-quoted attribute —
|
|
47
|
+
// base64 for anything free-form (the source, the alt text), digits for
|
|
48
|
+
// the page index;
|
|
49
|
+
// - a field is never omitted. A field with nothing to say carries the empty
|
|
50
|
+
// string, so the matcher's arity is fixed and "this one had no alt text"
|
|
51
|
+
// cannot degrade into a no-match.
|
|
52
|
+
function makePlaceholderShape(className, fields) {
|
|
53
|
+
const open = `<div class="${className}" `;
|
|
54
|
+
const quoteRe = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, (c) => '\\' + c);
|
|
55
|
+
return {
|
|
56
|
+
// `probe` is the cheap "is there anything to do at all?" test the bake
|
|
57
|
+
// passes run before doing any work. Derived from the first field rather
|
|
58
|
+
// than spelled out again, for the same no-drift reason as `re`.
|
|
59
|
+
probe: new RegExp(quoteRe(fields[0][0]) + '='),
|
|
60
|
+
html(values) {
|
|
61
|
+
return open + fields.map((f, i) => `${f[0]}="${values[i]}"`).join(' ') + '></div>';
|
|
62
|
+
},
|
|
63
|
+
re: new RegExp(
|
|
64
|
+
quoteRe(open) + fields.map((f) => quoteRe(f[0]) + '="' + f[1] + '"').join(' ') + '><\\/div>',
|
|
65
|
+
'g'),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DRAWIO_PLACEHOLDER = makePlaceholderShape('drawio', [
|
|
70
|
+
['data-drawio-src', '([^"]*)'],
|
|
71
|
+
['data-drawio-page', '(\\d+)'],
|
|
72
|
+
// v3.4.0 batch2 final review R5 — the markdown author's alt text, base64 of
|
|
73
|
+
// the already-HTML-escaped string marked hands us, so it survives the trip
|
|
74
|
+
// to bakeDrawio() and becomes the baked box's accessible name.
|
|
75
|
+
['data-drawio-alt', '([^"]*)'],
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const GRAPHVIZ_PLACEHOLDER = makePlaceholderShape('graphviz', [
|
|
79
|
+
['data-graphviz-src', '([^"]*)'],
|
|
80
|
+
]);
|
|
81
|
+
|
|
28
82
|
function firstExistingPath(candidates) {
|
|
29
83
|
for (const candidate of candidates) {
|
|
30
84
|
if (!candidate) {
|
|
@@ -321,6 +375,29 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
321
375
|
const src = srcPath;
|
|
322
376
|
let usesMermaid = false;
|
|
323
377
|
let usesWaveDrom = false;
|
|
378
|
+
let usesDrawio = false;
|
|
379
|
+
// Set only when some drawio reference actually resolves to 2+ pages — the
|
|
380
|
+
// sheet bar (CSS + runtime) is gated on this, not on usesDrawio alone, so a
|
|
381
|
+
// single-page .drawio file never pays for multi-page UI.
|
|
382
|
+
let usesDrawioMultiPage = false;
|
|
383
|
+
// v3.4.0 batch2 Task 6: every `.drawio`/`.xml` source path this render
|
|
384
|
+
// actually resolved and read, so a caller (lib/editor/server.js) can stat
|
|
385
|
+
// them later to notice an external edit — resolveAssetPath()'s result was
|
|
386
|
+
// previously a pure local inside drawioPlaceholderFor(), never surviving
|
|
387
|
+
// past the base64 it gets embedded as (see the investigation note this
|
|
388
|
+
// task shipped with). A Map, not an array, so the same referenced file
|
|
389
|
+
// linked from two different images is only stat'd once.
|
|
390
|
+
//
|
|
391
|
+
// Fix round 1 (review F4): the VALUE is the file's stamp taken here, at
|
|
392
|
+
// read time, not later by the caller. bakeDrawio() launches headless
|
|
393
|
+
// Chromium and bakes every page after this function returns — seconds, not
|
|
394
|
+
// microseconds, on first use — so a stamp taken by the caller AFTER the
|
|
395
|
+
// bake records bytes that were never baked, and every later comparison
|
|
396
|
+
// then answers "up to date" about a revision nobody ever rendered. Taken
|
|
397
|
+
// BEFORE the readFileSync below for the same reason in miniature: a stamp
|
|
398
|
+
// that is older than the bytes we baked costs one redundant re-render,
|
|
399
|
+
// while one that is newer loses the change permanently.
|
|
400
|
+
const drawioRefs = new Map();
|
|
324
401
|
let usesMath = false;
|
|
325
402
|
let blocks = null;
|
|
326
403
|
// ── Local image assets ───────────────────────────────────────────────────────
|
|
@@ -436,6 +513,68 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
|
|
|
436
513
|
return uri ? uri + fragment : null;
|
|
437
514
|
}
|
|
438
515
|
|
|
516
|
+
// v3.4.0 §4. drawio (.drawio / .xml) goes down the graphviz-style path — a
|
|
517
|
+
// placeholder carrying the source, baked to inline SVG by an async post-pass
|
|
518
|
+
// — instead of image inlining. Returns the placeholder markup, or null to
|
|
519
|
+
// mean "not a drawio reference, fall through to the normal image path"
|
|
520
|
+
// (missing file, non-drawio content, or an extension we don't touch at all).
|
|
521
|
+
// Must run BEFORE inlineImageSrc(): that function warnOnce()s "not a known
|
|
522
|
+
// image extension" for anything outside IMAGE_MIME_BY_EXT, and .drawio/.xml
|
|
523
|
+
// are not image extensions — by the time control reached inlineImageSrc it
|
|
524
|
+
// would already be too late.
|
|
525
|
+
function drawioPlaceholderFor(href, altText) {
|
|
526
|
+
if (!href || isExternalRef(href)) return null;
|
|
527
|
+
const { filePart, fragment } = splitAssetRef(href);
|
|
528
|
+
if (!filePart) return null;
|
|
529
|
+
const ext = path.extname(filePart).toLowerCase();
|
|
530
|
+
if (ext !== '.drawio' && ext !== '.xml') return null;
|
|
531
|
+
|
|
532
|
+
const abs = resolveAssetPath(filePart);
|
|
533
|
+
if (!abs) return null; // No such file: let the ordinary image path warn.
|
|
534
|
+
|
|
535
|
+
// Stamp BEFORE the read — see the drawioRefs comment above.
|
|
536
|
+
const stamp = drawio.drawioStampOf(abs);
|
|
537
|
+
let xml;
|
|
538
|
+
try { xml = fs.readFileSync(abs, 'utf8'); }
|
|
539
|
+
catch (e) { return null; }
|
|
540
|
+
|
|
541
|
+
if (!drawio.isDrawioXml(xml)) {
|
|
542
|
+
// Extension matched but the content didn't — worth a warning for
|
|
543
|
+
// .drawio (that extension promises mxfile/mxGraphModel content), but
|
|
544
|
+
// not for .xml (most .xml files are not diagrams, so silently falling
|
|
545
|
+
// through to the normal image/HTML path is the right default).
|
|
546
|
+
if (ext === '.drawio') {
|
|
547
|
+
warnOnce(href, `[WARN] .drawio file is not mxfile/mxGraphModel, left as-is: ${href}`);
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
usesDrawio = true;
|
|
553
|
+
drawioRefs.set(abs, stamp);
|
|
554
|
+
const names = drawio.pageNamesOf(xml);
|
|
555
|
+
if (names.length >= 2) usesDrawioMultiPage = true;
|
|
556
|
+
// splitAssetRef() keeps the leading '#' (it's re-appended verbatim to a
|
|
557
|
+
// data: URI elsewhere), but resolvePageIndex() expects the bare page
|
|
558
|
+
// name/number — strip it here rather than teach resolvePageIndex two
|
|
559
|
+
// input shapes.
|
|
560
|
+
const pageFragment = fragment ? fragment.slice(1) : '';
|
|
561
|
+
const pageIndex = drawio.resolvePageIndex(names, pageFragment);
|
|
562
|
+
const b64 = Buffer.from(xml, 'utf8').toString('base64');
|
|
563
|
+
// v3.4.0 batch2 final review R5 — carry the author's alt text through to
|
|
564
|
+
// bakeDrawio(), which turns it into the baked box's accessible name. It
|
|
565
|
+
// travels base64-encoded for the same reason the source does: the shared
|
|
566
|
+
// placeholder shape's contract is that every value is already safe inside
|
|
567
|
+
// a double-quoted attribute, and an alt text can contain anything.
|
|
568
|
+
//
|
|
569
|
+
// `altText` is marked's `token.text`, which is ALREADY HTML-escaped
|
|
570
|
+
// (MEASURED on marked 14: `` arrives as
|
|
571
|
+
// `A "q" & <b>`) — the same string the ordinary
|
|
572
|
+
// `<img alt="${text}">` path below interpolates raw. So it must not be
|
|
573
|
+
// escaped again here or on the way out.
|
|
574
|
+
const altB64 = Buffer.from(String(altText || ''), 'utf8').toString('base64');
|
|
575
|
+
return '\n' + DRAWIO_PLACEHOLDER.html([b64, pageIndex, altB64]) + '\n';
|
|
576
|
+
}
|
|
577
|
+
|
|
439
578
|
// A srcset value is a comma-separated list of `<url> <descriptor>` candidates.
|
|
440
579
|
function inlineSrcsetValue(value) {
|
|
441
580
|
const parts = String(value).split(',');
|
|
@@ -588,6 +727,8 @@ ${itemsHtml}
|
|
|
588
727
|
</ul>`;
|
|
589
728
|
}
|
|
590
729
|
|
|
730
|
+
// 契約:一律回傳純文字,永遠不帶 HTML 實體。呼叫端(TOC 標籤、
|
|
731
|
+
// slugifyHeading、startSection)三者都假設這一點。
|
|
591
732
|
function flattenTokenText(tokens) {
|
|
592
733
|
if (!Array.isArray(tokens)) {
|
|
593
734
|
return '';
|
|
@@ -598,12 +739,12 @@ ${itemsHtml}
|
|
|
598
739
|
return flattenTokenText(item.tokens);
|
|
599
740
|
}
|
|
600
741
|
if (item.type === 'codespan') {
|
|
601
|
-
return item.text || '';
|
|
742
|
+
return unescapeHtml(item.text || '');
|
|
602
743
|
}
|
|
603
744
|
if (item.tokens) {
|
|
604
745
|
return flattenTokenText(item.tokens);
|
|
605
746
|
}
|
|
606
|
-
return item.text || '';
|
|
747
|
+
return unescapeHtml(item.text || '');
|
|
607
748
|
})
|
|
608
749
|
.join('');
|
|
609
750
|
}
|
|
@@ -667,8 +808,21 @@ ${itemsHtml}
|
|
|
667
808
|
return classes;
|
|
668
809
|
}
|
|
669
810
|
|
|
811
|
+
// Real HTML tags always have a letter (optionally after a `/`) immediately
|
|
812
|
+
// after `<` — `<x>`, `</div>`, `<br/>`. This deliberately does NOT match
|
|
813
|
+
// `<[^>]*>` generically: once flattenTokenText() started handing back
|
|
814
|
+
// unescaped plain text (v3.4.0 §1), literal author text like "C < D > E"
|
|
815
|
+
// would otherwise look like a tag and get silently eaten here.
|
|
816
|
+
//
|
|
817
|
+
// HTML comments (`<!-- ... -->`) don't start with a letter either, so they
|
|
818
|
+
// need their own alternative — without it a heading like
|
|
819
|
+
// "## Section <!-- note --> Title" leaks "note" into the slug
|
|
820
|
+
// (v3.4.0 Task 1 fix round 1). Known gap, deliberately left open: this
|
|
821
|
+
// does NOT special-case `<!DOCTYPE ...>` or `<? ... ?>` (processing
|
|
822
|
+
// instructions) — same non-letter-after-`<` shape, but neither has shown
|
|
823
|
+
// up in a real heading/prose fixture. Add an alternative here if one does.
|
|
670
824
|
function stripHtmlTags(value) {
|
|
671
|
-
return String(value || '').replace(
|
|
825
|
+
return String(value || '').replace(/<!--[\s\S]*?-->|<\/?[a-zA-Z][^<>]*>/g, '');
|
|
672
826
|
}
|
|
673
827
|
|
|
674
828
|
function slugifyHeading(value) {
|
|
@@ -694,6 +848,19 @@ ${itemsHtml}
|
|
|
694
848
|
.replace(/'/g, ''');
|
|
695
849
|
}
|
|
696
850
|
|
|
851
|
+
// escapeHtml() 的精確逆函式。marked 的 inline lexer 會把 text / codespan
|
|
852
|
+
// token 的 `text` 欄位跳脫過再交出來,而 heading token 自己的 `text` 是
|
|
853
|
+
// 原始的 —— flattenTokenText() 用這一支把兩邊拉回同一個狀態。
|
|
854
|
+
// & 必須最後解:先解它會讓 `&lt;` 變成 `<` 而不是 `<`。
|
|
855
|
+
function unescapeHtml(value) {
|
|
856
|
+
return String(value || '')
|
|
857
|
+
.replace(/</g, '<')
|
|
858
|
+
.replace(/>/g, '>')
|
|
859
|
+
.replace(/"/g, '"')
|
|
860
|
+
.replace(/'/g, "'")
|
|
861
|
+
.replace(/&/g, '&');
|
|
862
|
+
}
|
|
863
|
+
|
|
697
864
|
// marked's tokenizer already escapes alt / title (its default image renderer
|
|
698
865
|
// interpolates both raw), so this must NOT escape them again. Only the src is
|
|
699
866
|
// ours; everything else — including cleanUrl() on a src we leave alone — stays
|
|
@@ -704,6 +871,13 @@ ${itemsHtml}
|
|
|
704
871
|
const href = isObj ? (token.href || '') : (token || '');
|
|
705
872
|
const title = isObj ? (token.title || '') : (arguments[1] || '');
|
|
706
873
|
const text = isObj ? (token.text || '') : (arguments[2] || '');
|
|
874
|
+
|
|
875
|
+
// v3.4.0 §4: drawio 走 graphviz 那條路(建置時烤成內嵌 SVG),不走圖片
|
|
876
|
+
// 內嵌。判別看的是檔案的根元素而不是副檔名 ——  只有在內容
|
|
877
|
+
// 真的是 mxfile / mxGraphModel 時才是圖,否則原樣落到下面既有的路徑。
|
|
878
|
+
const drawioOut = drawioPlaceholderFor(href, text);
|
|
879
|
+
if (drawioOut) return drawioOut;
|
|
880
|
+
|
|
707
881
|
const uri = inlineImageSrc(href);
|
|
708
882
|
if (!uri) return baseImage.apply(this, arguments);
|
|
709
883
|
// A data: URI is base64 (or an already-encoded payload): no quote, no '<'.
|
|
@@ -774,7 +948,7 @@ ${itemsHtml}
|
|
|
774
948
|
// base64 in a data attribute — safe for arbitrary dot syntax (quotes,
|
|
775
949
|
// angle brackets, newlines) inside an HTML attribute.
|
|
776
950
|
const b64 = Buffer.from(code, 'utf8').toString('base64');
|
|
777
|
-
return
|
|
951
|
+
return '\n' + GRAPHVIZ_PLACEHOLDER.html([b64]) + '\n';
|
|
778
952
|
}
|
|
779
953
|
// Default: syntax-highlighted code block
|
|
780
954
|
const escaped = code.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
@@ -932,6 +1106,20 @@ ${itemsHtml}
|
|
|
932
1106
|
// falls entirely inside one part, so this is byte-identical to baking the
|
|
933
1107
|
// joined string.
|
|
934
1108
|
parts = await Promise.all(parts.map(bakeGraphviz));
|
|
1109
|
+
// v3.4.0 §4: 與 graphviz 同一個理由 —— 逐 part 烤,不是 join 之後才烤。
|
|
1110
|
+
// patch 路徑會把 parts[i] 直接插進 DOM,而用戶端沒有 drawio 渲染器
|
|
1111
|
+
// (__md2docInitDiagrams 只處理 mermaid 與 WaveDrom),沒烤的 part 會
|
|
1112
|
+
// 變成一個空的 <div class="drawio">。與 bakeGraphviz 不同的是 drawio
|
|
1113
|
+
// 需要一顆 headless Chromium,所以逐 part 依序(而非並行)烤,讓所有
|
|
1114
|
+
// part 共用同一顆延遲啟動的 browser,一份文件最多開一次。
|
|
1115
|
+
const drawioBrowser = makeLazyBrowserRef();
|
|
1116
|
+
try {
|
|
1117
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1118
|
+
parts[i] = await bakeDrawio(parts[i], drawioBrowser);
|
|
1119
|
+
}
|
|
1120
|
+
} finally {
|
|
1121
|
+
await drawioBrowser.closeIfOpen();
|
|
1122
|
+
}
|
|
935
1123
|
bodyHtml = parts.join('\n');
|
|
936
1124
|
} else {
|
|
937
1125
|
bodyHtml = marked.parse(mdPre);
|
|
@@ -1428,7 +1616,164 @@ ${itemsHtml}
|
|
|
1428
1616
|
it against the textarea's own line numbers instead is a separate
|
|
1429
1617
|
feasibility question, not answered here. */
|
|
1430
1618
|
body[data-ed-mode="source"] .toc,
|
|
1431
|
-
body[data-ed-mode="source"] .search-results { display: none !important; }
|
|
1619
|
+
body[data-ed-mode="source"] .search-results { display: none !important; }
|
|
1620
|
+
|
|
1621
|
+
/* ── v3.4.0 batch3 Task 6: the wavedrom editing surface ───────────────
|
|
1622
|
+
Both of these are mounted on document.body by lib/editor/client.js, and
|
|
1623
|
+
that is not a styling choice: .content's children are what the client
|
|
1624
|
+
reads back as its own render and what the WYSIWYG path serialises into
|
|
1625
|
+
the user's markdown, so a panel or a button parked in there ends up in
|
|
1626
|
+
their file. Everything below is therefore positioned against the
|
|
1627
|
+
viewport rather than against .content.
|
|
1628
|
+
|
|
1629
|
+
z-index 998 sits UNDER .ed-conflict (999) on purpose — a save conflict
|
|
1630
|
+
has to stay reachable even while the wave editor is open — and over the
|
|
1631
|
+
toolbar's own band, because the editor is modal over the document. */
|
|
1632
|
+
.ed-wave-edit-btn {
|
|
1633
|
+
position: fixed; z-index: 12; padding: 3px 10px; font-size: 12px;
|
|
1634
|
+
border: 1px solid #bbb; border-radius: 4px; background: #ffffffee;
|
|
1635
|
+
color: #222; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,.2);
|
|
1636
|
+
}
|
|
1637
|
+
.ed-wave-edit-btn:hover { background: #fff; border-color: #888; }
|
|
1638
|
+
.ed-wave-overlay {
|
|
1639
|
+
position: fixed; inset: 0; z-index: 998; background: rgba(0,0,0,.45);
|
|
1640
|
+
display: flex; align-items: center; justify-content: center;
|
|
1641
|
+
}
|
|
1642
|
+
.ed-wave-panel {
|
|
1643
|
+
background: #fff; border-radius: 6px; width: min(1200px, 94vw);
|
|
1644
|
+
height: min(760px, 90vh); display: flex; flex-direction: column;
|
|
1645
|
+
box-shadow: 0 6px 28px rgba(0,0,0,.35); overflow: hidden;
|
|
1646
|
+
}
|
|
1647
|
+
.ed-wave-head {
|
|
1648
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
1649
|
+
padding: 8px 12px; border-bottom: 1px solid #e2e2e2; font-weight: bold;
|
|
1650
|
+
}
|
|
1651
|
+
.ed-wave-close {
|
|
1652
|
+
border: 1px solid #bbb; border-radius: 4px; background: #f6f6f6;
|
|
1653
|
+
padding: 3px 12px; cursor: pointer;
|
|
1654
|
+
}
|
|
1655
|
+
/* Which way out keeps the work and which throws it away. Escape is a modal's
|
|
1656
|
+
universal close key and here it discards a whole session, so the header
|
|
1657
|
+
says so next to the button that does not. Normal weight against the bar's
|
|
1658
|
+
bold heading: it is a caption, not a warning the user has to clear. */
|
|
1659
|
+
.ed-wave-escape-hint {
|
|
1660
|
+
margin-left: auto; margin-right: 12px; font-weight: normal;
|
|
1661
|
+
font-size: 11px; color: #7a4b00;
|
|
1662
|
+
}
|
|
1663
|
+
.ed-wave-toolbar {
|
|
1664
|
+
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
|
|
1665
|
+
padding: 8px 12px; border-bottom: 1px solid #e2e2e2; background: #fafafa;
|
|
1666
|
+
}
|
|
1667
|
+
.ed-wave-brushes, .ed-wave-cycleops, .ed-wave-meta {
|
|
1668
|
+
display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
|
|
1669
|
+
}
|
|
1670
|
+
.ed-wave-brush {
|
|
1671
|
+
width: 28px; height: 28px; border: 1px solid #c4c4c4; border-radius: 4px;
|
|
1672
|
+
background: #fff; font-family: monospace; font-size: 14px; cursor: pointer;
|
|
1673
|
+
}
|
|
1674
|
+
.ed-wave-brush.is-on { background: #1a73e8; color: #fff; border-color: #1a73e8; }
|
|
1675
|
+
.ed-wave-cycleops button, .ed-wave-toolbar button {
|
|
1676
|
+
border: 1px solid #c4c4c4; border-radius: 4px; background: #fff;
|
|
1677
|
+
padding: 3px 8px; font-size: 12px; cursor: pointer;
|
|
1678
|
+
}
|
|
1679
|
+
.ed-wave-toolbar button:disabled { opacity: .45; cursor: default; }
|
|
1680
|
+
.ed-wave-metafield { display: flex; align-items: center; gap: 4px; font-size: 12px; }
|
|
1681
|
+
.ed-wave-metafield input { width: 110px; font-size: 12px; padding: 2px 4px; }
|
|
1682
|
+
/* The "this drawing is not showing you everything" notice. period / phase /
|
|
1683
|
+
config.hscale all change what the ENGINE paints and none of them is
|
|
1684
|
+
modelled by the hand drawing, so a document carrying one says so instead
|
|
1685
|
+
of drawing a confident wrong picture. Full row, so it cannot be mistaken
|
|
1686
|
+
for another toolbar control. */
|
|
1687
|
+
.ed-wave-unmodelled {
|
|
1688
|
+
flex: 1 1 100%; font-size: 12px; color: #7a4b00; background: #fff6e0;
|
|
1689
|
+
border: 1px solid #e8c987; border-radius: 4px; padding: 4px 8px;
|
|
1690
|
+
}
|
|
1691
|
+
/* The stronger of the two notices: not "this drawing is incomplete" but
|
|
1692
|
+
"this block has nothing this editor can edit" (a reg: / assign: block).
|
|
1693
|
+
Same shape as its neighbour so the two read as one family, one step
|
|
1694
|
+
louder because the information gap is bigger. */
|
|
1695
|
+
.ed-wave-nolanes { color: #7a1f00; background: #ffe9e0; border-color: #e8a987; }
|
|
1696
|
+
.ed-wave-body { display: flex; flex: 1 1 auto; min-height: 0; }
|
|
1697
|
+
/* Widths chosen so the DRAWING still gets room at the narrowest viewport this
|
|
1698
|
+
repo's browser tests use (800x600 -> panel 752): 752 - 200 - 160 = 392px,
|
|
1699
|
+
which is 8 cycles at .ed-wave-canvas's 48px pitch. The canvas is a scroll
|
|
1700
|
+
container, so a wider diagram scrolls rather than pushing the preview off —
|
|
1701
|
+
but a canvas column narrower than a couple of cycles would make the drag
|
|
1702
|
+
gesture unusable, and that was the first thing measured to go wrong here
|
|
1703
|
+
(a press landed on the preview column, 116px past the canvas's clip). */
|
|
1704
|
+
.ed-wave-lanes {
|
|
1705
|
+
flex: 0 0 200px; border-right: 1px solid #e2e2e2; overflow: auto;
|
|
1706
|
+
padding: 8px 0 8px 0;
|
|
1707
|
+
}
|
|
1708
|
+
/* The rows are absolutely positioned at index * laneHeight so they line up
|
|
1709
|
+
with the drawing beside them cell for cell. A group title is NOT a row —
|
|
1710
|
+
measured against the pinned engine in Task 2 — so it is drawn as a label in
|
|
1711
|
+
the rail spanning its lanes' rows instead, which is the shape the engine
|
|
1712
|
+
itself gives it (horizontal space in the name column, no vertical space). */
|
|
1713
|
+
.ed-wave-lane-rows { position: relative; }
|
|
1714
|
+
.ed-wave-lane-row {
|
|
1715
|
+
position: absolute; left: 0; right: 0; display: flex; align-items: center;
|
|
1716
|
+
gap: 3px; padding-right: 6px; box-sizing: border-box;
|
|
1717
|
+
}
|
|
1718
|
+
.ed-wave-lane-row.is-selected { background: #eaf2fe; }
|
|
1719
|
+
.ed-wave-lane-name { flex: 1 1 auto; min-width: 0; font-size: 12px; padding: 2px 4px; }
|
|
1720
|
+
.ed-wave-lane-row button {
|
|
1721
|
+
border: 1px solid #cfcfcf; border-radius: 3px; background: #fff;
|
|
1722
|
+
font-size: 11px; line-height: 1; padding: 3px 4px; cursor: pointer;
|
|
1723
|
+
}
|
|
1724
|
+
.ed-wave-lane-row button:disabled { opacity: .4; cursor: default; }
|
|
1725
|
+
.ed-wave-lane-add-tail { position: absolute; left: 6px; }
|
|
1726
|
+
.ed-wave-group, .ed-wave-group-input {
|
|
1727
|
+
position: absolute; width: 14px; padding: 0; font-size: 11px;
|
|
1728
|
+
writing-mode: vertical-rl; text-orientation: mixed; overflow: hidden;
|
|
1729
|
+
border: 1px solid #d8d8d8; border-radius: 3px; background: #f2f2f2;
|
|
1730
|
+
color: #555; cursor: pointer; box-sizing: border-box;
|
|
1731
|
+
}
|
|
1732
|
+
.ed-wave-group-input { width: 16px; height: 90px; cursor: text; }
|
|
1733
|
+
.ed-wave-canvas-wrap { flex: 1 1 auto; overflow: auto; padding: 8px; }
|
|
1734
|
+
.ed-wave-canvas { display: block; }
|
|
1735
|
+
.ed-wave-grid { stroke: #ececec; stroke-width: 1; }
|
|
1736
|
+
.ed-wave-line, .ed-wave-edge { stroke: #111; stroke-width: 2; fill: none; }
|
|
1737
|
+
.ed-wave-weak { stroke-dasharray: 4 3; }
|
|
1738
|
+
.ed-wave-x { fill: #d9d9d9; stroke: #111; stroke-width: 2; }
|
|
1739
|
+
.ed-wave-bus { fill: #f5e9c8; stroke: #111; stroke-width: 2; }
|
|
1740
|
+
.ed-wave-bus-3 { fill: #d8ead3; }
|
|
1741
|
+
.ed-wave-bus-4 { fill: #d6e4f7; }
|
|
1742
|
+
.ed-wave-bus-5 { fill: #f7d6e4; }
|
|
1743
|
+
.ed-wave-buslabel { font-size: 11px; fill: #222; }
|
|
1744
|
+
.ed-wave-selection { fill: rgba(26,115,232,.18); stroke: #1a73e8; stroke-width: 1; }
|
|
1745
|
+
/* The KEYBOARD's own cell, and deliberately not the same mark as the
|
|
1746
|
+
selection: the selection is the RANGE a cycle operation acts on, the
|
|
1747
|
+
cursor is the ONE cell the next keystroke paints, and after Shift+left /
|
|
1748
|
+
right both are on screen at once. pointer-events: none so the rect can
|
|
1749
|
+
never swallow the press the drag underneath it is made of. */
|
|
1750
|
+
.ed-wave-cursor {
|
|
1751
|
+
fill: none; stroke: #d93025; stroke-width: 2; pointer-events: none;
|
|
1752
|
+
}
|
|
1753
|
+
/* The drawing is in the Tab cycle, so it has to SAY when it holds the
|
|
1754
|
+
keyboard: a cell cursor alone leaves a user who just Tabbed onto it with
|
|
1755
|
+
nothing that distinguishes "the drawing has the keyboard" from "the
|
|
1756
|
+
drawing is where I left a cursor before I walked away". */
|
|
1757
|
+
.ed-wave-canvas:focus { outline: 2px solid #1a73e8; outline-offset: 2px; }
|
|
1758
|
+
/* The discontinuity marker. The engine draws one gap <use> per pipe
|
|
1759
|
+
character; drawing none left the two pictures disagreeing on a lane whose
|
|
1760
|
+
levels agreed exactly. */
|
|
1761
|
+
.ed-wave-gap { stroke: #111; stroke-width: 2; fill: none; }
|
|
1762
|
+
.ed-wave-preview {
|
|
1763
|
+
flex: 0 1 340px; min-width: 160px; border-left: 1px solid #e2e2e2;
|
|
1764
|
+
overflow: auto; padding: 8px;
|
|
1765
|
+
}
|
|
1766
|
+
.ed-wave-preview-label { font-size: 11px; color: #666; margin-bottom: 6px; }
|
|
1767
|
+
.ed-wave-status {
|
|
1768
|
+
border-top: 1px solid #e2e2e2; padding: 6px 12px; font-size: 12px;
|
|
1769
|
+
color: #444; min-height: 18px;
|
|
1770
|
+
}
|
|
1771
|
+
.ed-wave-parse-error { padding: 16px; }
|
|
1772
|
+
.ed-wave-parse-message { color: #b00020; font-weight: bold; }
|
|
1773
|
+
.ed-wave-parse-excerpt {
|
|
1774
|
+
background: #f6f6f6; border: 1px solid #e2e2e2; padding: 6px;
|
|
1775
|
+
overflow-x: auto; font-family: monospace; font-size: 12px;
|
|
1776
|
+
}`;
|
|
1432
1777
|
|
|
1433
1778
|
const html = `<!DOCTYPE html>
|
|
1434
1779
|
<html lang="en">
|
|
@@ -1576,6 +1921,24 @@ ${itemsHtml}
|
|
|
1576
1921
|
z-index: 98;
|
|
1577
1922
|
}
|
|
1578
1923
|
body[data-sidebar-open] .sidebar-scrim { display: block; }
|
|
1924
|
+
/* The scroll LOCK for this same attribute lives inside the
|
|
1925
|
+
'@media (max-width: 1080px)' block below, next to
|
|
1926
|
+
'body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }'
|
|
1927
|
+
— deliberately NOT here at base scope, unlike the rule immediately
|
|
1928
|
+
above. Reason (Ruling I1, review round 2): the mobile-drawer meaning of
|
|
1929
|
+
data-sidebar-open, including .sidebar-scrim's own display:block rule
|
|
1930
|
+
just above, already leaks past its breakpoint by design (see the
|
|
1931
|
+
⚠ comment above '@media (min-width: 1081px) { body[data-ed-outline-hidden]
|
|
1932
|
+
… }' for why the desktop 大綱 toggle needed its OWN attribute instead of
|
|
1933
|
+
reusing this one — reusing it once already painted the whole desktop
|
|
1934
|
+
viewport grey and click-dead). Nothing here clears data-sidebar-open on
|
|
1935
|
+
resize (grep this file's two resize listeners — scroll anchor and
|
|
1936
|
+
lightbox fit — neither touches it), so a lock at base scope would ALSO
|
|
1937
|
+
survive: open the drawer narrow, widen the window, and the desktop page
|
|
1938
|
+
would go unscrollable on top of the pre-existing grey scrim. The grey
|
|
1939
|
+
scrim is that already-accepted gap; an unscrollable desktop page is not
|
|
1940
|
+
— it is new, and scoping the lock to the same breakpoint the drawer
|
|
1941
|
+
itself is scoped to is what keeps it from outliving the drawer. */
|
|
1579
1942
|
.reader-tools,
|
|
1580
1943
|
.search-results,
|
|
1581
1944
|
.toc {
|
|
@@ -1997,7 +2360,29 @@ ${itemsHtml}
|
|
|
1997
2360
|
hr { border: none; border-top: 1px solid #e1e4e8; margin: 24px 0; }
|
|
1998
2361
|
.mermaid { text-align: center; margin: 20px 0; }
|
|
1999
2362
|
.graphviz { text-align: center; margin: 20px 0; }
|
|
2000
|
-
.
|
|
2363
|
+
${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pay for this —
|
|
2364
|
+
conditional injection, same pattern as usesWaveDrom / usesMath below.
|
|
2365
|
+
.drawio needs position: relative because the (conditionally injected,
|
|
2366
|
+
see below) sheet bar is its absolute child — same relationship as
|
|
2367
|
+
.lightbox-stage / .lightbox-canvas, see CLAUDE.md. */
|
|
2368
|
+
.drawio { text-align: center; margin: 20px 0; position: relative; }
|
|
2369
|
+
.drawio svg { max-width: 100%; height: auto; }
|
|
2370
|
+
.drawio-page[hidden] { display: none !important; }
|
|
2371
|
+
.drawio-failed { color: #b91c1c; }
|
|
2372
|
+
` : ''}${usesDrawioMultiPage ? ` /* Sheet bar: only when a document actually has a multi-page drawio
|
|
2373
|
+
diagram — a single-page file must not pay for it either. */
|
|
2374
|
+
.drawio-sheetbar {
|
|
2375
|
+
position: absolute; left: 50%; transform: translateX(-50%); bottom: 4px;
|
|
2376
|
+
display: none; gap: 4px; padding: 4px 6px; border-radius: 6px;
|
|
2377
|
+
background: rgba(255,255,255,.92); box-shadow: 0 1px 4px rgba(0,0,0,.18);
|
|
2378
|
+
}
|
|
2379
|
+
.drawio:hover .drawio-sheetbar { display: flex; }
|
|
2380
|
+
.drawio-sheetbar button {
|
|
2381
|
+
border: 0; background: transparent; cursor: pointer;
|
|
2382
|
+
font: inherit; padding: 2px 8px; border-radius: 4px;
|
|
2383
|
+
}
|
|
2384
|
+
.drawio-sheetbar button[aria-pressed="true"] { background: #dbeafe; }
|
|
2385
|
+
` : ''} .content img {
|
|
2001
2386
|
max-width: 100%;
|
|
2002
2387
|
height: auto;
|
|
2003
2388
|
}
|
|
@@ -2014,6 +2399,7 @@ ${itemsHtml}
|
|
|
2014
2399
|
.content img,
|
|
2015
2400
|
.content .mermaid,
|
|
2016
2401
|
.content .graphviz,
|
|
2402
|
+
.content .drawio,
|
|
2017
2403
|
.content [id^="WaveDrom_Display_"],
|
|
2018
2404
|
.content .wavedrom-diagram { cursor: zoom-in; }
|
|
2019
2405
|
.content a img { cursor: pointer; }
|
|
@@ -2179,6 +2565,21 @@ ${itemsHtml}
|
|
|
2179
2565
|
flex: initial;
|
|
2180
2566
|
}
|
|
2181
2567
|
body[data-sidebar-open] .reader-sidebar { transform: translateX(0); }
|
|
2568
|
+
/* Scroll lock for the drawer, same two-rule shape the lightbox uses a
|
|
2569
|
+
few lines above (body[data-lightbox-open] / html[data-lightbox-open])
|
|
2570
|
+
and for the identical reason: 'html, body { overflow-x: clip }' keeps
|
|
2571
|
+
documentElement's own overflow-y at the visible default, so it stays
|
|
2572
|
+
the scrolling element regardless of what 'body' says — a body-only
|
|
2573
|
+
rule leaves the page scrollable behind the scrim. Was missing
|
|
2574
|
+
entirely before this task (a missing lock, not a defeated one). Scoped
|
|
2575
|
+
to THIS breakpoint on purpose — see the comment above
|
|
2576
|
+
'.sidebar-scrim { display: none; … }' near the top of this stylesheet
|
|
2577
|
+
for why base scope would leak an unscrollable desktop page past a
|
|
2578
|
+
resize, since nothing clears data-sidebar-open on one. JS mirrors
|
|
2579
|
+
data-sidebar-open onto documentElement in setSidebarOpen(), matching
|
|
2580
|
+
openLightbox/closeLightbox. */
|
|
2581
|
+
body[data-sidebar-open] { overflow: hidden; }
|
|
2582
|
+
html[data-sidebar-open] { overflow: hidden; }
|
|
2182
2583
|
.sidebar-splitter { display: none; }
|
|
2183
2584
|
.heading-anchor { opacity: 1; }
|
|
2184
2585
|
}
|
|
@@ -2639,7 +3040,31 @@ ${itemsHtml}
|
|
|
2639
3040
|
box-sizing: border-box; height: var(--ed-toolbar-h);
|
|
2640
3041
|
display: flex; align-items: center; justify-content: flex-start;
|
|
2641
3042
|
flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden;
|
|
2642
|
-
|
|
3043
|
+
/* v3.4.0 §3: gap was 6px until the save button (id 'save', file group)
|
|
3044
|
+
landed at the FRONT of the row. Ruling T11-2 (below) requires the
|
|
3045
|
+
outline (☰) button's own centre point to hit-test successfully at
|
|
3046
|
+
800/1080px WITHOUT any scrolling — it is the only way to open the
|
|
3047
|
+
sidebar drawer in edit mode, and that test deliberately does not
|
|
3048
|
+
scroll first. A leading addition shifts every button after it right
|
|
3049
|
+
by its own width; MEASURED at 800px wide before this change: 22
|
|
3050
|
+
buttons put outline's centre at x=795 (row content 975px, overflow
|
|
3051
|
+
175px) — already only a 5px margin inside the 800px viewport. Adding
|
|
3052
|
+
save (23 buttons, row content 1026px, overflow 226px) pushed it to
|
|
3053
|
+
x=846, entirely off screen (elementFromPoint at that point returns
|
|
3054
|
+
null — off-screen, not occluded by another element; contrast the
|
|
3055
|
+
.ed-toolbar-status overlap a few lines down, which returns the
|
|
3056
|
+
covered BUTTON because that slot has pointer-events: none, an
|
|
3057
|
+
entirely different failure shape). This gap value was chosen
|
|
3058
|
+
empirically (a one-off puppeteer script, not a formula — flex gap
|
|
3059
|
+
plus intrinsic button/separator widths do not sum cleanly by hand):
|
|
3060
|
+
re-measured at 800px, outline's centre lands at x=765, a 35px margin
|
|
3061
|
+
— comfortably more headroom than the original 5px, not just barely
|
|
3062
|
+
restored. Also re-verified against the F12 "narrow viewport,
|
|
3063
|
+
scrolling allowed" sweep just below (420/640/820px): every button,
|
|
3064
|
+
save included, still reaches an empty unreachable list and an empty
|
|
3065
|
+
neverClear list at all three — this change does not trade the T11-2
|
|
3066
|
+
no-scroll guarantee for the F12 with-scroll one. */
|
|
3067
|
+
gap: 3px; padding: 8px 12px;
|
|
2643
3068
|
/* F12, both halves measured on this build at 900px viewport height.
|
|
2644
3069
|
justify-content was center. Centering an overflowing flex row splits
|
|
2645
3070
|
the overflow across BOTH ends, and the leading end of a scroll
|
|
@@ -2805,6 +3230,18 @@ ${itemsHtml}
|
|
|
2805
3230
|
font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer;
|
|
2806
3231
|
}
|
|
2807
3232
|
.ed-toolbar-menu-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
|
3233
|
+
/* backlog #7 (Task 9 review, M1): the panel's OWN keyboard cursor
|
|
3234
|
+
(lib/editor/client.js's toolbarMenuIndex / data-ed-tb-menu-cursor) had
|
|
3235
|
+
no matching rule at all — the fix made the six items operable by
|
|
3236
|
+
keyboard but left the cursor invisible, the exact backlog #10 class of
|
|
3237
|
+
defect ("can act, but cannot see where"). Same outline idiom as
|
|
3238
|
+
.ed-toolbar-btn[data-ed-tb-cursor] above, for the same reason (legible
|
|
3239
|
+
over :hover's own background, and over nothing at all when neither
|
|
3240
|
+
applies). */
|
|
3241
|
+
.ed-toolbar-menu-btn[data-ed-tb-menu-cursor] {
|
|
3242
|
+
outline: 2px solid #6ea8fe; outline-offset: -2px;
|
|
3243
|
+
background: rgba(255, 255, 255, 0.18);
|
|
3244
|
+
}
|
|
2808
3245
|
/* 追加 4: the whole-document source escape hatch. Replaces the rendered
|
|
2809
3246
|
content column for the duration of source mode; the document's own
|
|
2810
3247
|
lines[] is untouched until the mode is left (see docsource.js). */
|
|
@@ -2828,8 +3265,45 @@ ${itemsHtml}
|
|
|
2828
3265
|
}
|
|
2829
3266
|
.ed-commit { color: #0a7a0a; }
|
|
2830
3267
|
.ed-cancel { color: #b00020; }
|
|
3268
|
+
/* backlog #8: 'top: 0' used to put this banner directly OVER .ed-toolbar
|
|
3269
|
+
(same top-of-viewport band, and z-index 999 against the bar's 101 wins
|
|
3270
|
+
the paint) — MEASURED (task-10-toolbar-probe.js): all 23 toolbar
|
|
3271
|
+
buttons' own centre points hit-tested to the banner, not the button,
|
|
3272
|
+
for every .ed-conflict banner showBanner() raises (conflict /
|
|
3273
|
+
render-failed / save-failed / dropped-gesture / structural-refusal /
|
|
3274
|
+
swallow all share this one class — there is exactly one producer, see
|
|
3275
|
+
showBanner() in lib/editor/client.js). Floors at the SAME
|
|
3276
|
+
--ed-toolbar-h token placeTeMenuAboveCell()'s .ed-te-menu already
|
|
3277
|
+
clamps to (if top < topInset, top = r.bottom + …, both in
|
|
3278
|
+
lib/editor/client.js) — rather than inventing a new offset.
|
|
3279
|
+
Final-review I1 correction: an earlier draft of this comment also named
|
|
3280
|
+
positionSelToolbar()'s .ed-seltb here, as if it clamped to this same
|
|
3281
|
+
token. Retracted — MEASURED: positionSelToolbar() reads --ed-toolbar-h
|
|
3282
|
+
only to decide whether a selection scrolled fully out of view should
|
|
3283
|
+
hide the bar entirely (rect.bottom <= topInset); the vertical floor it
|
|
3284
|
+
actually clamps 'top' to is a bare margin = 4 local, independent
|
|
3285
|
+
of the toolbar's height. Swept 66 scroll positions: at 17 of them
|
|
3286
|
+
.ed-seltb's own top measures as low as 14.75px — inside this banner's
|
|
3287
|
+
band — with no --ed-toolbar-h involved (task10-seltb-check-probe.js).
|
|
3288
|
+
Not reproduced with a LIVE banner open at the same time (two attempts:
|
|
3289
|
+
a burst's bold toggle never hits /api/render, and Ctrl+S's
|
|
3290
|
+
switchAwayFrom() tears the bar down before a banner could show) — left
|
|
3291
|
+
as a geometry note in CHANGELOG.md's Known issues rather than a
|
|
3292
|
+
behaviour change.
|
|
3293
|
+
Deliberately NOT a z-index change: the toolbar comment above
|
|
3294
|
+
("z-index sits ... below .ed-conflict (999): a save conflict must
|
|
3295
|
+
always be reachable") still holds — the banner stays the topmost
|
|
3296
|
+
thing, it just no longer shares the toolbar's own band to be on top
|
|
3297
|
+
OF. showBanner() additionally closes .ed-te-grip / .ed-tb-insert /
|
|
3298
|
+
.ed-te-menu / .ed-toolbar-menu — four dynamic fixed overlays that can
|
|
3299
|
+
each land in that band, though (per the correction above) only
|
|
3300
|
+
.ed-te-menu's own code floors there via --ed-toolbar-h; the others
|
|
3301
|
+
land nearby as a side effect of their own trigger's position, not a
|
|
3302
|
+
shared clamp — see showBanner()'s parallel comment in
|
|
3303
|
+
lib/editor/client.js. Closed regardless, so a banner can never land on
|
|
3304
|
+
top of one of those instead. */
|
|
2831
3305
|
.ed-conflict {
|
|
2832
|
-
position: fixed; top:
|
|
3306
|
+
position: fixed; top: var(--ed-toolbar-h); left: 0; right: 0; padding: 10px;
|
|
2833
3307
|
background: #b00020; color: #fff; z-index: 999; text-align: center;
|
|
2834
3308
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
|
2835
3309
|
}
|
|
@@ -3423,8 +3897,10 @@ ${mermaidInitTag}`)
|
|
|
3423
3897
|
function setSidebarOpen(open) {
|
|
3424
3898
|
if (open) {
|
|
3425
3899
|
document.body.setAttribute('data-sidebar-open', '');
|
|
3900
|
+
document.documentElement.setAttribute('data-sidebar-open', '');
|
|
3426
3901
|
} else {
|
|
3427
3902
|
document.body.removeAttribute('data-sidebar-open');
|
|
3903
|
+
document.documentElement.removeAttribute('data-sidebar-open');
|
|
3428
3904
|
}
|
|
3429
3905
|
if (sidebarToggle) sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
3430
3906
|
}
|
|
@@ -3591,11 +4067,62 @@ ${mermaidInitTag}`)
|
|
|
3591
4067
|
|
|
3592
4068
|
captureScrollAnchor();
|
|
3593
4069
|
|
|
4070
|
+
// ── drawio sheet bar ─────────────────────────────────────────────────────
|
|
4071
|
+
// v3.4.0 §4: switching pages is a pure hidden-attribute toggle. Every page was
|
|
4072
|
+
// rendered to inert SVG at build time (lib/drawio.js's bake pass), so there
|
|
4073
|
+
// is no engine, no diagrams.net script, and no network request here — this
|
|
4074
|
+
// works exactly the same with the network unplugged. Only injected when
|
|
4075
|
+
// usesDrawioMultiPage: a single-page .drawio file must not pay for this UI
|
|
4076
|
+
// at all (matching CSS gate is in the <style> block above).
|
|
4077
|
+
${usesDrawioMultiPage ? ` // v3.4.0 batch2 Task 6: wired through a NAMED, reusable hook instead of a
|
|
4078
|
+
// bare one-shot forEach, so the edit-mode client runtime can re-invoke it
|
|
4079
|
+
// — scoped to just one freshly swapped-in block — after a background
|
|
4080
|
+
// refresh re-bakes a \`.drawio\`/\`.xml\` source that changed on disk (see
|
|
4081
|
+
// lib/editor/client.js's stale-drawio handler). Still gated on
|
|
4082
|
+
// usesDrawioMultiPage exactly as before: a single-page document must never
|
|
4083
|
+
// pay for (or even mention) this UI at all — a guard test asserts the
|
|
4084
|
+
// whole feature's class name is absent byte-for-byte from that output, so
|
|
4085
|
+
// nothing below this line may ever run, or exist as source text, outside
|
|
4086
|
+
// this branch.
|
|
4087
|
+
window.__md2docInitDrawioSheetBar = function (rootEl) {
|
|
4088
|
+
var scope = rootEl || document;
|
|
4089
|
+
scope.querySelectorAll('.drawio[data-drawio-pages]').forEach(function (box) {
|
|
4090
|
+
if (box.querySelector('.drawio-sheetbar')) return; // already wired
|
|
4091
|
+
var names;
|
|
4092
|
+
try { names = JSON.parse(atob(box.getAttribute('data-drawio-pages') || '')); }
|
|
4093
|
+
catch (e) { return; }
|
|
4094
|
+
if (!Array.isArray(names)) return;
|
|
4095
|
+
if (names.length < 2) return;
|
|
4096
|
+
var pages = box.querySelectorAll('.drawio-page');
|
|
4097
|
+
var bar = document.createElement('div');
|
|
4098
|
+
bar.className = 'drawio-sheetbar';
|
|
4099
|
+
names.forEach(function (name, i) {
|
|
4100
|
+
var b = document.createElement('button');
|
|
4101
|
+
b.type = 'button';
|
|
4102
|
+
b.textContent = name || String(i + 1);
|
|
4103
|
+
b.setAttribute('aria-pressed', pages[i] && !pages[i].hidden ? 'true' : 'false');
|
|
4104
|
+
b.addEventListener('click', function (event) {
|
|
4105
|
+
// The bar lives inside .drawio, which is also a lightbox click
|
|
4106
|
+
// target below (LIGHTBOX_TARGETS) — without stopPropagation a page
|
|
4107
|
+
// switch would also bubble up and pop the lightbox open.
|
|
4108
|
+
event.stopPropagation();
|
|
4109
|
+
for (var j = 0; j < pages.length; j++) pages[j].hidden = j !== i;
|
|
4110
|
+
bar.querySelectorAll('button').forEach(function (x, j) {
|
|
4111
|
+
x.setAttribute('aria-pressed', j === i ? 'true' : 'false');
|
|
4112
|
+
});
|
|
4113
|
+
});
|
|
4114
|
+
bar.appendChild(b);
|
|
4115
|
+
});
|
|
4116
|
+
box.appendChild(bar);
|
|
4117
|
+
});
|
|
4118
|
+
};
|
|
4119
|
+
window.__md2docInitDrawioSheetBar(document);
|
|
4120
|
+
` : ''}
|
|
3594
4121
|
// ── Diagram / image lightbox ──────────────────────────────────────────────
|
|
3595
4122
|
// Specs are read at 100% but their block diagrams and waveforms are drawn far
|
|
3596
4123
|
// wider than the column, so the inline copy is unreadably small. Clicking one
|
|
3597
4124
|
// pops it into a modal stage that zooms and scrolls.
|
|
3598
|
-
var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
4125
|
+
var LIGHTBOX_TARGETS = 'img, .mermaid, .graphviz, .drawio, [id^="WaveDrom_Display_"], .wavedrom-diagram';
|
|
3599
4126
|
var LIGHTBOX_MIN_ZOOM = 0.05;
|
|
3600
4127
|
var LIGHTBOX_MAX_ZOOM = 8;
|
|
3601
4128
|
var LIGHTBOX_STEP = 1.25;
|
|
@@ -4333,6 +4860,15 @@ ${mermaidInitTag}`)
|
|
|
4333
4860
|
|
|
4334
4861
|
function lightboxSourceOf(node) {
|
|
4335
4862
|
if (node.tagName === 'IMG') return node;
|
|
4863
|
+
// A multi-page .drawio bakes every page's <svg> into the DOM (hidden
|
|
4864
|
+
// ones included, so page switching needs no engine) — a plain
|
|
4865
|
+
// querySelector('svg') returns the FIRST one in document order, which is
|
|
4866
|
+
// wrong whenever page 1 isn't the one currently shown. Pick the visible
|
|
4867
|
+
// page's svg instead.
|
|
4868
|
+
if (node.classList && node.classList.contains('drawio')) {
|
|
4869
|
+
var visiblePage = node.querySelector('.drawio-page:not([hidden])');
|
|
4870
|
+
if (visiblePage) return visiblePage.querySelector('svg') || node;
|
|
4871
|
+
}
|
|
4336
4872
|
return node.querySelector('svg') || node;
|
|
4337
4873
|
}
|
|
4338
4874
|
|
|
@@ -4468,12 +5004,34 @@ ${mermaidInitTag}`)
|
|
|
4468
5004
|
</body>
|
|
4469
5005
|
</html>`;
|
|
4470
5006
|
|
|
4471
|
-
const
|
|
5007
|
+
const gvHtml = await bakeGraphviz(html);
|
|
4472
5008
|
// editMode's bodyHtml is already baked per-part above; re-baking here is a
|
|
4473
5009
|
// short-circuit no-op (bakeGraphviz bails out when there's no
|
|
4474
5010
|
// data-graphviz-src left). Non-editMode bodyHtml is baked here as before.
|
|
4475
|
-
const
|
|
4476
|
-
|
|
5011
|
+
const gvBody = await bakeGraphviz(bodyHtml);
|
|
5012
|
+
// v3.4.0 §4: same short-circuit story for drawio. editMode already baked
|
|
5013
|
+
// every part above, so both calls below are no-ops there (no browser is
|
|
5014
|
+
// opened) — this is only load-bearing for the non-editMode
|
|
5015
|
+
// `bodyHtml = marked.parse(mdPre)` path, which never went through the
|
|
5016
|
+
// per-part loop. `html` and `bodyHtml` share the same placeholders (html
|
|
5017
|
+
// embeds bodyHtml), so both bakes share one lazily-opened browser instead
|
|
5018
|
+
// of paying for two.
|
|
5019
|
+
const drawioBrowser = makeLazyBrowserRef();
|
|
5020
|
+
let bakedHtml, bakedBody;
|
|
5021
|
+
try {
|
|
5022
|
+
bakedHtml = await bakeDrawio(gvHtml, drawioBrowser);
|
|
5023
|
+
bakedBody = await bakeDrawio(gvBody, drawioBrowser);
|
|
5024
|
+
} finally {
|
|
5025
|
+
await drawioBrowser.closeIfOpen();
|
|
5026
|
+
}
|
|
5027
|
+
return {
|
|
5028
|
+
html: bakedHtml, bodyHtml: bakedBody, parts, blocks,
|
|
5029
|
+
// v3.4.0 batch2 Task 6: an added field, not a shape change to the four
|
|
5030
|
+
// existing ones — every call site destructures the specific keys it
|
|
5031
|
+
// wants (checked across lib/editor/server.js and test/*.js), none reads
|
|
5032
|
+
// the object as a whole, so an addition here is backward compatible.
|
|
5033
|
+
drawioRefs: Array.from(drawioRefs, ([p, stamp]) => ({ path: p, stamp })),
|
|
5034
|
+
};
|
|
4477
5035
|
}
|
|
4478
5036
|
module.exports = { renderMarkdown };
|
|
4479
5037
|
|
|
@@ -4481,7 +5039,7 @@ module.exports = { renderMarkdown };
|
|
|
4481
5039
|
// in-process WASM engine. Loads the WASM module only when at least one dot
|
|
4482
5040
|
// block exists, so text-only docs pay nothing.
|
|
4483
5041
|
async function bakeGraphviz(htmlStr) {
|
|
4484
|
-
if (
|
|
5042
|
+
if (!GRAPHVIZ_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
|
|
4485
5043
|
let Graphviz;
|
|
4486
5044
|
try {
|
|
4487
5045
|
({ Graphviz } = require('@hpcc-js/wasm-graphviz'));
|
|
@@ -4490,7 +5048,7 @@ async function bakeGraphviz(htmlStr) {
|
|
|
4490
5048
|
return htmlStr;
|
|
4491
5049
|
}
|
|
4492
5050
|
const gv = await Graphviz.load();
|
|
4493
|
-
return htmlStr.replace(
|
|
5051
|
+
return htmlStr.replace(GRAPHVIZ_PLACEHOLDER.re, (m, b64) => {
|
|
4494
5052
|
const dotSrc = Buffer.from(b64, 'base64').toString('utf8');
|
|
4495
5053
|
try {
|
|
4496
5054
|
const svg = gv.dot(dotSrc)
|
|
@@ -4515,6 +5073,111 @@ function launchBrowser(puppeteer) {
|
|
|
4515
5073
|
});
|
|
4516
5074
|
}
|
|
4517
5075
|
|
|
5076
|
+
// v3.4.0 §4. A document with no drawio reference must not pay for opening
|
|
5077
|
+
// Chromium. `get()` launches on its first call only (and every later caller
|
|
5078
|
+
// awaits the same promise, so a document with several drawio placeholders
|
|
5079
|
+
// still opens exactly one browser); `closeIfOpen()` closes it only if `get()`
|
|
5080
|
+
// was ever actually called.
|
|
5081
|
+
function makeLazyBrowserRef() {
|
|
5082
|
+
let browserPromise = null;
|
|
5083
|
+
return {
|
|
5084
|
+
get() {
|
|
5085
|
+
if (!browserPromise) {
|
|
5086
|
+
const puppeteer = require('puppeteer');
|
|
5087
|
+
browserPromise = launchBrowser(puppeteer);
|
|
5088
|
+
}
|
|
5089
|
+
return browserPromise;
|
|
5090
|
+
},
|
|
5091
|
+
async closeIfOpen() {
|
|
5092
|
+
if (!browserPromise) return;
|
|
5093
|
+
const browser = await browserPromise;
|
|
5094
|
+
await browser.close();
|
|
5095
|
+
},
|
|
5096
|
+
};
|
|
5097
|
+
}
|
|
5098
|
+
|
|
5099
|
+
// Render every deferred drawio placeholder to inline SVG using a headless
|
|
5100
|
+
// Chromium running the vendored drawio viewer (lib/drawio.js). Every page is
|
|
5101
|
+
// baked, not just the one selected by the markdown fragment (`#PageName`),
|
|
5102
|
+
// so switching pages client-side is a pure `hidden`-attribute toggle with no
|
|
5103
|
+
// engine involved — see lib/drawio.js's own comment on why a Node-side XML
|
|
5104
|
+
// parser can't substitute for the real layout engine.
|
|
5105
|
+
async function bakeDrawio(htmlStr, browserRef) {
|
|
5106
|
+
if (!DRAWIO_PLACEHOLDER.probe.test(htmlStr)) return htmlStr;
|
|
5107
|
+
|
|
5108
|
+
// replace()'s callback can't be async, so collect the jobs first and apply
|
|
5109
|
+
// them (each with its own possible browser-page await) afterwards.
|
|
5110
|
+
const jobs = [];
|
|
5111
|
+
htmlStr.replace(DRAWIO_PLACEHOLDER.re, (m, b64, pageIdx, altB64) => {
|
|
5112
|
+
jobs.push({
|
|
5113
|
+
m,
|
|
5114
|
+
b64,
|
|
5115
|
+
pageIdx: Number(pageIdx),
|
|
5116
|
+
alt: Buffer.from(altB64, 'base64').toString('utf8'),
|
|
5117
|
+
});
|
|
5118
|
+
return m;
|
|
5119
|
+
});
|
|
5120
|
+
|
|
5121
|
+
for (const job of jobs) {
|
|
5122
|
+
const xml = Buffer.from(job.b64, 'base64').toString('utf8');
|
|
5123
|
+
const names = drawio.pageNamesOf(xml);
|
|
5124
|
+
let svgs;
|
|
5125
|
+
try {
|
|
5126
|
+
// One document shares one browser: browserRef.get() only really opens
|
|
5127
|
+
// Chromium on its first call.
|
|
5128
|
+
svgs = await drawio.bakeDrawioSvg(xml, { browser: await browserRef.get() });
|
|
5129
|
+
} catch (e) {
|
|
5130
|
+
// page.evaluate() can reject with a plain string rather than an Error
|
|
5131
|
+
// (observed: puppeteer surfacing a serialization failure as "buffer
|
|
5132
|
+
// error") — e.message would silently read as "undefined" then.
|
|
5133
|
+
const reason = (e && e.message) ? e.message : String(e);
|
|
5134
|
+
console.error('[WARN] drawio render failed:', reason);
|
|
5135
|
+
// A silent blank is the most expensive failure this project has learned
|
|
5136
|
+
// from (v3.3.0) — leave a visible placeholder naming the error instead.
|
|
5137
|
+
const msg = ('drawio 圖無法渲染:' + reason)
|
|
5138
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
5139
|
+
// The callback form, NOT a replacement string: `msg` is generated
|
|
5140
|
+
// content (a renderer error message), and String.replace() interprets
|
|
5141
|
+
// $$ / $& / $` / $' inside a replacement STRING even when the pattern
|
|
5142
|
+
// is a plain string. A callback's return value is never re-scanned.
|
|
5143
|
+
// See the same note on the success path below.
|
|
5144
|
+
htmlStr = htmlStr.replace(job.m,
|
|
5145
|
+
() => `<div class="drawio drawio-failed"><p>${msg}</p></div>`);
|
|
5146
|
+
continue;
|
|
5147
|
+
}
|
|
5148
|
+
const inner = svgs.map((svg, i) =>
|
|
5149
|
+
`<div class="drawio-page"${i === job.pageIdx ? '' : ' hidden'}>${svg}</div>`).join('');
|
|
5150
|
+
// Page names may contain any character (commas and quotes included), so
|
|
5151
|
+
// they travel as base64-encoded JSON rather than a delimited string — a
|
|
5152
|
+
// delimiter breaks on both "two names glued together" and "a name that
|
|
5153
|
+
// happens to contain the delimiter", and a literal control character
|
|
5154
|
+
// living in the document is invisible to boot.
|
|
5155
|
+
const attr = Buffer.from(JSON.stringify(names), 'utf8').toString('base64');
|
|
5156
|
+
// v3.4.0 batch2 final review R5 — the author already wrote a name in
|
|
5157
|
+
// ``; give the baked box that name.
|
|
5158
|
+
// The role differs by page count on purpose: a multi-page box has the
|
|
5159
|
+
// sheet bar's <button>s appended INSIDE it at runtime, and role="img"
|
|
5160
|
+
// would take them off the accessibility tree entirely, so those boxes get
|
|
5161
|
+
// role="group" — also nameable, children still exposed. No alt text means
|
|
5162
|
+
// no attributes at all: an empty accessible name is worse than none.
|
|
5163
|
+
const named = job.alt
|
|
5164
|
+
? ` role="${names.length >= 2 ? 'group' : 'img'}" aria-label="${job.alt}"`
|
|
5165
|
+
: '';
|
|
5166
|
+
// v3.4.0 batch2 final review B2 — the callback form is load-bearing.
|
|
5167
|
+
// `inner` is the baked SVG, i.e. arbitrary diagram text, and a
|
|
5168
|
+
// replacement STRING has $$ / $& / $` / $' interpreted in it even when
|
|
5169
|
+
// the pattern is a plain string: a `$$` label silently collapsed to a
|
|
5170
|
+
// single `$`, and a `$&` label (the byte sequence any label with `$`
|
|
5171
|
+
// before `&` produces once the SVG escapes the `&`) spliced this very
|
|
5172
|
+
// placeholder — the whole .drawio file's base64 — into the middle of a
|
|
5173
|
+
// <text> element. bakeGraphviz() above was always a callback; this is the
|
|
5174
|
+
// same rule. Keep it a callback.
|
|
5175
|
+
htmlStr = htmlStr.replace(job.m,
|
|
5176
|
+
() => `<div class="drawio" data-drawio-pages="${attr}"${named}>${inner}</div>`);
|
|
5177
|
+
}
|
|
5178
|
+
return htmlStr;
|
|
5179
|
+
}
|
|
5180
|
+
|
|
4518
5181
|
// Pre-render mermaid/wavedrom to inert SVG using headless Chromium, then strip
|
|
4519
5182
|
// the diagram-engine runtime scripts so the output HTML carries no diagram JS.
|
|
4520
5183
|
async function bakeDiagrams(htmlStr, dstPath) {
|