dsh-figmark 1.1.0 → 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
@@ -26,6 +26,7 @@ window.__ModuleLoader__.load({
26
26
  clear: "清空",
27
27
  detail: "这个元素的详细说明",
28
28
  addComment: "添加评论…",
29
+ pinSize: "标记大小",
29
30
  weight: "线条粗细",
30
31
  detailPlaceholder: "这一处具体要改什么(例如「这个标号应指向滑套 34」)",
31
32
  summary: "总体目标",
@@ -113,6 +114,7 @@ window.__ModuleLoader__.load({
113
114
  clear: "Clear",
114
115
  detail: "What this element needs",
115
116
  addComment: "Add a comment…",
117
+ pinSize: "Marker size",
116
118
  weight: "Line weight",
117
119
  detailPlaceholder: "What should change here (for example \"this numeral should point at the sleeve 34\")",
118
120
  summary: "Overall goal",
@@ -1013,7 +1015,7 @@ window.__ModuleLoader__.load({
1013
1015
  }
1014
1016
  //#endregion
1015
1017
  //#region src/client/render.ts
1016
- /** 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. */
1017
1019
  const STROKE_WIDTH = 2.5;
1018
1020
  /**
1019
1021
  * The stroke width one mark is drawn at.
@@ -1023,17 +1025,36 @@ window.__ModuleLoader__.load({
1023
1025
  * thicker pen. The arrow's barbs are weighed by the same multiple, because an
1024
1026
  * arrow drawn with a thick pen has a thick head.
1025
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
+ *
1026
1031
  * @param mark - the mark to weigh.
1032
+ * @param unit - the figure's size multiple, 1 for a reference-sized figure.
1027
1033
  * @returns its stroke width in figure pixels.
1028
1034
  */
1029
- function strokeWidthOf(mark) {
1030
- return STROKE_WIDTH * (mark.weight ?? 1);
1035
+ function strokeWidthOf(mark, unit = 1) {
1036
+ return STROKE_WIDTH * (mark.weight ?? 1) * unit;
1031
1037
  }
1032
1038
  /** Arrow head length, in figure pixels. */
1033
1039
  const ARROW_HEAD = 14;
1034
1040
  /** Half-angle of the arrow head, in radians. */
1035
1041
  const ARROW_SPREAD = Math.PI / 7;
1036
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
+ /**
1037
1058
  * How far a pin's head sits above its coordinate, as a multiple of its half-width.
1038
1059
  *
1039
1060
  * A map pin puts its *tip* on the coordinate it names, so the head stands clear of
@@ -1111,11 +1132,11 @@ window.__ModuleLoader__.load({
1111
1132
  * @param points - the arrow's tail and tip.
1112
1133
  * @returns tail, tip and both barbs; the stored points alone when it is half drawn.
1113
1134
  */
1114
- function arrowBarbs(points, weight = 1) {
1135
+ function arrowBarbs(points, weight = 1, unit = 1) {
1115
1136
  const [first, second] = points;
1116
1137
  if (first === void 0 || second === void 0) return points;
1117
1138
  const angle = Math.atan2(second[1] - first[1], second[0] - first[0]);
1118
- const head = ARROW_HEAD * weight;
1139
+ const head = ARROW_HEAD * weight * unit;
1119
1140
  const left = [second[0] - head * Math.cos(angle - ARROW_SPREAD), second[1] - head * Math.sin(angle - ARROW_SPREAD)];
1120
1141
  const right = [second[0] - head * Math.cos(angle + ARROW_SPREAD), second[1] - head * Math.sin(angle + ARROW_SPREAD)];
1121
1142
  return [
@@ -1170,10 +1191,10 @@ window.__ModuleLoader__.load({
1170
1191
  * @param mark - the point mark.
1171
1192
  * @returns the marker's path, number centre and box, or undefined with no coordinate.
1172
1193
  */
1173
- function pinGeometry(mark) {
1194
+ function pinGeometry(mark, unit = 1) {
1174
1195
  const tip = mark.points[0];
1175
1196
  if (tip === void 0) return void 0;
1176
- const radius = 10;
1197
+ const radius = pinRadiusOf(mark, unit);
1177
1198
  const lift = radius * PIN_LIFT;
1178
1199
  const [tipX, tipY] = tip;
1179
1200
  const up = tipY - lift - radius >= 0;
@@ -1207,16 +1228,16 @@ window.__ModuleLoader__.load({
1207
1228
  * @param mark - the point mark.
1208
1229
  * @returns path data, or undefined when the mark carries no coordinate.
1209
1230
  */
1210
- function pinPathData(mark) {
1211
- return pinGeometry(mark)?.path;
1231
+ function pinPathData(mark, unit = 1) {
1232
+ return pinGeometry(mark, unit)?.path;
1212
1233
  }
1213
1234
  /**
1214
1235
  * Where the number inside a `point` mark's marker sits.
1215
1236
  * @param mark - the point mark.
1216
1237
  * @returns the number's centre, or undefined when the mark carries no coordinate.
1217
1238
  */
1218
- function pinNumberCentre(mark) {
1219
- return pinGeometry(mark)?.number;
1239
+ function pinNumberCentre(mark, unit = 1) {
1240
+ return pinGeometry(mark, unit)?.number;
1220
1241
  }
1221
1242
  /**
1222
1243
  * The box a `point` mark's pin covers.
@@ -1229,8 +1250,8 @@ window.__ModuleLoader__.load({
1229
1250
  * @param mark - the point mark.
1230
1251
  * @returns the pin's box, or undefined when the mark carries no point.
1231
1252
  */
1232
- function pointMarkBox(mark) {
1233
- return pinGeometry(mark)?.box;
1253
+ function pointMarkBox(mark, unit = 1) {
1254
+ return pinGeometry(mark, unit)?.box;
1234
1255
  }
1235
1256
  /**
1236
1257
  * The box a mark covers once it is drawn, rotation included.
@@ -1243,14 +1264,14 @@ window.__ModuleLoader__.load({
1243
1264
  * @param mark - the mark to measure.
1244
1265
  * @returns the box, or undefined when the mark carries no coordinate.
1245
1266
  */
1246
- function markOutline(mark) {
1247
- if (mark.kind === "point") return pointMarkBox(mark);
1248
- const geometry = mark.kind === "arrow" ? arrowBarbs(mark.points, mark.weight ?? 1) : 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;
1249
1270
  const straight = pointsBox(geometry);
1250
1271
  if (straight === void 0) return void 0;
1251
1272
  const radians = markRadians(mark);
1252
1273
  const box = radians === 0 ? straight : turnedBox(geometry, straight, radians);
1253
- const grow = strokeWidthOf(mark) / 2;
1274
+ const grow = strokeWidthOf(mark, unit) / 2;
1254
1275
  return {
1255
1276
  x: box.x - grow,
1256
1277
  y: box.y - grow,
@@ -1324,6 +1345,42 @@ window.__ModuleLoader__.load({
1324
1345
  return `rotate(${n(radians * 180 / Math.PI)} ${n(cx)} ${n(cy)})`;
1325
1346
  }
1326
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
1327
1384
  //#region src/client/Canvas.tsx
1328
1385
  /**
1329
1386
  * The drawing surface.
@@ -1334,6 +1391,19 @@ window.__ModuleLoader__.load({
1334
1391
  * means the same thing at any zoom or pane width.
1335
1392
  * @module dsh-figmark/client/Canvas
1336
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
+ }
1337
1407
  /** Hit width of an invisible selection stroke, in figure pixels. */
1338
1408
  const HIT_WIDTH = 14;
1339
1409
  /** Minimum distance between recorded freehand samples, in figure pixels. */
@@ -1357,6 +1427,7 @@ window.__ModuleLoader__.load({
1357
1427
  */
1358
1428
  function Canvas(props) {
1359
1429
  const { width, height, marks, tool, color, containerRef, anchoring } = props;
1430
+ const [originX, originY] = props.origin ?? [0, 0];
1360
1431
  const weight = props.weight ?? 1;
1361
1432
  /**
1362
1433
  * Each mark's number, by id.
@@ -1366,6 +1437,14 @@ window.__ModuleLoader__.load({
1366
1437
  * otherwise.
1367
1438
  */
1368
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);
1369
1448
  const [draft, setDraft] = (0, react.useState)(null);
1370
1449
  const [press, setPress] = (0, react.useState)(null);
1371
1450
  const drawing = (0, react.useRef)(false);
@@ -1410,7 +1489,7 @@ window.__ModuleLoader__.load({
1410
1489
  const rect = event.currentTarget.getBoundingClientRect();
1411
1490
  const scaleX = rect.width === 0 ? 1 : width / rect.width;
1412
1491
  const scaleY = rect.height === 0 ? 1 : height / rect.height;
1413
- 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];
1414
1493
  };
1415
1494
  /**
1416
1495
  * Describe what the mark landed on.
@@ -1592,6 +1671,7 @@ window.__ModuleLoader__.load({
1592
1671
  kind: "point",
1593
1672
  color,
1594
1673
  points: [point],
1674
+ ...weightField(weight),
1595
1675
  ...anchor === void 0 ? {} : { anchor }
1596
1676
  };
1597
1677
  props.onAdd(mark);
@@ -1608,7 +1688,7 @@ window.__ModuleLoader__.load({
1608
1688
  kind: tool,
1609
1689
  color,
1610
1690
  points: seed,
1611
- weight
1691
+ ...weightField(weight)
1612
1692
  });
1613
1693
  };
1614
1694
  const move = (event) => {
@@ -1634,10 +1714,18 @@ window.__ModuleLoader__.load({
1634
1714
  ...press,
1635
1715
  snap: event.shiftKey
1636
1716
  };
1717
+ const edit = editFor(held, point, dx, dy);
1637
1718
  setPress({
1638
1719
  ...held,
1639
- edit: editFor(held, point, dx, dy)
1720
+ edit
1640
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);
1641
1729
  return;
1642
1730
  }
1643
1731
  if (!drawing.current || draft === null) return;
@@ -1665,6 +1753,7 @@ window.__ModuleLoader__.load({
1665
1753
  if (press !== null) {
1666
1754
  const done = press.edit;
1667
1755
  setPress(null);
1756
+ props.onLiveEdit?.(null);
1668
1757
  if (done === null) return;
1669
1758
  if (press.handle === void 0) {
1670
1759
  /* v8 ignore next -- a move always carries the geometry it moved to. */
@@ -1754,8 +1843,8 @@ window.__ModuleLoader__.load({
1754
1843
  * compared to a washed-out sticker.
1755
1844
  */
1756
1845
  const renderPoint = (mark) => {
1757
- const pin = pinPathData(mark);
1758
- const centre = pinNumberCentre(mark);
1846
+ const pin = pinPathData(mark, unit);
1847
+ const centre = pinNumberCentre(mark, unit);
1759
1848
  /* v8 ignore next -- a point mark carries exactly the one coordinate it was placed at. */
1760
1849
  if (pin === void 0 || centre === void 0) return null;
1761
1850
  const number = numbers.get(mark.id);
@@ -1764,21 +1853,21 @@ window.__ModuleLoader__.load({
1764
1853
  d: pin,
1765
1854
  fill: "#ffffff",
1766
1855
  stroke: "#ffffff",
1767
- strokeWidth: 4.5,
1856
+ strokeWidth: 4.5 * unit,
1768
1857
  strokeLinejoin: "round"
1769
1858
  }),
1770
1859
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
1771
1860
  d: pin,
1772
1861
  fill: mark.color,
1773
1862
  stroke: "#ffffff",
1774
- strokeWidth: 1.5,
1863
+ strokeWidth: 1.5 * unit,
1775
1864
  strokeLinejoin: "round"
1776
1865
  }),
1777
1866
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("text", {
1778
1867
  x: centre[0],
1779
1868
  y: centre[1],
1780
1869
  fontFamily: PIN_FONT_FAMILY,
1781
- fontSize: 11,
1870
+ fontSize: 11 * unit,
1782
1871
  fontWeight: 700,
1783
1872
  fill: PIN_NUMBER_COLOR,
1784
1873
  textAnchor: "middle",
@@ -1796,19 +1885,19 @@ window.__ModuleLoader__.load({
1796
1885
  * handles that would do nothing.
1797
1886
  */
1798
1887
  const renderPointFrame = (shown) => {
1799
- const box = pointMarkBox(shown);
1888
+ const box = pointMarkBox(shown, unit);
1800
1889
  /* v8 ignore next -- a point mark carries exactly the one coordinate it was placed at. */
1801
1890
  if (box === void 0) return null;
1802
1891
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
1803
- x: box.x - 3,
1804
- y: box.y - 3,
1805
- width: box.width + 6,
1806
- height: box.height + 6,
1807
- 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,
1808
1897
  fill: "none",
1809
1898
  stroke: "#4c8dff",
1810
- strokeWidth: 1,
1811
- strokeDasharray: "3 2",
1899
+ strokeWidth: unit,
1900
+ strokeDasharray: `${3 * unit} ${2 * unit}`,
1812
1901
  style: { pointerEvents: "none" }
1813
1902
  });
1814
1903
  };
@@ -1818,7 +1907,7 @@ window.__ModuleLoader__.load({
1818
1907
  const selected = props.selectedId === mark.id;
1819
1908
  const transform = markTransform(shown);
1820
1909
  const isPoint = mark.kind === "point";
1821
- const pin = isPoint ? pinPathData(shown) : void 0;
1910
+ const pin = isPoint ? pinPathData(shown, unit) : void 0;
1822
1911
  /**
1823
1912
  * What a press on the drawn shape starts.
1824
1913
  *
@@ -1830,7 +1919,7 @@ window.__ModuleLoader__.load({
1830
1919
  d: pin,
1831
1920
  fill: "transparent",
1832
1921
  stroke: "transparent",
1833
- strokeWidth: HIT_WIDTH,
1922
+ strokeWidth: HIT_WIDTH * unit,
1834
1923
  style: {
1835
1924
  pointerEvents: "all",
1836
1925
  cursor: "pointer"
@@ -1855,7 +1944,7 @@ window.__ModuleLoader__.load({
1855
1944
  d: path,
1856
1945
  fill: "none",
1857
1946
  stroke: "#ffffff",
1858
- strokeWidth: strokeWidthOf(shown) + 2.5,
1947
+ strokeWidth: strokeWidthOf(shown, unit) + 2.5 * unit,
1859
1948
  strokeLinecap: "round",
1860
1949
  strokeLinejoin: "round"
1861
1950
  }),
@@ -1863,7 +1952,7 @@ window.__ModuleLoader__.load({
1863
1952
  d: path,
1864
1953
  fill: "none",
1865
1954
  stroke: shown.color,
1866
- strokeWidth: strokeWidthOf(shown),
1955
+ strokeWidth: strokeWidthOf(shown, unit),
1867
1956
  strokeLinecap: "round",
1868
1957
  strokeLinejoin: "round"
1869
1958
  }),
@@ -1871,7 +1960,7 @@ window.__ModuleLoader__.load({
1871
1960
  d: path,
1872
1961
  fill: "none",
1873
1962
  stroke: "transparent",
1874
- strokeWidth: HIT_WIDTH,
1963
+ strokeWidth: HIT_WIDTH * unit,
1875
1964
  style: {
1876
1965
  pointerEvents: "stroke",
1877
1966
  cursor: "pointer"
@@ -1892,9 +1981,13 @@ window.__ModuleLoader__.load({
1892
1981
  ]
1893
1982
  }, mark.id);
1894
1983
  };
1895
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1984
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1896
1985
  className: "da-overlay",
1897
1986
  "data-da-overlay": "",
1987
+ style: {
1988
+ left: originX,
1989
+ top: originY
1990
+ },
1898
1991
  width,
1899
1992
  height,
1900
1993
  viewBox: `0 0 ${width} ${height}`,
@@ -1916,7 +2009,10 @@ window.__ModuleLoader__.load({
1916
2009
  }
1917
2010
  props.onSelect(null);
1918
2011
  },
1919
- 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
+ })
1920
2016
  });
1921
2017
  }
1922
2018
  /** A difference that never changes, for a body that has no edit yet. */
@@ -2478,6 +2574,44 @@ window.__ModuleLoader__.load({
2478
2574
  const existing = shell.snapshot.draft.trim();
2479
2575
  shell.setDraft(existing === "" ? message : `${existing}\n\n${message}`);
2480
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
+ }
2481
2615
  //#endregion
2482
2616
  //#region src/client/export.ts
2483
2617
  /**
@@ -2485,17 +2619,19 @@ window.__ModuleLoader__.load({
2485
2619
  *
2486
2620
  * @param mark - the mark to draw.
2487
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.
2488
2624
  * @returns the mark's elements.
2489
2625
  */
2490
- function markSvg(mark, number) {
2626
+ function markSvg(mark, number, unit) {
2491
2627
  const path = markPathData(mark);
2492
2628
  const parts = [];
2493
- if (path !== void 0) parts.push(`<path d="${path}" fill="none" stroke="#ffffff" stroke-width="${strokeWidthOf(mark) + 2.5}" stroke-linecap="round" stroke-linejoin="round"/>`, `<path d="${path}" fill="none" stroke="${mark.color}" stroke-width="${strokeWidthOf(mark)}" stroke-linecap="round" stroke-linejoin="round"/>`);
2494
- const pin = mark.kind === "point" ? pinPathData(mark) : void 0;
2495
- 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;
2496
2632
  if (pin !== void 0 && centre !== void 0) {
2497
- parts.push(`<path d="${pin}" fill="${mark.color}" stroke="#ffffff" stroke-width="2" stroke-linejoin="round"/>`);
2498
- 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>`);
2499
2635
  }
2500
2636
  return parts.join("");
2501
2637
  }
@@ -2531,7 +2667,27 @@ window.__ModuleLoader__.load({
2531
2667
  */
2532
2668
  function composeReviewSvg(layer, marks, numbering) {
2533
2669
  const numbers = new Map(marks.map((mark, index) => [mark.id, numbering?.get(mark.id) ?? index + 1]));
2534
- 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
+ };
2535
2691
  }
2536
2692
  /**
2537
2693
  * Rasterization scale for one review image.
@@ -3120,39 +3276,53 @@ window.__ModuleLoader__.load({
3120
3276
  .da-palette .da-tool .da-tool-icon{height:auto}
3121
3277
  .da-palette .da-tool .da-tool-icon svg{width:16px;height:16px;stroke-width:1.9}
3122
3278
 
3123
- /* --- the weight rail ----------------------------------------------------- */
3124
- /* A vertical rail along the left edge of the figure. Idle it is a hairline: the
3125
- whole control is 6px wide, so it reads as a rule at the edge of the drawing
3126
- rather than as a panel competing with it. Crossing it opens it out to a track
3127
- with a knob, which is where the width can be read and dragged. A horizontal
3128
- range input is rotated upright inside the pill, because a range is the one
3129
- control the browser already knows how to drag with every pointer. */
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. */
3130
3289
  .da-weight{position:absolute;z-index:7;left:6px;top:50%;transform:translateY(-50%);
3131
- display:flex;align-items:center;justify-content:center;width:6px;height:132px;
3290
+ display:flex;align-items:center;justify-content:center;width:7px;height:200px;
3132
3291
  border-radius:999px;background:var(--dsw-alias-border-l2,rgba(127,127,127,.22));
3133
3292
  cursor:ns-resize;transition:width .16s ease,background .16s ease,box-shadow .16s ease}
3134
- .da-weight:hover,.da-weight:focus-within{width:26px;background:var(--dsw-alias-bg-layer-2,#fff);
3293
+ .da-weight:hover,.da-weight:focus-within{width:30px;background:var(--dsw-alias-bg-layer-2,#fff);
3135
3294
  box-shadow:0 5px 18px rgba(20,40,80,.16),0 0 0 0.5px var(--dsw-alias-border-l2,currentColor)}
3136
- /* The track: a hairline down the middle of the pill, only wide enough to see. */
3137
- .da-weight::before{content:"";position:absolute;left:50%;top:11px;bottom:11px;width:2px;
3138
- transform:translateX(-50%);border-radius:1px;background:var(--dsw-alias-border-l3,rgba(127,127,127,.34))}
3139
- /* The filled part of the track, drawn from the bottom up. */
3140
- .da-weight-fill{position:absolute;left:50%;bottom:11px;width:2px;transform:translateX(-50%);
3141
- border-radius:1px;background:var(--dsw-alias-brand-primary,#176df2);opacity:0;transition:opacity .16s ease}
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)}
3142
3311
  .da-weight:hover .da-weight-fill,.da-weight:focus-within .da-weight-fill{opacity:1}
3143
- /* The knob sits on the value. It is the only part that shows at rest, so the rail
3144
- reads as a slider waiting to be touched rather than a bare rule. */
3145
- .da-weight-knob{position:absolute;left:50%;width:9px;height:9px;border-radius:50%;
3146
- transform:translateX(-50%);background:var(--dsw-alias-bg-layer-2,#fff);
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);
3147
3317
  box-shadow:0 1px 4px rgba(20,40,80,.32),0 0 0 1px var(--dsw-alias-border-l3,currentColor);
3148
3318
  transition:width .16s ease,height .16s ease,background .16s ease}
3149
3319
  .da-weight:hover .da-weight-knob,.da-weight:focus-within .da-weight-knob{
3150
- width:13px;height:13px;background:var(--dsw-alias-brand-primary,#176df2);
3320
+ width:15px;height:15px;background:var(--dsw-alias-brand-primary,#176df2);
3151
3321
  box-shadow:0 2px 7px rgba(20,40,80,.34)}
3152
- /* The browser's own range, rotated upright and made transparent: it keeps the
3153
- dragging, the arrow keys, the step and the accessible name, while the rail above
3154
- does the drawing. */
3155
- .da-weight-input{position:absolute;left:50%;top:50%;width:112px;height:26px;margin:0;opacity:0;
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;
3156
3326
  transform:translate(-50%,-50%) rotate(-90deg);cursor:ns-resize;appearance:none;background:transparent}
3157
3327
 
3158
3328
  /* --- the floating bar ---------------------------------------------------- */
@@ -3243,6 +3413,10 @@ window.__ModuleLoader__.load({
3243
3413
  needs to see where the drawing stops. */
3244
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)}
3245
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}
3246
3420
  .da-figure{display:block;user-select:none;-webkit-user-drag:none}
3247
3421
  /* The table is a thing you can push. The hand only shows under the tools that
3248
3422
  leave a drag free to move the view: under a drawing tool the same drag draws. */
@@ -3253,7 +3427,7 @@ window.__ModuleLoader__.load({
3253
3427
  /* The rendered document takes the figure's place; the overlay above it owns every
3254
3428
  pointer event, which is also what keeps a link inside it from navigating. */
3255
3429
  .da-html{display:block;border:0;background:#fff;pointer-events:none}
3256
- .da-overlay{position:absolute;inset:0;touch-action:none}
3430
+ .da-overlay{position:absolute;touch-action:none}
3257
3431
  .da-overlay[data-mode="view"]{pointer-events:none}
3258
3432
  .da-overlay[data-tool="select"]{cursor:default}
3259
3433
  .da-overlay:not([data-tool="select"]){cursor:crosshair}
@@ -3322,9 +3496,14 @@ window.__ModuleLoader__.load({
3322
3496
  box-shadow:0 8px 26px rgba(20,40,80,.2),0 0 0 1px rgba(20,40,80,.04)}
3323
3497
  .da-float .da-comment{flex:1 1 auto;min-width:0;box-sizing:border-box;border:0;background:transparent;
3324
3498
  color:inherit;font:inherit;font-size:13px;line-height:1.55;padding:7px 10px;resize:none;
3325
- /* One line until the sentence needs more: the pill is a place to type, not a
3326
- panel, and the rule for the record cards' textareas sets a 74px floor. */
3327
- min-height:0;height:33px;max-height:120px;overflow-y:auto;outline:none;field-sizing:content}
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}
3328
3507
  .da-float .da-comment::placeholder{color:var(--dsw-alias-label-tertiary,currentColor);opacity:.85}
3329
3508
  /* The hand-off appears only once the field says something: an empty pill is a
3330
3509
  place to type, not a decision to make. */
@@ -3544,13 +3723,34 @@ window.__ModuleLoader__.load({
3544
3723
  * @param size - the card's size, in CSS pixels.
3545
3724
  * @returns the card's top-left corner, in the panel's coordinates.
3546
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
+ }
3547
3745
  function placeCard(anchor, panelWidth, panelHeight, size = {
3548
- width: 252,
3549
- height: 196
3746
+ width: 300,
3747
+ height: 48
3550
3748
  }) {
3551
3749
  const below = anchor.y + anchor.height + 10;
3552
3750
  const above = anchor.y - size.height - 10;
3553
- 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);
3554
3754
  const widest = Math.max(8, panelWidth - size.width - 8);
3555
3755
  return {
3556
3756
  left: Math.min(Math.max(8, anchor.x), widest),
@@ -3640,6 +3840,20 @@ window.__ModuleLoader__.load({
3640
3840
  label: "toolPoint"
3641
3841
  }
3642
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
+ ]);
3643
3857
  /** The copy key naming the shape each kind of mark is, for the list's second line. */
3644
3858
  const TOOL_COPY = {
3645
3859
  arrow: "toolArrow",
@@ -3988,6 +4202,14 @@ window.__ModuleLoader__.load({
3988
4202
  */
3989
4203
  const [detailAt, setDetailAt] = (0, react.useState)(null);
3990
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
+ /**
3991
4213
  * Shut the style panel when the reader picks up a tool.
3992
4214
  *
3993
4215
  * Arming a tool is the moment the panel has done its job; leaving it open over
@@ -4175,6 +4397,23 @@ window.__ModuleLoader__.load({
4175
4397
  rasterSize
4176
4398
  ]);
4177
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
+ /**
4178
4417
  * Everything a background write needs, read at write time.
4179
4418
  *
4180
4419
  * A write runs from a timer that outlives the render that scheduled it, so it has
@@ -4406,31 +4645,38 @@ window.__ModuleLoader__.load({
4406
4645
  const root = rootRef.current;
4407
4646
  const host = figureRef.current;
4408
4647
  const mark = marks.find((candidate) => candidate.id === selectedId);
4409
- 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);
4410
4650
  if (root === null || host === null || box === void 0 || size === null) {
4411
4651
  setDetailAt(null);
4412
4652
  return;
4413
4653
  }
4414
4654
  const panel = root.getBoundingClientRect();
4415
- const drawn = host.getBoundingClientRect();
4655
+ const measured = host.getBoundingClientRect();
4656
+ const pillSize = measuredSize(detailRef.current, {
4657
+ width: 300,
4658
+ height: 48
4659
+ });
4416
4660
  const at = placeCard(scaledBox(box, {
4417
- x: drawn.left,
4418
- y: drawn.top,
4419
- width: drawn.width,
4420
- height: drawn.height
4661
+ x: measured.left,
4662
+ y: measured.top,
4663
+ width: measured.width,
4664
+ height: measured.height
4421
4665
  }, {
4422
4666
  x: panel.left,
4423
4667
  y: panel.top,
4424
4668
  width: panel.width,
4425
4669
  height: panel.height
4426
- }, size.width), panel.width, panel.height);
4670
+ }, size.width), panel.width, panel.height, pillSize);
4427
4671
  setDetailAt((previous) => previous !== null && previous.left === at.left && previous.top === at.top ? previous : at);
4428
4672
  }, [
4673
+ htmlHeight,
4674
+ liveMark,
4429
4675
  marks,
4430
- selectedId,
4431
4676
  scale,
4677
+ selectedId,
4432
4678
  size,
4433
- htmlHeight
4679
+ unit
4434
4680
  ]);
4435
4681
  /**
4436
4682
  * The factor currently drawn, kept beside the state that will hold it.
@@ -4748,8 +4994,9 @@ window.__ModuleLoader__.load({
4748
4994
  ]);
4749
4995
  const exportPng = (0, react.useCallback)(async (target) => {
4750
4996
  const svg = composeReviewSvg(target, marks);
4751
- const exportScale = Math.min(2, Math.max(1, 2400 / Math.max(target.width, target.height)));
4752
- 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);
4753
5000
  }, [marks]);
4754
5001
  const buildDocument = (0, react.useCallback)((target, scope = marks) => {
4755
5002
  if (loaded === null) return null;
@@ -4973,7 +5220,16 @@ window.__ModuleLoader__.load({
4973
5220
  * the next stroke, which is where the remembered preference comes in.
4974
5221
  */
4975
5222
  const shownColor = selected === null ? color : selected.color;
4976
- const shownWeight = selected === null ? weight : selected.weight ?? 1;
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;
4977
5233
  /**
4978
5234
  * Change one attribute of the selected mark, or of the next stroke when nothing
4979
5235
  * is selected.
@@ -5004,16 +5260,32 @@ window.__ModuleLoader__.load({
5004
5260
  }
5005
5261
  repaintSelected(selected, { weight: held });
5006
5262
  };
5007
- const surfaceStyle = size === null ? {} : {
5008
- width: size.width * scale,
5009
- height: size.height * scale
5263
+ const surfaceStyle = paper === null ? {} : {
5264
+ width: paper.width * scale,
5265
+ height: paper.height * scale
5010
5266
  };
5011
- const figureStyle = size === null ? {} : {
5012
- width: size.width,
5013
- height: size.height,
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,
5014
5279
  transform: `scale(${scale})`,
5015
5280
  transformOrigin: "top left"
5016
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
+ };
5017
5289
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5018
5290
  className: "da-root",
5019
5291
  ref: rootRef,
@@ -5042,64 +5314,67 @@ window.__ModuleLoader__.load({
5042
5314
  className: "da-surface",
5043
5315
  ref: surfaceRef,
5044
5316
  style: surfaceStyle,
5045
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5046
- ref: figureRef,
5047
- style: {
5048
- ...figureStyle,
5049
- position: "relative"
5050
- },
5051
- children: [figure?.kind === "svg" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
5052
- ref: figureHostRef,
5053
- dangerouslySetInnerHTML: { __html: figure.markup }
5054
- }) : figure?.kind === "raster" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
5055
- className: "da-figure",
5056
- src: figure.objectUrl,
5057
- width: size?.width,
5058
- height: size?.height,
5059
- alt: "",
5060
- onLoad: (event) => {
5061
- const image = event.currentTarget;
5062
- setFileRasterSize({
5063
- width: image.naturalWidth,
5064
- height: image.naturalHeight
5065
- });
5066
- }
5067
- }) : figure?.kind === "html" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("iframe", {
5068
- ref: frameRef,
5069
- className: "da-html",
5070
- title: t("htmlFrame"),
5071
- sandbox: "allow-same-origin",
5072
- style: {
5073
- width: figure.width,
5074
- height: htmlHeight
5075
- }
5076
- }) : null, size === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Canvas, {
5077
- width: size.width,
5078
- height: size.height,
5079
- marks,
5080
- tool,
5081
- color,
5082
- weight,
5083
- containerRef: figureHostRef,
5084
- anchoring: isVector,
5085
- anchorAtFigurePoint: figure?.kind === "html" ? anchorAtFigurePoint : void 0,
5086
- selectedId,
5087
- onSelect: setSelectedId,
5088
- onAdd: addMark,
5089
- onMove: moveMark,
5090
- onUpdate: updateMark,
5091
- onRemove: removeMark,
5092
- onPan: panBy,
5093
- onPanStart: () => {
5094
- setPanning(true);
5095
- },
5096
- onPanEnd: () => {
5097
- setPanning(false);
5098
- },
5099
- onEdit: editMark,
5100
- onDrawn: finishDrawing,
5101
- numbering: props.numbering
5102
- })]
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
+ })
5103
5378
  })
5104
5379
  })
5105
5380
  }),
@@ -5281,14 +5556,14 @@ window.__ModuleLoader__.load({
5281
5556
  })]
5282
5557
  })]
5283
5558
  }),
5284
- tool === "point" || selected?.kind === "point" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5559
+ shownWeight === null ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
5285
5560
  className: "da-weight",
5286
- title: t("weight"),
5561
+ title: t(selected?.kind === "point" || tool === "point" ? "pinSize" : "weight"),
5287
5562
  children: [
5288
5563
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
5289
5564
  type: "range",
5290
5565
  className: "da-weight-input",
5291
- "aria-label": t("weight"),
5566
+ "aria-label": t(selected?.kind === "point" || tool === "point" ? "pinSize" : "weight"),
5292
5567
  min: WEIGHT_MIN,
5293
5568
  max: 4,
5294
5569
  step: .1,
@@ -5305,7 +5580,7 @@ window.__ModuleLoader__.load({
5305
5580
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5306
5581
  className: "da-weight-knob",
5307
5582
  "aria-hidden": true,
5308
- style: { bottom: `calc(${(shownWeight - WEIGHT_MIN) / (4 - WEIGHT_MIN) * 100}% - 5px)` }
5583
+ style: { bottom: `calc(${(shownWeight - WEIGHT_MIN) / (4 - WEIGHT_MIN) * 100}% - 5.5px)` }
5309
5584
  })
5310
5585
  ]
5311
5586
  }),
@@ -5644,15 +5919,18 @@ window.__ModuleLoader__.load({
5644
5919
  const images = [];
5645
5920
  const numbering = new Map(document.marks.map((mark, index) => [mark.id, index + 1]));
5646
5921
  for (const page of pages) {
5647
- const svg = composeReviewSvg({
5922
+ const layer = {
5648
5923
  kind: "raster",
5649
5924
  dataUrl: page.dataUrl,
5650
5925
  width: page.width,
5651
5926
  height: page.height
5652
- }, 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);
5653
5931
  images.push({
5654
5932
  page: page.page,
5655
- 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))
5656
5934
  });
5657
5935
  }
5658
5936
  return images;
@@ -6178,6 +6456,7 @@ window.__ModuleLoader__.load({
6178
6456
  createAutosave,
6179
6457
  annotationSignature,
6180
6458
  autosaveDelayMs: 600,
6459
+ figureUnit,
6181
6460
  seat: createDocumentSeat({
6182
6461
  sessions: ctx.get("sessions"),
6183
6462
  conversation: ctx.get("conversation"),
package/lib/client.pdf.js CHANGED
@@ -25856,7 +25856,7 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
25856
25856
  * @param transform - `[a b c d e f]`, user space → page units.
25857
25857
  * @returns the placed geometry.
25858
25858
  */
25859
- function place(mark, transform) {
25859
+ function place(mark, transform, unit) {
25860
25860
  const scale = unitScale(transform);
25861
25861
  const radians = (mark.rotation ?? 0) * Math.PI / 180;
25862
25862
  const box = pointsBox(mark.points);
@@ -25867,7 +25867,7 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
25867
25867
  if (mark.kind === "rect" || mark.kind === "ellipse") {
25868
25868
  if (turned) points = turnedRing(mark.kind, mark.points, centre, radians);
25869
25869
  } else if (spins) points = mark.points.map((point) => turnAround(point, centre, radians));
25870
- const weight = mark.weight ?? 1;
25870
+ const weight = (mark.weight ?? 1) * unit;
25871
25871
  return {
25872
25872
  points: points.map((point) => toUserSpace(transform, point)),
25873
25873
  width: STROKE_WIDTH * weight / scale,
@@ -25963,13 +25963,14 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
25963
25963
  * @param rect - the box the appearance is drawn in.
25964
25964
  * @returns the content stream operators.
25965
25965
  */
25966
- function pointContent(mark, placed, rect) {
25966
+ function pointContent(mark, placed, rect, unit) {
25967
25967
  const point = placed.points[0];
25968
25968
  /* v8 ignore next -- a point mark carries exactly the one coordinate it was placed at. */
25969
25969
  if (point === void 0) return "";
25970
25970
  const [x, y] = [point[0] - rect.x, point[1] - rect.y];
25971
- const radius = POINT_RADIUS / Math.max(placed.width / STROKE_WIDTH, 1e-4);
25972
- const dot = POINT_DOT_RADIUS / Math.max(placed.width / STROKE_WIDTH, 1e-4);
25971
+ const base = Math.max(placed.width / (STROKE_WIDTH * unit), 1e-4);
25972
+ const radius = POINT_RADIUS / base;
25973
+ const dot = POINT_DOT_RADIUS / base;
25973
25974
  const ring = ellipsePath(x, y, radius, radius);
25974
25975
  const centre = ellipsePath(x, y, dot, dot);
25975
25976
  return `${colorOperators(mark.color, "RG")} ${num(placed.width)} w 1 J ${ring} S ${colorOperators(mark.color, "rg")} ${centre} f`;
@@ -26014,8 +26015,8 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
26014
26015
  * @returns the appearance stream reference, or undefined when the mark draws
26015
26016
  * nothing a stream is needed for.
26016
26017
  */
26017
- function appearance(context, mark, placed, rect) {
26018
- const content = mark.kind === "point" ? pointContent(mark, placed, rect) : strokeContent(mark, placed, rect);
26018
+ function appearance(context, mark, placed, rect, unit) {
26019
+ const content = mark.kind === "point" ? pointContent(mark, placed, rect, unit) : strokeContent(mark, placed, rect);
26019
26020
  if (content === "") return void 0;
26020
26021
  const dictionary = import_cjs.PDFDict.withContext(context);
26021
26022
  dictionary.set(import_cjs.PDFName.of("Type"), import_cjs.PDFName.of("XObject"));
@@ -26054,11 +26055,11 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
26054
26055
  * @param transform - `[a b c d e f]`, user space → page units.
26055
26056
  * @returns the annotation dictionary.
26056
26057
  */
26057
- function annotation(context, mark, transform) {
26058
- const placed = place(mark, transform);
26058
+ function annotation(context, mark, transform, unit) {
26059
+ const placed = place(mark, transform, unit);
26059
26060
  if (mark.points[0] === void 0 || placed.points[0] === void 0) throw new Error("an annotation mark needs at least one coordinate pair");
26060
26061
  const rect = boundsOf(drawnPoints(mark, placed), placed.width * 2);
26061
- const box = mark.kind === "point" ? boundsOf(placed.points, POINT_RADIUS * 2) : rect;
26062
+ const box = mark.kind === "point" ? boundsOf(placed.points, POINT_RADIUS * unit * 2) : rect;
26062
26063
  const dictionary = import_cjs.PDFDict.withContext(context);
26063
26064
  dictionary.set(import_cjs.PDFName.of("Type"), import_cjs.PDFName.of("Annot"));
26064
26065
  dictionary.set(import_cjs.PDFName.of("Subtype"), import_cjs.PDFName.of(annotationSubtype(mark)));
@@ -26102,7 +26103,7 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
26102
26103
  dictionary.set(import_cjs.PDFName.of("LE"), endings);
26103
26104
  }
26104
26105
  }
26105
- const stream = appearance(context, mark, placed, box);
26106
+ const stream = appearance(context, mark, placed, box, unit);
26106
26107
  if (stream !== void 0) {
26107
26108
  const streams = import_cjs.PDFDict.withContext(context);
26108
26109
  streams.set(import_cjs.PDFName.of("N"), stream);
@@ -26150,7 +26151,10 @@ while (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan] &
26150
26151
  const page = document.getPage(input.page - 1);
26151
26152
  if (input.marks.length === 0) continue;
26152
26153
  const annots = pageAnnots(document.context, page);
26153
- for (const mark of input.marks) annots.push(document.context.register(annotation(document.context, mark, input.transform)));
26154
+ for (const mark of input.marks) {
26155
+ const unit = input.unit ?? 1;
26156
+ annots.push(document.context.register(annotation(document.context, mark, input.transform, unit)));
26157
+ }
26154
26158
  }
26155
26159
  return await document.save();
26156
26160
  }
@@ -50274,7 +50278,7 @@ fn fs_main(in : VertexOutput) -> @location(0) vec4<f32> {
50274
50278
  });
50275
50279
  try {
50276
50280
  const pages = markedPages(marks);
50277
- const annotatedPdf = await annotatePdf(bytes, await annotateInputs(pdf, pages));
50281
+ const annotatedPdf = await annotatePdf(bytes, await annotateInputs(pdf, pages, props.figureUnit));
50278
50282
  const saved = await seats.save(address, input, annotatedPdf);
50279
50283
  autosave.cancel();
50280
50284
  writtenSignature.current = signature(input);
@@ -50456,14 +50460,15 @@ fn fs_main(in : VertexOutput) -> @location(0) vec4<f32> {
50456
50460
  * @param pages - the marked pages.
50457
50461
  * @returns one input per page, each with its marks and its user-space transform.
50458
50462
  */
50459
- async function annotateInputs(pdf, pages) {
50463
+ async function annotateInputs(pdf, pages, unitOf) {
50460
50464
  const inputs = [];
50461
50465
  for (const entry of pages) {
50462
50466
  const size = await pdf.size(entry.page);
50463
50467
  inputs.push({
50464
50468
  page: entry.page,
50465
50469
  transform: size.transform,
50466
- marks: entry.marks
50470
+ marks: entry.marks,
50471
+ unit: unitOf(size.width, size.height)
50467
50472
  });
50468
50473
  }
50469
50474
  return inputs;
package/market/index.json CHANGED
@@ -93,8 +93,8 @@
93
93
  },
94
94
  "versions": [
95
95
  {
96
- "version": "1.1.0",
97
- "publishTimestamp": 1792725959,
96
+ "version": "1.1.1",
97
+ "publishTimestamp": 1792729486,
98
98
  "source": {
99
99
  "type": "npm",
100
100
  "registry": "https://registry.npmjs.org"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-figmark",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "private": false,
5
5
  "description": "DSH 插件:在右侧栏预览里对科研配图、SVG、PDF、HTML 直接圈画批注,标注写进侧车文件并可一键交给智能体改图源",
6
6
  "license": "MIT",