@grafloria/element 0.4.2 → 0.4.3

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.
@@ -81,6 +81,133 @@ export function entityAutoHeight(entity, editable = false) {
81
81
  return entity.height;
82
82
  return ER_HEAD_H + entity.columns.length * ER_ROW_H + ER_BORDER_SLACK + (editable ? ER_ADD_H : 0);
83
83
  }
84
+ /* ---------------------------------------------------------------------------
85
+ * Card WIDTH — the half of the sizing contract that was missing.
86
+ *
87
+ * Height has always been derived from content (entityAutoHeight above), but
88
+ * width was a flat constant, so anything wider than the default simply fell off
89
+ * the card — and every one of those overflows was SILENT. Measured on the
90
+ * shipped demos: the ER head lost 125px of `A_VERY_LONG_TABLE_NAME…` with no
91
+ * ellipsis, `from_warehouse_id` needed 221px against the 190 it got, and a UML
92
+ * method signature needed 487px against 190 — 297px of it cut mid-glyph, on the
93
+ * one line of a class diagram that carries the actual information.
94
+ *
95
+ * So a card now sizes to its content in BOTH axes. The constants below mirror
96
+ * the stylesheet's box model exactly; if a rule there changes, change it here.
97
+ * ------------------------------------------------------------------------- */
98
+ /** Horizontal padding shared by `.axk-row`, `.axk-entity-head`, `.axk-member`
99
+ * and `.axk-uml-name` — all of them are `padding: Npx 10px`. */
100
+ export const CARD_PAD_X = 20;
101
+ /** `.axk-row`'s `gap: 8px`. */
102
+ export const ER_ROW_GAP = 8;
103
+ /** `.axk-key`'s fixed `width: 22px`. */
104
+ export const ER_KEY_W = 22;
105
+ /** `.axk-ty`'s `min-width: 52px` — it never measures narrower than its target. */
106
+ export const ER_TYPE_MIN_W = 52;
107
+ /** `.axk-col-del`'s `width: 14px` (present only when editable). */
108
+ export const CARD_DEL_W = 14;
109
+ /**
110
+ * Horizontal counterpart of ER_BORDER_SLACK: the html wrapper's padding
111
+ * (`html.padding ?? 4`, so 8px across) plus the card's own 1px left/right
112
+ * borders. The node's width is the OUTER box; the row only ever gets what is
113
+ * left after these, so a derived width that ignored them came out 10px short
114
+ * and truncated by a hair — which is exactly how it was first measured.
115
+ */
116
+ export const CARD_SLACK_X = 10;
117
+ /** Defaults, and the floor every derived width is taken against. */
118
+ export const ER_DEFAULT_WIDTH = 190;
119
+ export const UML_DEFAULT_WIDTH = 200;
120
+ /**
121
+ * Ceiling for a DERIVED width. A card that sized itself to a pathological
122
+ * identifier would dominate the diagram, so past this the stylesheet's ellipsis
123
+ * takes over — visibly, which is the point — and an author who needs the whole
124
+ * string sets an explicit `width`. An explicit width is never clamped.
125
+ */
126
+ export const CARD_MAX_AUTO_W = 420;
127
+ /* The exact CSS fonts the stylesheet paints these strings with. */
128
+ const FONT_COL = '12px system-ui, sans-serif';
129
+ const FONT_COL_PK = '600 12px system-ui, sans-serif';
130
+ const FONT_TYPE = '11px system-ui, sans-serif';
131
+ const FONT_HEAD = '600 11px system-ui, sans-serif';
132
+ const FONT_ADD = '600 11px system-ui, sans-serif';
133
+ const FONT_MEMBER = '11px ui-monospace, Menlo, monospace';
134
+ const FONT_UML_NAME = '700 12px system-ui, sans-serif';
135
+ const FONT_UML_STEREO = '500 10px system-ui, sans-serif';
136
+ /** `.axk-entity-head` is `letter-spacing: .3px`, which canvas does not apply. */
137
+ const HEAD_TRACKING = 0.3;
138
+ let measureCtx;
139
+ const measureCache = new Map();
140
+ /**
141
+ * Width of `text` in CSS pixels, as the browser will really lay it out.
142
+ *
143
+ * Canvas `measureText` was calibrated against live `getBoundingClientRect()`
144
+ * widths for these exact fonts and agreed to within 0.02px, which is what makes
145
+ * deriving a card's width from its content safe rather than a guess.
146
+ *
147
+ * Outside a browser (jsdom, SSR) there is no 2d context and this falls back to
148
+ * a per-character average. The fallback only has to be roughly right: it feeds
149
+ * a `Math.max` against the default width, and anything it underestimates is
150
+ * ellipsised by the stylesheet rather than lost.
151
+ */
152
+ export function measureCardText(text, font, tracking = 0) {
153
+ var _a, _b;
154
+ if (!text)
155
+ return 0;
156
+ const key = `${font}\u0000${tracking}\u0000${text}`;
157
+ const hit = measureCache.get(key);
158
+ if (hit !== undefined)
159
+ return hit;
160
+ if (measureCtx === undefined) {
161
+ // Feature-DETECT rather than probe: jsdom defines CanvasRenderingContext2D
162
+ // only when node-canvas is installed, and calling getContext('2d') without
163
+ // it logs a "not implemented" through the virtual console on every suite.
164
+ measureCtx = null;
165
+ if (typeof document !== 'undefined' && typeof CanvasRenderingContext2D !== 'undefined') {
166
+ try {
167
+ measureCtx = document.createElement('canvas').getContext('2d');
168
+ }
169
+ catch (_c) {
170
+ measureCtx = null;
171
+ }
172
+ }
173
+ }
174
+ let width;
175
+ if (measureCtx) {
176
+ measureCtx.font = font;
177
+ width = measureCtx.measureText(text).width + tracking * text.length;
178
+ }
179
+ else {
180
+ const px = Number.parseFloat((_b = (_a = /(\d+(?:\.\d+)?)px/.exec(font)) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : '12');
181
+ width = text.length * (px * 0.55 + tracking);
182
+ }
183
+ measureCache.set(key, width);
184
+ return width;
185
+ }
186
+ /** Card width for an entity — explicit width wins, else derived from content. */
187
+ export function entityAutoWidth(entity, editable = false) {
188
+ var _a, _b;
189
+ if (entity.width != null)
190
+ return entity.width;
191
+ // The head is uppercased and tracked by CSS, so measure what is PAINTED
192
+ // rather than what the author typed.
193
+ const title = ((_a = entity.name) !== null && _a !== void 0 ? _a : entity.id).toUpperCase();
194
+ let needed = CARD_PAD_X + measureCardText(title, FONT_HEAD, HEAD_TRACKING);
195
+ for (const c of entity.columns) {
196
+ const name = measureCardText(c.name, c.pk ? FONT_COL_PK : FONT_COL);
197
+ const type = Math.max(ER_TYPE_MIN_W, measureCardText((_b = c.type) !== null && _b !== void 0 ? _b : '', FONT_TYPE));
198
+ needed = Math.max(needed, CARD_PAD_X +
199
+ ER_KEY_W +
200
+ ER_ROW_GAP +
201
+ name +
202
+ ER_ROW_GAP +
203
+ type +
204
+ (editable ? ER_ROW_GAP + CARD_DEL_W : 0));
205
+ }
206
+ if (editable) {
207
+ needed = Math.max(needed, CARD_PAD_X + measureCardText('\uFF0B add column', FONT_ADD));
208
+ }
209
+ return Math.min(CARD_MAX_AUTO_W, Math.max(ER_DEFAULT_WIDTH, Math.ceil(needed + CARD_SLACK_X)));
210
+ }
84
211
  /**
85
212
  * The `.axk-uml` content tree. `editable` adds a delete control per member and
86
213
  * a trailing add affordance in each compartment.
@@ -152,6 +279,28 @@ export function classAutoHeight(cls, editable = false) {
152
279
  UML_PAD * 2 +
153
280
  12);
154
281
  }
282
+ /** Card width for a class — explicit width wins, else derived from content. */
283
+ export function classAutoWidth(cls, editable = false) {
284
+ var _a, _b, _c;
285
+ if (cls.width != null)
286
+ return cls.width;
287
+ // `.axk-member` goes flex only when editable, and it has NO gap — the delete
288
+ // control sits straight against the text.
289
+ const del = editable ? CARD_DEL_W : 0;
290
+ let needed = CARD_PAD_X + measureCardText((_a = cls.name) !== null && _a !== void 0 ? _a : cls.id, FONT_UML_NAME);
291
+ if (cls.stereotype) {
292
+ needed = Math.max(needed, CARD_PAD_X + measureCardText(`\u00AB${cls.stereotype}\u00BB`, FONT_UML_STEREO));
293
+ }
294
+ for (const m of [...((_b = cls.attributes) !== null && _b !== void 0 ? _b : []), ...((_c = cls.methods) !== null && _c !== void 0 ? _c : [])]) {
295
+ needed = Math.max(needed, CARD_PAD_X + measureCardText(m, FONT_MEMBER) + del);
296
+ }
297
+ if (editable) {
298
+ for (const label of ['\uFF0B attribute', '\uFF0B method']) {
299
+ needed = Math.max(needed, CARD_PAD_X + measureCardText(label, FONT_ADD));
300
+ }
301
+ }
302
+ return Math.min(CARD_MAX_AUTO_W, Math.max(UML_DEFAULT_WIDTH, Math.ceil(needed + CARD_SLACK_X)));
303
+ }
155
304
  /**
156
305
  * Match old columns to new columns for port reconciliation. Returns a map from
157
306
  * OLD index → NEW index; an old index absent from the map is a REMOVED column
@@ -22,13 +22,12 @@
22
22
  */
23
23
  import { ensureDiagramKitStyles } from './styles.js';
24
24
  import { bindRowInteractions } from './rows.js';
25
- import { entityCardContent, entityAutoHeight, erRowCenterY, ER_ROW_H, ER_HEAD_H } from './card.js';
25
+ import { entityCardContent, entityAutoHeight, entityAutoWidth, erRowCenterY, ER_ROW_H, ER_HEAD_H, } from './card.js';
26
26
  import { bindCardEditing } from './editing.js';
27
27
  // Layout constants + the row-centre helper live in card.ts now (the ONE source
28
28
  // of truth shared with update.ts). Re-exported here so `import … from './er.js'`
29
29
  // keeps working.
30
30
  export { erRowCenterY, ER_ROW_H, ER_HEAD_H };
31
- const DEFAULT_WIDTH = 190;
32
31
  const CARDINALITY = {
33
32
  'one-to-many': { tail: 'one', head: 'crow-foot' },
34
33
  'one-to-one': { tail: 'one', head: 'one' },
@@ -68,7 +67,10 @@ export function erDiagram(options) {
68
67
  // How many edges already landed on a given entity row+side — drives the
69
68
  // spread (dy) so shared columns (a PK referenced twice) don't stack.
70
69
  const rowLandings = new Map();
71
- const width = (e) => { var _a; return (_a = e.width) !== null && _a !== void 0 ? _a : DEFAULT_WIDTH; };
70
+ // Width is derived from content the same way height is, so a long column name
71
+ // or table title widens the card instead of falling off it. Ports read this
72
+ // same function, so they stay glued to the edge whatever it works out to.
73
+ const width = (e) => entityAutoWidth(e, options.editable === true);
72
74
  const fieldPort = (end, side) => {
73
75
  var _a, _b;
74
76
  const entity = end.entity;
@@ -22,19 +22,42 @@ const CSS = `
22
22
  display: flex; flex-direction: column; }
23
23
  .axk-entity-body { flex: 1; min-height: 0; overflow-y: hidden; }
24
24
  .axk-entity-body.axk-scroll { overflow-y: auto; scrollbar-width: thin; }
25
+ /* The head is sized by entityAutoWidth, but a title past the auto-width ceiling
26
+ still has to degrade VISIBLY: this rule used to be absent entirely, so a long
27
+ table name ran off the card and was cut by .axk-entity's overflow:hidden with
28
+ nothing to show for it (measured: 305px of name in a 180px head — 125px gone,
29
+ no ellipsis, no hint). nowrap also keeps the head exactly ER_HEAD_H tall,
30
+ which entityAutoHeight's row math depends on. */
25
31
  .axk-entity-head { background: #334155; color: #fff; font-weight: 600;
26
- letter-spacing: .3px; padding: 5px 10px; text-transform: uppercase; font-size: 11px; }
32
+ letter-spacing: .3px; padding: 5px 10px; text-transform: uppercase; font-size: 11px;
33
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
27
34
  .axk-row { display: flex; align-items: center; gap: 8px; padding: 3px 10px;
28
35
  border-top: 1px solid #e2e8f0; }
29
36
  .axk-key { width: 22px; font-size: 9px; font-weight: 700; color: #b45309; }
30
37
  .axk-key.axk-fk { color: #6d28d9; }
31
- .axk-col { flex: 1; color: #0f172a; }
38
+ /* ONE LINE PER COLUMN, ALWAYS — this rule is load-bearing for the card's HEIGHT.
39
+ entityAutoHeight allocates exactly ER_ROW_H per column, and .axk-entity-body is
40
+ overflow-y:hidden, so a name that wrapped to a second line pushed the last row
41
+ past the card's bottom edge and it silently vanished (measured: a 40px row
42
+ against the 25px the height math had reserved — 14px of the final column gone,
43
+ with no scrollbar to hint at it). Long identifiers ellipsis instead, so the
44
+ ROW always survives even when the text does not fit; an author who needs the
45
+ whole identifier visible sets an explicit width on the entity. (No title
46
+ attribute: the HTML-node contract deliberately passes text through
47
+ textContent and no attributes, and a tooltip is not worth widening it.)
48
+ min-width:0 is what lets a flex child shrink below its content. */
49
+ .axk-col { flex: 1; min-width: 0; color: #0f172a;
50
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
32
51
  .axk-ty {
33
52
  color: #64748b; font-size: 11px;
34
53
  /* A new column starts with an EMPTY type, which collapsed the cell to zero
35
54
  width — there was nothing to double-click, so a type could never be set on
36
55
  a field you just added. Reserve a target and hint that it is editable. */
37
56
  min-width: 52px; text-align: right; cursor: text;
57
+ /* Same contract as .axk-col: a long type must not wrap the row either.
58
+ It keeps its reserved width and never shrinks away. */
59
+ flex: 0 0 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
60
+ max-width: 45%;
38
61
  }
39
62
  .axk-ty:empty::before { content: 'type'; color: #cbd5e1; font-style: italic; }
40
63
  .axk-ty:hover { color: #0f172a; }
@@ -47,14 +70,25 @@ const CSS = `
47
70
  display: flex; flex-direction: column; }
48
71
  .axk-uml-body { flex: 1; min-height: 0; overflow-y: hidden; }
49
72
  .axk-uml-body.axk-scroll { overflow-y: auto; scrollbar-width: thin; }
73
+ /* Same one-line contract as .axk-entity-head: UML_NAME_H is what
74
+ classAutoHeight reserves, so a wrapped class name would push the first
75
+ compartment past the card's bottom edge. */
50
76
  .axk-uml-name { text-align: center; font-weight: 700; padding: 5px 10px;
51
- background: #eef2ff; color: #1e1b4b; }
77
+ background: #eef2ff; color: #1e1b4b;
78
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
52
79
  .axk-uml-name.axk-abstract { font-style: italic; }
53
80
  .axk-uml-stereo { display: block; font-size: 10px; font-weight: 500; opacity: .8; }
54
81
  .axk-uml-comp { border-top: 1px solid #475569; padding: 3px 0; }
55
82
  .axk-uml-comp.axk-empty { min-height: 8px; }
83
+ /* Members already refused to wrap, but with no ellipsis they were cut
84
+ MID-GLYPH by the card's overflow:hidden — a method signature needing 487px
85
+ in a 190px card lost 297px of itself silently, on the one line a class
86
+ diagram exists to show. classAutoWidth now widens the card to fit; past its
87
+ ceiling this ellipsis says so. min-width:0 lets the editable flex variant
88
+ shrink. */
56
89
  .axk-member { padding: 1px 10px; font: 11px/1.5 ui-monospace, Menlo, monospace;
57
- color: #0f172a; white-space: nowrap; }
90
+ color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
91
+ min-width: 0; }
58
92
 
59
93
  /* ===== Row interactivity (cards are interactive; drag stays geometric) ===== */
60
94
  .axk-entity, .axk-uml { user-select: none; -webkit-user-select: none; }
@@ -84,7 +118,8 @@ g.node-group[data-selected="true"]:has(.axk-uml) rect.diagram-node {
84
118
  /* Only editable members (which wrap their text in .axk-mtext) go flex — a
85
119
  read-only member stays a plain text div, so its golden never shifts. */
86
120
  .axk-member:has(.axk-mtext) { display: flex; align-items: center; }
87
- .axk-member .axk-mtext { flex: 1; }
121
+ .axk-member .axk-mtext { flex: 1; min-width: 0;
122
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
88
123
  .axk-entity-add, .axk-uml-add { padding: 3px 10px; font-size: 11px; font-weight: 600;
89
124
  color: #2563eb; cursor: pointer; border-top: 1px dashed #cbd5e1; user-select: none; }
90
125
  .axk-uml-add { color: #4f46e5; border-top: 1px dashed #c7d2fe; text-align: left; }
@@ -24,9 +24,8 @@
24
24
  */
25
25
  import { ensureDiagramKitStyles } from './styles.js';
26
26
  import { bindRowInteractions } from './rows.js';
27
- import { classCardContent, classAutoHeight } from './card.js';
27
+ import { classCardContent, classAutoHeight, classAutoWidth } from './card.js';
28
28
  import { bindCardEditing } from './editing.js';
29
- const DEFAULT_WIDTH = 200;
30
29
  const STROKE = '#475569';
31
30
  const DASH = '6,4';
32
31
  /**
@@ -70,11 +69,11 @@ export function umlDiagram(options) {
70
69
  ensureDiagramKitStyles();
71
70
  const editable = options.editable === true;
72
71
  const nodes = options.classes.map((cls, i) => {
73
- var _a, _b;
72
+ var _a;
74
73
  return {
75
74
  id: cls.id,
76
75
  position: (_a = cls.position) !== null && _a !== void 0 ? _a : { x: 80 + (i % 3) * 320, y: 60 + Math.floor(i / 3) * 260 },
77
- size: { width: (_b = cls.width) !== null && _b !== void 0 ? _b : DEFAULT_WIDTH, height: classAutoHeight(cls, editable) },
76
+ size: { width: classAutoWidth(cls, editable), height: classAutoHeight(cls, editable) },
78
77
  // interactive: members are real DOM targets (hover, row selection, inline
79
78
  // editing); node drag/select stay geometric in the binder.
80
79
  metadata: Object.assign({ html: { content: classCardContent(cls, editable), interactive: true }, kitClass: cls, kitEditable: editable }, (options.rowSelection === false ? { kitRowSelection: false } : {})),
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import { __awaiter } from "tslib";
31
31
  import { Command, PortModel, LinkModel } from '@grafloria/engine';
32
- import { entityCardContent, entityAutoHeight, classCardContent, classAutoHeight, erRowCenterY, rowIndexFromY, matchColumns, } from './card.js';
32
+ import { entityCardContent, entityAutoHeight, entityAutoWidth, classCardContent, classAutoHeight, classAutoWidth, erRowCenterY, rowIndexFromY, matchColumns, } from './card.js';
33
33
  /**
34
34
  * The single undoable edit. Captures the card's whole before-state on first
35
35
  * execute and restores it on undo, so an edit — however many rows and edges it
@@ -187,7 +187,11 @@ export function updateEntity(api, entityId, delta) {
187
187
  newKit: newEntity,
188
188
  content: entityCardContent(newEntity, editable),
189
189
  height: entityAutoHeight(newEntity, editable),
190
- width: newEntity.width,
190
+ // Derived, not carried over: renaming a column to something longer has to
191
+ // widen the card the same way the builder would have, or the edit lands
192
+ // inside a card still sized for the old text and is truncated on arrival.
193
+ // An explicit delta.width still wins — entityAutoWidth honours it.
194
+ width: entityAutoWidth(newEntity, editable),
191
195
  };
192
196
  };
193
197
  return runUpdate(api, entityId, 'er', build);
@@ -219,7 +223,7 @@ export function updateClass(api, classId, delta) {
219
223
  newKit: newClass,
220
224
  content: classCardContent(newClass, editable),
221
225
  height: classAutoHeight(newClass, editable),
222
- width: newClass.width,
226
+ width: classAutoWidth(newClass, editable),
223
227
  };
224
228
  };
225
229
  return runUpdate(api, classId, 'uml', build);
@@ -12,7 +12,7 @@ import { registerNodeType, registeredNodeTypes, getNodeType } from './node-type-
12
12
  * card — `render()` is the embedding surface, not a parser.
13
13
  */
14
14
  export function render(spec, target, options = {}) {
15
- var _a, _b, _c, _d;
15
+ var _a, _b, _c, _d, _e;
16
16
  const element = typeof target === 'string'
17
17
  ? document.querySelector(target)
18
18
  : target;
@@ -22,14 +22,14 @@ export function render(spec, target, options = {}) {
22
22
  // Kit specs (dashboard()) carry looser node typing than DiagramSpec — both
23
23
  // flow into createDiagram's NodeInput[] the same way.
24
24
  const parsed = (typeof spec === 'string' ? parseSpec(spec) : spec);
25
- const instance = createDiagram(element, Object.assign(Object.assign({}, options), { nodes: (_a = parsed.nodes) !== null && _a !== void 0 ? _a : [], edges: (_b = parsed.edges) !== null && _b !== void 0 ? _b : [],
25
+ const instance = createDiagram(element, Object.assign(Object.assign(Object.assign({}, ((_a = parsed.renderOptions) !== null && _a !== void 0 ? _a : {})), options), { nodes: (_b = parsed.nodes) !== null && _b !== void 0 ? _b : [], edges: (_c = parsed.edges) !== null && _c !== void 0 ? _c : [],
26
26
  // Wire the global registry in, so `registerNodeType` works for the tiny API
27
27
  // exactly as it does for `<grafloria-flow>` — unless the caller supplies their
28
28
  // own. A KIT SPEC may also carry its own painter (dashboard() does: every
29
29
  // widget is a custom HTML node), and it must be honoured — otherwise the
30
30
  // documented one-liner `render(dashboard({…}), host)` mounts a board whose
31
31
  // widgets never paint. Precedence: explicit option > spec > registry.
32
- renderCustomNode: (_d = (_c = options.renderCustomNode) !== null && _c !== void 0 ? _c : parsed.renderCustomNode) !== null && _d !== void 0 ? _d : ((node, host) => { var _a; return (_a = getNodeType(node.type)) === null || _a === void 0 ? void 0 : _a(node, host); }) }));
32
+ renderCustomNode: (_e = (_d = options.renderCustomNode) !== null && _d !== void 0 ? _d : parsed.renderCustomNode) !== null && _e !== void 0 ? _e : ((node, host) => { var _a; return (_a = getNodeType(node.type)) === null || _a === void 0 ? void 0 : _a(node, host); }) }));
33
33
  // Diagram-kit specs (erDiagram/umlDiagram) carry a `finalize(api)` for the
34
34
  // post-render wiring that needs the LIVE instance — row interactions,
35
35
  // multiplicity chips. Auto-run it so a kit diagram is fully wired from one
@@ -39,7 +39,7 @@ export function render(spec, target, options = {}) {
39
39
  try {
40
40
  maybeFinalize(instance);
41
41
  }
42
- catch (_e) {
42
+ catch (_f) {
43
43
  /* a kit's finalize must never break the mount */
44
44
  }
45
45
  }
package/src/lib/load.d.ts CHANGED
@@ -56,6 +56,11 @@ export interface LoadedDiagramSpec {
56
56
  * app's own painter, exactly as `dashboard({ renderWidget })` did.
57
57
  */
58
58
  readonly handle: DashboardHandle;
59
+ /** Instance options the loaded spec asks `render()` to apply (a fluid board pins zoom). */
60
+ renderOptions?: {
61
+ minZoom?: number;
62
+ maxZoom?: number;
63
+ };
59
64
  }
60
65
  /**
61
66
  * Turn a saved document back into something `render()` can mount.
package/src/lib/load.js CHANGED
@@ -67,6 +67,8 @@ import { bindRowInteractions } from './diagram-kit/rows.js';
67
67
  import { bindCardEditing } from './diagram-kit/editing.js';
68
68
  import { ensureDiagramKitStyles } from './diagram-kit/styles.js';
69
69
  import { bindDashboardGrid } from './dashboard-kit/grid-binder.js';
70
+ import { bindDashboardSplit, SPLIT_TREE_KEY } from './dashboard-kit/split-binder.js';
71
+ import { gridItemFromCell } from './dashboard-kit/grid-mapping.js';
70
72
  import { cellFromGridItem } from './dashboard-kit/grid-mapping.js';
71
73
  import { ensureDashboardKitStyles } from './dashboard-kit/styles.js';
72
74
  import { defaultWidgetRenderer } from './dashboard-kit/widgets.js';
@@ -86,7 +88,9 @@ function widgetSpecOf(node) {
86
88
  if (kind === undefined)
87
89
  return null;
88
90
  const title = node.getMetadata('widgetTitle');
89
- return Object.assign(Object.assign({ id: node.id, kind: kind }, (typeof title === 'string' ? { title } : {})), { data: ((_a = node.getMetadata('widgetSpec')) !== null && _a !== void 0 ? _a : {}), span: node.getMetadata('columnSpan'), rows: node.getMetadata('rowSpan') });
91
+ return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ id: node.id, kind: kind }, (typeof title === 'string' ? { title } : {})), { data: ((_a = node.getMetadata('widgetSpec')) !== null && _a !== void 0 ? _a : {}), span: node.getMetadata('columnSpan'), rows: node.getMetadata('rowSpan') }), (node.getMetadata('widgetLimits') !== undefined
92
+ ? { limits: Object.assign({}, node.getMetadata('widgetLimits')) }
93
+ : {})), (node.getMetadata('widgetMovable') === false ? { movable: false } : {})), (node.getMetadata('widgetResizable') === false ? { resizable: false } : {}));
90
94
  }
91
95
  /**
92
96
  * Turn a saved document back into something `render()` can mount.
@@ -101,7 +105,7 @@ function widgetSpecOf(node) {
101
105
  * of either.
102
106
  */
103
107
  export function fromDocument(document, options = {}) {
104
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t;
108
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x;
105
109
  const parsed = typeof document === 'string' ? parseDocument(document) : document;
106
110
  const model = new DiagramSerializer().deserialize(parsed);
107
111
  const nodes = model.getNodes();
@@ -198,6 +202,11 @@ export function fromDocument(document, options = {}) {
198
202
  rowHeight: (_g = firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.baseRowHeight) !== null && _g !== void 0 ? _g : 130,
199
203
  boardW: (_k = (_j = (_h = viewGroups[0]) === null || _h === void 0 ? void 0 : _h.size) === null || _j === void 0 ? void 0 : _j.width) !== null && _k !== void 0 ? _k : 1180,
200
204
  boardH: (_o = (_m = (_l = viewGroups[0]) === null || _l === void 0 ? void 0 : _l.size) === null || _m === void 0 ? void 0 : _m.height) !== null && _o !== void 0 ? _o : 660,
205
+ // A board saved before `mode` existed carries no flag and was authored as
206
+ // a fixed world — it stays one. Fluid is only what was saved fluid.
207
+ mode: (firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.fluid) === true ? 'fluid' : 'fixed',
208
+ overflow: (_p = firstBoard === null || firstBoard === void 0 ? void 0 : firstBoard.overflow) !== null && _p !== void 0 ? _p : 'bounded',
209
+ layoutOf: new Map(viewGroups.map((g) => { var _a, _b; return [g.id, ((_b = (_a = g.getMetadata('dashboardBoard')) === null || _a === void 0 ? void 0 : _a.layout) !== null && _b !== void 0 ? _b : 'grid')]; })),
201
210
  // responsive is NOT in the document (a runtime seam), so it is deliberately
202
211
  // absent from the round-trip; width/height/columns/gap/sizing/float/rtl are.
203
212
  optionsBase: firstBoard
@@ -208,11 +217,15 @@ export function fromDocument(document, options = {}) {
208
217
  sizing: firstBoard.sizing,
209
218
  float: firstBoard.float,
210
219
  rtl: firstBoard.rtl,
211
- width: (_q = (_p = viewGroups[0]) === null || _p === void 0 ? void 0 : _p.size) === null || _q === void 0 ? void 0 : _q.width,
212
- height: (_s = (_r = viewGroups[0]) === null || _r === void 0 ? void 0 : _r.size) === null || _s === void 0 ? void 0 : _s.height,
220
+ mode: firstBoard.fluid === true ? 'fluid' : 'fixed',
221
+ overflow: (_q = firstBoard.overflow) !== null && _q !== void 0 ? _q : 'bounded',
222
+ static: (_r = firstBoard.static) !== null && _r !== void 0 ? _r : false,
223
+ layout: (_s = firstBoard.layout) !== null && _s !== void 0 ? _s : 'grid',
224
+ width: (_u = (_t = viewGroups[0]) === null || _t === void 0 ? void 0 : _t.size) === null || _u === void 0 ? void 0 : _u.width,
225
+ height: (_w = (_v = viewGroups[0]) === null || _v === void 0 ? void 0 : _v.size) === null || _w === void 0 ? void 0 : _w.height,
213
226
  }
214
227
  : {},
215
- active: (_t = activeGroup === null || activeGroup === void 0 ? void 0 : activeGroup.id) !== null && _t !== void 0 ? _t : 'main',
228
+ active: (_x = activeGroup === null || activeGroup === void 0 ? void 0 : activeGroup.id) !== null && _x !== void 0 ? _x : 'main',
216
229
  apiRef: null,
217
230
  };
218
231
  const handle = createDashboardHandle(ctx);
@@ -231,7 +244,7 @@ export function fromDocument(document, options = {}) {
231
244
  (_b = getNodeType(node.type)) === null || _b === void 0 ? void 0 : _b(node, host);
232
245
  };
233
246
  const finalize = (api) => {
234
- var _a, _b, _c, _d;
247
+ var _a, _b, _c, _d, _e;
235
248
  const a = api;
236
249
  if (!a)
237
250
  return;
@@ -276,10 +289,61 @@ export function fromDocument(document, options = {}) {
276
289
  const board = group.getMetadata('dashboardBoard');
277
290
  if (!board)
278
291
  continue;
279
- boards.set(group.id, bindDashboardGrid(a, group, Object.assign({}, board)));
292
+ boards.set(group.id, bindBoard(group, board));
280
293
  }
294
+ // LIVE LAYOUT SWITCH on a loaded document — the same contract dashboard()
295
+ // finalize offers: cells persisted where the grid reads them, any tree and
296
+ // column cache cleared, the board's `layout` flag flipped, a fresh binder.
297
+ ctx.rebindView = (viewId, next) => {
298
+ var _a;
299
+ const group = model.getGroup(viewId);
300
+ const b = boards.get(viewId);
301
+ const board = group === null || group === void 0 ? void 0 : group.getMetadata('dashboardBoard');
302
+ if (!group || !b || !board)
303
+ return;
304
+ const cells = b.saveLayout().cells;
305
+ b.dispose();
306
+ model.runSystemWrite(() => {
307
+ var _a;
308
+ for (const [id, cell] of cells) {
309
+ const n = model.getNode(id);
310
+ if (n)
311
+ n.setMetadata('gridItem', gridItemFromCell(cell));
312
+ else
313
+ (_a = model.getGroup(id)) === null || _a === void 0 ? void 0 : _a.setMetadata('gridItem', gridItemFromCell(cell));
314
+ }
315
+ group.setMetadata(SPLIT_TREE_KEY, undefined);
316
+ group.setMetadata('dashboardLayouts', undefined);
317
+ group.setMetadata('dashboardBoard', Object.assign(Object.assign({}, board), { layout: next }));
318
+ });
319
+ ctx.layoutOf.set(viewId, next);
320
+ boards.set(viewId, bindBoard(group, Object.assign(Object.assign({}, board), { layout: next })));
321
+ (_a = boards.get(viewId)) === null || _a === void 0 ? void 0 : _a.sync();
322
+ };
323
+ // A container removed and restored through the history comes back as a
324
+ // fresh group: bind it again from its own persisted geometry.
325
+ ctx.rebindContainer = (id) => {
326
+ const group = model.getGroup(id);
327
+ const board = group === null || group === void 0 ? void 0 : group.getMetadata('dashboardBoard');
328
+ if (!group || !board)
329
+ return;
330
+ ctx.boardGroups.set(id, group);
331
+ boards.set(id, bindBoard(group, board));
332
+ };
333
+ function bindBoard(group, board) {
334
+ return board.layout === 'split'
335
+ ? bindDashboardSplit(a, group, Object.assign({}, board))
336
+ : bindDashboardGrid(a, group, Object.assign({}, board));
337
+ }
338
+ // A loaded board follows the history exactly as an authored one does: an
339
+ // undo re-syncs every binder without the consumer calling refresh().
340
+ (_e = ctx.attachHistory) === null || _e === void 0 ? void 0 : _e.call(ctx);
281
341
  };
282
- return { nodes, edges: model.getLinks(), renderCustomNode, finalize, model, boards, handle };
342
+ return Object.assign({ nodes, edges: model.getLinks(), renderCustomNode,
343
+ finalize,
344
+ model,
345
+ boards,
346
+ handle }, (ctx.mode === 'fluid' ? { renderOptions: { minZoom: 1, maxZoom: 1 } } : {}));
283
347
  }
284
348
  function parseDocument(json) {
285
349
  try {