@embedpdf/plugin-annotation 3.0.0-next.13 → 3.0.0-next.15

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