@sametalmdr/word-editor 0.2.0-alpha → 0.3.0-alpha

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +108 -70
  2. package/LICENSE +21 -21
  3. package/README.md +286 -247
  4. package/dist/canvas/box-clipboard.js +238 -238
  5. package/dist/canvas/box-selection.js +80 -80
  6. package/dist/canvas/canvas-editor.js +502 -502
  7. package/dist/canvas/controller.js +717 -717
  8. package/dist/canvas/geometry.js +131 -131
  9. package/dist/canvas/gesture.d.ts +0 -1
  10. package/dist/canvas/gesture.js +289 -289
  11. package/dist/canvas/handles.js +212 -212
  12. package/dist/canvas/menu.js +82 -82
  13. package/dist/canvas/mode-controller.js +81 -81
  14. package/dist/canvas/nudge.js +108 -108
  15. package/dist/canvas/overlay.js +149 -149
  16. package/dist/canvas/snap.js +132 -132
  17. package/dist/canvas/viewport.js +112 -112
  18. package/dist/canvas/z-order.js +171 -171
  19. package/dist/commands/alignment.js +150 -150
  20. package/dist/commands/box.js +272 -272
  21. package/dist/commands/clipboard.js +93 -93
  22. package/dist/commands/graphemes.js +112 -112
  23. package/dist/commands/marks.js +153 -153
  24. package/dist/commands/paragraph-props.js +176 -176
  25. package/dist/commands/size.js +368 -368
  26. package/dist/commands/text.js +291 -291
  27. package/dist/docx.d.ts +1 -0
  28. package/dist/docx.js +15 -13
  29. package/dist/editor/editing-host.js +434 -434
  30. package/dist/element.js +41 -41
  31. package/dist/fonts/OFL-Carlito.txt +94 -0
  32. package/dist/fonts/calibri-metrics.d.ts +27 -0
  33. package/dist/fonts/calibri-metrics.js +88 -0
  34. package/dist/history/history.js +433 -433
  35. package/dist/index.js +63 -63
  36. package/dist/input/beforeinput.js +224 -224
  37. package/dist/input/clipboard.js +107 -107
  38. package/dist/input/composition.js +341 -341
  39. package/dist/input/keymap.js +54 -54
  40. package/dist/io/ooxml/custgeom.js +72 -72
  41. package/dist/io/ooxml/drawingml.js +232 -232
  42. package/dist/io/ooxml/edits.js +67 -67
  43. package/dist/io/ooxml/export-splice.js +880 -880
  44. package/dist/io/ooxml/export.js +365 -365
  45. package/dist/io/ooxml/fill.d.ts +93 -8
  46. package/dist/io/ooxml/fill.js +618 -562
  47. package/dist/io/ooxml/fit.d.ts +252 -0
  48. package/dist/io/ooxml/fit.js +598 -0
  49. package/dist/io/ooxml/import-gate.js +200 -200
  50. package/dist/io/ooxml/import.js +691 -691
  51. package/dist/io/ooxml/inflate.js +126 -126
  52. package/dist/io/ooxml/media.js +87 -87
  53. package/dist/io/ooxml/ns.d.ts +2 -0
  54. package/dist/io/ooxml/ns.js +26 -21
  55. package/dist/io/ooxml/numbering.js +290 -290
  56. package/dist/io/ooxml/opc-write.js +171 -171
  57. package/dist/io/ooxml/provenance.js +93 -93
  58. package/dist/io/ooxml/recipe.js +708 -708
  59. package/dist/io/ooxml/relationships.js +59 -59
  60. package/dist/io/ooxml/run-fonts.d.ts +89 -0
  61. package/dist/io/ooxml/run-fonts.js +378 -0
  62. package/dist/io/ooxml/slots.d.ts +4 -0
  63. package/dist/io/ooxml/slots.js +582 -581
  64. package/dist/io/ooxml/styles-resolve.js +257 -257
  65. package/dist/io/ooxml/units.js +43 -43
  66. package/dist/io/ooxml/value.js +120 -120
  67. package/dist/io/ooxml/xml-scan.js +616 -616
  68. package/dist/io/ooxml/zip-container.js +228 -228
  69. package/dist/io/ooxml/zip-reject.js +106 -106
  70. package/dist/measure/placement.js +83 -83
  71. package/dist/measure/report.js +75 -75
  72. package/dist/model/ids.js +70 -70
  73. package/dist/model/normalize.js +146 -146
  74. package/dist/model/position.js +283 -283
  75. package/dist/model/transaction.js +209 -209
  76. package/dist/platform/capabilities.js +25 -25
  77. package/dist/react/index.js +21 -21
  78. package/dist/selection/controller.js +315 -315
  79. package/dist/selection/dom-map.js +334 -334
  80. package/dist/state/editor-state.js +109 -109
  81. package/dist/styles.css +66 -66
  82. package/package.json +94 -93
@@ -1,112 +1,112 @@
1
- // Pure px (client/viewport) <-> pt (page/model) bridge for the canvas
2
- // interaction layer (ADR-0018 §9/Karar 9, §9.1).
3
- //
4
- // The renderer never computes a scale factor of its own — `syncPages`
5
- // (src/render/renderer.js) writes raw `pt` CSS strings and lets the browser
6
- // do px conversion (measured, not assumed: no ptToPx/getBoundingClientRect
7
- // call exists anywhere in src/ today). This module is the FIRST place that
8
- // conversion is named, and it takes an already-measured `PageRect` as
9
- // input rather than measuring the DOM itself — invariant #7 ("the
10
- // browser's DOM is not the data model") applied to geometry: the real
11
- // `getBoundingClientRect()` call is a controller-level concern (a later
12
- // phase's `readPageMetrics()`), enjected in, never called from here.
13
- //
14
- // No src/, DOM, or history import — this module is pure data in, data out.
15
-
16
- /**
17
- * @typedef {object} PageRect
18
- * @property {number} left client px
19
- * @property {number} top client px
20
- * @property {number} width client px
21
- * @property {number} height client px
22
- * @property {number} scale client px per pt
23
- */
24
-
25
- /**
26
- * @param {PageRect} pageRect
27
- * @returns {number}
28
- */
29
- export function pageScale(pageRect) {
30
- return pageRect.scale;
31
- }
32
-
33
- /**
34
- * A client-space point, converted to page-space pt relative to the page's
35
- * own top-left corner. The only place a pointer event's clientX/clientY
36
- * becomes a pt coordinate hit-test.js can use.
37
- *
38
- * @param {PageRect} pageRect
39
- * @param {number} clientX
40
- * @param {number} clientY
41
- * @returns {{x: number, y: number}}
42
- */
43
- export function clientToPagePoint(pageRect, clientX, clientY) {
44
- return {
45
- x: (clientX - pageRect.left) / pageRect.scale,
46
- y: (clientY - pageRect.top) / pageRect.scale,
47
- };
48
- }
49
-
50
- /**
51
- * A page-space pt rect (BoxLayout/geometry.js's `Rect` shape), converted to
52
- * a client-space px rect — the inverse of `clientToPagePoint`, for drawing
53
- * a tutamak/overlay (a later phase) over a real box.
54
- *
55
- * @param {PageRect} pageRect
56
- * @param {import("./geometry.js").Rect} rect
57
- * @returns {{left: number, top: number, width: number, height: number}}
58
- */
59
- export function pageToClientRect(pageRect, rect) {
60
- return {
61
- left: pageRect.left + rect.x * pageRect.scale,
62
- top: pageRect.top + rect.y * pageRect.scale,
63
- width: rect.cx * pageRect.scale,
64
- height: rect.cy * pageRect.scale,
65
- };
66
- }
67
-
68
- /**
69
- * Which page (1-based, matching `BoxLayout.page`'s convention) a client
70
- * point falls on, or null if it lands on none of them — the space between
71
- * pages, or above/below the whole stack. `pageRects[0]` is page 1,
72
- * `pageRects[1]` is page 2, and so on: `PageRect` itself carries no page
73
- * number (it is exactly the five numbers a controller measures for ONE
74
- * page), so the array's own order carries that instead.
75
- *
76
- * @param {PageRect[]} pageRects
77
- * @param {number} clientX
78
- * @param {number} clientY
79
- * @returns {number | null}
80
- */
81
- export function pageAtClientPoint(pageRects, clientX, clientY) {
82
- for (let i = 0; i < pageRects.length; i += 1) {
83
- const rect = pageRects[i];
84
- if (
85
- clientX >= rect.left &&
86
- clientX <= rect.left + rect.width &&
87
- clientY >= rect.top &&
88
- clientY <= rect.top + rect.height
89
- ) {
90
- return i + 1;
91
- }
92
- }
93
- return null;
94
- }
95
-
96
- /**
97
- * Observation only, not a rule (ADR-0018 §10.2): whether a page's measured
98
- * client-px footprint scales its width and height by the SAME factor
99
- * relative to the page's own pt size. `PageRect.scale` is a single number
100
- * because every other function here assumes uniform scaling; this is the
101
- * one place that assumption gets checked rather than silently trusted.
102
- * Zero means no skew. No consumer reads this value for real behavior, and
103
- * no mutation is registered for it — see the file header of
104
- * `test/canvas/viewport.test.js`.
105
- *
106
- * @param {{width: number, height: number}} pageRectPx raw client px measurement
107
- * @param {{width: number, height: number}} pagePt the page's own pt size (Doc.page)
108
- * @returns {number}
109
- */
110
- export function pageScaleSkew(pageRectPx, pagePt) {
111
- return pageRectPx.width / pagePt.width - pageRectPx.height / pagePt.height;
112
- }
1
+ // Pure px (client/viewport) <-> pt (page/model) bridge for the canvas
2
+ // interaction layer (ADR-0018 §9/Karar 9, §9.1).
3
+ //
4
+ // The renderer never computes a scale factor of its own — `syncPages`
5
+ // (src/render/renderer.js) writes raw `pt` CSS strings and lets the browser
6
+ // do px conversion (measured, not assumed: no ptToPx/getBoundingClientRect
7
+ // call exists anywhere in src/ today). This module is the FIRST place that
8
+ // conversion is named, and it takes an already-measured `PageRect` as
9
+ // input rather than measuring the DOM itself — invariant #7 ("the
10
+ // browser's DOM is not the data model") applied to geometry: the real
11
+ // `getBoundingClientRect()` call is a controller-level concern (a later
12
+ // phase's `readPageMetrics()`), enjected in, never called from here.
13
+ //
14
+ // No src/, DOM, or history import — this module is pure data in, data out.
15
+
16
+ /**
17
+ * @typedef {object} PageRect
18
+ * @property {number} left client px
19
+ * @property {number} top client px
20
+ * @property {number} width client px
21
+ * @property {number} height client px
22
+ * @property {number} scale client px per pt
23
+ */
24
+
25
+ /**
26
+ * @param {PageRect} pageRect
27
+ * @returns {number}
28
+ */
29
+ export function pageScale(pageRect) {
30
+ return pageRect.scale;
31
+ }
32
+
33
+ /**
34
+ * A client-space point, converted to page-space pt relative to the page's
35
+ * own top-left corner. The only place a pointer event's clientX/clientY
36
+ * becomes a pt coordinate hit-test.js can use.
37
+ *
38
+ * @param {PageRect} pageRect
39
+ * @param {number} clientX
40
+ * @param {number} clientY
41
+ * @returns {{x: number, y: number}}
42
+ */
43
+ export function clientToPagePoint(pageRect, clientX, clientY) {
44
+ return {
45
+ x: (clientX - pageRect.left) / pageRect.scale,
46
+ y: (clientY - pageRect.top) / pageRect.scale,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * A page-space pt rect (BoxLayout/geometry.js's `Rect` shape), converted to
52
+ * a client-space px rect — the inverse of `clientToPagePoint`, for drawing
53
+ * a tutamak/overlay (a later phase) over a real box.
54
+ *
55
+ * @param {PageRect} pageRect
56
+ * @param {import("./geometry.js").Rect} rect
57
+ * @returns {{left: number, top: number, width: number, height: number}}
58
+ */
59
+ export function pageToClientRect(pageRect, rect) {
60
+ return {
61
+ left: pageRect.left + rect.x * pageRect.scale,
62
+ top: pageRect.top + rect.y * pageRect.scale,
63
+ width: rect.cx * pageRect.scale,
64
+ height: rect.cy * pageRect.scale,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Which page (1-based, matching `BoxLayout.page`'s convention) a client
70
+ * point falls on, or null if it lands on none of them — the space between
71
+ * pages, or above/below the whole stack. `pageRects[0]` is page 1,
72
+ * `pageRects[1]` is page 2, and so on: `PageRect` itself carries no page
73
+ * number (it is exactly the five numbers a controller measures for ONE
74
+ * page), so the array's own order carries that instead.
75
+ *
76
+ * @param {PageRect[]} pageRects
77
+ * @param {number} clientX
78
+ * @param {number} clientY
79
+ * @returns {number | null}
80
+ */
81
+ export function pageAtClientPoint(pageRects, clientX, clientY) {
82
+ for (let i = 0; i < pageRects.length; i += 1) {
83
+ const rect = pageRects[i];
84
+ if (
85
+ clientX >= rect.left &&
86
+ clientX <= rect.left + rect.width &&
87
+ clientY >= rect.top &&
88
+ clientY <= rect.top + rect.height
89
+ ) {
90
+ return i + 1;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Observation only, not a rule (ADR-0018 §10.2): whether a page's measured
98
+ * client-px footprint scales its width and height by the SAME factor
99
+ * relative to the page's own pt size. `PageRect.scale` is a single number
100
+ * because every other function here assumes uniform scaling; this is the
101
+ * one place that assumption gets checked rather than silently trusted.
102
+ * Zero means no skew. No consumer reads this value for real behavior, and
103
+ * no mutation is registered for it — see the file header of
104
+ * `test/canvas/viewport.test.js`.
105
+ *
106
+ * @param {{width: number, height: number}} pageRectPx raw client px measurement
107
+ * @param {{width: number, height: number}} pagePt the page's own pt size (Doc.page)
108
+ * @returns {number}
109
+ */
110
+ export function pageScaleSkew(pageRectPx, pagePt) {
111
+ return pageRectPx.width / pagePt.width - pageRectPx.height / pagePt.height;
112
+ }
@@ -1,171 +1,171 @@
1
- // Which z values a "bring to front / send to back / step forward / step
2
- // backward" command should write (ADR-0018 §8, §9/Karar 9). Pure planning —
3
- // the caller feeds the returned entries straight to `setBoxZOrder`
4
- // (src/commands/box.js). No src/, DOM, or history import.
5
- //
6
- // Reference set is CLOSED (§8, measured): boxes UNION decorations.
7
- // `Renderer.writeLayout`/`syncDecorations` write `zIndex` onto the SAME
8
- // page element's children, so a "bring to front" that ignored decorations
9
- // could leave a box BEHIND a decoration that is already above it.
10
- // `pageOccupants` is called with no `kind` filter for the same reason
11
- // snap.js does not filter it — the "is this a box or a decoration"
12
- // question stays in ONE file (page.js).
13
- //
14
- // z is a SPARSE integer, never renumbered: reassigning every occupant on a
15
- // page for a single "bring to front" would turn one user gesture into a
16
- // step touching every box on the page. Decorations' own z never appears in
17
- // the returned entries (ADR-0015: it never changes) — they are read here
18
- // only as part of the max/min reference set.
19
- //
20
- // `selectedIds` is assumed to name only boxes, never decorations — a
21
- // decoration cannot be selected in the first place (ADR-0015: not a
22
- // hit-test target), so this is a caller invariant, not something this file
23
- // re-validates.
24
-
25
- import { pageOccupants } from "./page.js";
26
- import { stackOrder } from "./hit-test.js";
27
-
28
- /**
29
- * @typedef {"front" | "back" | "forward" | "backward"} ZOrderOp
30
- */
31
-
32
- /**
33
- * @param {number} minZ
34
- * @returns {void}
35
- */
36
- export function requireMinZ(minZ) {
37
- if (typeof minZ !== "number" || !Number.isFinite(minZ)) {
38
- throw new TypeError(`zOrderPlan: minZ must be a finite number, got ${JSON.stringify(minZ)}`);
39
- }
40
- }
41
-
42
- /**
43
- * `front`/`back` for a WHOLE selection at once: every selected occupant
44
- * gets its OWN new z, consecutive, positioned entirely above (front) or
45
- * below (back) the current max/min — never a single shared value. A shared
46
- * value would leave the group's mutual order to whichever occupant happens
47
- * to have the smaller `index` (its unrelated `doc.content` position),
48
- * silently discarding the group's actual prior stacking. Values are
49
- * assigned in the group's own current bottom-to-top order, so the occupant
50
- * that was already highest among the selection is still the highest once
51
- * the whole group lands above (or below) everything else.
52
- *
53
- * @param {import("./page.js").PageOccupant[]} occupants the WHOLE page's reference set (boxes + decorations)
54
- * @param {import("./page.js").PageOccupant[]} ordered `occupants`, `stackOrder`'d (top-to-bottom)
55
- * @param {Set<import("../model/node.js").NodeId>} selected
56
- * @param {"front" | "back"} end
57
- * @param {number} minZ
58
- * @returns {import("../commands/box.js").BoxZOrderEntry[]}
59
- */
60
- function edgePlan(occupants, ordered, selected, end, minZ) {
61
- const selectedBottomToTop = ordered.filter((occupant) => selected.has(occupant.id)).reverse();
62
- const k = selectedBottomToTop.length;
63
- if (k === 0) {
64
- return [];
65
- }
66
-
67
- const zs = occupants.map((occupant) => occupant.z);
68
- if (end === "front") {
69
- const base = Math.max(...zs) + 1;
70
- return selectedBottomToTop.map((occupant, i) => ({ containerId: occupant.id, z: base + i }));
71
- }
72
-
73
- // back — the group's lowest member would land at min(zs) - k; ADR-0018
74
- // §4.3b's floor-clamp pattern (CANV14) applied to z instead of cx/cy:
75
- // never let that go below minZ, shifting the WHOLE group up together so
76
- // it stays a contiguous, distinct block instead of several members
77
- // collapsing onto the same clamped value.
78
- const base = Math.max(minZ, Math.min(...zs) - k);
79
- return selectedBottomToTop.map((occupant, i) => ({ containerId: occupant.id, z: base + i }));
80
- }
81
-
82
- /**
83
- * `forward`/`backward` for a WHOLE selection: each selected occupant swaps
84
- * z with its immediate neighbor in the CURRENT stacking order — `step: -1`
85
- * moves toward the front (swap with the occupant just above), `step: +1`
86
- * moves toward the back (swap with the occupant just below). An occupant
87
- * already at that edge of the page has no neighbor to swap with and is
88
- * left out of the result entirely.
89
- *
90
- * When two selected occupants are immediately adjacent to EACH OTHER, the
91
- * one further from the edge sits out this step rather than swapping with
92
- * its selected neighbor — trading two selected occupants' z values would
93
- * not change their relative order at all, and a swap needs a genuine
94
- * (non-selected) neighbor to make progress against. A block of N
95
- * consecutive selected occupants therefore advances one member per call
96
- * rather than shifting as a unit. Two DIFFERENT selected occupants can
97
- * never target the same neighbor position in one call (each position has a
98
- * unique -1/+1 partner), so this never produces two conflicting entries
99
- * for the same id.
100
- *
101
- * A DECORATION never becomes a swap partner: its own z must never appear
102
- * in the returned entries (ADR-0015 — no step ever targets one; `box.js`'s
103
- * `setBoxZOrder` would in fact silently no-op the WHOLE batch if it did,
104
- * since a decoration has no `doc.layout` entry). When the immediate
105
- * neighbor in `step`'s direction is a decoration, the search keeps walking
106
- * past it to the nearest actual BOX — decorations are fixed scenery a box
107
- * steps over, not something a step swaps with. A box's z crossing a
108
- * decoration's is itself enough to change their relative order; the
109
- * decoration's own z does not need to (and must not) move for that.
110
- *
111
- * @param {import("./page.js").PageOccupant[]} ordered `stackOrder`'d, top-to-bottom
112
- * @param {Set<import("../model/node.js").NodeId>} selected
113
- * @param {-1 | 1} step
114
- * @returns {import("../commands/box.js").BoxZOrderEntry[]}
115
- */
116
- function swapPlan(ordered, selected, step) {
117
- /** @type {import("../commands/box.js").BoxZOrderEntry[]} */
118
- const entries = [];
119
- for (let position = 0; position < ordered.length; position += 1) {
120
- const occupant = ordered[position];
121
- if (!selected.has(occupant.id)) {
122
- continue;
123
- }
124
- let partnerPosition = position + step;
125
- while (partnerPosition >= 0 && partnerPosition < ordered.length && ordered[partnerPosition].kind === "decoration") {
126
- partnerPosition += step;
127
- }
128
- if (partnerPosition < 0 || partnerPosition >= ordered.length) {
129
- continue;
130
- }
131
- const partner = ordered[partnerPosition];
132
- if (selected.has(partner.id)) {
133
- continue;
134
- }
135
- entries.push({ containerId: occupant.id, z: partner.z });
136
- entries.push({ containerId: partner.id, z: occupant.z });
137
- }
138
- return entries;
139
- }
140
-
141
- /**
142
- * The `BoxZOrderEntry[]` a z-order command should write, ready for
143
- * `setBoxZOrder` (`src/commands/box.js`) — never renumbers an occupant this
144
- * op did not target.
145
- *
146
- * @param {import("../model/node.js").Doc} doc
147
- * @param {number} page
148
- * @param {import("../model/node.js").NodeId[]} selectedIds
149
- * @param {ZOrderOp} op
150
- * @param {number} minZ pt — injected, no default (ADR-0018 §8, unmeasured: only `back`'s floor is decided open; front/forward/backward do not consult it)
151
- * @returns {import("../commands/box.js").BoxZOrderEntry[]}
152
- */
153
- export function zOrderPlan(doc, page, selectedIds, op, minZ) {
154
- requireMinZ(minZ);
155
- const occupants = pageOccupants(doc, page);
156
- const selected = new Set(selectedIds);
157
- const ordered = stackOrder(occupants);
158
-
159
- switch (op) {
160
- case "front":
161
- return edgePlan(occupants, ordered, selected, "front", minZ);
162
- case "back":
163
- return edgePlan(occupants, ordered, selected, "back", minZ);
164
- case "forward":
165
- return swapPlan(ordered, selected, -1);
166
- case "backward":
167
- return swapPlan(ordered, selected, 1);
168
- default:
169
- throw new TypeError(`zOrderPlan: unknown op ${JSON.stringify(op)}`);
170
- }
171
- }
1
+ // Which z values a "bring to front / send to back / step forward / step
2
+ // backward" command should write (ADR-0018 §8, §9/Karar 9). Pure planning —
3
+ // the caller feeds the returned entries straight to `setBoxZOrder`
4
+ // (src/commands/box.js). No src/, DOM, or history import.
5
+ //
6
+ // Reference set is CLOSED (§8, measured): boxes UNION decorations.
7
+ // `Renderer.writeLayout`/`syncDecorations` write `zIndex` onto the SAME
8
+ // page element's children, so a "bring to front" that ignored decorations
9
+ // could leave a box BEHIND a decoration that is already above it.
10
+ // `pageOccupants` is called with no `kind` filter for the same reason
11
+ // snap.js does not filter it — the "is this a box or a decoration"
12
+ // question stays in ONE file (page.js).
13
+ //
14
+ // z is a SPARSE integer, never renumbered: reassigning every occupant on a
15
+ // page for a single "bring to front" would turn one user gesture into a
16
+ // step touching every box on the page. Decorations' own z never appears in
17
+ // the returned entries (ADR-0015: it never changes) — they are read here
18
+ // only as part of the max/min reference set.
19
+ //
20
+ // `selectedIds` is assumed to name only boxes, never decorations — a
21
+ // decoration cannot be selected in the first place (ADR-0015: not a
22
+ // hit-test target), so this is a caller invariant, not something this file
23
+ // re-validates.
24
+
25
+ import { pageOccupants } from "./page.js";
26
+ import { stackOrder } from "./hit-test.js";
27
+
28
+ /**
29
+ * @typedef {"front" | "back" | "forward" | "backward"} ZOrderOp
30
+ */
31
+
32
+ /**
33
+ * @param {number} minZ
34
+ * @returns {void}
35
+ */
36
+ export function requireMinZ(minZ) {
37
+ if (typeof minZ !== "number" || !Number.isFinite(minZ)) {
38
+ throw new TypeError(`zOrderPlan: minZ must be a finite number, got ${JSON.stringify(minZ)}`);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * `front`/`back` for a WHOLE selection at once: every selected occupant
44
+ * gets its OWN new z, consecutive, positioned entirely above (front) or
45
+ * below (back) the current max/min — never a single shared value. A shared
46
+ * value would leave the group's mutual order to whichever occupant happens
47
+ * to have the smaller `index` (its unrelated `doc.content` position),
48
+ * silently discarding the group's actual prior stacking. Values are
49
+ * assigned in the group's own current bottom-to-top order, so the occupant
50
+ * that was already highest among the selection is still the highest once
51
+ * the whole group lands above (or below) everything else.
52
+ *
53
+ * @param {import("./page.js").PageOccupant[]} occupants the WHOLE page's reference set (boxes + decorations)
54
+ * @param {import("./page.js").PageOccupant[]} ordered `occupants`, `stackOrder`'d (top-to-bottom)
55
+ * @param {Set<import("../model/node.js").NodeId>} selected
56
+ * @param {"front" | "back"} end
57
+ * @param {number} minZ
58
+ * @returns {import("../commands/box.js").BoxZOrderEntry[]}
59
+ */
60
+ function edgePlan(occupants, ordered, selected, end, minZ) {
61
+ const selectedBottomToTop = ordered.filter((occupant) => selected.has(occupant.id)).reverse();
62
+ const k = selectedBottomToTop.length;
63
+ if (k === 0) {
64
+ return [];
65
+ }
66
+
67
+ const zs = occupants.map((occupant) => occupant.z);
68
+ if (end === "front") {
69
+ const base = Math.max(...zs) + 1;
70
+ return selectedBottomToTop.map((occupant, i) => ({ containerId: occupant.id, z: base + i }));
71
+ }
72
+
73
+ // back — the group's lowest member would land at min(zs) - k; ADR-0018
74
+ // §4.3b's floor-clamp pattern (CANV14) applied to z instead of cx/cy:
75
+ // never let that go below minZ, shifting the WHOLE group up together so
76
+ // it stays a contiguous, distinct block instead of several members
77
+ // collapsing onto the same clamped value.
78
+ const base = Math.max(minZ, Math.min(...zs) - k);
79
+ return selectedBottomToTop.map((occupant, i) => ({ containerId: occupant.id, z: base + i }));
80
+ }
81
+
82
+ /**
83
+ * `forward`/`backward` for a WHOLE selection: each selected occupant swaps
84
+ * z with its immediate neighbor in the CURRENT stacking order — `step: -1`
85
+ * moves toward the front (swap with the occupant just above), `step: +1`
86
+ * moves toward the back (swap with the occupant just below). An occupant
87
+ * already at that edge of the page has no neighbor to swap with and is
88
+ * left out of the result entirely.
89
+ *
90
+ * When two selected occupants are immediately adjacent to EACH OTHER, the
91
+ * one further from the edge sits out this step rather than swapping with
92
+ * its selected neighbor — trading two selected occupants' z values would
93
+ * not change their relative order at all, and a swap needs a genuine
94
+ * (non-selected) neighbor to make progress against. A block of N
95
+ * consecutive selected occupants therefore advances one member per call
96
+ * rather than shifting as a unit. Two DIFFERENT selected occupants can
97
+ * never target the same neighbor position in one call (each position has a
98
+ * unique -1/+1 partner), so this never produces two conflicting entries
99
+ * for the same id.
100
+ *
101
+ * A DECORATION never becomes a swap partner: its own z must never appear
102
+ * in the returned entries (ADR-0015 — no step ever targets one; `box.js`'s
103
+ * `setBoxZOrder` would in fact silently no-op the WHOLE batch if it did,
104
+ * since a decoration has no `doc.layout` entry). When the immediate
105
+ * neighbor in `step`'s direction is a decoration, the search keeps walking
106
+ * past it to the nearest actual BOX — decorations are fixed scenery a box
107
+ * steps over, not something a step swaps with. A box's z crossing a
108
+ * decoration's is itself enough to change their relative order; the
109
+ * decoration's own z does not need to (and must not) move for that.
110
+ *
111
+ * @param {import("./page.js").PageOccupant[]} ordered `stackOrder`'d, top-to-bottom
112
+ * @param {Set<import("../model/node.js").NodeId>} selected
113
+ * @param {-1 | 1} step
114
+ * @returns {import("../commands/box.js").BoxZOrderEntry[]}
115
+ */
116
+ function swapPlan(ordered, selected, step) {
117
+ /** @type {import("../commands/box.js").BoxZOrderEntry[]} */
118
+ const entries = [];
119
+ for (let position = 0; position < ordered.length; position += 1) {
120
+ const occupant = ordered[position];
121
+ if (!selected.has(occupant.id)) {
122
+ continue;
123
+ }
124
+ let partnerPosition = position + step;
125
+ while (partnerPosition >= 0 && partnerPosition < ordered.length && ordered[partnerPosition].kind === "decoration") {
126
+ partnerPosition += step;
127
+ }
128
+ if (partnerPosition < 0 || partnerPosition >= ordered.length) {
129
+ continue;
130
+ }
131
+ const partner = ordered[partnerPosition];
132
+ if (selected.has(partner.id)) {
133
+ continue;
134
+ }
135
+ entries.push({ containerId: occupant.id, z: partner.z });
136
+ entries.push({ containerId: partner.id, z: occupant.z });
137
+ }
138
+ return entries;
139
+ }
140
+
141
+ /**
142
+ * The `BoxZOrderEntry[]` a z-order command should write, ready for
143
+ * `setBoxZOrder` (`src/commands/box.js`) — never renumbers an occupant this
144
+ * op did not target.
145
+ *
146
+ * @param {import("../model/node.js").Doc} doc
147
+ * @param {number} page
148
+ * @param {import("../model/node.js").NodeId[]} selectedIds
149
+ * @param {ZOrderOp} op
150
+ * @param {number} minZ pt — injected, no default (ADR-0018 §8, unmeasured: only `back`'s floor is decided open; front/forward/backward do not consult it)
151
+ * @returns {import("../commands/box.js").BoxZOrderEntry[]}
152
+ */
153
+ export function zOrderPlan(doc, page, selectedIds, op, minZ) {
154
+ requireMinZ(minZ);
155
+ const occupants = pageOccupants(doc, page);
156
+ const selected = new Set(selectedIds);
157
+ const ordered = stackOrder(occupants);
158
+
159
+ switch (op) {
160
+ case "front":
161
+ return edgePlan(occupants, ordered, selected, "front", minZ);
162
+ case "back":
163
+ return edgePlan(occupants, ordered, selected, "back", minZ);
164
+ case "forward":
165
+ return swapPlan(ordered, selected, -1);
166
+ case "backward":
167
+ return swapPlan(ordered, selected, 1);
168
+ default:
169
+ throw new TypeError(`zOrderPlan: unknown op ${JSON.stringify(op)}`);
170
+ }
171
+ }