@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,238 +1,238 @@
1
- // The box clipboard: what a copy/cut writes, and what a paste rebuilds
2
- // (ADR-0018 Karar 7, §9/Karar 9). No src/, DOM, or history import — this
3
- // file only produces plain data (a payload, and fresh Container/BoxLayout
4
- // values); it never builds a Transaction or touches HistoryEngine. The
5
- // (future) CanvasController turns this data into steps, the same
6
- // boundary `src/commands/box.js`'s functions sit on the other side of.
7
- //
8
- // NO SPECIAL MIME TYPE (§7.1 — present in an earlier draft, WITHDRAWN):
9
- // how well a custom `setData` type survives the synchronous `copy` event
10
- // versus the asynchronous Clipboard API is UNMEASURED in this repo, and
11
- // `src/input/clipboard.js` is `text/plain` in both directions today.
12
- // Depending on that unmeasured behavior is the same call Karar 1 already
13
- // made rejecting `elementFromPoint`. Honest consequence: cross-document
14
- // paste of boxes does not work in P3b-a — `payloadToText` is the
15
- // `text/plain` mirror written to the system clipboard alongside the real
16
- // `BoxPayload`, which lives only in the (future) CanvasController's own
17
- // instance.
18
- //
19
- // INVARIANT #13: `serializeBoxes` reuses `toJSON`'s field-by-field copying
20
- // discipline (document-json.js:63-77) for `layout`, never `{ ...entry }`
21
- // let alone `layout: entry`. That comment documents a production bug: a
22
- // shallow spread still aliases every BoxLayout entry to the live
23
- // document, and `devFreeze` is dev-only, so the alias goes undetected in
24
- // production until something writes through it.
25
-
26
- import { containerById, createContainer, createParagraph, createRun, layoutOf, LAYOUT_KEYS } from "../model/node.js";
27
- import { anchorBounds } from "./box-selection.js";
28
-
29
- /** Bumped when the shape changes incompatibly — mirrors document-json.js's own field. */
30
- export const BOX_PAYLOAD_VERSION = 1;
31
-
32
- /**
33
- * @typedef {object} BoxPayloadParagraph
34
- * @property {import("../model/node.js").NodeId} id
35
- * @property {import("../model/node.js").PropRef} [pPrRef]
36
- * @property {{text: string, marks: import("../model/node.js").Marks}[]} runs
37
- */
38
-
39
- /**
40
- * @typedef {object} BoxPayloadEntry
41
- * @property {Partial<import("../model/node.js").BoxLayout>} layout a fresh, field-by-field copy — never a live reference
42
- * @property {BoxPayloadParagraph[]} paragraphs
43
- */
44
-
45
- /**
46
- * @typedef {object} BoxPayload
47
- * @property {number} version
48
- * @property {{x: number, y: number}} origin the copied selection's own bounding-rect top-left (ADR-0018 §7.3's "grubun sol-üstü")
49
- * @property {BoxPayloadEntry[]} boxes
50
- */
51
-
52
- /**
53
- * A fresh, field-by-field copy of one container's paragraphs — the SAME
54
- * per-paragraph shape `document-json.js`'s `toJSON` builds, reused rather
55
- * than re-invented.
56
- * @param {import("../model/node.js").Container} container
57
- * @returns {BoxPayloadParagraph[]}
58
- */
59
- function copyParagraphs(container) {
60
- return container.content.map((para) => ({
61
- id: para.id,
62
- ...(para.pPrRef !== undefined ? { pPrRef: para.pPrRef } : {}),
63
- runs: para.content.map((run) => ({ text: run.text, marks: { ...run.marks } })),
64
- }));
65
- }
66
-
67
- /**
68
- * A fresh, field-by-field copy of one `BoxLayout` entry, built from
69
- * `LAYOUT_KEYS` (model/node.js's single source) — never `{ ...entry }`,
70
- * which would only copy the top-level object and leave every field an
71
- * alias if this were ever mutated by reference instead of replaced
72
- * (invariant #13, CANV11's own target).
73
- * @param {import("../model/node.js").BoxLayout} entry
74
- * @returns {Partial<import("../model/node.js").BoxLayout>}
75
- */
76
- function copyLayout(entry) {
77
- /** @type {any} */
78
- const copy = {};
79
- for (const key of LAYOUT_KEYS) {
80
- if (entry[key] !== undefined) {
81
- copy[key] = entry[key];
82
- }
83
- }
84
- return copy;
85
- }
86
-
87
- /**
88
- * The clipboard payload for a set of boxes — `null` if the selection is
89
- * empty or any id no longer names a real box (a stale selection copying
90
- * nothing meaningful is a no-op, not a partial copy).
91
- *
92
- * @param {import("../model/node.js").Doc} doc
93
- * @param {import("../model/node.js").NodeId[]} containerIds
94
- * @returns {BoxPayload | null}
95
- */
96
- export function serializeBoxes(doc, containerIds) {
97
- const bounds = anchorBounds(doc, containerIds);
98
- if (bounds === null) {
99
- return null;
100
- }
101
-
102
- const boxes = [];
103
- for (const id of containerIds) {
104
- const layout = layoutOf(doc, id);
105
- const container = containerById(doc, id);
106
- if (layout === null || container === null) {
107
- return null;
108
- }
109
- boxes.push({ layout: copyLayout(layout), paragraphs: copyParagraphs(container) });
110
- }
111
-
112
- return { version: BOX_PAYLOAD_VERSION, origin: { x: bounds.x, y: bounds.y }, boxes };
113
- }
114
-
115
- /**
116
- * The plain-text mirror written to the system clipboard's `text/plain`
117
- * slot alongside the real payload (§7.1) — every paragraph of every box,
118
- * in payload order, joined the same way `serializeToText` joins a text
119
- * selection's paragraphs.
120
- * @param {BoxPayload} payload
121
- * @returns {string}
122
- */
123
- export function payloadToText(payload) {
124
- return payload.boxes
125
- .flatMap((box) => box.paragraphs.map((para) => para.runs.map((run) => run.text).join("")))
126
- .join("\n");
127
- }
128
-
129
- /**
130
- * @typedef {object} MintedBoxIds
131
- * @property {import("../model/node.js").NodeId} containerId
132
- * @property {import("../model/node.js").NodeId[]} paragraphIds same length and order as `box.paragraphs`
133
- */
134
-
135
- /**
136
- * Fresh ids for one payload box, minted from `ids` — NEVER the ids the
137
- * payload itself carries (those are the ORIGINAL box's, and reusing them
138
- * would collide the moment the original still exists, CANV12's own
139
- * target). Minted before any step exists (ADR-0003: `apply()` never mints
140
- * an id) — the same ordering `commands/clipboard.js`'s `pasteText` already
141
- * uses for a pasted paragraph's id, generalized to a whole box.
142
- *
143
- * @param {BoxPayloadEntry} box
144
- * @param {import("../model/ids.js").IdFactory} ids
145
- * @returns {MintedBoxIds}
146
- */
147
- export function mintBoxIds(box, ids) {
148
- return {
149
- containerId: ids.next(),
150
- paragraphIds: box.paragraphs.map(() => ids.next()),
151
- };
152
- }
153
-
154
- /**
155
- * @typedef {object} PastePage
156
- * @property {number} page 1-based, the ACTIVE page paste targets — never the page the boxes were copied from (§7.2)
157
- * @property {number} width
158
- * @property {number} height
159
- */
160
-
161
- /**
162
- * @typedef {object} PastedBox
163
- * @property {import("../model/node.js").Container} container fresh ids throughout
164
- * @property {import("../model/node.js").BoxLayout} layout
165
- */
166
-
167
- /**
168
- * Rebuilds `payload` as fresh containers + layouts, ready for the (future)
169
- * caller to turn into `insertContainerStep`s — one transaction, so the
170
- * whole paste is one undo entry.
171
- *
172
- * Relative geometry is preserved EXACTLY (§7.4): the offset from
173
- * `payload.origin` to its post-paste position is computed ONCE, from the
174
- * group's own bounding rect, and applied IDENTICALLY to every box's own
175
- * absolute `{x,y}` — never computed per box. A per-box offset (e.g. each
176
- * box separately clamped against the page) would let two boxes drift
177
- * apart or together depending on which one needed clamping (CANV9's own
178
- * target): two boxes 40pt apart must land 40pt apart, whether or not the
179
- * paste needed to shift for the page edge.
180
- *
181
- * Falling outside the target page CLIPS the whole group's bounding rect
182
- * into it (§7.2) — smart placement is not attempted; ARCHITECTURE.md:357
183
- * already calls "no overlap" an unrealistic goal, and clipping the whole
184
- * group keeps that same "don't pretend to solve overlap" stance instead
185
- * of inventing a different unsolved problem (per-box collision avoidance).
186
- *
187
- * @param {BoxPayload} payload
188
- * @param {import("../model/ids.js").IdFactory} ids
189
- * @param {PastePage} targetPage
190
- * @param {number} pasteOffsetPt pt — injected, no default (an unmeasured UI constant, CanvasMetrics' `pasteOffsetPt`)
191
- * @returns {PastedBox[]}
192
- */
193
- export function pasteBoxes(payload, ids, targetPage, pasteOffsetPt) {
194
- if (payload.boxes.length === 0) {
195
- return [];
196
- }
197
-
198
- const groupLeft = Math.min(...payload.boxes.map((box) => /** @type {number} */ (box.layout.x)));
199
- const groupTop = Math.min(...payload.boxes.map((box) => /** @type {number} */ (box.layout.y)));
200
- const groupRight = Math.max(
201
- ...payload.boxes.map((box) => /** @type {number} */ (box.layout.x) + /** @type {number} */ (box.layout.cx)),
202
- );
203
- const groupBottom = Math.max(
204
- ...payload.boxes.map((box) => /** @type {number} */ (box.layout.y) + /** @type {number} */ (box.layout.cy)),
205
- );
206
- const groupWidth = groupRight - groupLeft;
207
- const groupHeight = groupBottom - groupTop;
208
-
209
- const desiredLeft = payload.origin.x + pasteOffsetPt;
210
- const desiredTop = payload.origin.y + pasteOffsetPt;
211
-
212
- const clampedLeft = Math.min(Math.max(desiredLeft, 0), Math.max(0, targetPage.width - groupWidth));
213
- const clampedTop = Math.min(Math.max(desiredTop, 0), Math.max(0, targetPage.height - groupHeight));
214
-
215
- const dx = clampedLeft - groupLeft;
216
- const dy = clampedTop - groupTop;
217
-
218
- return payload.boxes.map((box) => {
219
- const minted = mintBoxIds(box, ids);
220
- const container = createContainer(
221
- minted.containerId,
222
- box.paragraphs.map((para, i) =>
223
- createParagraph(
224
- minted.paragraphIds[i],
225
- para.runs.map((run) => createRun(run.text, { ...run.marks })),
226
- { pPrRef: para.pPrRef },
227
- ),
228
- ),
229
- );
230
- const layout = /** @type {import("../model/node.js").BoxLayout} */ ({
231
- ...box.layout,
232
- page: targetPage.page,
233
- x: /** @type {number} */ (box.layout.x) + dx,
234
- y: /** @type {number} */ (box.layout.y) + dy,
235
- });
236
- return { container, layout };
237
- });
238
- }
1
+ // The box clipboard: what a copy/cut writes, and what a paste rebuilds
2
+ // (ADR-0018 Karar 7, §9/Karar 9). No src/, DOM, or history import — this
3
+ // file only produces plain data (a payload, and fresh Container/BoxLayout
4
+ // values); it never builds a Transaction or touches HistoryEngine. The
5
+ // (future) CanvasController turns this data into steps, the same
6
+ // boundary `src/commands/box.js`'s functions sit on the other side of.
7
+ //
8
+ // NO SPECIAL MIME TYPE (§7.1 — present in an earlier draft, WITHDRAWN):
9
+ // how well a custom `setData` type survives the synchronous `copy` event
10
+ // versus the asynchronous Clipboard API is UNMEASURED in this repo, and
11
+ // `src/input/clipboard.js` is `text/plain` in both directions today.
12
+ // Depending on that unmeasured behavior is the same call Karar 1 already
13
+ // made rejecting `elementFromPoint`. Honest consequence: cross-document
14
+ // paste of boxes does not work in P3b-a — `payloadToText` is the
15
+ // `text/plain` mirror written to the system clipboard alongside the real
16
+ // `BoxPayload`, which lives only in the (future) CanvasController's own
17
+ // instance.
18
+ //
19
+ // INVARIANT #13: `serializeBoxes` reuses `toJSON`'s field-by-field copying
20
+ // discipline (document-json.js:63-77) for `layout`, never `{ ...entry }`
21
+ // let alone `layout: entry`. That comment documents a production bug: a
22
+ // shallow spread still aliases every BoxLayout entry to the live
23
+ // document, and `devFreeze` is dev-only, so the alias goes undetected in
24
+ // production until something writes through it.
25
+
26
+ import { containerById, createContainer, createParagraph, createRun, layoutOf, LAYOUT_KEYS } from "../model/node.js";
27
+ import { anchorBounds } from "./box-selection.js";
28
+
29
+ /** Bumped when the shape changes incompatibly — mirrors document-json.js's own field. */
30
+ export const BOX_PAYLOAD_VERSION = 1;
31
+
32
+ /**
33
+ * @typedef {object} BoxPayloadParagraph
34
+ * @property {import("../model/node.js").NodeId} id
35
+ * @property {import("../model/node.js").PropRef} [pPrRef]
36
+ * @property {{text: string, marks: import("../model/node.js").Marks}[]} runs
37
+ */
38
+
39
+ /**
40
+ * @typedef {object} BoxPayloadEntry
41
+ * @property {Partial<import("../model/node.js").BoxLayout>} layout a fresh, field-by-field copy — never a live reference
42
+ * @property {BoxPayloadParagraph[]} paragraphs
43
+ */
44
+
45
+ /**
46
+ * @typedef {object} BoxPayload
47
+ * @property {number} version
48
+ * @property {{x: number, y: number}} origin the copied selection's own bounding-rect top-left (ADR-0018 §7.3's "grubun sol-üstü")
49
+ * @property {BoxPayloadEntry[]} boxes
50
+ */
51
+
52
+ /**
53
+ * A fresh, field-by-field copy of one container's paragraphs — the SAME
54
+ * per-paragraph shape `document-json.js`'s `toJSON` builds, reused rather
55
+ * than re-invented.
56
+ * @param {import("../model/node.js").Container} container
57
+ * @returns {BoxPayloadParagraph[]}
58
+ */
59
+ function copyParagraphs(container) {
60
+ return container.content.map((para) => ({
61
+ id: para.id,
62
+ ...(para.pPrRef !== undefined ? { pPrRef: para.pPrRef } : {}),
63
+ runs: para.content.map((run) => ({ text: run.text, marks: { ...run.marks } })),
64
+ }));
65
+ }
66
+
67
+ /**
68
+ * A fresh, field-by-field copy of one `BoxLayout` entry, built from
69
+ * `LAYOUT_KEYS` (model/node.js's single source) — never `{ ...entry }`,
70
+ * which would only copy the top-level object and leave every field an
71
+ * alias if this were ever mutated by reference instead of replaced
72
+ * (invariant #13, CANV11's own target).
73
+ * @param {import("../model/node.js").BoxLayout} entry
74
+ * @returns {Partial<import("../model/node.js").BoxLayout>}
75
+ */
76
+ function copyLayout(entry) {
77
+ /** @type {any} */
78
+ const copy = {};
79
+ for (const key of LAYOUT_KEYS) {
80
+ if (entry[key] !== undefined) {
81
+ copy[key] = entry[key];
82
+ }
83
+ }
84
+ return copy;
85
+ }
86
+
87
+ /**
88
+ * The clipboard payload for a set of boxes — `null` if the selection is
89
+ * empty or any id no longer names a real box (a stale selection copying
90
+ * nothing meaningful is a no-op, not a partial copy).
91
+ *
92
+ * @param {import("../model/node.js").Doc} doc
93
+ * @param {import("../model/node.js").NodeId[]} containerIds
94
+ * @returns {BoxPayload | null}
95
+ */
96
+ export function serializeBoxes(doc, containerIds) {
97
+ const bounds = anchorBounds(doc, containerIds);
98
+ if (bounds === null) {
99
+ return null;
100
+ }
101
+
102
+ const boxes = [];
103
+ for (const id of containerIds) {
104
+ const layout = layoutOf(doc, id);
105
+ const container = containerById(doc, id);
106
+ if (layout === null || container === null) {
107
+ return null;
108
+ }
109
+ boxes.push({ layout: copyLayout(layout), paragraphs: copyParagraphs(container) });
110
+ }
111
+
112
+ return { version: BOX_PAYLOAD_VERSION, origin: { x: bounds.x, y: bounds.y }, boxes };
113
+ }
114
+
115
+ /**
116
+ * The plain-text mirror written to the system clipboard's `text/plain`
117
+ * slot alongside the real payload (§7.1) — every paragraph of every box,
118
+ * in payload order, joined the same way `serializeToText` joins a text
119
+ * selection's paragraphs.
120
+ * @param {BoxPayload} payload
121
+ * @returns {string}
122
+ */
123
+ export function payloadToText(payload) {
124
+ return payload.boxes
125
+ .flatMap((box) => box.paragraphs.map((para) => para.runs.map((run) => run.text).join("")))
126
+ .join("\n");
127
+ }
128
+
129
+ /**
130
+ * @typedef {object} MintedBoxIds
131
+ * @property {import("../model/node.js").NodeId} containerId
132
+ * @property {import("../model/node.js").NodeId[]} paragraphIds same length and order as `box.paragraphs`
133
+ */
134
+
135
+ /**
136
+ * Fresh ids for one payload box, minted from `ids` — NEVER the ids the
137
+ * payload itself carries (those are the ORIGINAL box's, and reusing them
138
+ * would collide the moment the original still exists, CANV12's own
139
+ * target). Minted before any step exists (ADR-0003: `apply()` never mints
140
+ * an id) — the same ordering `commands/clipboard.js`'s `pasteText` already
141
+ * uses for a pasted paragraph's id, generalized to a whole box.
142
+ *
143
+ * @param {BoxPayloadEntry} box
144
+ * @param {import("../model/ids.js").IdFactory} ids
145
+ * @returns {MintedBoxIds}
146
+ */
147
+ export function mintBoxIds(box, ids) {
148
+ return {
149
+ containerId: ids.next(),
150
+ paragraphIds: box.paragraphs.map(() => ids.next()),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * @typedef {object} PastePage
156
+ * @property {number} page 1-based, the ACTIVE page paste targets — never the page the boxes were copied from (§7.2)
157
+ * @property {number} width
158
+ * @property {number} height
159
+ */
160
+
161
+ /**
162
+ * @typedef {object} PastedBox
163
+ * @property {import("../model/node.js").Container} container fresh ids throughout
164
+ * @property {import("../model/node.js").BoxLayout} layout
165
+ */
166
+
167
+ /**
168
+ * Rebuilds `payload` as fresh containers + layouts, ready for the (future)
169
+ * caller to turn into `insertContainerStep`s — one transaction, so the
170
+ * whole paste is one undo entry.
171
+ *
172
+ * Relative geometry is preserved EXACTLY (§7.4): the offset from
173
+ * `payload.origin` to its post-paste position is computed ONCE, from the
174
+ * group's own bounding rect, and applied IDENTICALLY to every box's own
175
+ * absolute `{x,y}` — never computed per box. A per-box offset (e.g. each
176
+ * box separately clamped against the page) would let two boxes drift
177
+ * apart or together depending on which one needed clamping (CANV9's own
178
+ * target): two boxes 40pt apart must land 40pt apart, whether or not the
179
+ * paste needed to shift for the page edge.
180
+ *
181
+ * Falling outside the target page CLIPS the whole group's bounding rect
182
+ * into it (§7.2) — smart placement is not attempted; ARCHITECTURE.md:357
183
+ * already calls "no overlap" an unrealistic goal, and clipping the whole
184
+ * group keeps that same "don't pretend to solve overlap" stance instead
185
+ * of inventing a different unsolved problem (per-box collision avoidance).
186
+ *
187
+ * @param {BoxPayload} payload
188
+ * @param {import("../model/ids.js").IdFactory} ids
189
+ * @param {PastePage} targetPage
190
+ * @param {number} pasteOffsetPt pt — injected, no default (an unmeasured UI constant, CanvasMetrics' `pasteOffsetPt`)
191
+ * @returns {PastedBox[]}
192
+ */
193
+ export function pasteBoxes(payload, ids, targetPage, pasteOffsetPt) {
194
+ if (payload.boxes.length === 0) {
195
+ return [];
196
+ }
197
+
198
+ const groupLeft = Math.min(...payload.boxes.map((box) => /** @type {number} */ (box.layout.x)));
199
+ const groupTop = Math.min(...payload.boxes.map((box) => /** @type {number} */ (box.layout.y)));
200
+ const groupRight = Math.max(
201
+ ...payload.boxes.map((box) => /** @type {number} */ (box.layout.x) + /** @type {number} */ (box.layout.cx)),
202
+ );
203
+ const groupBottom = Math.max(
204
+ ...payload.boxes.map((box) => /** @type {number} */ (box.layout.y) + /** @type {number} */ (box.layout.cy)),
205
+ );
206
+ const groupWidth = groupRight - groupLeft;
207
+ const groupHeight = groupBottom - groupTop;
208
+
209
+ const desiredLeft = payload.origin.x + pasteOffsetPt;
210
+ const desiredTop = payload.origin.y + pasteOffsetPt;
211
+
212
+ const clampedLeft = Math.min(Math.max(desiredLeft, 0), Math.max(0, targetPage.width - groupWidth));
213
+ const clampedTop = Math.min(Math.max(desiredTop, 0), Math.max(0, targetPage.height - groupHeight));
214
+
215
+ const dx = clampedLeft - groupLeft;
216
+ const dy = clampedTop - groupTop;
217
+
218
+ return payload.boxes.map((box) => {
219
+ const minted = mintBoxIds(box, ids);
220
+ const container = createContainer(
221
+ minted.containerId,
222
+ box.paragraphs.map((para, i) =>
223
+ createParagraph(
224
+ minted.paragraphIds[i],
225
+ para.runs.map((run) => createRun(run.text, { ...run.marks })),
226
+ { pPrRef: para.pPrRef },
227
+ ),
228
+ ),
229
+ );
230
+ const layout = /** @type {import("../model/node.js").BoxLayout} */ ({
231
+ ...box.layout,
232
+ page: targetPage.page,
233
+ x: /** @type {number} */ (box.layout.x) + dx,
234
+ y: /** @type {number} */ (box.layout.y) + dy,
235
+ });
236
+ return { container, layout };
237
+ });
238
+ }
@@ -1,80 +1,80 @@
1
- // How a set of selected box ids changes under shift+click and the marquee
2
- // (ADR-0018 Karar 5, §9/Karar 9). No src/, DOM, or history import — pure
3
- // data in, data out.
4
- //
5
- // The marquee rule is CONTAINMENT, not intersection (§5.1, measured):
6
- // overlap exists in 46/46 corpus documents at 3-36% density
7
- // (corpus-inventory.md:390). An intersection rule would let one small
8
- // marquee over a dense document scoop up dozens of unrelated boxes sitting
9
- // under a full-page background shape; containment is what the ADR settled
10
- // on instead.
11
- //
12
- // `rectContains`/`unionRect`/`normalizeRect` (geometry.js) are the ONLY
13
- // place this arithmetic is defined — this file never recomputes them.
14
- // `boxesOnPage` (hit-test.js) is the ONLY place "is this a decoration"
15
- // is decided — a decoration can never be selected (ADR-0015), so the
16
- // marquee reuses that filter rather than re-checking `kind` itself.
17
-
18
- import { boxesOnPage } from "./hit-test.js";
19
- import { normalizeRect, rectContains, rectOfLayout, unionRect } from "./geometry.js";
20
- import { layoutOf } from "../model/node.js";
21
-
22
- /**
23
- * Shift+click: add `id` if it is not selected, remove it if it is. Order is
24
- * append-on-add — the array is a set with a stable iteration order, not
25
- * re-sorted on every toggle.
26
- *
27
- * @param {import("../model/node.js").NodeId[]} selected
28
- * @param {import("../model/node.js").NodeId} id
29
- * @returns {import("../model/node.js").NodeId[]}
30
- */
31
- export function toggleSelection(selected, id) {
32
- return selected.includes(id) ? selected.filter((existing) => existing !== id) : [...selected, id];
33
- }
34
-
35
- /**
36
- * Every box on `page` ENTIRELY inside `rect` — never a decoration
37
- * (`boxesOnPage` already excludes them, ADR-0015: not selectable). `rect`
38
- * is normalized first: a real marquee drag can go in any direction and
39
- * `rectContains` assumes non-negative size (`geometry.js`'s own reason
40
- * `normalizeRect` exists).
41
- *
42
- * @param {import("../model/node.js").Doc} doc
43
- * @param {number} page
44
- * @param {import("./geometry.js").Rect} rect the dragged marquee, any direction
45
- * @returns {import("../model/node.js").NodeId[]}
46
- */
47
- export function boxesInMarquee(doc, page, rect) {
48
- const normalized = normalizeRect(rect);
49
- return boxesOnPage(doc, page)
50
- .filter((occupant) => rectContains(normalized, occupant.rect))
51
- .map((occupant) => occupant.id);
52
- }
53
-
54
- /**
55
- * The union of every selected box's own rect — the single bounding
56
- * rectangle a multi-select's move handle is drawn around, and the
57
- * reference corner `box-clipboard.js`'s copy/paste offset math uses.
58
- * `null` for an empty selection, or if any id no longer names a real box
59
- * (deleted out from under a stale selection) — a caller must not draw a
60
- * bounds rect for a selection it cannot fully resolve.
61
- *
62
- * @param {import("../model/node.js").Doc} doc
63
- * @param {import("../model/node.js").NodeId[]} containerIds
64
- * @returns {import("./geometry.js").Rect | null}
65
- */
66
- export function anchorBounds(doc, containerIds) {
67
- if (containerIds.length === 0) {
68
- return null;
69
- }
70
- /** @type {import("./geometry.js").Rect[]} */
71
- const rects = [];
72
- for (const id of containerIds) {
73
- const layout = layoutOf(doc, id);
74
- if (layout === null) {
75
- return null;
76
- }
77
- rects.push(rectOfLayout(layout));
78
- }
79
- return rects.reduce((acc, rect) => unionRect(acc, rect));
80
- }
1
+ // How a set of selected box ids changes under shift+click and the marquee
2
+ // (ADR-0018 Karar 5, §9/Karar 9). No src/, DOM, or history import — pure
3
+ // data in, data out.
4
+ //
5
+ // The marquee rule is CONTAINMENT, not intersection (§5.1, measured):
6
+ // overlap exists in 46/46 corpus documents at 3-36% density
7
+ // (corpus-inventory.md:390). An intersection rule would let one small
8
+ // marquee over a dense document scoop up dozens of unrelated boxes sitting
9
+ // under a full-page background shape; containment is what the ADR settled
10
+ // on instead.
11
+ //
12
+ // `rectContains`/`unionRect`/`normalizeRect` (geometry.js) are the ONLY
13
+ // place this arithmetic is defined — this file never recomputes them.
14
+ // `boxesOnPage` (hit-test.js) is the ONLY place "is this a decoration"
15
+ // is decided — a decoration can never be selected (ADR-0015), so the
16
+ // marquee reuses that filter rather than re-checking `kind` itself.
17
+
18
+ import { boxesOnPage } from "./hit-test.js";
19
+ import { normalizeRect, rectContains, rectOfLayout, unionRect } from "./geometry.js";
20
+ import { layoutOf } from "../model/node.js";
21
+
22
+ /**
23
+ * Shift+click: add `id` if it is not selected, remove it if it is. Order is
24
+ * append-on-add — the array is a set with a stable iteration order, not
25
+ * re-sorted on every toggle.
26
+ *
27
+ * @param {import("../model/node.js").NodeId[]} selected
28
+ * @param {import("../model/node.js").NodeId} id
29
+ * @returns {import("../model/node.js").NodeId[]}
30
+ */
31
+ export function toggleSelection(selected, id) {
32
+ return selected.includes(id) ? selected.filter((existing) => existing !== id) : [...selected, id];
33
+ }
34
+
35
+ /**
36
+ * Every box on `page` ENTIRELY inside `rect` — never a decoration
37
+ * (`boxesOnPage` already excludes them, ADR-0015: not selectable). `rect`
38
+ * is normalized first: a real marquee drag can go in any direction and
39
+ * `rectContains` assumes non-negative size (`geometry.js`'s own reason
40
+ * `normalizeRect` exists).
41
+ *
42
+ * @param {import("../model/node.js").Doc} doc
43
+ * @param {number} page
44
+ * @param {import("./geometry.js").Rect} rect the dragged marquee, any direction
45
+ * @returns {import("../model/node.js").NodeId[]}
46
+ */
47
+ export function boxesInMarquee(doc, page, rect) {
48
+ const normalized = normalizeRect(rect);
49
+ return boxesOnPage(doc, page)
50
+ .filter((occupant) => rectContains(normalized, occupant.rect))
51
+ .map((occupant) => occupant.id);
52
+ }
53
+
54
+ /**
55
+ * The union of every selected box's own rect — the single bounding
56
+ * rectangle a multi-select's move handle is drawn around, and the
57
+ * reference corner `box-clipboard.js`'s copy/paste offset math uses.
58
+ * `null` for an empty selection, or if any id no longer names a real box
59
+ * (deleted out from under a stale selection) — a caller must not draw a
60
+ * bounds rect for a selection it cannot fully resolve.
61
+ *
62
+ * @param {import("../model/node.js").Doc} doc
63
+ * @param {import("../model/node.js").NodeId[]} containerIds
64
+ * @returns {import("./geometry.js").Rect | null}
65
+ */
66
+ export function anchorBounds(doc, containerIds) {
67
+ if (containerIds.length === 0) {
68
+ return null;
69
+ }
70
+ /** @type {import("./geometry.js").Rect[]} */
71
+ const rects = [];
72
+ for (const id of containerIds) {
73
+ const layout = layoutOf(doc, id);
74
+ if (layout === null) {
75
+ return null;
76
+ }
77
+ rects.push(rectOfLayout(layout));
78
+ }
79
+ return rects.reduce((acc, rect) => unionRect(acc, rect));
80
+ }