figdown 0.5.0 → 0.5.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/figdown.js +339 -29
- package/dist/figdown.mjs +339 -29
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/package.json +10 -1
- package/skill/figdown/build-svg.js +17 -4
- package/skill/figdown/figdown.html +480 -60
|
@@ -184,14 +184,14 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
|
|
|
184
184
|
// input to that promise, and under core §13 a 0.x renderer may differ from
|
|
185
185
|
// the next — which makes the recorded version the only thing that can
|
|
186
186
|
// explain a diff between two renderings of one source.
|
|
187
|
-
const FIGDOWN_VERSION = '0.5.
|
|
187
|
+
const FIGDOWN_VERSION = '0.5.1';
|
|
188
188
|
// `TYPED-BLOCK-TITLE-CANVAS`: a `Z`-only dev bump — RENDERER ONLY, no keyword, no
|
|
189
189
|
// option key, no model field, so `figdown 0.5` still names one language. A
|
|
190
190
|
// typed block's own title (bitfield/table/timing/chart) now joins the
|
|
191
191
|
// section's canvas computation: the returned width is the union of the data
|
|
192
192
|
// extent and the title extent (`typedBlockTitleW`, shared by all four
|
|
193
193
|
// renderers), so a title wider than the data widens the canvas instead of
|
|
194
|
-
// running off it at x=0. Fixes backlog item 66
|
|
194
|
+
// running off it at x=0. Fixes backlog item 66, raised downstream. Corpus impact
|
|
195
195
|
// surveyed first: zero shipped drawings change (every typed-block title in
|
|
196
196
|
// the corpus already fit its data-derived canvas); the fix only reaches
|
|
197
197
|
// fixtures the corpus does not yet have. See spec/migrations.md.
|
|
@@ -1458,6 +1458,35 @@ const WORD_WHY={
|
|
|
1458
1458
|
const WRONG_WORD=(surf,want,genre)=>
|
|
1459
1459
|
'"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
|
|
1460
1460
|
'. Each genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`, sequence `lifeline` `message`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
|
|
1461
|
+
// `LANGUAGE-EXTENSION-POLICY`/`RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): the `x-` prefix is RESERVED against FigDown's own
|
|
1462
|
+
// vocabulary and is NOT an extension namespace. Until this entry it was the
|
|
1463
|
+
// one reservation core §10 wrote down by name and NOTHING enforced: `x-note`
|
|
1464
|
+
// earned `"x-note" is not allowed in genre block` — the same words a typo
|
|
1465
|
+
// gets — while `page`, `;` and `step` each got a diagnostic naming their
|
|
1466
|
+
// reservation. `SEMICOLON-STATUS`'s own title says a reservation that is not enforced is not
|
|
1467
|
+
// a reservation, so this is what makes `x-` one.
|
|
1468
|
+
//
|
|
1469
|
+
// The message says three things, and the THIRD is the whole value: `LANGUAGE-EXTENSION-POLICY`
|
|
1470
|
+
// RETIRED the promise of a future extension mechanism, so an author who reads
|
|
1471
|
+
// core §10's pre-0.5 wording is being told the opposite of the rule. It must
|
|
1472
|
+
// say the prefix will not open, not merely that it is taken.
|
|
1473
|
+
//
|
|
1474
|
+
// IT MUST NOT REACH IDENTIFIERS. The reservation is about the language's own
|
|
1475
|
+
// vocabulary; an id is the author's. `node x-foo "A"` and `class x-bar
|
|
1476
|
+
// "meaning"` parsed and rendered before this entry and still do, which is why
|
|
1477
|
+
// both call sites below are in KEYWORD position and OPTION-KEY position and
|
|
1478
|
+
// nowhere else.
|
|
1479
|
+
const X_RESERVED_WHY =
|
|
1480
|
+
': the "x-" prefix is reserved against FigDown\'s OWN vocabulary — no standard '+
|
|
1481
|
+
'keyword or option key may begin "x-" — and it is NOT an extension namespace. '+
|
|
1482
|
+
'The language is CLOSED, so this does not become legal by waiting (core §10, `LANGUAGE-EXTENSION-POLICY`). '+
|
|
1483
|
+
'Record the fact in a publication manifest instead, spelled x-<owner>-<key> '+
|
|
1484
|
+
'(spec/figdown-manifest.md §2.1); propose the construct through .github/CONTRIBUTING.md §2; '+
|
|
1485
|
+
'or fork with your own header token. Identifiers are untouched: node x-foo "A" and '+
|
|
1486
|
+
'class x-bar "meaning" both parse';
|
|
1487
|
+
const X_RESERVED_KW = kw => '"'+kw+'" is RESERVED, not an extension keyword'+X_RESERVED_WHY;
|
|
1488
|
+
const X_RESERVED_OPT = k => 'option "'+k+'=" is RESERVED, not an extension option key'+X_RESERVED_WHY;
|
|
1489
|
+
const isXReserved = s => typeof s==='string' && s.slice(0,2)==='x-';
|
|
1461
1490
|
// `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
|
|
1462
1491
|
// and `"threshold" is not allowed in genre topology` would send an author
|
|
1463
1492
|
// looking for a typo. Each cell below was legal until 0.3 and states
|
|
@@ -2097,7 +2126,12 @@ function parseOne(text){
|
|
|
2097
2126
|
// `message` is scanned by this function and never reaches that one.
|
|
2098
2127
|
if(u2.length){
|
|
2099
2128
|
const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
2100
|
-
|
|
2129
|
+
// `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2a of 2 — the reserved `x-` prefix in
|
|
2130
|
+
// OPTION-KEY position, on the connector path. `message` is scanned by
|
|
2131
|
+
// this function and never reaches `badOpts`, which is why the check has
|
|
2132
|
+
// to exist in both places (the same reason `UNDELIVERED-MESSAGE-MARKING`'s genre refusal does).
|
|
2133
|
+
if(isXReserved(u2[0])) err(n,X_RESERVED_OPT(u2[0]));
|
|
2134
|
+
else if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
|
|
2101
2135
|
else err(n,'unknown option "'+u2[0]+'="');
|
|
2102
2136
|
return; }
|
|
2103
2137
|
// `SEQUENCE-GENRE-VOCABULARY`: `message` adds ONE trailing positional — the
|
|
@@ -2438,7 +2472,12 @@ function parseOne(text){
|
|
|
2438
2472
|
// ground and the replacement spelling instead of a spellcheck.
|
|
2439
2473
|
const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
2440
2474
|
for(const u of unk){
|
|
2441
|
-
|
|
2475
|
+
// `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2b of 2 — the reserved `x-` prefix in
|
|
2476
|
+
// OPTION-KEY position, on the ordinary directive path. Checked before
|
|
2477
|
+
// the genre refusal and before the generic message, because a key the
|
|
2478
|
+
// language will never register is a stronger fact than either.
|
|
2479
|
+
if(isXReserved(u)) err(n,X_RESERVED_OPT(u));
|
|
2480
|
+
else if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
|
|
2442
2481
|
else err(n,'unknown option "'+u+'="');
|
|
2443
2482
|
bad=true; }
|
|
2444
2483
|
// `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
|
|
@@ -2620,6 +2659,18 @@ function parseOne(text){
|
|
|
2620
2659
|
} else { err(n,'first line must be "figdown 0.1 <genre>"'); }
|
|
2621
2660
|
} else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
|
|
2622
2661
|
|
|
2662
|
+
// `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 1 of 2 — the reserved `x-` prefix in
|
|
2663
|
+
// KEYWORD position. It sits here, above every other keyword answer, for
|
|
2664
|
+
// two reasons: no registered keyword begins `x-`, so it can pre-empt
|
|
2665
|
+
// nothing legitimate; and every downstream answer this would otherwise
|
|
2666
|
+
// reach (`not allowed in genre <g>`, `not valid inside <block>`,
|
|
2667
|
+
// `unrecognized line`) is a message about a typo, which is what §8.4 of
|
|
2668
|
+
// decisions/registry.md measured and what this replaces.
|
|
2669
|
+
// It sits BELOW the first-line header check deliberately: "first line
|
|
2670
|
+
// must be figdown …" is the more important thing to tell an author, and
|
|
2671
|
+
// an `x-` first line reports both, exactly as it reported two before.
|
|
2672
|
+
if(isXReserved(kw)){ err(n,X_RESERVED_KW(kw)); continue; }
|
|
2673
|
+
|
|
2623
2674
|
// Retired spelling: `colw` → `width`. Keyword naming
|
|
2624
2675
|
// discipline — one lowercase word, borrowed standard terminology; `colw`
|
|
2625
2676
|
// was the only invented abbreviation in the registry. Reported wherever
|
|
@@ -4090,6 +4141,163 @@ function stackSectionSvgs(results){
|
|
|
4090
4141
|
+chunks.join('')+'</svg>';
|
|
4091
4142
|
}
|
|
4092
4143
|
|
|
4144
|
+
// ---- the accessibility profile's emission (spec/figdown-a11y.md) ----------
|
|
4145
|
+
// `ACCESSIBILITY-PROFILE`/`ACCESSIBLE-DESCRIPTION-SOURCES`/`ACCESSIBLE-TEXT-EMISSION` (decisions/registry.md).
|
|
4146
|
+
// The publication profile wants three things in a published artifact: a role,
|
|
4147
|
+
// a NON-VISUAL name, and a description whose review state a machine can read.
|
|
4148
|
+
// None of it is emitted by default and none of it is a language construct —
|
|
4149
|
+
// the `with-a11y` RENDER OPTION (core §7, `ACCESSIBLE-TEXT-EMISSION` route 1) is the only way in,
|
|
4150
|
+
// exactly as `with-title` (`TITLE-RENDER-DEFAULT`) is the only way to draw the title as ink.
|
|
4151
|
+
//
|
|
4152
|
+
// WHY THIS SITS OUTSIDE `render` AND NOT INSIDE IT. Two reasons, and the
|
|
4153
|
+
// second is the load-bearing one:
|
|
4154
|
+
// 1. Core §7/`RENDERING-DETERMINISM` make an artifact a pure function of (source, recorded
|
|
4155
|
+
// options) and promise byte-identical output for one engine version.
|
|
4156
|
+
// Code that never runs cannot move a byte, and `render` is left exactly
|
|
4157
|
+
// as it was so that every existing artifact and golden is untouched.
|
|
4158
|
+
// 2. `ACCESSIBILITY-PROFILE` rules ONE artifact, ONE root, ONE name — the FIRST section's
|
|
4159
|
+
// `title`. A multi-section artifact's root is built by
|
|
4160
|
+
// `stackSectionSvgs` from N per-section renders, so a `<title>` emitted
|
|
4161
|
+
// inside `render` would land inside a `<g>` (naming the group, per `DESCRIPTION-KEY-SPELLING`
|
|
4162
|
+
// and SVG 1.1 §5.4) and there would be N of them. The name belongs to
|
|
4163
|
+
// the finished root, which only the artifact layer holds.
|
|
4164
|
+
// So the artifact layer (tools/build-svg.js, dist/figdown.js's `artifact()`)
|
|
4165
|
+
// calls `a11yApply(svg, docs[0])` on the finished root and records
|
|
4166
|
+
// `with-a11y` in `data-render-options`.
|
|
4167
|
+
const A11Y_ROLE = 'graphics-document';
|
|
4168
|
+
|
|
4169
|
+
// `ACCESSIBILITY-PROFILE` item 3: the accessible name is the document's `title` string,
|
|
4170
|
+
// UNMODIFIED — not truncated, not prefixed, never invented. `title` is
|
|
4171
|
+
// optional in the grammar and stays optional (`ACCESSIBILITY-PROFILE` is explicit that the
|
|
4172
|
+
// untitled figure still parses and still renders): a document with no
|
|
4173
|
+
// `title` line gets NO root `<title>`, and whether such a figure may be
|
|
4174
|
+
// PUBLISHED is the profile's question and the verifier's, never the
|
|
4175
|
+
// engine's. The test is ABSENCE, not truthiness (`EMPTY-LABEL-STATE`): `title ""` is a
|
|
4176
|
+
// written empty name and is emitted as one.
|
|
4177
|
+
function a11yTitleOf(doc){
|
|
4178
|
+
return (doc && doc.title!==null && doc.title!==undefined) ? String(doc.title) : null;
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
// ---- the derived description (`ACCESSIBLE-DESCRIPTION-SOURCES` item 7) --------------------------------
|
|
4182
|
+
// A `derived` description is a deterministic projection of the MODEL into
|
|
4183
|
+
// prose — "exactly as good as the model, and no better" — and `ACCESSIBLE-DESCRIPTION-SOURCES`'s
|
|
4184
|
+
// operative half is the NEGATIVE rule: it MUST NOT state what the model does
|
|
4185
|
+
// not assert. No causality, no "this shows how X works", no count the model
|
|
4186
|
+
// cannot produce. Every sentence below is an inventory statement over a
|
|
4187
|
+
// collection the model actually carries.
|
|
4188
|
+
//
|
|
4189
|
+
// COVERED GENRES ARE EXACTLY THE THREE `ACCESSIBLE-DESCRIPTION-SOURCES` RULED CONTENT RULES FOR:
|
|
4190
|
+
// `topology`, `bitfield`, `sequence`. Every other genre the engine supports
|
|
4191
|
+
// (`block`, `flowchart`, `statechart`, `table`, `timing`, and the
|
|
4192
|
+
// experimental `chart` region) gets NO derived description — the state stays
|
|
4193
|
+
// `absent` — because inventing content rules for them here would be design,
|
|
4194
|
+
// not implementation. That boundary is stated, not silent: spec/figdown-a11y.md
|
|
4195
|
+
// §4.4 records which genres are covered, which are not, and why.
|
|
4196
|
+
const A11Y_DERIVED_GENRES = ['topology','bitfield','sequence'];
|
|
4197
|
+
function a11yQ(s){ return '"'+String(s)+'"'; }
|
|
4198
|
+
function a11yCount(n,word){ return n+' '+word+(n===1?'':'s'); }
|
|
4199
|
+
// id, plus the label the model carries WHEN it carries one. The renderer's
|
|
4200
|
+
// id-for-absent-label fallback (`OMITTED-LABEL-RECORDING`) is a DISPLAY rule and deliberately does
|
|
4201
|
+
// not run here: the model records absence, and a derived description that
|
|
4202
|
+
// silently promoted an id to a name would assert a name nobody wrote.
|
|
4203
|
+
function a11yNamed(x){
|
|
4204
|
+
return (x.label===null||x.label===undefined) ? String(x.id) : String(x.id)+' '+a11yQ(x.label);
|
|
4205
|
+
}
|
|
4206
|
+
function a11yDeriveTopology(doc){
|
|
4207
|
+
const s=[];
|
|
4208
|
+
s.push('Topology figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
|
|
4209
|
+
s.push(a11yCount(doc.nodes.length,'node')+', '+a11yCount(doc.edges.length,'connector')
|
|
4210
|
+
+', '+a11yCount(doc.groups.length,'group')+'.');
|
|
4211
|
+
s.push('Flow direction: '+String(doc.flow)+'.');
|
|
4212
|
+
if(doc.nodes.length) s.push('Nodes: '+doc.nodes.map(a11yNamed).join(', ')+'.');
|
|
4213
|
+
for(const g of doc.groups){
|
|
4214
|
+
const mem=doc.nodes.filter(n=>n.group===g.id).map(n=>String(n.id));
|
|
4215
|
+
s.push('Group '+a11yNamed(g)+(mem.length?' contains: '+mem.join(', ')+'.':' has no declared members.'));
|
|
4216
|
+
}
|
|
4217
|
+
// A class's MEANING is `label` on the engine object (the canonical
|
|
4218
|
+
// projection renames it `meaning`). `ACCESSIBLE-DESCRIPTION-SOURCES` says "each declared class and its
|
|
4219
|
+
// stated meaning (which the legend already prints)", so the legend's own
|
|
4220
|
+
// filter is inherited rather than re-invented: `class x ""` is an attribute
|
|
4221
|
+
// grouping that states no meaning, it prints no legend entry, and it makes
|
|
4222
|
+
// no sentence here either.
|
|
4223
|
+
for(const c of (doc.classes||[])) if(c.label!=='')
|
|
4224
|
+
s.push('Class '+String(c.id)+' means '+a11yQ(c.label)+'.');
|
|
4225
|
+
return s.join(' ');
|
|
4226
|
+
}
|
|
4227
|
+
function a11yDeriveBitfield(doc){
|
|
4228
|
+
const s=[];
|
|
4229
|
+
s.push('Bitfield figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
|
|
4230
|
+
const blocks=(doc.blocks||[]).filter(b=>b.type==='bitfield');
|
|
4231
|
+
if(!blocks.length) return null;
|
|
4232
|
+
for(const b of blocks){
|
|
4233
|
+
s.push('Bitfield '+a11yNamed(b)+': word '+a11yCount(b.word,'bit')
|
|
4234
|
+
+', numbering '+String(b.numbering)+'.');
|
|
4235
|
+
// `wrap` entries are ROW BREAKS (projected as `break`), a division of the
|
|
4236
|
+
// drawing and not a field; `ACCESSIBLE-DESCRIPTION-SOURCES`'s rule is "fields in order with widths".
|
|
4237
|
+
// A field's width is `w` on the engine object: a bit count, or the string
|
|
4238
|
+
// `*` for the remainder of the row.
|
|
4239
|
+
const fields=(b.fields||[]).filter(f=>!f.wrap);
|
|
4240
|
+
if(!fields.length){ s.push('No fields declared.'); continue; }
|
|
4241
|
+
s.push('Fields in order: '+fields.map(f=>
|
|
4242
|
+
a11yQ(f.name)+' '+(String(f.w)==='*'?'width *':a11yCount(f.w,'bit'))).join(', ')+'.');
|
|
4243
|
+
}
|
|
4244
|
+
return s.join(' ');
|
|
4245
|
+
}
|
|
4246
|
+
function a11yDeriveSequence(doc){
|
|
4247
|
+
const s=[];
|
|
4248
|
+
s.push('Sequence figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
|
|
4249
|
+
const lls=doc.lifelines||[], msgs=doc.messages||[], frags=doc.fragments||[];
|
|
4250
|
+
s.push('Lifelines in declaration order: '+(lls.length?lls.map(a11yNamed).join(', '):'none')+'.');
|
|
4251
|
+
// The time axis is declaration order and it is TOTAL (`SEQUENCE-GENRE-VOCABULARY`, draft §31),
|
|
4252
|
+
// so "in their stated total order" is `line` ascending — the model's own
|
|
4253
|
+
// ordering key, not a re-reading of the picture.
|
|
4254
|
+
const ordered=msgs.slice().sort((p,q)=>p.line-q.line);
|
|
4255
|
+
s.push('Messages in stated order: '+(ordered.length?ordered.map((m,i)=>
|
|
4256
|
+
(i+1)+'. '+String(m.a)+' '+String(m.op)+' '+String(m.b)
|
|
4257
|
+
+(m.label===null||m.label===undefined?'':' '+a11yQ(m.label))).join('; '):'none')+'.');
|
|
4258
|
+
if(frags.length) s.push('Fragments: '+frags.map(f=>
|
|
4259
|
+
a11yNamed(f)+' (type '+String(f.type)+')').join(', ')+'.');
|
|
4260
|
+
return s.join(' ');
|
|
4261
|
+
}
|
|
4262
|
+
function a11yDerivedDesc(doc){
|
|
4263
|
+
if(!doc||A11Y_DERIVED_GENRES.indexOf(doc.genre)<0) return null;
|
|
4264
|
+
if(doc.genre==='topology') return a11yDeriveTopology(doc);
|
|
4265
|
+
if(doc.genre==='bitfield') return a11yDeriveBitfield(doc);
|
|
4266
|
+
if(doc.genre==='sequence') return a11yDeriveSequence(doc);
|
|
4267
|
+
return null; // state `absent` — an uncovered genre, spec/figdown-a11y.md §4.4
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
// Apply the profile to a FINISHED root `<svg>`: the role on the root, the
|
|
4271
|
+
// non-visual `<title>` as its FIRST child (SVG 1.1 §5.4 / `DESCRIPTION-KEY-SPELLING` — a `<title>`
|
|
4272
|
+
// names its parent and belongs first), then the `<desc>` carrying the derived
|
|
4273
|
+
// description with its state in a `data-*` attribute (`ACCESSIBLE-DESCRIPTION-SOURCES` item 6, way 1; the
|
|
4274
|
+
// manifest carries the evidence, way 3). Idempotent: a root that already
|
|
4275
|
+
// declares a role is returned unchanged.
|
|
4276
|
+
function a11yApply(svg, doc){
|
|
4277
|
+
const s=String(svg);
|
|
4278
|
+
const m=/^<svg\b[^>]*>/.exec(s);
|
|
4279
|
+
if(!m) return s;
|
|
4280
|
+
if(/\brole="/.test(m[0])) return s;
|
|
4281
|
+
const parts=[];
|
|
4282
|
+
const t=a11yTitleOf(doc);
|
|
4283
|
+
if(t!==null) parts.push('<title>'+esc(t)+'</title>');
|
|
4284
|
+
const d=a11yDerivedDesc(doc);
|
|
4285
|
+
if(d!==null&&d!=='') parts.push('<desc data-desc-state="derived">'+esc(d)+'</desc>');
|
|
4286
|
+
return m[0].replace(/>$/,' role="'+A11Y_ROLE+'">')+parts.join('')+s.slice(m[0].length);
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
// The `data-render-options` value for a render (core §7). ONE spelling, in
|
|
4290
|
+
// ONE place, so the artifact layers cannot drift: options in declaration
|
|
4291
|
+
// order, space-separated — the SVG/HTML idiom for a token list. A default
|
|
4292
|
+
// render writes no attribute at all, which is why the empty string is
|
|
4293
|
+
// returned rather than an empty attribute.
|
|
4294
|
+
function renderOptionsAttr(opts){
|
|
4295
|
+
const names=[];
|
|
4296
|
+
if(opts&&opts.title===true) names.push('with-title');
|
|
4297
|
+
if(opts&&opts.a11y===true) names.push('with-a11y');
|
|
4298
|
+
return names.length?' data-render-options="'+names.join(' ')+'"':'';
|
|
4299
|
+
}
|
|
4300
|
+
|
|
4093
4301
|
// `SEMICOLON-STATUS`: `;` is RESERVED for a future statement separator, and
|
|
4094
4302
|
// RULE 1.3 says a reserved mark MUST NOT be given any other meaning. Until
|
|
4095
4303
|
// this release it was an ordinary character: `node a ;` parsed and `;`
|
|
@@ -4166,7 +4374,7 @@ function findComment(s){
|
|
|
4166
4374
|
// ============================================================
|
|
4167
4375
|
const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
|
|
4168
4376
|
|
|
4169
|
-
// ---- cw(): script-aware advance width --------------------------------
|
|
4377
|
+
// ---- cw(): script-aware advance width (`TEXT-ADVANCE-MEASUREMENT`) --------------------------------
|
|
4170
4378
|
// Every px-per-character constant in this file — CH here, and its siblings at
|
|
4171
4379
|
// the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
|
|
4172
4380
|
// caption) — is calibrated on LATIN, where one character advances ~0.554 em.
|
|
@@ -4737,15 +4945,80 @@ function render(doc,ropts){
|
|
|
4737
4945
|
// Retiring `color=` removes the first case; omitting the swatch removes
|
|
4738
4946
|
// the second. Every channel a class can still declare (`fill`, `stroke`,
|
|
4739
4947
|
// `style`) is drawn, so "declared but not shown" is now unreachable.
|
|
4948
|
+
// 0.5 (`LEGEND-SWATCH-SHAPE`): the swatch depicts THE CHANNEL THE CLASS ACTUALLY
|
|
4949
|
+
// PAINTS. `CLASS-PAINT-REQUIREMENT` ruled WHETHER a swatch is drawn and never WHAT SHAPE it
|
|
4950
|
+
// takes, so every painting class got a `<rect>` — and a class that
|
|
4951
|
+
// declares only `stroke=` and is carried only by CONNECTORS then appeared
|
|
4952
|
+
// as a white box with a coloured outline while the drawing showed a
|
|
4953
|
+
// coloured LINE. The legend's visual vocabulary did not match the
|
|
4954
|
+
// figure's, and with `style=dashed` the dash landed on a rectangle's
|
|
4955
|
+
// perimeter instead of along a run. Measured on 2026-08-26: 36 of this
|
|
4956
|
+
// repository's 76 legend-bearing class-uses are connector-only (33 of them
|
|
4957
|
+
// stroke-without-fill) and 10 figures are entirely connector-only; in the
|
|
4958
|
+
// production corpus behind the field reports, 160 of 1350 legend-bearing
|
|
4959
|
+
// class-uses are connector-only and 159 of those are stroke-without-fill.
|
|
4960
|
+
//
|
|
4961
|
+
// WHICH KINDS ARE CONNECTORS is read off the model, not guessed and not
|
|
4962
|
+
// stored: the four connector spellings (`edge` `flowline` `transition`
|
|
4963
|
+
// `message`, core §10) are exactly `doc.edges` and `doc.messages` in the
|
|
4964
|
+
// model — every other collection that carries `class=` is a box, a frame
|
|
4965
|
+
// or a cell, i.e. something with an area. This adds no model field, no
|
|
4966
|
+
// option key and no spelling; it is a byte-moving RENDER change.
|
|
4967
|
+
const clsUse={};
|
|
4968
|
+
{ const use=(x,conn)=>{
|
|
4969
|
+
const ids=(x.cls===undefined||x.cls===null)?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
|
|
4970
|
+
for(const id of ids){ const u=clsUse[id]||(clsUse[id]={conn:false,box:false});
|
|
4971
|
+
if(conn) u.conn=true; else u.box=true; } };
|
|
4972
|
+
for(const x of doc.nodes) use(x,false);
|
|
4973
|
+
for(const x of doc.groups) use(x,false);
|
|
4974
|
+
for(const x of doc.edges) use(x,true);
|
|
4975
|
+
for(const x of (doc.messages||[])) use(x,true);
|
|
4976
|
+
for(const x of (doc.lifelines||[])) use(x,false);
|
|
4977
|
+
for(const x of (doc.states||[])) use(x,false);
|
|
4978
|
+
for(const x of (doc.fragments||[])) use(x,false);
|
|
4979
|
+
for(const x of (doc.operands||[])) use(x,false);
|
|
4980
|
+
for(const b of doc.blocks){ use(b,false);
|
|
4981
|
+
if(b.fields) for(const f of b.fields) use(f,false);
|
|
4982
|
+
if(b.marks) for(const mk of b.marks) use(mk,false); } }
|
|
4740
4983
|
for(const c of legendCls){
|
|
4741
4984
|
const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
|
|
4742
|
-
|
|
4743
|
-
|
|
4985
|
+
// The samples this class earns, in the ruled order: LINE then BOX.
|
|
4986
|
+
// · carried only by connectors → a line sample (`LEGEND-SWATCH-SHAPE` clause 1)
|
|
4987
|
+
// · carried only by boxes → the `<rect>` `CLASS-PAINT-REQUIREMENT` already drew (2)
|
|
4988
|
+
// · carried by both → both samples, side by side (3): the
|
|
4989
|
+
// legend exists to explain, and the extra width is cheaper than
|
|
4990
|
+
// making a reader guess which half of the figure an entry is about.
|
|
4991
|
+
// · declares `fill=` → a box sample WHATEVER it is carried
|
|
4992
|
+
// by (4), because a fill needs an area to be visible. A box on a
|
|
4993
|
+
// connector-only class is then the class declaring a channel its
|
|
4994
|
+
// elements cannot show — `CLASS-CHANNEL-COLLISION`'s territory, reported there, not
|
|
4995
|
+
// re-ruled here (and `gate:legend`'s unreachable-channel check
|
|
4996
|
+
// already refuses that shape in this repository's corpus).
|
|
4997
|
+
// · declares no paint → no sample at all (5, `CLASS-PAINT-REQUIREMENT` unchanged).
|
|
4998
|
+
// · declared but never carried → the shape comes from the DECLARED
|
|
4999
|
+
// channels alone: `fill` → box, stroke-only → line. Nothing else is
|
|
5000
|
+
// known about it, and its own paint is the only evidence there is.
|
|
5001
|
+
const u=clsUse[c.id];
|
|
5002
|
+
const samples=[];
|
|
4744
5003
|
if(paints){
|
|
4745
|
-
|
|
4746
|
-
|
|
5004
|
+
if(u ? u.conn : c.fill===undefined) samples.push('line');
|
|
5005
|
+
if(u ? (u.box||c.fill!==undefined) : c.fill!==undefined) samples.push('box');
|
|
4747
5006
|
}
|
|
4748
|
-
|
|
5007
|
+
const tw=cw(c.label)*6.6+9+21*samples.length;
|
|
5008
|
+
if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
|
|
5009
|
+
const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
5010
|
+
let sx=lx;
|
|
5011
|
+
for(const s of samples){
|
|
5012
|
+
// Both samples take the SAME 16 px advance, so a mixed entry is two
|
|
5013
|
+
// swatches and not a wider one. The line is drawn at the rect's own
|
|
5014
|
+
// vertical centre (3 + 11/2) and at 1.6 — the stroke-width every
|
|
5015
|
+
// router in this engine gives a drawn connector — so the sample is
|
|
5016
|
+
// the same ink the figure lays down, at the same weight.
|
|
5017
|
+
if(s==='line') es.push('<line x1="'+sx+'" y1="'+(ly+8.5)+'" x2="'+(sx+16)+'" y2="'+(ly+8.5)+'" stroke="'+(c.stroke||'#555')+'" stroke-width="1.6"'+dash+'/>');
|
|
5018
|
+
else es.push('<rect x="'+sx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
|
|
5019
|
+
sx+=21;
|
|
5020
|
+
}
|
|
5021
|
+
es.push('<text x="'+(lx+21*samples.length)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
|
|
4749
5022
|
lx+=tw+14; maxW=Math.max(maxW,lx);
|
|
4750
5023
|
}
|
|
4751
5024
|
parts.push(es.join(''));
|
|
@@ -6674,7 +6947,18 @@ function renderScene(doc,y0){
|
|
|
6674
6947
|
// node box, which is already an obstacle.
|
|
6675
6948
|
//
|
|
6676
6949
|
// An external's label is NOT an obstacle to its OWN edge — that edge must
|
|
6677
|
-
// reach the anchor the label names.
|
|
6950
|
+
// reach the anchor the label names. A GROUP's name is the same rule one
|
|
6951
|
+
// construct over, and 0.4 shipped only half of it (`GROUP-BOUNDARY-OBSTACLE`): a group's
|
|
6952
|
+
// name strip runs the full width of the band's TOP, so it lies across every
|
|
6953
|
+
// approach an outside node has to a member inside. Made an obstacle to all
|
|
6954
|
+
// comers, it turned every boundary-crossing edge into a detour that left the
|
|
6955
|
+
// corridor, ran down the band's outer edge and entered the member from the
|
|
6956
|
+
// side — the drawing then said the line arrives at the CONTAINER when the
|
|
6957
|
+
// source says it arrives at the MEMBER. `EDGE-BEND-RETENTION` had already ruled the group
|
|
6958
|
+
// BOX is an obstacle only to an edge that "neither starts nor ends inside";
|
|
6959
|
+
// the name is part of the same band and takes the same exemption. A foreign
|
|
6960
|
+
// shaft — neither endpoint in the group — is still detoured, which is the
|
|
6961
|
+
// whole of what 0.4 was right about.
|
|
6678
6962
|
const extLbl=n=>{
|
|
6679
6963
|
const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
|
|
6680
6964
|
const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
|
|
@@ -6689,18 +6973,24 @@ function renderScene(doc,y0){
|
|
|
6689
6973
|
for(const k in gBox){ const g=doc.groups.find(z=>z.id===k);
|
|
6690
6974
|
if(!g||!g.label) continue;
|
|
6691
6975
|
const B=gBox[k];
|
|
6692
|
-
nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1});
|
|
6693
|
-
}
|
|
6694
|
-
//
|
|
6695
|
-
|
|
6976
|
+
nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1, id:k});
|
|
6977
|
+
}
|
|
6978
|
+
// Which group an endpoint id belongs to, for the name-strip exemption below.
|
|
6979
|
+
const grpOfId=new Map();
|
|
6980
|
+
for(const n of nodes) if(n.group) grpOfId.set(n.id,n.group);
|
|
6981
|
+
// The obstacle list a given edge must respect: every band name except the
|
|
6982
|
+
// ones this edge terminates AT or INSIDE, plus every external label except
|
|
6983
|
+
// the ones this edge itself terminates at.
|
|
6696
6984
|
// A label's ink is its glyphs plus the clearance that keeps a line from
|
|
6697
6985
|
// READING as struck through it. 4 px on every side — the same number the
|
|
6698
6986
|
// legibility floor uses for a label's association margin — so a shaft that
|
|
6699
6987
|
// grazes a name at 3 px is detoured rather than tolerated.
|
|
6700
6988
|
const LBL_PAD=4;
|
|
6701
6989
|
const padded=o=>({x:o.x-LBL_PAD,y:o.y-LBL_PAD,w:o.w+2*LBL_PAD,h:o.h+2*LBL_PAD});
|
|
6990
|
+
const ownsName=(o,e)=>o.id===e.a||o.id===e.b||
|
|
6991
|
+
o.id===grpOfId.get(e.a)||o.id===grpOfId.get(e.b);
|
|
6702
6992
|
const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
|
|
6703
|
-
.concat(nameObs.map(padded));
|
|
6993
|
+
.concat(nameObs.filter(o=>!ownsName(o,e)).map(padded));
|
|
6704
6994
|
const rings=new Map();
|
|
6705
6995
|
for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
|
|
6706
6996
|
// ── A LASSO THAT ENCLOSES A NON-MEMBER IS A FALSE DRAWING (item 69) ──────
|
|
@@ -9819,8 +10109,8 @@ function r2(v){ return Math.round(v*100)/100; }
|
|
|
9819
10109
|
// canvas grows right and down only, so text at a negative coordinate is
|
|
9820
10110
|
// CLIPPED, never merely misplaced (`LABEL-PLACEMENT-METRIC`, which fixed exactly this
|
|
9821
10111
|
// for a `table` caption and a `chart` row/column label gutter but not for a
|
|
9822
|
-
// bitfield/timing title or a chart's own top caption —
|
|
9823
|
-
//
|
|
10112
|
+
// bitfield/timing title or a chart's own top caption — reported from
|
|
10113
|
+
// downstream production authoring).
|
|
9824
10114
|
//
|
|
9825
10115
|
// ONE MEASUREMENT, used by all four renderers below, so the calibration lives
|
|
9826
10116
|
// in one place rather than four. Bold text at this size is measured ~8%
|
|
@@ -10327,9 +10617,14 @@ function renderTable(t,y0){
|
|
|
10327
10617
|
const mk=markOf(r,c);
|
|
10328
10618
|
// block fill= is the default DATA-cell fill (header tint is structural)
|
|
10329
10619
|
const fill=(mk&&mk.fill)||(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':(t.fill||'#fff')));
|
|
10330
|
-
// addressable cells carry table-id:row:col
|
|
10331
|
-
|
|
10332
|
-
|
|
10620
|
+
// addressable cells carry table-id:row:col for EVERY tier (backlog 71):
|
|
10621
|
+
// data rows are 1..n, the bottom header tier keeps its historical `0`
|
|
10622
|
+
// spelling so existing consumers of that channel see no change, and
|
|
10623
|
+
// every tier above it is `hN` (1-indexed from the top) — the same
|
|
10624
|
+
// spelling the editor's own internal row-token model already used, so
|
|
10625
|
+
// the editor can read this channel with no translation table.
|
|
10626
|
+
const addrR = r>=H ? String(r-H+1) : (r===H-1 ? '0' : 'h'+(r+1));
|
|
10627
|
+
const addr = ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
|
|
10333
10628
|
// A merged cell owns every grid square it spans, so its internal
|
|
10334
10629
|
// boundaries have the same owner on both sides and are never drawn.
|
|
10335
10630
|
const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
|
|
@@ -11645,6 +11940,18 @@ function snapshotAbs(overrides){
|
|
|
11645
11940
|
// snapshotted origin); ungrouped nodes and group origins are canvas px.
|
|
11646
11941
|
function emitPins(snap, frame, sourceDoc){
|
|
11647
11942
|
const left=frame.left||0, top=frame.top||0, pins=[];
|
|
11943
|
+
// THREE DECIMALS, not integers, and the reason is §4.3's fixed point.
|
|
11944
|
+
// `materializePins` writes pins, re-renders to read back the pinned
|
|
11945
|
+
// regime's own frame, and re-emits the SAME absolute targets against it;
|
|
11946
|
+
// that second pass is only a fixed point if a coordinate survives the
|
|
11947
|
+
// write. `Math.round(v)` did not: a guide hit or an aligned axis produced
|
|
11948
|
+
// by align/distribute is generally fractional, so rounding moved the very
|
|
11949
|
+
// element the gesture had just placed, by up to half a pixel, and the
|
|
11950
|
+
// alignment the user had asked for was not the alignment written down.
|
|
11951
|
+
// The cost is that EVERY node in the snapshot is written to three
|
|
11952
|
+
// decimals, not only the one the gesture moved, which is noisier source
|
|
11953
|
+
// for a language whose case is that a reader can check it — filed, with
|
|
11954
|
+
// its measurement and a candidate fix, as backlog 73 (§7).
|
|
11648
11955
|
const coord=v=>Math.round(v*1000)/1000;
|
|
11649
11956
|
sourceDoc=sourceDoc||lastDoc;
|
|
11650
11957
|
for(const gid in snap.groups){
|
|
@@ -12246,8 +12553,9 @@ function installDirectEdit(){
|
|
|
12246
12553
|
hit.addEventListener('click',choose);
|
|
12247
12554
|
hit.addEventListener('dblclick',choose);
|
|
12248
12555
|
});
|
|
12249
|
-
// Table cell addresses are UI-only
|
|
12250
|
-
//
|
|
12556
|
+
// Table cell addresses are UI-only: `data-ui-cell` re-spells the engine's
|
|
12557
|
+
// own `data-cell` channel (which now covers every header tier, backlog 71)
|
|
12558
|
+
// into the editor's internal row-token form — see installTableCellAddresses.
|
|
12251
12559
|
svg.querySelectorAll('[data-ui-cell]').forEach(rc=>{
|
|
12252
12560
|
if(!editable(rc)) return;
|
|
12253
12561
|
rc.addEventListener('click',ev=>{
|
|
@@ -12732,6 +13040,23 @@ function highlightSelection(){
|
|
|
12732
13040
|
sh.classList.add('element-selected');
|
|
12733
13041
|
}
|
|
12734
13042
|
}
|
|
13043
|
+
// THE ZERO-LENGTH SEGMENT IS A MERGE. This is the single distinction the
|
|
13044
|
+
// whole merge-safety of `setTableCellText`, `insertTableColumn` and
|
|
13045
|
+
// `deleteTableColumn` rests on, and nothing in the code says it out loud:
|
|
13046
|
+
//
|
|
13047
|
+
// `| a || b |` -> ['a', '', 'b'] the '' is a COLSPAN CONTINUATION:
|
|
13048
|
+
// the cell to its left spans into it
|
|
13049
|
+
// `| a | | b |` -> ['a', ' ', 'b'] two spaces is an EMPTY CELL that
|
|
13050
|
+
// occupies its own column
|
|
13051
|
+
//
|
|
13052
|
+
// So the segments are returned VERBATIM — no trimming, ever. Trimming them
|
|
13053
|
+
// would map both of the rows above onto the same array and silently turn
|
|
13054
|
+
// every empty cell into a merge (and every merge into an empty cell on the
|
|
13055
|
+
// way back through `buildPipeSource`, which joins with no padding of its
|
|
13056
|
+
// own). The three call sites each read the distinction differently: a write
|
|
13057
|
+
// into '' is refused outright, a column inserted at a '' must itself be ''
|
|
13058
|
+
// to stay inside the span, and a delete moves the left neighbour's text
|
|
13059
|
+
// rightwards into a trailing '' rather than dropping the span's head.
|
|
12735
13060
|
function splitPipeSource(line){
|
|
12736
13061
|
const s=String(line).trim();
|
|
12737
13062
|
if(!s.startsWith('|')||!s.endsWith('|')) return null;
|
|
@@ -12826,6 +13151,8 @@ function setTableCellText(lines,sectionIndex,tid,rowTok,col,text){
|
|
|
12826
13151
|
const idx=hm?info.headers[+hm[1]-1]:info.dataRows[+rowTok-1];
|
|
12827
13152
|
if(idx===undefined)return {ok:false,error:'table cell row '+rowTok+' does not exist'};
|
|
12828
13153
|
const parts=splitPipeSource(lines[idx]); if(!parts||col<1||col>parts.length)return {ok:false,error:'table column '+col+' does not exist'};
|
|
13154
|
+
// '' is a merge continuation, not an empty cell (see `splitPipeSource`):
|
|
13155
|
+
// writing text into it would give the span two texts.
|
|
12829
13156
|
if(parts[col-1]==='')return {ok:false,error:'a merged continuation cell cannot be edited directly'};
|
|
12830
13157
|
const old=parts[col-1],lead=(/^\s*/.exec(old)||[''])[0],tail=(/\s*$/.exec(old)||[''])[0];
|
|
12831
13158
|
const escaped=String(text).replace(/\|/g,'\\|').replace(/\r/g,'').replace(/\n/g,'<br>');
|
|
@@ -12922,6 +13249,11 @@ function insertTableColumn(lines,sectionIndex,tid,afterCol){
|
|
|
12922
13249
|
const pos=Math.max(0,Math.min(afterCol,info.nCols));
|
|
12923
13250
|
for(const idx of info.headers.concat([info.delimiter],info.dataRows)){
|
|
12924
13251
|
const parts=splitPipeSource(lines[idx]); if(!parts)return {ok:false,error:'cannot parse table pipe row'};
|
|
13252
|
+
// Inserting INSIDE a span must widen the span, not punch a cell into it:
|
|
13253
|
+
// a '' at the insertion point means the column being pushed right is a
|
|
13254
|
+
// merge continuation, so the new segment is '' too (see
|
|
13255
|
+
// `splitPipeSource`). Everywhere else the new segment is ' ' — two
|
|
13256
|
+
// spaces, an EMPTY CELL, which is a different thing from ''.
|
|
12925
13257
|
const insideMerge=idx!==info.delimiter&&parts[pos]==='';
|
|
12926
13258
|
parts.splice(pos,0,idx===info.delimiter?'---':(insideMerge?'':' '));
|
|
12927
13259
|
lines[idx]=buildPipeSource(parts);
|
|
@@ -12955,6 +13287,11 @@ function deleteTableColumn(lines,sectionIndex,tid,col){
|
|
|
12955
13287
|
for(const idx of info.headers.concat([info.delimiter],info.dataRows)){
|
|
12956
13288
|
const parts=splitPipeSource(lines[idx]); if(!parts)return {ok:false,error:'cannot parse table pipe row'};
|
|
12957
13289
|
const k=col-1;
|
|
13290
|
+
// Deleting the HEAD of a span would delete the span's only text and
|
|
13291
|
+
// leave its continuations ('' segments, see `splitPipeSource`) headless —
|
|
13292
|
+
// the row would re-parse with the merge starting one column later and no
|
|
13293
|
+
// label. So the text moves rightwards into the first continuation, which
|
|
13294
|
+
// becomes the span's new head, before the column is spliced out.
|
|
12958
13295
|
if(idx!==info.delimiter&&parts[k]!==''&&parts[k+1]==='')parts[k+1]=parts[k];
|
|
12959
13296
|
parts.splice(k,1);lines[idx]=buildPipeSource(parts);
|
|
12960
13297
|
}
|
|
@@ -12994,40 +13331,30 @@ function tableAnchorInRow(table,rowTok,col){
|
|
|
12994
13331
|
anchors.sort((a,b)=>Math.abs(a-col)-Math.abs(b-col)||a-b);
|
|
12995
13332
|
return anchors[0];
|
|
12996
13333
|
}
|
|
12997
|
-
|
|
12998
|
-
|
|
12999
|
-
|
|
13334
|
+
// installTableCellAddresses reads the engine's own `data-cell` channel —
|
|
13335
|
+
// backlog 71 closed the gap where the upper header tiers carried no channel
|
|
13336
|
+
// and the UI had to zip a scraped caption/rect run against the model as a
|
|
13337
|
+
// stand-in identity. The engine now addresses every tier (see renderTable),
|
|
13338
|
+
// so the UI's job is a pure re-spelling: `data-cell`'s row component uses
|
|
13339
|
+
// `0` for the bottom header tier (its historical, compatibility spelling)
|
|
13340
|
+
// and `hN` (1-indexed from the top) for every tier above it; the editor's
|
|
13341
|
+
// own internal row-token model spells ALL header tiers `h1..hH` including
|
|
13342
|
+
// the bottom one, so the only translation is `0` -> `h<H>`. An element that
|
|
13343
|
+
// carries no `data-cell` (an old render, or a DOM fixture that predates this
|
|
13344
|
+
// channel) is simply never visited here and stays unaddressed — losing the
|
|
13345
|
+
// cell affordance is recoverable, and there is no ink-based fallback left to
|
|
13346
|
+
// reach for.
|
|
13000
13347
|
function installTableCellAddresses(root,doc){
|
|
13001
|
-
const
|
|
13002
|
-
|
|
13003
|
-
for(const
|
|
13004
|
-
|
|
13005
|
-
|
|
13006
|
-
if(
|
|
13007
|
-
const
|
|
13008
|
-
while(el){
|
|
13009
|
-
if(el.matches&&el.matches('text[font-size="13"][font-weight="600"]'))break;
|
|
13010
|
-
if(el.tagName&&el.tagName.toLowerCase()==='rect'&&el.getAttribute('stroke')==='none')rects.push(el);
|
|
13011
|
-
el=el.nextElementSibling;
|
|
13012
|
-
}
|
|
13013
|
-
const addrs=tableAnchorAddresses(table);
|
|
13014
|
-
// WHERE BOTH CHANNELS SPEAK, THEY MUST AGREE. The run above is taken in
|
|
13015
|
-
// anchor order because the upper header tiers have no public channel to
|
|
13016
|
-
// read; the engine's own `data-cell` covers the bottom tier and every data
|
|
13017
|
-
// row, and a run that has slipped by one shows up there first. A table
|
|
13018
|
-
// whose ink stops matching its model is left unaddressed — losing the cell
|
|
13019
|
-
// affordance is recoverable, editing the cell the click did not name is
|
|
13020
|
-
// not.
|
|
13348
|
+
const tables=(doc.blocks||[]).filter(x=>x.type==='table');
|
|
13349
|
+
if(!tables.length)return;
|
|
13350
|
+
const byId={}; for(const t of tables) byId[t.id]=t;
|
|
13351
|
+
for(const el of root.querySelectorAll('[data-cell]')){
|
|
13352
|
+
const parts=String(el.getAttribute('data-cell')).split(':');
|
|
13353
|
+
if(parts.length!==3)continue;
|
|
13354
|
+
const table=byId[parts[0]]; if(!table)continue;
|
|
13021
13355
|
const H=(table.heads||[]).length;
|
|
13022
|
-
const
|
|
13023
|
-
|
|
13024
|
-
for(let i=0;i<n&&agree;i++){
|
|
13025
|
-
const own=rects[i].dataset.cell; if(!own) continue;
|
|
13026
|
-
agree=own===table.id+':'+(addrs[i].rowTok==='h'+H?'0':addrs[i].rowTok)+':'+addrs[i].col;
|
|
13027
|
-
}
|
|
13028
|
-
if(!agree) continue;
|
|
13029
|
-
for(let i=0;i<n;i++)
|
|
13030
|
-
rects[i].dataset.uiCell=tableCellId(table.id,addrs[i].rowTok,addrs[i].col);
|
|
13356
|
+
const rowTok=parts[1]==='0'?'h'+H:parts[1];
|
|
13357
|
+
el.dataset.uiCell=tableCellId(table.id,rowTok,+parts[2]);
|
|
13031
13358
|
}
|
|
13032
13359
|
}
|
|
13033
13360
|
function tableSelectableElement(id){
|
|
@@ -13136,6 +13463,21 @@ function bitfieldItems(lines,sectionIndex,bid){
|
|
|
13136
13463
|
}
|
|
13137
13464
|
return Object.assign(info,{items});
|
|
13138
13465
|
}
|
|
13466
|
+
// A bitfield item's identity has TWO shapes because the source has two
|
|
13467
|
+
// spellings, and the arity of the id is what distinguishes them:
|
|
13468
|
+
//
|
|
13469
|
+
// `bid:line` a CLASSIC field, or a `break` — one line, one item,
|
|
13470
|
+
// so the line IS the identity
|
|
13471
|
+
// `bid:line:itemIndex` one item of a COMPACT list (`field A:2,B:2,C:4`) —
|
|
13472
|
+
// several items share one line, so the line alone
|
|
13473
|
+
// cannot name one
|
|
13474
|
+
//
|
|
13475
|
+
// The two-part form is not "index 0 omitted": a `null` itemIndex means *the
|
|
13476
|
+
// whole line is the item*, which is why `parseBitfieldSelectionId` returns
|
|
13477
|
+
// `null` rather than `0` for it and every consumer branches on that. The
|
|
13478
|
+
// per-item transactions (`rewriteCompactFieldItem`, the `data-compact-*`
|
|
13479
|
+
// controls) need the index; the per-LINE ones (move up/down, and the drag
|
|
13480
|
+
// reorder) act on the line and must not be given one.
|
|
13139
13481
|
function bitfieldSelectionId(bid,line,itemIndex){
|
|
13140
13482
|
return bid+':'+line+(itemIndex===undefined||itemIndex===null?'':':'+itemIndex);
|
|
13141
13483
|
}
|
|
@@ -13214,6 +13556,30 @@ function fieldOptionAbsent(text,sectionIndex,bid,line,key){
|
|
|
13214
13556
|
const item=info.items.find(x=>x.line===line&&x.kind!=='break');if(!item)return false;
|
|
13215
13557
|
return !authoredOptionSpan(splitLineComment(lines[item.abs]).code,key,0,null);
|
|
13216
13558
|
}
|
|
13559
|
+
// `fieldOptionAbsent`'s positive twin: the option IS on the field line, and
|
|
13560
|
+
// the authored bytes are exactly `key=<value>` — the value as the caller
|
|
13561
|
+
// spelled it, quoting and FigDown escaping included.
|
|
13562
|
+
//
|
|
13563
|
+
// It exists because the postcondition it serves used to recover the authored
|
|
13564
|
+
// value with `JSON.parse(value)`, which is a SECOND string grammar over bytes
|
|
13565
|
+
// the first one already governs. `escapeFdString` escapes `\`, `"` and
|
|
13566
|
+
// newline and nothing else, so a raw control character — a TAB, which has to
|
|
13567
|
+
// be pasted rather than typed — is written verbatim, is legal FigDown, and is
|
|
13568
|
+
// read back correctly by the engine. JSON forbids a raw control character in
|
|
13569
|
+
// a string literal, so the check threw and the transaction reported
|
|
13570
|
+
// `postcondition threw` for a write that was already correct: a legal edit
|
|
13571
|
+
// refused by the postcondition rather than by the grammar (backlog 74, `DESIGN-ARTIFACT-DRIFT-DETECTION`).
|
|
13572
|
+
//
|
|
13573
|
+
// `authoredOptionSpan` is the same locator `rewriteFieldOption` uses to PLACE
|
|
13574
|
+
// the write, so the two agree by construction; comparing the span's bytes
|
|
13575
|
+
// asks the only question a source-transaction postcondition can honestly ask
|
|
13576
|
+
// — are the bytes I claimed to write the bytes that are there?
|
|
13577
|
+
function fieldOptionWritten(text,sectionIndex,bid,line,key,value){
|
|
13578
|
+
const lines=String(text).split('\n'),info=bitfieldItems(lines,sectionIndex,bid);if(!info.ok)return false;
|
|
13579
|
+
const item=info.items.find(x=>x.line===line&&x.kind!=='break');if(!item)return false;
|
|
13580
|
+
const code=splitLineComment(lines[item.abs]).code,span=authoredOptionSpan(code,key,0,null);
|
|
13581
|
+
return !!span&&code.slice(span.start,span.end).trim()===key+'='+value;
|
|
13582
|
+
}
|
|
13217
13583
|
function setBitfieldOption(lines,sectionIndex,bid,key,val){
|
|
13218
13584
|
const info=bitfieldSourceInfo(lines,sectionIndex,bid);if(!info.ok)return info;
|
|
13219
13585
|
const sp=splitLineComment(lines[info.start]),span=authoredOptionSpan(sp.code,key,0,null);
|
|
@@ -13268,6 +13634,27 @@ function moveBitfieldLineBefore(lines,sectionIndex,bid,line,beforeLine,after){
|
|
|
13268
13634
|
order.forEach((oldLine,i)=>{lineMap[oldLine]=positions[i];});
|
|
13269
13635
|
return {ok:true,lines,line:at-b.start+1,lineMap};
|
|
13270
13636
|
}
|
|
13637
|
+
// Bit budget for the inspector's read-out. `pos` is a running bit CURSOR over
|
|
13638
|
+
// the whole diagram, not an offset inside one row, and every awkward
|
|
13639
|
+
// expression below is that cursor meeting a row boundary:
|
|
13640
|
+
//
|
|
13641
|
+
// f.wrap a `break`. `Math.ceil((pos||1)/word)*word` rounds the
|
|
13642
|
+
// cursor UP to the next row boundary — the `||1` is what
|
|
13643
|
+
// makes a break at bit 0 advance one full row instead of
|
|
13644
|
+
// staying put (ceil(0/word) is 0), so a leading break draws
|
|
13645
|
+
// an empty row rather than nothing.
|
|
13646
|
+
// f.w==='*' the row remainder. `word-(pos%word||0)` is the distance to
|
|
13647
|
+
// the next boundary, and the `||0` -> `||word` pair is the
|
|
13648
|
+
// boundary case: sitting exactly ON a boundary, `pos%word`
|
|
13649
|
+
// is 0, `rest` computes as the full `word`, and `rest||word`
|
|
13650
|
+
// keeps it a full row rather than a zero-width field.
|
|
13651
|
+
// last bits used in the FINAL row, 1-based: `((occupied-1)%word)+1`
|
|
13652
|
+
// rather than `occupied%word`, so a row filled exactly to the
|
|
13653
|
+
// word reads `word` and not 0.
|
|
13654
|
+
//
|
|
13655
|
+
// `declared` deliberately counts only fixed widths — a `*` declares no
|
|
13656
|
+
// number — and `rowFields` tracks whether the CURRENT row has any field yet,
|
|
13657
|
+
// which is why a break resets it.
|
|
13271
13658
|
function bitfieldBudget(block){
|
|
13272
13659
|
let declared=0,pos=0,occupied=0,remainder=false,rowFields=false;
|
|
13273
13660
|
for(const f of block.fields||[]){
|
|
@@ -13451,18 +13838,38 @@ function buildTableInspector(ins,item,doc){
|
|
|
13451
13838
|
if(r&&r.ok)setSelection(null,false);
|
|
13452
13839
|
};
|
|
13453
13840
|
}
|
|
13841
|
+
// WHICH table the toolbar's `+ Row` / `+ Col` act on. The selection is the
|
|
13842
|
+
// answer whenever it names one: the inspector's own insert/delete already
|
|
13843
|
+
// honour the selected cell's table, and a toolbar that quietly appended to a
|
|
13844
|
+
// different table than the one the user was standing in reported success for
|
|
13845
|
+
// an edit they did not ask for (backlog 75, `DESIGN-ARTIFACT-DRIFT-DETECTION`). Resolution is via
|
|
13846
|
+
// `parseTableCellId` and the block id — NOT `tableSelectionModel` — because
|
|
13847
|
+
// the toolbar needs the TABLE, and `tableSelectionModel` additionally
|
|
13848
|
+
// requires the addressed cell to exist and not be a merge continuation
|
|
13849
|
+
// (`cell&&!cell.m`); routing through it would silently fall back to the first
|
|
13850
|
+
// table whenever the caret sat in a merged span, which is the same defect in
|
|
13851
|
+
// a rarer case. `firstTypedBlock` remains the fallback for the only situation
|
|
13852
|
+
// that has no better answer: nothing names a table.
|
|
13853
|
+
// PURE on purpose — `(doc, selection item)` in, block out — so the choice is
|
|
13854
|
+
// exercisable by tools/editor-check.js, which cannot reach `$()` or a live
|
|
13855
|
+
// selection.
|
|
13856
|
+
function tableActionTarget(doc,sel){
|
|
13857
|
+
const a=sel?parseTableCellId(sel.id):null;
|
|
13858
|
+
const named=a?((doc&&doc.blocks||[]).find(x=>x.type==='table'&&x.id===a.tid)||null):null;
|
|
13859
|
+
return named||firstTypedBlock(doc,'table');
|
|
13860
|
+
}
|
|
13454
13861
|
function addTableRowAction(){
|
|
13455
|
-
const t=
|
|
13862
|
+
const t=tableActionTarget(lastDoc,oneSelected('cell'));if(!t)return failEdit('the first editable section has no table');
|
|
13456
13863
|
const before=t.rows.length,refs=tableRefState(t);
|
|
13457
13864
|
return commitGuiEdit('append table row',({lines})=>insertTableRow(lines,0,t.id,before),
|
|
13458
|
-
({parsed})=>{const nt=
|
|
13865
|
+
({parsed})=>{const nt=(parsed.docs[0].blocks||[]).find(x=>x.type==='table'&&x.id===t.id);
|
|
13459
13866
|
return !!nt&&nt.rows.length===before+1&&tableRefState(nt)===refs;},{sectionIndex:0});
|
|
13460
13867
|
}
|
|
13461
13868
|
function addTableColumnAction(){
|
|
13462
|
-
const t=
|
|
13869
|
+
const t=tableActionTarget(lastDoc,oneSelected('cell'));if(!t)return failEdit('the first editable section has no table');
|
|
13463
13870
|
const before=t.cols.length,refs=tableRefState(t);
|
|
13464
13871
|
return commitGuiEdit('append table column',({lines})=>insertTableColumn(lines,0,t.id,before),
|
|
13465
|
-
({parsed})=>{const nt=
|
|
13872
|
+
({parsed})=>{const nt=(parsed.docs[0].blocks||[]).find(x=>x.type==='table'&&x.id===t.id);
|
|
13466
13873
|
return !!nt&&nt.cols.length===before+1&&(!nt.width||nt.width.vals.length===nt.cols.length)&&tableRefState(nt)===refs;},{sectionIndex:0});
|
|
13467
13874
|
}
|
|
13468
13875
|
function addBitfieldFieldAction(){
|
|
@@ -13510,6 +13917,15 @@ function buildBitfieldInspector(ins,item,doc){
|
|
|
13510
13917
|
'<button data-field-up="'+row.line+'">↑</button><button data-field-down="'+row.line+'">↓</button><button class="danger" data-field-delete="'+row.line+'">×</button></div>';
|
|
13511
13918
|
continue;
|
|
13512
13919
|
}
|
|
13920
|
+
// A compact list draws one row PER ITEM but reorders one SOURCE LINE, so
|
|
13921
|
+
// only the first item carries the drag handle and the ↑/↓ buttons
|
|
13922
|
+
// (`i===0`, twice below). Giving every item a handle would offer the user
|
|
13923
|
+
// three ways to drag the same line and let them "reorder" items within a
|
|
13924
|
+
// list by a gesture that in fact moves the whole line — a control that
|
|
13925
|
+
// lies about its own grain. Reordering WITHIN a compact list is
|
|
13926
|
+
// `rewriteCompactFieldItem`'s business, not the drag's. The delete button
|
|
13927
|
+
// is per ITEM (`row.line+':'+i`) because deleting one item of a list is a
|
|
13928
|
+
// real operation; every other per-line control is on `row.line` alone.
|
|
13513
13929
|
row.fields.forEach((f,i)=>{
|
|
13514
13930
|
const rid=bitfieldSelectionId(block.id,row.line,i);
|
|
13515
13931
|
list+='<div class="field-row'+(rid===selectedKey?' selected':'')+'"'+(i===0?' draggable="true" data-field-dnd="'+row.line+'"':'')+'>'+
|
|
@@ -13610,11 +14026,15 @@ function buildBitfieldInspector(ins,item,doc){
|
|
|
13610
14026
|
$('iFieldName').onchange=()=>fieldName(a.line,idx,$('iFieldName').value);
|
|
13611
14027
|
$('iFieldWidth').onchange=()=>fieldWidth(a.line,idx,$('iFieldWidth').value);
|
|
13612
14028
|
$('iFieldStar').onclick=()=>fieldWidth(a.line,idx,'*');
|
|
14029
|
+
// The postcondition reads back the AUTHORED SPAN (`fieldOptionWritten`),
|
|
14030
|
+
// never a round-trip of the value through a second string grammar — see
|
|
14031
|
+
// that function for why (backlog 74, `DESIGN-ARTIFACT-DRIFT-DETECTION`). The model lookup is kept
|
|
14032
|
+
// ahead of it: the field must still be addressable after the edit.
|
|
13613
14033
|
const opt=(key,value)=>commitGuiEdit((value===''?'clear ':'set ')+'field '+key,({lines})=>rewriteFieldOption(lines,sectionIndex,block.id,a.line,key,value),
|
|
13614
14034
|
({after,parsed})=>{
|
|
13615
14035
|
if(value==='')return fieldOptionAbsent(after,sectionIndex,block.id,a.line,key);
|
|
13616
14036
|
const m=bitfieldSelectionModel(parsed.docs[sectionIndex],item);if(!m)return false;
|
|
13617
|
-
return
|
|
14037
|
+
return fieldOptionWritten(after,sectionIndex,block.id,a.line,key,value);
|
|
13618
14038
|
},{sectionIndex});
|
|
13619
14039
|
const auth=bitfieldAuthoredOptions(item);
|
|
13620
14040
|
$('iFieldFill').onchange=()=>opt('fill',$('iFieldFill').value.trim());if(auth.fill)$('iFieldFillClear').onclick=()=>opt('fill','');
|