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

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 (46) hide show
  1. package/dist/{capability-DBsr2D-L.js → capability-B_dkoB1K.js} +567 -558
  2. package/dist/capability-B_dkoB1K.js.map +1 -0
  3. package/dist/{capability-Csb9WMm-.cjs → capability-DVx1NmTe.cjs} +568 -577
  4. package/dist/capability-DVx1NmTe.cjs.map +1 -0
  5. package/dist/contract/host.cjs +1 -1
  6. package/dist/contract/host.d.cts +1 -1
  7. package/dist/contract/host.d.ts +1 -1
  8. package/dist/contract/host.js +1 -1
  9. package/dist/contract-CgAcfAE7.d.cts +34 -0
  10. package/dist/contract-CiG2_HBq.d.ts +34 -0
  11. package/dist/contract-DtXE1hjY.js +32 -0
  12. package/dist/contract-DtXE1hjY.js.map +1 -0
  13. package/dist/contract-a4eaQWga.cjs +67 -0
  14. package/dist/contract-a4eaQWga.cjs.map +1 -0
  15. package/dist/contract.cjs +11 -7
  16. package/dist/contract.d.cts +3 -10
  17. package/dist/contract.d.ts +3 -10
  18. package/dist/contract.js +4 -8
  19. package/dist/index.cjs +47 -8
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.cts +3 -3
  22. package/dist/index.d.ts +3 -3
  23. package/dist/index.js +39 -7
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal.cjs +4 -3
  26. package/dist/internal.d.cts +1 -1
  27. package/dist/internal.d.ts +1 -1
  28. package/dist/internal.js +3 -2
  29. package/dist/tools-0NI3b9z8.cjs +536 -0
  30. package/dist/tools-0NI3b9z8.cjs.map +1 -0
  31. package/dist/tools-DBEYx3-c.js +513 -0
  32. package/dist/tools-DBEYx3-c.js.map +1 -0
  33. package/dist/{types-CwytT-TI.js → types-BvB1vUKb.js} +9 -2
  34. package/dist/types-BvB1vUKb.js.map +1 -0
  35. package/dist/{types-otwv-Cdl.d.ts → types-D5V0TIwk.d.ts} +133 -19
  36. package/dist/types-Dlhdw2k_.cjs +31 -0
  37. package/dist/types-Dlhdw2k_.cjs.map +1 -0
  38. package/dist/{types-eLTpocXz.d.cts → types-ObcPYc3-.d.cts} +133 -19
  39. package/package.json +9 -9
  40. package/dist/capability-Csb9WMm-.cjs.map +0 -1
  41. package/dist/capability-DBsr2D-L.js.map +0 -1
  42. package/dist/contract.cjs.map +0 -1
  43. package/dist/contract.js.map +0 -1
  44. package/dist/types-CwytT-TI.js.map +0 -1
  45. package/dist/types-DimcLIic.cjs +0 -17
  46. package/dist/types-DimcLIic.cjs.map +0 -1
@@ -1,11 +1,13 @@
1
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";
2
4
  import { CONTINUOUS_RENDER_POLICY, snapAppearanceScale } from "@embedpdf/core";
3
5
  import { ActionsToken, createHoverPump } from "@embedpdf/plugin-actions/contract";
4
6
  import { InteractionToken } from "@embedpdf/plugin-interaction/contract";
5
7
  import { buildCommentThreads, encodeStableIdKey, resolveBinarySource, sniffBinaryMetadata } from "@embedpdf/engine-core/runtime";
6
8
  import { scriptColorToRgb } from "@embedpdf/core-acrojs";
7
9
  import { SelectionToken } from "@embedpdf/plugin-selection/contract";
8
- import { FLAG_KEYS, annotContentsEditable, annotDeletable, annotTransformable, calloutLinePoints, canMove, chrome, clickCreateGeom, cloudyBorderExtent, contentToPdfRect, creationDraftAnchor, cursorAt, defaultsFor, expandGroups, fitStampBox, geomPdfBounds, geomRotation, geomVisualBounds, groupKeyOf, hitTest, initialTextStyle, isSubstrateOnly, linkChildrenOf, linkOf, pageItems, pdfToContentPoint, pdfToContentRect, propsFor, readProp, resolveClickPlacement, rotatedAabb, selectionAnchor, selectionQuad, sharedProps, styleFromProps, textBoxes, unionRect, update, uprightRotation, viewable } from "@embedpdf/core-annotation";
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";
9
11
  import { normalizeQuad, textQuadBounds } from "@embedpdf/core-geometry";
10
12
  //#region src/repository/props.ts
11
13
  /**
@@ -207,18 +209,387 @@ const ink = {
207
209
  draftExtras: (a) => a.intent === "ink-highlight" ? { intent: a.intent } : {}
208
210
  };
209
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
210
557
  //#region src/repository/kinds/freeText.ts
211
558
  /** Free-text `/DA` fields → content {@link TextStyle}. An absent `fontColor`
212
559
  * falls back to the `/DA` colour — the same rule the CPVT renderer applies. */
213
560
  function textFromDTO(dto) {
561
+ const body = dto.richText?.body;
214
562
  return {
215
563
  fontFamily: dto.fontFamily,
216
564
  fontSize: dto.fontSize,
217
565
  fontColor: colorToCss(dto.fontColor ?? dto.color),
218
- textAlign: dto.textAlign
566
+ textAlign: dto.textAlign,
567
+ ...body && body.weight >= 600 ? { bold: true } : {},
568
+ ...body?.italic ? { italic: true } : {},
569
+ ...body?.decoration.includes("underline") ? { underline: true } : {}
219
570
  };
220
571
  }
221
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
+ /**
222
593
  * The engine geometry for a callout: the overall `/Rect` (text box ∪ leader ∪
223
594
  * arrow), the `/RD` inset that recovers the text box from it, the `/CL` leader
224
595
  * (`[tip, knee, conn]` with the connection point derived), and the `/LE`
@@ -302,6 +673,11 @@ const freeText = {
302
673
  if (cf) return { ...cf };
303
674
  return boxEmit(a, crop);
304
675
  },
676
+ prop: {
677
+ bold: formattingBody,
678
+ italic: formattingBody,
679
+ underline: formattingBody
680
+ },
305
681
  draftExtras: (a, crop) => ({
306
682
  intent: calloutFields(a, crop) ? "free-text-callout" : "free-text",
307
683
  contents: a.data?.contents ?? ""
@@ -731,18 +1107,6 @@ function createAnnotationHoverFeed(actions, annotOf) {
731
1107
  * the plugin (not the portable core) because the font→CSS stack mapping and the
732
1108
  * engine `Color`→CSS seam are web concerns, shared across every web framework.
733
1109
  */
734
- /** Map a free-text `/DA` font to a CSS font-family. A standard PDF font → a web
735
- * stack with the same metrics; anything else is a registered font key → mount it
736
- * as a `@font-face` of that family (see the framework `mountWebFont` helper). */
737
- const STANDARD_FONT_CSS = {
738
- helvetica: "Helvetica, Arial, sans-serif",
739
- "helvetica-bold": "Helvetica, Arial, sans-serif",
740
- courier: "\"Courier New\", Courier, monospace",
741
- "times-roman": "\"Times New Roman\", Times, serif",
742
- symbol: "serif",
743
- "zapf-dingbats": "serif"
744
- };
745
- const cssFontFor = (font) => STANDARD_FONT_CSS[font] ?? `"${font}", sans-serif`;
746
1110
  /** Project the model's free-text boxes into render-ready {@link TextItem}s — the
747
1111
  * core geometry (`textBoxes`) joined with the DTO-derived CSS. Pure; memoized by
748
1112
  * model identity at the call site so selectors get a stable reference. */
@@ -751,516 +1115,29 @@ function buildTextItems(m, pon, view) {
751
1115
  const a = m.byId[tb.id];
752
1116
  const t = a?.text ?? initialTextStyle;
753
1117
  const sw = a?.style.strokeWidth ?? 0;
754
- const isCallout = a?.geom.t === "text" && !!a.geom.callout;
1118
+ const doc = a ? richDocOf(a) : null;
755
1119
  return {
756
1120
  id: tb.id,
757
1121
  ref: a?.ref ?? null,
758
1122
  box: tb.box,
759
1123
  contents: a?.data?.contents ?? "",
1124
+ richText: { paragraphs: doc ? stripBodyDefaults(doc.paragraphs, doc.body) : [{ runs: [{ text: "" }] }] },
760
1125
  editing: tb.editing,
761
1126
  ...tb.rot ? { rot: tb.rot } : {},
762
1127
  css: {
763
- fontFamily: cssFontFor(t.fontFamily),
1128
+ fontFamily: cssFontFamilyForFont(t.fontFamily),
764
1129
  fontSize: t.fontSize,
765
- lineHeight: t.fontSize,
766
1130
  color: t.fontColor,
1131
+ fontWeight: t.bold ? 700 : 400,
1132
+ fontStyle: t.italic ? "italic" : "normal",
1133
+ textDecoration: t.underline ? "underline" : "none",
767
1134
  align: t.textAlign,
768
- padding: isCallout ? sw + 2 : sw,
769
- background: a?.style.interiorColor ?? null
1135
+ padding: textPlateInset(sw)
770
1136
  }
771
1137
  };
772
1138
  });
773
1139
  }
774
1140
  //#endregion
775
- //#region src/tools.ts
776
- /**
777
- * The creation properties each known authoring kind actually consumes. This is
778
- * deliberately separate from the flat internal patch vocabulary: it gives tool
779
- * configuration precise compile-time and runtime validation while mixed-selection
780
- * edits can keep using `AnnotationPropsPatch`.
781
- */
782
- const TOOL_DEFAULT_KEYS = {
783
- square: [
784
- "color",
785
- "interiorColor",
786
- "opacity",
787
- "strokeWidth",
788
- "border"
789
- ],
790
- circle: [
791
- "color",
792
- "interiorColor",
793
- "opacity",
794
- "strokeWidth",
795
- "border"
796
- ],
797
- line: [
798
- "color",
799
- "interiorColor",
800
- "opacity",
801
- "strokeWidth",
802
- "border",
803
- "lineEndings"
804
- ],
805
- polygon: [
806
- "color",
807
- "interiorColor",
808
- "opacity",
809
- "strokeWidth",
810
- "border"
811
- ],
812
- polyline: [
813
- "color",
814
- "interiorColor",
815
- "opacity",
816
- "strokeWidth",
817
- "border",
818
- "lineEndings"
819
- ],
820
- ink: [
821
- "color",
822
- "opacity",
823
- "strokeWidth",
824
- "blendMode"
825
- ],
826
- "free-text": [
827
- "fontFamily",
828
- "fontSize",
829
- "fontColor",
830
- "textAlign",
831
- "opacity",
832
- "interiorColor",
833
- "color",
834
- "strokeWidth",
835
- "border"
836
- ],
837
- "free-text-callout": [
838
- "fontFamily",
839
- "fontSize",
840
- "fontColor",
841
- "textAlign",
842
- "opacity",
843
- "interiorColor",
844
- "color",
845
- "strokeWidth",
846
- "border",
847
- "lineEndings"
848
- ],
849
- highlight: [
850
- "color",
851
- "opacity",
852
- "blendMode"
853
- ],
854
- underline: [
855
- "color",
856
- "opacity",
857
- "blendMode"
858
- ],
859
- strikeout: [
860
- "color",
861
- "opacity",
862
- "blendMode"
863
- ],
864
- squiggly: [
865
- "color",
866
- "opacity",
867
- "blendMode"
868
- ],
869
- caret: ["color", "opacity"],
870
- redact: [
871
- "color",
872
- "interiorColor",
873
- "opacity",
874
- "fontFamily",
875
- "fontSize",
876
- "fontColor",
877
- "textAlign"
878
- ],
879
- stamp: [],
880
- link: ["link"],
881
- text: [
882
- "icon",
883
- "color",
884
- "opacity"
885
- ],
886
- "file-attachment": [
887
- "icon",
888
- "color",
889
- "opacity"
890
- ],
891
- "widget-text": [
892
- "color",
893
- "interiorColor",
894
- "strokeWidth",
895
- "border",
896
- "fontFamily",
897
- "fontSize",
898
- "fontColor",
899
- "textAlign"
900
- ],
901
- "widget-choice": [
902
- "color",
903
- "interiorColor",
904
- "strokeWidth",
905
- "border",
906
- "fontFamily",
907
- "fontSize",
908
- "fontColor",
909
- "textAlign"
910
- ],
911
- "widget-toggle": [
912
- "color",
913
- "interiorColor",
914
- "strokeWidth",
915
- "border"
916
- ]
917
- };
918
- const DRAW_TAGS = ["annotation-draw", "annotation-edit"];
919
- const MARKUP_TAGS = ["text-select", "annotation-edit"];
920
- /**
921
- * Whether arming this ANNOTATION tool is touch consent (`Tool.touchDirect`):
922
- * drag-CREATE tools — shape/ink draw and text-markup select — take
923
- * single-finger touch wholesale (finger draws/marks, two fingers navigate:
924
- * the drawing-app convention). Click-to-PLACE tools (stamp, note) do not: a
925
- * tap places without any claim, and drags keep scrolling. Derived from the
926
- * gesture tags, so custom registered tools inherit the right behavior. Only
927
- * annotation-owned tools run through this — the built-in `pointer` tool also
928
- * enables `text-select`, but it is registered by the hub, not here.
929
- */
930
- const isTouchDirect = (enables) => enables.has("annotation-draw") || enables.has("text-select");
931
- /**
932
- * The built-in tools — a data mirror of v2's registrations (shapes + lines + ink
933
- * + free text in the draw channel, text markup + caret behind text selection, and
934
- * the click-to-place stamp). Order is display-neutral; the toolbar owns layout.
935
- */
936
- const DEFAULT_TOOLS = [
937
- {
938
- id: "square",
939
- subtype: "square",
940
- cursor: "crosshair",
941
- enables: DRAW_TAGS,
942
- defaults: { strokeWidth: 6 },
943
- clickCreate: {
944
- width: 80,
945
- height: 60
946
- }
947
- },
948
- {
949
- id: "circle",
950
- subtype: "circle",
951
- cursor: "crosshair",
952
- enables: DRAW_TAGS,
953
- defaults: { strokeWidth: 6 },
954
- clickCreate: {
955
- width: 80,
956
- height: 60
957
- }
958
- },
959
- {
960
- id: "line",
961
- subtype: "line",
962
- cursor: "crosshair",
963
- enables: DRAW_TAGS,
964
- defaults: { strokeWidth: 6 },
965
- clickCreate: { length: 80 }
966
- },
967
- {
968
- id: "polygon",
969
- subtype: "polygon",
970
- cursor: "crosshair",
971
- enables: DRAW_TAGS,
972
- defaults: { strokeWidth: 6 }
973
- },
974
- {
975
- id: "polyline",
976
- subtype: "polyline",
977
- cursor: "crosshair",
978
- enables: DRAW_TAGS,
979
- defaults: { strokeWidth: 6 }
980
- },
981
- {
982
- id: "ink",
983
- subtype: "ink",
984
- cursor: "crosshair",
985
- enables: DRAW_TAGS,
986
- defaults: {
987
- color: "#ef4444",
988
- strokeWidth: 6,
989
- blendMode: "normal"
990
- },
991
- ink: { groupStrokesMs: 800 }
992
- },
993
- {
994
- id: "ink-highlight",
995
- extends: "ink",
996
- intent: "ink-highlight",
997
- defaults: {
998
- color: "#ffcd45",
999
- strokeWidth: 14,
1000
- blendMode: "multiply"
1001
- },
1002
- ink: { straighten: {
1003
- deviationThreshold: .15,
1004
- axisSnapDegrees: 15
1005
- } }
1006
- },
1007
- {
1008
- id: "free-text",
1009
- subtype: "free-text",
1010
- cursor: "crosshair",
1011
- enables: DRAW_TAGS,
1012
- defaults: { fontColor: "#ef4444" },
1013
- upright: true,
1014
- clickCreate: {
1015
- width: 180,
1016
- height: 40,
1017
- anchor: "top-left"
1018
- }
1019
- },
1020
- {
1021
- id: "free-text-callout",
1022
- subtype: "free-text-callout",
1023
- propsKind: "free-text",
1024
- cursor: "crosshair",
1025
- enables: DRAW_TAGS,
1026
- defaults: {
1027
- strokeWidth: 6,
1028
- lineEndings: { end: "open-arrow" }
1029
- },
1030
- upright: true
1031
- },
1032
- {
1033
- id: "highlight",
1034
- subtype: "highlight",
1035
- cursor: "default",
1036
- enables: MARKUP_TAGS,
1037
- defaults: {
1038
- color: "#ffe16a",
1039
- blendMode: "multiply"
1040
- },
1041
- selection: { kind: "markup" }
1042
- },
1043
- {
1044
- id: "redact",
1045
- subtype: "redact",
1046
- cursor: "crosshair",
1047
- enables: [
1048
- "text-select",
1049
- "annotation-draw",
1050
- "annotation-edit"
1051
- ],
1052
- defaults: {
1053
- color: "#e44234",
1054
- interiorColor: "#000000",
1055
- fontColor: "#ffffff",
1056
- opacity: 1
1057
- },
1058
- selection: { kind: "markup" }
1059
- },
1060
- {
1061
- id: "underline",
1062
- subtype: "underline",
1063
- cursor: "default",
1064
- enables: MARKUP_TAGS,
1065
- defaults: { color: "#ef4444" },
1066
- selection: { kind: "markup" }
1067
- },
1068
- {
1069
- id: "strikeout",
1070
- subtype: "strikeout",
1071
- cursor: "default",
1072
- enables: MARKUP_TAGS,
1073
- defaults: { color: "#ef4444" },
1074
- selection: { kind: "markup" }
1075
- },
1076
- {
1077
- id: "squiggly",
1078
- subtype: "squiggly",
1079
- cursor: "default",
1080
- enables: MARKUP_TAGS,
1081
- defaults: { color: "#ef4444" },
1082
- selection: { kind: "markup" }
1083
- },
1084
- {
1085
- id: "insert-text",
1086
- subtype: "caret",
1087
- propsKind: "caret",
1088
- preset: "caret",
1089
- cursor: "default",
1090
- enables: MARKUP_TAGS,
1091
- defaults: { color: "#ef4444" },
1092
- selection: {
1093
- kind: "text-edit",
1094
- operation: "insert"
1095
- }
1096
- },
1097
- {
1098
- id: "replace-text",
1099
- subtype: "strikeout",
1100
- propsKind: "strikeout",
1101
- cursor: "default",
1102
- enables: MARKUP_TAGS,
1103
- defaults: { color: "#ef4444" },
1104
- selection: {
1105
- kind: "text-edit",
1106
- operation: "replace"
1107
- }
1108
- },
1109
- {
1110
- id: "stamp",
1111
- subtype: "stamp",
1112
- cursor: "copy",
1113
- enables: ["annotation-place", "annotation-edit"],
1114
- source: {
1115
- kind: "prompt",
1116
- accept: "image/png,image/jpeg,application/pdf"
1117
- },
1118
- upright: true,
1119
- ghost: { mode: "footprint" }
1120
- },
1121
- {
1122
- id: "link",
1123
- subtype: "link",
1124
- cursor: "crosshair",
1125
- enables: DRAW_TAGS
1126
- },
1127
- {
1128
- id: "note",
1129
- subtype: "text",
1130
- cursor: "crosshair",
1131
- enables: ["annotation-place", "annotation-edit"],
1132
- defaults: {
1133
- icon: "comment",
1134
- color: "#facc15"
1135
- },
1136
- flags: {
1137
- noZoom: true,
1138
- noRotate: true
1139
- },
1140
- upright: true,
1141
- ghost: { mode: "footprint" }
1142
- },
1143
- {
1144
- id: "attachment",
1145
- subtype: "file-attachment",
1146
- cursor: "copy",
1147
- enables: ["annotation-place", "annotation-edit"],
1148
- source: { kind: "prompt" },
1149
- defaults: {
1150
- icon: "paperclip",
1151
- color: "#facc15"
1152
- },
1153
- flags: {
1154
- noZoom: true,
1155
- noRotate: true
1156
- },
1157
- upright: true,
1158
- ghost: { mode: "footprint" }
1159
- }
1160
- ];
1161
- /** Merge two default patches, `b` over `a`, with line endings merged per side. */
1162
- function mergeDefaults(a, b) {
1163
- if (!a) return b;
1164
- if (!b) return a;
1165
- const merged = {
1166
- ...a,
1167
- ...b
1168
- };
1169
- if (a.lineEndings || b.lineEndings) merged.lineEndings = {
1170
- ...a.lineEndings,
1171
- ...b.lineEndings
1172
- };
1173
- return merged;
1174
- }
1175
- /** Overlay a same-id override onto a base definition (configure a built-in). */
1176
- function mergeDef(base, over) {
1177
- return {
1178
- ...base,
1179
- ...over,
1180
- enables: over.enables ?? base.enables,
1181
- meta: base.meta || over.meta ? {
1182
- ...base.meta,
1183
- ...over.meta
1184
- } : void 0,
1185
- defaults: mergeDefaults(base.defaults, over.defaults),
1186
- flags: base.flags || over.flags ? {
1187
- ...base.flags,
1188
- ...over.flags
1189
- } : void 0,
1190
- ink: base.ink || over.ink ? {
1191
- ...base.ink,
1192
- ...over.ink
1193
- } : void 0
1194
- };
1195
- }
1196
- function validateDefaults(tool) {
1197
- if (!tool.defaults) return;
1198
- const allowed = TOOL_DEFAULT_KEYS[tool.subtype];
1199
- if (!allowed) return;
1200
- const keys = new Set(allowed);
1201
- for (const key of Object.keys(tool.defaults)) if (!keys.has(key)) throw new Error(`[annotation] tool '${tool.id}' does not support default '${key}' for '${tool.subtype}'`);
1202
- }
1203
- /**
1204
- * Resolve {@link DEFAULT_TOOLS} + embedder overrides into the ready-to-use table.
1205
- * A same-id override merges over the built-in; a new id adds a tool; `extends`
1206
- * inherits from an already-defined tool. Keyed by id, so lookups are O(1).
1207
- */
1208
- function buildToolRegistry(overrides = []) {
1209
- const defs = /* @__PURE__ */ new Map();
1210
- for (const d of DEFAULT_TOOLS) defs.set(d.id, d);
1211
- for (const o of overrides) {
1212
- const prev = defs.get(o.id);
1213
- defs.set(o.id, prev ? mergeDef(prev, o) : o);
1214
- }
1215
- const out = /* @__PURE__ */ new Map();
1216
- const resolving = /* @__PURE__ */ new Set();
1217
- const resolve = (id) => {
1218
- const cached = out.get(id);
1219
- if (cached) return cached;
1220
- const def = defs.get(id);
1221
- if (!def) throw new Error(`[annotation] tool '${id}' extends an unknown tool`);
1222
- let base;
1223
- if (def.extends && def.extends !== id && !resolving.has(def.extends)) {
1224
- resolving.add(id);
1225
- base = resolve(def.extends);
1226
- resolving.delete(id);
1227
- }
1228
- const subtype = def.subtype ?? base?.subtype ?? def.id;
1229
- const resolved = {
1230
- id: def.id,
1231
- subtype,
1232
- preset: def.preset ?? def.id,
1233
- propsKind: def.propsKind ?? base?.propsKind ?? subtype,
1234
- cursor: def.cursor ?? base?.cursor ?? "crosshair",
1235
- enables: new Set(def.enables ?? (base ? [...base.enables] : [])),
1236
- defaults: mergeDefaults(base?.defaults, def.defaults),
1237
- flags: base?.flags || def.flags ? {
1238
- ...base?.flags,
1239
- ...def.flags
1240
- } : void 0,
1241
- source: def.source ?? base?.source,
1242
- selection: def.selection ?? base?.selection,
1243
- intent: def.intent ?? base?.intent,
1244
- ink: base?.ink || def.ink ? {
1245
- ...base?.ink,
1246
- ...def.ink
1247
- } : void 0,
1248
- upright: def.upright ?? base?.upright ?? false,
1249
- clickCreate: def.clickCreate ?? base?.clickCreate ?? false,
1250
- ghost: def.ghost ?? base?.ghost ?? false,
1251
- meta: base?.meta || def.meta ? {
1252
- ...base?.meta,
1253
- ...def.meta
1254
- } : void 0
1255
- };
1256
- validateDefaults(resolved);
1257
- out.set(id, resolved);
1258
- return resolved;
1259
- };
1260
- for (const id of defs.keys()) resolve(id);
1261
- return out;
1262
- }
1263
- //#endregion
1264
1141
  //#region src/placement.ts
1265
1142
  /**
1266
1143
  * Per-kind CODE for the click-to-place ICON kinds (note / file attachment)
@@ -1327,8 +1204,11 @@ const TEXT_COMMIT_DEBOUNCE_MS = 250;
1327
1204
  */
1328
1205
  function createAnnotationCapability(ctx, config = {}) {
1329
1206
  const behaviors = [];
1330
- /** Per-annotation debounce timer for the engine `contents` write while typing. */
1207
+ /** Per-annotation debounce timer for the engine text write while typing. */
1331
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() ?? [];
1332
1212
  const configTools = config.tools ?? [];
1333
1213
  const registry = buildToolRegistry(configTools);
1334
1214
  /** The installed file-picker port (a DOM file dialog, wired by the framework
@@ -1452,10 +1332,18 @@ function createAnnotationCapability(ctx, config = {}) {
1452
1332
  };
1453
1333
  return v;
1454
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
+ };
1455
1342
  let selPropsCache = null;
1456
1343
  const memoSelectionProps = () => {
1457
1344
  const m = model();
1458
- if (selPropsCache && selPropsCache.model === m) return selPropsCache.v;
1345
+ const range = activeTextRange(m);
1346
+ if (selPropsCache && selPropsCache.model === m && selPropsCache.range === range) return selPropsCache.v;
1459
1347
  const members = m.selected.map((id) => m.byId[id]).filter((a) => !!a);
1460
1348
  const specs = sharedProps(members.map((a) => a.subtype));
1461
1349
  const values = {};
@@ -1467,6 +1355,16 @@ function createAnnotationCapability(ctx, config = {}) {
1467
1355
  const firstJson = JSON.stringify(first);
1468
1356
  if (members.some((a) => JSON.stringify(valueOf(a, spec.key)) !== firstJson)) mixed.push(spec.key);
1469
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
+ }
1470
1368
  const v = {
1471
1369
  specs,
1472
1370
  values,
@@ -1474,6 +1372,7 @@ function createAnnotationCapability(ctx, config = {}) {
1474
1372
  };
1475
1373
  selPropsCache = {
1476
1374
  model: m,
1375
+ range,
1477
1376
  v
1478
1377
  };
1479
1378
  return v;
@@ -1565,6 +1464,14 @@ function createAnnotationCapability(ctx, config = {}) {
1565
1464
  * rendered, never synced, and dies with the tool.
1566
1465
  */
1567
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
+ };
1568
1475
  /**
1569
1476
  * The desired stamp size (PDF points) from sniffed bytes: the image's
1570
1477
  * INTRINSIC pixel dimensions taken 1:1 as points (the v2 rule — keep the
@@ -1593,23 +1500,21 @@ function createAnnotationCapability(ctx, config = {}) {
1593
1500
  const meta = sniffBinaryMetadata(resolved.bytes);
1594
1501
  if (!meta) throw new Error("[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes");
1595
1502
  let preview = null;
1596
- if (input.preview) {
1503
+ if (typeof input.preview === "function") preview = input.preview;
1504
+ else if (input.preview) {
1597
1505
  const p = await resolveBinarySource(input.preview);
1598
- preview = {
1599
- bytes: new Uint8Array(p.bytes),
1600
- mimeType: p.mimeType
1601
- };
1602
- } else if (meta.mimeType !== "application/pdf") preview = {
1603
- bytes: new Uint8Array(resolved.bytes),
1604
- mimeType: meta.mimeType
1605
- };
1506
+ preview = fixedPreview(new Uint8Array(p.bytes), p.mimeType);
1507
+ } else if (meta.mimeType !== "application/pdf") preview = fixedPreview(new Uint8Array(resolved.bytes), meta.mimeType);
1606
1508
  armedStamp = {
1607
1509
  source: input.source,
1608
1510
  ...desiredStampSize(meta, input.targetWidth, input.intrinsicSize),
1609
- preview
1511
+ preview,
1512
+ previewCache: /* @__PURE__ */ new Map(),
1513
+ ...input.name !== void 0 ? { name: input.name } : {},
1514
+ ...input.subject !== void 0 ? { subject: input.subject } : {}
1610
1515
  };
1611
1516
  ctx.dispatch({ type: "STAMP_ARM_CHANGED" });
1612
- ctx.tryGet(InteractionToken)?.activateTool("stamp");
1517
+ ctx.tryGet(InteractionToken)?.activateTool(ARMED_STAMP_TOOL_ID);
1613
1518
  };
1614
1519
  const disarmStamp = () => {
1615
1520
  if (!armedStamp) return;
@@ -1736,28 +1641,49 @@ function createAnnotationCapability(ctx, config = {}) {
1736
1641
  * emits the repository's box rotation fields — the engine bakes the tilted
1737
1642
  * /AP exactly as an interactively rotated stamp round-trips, and the fit uses
1738
1643
  * the ROTATED footprint. Returns false when the page/document isn't ready. */
1739
- const createStampAt = (pon, point, source, desired, rotCW = 0) => {
1644
+ const createStampAt = (pon, point, source, desired, rotCW = 0, identity = {}) => {
1740
1645
  const doc = ctx.doc;
1741
1646
  const crop = cropOf(pon);
1742
- if (!doc || !crop) return false;
1647
+ if (!doc || !crop) return null;
1743
1648
  const box = fitStampBox(point, desired, {
1744
1649
  width: crop.right - crop.left,
1745
1650
  height: crop.top - crop.bottom
1746
1651
  }, rotCW);
1747
- doc.page(pon).annotations.create({
1652
+ return doc.page(pon).annotations.create({
1748
1653
  subtype: "stamp",
1749
1654
  ...boxGeomFields(box, rotCW, crop),
1750
1655
  source,
1751
- fit: "contain"
1656
+ fit: "contain",
1657
+ ...identity.name !== void 0 ? { name: identity.name } : {},
1658
+ ...identity.subject !== void 0 ? { subject: identity.subject } : {}
1752
1659
  }).then((res) => {
1753
1660
  syncDTO(res.created, "baked");
1754
1661
  apply({
1755
1662
  t: "select",
1756
1663
  ids: [refKey(res.created.ref)]
1757
1664
  });
1758
- }, (err) => console.error("[annotation] stamp placement failed:", err));
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));
1759
1674
  return true;
1760
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
+ };
1761
1687
  /** The active tool's upright counter-rotation for a click at `displayRotation`
1762
1688
  * (0 when the tool doesn't ask for upright, or the display isn't rotated). */
1763
1689
  const uprightRotFor = (displayRotation) => {
@@ -1768,10 +1694,13 @@ function createAnnotationCapability(ctx, config = {}) {
1768
1694
  const placeArmedStamp = (pon, point, displayRotation) => {
1769
1695
  const armed = armedStamp;
1770
1696
  if (!armed) return false;
1771
- return createStampAt(pon, point, armed.source, {
1697
+ return createStampAtSync(pon, point, armed.source, {
1772
1698
  width: armed.width,
1773
1699
  height: armed.height
1774
- }, uprightRotFor(displayRotation));
1700
+ }, uprightRotFor(displayRotation), {
1701
+ ...armed.name !== void 0 ? { name: armed.name } : {},
1702
+ ...armed.subject !== void 0 ? { subject: armed.subject } : {}
1703
+ });
1775
1704
  };
1776
1705
  /** Sniff a stamp source (rejecting non-image bytes) and place it at its
1777
1706
  * intrinsic size (fit to the page in {@link createStampAt}); `targetWidth`
@@ -1782,7 +1711,7 @@ function createAnnotationCapability(ctx, config = {}) {
1782
1711
  console.error("[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes");
1783
1712
  return;
1784
1713
  }
1785
- createStampAt(pon, point, source, desiredStampSize(meta, targetWidth), rotCW);
1714
+ createStampAtSync(pon, point, source, desiredStampSize(meta, targetWidth), rotCW);
1786
1715
  };
1787
1716
  /**
1788
1717
  * Click-to-place: resolve the ACTIVE tool's source spec. Fixed `bytes` place
@@ -1903,6 +1832,34 @@ function createAnnotationCapability(ctx, config = {}) {
1903
1832
  const res = await doc.page(pon).annotations.update(ref, patch);
1904
1833
  syncDTO(res.updated, "vector");
1905
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
+ };
1906
1863
  function apply(msg) {
1907
1864
  const [next, effects] = update(model(), msg);
1908
1865
  ctx.dispatch({
@@ -2519,6 +2476,36 @@ function createAnnotationCapability(ctx, config = {}) {
2519
2476
  if (patch.flags) out.flags = patch.flags;
2520
2477
  return out;
2521
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
+ };
2522
2509
  return {
2523
2510
  create: async (pon, draft) => {
2524
2511
  const doc = ctx.doc;
@@ -2532,6 +2519,7 @@ function createAnnotationCapability(ctx, config = {}) {
2532
2519
  return res.created.ref;
2533
2520
  },
2534
2521
  armStamp,
2522
+ placeStamp,
2535
2523
  disarmStamp,
2536
2524
  placeArmedStamp,
2537
2525
  requestStampAt,
@@ -2545,7 +2533,20 @@ function createAnnotationCapability(ctx, config = {}) {
2545
2533
  const g = ctx.getState().toolGhost;
2546
2534
  return g && g.pon === pon ? g : null;
2547
2535
  },
2548
- armedStampPreview: () => armedStamp?.preview ?? null,
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
+ },
2549
2550
  stampArmEpoch: () => ctx.getState().stampArmEpoch,
2550
2551
  setFilePickerProvider: (provider) => {
2551
2552
  filePickerProvider = provider;
@@ -2583,10 +2584,11 @@ function createAnnotationCapability(ctx, config = {}) {
2583
2584
  };
2584
2585
  },
2585
2586
  update: (ref, patch) => updateOne(ref, patch),
2586
- updateSelection: (patch) => apply({
2587
- t: "setProps",
2588
- patch
2589
- }),
2587
+ updateSelection,
2588
+ toggleTextFormat: (format) => {
2589
+ const current = memoSelectionProps().values[format];
2590
+ updateSelection({ [format]: !current });
2591
+ },
2590
2592
  updateSelectionFlags: (patch) => apply({
2591
2593
  t: "setFlags",
2592
2594
  patch
@@ -2925,30 +2927,37 @@ function createAnnotationCapability(ctx, config = {}) {
2925
2927
  id: refKey(ref),
2926
2928
  text
2927
2929
  });
2928
- const key = refKey(ref);
2929
- clearTimeout(textTimers.get(key));
2930
- textTimers.set(key, setTimeout(() => {
2931
- textTimers.delete(key);
2932
- const pon = ponForRef(ref);
2933
- if (pon != null) ctx.doc?.page(pon).annotations.update(ref, {
2934
- subtype: "free-text",
2935
- contents: text
2936
- }).then(() => {}, () => {});
2937
- }, TEXT_COMMIT_DEBOUNCE_MS));
2930
+ scheduleTextCommit(ref);
2938
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),
2939
2955
  endTextEdit: () => {
2940
- for (const t of textTimers.values()) clearTimeout(t);
2941
- const id = model().editing;
2942
- const a = id ? model().byId[id] : null;
2943
- if (a?.ref) {
2944
- const pon = ponForRef(a.ref);
2945
- const text = a.data?.contents ?? "";
2946
- if (pon != null) ctx.doc?.page(pon).annotations.update(a.ref, {
2947
- subtype: "free-text",
2948
- contents: text
2949
- }).then(() => {}, () => {});
2950
- }
2951
- textTimers.clear();
2956
+ flushTextCommits();
2957
+ if (ctx.getState().textSelection) ctx.dispatch({
2958
+ type: "SET_TEXT_SELECTION",
2959
+ selection: null
2960
+ });
2952
2961
  apply({ t: "endTextEdit" });
2953
2962
  },
2954
2963
  commitScriptEffects: async (entries) => {
@@ -3027,6 +3036,6 @@ function createAnnotationCapability(ctx, config = {}) {
3027
3036
  };
3028
3037
  }
3029
3038
  //#endregion
3030
- export { fromDTO as a, isTouchDirect as i, DEFAULT_TOOLS as n, toCreateDraft as o, buildToolRegistry as r, toPatch as s, createAnnotationCapability as t };
3039
+ export { toPatch as i, fromDTO as n, toCreateDraft as r, createAnnotationCapability as t };
3031
3040
 
3032
- //# sourceMappingURL=capability-DBsr2D-L.js.map
3041
+ //# sourceMappingURL=capability-B_dkoB1K.js.map