@reekon-tools/boldr-cad 1.4.0 → 1.6.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 (311) hide show
  1. package/README.md +13 -3
  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 +6 -8
  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 +255 -181
  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 +4 -3
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/laser.d.ts +4 -2
  23. package/dist/editor/laser.js +4 -2
  24. package/dist/editor/laser.js.map +1 -1
  25. package/dist/editor/linkActions.d.ts +49 -12
  26. package/dist/editor/linkActions.js +164 -35
  27. package/dist/editor/linkActions.js.map +1 -1
  28. package/dist/editor/links.d.ts +13 -0
  29. package/dist/editor/links.js +7 -1
  30. package/dist/editor/links.js.map +1 -1
  31. package/dist/editor/mode.d.ts +2 -1
  32. package/dist/editor/mode.js +8 -5
  33. package/dist/editor/mode.js.map +1 -1
  34. package/dist/editor/project.js +1 -2
  35. package/dist/editor/project.js.map +1 -1
  36. package/dist/editor/scaleGate.d.ts +13 -0
  37. package/dist/editor/scaleGate.js +27 -0
  38. package/dist/editor/scaleGate.js.map +1 -0
  39. package/dist/editor/selection.js +13 -9
  40. package/dist/editor/selection.js.map +1 -1
  41. package/dist/editor/tooltips.js +10 -0
  42. package/dist/editor/tooltips.js.map +1 -1
  43. package/dist/editor/view3d.d.ts +4 -1
  44. package/dist/editor/view3d.js +6 -2
  45. package/dist/editor/view3d.js.map +1 -1
  46. package/dist/export/pdf.d.ts +5 -2
  47. package/dist/export/pdf.js +12 -8
  48. package/dist/export/pdf.js.map +1 -1
  49. package/dist/export/plan.d.ts +1 -1
  50. package/dist/export/plan.js +14 -9
  51. package/dist/export/plan.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/model/constraints.js +2 -0
  56. package/dist/model/constraints.js.map +1 -1
  57. package/dist/model/demo.d.ts +1 -0
  58. package/dist/model/demo.js +2 -1
  59. package/dist/model/demo.js.map +1 -1
  60. package/dist/model/elevation.d.ts +1 -1
  61. package/dist/model/elevation.js +3 -3
  62. package/dist/model/elevation.js.map +1 -1
  63. package/dist/model/geom.d.ts +2 -0
  64. package/dist/model/geom.js +11 -0
  65. package/dist/model/geom.js.map +1 -1
  66. package/dist/model/history.js +4 -1
  67. package/dist/model/history.js.map +1 -1
  68. package/dist/model/links.d.ts +10 -2
  69. package/dist/model/links.js +15 -3
  70. package/dist/model/links.js.map +1 -1
  71. package/dist/model/measurement.d.ts +2 -2
  72. package/dist/model/measurement.js +3 -3
  73. package/dist/model/measurement.js.map +1 -1
  74. package/dist/model/openings.d.ts +10 -0
  75. package/dist/model/openings.js +18 -0
  76. package/dist/model/openings.js.map +1 -1
  77. package/dist/model/planGeom.d.ts +37 -2
  78. package/dist/model/planGeom.js +72 -9
  79. package/dist/model/planGeom.js.map +1 -1
  80. package/dist/model/project.js +5 -2
  81. package/dist/model/project.js.map +1 -1
  82. package/dist/model/rooms.d.ts +18 -5
  83. package/dist/model/rooms.js +178 -73
  84. package/dist/model/rooms.js.map +1 -1
  85. package/dist/model/scale.d.ts +30 -0
  86. package/dist/model/scale.js +123 -0
  87. package/dist/model/scale.js.map +1 -0
  88. package/dist/model/settings.d.ts +0 -1
  89. package/dist/model/settings.js +1 -1
  90. package/dist/model/settings.js.map +1 -1
  91. package/dist/model/sketch.d.ts +26 -2
  92. package/dist/model/sketch.js +77 -22
  93. package/dist/model/sketch.js.map +1 -1
  94. package/dist/model/stroke.js +2 -2
  95. package/dist/model/trim.d.ts +54 -0
  96. package/dist/model/trim.js +219 -0
  97. package/dist/model/trim.js.map +1 -0
  98. package/dist/model/types.d.ts +62 -9
  99. package/dist/react/BottomBar.d.ts +2 -1
  100. package/dist/react/BottomBar.js +144 -46
  101. package/dist/react/BottomBar.js.map +1 -1
  102. package/dist/react/CadEditor.js +18 -9
  103. package/dist/react/CadEditor.js.map +1 -1
  104. package/dist/react/Drawer.js +6 -4
  105. package/dist/react/Drawer.js.map +1 -1
  106. package/dist/react/Duck.js.map +1 -1
  107. package/dist/react/KeypadSheet.js.map +1 -1
  108. package/dist/react/MarkupPanel.js +1 -1
  109. package/dist/react/PlanCanvas.native.js +54 -7
  110. package/dist/react/PlanCanvas.native.js.map +1 -1
  111. package/dist/react/PlanCanvas.web.js +17 -5
  112. package/dist/react/PlanCanvas.web.js.map +1 -1
  113. package/dist/react/Popovers.js +14 -3
  114. package/dist/react/Popovers.js.map +1 -1
  115. package/dist/react/Rail.js +1 -1
  116. package/dist/react/ShapeMenu.js +3 -2
  117. package/dist/react/ShapeMenu.js.map +1 -1
  118. package/dist/react/Sheet.d.ts +2 -2
  119. package/dist/react/Sheet.js.map +1 -1
  120. package/dist/react/ToolTip.js +3 -2
  121. package/dist/react/ToolTip.js.map +1 -1
  122. package/dist/react/TopBar.js +4 -3
  123. package/dist/react/TopBar.js.map +1 -1
  124. package/dist/react/barScroll.d.ts +23 -0
  125. package/dist/react/barScroll.js +47 -0
  126. package/dist/react/barScroll.js.map +1 -0
  127. package/dist/react/corners.d.ts +6 -2
  128. package/dist/react/corners.js.map +1 -1
  129. package/dist/react/editorBox.d.ts +8 -0
  130. package/dist/react/editorBox.js +10 -0
  131. package/dist/react/editorBox.js.map +1 -0
  132. package/dist/react/host.d.ts +1 -1
  133. package/dist/react/host.js +1 -1
  134. package/dist/react/host.js.map +1 -1
  135. package/dist/react/motion.d.ts +1 -1
  136. package/dist/react/motion.js +1 -1
  137. package/dist/react/motion.js.map +1 -1
  138. package/dist/react/pageLock.native.d.ts +2 -0
  139. package/dist/react/pageLock.native.js +2 -0
  140. package/dist/react/pageLock.native.js.map +1 -1
  141. package/dist/react/pageLock.web.d.ts +6 -0
  142. package/dist/react/pageLock.web.js +12 -3
  143. package/dist/react/pageLock.web.js.map +1 -1
  144. package/dist/react/ports.js +8 -3
  145. package/dist/react/ports.js.map +1 -1
  146. package/dist/react/saving.d.ts +7 -1
  147. package/dist/react/saving.js +10 -2
  148. package/dist/react/saving.js.map +1 -1
  149. package/dist/react/store.d.ts +5 -2
  150. package/dist/react/store.js +4 -1
  151. package/dist/react/store.js.map +1 -1
  152. package/dist/render/draw.d.ts +21 -4
  153. package/dist/render/draw.js +203 -151
  154. package/dist/render/draw.js.map +1 -1
  155. package/dist/render/elevation.js +3 -3
  156. package/dist/render/elevation.js.map +1 -1
  157. package/dist/render/figmaIcons.d.ts +6 -0
  158. package/dist/render/figmaIcons.js +16 -1
  159. package/dist/render/figmaIcons.js.map +1 -1
  160. package/dist/render/hit.d.ts +8 -1
  161. package/dist/render/hit.js +23 -4
  162. package/dist/render/hit.js.map +1 -1
  163. package/dist/render/icons.js +27 -20
  164. package/dist/render/icons.js.map +1 -1
  165. package/dist/render/lens.d.ts +27 -0
  166. package/dist/render/lens.js +41 -0
  167. package/dist/render/lens.js.map +1 -0
  168. package/dist/render/linkTile.d.ts +20 -2
  169. package/dist/render/linkTile.js +476 -246
  170. package/dist/render/linkTile.js.map +1 -1
  171. package/dist/render/snap.d.ts +10 -1
  172. package/dist/render/snap.js +87 -15
  173. package/dist/render/snap.js.map +1 -1
  174. package/dist/render/svgPath.d.ts +29 -0
  175. package/dist/render/svgPath.js +114 -0
  176. package/dist/render/svgPath.js.map +1 -0
  177. package/dist/render/tileIcons.d.ts +18 -0
  178. package/dist/render/tileIcons.js +67 -0
  179. package/dist/render/tileIcons.js.map +1 -0
  180. package/dist/render/view.d.ts +18 -1
  181. package/dist/render/view.js +38 -6
  182. package/dist/render/view.js.map +1 -1
  183. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  184. package/dist/skia/SkiaDrawCtx.js +27 -5
  185. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  186. package/dist/solver/equations.d.ts +6 -0
  187. package/dist/solver/equations.js +53 -22
  188. package/dist/solver/equations.js.map +1 -1
  189. package/dist/solver/gauss-newton.js +6 -1
  190. package/dist/solver/gauss-newton.js.map +1 -1
  191. package/dist/tools/annotate.d.ts +0 -2
  192. package/dist/tools/annotate.js +1 -22
  193. package/dist/tools/annotate.js.map +1 -1
  194. package/dist/tools/chamfer.js +4 -0
  195. package/dist/tools/chamfer.js.map +1 -1
  196. package/dist/tools/dim.d.ts +3 -3
  197. package/dist/tools/dim.js +26 -65
  198. package/dist/tools/dim.js.map +1 -1
  199. package/dist/tools/fillet.js +4 -0
  200. package/dist/tools/fillet.js.map +1 -1
  201. package/dist/tools/freehand.js +1 -4
  202. package/dist/tools/freehand.js.map +1 -1
  203. package/dist/tools/line.js +15 -11
  204. package/dist/tools/line.js.map +1 -1
  205. package/dist/tools/modify.d.ts +32 -4
  206. package/dist/tools/modify.js +253 -98
  207. package/dist/tools/modify.js.map +1 -1
  208. package/dist/tools/registry.d.ts +7 -0
  209. package/dist/tools/registry.js +14 -1
  210. package/dist/tools/registry.js.map +1 -1
  211. package/dist/tools/select.d.ts +30 -8
  212. package/dist/tools/select.js +165 -34
  213. package/dist/tools/select.js.map +1 -1
  214. package/dist/tools/shapes.d.ts +1 -1
  215. package/dist/tools/shapes.js +12 -5
  216. package/dist/tools/shapes.js.map +1 -1
  217. package/dist/view3d/build.js +9 -8
  218. package/dist/view3d/build.js.map +1 -1
  219. package/package.json +9 -9
  220. package/src/app/edit.ts +25 -1
  221. package/src/app/state.ts +6 -8
  222. package/src/data/sync.ts +7 -2
  223. package/src/editor/actions.ts +213 -122
  224. package/src/editor/elevation.ts +13 -16
  225. package/src/editor/gestures.ts +83 -13
  226. package/src/editor/keys.ts +3 -2
  227. package/src/editor/laser.ts +7 -4
  228. package/src/editor/linkActions.ts +169 -31
  229. package/src/editor/links.ts +17 -1
  230. package/src/editor/mode.ts +8 -5
  231. package/src/editor/project.ts +1 -2
  232. package/src/editor/scaleGate.ts +32 -0
  233. package/src/editor/selection.ts +13 -7
  234. package/src/editor/tooltips.ts +9 -0
  235. package/src/editor/view3d.ts +6 -2
  236. package/src/export/pdf.ts +7 -4
  237. package/src/export/plan.ts +14 -10
  238. package/src/index.ts +1 -0
  239. package/src/model/constraints.ts +1 -0
  240. package/src/model/demo.ts +2 -1
  241. package/src/model/elevation.ts +4 -4
  242. package/src/model/geom.ts +10 -0
  243. package/src/model/history.ts +4 -1
  244. package/src/model/links.ts +14 -3
  245. package/src/model/measurement.ts +3 -3
  246. package/src/model/openings.ts +18 -0
  247. package/src/model/planGeom.ts +70 -10
  248. package/src/model/project.ts +6 -2
  249. package/src/model/rooms.ts +151 -63
  250. package/src/model/scale.ts +107 -0
  251. package/src/model/settings.ts +1 -2
  252. package/src/model/sketch.ts +62 -11
  253. package/src/model/stroke.ts +2 -2
  254. package/src/model/trim.ts +188 -0
  255. package/src/model/types.ts +57 -9
  256. package/src/react/BottomBar.tsx +177 -52
  257. package/src/react/CadEditor.tsx +19 -9
  258. package/src/react/Drawer.tsx +17 -12
  259. package/src/react/Duck.tsx +3 -3
  260. package/src/react/KeypadSheet.tsx +2 -2
  261. package/src/react/MarkupPanel.tsx +1 -1
  262. package/src/react/PlanCanvas.native.tsx +60 -6
  263. package/src/react/PlanCanvas.web.tsx +17 -5
  264. package/src/react/Popovers.tsx +24 -9
  265. package/src/react/Rail.tsx +1 -1
  266. package/src/react/ShapeMenu.tsx +3 -2
  267. package/src/react/Sheet.tsx +3 -3
  268. package/src/react/ToolTip.tsx +3 -2
  269. package/src/react/TopBar.tsx +4 -3
  270. package/src/react/barScroll.ts +57 -0
  271. package/src/react/corners.ts +6 -3
  272. package/src/react/editorBox.ts +20 -0
  273. package/src/react/host.ts +1 -1
  274. package/src/react/motion.tsx +6 -6
  275. package/src/react/pageLock.native.ts +2 -0
  276. package/src/react/pageLock.web.ts +12 -3
  277. package/src/react/ports.ts +7 -2
  278. package/src/react/saving.ts +10 -2
  279. package/src/react/store.ts +5 -2
  280. package/src/render/draw.ts +169 -118
  281. package/src/render/elevation.ts +5 -4
  282. package/src/render/figmaIcons.ts +17 -1
  283. package/src/render/hit.ts +21 -4
  284. package/src/render/icons.ts +27 -20
  285. package/src/render/lens.ts +52 -0
  286. package/src/render/linkTile.ts +436 -190
  287. package/src/render/snap.ts +87 -17
  288. package/src/render/svgPath.ts +95 -0
  289. package/src/render/tileIcons.ts +75 -0
  290. package/src/render/view.ts +43 -7
  291. package/src/skia/SkiaDrawCtx.ts +26 -4
  292. package/src/solver/equations.ts +52 -22
  293. package/src/solver/gauss-newton.ts +4 -1
  294. package/src/styles.css +3 -2
  295. package/src/tools/annotate.ts +1 -18
  296. package/src/tools/chamfer.ts +3 -0
  297. package/src/tools/dim.ts +26 -52
  298. package/src/tools/fillet.ts +3 -0
  299. package/src/tools/freehand.ts +1 -3
  300. package/src/tools/line.ts +12 -11
  301. package/src/tools/modify.ts +210 -70
  302. package/src/tools/registry.ts +18 -1
  303. package/src/tools/select.ts +144 -36
  304. package/src/tools/shapes.ts +9 -4
  305. package/src/ui/ctx.ts +4 -3
  306. package/src/ui/drawer.ts +8 -2
  307. package/src/view3d/build.ts +11 -10
  308. package/dist/react/LaserTray.d.ts +0 -4
  309. package/dist/react/LaserTray.js +0 -26
  310. package/dist/react/LaserTray.js.map +0 -1
  311. package/src/react/LaserTray.tsx +0 -42
@@ -24,6 +24,7 @@ import { fmtLen } from '../units';
24
24
  import { addCabinet, addHeightLine, CAB, cabinetSpot, deleteCabinets, dimensionFace, editCabinet, editCabinetDepth, snapObjectMove } from './face';
25
25
  import type { RailItem } from './mode';
26
26
  import { openKeypad, refreshModes, renderElevation, toast, updateCtx, updateHint, updateRail, updateTopBar } from './ports';
27
+ import { waitForScale } from './scaleGate';
27
28
 
28
29
  /*
29
30
  The wall view: one straight wall seen straight on, edited with the plan's own tools. Opening a wall makes the
@@ -50,8 +51,12 @@ export const elevOpen = (): boolean => elev.lid != null;
50
51
  const wallBox = () => (elev.frame ? { x0: elev.frame.face[0], y0: 0, x1: elev.frame.face[1], y1: elev.frame.H } : null);
51
52
 
52
53
  /** Open the wall view of a straight wall, seen from the room side. */
53
- /** Open a wall's view: from the side a room is on — or `side`, the face to the left (+1) or right (−1) of its A → B. */
54
+ /**
55
+ * Open a wall's view: from the side a room is on — or `side`, the face to the left (+1) or right (−1) of its A → B.
56
+ * Not before the page's first length: the wall's height is real, its length only a placeholder until then.
57
+ */
54
58
  export function openElevation(lid: string, side?: 1 | -1): void {
59
+ if (waitForScale()) return;
55
60
  if (!elevFrame(planSK(), lid)) { toast('Elevations work on straight walls. Turn the line into a wall first.', { warn: true }); return; }
56
61
  cancelTool();
57
62
  elev.lid = lid; elev.flip = side != null && side !== (roomSideOf(planSK(), lid) || 1);
@@ -331,7 +336,7 @@ export const elevAct = (id: string): ElevAct => JSON.parse(id) as ElevAct;
331
336
  /** A chip's name, as the bar shows it. */
332
337
  export function elevChipName(a: ElevAct): string {
333
338
  if (a.k === 'gap') return a.title;
334
- if (a.k === 'len') return 'Wall length · set in the floor plan';
339
+ if (a.k === 'len') return 'Wall length';
335
340
  if (a.k === 'wallH') return 'Wall height';
336
341
  if (a.k === 'pos') return a.title;
337
342
  if (a.k === 'fdim') return 'Dimension';
@@ -698,7 +703,8 @@ export function wallBar(): { label?: string; items: WallBarItem[]; plus: boolean
698
703
  if (pair) {
699
704
  const name = (id: string) => (planSK().openings[id]?.type === 'door' ? 'Door' : 'Window');
700
705
  const label = name(pair.op) + ' + ' + ('op' in pair.other ? name(pair.other.op).toLowerCase() : 'corner');
701
- return { label, items: [{ ic: 'dim', label: 'Distance', act: 'wallDist' }], plus: true };
706
+ // (the distance between them: the yellow +)
707
+ return { label, items: [], plus: true };
702
708
  }
703
709
  if (ed.sel.length !== 1) return null;
704
710
  const s = ed.sel[0], sk = planSK();
@@ -706,19 +712,10 @@ export function wallBar(): { label?: string; items: WallBarItem[]; plus: boolean
706
712
  const a = elevAct(s.id);
707
713
  return { label: elevChipName(a), items: a.k === 'len' ? [{ ic: 'back', label: 'Go to plan', act: 'wallGoPlan' }] : [], plus: a.k !== 'len' };
708
714
  }
709
- // a door, window or cabinet picked as a whole: its sizes to set, by name (no + — it isn't one dimension)
710
- if (s.type === 'opening') {
711
- const o = sk.openings[s.id];
712
- if (!o) return null;
713
- return { label: openingLabel(o), items: OPENING_SIZES, plus: false };
714
- }
715
- if (s.type === 'cab') {
716
- const face = SK() as WallFace, b = cabBox(face, s.id), cb = face.cabs[s.id];
717
- return {
718
- label: 'Cabinet' + (b ? ' ' + fmtLen(b.u1 - b.u0) + ' L × ' + fmtLen(b.z1 - b.z0) + ' H × ' + fmtLen(cb.depth) + ' D' : ''),
719
- items: [{ ic: 'v', label: 'Height', act: 'cabH' }, { ic: 'h', label: 'Length', act: 'cabW' }, { ic: 'thick', label: 'Depth', act: 'cabD' }], plus: false,
720
- };
721
- }
715
+ // a door, window or cabinet picked as a whole: its sizes to set (no + — it isn't one dimension), with nothing saying
716
+ // them: the wall view shows them (the user's call, 2026-10-27: "get rid of extra dimensions text")
717
+ if (s.type === 'opening') return sk.openings[s.id] ? { items: OPENING_SIZES, plus: false } : null;
718
+ if (s.type === 'cab') return { items: [{ ic: 'v', label: 'Height', act: 'cabH' }, { ic: 'h', label: 'Length', act: 'cabW' }, { ic: 'thick', label: 'Depth', act: 'cabD' }], plus: false };
722
719
  const face = SK() as WallFace;
723
720
  if (s.type === 'line' && face.lines[s.id]?.anchor) return { label: anchorName(s.id), items: [], plus: false };
724
721
  if (s.type === 'line' && face.lines[s.id]?.cab) return { label: 'Cabinet edge', items: [], plus: false };
@@ -17,7 +17,8 @@ import type { SelItem } from '../model/types';
17
17
  /*
18
18
  Pointer routing for the plan canvas, whatever delivers the pointers (DOM pointer events, React Native touches).
19
19
  Pen draws, fingers navigate: once a pen has been seen, touch only pans and zooms. Without a pen, one finger drives
20
- the tool and a second finger turns the action into a pan/pinch. Two-finger tap undoes, three-finger tap redoes.
20
+ the tool and a second finger turns the action into a pan/pinch — a finger's press waits a moment for one first
21
+ (pressTiming). Two-finger tap undoes, three-finger tap redoes.
21
22
  Mouse: middle or right button, or Space held, drags to pan; the wheel zooms.
22
23
  With a sheet up over the drawing (the keypad, a conflict, a question), the drawing only pans and zooms, by any
23
24
  pointer, and a tap on it closes the sheet. View only, a tap on a link's tile asks to open what it's for.
@@ -52,22 +53,40 @@ export function setHostNavigation(on: boolean): void { hostNavigates = on; }
52
53
  */
53
54
  const MOTION_PX = 14;
54
55
 
56
+ /**
57
+ * How long a finger's press waits before the tool hears of it (ms), unless it lifts or moves past a tap first: time
58
+ * for a second finger to land and make it a pan or pinch from the start. Then the first finger never pressed anything:
59
+ * nothing is picked, no bar or sheet shows for it, and nothing is drawn where it landed (the user's report, 2026-10-30:
60
+ * zooming, "there is a brief moment where my touch brings up a bottom sheet"). A pinch's fingers land a few tens of
61
+ * milliseconds apart; iOS holds a scroll view's touches 150 ms and Android a pressed look 100 ms, for the same reason.
62
+ * A second finger after the wait still undoes the press (cancelPrimary). A mouse and a Pencil press at once.
63
+ */
64
+ export const pressTiming = {
65
+ /** tests pin it (0: a finger presses at once) */
66
+ holdMs: 120,
67
+ };
68
+
55
69
  const ptrs = new Map<number, PointerIn>();
56
70
  let penSeen = false, gesture: Gesture | null = null, primary: PointerIn | null = null;
57
71
  /** close: a press under a sheet (see sheetUp) — a tap closes it; open: a view-only press — a tap opens a link's tile */
58
72
  let panDrag: { x: number; y: number; id: number; moved: number; close?: boolean; open?: boolean } | null = null;
59
73
  /** where the primary pointer went down, and whether a gesture on the drawing is in motion */
60
74
  let primaryAt: { x: number; y: number } | null = null, inMotion = false;
75
+ /** the primary pointer has moved past a tap since it went down (see lensPointer) */
76
+ let primaryMoved = false;
61
77
  /**
62
78
  * What a press can change, as it was before the primary pointer went down: if a second finger turns the press into a
63
79
  * pan or pinch, it's put back (see cancelPrimary). A tool's own state is kept a level deep — its presses set its fields,
64
80
  * they don't change what's in them.
65
81
  */
66
82
  let beforePress: { tool: string; st: unknown; sel: SelItem[]; preview: Preview | null; snap: Snap | null } | null = null;
83
+ /** A finger down whose press is waiting (pressTiming): the pointer, where it landed, and each move it's made since. */
84
+ let held: { p: PointerIn; at: PointerIn; moves: PointerIn[]; timer: ReturnType<typeof setTimeout> } | null = null;
67
85
  let wheelT: ReturnType<typeof setTimeout> | undefined;
68
86
  export const keys = { spaceDown: false };
69
87
  onSessionReset(() => {
70
- ptrs.clear(); penSeen = false; gesture = null; primary = null; panDrag = null; keys.spaceDown = false; primaryAt = null; beforePress = null;
88
+ ptrs.clear(); penSeen = false; gesture = null; primary = null; panDrag = null; keys.spaceDown = false; primaryAt = null; beforePress = null; primaryMoved = false;
89
+ dropHeld();
71
90
  setMotion(false);
72
91
  clearTimeout(wheelT);
73
92
  });
@@ -164,6 +183,39 @@ function cancelPrimary(): void {
164
183
  updateCtx(); updateHint(); requestDraw();
165
184
  }
166
185
 
186
+ /** The primary pointer's press: the tool hears it land where `at` says (its pointer `p` may have moved on since). */
187
+ function press(p: PointerIn, at: PointerIn): void {
188
+ primary = p; primaryMoved = false;
189
+ primaryAt = { x: at.x, y: at.y };
190
+ // (what it lands on is picked from the drawing as it is now, not as the last frame painted it)
191
+ paintPending();
192
+ const t = curTool();
193
+ beforePress = { tool: ed.tool, st: t && t.st && typeof t.st === 'object' ? { ...t.st } : t?.st, sel: [...ed.sel], preview: ed.preview, snap: ed.snapState };
194
+ if (t && t.down) t.down(evOf(at));
195
+ }
196
+
197
+ /** The primary pointer moved: past a tap, a gesture is in motion (and the lens may show); the tool follows it. */
198
+ function movePrimary(e: PointerIn): void {
199
+ if (primaryAt && hyp(e.x - primaryAt.x, e.y - primaryAt.y) >= MOTION_PX) { setMotion(true); primaryMoved = true; }
200
+ const t = curTool();
201
+ if (t && t.move) t.move(evOf(e));
202
+ }
203
+
204
+ function dropHeld(): void {
205
+ if (held) clearTimeout(held.timer);
206
+ held = null;
207
+ }
208
+
209
+ /** A finger's waiting press goes ahead: the tool hears it land where it did, then each move it's made since. */
210
+ function releaseHeld(): void {
211
+ const h = held;
212
+ dropHeld();
213
+ // (something else took the drawing meanwhile: a mouse or Pencil press, a sheet, view only)
214
+ if (!h || primary || navigationOnly()) return;
215
+ press(h.p, h.at);
216
+ for (const m of h.moves) movePrimary(m);
217
+ }
218
+
167
219
  function endPtr(e: PointerIn, cancel: boolean): void {
168
220
  const p = ptrs.get(e.id);
169
221
  ptrs.delete(e.id);
@@ -175,6 +227,8 @@ function endPtr(e: PointerIn, cancel: boolean): void {
175
227
  return;
176
228
  }
177
229
  if (gesture && p && p.kind === 'touch') { if (!touchList().length) endGesture(e.t); return; }
230
+ // (a finger let go while its press waited: a tap, pressed and let go now; lost, it did nothing)
231
+ if (held && e.id === held.at.id) { if (cancel) dropHeld(); else releaseHeld(); }
178
232
  if (primary && e.id === primary.id) {
179
233
  primary = null;
180
234
  const t = curTool();
@@ -199,6 +253,8 @@ export function pointerDown(e: PointerIn): void {
199
253
  if (e.kind === 'touch') {
200
254
  const touches = touchList();
201
255
  if (penSeen || touches.length >= 2 || navOnly) {
256
+ // (a second finger while the first one's press waited: that never pressed anything)
257
+ dropHeld();
202
258
  if (primary && primary.kind === 'touch') cancelPrimary();
203
259
  // (a finger joining one already moving the view: the pinch is measured from here, none of its first move lost)
204
260
  if (!gesture) startGesture(e.t); else { gesture.max = Math.max(gesture.max, touches.length); gesture.last = gState(touches); }
@@ -206,13 +262,13 @@ export function pointerDown(e: PointerIn): void {
206
262
  }
207
263
  }
208
264
  if (primary || navOnly) return;
209
- primary = p;
210
- primaryAt = { x: e.x, y: e.y };
211
- // (what it lands on is picked from the drawing as it is now, not as the last frame painted it)
212
- paintPending();
213
- const t = curTool();
214
- beforePress = { tool: ed.tool, st: t && t.st && typeof t.st === 'object' ? { ...t.st } : t?.st, sel: [...ed.sel], preview: ed.preview, snap: ed.snapState };
215
- if (t && t.down) t.down(evOf(e));
265
+ // a finger's press waits a moment, in case another finger lands (pressTiming)
266
+ if (e.kind === 'touch' && pressTiming.holdMs > 0) {
267
+ dropHeld();
268
+ held = { p, at: { ...e }, moves: [], timer: setTimeout(releaseHeld, pressTiming.holdMs) };
269
+ return;
270
+ }
271
+ press(p, e);
216
272
  }
217
273
 
218
274
  export function pointerMove(e: PointerIn): void {
@@ -225,15 +281,29 @@ export function pointerMove(e: PointerIn): void {
225
281
  }
226
282
  if (p) { p.x = e.x; p.y = e.y; }
227
283
  if (gesture && p && p.kind === 'touch') { updateGesture(); return; }
284
+ // (a finger whose press is waiting: its moves wait with it, and one past a tap sends them all now — a drag)
285
+ if (held && e.id === held.at.id) {
286
+ held.moves.push({ ...e });
287
+ if (hyp(e.x - held.at.x, e.y - held.at.y) >= MOTION_PX) releaseHeld();
288
+ return;
289
+ }
228
290
  const t = curTool();
229
291
  if (!t) return;
230
- if (primary && e.id === primary.id) {
231
- if (primaryAt && hyp(e.x - primaryAt.x, e.y - primaryAt.y) >= MOTION_PX) setMotion(true);
232
- if (t.move) t.move(evOf(e));
233
- }
292
+ if (primary && e.id === primary.id) movePrimary(e);
234
293
  else if (!primary && e.kind !== 'touch' && !navigationOnly()) { ed.lastPointerTouch = false; if (t.hover) t.hover(evOf(e)); }
235
294
  }
236
295
 
296
+ /**
297
+ * The finger or Apple Pencil working the drawing, where it is on the canvas, once it's moved past a tap placing or
298
+ * dragging something (Tool.magnify): what a device's magnifier is centred on (render/lens.ts). Null for a mouse, a tap,
299
+ * a pan or a pinch, and a drawing that can't be edited or has a sheet up.
300
+ */
301
+ export function lensPointer(): { x: number; y: number } | null {
302
+ if (!primary || primary.kind === 'mouse' || !primaryMoved || navigationOnly()) return null;
303
+ const t = curTool();
304
+ return t && (!t.magnify || t.magnify()) ? { x: primary.x, y: primary.y } : null;
305
+ }
306
+
237
307
  export function pointerUp(e: PointerIn): void { endPtr(e, false); settle(); }
238
308
  export function pointerCancel(e: PointerIn): void { endPtr(e, true); settle(); }
239
309
 
@@ -5,7 +5,7 @@ import { saveSoon } from '../model/store';
5
5
  import { fitView, requestDraw } from '../render/view';
6
6
  import { cancelTool, setTool, tools } from '../tools/registry';
7
7
  import { applyCtxAction, deleteSelection } from './actions';
8
- import { addLinks, linkPlusVisible, openSelectedLink, selectedLink } from './linkActions';
8
+ import { addLinks, linkPlusVisible, openSelectedLink, selectedLink, selectedLinkedImage } from './linkActions';
9
9
  import { keys } from './gestures';
10
10
  import { keypadApply, keypadDeleteLast, keypadInsert, keypadOpen } from './keypad';
11
11
  import { confirmCancel, confirmChoose, confirmOpen } from './confirm';
@@ -89,7 +89,8 @@ export function keyDown(e: KeyIn): boolean {
89
89
  }
90
90
  // Annotate: + adds files and measurements (the + by the rail), Enter opens the selected link
91
91
  if ((e.key === '+' || e.key === '=') && linkPlusVisible()) { void addLinks(); return true; }
92
- if (e.key === 'Enter' && ed.mode === 'annotate' && selectedLink()) { openSelectedLink(); return true; }
92
+ // (⇧Enter: beside the drawing, where the host can show it there)
93
+ if (e.key === 'Enter' && ed.mode === 'annotate' && (selectedLink() || selectedLinkedImage())) { openSelectedLink(e.shift ? 'beside' : 'auto'); return true; }
93
94
  if (e.shift) {
94
95
  const c = SHIFT_CONS[e.key.toUpperCase()];
95
96
  if (c && ed.mode === 'draft') { applyCtxAction(c); return true; }
@@ -17,10 +17,11 @@ import { selInfo } from './selection';
17
17
 
18
18
  /*
19
19
  Measure-then-assign for laser and tape readings (inches), whatever device sends them: a reading goes into an open
20
- keypad, onto the one selected line, or to the tray, where it can be dragged onto a line later.
20
+ keypad, or onto the one selected line; one with nowhere to go is kept for a keypad's Laser key (and, in the harness,
21
+ its tray, where it can be dragged onto a line later).
21
22
  */
22
23
 
23
- /** A reading, and the host's record of it (laserReading's `capture`), kept with it in the tray. */
24
+ /** A reading, and the host's record of it (laserReading's `capture`), kept with it until it's used. */
24
25
  export interface Reading { id: string; v: number; capture?: unknown }
25
26
  /** `external`: a real measure the host app is connected to (otherwise the harness's simulated one). */
26
27
  export const laser = sessionState(() => ({ on: false, external: false, readings: [] as Reading[] }));
@@ -100,7 +101,9 @@ function appliedTo(id: string): string {
100
101
  }
101
102
 
102
103
  /**
103
- * A reading arrived: into the keypad, onto the selected line or curve, or into the tray (newest first, at most 8).
104
+ * A reading arrived: into the keypad, onto the selected line or curve, or — with nowhere to go — kept for a keypad's
105
+ * Laser key (newest first, at most 8) and said so. The hosts show no tray of them (the user's call, 2026-11-03: "get rid
106
+ * of the long laser reading pop up … it is not needed"); the harness's own still lists them (ui/laser.ts).
104
107
  * `capture` is the host's record of it (its device, its number), kept with the dimension it sets until that's saved
105
108
  * to the group — or handed to the job with it, for one linked to a measurement there.
106
109
  */
@@ -117,7 +120,7 @@ export function applyReading(v: number, capture?: unknown): void {
117
120
  laser.readings.unshift({ id: uid('m'), v, ...(capture !== undefined ? { capture } : {}) });
118
121
  laser.readings = laser.readings.slice(0, 8);
119
122
  refreshTray();
120
- toast('Reading ' + fmtLen(v) + ' saved to the tray');
123
+ toast('Select a line, then take the reading.', { warn: true });
121
124
  }
122
125
 
123
126
  /**
@@ -3,17 +3,20 @@ import { ed } from '../app/state';
3
3
  import { clamp } from '../model/geom';
4
4
  import { pushHistory } from '../model/history';
5
5
  import { uid } from '../model/ids';
6
- import { isLink, LINK_COLOR, LINK_MAX_IN, LINK_MIN_IN, LINK_TILE_PX, linkInfoOf, linkName, linkSide, linkSnapshot } from '../model/links';
6
+ import { isLink, LINK_COLOR, LINK_HARD_MAX_IN, LINK_HARD_MIN_IN, LINK_MAX_IN, LINK_MIN_IN, LINK_SIDE_IN, LINK_TILE_PX, linkInfoOf, linkName, linkSide, linkSnapshot } from '../model/links';
7
7
  import { dimMeasurement } from '../model/measurement';
8
- import { linkAt } from '../render/hit';
8
+ import { imageLinkAt, linkAt } from '../render/hit';
9
9
  import { askConfirm } from './confirm';
10
- import { SK, store } from '../model/store';
11
- import type { LinkTarget, Note } from '../model/types';
12
- import { cvs, linkBox, mainT, requestDraw, s2w } from '../render/view';
10
+ import { imageById } from '../model/images';
11
+ import { settings } from '../model/settings';
12
+ import { page, SK, store } from '../model/store';
13
+ import type { LinkTarget, Note, Sketch, Underlay } from '../model/types';
14
+ import { tileTypeOf } from '../render/linkTile';
15
+ import { cvs, linkBox, linkThumb, mainT, requestDraw, s2w, underlayMid } from '../render/view';
13
16
  import { setTool } from '../tools/registry';
14
17
  import { elevOpen } from './elevation';
15
18
  import { is3d } from './view3dState';
16
- import { canLink, linksHost, openLink, pickLinks } from './links';
19
+ import { besideOpen, canLink, canOpenBeside, linksHost, openLink, openLinkBeside, pickLinks } from './links';
17
20
  import { toast } from './ports';
18
21
 
19
22
  /*
@@ -65,6 +68,8 @@ export async function addLinks(): Promise<void> {
65
68
  let k = 0;
66
69
  while (k < 40 && spots.some(([x, y]) => taken(x + k * step, y - k * step))) k++;
67
70
  pushHistory();
71
+ // (the page's first tiles: the size they're placed at is their 100%)
72
+ if (pageLinkSize(sk) == null) sk.tileRef = ts;
68
73
  const ids = picked.map((t, i) => {
69
74
  const n: Note = { id: uid('lk'), type: 'link', x: spots[i][0] + k * step, y: spots[i][1] - k * step, ts, color: LINK_COLOR, link: linkSnapshot(t) };
70
75
  sk.notes.push(n);
@@ -80,7 +85,9 @@ const LINK_GAP_PX = 16;
80
85
 
81
86
  /*
82
87
  Every tile on a page is one size, on the drawing (so it zooms with the plan and prints at that size): the Tile size
83
- slider above the bar, shown while a tile is selected, sizes them all together.
88
+ slider above the bar, shown while a tile is selected, sizes them all together. It says how big in percent of the size
89
+ they were first placed at (the user's call, 2026-11-03): a tile has no real size, and before the page's first length
90
+ its inches are only placeholders, so feet and metres said nothing true.
84
91
  */
85
92
 
86
93
  /** The size of the page's tiles, inches on the drawing (null: it has none). */
@@ -96,21 +103,44 @@ export function linkSizeVisible(): boolean {
96
103
  return ed.sel.some(x => x.type === 'note' && sk.notes.find(n => n.id === x.id)?.type === 'link');
97
104
  }
98
105
 
106
+ /** What the page's tiles call 100%, inches on the drawing: the size they were first placed at (a page from before: theirs now). */
107
+ const tileRef = (sk: Sketch): number => sk.tileRef ?? pageLinkSize(sk) ?? LINK_SIDE_IN;
108
+
109
+ /** How big the page's tiles are, in percent of the size they were first placed at (null: it has none). */
110
+ export function pageTilePct(sk = SK()): number | null {
111
+ const s = pageLinkSize(sk);
112
+ return s == null ? null : Math.round((s / tileRef(sk)) * 100);
113
+ }
114
+
115
+ /** The slider's range, percent. */
116
+ export const TILE_PCT_MIN = 10, TILE_PCT_MAX = 1000;
117
+
99
118
  /**
100
- * Size every tile on the page (inches, to the quarter, from LINK_MIN_IN to LINK_MAX_IN), drawn at once — for the slider
101
- * as it moves. The caller makes the undo step before, and finishes the edit after.
119
+ * Size every tile on the page to `pct` percent (TILE_PCT_MIN to TILE_PCT_MAX; and never past LINK_HARD_MIN_IN to
120
+ * LINK_HARD_MAX_IN on the drawing — placing's 6"–40' bounds would cut the range short), drawn at once — for the slider as
121
+ * it moves. The caller makes the undo step before, and finishes the edit after. (A page from before keeps its 100% from
122
+ * here on.)
102
123
  */
103
- export function setLinkSize(inches: number): void {
104
- const v = Math.round(clamp(inches, LINK_MIN_IN, LINK_MAX_IN) * 4) / 4;
105
- for (const n of SK().notes) if (isLink(n)) n.ts = v;
124
+ export function setTilePct(pct: number): void {
125
+ const sk = SK(), ref = tileRef(sk), v = clamp((ref * clamp(pct, TILE_PCT_MIN, TILE_PCT_MAX)) / 100, LINK_HARD_MIN_IN, LINK_HARD_MAX_IN);
126
+ sk.tileRef = ref;
127
+ for (const n of sk.notes) if (isLink(n)) n.ts = v;
106
128
  requestDraw();
107
129
  }
108
130
 
109
- /** The slider runs along a log scale (a few inches to many feet, each step as noticeable as the last): 0 to SIZE_STEPS. */
131
+ /**
132
+ * The slider runs along a log scale (each step as noticeable as the last), 0 to SIZE_STEPS, 100% in the middle; it
133
+ * catches at 100%, so the size they came in at is easy to find again, and says round numbers: whole percents to 200%,
134
+ * then fives, then tens from 500%.
135
+ */
110
136
  export const SIZE_STEPS = 1000;
111
- const LOG_MIN = Math.log(LINK_MIN_IN), LOG_SPAN = Math.log(LINK_MAX_IN) - LOG_MIN;
112
- export const sizeToStep = (inches: number): number => Math.round(((Math.log(clamp(inches, LINK_MIN_IN, LINK_MAX_IN)) - LOG_MIN) / LOG_SPAN) * SIZE_STEPS);
113
- export const stepToSize = (step: number): number => Math.exp(LOG_MIN + (clamp(step, 0, SIZE_STEPS) / SIZE_STEPS) * LOG_SPAN);
137
+ const LOG_MIN = Math.log(TILE_PCT_MIN), LOG_SPAN = Math.log(TILE_PCT_MAX) - LOG_MIN;
138
+ export const pctToStep = (pct: number): number => Math.round(((Math.log(clamp(pct, TILE_PCT_MIN, TILE_PCT_MAX)) - LOG_MIN) / LOG_SPAN) * SIZE_STEPS);
139
+ export function stepToPct(step: number): number {
140
+ const p = Math.exp(LOG_MIN + (clamp(step, 0, SIZE_STEPS) / SIZE_STEPS) * LOG_SPAN);
141
+ if (Math.abs(p - 100) < 4) return 100;
142
+ return p >= 500 ? Math.round(p / 10) * 10 : p >= 200 ? Math.round(p / 5) * 5 : Math.round(p);
143
+ }
114
144
 
115
145
  /** A tile's leader on or off. On, it points out beside the tile (to the right, or the left near the screen's edge). */
116
146
  export function toggleLeader(id: string): void {
@@ -126,24 +156,134 @@ export function toggleLeader(id: string): void {
126
156
  afterEdit();
127
157
  }
128
158
 
129
- /** Open what the selected tile is for. */
130
- export function openSelectedLink(): void {
131
- const n = selectedLink();
132
- if (!n) return;
133
- if (!canOpenLink(n.link)) { toast(linkInfoOf(n.link)?.exists === false ? 'That’s been removed from the job, so it can’t be opened.' : 'That can’t be opened from here.', { warn: true }); return; }
134
- openLink(n.link);
159
+ /*
160
+ Open, for what a link is for: the host's own way (another screen, a sheet over the drawing), or — the user's call,
161
+ 2026-10-30 — in a pane beside the drawing, a split view, where the host has one (CadLinks.openBeside). The bar's Open
162
+ asks which while no pane is open (opensWithChoice); once one is, Open goes to it.
163
+ */
164
+
165
+ /** How Open opens: `here`, the host's own way; `beside`, in a pane beside the drawing; `auto`, beside if one's open. */
166
+ export type OpenHow = 'auto' | 'here' | 'beside';
167
+
168
+ /** Open what the selected tile, or the selected expanded photo, is for. */
169
+ export function openSelectedLink(how: OpenHow = 'auto'): void {
170
+ const t = selectedLink()?.link ?? selectedLinkedImage()?.link;
171
+ if (t) openLinkTarget(t, how);
172
+ }
173
+
174
+ /** What the bar's Open is for: the selected tile's or expanded photo's link, or the selected linked dimension's. */
175
+ export function selectedOpenTarget(): LinkTarget | null {
176
+ const t = selectedLink()?.link ?? selectedLinkedImage()?.link;
177
+ if (t) return t;
178
+ const s = ed.sel;
179
+ return s.length === 1 && s[0].type === 'dim' ? SK().cons[s[0].id]?.measurementRef ?? null : null;
180
+ }
181
+
182
+ /** Whether Open asks how to open `t`: the host can show it beside the drawing, and no pane's open yet. */
183
+ export function opensWithChoice(t: LinkTarget): boolean {
184
+ return canOpenLink(t) && canOpenBeside(t) && !besideOpen();
185
+ }
186
+
187
+ /** Open what `t` is for, `how` it's asked to (see OpenHow); one that's gone, or can't be, says so instead. */
188
+ export function openLinkTarget(t: LinkTarget, how: OpenHow = 'auto'): void {
189
+ if (!canOpenLink(t)) {
190
+ const gone = linkInfoOf(t)?.exists === false;
191
+ toast(!gone ? 'That can’t be opened from here.'
192
+ : t.kind === 'measurement' ? 'That measurement has been removed from the job, so it can’t be opened.'
193
+ : 'That’s been removed from the job, so it can’t be opened.', { warn: true });
194
+ return;
195
+ }
196
+ if ((how === 'beside' || (how === 'auto' && besideOpen())) && canOpenBeside(t)) openLinkBeside(t);
197
+ else openLink(t);
198
+ }
199
+
200
+ /** An expanded photo's badge, tapped: what it's for opens (beside the drawing, if a pane's open there). */
201
+ export function openImageLink(id: string): void {
202
+ const t = imageById(id)?.link;
203
+ if (t) openLinkTarget(t);
204
+ }
205
+
206
+ /*
207
+ A photo's tile expands into the photo whole, and back (the user's call, 2026-10-26: "for image objects … you should
208
+ be able to expand and condense the tile … this is how skia works, it expands the image to full size + shows a link
209
+ icon in the corner"). Expanded, it's one of the page's images — moved, sized, cropped, turned, faded and locked as
210
+ any is — with the link's badge in its corner; Expand in the bar (on) folds it back into its tile where it is. An image
211
+ from the computer has no link, so none of this. Each way is one undo step, and what was selected stays selected.
212
+ */
213
+
214
+ /** The one selected image, if it's a photo expanded from its tile and all that's selected. */
215
+ export function selectedLinkedImage(): (Underlay & { link: LinkTarget }) | null {
216
+ if (ed.sel.length !== 1 || ed.sel[0].type !== 'underlay') return null;
217
+ const u = imageById(ed.sel[0].id);
218
+ return u?.link ? (u as Underlay & { link: LinkTarget }) : null;
219
+ }
220
+
221
+ /** Whether a tile can expand: a photo's (not a sketch's, a document's or a measurement's), and the photo still there. */
222
+ export function canExpand(n: Note & { link: LinkTarget }): boolean {
223
+ const info = linkInfoOf(n.link);
224
+ return tileTypeOf(n, info) === 'image' && info?.exists !== false;
225
+ }
226
+
227
+ /**
228
+ * A photo's tile, expanded: the photo whole where the tile was, as big as it fits in 60% of the screen across and 80%
229
+ * down (an image from the computer is placed 60% across), at full strength, free to move.
230
+ */
231
+ export function expandLink(id: string): void {
232
+ const sk = SK(), at = sk.notes.findIndex(x => x.id === id), n = sk.notes[at];
233
+ if (!n || !isLink(n) || !canExpand(n) || ed.readOnly) return;
234
+ const url = linkInfoOf(n.link)?.thumbUrl, im = url ? linkThumb(url) : null;
235
+ if (!im?.complete || !im.naturalWidth) { toast('The photo is still loading. Try again in a moment.', { warn: true }); return; }
236
+ const T = mainT(), aspect = (im.naturalHeight || 1) / im.naturalWidth;
237
+ const w = Math.min(cvs.W * 0.6, (cvs.H * 0.8) / aspect) / T.s, h = w * aspect;
238
+ const { x = 0, y = 0, link, ...tile } = n;
239
+ pushHistory();
240
+ sk.notes.splice(at, 1);
241
+ const u: Underlay = { id: uid('img'), src: '', x: x - w / 2, y: y + h / 2, w, h, opacity: 1, locked: false, link, tile };
242
+ page().underlays.push(u);
243
+ settings.layers.underlay = true;
244
+ ed.sel = [{ type: 'underlay', id: u.id }];
245
+ afterEdit();
246
+ }
247
+
248
+ /** An expanded photo, folded back into its tile: where the photo's middle is, the page's tiles' size, its leader back. */
249
+ export function collapseLink(id: string): void {
250
+ const u = imageById(id);
251
+ if (!u?.link || ed.readOnly) return;
252
+ if (u.locked) { toast('This photo is locked. Unlock it to fold it back into its tile.', { warn: true }); return; }
253
+ const sk = SK(), m = underlayMid(u), was = u.tile ? (JSON.parse(JSON.stringify(u.tile)) as Omit<Note, 'x' | 'y' | 'link'>) : null;
254
+ const ts = pageLinkSize(sk) ?? was?.ts ?? Math.round(clamp(LINK_TILE_PX / mainT().s, LINK_MIN_IN, LINK_MAX_IN) * 4) / 4;
255
+ const n: Note = { ...(was ?? { id: '', type: 'link', color: LINK_COLOR }), type: 'link', x: m.x, y: m.y, ts, link: u.link };
256
+ if (!n.id || sk.notes.some(x => x.id === n.id)) n.id = uid('lk');
257
+ pushHistory();
258
+ // (the page's only tile, back: its size is the 100%, unless the page already has one)
259
+ if (pageLinkSize(sk) == null && sk.tileRef == null) sk.tileRef = ts;
260
+ page().underlays.splice(page().underlays.indexOf(u), 1);
261
+ sk.notes.push(n);
262
+ ed.sel = [{ type: 'note', id: n.id }];
263
+ afterEdit();
264
+ }
265
+
266
+ /** Expand in the bar: the selected photo's tile expanded, or the selected expanded photo folded back. */
267
+ export function toggleExpand(): void {
268
+ const n = selectedLink(), u = selectedLinkedImage();
269
+ if (n) expandLink(n.id);
270
+ else if (u) collapseLink(u.id);
135
271
  }
136
272
 
137
- /** A view-only tap on the plan: on a link's tile, the person is asked, then what it's for opens. */
273
+ /** A view-only tap on the plan: on a link's tile or an expanded photo's badge, the person is asked, then what it's for opens. */
138
274
  export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
139
275
  if (!ed.readOnly || !canLink()) return;
140
- const n = linkAt(sx, sy, touch);
141
- if (!n || !canOpenLink(n.link)) return;
142
- const t = n.link, where = linkInfoOf(t)?.groupName ?? t.groupName;
276
+ const t = linkAt(sx, sy, touch)?.link ?? imageLinkAt(sx, sy, touch)?.link;
277
+ if (!t || !canOpenLink(t)) return;
278
+ const where = linkInfoOf(t)?.groupName ?? t.groupName;
143
279
  askConfirm({
144
280
  title: 'Open ' + linkName(t) + '?',
145
281
  msg: where ? 'It’s in ' + where + '.' : undefined,
146
- choices: [{ label: 'Open', run: () => openLink(t) }, { label: 'Cancel', run: () => {} }],
282
+ choices: [
283
+ { label: 'Open', run: () => openLinkTarget(t, 'here') },
284
+ ...(canOpenBeside(t) ? [{ label: 'Open side by side', run: () => openLinkTarget(t, 'beside') }] : []),
285
+ { label: 'Cancel', run: () => {} },
286
+ ],
147
287
  });
148
288
  }
149
289
 
@@ -157,11 +297,9 @@ export function unlinkDim(id: string): void {
157
297
  }
158
298
 
159
299
  /** Open the measurement a dimension is linked to. */
160
- export function openDimLink(id: string): void {
300
+ export function openDimLink(id: string, how: OpenHow = 'auto'): void {
161
301
  const t = SK().cons[id]?.measurementRef;
162
- if (!t) return;
163
- if (!canOpenLink(t)) { toast(linkInfoOf(t)?.exists === false ? 'That measurement has been removed from the job, so it can’t be opened.' : 'That can’t be opened from here.', { warn: true }); return; }
164
- openLink(t);
302
+ if (t) openLinkTarget(t, how);
165
303
  }
166
304
 
167
305
  /**
@@ -1,4 +1,4 @@
1
- import { linkInfo, linkKey, linkTargets } from '../model/links';
1
+ import { linkInfo, linkInfoOf, linkKey, linkTargets } from '../model/links';
2
2
  import { onSessionReset } from '../model/session';
3
3
  import { store } from '../model/store';
4
4
  import type { LinkInfo, LinkTarget } from '../model/types';
@@ -67,6 +67,13 @@ export interface CadLinks {
67
67
  subscribe(targets: LinkTarget[], cb: (info: Record<string, LinkInfo>) => void): () => void;
68
68
  /** Open what `target` is for (another screen, or a sheet over the drawing). */
69
69
  open(target: LinkTarget): void;
70
+ /**
71
+ * Open what `target` is for in a pane beside the drawing (a split view), the drawing staying as it is. With this, the
72
+ * bar's Open asks how — Open, or Open side by side — until a pane is open (besideOpen), when Open goes to it.
73
+ */
74
+ openBeside?(target: LinkTarget): void;
75
+ /** Whether a pane is open beside the drawing now (with openBeside). */
76
+ besideOpen?(): boolean;
70
77
  /** Change a linked measurement (micrometres, or degrees), the person having said to; `capture` as saveMeasurement's. */
71
78
  update?(target: LinkTarget, value: number, capture?: unknown): void;
72
79
  /** Save a dimension as a new measurement in the drawing's own group; resolves with it (null: not saved). */
@@ -179,3 +186,12 @@ export async function pickJobMeasurement(kind: MeasurementKind): Promise<LinkTar
179
186
 
180
187
  /** Open what a link is for. */
181
188
  export function openLink(t: LinkTarget): void { host?.open(t); }
189
+
190
+ /** Open what a link is for in a pane beside the drawing (CadLinks.openBeside). */
191
+ export function openLinkBeside(t: LinkTarget): void { host?.openBeside?.(t); }
192
+
193
+ /** Whether the host can show `t` in a pane beside the drawing. */
194
+ export function canOpenBeside(t: LinkTarget): boolean { return !!host?.openBeside && linkInfoOf(t)?.canOpenBeside !== false; }
195
+
196
+ /** Whether a pane is open beside the drawing now. */
197
+ export function besideOpen(): boolean { return !!host?.besideOpen?.(); }
@@ -58,12 +58,14 @@ export const railIcon = (id: string): string => WALL_ICON[id] ?? id;
58
58
  /** The tool rail for each mode. 'toggle' items switch something on and off; 'act' items run an action. */
59
59
  export const RAIL: Record<Mode, RailItem[]> = {
60
60
  draft: [
61
- // drawing; then the building (walls, what goes in them, spaces); then measuring and guides
62
- ['select', 'Select', 'V'], ['line', 'Line', 'L'], ['freehand', 'Draw to shape', 'S'], ['rect', 'Rectangle', 'R'], ['circle', 'Circle', 'C'], ['arc', 'Arc', 'A'], '|',
61
+ // drawing, and Trim last of it (the user's call, 2026-11-03); then the building (walls, what goes in them, spaces);
62
+ // then guides
63
+ ['select', 'Select', 'V'], ['line', 'Line', 'L'], ['freehand', 'Draw to shape', 'S'], ['rect', 'Rectangle', 'R'], ['circle', 'Circle', 'C'], ['arc', 'Arc', 'A'], ['trim', 'Trim', 'T'], '|',
63
64
  ['door', 'Door', 'B'], ['window', 'Window', 'N'], ['room', 'Space', 'M'], '|',
64
- // Trim (T) and Offset (O) stay on the keyboard; Offset is also in the bar for a selected line.
65
+ // Offset (O) and Dimension (D) stay on the keyboard; Offset is also in the bar for a selected line, and measuring is
66
+ // the yellow + for what's selected (the user's call, 2026-10-24: no Dimension on the rail).
65
67
  // The toggles together: what's drawn is walls, or guides (one or the other, never both)
66
- ['dim', 'Dimension', 'D'], ['wall', 'Walls', 'W', 'toggle'], ['constr', 'Construction lines', 'X', 'toggle'], '|',
68
+ ['wall', 'Walls', 'W', 'toggle'], ['constr', 'Construction lines', 'X', 'toggle'], '|',
67
69
  ['undo', 'Undo', '⌘Z', 'act'], ['redo', 'Redo', '⇧⌘Z', 'act'],
68
70
  ],
69
71
  annotate: [
@@ -78,7 +80,8 @@ export const RAIL: Record<Mode, RailItem[]> = {
78
80
  /**
79
81
  * Tools that share one place on a phone's rail, which has no room for each: a tap draws with the one used last (the
80
82
  * place shows it), and a long press opens a menu of them all (react/ShapeMenu.tsx) — Draft's Line, Rectangle, Circle
81
- * and Arc, and Annotate's markup shapes. A tablet's or a browser's rail shows every one in its own place.
83
+ * and Arc, and Annotate's markup shapes. A tablet's or a browser's rail shows every one in its own place. (Trim isn't
84
+ * one of them: on a phone it has its own place, under Draw to shape — the user's call, 2026-11-03.)
82
85
  */
83
86
  export const SHAPE_TOOLS = ['line', 'rect', 'circle', 'arc'];
84
87
  export const SHAPES_GROUP = 'shapes';
@@ -83,11 +83,10 @@ export type LayerKey = keyof Layers | 'showCons';
83
83
  export const LAYER_ROWS: [LayerKey, string, string][] = [
84
84
  ['construction', 'Construction lines', 'Dashed guides'],
85
85
  ['dims', 'Driving dimensions', 'Blue chips that set sizes'],
86
- ['print', 'Print dimensions', 'Labels for the crew; never move geometry'],
87
86
  ['rooms', 'Spaces', 'Fill, name and area'],
88
87
  ['cabinets', 'Cabinets', 'Their footprints, from each wall’s view'],
89
88
  ['annot', 'Markup', 'Pen, arrows, shapes, clouds and notes'],
90
- ['showCons', 'Constraint icons', 'H, V, ∥, ⊥ badges'],
89
+ ['showCons', 'Constraint icons', 'H, V, ∥, ⊥ and lock badges'],
91
90
  ];
92
91
 
93
92
  export const layerOn = (k: LayerKey): boolean => (k === 'showCons' ? settings.showCons : settings.layers[k]);