dsh-figmark 1.0.1 → 1.1.1

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.
package/lib/client.js CHANGED
@@ -25,6 +25,9 @@ window.__ModuleLoader__.load({
25
25
  redo: "重做",
26
26
  clear: "清空",
27
27
  detail: "这个元素的详细说明",
28
+ addComment: "添加评论…",
29
+ pinSize: "标记大小",
30
+ weight: "线条粗细",
28
31
  detailPlaceholder: "这一处具体要改什么(例如「这个标号应指向滑套 34」)",
29
32
  summary: "总体目标",
30
33
  summaryPlaceholder: "可选:这张图整体要往哪个方向改(智能体会连同下面逐条说明一起读)",
@@ -110,6 +113,9 @@ window.__ModuleLoader__.load({
110
113
  redo: "Redo",
111
114
  clear: "Clear",
112
115
  detail: "What this element needs",
116
+ addComment: "Add a comment…",
117
+ pinSize: "Marker size",
118
+ weight: "Line weight",
113
119
  detailPlaceholder: "What should change here (for example \"this numeral should point at the sleeve 34\")",
114
120
  summary: "Overall goal",
115
121
  summaryPlaceholder: "Optional: what this figure should become overall (the agent reads it with the marks below)",
@@ -253,6 +259,26 @@ window.__ModuleLoader__.load({
253
259
  }
254
260
  //#endregion
255
261
  //#region src/shared/annotation.ts
262
+ /** Thinnest stroke a mark can be drawn at, as a multiple of the default width. */
263
+ const WEIGHT_MIN = .5;
264
+ /**
265
+ * Hold a stroke weight inside the range the painter can draw.
266
+ *
267
+ * @param value - the weight to hold.
268
+ * @returns the value, pulled to the nearest end of the range.
269
+ */
270
+ function clampWeight(value) {
271
+ return Math.min(4, Math.max(WEIGHT_MIN, value));
272
+ }
273
+ /**
274
+ * Whether a value is a stroke weight this plugin would write.
275
+ *
276
+ * @param value - the value to test.
277
+ * @returns true when it is one.
278
+ */
279
+ function isWeight(value) {
280
+ return typeof value === "number" && Number.isFinite(value) && value >= .5 && value <= 4;
281
+ }
256
282
  /**
257
283
  * Suffix naming one page's files inside a paged document's name.
258
284
  * @param page - 1-based page, or undefined for a single-surface figure.
@@ -989,13 +1015,46 @@ window.__ModuleLoader__.load({
989
1015
  }
990
1016
  //#endregion
991
1017
  //#region src/client/render.ts
992
- /** Stroke width of every mark, in figure pixels. */
1018
+ /** Stroke width of a mark drawn at weight 1 on a reference-sized figure, in pixels. */
993
1019
  const STROKE_WIDTH = 2.5;
1020
+ /**
1021
+ * The stroke width one mark is drawn at.
1022
+ *
1023
+ * A mark's weight is a multiple of {@link STROKE_WIDTH}, so the default is the
1024
+ * width marks have always had and a heavier mark is the same drawing with a
1025
+ * thicker pen. The arrow's barbs are weighed by the same multiple, because an
1026
+ * arrow drawn with a thick pen has a thick head.
1027
+ *
1028
+ * The figure's own unit multiplies everything drawn on it, so the same mark is a
1029
+ * bold line on a thumbnail and a hairline on a scan — see `./scale`.
1030
+ *
1031
+ * @param mark - the mark to weigh.
1032
+ * @param unit - the figure's size multiple, 1 for a reference-sized figure.
1033
+ * @returns its stroke width in figure pixels.
1034
+ */
1035
+ function strokeWidthOf(mark, unit = 1) {
1036
+ return STROKE_WIDTH * (mark.weight ?? 1) * unit;
1037
+ }
994
1038
  /** Arrow head length, in figure pixels. */
995
1039
  const ARROW_HEAD = 14;
996
1040
  /** Half-angle of the arrow head, in radians. */
997
1041
  const ARROW_SPREAD = Math.PI / 7;
998
1042
  /**
1043
+ * Half-width of one pin's head, in figure pixels.
1044
+ *
1045
+ * A pin is a marker the reader compares against the line work, so it is sized like a
1046
+ * stroke: by the figure's unit and by the mark's own weight. The weight is reused
1047
+ * rather than given its own field because it already means "how big this mark is" to
1048
+ * the reader, and one rail on the left is enough — a stroke and a pin are never the
1049
+ * same mark, so the one number never has to mean two things at once.
1050
+ * @param mark - the point mark.
1051
+ * @param unit - the figure's size multiple.
1052
+ * @returns its head's half-width in figure pixels.
1053
+ */
1054
+ function pinRadiusOf(mark, unit = 1) {
1055
+ return 10 * (mark.weight ?? 1) * unit;
1056
+ }
1057
+ /**
999
1058
  * How far a pin's head sits above its coordinate, as a multiple of its half-width.
1000
1059
  *
1001
1060
  * A map pin puts its *tip* on the coordinate it names, so the head stands clear of
@@ -1073,12 +1132,13 @@ window.__ModuleLoader__.load({
1073
1132
  * @param points - the arrow's tail and tip.
1074
1133
  * @returns tail, tip and both barbs; the stored points alone when it is half drawn.
1075
1134
  */
1076
- function arrowBarbs(points) {
1135
+ function arrowBarbs(points, weight = 1, unit = 1) {
1077
1136
  const [first, second] = points;
1078
1137
  if (first === void 0 || second === void 0) return points;
1079
1138
  const angle = Math.atan2(second[1] - first[1], second[0] - first[0]);
1080
- const left = [second[0] - ARROW_HEAD * Math.cos(angle - ARROW_SPREAD), second[1] - ARROW_HEAD * Math.sin(angle - ARROW_SPREAD)];
1081
- const right = [second[0] - ARROW_HEAD * Math.cos(angle + ARROW_SPREAD), second[1] - ARROW_HEAD * Math.sin(angle + ARROW_SPREAD)];
1139
+ const head = ARROW_HEAD * weight * unit;
1140
+ const left = [second[0] - head * Math.cos(angle - ARROW_SPREAD), second[1] - head * Math.sin(angle - ARROW_SPREAD)];
1141
+ const right = [second[0] - head * Math.cos(angle + ARROW_SPREAD), second[1] - head * Math.sin(angle + ARROW_SPREAD)];
1082
1142
  return [
1083
1143
  ...points,
1084
1144
  left,
@@ -1096,7 +1156,7 @@ window.__ModuleLoader__.load({
1096
1156
  switch (mark.kind) {
1097
1157
  case "arrow": {
1098
1158
  if (second === void 0) return void 0;
1099
- const [left, right] = arrowBarbs(mark.points).slice(2);
1159
+ const [left, right] = arrowBarbs(mark.points, mark.weight ?? 1).slice(2);
1100
1160
  return `M ${n(first[0])} ${n(first[1])} L ${n(second[0])} ${n(second[1])} M ${n(left[0])} ${n(left[1])} L ${n(second[0])} ${n(second[1])} L ${n(right[0])} ${n(right[1])}`;
1101
1161
  }
1102
1162
  case "rect": {
@@ -1131,10 +1191,10 @@ window.__ModuleLoader__.load({
1131
1191
  * @param mark - the point mark.
1132
1192
  * @returns the marker's path, number centre and box, or undefined with no coordinate.
1133
1193
  */
1134
- function pinGeometry(mark) {
1194
+ function pinGeometry(mark, unit = 1) {
1135
1195
  const tip = mark.points[0];
1136
1196
  if (tip === void 0) return void 0;
1137
- const radius = 10;
1197
+ const radius = pinRadiusOf(mark, unit);
1138
1198
  const lift = radius * PIN_LIFT;
1139
1199
  const [tipX, tipY] = tip;
1140
1200
  const up = tipY - lift - radius >= 0;
@@ -1168,16 +1228,16 @@ window.__ModuleLoader__.load({
1168
1228
  * @param mark - the point mark.
1169
1229
  * @returns path data, or undefined when the mark carries no coordinate.
1170
1230
  */
1171
- function pinPathData(mark) {
1172
- return pinGeometry(mark)?.path;
1231
+ function pinPathData(mark, unit = 1) {
1232
+ return pinGeometry(mark, unit)?.path;
1173
1233
  }
1174
1234
  /**
1175
1235
  * Where the number inside a `point` mark's marker sits.
1176
1236
  * @param mark - the point mark.
1177
1237
  * @returns the number's centre, or undefined when the mark carries no coordinate.
1178
1238
  */
1179
- function pinNumberCentre(mark) {
1180
- return pinGeometry(mark)?.number;
1239
+ function pinNumberCentre(mark, unit = 1) {
1240
+ return pinGeometry(mark, unit)?.number;
1181
1241
  }
1182
1242
  /**
1183
1243
  * The box a `point` mark's pin covers.
@@ -1190,8 +1250,8 @@ window.__ModuleLoader__.load({
1190
1250
  * @param mark - the point mark.
1191
1251
  * @returns the pin's box, or undefined when the mark carries no point.
1192
1252
  */
1193
- function pointMarkBox(mark) {
1194
- return pinGeometry(mark)?.box;
1253
+ function pointMarkBox(mark, unit = 1) {
1254
+ return pinGeometry(mark, unit)?.box;
1195
1255
  }
1196
1256
  /**
1197
1257
  * The box a mark covers once it is drawn, rotation included.
@@ -1204,14 +1264,14 @@ window.__ModuleLoader__.load({
1204
1264
  * @param mark - the mark to measure.
1205
1265
  * @returns the box, or undefined when the mark carries no coordinate.
1206
1266
  */
1207
- function markOutline(mark) {
1208
- if (mark.kind === "point") return pointMarkBox(mark);
1209
- const geometry = mark.kind === "arrow" ? arrowBarbs(mark.points) : mark.points;
1267
+ function markOutline(mark, unit = 1) {
1268
+ if (mark.kind === "point") return pointMarkBox(mark, unit);
1269
+ const geometry = mark.kind === "arrow" ? arrowBarbs(mark.points, mark.weight ?? 1, unit) : mark.points;
1210
1270
  const straight = pointsBox(geometry);
1211
1271
  if (straight === void 0) return void 0;
1212
1272
  const radians = markRadians(mark);
1213
1273
  const box = radians === 0 ? straight : turnedBox(geometry, straight, radians);
1214
- const grow = STROKE_WIDTH / 2;
1274
+ const grow = strokeWidthOf(mark, unit) / 2;
1215
1275
  return {
1216
1276
  x: box.x - grow,
1217
1277
  y: box.y - grow,
@@ -1285,6 +1345,42 @@ window.__ModuleLoader__.load({
1285
1345
  return `rotate(${n(radians * 180 / Math.PI)} ${n(cx)} ${n(cy)})`;
1286
1346
  }
1287
1347
  //#endregion
1348
+ //#region src/client/scale.ts
1349
+ /**
1350
+ * How big the marks should be for the figure they are drawn on.
1351
+ *
1352
+ * A stroke is not a physical thing: 2.5 pixels is a bold line on a 300px thumbnail
1353
+ * and a hairline on a 4000px scan. Marks are comparisons the reader makes against
1354
+ * the line work underneath them, so they have to be sized in the same currency as
1355
+ * the figure — a fraction of it — rather than in the figure's pixels.
1356
+ *
1357
+ * The reference edge is the size a science figure is usually exported at. An
1358
+ * ordinary figure then gets the unit it always had, and only the outliers (a
1359
+ * thumbnail, a poster) are scaled. The range is clamped rather than proportional
1360
+ * because a 20000px figure would otherwise get strokes 40px wide, which reads as a
1361
+ * highlighter rather than a line.
1362
+ * @module dsh-figmark/client/scale
1363
+ */
1364
+ /** The figure edge, in pixels, that gets the unit it always had. */
1365
+ const REFERENCE_EDGE = 1200;
1366
+ /** Smallest multiple of the base sizes, for figures far below the reference. */
1367
+ const MIN_UNIT = .75;
1368
+ /**
1369
+ * The size everything drawn on a figure is multiplied by.
1370
+ *
1371
+ * Measured against the figure's longer edge: that is the dimension a reader judges
1372
+ * a stroke against, and using the area would shrink a tall thin panel's marks for a
1373
+ * reason the eye does not see.
1374
+ * @param width - the figure's intrinsic width, in figure pixels.
1375
+ * @param height - the figure's intrinsic height, in figure pixels.
1376
+ * @returns the multiple of the base sizes, held inside {@link MIN_UNIT} and {@link MAX_UNIT}.
1377
+ */
1378
+ function figureUnit(width, height) {
1379
+ const edge = Math.max(width, height);
1380
+ if (!Number.isFinite(edge) || edge <= 0) return 1;
1381
+ return Math.min(3, Math.max(MIN_UNIT, edge / REFERENCE_EDGE));
1382
+ }
1383
+ //#endregion
1288
1384
  //#region src/client/Canvas.tsx
1289
1385
  /**
1290
1386
  * The drawing surface.
@@ -1295,6 +1391,19 @@ window.__ModuleLoader__.load({
1295
1391
  * means the same thing at any zoom or pane width.
1296
1392
  * @module dsh-figmark/client/Canvas
1297
1393
  */
1394
+ /**
1395
+ * The `weight` field of a new mark, or nothing when it is the default.
1396
+ *
1397
+ * Absent means the default throughout this plugin, so writing `weight: 1` would put
1398
+ * a field into every mark that says nothing — sidecars would grow a key for every
1399
+ * mark ever drawn, and every comparison of two documents would see a difference
1400
+ * where there is none.
1401
+ * @param weight - the weight the next stroke is drawn at.
1402
+ * @returns the field, or an empty object for the default.
1403
+ */
1404
+ function weightField(weight) {
1405
+ return weight === 1 ? {} : { weight };
1406
+ }
1298
1407
  /** Hit width of an invisible selection stroke, in figure pixels. */
1299
1408
  const HIT_WIDTH = 14;
1300
1409
  /** Minimum distance between recorded freehand samples, in figure pixels. */
@@ -1318,6 +1427,8 @@ window.__ModuleLoader__.load({
1318
1427
  */
1319
1428
  function Canvas(props) {
1320
1429
  const { width, height, marks, tool, color, containerRef, anchoring } = props;
1430
+ const [originX, originY] = props.origin ?? [0, 0];
1431
+ const weight = props.weight ?? 1;
1321
1432
  /**
1322
1433
  * Each mark's number, by id.
1323
1434
  *
@@ -1326,6 +1437,14 @@ window.__ModuleLoader__.load({
1326
1437
  * otherwise.
1327
1438
  */
1328
1439
  const numbers = new Map(marks.map((mark, index) => [mark.id, props.numbering?.get(mark.id) ?? index + 1]));
1440
+ /**
1441
+ * How big everything is drawn here, as a multiple of the base sizes.
1442
+ *
1443
+ * Derived from the surface rather than passed in: every mark on this canvas shares
1444
+ * one figure, so one number decides the pen for all of them, and a caller that
1445
+ * forgot to thread it through could not make a mark disagree with its hit area.
1446
+ */
1447
+ const unit = figureUnit(width, height);
1329
1448
  const [draft, setDraft] = (0, react.useState)(null);
1330
1449
  const [press, setPress] = (0, react.useState)(null);
1331
1450
  const drawing = (0, react.useRef)(false);
@@ -1370,7 +1489,7 @@ window.__ModuleLoader__.load({
1370
1489
  const rect = event.currentTarget.getBoundingClientRect();
1371
1490
  const scaleX = rect.width === 0 ? 1 : width / rect.width;
1372
1491
  const scaleY = rect.height === 0 ? 1 : height / rect.height;
1373
- return [(event.clientX - rect.left) * scaleX, (event.clientY - rect.top) * scaleY];
1492
+ return [(event.clientX - rect.left) * scaleX - originX, (event.clientY - rect.top) * scaleY - originY];
1374
1493
  };
1375
1494
  /**
1376
1495
  * Describe what the mark landed on.
@@ -1552,6 +1671,7 @@ window.__ModuleLoader__.load({
1552
1671
  kind: "point",
1553
1672
  color,
1554
1673
  points: [point],
1674
+ ...weightField(weight),
1555
1675
  ...anchor === void 0 ? {} : { anchor }
1556
1676
  };
1557
1677
  props.onAdd(mark);
@@ -1567,7 +1687,8 @@ window.__ModuleLoader__.load({
1567
1687
  id: nextMarkId(),
1568
1688
  kind: tool,
1569
1689
  color,
1570
- points: seed
1690
+ points: seed,
1691
+ ...weightField(weight)
1571
1692
  });
1572
1693
  };
1573
1694
  const move = (event) => {
@@ -1593,10 +1714,18 @@ window.__ModuleLoader__.load({
1593
1714
  ...press,
1594
1715
  snap: event.shiftKey
1595
1716
  };
1717
+ const edit = editFor(held, point, dx, dy);
1596
1718
  setPress({
1597
1719
  ...held,
1598
- edit: editFor(held, point, dx, dy)
1720
+ edit
1599
1721
  });
1722
+ const base = props.marks.find((candidate) => candidate.id === press.id);
1723
+ /* v8 ignore next -- a press always begins on a mark that is still in the list. */
1724
+ if (base === void 0) return;
1725
+ const live = { ...base };
1726
+ if (edit.points !== void 0) live.points = edit.points;
1727
+ if (edit.rotation !== void 0) live.rotation = edit.rotation;
1728
+ props.onLiveEdit?.(live);
1600
1729
  return;
1601
1730
  }
1602
1731
  if (!drawing.current || draft === null) return;
@@ -1624,6 +1753,7 @@ window.__ModuleLoader__.load({
1624
1753
  if (press !== null) {
1625
1754
  const done = press.edit;
1626
1755
  setPress(null);
1756
+ props.onLiveEdit?.(null);
1627
1757
  if (done === null) return;
1628
1758
  if (press.handle === void 0) {
1629
1759
  /* v8 ignore next -- a move always carries the geometry it moved to. */
@@ -1713,8 +1843,8 @@ window.__ModuleLoader__.load({
1713
1843
  * compared to a washed-out sticker.
1714
1844
  */
1715
1845
  const renderPoint = (mark) => {
1716
- const pin = pinPathData(mark);
1717
- const centre = pinNumberCentre(mark);
1846
+ const pin = pinPathData(mark, unit);
1847
+ const centre = pinNumberCentre(mark, unit);
1718
1848
  /* v8 ignore next -- a point mark carries exactly the one coordinate it was placed at. */
1719
1849
  if (pin === void 0 || centre === void 0) return null;
1720
1850
  const number = numbers.get(mark.id);
@@ -1723,21 +1853,21 @@ window.__ModuleLoader__.load({
1723
1853
  d: pin,
1724
1854
  fill: "#ffffff",
1725
1855
  stroke: "#ffffff",
1726
- strokeWidth: 4.5,
1856
+ strokeWidth: 4.5 * unit,
1727
1857
  strokeLinejoin: "round"
1728
1858
  }),
1729
1859
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1730
1860
  d: pin,
1731
1861
  fill: mark.color,
1732
1862
  stroke: "#ffffff",
1733
- strokeWidth: 1.5,
1863
+ strokeWidth: 1.5 * unit,
1734
1864
  strokeLinejoin: "round"
1735
1865
  }),
1736
1866
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1737
1867
  x: centre[0],
1738
1868
  y: centre[1],
1739
1869
  fontFamily: PIN_FONT_FAMILY,
1740
- fontSize: 11,
1870
+ fontSize: 11 * unit,
1741
1871
  fontWeight: 700,
1742
1872
  fill: PIN_NUMBER_COLOR,
1743
1873
  textAnchor: "middle",
@@ -1755,19 +1885,19 @@ window.__ModuleLoader__.load({
1755
1885
  * handles that would do nothing.
1756
1886
  */
1757
1887
  const renderPointFrame = (shown) => {
1758
- const box = pointMarkBox(shown);
1888
+ const box = pointMarkBox(shown, unit);
1759
1889
  /* v8 ignore next -- a point mark carries exactly the one coordinate it was placed at. */
1760
1890
  if (box === void 0) return null;
1761
1891
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
1762
- x: box.x - 3,
1763
- y: box.y - 3,
1764
- width: box.width + 6,
1765
- height: box.height + 6,
1766
- rx: 3,
1892
+ x: box.x - 3 * unit,
1893
+ y: box.y - 3 * unit,
1894
+ width: box.width + 6 * unit,
1895
+ height: box.height + 6 * unit,
1896
+ rx: 3 * unit,
1767
1897
  fill: "none",
1768
1898
  stroke: "#4c8dff",
1769
- strokeWidth: 1,
1770
- strokeDasharray: "3 2",
1899
+ strokeWidth: unit,
1900
+ strokeDasharray: `${3 * unit} ${2 * unit}`,
1771
1901
  style: { pointerEvents: "none" }
1772
1902
  });
1773
1903
  };
@@ -1777,7 +1907,7 @@ window.__ModuleLoader__.load({
1777
1907
  const selected = props.selectedId === mark.id;
1778
1908
  const transform = markTransform(shown);
1779
1909
  const isPoint = mark.kind === "point";
1780
- const pin = isPoint ? pinPathData(shown) : void 0;
1910
+ const pin = isPoint ? pinPathData(shown, unit) : void 0;
1781
1911
  /**
1782
1912
  * What a press on the drawn shape starts.
1783
1913
  *
@@ -1789,7 +1919,7 @@ window.__ModuleLoader__.load({
1789
1919
  d: pin,
1790
1920
  fill: "transparent",
1791
1921
  stroke: "transparent",
1792
- strokeWidth: HIT_WIDTH,
1922
+ strokeWidth: HIT_WIDTH * unit,
1793
1923
  style: {
1794
1924
  pointerEvents: "all",
1795
1925
  cursor: "pointer"
@@ -1814,7 +1944,7 @@ window.__ModuleLoader__.load({
1814
1944
  d: path,
1815
1945
  fill: "none",
1816
1946
  stroke: "#ffffff",
1817
- strokeWidth: 5,
1947
+ strokeWidth: strokeWidthOf(shown, unit) + 2.5 * unit,
1818
1948
  strokeLinecap: "round",
1819
1949
  strokeLinejoin: "round"
1820
1950
  }),
@@ -1822,7 +1952,7 @@ window.__ModuleLoader__.load({
1822
1952
  d: path,
1823
1953
  fill: "none",
1824
1954
  stroke: shown.color,
1825
- strokeWidth: STROKE_WIDTH,
1955
+ strokeWidth: strokeWidthOf(shown, unit),
1826
1956
  strokeLinecap: "round",
1827
1957
  strokeLinejoin: "round"
1828
1958
  }),
@@ -1830,7 +1960,7 @@ window.__ModuleLoader__.load({
1830
1960
  d: path,
1831
1961
  fill: "none",
1832
1962
  stroke: "transparent",
1833
- strokeWidth: HIT_WIDTH,
1963
+ strokeWidth: HIT_WIDTH * unit,
1834
1964
  style: {
1835
1965
  pointerEvents: "stroke",
1836
1966
  cursor: "pointer"
@@ -1851,9 +1981,13 @@ window.__ModuleLoader__.load({
1851
1981
  ]
1852
1982
  }, mark.id);
1853
1983
  };
1854
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1984
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1855
1985
  className: "da-overlay",
1856
1986
  "data-da-overlay": "",
1987
+ style: {
1988
+ left: originX,
1989
+ top: originY
1990
+ },
1857
1991
  width,
1858
1992
  height,
1859
1993
  viewBox: `0 0 ${width} ${height}`,
@@ -1875,7 +2009,10 @@ window.__ModuleLoader__.load({
1875
2009
  }
1876
2010
  props.onSelect(null);
1877
2011
  },
1878
- children: [marks.map((mark) => renderMark(mark, false)), draft === null ? null : renderMark(draft, true)]
2012
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("g", {
2013
+ transform: `translate(${originX} ${originY})`,
2014
+ children: [marks.map((mark) => renderMark(mark, false)), draft === null ? null : renderMark(draft, true)]
2015
+ })
1879
2016
  });
1880
2017
  }
1881
2018
  /** A difference that never changes, for a body that has no edit yet. */
@@ -2437,6 +2574,44 @@ window.__ModuleLoader__.load({
2437
2574
  const existing = shell.snapshot.draft.trim();
2438
2575
  shell.setDraft(existing === "" ? message : `${existing}\n\n${message}`);
2439
2576
  }
2577
+ /**
2578
+ * The box the whole drawing occupies: the figure, plus wherever the marks reach.
2579
+ *
2580
+ * A figure with every mark inside it gets exactly its own box back, which is the
2581
+ * case the overlay has always drawn; the growth only happens for marks that leave
2582
+ * it. The margin is applied only on sides that actually grew, so an ordinary figure
2583
+ * is not padded on all four sides for nothing.
2584
+ * @param width - the figure's width, in figure pixels.
2585
+ * @param height - the figure's height, in figure pixels.
2586
+ * @param marks - the marks drawn on it.
2587
+ * @param unit - the figure's size multiple, so outlines are measured as drawn.
2588
+ * @returns the box the drawing occupies, in figure coordinates.
2589
+ */
2590
+ function plotBounds(width, height, marks, unit = 1) {
2591
+ let left = 0;
2592
+ let top = 0;
2593
+ let right = width;
2594
+ let bottom = height;
2595
+ for (const mark of marks) {
2596
+ const box = markOutline(mark, unit);
2597
+ /* v8 ignore next -- a mark with no coordinate covers nothing to grow for. */
2598
+ if (box === void 0) continue;
2599
+ left = Math.min(left, box.x);
2600
+ top = Math.min(top, box.y);
2601
+ right = Math.max(right, box.x + box.width);
2602
+ bottom = Math.max(bottom, box.y + box.height);
2603
+ }
2604
+ const padLeft = left < 0 ? 12 : 0;
2605
+ const padTop = top < 0 ? 12 : 0;
2606
+ const padRight = right > width ? 12 : 0;
2607
+ const padBottom = bottom > height ? 12 : 0;
2608
+ return {
2609
+ x: left - padLeft,
2610
+ y: top - padTop,
2611
+ width: right + padRight - (left - padLeft),
2612
+ height: bottom + padBottom - (top - padTop)
2613
+ };
2614
+ }
2440
2615
  //#endregion
2441
2616
  //#region src/client/export.ts
2442
2617
  /**
@@ -2444,17 +2619,19 @@ window.__ModuleLoader__.load({
2444
2619
  *
2445
2620
  * @param mark - the mark to draw.
2446
2621
  * @param number - the mark's number in the list, when it carries one.
2622
+ * @param unit - the figure's size multiple, so the export is drawn at the same
2623
+ * weight the reader saw.
2447
2624
  * @returns the mark's elements.
2448
2625
  */
2449
- function markSvg(mark, number) {
2626
+ function markSvg(mark, number, unit) {
2450
2627
  const path = markPathData(mark);
2451
2628
  const parts = [];
2452
- if (path !== void 0) parts.push(`<path d="${path}" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="${path}" fill="none" stroke="${mark.color}" stroke-width="${STROKE_WIDTH}" stroke-linecap="round" stroke-linejoin="round"/>`);
2453
- const pin = mark.kind === "point" ? pinPathData(mark) : void 0;
2454
- const centre = mark.kind === "point" ? pinNumberCentre(mark) : void 0;
2629
+ if (path !== void 0) parts.push(`<path d="${path}" fill="none" stroke="#ffffff" stroke-width="${strokeWidthOf(mark, unit) + 2.5 * unit}" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="${path}" fill="none" stroke="${mark.color}" stroke-width="${strokeWidthOf(mark, unit)}" stroke-linecap="round" stroke-linejoin="round"/>`);
2630
+ const pin = mark.kind === "point" ? pinPathData(mark, unit) : void 0;
2631
+ const centre = mark.kind === "point" ? pinNumberCentre(mark, unit) : void 0;
2455
2632
  if (pin !== void 0 && centre !== void 0) {
2456
- parts.push(`<path d="${pin}" fill="${mark.color}" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/>`);
2457
- if (number !== void 0) parts.push(`<text x="${centre[0]}" y="${centre[1]}" font-family="${PIN_FONT_FAMILY}" font-size="11" font-weight="700" fill="${PIN_NUMBER_COLOR}" text-anchor="middle" dominant-baseline="central">${number}</text>`);
2633
+ parts.push(`<path d="${pin}" fill="${mark.color}" stroke="#ffffff" stroke-width="${2 * unit}" stroke-linejoin="round"/>`);
2634
+ if (number !== void 0) parts.push(`<text x="${centre[0]}" y="${centre[1]}" font-family="${PIN_FONT_FAMILY}" font-size="${11 * unit}" font-weight="700" fill="${PIN_NUMBER_COLOR}" text-anchor="middle" dominant-baseline="central">${number}</text>`);
2458
2635
  }
2459
2636
  return parts.join("");
2460
2637
  }
@@ -2490,7 +2667,27 @@ window.__ModuleLoader__.load({
2490
2667
  */
2491
2668
  function composeReviewSvg(layer, marks, numbering) {
2492
2669
  const numbers = new Map(marks.map((mark, index) => [mark.id, numbering?.get(mark.id) ?? index + 1]));
2493
- return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${layer.width}" height="${layer.height}" viewBox="0 0 ${layer.width} ${layer.height}"><rect x="0" y="0" width="${layer.width}" height="${layer.height}" fill="#ffffff"/>` + figureSvg(layer) + marks.map((mark) => markSvg(mark, numbers.get(mark.id))).join("") + "</svg>";
2670
+ const unit = figureUnit(layer.width, layer.height);
2671
+ const paper = plotBounds(layer.width, layer.height, marks, unit);
2672
+ const shift = `translate(${-paper.x} ${-paper.y})`;
2673
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${paper.width}" height="${paper.height}" viewBox="0 0 ${paper.width} ${paper.height}"><rect x="0" y="0" width="${paper.width}" height="${paper.height}" fill="#ffffff"/><g transform="${shift}">` + figureSvg(layer) + marks.map((mark) => markSvg(mark, numbers.get(mark.id), unit)).join("") + "</g></svg>";
2674
+ }
2675
+ /**
2676
+ * The size of the picture {@link composeReviewSvg} builds.
2677
+ *
2678
+ * A caller has to know it before rasterizing: an SVG rendered at the figure's size
2679
+ * would crop away exactly the marks that made the paper grow.
2680
+ * @param layer - the figure to place underneath.
2681
+ * @param marks - marks in draw order.
2682
+ * @param unit - the figure's size multiple.
2683
+ * @returns the paper's width and height, in figure pixels.
2684
+ */
2685
+ function reviewSize(layer, marks, unit = figureUnit(layer.width, layer.height)) {
2686
+ const paper = plotBounds(layer.width, layer.height, marks, unit);
2687
+ return {
2688
+ width: paper.width,
2689
+ height: paper.height
2690
+ };
2494
2691
  }
2495
2692
  /**
2496
2693
  * Rasterization scale for one review image.
@@ -2787,6 +2984,22 @@ window.__ModuleLoader__.load({
2787
2984
  }
2788
2985
  //#endregion
2789
2986
  //#region src/client/preferences.ts
2987
+ /**
2988
+ * The drawing choices a reader expects to still be there after a reload.
2989
+ *
2990
+ * Marks and their sentences belong to the annotation and live in its sidecar. The
2991
+ * colour the reader is drawing in, and how large the numbers are, are a different
2992
+ * kind of thing: they describe how the *next* mark should look, not what any mark
2993
+ * already means. Keeping them in a sidecar would tie them to a figure that has one —
2994
+ * a reader who picks a colour, draws nothing, and reloads would find the colour gone,
2995
+ * because a figure with no annotation has no sidecar to remember it in.
2996
+ *
2997
+ * So they go in browser storage, which is scoped to the reader rather than to the
2998
+ * figure, and which exists before the first mark does. An annotation that records
2999
+ * its own number style still wins over this: the picture the agent reads has to match
3000
+ * the annotation, not the reader's current preference.
3001
+ * @module dsh-figmark/client/preferences
3002
+ */
2790
3003
  /** The storage key. Namespaced so it cannot collide with the shell's own keys. */
2791
3004
  const PREFERENCES_KEY = "dsh-figmark:drawing";
2792
3005
  /** A colour this plugin would have written: six hex digits. */
@@ -2842,6 +3055,7 @@ window.__ModuleLoader__.load({
2842
3055
  const bar = spotOf(record["bar"]);
2843
3056
  return {
2844
3057
  ...typeof color === "string" && HEX_COLOR.test(color) ? { color } : {},
3058
+ ...isWeight(record["weight"]) ? { weight: record["weight"] } : {},
2845
3059
  ...palette === void 0 ? {} : { palette },
2846
3060
  ...bar === void 0 ? {} : { bar }
2847
3061
  };
@@ -2868,10 +3082,11 @@ window.__ModuleLoader__.load({
2868
3082
  */
2869
3083
  function rememberPreferences(preferences, store = browserStore()) {
2870
3084
  if (store === void 0) return;
2871
- const { color, palette, bar } = preferences;
3085
+ const { color, weight, palette, bar } = preferences;
2872
3086
  try {
2873
3087
  store.setItem(PREFERENCES_KEY, JSON.stringify({
2874
3088
  ...color === void 0 ? {} : { color },
3089
+ ...weight === void 0 ? {} : { weight: clampWeight(weight) },
2875
3090
  ...palette === void 0 ? {} : { palette: {
2876
3091
  x: palette.x,
2877
3092
  y: palette.y
@@ -3061,6 +3276,55 @@ window.__ModuleLoader__.load({
3061
3276
  .da-palette .da-tool .da-tool-icon{height:auto}
3062
3277
  .da-palette .da-tool .da-tool-icon svg{width:16px;height:16px;stroke-width:1.9}
3063
3278
 
3279
+ /* --- the size rail ------------------------------------------------------- */
3280
+ /* A vertical rail along the left edge of the figure: how heavy the pen is, and how
3281
+ big a point mark's pin is. Idle it is a hairline, so it costs the figure nothing;
3282
+ crossing it opens it out to a track. The track is *tapered* — a thin line at the
3283
+ bottom and a broad one at the top — because the control's whole subject is
3284
+ thickness, and a wedge says which way is heavier without reading a number.
3285
+ A horizontal range input is rotated upright inside the pill: it keeps dragging,
3286
+ arrow keys, the step and the accessible name, while the rail above does the
3287
+ drawing. The drawn parts never take the pointer, or grabbing the knob would grab
3288
+ the knob instead of the slider. */
3289
+ .da-weight{position:absolute;z-index:7;left:6px;top:50%;transform:translateY(-50%);
3290
+ display:flex;align-items:center;justify-content:center;width:7px;height:200px;
3291
+ border-radius:999px;background:var(--dsw-alias-border-l2,rgba(127,127,127,.22));
3292
+ cursor:ns-resize;transition:width .16s ease,background .16s ease,box-shadow .16s ease}
3293
+ .da-weight:hover,.da-weight:focus-within{width:30px;background:var(--dsw-alias-bg-layer-2,#fff);
3294
+ box-shadow:0 5px 18px rgba(20,40,80,.16),0 0 0 0.5px var(--dsw-alias-border-l2,currentColor)}
3295
+ /* The wedge: a hairline at the bottom, the heaviest width at the top. Drawn as a
3296
+ clipped gradient box so it costs one element and no SVG. */
3297
+ .da-weight::before{content:"";position:absolute;left:50%;top:14px;bottom:14px;width:11px;
3298
+ transform:translateX(-50%);opacity:0;
3299
+ background:linear-gradient(to top,var(--dsw-alias-border-l3,rgba(127,127,127,.34)),
3300
+ var(--dsw-alias-border-l4,rgba(127,127,127,.5)));
3301
+ clip-path:polygon(calc(50% - 0.5px) 100%,calc(50% + 0.5px) 100%,
3302
+ calc(50% + 5.5px) 0,calc(50% - 5.5px) 0);
3303
+ transition:opacity .16s ease}
3304
+ .da-weight:hover::before,.da-weight:focus-within::before{opacity:1}
3305
+ /* The part already chosen, drawn from the bottom up in the brand colour. */
3306
+ .da-weight-fill{position:absolute;left:50%;bottom:14px;width:11px;transform:translateX(-50%);
3307
+ opacity:0;pointer-events:none;transition:opacity .16s ease;
3308
+ background:var(--dsw-alias-brand-primary,#176df2);
3309
+ clip-path:polygon(calc(50% - 0.5px) 100%,calc(50% + 0.5px) 100%,
3310
+ calc(50% + 5.5px) 0,calc(50% - 5.5px) 0)}
3311
+ .da-weight:hover .da-weight-fill,.da-weight:focus-within .da-weight-fill{opacity:1}
3312
+ /* The knob sits on the value: the only part that shows at rest, so the rail reads as
3313
+ a slider waiting to be touched rather than a bare rule. */
3314
+ .da-weight-knob{position:absolute;left:50%;width:11px;height:11px;border-radius:50%;
3315
+ transform:translateX(-50%);pointer-events:none;
3316
+ background:var(--dsw-alias-bg-layer-2,#fff);
3317
+ box-shadow:0 1px 4px rgba(20,40,80,.32),0 0 0 1px var(--dsw-alias-border-l3,currentColor);
3318
+ transition:width .16s ease,height .16s ease,background .16s ease}
3319
+ .da-weight:hover .da-weight-knob,.da-weight:focus-within .da-weight-knob{
3320
+ width:15px;height:15px;background:var(--dsw-alias-brand-primary,#176df2);
3321
+ box-shadow:0 2px 7px rgba(20,40,80,.34)}
3322
+ /* The browser's own range, rotated upright and made transparent. It spans the whole
3323
+ rail so a press anywhere on it lands on the slider — including on the knob, which
3324
+ is why the knob is pointer-transparent. */
3325
+ .da-weight-input{position:absolute;left:50%;top:50%;width:200px;height:30px;margin:0;opacity:0;
3326
+ transform:translate(-50%,-50%) rotate(-90deg);cursor:ns-resize;appearance:none;background:transparent}
3327
+
3064
3328
  /* --- the floating bar ---------------------------------------------------- */
3065
3329
  /* The same object as the tool palette: a dark rounded strip over the figure, which
3066
3330
  the reader can drag clear of whatever they are looking at. It carries the records
@@ -3149,6 +3413,10 @@ window.__ModuleLoader__.load({
3149
3413
  needs to see where the drawing stops. */
3150
3414
  .da-stage{position:relative;display:flex;align-items:safe center;justify-content:safe center;flex:1 1 auto;overflow:auto;min-height:0;padding:14px;background:var(--dsw-alias-bg-document-preview,transparent)}
3151
3415
  .da-surface{position:relative;flex:0 0 auto;margin:0;transform-origin:top left;background:#fff;border-radius:4px;box-shadow:var(--dsw-elevation-prominent,0 1px 3px rgba(0,0,0,.14),0 8px 24px rgba(0,0,0,.08))}
3416
+ /* The paper: the figure plus the margin a mark may have needed. It is the white sheet
3417
+ the drawing sits on, so a mark that reaches past the figure lands on paper rather
3418
+ than on the panel behind it. */
3419
+ .da-paper{background:#fff}
3152
3420
  .da-figure{display:block;user-select:none;-webkit-user-drag:none}
3153
3421
  /* The table is a thing you can push. The hand only shows under the tools that
3154
3422
  leave a drag free to move the view: under a drawing tool the same drag draws. */
@@ -3159,7 +3427,7 @@ window.__ModuleLoader__.load({
3159
3427
  /* The rendered document takes the figure's place; the overlay above it owns every
3160
3428
  pointer event, which is also what keeps a link inside it from navigating. */
3161
3429
  .da-html{display:block;border:0;background:#fff;pointer-events:none}
3162
- .da-overlay{position:absolute;inset:0;touch-action:none}
3430
+ .da-overlay{position:absolute;touch-action:none}
3163
3431
  .da-overlay[data-mode="view"]{pointer-events:none}
3164
3432
  .da-overlay[data-tool="select"]{cursor:default}
3165
3433
  .da-overlay:not([data-tool="select"]){cursor:crosshair}
@@ -3219,9 +3487,36 @@ window.__ModuleLoader__.load({
3219
3487
  .da-float-record{width:min(330px,calc(100% - 22px))}
3220
3488
  .da-float-list{max-height:min(58%,400px)}
3221
3489
  .da-float-list .da-marks{overflow:auto;min-height:0}
3222
- /* The detail card is placed by arithmetic, not by the stylesheet: its left and top
3223
- come from the mark it belongs to. */
3224
- .da-float-detail{width:252px}
3490
+ /* The detail field is placed by arithmetic, not by the stylesheet: its left and top
3491
+ come from the mark it belongs to. It is a pill rather than a card — no head, no
3492
+ border between the field and the panel — so writing about a mark costs one line
3493
+ over the figure instead of a box that covers it. */
3494
+ .da-float-detail{width:300px;gap:0;padding:4px;border-radius:22px;
3495
+ flex-direction:row;align-items:flex-end;
3496
+ box-shadow:0 8px 26px rgba(20,40,80,.2),0 0 0 1px rgba(20,40,80,.04)}
3497
+ .da-float .da-comment{flex:1 1 auto;min-width:0;box-sizing:border-box;border:0;background:transparent;
3498
+ color:inherit;font:inherit;font-size:13px;line-height:1.55;padding:7px 10px;resize:none;
3499
+ /* One line until the sentence needs more, then as many as it takes: the pill is a
3500
+ place to type, and a sentence read through a slot two lines tall is a sentence
3501
+ the reader has to scroll to finish. The rule for the record cards' textareas sets
3502
+ a 74px floor, hence the explicit reset. The scrollbar is hidden because it is not
3503
+ a scroll view — wheel and keys still work once the sentence outgrows the pane. */
3504
+ min-height:0;height:auto;max-height:min(180px,42vh);overflow-y:auto;outline:none;
3505
+ field-sizing:content;scrollbar-width:none}
3506
+ .da-float .da-comment::-webkit-scrollbar{width:0;height:0}
3507
+ .da-float .da-comment::placeholder{color:var(--dsw-alias-label-tertiary,currentColor);opacity:.85}
3508
+ /* The hand-off appears only once the field says something: an empty pill is a
3509
+ place to type, not a decision to make. */
3510
+ .da-comment-send{display:flex;align-items:center;gap:4px;flex:0 0 auto;padding-bottom:3px;padding-right:3px}
3511
+ .da-comment-send-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;
3512
+ border:0;border-radius:50%;background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));
3513
+ color:var(--dsw-alias-label-secondary,currentColor);cursor:pointer;padding:0}
3514
+ .da-comment-send-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.2));
3515
+ color:var(--dsw-alias-label-primary,currentColor)}
3516
+ .da-comment-send-btn:disabled{opacity:.45;cursor:default}
3517
+ .da-comment-send-btn svg{width:15px;height:15px}
3518
+ .da-comment-send-primary{background:var(--dsw-alias-brand-primary,#176df2);color:#fff}
3519
+ .da-comment-send-primary:hover{background:var(--dsw-alias-brand-primary,#176df2);color:#fff;filter:brightness(1.08)}
3225
3520
  .da-float textarea{width:100%;box-sizing:border-box;min-height:74px;max-height:200px;resize:vertical;border:0.5px solid var(--dsw-alias-border-l3,currentColor);border-radius:9px;background:var(--dsw-alias-bg-layer-2,transparent);color:inherit;font:inherit;font-size:12px;padding:8px 10px;line-height:1.65}
3226
3521
  .da-float textarea:focus{outline:none;border-color:var(--dsw-alias-brand-primary,#176df2)}
3227
3522
  .da-btn-count{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;
@@ -3428,13 +3723,34 @@ window.__ModuleLoader__.load({
3428
3723
  * @param size - the card's size, in CSS pixels.
3429
3724
  * @returns the card's top-left corner, in the panel's coordinates.
3430
3725
  */
3726
+ /**
3727
+ * The size to place a card by: the card's own, or a stand-in before it is measured.
3728
+ *
3729
+ * A comment pill grows with the sentence in it, so a placement that assumed one line
3730
+ * would push a long comment off the bottom of the panel. The element is measured once
3731
+ * it has been laid out; before that — the first frame of a selection, and in any
3732
+ * environment that reports zero boxes — the caller's estimate is the only size there
3733
+ * is, and the next pass corrects it.
3734
+ * @param element - the laid-out card, when there is one.
3735
+ * @param fallback - the size to use when the card has not been measured.
3736
+ * @returns the width and height to place by.
3737
+ */
3738
+ function measuredSize(element, fallback) {
3739
+ if (element === null || element.offsetHeight === 0) return fallback;
3740
+ return {
3741
+ width: element.offsetWidth,
3742
+ height: element.offsetHeight
3743
+ };
3744
+ }
3431
3745
  function placeCard(anchor, panelWidth, panelHeight, size = {
3432
- width: 252,
3433
- height: 196
3746
+ width: 300,
3747
+ height: 48
3434
3748
  }) {
3435
3749
  const below = anchor.y + anchor.height + 10;
3436
3750
  const above = anchor.y - size.height - 10;
3437
- const top = below + size.height <= panelHeight - 8 ? below : Math.max(8, above);
3751
+ const roomBelow = panelHeight - 8 - below;
3752
+ const roomAbove = anchor.y - 10 - 8;
3753
+ const top = roomBelow >= size.height ? below : roomAbove >= size.height ? above : Math.max(8, roomAbove > roomBelow ? above : below);
3438
3754
  const widest = Math.max(8, panelWidth - size.width - 8);
3439
3755
  return {
3440
3756
  left: Math.min(Math.max(8, anchor.x), widest),
@@ -3524,6 +3840,20 @@ window.__ModuleLoader__.load({
3524
3840
  label: "toolPoint"
3525
3841
  }
3526
3842
  ];
3843
+ /**
3844
+ * The tools that draw something with a size the rail can set.
3845
+ *
3846
+ * `select` is not among them: it draws nothing, so with it armed and no mark
3847
+ * selected the rail has no subject. `point` is, because a pin is sized by the same
3848
+ * number — the rail asks "how big is this mark" either way.
3849
+ */
3850
+ const WEIGHT_TOOLS = /* @__PURE__ */ new Set([
3851
+ "arrow",
3852
+ "rect",
3853
+ "ellipse",
3854
+ "pen",
3855
+ "point"
3856
+ ]);
3527
3857
  /** The copy key naming the shape each kind of mark is, for the list's second line. */
3528
3858
  const TOOL_COPY = {
3529
3859
  arrow: "toolArrow",
@@ -3587,27 +3917,6 @@ window.__ModuleLoader__.load({
3587
3917
  });
3588
3918
  }
3589
3919
  /**
3590
- * The small pictures the panel's furniture wears.
3591
- *
3592
- * Each is a 14px line drawing on the same 16-unit grid as the tool icons, so the
3593
- * chrome reads as one set rather than as a crate of borrowed glyphs.
3594
- */
3595
- function NoteIcon() {
3596
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
3597
- className: "da-ico",
3598
- viewBox: "0 0 16 16",
3599
- "aria-hidden": "true",
3600
- focusable: "false",
3601
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
3602
- x: "2.5",
3603
- y: "2.5",
3604
- width: "11",
3605
- height: "11",
3606
- rx: "2"
3607
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M 5.5 6 h 5 M 5.5 9 h 3.5" })]
3608
- });
3609
- }
3610
- /**
3611
3920
  * The overall goal's mark: an arrow that has hit a target.
3612
3921
  *
3613
3922
  * The record this heads is not a note about one place on the figure — it is what the
@@ -3842,6 +4151,7 @@ window.__ModuleLoader__.load({
3842
4151
  */
3843
4152
  const remembered = (0, react.useMemo)(() => readPreferences(), []);
3844
4153
  const [color, setColor] = (0, react.useState)(remembered.color ?? COLORS[0]);
4154
+ const [weight, setWeight] = (0, react.useState)(remembered.weight ?? 1);
3845
4155
  const [marks, setMarks] = (0, react.useState)(() => [...props.draft?.marks ?? []]);
3846
4156
  const [past, setPast] = (0, react.useState)([]);
3847
4157
  const [future, setFuture] = (0, react.useState)([]);
@@ -3892,6 +4202,14 @@ window.__ModuleLoader__.load({
3892
4202
  */
3893
4203
  const [detailAt, setDetailAt] = (0, react.useState)(null);
3894
4204
  /**
4205
+ * The selected mark as a drag is currently shaping it.
4206
+ *
4207
+ * Kept beside the stored marks rather than in them: the gesture is not committed
4208
+ * until it is released, so writing it into the list would make every frame an
4209
+ * undoable edit. It exists only so the pill can follow the hand.
4210
+ */
4211
+ const [liveMark, setLiveMark] = (0, react.useState)(null);
4212
+ /**
3895
4213
  * Shut the style panel when the reader picks up a tool.
3896
4214
  *
3897
4215
  * Arming a tool is the moment the panel has done its job; leaving it open over
@@ -3998,13 +4316,15 @@ window.__ModuleLoader__.load({
3998
4316
  (0, react.useEffect)(() => {
3999
4317
  rememberPreferences({
4000
4318
  color,
4319
+ weight,
4001
4320
  ...palette === void 0 ? {} : { palette },
4002
4321
  ...barSpot === void 0 ? {} : { bar: barSpot }
4003
4322
  });
4004
4323
  }, [
4005
4324
  barSpot,
4006
4325
  color,
4007
- palette
4326
+ palette,
4327
+ weight
4008
4328
  ]);
4009
4329
  /**
4010
4330
  * The bar's box, when the reader has moved it off the bottom edge.
@@ -4077,6 +4397,23 @@ window.__ModuleLoader__.load({
4077
4397
  rasterSize
4078
4398
  ]);
4079
4399
  /**
4400
+ * How big everything drawn is, as a multiple of the base sizes.
4401
+ *
4402
+ * One number for the whole surface: the pen, the pins and the review image all
4403
+ * have to agree, and a stroke is only bold or thin relative to the figure it sits
4404
+ * on. A 4000px scan and a 300px thumbnail get the same-looking mark this way.
4405
+ */
4406
+ const unit = size === null ? 1 : figureUnit(size.width, size.height);
4407
+ /**
4408
+ * The paper the drawing needs: the figure, plus anywhere the marks reach past it.
4409
+ *
4410
+ * A reader boxing the margin a plot clips, or flagging a spot just outside the
4411
+ * axes, is drawing something the figure does not contain. The paper grows to hold
4412
+ * it rather than the mark being clipped — and because the surface is white, a mark
4413
+ * out there lands on paper instead of on nothing.
4414
+ */
4415
+ const paper = size === null ? null : plotBounds(size.width, size.height, marks, unit);
4416
+ /**
4080
4417
  * Everything a background write needs, read at write time.
4081
4418
  *
4082
4419
  * A write runs from a timer that outlives the render that scheduled it, so it has
@@ -4283,11 +4620,12 @@ window.__ModuleLoader__.load({
4283
4620
  * exchanged; so is everything inside a record, which is where the writing happens.
4284
4621
  */
4285
4622
  (0, react.useEffect)(() => {
4286
- if (openPanel === null) return;
4287
4623
  const onDown = (event) => {
4288
4624
  const target = event.target;
4289
- if (target instanceof Element && (target.closest(".da-float") !== null || target.closest(".da-style-row") !== null || barRef.current?.contains(target) === true)) return;
4290
- setOpenPanel(null);
4625
+ if (target instanceof Element && (target.closest(".da-float") !== null || target.closest(".da-style-row") !== null || target.closest(".da-palette") !== null || target.closest(".da-weight") !== null || barRef.current?.contains(target) === true)) return;
4626
+ if (openPanel !== null) setOpenPanel(null);
4627
+ if (stageRef.current?.contains(target) === true) return;
4628
+ setSelectedId(null);
4291
4629
  };
4292
4630
  document.addEventListener("pointerdown", onDown, true);
4293
4631
  return () => {
@@ -4307,31 +4645,38 @@ window.__ModuleLoader__.load({
4307
4645
  const root = rootRef.current;
4308
4646
  const host = figureRef.current;
4309
4647
  const mark = marks.find((candidate) => candidate.id === selectedId);
4310
- const box = mark === void 0 ? void 0 : markOutline(mark);
4648
+ const drawn = liveMark !== null && liveMark.id === selectedId ? liveMark : mark;
4649
+ const box = drawn === void 0 ? void 0 : markOutline(drawn, unit);
4311
4650
  if (root === null || host === null || box === void 0 || size === null) {
4312
4651
  setDetailAt(null);
4313
4652
  return;
4314
4653
  }
4315
4654
  const panel = root.getBoundingClientRect();
4316
- const drawn = host.getBoundingClientRect();
4655
+ const measured = host.getBoundingClientRect();
4656
+ const pillSize = measuredSize(detailRef.current, {
4657
+ width: 300,
4658
+ height: 48
4659
+ });
4317
4660
  const at = placeCard(scaledBox(box, {
4318
- x: drawn.left,
4319
- y: drawn.top,
4320
- width: drawn.width,
4321
- height: drawn.height
4661
+ x: measured.left,
4662
+ y: measured.top,
4663
+ width: measured.width,
4664
+ height: measured.height
4322
4665
  }, {
4323
4666
  x: panel.left,
4324
4667
  y: panel.top,
4325
4668
  width: panel.width,
4326
4669
  height: panel.height
4327
- }, size.width), panel.width, panel.height);
4670
+ }, size.width), panel.width, panel.height, pillSize);
4328
4671
  setDetailAt((previous) => previous !== null && previous.left === at.left && previous.top === at.top ? previous : at);
4329
4672
  }, [
4673
+ htmlHeight,
4674
+ liveMark,
4330
4675
  marks,
4331
- selectedId,
4332
4676
  scale,
4677
+ selectedId,
4333
4678
  size,
4334
- htmlHeight
4679
+ unit
4335
4680
  ]);
4336
4681
  /**
4337
4682
  * The factor currently drawn, kept beside the state that will hold it.
@@ -4526,22 +4871,15 @@ window.__ModuleLoader__.load({
4526
4871
  * so a recolour can be taken back like any other edit.
4527
4872
  * @param value - the CSS hex string of the chosen swatch.
4528
4873
  */
4529
- const chooseColor = (0, react.useCallback)((value) => {
4530
- if (selectedId === null) {
4531
- setColor(value);
4532
- return;
4533
- }
4534
- const repainted = marks.map((mark) => mark.id === selectedId && mark.color !== value ? {
4535
- ...mark,
4536
- color: value
4537
- } : mark);
4538
- if (!repainted.some((mark, index) => mark !== marks[index])) return;
4539
- commit(repainted);
4540
- }, [
4541
- commit,
4542
- marks,
4543
- selectedId
4544
- ]);
4874
+ /**
4875
+ * Change one attribute of the selected mark, or of the next stroke when nothing
4876
+ * is selected.
4877
+ *
4878
+ * A control that is already at the mark's value leaves the list identical, so a
4879
+ * press that changes nothing records no undo step — whether the mark is still
4880
+ * there or was removed while it was selected.
4881
+ * @param patch - the attribute to change.
4882
+ */
4545
4883
  const undo = (0, react.useCallback)(() => {
4546
4884
  setPast((history) => {
4547
4885
  const previous = history[history.length - 1];
@@ -4656,10 +4994,11 @@ window.__ModuleLoader__.load({
4656
4994
  ]);
4657
4995
  const exportPng = (0, react.useCallback)(async (target) => {
4658
4996
  const svg = composeReviewSvg(target, marks);
4659
- const exportScale = Math.min(2, Math.max(1, 2400 / Math.max(target.width, target.height)));
4660
- return await rasterizePng(svg, target.width, target.height, exportScale);
4997
+ const paper = reviewSize(target, marks);
4998
+ const exportScale = Math.min(2, Math.max(1, 2400 / Math.max(paper.width, paper.height)));
4999
+ return await rasterizePng(svg, paper.width, paper.height, exportScale);
4661
5000
  }, [marks]);
4662
- const buildDocument = (0, react.useCallback)((target) => {
5001
+ const buildDocument = (0, react.useCallback)((target, scope = marks) => {
4663
5002
  if (loaded === null) return null;
4664
5003
  const now = (/* @__PURE__ */ new Date()).toISOString();
4665
5004
  return {
@@ -4674,7 +5013,7 @@ window.__ModuleLoader__.load({
4674
5013
  },
4675
5014
  createdAt: createdAt ?? now,
4676
5015
  updatedAt: now,
4677
- marks,
5016
+ marks: scope,
4678
5017
  ...summary.trim() === "" ? {} : { summary: summary.trim() }
4679
5018
  };
4680
5019
  }, [
@@ -4777,15 +5116,16 @@ window.__ModuleLoader__.load({
4777
5116
  * would make the reader clear it twice.
4778
5117
  * @param target - who receives the annotation.
4779
5118
  */
4780
- const run = (0, react.useCallback)(async (target) => {
4781
- if (marks.length === 0 || size === null || size.height <= 0) return;
5119
+ const run = (0, react.useCallback)(async (target, only) => {
5120
+ const scope = only === void 0 ? marks : [only];
5121
+ if (scope.length === 0 || size === null || size.height <= 0) return;
4782
5122
  setBusy("sending");
4783
5123
  setStatus({
4784
5124
  tone: "info",
4785
5125
  text: t(target === "agent" ? "sending" : "composing")
4786
5126
  });
4787
5127
  try {
4788
- const document_ = buildDocument(size);
5128
+ const document_ = buildDocument(size, scope);
4789
5129
  if (document_ === null) throw new Error(t("loadFailed"));
4790
5130
  const review = layer === null || !needsReviewImage(document_) ? void 0 : await exportPng(layer);
4791
5131
  const saved = await saveAnnotation(address, document_, review);
@@ -4846,7 +5186,7 @@ window.__ModuleLoader__.load({
4846
5186
  discard,
4847
5187
  exportPng,
4848
5188
  layer,
4849
- marks.length,
5189
+ marks,
4850
5190
  props.conversation,
4851
5191
  props.localeId,
4852
5192
  props.sessions,
@@ -4880,16 +5220,72 @@ window.__ModuleLoader__.load({
4880
5220
  * the next stroke, which is where the remembered preference comes in.
4881
5221
  */
4882
5222
  const shownColor = selected === null ? color : selected.color;
4883
- const surfaceStyle = size === null ? {} : {
4884
- width: size.width * scale,
4885
- height: size.height * scale
5223
+ /**
5224
+ * What the size rail currently means, or null when it means nothing.
5225
+ *
5226
+ * It sizes one thing: the stroke of the mark the reader is working on, or the pen
5227
+ * of the next stroke. With nothing selected and no drawing tool out there is no
5228
+ * such thing, so the rail stands down instead of offering a control for a decision
5229
+ * nobody is making. A point mark borrows the same number for its pin, so the rail
5230
+ * stays when one is selected — it is the mark's size, whichever way it is drawn.
5231
+ */
5232
+ const shownWeight = selected === null ? WEIGHT_TOOLS.has(tool) ? weight : null : selected.weight ?? 1;
5233
+ /**
5234
+ * Change one attribute of the selected mark, or of the next stroke when nothing
5235
+ * is selected.
5236
+ *
5237
+ * A control already at the mark's value leaves the list untouched, so a press
5238
+ * that changes nothing records no undo step.
5239
+ * @param patch - the attribute to change.
5240
+ */
5241
+ const repaintSelected = (mark, patch) => {
5242
+ if (Object.keys(patch).every((key) => mark[key] === patch[key])) return;
5243
+ commit(marks.map((entry) => entry.id === mark.id ? {
5244
+ ...entry,
5245
+ ...patch
5246
+ } : entry));
5247
+ };
5248
+ const chooseColor = (value) => {
5249
+ if (selected === null) {
5250
+ setColor(value);
5251
+ return;
5252
+ }
5253
+ repaintSelected(selected, { color: value });
4886
5254
  };
4887
- const figureStyle = size === null ? {} : {
4888
- width: size.width,
4889
- height: size.height,
5255
+ const chooseWeight = (value) => {
5256
+ const held = clampWeight(value);
5257
+ if (selected === null) {
5258
+ setWeight(held);
5259
+ return;
5260
+ }
5261
+ repaintSelected(selected, { weight: held });
5262
+ };
5263
+ const surfaceStyle = paper === null ? {} : {
5264
+ width: paper.width * scale,
5265
+ height: paper.height * scale
5266
+ };
5267
+ /**
5268
+ * The paper at its unscaled size, with the figure placed at its own offset on it.
5269
+ *
5270
+ * The whole paper is scaled by one transform rather than each part scaling itself,
5271
+ * so the figure and the overlay cannot drift apart at a fractional factor — which
5272
+ * at 60% is exactly what a mark drawn on a line work and a mark drawn on paper
5273
+ * would do.
5274
+ */
5275
+ const paperStyle = paper === null ? {} : {
5276
+ position: "relative",
5277
+ width: paper.width,
5278
+ height: paper.height,
4890
5279
  transform: `scale(${scale})`,
4891
5280
  transformOrigin: "top left"
4892
5281
  };
5282
+ const figureStyle = paper === null ? {} : {
5283
+ position: "absolute",
5284
+ left: -paper.x,
5285
+ top: -paper.y,
5286
+ width: size?.width,
5287
+ height: size?.height
5288
+ };
4893
5289
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4894
5290
  className: "da-root",
4895
5291
  ref: rootRef,
@@ -4918,63 +5314,67 @@ window.__ModuleLoader__.load({
4918
5314
  className: "da-surface",
4919
5315
  ref: surfaceRef,
4920
5316
  style: surfaceStyle,
4921
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4922
- ref: figureRef,
4923
- style: {
4924
- ...figureStyle,
4925
- position: "relative"
4926
- },
4927
- children: [figure?.kind === "svg" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4928
- ref: figureHostRef,
4929
- dangerouslySetInnerHTML: { __html: figure.markup }
4930
- }) : figure?.kind === "raster" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
4931
- className: "da-figure",
4932
- src: figure.objectUrl,
4933
- width: size?.width,
4934
- height: size?.height,
4935
- alt: "",
4936
- onLoad: (event) => {
4937
- const image = event.currentTarget;
4938
- setFileRasterSize({
4939
- width: image.naturalWidth,
4940
- height: image.naturalHeight
4941
- });
4942
- }
4943
- }) : figure?.kind === "html" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
4944
- ref: frameRef,
4945
- className: "da-html",
4946
- title: t("htmlFrame"),
4947
- sandbox: "allow-same-origin",
4948
- style: {
4949
- width: figure.width,
4950
- height: htmlHeight
4951
- }
4952
- }) : null, size === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Canvas, {
4953
- width: size.width,
4954
- height: size.height,
4955
- marks,
4956
- tool,
4957
- color,
4958
- containerRef: figureHostRef,
4959
- anchoring: isVector,
4960
- anchorAtFigurePoint: figure?.kind === "html" ? anchorAtFigurePoint : void 0,
4961
- selectedId,
4962
- onSelect: setSelectedId,
4963
- onAdd: addMark,
4964
- onMove: moveMark,
4965
- onUpdate: updateMark,
4966
- onRemove: removeMark,
4967
- onPan: panBy,
4968
- onPanStart: () => {
4969
- setPanning(true);
4970
- },
4971
- onPanEnd: () => {
4972
- setPanning(false);
4973
- },
4974
- onEdit: editMark,
4975
- onDrawn: finishDrawing,
4976
- numbering: props.numbering
4977
- })]
5317
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5318
+ className: "da-paper",
5319
+ style: paperStyle,
5320
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5321
+ ref: figureRef,
5322
+ style: figureStyle,
5323
+ children: [figure?.kind === "svg" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5324
+ ref: figureHostRef,
5325
+ dangerouslySetInnerHTML: { __html: figure.markup }
5326
+ }) : figure?.kind === "raster" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
5327
+ className: "da-figure",
5328
+ src: figure.objectUrl,
5329
+ width: size?.width,
5330
+ height: size?.height,
5331
+ alt: "",
5332
+ onLoad: (event) => {
5333
+ const image = event.currentTarget;
5334
+ setFileRasterSize({
5335
+ width: image.naturalWidth,
5336
+ height: image.naturalHeight
5337
+ });
5338
+ }
5339
+ }) : figure?.kind === "html" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
5340
+ ref: frameRef,
5341
+ className: "da-html",
5342
+ title: t("htmlFrame"),
5343
+ sandbox: "allow-same-origin",
5344
+ style: {
5345
+ width: figure.width,
5346
+ height: htmlHeight
5347
+ }
5348
+ }) : null, paper === null || size === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Canvas, {
5349
+ width: paper.width,
5350
+ height: paper.height,
5351
+ origin: [-paper.x, -paper.y],
5352
+ marks,
5353
+ tool,
5354
+ color,
5355
+ weight,
5356
+ containerRef: figureHostRef,
5357
+ anchoring: isVector,
5358
+ anchorAtFigurePoint: figure?.kind === "html" ? anchorAtFigurePoint : void 0,
5359
+ selectedId,
5360
+ onSelect: setSelectedId,
5361
+ onAdd: addMark,
5362
+ onMove: moveMark,
5363
+ onUpdate: updateMark,
5364
+ onRemove: removeMark,
5365
+ onPan: panBy,
5366
+ onPanStart: () => {
5367
+ setPanning(true);
5368
+ },
5369
+ onPanEnd: () => {
5370
+ setPanning(false);
5371
+ },
5372
+ onEdit: editMark,
5373
+ onDrawn: finishDrawing,
5374
+ onLiveEdit: setLiveMark,
5375
+ numbering: props.numbering
5376
+ })]
5377
+ })
4978
5378
  })
4979
5379
  })
4980
5380
  }),
@@ -5122,42 +5522,67 @@ window.__ModuleLoader__.load({
5122
5522
  left: detailAt.left,
5123
5523
  top: detailAt.top
5124
5524
  },
5125
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5126
- className: "da-float-head",
5127
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5128
- className: "da-card-title",
5129
- children: [
5130
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(NoteIcon, {}),
5131
- t("detail"),
5132
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5133
- className: "da-chip",
5134
- children: t(TOOL_COPY[selected.kind])
5135
- })
5136
- ]
5525
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
5526
+ ref: detailRef,
5527
+ className: "da-comment",
5528
+ value: selected.detail ?? "",
5529
+ placeholder: selected.detail === void 0 || selected.detail.trim() === "" ? t("addComment") : t("detailPlaceholder"),
5530
+ rows: 1,
5531
+ onChange: (event) => {
5532
+ setMarkDetail(selected.id, event.target.value);
5533
+ }
5534
+ }), selected.detail === void 0 || selected.detail.trim() === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5535
+ className: "da-comment-send",
5536
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5537
+ type: "button",
5538
+ className: "da-comment-send-btn da-comment-send-primary",
5539
+ disabled: busy !== null,
5540
+ "aria-label": t("sendToAgent"),
5541
+ title: t("sendToAgent"),
5542
+ onClick: () => {
5543
+ run("agent", selected);
5544
+ },
5545
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SendIcon, {})
5137
5546
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
5138
5547
  type: "button",
5139
- className: "da-btn da-btn-quiet",
5140
- "aria-label": t("close"),
5141
- title: t("close"),
5548
+ className: "da-comment-send-btn",
5549
+ disabled: busy !== null,
5550
+ "aria-label": t("sendToComposer"),
5551
+ title: t("sendToComposer"),
5142
5552
  onClick: () => {
5143
- setSelectedId(null);
5553
+ run("composer", selected);
5144
5554
  },
5145
- children: "✕"
5555
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChatIcon, {})
5146
5556
  })]
5147
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5148
- className: "da-field",
5149
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
5150
- ref: detailRef,
5151
- value: selected.detail ?? "",
5152
- placeholder: t("detailPlaceholder"),
5557
+ })]
5558
+ }),
5559
+ shownWeight === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5560
+ className: "da-weight",
5561
+ title: t(selected?.kind === "point" || tool === "point" ? "pinSize" : "weight"),
5562
+ children: [
5563
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5564
+ type: "range",
5565
+ className: "da-weight-input",
5566
+ "aria-label": t(selected?.kind === "point" || tool === "point" ? "pinSize" : "weight"),
5567
+ min: WEIGHT_MIN,
5568
+ max: 4,
5569
+ step: .1,
5570
+ value: shownWeight,
5153
5571
  onChange: (event) => {
5154
- setMarkDetail(selected.id, event.target.value);
5572
+ chooseWeight(Number(event.target.value));
5155
5573
  }
5156
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5157
- className: "da-count",
5158
- children: [(selected.detail ?? "").length, "/500"]
5159
- })]
5160
- })]
5574
+ }),
5575
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5576
+ className: "da-weight-fill",
5577
+ "aria-hidden": true,
5578
+ style: { height: `${(shownWeight - WEIGHT_MIN) / (4 - WEIGHT_MIN) * 100}%` }
5579
+ }),
5580
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5581
+ className: "da-weight-knob",
5582
+ "aria-hidden": true,
5583
+ style: { bottom: `calc(${(shownWeight - WEIGHT_MIN) / (4 - WEIGHT_MIN) * 100}% - 5.5px)` }
5584
+ })
5585
+ ]
5161
5586
  }),
5162
5587
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5163
5588
  className: "da-palette",
@@ -5494,15 +5919,18 @@ window.__ModuleLoader__.load({
5494
5919
  const images = [];
5495
5920
  const numbering = new Map(document.marks.map((mark, index) => [mark.id, index + 1]));
5496
5921
  for (const page of pages) {
5497
- const svg = composeReviewSvg({
5922
+ const layer = {
5498
5923
  kind: "raster",
5499
5924
  dataUrl: page.dataUrl,
5500
5925
  width: page.width,
5501
5926
  height: page.height
5502
- }, document.marks.filter((mark) => mark.page === page.page), numbering);
5927
+ };
5928
+ const marks = document.marks.filter((mark) => mark.page === page.page);
5929
+ const svg = composeReviewSvg(layer, marks, numbering);
5930
+ const paper = reviewSize(layer, marks);
5503
5931
  images.push({
5504
5932
  page: page.page,
5505
- image: await rasterizePng(svg, page.width, page.height, reviewScale(page.width, page.height))
5933
+ image: await rasterizePng(svg, paper.width, paper.height, reviewScale(paper.width, paper.height))
5506
5934
  });
5507
5935
  }
5508
5936
  return images;
@@ -6028,6 +6456,7 @@ window.__ModuleLoader__.load({
6028
6456
  createAutosave,
6029
6457
  annotationSignature,
6030
6458
  autosaveDelayMs: 600,
6459
+ figureUnit,
6031
6460
  seat: createDocumentSeat({
6032
6461
  sessions: ctx.get("sessions"),
6033
6462
  conversation: ctx.get("conversation"),