@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
@@ -1,11 +1,9 @@
1
- import { memo, useRef, useState } from 'react';
1
+ import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
2
  import { Animated, ScrollView, Text, View } from 'react-native';
3
3
  import { ed } from '../app/state';
4
- import { applyCtxAction, barItems, deleteSelection, plusAction, plusLabel, plusVisible } from '../editor/actions';
5
- import { addLinks, LINK_PLUS_LABEL, linkPlusVisible, linkSizeVisible, pageLinkSize, setLinkSize, SIZE_STEPS, sizeToStep, stepToSize } from '../editor/linkActions';
4
+ import { applyCtxAction, barItems, deleteSelection, iconAlone, plusAction, plusLabel, plusVisible } from '../editor/actions';
5
+ import { addLinks, LINK_PLUS_LABEL, linkPlusVisible, linkSizeVisible, openLinkTarget, opensWithChoice, pageLinkSize, pageTilePct, pctToStep, selectedOpenTarget, setTilePct, SIZE_STEPS, stepToPct } from '../editor/linkActions';
6
6
  import { afterEdit } from '../app/edit';
7
- import { LINK_SIDE_IN } from '../model/links';
8
- import { fmtLen } from '../units';
9
7
  import { bar3d, run3d } from '../editor/view3d';
10
8
  import { is3d } from '../editor/view3dState';
11
9
  import { OPACITY_MIN, OPACITY_STEP, ROTATE_MAX, ROTATE_STEP, setUnderlayOpacity, setUnderlayRotation } from '../editor/project';
@@ -15,48 +13,65 @@ import { Slider } from './Slider';
15
13
  import { notify, ui, useEditorKey } from './store';
16
14
  import { currentHint } from '../editor/status';
17
15
  import { hovered } from './Buttons';
18
- import { Icon } from './Icon';
16
+ import { Icon, RawSvg } from './Icon';
19
17
  import { RichText } from './RichText';
20
18
  import { C, FONT, shadow } from './theme';
21
- import { Tap, usePopup } from './motion';
19
+ import { reducedMotion, Tap, usePopup } from './motion';
22
20
  import { createSheet } from './corners';
21
+ import { NATIVE } from './easing';
22
+ import { barCut, barSpans, FADE, FADE_RAMP, fadeSvg, NUDGE, NUDGE_AFTER, NUDGE_HOLD } from './barScroll';
23
23
 
24
24
  /**
25
25
  * The contextual bar: what fits the tool and selection, the yellow + to measure, and the trash on its own. It's as
26
26
  * long as what's in it, centred on the screen — on a phone, unless that would put it under the trash, when it moves
27
27
  * just clear of it (and scrolls only if even that leaves too little room). In Annotate on a phone (or a window too
28
28
  * short for it under the rail) the + that adds the job's files and measurements has the trash's corner while nothing's
29
- * selected: selecting anything puts the trash there instead, and letting go brings the + back.
29
+ * selected: selecting anything puts the trash there instead, and letting go brings the + back. A bar that scrolls
30
+ * says so (react/barScroll.ts).
30
31
  */
31
32
  export function BottomBar({ phone }: { phone: boolean }) {
32
- // what's in the bar, and the selected image's opacity and turn (its sliders)
33
+ // what's in the bar and what it's for, and the selected image's opacity and turn (its sliders)
33
34
  useEditorKey(() => {
34
35
  const u = ed.sel.length === 1 && ed.sel[0].type === 'underlay' ? imageById(ed.sel[0].id) : undefined;
35
- return [buttonsKey(), plusVisible(), ed.sel.length, u ? u.opacity ?? 0.6 : '', u ? (u.rot ?? 0) + (u.locked ? 'L' : '') : '', linkPlusVisible(), ui.linkFab, linkSizeVisible(), pageLinkSize() ?? ''].join('|');
36
+ return [buttonsKey(), barFor(), plusVisible(), ed.sel.length, u ? u.opacity ?? 0.6 : '', u ? (u.rot ?? 0) + (u.locked ? 'L' : '') : '', linkPlusVisible(), ui.linkFab, linkSizeVisible(), pageLinkSize() ?? ''].join('|');
36
37
  });
37
38
  // (on a phone only buttons show: a bar that would hold just a label isn't shown at all. The 3D tab measures and
38
39
  // deletes nothing: what's tapped there shows its sizes, and what can be done with it from there)
39
40
  const items = shown(phone), plus = !is3d() && plusVisible(), trash = !is3d() && ed.sel.length > 0;
40
41
  const fab = !trash && (phone || ui.linkFab) && linkPlusVisible(), corner = trash || fab;
41
- // it scrolls only when its buttons can't all fit (a narrow phone): otherwise a drag on the bar would move it
42
- const [room, setRoom] = useState(0), [content, setContent] = useState(0), over = content > room + 1;
42
+ // It scrolls only when its buttons can't all fit (a narrow phone): otherwise a drag on the bar would move it. Its
43
+ // room comes from its row's width, not the scroller's, which barCut makes shorter than that; where its buttons are
44
+ // comes from their widths, which a browser reports as they change (not as they move along).
45
+ const [row, setRow] = useState(0), [content, setContent] = useState(0), [widths, setWidths] = useState<readonly (number | undefined)[]>([]);
46
+ const room = row - (phone && corner ? TRASH_ROOM : 0) - 2 * PAD - (plus ? ADD + ADD_GAP : 0), over = row > 0 && content > room + 1;
47
+ const spans = over ? barSpans(items.map((it, i) => (it === 'sep' ? { w: SEP_W + 2 * SEP_M, button: false } : { w: widths[i], button: !('lbl' in it) })), GAP) : null;
48
+ const cut = !over ? undefined : spans ? barCut(spans, room) : room;
49
+ const onWidth = useCallback((i: number, w: number) => setWidths(p => (p[i] === w ? p : Object.assign(p.slice(), { [i]: w }))), []);
50
+ const scroll = useBarScroll(over, cut == null ? 0 : content - cut, barFor());
43
51
  return (
44
52
  <>
45
53
  {(items.length > 0 || plus) && (
46
- <View style={[s.bottom, phone ? s.phoneRow : s.centered]}>
54
+ <View style={[s.bottom, phone ? s.phoneRow : s.centered]} onLayout={e => setRow(e.nativeEvent.layout.width)}>
47
55
  <ImagePopover />
48
56
  <LinkSizePopover />
57
+ <OpenPopover />
49
58
  {/* a phone's bar sits between two springs that start as wide as the trash takes and share what's left
50
59
  equally, so it's centred; only the left one gives way (first, before the bar), so a long bar slides left
51
60
  to clear the trash. (A minimum width on the right one alone did that in a browser, but a device's layout
52
61
  shares the room out differently and left a short bar off-centre.) The springs let touches through. */}
53
62
  {phone && <View pointerEvents="none" style={[s.springL, { flexBasis: corner ? TRASH_ROOM : 0 }]} />}
54
63
  <View testID="ctxBar" style={s.ctx}>
55
- {/* (a native ScrollView grows to fill its row by default, which stretched the bar across a tablet) */}
56
- <ScrollView horizontal showsHorizontalScrollIndicator={false} scrollEnabled={over} bounces={false} contentContainerStyle={s.items}
57
- style={{ flexGrow: 0, flexShrink: 1 }} onLayout={e => setRoom(e.nativeEvent.layout.width)} onContentSizeChange={w => setContent(w)}>
58
- <BarButtons k={buttonsKey()} phone={phone} />
59
- </ScrollView>
64
+ {/* the buttons and their fades, clipped to the round ends the buttons have: scrolled, they'd stick out past
65
+ the bar's own. (A native ScrollView grows to fill its row by default, which stretched the bar across a
66
+ tablet.) */}
67
+ <View style={s.clip}>
68
+ <Animated.ScrollView ref={scroll.ref} testID="ctxScroll" horizontal showsHorizontalScrollIndicator={false} scrollEnabled={over} bounces={over}
69
+ contentContainerStyle={s.items} style={[s.scroller, cut != null && { width: cut }]} onContentSizeChange={w => setContent(w)}
70
+ onScroll={scroll.onScroll} scrollEventThrottle={16} onScrollBeginDrag={scroll.stop}>
71
+ <BarButtons k={buttonsKey()} phone={phone} onWidth={onWidth} />
72
+ </Animated.ScrollView>
73
+ {cut != null && <Fades x={scroll.x} end={cut} max={content - cut} />}
74
+ </View>
60
75
  {plus && (
61
76
  <Tap accessibilityRole="button" accessibilityLabel={plusLabel()} testID="addbtn" onPress={plusAction} style={s.add}>
62
77
  <Icon name="plus" color={C.dark} />
@@ -96,6 +111,61 @@ function buttonsKey(): string {
96
111
  return JSON.stringify(its) + '|' + ui.imgPop;
97
112
  }
98
113
 
114
+ /** What the bar is for: the selection, or what's tapped in 3D. A new one's bar starts at its start. */
115
+ const barFor = (): string => (is3d() ? JSON.stringify(bar3d()) : ed.sel.map(x => x.type + ':' + x.id).join(','));
116
+
117
+ /** The first bar that scrolls in a session (until the app is next started) is nudged; no other is. */
118
+ let nudged = false;
119
+
120
+ /**
121
+ * A bar's scrolling: how far it's scrolled (for its fades), back to its start for each new selection, and the nudge —
122
+ * the first bar of a session that doesn't fit slides NUDGE along and back, unless Reduce Motion is on. `max`: how far
123
+ * it scrolls.
124
+ */
125
+ function useBarScroll(over: boolean, max: number, forKey: string) {
126
+ const ref = useRef<ComponentRef<typeof ScrollView>>(null), x = useRef(new Animated.Value(0)).current;
127
+ const back = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
128
+ const onScroll = useMemo(() => Animated.event([{ nativeEvent: { contentOffset: { x } } }], { useNativeDriver: NATIVE }), [x]);
129
+ // (and one that's come to fit has only its start)
130
+ useEffect(() => { ref.current?.scrollTo({ x: 0, animated: false }); x.setValue(0); }, [forKey, over, x]);
131
+ useEffect(() => {
132
+ if (!over || nudged || max <= 0 || reducedMotion()) return;
133
+ // (once what's in it has stopped changing: its buttons' widths come in one by one)
134
+ const t = setTimeout(() => {
135
+ nudged = true;
136
+ ref.current?.scrollTo({ x: Math.min(NUDGE, max), animated: true });
137
+ back.current = setTimeout(() => ref.current?.scrollTo({ x: 0, animated: true }), NUDGE_HOLD);
138
+ }, NUDGE_AFTER);
139
+ return () => clearTimeout(t);
140
+ }, [over, max]);
141
+ // a finger dragging it mid-nudge keeps it where it's taken
142
+ const stop = useCallback(() => clearTimeout(back.current), []);
143
+ useEffect(() => stop, [stop]);
144
+ return { ref, x, onScroll, stop };
145
+ }
146
+
147
+ const FADE_START = fadeSvg('left'), FADE_END = fadeSvg('right');
148
+
149
+ /**
150
+ * A scrolling bar's fades, over its scroller: at its start once it's scrolled from there, and at its end (`end` along
151
+ * it) while there's more past it. They come and go as it's scrolled, on the native driver; touches go through them.
152
+ */
153
+ function Fades({ x, end, max }: { x: Animated.Value; end: number; max: number }) {
154
+ const [start, more] = useMemo(() => {
155
+ const ramp = Math.max(1, Math.min(FADE_RAMP, max));
156
+ return [
157
+ x.interpolate({ inputRange: [0, ramp], outputRange: [0, 1], extrapolate: 'clamp' }),
158
+ x.interpolate({ inputRange: [max - ramp, max], outputRange: [1, 0], extrapolate: 'clamp' }),
159
+ ];
160
+ }, [x, max]);
161
+ return (
162
+ <>
163
+ <Animated.View testID="barFadeStart" style={[s.fade, { left: 0, opacity: start }]}><RawSvg xml={FADE_START} width={FADE} height={BTN_H} /></Animated.View>
164
+ <Animated.View testID="barFadeEnd" style={[s.fade, { left: end - FADE, opacity: more }]}><RawSvg xml={FADE_END} width={FADE} height={BTN_H} /></Animated.View>
165
+ </>
166
+ );
167
+ }
168
+
99
169
  /**
100
170
  * A phone's bar: the buttons alone — the line saying what's selected and its sizes ("Cabinet 24" L × 34 1/2" H…") takes
101
171
  * room a phone hasn't got, and the drawing shows the sizes anyway — with no divider left at an end or doubled up.
@@ -113,23 +183,30 @@ function buttonsOnly(items: ReturnType<typeof barItems>): ReturnType<typeof barI
113
183
 
114
184
  /**
115
185
  * The bar's buttons, drawn again only when what they show changes (`k`, see buttonsKey) — not when the bar itself
116
- * is, as it measures whether they fit.
186
+ * is, as it measures whether they fit. Each says how wide it is (`onWidth`, by its place; a divider's is fixed).
117
187
  */
118
- const BarButtons = memo(function BarButtons({ phone }: { k: string; phone: boolean }) {
188
+ const BarButtons = memo(function BarButtons({ phone, onWidth }: { k: string; phone: boolean; onWidth: (i: number, w: number) => void }) {
119
189
  return (
120
190
  <>
121
191
  {shown(phone).map((it, i) => {
122
192
  if (it === 'sep') return <View key={'s' + i} style={s.sep} />;
123
- if ('lbl' in it) return <Text key={'l' + i} style={s.lbl}>{it.lbl}</Text>;
193
+ if ('lbl' in it) return <Text key={'l' + i} style={s.lbl} onLayout={e => onWidth(i, e.nativeEvent.layout.width)}>{it.lbl}</Text>;
124
194
  const fg = it.on ? C.dark : it.locked ? C.red : '#fff';
125
195
  return (
126
- <Tap key={it.act + i} accessibilityRole="button" accessibilityLabel={it.label}
196
+ <Tap key={it.act + i} onLayout={e => onWidth(i, e.nativeEvent.layout.width)} accessibilityRole="button" accessibilityLabel={it.label}
127
197
  accessibilityState={it.on != null || it.locked != null ? { selected: !!(it.on || it.locked) } : undefined}
128
- onPress={e => { if (!(is3d() && run3d(it.act))) applyCtxAction(it.act, e.currentTarget); }}
129
- style={st => [s.cb, it.on || (it.act === 'imgOpacity' && ui.imgPop === 'opacity') || (it.act === 'imgRotate' && ui.imgPop === 'rotate') || (it.act === 'linkSize' && ui.imgPop === 'tilesize') ? { backgroundColor: 'rgba(255,255,255,0.14)' } : null,
198
+ onPress={e => {
199
+ if (is3d() && run3d(it.act)) return;
200
+ // (Open asks how — here, or beside the drawing — where the host can show it there and no pane's open yet)
201
+ const t = isOpen(it.act) ? selectedOpenTarget() : null;
202
+ if (t && opensWithChoice(t)) { ui.imgPop = ui.imgPop === 'open' ? null : 'open'; notify(); return; }
203
+ applyCtxAction(it.act, e.currentTarget);
204
+ }}
205
+ style={st => [s.cb, iconAlone(it) && s.lead, it.on || (it.act === 'imgOpacity' && ui.imgPop === 'opacity') || (it.act === 'imgRotate' && ui.imgPop === 'rotate') || (it.act === 'linkSize' && ui.imgPop === 'tilesize') || (isOpen(it.act) && ui.imgPop === 'open') ? { backgroundColor: 'rgba(255,255,255,0.14)' } : null,
130
206
  it.on ? { backgroundColor: '#fff' } : hovered(st) && { backgroundColor: 'rgba(255,255,255,0.08)' }]}>
131
207
  <Icon name={it.ic} color={fg} />
132
- <Text style={[s.cbText, { color: fg }]}>{it.label}</Text>
208
+ {/* (the bar's lead — Lock, or Open — and Lock wherever it is: its icon alone, its name said, not shown) */}
209
+ {!iconAlone(it) && <Text style={[s.cbText, { color: fg }]}>{it.label}</Text>}
133
210
  </Tap>
134
211
  );
135
212
  })}
@@ -137,6 +214,41 @@ const BarButtons = memo(function BarButtons({ phone }: { k: string; phone: boole
137
214
  );
138
215
  });
139
216
 
217
+ /** The bar's Open: a tile's or expanded photo's, or a linked dimension's. */
218
+ const isOpen = (act: string): boolean => act === 'linkOpen' || act === 'dimLinkOpen';
219
+
220
+ /**
221
+ * Open's question, just above the bar (the user's call, 2026-10-30): Open, the host's own way as before, or Open side
222
+ * by side, in a pane beside the drawing. Asked only where the host can show it there and no pane is open yet
223
+ * (opensWithChoice); a press on the drawing closes it, as it does the bar's sliders.
224
+ */
225
+ function OpenPopover() {
226
+ const t = ui.imgPop === 'open' ? selectedOpenTarget() : null, open = !!t && opensWithChoice(t);
227
+ const pop = usePopup(open, { y: 8 }, 'bottom center');
228
+ if (!pop.present) return null;
229
+ const go = (how: 'here' | 'beside') => {
230
+ const target = selectedOpenTarget();
231
+ ui.imgPop = null;
232
+ notify();
233
+ if (target) openLinkTarget(target, how);
234
+ };
235
+ return (
236
+ <View style={s.popWrap} pointerEvents="box-none">
237
+ <Animated.View style={[s.pop, s.choices, pop.style]} pointerEvents={open ? 'auto' : 'none'} testID={open ? 'openPop' : undefined}>
238
+ <Tap feel="dim" accessibilityRole="button" accessibilityLabel="Open" testID="openHere" onPress={() => go('here')}
239
+ style={st => [s.choice, hovered(st) && s.choiceHover]}>
240
+ <Text style={s.choiceText}>Open</Text>
241
+ </Tap>
242
+ <View style={s.sep} />
243
+ <Tap feel="dim" accessibilityRole="button" accessibilityLabel="Open side by side" testID="openBeside" onPress={() => go('beside')}
244
+ style={st => [s.choice, hovered(st) && s.choiceHover]}>
245
+ <Text style={s.choiceText}>Open side by side</Text>
246
+ </Tap>
247
+ </Animated.View>
248
+ </View>
249
+ );
250
+ }
251
+
140
252
  /** Degrees, as the rotation slider shows them: whole, or to the half degree. */
141
253
  const fmtDeg = (v: number): string => (Math.round(v * 2) / 2).toString().replace('-', '−') + '°';
142
254
 
@@ -198,36 +310,32 @@ function ImagePopover() {
198
310
  );
199
311
  }
200
312
 
201
- /** A tile size from the slider, as it's kept: to the quarter inch. */
202
- const tileSize = (step: number): number => Math.round(stepToSize(step) * 4) / 4;
203
-
204
313
  /**
205
314
  * The tiles' size, just above the bar (Size in it opens and closes it): one size for every tile on the page, on the drawing
206
- * (they zoom with the plan and print at it). Kept quick on a device, as ImagePopover is: the thumb and the number follow
207
- * the finger from here, the tiles are sized at most once a frame, and the rest of the editor hears of it as it's let go.
315
+ * (they zoom with the plan and print at it), said in percent of the size they came in at — never feet or metres (the
316
+ * user's call, 2026-11-03: see editor/linkActions.ts). Kept quick on a device, as ImagePopover is: the thumb and the
317
+ * number follow the finger from here, the tiles are sized at most once a frame, and the rest of the editor hears of it
318
+ * as it's let go.
208
319
  */
209
320
  function LinkSizePopover() {
210
321
  const [live, setLive] = useState<number | null>(null);
211
- // the number's width as it was laid out, held while a drag goes on (the track ends at it: as long as the number is,
212
- // no longer — but never narrower mid-drag, which would move the track under the finger)
213
- const [numW, setNumW] = useState(0);
214
322
  const pending = useRef<{ v: number; frame: number } | null>(null);
215
323
  // (opened from the bar's Size, as an image's sliders are; a press on the drawing closes it)
216
- const size = ui.imgPop === 'tilesize' && linkSizeVisible() ? pageLinkSize() : null, open = size != null;
324
+ const pct = ui.imgPop === 'tilesize' && linkSizeVisible() ? pageTilePct() : null, open = pct != null;
217
325
  // (as it goes it keeps showing the size it had)
218
- const last = useRef(size ?? LINK_SIDE_IN), pop = usePopup(open, { y: 8 }, 'bottom center');
219
- if (size != null) last.current = size;
326
+ const last = useRef(pct ?? 100), pop = usePopup(open, { y: 8 }, 'bottom center');
327
+ if (pct != null) last.current = pct;
220
328
  if (!pop.present) return null;
221
- const value = live ?? sizeToStep(last.current);
329
+ const value = live ?? pctToStep(last.current);
222
330
  const change = (v: number) => {
223
331
  setLive(v);
224
332
  const p = pending.current;
225
333
  if (p) { p.v = v; return; }
226
- pending.current = { v, frame: requestAnimationFrame(() => { const q = pending.current; pending.current = null; if (q) setLinkSize(tileSize(q.v)); }) };
334
+ pending.current = { v, frame: requestAnimationFrame(() => { const q = pending.current; pending.current = null; if (q) setTilePct(stepToPct(q.v)); }) };
227
335
  };
228
336
  const end = () => {
229
337
  const p = pending.current;
230
- if (p) { cancelAnimationFrame(p.frame); pending.current = null; setLinkSize(tileSize(p.v)); }
338
+ if (p) { cancelAnimationFrame(p.frame); pending.current = null; setTilePct(stepToPct(p.v)); }
231
339
  setLive(null);
232
340
  afterEdit();
233
341
  notify();
@@ -237,11 +345,11 @@ function LinkSizePopover() {
237
345
  <Animated.View style={[s.pop, pop.style]} pointerEvents={open ? 'auto' : 'none'} testID={open ? 'tileSizePop' : undefined}>
238
346
  <Text style={s.popLabel}>Tile size</Text>
239
347
  <View style={{ flex: 1 }}>
240
- <Slider label="Tile size" testID="tileSizeSlider" value={value} min={0} max={SIZE_STEPS} step={1} format={v => fmtLen(tileSize(v))}
348
+ <Slider label="Tile size" testID="tileSizeSlider" value={value} min={0} max={SIZE_STEPS} step={1} format={v => stepToPct(v) + '%'}
241
349
  onStart={pushHistory} onChange={change} onEnd={end} />
242
350
  </View>
243
- <Text onLayout={e => { if (live == null) setNumW(e.nativeEvent.layout.width); }}
244
- style={[s.popValue, s.sizeValue, live != null && numW ? { minWidth: numW } : null]}>{fmtLen(live != null ? tileSize(live) : last.current)}</Text>
351
+ {/* (a fixed width, as the image sliders' numbers: the track stays one length whatever the number — "70%" or "1000%" — the user's call, 2026-11-03) */}
352
+ <Text style={s.popValue}>{(live != null ? stepToPct(live) : last.current) + '%'}</Text>
245
353
  </Animated.View>
246
354
  </View>
247
355
  );
@@ -259,6 +367,12 @@ export function SpokenHint() {
259
367
 
260
368
  /** What the trash (or Annotate's +, in its place) takes at the right of a phone's bar row: its 60 pt, and the 12 between them. */
261
369
  const TRASH_ROOM = 72;
370
+ /**
371
+ * The bar's measures, which its room is worked out from as well as drawn with: its height and its buttons', its padding
372
+ * at each end, the yellow + and the gap before it, a divider and the margin at each side of it, and the gap between
373
+ * its buttons.
374
+ */
375
+ const BAR_H = 60, BTN_H = 54, PAD = 6, ADD = 48, ADD_GAP = 4, SEP_W = 1, SEP_M = 4, GAP = 2;
262
376
 
263
377
  const s = createSheet({
264
378
  bottom: { pointerEvents: 'box-none', position: 'absolute', bottom: 14, zIndex: 24, flexDirection: 'row', alignItems: 'center' },
@@ -266,28 +380,39 @@ const s = createSheet({
266
380
  phoneRow: { left: 12, right: 12 },
267
381
  springL: { flexGrow: 1, flexShrink: 1000 },
268
382
  springR: { flexGrow: 1, flexShrink: 0 },
269
- // the same 6 all round, and corners of the buttons' radius plus 6: the end buttons sit concentric in the bar
383
+ // 60 tall (Figma's bars, 2528:101128 — the user's call, 2026-10-27: "60 px in height - not 66"): its 54 buttons 3 in
384
+ // from its top and bottom, 6 from its ends, its ends round
270
385
  // (it gives way when its buttons don't fit — React Native doesn't shrink a view unless told — and then scrolls)
271
- ctx: { flexDirection: 'row', alignItems: 'center', backgroundColor: C.g2, borderRadius: 27 + 6, minHeight: 54 + 12, padding: 6, maxWidth: '100%', flexShrink: 1, ...shadow },
272
- items: { flexDirection: 'row', alignItems: 'center', gap: 2 },
273
- cb: { minWidth: 58, height: 54, borderRadius: 27, alignItems: 'center', justifyContent: 'center', gap: 3, paddingHorizontal: 8 },
386
+ ctx: { flexDirection: 'row', alignItems: 'center', backgroundColor: C.g2, borderRadius: BAR_H / 2, minHeight: BAR_H, paddingVertical: (BAR_H - BTN_H) / 2, paddingHorizontal: PAD, maxWidth: '100%', flexShrink: 1, ...shadow },
387
+ clip: { flexGrow: 0, flexShrink: 1, borderRadius: BTN_H / 2, overflow: 'hidden' },
388
+ scroller: { flexGrow: 0, flexShrink: 1 },
389
+ items: { flexDirection: 'row', alignItems: 'center', gap: GAP },
390
+ // a scrolling bar's fade, over an end of its scroller
391
+ fade: { position: 'absolute', top: 0, bottom: 0, width: FADE, pointerEvents: 'none' },
392
+ cb: { minWidth: 58, height: BTN_H, borderRadius: BTN_H / 2, alignItems: 'center', justifyContent: 'center', gap: 3, paddingHorizontal: 8 },
393
+ // the lead: a 48 round button, its icon alone
394
+ lead: { minWidth: 48, width: 48, height: 48, borderRadius: 24, paddingHorizontal: 0 },
274
395
  cbText: { fontFamily: FONT, fontSize: 10.5, fontWeight: '500' },
275
- sep: { width: 1, height: 32, backgroundColor: C.g3, marginHorizontal: 4 },
396
+ sep: { width: SEP_W, height: 32, backgroundColor: C.g3, marginHorizontal: SEP_M },
276
397
  lbl: { fontFamily: FONT, fontSize: 12, color: C.lg1, paddingHorizontal: 10 },
277
398
  // the slider sits just above the bar, centred on it
278
- popWrap: { position: 'absolute', left: 0, right: 0, bottom: 76, alignItems: 'center' },
399
+ popWrap: { position: 'absolute', left: 0, right: 0, bottom: 70, alignItems: 'center' },
279
400
  pop: { width: 400, maxWidth: '100%', flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: C.g2, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 4, ...shadow },
280
401
  popLabel: { fontFamily: FONT, fontSize: 12.5, fontWeight: '600', color: C.lg2 },
281
402
  popValue: { fontFamily: FONT, fontSize: 12.5, fontWeight: '600', color: '#fff', width: 48, textAlign: 'right' },
282
- // (a length: as long as it is, the track running up to it)
283
- sizeValue: { width: 'auto', minWidth: 0 },
284
403
  // back to upright: a pill at the end of the rotation slider, concentric with it — the same 4 above, below and beside
285
404
  // it, and the slider's radius (22, half its 44) less that 4 for its own
286
405
  popReset: { paddingRight: 4 },
287
406
  reset: { height: 36, paddingHorizontal: 16, borderRadius: 18, backgroundColor: C.g3, justifyContent: 'center' },
288
407
  resetText: { fontFamily: FONT, fontSize: 12.5, fontWeight: '600', color: '#fff' },
289
- // (smaller than a button, so 3 in from the bar's end as well as from its top and bottom: concentric too)
290
- add: { width: 48, height: 48, borderRadius: 24, backgroundColor: C.yellow, alignItems: 'center', justifyContent: 'center', marginLeft: 4, marginRight: 3 },
408
+ // Open's question: its two answers as the bar's own buttons are (white on the bar's grey, a divider between),
409
+ // concentric with it (4 in all round)
410
+ choices: { width: 'auto', paddingHorizontal: 4, gap: 2 },
411
+ choice: { height: 40, paddingHorizontal: 16, borderRadius: 20, justifyContent: 'center' },
412
+ choiceHover: { backgroundColor: 'rgba(255,255,255,0.08)' },
413
+ choiceText: { fontFamily: FONT, fontSize: 14, fontWeight: '600', color: '#fff' },
414
+ // (6 in from the bar's end, top and bottom: concentric with it)
415
+ add: { width: ADD, height: ADD, borderRadius: ADD / 2, backgroundColor: C.yellow, alignItems: 'center', justifyContent: 'center', marginLeft: ADD_GAP },
291
416
  // BOLDR's delete FAB (the annotation editors' DeleteFab): a 60 pt solid red circle 12 from the right and 14 from the
292
417
  // bottom, level with the bar beside it, on every screen size
293
418
  trash: {
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import { useColorScheme, useWindowDimensions, View } from 'react-native';
3
3
  import { analyze } from '../app/edit';
4
4
  import { ed } from '../app/state';
@@ -19,7 +19,6 @@ import { confirmCancel, confirmOpen } from '../editor/confirm';
19
19
  import { Drawer } from './Drawer';
20
20
  import { Duck } from './Duck';
21
21
  import { elevOpen } from '../editor/elevation';
22
- import { LaserTray } from './LaserTray';
23
22
  import { MarkupPanel } from './MarkupPanel';
24
23
  import { Menu, NoteInput } from './Popovers';
25
24
  import { KeypadSheet } from './KeypadSheet';
@@ -29,7 +28,7 @@ import { Empty3D } from './Empty3D';
29
28
  import { is3d } from '../editor/view3dState';
30
29
  import { reactPorts, setFileSaver } from './ports';
31
30
  import { editorHost } from './host';
32
- import { checkChanges, startManualSave, stopManualSave, type CadSaveState } from './saving';
31
+ import { announceSaveState, checkChanges, startManualSave, stopManualSave, type CadSaveState } from './saving';
33
32
  import { PAGE_LOCK_ROOT, usePageLock } from './pageLock';
34
33
  import { setSkiaFonts } from '../skia/fonts';
35
34
  import type { SkiaFonts } from '../skia/SkiaDrawCtx';
@@ -47,6 +46,7 @@ import { setLinksHost, type CadLinks } from '../editor/links';
47
46
  import { ShapeMenu } from './ShapeMenu';
48
47
  import { PHONE_MAX } from './theme';
49
48
  import { createSheet } from './corners';
49
+ import { EditorBoxContext, type EditorBox } from './editorBox';
50
50
 
51
51
  export interface CadEditorProps {
52
52
  /** Back to the host (otherwise the back button opens the project's pages) */
@@ -154,6 +154,8 @@ export function CadEditor({ onBack, headerRight, pickImage = pickImageInBrowser,
154
154
  clearTimeout(closing.current);
155
155
  setPorts(reactPorts);
156
156
  editorHost.open = true;
157
+ // saving manually: the state it opens in, now it's mounted (not as it rendered: see startManualSave)
158
+ announceSaveState();
157
159
  return () => {
158
160
  // saving as they draw: the last edits and the preview go now. Saving manually: unsaved edits are dropped.
159
161
  if (!manualSave) {
@@ -172,17 +174,21 @@ export function CadEditor({ onBack, headerRight, pickImage = pickImageInBrowser,
172
174
  // the canvases read their colors when they draw: set them before this render's children draw
173
175
  setCanvasTheme(theme);
174
176
  useEffect(() => { requestDraw(); }, [theme]);
175
- const { width } = useWindowDimensions(), phone = width < PHONE_MAX, wide = width >= 900;
177
+ // the chrome is laid out in the editor's own area, not the window: the host can put it in a pane (editorBox.ts)
178
+ const win = useWindowDimensions(), [laid, setLaid] = useState<EditorBox | null>(null);
179
+ const box = useMemo(() => laid ?? { width: win.width, height: win.height }, [laid, win.width, win.height]);
180
+ const phone = box.width < PHONE_MAX, wide = box.width >= 900;
176
181
  // (asked about once the drawing's loaded: what it links to)
177
182
  useEffect(() => {
178
183
  setLinksHost(links ?? null);
179
184
  return () => setLinksHost(null);
180
185
  }, [links]);
181
- // (a phone asks with the host's keypad, when it has one)
186
+ // (a phone asks with the host's keypad, when it has one: the device is a phone, whatever the editor's area)
187
+ const phoneDevice = win.width < PHONE_MAX;
182
188
  useEffect(() => {
183
- setHostKeypad(phone && keypad ? keypad : null);
189
+ setHostKeypad(phoneDevice && keypad ? keypad : null);
184
190
  return () => setHostKeypad(null);
185
- }, [phone, keypad]);
191
+ }, [phoneDevice, keypad]);
186
192
  // (a modeless keypad leaves the drawing live: no backdrop. On a tablet or a computer the drawing stays in reach
187
193
  // around a sheet too — it pans and zooms, and a tap on it (or the chrome over it) closes the sheet: see
188
194
  // editor/gestures.ts sheetUp. A phone's sheet covers most of it: there a backdrop closes it.)
@@ -198,7 +204,11 @@ export function CadEditor({ onBack, headerRight, pickImage = pickImageInBrowser,
198
204
  return () => clearTimeout(t);
199
205
  }, [threeD, kept3d]);
200
206
  return (
201
- <View nativeID={PAGE_LOCK_ROOT} style={[s.root, { backgroundColor: CANVAS_THEMES[theme].bg }]}>
207
+ <EditorBoxContext.Provider value={box}>
208
+ <View nativeID={PAGE_LOCK_ROOT} style={[s.root, { backgroundColor: CANVAS_THEMES[theme].bg }]} onLayout={e => {
209
+ const { width, height } = e.nativeEvent.layout;
210
+ setLaid(b => (b && Math.abs(b.width - width) < 0.5 && Math.abs(b.height - height) < 0.5 ? b : { width, height }));
211
+ }}>
202
212
  <PlanCanvas />
203
213
  {/* the 3D tab: over the plan, which stays as it was underneath */}
204
214
  {(threeD || kept3d) && <View3D theme={theme} active={threeD} />}
@@ -212,7 +222,6 @@ export function CadEditor({ onBack, headerRight, pickImage = pickImageInBrowser,
212
222
  {/* (none in 3D: it's a view, its camera moved with the fingers) */}
213
223
  {!readOnly && !threeD && <Rail phone={phone} onAddImage={() => { pickImage().then(img => { if (img) placeUnderlay(img.dataUrl, img.w, img.h); }); }} />}
214
224
  {!readOnly && <BottomBar phone={phone} />}
215
- {!threeD && <LaserTray phone={phone} />}
216
225
  {!threeD && <MarkupPanel phone={phone} />}
217
226
  </Duck>
218
227
  {!readOnly && !elevOpen() && !threeD && <ShapeMenu />}
@@ -226,6 +235,7 @@ export function CadEditor({ onBack, headerRight, pickImage = pickImageInBrowser,
226
235
  <ConflictSheet wide={wide} open={!!ui.conflict && !ui.keypad} />
227
236
  <ConfirmSheet wide={wide} />
228
237
  </View>
238
+ </EditorBoxContext.Provider>
229
239
  );
230
240
  }
231
241
 
@@ -1,12 +1,13 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
- Animated, Easing, Keyboard, PanResponder, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useWindowDimensions, View,
3
+ Animated, Easing, Keyboard, PanResponder, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View,
4
4
  } from 'react-native';
5
5
  import {
6
6
  addPage, deletePage, deletePending, duplicatePage, focusSpace, renamePage, renameSpace, spaceSchedule, switchPage,
7
7
  } from '../editor/project';
8
8
  import { ed } from '../app/state';
9
9
  import type { DrawerTab } from '../editor/ports';
10
+ import { noScaleYet } from '../editor/scaleGate';
10
11
  import { page, store } from '../model/store';
11
12
  import { fmtArea, fmtLen } from '../units';
12
13
  import { Icon, RawSvg } from './Icon';
@@ -17,6 +18,7 @@ import { C, FONT } from './theme';
17
18
  import { EASE, Segmented, Tap } from './motion';
18
19
  import { NATIVE } from './easing';
19
20
  import { createSheet } from './corners';
21
+ import { useEditorBox } from './editorBox';
20
22
 
21
23
  /**
22
24
  * The Pages frame's sizes, scaled to the panel: Figma draws it 515 wide, the panel's content is about 370 (K). Its
@@ -43,7 +45,7 @@ const CLOSE_DRAG = 80, CLOSE_FLICK = 0.5;
43
45
  * down or a tap outside). Either way it slides back out when closed.
44
46
  */
45
47
  export function Drawer({ phone = false }: { phone?: boolean }) {
46
- const version = useEditorVersion(), { width, height } = useWindowDimensions(), w = Math.min(400, width);
48
+ const version = useEditorVersion(), { width, height } = useEditorBox(), w = Math.min(400, width);
47
49
  const open = !!ui.drawer;
48
50
  // what it showed last stays while it slides away
49
51
  const last = useRef<DrawerTab>('pages');
@@ -60,7 +62,7 @@ export function Drawer({ phone = false }: { phone?: boolean }) {
60
62
  const slide = useMemo(() => x.interpolate({ inputRange: [0, 1], outputRange: [0, (phone ? travel ?? height : w) + 40] }), [x, w, height, phone, travel]);
61
63
  const fade = useMemo(() => x.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }), [x]);
62
64
  // the phone's sheet: how far it's dragged down, when it opened, and how far the keyboard lifts it
63
- const drag = useRef(new Animated.Value(0)).current, openedAt = useRef(0), sheet = useRef<View>(null), [lift, setLift] = useState(0);
65
+ const drag = useRef(new Animated.Value(0)).current, openedAt = useRef(0), sheet = useRef<ComponentRef<typeof View>>(null), [lift, setLift] = useState(0);
64
66
  const dropped = useMemo(() => Animated.add(slide, drag), [slide, drag]);
65
67
  useEffect(() => {
66
68
  if (open) { setPresent(true); openedAt.current = Date.now(); }
@@ -151,7 +153,7 @@ export function Drawer({ phone = false }: { phone?: boolean }) {
151
153
  function NameField({ value, onCommit, style, label, autoFocus, onFocus }: {
152
154
  value: string; onCommit: (v: string) => void; style?: object; label: string; autoFocus?: boolean; onFocus?: () => void;
153
155
  }) {
154
- const [text, setText] = useState(value), editing = useRef(false), ref = useRef<TextInput>(null);
156
+ const [text, setText] = useState(value), editing = useRef(false), ref = useRef<ComponentRef<typeof TextInput>>(null);
155
157
  useEffect(() => { if (!editing.current) setText(value); }, [value]);
156
158
  useEffect(() => { if (autoFocus) setTimeout(() => ref.current?.focus(), 320); }, [autoFocus]);
157
159
  return (
@@ -215,26 +217,29 @@ function Pages({ version, thumbs }: { version: number; thumbs: boolean }) {
215
217
  }
216
218
 
217
219
  function Spaces() {
218
- const sched = spaceSchedule();
220
+ const sched = spaceSchedule(), sketchOnly = noScaleYet();
219
221
  if (!sched.rows.length) return <Text style={s.empty}>No spaces on this page yet. Close a loop of walls or lines, or pick the Space tool and tap inside a closed shape.</Text>;
220
222
  return (
221
223
  <>
222
224
  {sched.rows.map(r => (
223
225
  <View key={r.id} style={s.roomRow}>
224
226
  <NameField label="Space name" value={r.name} onCommit={v => renameSpace(r.id, v)} autoFocus={ui.renameRoom === r.id} onFocus={() => focusSpace(r.id)} style={{ fontSize: 14 }} />
225
- <View style={s.nums}>
226
- <Text style={s.num}><Text style={s.numB}>{fmtArea(r.area)}</Text> floor</Text>
227
- <Text style={s.num}><Text style={s.numB}>{fmtLen(r.per)}</Text> perimeter</Text>
228
- </View>
227
+ {!sketchOnly && (
228
+ <View style={s.nums}>
229
+ <Text style={s.num}><Text style={s.numB}>{fmtArea(r.area)}</Text> floor</Text>
230
+ <Text style={s.num}><Text style={s.numB}>{fmtLen(r.per)}</Text> perimeter</Text>
231
+ </View>
232
+ )}
229
233
  </View>
230
234
  ))}
231
- <View style={s.row}>
235
+ {/* (a sketch's sizes are placeholders until its first length: no areas until then) */}
236
+ {sketchOnly ? <Text style={s.empty}>Areas show once the drawing has a length. Select a line and tap + to enter one.</Text> : <View style={s.row}>
232
237
  <View style={{ flex: 1 }}>
233
238
  <Text style={[s.rowText, { fontWeight: '700' }]}>Total</Text>
234
239
  <Text style={s.rowSub}>Measured to inside wall faces</Text>
235
240
  </View>
236
241
  <Text style={[s.rowText, { fontWeight: '700' }]}>{fmtArea(sched.total)}</Text>
237
- </View>
242
+ </View>}
238
243
  </>
239
244
  );
240
245
  }
@@ -1,5 +1,5 @@
1
- import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react';
2
- import { Animated, Platform, StyleSheet, type View } from 'react-native';
1
+ import { useEffect, useRef, useSyncExternalStore, type ComponentRef, type ReactNode } from 'react';
2
+ import { Animated, Platform, StyleSheet, View } from 'react-native';
3
3
 
4
4
  /*
5
5
  On a phone the floating chrome — the top bar, the tabs, the rail, the bottom bar and trash, the trays — steps aside
@@ -31,7 +31,7 @@ const OUT_MS = 120, IN_MS = 200;
31
31
  */
32
32
  export function Duck({ active, held = false, children }: { active: boolean; held?: boolean; children: ReactNode }) {
33
33
  const away = useSyncExternalStore(subscribe, isMoving) && active, off = away || held;
34
- const opacity = useRef(new Animated.Value(1)).current, ref = useRef<View>(null);
34
+ const opacity = useRef(new Animated.Value(1)).current, ref = useRef<ComponentRef<typeof View>>(null);
35
35
  useEffect(() => {
36
36
  Animated.timing(opacity, { toValue: away ? 0 : 1, duration: away ? OUT_MS : IN_MS, useNativeDriver: Platform.OS !== 'web' }).start();
37
37
  }, [away, opacity]);
@@ -1,4 +1,4 @@
1
- import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState, type ReactNode } from 'react';
1
+ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState, type ComponentRef, type ReactNode } from 'react';
2
2
  import { FlatList, ScrollView, Text, View } from 'react-native';
3
3
  import {
4
4
  hostPicksMeasurements, measurementFits, offersJobMeasurements, pickJobMeasurement, watchJobMeasurements, type MeasurementKind,
@@ -164,7 +164,7 @@ function Keys({ spec, wide, selectFromList }: { spec: NonNullable<ReturnType<typ
164
164
  * It's the host's list when it draws one (measurementList), and the editor's grid when it doesn't.
165
165
  */
166
166
  const Pager = forwardRef<PagerHandle, { kind: MeasurementKind; pick: (t: LinkTarget) => void; measurementList?: MeasurementList; children: ReactNode }>(function Pager({ kind, pick, measurementList, children }, ref) {
167
- const scroll = useRef<ScrollView>(null);
167
+ const scroll = useRef<ComponentRef<typeof ScrollView>>(null);
168
168
  const [w, setW] = useState(0), [h, setH] = useState(0), [page, setPage] = useState(0), [listed, setListed] = useState(false);
169
169
  const show = (p: number) => { if (p) setListed(true); scroll.current?.scrollTo({ x: p * w, animated: true }); };
170
170
  useImperativeHandle(ref, () => ({ show }));
@@ -124,7 +124,7 @@ export function MarkupPanel({ phone }: { phone: boolean }) {
124
124
  }
125
125
 
126
126
  const s = createSheet({
127
- wrap: { position: 'absolute', left: 76, top: 112, bottom: 84, zIndex: 46, alignItems: 'flex-start' },
127
+ wrap: { position: 'absolute', left: 76, top: 112, bottom: 78, zIndex: 46, alignItems: 'flex-start' },
128
128
  panel: { width: 264, maxHeight: '100%', backgroundColor: C.g1, borderRadius: 20, borderWidth: 1, borderColor: C.g2, ...shadow },
129
129
  body: { padding: 12, gap: 8 },
130
130
  section: { gap: 8 },