@embedpdf/plugin-annotation 3.0.0-next.14 → 3.0.0-next.16

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 (61) hide show
  1. package/README.md +129 -0
  2. package/dist/authoring.cjs +12 -2
  3. package/dist/authoring.d.cts +2 -2
  4. package/dist/authoring.d.ts +2 -2
  5. package/dist/authoring.js +2 -3
  6. package/dist/contract/host.cjs +18 -2
  7. package/dist/contract/host.d.cts +3 -2
  8. package/dist/contract/host.d.ts +3 -2
  9. package/dist/contract/host.js +3 -2
  10. package/dist/contract-BK4LHe9v.d.cts +1127 -0
  11. package/dist/contract-BOo9iZKo.d.ts +1127 -0
  12. package/dist/contract-CPzpRG3h.cjs +692 -0
  13. package/dist/contract-CPzpRG3h.cjs.map +1 -0
  14. package/dist/contract-b2e_LZvu.js +651 -0
  15. package/dist/contract-b2e_LZvu.js.map +1 -0
  16. package/dist/contract.cjs +11 -10
  17. package/dist/contract.d.cts +2 -3
  18. package/dist/contract.d.ts +2 -3
  19. package/dist/contract.js +2 -4
  20. package/dist/host-contract-BU29qDEw.d.cts +320 -0
  21. package/dist/host-contract-CxrQPcPj.js +52 -0
  22. package/dist/host-contract-CxrQPcPj.js.map +1 -0
  23. package/dist/{contract-a4eaQWga.cjs → host-contract-DY9jFPPF.cjs} +36 -16
  24. package/dist/host-contract-DY9jFPPF.cjs.map +1 -0
  25. package/dist/host-contract-DfSvbt8c.d.ts +320 -0
  26. package/dist/index.cjs +5043 -419
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +12 -17
  29. package/dist/index.d.ts +12 -17
  30. package/dist/index.js +4995 -391
  31. package/dist/index.js.map +1 -1
  32. package/package.json +11 -14
  33. package/dist/authoring-BhtyfsMx.d.cts +0 -37
  34. package/dist/authoring-R4ezdnXd.d.ts +0 -37
  35. package/dist/capability-Bt320iJP.cjs +0 -3249
  36. package/dist/capability-Bt320iJP.cjs.map +0 -1
  37. package/dist/capability-CoL4na7g.js +0 -3226
  38. package/dist/capability-CoL4na7g.js.map +0 -1
  39. package/dist/contract-C7rcuNp_.d.cts +0 -34
  40. package/dist/contract-CM2Ovdq_.d.ts +0 -34
  41. package/dist/contract-DtXE1hjY.js +0 -32
  42. package/dist/contract-DtXE1hjY.js.map +0 -1
  43. package/dist/contract-a4eaQWga.cjs.map +0 -1
  44. package/dist/internal.cjs +0 -8
  45. package/dist/internal.d.cts +0 -14
  46. package/dist/internal.d.ts +0 -14
  47. package/dist/internal.js +0 -5
  48. package/dist/seam-6pJeKOOJ.cjs +0 -296
  49. package/dist/seam-6pJeKOOJ.cjs.map +0 -1
  50. package/dist/seam-C4L_7rDt.js +0 -225
  51. package/dist/seam-C4L_7rDt.js.map +0 -1
  52. package/dist/tools-B3i1gjRs.cjs +0 -589
  53. package/dist/tools-B3i1gjRs.cjs.map +0 -1
  54. package/dist/tools-DsfqBn_a.js +0 -566
  55. package/dist/tools-DsfqBn_a.js.map +0 -1
  56. package/dist/types-BvB1vUKb.js +0 -20
  57. package/dist/types-BvB1vUKb.js.map +0 -1
  58. package/dist/types-ChRnKozg.d.cts +0 -1246
  59. package/dist/types-Dlhdw2k_.cjs +0 -31
  60. package/dist/types-Dlhdw2k_.cjs.map +0 -1
  61. package/dist/types-Dmz3eRSG.d.ts +0 -1246
@@ -1,3226 +0,0 @@
1
- import { a as contentToPdfRect$1, c as insetPdfRectByRD, d as refKey, f as rotFromDTO, g as writableTarget, i as contentToPdfPoint, l as pdfToContentPoint$1, m as toPdfRotation, n as boxGeomFromDTO, o as cssToColor, p as styleFromDTO, r as colorToCss, s as fromPdfRotation, t as boxGeomFields, u as pdfToContentRect$1 } from "./seam-C4L_7rDt.js";
2
- import { i as isTouchDirect, r as buildToolRegistry, t as ARMED_STAMP_TOOL_ID } from "./tools-DsfqBn_a.js";
3
- import { n as previewBucket } from "./types-BvB1vUKb.js";
4
- import { CONTINUOUS_RENDER_POLICY, snapAppearanceScale } from "@embedpdf/core";
5
- import { ActionsToken, createHoverPump } from "@embedpdf/plugin-actions/contract";
6
- import { InteractionToken } from "@embedpdf/plugin-interaction/contract";
7
- import { buildCommentThreads, encodeStableIdKey, isDimension, isReadout, measurementReadout, resolveBinarySource, serializeError, sniffBinaryMetadata, viewportForPoint } from "@embedpdf/engine-core/runtime";
8
- import { scriptColorToRgb } from "@embedpdf/core-acrojs";
9
- import { SelectionToken } from "@embedpdf/plugin-selection/contract";
10
- import { FLAG_KEYS, annotContentsEditable, annotDeletable, annotTransformable, applyStyleToRange, calloutLinePoints, canMove, chrome, clickCreateGeom, cloudyBorderExtent, contentToPdfRect, creationDraftAnchor, cursorAt, defaultsFor, distanceLabel, expandGroups, fitStampBox, geomPdfBounds, geomRotation, geomVisualBounds, groupKeyOf, hitTest, initialTextStyle, isSubstrateOnly, linkChildrenOf, linkOf, locateOffset, measurementLayout, pageItems, paragraphsFromPlainText, pdfToContentPoint, pdfToContentRect, propsFor, readProp, resolveClickPlacement, rotatedAabb, selectionAnchor, selectionQuad, shapeMeasurementLabel, sharedProps, styleFromProps, textBoxes, textPlateInset, unionRect, update, uprightRotation, viewable } from "@embedpdf/core-annotation";
11
- import { normalizeQuad, textQuadBounds } from "@embedpdf/core-geometry";
12
- //#region src/repository/props.ts
13
- /**
14
- * GENERIC per-key prop lowerings — the 1:1 mappings from the flat props
15
- * vocabulary to wire fields that hold for every kind that declares the key.
16
- * Kind modules override ONLY their exceptions (a coupling lives in its
17
- * owner's file: cloudy `/RD` in shape.ts, visual-bounds `/Rect` in stroke.ts,
18
- * the link target in link.ts).
19
- */
20
- /** /BS slice of the style — a cloudy border keeps a solid underlying stroke
21
- * (the scallops are the /BE effect, layered on by the shape kinds). */
22
- const borderSlice = (style) => ({
23
- borderStyle: style.border.kind === "dashed" ? "dashed" : "solid",
24
- ...style.border.kind === "dashed" ? { dashArray: style.border.dash } : {}
25
- });
26
- /** The `/DA`-styled text slice falls back to the draw-time seed exactly like
27
- * the old projections did (a fresh draft may not carry `text` yet). */
28
- const textOf = (a) => a.text ?? initialTextStyle;
29
- const GENERIC_PROPS = {
30
- color: (a) => ({ color: cssToColor(a.style.color) }),
31
- opacity: (a) => ({ opacity: a.style.opacity }),
32
- blendMode: (a) => ({ blendMode: a.style.blendMode }),
33
- interiorColor: (a) => ({ interiorColor: a.style.interiorColor ? cssToColor(a.style.interiorColor) : null }),
34
- strokeWidth: (a) => ({ strokeWidth: a.style.strokeWidth }),
35
- border: (a) => borderSlice(a.style),
36
- fontFamily: (a) => ({ fontFamily: textOf(a).fontFamily }),
37
- fontSize: (a) => ({ fontSize: textOf(a).fontSize }),
38
- fontColor: (a) => ({ fontColor: cssToColor(textOf(a).fontColor) }),
39
- textAlign: (a) => ({ textAlign: textOf(a).textAlign }),
40
- icon: (a) => a.icon !== void 0 ? { icon: a.icon } : {},
41
- link: () => ({})
42
- };
43
- //#endregion
44
- //#region src/repository/projection.ts
45
- /** Box-kind geometry emission: the model's unrotated `rect` + applied tilt →
46
- * `/Rect`(+`unrotatedRect`+`rotation`), total (nulls state the clears). */
47
- const boxEmit = (a, crop) => {
48
- const g = a.geom;
49
- return boxGeomFields(g.rect, geomRotation(a.geom), crop);
50
- };
51
- //#endregion
52
- //#region src/repository/kinds/shape.ts
53
- /** Cloudy `/BE` + `/RD` for a rect shape — TOTAL: the non-cloudy state is
54
- * stated as `null` (tri-state remove), never omitted, so toggling cloudy OFF
55
- * removes the stale inset (the Adobe phantom-padding bug). */
56
- function cloudyExtras(a) {
57
- if (a.geom.t !== "rect") return {};
58
- if (a.style.border.kind === "cloudy") {
59
- const inset = cloudyBorderExtent(a.style.border.intensity, a.style.strokeWidth, a.geom.ellipse);
60
- return {
61
- cloudyIntensity: a.style.border.intensity,
62
- rectDifferences: {
63
- left: inset,
64
- top: inset,
65
- right: inset,
66
- bottom: inset
67
- }
68
- };
69
- }
70
- return {
71
- cloudyIntensity: null,
72
- rectDifferences: null
73
- };
74
- }
75
- const ingest = (dto, crop, ellipse) => {
76
- const d = dto;
77
- return { geom: boxGeomFromDTO(d, d.rotation, d.unrotatedRect, crop, ellipse) };
78
- };
79
- const projection = (ellipse) => ({
80
- ingest: (dto, crop) => ingest(dto, crop, ellipse),
81
- geometry: (a, crop) => boxEmit(a, crop),
82
- prop: {
83
- strokeWidth: (a) => ({
84
- strokeWidth: a.style.strokeWidth,
85
- ...cloudyExtras(a)
86
- }),
87
- border: (a) => ({
88
- ...borderSlice(a.style),
89
- ...cloudyExtras(a)
90
- })
91
- }
92
- });
93
- const square = projection(false);
94
- const circle = projection(true);
95
- //#endregion
96
- //#region src/repository/kinds/stroke.ts
97
- /** Advisory rotation, TOTAL: rotation 0 states `null` (tri-state clear) —
98
- * omission would preserve a stale advisory angle. */
99
- const advisoryRotation = (g) => {
100
- const rot = geomRotation(g);
101
- return { rotation: rot ? toPdfRotation(rot) : null };
102
- };
103
- /** `/BE` intensity for a closed poly (polygon): the curls are generated from
104
- * /Vertices + /BE alone — per ISO 32000 no /RD applies — and the /Rect
105
- * (geomPdfBounds WITH the border) already includes the outward cloud extent. */
106
- const polyCloudy = (a) => a.geom.t === "poly" && a.geom.closed ? { cloudyIntensity: a.style.border.kind === "cloudy" ? a.style.border.intensity : null } : {};
107
- /** The visual-bounds `/Rect` (stroke + border included) of a stroke geom. */
108
- const visualRect = (a, crop) => {
109
- const g = a.geom;
110
- if (a.measure) {
111
- const bounds = measurementLayout(g, a.measure, a.style)?.visualBounds;
112
- if (bounds) return { rect: {
113
- left: crop.left + bounds.x,
114
- right: crop.left + bounds.x + bounds.width,
115
- top: crop.top - bounds.y,
116
- bottom: crop.top - bounds.y - bounds.height
117
- } };
118
- }
119
- if (g.t === "line" || g.t === "ink") return { rect: geomPdfBounds(g, a.style.strokeWidth, crop) };
120
- if (g.t === "poly") return { rect: geomPdfBounds(g, a.style.strokeWidth, crop, a.style.border) };
121
- return {};
122
- };
123
- /**
124
- * A lowering whose key is an INPUT of the derived /Rect: the visual bounds
125
- * ride along with every emission, so a sparse patch can never change an input
126
- * without re-emitting the derivation. (The bug this kills: patch `lineEndings`
127
- * alone → the engine re-bakes the /AP inside the stale /Rect → the new
128
- * arrowhead is clipped in every viewer except the live vector one.)
129
- */
130
- const withRect = (lower) => (a, crop) => ({
131
- ...lower(a, crop),
132
- ...visualRect(a, crop)
133
- });
134
- /** Stroke-family prop exceptions: width, border, AND line endings feed the
135
- * derived /Rect — an ending's arrowhead reaches well past the endpoint, and
136
- * ISO 32000 requires /Rect to enclose it. Where a key can't change the
137
- * bounds (a line's border restyle), the re-emitted rect is an idempotent
138
- * no-op — uniformity beats per-case reasoning here. */
139
- const strokeProps = {
140
- strokeWidth: withRect((a) => ({ strokeWidth: a.style.strokeWidth })),
141
- border: withRect((a) => ({
142
- ...borderSlice(a.style),
143
- ...polyCloudy(a)
144
- })),
145
- lineEndings: withRect((a) => (a.geom.t === "line" || a.geom.t === "poly") && a.geom.ends ? { lineEndings: a.geom.ends } : {})
146
- };
147
- const line = {
148
- ingest: (dto, crop) => {
149
- const d = dto;
150
- return {
151
- ...d.intent === "LineDimension" ? { measure: {
152
- intent: d.intent,
153
- measure: d.measure ?? null,
154
- caption: d.caption ?? { enabled: false },
155
- leader: d.leader ?? void 0,
156
- crop,
157
- text: d.contents ?? ""
158
- } } : {},
159
- geom: {
160
- t: "line",
161
- a: pdfToContentPoint(d.linePoints.start, crop),
162
- b: pdfToContentPoint(d.linePoints.end, crop),
163
- ends: d.lineEndings,
164
- ...rotFromDTO(d.rotation)
165
- }
166
- };
167
- },
168
- geometry: (a, crop) => {
169
- const g = a.geom;
170
- if (g.t !== "line") return null;
171
- return {
172
- ...a.measure?.intent === "LineDimension" ? { contents: distanceLabel(g, a.measure) } : {},
173
- linePoints: {
174
- start: contentToPdfPoint(g.a, crop),
175
- end: contentToPdfPoint(g.b, crop)
176
- },
177
- ...visualRect(a, crop),
178
- ...advisoryRotation(g)
179
- };
180
- },
181
- prop: strokeProps,
182
- draftExtras: (a) => a.measure?.intent === "LineDimension" ? {
183
- intent: a.measure.intent,
184
- measure: a.measure.measure?.subtype === "RL" ? a.measure.measure : null,
185
- caption: a.measure.caption,
186
- leader: a.measure.leader,
187
- subject: "Distance"
188
- } : {}
189
- };
190
- const polyProjection = (closed) => ({
191
- ingest: (dto, crop) => {
192
- const d = dto;
193
- return {
194
- ...d.intent === "PolygonDimension" || d.intent === "PolyLineDimension" ? { measure: {
195
- intent: d.intent,
196
- measure: d.measure ?? null,
197
- caption: d.caption ?? { enabled: false },
198
- crop,
199
- text: d.contents ?? ""
200
- } } : {},
201
- geom: {
202
- t: "poly",
203
- points: d.vertices.map((p) => pdfToContentPoint(p, crop)),
204
- closed,
205
- ..."lineEndings" in d ? { ends: d.lineEndings } : {},
206
- ...rotFromDTO(d.rotation)
207
- }
208
- };
209
- },
210
- geometry: (a, crop) => {
211
- const g = a.geom;
212
- if (g.t !== "poly") return null;
213
- return {
214
- ...a.measure && a.measure.intent !== "LineDimension" ? {
215
- contents: shapeMeasurementLabel(g, a.measure),
216
- caption: a.measure.caption
217
- } : {},
218
- vertices: g.points.map((p) => contentToPdfPoint(p, crop)),
219
- ...visualRect(a, crop),
220
- ...advisoryRotation(g)
221
- };
222
- },
223
- prop: strokeProps,
224
- draftExtras: (a) => a.measure && a.measure.intent !== "LineDimension" ? {
225
- intent: a.measure.intent,
226
- measure: a.measure.measure?.subtype === "RL" ? a.measure.measure : null,
227
- caption: a.measure.caption,
228
- subject: closed ? "Area" : "Perimeter"
229
- } : {}
230
- });
231
- const polygon = polyProjection(true);
232
- const polyline = polyProjection(false);
233
- const ink = {
234
- ingest: (dto, crop) => {
235
- const d = dto;
236
- return {
237
- geom: {
238
- t: "ink",
239
- strokes: d.inkList.map((stroke) => stroke.map((p) => pdfToContentPoint(p, crop))),
240
- ...rotFromDTO(d.rotation)
241
- },
242
- ...d.intent ? { intent: d.intent } : {}
243
- };
244
- },
245
- geometry: (a, crop) => {
246
- const g = a.geom;
247
- if (g.t !== "ink") return null;
248
- return {
249
- inkList: g.strokes.map((stroke) => stroke.map((p) => contentToPdfPoint(p, crop))),
250
- ...visualRect(a, crop),
251
- ...advisoryRotation(g)
252
- };
253
- },
254
- prop: strokeProps,
255
- draftExtras: (a) => a.intent === "ink-highlight" ? { intent: a.intent } : {}
256
- };
257
- //#endregion
258
- //#region src/rich-text.ts
259
- /**
260
- * Rich text POLICY — what the annotation plugin decides about a free-text
261
- * annotation's rich document, kept pure so every framework's editor glue
262
- * behaves identically (the mechanics live in `@embedpdf/web`'s binding, the
263
- * run algebra in the core):
264
- *
265
- * • the rich document of any annot (the DTO's, or one synthesised from the
266
- * plain text + the `/DA` text style for a draft that has no DTO yet)
267
- * • a flat props patch → the run delta it means for a text RANGE (font,
268
- * size, colour, bold/italic/underline) and the keys left for the body
269
- * • what a range reads back for those keys (agree → the value, else mixed)
270
- * • faces: a DTO font (standard kebab name / registered key) ↔ the face a
271
- * run names (family + weight + italic) ↔ a CSS family list
272
- * • the commit rule: a document nothing overrides on a plain annotation
273
- * commits as `contents`; anything else as `richText`
274
- */
275
- /** The props keys a text range takes as run deltas. */
276
- const RANGE_KEYS = [
277
- "fontFamily",
278
- "fontSize",
279
- "fontColor",
280
- "bold",
281
- "italic",
282
- "underline"
283
- ];
284
- /** The engine's standard-14 vocabulary (the `StandardFont` kebab names). */
285
- const STANDARD_FACES = {
286
- courier: {
287
- family: "Courier",
288
- weight: 400,
289
- italic: false
290
- },
291
- "courier-bold": {
292
- family: "Courier",
293
- weight: 700,
294
- italic: false
295
- },
296
- "courier-bold-oblique": {
297
- family: "Courier",
298
- weight: 700,
299
- italic: true
300
- },
301
- "courier-oblique": {
302
- family: "Courier",
303
- weight: 400,
304
- italic: true
305
- },
306
- helvetica: {
307
- family: "Helvetica",
308
- weight: 400,
309
- italic: false
310
- },
311
- "helvetica-bold": {
312
- family: "Helvetica",
313
- weight: 700,
314
- italic: false
315
- },
316
- "helvetica-bold-oblique": {
317
- family: "Helvetica",
318
- weight: 700,
319
- italic: true
320
- },
321
- "helvetica-oblique": {
322
- family: "Helvetica",
323
- weight: 400,
324
- italic: true
325
- },
326
- "times-roman": {
327
- family: "Times",
328
- weight: 400,
329
- italic: false
330
- },
331
- "times-bold": {
332
- family: "Times",
333
- weight: 700,
334
- italic: false
335
- },
336
- "times-bold-italic": {
337
- family: "Times",
338
- weight: 700,
339
- italic: true
340
- },
341
- "times-italic": {
342
- family: "Times",
343
- weight: 400,
344
- italic: true
345
- },
346
- symbol: {
347
- family: "Symbol",
348
- weight: 400,
349
- italic: false
350
- },
351
- "zapf-dingbats": {
352
- family: "ZapfDingbats",
353
- weight: 400,
354
- italic: false
355
- }
356
- };
357
- /** Family names compare without case, spaces, hyphens, underscores and
358
- * quotes — the engine's own rule. */
359
- function familyKey(family) {
360
- return family.toLowerCase().replace(/[\s\-_'"]/g, "");
361
- }
362
- const STANDARD_FAMILY_KEYS = {
363
- helvetica: "Helvetica",
364
- arial: "Helvetica",
365
- arialmt: "Helvetica",
366
- sansserif: "Helvetica",
367
- times: "Times",
368
- timesroman: "Times",
369
- timesnewroman: "Times",
370
- timesnewromanpsmt: "Times",
371
- serif: "Times",
372
- courier: "Courier",
373
- couriernew: "Courier",
374
- couriernewpsmt: "Courier",
375
- monospace: "Courier",
376
- symbol: "Symbol",
377
- zapfdingbats: "ZapfDingbats",
378
- dingbats: "ZapfDingbats"
379
- };
380
- /** Web stacks with the standard families' metrics (what the DOM editor and
381
- * the vector renderer show for text the engine sets in a standard font). */
382
- const STANDARD_STACKS = {
383
- Helvetica: "Helvetica, Arial, sans-serif",
384
- Times: "\"Times New Roman\", Times, serif",
385
- Courier: "\"Courier New\", Courier, monospace",
386
- Symbol: "serif",
387
- ZapfDingbats: "serif"
388
- };
389
- /** The face a DTO font names: a standard font's family/weight/italic, a
390
- * registered key's identity, else the string itself as a family. */
391
- function faceForFont(font, fonts) {
392
- const standard = STANDARD_FACES[font];
393
- if (standard) return { ...standard };
394
- const registered = fonts?.().find((f) => f.key === font);
395
- if (registered) return {
396
- family: registered.familyName,
397
- weight: registered.weight,
398
- italic: registered.italic
399
- };
400
- return { family: font };
401
- }
402
- /** The DTO font for a face: the registered key whose identity matches (the
403
- * closest weight, italic first), else the standard kebab name, else the
404
- * family itself. The inverse of {@link faceForFont}. */
405
- function fontForFace(face, fonts) {
406
- const wanted = familyKey(face.family);
407
- const weight = face.weight ?? 400;
408
- const italic = face.italic ?? false;
409
- let best;
410
- for (const f of fonts?.() ?? []) {
411
- if (familyKey(f.familyName) !== wanted) continue;
412
- const score = Math.abs(f.weight - weight) + (f.italic === italic ? 0 : 1e3);
413
- if (!best || score < best.score) best = {
414
- key: f.key,
415
- score
416
- };
417
- }
418
- if (best) return best.key;
419
- const standard = STANDARD_FAMILY_KEYS[wanted];
420
- if (standard) {
421
- const bold = weight >= 600;
422
- for (const [name, spec] of Object.entries(STANDARD_FACES)) if (spec.family === standard && spec.weight >= 600 === bold && spec.italic === italic) return name;
423
- for (const [name, spec] of Object.entries(STANDARD_FACES)) if (spec.family === standard) return name;
424
- }
425
- return face.family;
426
- }
427
- /** The CSS family list for a face family: a standard family's web stack, a
428
- * registered family's `@font-face` name (its key — see `mountWebFont`),
429
- * else the family itself with a generic fallback. */
430
- function cssFontFamilyForFace(family, fonts) {
431
- const standard = STANDARD_FAMILY_KEYS[familyKey(family)];
432
- if (standard) return STANDARD_STACKS[standard];
433
- const wanted = familyKey(family);
434
- const registered = fonts?.().find((f) => familyKey(f.familyName) === wanted);
435
- if (registered) return `"${registered.key}"`;
436
- return `"${family}", sans-serif`;
437
- }
438
- /** The CSS family list for a DTO font (a standard kebab name or a key). */
439
- function cssFontFamilyForFont(font, fonts) {
440
- const standard = STANDARD_FACES[font];
441
- if (standard) return STANDARD_STACKS[standard.family];
442
- return `"${font}", sans-serif`;
443
- }
444
- const hex = (css) => css.trim().toUpperCase();
445
- /** The rich body the `/DA` text style describes (a draft's body before its
446
- * DTO exists; also the fallback for a DTO without `richText`). */
447
- function bodyFromTextStyle(t, fonts) {
448
- const face = faceForFont(t.fontFamily, fonts);
449
- return {
450
- family: face.family,
451
- weight: t.bold ? 700 : face.weight ?? 400,
452
- italic: t.italic ?? face.italic ?? false,
453
- size: t.fontSize,
454
- color: hex(t.fontColor),
455
- decoration: t.underline ? ["underline"] : [],
456
- script: "normal",
457
- letterSpacing: 0,
458
- horizontalScale: 1,
459
- align: t.textAlign,
460
- dir: "ltr"
461
- };
462
- }
463
- /** The annotation's rich document: the DTO's, else one synthesised from
464
- * its plain text and text style (a draft the engine has not echoed yet). */
465
- function richDocOf(a, fonts) {
466
- if (a.data?.subtype === "free-text" && a.data.richText) return a.data.richText;
467
- return {
468
- body: bodyFromTextStyle(a.text ?? {
469
- fontFamily: "helvetica",
470
- fontSize: 12,
471
- fontColor: "#000000",
472
- textAlign: "left"
473
- }, fonts),
474
- paragraphs: paragraphsFromPlainText(a.data?.contents ?? "")
475
- };
476
- }
477
- /**
478
- * The write a text edit commits: the rich paragraphs, with paragraph
479
- * properties equal to the body's stripped (they are inherited, not
480
- * overrides) and the body omitted — it stays what the props path last
481
- * wrote. One engine, one path: plain and formatted text alike.
482
- */
483
- function textCommitPatch(a, paragraphs, fonts) {
484
- return { richText: { paragraphs: stripBodyDefaults(paragraphs, richDocOf(a, fonts).body) } };
485
- }
486
- /**
487
- * Paragraph alignment/direction equal to the body's are not overrides: the
488
- * engine echoes them resolved, the editor renders them as inline styles and
489
- * serialises them back, and the commit rule must see through that (a plain
490
- * `/Contents` annotation must not grow an `/RC` from typing).
491
- */
492
- function stripBodyDefaults(paragraphs, body) {
493
- return paragraphs.map((p) => {
494
- if ((p.align === void 0 || p.align !== body.align) && (p.dir === void 0 || p.dir !== body.dir)) return p;
495
- const { align, dir, ...rest } = p;
496
- return {
497
- ...rest,
498
- ...align !== void 0 && align !== body.align ? { align } : {},
499
- ...dir !== void 0 && dir !== body.dir ? { dir } : {}
500
- };
501
- });
502
- }
503
- /**
504
- * Split a props patch for an annotation whose editor holds a text RANGE:
505
- * the run delta the range takes (font → face, size, colour, bold → weight,
506
- * italic, underline → decoration) and the keys that still go to the body.
507
- */
508
- function runDeltaForProps(patch, fonts) {
509
- const delta = {};
510
- const rest = {};
511
- for (const key of Object.keys(patch)) {
512
- const value = patch[key];
513
- if (value === void 0) continue;
514
- switch (key) {
515
- case "fontFamily": {
516
- const face = faceForFont(patch.fontFamily, fonts);
517
- delta.family = face.family;
518
- if (face.weight !== void 0) delta.weight = face.weight;
519
- if (face.italic !== void 0) delta.italic = face.italic;
520
- break;
521
- }
522
- case "fontSize":
523
- delta.size = patch.fontSize;
524
- break;
525
- case "fontColor":
526
- delta.color = hex(patch.fontColor);
527
- break;
528
- case "bold":
529
- delta.weight = patch.bold ? 700 : 400;
530
- break;
531
- case "italic":
532
- delta.italic = patch.italic;
533
- break;
534
- case "underline":
535
- delta.decoration = patch.underline ? ["underline"] : [];
536
- break;
537
- default: rest[key] = value;
538
- }
539
- }
540
- return {
541
- delta,
542
- rest
543
- };
544
- }
545
- /** The style deltas of every non-empty run a range overlaps. */
546
- function runsInRange(doc, range) {
547
- const start = Math.min(range.start, range.end);
548
- const end = Math.max(range.start, range.end);
549
- if (end <= start) return [];
550
- const from = locateOffset(doc, start);
551
- const to = locateOffset(doc, end);
552
- const out = [];
553
- for (let index = from.paragraph; index <= to.paragraph; index++) {
554
- const paragraph = doc.paragraphs[index];
555
- const localStart = index === from.paragraph ? from.offset : 0;
556
- const localEnd = index === to.paragraph ? to.offset : paragraph.runs.reduce((n, r) => n + r.text.length, 0);
557
- let pos = 0;
558
- for (const run of paragraph.runs) {
559
- const runEnd = pos + run.text.length;
560
- if (runEnd > localStart && pos < localEnd && run.text.length > 0) out.push(run.style ?? {});
561
- pos = runEnd;
562
- }
563
- }
564
- return out;
565
- }
566
- /** The values a range reads back for the range keys — resolved against the
567
- * body, one value where every run agrees, `mixed` where they don't. */
568
- function rangeProps(doc, range, fonts) {
569
- const runs = runsInRange(doc, range);
570
- const body = doc.body;
571
- const resolve = (d) => ({
572
- family: d.family ?? body.family,
573
- weight: d.weight ?? body.weight,
574
- italic: d.italic ?? body.italic,
575
- size: d.size ?? body.size,
576
- color: d.color ?? body.color,
577
- decoration: d.decoration ?? body.decoration
578
- });
579
- const styles = (runs.length ? runs : [{}]).map(resolve);
580
- const values = {};
581
- const mixed = [];
582
- const read = (key, of) => {
583
- const first = of(styles[0]);
584
- values[key] = first;
585
- if (styles.some((s) => JSON.stringify(of(s)) !== JSON.stringify(first))) mixed.push(key);
586
- };
587
- read("fontFamily", (s) => fontForFace({
588
- family: s.family,
589
- weight: body.weight,
590
- italic: body.italic
591
- }, fonts));
592
- read("fontSize", (s) => s.size);
593
- read("fontColor", (s) => s.color.toLowerCase());
594
- read("bold", (s) => (s.weight ?? 400) >= 600);
595
- read("italic", (s) => !!s.italic);
596
- read("underline", (s) => (s.decoration ?? []).includes("underline"));
597
- return {
598
- values,
599
- mixed
600
- };
601
- }
602
- //#endregion
603
- //#region src/repository/kinds/freeText.ts
604
- /** Free-text `/DA` fields → content {@link TextStyle}. An absent `fontColor`
605
- * falls back to the `/DA` colour — the same rule the CPVT renderer applies. */
606
- function textFromDTO(dto) {
607
- const body = dto.richText?.body;
608
- return {
609
- fontFamily: dto.fontFamily,
610
- fontSize: dto.fontSize,
611
- fontColor: colorToCss(dto.fontColor ?? dto.color),
612
- textAlign: dto.textAlign,
613
- ...body && body.weight >= 600 ? { bold: true } : {},
614
- ...body?.italic ? { italic: true } : {},
615
- ...body?.decoration.includes("underline") ? { underline: true } : {}
616
- };
617
- }
618
- /**
619
- * A formatting toggle is a BODY change (runs are deltas over it), so it
620
- * lowers as a rich write carrying the current paragraphs — and every toggle
621
- * lowers the same COMPLETE body (the current one with the three toggles
622
- * applied: a partial body means engine defaults, which would reset the
623
- * size, face and colour), so a patch of several merges cleanly.
624
- */
625
- const formattingBody = (a) => {
626
- const doc = richDocOf(a);
627
- const t = a.text;
628
- return { richText: {
629
- body: {
630
- ...doc.body,
631
- weight: t?.bold ? 700 : 400,
632
- italic: !!t?.italic,
633
- decoration: t?.underline ? ["underline"] : []
634
- },
635
- paragraphs: doc.paragraphs
636
- } };
637
- };
638
- /**
639
- * The engine geometry for a callout: the overall `/Rect` (text box ∪ leader ∪
640
- * arrow), the `/RD` inset that recovers the text box from it, the `/CL` leader
641
- * (`[tip, knee, conn]` with the connection point derived), and the `/LE`
642
- * ending. All in PDF user space (y-up), with every `/RD` inset clamped
643
- * non-negative.
644
- *
645
- * A tilted box (the upright policy) additionally emits `rotation` +
646
- * `unrotatedRect` (the logical text box) — the engine bakes the box + text
647
- * under an INLINE rotation about the box centre while the leader stays
648
- * page-space, so (unlike plain boxes) the /AP form `/Matrix` stays identity
649
- * and the raster stays placed by `/Rect`. `/RD` then insets to the ROTATED
650
- * box's AABB — the best axis-aligned text box a foreign viewer regenerating
651
- * the AP can draw (spec-conformant degradation). The transform pair is TOTAL
652
- * (nulls state the clears) like every box emission.
653
- */
654
- function calloutFields(a, crop) {
655
- const g = a.geom;
656
- if (g.t !== "text" || !g.callout) return null;
657
- const rot = geomRotation(g);
658
- const overall = geomPdfBounds(g, a.style.strokeWidth, crop);
659
- const tb = contentToPdfRect$1(rot ? rotatedAabb(g.rect, rot) : g.rect, crop);
660
- const nn = (n) => Math.max(0, n);
661
- const pts = calloutLinePoints(g).map((p) => contentToPdfPoint(p, crop));
662
- const calloutLine = pts.length === 3 ? [
663
- pts[0],
664
- pts[1],
665
- pts[2]
666
- ] : [pts[0], pts[1]];
667
- return {
668
- rect: overall,
669
- rectDifferences: {
670
- left: nn(tb.left - overall.left),
671
- bottom: nn(tb.bottom - overall.bottom),
672
- right: nn(overall.right - tb.right),
673
- top: nn(overall.top - tb.top)
674
- },
675
- calloutLine,
676
- lineEnding: g.callout.ending,
677
- ...rot ? {
678
- rotation: toPdfRotation(rot),
679
- unrotatedRect: contentToPdfRect$1(g.rect, crop)
680
- } : {
681
- rotation: null,
682
- unrotatedRect: null
683
- }
684
- };
685
- }
686
- const freeText = {
687
- ingest: (dto, crop) => {
688
- const d = dto;
689
- if (d.intent === "free-text-callout" && d.calloutLine && d.calloutLine.length >= 2) {
690
- const cl = d.calloutLine;
691
- const rot = d.rotation ? fromPdfRotation(d.rotation) : 0;
692
- return {
693
- geom: {
694
- t: "text",
695
- rect: rot && d.unrotatedRect ? pdfToContentRect$1(d.unrotatedRect, crop) : pdfToContentRect$1(insetPdfRectByRD(d.rect, d.rectDifferences), crop),
696
- callout: {
697
- tip: pdfToContentPoint$1(cl[0], crop),
698
- knee: cl.length === 3 ? pdfToContentPoint$1(cl[1], crop) : void 0,
699
- ending: d.lineEnding ?? "none"
700
- },
701
- ...rot && d.unrotatedRect ? { rot } : {}
702
- },
703
- text: textFromDTO(d)
704
- };
705
- }
706
- const rot = d.rotation ? fromPdfRotation(d.rotation) : 0;
707
- return {
708
- geom: {
709
- t: "text",
710
- rect: pdfToContentRect$1(rot && d.unrotatedRect ? d.unrotatedRect : d.rect, crop),
711
- ...rot ? { rot } : {}
712
- },
713
- text: textFromDTO(d)
714
- };
715
- },
716
- geometry: (a, crop) => {
717
- if (a.geom.t !== "text") return null;
718
- const cf = calloutFields(a, crop);
719
- if (cf) return { ...cf };
720
- return boxEmit(a, crop);
721
- },
722
- prop: {
723
- bold: formattingBody,
724
- italic: formattingBody,
725
- underline: formattingBody
726
- },
727
- draftExtras: (a, crop) => ({
728
- intent: calloutFields(a, crop) ? "free-text-callout" : "free-text",
729
- contents: a.data?.contents ?? ""
730
- })
731
- };
732
- //#endregion
733
- //#region src/repository/kinds/quads.ts
734
- /**
735
- * Imported `/QuadPoints` → semantic TextQuads. `normalizeQuad` is a
736
- * NORMALIZER, not a cast: the de-facto zigzag order passes through, ring-order
737
- * producers are repaired, and garbage gets a deterministic labeling — so
738
- * drawing code can never put an underline on the wrong edge of a well-formed
739
- * quad, rotated ones included.
740
- */
741
- const quadsFromDTO = (quadPoints, crop) => quadPoints.map((q) => normalizeQuad({
742
- p1: pdfToContentPoint$1(q.p1, crop),
743
- p2: pdfToContentPoint$1(q.p2, crop),
744
- p3: pdfToContentPoint$1(q.p3, crop),
745
- p4: pdfToContentPoint$1(q.p4, crop)
746
- }));
747
- /** Content quads → engine `/QuadPoints` (PDF user space, zigzag slot order:
748
- * p1..p4 = upper-start, upper-end, lower-start, lower-end — under the y-flip
749
- * exactly PDFium's documented TL, TR, BL, BR); null off quads geom. */
750
- function quadPointsFor(a, crop) {
751
- if (a.geom.t !== "quads") return null;
752
- return a.geom.quads.map((q) => ({
753
- p1: contentToPdfPoint(q.upperStart, crop),
754
- p2: contentToPdfPoint(q.upperEnd, crop),
755
- p3: contentToPdfPoint(q.lowerStart, crop),
756
- p4: contentToPdfPoint(q.lowerEnd, crop)
757
- }));
758
- }
759
- /** PDF-space bounding box of a set of `/QuadPoints` quads — the `/Rect` a
760
- * quad-bearing draft must carry alongside its quads. */
761
- const pdfBoundsOfQuads = (quads) => {
762
- let left = Infinity;
763
- let bottom = Infinity;
764
- let right = -Infinity;
765
- let top = -Infinity;
766
- for (const q of quads) for (const p of [
767
- q.p1,
768
- q.p2,
769
- q.p3,
770
- q.p4
771
- ]) {
772
- if (p.x < left) left = p.x;
773
- if (p.x > right) right = p.x;
774
- if (p.y < bottom) bottom = p.y;
775
- if (p.y > top) top = p.y;
776
- }
777
- return {
778
- left,
779
- bottom,
780
- right,
781
- top
782
- };
783
- };
784
- const markupProjection = (subtype) => {
785
- return {
786
- ingest: (dto, crop) => {
787
- const d = dto;
788
- return {
789
- geom: {
790
- t: "quads",
791
- quads: quadsFromDTO(d.quadPoints, crop)
792
- },
793
- ...subtype === "strikeout" && "intent" in d && d.intent ? { intent: d.intent } : {}
794
- };
795
- },
796
- geometry: () => null,
797
- draftExtras: (a, crop) => {
798
- const quads = quadPointsFor(a, crop);
799
- if (!quads) return null;
800
- return {
801
- quadPoints: quads,
802
- ...subtype === "strikeout" && a.intent === "strikeout-text-edit" ? { intent: a.intent } : {}
803
- };
804
- }
805
- };
806
- };
807
- const highlight = markupProjection("highlight");
808
- const underline = markupProjection("underline");
809
- const squiggly = markupProjection("squiggly");
810
- const strikeout = markupProjection("strikeout");
811
- const caret = {
812
- ingest: (dto, crop) => {
813
- const d = dto;
814
- const rot = d.rotation ? fromPdfRotation(d.rotation) : 0;
815
- return {
816
- geom: {
817
- t: "caret",
818
- rect: pdfToContentRect$1(rot && d.unrotatedRect ? d.unrotatedRect : d.rect, crop),
819
- ...rot ? { rot } : {}
820
- },
821
- ...d.intent ? { intent: d.intent } : {}
822
- };
823
- },
824
- geometry: (a, crop) => a.geom.t === "caret" ? boxGeomFields(a.geom.rect, a.geom.rot ?? 0, crop) : null,
825
- draftExtras: (a) => ({
826
- rectDifferences: {
827
- left: .5,
828
- top: .5,
829
- right: .5,
830
- bottom: .5
831
- },
832
- ...a.intent === "replace" ? { intent: a.intent } : {},
833
- ...a.data?.contents != null ? { contents: a.data.contents } : {}
834
- })
835
- };
836
- const redact = {
837
- ingest: (dto, crop) => {
838
- const d = dto;
839
- return {
840
- geom: d.quadPoints.length > 0 ? {
841
- t: "quads",
842
- quads: quadsFromDTO(d.quadPoints, crop)
843
- } : {
844
- t: "rect",
845
- rect: pdfToContentRect$1(d.rect, crop),
846
- ellipse: false
847
- },
848
- text: {
849
- fontFamily: d.fontFamily,
850
- fontSize: d.fontSize,
851
- fontColor: colorToCss(d.fontColor),
852
- textAlign: d.textAlign
853
- },
854
- ...d.overlayText ? { label: {
855
- text: d.overlayText,
856
- repeat: d.repeat
857
- } } : {}
858
- };
859
- },
860
- geometry: (a, crop) => a.geom.t === "rect" ? { rect: contentToPdfRect$1(a.geom.rect, crop) } : null,
861
- draftExtras: (a, crop) => {
862
- const quads = quadPointsFor(a, crop);
863
- return quads ? {
864
- quadPoints: quads,
865
- rect: pdfBoundsOfQuads(quads)
866
- } : {};
867
- }
868
- };
869
- //#endregion
870
- //#region src/repository/kinds/misc.ts
871
- const rectGeometry = (a, crop) => "rect" in a.geom ? { rect: contentToPdfRect$1(a.geom.rect, crop) } : null;
872
- const iconProjection = (subtype) => ({
873
- ingest: (dto, crop) => {
874
- const d = dto;
875
- return {
876
- geom: {
877
- t: "rect",
878
- rect: pdfToContentRect$1(d.rect, crop),
879
- ellipse: false
880
- },
881
- icon: d.icon
882
- };
883
- },
884
- geometry: rectGeometry,
885
- createable: false
886
- });
887
- const textNote = iconProjection("text");
888
- const fileAttachment = iconProjection("file-attachment");
889
- const stamp = {
890
- ingest: (dto, crop) => {
891
- const d = dto;
892
- return { geom: boxGeomFromDTO(d, d.rotation, d.unrotatedRect, crop, false) };
893
- },
894
- geometry: (a, crop) => boxEmit(a, crop),
895
- createable: false
896
- };
897
- const link = {
898
- ingest: (dto, crop) => {
899
- const d = dto;
900
- return {
901
- geom: {
902
- t: "rect",
903
- rect: pdfToContentRect$1(d.rect, crop),
904
- ellipse: false
905
- },
906
- link: d.target
907
- };
908
- },
909
- geometry: rectGeometry,
910
- prop: { link: (a) => {
911
- const target = writableTarget(a.link);
912
- return target ? { target } : a.link == null ? { target: null } : {};
913
- } },
914
- draftExtras: (a) => ({ target: writableTarget(a.link) })
915
- };
916
- /** One PDF `widget` subtype → per-family CLIENT kinds (radios have no font). */
917
- const WIDGET_KIND_BY_FAMILY = {
918
- text: "widget-text",
919
- combobox: "widget-choice",
920
- listbox: "widget-choice",
921
- pushbutton: "widget-button",
922
- checkbox: "widget-toggle",
923
- radio: "widget-toggle"
924
- };
925
- const widgetKindOf = (family) => WIDGET_KIND_BY_FAMILY[family] ?? "widget-box";
926
- const WIDGET_TEXT_KINDS = /* @__PURE__ */ new Set([
927
- "widget-text",
928
- "widget-choice",
929
- "widget-button"
930
- ]);
931
- function widgetTextFromDTO(dto) {
932
- return {
933
- fontFamily: dto.fontFamily ?? "helvetica",
934
- fontSize: dto.fontSize ?? 0,
935
- fontColor: dto.fontColor ? colorToCss(dto.fontColor) : "#000000",
936
- textAlign: dto.textAlign
937
- };
938
- }
939
- const widget = {
940
- ingest: (dto, crop) => {
941
- const d = dto;
942
- return {
943
- geom: boxGeomFromDTO(d, void 0, void 0, crop, false),
944
- ...WIDGET_TEXT_KINDS.has(widgetKindOf(d.fieldFamily)) ? { text: widgetTextFromDTO(d) } : {}
945
- };
946
- },
947
- geometry: rectGeometry,
948
- createable: false
949
- };
950
- const unsupported = {
951
- ingest: (dto, crop) => ({ geom: {
952
- t: "rect",
953
- rect: pdfToContentRect$1(dto.rect, crop),
954
- ellipse: false
955
- } }),
956
- geometry: () => null,
957
- createable: false
958
- };
959
- //#endregion
960
- //#region src/repository/links.ts
961
- /**
962
- * Attached-link folding + desired-state derivation — RELATIONSHIP logic, not
963
- * projection: a `/Link` child grouped under a linkable parent becomes the
964
- * parent's `link` prop, and the reconciler derives the children it should
965
- * have from the parent's committed geometry.
966
- */
967
- /**
968
- * The DESIRED hit rects (content space) of a parent's attached link
969
- * children — derived fresh from the parent's committed geometry, never
970
- * tracked: markup gets one child per quad (per-line hit areas, the v2
971
- * segment behaviour), every other kind one child over its visual bounds.
972
- * The reconciler and the navigation lens both read THIS, so the clickable
973
- * area and the written `/Rect`s can never disagree.
974
- *
975
- * A link's `/Rect` is axis-aligned by spec (no rotation exists for links),
976
- * so a ROTATED parent gets the AABB of its rotated footprint — the
977
- * `selectionQuad` corners (rotation + stroke included), the same envelope
978
- * the selection chrome outlines. Exact rotated hit regions need
979
- * `/QuadPoints` (tier 2).
980
- */
981
- function linkChildRects(a) {
982
- if (a.geom.t === "quads") return a.geom.quads.map(textQuadBounds);
983
- return [unionRect(selectionQuad(a.geom, a.style.strokeWidth, a.style.border))];
984
- }
985
- //#endregion
986
- //#region src/repository/index.ts
987
- /** Every wire subtype declares exactly one projection — a missing kind is a
988
- * COMPILE error, not a silent fall-through. */
989
- const KINDS = {
990
- square,
991
- circle,
992
- line,
993
- polygon,
994
- polyline,
995
- ink,
996
- "free-text": freeText,
997
- highlight,
998
- underline,
999
- squiggly,
1000
- strikeout,
1001
- caret,
1002
- redact,
1003
- text: textNote,
1004
- "file-attachment": fileAttachment,
1005
- stamp,
1006
- link,
1007
- widget,
1008
- unsupported
1009
- };
1010
- /** Model subtypes are wire subtypes except the widget CLIENT kinds
1011
- * (`widget-text`…), which all project through the widget family. */
1012
- const projectionOf = (subtype) => subtype.startsWith("widget") ? KINDS.widget : KINDS[subtype] ?? KINDS.unsupported;
1013
- const wireSubtypeOf = (a) => a.subtype.startsWith("widget") ? "widget" : a.subtype;
1014
- /**
1015
- * Engine DTO → content-space Annot. `source` decides how it renders: `'baked'`
1016
- * shows the engine's appearance raster (a page load, or a remote edit — trust
1017
- * the authored AP); `'vector'` renders live from geom/style (we authored or
1018
- * changed it). `apBox` is the raster's content-space box, used while baked.
1019
- */
1020
- function fromDTO(dto, crop, source = "baked") {
1021
- const slice = projectionOf(dto.subtype).ingest(dto, crop);
1022
- const strippedRect = !(dto.subtype === "free-text" && dto.intent === "free-text-callout") && "unrotatedRect" in dto && "rotation" in dto && dto.rotation ? dto.unrotatedRect : void 0;
1023
- return {
1024
- id: refKey(dto.ref),
1025
- ref: dto.ref,
1026
- pon: dto.pageObjectNumber,
1027
- subtype: dto.subtype === "widget" ? widgetKindOf(dto.fieldFamily) : dto.subtype,
1028
- flags: dto.flags,
1029
- source,
1030
- data: dto,
1031
- ...dto.inReplyTo ? { irt: refKey(dto.inReplyTo) } : {},
1032
- ...dto.replyType === "group" && dto.inReplyTo ? { group: refKey(dto.inReplyTo) } : {},
1033
- style: styleFromDTO(dto),
1034
- ...slice,
1035
- apBox: pdfToContentRect$1(strippedRect ?? dto.rect, crop),
1036
- ...strippedRect ? { apRot: geomRotation(slice.geom) } : {}
1037
- };
1038
- }
1039
- /** Lower `keys` through the kind's overrides + the generic table. `null` =
1040
- * some key has no lowering — the caller degrades to the full projection. */
1041
- function emitProps(a, crop, keys) {
1042
- const kind = projectionOf(a.subtype);
1043
- const out = {};
1044
- for (const key of keys) {
1045
- const lower = kind.prop?.[key] ?? GENERIC_PROPS[key];
1046
- if (!lower) return null;
1047
- Object.assign(out, lower(a, crop));
1048
- }
1049
- return out;
1050
- }
1051
- const editableKeys = (subtype) => propsFor(subtype).map((s) => s.key);
1052
- /** Content Annot → the FULL engine patch: the kind's geometry group plus every
1053
- * prop it declares editable. The reference statement — scoped emission and
1054
- * drafts both build on it. */
1055
- function toPatch(a, crop) {
1056
- const geo = projectionOf(a.subtype).geometry(a, crop);
1057
- const props = emitProps(a, crop, editableKeys(a.subtype)) ?? {};
1058
- if (!geo && Object.keys(props).length === 0) return null;
1059
- return {
1060
- subtype: wireSubtypeOf(a),
1061
- ...geo,
1062
- ...props
1063
- };
1064
- }
1065
- /**
1066
- * Content Annot + a {@link PatchScope} → the SPARSE patch for exactly that
1067
- * intent (the shell's `patch` effect emitter): the reducer says WHAT changed —
1068
- * geometry, or the props keys verbatim — and this lowers only that. Kinds
1069
- * without editable geometry (text markup) and unlowerable keys degrade to the
1070
- * full patch: verbose, never a dropped write.
1071
- */
1072
- function toScopedPatch(a, scope, crop) {
1073
- const kind = projectionOf(a.subtype);
1074
- if (scope.kind === "caption") return a.measure ? {
1075
- subtype: wireSubtypeOf(a),
1076
- caption: a.measure.caption
1077
- } : null;
1078
- if (scope.kind === "leader") return a.measure?.intent === "LineDimension" ? {
1079
- subtype: "line",
1080
- leader: a.measure.leader
1081
- } : null;
1082
- if (scope.kind === "geometry") {
1083
- const geo = kind.geometry(a, crop);
1084
- return geo ? {
1085
- subtype: wireSubtypeOf(a),
1086
- ...geo
1087
- } : toPatch(a, crop);
1088
- }
1089
- const props = emitProps(a, crop, scope.keys);
1090
- if (props === null) return toPatch(a, crop);
1091
- if (Object.keys(props).length === 0) return null;
1092
- return {
1093
- subtype: wireSubtypeOf(a),
1094
- ...props
1095
- };
1096
- }
1097
- /** Content Annot → engine create draft: the full statement plus the kind's
1098
- * create-only extras, with the model's `/F` emitted verbatim, once, for every
1099
- * kind (a fresh draw carries DRAWN_FLAGS plus any tool seed). `null` for the
1100
- * kinds whose creates travel their own path (stamps, widgets, icon place). */
1101
- function toCreateDraft(a, crop) {
1102
- const kind = projectionOf(a.subtype);
1103
- if (kind.createable === false) return null;
1104
- const base = toPatch(a, crop);
1105
- if (!base) return null;
1106
- const extras = kind.draftExtras?.(a, crop);
1107
- if (kind.draftExtras && extras === null) return null;
1108
- return {
1109
- ...base,
1110
- ...extras,
1111
- nm: crypto.randomUUID(),
1112
- flags: a.flags
1113
- };
1114
- }
1115
- //#endregion
1116
- //#region src/hover-feed.ts
1117
- /**
1118
- * The annotation plane's E/X trigger feed — a thin adapter from the ONE
1119
- * pointer-driven hover seam (the `hoverAt` diff) onto the shared hover pump.
1120
- *
1121
- * Anti-cascade is structural and lives in the CALLER's seam placement:
1122
- * reducer-side hover clears (session-hide, remove, reload) never pass
1123
- * through `hoverAt`, so an effect-induced hover loss can never masquerade as
1124
- * a cursor exit. This module only decides WHO is hoverable here:
1125
- *
1126
- * - Drafts (no engine ref) can't carry /AA — skipped.
1127
- * - Widgets and links belong to their own event planes (fill controls /
1128
- * LinkLayer anchors own their pixels). When they ARE hit-testable on this
1129
- * plane, an authoring tool owns them — firing hover actions while editing
1130
- * would be wrong — so they are skipped unconditionally.
1131
- * - Tree-less annotations are FREE: no target, no dispatch, and per-event
1132
- * flags keep a lone /E or /X from dispatching its inert twin.
1133
- */
1134
- function createAnnotationHoverFeed(actions, annotOf) {
1135
- const pump = createHoverPump(actions.dispatch);
1136
- const targetOf = (id) => {
1137
- if (!id) return null;
1138
- const annot = annotOf(id);
1139
- if (!annot?.ref) return null;
1140
- if (annot.subtype.startsWith("widget") || annot.subtype === "link") return null;
1141
- const enter = Boolean(annot.data?.actions?.cursorEnter?.root);
1142
- const exit = Boolean(annot.data?.actions?.cursorExit?.root);
1143
- if (!enter && !exit) return null;
1144
- return {
1145
- ref: annot.ref,
1146
- pon: annot.pon,
1147
- events: {
1148
- enter,
1149
- exit
1150
- }
1151
- };
1152
- };
1153
- return { hover: (id) => pump.hover(targetOf(id)) };
1154
- }
1155
- //#endregion
1156
- //#region src/text-item.ts
1157
- /**
1158
- * The free-text presentation projection: the core's geometry-only `textBoxes`
1159
- * joined with the DTO-derived font + CSS into render-ready {@link TextItem}s. This
1160
- * is the text analogue of the core's `scene()` "paint" for shapes — it lives in
1161
- * the plugin (not the portable core) because the font→CSS stack mapping and the
1162
- * engine `Color`→CSS seam are web concerns, shared across every web framework.
1163
- */
1164
- /** Project the model's free-text boxes into render-ready {@link TextItem}s — the
1165
- * core geometry (`textBoxes`) joined with the DTO-derived CSS. Pure; memoized by
1166
- * model identity at the call site so selectors get a stable reference. */
1167
- function buildTextItems(m, pon, view) {
1168
- return textBoxes(m, pon, view).map((tb) => {
1169
- const a = m.byId[tb.id];
1170
- const t = a?.text ?? initialTextStyle;
1171
- const sw = a?.style.strokeWidth ?? 0;
1172
- const doc = a ? richDocOf(a) : null;
1173
- return {
1174
- id: tb.id,
1175
- ref: a?.ref ?? null,
1176
- box: tb.box,
1177
- contents: a?.data?.contents ?? "",
1178
- richText: { paragraphs: doc ? stripBodyDefaults(doc.paragraphs, doc.body) : [{ runs: [{ text: "" }] }] },
1179
- editing: tb.editing,
1180
- ...tb.rot ? { rot: tb.rot } : {},
1181
- css: {
1182
- fontFamily: cssFontFamilyForFont(t.fontFamily),
1183
- fontSize: t.fontSize,
1184
- color: t.fontColor,
1185
- fontWeight: t.bold ? 700 : 400,
1186
- fontStyle: t.italic ? "italic" : "normal",
1187
- textDecoration: t.underline ? "underline" : "none",
1188
- align: t.textAlign,
1189
- padding: textPlateInset(sw)
1190
- }
1191
- };
1192
- });
1193
- }
1194
- //#endregion
1195
- //#region src/placement.ts
1196
- /**
1197
- * Per-kind CODE for the click-to-place ICON kinds (note / file attachment)
1198
- * — the `props.ts`/`repository.ts` pattern: all tool CONFIG stays in the
1199
- * tool table; this module only interprets it. The stamp keeps its own
1200
- * sizing/sniffing path in the capability; everything funnels through the
1201
- * one `placeAt` entry there.
1202
- */
1203
- /**
1204
- * Icon kinds place at the generator's fixed box: the engine forces the
1205
- * `/Rect` to 20×20 from its bottom-left corner (the spec's note-icon rule),
1206
- * so the footprint ghost and the placement use exactly this size.
1207
- */
1208
- const ICON_PLACE_SIZE = {
1209
- width: 20,
1210
- height: 20
1211
- };
1212
- const isIconPlaceKind = (subtype) => subtype === "text" || subtype === "file-attachment";
1213
- /**
1214
- * Build the engine create draft for a placed icon annotation. `geom` is the
1215
- * repository's `boxGeomFields` emit (the `/Rect` + upright rotation pair);
1216
- * `defaults` is the tool's resolved flat props bag (`defaultsFor`) — the
1217
- * colour seam is crossed here via the repository's `cssToColor`, and the
1218
- * icon falls back to the kind's own default when the bag carries none.
1219
- */
1220
- function iconPlacementDraft(subtype, geom, defaults, flags, file) {
1221
- const shared = {
1222
- ...geom,
1223
- color: cssToColor(defaults.color),
1224
- opacity: defaults.opacity,
1225
- flags: {
1226
- print: true,
1227
- ...flags
1228
- }
1229
- };
1230
- if (subtype === "text") return {
1231
- subtype: "text",
1232
- icon: defaults.icon ?? "comment",
1233
- ...shared
1234
- };
1235
- if (!file) throw new Error("[annotation] a file-attachment placement requires a file payload");
1236
- return {
1237
- subtype: "file-attachment",
1238
- icon: defaults.icon ?? "paperclip",
1239
- file,
1240
- ...shared
1241
- };
1242
- }
1243
- //#endregion
1244
- //#region src/capability.ts
1245
- /** Fold `zoom`/`rotation` host args into the core's ViewEnv (or none).
1246
- * `zoom` is the page's RELATIVE zoom (`transform.zoom`) — never the
1247
- * px-per-point `scale` (that one only converts CSS-px chrome settings). */
1248
- const viewEnv = (zoom, rotation) => zoom != null || rotation != null ? {
1249
- zoom: zoom ?? 1,
1250
- rotation: rotation ?? 0
1251
- } : void 0;
1252
- const TEXT_COMMIT_DEBOUNCE_MS = 250;
1253
- /**
1254
- * The annotation shell. The pure `update` runs HERE (so it can emit effects);
1255
- * the resulting model is dispatched to the store, and each effect is performed
1256
- * against the engine repository — optimistic create → reconcile to the durable
1257
- * ref, patch/delete fire-and-forget.
1258
- */
1259
- function createAnnotationCapability(ctx, config = {}) {
1260
- const behaviors = [];
1261
- /** Per-annotation debounce timer for the engine text write while typing. */
1262
- const textTimers = /* @__PURE__ */ new Map();
1263
- /** The registered fonts, for face ↔ key mapping (the local engine's list;
1264
- * the cloud engine registers none). */
1265
- const fonts = () => ctx.engine?.fonts?.list() ?? [];
1266
- const configTools = config.tools ?? [];
1267
- const registry = buildToolRegistry(configTools);
1268
- /** The installed file-picker port (a DOM file dialog, wired by the framework
1269
- * adapter), or null — every click-then-pick tool resolves through this ONE
1270
- * slot. See {@link FilePickerProvider}. */
1271
- let filePickerProvider = null;
1272
- const pageViewports = /* @__PURE__ */ new Map();
1273
- const captured = /* @__PURE__ */ new Set();
1274
- ctx.cleanup(() => {
1275
- captured.clear();
1276
- pageViewports.clear();
1277
- });
1278
- const model = () => ctx.getState().model;
1279
- const actionsForHover = ctx.tryGet(ActionsToken);
1280
- const hoverFeed = actionsForHover ? createAnnotationHoverFeed(actionsForHover, (id) => model().byId[id] ?? null) : null;
1281
- const chromeSettings = () => ctx.getState().chrome;
1282
- /**
1283
- * Ids on a page whose Behavior is currently ENGAGED (form widgets under a
1284
- * fill tool): they render their own DOM, so hit-test/marquee must not see
1285
- * them. Resolved per event — engagement follows the active tool live.
1286
- */
1287
- const inertIdsAt = (pon) => {
1288
- if (!behaviors.length) return void 0;
1289
- const m = model();
1290
- let out;
1291
- for (const id of m.order) {
1292
- const a = m.byId[id];
1293
- if (!a || a.pon !== pon) continue;
1294
- if (behaviors.some((b) => b.matches({
1295
- subtype: a.subtype,
1296
- ref: a.ref
1297
- }) && b.engaged())) (out ?? (out = /* @__PURE__ */ new Set())).add(id);
1298
- }
1299
- return out;
1300
- };
1301
- /** The CSS-px chrome settings converted to CONTENT units by the page's view
1302
- * scale (px per content unit) — screen-constant grab zones + stalk at every
1303
- * zoom. No scale → the values are read as content units (headless callers).
1304
- * `boost` widens the GRAB tolerances only (never the drawn chrome or the
1305
- * knob's position) — the touch path passes {@link TOUCH_GRAB_BOOST} so
1306
- * handles present finger-sized targets. */
1307
- const chromeGeomAt = (scale, boost = 1) => {
1308
- const cs = chromeSettings();
1309
- const s = scale || 1;
1310
- return {
1311
- handleTol: cs.handles.hitSize / 2 / s * boost,
1312
- knobTol: cs.knob.hitSize / 2 / s * boost,
1313
- knobOffset: cs.knob.offset / s
1314
- };
1315
- };
1316
- /** Finger-sized grab zones: 2× the mouse tolerances lands a ~24px handle hit
1317
- * box in Apple's ~44pt-target territory without moving any visuals. */
1318
- const TOUCH_GRAB_BOOST = 2;
1319
- const grabBoost = (touch) => touch ? TOUCH_GRAB_BOOST : 1;
1320
- const itemsCache = /* @__PURE__ */ new Map();
1321
- const chromeCache = /* @__PURE__ */ new Map();
1322
- const memoItems = (pon, view) => {
1323
- const m = model();
1324
- const g = ctx.getState().toolGhost;
1325
- const c = itemsCache.get(pon);
1326
- if (c && c.model === m && c.ghost === g && c.zoom === view?.zoom && c.rotation === view?.rotation) return c.v;
1327
- const v = pageItems(m, pon, view);
1328
- if (g && g.pon === pon && g.kind === "vector") {
1329
- const tool = registry.get(g.toolId);
1330
- const style = styleFromProps(defaultsFor(m, tool?.preset ?? g.toolId));
1331
- v.push({
1332
- id: "tool-ghost",
1333
- ref: null,
1334
- subtype: tool?.subtype ?? "square",
1335
- geom: g.geom,
1336
- box: g.box,
1337
- style,
1338
- source: "ghost",
1339
- selected: false
1340
- });
1341
- }
1342
- itemsCache.set(pon, {
1343
- model: m,
1344
- ghost: g,
1345
- zoom: view?.zoom,
1346
- rotation: view?.rotation,
1347
- v
1348
- });
1349
- return v;
1350
- };
1351
- const memoChrome = (pon, scale, rotation, zoom) => {
1352
- const m = model();
1353
- const cs = chromeSettings();
1354
- const c = chromeCache.get(pon);
1355
- if (c && c.model === m && c.cs === cs && c.scale === scale && c.rotation === rotation && c.zoom === zoom) return c.v;
1356
- let v = chrome(m, pon, pageBoxOf(pon), chromeGeomAt(scale).knobOffset, viewEnv(zoom, rotation));
1357
- if (!cs.guides.enabled) v = v.filter((n) => n.kind !== "rotate-guides");
1358
- chromeCache.set(pon, {
1359
- model: m,
1360
- cs,
1361
- scale,
1362
- rotation,
1363
- zoom,
1364
- v
1365
- });
1366
- return v;
1367
- };
1368
- let anchorCache = null;
1369
- const memoAnchor = (scale, rotation, zoom) => {
1370
- const m = model();
1371
- const cs = chromeSettings();
1372
- if (anchorCache && anchorCache.model === m && anchorCache.cs === cs && anchorCache.scale === scale && anchorCache.rotation === rotation && anchorCache.zoom === zoom) return anchorCache.v;
1373
- const v = selectionAnchor(m, pageBoxOf, () => chromeGeomAt(scale).knobOffset, () => viewEnv(zoom, rotation));
1374
- anchorCache = {
1375
- model: m,
1376
- cs,
1377
- scale,
1378
- rotation,
1379
- zoom,
1380
- v
1381
- };
1382
- return v;
1383
- };
1384
- let draftAnchorCache = null;
1385
- const memoDraftAnchor = () => {
1386
- const m = model();
1387
- if (draftAnchorCache && draftAnchorCache.model === m) return draftAnchorCache.v;
1388
- const v = creationDraftAnchor(m);
1389
- draftAnchorCache = {
1390
- model: m,
1391
- v
1392
- };
1393
- return v;
1394
- };
1395
- /** The editor's text RANGE inside the annotation being edited, or null
1396
- * (no editor selection, a bare caret, or a selection left behind by a
1397
- * previous edit). The one condition that routes the range keys to runs. */
1398
- const activeTextRange = (m) => {
1399
- const ts = ctx.getState().textSelection;
1400
- return ts && m.editing === ts.id && ts.end > ts.start && m.byId[ts.id] ? ts : null;
1401
- };
1402
- let selPropsCache = null;
1403
- const memoSelectionProps = () => {
1404
- const m = model();
1405
- const range = activeTextRange(m);
1406
- if (selPropsCache && selPropsCache.model === m && selPropsCache.range === range) return selPropsCache.v;
1407
- const members = m.selected.map((id) => m.byId[id]).filter((a) => !!a);
1408
- const specs = sharedProps(members.map((a) => a.subtype));
1409
- const values = {};
1410
- const mixed = [];
1411
- const valueOf = (a, key) => key === "link" && a.subtype !== "link" ? linkOf(m, a.id) : readProp(a, key);
1412
- for (const spec of specs) {
1413
- const first = valueOf(members[0], spec.key);
1414
- values[spec.key] = first;
1415
- const firstJson = JSON.stringify(first);
1416
- if (members.some((a) => JSON.stringify(valueOf(a, spec.key)) !== firstJson)) mixed.push(spec.key);
1417
- }
1418
- if (range && members.length === 1 && members[0].id === range.id) {
1419
- const rp = rangeProps(richDocOf(members[0], fonts), range, fonts);
1420
- for (const spec of specs) {
1421
- if (!RANGE_KEYS.includes(spec.key)) continue;
1422
- values[spec.key] = rp.values[spec.key];
1423
- const i = mixed.indexOf(spec.key);
1424
- if (i >= 0) mixed.splice(i, 1);
1425
- if (rp.mixed.includes(spec.key)) mixed.push(spec.key);
1426
- }
1427
- }
1428
- const v = {
1429
- specs,
1430
- values,
1431
- mixed
1432
- };
1433
- selPropsCache = {
1434
- model: m,
1435
- range,
1436
- v
1437
- };
1438
- return v;
1439
- };
1440
- const linkItemsCache = /* @__PURE__ */ new Map();
1441
- const memoLinkItems = (pon) => {
1442
- const m = model();
1443
- const c = linkItemsCache.get(pon);
1444
- if (c && c.model === m) return c.v;
1445
- const v = [];
1446
- for (const id of m.order) {
1447
- const a = m.byId[id];
1448
- if (!a || a.pon !== pon || a.subtype !== "link") continue;
1449
- if (!viewable(a.flags, false)) continue;
1450
- const target = a.link ?? (a.data?.subtype === "link" ? a.data.target ?? null : null);
1451
- if (target == null || a.geom.t !== "rect") continue;
1452
- const activate = a.data?.actions?.activate;
1453
- const ref = a.ref ?? a.data?.ref ?? void 0;
1454
- const hoverEnter = Boolean(a.data?.actions?.cursorEnter?.root);
1455
- const hoverExit = Boolean(a.data?.actions?.cursorExit?.root);
1456
- v.push({
1457
- id,
1458
- rect: a.geom.rect,
1459
- target,
1460
- attached: a.group !== void 0,
1461
- ...activate ? { activate } : {},
1462
- ...ref ? { ref } : {},
1463
- ...hoverEnter || hoverExit ? { hoverEvents: {
1464
- enter: hoverEnter,
1465
- exit: hoverExit
1466
- } } : {}
1467
- });
1468
- }
1469
- linkItemsCache.set(pon, {
1470
- model: m,
1471
- v
1472
- });
1473
- return v;
1474
- };
1475
- const textsCache = /* @__PURE__ */ new Map();
1476
- const memoTexts = (pon, view) => {
1477
- const m = model();
1478
- const c = textsCache.get(pon);
1479
- if (c && c.model === m && c.zoom === view?.zoom && c.rotation === view?.rotation) return c.v;
1480
- const v = buildTextItems(m, pon, view);
1481
- textsCache.set(pon, {
1482
- model: m,
1483
- zoom: view?.zoom,
1484
- rotation: view?.rotation,
1485
- v
1486
- });
1487
- return v;
1488
- };
1489
- let selFlagsCache = null;
1490
- const memoSelectionFlags = () => {
1491
- const m = model();
1492
- if (selFlagsCache && selFlagsCache.model === m) return selFlagsCache.v;
1493
- const members = m.selected.map((id) => m.byId[id]).filter((a) => !!a);
1494
- let v = null;
1495
- if (members.length) {
1496
- v = {};
1497
- for (const key of FLAG_KEYS) {
1498
- const first = members[0].flags[key];
1499
- v[key] = members.every((a) => a.flags[key] === first) ? first : null;
1500
- }
1501
- }
1502
- selFlagsCache = {
1503
- model: m,
1504
- v
1505
- };
1506
- return v;
1507
- };
1508
- const cropOf = (pon) => ctx.document()?.pages.find((p) => p.pageObjectNumber === pon)?.boxes.crop ?? null;
1509
- /** The page's box in content space (origin at the crop top-left) — the box
1510
- * pointer gestures clamp to, so annotations stay page-bound. */
1511
- const pageBoxOf = (pon) => {
1512
- const crop = cropOf(pon);
1513
- return crop ? {
1514
- x: 0,
1515
- y: 0,
1516
- width: crop.right - crop.left,
1517
- height: crop.top - crop.bottom
1518
- } : void 0;
1519
- };
1520
- /**
1521
- * The armed stamp-tool payload: the bytes the next click places, plus the
1522
- * PDF-point placement size (derived from the sniffed intrinsic aspect).
1523
- * Transient tool state — deliberately NOT in the model: it is never
1524
- * rendered, never synced, and dies with the tool.
1525
- */
1526
- let armedStamp = null;
1527
- /** Fixed bytes as a provider: the same image at every size. */
1528
- const fixedPreview = (bytes, mimeType) => {
1529
- const preview = {
1530
- bytes,
1531
- ...mimeType ? { mimeType } : {}
1532
- };
1533
- return async () => preview;
1534
- };
1535
- /**
1536
- * The desired stamp size (PDF points) from sniffed bytes: the image's
1537
- * INTRINSIC pixel dimensions taken 1:1 as points (the v2 rule — keep the
1538
- * artwork's own size, then clamp to the page at placement). A `targetWidth`
1539
- * override scales to that width, aspect preserved. Vector (PDF) stamps carry
1540
- * no sniffable dimensions — a caller-supplied `intrinsic` override (a stamp
1541
- * library knows its page size) keeps the true aspect; without one they fall
1542
- * back to a square target.
1543
- */
1544
- const desiredStampSize = (meta, targetWidth, intrinsicOverride) => {
1545
- const intrinsic = intrinsicOverride && intrinsicOverride.width > 0 && intrinsicOverride.height > 0 ? intrinsicOverride : "width" in meta && meta.width > 0 ? {
1546
- width: meta.width,
1547
- height: meta.height
1548
- } : {
1549
- width: targetWidth ?? 150,
1550
- height: targetWidth ?? 150
1551
- };
1552
- if (targetWidth === void 0) return intrinsic;
1553
- return {
1554
- width: targetWidth,
1555
- height: targetWidth * (intrinsic.height / intrinsic.width)
1556
- };
1557
- };
1558
- const armStamp = async (input) => {
1559
- const resolved = await resolveBinarySource(input.source);
1560
- const meta = sniffBinaryMetadata(resolved.bytes);
1561
- if (!meta) throw new Error("[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes");
1562
- let preview = null;
1563
- if (typeof input.preview === "function") preview = input.preview;
1564
- else if (input.preview) {
1565
- const p = await resolveBinarySource(input.preview);
1566
- preview = fixedPreview(new Uint8Array(p.bytes), p.mimeType);
1567
- } else if (meta.mimeType !== "application/pdf") preview = fixedPreview(new Uint8Array(resolved.bytes), meta.mimeType);
1568
- armedStamp = {
1569
- source: input.source,
1570
- ...desiredStampSize(meta, input.targetWidth, input.intrinsicSize),
1571
- preview,
1572
- previewCache: /* @__PURE__ */ new Map(),
1573
- ...input.name !== void 0 ? { name: input.name } : {},
1574
- ...input.subject !== void 0 ? { subject: input.subject } : {}
1575
- };
1576
- ctx.dispatch({ type: "STAMP_ARM_CHANGED" });
1577
- ctx.tryGet(InteractionToken)?.activateTool(ARMED_STAMP_TOOL_ID);
1578
- };
1579
- const disarmStamp = () => {
1580
- if (!armedStamp) return;
1581
- armedStamp = null;
1582
- ctx.dispatch({ type: "STAMP_ARM_CHANGED" });
1583
- };
1584
- /** Move the hover FOOTPRINT ghost to a content point. The box/geometry is
1585
- * computed by the SAME rules the click's placement uses ({@link createStampAt}
1586
- * fit + clamp for an armed stamp; the click-create anchor + page clamp for a
1587
- * draw tool), so the ghost is the placement, not an approximation of it. */
1588
- const ghostHoverAt = (toolId, pon, point, displayRotation) => {
1589
- const tool = registry.get(toolId);
1590
- const crop = cropOf(pon);
1591
- if (!tool || tool.ghost === false || !crop) {
1592
- clearGhost();
1593
- return;
1594
- }
1595
- const page = {
1596
- width: crop.right - crop.left,
1597
- height: crop.top - crop.bottom
1598
- };
1599
- if (armedStamp) {
1600
- const rot = uprightRotFor(displayRotation);
1601
- const box = fitStampBox(point, {
1602
- width: armedStamp.width,
1603
- height: armedStamp.height
1604
- }, page, rot);
1605
- ctx.dispatch({
1606
- type: "SET_TOOL_GHOST",
1607
- ghost: {
1608
- pon,
1609
- box,
1610
- rot,
1611
- kind: "image"
1612
- }
1613
- });
1614
- return;
1615
- }
1616
- if (isIconPlaceKind(tool.subtype)) {
1617
- const box = fitStampBox(point, ICON_PLACE_SIZE, page, uprightRotFor(displayRotation));
1618
- dispatchVectorGhost(pon, toolId, {
1619
- t: "rect",
1620
- rect: box,
1621
- ellipse: false
1622
- });
1623
- return;
1624
- }
1625
- if (!tool.clickCreate) {
1626
- clearGhost();
1627
- return;
1628
- }
1629
- const placement = resolveClickPlacement(point, tool.clickCreate, {
1630
- pageBox: {
1631
- x: 0,
1632
- y: 0,
1633
- width: page.width,
1634
- height: page.height
1635
- },
1636
- upright: tool.upright,
1637
- displayRotation
1638
- });
1639
- const geom = clickCreateGeom(tool.subtype, placement, defaultsFor(model(), tool.preset));
1640
- if (!geom) {
1641
- clearGhost();
1642
- return;
1643
- }
1644
- dispatchVectorGhost(pon, toolId, geom);
1645
- };
1646
- /** Paint a vector ghost item for a tool's would-be geometry — shared by the
1647
- * hover footprint and the externally-driven placement preview. */
1648
- const dispatchVectorGhost = (pon, toolId, geom) => {
1649
- const tool = registry.get(toolId);
1650
- const style = styleFromProps(defaultsFor(model(), tool?.preset ?? toolId));
1651
- ctx.dispatch({
1652
- type: "SET_TOOL_GHOST",
1653
- ghost: {
1654
- pon,
1655
- box: geomVisualBounds(geom, style.strokeWidth, style.border),
1656
- rot: 0,
1657
- kind: "vector",
1658
- toolId,
1659
- geom
1660
- }
1661
- });
1662
- };
1663
- /**
1664
- * Drive the placement preview during an EXTERNALLY-owned creation gesture
1665
- * (the form plugin's drag-to-place): paint the box the commit would use,
1666
- * styled from the TOOL's defaults, through the same ghost pipeline as every
1667
- * footprint. The box is clamped to the page (a drag may overshoot).
1668
- */
1669
- const setPlacementPreview = (toolId, pon, box) => {
1670
- const crop = cropOf(pon);
1671
- if (!crop) return;
1672
- const page = {
1673
- width: crop.right - crop.left,
1674
- height: crop.top - crop.bottom
1675
- };
1676
- const x = Math.max(0, Math.min(box.x, page.width));
1677
- const y = Math.max(0, Math.min(box.y, page.height));
1678
- const rect = {
1679
- x,
1680
- y,
1681
- width: Math.max(0, Math.min(box.x + box.width, page.width) - x),
1682
- height: Math.max(0, Math.min(box.y + box.height, page.height) - y)
1683
- };
1684
- dispatchVectorGhost(pon, toolId, {
1685
- t: "rect",
1686
- rect,
1687
- ellipse: false
1688
- });
1689
- };
1690
- const clearGhost = () => {
1691
- if (ctx.getState().toolGhost) ctx.dispatch({
1692
- type: "SET_TOOL_GHOST",
1693
- ghost: null
1694
- });
1695
- };
1696
- /** Place a stamp of `desired` PDF-point size centred on a content point — the
1697
- * one engine-write both the armed and click-to-place paths funnel through.
1698
- * The size is fit to the page and clamped fully onto it (v2 rubber-stamp
1699
- * rule: never larger than the page, aspect preserved), and never spills off
1700
- * the edge. `rotCW` (the tool's upright counter-rotation, CW content degrees)
1701
- * emits the repository's box rotation fields — the engine bakes the tilted
1702
- * /AP exactly as an interactively rotated stamp round-trips, and the fit uses
1703
- * the ROTATED footprint. Returns false when the page/document isn't ready. */
1704
- const createStampAt = (pon, point, source, desired, rotCW = 0, identity = {}) => {
1705
- const doc = ctx.doc;
1706
- const crop = cropOf(pon);
1707
- if (!doc || !crop) return null;
1708
- const box = fitStampBox(point, desired, {
1709
- width: crop.right - crop.left,
1710
- height: crop.top - crop.bottom
1711
- }, rotCW);
1712
- return doc.page(pon).annotations.create({
1713
- subtype: "stamp",
1714
- ...boxGeomFields(box, rotCW, crop),
1715
- source,
1716
- fit: "contain",
1717
- ...identity.name !== void 0 ? { name: identity.name } : {},
1718
- ...identity.subject !== void 0 ? { subject: identity.subject } : {}
1719
- }).then((res) => {
1720
- syncDTO(res.created, "baked");
1721
- apply({
1722
- t: "select",
1723
- ids: [refKey(res.created.ref)]
1724
- });
1725
- return res.created.ref;
1726
- });
1727
- };
1728
- /** The click path's fire-and-forget wrapper: a rejected placement is logged,
1729
- * never thrown into the gesture. */
1730
- const createStampAtSync = (pon, point, source, desired, rotCW = 0, identity = {}) => {
1731
- const placed = createStampAt(pon, point, source, desired, rotCW, identity);
1732
- if (!placed) return false;
1733
- placed.catch((err) => console.error("[annotation] stamp placement failed:", err));
1734
- return true;
1735
- };
1736
- /** Programmatic placement — the same law as a click, awaited. */
1737
- const placeStamp = async (input, placement) => {
1738
- const meta = sniffBinaryMetadata((await resolveBinarySource(input.source)).bytes);
1739
- if (!meta) throw new Error("[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes");
1740
- const placed = createStampAt(placement.pageObjectNumber, placement.at, input.source, desiredStampSize(meta, placement.targetWidth ?? input.targetWidth, input.intrinsicSize), placement.rotation ?? 0, {
1741
- ...input.name !== void 0 ? { name: input.name } : {},
1742
- ...input.subject !== void 0 ? { subject: input.subject } : {}
1743
- });
1744
- if (!placed) throw new Error(`[annotation] cannot place a stamp on page ${placement.pageObjectNumber}: document or page not ready`);
1745
- return placed;
1746
- };
1747
- /** The active tool's upright counter-rotation for a click at `displayRotation`
1748
- * (0 when the tool doesn't ask for upright, or the display isn't rotated). */
1749
- const uprightRotFor = (displayRotation) => {
1750
- if (!displayRotation) return 0;
1751
- const ix = ctx.tryGet(InteractionToken);
1752
- return (ix ? registry.get(ix.activeToolId()) : void 0)?.upright ? uprightRotation(displayRotation) : 0;
1753
- };
1754
- const placeArmedStamp = (pon, point, displayRotation) => {
1755
- const armed = armedStamp;
1756
- if (!armed) return false;
1757
- return createStampAtSync(pon, point, armed.source, {
1758
- width: armed.width,
1759
- height: armed.height
1760
- }, uprightRotFor(displayRotation), {
1761
- ...armed.name !== void 0 ? { name: armed.name } : {},
1762
- ...armed.subject !== void 0 ? { subject: armed.subject } : {}
1763
- });
1764
- };
1765
- /** Sniff a stamp source (rejecting non-image bytes) and place it at its
1766
- * intrinsic size (fit to the page in {@link createStampAt}); `targetWidth`
1767
- * overrides the width, aspect preserved. */
1768
- const placeStampSource = async (pon, point, source, rotCW, targetWidth) => {
1769
- const meta = sniffBinaryMetadata((await resolveBinarySource(source)).bytes);
1770
- if (!meta) {
1771
- console.error("[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes");
1772
- return;
1773
- }
1774
- createStampAtSync(pon, point, source, desiredStampSize(meta, targetWidth), rotCW);
1775
- };
1776
- /**
1777
- * Click-to-place: resolve the ACTIVE tool's source spec. Fixed `bytes` place
1778
- * immediately; a `'prompt'` source asks the installed provider, then places on
1779
- * resolve — dropping the placement if it was cancelled, or if the tool or
1780
- * document changed while the picker was open (the intent expired).
1781
- */
1782
- const requestStampAt = (pon, point, displayRotation) => {
1783
- const ix = ctx.tryGet(InteractionToken);
1784
- const tool = ix ? registry.get(ix.activeToolId()) : void 0;
1785
- const spec = tool?.source;
1786
- if (!spec) return false;
1787
- const rotCW = tool.upright && displayRotation ? uprightRotation(displayRotation) : 0;
1788
- if (spec.kind === "bytes") {
1789
- placeStampSource(pon, point, spec.source, rotCW);
1790
- return true;
1791
- }
1792
- return promptFileAt(tool, pon, point, (picked) => void placeStampSource(pon, point, picked.data, rotCW));
1793
- };
1794
- /**
1795
- * Run the installed file-picker port for a click-then-pick tool, with the
1796
- * shared expiry rule: the placement is dropped if the picker cancels, or if
1797
- * the document or the active tool changed while it was open (the intent
1798
- * expired with the click). No provider installed → decline the click (let a
1799
- * lower-priority handler act) rather than swallow it.
1800
- */
1801
- const promptFileAt = (tool, pon, point, place) => {
1802
- const provider = filePickerProvider;
1803
- if (!provider) return false;
1804
- const spec = tool.source;
1805
- const req = {
1806
- toolId: tool.id,
1807
- subtype: tool.subtype,
1808
- accept: spec?.kind === "prompt" ? spec.accept : void 0,
1809
- pon,
1810
- point
1811
- };
1812
- const docAtClick = ctx.doc;
1813
- provider(req).then((picked) => {
1814
- if (!picked) return;
1815
- if (ctx.doc !== docAtClick) return;
1816
- if (ctx.tryGet(InteractionToken)?.activeToolId() !== tool.id) return;
1817
- place(picked);
1818
- }, (err) => console.error("[annotation] file-picker provider failed:", err));
1819
- return true;
1820
- };
1821
- /** Place a fixed-size ICON annotation (note / file attachment) centred on a
1822
- * content point — the icon-kind sibling of {@link createStampAt}. The
1823
- * engine bakes the 20×20 /AP from /C + /Name; a successful create selects
1824
- * the new annotation (the anchor for its menu / future comment popup). */
1825
- const createIconAt = (tool, pon, point, rotCW, file) => {
1826
- const doc = ctx.doc;
1827
- const crop = cropOf(pon);
1828
- if (!doc || !crop || !isIconPlaceKind(tool.subtype)) return false;
1829
- const box = fitStampBox(point, ICON_PLACE_SIZE, {
1830
- width: crop.right - crop.left,
1831
- height: crop.top - crop.bottom
1832
- }, rotCW);
1833
- const draft = iconPlacementDraft(tool.subtype, boxGeomFields(box, rotCW, crop), defaultsFor(model(), tool.preset), tool.flags, file);
1834
- doc.page(pon).annotations.create(draft).then((res) => {
1835
- syncDTO(res.created, "baked");
1836
- apply({
1837
- t: "select",
1838
- ids: [refKey(res.created.ref)]
1839
- });
1840
- }, (err) => console.error("[annotation] icon placement failed:", err));
1841
- return true;
1842
- };
1843
- /**
1844
- * The ONE click-to-place entry the place handler forwards every down to —
1845
- * armed payload first, then the active tool's kind decides:
1846
- * - stamp → the source spec (fixed bytes, or the file-picker port)
1847
- * - note (text) → place immediately (no payload)
1848
- * - attachment → spot first, file second: the file-picker port opens
1849
- * inside the click gesture, and placement lands when it
1850
- * resolves (dropped on cancel, or if the tool/document
1851
- * changed while the picker was open).
1852
- * Returns whether the click was consumed.
1853
- */
1854
- const placeAt = (pon, point, displayRotation) => {
1855
- if (placeArmedStamp(pon, point, displayRotation)) return true;
1856
- const ix = ctx.tryGet(InteractionToken);
1857
- const tool = ix ? registry.get(ix.activeToolId()) : void 0;
1858
- if (!tool) return false;
1859
- if (tool.subtype === "stamp") return requestStampAt(pon, point, displayRotation);
1860
- if (!isIconPlaceKind(tool.subtype)) return false;
1861
- const rotCW = tool.upright && displayRotation ? uprightRotation(displayRotation) : 0;
1862
- if (tool.subtype === "text") return createIconAt(tool, pon, point, rotCW, null);
1863
- return promptFileAt(tool, pon, point, (picked) => createIconAt(tool, pon, point, rotCW, picked));
1864
- };
1865
- /** The page a ref lives on: from the loaded model first (covers obj/nm refs
1866
- * that don't carry a pon), else the ref itself (index refs do). */
1867
- const ponForRef = (ref) => model().byId[refKey(ref)]?.pon ?? (ref.kind === "index" ? ref.pageObjectNumber : null);
1868
- /**
1869
- * Re-sync one annotation into the model from the authoritative engine DTO,
1870
- * with the render `source` the caller decides: `'vector'` when WE authored or
1871
- * changed the appearance (create / restyle / resize), `'baked'` when the AP is
1872
- * still authoritative (a move, which preserves it, or a remote edit).
1873
- * `bumpAp` marks the upsert as confirming an engine /AP re-bake with new
1874
- * content, advancing the annotation's `apVersion`. Only baked annotations
1875
- * contribute to the page's appearance epoch and trigger a raster fetch.
1876
- */
1877
- const syncDTO = (dto, source, bumpAp = false) => {
1878
- const crop = cropOf(dto.pageObjectNumber);
1879
- if (crop) apply({
1880
- t: "upsert",
1881
- annots: [ingestDTO(dto, crop, source)],
1882
- bumpAp
1883
- });
1884
- };
1885
- /** The one engine-update path, shared by `update` and `updateSelection`. A
1886
- * programmatic patch changes the appearance → render live (vector). */
1887
- const updateOne = async (ref, patch) => {
1888
- const doc = ctx.doc;
1889
- if (!doc) throw new Error("[annotation] no document bound");
1890
- const pon = ponForRef(ref);
1891
- if (pon == null) throw new Error("[annotation] cannot resolve page for ref");
1892
- const res = await doc.page(pon).annotations.update(ref, patch);
1893
- syncDTO(res.updated, "vector", res.appearance?.changed);
1894
- };
1895
- const commitText = (ref) => {
1896
- const key = refKey(ref);
1897
- clearTimeout(textTimers.get(key));
1898
- textTimers.delete(key);
1899
- const a = model().byId[key];
1900
- const pon = ponForRef(ref);
1901
- if (!a || pon == null) return;
1902
- const patch = textCommitPatch(a, richDocOf(a, fonts).paragraphs, fonts);
1903
- ctx.doc?.page(pon).annotations.update(ref, {
1904
- subtype: "free-text",
1905
- ...patch
1906
- }).then(() => {}, () => {});
1907
- };
1908
- const scheduleTextCommit = (ref) => {
1909
- const key = refKey(ref);
1910
- clearTimeout(textTimers.get(key));
1911
- textTimers.set(key, setTimeout(() => commitText(ref), TEXT_COMMIT_DEBOUNCE_MS));
1912
- };
1913
- const flushTextCommits = () => {
1914
- for (const key of [...textTimers.keys()]) {
1915
- const a = model().byId[key];
1916
- if (a?.ref) commitText(a.ref);
1917
- else {
1918
- clearTimeout(textTimers.get(key));
1919
- textTimers.delete(key);
1920
- }
1921
- }
1922
- };
1923
- function apply(msg) {
1924
- const [next, effects] = update(model(), msg);
1925
- ctx.dispatch({
1926
- type: "SET_MODEL",
1927
- model: next
1928
- });
1929
- for (const fx of effects) perform(fx, next);
1930
- }
1931
- let hydrationStarted = false;
1932
- let hydrationWindow = false;
1933
- let hydrationRunning = null;
1934
- let hydrateAgain = false;
1935
- const pendingRemote = [];
1936
- const setHydration = (hydration) => ctx.dispatch({
1937
- type: "SET_HYDRATION",
1938
- hydration
1939
- });
1940
- const ingestSnapshot = (snap, bumpAp) => {
1941
- const annots = [];
1942
- for (const page of snap.pages) {
1943
- const crop = cropOf(page.pageState.pageObjectNumber);
1944
- if (!crop) continue;
1945
- annots.push(...page.annotations.map((d) => ingestDTO(d, crop)));
1946
- }
1947
- apply({
1948
- t: "hydrated",
1949
- annots,
1950
- bumpAp
1951
- });
1952
- };
1953
- const canRead = () => ctx.doc?.security.allows("doc.annotate.read") ?? true;
1954
- const runHydration = (bumpAp) => {
1955
- const doc = ctx.doc;
1956
- if (!doc) return Promise.resolve();
1957
- if (!canRead()) {
1958
- setHydration({ status: "forbidden" });
1959
- return Promise.resolve();
1960
- }
1961
- hydrationWindow = true;
1962
- setHydration({ status: "loading" });
1963
- const run = doc.annotations.listRawAll().then((snap) => {
1964
- ingestSnapshot(snap, bumpAp);
1965
- const head = snap.auditHead;
1966
- const queued = pendingRemote.splice(0);
1967
- hydrationWindow = false;
1968
- for (const event of queued) {
1969
- const serverId = "origin" in event ? event.origin.serverId : null;
1970
- if (head !== void 0 && serverId !== null && serverId <= head) continue;
1971
- applyRemoteAnnotationEvent(event);
1972
- }
1973
- setHydration({ status: "complete" });
1974
- }).catch((error) => {
1975
- hydrationWindow = false;
1976
- for (const event of pendingRemote.splice(0)) applyRemoteAnnotationEvent(event);
1977
- setHydration({
1978
- status: "error",
1979
- error
1980
- });
1981
- }).finally(() => {
1982
- hydrationRunning = null;
1983
- if (hydrateAgain) {
1984
- hydrateAgain = false;
1985
- hydrationRunning = runHydration(true);
1986
- }
1987
- });
1988
- hydrationRunning = run;
1989
- return run;
1990
- };
1991
- const rehydrate = () => {
1992
- if (hydrationRunning) {
1993
- hydrateAgain = true;
1994
- return hydrationRunning;
1995
- }
1996
- hydrationRunning = runHydration(true);
1997
- return hydrationRunning;
1998
- };
1999
- /**
2000
- * Fold one REMOTE annotation event into the model (transplanted from the
2001
- * effects layer so hydration can queue + replay through the same code
2002
- * path). `bump` re-fetches rasters, `keep` doesn't — driven by the
2003
- * ENGINE'S appearance echo riding the event, so a remote MOVE costs
2004
- * peers zero re-renders while a remote restyle refreshes exactly once.
2005
- */
2006
- const upsertRemote = (dtos, bumpAp) => {
2007
- const bump = [];
2008
- const keep = [];
2009
- for (const dto of dtos) {
2010
- const crop = cropOf(dto.pageObjectNumber);
2011
- if (!crop) continue;
2012
- const a = ingestDTO(dto, crop, "baked");
2013
- (bumpAp && !isSubstrateOnly(a) ? bump : keep).push(a);
2014
- }
2015
- if (bump.length) apply({
2016
- t: "upsert",
2017
- annots: bump,
2018
- bumpAp: true
2019
- });
2020
- if (keep.length) apply({
2021
- t: "upsert",
2022
- annots: keep
2023
- });
2024
- };
2025
- const applyRemoteAnnotationEvent = (event) => {
2026
- switch (event.type) {
2027
- case "annotation.created":
2028
- upsertRemote([event.created], true);
2029
- break;
2030
- case "annotation.updated":
2031
- upsertRemote([event.updated], event.appearance.changed);
2032
- break;
2033
- case "annotation.moved":
2034
- upsertRemote(event.moved, false);
2035
- break;
2036
- case "annotation.deleted":
2037
- if (event.deleted) {
2038
- const key = encodeStableIdKey(event.deleted);
2039
- if (model().byId[key]) apply({
2040
- t: "remove",
2041
- ids: [key]
2042
- });
2043
- }
2044
- break;
2045
- default: break;
2046
- }
2047
- };
2048
- const currentUserId = () => ctx.doc?.security.identity?.user_id;
2049
- let threadsMemo = null;
2050
- /** Value-stable layout key: display order is all the sort consumes, so
2051
- * the memo must survive hosts that rebuild the pages array per read. */
2052
- const layoutSignature = () => (ctx.document()?.pages ?? []).map((p) => p.pageObjectNumber).join(",");
2053
- const computeThreadsIndex = () => {
2054
- const m = model();
2055
- const dtos = [];
2056
- for (const id of m.order) {
2057
- const data = m.byId[id]?.data;
2058
- if (data) dtos.push(data);
2059
- }
2060
- const threads = buildCommentThreads(dtos, { currentUserId: currentUserId() });
2061
- const pages = ctx.document()?.pages ?? [];
2062
- const displayIndex = new Map(pages.map((p, i) => [p.pageObjectNumber, i]));
2063
- threads.sort((a, b) => {
2064
- const pa = displayIndex.get(a.pageObjectNumber) ?? Number.MAX_SAFE_INTEGER;
2065
- const pb = displayIndex.get(b.pageObjectNumber) ?? Number.MAX_SAFE_INTEGER;
2066
- if (pa !== pb) return pa - pb;
2067
- if (a.root.rect.top !== b.root.rect.top) return b.root.rect.top - a.root.rect.top;
2068
- const ca = a.root.created ?? "";
2069
- const cb = b.root.created ?? "";
2070
- return ca < cb ? -1 : ca > cb ? 1 : 0;
2071
- });
2072
- const byMember = /* @__PURE__ */ new Map();
2073
- for (const t of threads) {
2074
- byMember.set(refKey(t.root.ref), t);
2075
- for (const r of t.replies) byMember.set(refKey(r.ref), t);
2076
- for (const g of t.groupedParts) byMember.set(refKey(g.ref), t);
2077
- for (const s of t.review.statusRefs) byMember.set(refKey(s), t);
2078
- }
2079
- return {
2080
- threads,
2081
- byMember
2082
- };
2083
- };
2084
- const threadsIndex = () => {
2085
- const m = model();
2086
- const layout = layoutSignature();
2087
- const userId = currentUserId();
2088
- if (threadsMemo && threadsMemo.byId === m.byId && threadsMemo.order === m.order && threadsMemo.layout === layout && threadsMemo.userId === userId) return threadsMemo;
2089
- threadsMemo = {
2090
- ...computeThreadsIndex(),
2091
- byId: m.byId,
2092
- order: m.order,
2093
- layout,
2094
- userId
2095
- };
2096
- return threadsMemo;
2097
- };
2098
- const threadOf = (ref) => {
2099
- const t = threadsIndex().byMember.get(refKey(ref));
2100
- if (!t) throw new Error("[annotation] no comment thread contains this ref");
2101
- return t;
2102
- };
2103
- /** Engine create + model sync for conversation-plane annotations (they
2104
- * never paint, so the render source is immaterial — 'baked' avoids any
2105
- * vector-scene work). */
2106
- const createConversationAnnot = async (pon, draft) => {
2107
- const doc = ctx.doc;
2108
- if (!doc) throw new Error("[annotation] no document bound");
2109
- const res = await doc.page(pon).annotations.create(draft);
2110
- syncDTO(res.created, "baked");
2111
- return res.created;
2112
- };
2113
- const deleteOne = async (ref) => {
2114
- const doc = ctx.doc;
2115
- if (!doc) throw new Error("[annotation] no document bound");
2116
- await doc.page(ref.pageObjectNumber).annotations.delete(ref);
2117
- apply({
2118
- t: "remove",
2119
- ids: [refKey(ref)]
2120
- });
2121
- };
2122
- const REPLY_FLAGS = {
2123
- print: true,
2124
- noZoom: true,
2125
- noRotate: true
2126
- };
2127
- const STATUS_FLAGS = {
2128
- hidden: true,
2129
- noZoom: true,
2130
- noRotate: true
2131
- };
2132
- /** Coarse authority mirror (doc.annotate.modify) — B3 replaces this with
2133
- * the collab-scope mirrors; the FLAG split is already the real rule. */
2134
- const mutationTarget = (ref) => {
2135
- const d = model().byId[refKey(ref)]?.data;
2136
- return {
2137
- ...d?.userId !== void 0 ? { userId: d.userId } : {},
2138
- ...d?.groupId !== void 0 ? { groupId: d.groupId } : {}
2139
- };
2140
- };
2141
- const allowsCreate = () => ctx.doc?.security.allowsAnnotationCreate() ?? false;
2142
- /**
2143
- * Ingest an engine DTO with this session's per-record AUTHORITY projected
2144
- * onto it (permissions.md) — asked of the SAME mirrors the server enforces
2145
- * with, against the record's stamped owner. Fused into
2146
- * `annotTransformable`/`annotDeletable`, so hit-test, chrome, props and
2147
- * delete all agree with the engine by construction. No security context
2148
- * (bare local engines, tests) → unstamped → allowed; the engine enforces.
2149
- */
2150
- const ingestDTO = (dto, crop, source) => {
2151
- const a = fromDTO(dto, crop, source);
2152
- const sec = ctx.doc?.security;
2153
- if (!sec) return a;
2154
- const target = {
2155
- ...dto.userId !== void 0 ? { userId: dto.userId } : {},
2156
- ...dto.groupId !== void 0 ? { groupId: dto.groupId } : {}
2157
- };
2158
- return {
2159
- ...a,
2160
- authority: {
2161
- update: sec.allowsAnnotationMutation("update", target),
2162
- delete: sec.allowsAnnotationMutation("delete", target)
2163
- }
2164
- };
2165
- };
2166
- const allowsMutation = (action, ref) => ctx.doc?.security.allowsAnnotationMutation(action, mutationTarget(ref)) ?? false;
2167
- const deletableOne = (ref) => {
2168
- const a = model().byId[refKey(ref)];
2169
- return !!a && annotDeletable(a);
2170
- };
2171
- const threadMemberRefs = (t) => [
2172
- ...t.replies.map((r) => r.ref),
2173
- ...t.groupedParts.map((g) => g.ref),
2174
- ...t.review.statusRefs,
2175
- t.root.ref
2176
- ];
2177
- const comments = {
2178
- threads: () => threadsIndex().threads,
2179
- thread: (ref) => threadsIndex().byMember.get(refKey(ref)) ?? null,
2180
- hydration: () => ctx.getState().hydration,
2181
- rehydrate,
2182
- reply: async (ref, text) => {
2183
- const t = threadOf(ref);
2184
- return (await createConversationAnnot(t.pageObjectNumber, {
2185
- subtype: "text",
2186
- rect: t.root.rect,
2187
- icon: "comment",
2188
- contents: text,
2189
- inReplyTo: t.root.ref,
2190
- flags: { ...REPLY_FLAGS }
2191
- })).ref;
2192
- },
2193
- edit: async (ref, text) => {
2194
- const data = model().byId[refKey(ref)]?.data;
2195
- if (data && isDimension(data)) throw new Error("[annotation] measurement contents are derived");
2196
- const subtype = data?.subtype;
2197
- if (!subtype) throw new Error("[annotation] cannot edit an uncommitted annotation");
2198
- await updateOne(ref, {
2199
- subtype,
2200
- contents: text
2201
- });
2202
- },
2203
- setStatus: async (ref, state) => {
2204
- const t = threadOf(ref);
2205
- const userId = currentUserId();
2206
- const previous = userId ? t.review.byReviewer[userId] : void 0;
2207
- await createConversationAnnot(t.pageObjectNumber, {
2208
- subtype: "text",
2209
- rect: t.root.rect,
2210
- inReplyTo: previous?.ref ?? t.root.ref,
2211
- state,
2212
- stateModel: "review",
2213
- flags: { ...STATUS_FLAGS }
2214
- });
2215
- },
2216
- setMarked: async (ref, marked) => {
2217
- const t = threadOf(ref);
2218
- await createConversationAnnot(t.pageObjectNumber, {
2219
- subtype: "text",
2220
- rect: t.root.rect,
2221
- inReplyTo: t.root.ref,
2222
- state: marked ? "marked" : "unmarked",
2223
- stateModel: "marked",
2224
- flags: { ...STATUS_FLAGS }
2225
- });
2226
- },
2227
- remove: deleteOne,
2228
- removeThread: async (ref) => {
2229
- const t = threadOf(ref);
2230
- const members = threadMemberRefs(t);
2231
- const blocked = members.filter((r) => !deletableOne(r));
2232
- if (blocked.length > 0) return {
2233
- deleted: [],
2234
- failed: blocked.map((r) => ({
2235
- ref: r,
2236
- error: /* @__PURE__ */ new Error("delete not permitted")
2237
- }))
2238
- };
2239
- const deleted = [];
2240
- const failed = [];
2241
- for (const member of members) {
2242
- if (failed.length > 0) break;
2243
- try {
2244
- await deleteOne(member);
2245
- deleted.push(member);
2246
- } catch (error) {
2247
- failed.push({
2248
- ref: member,
2249
- error
2250
- });
2251
- }
2252
- }
2253
- return {
2254
- deleted,
2255
- failed
2256
- };
2257
- },
2258
- permissionsFor: (ref) => {
2259
- model().byId[refKey(ref)]?.flags;
2260
- const t = threadsIndex().byMember.get(refKey(ref)) ?? null;
2261
- return {
2262
- canReply: allowsCreate(),
2263
- canSetStatus: allowsCreate(),
2264
- canEditText: (() => {
2265
- const a = model().byId[refKey(ref)];
2266
- return !!a && !(a.data && isDimension(a.data)) && annotContentsEditable(a);
2267
- })(),
2268
- canDelete: deletableOne(ref),
2269
- canDeleteThread: t !== null && threadMemberRefs(t).every(deletableOne)
2270
- };
2271
- }
2272
- };
2273
- /**
2274
- * Per-parent serialization of attached-link reconciles: rapid edits chain
2275
- * instead of interleaving (two overlapping runs could double-create
2276
- * children). Each run reads the CURRENT model at execution time, so a
2277
- * chained run converges on the latest desired state.
2278
- */
2279
- const linkSyncChains = /* @__PURE__ */ new Map();
2280
- /**
2281
- * Queue a reconcile. `intent` is either an explicit target (a set/clear —
2282
- * captured by THIS run's closure, so chained sets stay latest-wins) or
2283
- * `'keep'` (a geometry follow: re-rect the children toward whatever target
2284
- * the substrate holds AT RUN TIME — so a remote retarget is never undone
2285
- * by a local move). Returns the chain, so `links.set()` can await commit.
2286
- */
2287
- const scheduleLinkSync = (id, intent) => {
2288
- const next = (linkSyncChains.get(id) ?? Promise.resolve()).then(() => reconcileLinkChildren(id, intent === "keep" ? linkOf(model(), id) : intent.target));
2289
- linkSyncChains.set(id, next);
2290
- next.finally(() => {
2291
- if (linkSyncChains.get(id) === next) linkSyncChains.delete(id);
2292
- });
2293
- return next;
2294
- };
2295
- /**
2296
- * THE one place attached link children are created, retargeted, re-rected,
2297
- * or deleted. Declarative: desired state = `desired` target + the parent's
2298
- * committed geometry (`linkChildRects`); CURRENT state is read straight
2299
- * from the substrate (`linkChildrenOf`) — no join-key ledger. Results land
2300
- * as ordinary substrate upserts/removes, so the `linkOf` lens converges
2301
- * immediately locally and via events everywhere else. Idempotent — foreign
2302
- * inconsistencies heal on the next local edit.
2303
- */
2304
- const reconcileLinkChildren = async (id, desired) => {
2305
- const doc = ctx.doc;
2306
- const a = model().byId[id];
2307
- if (!doc || !a || !a.ref || a.subtype === "link") return;
2308
- const crop = cropOf(a.pon);
2309
- if (!crop) return;
2310
- const page = doc.page(a.pon);
2311
- const target = writableTarget(desired);
2312
- const rects = desired == null ? [] : linkChildRects(a).map((r) => contentToPdfRect(r, crop));
2313
- const current = linkChildrenOf(model(), id);
2314
- try {
2315
- const paired = Math.min(current.length, rects.length);
2316
- for (let i = 0; i < paired; i++) {
2317
- const ref = current[i].ref;
2318
- if (!ref) continue;
2319
- const res = await page.annotations.update(ref, {
2320
- subtype: "link",
2321
- rect: rects[i],
2322
- ...target ? { target } : {}
2323
- });
2324
- apply({
2325
- t: "upsert",
2326
- annots: [ingestDTO(res.updated, crop, "baked")]
2327
- });
2328
- }
2329
- for (let i = current.length; i < rects.length; i++) {
2330
- const res = await page.annotations.create({
2331
- subtype: "link",
2332
- rect: rects[i],
2333
- target,
2334
- inReplyTo: a.ref,
2335
- replyType: "group"
2336
- });
2337
- apply({
2338
- t: "upsert",
2339
- annots: [ingestDTO(res.created, crop, "baked")]
2340
- });
2341
- }
2342
- for (let i = rects.length; i < current.length; i++) {
2343
- const child = current[i];
2344
- if (child.ref) await page.annotations.delete(child.ref);
2345
- apply({
2346
- t: "remove",
2347
- ids: [child.id]
2348
- });
2349
- }
2350
- } catch (err) {
2351
- console.error("[annotation] attached-link sync failed:", err);
2352
- }
2353
- };
2354
- /** A relationship-only engine patch (sets/clears `/IRT` + `/RT`) — geometry and
2355
- * style are left untouched, so grouping never re-bakes an appearance. */
2356
- const relationshipPatch = (subtype, rel) => ({
2357
- subtype,
2358
- ...rel
2359
- });
2360
- /** Write a relationship change to one committed annotation and re-sync it from
2361
- * the authoritative DTO (preserving its render source — relationships don't
2362
- * change the appearance). */
2363
- const writeRelationship = async (a, rel) => {
2364
- const doc = ctx.doc;
2365
- if (!doc || !a.ref || !a.data) return;
2366
- const res = await doc.page(a.pon).annotations.update(a.ref, relationshipPatch(a.data.subtype, rel));
2367
- syncDTO(res.updated, a.source);
2368
- };
2369
- /** Committed, data-backed annotations in the current selection. */
2370
- const selectedCommitted = () => {
2371
- const m = model();
2372
- return m.selected.map((id) => m.byId[id]).filter((a) => !!a && !!a.ref && !!a.data);
2373
- };
2374
- function perform(fx, m) {
2375
- const doc = ctx.doc;
2376
- if (!doc) return;
2377
- if (fx.fx === "captured") {
2378
- const crop = cropOf(fx.pon);
2379
- if (crop && fx.geom.t === "line") {
2380
- const pdf = (p) => ({
2381
- x: p.x + crop.left,
2382
- y: crop.top - p.y
2383
- });
2384
- for (const cb of captured) cb({
2385
- tool: fx.tool,
2386
- pon: fx.pon,
2387
- from: pdf(fx.geom.a),
2388
- to: pdf(fx.geom.b)
2389
- });
2390
- }
2391
- } else if (fx.fx === "createGroup") {
2392
- const ids = [fx.primary, ...fx.members];
2393
- const annots = ids.map((id) => m.byId[id]);
2394
- const primary = annots[0];
2395
- if (!primary || annots.some((a) => !a || a.pon !== primary.pon)) {
2396
- apply({
2397
- t: "remove",
2398
- ids
2399
- });
2400
- return;
2401
- }
2402
- const crop = cropOf(primary.pon);
2403
- const drafts = crop ? annots.map((a) => a ? toCreateDraft(a, crop) : null) : annots.map(() => null);
2404
- if (drafts.some((d) => !d)) {
2405
- apply({
2406
- t: "remove",
2407
- ids
2408
- });
2409
- return;
2410
- }
2411
- (async () => {
2412
- const committed = [];
2413
- try {
2414
- const page = doc.page(primary.pon);
2415
- const primaryResult = await page.annotations.create(drafts[0]);
2416
- committed.push({
2417
- tempId: fx.primary,
2418
- ref: primaryResult.created.ref
2419
- });
2420
- apply({
2421
- t: "created",
2422
- tempId: fx.primary,
2423
- id: refKey(primaryResult.created.ref),
2424
- ref: primaryResult.created.ref
2425
- });
2426
- syncDTO(primaryResult.created, "vector");
2427
- for (let i = 0; i < fx.members.length; i++) {
2428
- const tempId = fx.members[i];
2429
- const draft = {
2430
- ...drafts[i + 1],
2431
- inReplyTo: primaryResult.created.ref,
2432
- replyType: "group"
2433
- };
2434
- const result = await page.annotations.create(draft);
2435
- committed.push({
2436
- tempId,
2437
- ref: result.created.ref
2438
- });
2439
- apply({
2440
- t: "created",
2441
- tempId,
2442
- id: refKey(result.created.ref),
2443
- ref: result.created.ref
2444
- });
2445
- syncDTO(result.created, "vector");
2446
- }
2447
- } catch (error) {
2448
- const removeIds = ids.filter((id) => !committed.some((c) => c.tempId === id));
2449
- for (const part of [...committed].reverse()) try {
2450
- await doc.page(primary.pon).annotations.delete(part.ref);
2451
- removeIds.push(refKey(part.ref));
2452
- } catch {}
2453
- if (removeIds.length) apply({
2454
- t: "remove",
2455
- ids: removeIds
2456
- });
2457
- console.error("[annotation] grouped annotation creation failed:", error);
2458
- }
2459
- })();
2460
- } else if (fx.fx === "create") {
2461
- const a = m.byId[fx.id];
2462
- const crop = a && cropOf(a.pon);
2463
- const draft = a && crop ? toCreateDraft(a, crop) : null;
2464
- if (!a || !draft) return;
2465
- doc.page(a.pon).annotations.create(draft).then((res) => {
2466
- apply({
2467
- t: "created",
2468
- tempId: fx.id,
2469
- id: refKey(res.created.ref),
2470
- ref: res.created.ref
2471
- });
2472
- syncDTO(res.created, "vector");
2473
- }, () => apply({
2474
- t: "createFailed",
2475
- tempId: fx.id
2476
- }));
2477
- } else if (fx.fx === "flags") {
2478
- const a = m.byId[fx.id];
2479
- if (!a || !a.ref || !a.data) return;
2480
- doc.page(a.pon).annotations.update(a.ref, {
2481
- subtype: a.data.subtype,
2482
- flags: a.flags
2483
- }).then((res) => syncDTO(res.updated, a.source), (err) => console.error("[annotation] flags write failed:", err));
2484
- } else if (fx.fx === "patch") {
2485
- const a = m.byId[fx.id];
2486
- const crop = a && cropOf(a.pon);
2487
- const patch = a && a.ref && crop ? toScopedPatch(a, fx.scope, crop) : null;
2488
- if (!a || !a.ref || !patch) return;
2489
- doc.page(a.pon).annotations.update(a.ref, patch).then((res) => {
2490
- syncDTO(res.updated, a.source, res.appearance.changed);
2491
- if (linkChildrenOf(model(), fx.id).length) scheduleLinkSync(fx.id, "keep");
2492
- }, () => {
2493
- if (a.data && crop) apply({
2494
- t: "upsert",
2495
- annots: [ingestDTO(a.data, crop, a.source)]
2496
- });
2497
- });
2498
- } else if (fx.fx === "syncLink") scheduleLinkSync(fx.id, { target: fx.target });
2499
- else {
2500
- refKey(fx.ref);
2501
- doc.page(fx.ref.pageObjectNumber).annotations.delete(fx.ref).then(() => {}, () => {});
2502
- }
2503
- }
2504
- const reloadPageNow = async (pon) => {
2505
- const doc = ctx.doc;
2506
- const crop = cropOf(pon);
2507
- if (!doc || !crop) return;
2508
- try {
2509
- const snap = await doc.page(pon).annotations.list();
2510
- const m = model();
2511
- const stale = m.order.filter((id) => m.byId[id]?.pon === pon);
2512
- if (stale.length) apply({
2513
- t: "remove",
2514
- ids: stale
2515
- });
2516
- apply({
2517
- t: "loaded",
2518
- annots: snap.annotations.map((d) => ingestDTO(d, crop))
2519
- });
2520
- } catch {}
2521
- };
2522
- /** Script patch → the engine's per-kind patch vocabulary. Colors cross the
2523
- * Acrobat-array → engine {r,g,b}/255 boundary here; everything else maps
2524
- * one-to-one (the VM's validity matrix already scoped keys per kind). */
2525
- const engineScriptPatch = (subtype, patch) => {
2526
- const out = { subtype };
2527
- const toEngineColor = (color) => {
2528
- const rgb = scriptColorToRgb(color);
2529
- return rgb ? {
2530
- r: Math.round(rgb.r * 255),
2531
- g: Math.round(rgb.g * 255),
2532
- b: Math.round(rgb.b * 255)
2533
- } : null;
2534
- };
2535
- if (patch.strokeColor) {
2536
- const color = toEngineColor(patch.strokeColor);
2537
- if (!color) return /* @__PURE__ */ new Error("transparent stroke colours are not supported");
2538
- out.color = color;
2539
- }
2540
- if (patch.fillColor) out.interiorColor = toEngineColor(patch.fillColor);
2541
- if (patch.opacity !== void 0) out.opacity = patch.opacity;
2542
- if (patch.width !== void 0) out.strokeWidth = patch.width;
2543
- if (patch.borderStyle) out.borderStyle = patch.borderStyle === "D" ? "dashed" : "solid";
2544
- if (patch.dash) out.dashArray = patch.dash;
2545
- if (patch.rect) out.rect = {
2546
- left: patch.rect[0],
2547
- bottom: patch.rect[1],
2548
- right: patch.rect[2],
2549
- top: patch.rect[3]
2550
- };
2551
- if (patch.contents !== void 0) out.contents = patch.contents;
2552
- if (patch.flags) out.flags = patch.flags;
2553
- return out;
2554
- };
2555
- const updateSelection = (patch) => {
2556
- const m = model();
2557
- const range = activeTextRange(m);
2558
- if (range) {
2559
- const a = m.byId[range.id];
2560
- const { delta, rest } = runDeltaForProps(patch, fonts);
2561
- if (Object.keys(delta).length) {
2562
- const next = applyStyleToRange({ paragraphs: richDocOf(a, fonts).paragraphs }, range, delta);
2563
- apply({
2564
- t: "setRichText",
2565
- id: range.id,
2566
- doc: next
2567
- });
2568
- if (a.ref) scheduleTextCommit(a.ref);
2569
- }
2570
- if (Object.keys(rest).length) {
2571
- flushTextCommits();
2572
- apply({
2573
- t: "setProps",
2574
- patch: rest
2575
- });
2576
- }
2577
- return;
2578
- }
2579
- if (m.editing) flushTextCommits();
2580
- apply({
2581
- t: "setProps",
2582
- patch
2583
- });
2584
- };
2585
- return {
2586
- setPageViewports: (pon, viewports, fallback) => {
2587
- pageViewports.set(pon, {
2588
- viewports,
2589
- fallback
2590
- });
2591
- },
2592
- onDraftCaptured: (cb) => {
2593
- captured.add(cb);
2594
- return () => {
2595
- captured.delete(cb);
2596
- };
2597
- },
2598
- remeasurePage: async (pon, scale) => {
2599
- await rehydrate();
2600
- const report = {
2601
- pon,
2602
- scale,
2603
- updated: [],
2604
- skipped: [],
2605
- failed: []
2606
- };
2607
- const hydration = ctx.getState().hydration;
2608
- if (hydration.status !== "complete") {
2609
- report.error = serializeError(hydration.status === "error" ? hydration.error : /* @__PURE__ */ new Error("Annotation hydration is incomplete"));
2610
- return report;
2611
- }
2612
- const candidates = Object.values(model().byId).filter((a) => a.pon === pon && a.data && isDimension(a.data));
2613
- for (const a of candidates) {
2614
- const dto = a.data, ref = dto.ref;
2615
- const reason = a.authority?.update === false ? "no-authority" : !annotTransformable(a) || !annotContentsEditable(a) ? "locked" : "measure" in dto && dto.measure && dto.measure.subtype !== "RL" ? "foreign-measure" : !isReadout(measurementReadout({
2616
- ...dto,
2617
- measure: scale
2618
- })) ? "unavailable" : null;
2619
- if (reason) {
2620
- report.skipped.push({
2621
- ref,
2622
- reason
2623
- });
2624
- continue;
2625
- }
2626
- try {
2627
- await updateOne(ref, {
2628
- subtype: dto.subtype,
2629
- measure: scale
2630
- });
2631
- report.updated.push(ref);
2632
- } catch (error) {
2633
- report.failed.push({
2634
- ref,
2635
- error: serializeError(error)
2636
- });
2637
- }
2638
- }
2639
- return report;
2640
- },
2641
- create: async (pon, draft) => {
2642
- const doc = ctx.doc;
2643
- if (!doc) throw new Error("[annotation] no document bound");
2644
- const withFlags = draft.flags ? draft : {
2645
- ...draft,
2646
- flags: { print: true }
2647
- };
2648
- const res = await doc.page(pon).annotations.create(withFlags);
2649
- syncDTO(res.created, res.created.subtype === "stamp" ? "baked" : "vector");
2650
- return res.created.ref;
2651
- },
2652
- armStamp,
2653
- placeStamp,
2654
- disarmStamp,
2655
- placeArmedStamp,
2656
- requestStampAt,
2657
- placeAt,
2658
- hasArmedStamp: () => armedStamp != null,
2659
- ghostHoverAt,
2660
- clearGhost,
2661
- setPlacementPreview,
2662
- clearPlacementPreview: clearGhost,
2663
- toolGhost: (pon) => {
2664
- const g = ctx.getState().toolGhost;
2665
- return g && g.pon === pon ? g : null;
2666
- },
2667
- armedStampPreview: (devicePixelWidth) => {
2668
- const armed = armedStamp;
2669
- if (!armed?.preview) return Promise.resolve(null);
2670
- const bucket = previewBucket(devicePixelWidth ?? 0);
2671
- let pending = armed.previewCache.get(bucket);
2672
- if (!pending) {
2673
- pending = armed.preview(bucket).catch((error) => {
2674
- console.error("[annotation] stamp ghost preview failed:", error);
2675
- return null;
2676
- });
2677
- armed.previewCache.set(bucket, pending);
2678
- }
2679
- return pending;
2680
- },
2681
- stampArmEpoch: () => ctx.getState().stampArmEpoch,
2682
- setFilePickerProvider: (provider) => {
2683
- filePickerProvider = provider;
2684
- },
2685
- downloadAttachment: async (ref) => {
2686
- const doc = ctx.doc;
2687
- if (!doc) throw new Error("[annotation] no document bound");
2688
- const pon = ponForRef(ref);
2689
- if (pon == null) throw new Error("[annotation] cannot resolve page for ref");
2690
- const annotations = doc.page(pon).annotations;
2691
- if (!annotations.downloadFile) throw new Error("[annotation] this engine does not support attachment download");
2692
- return annotations.downloadFile(ref);
2693
- },
2694
- tools: () => [...registry.values()],
2695
- tool: (id) => registry.get(id) ?? null,
2696
- toolSubtype: (id) => registry.get(id)?.subtype ?? id,
2697
- registerTool: (def) => {
2698
- const resolved = buildToolRegistry([...configTools, def]).get(def.id);
2699
- if (!resolved) throw new Error(`[annotation] could not resolve tool '${def.id}'`);
2700
- registry.set(resolved.id, resolved);
2701
- const un = ctx.tryGet(InteractionToken)?.registerTool({
2702
- id: resolved.id,
2703
- cursor: resolved.cursor,
2704
- enables: resolved.enables,
2705
- touchDirect: isTouchDirect(resolved.enables)
2706
- });
2707
- if (resolved.defaults) apply({
2708
- t: "setDefaults",
2709
- subtype: resolved.preset,
2710
- patch: resolved.defaults
2711
- });
2712
- return () => {
2713
- registry.delete(resolved.id);
2714
- un?.();
2715
- };
2716
- },
2717
- update: (ref, patch) => updateOne(ref, patch),
2718
- updateSelection,
2719
- toggleTextFormat: (format) => {
2720
- const current = memoSelectionProps().values[format];
2721
- updateSelection({ [format]: !current });
2722
- },
2723
- updateSelectionFlags: (patch) => apply({
2724
- t: "setFlags",
2725
- patch
2726
- }),
2727
- getSelectionFlags: () => memoSelectionFlags(),
2728
- delete: async (ref) => {
2729
- const doc = ctx.doc;
2730
- if (!doc) throw new Error("[annotation] no document bound");
2731
- const pon = ponForRef(ref);
2732
- if (pon == null) throw new Error("[annotation] cannot resolve page for ref");
2733
- await doc.page(pon).annotations.delete(ref);
2734
- apply({
2735
- t: "remove",
2736
- ids: [refKey(ref)]
2737
- });
2738
- },
2739
- canRead: () => canRead(),
2740
- canCreate: () => allowsCreate(),
2741
- canEdit: (ref) => {
2742
- const a = model().byId[refKey(ref)];
2743
- return !!a && annotTransformable(a);
2744
- },
2745
- canDelete: (ref) => {
2746
- const a = model().byId[refKey(ref)];
2747
- return !!a && annotDeletable(a);
2748
- },
2749
- comments,
2750
- links: {
2751
- of: (ref) => {
2752
- const a = model().byId[refKey(ref)];
2753
- if (!a) return null;
2754
- return a.subtype === "link" ? a.link ?? null : linkOf(model(), a.id);
2755
- },
2756
- set: (ref, target) => scheduleLinkSync(refKey(ref), { target }),
2757
- clear: (ref) => scheduleLinkSync(refKey(ref), { target: null })
2758
- },
2759
- getSelection: () => {
2760
- const m = model();
2761
- return m.selected.map((id) => m.byId[id]?.ref).filter((r) => r != null);
2762
- },
2763
- get: (ref) => model().byId[refKey(ref)]?.data ?? null,
2764
- list: (pon) => {
2765
- const m = model();
2766
- return m.order.map((id) => m.byId[id]).filter((a) => a?.pon === pon).map((a) => a?.data).filter((d) => d != null);
2767
- },
2768
- getSelected: () => {
2769
- const m = model();
2770
- return m.selected.map((id) => m.byId[id]?.data).filter((d) => d != null);
2771
- },
2772
- getSelectionProps: () => memoSelectionProps(),
2773
- propsForTool: (toolId) => propsFor(registry.get(toolId)?.propsKind ?? toolId),
2774
- pageItems: (pon, view) => memoItems(pon, view),
2775
- chrome: (pon, scale, rotation, zoom) => memoChrome(pon, scale, rotation, zoom),
2776
- selectionAnchor: (scale, rotation, zoom) => memoAnchor(scale, rotation, zoom),
2777
- creationDraftAnchor: () => memoDraftAnchor(),
2778
- distanceCreationPage: () => {
2779
- const draft = model().draft;
2780
- return draft?.g === "create-distance" && draft.step === "offset" ? draft.pon : null;
2781
- },
2782
- selection: () => model().selected,
2783
- hitKind: (pon, point, scale, rotation, zoom, touch) => hitTest(model(), pon, point, chromeGeomAt(scale, grabBoost(touch)), model().hitMargin, pageBoxOf(pon), inertIdsAt(pon), viewEnv(zoom, rotation)).t,
2784
- claimsTouchAt: (pon, point, scale, rotation, zoom) => {
2785
- const m = model();
2786
- const t = hitTest(m, pon, point, chromeGeomAt(scale, TOUCH_GRAB_BOOST), m.hitMargin, pageBoxOf(pon), inertIdsAt(pon), viewEnv(zoom, rotation));
2787
- if (t.t === "handle" || t.t === "rotate" || t.t === "group-handle") return true;
2788
- if (t.t === "annot") return m.selected.includes(t.id) && m.selected.every((id) => canMove(m, id));
2789
- return false;
2790
- },
2791
- cursorAt: (pon, point, scale, rotation, zoom) => cursorAt(model(), pon, point, chromeGeomAt(scale), model().hitMargin, pageBoxOf(pon), inertIdsAt(pon), viewEnv(zoom, rotation)),
2792
- hoverAt: (at) => {
2793
- const m = model();
2794
- let id = null;
2795
- if (at) {
2796
- const h = hitTest(m, at.pon, at.point, chromeGeomAt(at.scale), m.hitMargin, pageBoxOf(at.pon), inertIdsAt(at.pon), viewEnv(at.zoom, at.rotation));
2797
- if (h.t === "annot") id = h.id;
2798
- }
2799
- if (m.hovered !== id) {
2800
- hoverFeed?.hover(id);
2801
- apply({
2802
- t: "hover",
2803
- id
2804
- });
2805
- }
2806
- },
2807
- behaviorFor: (a) => behaviors.find((b) => b.matches(a) && b.engaged()) ?? null,
2808
- linkItemsOn: (pon) => memoLinkItems(pon),
2809
- appearanceEpoch: (pon) => {
2810
- const m = model();
2811
- const parts = [];
2812
- for (const id of m.order) {
2813
- const a = m.byId[id];
2814
- if (!a || a.pon !== pon || a.source !== "baked" || !a.ref) continue;
2815
- if (isSubstrateOnly(a)) continue;
2816
- parts.push(`${id}@${a.apVersion ?? 0}`);
2817
- }
2818
- return parts.sort().join("|");
2819
- },
2820
- bakeScale: (renderScale) => snapAppearanceScale(ctx.document()?.renderPolicy ?? CONTINUOUS_RENDER_POLICY, renderScale),
2821
- appearances: (pon, scale, signal) => {
2822
- const doc = ctx.doc;
2823
- if (!doc) return Promise.resolve([]);
2824
- const task = doc.page(pon).annotations.renderAppearanceImages({ scale });
2825
- if (signal) if (signal.aborted) task.abort(signal.reason);
2826
- else signal.addEventListener("abort", () => task.abort(signal.reason), { once: true });
2827
- return task.then((r) => r.appearances, () => []);
2828
- },
2829
- toContentBox: (pon, rect) => {
2830
- const crop = cropOf(pon);
2831
- return crop ? pdfToContentRect(rect, crop) : null;
2832
- },
2833
- editPointer: (phase, pon, point, shift, scale, rotation, zoom, touch) => apply({
2834
- t: "editPointer",
2835
- phase,
2836
- in: {
2837
- pon,
2838
- point,
2839
- shift,
2840
- pageBox: pageBoxOf(pon),
2841
- chrome: chromeGeomAt(scale, grabBoost(touch)),
2842
- inert: inertIdsAt(pon),
2843
- ...zoom != null ? { zoom } : {},
2844
- ...rotation != null ? { displayRotation: rotation } : {}
2845
- }
2846
- }),
2847
- marqueePointer: (phase, pon, point, shift, scale, rotation, zoom) => apply({
2848
- t: "marqueePointer",
2849
- phase,
2850
- in: {
2851
- pon,
2852
- point,
2853
- shift,
2854
- pageBox: pageBoxOf(pon),
2855
- inert: inertIdsAt(pon),
2856
- ...zoom != null ? { zoom } : {},
2857
- ...rotation != null ? { displayRotation: rotation } : {}
2858
- }
2859
- }),
2860
- createPointer: (tool, phase, pon, point, finish = false, displayRotation) => {
2861
- const t = registry.get(tool);
2862
- if (t?.meta?.capture === true ? !ctx.doc?.security.allows("doc.annotate.modify") : !allowsCreate()) return;
2863
- const crop = cropOf(pon);
2864
- const cache = pageViewports.get(pon);
2865
- const draft = model().draft;
2866
- const continuingMeasurement = (draft?.g === "create-distance" || draft?.g === "create-poly") && draft.pon === pon && draft.preset === (t?.preset ?? tool);
2867
- const dimension = t && isDimension(t);
2868
- if (dimension && phase === "down" && !continuingMeasurement && (!crop || !cache?.viewports)) return;
2869
- const viewport = crop && cache?.viewports ? viewportForPoint(cache.viewports, {
2870
- x: point.x + crop.left,
2871
- y: crop.top - point.y
2872
- }) : void 0;
2873
- const measure = dimension && crop && cache ? {
2874
- intent: t.intent,
2875
- measure: viewport ? viewport.measure ?? null : cache.fallback,
2876
- caption: t.measurement?.caption ?? { enabled: true },
2877
- ...t.intent === "LineDimension" ? { leader: t.measurement?.leader } : {},
2878
- crop,
2879
- text: ""
2880
- } : void 0;
2881
- if (measure && phase === "down" && !continuingMeasurement && !isReadout(measurementReadout({
2882
- subtype: t.subtype,
2883
- intent: measure.intent,
2884
- measure: measure.measure,
2885
- linePoints: {
2886
- start: {
2887
- x: 0,
2888
- y: 0
2889
- },
2890
- end: {
2891
- x: 1,
2892
- y: 0
2893
- }
2894
- },
2895
- vertices: [
2896
- {
2897
- x: 0,
2898
- y: 0
2899
- },
2900
- {
2901
- x: 1,
2902
- y: 0
2903
- },
2904
- {
2905
- x: 1,
2906
- y: 1
2907
- }
2908
- ]
2909
- }))) return;
2910
- apply({
2911
- t: "createPointer",
2912
- measure,
2913
- capture: t?.meta?.capture === true ? tool : void 0,
2914
- phase,
2915
- subtype: t?.subtype ?? tool,
2916
- preset: t?.preset ?? tool,
2917
- intent: t?.intent === "ink-highlight" ? t.intent : void 0,
2918
- clickCreate: t?.clickCreate,
2919
- flags: t?.flags,
2920
- deferInkCommit: (t?.ink?.groupStrokesMs ?? 0) > 0,
2921
- straightenInk: t?.ink?.straighten,
2922
- in: {
2923
- pon,
2924
- point,
2925
- shift: false,
2926
- finish,
2927
- pageBox: pageBoxOf(pon),
2928
- displayRotation,
2929
- upright: t?.upright
2930
- }
2931
- });
2932
- },
2933
- hasCreationDraft: () => model().draft?.g.startsWith("create-") ?? false,
2934
- finishCreationDraft: () => apply({ t: "finishCreationDraft" }),
2935
- finishInkDraft: () => apply({ t: "finishInkDraft" }),
2936
- cancelCreationDraft: () => apply({ t: "cancel" }),
2937
- markupFromSelection: (subtype, preset) => {
2938
- if (!allowsCreate()) return false;
2939
- const selection = ctx.tryGet(SelectionToken);
2940
- if (!selection || !selection.hasSelection()) return false;
2941
- const snapshot = selection.snapshot();
2942
- let created = false;
2943
- for (const page of snapshot.pages) {
2944
- if (!page.segments.length) continue;
2945
- apply({
2946
- t: "createMarkup",
2947
- subtype,
2948
- pon: page.pon,
2949
- quads: page.segments.map((s) => s.quad),
2950
- preset,
2951
- flags: preset ? registry.get(preset)?.flags : void 0
2952
- });
2953
- created = true;
2954
- }
2955
- if (created) selection.clear();
2956
- return created;
2957
- },
2958
- createMarkup: (subtype, pon, quads, preset) => {
2959
- if (!allowsCreate()) return;
2960
- apply({
2961
- t: "createMarkup",
2962
- subtype,
2963
- pon,
2964
- quads,
2965
- preset,
2966
- flags: preset ? registry.get(preset)?.flags : void 0
2967
- });
2968
- },
2969
- createCaret: (pon, anchor) => {
2970
- if (!allowsCreate()) return;
2971
- apply({
2972
- t: "createCaret",
2973
- pon,
2974
- anchor
2975
- });
2976
- },
2977
- createReplaceText: (pon, quads, anchor, preset) => {
2978
- if (!allowsCreate()) return;
2979
- apply({
2980
- t: "createReplaceText",
2981
- pon,
2982
- quads,
2983
- anchor,
2984
- preset
2985
- });
2986
- },
2987
- previewMarkup: (subtype, quadsByPage, preset) => apply({
2988
- t: "setMarkupPreview",
2989
- subtype,
2990
- quadsByPage,
2991
- preset
2992
- }),
2993
- clearMarkupPreview: () => apply({ t: "clearMarkupPreview" }),
2994
- setDefaults: (subtype, patch) => apply({
2995
- t: "setDefaults",
2996
- subtype,
2997
- patch
2998
- }),
2999
- currentDefaults: (toolId) => defaultsFor(model(), registry.get(toolId)?.preset ?? toolId),
3000
- setSnap: (patch) => apply({
3001
- t: "setSnap",
3002
- patch
3003
- }),
3004
- snapSettings: () => model().snap,
3005
- setChrome: (patch) => ctx.dispatch({
3006
- type: "SET_CHROME",
3007
- patch
3008
- }),
3009
- chromeSettings: () => chromeSettings(),
3010
- deleteSelection: () => apply({ t: "delete" }),
3011
- deselect: () => apply({ t: "deselect" }),
3012
- select: (ref, options) => {
3013
- apply({
3014
- t: "select",
3015
- ids: [refKey(ref)],
3016
- add: options?.add
3017
- });
3018
- },
3019
- pruneEngagedSelection: () => {
3020
- const m = model();
3021
- const drop = m.selected.filter((id) => {
3022
- const a = m.byId[id];
3023
- return a && behaviors.some((b) => b.matches({
3024
- subtype: a.subtype,
3025
- ref: a.ref
3026
- }) && b.engaged());
3027
- });
3028
- if (drop.length) apply({
3029
- t: "deselect",
3030
- ids: drop
3031
- });
3032
- },
3033
- cancel: () => apply({ t: "cancel" }),
3034
- rotateSelection90: () => apply({ t: "rotate90" }),
3035
- resetSelectionRotation: () => apply({ t: "resetRotation" }),
3036
- /** Group the current selection into one unit: the bottom-most member becomes
3037
- * the primary, every other member becomes a `/RT /Group` subordinate of it.
3038
- * No-op unless 2+ committed annotations on a single page are selected. */
3039
- group: async () => {
3040
- const m = model();
3041
- const members = selectedCommitted();
3042
- if (members.length < 2) return;
3043
- const pon = members[0].pon;
3044
- if (members.some((a) => a.pon !== pon)) return;
3045
- const [primary, ...rest] = [...members].sort((a, b) => m.order.indexOf(a.id) - m.order.indexOf(b.id));
3046
- if (!primary.ref) return;
3047
- await Promise.all(rest.map((a) => writeRelationship(a, {
3048
- inReplyTo: primary.ref,
3049
- replyType: "group"
3050
- })));
3051
- },
3052
- /** Ungroup: clear `/IRT` (+ `/RT`) on every subordinate in the group(s) the
3053
- * selection touches, so each member becomes top-level again. */
3054
- ungroup: async () => {
3055
- const m = model();
3056
- const subs = expandGroups(m, m.selected).map((id) => m.byId[id]).filter((a) => !!a && !!a.ref && !!a.data && !!a.group);
3057
- await Promise.all(subs.map((a) => writeRelationship(a, { inReplyTo: null })));
3058
- },
3059
- canGroup: () => {
3060
- const m = model();
3061
- const members = selectedCommitted();
3062
- if (members.length < 2) return false;
3063
- if (members.some((a) => a.pon !== members[0].pon)) return false;
3064
- if (!members.every((a) => a.ref != null && allowsMutation("update", a.ref))) return false;
3065
- const keys = new Set(m.selected.map((id) => groupKeyOf(m, id)));
3066
- if (keys.size === 1 && !keys.has(null)) return false;
3067
- return true;
3068
- },
3069
- canUngroup: () => {
3070
- const m = model();
3071
- const subs = expandGroups(m, m.selected).map((id) => m.byId[id]).filter((a) => !!a && !!a.group);
3072
- return subs.length > 0 && subs.every((a) => a.ref != null && allowsMutation("update", a.ref));
3073
- },
3074
- ensurePage: () => {},
3075
- reloadPage: (pon) => reloadPageNow(pon),
3076
- hydration: () => ctx.getState().hydration,
3077
- ensureHydrated: () => {
3078
- if (hydrationStarted) return;
3079
- hydrationStarted = true;
3080
- runHydration(false);
3081
- },
3082
- rehydrate,
3083
- deliverRemoteAnnotationEvent: (event) => {
3084
- if (hydrationWindow) {
3085
- pendingRemote.push(event);
3086
- return;
3087
- }
3088
- applyRemoteAnnotationEvent(event);
3089
- },
3090
- textItems: (pon, view) => memoTexts(pon, view),
3091
- currentEditing: () => model().editing,
3092
- beginTextEdit: (ref) => apply({
3093
- t: "beginTextEdit",
3094
- id: refKey(ref)
3095
- }),
3096
- beginTextEditAt: (pon, point, scale, rotation, zoom) => {
3097
- const m = model();
3098
- const h = hitTest(m, pon, point, chromeGeomAt(scale), m.hitMargin, pageBoxOf(pon), void 0, viewEnv(zoom, rotation));
3099
- const id = h.t === "annot" || h.t === "handle" ? h.id : null;
3100
- if (id != null && m.byId[id]?.geom.t === "text") {
3101
- apply({
3102
- t: "beginTextEdit",
3103
- id
3104
- });
3105
- return true;
3106
- }
3107
- return false;
3108
- },
3109
- setContents: (ref, text) => {
3110
- apply({
3111
- t: "setText",
3112
- id: refKey(ref),
3113
- text
3114
- });
3115
- scheduleTextCommit(ref);
3116
- },
3117
- setRichText: (ref, doc) => {
3118
- apply({
3119
- t: "setRichText",
3120
- id: refKey(ref),
3121
- doc: { paragraphs: doc.paragraphs }
3122
- });
3123
- scheduleTextCommit(ref);
3124
- },
3125
- setTextSelection: (ref, range) => {
3126
- const id = refKey(ref);
3127
- const prev = ctx.getState().textSelection;
3128
- const next = range ? {
3129
- id,
3130
- start: range.start,
3131
- end: range.end
3132
- } : null;
3133
- if (prev === null === (next === null) && (!prev || !next || prev.id === next.id && prev.start === next.start && prev.end === next.end)) return;
3134
- ctx.dispatch({
3135
- type: "SET_TEXT_SELECTION",
3136
- selection: next
3137
- });
3138
- },
3139
- cssFontFamily: (family) => cssFontFamilyForFace(family, fonts),
3140
- endTextEdit: () => {
3141
- flushTextCommits();
3142
- if (ctx.getState().textSelection) ctx.dispatch({
3143
- type: "SET_TEXT_SELECTION",
3144
- selection: null
3145
- });
3146
- apply({ t: "endTextEdit" });
3147
- },
3148
- commitScriptEffects: async (entries) => {
3149
- const doc = ctx.doc;
3150
- if (!doc) return { results: entries.map((entry) => ({
3151
- annotObjectNumber: entry.annotObjectNumber,
3152
- status: "failed",
3153
- error: "no document"
3154
- })) };
3155
- const results = [];
3156
- const touchedPons = /* @__PURE__ */ new Set();
3157
- let failed = false;
3158
- for (const entry of entries) {
3159
- if (failed) {
3160
- results.push({
3161
- annotObjectNumber: entry.annotObjectNumber,
3162
- status: "skipped"
3163
- });
3164
- continue;
3165
- }
3166
- const loaded = model().byId[`obj:${entry.annotObjectNumber}`];
3167
- const pon = loaded?.pon ?? entry.pageObjectNumber;
3168
- let ref = loaded?.ref ?? null;
3169
- let subtype = loaded?.subtype;
3170
- if ((!ref || !subtype) && pon !== void 0) try {
3171
- const { annotations } = await doc.page(pon).annotations.list();
3172
- const dto = annotations.find((candidate) => candidate.ref.kind === "objectNumber" && candidate.ref.annotObjectNumber === entry.annotObjectNumber);
3173
- if (dto) {
3174
- ref = dto.ref;
3175
- subtype = dto.subtype;
3176
- }
3177
- } catch {}
3178
- if (pon === void 0 || !ref || !subtype) {
3179
- results.push({
3180
- annotObjectNumber: entry.annotObjectNumber,
3181
- status: "failed",
3182
- error: "annotation not resolved (page not loaded)"
3183
- });
3184
- continue;
3185
- }
3186
- const patch = engineScriptPatch(subtype, entry.patch);
3187
- if (patch instanceof Error) {
3188
- results.push({
3189
- annotObjectNumber: entry.annotObjectNumber,
3190
- status: "failed",
3191
- error: patch.message
3192
- });
3193
- continue;
3194
- }
3195
- try {
3196
- await doc.page(pon).annotations.update(ref, patch);
3197
- touchedPons.add(pon);
3198
- results.push({
3199
- annotObjectNumber: entry.annotObjectNumber,
3200
- status: "applied"
3201
- });
3202
- } catch (error) {
3203
- failed = true;
3204
- results.push({
3205
- annotObjectNumber: entry.annotObjectNumber,
3206
- status: "failed",
3207
- error: error instanceof Error ? error.message : String(error)
3208
- });
3209
- }
3210
- }
3211
- for (const touched of touchedPons) await reloadPageNow(touched);
3212
- return { results };
3213
- },
3214
- registerBehavior: (b) => {
3215
- behaviors.push(b);
3216
- return () => {
3217
- const i = behaviors.indexOf(b);
3218
- if (i >= 0) behaviors.splice(i, 1);
3219
- };
3220
- }
3221
- };
3222
- }
3223
- //#endregion
3224
- export { toPatch as i, fromDTO as n, toCreateDraft as r, createAnnotationCapability as t };
3225
-
3226
- //# sourceMappingURL=capability-CoL4na7g.js.map