@reekon-tools/boldr-cad 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +21 -4
  2. package/dist/app/edit.d.ts +3 -3
  3. package/dist/app/edit.js +26 -1
  4. package/dist/app/edit.js.map +1 -1
  5. package/dist/app/state.d.ts +3 -5
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +149 -169
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +2 -2
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/linkActions.d.ts +22 -3
  23. package/dist/editor/linkActions.js +95 -13
  24. package/dist/editor/linkActions.js.map +1 -1
  25. package/dist/editor/mode.js +4 -3
  26. package/dist/editor/mode.js.map +1 -1
  27. package/dist/editor/project.js +1 -2
  28. package/dist/editor/project.js.map +1 -1
  29. package/dist/editor/scaleGate.d.ts +13 -0
  30. package/dist/editor/scaleGate.js +27 -0
  31. package/dist/editor/scaleGate.js.map +1 -0
  32. package/dist/editor/selection.js +13 -9
  33. package/dist/editor/selection.js.map +1 -1
  34. package/dist/editor/tooltips.js +9 -0
  35. package/dist/editor/tooltips.js.map +1 -1
  36. package/dist/editor/view3d.d.ts +4 -1
  37. package/dist/editor/view3d.js +6 -2
  38. package/dist/editor/view3d.js.map +1 -1
  39. package/dist/export/pdf.d.ts +5 -2
  40. package/dist/export/pdf.js +12 -8
  41. package/dist/export/pdf.js.map +1 -1
  42. package/dist/export/plan.d.ts +1 -1
  43. package/dist/export/plan.js +14 -9
  44. package/dist/export/plan.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/model/demo.d.ts +1 -0
  49. package/dist/model/demo.js +2 -1
  50. package/dist/model/demo.js.map +1 -1
  51. package/dist/model/elevation.d.ts +1 -1
  52. package/dist/model/elevation.js +3 -3
  53. package/dist/model/elevation.js.map +1 -1
  54. package/dist/model/history.js +4 -1
  55. package/dist/model/history.js.map +1 -1
  56. package/dist/model/links.d.ts +4 -1
  57. package/dist/model/links.js +9 -2
  58. package/dist/model/links.js.map +1 -1
  59. package/dist/model/measurement.d.ts +2 -2
  60. package/dist/model/measurement.js +3 -3
  61. package/dist/model/measurement.js.map +1 -1
  62. package/dist/model/openings.d.ts +10 -0
  63. package/dist/model/openings.js +18 -0
  64. package/dist/model/openings.js.map +1 -1
  65. package/dist/model/planGeom.d.ts +37 -2
  66. package/dist/model/planGeom.js +72 -9
  67. package/dist/model/planGeom.js.map +1 -1
  68. package/dist/model/project.js +5 -2
  69. package/dist/model/project.js.map +1 -1
  70. package/dist/model/rooms.d.ts +18 -5
  71. package/dist/model/rooms.js +178 -73
  72. package/dist/model/rooms.js.map +1 -1
  73. package/dist/model/scale.d.ts +30 -0
  74. package/dist/model/scale.js +120 -0
  75. package/dist/model/scale.js.map +1 -0
  76. package/dist/model/settings.d.ts +0 -1
  77. package/dist/model/settings.js +1 -1
  78. package/dist/model/settings.js.map +1 -1
  79. package/dist/model/sketch.d.ts +21 -1
  80. package/dist/model/sketch.js +58 -4
  81. package/dist/model/sketch.js.map +1 -1
  82. package/dist/model/stroke.js +2 -2
  83. package/dist/model/types.d.ts +38 -7
  84. package/dist/react/BottomBar.d.ts +2 -1
  85. package/dist/react/BottomBar.js +94 -25
  86. package/dist/react/BottomBar.js.map +1 -1
  87. package/dist/react/Drawer.js +3 -2
  88. package/dist/react/Drawer.js.map +1 -1
  89. package/dist/react/Duck.js.map +1 -1
  90. package/dist/react/KeypadSheet.js.map +1 -1
  91. package/dist/react/MarkupPanel.js +1 -1
  92. package/dist/react/PlanCanvas.native.js +54 -7
  93. package/dist/react/PlanCanvas.native.js.map +1 -1
  94. package/dist/react/Popovers.js +14 -3
  95. package/dist/react/Popovers.js.map +1 -1
  96. package/dist/react/Rail.js +1 -1
  97. package/dist/react/Sheet.d.ts +2 -2
  98. package/dist/react/Sheet.js.map +1 -1
  99. package/dist/react/barScroll.d.ts +23 -0
  100. package/dist/react/barScroll.js +47 -0
  101. package/dist/react/barScroll.js.map +1 -0
  102. package/dist/react/corners.d.ts +6 -2
  103. package/dist/react/corners.js.map +1 -1
  104. package/dist/react/host.d.ts +7 -0
  105. package/dist/react/host.js +14 -1
  106. package/dist/react/host.js.map +1 -1
  107. package/dist/react/motion.d.ts +1 -1
  108. package/dist/react/motion.js +1 -1
  109. package/dist/react/motion.js.map +1 -1
  110. package/dist/render/draw.d.ts +21 -4
  111. package/dist/render/draw.js +184 -149
  112. package/dist/render/draw.js.map +1 -1
  113. package/dist/render/elevation.js +3 -3
  114. package/dist/render/elevation.js.map +1 -1
  115. package/dist/render/figmaIcons.d.ts +6 -0
  116. package/dist/render/figmaIcons.js +16 -1
  117. package/dist/render/figmaIcons.js.map +1 -1
  118. package/dist/render/hit.d.ts +8 -1
  119. package/dist/render/hit.js +23 -4
  120. package/dist/render/hit.js.map +1 -1
  121. package/dist/render/icons.js +27 -20
  122. package/dist/render/icons.js.map +1 -1
  123. package/dist/render/lens.d.ts +27 -0
  124. package/dist/render/lens.js +41 -0
  125. package/dist/render/lens.js.map +1 -0
  126. package/dist/render/linkTile.d.ts +20 -2
  127. package/dist/render/linkTile.js +476 -246
  128. package/dist/render/linkTile.js.map +1 -1
  129. package/dist/render/snap.d.ts +5 -1
  130. package/dist/render/snap.js +3 -1
  131. package/dist/render/snap.js.map +1 -1
  132. package/dist/render/svgPath.d.ts +29 -0
  133. package/dist/render/svgPath.js +114 -0
  134. package/dist/render/svgPath.js.map +1 -0
  135. package/dist/render/tileIcons.d.ts +18 -0
  136. package/dist/render/tileIcons.js +67 -0
  137. package/dist/render/tileIcons.js.map +1 -0
  138. package/dist/render/view.d.ts +18 -1
  139. package/dist/render/view.js +31 -4
  140. package/dist/render/view.js.map +1 -1
  141. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  142. package/dist/skia/SkiaDrawCtx.js +27 -5
  143. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  144. package/dist/solver/equations.d.ts +6 -0
  145. package/dist/solver/equations.js +23 -6
  146. package/dist/solver/equations.js.map +1 -1
  147. package/dist/solver/gauss-newton.js +6 -1
  148. package/dist/solver/gauss-newton.js.map +1 -1
  149. package/dist/tools/annotate.d.ts +0 -2
  150. package/dist/tools/annotate.js +1 -22
  151. package/dist/tools/annotate.js.map +1 -1
  152. package/dist/tools/chamfer.js +4 -0
  153. package/dist/tools/chamfer.js.map +1 -1
  154. package/dist/tools/dim.d.ts +3 -3
  155. package/dist/tools/dim.js +26 -65
  156. package/dist/tools/dim.js.map +1 -1
  157. package/dist/tools/fillet.js +4 -0
  158. package/dist/tools/fillet.js.map +1 -1
  159. package/dist/tools/freehand.js +1 -4
  160. package/dist/tools/freehand.js.map +1 -1
  161. package/dist/tools/line.js +15 -11
  162. package/dist/tools/line.js.map +1 -1
  163. package/dist/tools/modify.js +6 -1
  164. package/dist/tools/modify.js.map +1 -1
  165. package/dist/tools/registry.d.ts +5 -0
  166. package/dist/tools/registry.js +6 -1
  167. package/dist/tools/registry.js.map +1 -1
  168. package/dist/tools/select.d.ts +19 -0
  169. package/dist/tools/select.js +116 -17
  170. package/dist/tools/select.js.map +1 -1
  171. package/dist/tools/shapes.d.ts +1 -1
  172. package/dist/tools/shapes.js +12 -5
  173. package/dist/tools/shapes.js.map +1 -1
  174. package/dist/tools/survey.d.ts +6 -0
  175. package/dist/tools/survey.js +28 -0
  176. package/dist/tools/survey.js.map +1 -1
  177. package/dist/view3d/build.js +9 -8
  178. package/dist/view3d/build.js.map +1 -1
  179. package/package.json +9 -9
  180. package/src/app/edit.ts +25 -1
  181. package/src/app/state.ts +3 -5
  182. package/src/data/sync.ts +7 -2
  183. package/src/editor/actions.ts +126 -112
  184. package/src/editor/elevation.ts +13 -16
  185. package/src/editor/gestures.ts +83 -13
  186. package/src/editor/keys.ts +2 -2
  187. package/src/editor/linkActions.ts +90 -13
  188. package/src/editor/mode.ts +4 -3
  189. package/src/editor/project.ts +1 -2
  190. package/src/editor/scaleGate.ts +32 -0
  191. package/src/editor/selection.ts +13 -7
  192. package/src/editor/tooltips.ts +8 -0
  193. package/src/editor/view3d.ts +6 -2
  194. package/src/export/pdf.ts +7 -4
  195. package/src/export/plan.ts +14 -10
  196. package/src/index.ts +1 -1
  197. package/src/model/demo.ts +2 -1
  198. package/src/model/elevation.ts +4 -4
  199. package/src/model/history.ts +4 -1
  200. package/src/model/links.ts +8 -2
  201. package/src/model/measurement.ts +3 -3
  202. package/src/model/openings.ts +18 -0
  203. package/src/model/planGeom.ts +70 -10
  204. package/src/model/project.ts +6 -2
  205. package/src/model/rooms.ts +151 -63
  206. package/src/model/scale.ts +105 -0
  207. package/src/model/settings.ts +1 -2
  208. package/src/model/sketch.ts +48 -4
  209. package/src/model/stroke.ts +2 -2
  210. package/src/model/types.ts +38 -7
  211. package/src/react/BottomBar.tsx +114 -29
  212. package/src/react/Drawer.tsx +14 -10
  213. package/src/react/Duck.tsx +3 -3
  214. package/src/react/KeypadSheet.tsx +2 -2
  215. package/src/react/MarkupPanel.tsx +1 -1
  216. package/src/react/PlanCanvas.native.tsx +60 -6
  217. package/src/react/Popovers.tsx +24 -9
  218. package/src/react/Rail.tsx +1 -1
  219. package/src/react/Sheet.tsx +3 -3
  220. package/src/react/barScroll.ts +57 -0
  221. package/src/react/corners.ts +6 -3
  222. package/src/react/host.ts +14 -1
  223. package/src/react/motion.tsx +6 -6
  224. package/src/render/draw.ts +156 -116
  225. package/src/render/elevation.ts +5 -4
  226. package/src/render/figmaIcons.ts +17 -1
  227. package/src/render/hit.ts +21 -4
  228. package/src/render/icons.ts +27 -20
  229. package/src/render/lens.ts +52 -0
  230. package/src/render/linkTile.ts +436 -190
  231. package/src/render/snap.ts +7 -2
  232. package/src/render/svgPath.ts +95 -0
  233. package/src/render/tileIcons.ts +75 -0
  234. package/src/render/view.ts +37 -5
  235. package/src/skia/SkiaDrawCtx.ts +26 -4
  236. package/src/solver/equations.ts +22 -6
  237. package/src/solver/gauss-newton.ts +4 -1
  238. package/src/styles.css +3 -2
  239. package/src/tools/annotate.ts +1 -18
  240. package/src/tools/chamfer.ts +3 -0
  241. package/src/tools/dim.ts +26 -52
  242. package/src/tools/fillet.ts +3 -0
  243. package/src/tools/freehand.ts +1 -3
  244. package/src/tools/line.ts +12 -11
  245. package/src/tools/modify.ts +5 -1
  246. package/src/tools/registry.ts +10 -1
  247. package/src/tools/select.ts +99 -18
  248. package/src/tools/shapes.ts +9 -4
  249. package/src/tools/survey.ts +21 -0
  250. package/src/ui/ctx.ts +4 -3
  251. package/src/ui/drawer.ts +8 -2
  252. package/src/view3d/build.ts +11 -10
@@ -2,10 +2,14 @@ import { LINK_PIN_PX, LINK_TILE_PX } from '../model/links';
2
2
  import { noteLook } from '../model/markup';
3
3
  import { strokeMarkup } from './markup';
4
4
  import { smoothRectPath } from './squircle';
5
+ import { drawSvgIcon, svgIcon } from './svgPath';
5
6
  import { FONT, TH } from './theme';
6
- import { cvs, leaderPath, linkBox, linkThumb } from './view';
7
+ import { BADGE_CAD_SVG, BADGE_CON_CALC_SVG, BADGE_CONVERSION_SVG, BADGE_FILE_SVG, BADGE_LABEL_SVG, BADGE_NOTE_SVG, KEY_CAMERA_SVG, KEY_MEASUREMENT_SVG, KEY_SKETCH_SVG, } from './tileIcons';
8
+ import { cvs, leaderPath, linkBox, linkThumb, requestDraw } from './view';
7
9
  /** Draw tile `n` and its leader. */
8
10
  export function drawLink(c, T, n, o) {
11
+ if (!watchingFonts)
12
+ watchFonts();
9
13
  const b = linkBox(T, n), lp = leaderPath(T, n, b);
10
14
  c.save();
11
15
  c.globalAlpha = o.alpha;
@@ -23,8 +27,42 @@ export function drawLink(c, T, n, o) {
23
27
  * (within a fifth of `px`): a pinch draws with the same few paints, on a device, not a new one at every step.
24
28
  */
25
29
  const screenW = (px, k) => Math.pow(2, Math.round(Math.log2(px / k) * 4) / 4);
26
- /** The layout's side, and its rounding; how wide a selected tile's ring is outside its edge (pt). */
27
- const L = LINK_TILE_PX, R = 12, SELECT_W = 3;
30
+ /** Figma's 120 tile in the layout's units: every measure of the frame × F. */
31
+ const L = LINK_TILE_PX, F = L / 120;
32
+ /** The card's corners (10, smoothed 60% as every corner in the editor); how wide a selected tile's ring is (pt). */
33
+ const R = 10 * F, SELECT_W = 3;
34
+ /** The bar along the card's foot: 28 tall. */
35
+ const FOOT_H = 28 * F;
36
+ /** The badge in it (20, corners 6), 4 in from the card's side and the bar's top; the name 5.37 past it, 4 short of the far side. */
37
+ const BADGE = 20 * F, BADGE_IN = 4 * F, NAME_X = 29.366 * F, NAME_END = 4 * F;
38
+ /** The name: Inter Medium 10 in the frame, its baseline 18 below the bar's top. */
39
+ const NAME_PX = 10 * F, NAME_BASE = 18 * F;
40
+ /**
41
+ * The bar, its badge and its name are one size on the screen at any zoom (the user's call, 2026-10-26: "on zoom
42
+ * nothing scales but the text — the bottom banner stays the same height, and so does the icon"): the frame's, ×
43
+ * BAR_SCALE. Its name is 12 (the user's call, 2026-10-26: "make it 12 px font … adjust the banner and icon size if
44
+ * needed to scale properly"), so the bar is 33.6 and its badge 24, as the frame has them to its 10. The card and what
45
+ * it shows grow with the drawing; a name shows more of itself as the card widens. BAR_K: the card's scale at which its
46
+ * layout's bar is that size.
47
+ */
48
+ const BAR_SCALE = 1.2, BAR_K = BAR_SCALE / F;
49
+ /**
50
+ * The tile's type: Figma's Inter. BOLDR Web's pages load it; on a device the host's font is drawn whatever is named
51
+ * here (BOLDR passes Inter). Anywhere without it, the editor's own.
52
+ */
53
+ const TILE_FONT = '"Inter",' + FONT;
54
+ /**
55
+ * The card's corners at scale `k`: R, up to the card at BAR_K (144 across); bigger than that, about as big on the
56
+ * screen as they are there (12: the frame's 10, as the bar is the frame's × BAR_SCALE), so they never reach into its
57
+ * badge or its name's ends — in steps, as screenW's, so a pinch makes a few shapes and not one a step: 11 to 13.1.
58
+ */
59
+ const cornerOf = (k) => (k <= BAR_K ? R : R * Math.pow(2, Math.round(Math.log2(BAR_K / k) * 4) / 4));
60
+ /**
61
+ * The tile's own colours (Figma's, the same in both themes: a tile is a dark card on any canvas): the card under its
62
+ * picture (General/Grey - 1), the bar (Grey - 2), its hairlines (Grey - 3), quiet text (Grey - 4), and the light grey a
63
+ * calculator's diagram sits on (Light Grey - 2).
64
+ */
65
+ const TILE = { card: '#2B2C2E', bar: '#424345', line: '#626364', quiet: '#9FA0A2', name: '#FFFFFF', paper: '#E8EAED' };
28
66
  /** A rounded rectangle's path, one shape for each size (as draw.ts's rr: kept by an engine that can). */
29
67
  function rr(c, x, y, w, h, r) {
30
68
  if (c.shape)
@@ -32,20 +70,93 @@ function rr(c, x, y, w, h, r) {
32
70
  else
33
71
  smoothRectPath(c, x, y, w, h, r);
34
72
  }
35
- const glyphOf = (n, info) => {
36
- if (info?.exists === false)
37
- return 'removed';
73
+ /**
74
+ * Clip to the card above its bar, or (`bar`) to the bar — the bar drawn at its scale `u` within the card's. Described in
75
+ * the bar's own scale, where it's one size whatever the zoom, so it's one shape kept, never one a step of a pinch.
76
+ */
77
+ function clipToBar(c, u, bar) {
78
+ // (far past the card every way but the one edge that counts, the bar's top: put where the shape starts, so it's
79
+ // exactly there however the rest rounds. The biggest card, 480" at the closest zoom (400 px an inch), is 128,000 of
80
+ // the bar's units across: LINK_MAX_IN × ZOOM_MAX / BAR_SCALE.)
81
+ const far = 1e6, d = bar ? far : -far;
82
+ const draw = (cc, X, Y) => {
83
+ cc.beginPath();
84
+ cc.moveTo(X - far, Y);
85
+ cc.lineTo(X + far, Y);
86
+ cc.lineTo(X + far, Y + d);
87
+ cc.lineTo(X - far, Y + d);
88
+ cc.closePath();
89
+ };
90
+ c.translate(0, L);
91
+ c.scale(u, u);
92
+ c.translate(0, -L);
93
+ if (c.shape)
94
+ c.shape('bar clip ' + bar, 0, L - FOOT_H, draw);
95
+ else
96
+ draw(c, 0, L - FOOT_H);
97
+ c.clip();
98
+ c.translate(0, L);
99
+ c.scale(1 / u, 1 / u);
100
+ c.translate(0, -L);
101
+ }
102
+ /** The host's kind of file (LinkInfo.fileType) as a tile type: a PDF or anything unknown is a file. */
103
+ export function tileTypeOf(n, info) {
38
104
  if (n.link.kind === 'measurement')
39
- return 'tape';
40
- const t = info?.fileType ?? n.link.fileType;
41
- return t === 'photo' || t === 'pdf' || t === 'note' || t === 'calculator' || t === 'label' || t === 'drawing' ? t : 'document';
105
+ return 'measurement';
106
+ switch (info?.fileType ?? n.link.fileType) {
107
+ case 'photo': return 'image';
108
+ case 'drawing': return 'sketch';
109
+ case 'calculator': return 'concalc';
110
+ case 'conversion': return 'conversion';
111
+ case 'note': return 'note';
112
+ case 'label': return 'label';
113
+ case 'cad': return 'cad';
114
+ default: return 'file';
115
+ }
116
+ }
117
+ const badge = (hue, svg, key, x, y, size) => ({ hue, icon: svgIcon(svg), key, x, y, size });
118
+ export const BADGES = {
119
+ file: badge('#E93C3E', BADGE_FILE_SVG, 'tile file', 5, 3.6034, 12.7932),
120
+ concalc: badge('#BD30A8', BADGE_CON_CALC_SVG, 'tile concalc', 2.9285, 4.5, 14.1429),
121
+ conversion: badge('#505DC1', BADGE_CONVERSION_SVG, 'tile conversion', 4.0002, 4.7068, 12),
122
+ note: badge('#0DBF4A', BADGE_NOTE_SVG, 'tile note', 3.5, 4, 13),
123
+ cad: badge('#23C7C7', BADGE_CAD_SVG, 'tile cad', 3.2557, 2.7557, 14.4887),
124
+ label: badge('#F7781E', BADGE_LABEL_SVG, 'tile label', 3.5, 3.7491, 13),
125
+ // (the sheet's icons are drawn on their 70 tile, their glyph in its middle: the tile scaled so the glyph is 13)
126
+ sketch: badge('#EFB107', KEY_SKETCH_SVG, 'tile sketch', 10 - 29.4 / 2, 10 - 29.4 / 2, 29.4),
127
+ measurement: badge('#0066FF', KEY_MEASUREMENT_SVG, 'tile measurement', 10 - 26.8 / 2, 10 - 26.8 / 2, 26.8),
128
+ image: badge('#000000', KEY_CAMERA_SVG, 'tile camera', 3.5, 10 - (26.8881 * 13) / 36 / 2, 13),
42
129
  };
130
+ /** Badge `t` `size` across, its top left at x, y: its hue (or `hue`), its icon in white in its middle. */
131
+ function drawBadge(c, t, x, y, size, hue) {
132
+ const bd = BADGES[t], s = size / 20;
133
+ rr(c, x, y, size, size, 6 * s);
134
+ c.fillStyle = hue ?? bd.hue;
135
+ c.fill();
136
+ drawSvgIcon(c, bd.icon, bd.key, x + bd.x * s, y + bd.y * s, bd.size * s);
137
+ }
138
+ /** Its picture, once loaded (asked for only near the screen: a drawing can link more than are kept). */
139
+ function pictureOf(b, n, info) {
140
+ const gone = info?.exists === false, near = b.x < cvs.W * 2 && b.y < cvs.H * 2 && b.x + b.s > -cvs.W && b.y + b.s > -cvs.H;
141
+ const pic = near && !gone && n.link.kind === 'file' && info?.thumbUrl ? linkThumb(info.thumbUrl) : null;
142
+ return pic && pic.complete && pic.naturalWidth > 0 ? pic : null;
143
+ }
144
+ /** What the card is filled with under its preview: a calculator's light grey, a label's bar grey, a conversion's page. */
145
+ function cardFill(type, pic, info) {
146
+ if (pic)
147
+ return type === 'concalc' ? TILE.paper : type === 'label' ? TILE.bar : TILE.card;
148
+ return type === 'conversion' && info?.text ? CONV.page : TILE.card;
149
+ }
43
150
  function drawCard(c, b, n, o) {
44
- const k = b.k, t = n.link, info = o.info, gone = info?.exists === false, glyph = glyphOf(n, info);
151
+ const k = b.k, t = n.link, info = o.info, gone = info?.exists === false, type = tileTypeOf(n, info);
152
+ const pic = pictureOf(b, n, info), picture = !!pic && (type === 'image' || type === 'sketch');
153
+ const withBar = !picture && b.lod !== 'compact';
154
+ // the bar's scale within the card's: one size on the screen (BAR_K)
155
+ const u = BAR_K / k, r = cornerOf(k);
45
156
  c.save();
46
157
  c.translate(b.x, b.y);
47
158
  c.scale(k, k);
48
- rr(c, 0, 0, L, L, R);
159
+ rr(c, 0, 0, L, L, r);
49
160
  // selected: a yellow ring round the card's edge, the same 3 pt at any size — stroked twice as wide on the card's own
50
161
  // outline before it's filled, so the card covers the inside half (and no new shape is made as it zooms)
51
162
  if (o.selected) {
@@ -53,100 +164,371 @@ function drawCard(c, b, n, o) {
53
164
  c.strokeStyle = TH.yellow;
54
165
  c.stroke();
55
166
  }
56
- c.fillStyle = gone ? TH.chip : TH.badge;
167
+ c.fillStyle = cardFill(type, pic, gone ? undefined : info);
57
168
  c.fill();
58
- if (!o.selected) {
59
- c.lineWidth = screenW(1, k);
60
- c.strokeStyle = TH.badgeline;
61
- c.stroke();
62
- }
63
- // (its picture asked for only near the screen: a drawing can link more than are kept)
64
- const near = b.x < cvs.W * 2 && b.y < cvs.H * 2 && b.x + b.s > -cvs.W && b.y + b.s > -cvs.H;
65
- const pic = near && !gone && t.kind === 'file' && info?.thumbUrl ? linkThumb(info.thumbUrl) : null, shown = pic && pic.complete && pic.naturalWidth > 0;
66
- // a photo is its picture, edge to edge — it says what it is (until it's loaded: its icon and name, as any file)
67
- const photo = shown && glyph === 'photo';
68
- if (photo)
69
- cover(c, pic, 0, 0, L, L, R);
70
- else if (b.lod === 'compact') {
71
- // the picture or the icon alone
72
- if (shown)
73
- cover(c, pic, 6, 6, L - 12, L - 12, R - 4);
169
+ // everything else within the card's edge: one clip, to the outline just filled (kept as it is, on a device)
170
+ c.clip();
171
+ if (picture)
172
+ cover(c, pic, 0, 0, L, L);
173
+ else if (!withBar) {
174
+ // small: the picture or the badge alone
175
+ if (pic)
176
+ cover(c, pic, 0, 0, L, L);
74
177
  else
75
- drawGlyph(c, glyph, L / 2, L / 2, 46, TH.ink2);
178
+ drawBadge(c, type, L / 4, L / 4, L / 2, gone ? TILE.line : undefined);
179
+ }
180
+ else {
181
+ drawPreview(c, type, pic, u, gone, gone ? undefined : info, t.text);
182
+ drawBar(c, u, k, type, gone, info?.name ?? t.name ?? (t.kind === 'file' ? 'File' : 'Measurement'));
183
+ }
184
+ // a grey hairline inside its edge, 1 px on the screen (the clip keeps the inside half of a line twice that); selected,
185
+ // the bar keeps its own inside the ring
186
+ if (!o.selected || withBar) {
187
+ if (o.selected) {
188
+ c.save();
189
+ clipToBar(c, u, true);
190
+ }
191
+ rr(c, 0, 0, L, L, r);
192
+ c.lineWidth = screenW(2, k);
193
+ c.strokeStyle = TILE.line;
194
+ c.stroke();
195
+ if (o.selected)
196
+ c.restore();
76
197
  }
77
- else if (t.kind === 'measurement' && !gone) {
78
- // its value, and its name
79
- drawGlyph(c, 'tape', 18, 17, 18, TH.ink2);
80
- const value = info?.text ?? t.text ?? '—';
81
- c.font = '700 18px ' + FONT;
82
- c.fillStyle = TH.blue;
198
+ c.restore();
199
+ }
200
+ /**
201
+ * Where a picture goes that isn't simply the whole card (Figma's frame, at its 120): a label as tall as the card, its
202
+ * foot under the bar, on the bar's grey, as the label printer shows it — kept whole within 8 of the card's sides; a
203
+ * calculator's diagram nine tenths of the card across, on light grey, its top cut off where the frame cuts it and its
204
+ * foot just above the bar. Each is a picture placed whole in that box, so the frame's own (a portrait label, a
205
+ * near-square diagram) land exactly there.
206
+ */
207
+ const LABEL_BOX = [8 * F, 3.45 * F, L - 16 * F, 112 * F];
208
+ const CALC_BOX = [6.244 * F, -8.956 * F, 107.984 * F, 106.86 * F];
209
+ /**
210
+ * A label's box with the bar at `u`: the frame's, and on a card past the 144 tile, where the bar is less of it than the
211
+ * frame's, as much further down as keeps its foot as far under the bar as the frame's (23.45 of its 28).
212
+ */
213
+ const labelBox = (u) => u >= 1 ? LABEL_BOX : [LABEL_BOX[0], LABEL_BOX[1], LABEL_BOX[2], L - 4.55 * F * u - LABEL_BOX[1]];
214
+ /**
215
+ * What the object looks like, above its bar (drawn at scale `u` within the card's): its picture over the whole card (a
216
+ * label's and a calculator's placed whole, as LABEL_BOX and CALC_BOX), a note's text, a conversion's screen, a
217
+ * measurement's value (`was`: as it was when linked, till the host says) — or, with none of those, its badge, larger,
218
+ * in the middle: one size on the screen, as the bar's, but never more than three quarters of what's above the bar (so
219
+ * smaller on a card under about 98 across). Removed: says so.
220
+ *
221
+ * A label, a calculator's diagram and a conversion's screen are laid out as the frame has them over its bar. On a card
222
+ * under the 144 tile (BAR_K) the bar is more of the card than the frame's, so that layout is drawn smaller, to fit
223
+ * what's above this bar as the frame's fits what's above its own: the diagram's foot and the screen's second unit stay
224
+ * in sight, and a label's foot as far under the bar.
225
+ */
226
+ function drawPreview(c, type, pic, u, gone, info, was) {
227
+ const foot = L - FOOT_H * u, mid = foot / 2;
228
+ if (gone) {
229
+ c.font = '500 ' + 11 * F + 'px ' + TILE_FONT;
230
+ c.fillStyle = TILE.quiet;
83
231
  c.textAlign = 'center';
84
232
  c.textBaseline = 'middle';
85
- c.fillText(fit(c, value, L - 12), L / 2, 46);
86
- drawName(c, k, info?.name ?? t.name ?? 'Measurement', 75, L - 14, TH.ink2, false);
233
+ c.fillText('Removed', L / 2, mid);
234
+ return;
87
235
  }
88
- else if (gone) {
89
- // the removed mark, and what it was
90
- drawGlyph(c, glyph, L / 2, 30, 28, TH.ink2);
91
- c.font = '700 11px ' + FONT;
92
- c.fillStyle = TH.ink2;
236
+ if (pic || (type === 'conversion' && info?.text)) {
237
+ // above the bar only: in Draft a tile is drawn see-through, and what's under the bar would show through it
238
+ c.save();
239
+ clipToBar(c, u, false);
240
+ if (pic && type !== 'label' && type !== 'concalc')
241
+ cover(c, pic, 0, 0, L, L);
242
+ else {
243
+ // (the frame's layout above its bar, in what's above this one: from its top middle)
244
+ const s = Math.min(1, foot / (L - FOOT_H));
245
+ if (s < 1) {
246
+ c.translate(L / 2, 0);
247
+ c.scale(s, s);
248
+ c.translate(-L / 2, 0);
249
+ }
250
+ if (!pic)
251
+ drawConversion(c, info.text);
252
+ else
253
+ contain(c, pic, ...(type === 'label' ? labelBox(u) : CALC_BOX));
254
+ }
255
+ c.restore();
256
+ return;
257
+ }
258
+ if (type === 'note' && info?.text) {
259
+ drawNoteText(c, info.text, foot);
260
+ return;
261
+ }
262
+ if (type === 'measurement') {
263
+ c.font = '700 ' + 22 * F + 'px ' + TILE_FONT;
264
+ c.fillStyle = TILE.name;
93
265
  c.textAlign = 'center';
94
266
  c.textBaseline = 'middle';
95
- c.fillText('Removed', L / 2, 58);
96
- drawName(c, k, t.name ?? (t.kind === 'file' ? 'File' : 'Measurement'), 79, L - 12, TH.ink2, false);
267
+ c.fillText(fit(c, info?.text ?? was ?? '—', L - 16 * F), L / 2, mid);
268
+ return;
97
269
  }
98
- else {
99
- // its picture or icon above, its name below
100
- if (shown)
101
- cover(c, pic, 6, 6, L - 12, 62, R - 4);
102
- else {
103
- const a = c.globalAlpha;
104
- rr(c, 6, 6, L - 12, 62, R - 4);
105
- c.fillStyle = TH.chip;
106
- c.globalAlpha = a * 0.5;
270
+ // (the frame's 40, one size on the screen as the bar's badge is, and as much bigger than the frame's: no bigger than
271
+ // three quarters of what's above the bar — drawn at 40 and scaled, so its shapes are the same at every step of a pinch)
272
+ const s = 40 * F, q = Math.min(u, (0.75 * foot) / s);
273
+ c.save();
274
+ c.translate(L / 2, mid);
275
+ c.scale(q, q);
276
+ drawBadge(c, type, -s / 2, -s / 2, s);
277
+ c.restore();
278
+ }
279
+ /**
280
+ * The bar along the card's foot (Figma: 28 tall, General/Grey - 2, a Grey - 3 hairline along its top), drawn at its own
281
+ * scale `u` within the card's (one size on the screen: BAR_K), anchored at the card's foot: its badge, then the name —
282
+ * white, Medium, on one line, cut short with an ellipsis. Once the object's gone, a grey badge.
283
+ */
284
+ function drawBar(c, u, k, type, gone, name) {
285
+ c.save();
286
+ c.translate(0, L);
287
+ c.scale(u, u);
288
+ c.translate(0, -L);
289
+ const W = L / u, top = L - FOOT_H;
290
+ c.fillStyle = TILE.bar;
291
+ c.fillRect(0, top, W, FOOT_H);
292
+ c.fillStyle = TILE.line;
293
+ c.fillRect(0, top, W, screenW(1, k * u));
294
+ drawBadge(c, type, BADGE_IN, top + BADGE_IN, BADGE, gone ? TILE.line : undefined);
295
+ c.font = '500 ' + NAME_PX + 'px ' + TILE_FONT;
296
+ c.fillStyle = TILE.name;
297
+ c.textAlign = 'left';
298
+ c.textBaseline = 'alphabetic';
299
+ c.fillText(fitName(c, name, W - NAME_X - NAME_END), NAME_X, top + NAME_BASE);
300
+ c.restore();
301
+ }
302
+ /**
303
+ * A note's text over the card, as its editor shows it (Figma's preview: white on Grey - 1, about 9 to the 120 tile): a
304
+ * line every 13.5, and 4.25 more where a new block starts (the editor's half-em paragraph margin; the host marks one
305
+ * with a blank line). Each of the host's lines wraps to the card's width at its spaces. A line the host starts with
306
+ * "• " is a list item, a dot before it, and one it starts with a number ("1. ") a numbered item, the number before it
307
+ * (as the editor numbers them, and the job's own tile): its text further in, and so are its lines after the first. As
308
+ * many lines as fit above the bar.
309
+ */
310
+ const NOTE_PX = 9 * F, NOTE_LINE = 13.5 * F, NOTE_GAP = 4.25 * F, NOTE_X = 8.75 * F, NOTE_FIRST = 17.25 * F;
311
+ const NOTE_DOT_X = 14 * F, NOTE_DOT_R = 1.7 * F, NOTE_ITEM_X = 22.25 * F;
312
+ /** where a number ends: a third of the type's size before its item's text */
313
+ const NOTE_NUM_END = NOTE_ITEM_X - 3 * F;
314
+ function drawNoteText(c, text, foot) {
315
+ c.font = '400 ' + NOTE_PX + 'px ' + TILE_FONT;
316
+ c.fillStyle = TILE.name;
317
+ c.textBaseline = 'alphabetic';
318
+ let y = NOTE_FIRST - NOTE_LINE;
319
+ for (const { text: line, x, mark, gap } of noteRows(c, text)) {
320
+ y += NOTE_LINE + (gap ? NOTE_GAP : 0);
321
+ if (y > foot - 4 * F)
322
+ break;
323
+ if (mark === '•') {
324
+ // (its dot on the line's x-height middle, kept as the badges are)
325
+ dot(c, NOTE_DOT_X, y - NOTE_PX * 0.33, NOTE_DOT_R);
107
326
  c.fill();
108
- c.globalAlpha = a;
109
- drawGlyph(c, glyph, L / 2, 37, 34, TH.ink2);
110
327
  }
111
- drawName(c, k, info?.name ?? t.name ?? 'File', 81, L - 12, TH.ink, true);
328
+ else if (mark) {
329
+ c.textAlign = 'right';
330
+ c.fillText(mark, NOTE_NUM_END, y);
331
+ }
332
+ c.textAlign = 'left';
333
+ c.fillText(line, x, y);
112
334
  }
113
- c.restore();
114
335
  }
115
- /** Far out, a tile is a pin: a small fixed-size card with its icon. */
336
+ /** A dot's path (kept, one a size). */
337
+ function dot(c, x, y, r) {
338
+ if (c.shape)
339
+ c.shape('dot ' + r, x - r, y - r, (cc, X, Y) => { cc.beginPath(); cc.arc(X + r, Y + r, r, 0, Math.PI * 2); });
340
+ else {
341
+ c.beginPath();
342
+ c.arc(x, y, r, 0, Math.PI * 2);
343
+ }
344
+ }
345
+ /**
346
+ * A note's text as the host's lines: each with its mark ("•" for a list item, its number for a numbered one) and
347
+ * whether a new block starts there (a blank line before it, left out itself) — at most NOTE_LINES, each no longer than
348
+ * NOTE_CHARS (more than a card shows of one: it's only wrapped as far as that). Split once for each text.
349
+ */
350
+ const NOTE_LINES = 12, NOTE_CHARS = 400;
351
+ const NUMBERED = /^(\d{1,3}\.)\s+/;
352
+ const lineCache = new Map();
353
+ function noteLines(text) {
354
+ let lines = lineCache.get(text);
355
+ if (!lines) {
356
+ lines = [];
357
+ let gap = false;
358
+ for (const raw of text.split(/\r?\n/)) {
359
+ const l = raw.trim();
360
+ if (!l) {
361
+ gap = lines.length > 0;
362
+ continue;
363
+ }
364
+ if (lines.length >= NOTE_LINES)
365
+ break;
366
+ const num = NUMBERED.exec(l), mark = l.startsWith('• ') ? '•' : num?.[1];
367
+ const s = mark === '•' ? l.slice(2).trim() : num ? l.slice(num[0].length) : l;
368
+ lines.push({ text: s.length > NOTE_CHARS ? Array.from(s).slice(0, NOTE_CHARS).join('') : s, mark, gap });
369
+ gap = false;
370
+ }
371
+ if (lineCache.size > 500)
372
+ lineCache.clear();
373
+ lineCache.set(text, lines);
374
+ }
375
+ return lines;
376
+ }
377
+ const rowCache = new Map();
378
+ function noteRows(c, text) {
379
+ const key = c.font + '|' + text;
380
+ let rows = rowCache.get(key);
381
+ if (rows)
382
+ return rows;
383
+ rows = [];
384
+ for (const { text: line, mark, gap } of noteLines(text)) {
385
+ const x = mark ? NOTE_ITEM_X : NOTE_X;
386
+ wrap(c, line, L - NOTE_X - x, NOTE_LINES - rows.length).forEach((t, i) => rows.push({ text: t, x, mark: i ? undefined : mark, gap: !i && gap }));
387
+ if (rows.length >= NOTE_LINES)
388
+ break;
389
+ }
390
+ if (rowCache.size > 500)
391
+ rowCache.clear();
392
+ rowCache.set(key, rows);
393
+ return rows;
394
+ }
395
+ /** `text` as at most `room` lines no wider than `maxW` at the current font, broken at its spaces (a word wider than a line, cut short). */
396
+ function wrap(c, text, maxW, room) {
397
+ const out = [];
398
+ let line = '';
399
+ for (const word of text.split(/\s+/)) {
400
+ if (!word || out.length >= room)
401
+ continue;
402
+ const next = line ? line + ' ' + word : word;
403
+ if (c.measureText(next).width <= maxW) {
404
+ line = next;
405
+ continue;
406
+ }
407
+ if (line)
408
+ out.push(line);
409
+ line = '';
410
+ if (c.measureText(word).width <= maxW)
411
+ line = word;
412
+ else if (out.length < room)
413
+ out.push(fit(c, word, maxW));
414
+ }
415
+ if (line && out.length < room)
416
+ out.push(line);
417
+ return out;
418
+ }
419
+ /**
420
+ * A conversion as its screen shows it — Figma's preview is that screen, its top cut to the card (657:8990): the unit
421
+ * it's from on a grey header, what was typed under it (the field's INPUT, with nothing), the swap button, and the unit
422
+ * it's to on a blue header, the result's grey below. `text` is the host's: the two units' names, a line each, then the
423
+ * value typed, if any. Measured at the 120 tile from the frame's screenshot; the colours are BOLDR's palette, as the
424
+ * screen's are (General/Dark Grey, Grey - 1, Grey - 2, Grey - 4; Secondary/Accent Blue - 2 and - 3).
425
+ */
426
+ const CONV = { page: '#181A1C', result: '#2B2C2E', head: '#424345', to: '#4B6074', chipLine: '#2E4C6A', hint: '#9FA0A2' };
427
+ /** its column (the screen's sides, 4.27 in), the headers' tops and height, the field's foot, the corners (8 pt) */
428
+ const CV_X = 4.27 * F, CV_W = 111.46 * F, CV_FROM = 4.48 * F, CV_HEAD = 13.13 * F, CV_FIELD = 66.16 * F;
429
+ const CV_TO = 69.21 * F, CV_RESULT = 82.04 * F, CV_R = 2.44 * F;
430
+ /**
431
+ * the units' names: Semibold 16 pt, 21 pt in from the header's side; the value: Regular 32 pt, its ink 14 pt in from the
432
+ * field's (the screenshot's, both: the screen's code says 24); the swap button: 52 pt, its line 2 pt
433
+ */
434
+ const CV_NAME_PX = 4.885 * F, CV_NAME_X = 10.74 * F, CV_VALUE_PX = 9.77 * F, CV_VALUE_X = 7.55 * F, CV_VALUE_Y = 45.1 * F;
435
+ const CV_CHIP = [52.11 * F, 59.75 * F, 15.88 * F], CV_CHIP_LINE = 0.61 * F;
436
+ /** the chevrons (16 pt, 21 pt in from the header's right, as the screenshot's) and the swap arrows (20 pt), Lucide's, drawn with their 2-in-24 line */
437
+ const CV_CHEV = 4.885 * F, CV_CHEV_X = 104.45 * F, CV_ARROWS = 6.107 * F;
438
+ function drawConversion(c, text) {
439
+ const [from = '', to = '', value = ''] = text.split('\n').map(s => s.trim());
440
+ const headMid = (top) => top + CV_HEAD / 2;
441
+ // each header, its top rounded, its foot under what's below it: the result's grey, the field
442
+ rr(c, CV_X, CV_TO, CV_W, CV_RESULT - CV_TO + CV_R, CV_R);
443
+ c.fillStyle = CONV.to;
444
+ c.fill();
445
+ c.fillStyle = CONV.result;
446
+ c.fillRect(CV_X, CV_RESULT, CV_W, L - CV_RESULT);
447
+ rr(c, CV_X, CV_FROM, CV_W, CV_HEAD + CV_R, CV_R);
448
+ c.fillStyle = CONV.head;
449
+ c.fill();
450
+ // the field: the page's dark, a Grey - 2 line round it (1 pt)
451
+ const fieldTop = CV_FROM + CV_HEAD, line = 0.305 * F;
452
+ c.fillStyle = CONV.page;
453
+ c.fillRect(CV_X, fieldTop, CV_W, CV_FIELD - fieldTop);
454
+ c.lineWidth = line;
455
+ c.strokeStyle = CONV.head;
456
+ c.strokeRect(CV_X + line / 2, fieldTop, CV_W - line, CV_FIELD - fieldTop - line / 2);
457
+ // the units, white, and what was typed (or the field's hint)
458
+ c.textAlign = 'left';
459
+ c.textBaseline = 'middle';
460
+ c.fillStyle = TILE.name;
461
+ c.font = '600 ' + CV_NAME_PX + 'px ' + TILE_FONT;
462
+ c.fillText(fit(c, from, CV_CHEV_X - CV_NAME_X - 2 * F), CV_NAME_X, headMid(CV_FROM));
463
+ c.fillText(fit(c, to, CV_CHEV_X - CV_NAME_X - 2 * F), CV_NAME_X, headMid(CV_TO));
464
+ c.font = '400 ' + CV_VALUE_PX + 'px ' + TILE_FONT;
465
+ c.textBaseline = 'alphabetic';
466
+ c.fillStyle = value ? TILE.name : CONV.hint;
467
+ c.fillText(fit(c, value || 'INPUT', CV_X + CV_W - CV_VALUE_X - 2 * F), CV_VALUE_X, CV_VALUE_Y);
468
+ // each header's chevron, and the swap button over where the two meet
469
+ c.lineCap = 'round';
470
+ c.lineJoin = 'round';
471
+ c.strokeStyle = TILE.name;
472
+ c.lineWidth = (2 * CV_CHEV) / 24;
473
+ for (const top of [CV_FROM, CV_TO]) {
474
+ icon(c, 'conv chevron', CHEVRON, CV_CHEV_X, headMid(top) - CV_CHEV / 2, CV_CHEV);
475
+ c.stroke();
476
+ }
477
+ const [cx, cy, cs] = CV_CHIP;
478
+ rr(c, cx, cy, cs, cs, CV_R);
479
+ c.fillStyle = CONV.chipLine;
480
+ c.fill();
481
+ rr(c, cx + CV_CHIP_LINE, cy + CV_CHIP_LINE, cs - 2 * CV_CHIP_LINE, cs - 2 * CV_CHIP_LINE, CV_R - CV_CHIP_LINE);
482
+ c.fillStyle = CONV.to;
483
+ c.fill();
484
+ c.lineWidth = (2 * CV_ARROWS) / 24;
485
+ icon(c, 'conv arrows', ARROWS, cx + (cs - CV_ARROWS) / 2, cy + (cs - CV_ARROWS) / 2, CV_ARROWS);
486
+ c.stroke();
487
+ }
488
+ /** Lucide's chevron-down and arrow-up-down, as polylines on their 24 grid. */
489
+ const CHEVRON = [[6, 9, 12, 15, 18, 9]];
490
+ const ARROWS = [[21, 16, 17, 20, 13, 16], [17, 20, 17, 4], [3, 8, 7, 4, 11, 8], [7, 4, 7, 20]];
491
+ /** An icon's path (polylines on a 24 grid), `size` across with its top left at x, y: kept, one a size. */
492
+ function icon(c, key, lines, x, y, size) {
493
+ const s = size / 24;
494
+ const draw = (cc, X, Y) => {
495
+ cc.beginPath();
496
+ for (const p of lines) {
497
+ cc.moveTo(X + p[0] * s, Y + p[1] * s);
498
+ for (let i = 2; i < p.length; i += 2)
499
+ cc.lineTo(X + p[i] * s, Y + p[i + 1] * s);
500
+ }
501
+ };
502
+ if (c.shape)
503
+ c.shape(key + ' ' + size, x, y, draw);
504
+ else
505
+ draw(c, x, y);
506
+ }
507
+ /** Far out, a tile is a pin: its badge, a fixed size, ringed when it's selected (a picture's: the camera's or sketch's). */
116
508
  function drawPin(c, b, n, o) {
117
- const s = LINK_PIN_PX;
118
- rr(c, b.cx - s / 2, b.cy - s / 2, s, s, 6);
119
- // (selected: ringed round its edge, as a card is)
509
+ const s = LINK_PIN_PX, x = b.cx - s / 2, y = b.cy - s / 2, gone = o.info?.exists === false;
120
510
  if (o.selected) {
511
+ rr(c, x, y, s, s, (6 * s) / 20);
121
512
  c.lineWidth = 2 * SELECT_W;
122
513
  c.strokeStyle = TH.yellow;
123
514
  c.stroke();
124
515
  }
125
- c.fillStyle = o.info?.exists === false ? TH.chip : TH.badge;
126
- c.fill();
127
- if (!o.selected) {
128
- c.lineWidth = 1;
129
- c.strokeStyle = TH.badgeline;
130
- c.stroke();
131
- }
132
- drawGlyph(c, glyphOf(n, o.info), b.cx, b.cy, 14, TH.ink2);
516
+ drawBadge(c, tileTypeOf(n, o.info), x, y, s, gone ? TILE.line : undefined);
133
517
  }
134
- /** A picture filling the rounded rectangle x, y, w, h (its middle shown, its sides cut off). */
135
- function cover(c, im, x, y, w, h, r) {
518
+ /** A picture filling x, y, w, h (its middle shown, its sides cut off: by the card's edge, which the card has clipped to). */
519
+ function cover(c, im, x, y, w, h) {
136
520
  const iw = im.naturalWidth, ih = im.naturalHeight || 1, k = Math.max(w / iw, h / ih), dw = iw * k, dh = ih * k;
137
- c.save();
138
- rr(c, x, y, w, h, r);
139
- c.clip();
140
521
  c.drawImage(im, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
141
- c.restore();
522
+ }
523
+ /** A picture whole within x, y, w, h, in its middle. */
524
+ function contain(c, im, x, y, w, h) {
525
+ const iw = im.naturalWidth, ih = im.naturalHeight || 1, k = Math.min(w / iw, h / ih), dw = iw * k, dh = ih * k;
526
+ c.drawImage(im, x + (w - dw) / 2, y + (h - dh) / 2, dw, dh);
142
527
  }
143
528
  /*
144
- A name grows with its card until it's NAME_MAX_PX on the screen; past that it stays that size, and the card, wider on
145
- the screen as it's zoomed in, shows more of it — in the end all of it: on two even lines once two show the whole of it
146
- (the strip under a picture always has the room by then), and on one once one does. Each name is measured once, at
147
- each of its two sizes: laying it out again at another width, every step of a pinch, measures nothing.
529
+ A name is one size on the screen, as the bar it's on is; the card, wider on the screen as it's zoomed in, shows more
530
+ of it. Each name is measured once: fitting it again at another width, every step of a pinch, measures nothing.
148
531
  */
149
- const NAME_PX = 11, NAME_MAX_PX = 14, NAME_LINE = 1.25;
150
532
  /** A name's prefixes' widths at a font (w[i]: its first i characters), and an ellipsis's. */
151
533
  const prefixes = new Map();
152
534
  function widthsOf(c, text) {
@@ -163,6 +545,8 @@ function widthsOf(c, text) {
163
545
  prefixes.set(key, p);
164
546
  return p;
165
547
  }
548
+ /** Where to cut `text` at `i` without parting a character in two (an emoji's surrogate pair): there, or one before. */
549
+ const cutAt = (text, i) => (i > 0 && (text.charCodeAt(i - 1) & 0xfc00) === 0xd800 ? i - 1 : i);
166
550
  /** The most of `text` that fits `maxW` at the current font: all of it, or its start and an ellipsis. */
167
551
  export function fitName(c, text, maxW) {
168
552
  const { w, ell } = widthsOf(c, text), n = text.length;
@@ -176,86 +560,9 @@ export function fitName(c, text, maxW) {
176
560
  else
177
561
  hi = mid - 1;
178
562
  }
179
- return text.slice(0, lo).trimEnd() + '…';
563
+ return text.slice(0, cutAt(text, lo)).trimEnd() + '…';
180
564
  }
181
- /**
182
- * Where a name breaks onto two lines: at the space nearest half its width (with none, after the _ - . or / nearest it;
183
- * with none of those, halfway across), so the two are even — and the same however wide its tile, so zooming never
184
- * moves the break. `width`: the wider of the two lines.
185
- */
186
- const splits = new Map();
187
- function splitOf(c, text) {
188
- const key = c.font + '|' + text;
189
- let sp = splits.get(key);
190
- if (sp)
191
- return sp;
192
- const { w } = widthsOf(c, text), n = text.length, total = w[n];
193
- sp = { cut: 0, next: 0, width: Infinity };
194
- for (let i = 1; i < n - 1; i++) {
195
- if (text[i] !== ' ')
196
- continue;
197
- const width = Math.max(w[i], total - w[i + 1]);
198
- if (width < sp.width)
199
- sp = { cut: i, next: i + 1, width };
200
- }
201
- // (no space, as a file's name often has none: after a _ - . or /, which stays on the first line)
202
- if (!sp.cut)
203
- for (let i = 2; i < n - 1; i++) {
204
- if (!'_-./'.includes(text[i - 1]))
205
- continue;
206
- const width = Math.max(w[i], total - w[i]);
207
- if (width < sp.width)
208
- sp = { cut: i, next: i, width };
209
- }
210
- if (!sp.cut) {
211
- let i = 1;
212
- while (i < n - 1 && w[i] < total / 2)
213
- i++;
214
- sp = { cut: i, next: i, width: Math.max(w[i], total - w[i]) };
215
- }
216
- if (splits.size > 1000)
217
- splits.clear();
218
- splits.set(key, sp);
219
- return sp;
220
- }
221
- /** All of `text` on two lines `maxW` wide, evenly (see splitOf); null if one line takes it, or two can't. */
222
- export function twoLines(c, text, maxW) {
223
- const n = text.length;
224
- if (n < 2 || widthsOf(c, text).w[n] <= maxW)
225
- return null;
226
- const sp = splitOf(c, text);
227
- return sp.width <= maxW ? [text.slice(0, sp.cut).trimEnd(), text.slice(sp.next).trimStart()] : null;
228
- }
229
- /**
230
- * A name on card `k` (its scale from the layout), centred on `y` and at most `maxW` wide in the layout: the layout's
231
- * size until that's NAME_MAX_PX on the screen; past it, that size on the screen (drawn at the screen's own scale, so one
232
- * font does), showing more of the name as the card grows — and with `wrap`, on two lines once two show all of it.
233
- */
234
- function drawName(c, k, text, y, maxW, color, wrap) {
235
- c.fillStyle = color;
236
- c.textAlign = 'center';
237
- c.textBaseline = 'middle';
238
- if (NAME_PX * k <= NAME_MAX_PX) {
239
- c.font = '600 ' + NAME_PX + 'px ' + FONT;
240
- c.fillText(fitName(c, text, maxW), L / 2, y);
241
- return;
242
- }
243
- c.save();
244
- c.scale(1 / k, 1 / k);
245
- c.font = '600 ' + NAME_MAX_PX + 'px ' + FONT;
246
- const W = maxW * k, x = (L / 2) * k, one = fitName(c, text, W);
247
- const two = wrap && one !== text ? twoLines(c, text, W) : null;
248
- if (two) {
249
- // (centred in the strip under the picture: from 68 to the card's foot)
250
- const h = NAME_MAX_PX * NAME_LINE, mid = 82 * k;
251
- c.fillText(two[0], x, mid - h / 2);
252
- c.fillText(two[1], x, mid + h / 2);
253
- }
254
- else
255
- c.fillText(one, x, y * k);
256
- c.restore();
257
- }
258
- /** Names cut to fit, by font, width and text: measured once, not every frame. */
565
+ /** Text cut to fit, by font, width and text: measured once, not every frame. */
259
566
  const fitted = new Map();
260
567
  function fit(c, text, maxW) {
261
568
  const key = c.font + '|' + maxW + '|' + text;
@@ -272,98 +579,21 @@ function fit(c, text, maxW) {
272
579
  else
273
580
  hi = mid - 1;
274
581
  }
275
- out = text.slice(0, lo).trimEnd() + '…';
582
+ out = text.slice(0, cutAt(text, lo)).trimEnd() + '…';
276
583
  }
277
584
  if (fitted.size > 1000)
278
585
  fitted.clear();
279
586
  fitted.set(key, out);
280
587
  return out;
281
588
  }
282
- const page = (c, fold) => {
283
- c.moveTo(6, 3);
284
- c.lineTo(fold ? 14 : 18, 3);
285
- if (fold)
286
- c.lineTo(18, 7);
287
- c.lineTo(18, 21);
288
- c.lineTo(6, 21);
289
- c.closePath();
290
- if (fold) {
291
- c.moveTo(14, 3);
292
- c.lineTo(14, 7);
293
- c.lineTo(18, 7);
294
- }
295
- };
296
- const rules = (c, ys) => { for (const y of ys) {
297
- c.moveTo(9, y);
298
- c.lineTo(15, y);
299
- } };
300
- const dots = (c, at, r) => { for (const [x, y] of at) {
301
- c.moveTo(x + r, y);
302
- c.arc(x, y, r, 0, Math.PI * 2);
303
- } };
304
- const GLYPHS = {
305
- photo: {
306
- stroke: c => { c.roundRect(3, 5, 18, 14, 2); c.moveTo(5, 17); c.lineTo(10, 11); c.lineTo(13, 14); c.lineTo(15, 12); c.lineTo(19, 17); },
307
- fill: c => dots(c, [[16, 9]], 1.6),
308
- },
309
- pdf: { stroke: c => page(c, true), text: 'PDF' },
310
- note: { stroke: c => { page(c, false); rules(c, [8, 12, 16]); } },
311
- calculator: {
312
- stroke: c => { c.roundRect(5, 3, 14, 18, 2); c.moveTo(8, 7); c.lineTo(16, 7); },
313
- fill: c => dots(c, [11.5, 15, 18.5].flatMap(y => [8.5, 12, 15.5].map(x => [x, y])), 0.9),
314
- },
315
- label: {
316
- stroke: c => { c.moveTo(4, 8); c.lineTo(14, 8); c.lineTo(20, 12); c.lineTo(14, 16); c.lineTo(4, 16); c.closePath(); },
317
- fill: c => dots(c, [[14, 12]], 1.2),
318
- },
319
- drawing: { stroke: c => { page(c, false); c.moveTo(9, 17); c.lineTo(15.5, 8.5); } },
320
- tape: { stroke: c => { c.roundRect(2.5, 8, 19, 8, 1.5); for (const x of [6, 9, 12, 15, 18]) {
321
- c.moveTo(x, 8);
322
- c.lineTo(x, x === 12 ? 12.5 : 11);
323
- } } },
324
- removed: { dashed: c => page(c, false), stroke: c => { c.moveTo(4, 20); c.lineTo(20, 4); } },
325
- document: { stroke: c => { page(c, true); rules(c, [12, 16]); } },
326
- };
327
- /** A glyph part's path: kept as a shape where the engine can (a tile's icon is drawn every frame, the same each time). */
328
- function part(c, key, draw) {
329
- if (c.shape)
330
- c.shape(key, 0, 0, cc => draw(cc));
331
- else {
332
- c.beginPath();
333
- draw(c);
334
- }
335
- }
336
- /** An icon for kind `g`, `size` across, centred on cx, cy (paths: the canvas can't draw the chrome's SVG icons). */
337
- function drawGlyph(c, g, cx, cy, size, color) {
338
- const u = size / 24, d = GLYPHS[g];
339
- c.save();
340
- c.translate(cx - size / 2, cy - size / 2);
341
- c.scale(u, u);
342
- c.strokeStyle = color;
343
- c.fillStyle = color;
344
- c.lineWidth = 1.8;
345
- c.lineCap = 'round';
346
- c.lineJoin = 'round';
347
- if (d.dashed) {
348
- c.setLineDash([2.2, 2]);
349
- part(c, 'glyph- ' + g, d.dashed);
350
- c.stroke();
351
- c.setLineDash([]);
352
- }
353
- if (d.stroke) {
354
- part(c, 'glyph| ' + g, d.stroke);
355
- c.stroke();
356
- }
357
- if (d.fill) {
358
- part(c, 'glyph. ' + g, d.fill);
359
- c.fill();
360
- }
361
- if (d.text) {
362
- c.font = '700 6px ' + FONT;
363
- c.textAlign = 'center';
364
- c.textBaseline = 'middle';
365
- c.fillText(d.text, 12, 15);
366
- }
367
- c.restore();
589
+ /**
590
+ * In a browser, a font can arrive after a tile's text was measured in another (Inter, still loading, the first time a
591
+ * page draws): measured again once it has. (A device draws in the fonts it's given.)
592
+ */
593
+ let watchingFonts = false;
594
+ function watchFonts() {
595
+ watchingFonts = true;
596
+ const fonts = typeof document !== 'undefined' ? document.fonts : undefined;
597
+ fonts?.addEventListener?.('loadingdone', () => { prefixes.clear(); fitted.clear(); rowCache.clear(); requestDraw(); });
368
598
  }
369
599
  //# sourceMappingURL=linkTile.js.map