ntk 3.10.1 → 4.0.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 CHANGED
@@ -54,9 +54,8 @@ text costs about a byte per glyph on the wire. Font names resolve through
54
54
  fontconfig (`fc-match`). Very large and continuously animated sizes render
55
55
  as server-side trapezoids instead of cached bitmaps. A `TextLayout` engine
56
56
  wraps styled text to a target width, a `MarkdownView` widget renders
57
- markdown (with syntax-highlighted code fences, mermaid flowchart/sequence
58
- diagrams, and KaTeX math via `TexView`) on top of it — see
59
- [docs/text.md](docs/text.md), [docs/mermaid.md](docs/mermaid.md) and
57
+ markdown (with syntax-highlighted code fences and KaTeX math via
58
+ `TexView`) on top of it — see [docs/text.md](docs/text.md) and
60
59
  [docs/tex.md](docs/tex.md).
61
60
 
62
61
  PNG/JPEG images decode client-side (`loadImage`) and composite server-side
@@ -236,7 +236,14 @@ class RenderingContext2d {
236
236
 
237
237
  // notify a double-buffered window that its backing content changed
238
238
  _markDirty() {
239
- if (typeof this.window._markDirty === 'function') this.window._markDirty();
239
+ if (typeof this.window._markDirty !== 'function') return;
240
+ // The clip bounds everything this operation could have touched, so it is
241
+ // also the region the window has to blit. Reporting it lets the present
242
+ // copy the part of the backing store that changed instead of all of it —
243
+ // a hover repaint of two tab headers used to blit the whole window. A
244
+ // context with no rectangular clip reports nothing, and the window falls
245
+ // back to a full blit, which is what any drawing outside a clip needs.
246
+ this.window._markDirty(this._clips.length ? this._clipRect() : null);
240
247
  }
241
248
 
242
249
  // html context2d compatibility: canvas.getContext('2d').canvas == canvas
@@ -1,7 +1,6 @@
1
1
  import { TextLayout } from '../text/layout.js';
2
2
  import { tokenize } from './highlight.js';
3
3
  import { parseMarkdown } from './markdown.js';
4
- import { layoutMermaid, parseMermaid } from './mermaid.js';
5
4
  import { layoutTex } from './tex.js';
6
5
 
7
6
  const MATH_LANGS = new Set(['math', 'tex', 'latex', 'katex']);
@@ -51,10 +50,8 @@ const DEFAULT_THEME = {
51
50
  * wnd.map();
52
51
  *
53
52
  * Standalone use (no window attach): `view.layout(width)` then
54
- * `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Content that
55
- * arrives asynchronously (mermaid models) invalidates the layout — pass
56
- * `onInvalidate` to re-layout/redraw when that happens (window mode
57
- * repaints automatically).
53
+ * `view.draw(ctx, x, y)`; `view.contentHeight` after layout. Layout is
54
+ * synchronous throughout — nothing here loads in the background.
58
55
  */
59
56
  export default class MarkdownView {
60
57
  constructor(window, opts = {}) {
@@ -73,10 +70,6 @@ export default class MarkdownView {
73
70
  /** called with (href, event) when a rendered link is clicked (window
74
71
  * mode wires mousedown automatically; standalone callers use linkAt) */
75
72
  this.onLink = opts.onLink ?? null;
76
- /** called when async content (a mermaid model) invalidates the layout;
77
- * standalone hosts re-layout/redraw here — window mode also repaints
78
- * on its own */
79
- this.onInvalidate = opts.onInvalidate ?? null;
80
73
 
81
74
  if (this.window) {
82
75
  this._ctx = this.window.getContext('2d');
@@ -105,41 +98,11 @@ export default class MarkdownView {
105
98
 
106
99
  setMarkdown(src) {
107
100
  this._blocks = parseMarkdown(src);
108
- this._mermaid = new Map();
109
101
  this._layoutWidth = -1;
110
102
  if (this.window && this.window._mapped) this.render();
111
103
  return this;
112
104
  }
113
105
 
114
- // ```mermaid fences parse asynchronously (the mermaid grammar loads
115
- // lazily); the fence shows as a code block until the model arrives, then
116
- // layout is invalidated — same pattern as image loading in HtmlView
117
- _mermaidEntry(text) {
118
- if (!this._mermaid) this._mermaid = new Map();
119
- let entry = this._mermaid.get(text);
120
- if (!entry) {
121
- entry = { model: null, failed: false };
122
- this._mermaid.set(text, entry);
123
- parseMermaid(text).then(
124
- (model) => {
125
- entry.model = model;
126
- this._invalidate();
127
- },
128
- () => {
129
- entry.failed = true; // unsupported/invalid: stays a code block
130
- }
131
- );
132
- }
133
- return entry;
134
- }
135
-
136
- /** async content arrived: drop the cached layout, repaint and/or notify */
137
- _invalidate() {
138
- this._layoutWidth = -1;
139
- if (this.window && this.window._mapped) this.render();
140
- if (this.onInvalidate) this.onInvalidate();
141
- }
142
-
143
106
  /** lay content out for a container width; returns total content height */
144
107
  layout(width) {
145
108
  const fonts = this._fonts();
@@ -208,31 +171,6 @@ export default class MarkdownView {
208
171
  }
209
172
 
210
173
  case 'code': {
211
- // ```mermaid fences render as diagrams (flowchart / sequence);
212
- // unsupported types or parse errors fall back to the code block
213
- if ((block.lang || '').toLowerCase() === 'mermaid') {
214
- let box = null;
215
- const entry = this._mermaidEntry(block.text);
216
- if (entry.model) {
217
- try {
218
- box = layoutMermaid(entry.model, {
219
- fonts,
220
- size: Math.round(t.size * 0.82),
221
- family: t.family,
222
- maxWidth: width,
223
- theme: { labelBg: t.background, text: t.color }
224
- });
225
- } catch {
226
- // fall through to the plain code block
227
- }
228
- }
229
- if (box) {
230
- const bx = x + Math.max(0, (width - box.width) / 2);
231
- this._items.push({ kind: 'tex', x: bx, y, box });
232
- y += box.height;
233
- break;
234
- }
235
- }
236
174
  // ```math / ```latex fences render as display-mode formulas
237
175
  if (MATH_LANGS.has((block.lang || '').toLowerCase())) {
238
176
  let box = null;
package/lib/window.js CHANGED
@@ -6,6 +6,108 @@ import Drawable from './drawable.js';
6
6
  import Pixmap from './pixmap.js';
7
7
  import * as xevents from './events_map.js';
8
8
 
9
+ /**
10
+ * How many rectangles a frame's dirty region is allowed to hold.
11
+ *
12
+ * Drawing reports one region per operation, so a repaint reports hundreds, and
13
+ * an uncapped list would turn every one of them into its own CopyArea. Past
14
+ * the cap the cheapest pair is merged (see addDirtyRect), which keeps
15
+ * genuinely separated clusters apart while adjacent ones coalesce.
16
+ */
17
+ const MAX_DIRTY_RECTS = 8;
18
+
19
+ /**
20
+ * How much of the surrounding box a split has to save to be worth making.
21
+ *
22
+ * Several rectangles are several CopyArea requests, and they only beat the one
23
+ * box around them when they are meaningfully smaller than it: two tab headers
24
+ * side by side describe nearly the same area either way, two corners of the
25
+ * window do not. Below this fraction the region is collapsed to its box.
26
+ */
27
+ const SPLIT_SAVING = 0.75;
28
+
29
+ function rectArea(r) {
30
+ return Math.max(0, r.w) * Math.max(0, r.h);
31
+ }
32
+
33
+ function coversRect(outer, inner) {
34
+ return (
35
+ inner.x >= outer.x &&
36
+ inner.y >= outer.y &&
37
+ inner.x + inner.w <= outer.x + outer.w &&
38
+ inner.y + inner.h <= outer.y + outer.h
39
+ );
40
+ }
41
+
42
+ function mergeRects(a, b) {
43
+ const x = Math.min(a.x, b.x);
44
+ const y = Math.min(a.y, b.y);
45
+ const right = Math.max(a.x + a.w, b.x + b.w);
46
+ const bottom = Math.max(a.y + a.h, b.y + b.h);
47
+ return { x, y, w: right - x, h: bottom - y };
48
+ }
49
+
50
+ /** The box around a non-empty list of rectangles. */
51
+ function rectsBounds(rects) {
52
+ let out = rects[0];
53
+ for (let i = 1; i < rects.length; i++) out = mergeRects(out, rects[i]);
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Add one rectangle to a capped list, returning a new list.
59
+ *
60
+ * A rectangle already covered by one in the list adds nothing, which is the
61
+ * common case by a wide margin: consecutive operations under the same clip all
62
+ * report the same region. Otherwise it goes on the end, and if that overflows
63
+ * the cap the pair whose merge wastes the least area is merged — waste being
64
+ * the area the merged rectangle covers that neither of the two did, so
65
+ * overlapping and adjacent pairs go first and far-apart ones last.
66
+ */
67
+ function addDirtyRect(rects, add) {
68
+ for (const r of rects) {
69
+ if (coversRect(r, add)) return rects;
70
+ }
71
+ const out = rects.filter((r) => !coversRect(add, r));
72
+ out.push({ x: add.x, y: add.y, w: add.w, h: add.h });
73
+ if (out.length <= MAX_DIRTY_RECTS) return out;
74
+ let bestI = 0;
75
+ let bestJ = 1;
76
+ let bestMerged = null;
77
+ let bestWaste = Infinity;
78
+ for (let i = 0; i < out.length; i++) {
79
+ for (let j = i + 1; j < out.length; j++) {
80
+ const merged = mergeRects(out[i], out[j]);
81
+ const waste = rectArea(merged) - rectArea(out[i]) - rectArea(out[j]);
82
+ if (waste < bestWaste) {
83
+ bestWaste = waste;
84
+ bestI = i;
85
+ bestJ = j;
86
+ bestMerged = merged;
87
+ }
88
+ }
89
+ }
90
+ out[bestI] = bestMerged;
91
+ out.splice(bestJ, 1);
92
+ return out;
93
+ }
94
+
95
+ /**
96
+ * Union a reported region into a frame's dirty region, where `undefined` is
97
+ * "nothing yet" and `null` is "unbounded".
98
+ *
99
+ * The asymmetry is the whole point: once any operation declines to say where it
100
+ * drew, the region cannot be trusted to bound the frame, and it has to stay
101
+ * unbounded no matter what is unioned into it afterwards. Getting that
102
+ * backwards means blitting less than was drawn, which leaves stale pixels on
103
+ * screen — so the absorbing state is the safe one.
104
+ */
105
+ function unionDirty(current, add) {
106
+ if (current === null || add === null || add === undefined) return null;
107
+ if (current === undefined) return [{ x: add.x, y: add.y, w: add.w, h: add.h }];
108
+ return addDirtyRect(current, add);
109
+ }
110
+
9
111
  // X window attributes forwarded verbatim from constructor args into the
10
112
  // CreateWindow value list (node-x11 valueMask names, see x11
11
113
  // lib/corereqs.js). Deliberately not forwarded:
@@ -397,9 +499,25 @@ export default class Window extends Drawable {
397
499
  this._scheduleFrame();
398
500
  }
399
501
 
400
- // called by rendering contexts after each drawing operation
401
- _markDirty() {
502
+ /**
503
+ * Called by rendering contexts after each drawing operation.
504
+ *
505
+ * `bounds` is the region that operation could have touched — a context
506
+ * reports its clip rectangle, which by definition contains everything it
507
+ * drew. The regions accumulate, and the next blit copies just those instead
508
+ * of the whole backing store: a repaint of two tab headers is a 125x31
509
+ * CopyArea rather than a 1000x700 one. They accumulate as a short list of
510
+ * rectangles rather than one box around them all, so two repaints at
511
+ * opposite corners of the window no longer drag everything between them
512
+ * along. An operation that reports nothing, because it was not clipped,
513
+ * gives up the optimisation for this frame and the blit covers everything —
514
+ * the safe direction, and the reason this needs no cooperation from the
515
+ * caller to be correct.
516
+ */
517
+ _markDirty(bounds) {
402
518
  if (!this._backing) return;
519
+ if (!this._dirty) this._dirtyRegion = undefined;
520
+ this._dirtyRegion = unionDirty(this._dirtyRegion, bounds);
403
521
  this._dirty = true;
404
522
  this._backingValid = true;
405
523
  if (this._presentScheduled) return;
@@ -591,14 +709,50 @@ export default class Window extends Drawable {
591
709
  this._presentPending = false;
592
710
  if (!this._backing) return;
593
711
  this._dirty = false;
712
+ const region = this._dirtyRegion;
713
+ this._dirtyRegion = undefined;
594
714
  const w = Math.min(this.width, this._backing.width);
595
715
  const h = Math.min(this.height, this._backing.height);
716
+ // Clamped to both drawables: CopyArea outside either is a no-op region at
717
+ // best, and the backing store is grow-only so it can be larger than the
718
+ // window after a shrink. A rectangle that survives clamping with no area
719
+ // left is dropped; if that leaves nothing, nothing needs copying.
720
+ const clamped = [];
721
+ for (const rect of region ?? [{ x: 0, y: 0, w, h }]) {
722
+ const x0 = Math.max(0, Math.floor(rect.x));
723
+ const y0 = Math.max(0, Math.floor(rect.y));
724
+ const x1 = Math.min(w, Math.ceil(rect.x + rect.w));
725
+ const y1 = Math.min(h, Math.ceil(rect.y + rect.h));
726
+ if (x1 > x0 && y1 > y0) clamped.push({ x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
727
+ }
728
+ if (!clamped.length) return;
729
+ const rects = this._blitList(clamped);
596
730
  // a paced frame can fire after the connection started closing
597
731
  safeRelease(this.X, () => {
598
- this.X.CopyArea(this._backing.id, this.id, this._presentGc, 0, 0, 0, 0, w, h);
732
+ for (const r of rects) {
733
+ this.X.CopyArea(this._backing.id, this.id, this._presentGc, r.x, r.y, r.x, r.y, r.w, r.h);
734
+ }
599
735
  });
600
736
  }
601
737
 
738
+ /**
739
+ * The rectangles a present actually copies, given the ones drawing reported.
740
+ *
741
+ * The list is not used as it stands: each rectangle is a request, and a
742
+ * region whose pieces nearly fill the box around them is better served by
743
+ * one copy of the box. So the split is kept only when it saves enough of
744
+ * that box to pay for the extra requests, and collapsed otherwise. Both
745
+ * directions cover every reported pixel, so this is a cost decision and
746
+ * never a correctness one.
747
+ */
748
+ _blitList(rects) {
749
+ if (rects.length < 2) return rects;
750
+ const box = rectsBounds(rects);
751
+ let sum = 0;
752
+ for (const r of rects) sum += rectArea(r);
753
+ return sum > rectArea(box) * SPLIT_SAVING ? [box] : rects;
754
+ }
755
+
602
756
  map() {
603
757
  this.X.MapWindow(this.id);
604
758
  return this;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "3.10.1",
3
+ "version": "4.0.0",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",
@@ -33,7 +33,6 @@
33
33
  "node": ">=20.19.0"
34
34
  },
35
35
  "dependencies": {
36
- "@dagrejs/dagre": "^3.0.0",
37
36
  "bidi-js": "^1.0.3",
38
37
  "canvas-fontstyle": "^1.0.1",
39
38
  "css-select": "^7.0.0",
@@ -47,7 +46,6 @@
47
46
  "keysym": "0.0.6",
48
47
  "linebreak": "^1.1.0",
49
48
  "marked": "^18.0.7",
50
- "mermaid": "^11.16.0",
51
49
  "parse-color": "^1.0.0",
52
50
  "pngjs": "^7.0.0",
53
51
  "postcss": "^8.5.23",
@@ -1,720 +0,0 @@
1
- // Mermaid diagram rendering for the markdown widget (```mermaid fences).
2
- //
3
- // Parsing uses the mermaid package's own grammar (lazily imported, run
4
- // headless — see loadMermaid()); mermaid's *renderer* is the only part
5
- // that needs a browser, so the two most-used diagram types are drawn
6
- // natively instead: **flowcharts** laid out by @dagrejs/dagre and
7
- // **sequence diagrams** with a classic column/row layout. Text is measured
8
- // with the ntk font pipeline and everything draws through the 2d context —
9
- // no SVG round-trip, no browser. Diagram types without a native renderer
10
- // here throw from parseMermaid(), and MarkdownView falls back to a plain
11
- // code block (same contract as invalid math).
12
- //
13
- // const model = await parseMermaid('flowchart LR\n A[Start] --> B{OK?}');
14
- // const box = layoutMermaid(model, { fonts });
15
- // box.draw(ctx, x, y); // box.width / box.height
16
- import dagre from '@dagrejs/dagre';
17
-
18
- const DEFAULT_THEME = {
19
- nodeFill: '#ececff',
20
- nodeBorder: '#9370db',
21
- line: '#555555',
22
- text: '#333333',
23
- labelBg: '#ffffff', // behind edge labels; set to the page background
24
- actorFill: '#ececff',
25
- actorBorder: '#9370db',
26
- noteFill: '#fff5ad',
27
- noteBorder: '#b8b855',
28
- frameBorder: '#999999',
29
- frameLabel: '#666666'
30
- };
31
-
32
- // ---------------------------------------------------------------------------
33
- // parsing: the mermaid package's own grammar, run headless
34
- //
35
- // mermaid's renderer needs a browser (getBBox and friends), but its parsers
36
- // and diagram DBs work in Node once DOMPurify — which mermaid only uses to
37
- // sanitize label HTML for browser injection — is shimmed to the identity.
38
- // Labels here never reach a DOM: they are drawn as plain text.
39
-
40
- let mermaidLoad = null;
41
-
42
- function loadMermaid() {
43
- if (!mermaidLoad) {
44
- mermaidLoad = (async () => {
45
- const DOMPurify = (await import('dompurify')).default;
46
- if (typeof DOMPurify.sanitize !== 'function') {
47
- DOMPurify.sanitize = (s) => String(s);
48
- DOMPurify.addHook = () => {};
49
- }
50
- const mermaid = (await import('mermaid')).default;
51
- mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
52
- return mermaid;
53
- })();
54
- }
55
- return mermaidLoad;
56
- }
57
-
58
- function cleanLabel(text) {
59
- return String(text ?? '')
60
- .replace(/<br\s*\/?>/gi, '\n')
61
- .trim();
62
- }
63
-
64
- const SHAPE_MAP = {
65
- square: 'rect',
66
- rect: 'rect',
67
- round: 'round',
68
- rounded: 'round',
69
- stadium: 'stadium',
70
- subroutine: 'subroutine',
71
- cylinder: 'cylinder',
72
- cyl: 'cylinder',
73
- circle: 'circle',
74
- doublecircle: 'circle',
75
- dbl_circ: 'circle',
76
- diamond: 'diamond',
77
- question: 'diamond',
78
- decision: 'diamond',
79
- hexagon: 'hexagon',
80
- hex: 'hexagon'
81
- };
82
-
83
- function normalizeFlowchart(db) {
84
- const nodes = [];
85
- for (const [id, v] of db.getVertices()) {
86
- const kind = v.type ?? v.shape;
87
- nodes.push({ id, shape: SHAPE_MAP[kind] ?? 'rect', label: cleanLabel(v.text ?? id) || id });
88
- }
89
- const edges = db.getEdges().map((e) => ({
90
- from: e.start,
91
- to: e.end,
92
- label: cleanLabel(e.text) || null,
93
- style: e.stroke === 'dotted' ? 'dotted' : e.stroke === 'thick' ? 'thick' : 'solid',
94
- arrow: e.type !== 'arrow_open'
95
- }));
96
- if (nodes.length === 0) throw new Error('flowchart: no nodes');
97
- const dir = db.getDirection?.() ?? 'TB';
98
- const rankdir = { TD: 'TB' }[dir] ?? (['TB', 'BT', 'LR', 'RL'].includes(dir) ? dir : 'TB');
99
- return { type: 'flowchart', rankdir, nodes, edges, warnings: [] };
100
- }
101
-
102
- // sequenceDb LINETYPE values (stable across mermaid 8-11)
103
- const LT = {
104
- SOLID: 0, DOTTED: 1, NOTE: 2, SOLID_CROSS: 3, DOTTED_CROSS: 4,
105
- SOLID_OPEN: 5, DOTTED_OPEN: 6,
106
- LOOP_START: 10, LOOP_END: 11, ALT_START: 12, ALT_ELSE: 13, ALT_END: 14,
107
- OPT_START: 15, OPT_END: 16, ACTIVE_START: 17, ACTIVE_END: 18,
108
- PAR_START: 19, PAR_AND: 20, PAR_END: 21, RECT_START: 22, RECT_END: 23,
109
- SOLID_POINT: 24, DOTTED_POINT: 25, AUTONUMBER: 26,
110
- CRITICAL_START: 27, CRITICAL_OPTION: 28, CRITICAL_END: 29,
111
- BREAK_START: 30, BREAK_END: 31, PAR_OVER_START: 32
112
- };
113
-
114
- const SEQ_LINE = {
115
- [LT.SOLID]: { dotted: false, head: 'arrow' },
116
- [LT.DOTTED]: { dotted: true, head: 'arrow' },
117
- [LT.SOLID_CROSS]: { dotted: false, head: 'cross' },
118
- [LT.DOTTED_CROSS]: { dotted: true, head: 'cross' },
119
- [LT.SOLID_OPEN]: { dotted: false, head: 'none' },
120
- [LT.DOTTED_OPEN]: { dotted: true, head: 'none' },
121
- [LT.SOLID_POINT]: { dotted: false, head: 'open' },
122
- [LT.DOTTED_POINT]: { dotted: true, head: 'open' }
123
- };
124
-
125
- const FRAME_START = {
126
- [LT.LOOP_START]: 'loop', [LT.ALT_START]: 'alt', [LT.OPT_START]: 'opt',
127
- [LT.PAR_START]: 'par', [LT.PAR_OVER_START]: 'par',
128
- [LT.CRITICAL_START]: 'critical', [LT.BREAK_START]: 'break'
129
- };
130
- const FRAME_END = new Set([LT.LOOP_END, LT.ALT_END, LT.OPT_END, LT.PAR_END, LT.CRITICAL_END, LT.BREAK_END]);
131
- const FRAME_DIVIDER = new Set([LT.ALT_ELSE, LT.PAR_AND, LT.CRITICAL_OPTION]);
132
-
133
- function normalizeSequence(db) {
134
- const participants = [];
135
- for (const [id, a] of db.getActors()) {
136
- participants.push({ id, label: cleanLabel(a.description) || id, actor: a.type === 'actor' });
137
- }
138
- if (participants.length === 0) throw new Error('sequence: no participants');
139
-
140
- const items = [];
141
- const warnings = [];
142
- let numbering = db.showSequenceNumbers?.() ? 1 : 0;
143
- const actorId = (v) => (typeof v === 'object' && v !== null ? v.actor : v);
144
-
145
- for (const m of db.getMessages()) {
146
- const t = m.type;
147
- if (t === LT.AUTONUMBER) {
148
- numbering = m.message?.visible === false ? 0 : 1;
149
- continue;
150
- }
151
- if (t === LT.NOTE) {
152
- const pos = m.placement === 0 ? 'left of' : m.placement === 1 ? 'right of' : 'over';
153
- const ids = [actorId(m.from)];
154
- const to = actorId(m.to);
155
- if (to && to !== ids[0]) ids.push(to);
156
- items.push({ kind: 'note', pos, ids, text: cleanLabel(m.message) });
157
- continue;
158
- }
159
- if (t in FRAME_START) {
160
- items.push({ kind: 'frame', frame: FRAME_START[t], text: cleanLabel(m.message ?? '') });
161
- continue;
162
- }
163
- if (FRAME_END.has(t)) {
164
- items.push({ kind: 'frameEnd' });
165
- continue;
166
- }
167
- if (FRAME_DIVIDER.has(t)) {
168
- items.push({ kind: 'divider', text: cleanLabel(m.message ?? '') });
169
- continue;
170
- }
171
- const line = SEQ_LINE[t];
172
- if (line) {
173
- let text = cleanLabel(m.message);
174
- if (numbering) text = `${numbering++}. ${text}`;
175
- items.push({ kind: 'message', from: actorId(m.from), to: actorId(m.to), text, ...line });
176
- continue;
177
- }
178
- if (t !== LT.ACTIVE_START && t !== LT.ACTIVE_END && t !== LT.RECT_START && t !== LT.RECT_END) {
179
- warnings.push(`sequence: ignored message type ${t}`);
180
- }
181
- }
182
- return { type: 'sequence', participants, items, warnings };
183
- }
184
-
185
- /**
186
- * Parse mermaid source with the mermaid package's own parser (loaded
187
- * lazily, headless) and normalize the diagram DB into the model consumed
188
- * by layoutMermaid(). Throws for diagram types without a native renderer
189
- * here (anything but flowchart / sequence).
190
- */
191
- export async function parseMermaid(text) {
192
- const mermaid = await loadMermaid();
193
- const diagram = await mermaid.mermaidAPI.getDiagramFromText(String(text));
194
- const type = diagram.type;
195
- if (/^flowchart/.test(type) || type === 'graph') return normalizeFlowchart(diagram.db);
196
- if (/^sequence/.test(type)) return normalizeSequence(diagram.db);
197
- throw new Error(`mermaid: no native renderer for diagram type "${type}"`);
198
- }
199
-
200
- // ---------------------------------------------------------------------------
201
- // text measurement helpers
202
-
203
- function makeMeasurer(fonts, family, size) {
204
- const style = { family, size, weight: 'normal', style: 'normal' };
205
- style.font = fonts.match(family, style);
206
- const metrics = style.font.metrics(size);
207
- const lineH = Math.ceil((metrics.ascent + metrics.descent) * 1.25);
208
- return {
209
- style,
210
- lineH,
211
- ascent: metrics.ascent,
212
- width: (s) => (s ? fonts.shape(s, style).width : 0),
213
- linesOf: (s) => String(s ?? '').split('\n'),
214
- block(s) {
215
- const lines = this.linesOf(s);
216
- let w = 0;
217
- for (const l of lines) w = Math.max(w, this.width(l));
218
- return { lines, w, h: lines.length * lineH };
219
- }
220
- };
221
- }
222
-
223
- function drawTextBlock(ctx, meas, family, size, color, lines, cx, topY) {
224
- ctx.fillStyle = color;
225
- ctx.font = `${size}px ${family}`;
226
- ctx.textAlign = 'left';
227
- ctx.textBaseline = 'alphabetic';
228
- let y = topY + meas.ascent;
229
- for (const line of lines) {
230
- const w = meas.width(line);
231
- ctx.fillText(line, cx - w / 2, y);
232
- y += meas.lineH;
233
- }
234
- }
235
-
236
- // manual dashes (the 2d context has no setLineDash)
237
- function dashedLine(ctx, x0, y0, x1, y1, dash = 4, gap = 4) {
238
- const dx = x1 - x0;
239
- const dy = y1 - y0;
240
- const len = Math.hypot(dx, dy);
241
- if (!len) return;
242
- const ux = dx / len;
243
- const uy = dy / len;
244
- let t = 0;
245
- ctx.beginPath();
246
- while (t < len) {
247
- const e = Math.min(t + dash, len);
248
- ctx.moveTo(x0 + ux * t, y0 + uy * t);
249
- ctx.lineTo(x0 + ux * e, y0 + uy * e);
250
- t = e + gap;
251
- }
252
- ctx.stroke();
253
- }
254
-
255
- function dashedPolyline(ctx, pts, dash = 4, gap = 4) {
256
- for (let i = 0; i + 1 < pts.length; i++) dashedLine(ctx, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y, dash, gap);
257
- }
258
-
259
- function arrowHead(ctx, x, y, fromX, fromY, size, color) {
260
- const a = Math.atan2(y - fromY, x - fromX);
261
- const s = size;
262
- ctx.fillStyle = color;
263
- ctx.beginPath();
264
- ctx.moveTo(x, y);
265
- ctx.lineTo(x - s * Math.cos(a - 0.45), y - s * Math.sin(a - 0.45));
266
- ctx.lineTo(x - s * Math.cos(a + 0.45), y - s * Math.sin(a + 0.45));
267
- ctx.closePath();
268
- ctx.fill();
269
- }
270
-
271
- // ---------------------------------------------------------------------------
272
- // flowchart layout + drawing
273
-
274
- function nodeSize(node, meas) {
275
- const { w, h } = meas.block(node.label);
276
- const padX = 14;
277
- const padY = 9;
278
- switch (node.shape) {
279
- case 'diamond':
280
- return { w: w * 1.5 + 28, h: h * 2 + 18 };
281
- case 'circle': {
282
- const d = Math.max(w, h) + 24;
283
- return { w: d, h: d };
284
- }
285
- case 'hexagon':
286
- return { w: w + padX * 2 + h + 12, h: h + padY * 2 };
287
- case 'cylinder':
288
- return { w: w + padX * 2, h: h + padY * 2 + 14 };
289
- case 'subroutine':
290
- return { w: w + padX * 2 + 12, h: h + padY * 2 };
291
- case 'stadium':
292
- return { w: w + padX * 2 + h, h: h + padY * 2 };
293
- default:
294
- return { w: w + padX * 2, h: h + padY * 2 };
295
- }
296
- }
297
-
298
- function nodePath(ctx, node, x, y, w, h) {
299
- // (x, y) is the node center
300
- ctx.beginPath();
301
- switch (node.shape) {
302
- case 'round':
303
- ctx.roundRect(x - w / 2, y - h / 2, w, h, 6);
304
- break;
305
- case 'stadium':
306
- ctx.roundRect(x - w / 2, y - h / 2, w, h, h / 2);
307
- break;
308
- case 'circle':
309
- ctx.arc(x, y, w / 2, 0, Math.PI * 2);
310
- break;
311
- case 'diamond':
312
- ctx.moveTo(x, y - h / 2);
313
- ctx.lineTo(x + w / 2, y);
314
- ctx.lineTo(x, y + h / 2);
315
- ctx.lineTo(x - w / 2, y);
316
- ctx.closePath();
317
- break;
318
- case 'hexagon': {
319
- const c = h / 2;
320
- ctx.moveTo(x - w / 2 + c, y - h / 2);
321
- ctx.lineTo(x + w / 2 - c, y - h / 2);
322
- ctx.lineTo(x + w / 2, y);
323
- ctx.lineTo(x + w / 2 - c, y + h / 2);
324
- ctx.lineTo(x - w / 2 + c, y + h / 2);
325
- ctx.lineTo(x - w / 2, y);
326
- ctx.closePath();
327
- break;
328
- }
329
- default:
330
- ctx.rect(x - w / 2, y - h / 2, w, h);
331
- }
332
- }
333
-
334
- function layoutFlowchart(model, meas, family, size, theme) {
335
- const g = new dagre.graphlib.Graph({ multigraph: true });
336
- // separations follow the font size so shrink-to-fit scales ~linearly
337
- g.setGraph({
338
- rankdir: model.rankdir,
339
- nodesep: Math.round(size * 2.8),
340
- ranksep: Math.round(size * 3),
341
- marginx: 6,
342
- marginy: 6
343
- });
344
- g.setDefaultEdgeLabel(() => ({}));
345
-
346
- for (const node of model.nodes) {
347
- const { w, h } = nodeSize(node, meas);
348
- g.setNode(node.id, { width: w, height: h, node });
349
- }
350
- model.edges.forEach((edge, i) => {
351
- const attrs = { edge };
352
- if (edge.label) {
353
- const { w, h } = meas.block(edge.label);
354
- attrs.width = w + 8;
355
- attrs.height = h + 4;
356
- attrs.labelpos = 'c';
357
- }
358
- g.setEdge(edge.from, edge.to, attrs, `e${i}`);
359
- });
360
-
361
- dagre.layout(g);
362
- const gw = Math.ceil(g.graph().width ?? 0);
363
- const gh = Math.ceil(g.graph().height ?? 0);
364
-
365
- return {
366
- type: 'flowchart',
367
- width: gw,
368
- height: gh,
369
- warnings: model.warnings,
370
- draw(ctx, ox = 0, oy = 0) {
371
- // edges under nodes
372
- for (const e of g.edges()) {
373
- const info = g.edge(e);
374
- const edge = info.edge;
375
- const pts = info.points.map((p) => ({ x: p.x + ox, y: p.y + oy }));
376
- ctx.strokeStyle = theme.line;
377
- ctx.lineWidth = edge.style === 'thick' ? 2.6 : 1.4;
378
- if (edge.style === 'dotted') {
379
- dashedPolyline(ctx, pts, 3, 3.5);
380
- } else {
381
- ctx.beginPath();
382
- ctx.moveTo(pts[0].x, pts[0].y);
383
- if (pts.length === 2) {
384
- ctx.lineTo(pts[1].x, pts[1].y);
385
- } else {
386
- for (let i = 1; i < pts.length - 1; i++) {
387
- const mx = (pts[i].x + pts[i + 1].x) / 2;
388
- const my = (pts[i].y + pts[i + 1].y) / 2;
389
- if (i === pts.length - 2) ctx.quadraticCurveTo(pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
390
- else ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my);
391
- }
392
- }
393
- ctx.stroke();
394
- }
395
- if (edge.arrow) {
396
- const tip = pts[pts.length - 1];
397
- const before = pts[pts.length - 2] ?? pts[0];
398
- arrowHead(ctx, tip.x, tip.y, before.x, before.y, 7 + (edge.style === 'thick' ? 2 : 0), theme.line);
399
- }
400
- if (edge.label) {
401
- const { lines, w, h } = meas.block(edge.label);
402
- const lx = info.x + ox;
403
- const ly = info.y + oy;
404
- ctx.fillStyle = theme.labelBg;
405
- ctx.fillRect(Math.round(lx - w / 2 - 3), Math.round(ly - h / 2 - 1), Math.ceil(w + 6), Math.ceil(h + 2));
406
- drawTextBlock(ctx, meas, family, size, theme.text, lines, lx, ly - h / 2);
407
- }
408
- }
409
- // nodes
410
- for (const id of g.nodes()) {
411
- const info = g.node(id);
412
- const node = info.node;
413
- const x = info.x + ox;
414
- const y = info.y + oy;
415
- ctx.fillStyle = theme.nodeFill;
416
- ctx.strokeStyle = theme.nodeBorder;
417
- ctx.lineWidth = 1.3;
418
- if (node.shape === 'cylinder') {
419
- const capH = 10;
420
- const w = info.width;
421
- const h = info.height;
422
- const top = y - h / 2;
423
- ctx.beginPath();
424
- ctx.rect(x - w / 2, top + capH / 2, w, h - capH);
425
- ctx.fill();
426
- ctx.beginPath();
427
- ctx.moveTo(x - w / 2, top + capH / 2);
428
- ctx.lineTo(x - w / 2, top + h - capH / 2);
429
- ctx.moveTo(x + w / 2, top + capH / 2);
430
- ctx.lineTo(x + w / 2, top + h - capH / 2);
431
- ctx.stroke();
432
- ctx.beginPath();
433
- ctx.ellipse(x, top + h - capH / 2, w / 2, capH / 2, 0, 0, Math.PI * 2);
434
- ctx.fill();
435
- ctx.stroke();
436
- ctx.beginPath();
437
- ctx.ellipse(x, top + capH / 2, w / 2, capH / 2, 0, 0, Math.PI * 2);
438
- ctx.fill();
439
- ctx.stroke();
440
- } else {
441
- nodePath(ctx, node, x, y, info.width, info.height);
442
- ctx.fill();
443
- nodePath(ctx, node, x, y, info.width, info.height);
444
- ctx.stroke();
445
- if (node.shape === 'subroutine') {
446
- ctx.beginPath();
447
- ctx.moveTo(x - info.width / 2 + 5, y - info.height / 2);
448
- ctx.lineTo(x - info.width / 2 + 5, y + info.height / 2);
449
- ctx.moveTo(x + info.width / 2 - 5, y - info.height / 2);
450
- ctx.lineTo(x + info.width / 2 - 5, y + info.height / 2);
451
- ctx.stroke();
452
- }
453
- }
454
- const { lines, h } = meas.block(node.label);
455
- drawTextBlock(ctx, meas, family, size, theme.text, lines, x, y - h / 2);
456
- }
457
- }
458
- };
459
- }
460
-
461
- // ---------------------------------------------------------------------------
462
- // sequence layout + drawing
463
-
464
- function layoutSequence(model, meas, family, size, theme) {
465
- const P = model.participants;
466
- const idx = new Map(P.map((p, i) => [p.id, i]));
467
- const actorPadX = 12;
468
- const actorH = meas.lineH + 16;
469
- const boxW = P.map((p) => Math.max(60, meas.width(p.label) + actorPadX * 2));
470
-
471
- // gaps between adjacent lifelines, widened until every message/note fits
472
- const gaps = new Array(Math.max(0, P.length - 1)).fill(110);
473
- const need = (a, b, w) => {
474
- if (a === b) return;
475
- const [lo, hi] = a < b ? [a, b] : [b, a];
476
- let sum = 0;
477
- for (let i = lo; i < hi; i++) sum += gaps[i];
478
- if (sum < w) {
479
- const add = (w - sum) / (hi - lo);
480
- for (let i = lo; i < hi; i++) gaps[i] += add;
481
- }
482
- };
483
- for (const item of model.items) {
484
- if (item.kind === 'message' && item.from !== item.to) {
485
- need(idx.get(item.from), idx.get(item.to), meas.width(item.text) + 36);
486
- } else if (item.kind === 'note' && item.pos === 'over' && item.ids.length > 1) {
487
- need(idx.get(item.ids[0]), idx.get(item.ids[item.ids.length - 1]), meas.block(item.text).w + 30);
488
- }
489
- }
490
- // lifeline x positions
491
- const cx = [];
492
- let x = Math.max(20, boxW[0] / 2 + 8);
493
- for (let i = 0; i < P.length; i++) {
494
- if (i > 0) x += Math.max(gaps[i - 1], boxW[i - 1] / 2 + boxW[i] / 2 + 20);
495
- cx.push(x);
496
- }
497
-
498
- // vertical layout pass: assign a y to every item
499
- const rows = [];
500
- const frames = [];
501
- const openFrames = [];
502
- let y = actorH + 14;
503
- const frameInset = () => openFrames.length * 8;
504
- for (const item of model.items) {
505
- if (item.kind === 'frame') {
506
- openFrames.push({ item, top: y, dividers: [] });
507
- y += meas.lineH + 12;
508
- continue;
509
- }
510
- if (item.kind === 'divider') {
511
- const f = openFrames[openFrames.length - 1];
512
- y += 8;
513
- if (f) f.dividers.push({ y, text: item.text });
514
- y += meas.lineH + 6;
515
- continue;
516
- }
517
- if (item.kind === 'frameEnd') {
518
- const f = openFrames.pop();
519
- if (f) {
520
- y += 8;
521
- frames.push({ ...f, bottom: y });
522
- y += 10;
523
- }
524
- continue;
525
- }
526
- if (item.kind === 'note') {
527
- const { w, h } = { w: meas.block(item.text).w, h: meas.block(item.text).h };
528
- rows.push({ item, y, noteW: w + 20, noteH: h + 10 });
529
- y += h + 24;
530
- continue;
531
- }
532
- // message
533
- const self = item.from === item.to;
534
- rows.push({ item, y });
535
- y += meas.lineH + (self ? 26 : 16);
536
- }
537
- while (openFrames.length) {
538
- const f = openFrames.pop();
539
- frames.push({ ...f, bottom: y });
540
- y += 10;
541
- }
542
- const bodyBottom = y + 6;
543
- const height = Math.ceil(bodyBottom + actorH);
544
- const width = Math.ceil(cx[cx.length - 1] + Math.max(24, boxW[P.length - 1] / 2 + 10));
545
-
546
- const drawActor = (ctx, i, ox, top) => {
547
- const w = boxW[i];
548
- const h = actorH;
549
- const x0 = ox + cx[i] - w / 2;
550
- ctx.fillStyle = theme.actorFill;
551
- ctx.strokeStyle = theme.actorBorder;
552
- ctx.lineWidth = 1.3;
553
- ctx.beginPath();
554
- ctx.roundRect(x0, top, w, h, 4);
555
- ctx.fill();
556
- ctx.beginPath();
557
- ctx.roundRect(x0, top, w, h, 4);
558
- ctx.stroke();
559
- drawTextBlock(ctx, meas, family, size, theme.text, [P[i].label], ox + cx[i], top + (h - meas.lineH) / 2);
560
- };
561
-
562
- return {
563
- type: 'sequence',
564
- width,
565
- height,
566
- warnings: model.warnings,
567
- draw(ctx, ox = 0, oy = 0) {
568
- // lifelines
569
- ctx.strokeStyle = theme.frameBorder;
570
- ctx.lineWidth = 1;
571
- for (let i = 0; i < P.length; i++) {
572
- dashedLine(ctx, cx[i] + ox, oy + actorH, cx[i] + ox, oy + bodyBottom, 4, 4);
573
- }
574
- // frames (behind messages, above lifelines)
575
- for (const f of frames) {
576
- const x0 = ox + 8;
577
- const x1 = ox + width - 8;
578
- ctx.strokeStyle = theme.frameBorder;
579
- ctx.lineWidth = 1;
580
- ctx.beginPath();
581
- ctx.rect(x0, oy + f.top, x1 - x0, f.bottom - f.top);
582
- ctx.stroke();
583
- const tag = f.item.frame;
584
- const tagW = meas.width(tag) + 14;
585
- ctx.fillStyle = theme.actorFill;
586
- ctx.fillRect(x0, oy + f.top, tagW, meas.lineH + 6);
587
- ctx.strokeRect(x0, oy + f.top, tagW, meas.lineH + 6);
588
- drawTextBlock(ctx, meas, family, size, theme.text, [tag], x0 + tagW / 2, oy + f.top + 3);
589
- if (f.item.text) {
590
- drawTextBlock(ctx, meas, family, size, theme.frameLabel, [`[${f.item.text}]`], x0 + tagW + meas.width(`[${f.item.text}]`) / 2 + 10, oy + f.top + 3);
591
- }
592
- for (const d of f.dividers) {
593
- dashedLine(ctx, x0, oy + d.y, x1, oy + d.y, 5, 4);
594
- if (d.text) {
595
- drawTextBlock(ctx, meas, family, size, theme.frameLabel, [`[${d.text}]`], x0 + 30 + meas.width(`[${d.text}]`) / 2, oy + d.y + 3);
596
- }
597
- }
598
- }
599
- // messages and notes
600
- for (const row of rows) {
601
- const item = row.item;
602
- if (item.kind === 'note') {
603
- const ids = item.ids.map((id) => idx.get(id));
604
- let nx;
605
- if (item.pos === 'over') {
606
- const lo = Math.min(...ids);
607
- const hi = Math.max(...ids);
608
- nx = (cx[lo] + cx[hi]) / 2;
609
- } else if (item.pos === 'right of') {
610
- nx = cx[ids[0]] + row.noteW / 2 + 12;
611
- } else {
612
- nx = cx[ids[0]] - row.noteW / 2 - 12;
613
- }
614
- const { lines } = meas.block(item.text);
615
- ctx.fillStyle = theme.noteFill;
616
- ctx.strokeStyle = theme.noteBorder;
617
- ctx.lineWidth = 1;
618
- ctx.fillRect(ox + nx - row.noteW / 2, oy + row.y, row.noteW, row.noteH);
619
- ctx.strokeRect(ox + nx - row.noteW / 2, oy + row.y, row.noteW, row.noteH);
620
- drawTextBlock(ctx, meas, family, size, theme.text, lines, ox + nx, oy + row.y + 5);
621
- continue;
622
- }
623
- // message
624
- const a = idx.get(item.from);
625
- const b = idx.get(item.to);
626
- const lineY = oy + row.y + meas.lineH + 4;
627
- ctx.strokeStyle = theme.line;
628
- ctx.lineWidth = 1.3;
629
- if (a === b) {
630
- // self message: out, down, back
631
- const x0 = ox + cx[a];
632
- const loopW = 28;
633
- const pts = [
634
- { x: x0, y: lineY },
635
- { x: x0 + loopW, y: lineY },
636
- { x: x0 + loopW, y: lineY + 14 },
637
- { x: x0, y: lineY + 14 }
638
- ];
639
- if (item.dotted) dashedPolyline(ctx, pts, 4, 3);
640
- else {
641
- ctx.beginPath();
642
- ctx.moveTo(pts[0].x, pts[0].y);
643
- for (const p of pts.slice(1)) ctx.lineTo(p.x, p.y);
644
- ctx.stroke();
645
- }
646
- arrowHead(ctx, x0 + 2, lineY + 14, x0 + loopW, lineY + 14, 7, theme.line);
647
- drawTextBlock(ctx, meas, family, size, theme.text, meas.linesOf(item.text), x0 + loopW + 8 + meas.width(item.text) / 2, oy + row.y + 6);
648
- continue;
649
- }
650
- const x0 = ox + cx[a];
651
- const x1 = ox + cx[b];
652
- if (item.dotted) dashedLine(ctx, x0, lineY, x1, lineY, 5, 4);
653
- else {
654
- ctx.beginPath();
655
- ctx.moveTo(x0, lineY);
656
- ctx.lineTo(x1, lineY);
657
- ctx.stroke();
658
- }
659
- const dir = x1 > x0 ? 1 : -1;
660
- if (item.head === 'cross') {
661
- const cxx = x1 - dir * 6;
662
- ctx.beginPath();
663
- ctx.moveTo(cxx - 5, lineY - 5);
664
- ctx.lineTo(cxx + 5, lineY + 5);
665
- ctx.moveTo(cxx - 5, lineY + 5);
666
- ctx.lineTo(cxx + 5, lineY - 5);
667
- ctx.stroke();
668
- } else if (item.head !== 'none') {
669
- arrowHead(ctx, x1, lineY, x0, lineY, 8, theme.line);
670
- }
671
- if (item.text) {
672
- drawTextBlock(ctx, meas, family, size, theme.text, [item.text], (x0 + x1) / 2, oy + row.y);
673
- }
674
- }
675
- // actors, top and bottom
676
- for (let i = 0; i < P.length; i++) {
677
- drawActor(ctx, i, ox, oy);
678
- drawActor(ctx, i, ox, oy + bodyBottom);
679
- }
680
- }
681
- };
682
- }
683
-
684
- // ---------------------------------------------------------------------------
685
-
686
- /**
687
- * Lay out a mermaid diagram without drawing it. Returns a box:
688
- * `{ width, height, type, warnings, draw(ctx, x, y) }`.
689
- *
690
- * Supported: `flowchart`/`graph` (TB/TD/BT/LR/RL, all common node shapes,
691
- * solid/dotted/thick edges with labels, chains and `&` fans) and
692
- * `sequenceDiagram` (participants/actors, all arrow kinds, notes,
693
- * loop/opt/alt/par frames with else/and dividers, autonumber).
694
- * Anything else throws — callers fall back to a code block.
695
- *
696
- * @param {object} model parsed diagram from `await parseMermaid(text)`
697
- * @param {object} options { fonts, size = 13, family = 'sans-serif', theme,
698
- * maxWidth } — when maxWidth is given and the diagram comes out wider,
699
- * it is laid out again at a proportionally smaller font size (floor 8px)
700
- */
701
- export function layoutMermaid(model, { fonts, size = 13, family = 'sans-serif', theme = {}, maxWidth } = {}) {
702
- if (!fonts) throw new Error('layoutMermaid: fonts (FontManager) is required');
703
- if (!model || (model.type !== 'flowchart' && model.type !== 'sequence')) {
704
- throw new Error('layoutMermaid: expected a model from parseMermaid()');
705
- }
706
- const th = { ...DEFAULT_THEME, ...theme };
707
- const attempt = (px) => {
708
- const meas = makeMeasurer(fonts, family, px);
709
- return model.type === 'flowchart'
710
- ? layoutFlowchart(model, meas, family, px, th)
711
- : layoutSequence(model, meas, family, px, th);
712
- };
713
- let box = attempt(size);
714
- let px = size;
715
- for (let i = 0; maxWidth && box.width > maxWidth && px > 8 && i < 4; i++) {
716
- px = Math.max(8, Math.floor((px * maxWidth) / box.width));
717
- box = attempt(px);
718
- }
719
- return box;
720
- }