@reekon-tools/boldr-cad 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +21 -4
  2. package/dist/app/edit.d.ts +3 -3
  3. package/dist/app/edit.js +26 -1
  4. package/dist/app/edit.js.map +1 -1
  5. package/dist/app/state.d.ts +3 -5
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +149 -169
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +2 -2
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/linkActions.d.ts +22 -3
  23. package/dist/editor/linkActions.js +95 -13
  24. package/dist/editor/linkActions.js.map +1 -1
  25. package/dist/editor/mode.js +4 -3
  26. package/dist/editor/mode.js.map +1 -1
  27. package/dist/editor/project.js +1 -2
  28. package/dist/editor/project.js.map +1 -1
  29. package/dist/editor/scaleGate.d.ts +13 -0
  30. package/dist/editor/scaleGate.js +27 -0
  31. package/dist/editor/scaleGate.js.map +1 -0
  32. package/dist/editor/selection.js +13 -9
  33. package/dist/editor/selection.js.map +1 -1
  34. package/dist/editor/tooltips.js +9 -0
  35. package/dist/editor/tooltips.js.map +1 -1
  36. package/dist/editor/view3d.d.ts +4 -1
  37. package/dist/editor/view3d.js +6 -2
  38. package/dist/editor/view3d.js.map +1 -1
  39. package/dist/export/pdf.d.ts +5 -2
  40. package/dist/export/pdf.js +12 -8
  41. package/dist/export/pdf.js.map +1 -1
  42. package/dist/export/plan.d.ts +1 -1
  43. package/dist/export/plan.js +14 -9
  44. package/dist/export/plan.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/model/demo.d.ts +1 -0
  49. package/dist/model/demo.js +2 -1
  50. package/dist/model/demo.js.map +1 -1
  51. package/dist/model/elevation.d.ts +1 -1
  52. package/dist/model/elevation.js +3 -3
  53. package/dist/model/elevation.js.map +1 -1
  54. package/dist/model/history.js +4 -1
  55. package/dist/model/history.js.map +1 -1
  56. package/dist/model/links.d.ts +4 -1
  57. package/dist/model/links.js +9 -2
  58. package/dist/model/links.js.map +1 -1
  59. package/dist/model/measurement.d.ts +2 -2
  60. package/dist/model/measurement.js +3 -3
  61. package/dist/model/measurement.js.map +1 -1
  62. package/dist/model/openings.d.ts +10 -0
  63. package/dist/model/openings.js +18 -0
  64. package/dist/model/openings.js.map +1 -1
  65. package/dist/model/planGeom.d.ts +37 -2
  66. package/dist/model/planGeom.js +72 -9
  67. package/dist/model/planGeom.js.map +1 -1
  68. package/dist/model/project.js +5 -2
  69. package/dist/model/project.js.map +1 -1
  70. package/dist/model/rooms.d.ts +18 -5
  71. package/dist/model/rooms.js +178 -73
  72. package/dist/model/rooms.js.map +1 -1
  73. package/dist/model/scale.d.ts +30 -0
  74. package/dist/model/scale.js +120 -0
  75. package/dist/model/scale.js.map +1 -0
  76. package/dist/model/settings.d.ts +0 -1
  77. package/dist/model/settings.js +1 -1
  78. package/dist/model/settings.js.map +1 -1
  79. package/dist/model/sketch.d.ts +21 -1
  80. package/dist/model/sketch.js +58 -4
  81. package/dist/model/sketch.js.map +1 -1
  82. package/dist/model/stroke.js +2 -2
  83. package/dist/model/types.d.ts +38 -7
  84. package/dist/react/BottomBar.d.ts +2 -1
  85. package/dist/react/BottomBar.js +94 -25
  86. package/dist/react/BottomBar.js.map +1 -1
  87. package/dist/react/Drawer.js +3 -2
  88. package/dist/react/Drawer.js.map +1 -1
  89. package/dist/react/Duck.js.map +1 -1
  90. package/dist/react/KeypadSheet.js.map +1 -1
  91. package/dist/react/MarkupPanel.js +1 -1
  92. package/dist/react/PlanCanvas.native.js +54 -7
  93. package/dist/react/PlanCanvas.native.js.map +1 -1
  94. package/dist/react/Popovers.js +14 -3
  95. package/dist/react/Popovers.js.map +1 -1
  96. package/dist/react/Rail.js +1 -1
  97. package/dist/react/Sheet.d.ts +2 -2
  98. package/dist/react/Sheet.js.map +1 -1
  99. package/dist/react/barScroll.d.ts +23 -0
  100. package/dist/react/barScroll.js +47 -0
  101. package/dist/react/barScroll.js.map +1 -0
  102. package/dist/react/corners.d.ts +6 -2
  103. package/dist/react/corners.js.map +1 -1
  104. package/dist/react/host.d.ts +7 -0
  105. package/dist/react/host.js +14 -1
  106. package/dist/react/host.js.map +1 -1
  107. package/dist/react/motion.d.ts +1 -1
  108. package/dist/react/motion.js +1 -1
  109. package/dist/react/motion.js.map +1 -1
  110. package/dist/render/draw.d.ts +21 -4
  111. package/dist/render/draw.js +184 -149
  112. package/dist/render/draw.js.map +1 -1
  113. package/dist/render/elevation.js +3 -3
  114. package/dist/render/elevation.js.map +1 -1
  115. package/dist/render/figmaIcons.d.ts +6 -0
  116. package/dist/render/figmaIcons.js +16 -1
  117. package/dist/render/figmaIcons.js.map +1 -1
  118. package/dist/render/hit.d.ts +8 -1
  119. package/dist/render/hit.js +23 -4
  120. package/dist/render/hit.js.map +1 -1
  121. package/dist/render/icons.js +27 -20
  122. package/dist/render/icons.js.map +1 -1
  123. package/dist/render/lens.d.ts +27 -0
  124. package/dist/render/lens.js +41 -0
  125. package/dist/render/lens.js.map +1 -0
  126. package/dist/render/linkTile.d.ts +20 -2
  127. package/dist/render/linkTile.js +476 -246
  128. package/dist/render/linkTile.js.map +1 -1
  129. package/dist/render/snap.d.ts +5 -1
  130. package/dist/render/snap.js +3 -1
  131. package/dist/render/snap.js.map +1 -1
  132. package/dist/render/svgPath.d.ts +29 -0
  133. package/dist/render/svgPath.js +114 -0
  134. package/dist/render/svgPath.js.map +1 -0
  135. package/dist/render/tileIcons.d.ts +18 -0
  136. package/dist/render/tileIcons.js +67 -0
  137. package/dist/render/tileIcons.js.map +1 -0
  138. package/dist/render/view.d.ts +18 -1
  139. package/dist/render/view.js +31 -4
  140. package/dist/render/view.js.map +1 -1
  141. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  142. package/dist/skia/SkiaDrawCtx.js +27 -5
  143. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  144. package/dist/solver/equations.d.ts +6 -0
  145. package/dist/solver/equations.js +23 -6
  146. package/dist/solver/equations.js.map +1 -1
  147. package/dist/solver/gauss-newton.js +6 -1
  148. package/dist/solver/gauss-newton.js.map +1 -1
  149. package/dist/tools/annotate.d.ts +0 -2
  150. package/dist/tools/annotate.js +1 -22
  151. package/dist/tools/annotate.js.map +1 -1
  152. package/dist/tools/chamfer.js +4 -0
  153. package/dist/tools/chamfer.js.map +1 -1
  154. package/dist/tools/dim.d.ts +3 -3
  155. package/dist/tools/dim.js +26 -65
  156. package/dist/tools/dim.js.map +1 -1
  157. package/dist/tools/fillet.js +4 -0
  158. package/dist/tools/fillet.js.map +1 -1
  159. package/dist/tools/freehand.js +1 -4
  160. package/dist/tools/freehand.js.map +1 -1
  161. package/dist/tools/line.js +15 -11
  162. package/dist/tools/line.js.map +1 -1
  163. package/dist/tools/modify.js +6 -1
  164. package/dist/tools/modify.js.map +1 -1
  165. package/dist/tools/registry.d.ts +5 -0
  166. package/dist/tools/registry.js +6 -1
  167. package/dist/tools/registry.js.map +1 -1
  168. package/dist/tools/select.d.ts +19 -0
  169. package/dist/tools/select.js +116 -17
  170. package/dist/tools/select.js.map +1 -1
  171. package/dist/tools/shapes.d.ts +1 -1
  172. package/dist/tools/shapes.js +12 -5
  173. package/dist/tools/shapes.js.map +1 -1
  174. package/dist/tools/survey.d.ts +6 -0
  175. package/dist/tools/survey.js +28 -0
  176. package/dist/tools/survey.js.map +1 -1
  177. package/dist/view3d/build.js +9 -8
  178. package/dist/view3d/build.js.map +1 -1
  179. package/package.json +9 -9
  180. package/src/app/edit.ts +25 -1
  181. package/src/app/state.ts +3 -5
  182. package/src/data/sync.ts +7 -2
  183. package/src/editor/actions.ts +126 -112
  184. package/src/editor/elevation.ts +13 -16
  185. package/src/editor/gestures.ts +83 -13
  186. package/src/editor/keys.ts +2 -2
  187. package/src/editor/linkActions.ts +90 -13
  188. package/src/editor/mode.ts +4 -3
  189. package/src/editor/project.ts +1 -2
  190. package/src/editor/scaleGate.ts +32 -0
  191. package/src/editor/selection.ts +13 -7
  192. package/src/editor/tooltips.ts +8 -0
  193. package/src/editor/view3d.ts +6 -2
  194. package/src/export/pdf.ts +7 -4
  195. package/src/export/plan.ts +14 -10
  196. package/src/index.ts +1 -1
  197. package/src/model/demo.ts +2 -1
  198. package/src/model/elevation.ts +4 -4
  199. package/src/model/history.ts +4 -1
  200. package/src/model/links.ts +8 -2
  201. package/src/model/measurement.ts +3 -3
  202. package/src/model/openings.ts +18 -0
  203. package/src/model/planGeom.ts +70 -10
  204. package/src/model/project.ts +6 -2
  205. package/src/model/rooms.ts +151 -63
  206. package/src/model/scale.ts +105 -0
  207. package/src/model/settings.ts +1 -2
  208. package/src/model/sketch.ts +48 -4
  209. package/src/model/stroke.ts +2 -2
  210. package/src/model/types.ts +38 -7
  211. package/src/react/BottomBar.tsx +114 -29
  212. package/src/react/Drawer.tsx +14 -10
  213. package/src/react/Duck.tsx +3 -3
  214. package/src/react/KeypadSheet.tsx +2 -2
  215. package/src/react/MarkupPanel.tsx +1 -1
  216. package/src/react/PlanCanvas.native.tsx +60 -6
  217. package/src/react/Popovers.tsx +24 -9
  218. package/src/react/Rail.tsx +1 -1
  219. package/src/react/Sheet.tsx +3 -3
  220. package/src/react/barScroll.ts +57 -0
  221. package/src/react/corners.ts +6 -3
  222. package/src/react/host.ts +14 -1
  223. package/src/react/motion.tsx +6 -6
  224. package/src/render/draw.ts +156 -116
  225. package/src/render/elevation.ts +5 -4
  226. package/src/render/figmaIcons.ts +17 -1
  227. package/src/render/hit.ts +21 -4
  228. package/src/render/icons.ts +27 -20
  229. package/src/render/lens.ts +52 -0
  230. package/src/render/linkTile.ts +436 -190
  231. package/src/render/snap.ts +7 -2
  232. package/src/render/svgPath.ts +95 -0
  233. package/src/render/tileIcons.ts +75 -0
  234. package/src/render/view.ts +37 -5
  235. package/src/skia/SkiaDrawCtx.ts +26 -4
  236. package/src/solver/equations.ts +22 -6
  237. package/src/solver/gauss-newton.ts +4 -1
  238. package/src/styles.css +3 -2
  239. package/src/tools/annotate.ts +1 -18
  240. package/src/tools/chamfer.ts +3 -0
  241. package/src/tools/dim.ts +26 -52
  242. package/src/tools/fillet.ts +3 -0
  243. package/src/tools/freehand.ts +1 -3
  244. package/src/tools/line.ts +12 -11
  245. package/src/tools/modify.ts +5 -1
  246. package/src/tools/registry.ts +10 -1
  247. package/src/tools/select.ts +99 -18
  248. package/src/tools/shapes.ts +9 -4
  249. package/src/tools/survey.ts +21 -0
  250. package/src/ui/ctx.ts +4 -3
  251. package/src/ui/drawer.ts +8 -2
  252. package/src/view3d/build.ts +11 -10
@@ -1,7 +1,7 @@
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';
4
+ import { applyCtxAction, barItems, deleteSelection, iconAlone, plusAction, plusLabel, plusVisible } from '../editor/actions';
5
5
  import { addLinks, LINK_PLUS_LABEL, linkPlusVisible, linkSizeVisible, pageLinkSize, setLinkSize, SIZE_STEPS, sizeToStep, stepToSize } from '../editor/linkActions';
6
6
  import { afterEdit } from '../app/edit';
7
7
  import { LINK_SIDE_IN } from '../model/links';
@@ -15,35 +15,45 @@ import { Slider } from './Slider';
15
15
  import { notify, ui, useEditorKey } from './store';
16
16
  import { currentHint } from '../editor/status';
17
17
  import { hovered } from './Buttons';
18
- import { Icon } from './Icon';
18
+ import { Icon, RawSvg } from './Icon';
19
19
  import { RichText } from './RichText';
20
20
  import { C, FONT, shadow } from './theme';
21
- import { Tap, usePopup } from './motion';
21
+ import { reducedMotion, Tap, usePopup } from './motion';
22
22
  import { createSheet } from './corners';
23
+ import { NATIVE } from './easing';
24
+ import { barCut, barSpans, FADE, FADE_RAMP, fadeSvg, NUDGE, NUDGE_AFTER, NUDGE_HOLD } from './barScroll';
23
25
 
24
26
  /**
25
27
  * The contextual bar: what fits the tool and selection, the yellow + to measure, and the trash on its own. It's as
26
28
  * 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
29
  * just clear of it (and scrolls only if even that leaves too little room). In Annotate on a phone (or a window too
28
30
  * 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.
31
+ * selected: selecting anything puts the trash there instead, and letting go brings the + back. A bar that scrolls
32
+ * says so (react/barScroll.ts).
30
33
  */
31
34
  export function BottomBar({ phone }: { phone: boolean }) {
32
- // what's in the bar, and the selected image's opacity and turn (its sliders)
35
+ // what's in the bar and what it's for, and the selected image's opacity and turn (its sliders)
33
36
  useEditorKey(() => {
34
37
  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('|');
38
+ 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
39
  });
37
40
  // (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
41
  // deletes nothing: what's tapped there shows its sizes, and what can be done with it from there)
39
42
  const items = shown(phone), plus = !is3d() && plusVisible(), trash = !is3d() && ed.sel.length > 0;
40
43
  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;
44
+ // It scrolls only when its buttons can't all fit (a narrow phone): otherwise a drag on the bar would move it. Its
45
+ // room comes from its row's width, not the scroller's, which barCut makes shorter than that; where its buttons are
46
+ // comes from their widths, which a browser reports as they change (not as they move along).
47
+ const [row, setRow] = useState(0), [content, setContent] = useState(0), [widths, setWidths] = useState<readonly (number | undefined)[]>([]);
48
+ const room = row - (phone && corner ? TRASH_ROOM : 0) - 2 * PAD - (plus ? ADD + ADD_GAP : 0), over = row > 0 && content > room + 1;
49
+ 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;
50
+ const cut = !over ? undefined : spans ? barCut(spans, room) : room;
51
+ const onWidth = useCallback((i: number, w: number) => setWidths(p => (p[i] === w ? p : Object.assign(p.slice(), { [i]: w }))), []);
52
+ const scroll = useBarScroll(over, cut == null ? 0 : content - cut, barFor());
43
53
  return (
44
54
  <>
45
55
  {(items.length > 0 || plus) && (
46
- <View style={[s.bottom, phone ? s.phoneRow : s.centered]}>
56
+ <View style={[s.bottom, phone ? s.phoneRow : s.centered]} onLayout={e => setRow(e.nativeEvent.layout.width)}>
47
57
  <ImagePopover />
48
58
  <LinkSizePopover />
49
59
  {/* a phone's bar sits between two springs that start as wide as the trash takes and share what's left
@@ -52,11 +62,17 @@ export function BottomBar({ phone }: { phone: boolean }) {
52
62
  shares the room out differently and left a short bar off-centre.) The springs let touches through. */}
53
63
  {phone && <View pointerEvents="none" style={[s.springL, { flexBasis: corner ? TRASH_ROOM : 0 }]} />}
54
64
  <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>
65
+ {/* the buttons and their fades, clipped to the round ends the buttons have: scrolled, they'd stick out past
66
+ the bar's own. (A native ScrollView grows to fill its row by default, which stretched the bar across a
67
+ tablet.) */}
68
+ <View style={s.clip}>
69
+ <Animated.ScrollView ref={scroll.ref} testID="ctxScroll" horizontal showsHorizontalScrollIndicator={false} scrollEnabled={over} bounces={over}
70
+ contentContainerStyle={s.items} style={[s.scroller, cut != null && { width: cut }]} onContentSizeChange={w => setContent(w)}
71
+ onScroll={scroll.onScroll} scrollEventThrottle={16} onScrollBeginDrag={scroll.stop}>
72
+ <BarButtons k={buttonsKey()} phone={phone} onWidth={onWidth} />
73
+ </Animated.ScrollView>
74
+ {cut != null && <Fades x={scroll.x} end={cut} max={content - cut} />}
75
+ </View>
60
76
  {plus && (
61
77
  <Tap accessibilityRole="button" accessibilityLabel={plusLabel()} testID="addbtn" onPress={plusAction} style={s.add}>
62
78
  <Icon name="plus" color={C.dark} />
@@ -96,6 +112,61 @@ function buttonsKey(): string {
96
112
  return JSON.stringify(its) + '|' + ui.imgPop;
97
113
  }
98
114
 
115
+ /** What the bar is for: the selection, or what's tapped in 3D. A new one's bar starts at its start. */
116
+ const barFor = (): string => (is3d() ? JSON.stringify(bar3d()) : ed.sel.map(x => x.type + ':' + x.id).join(','));
117
+
118
+ /** The first bar that scrolls in a session (until the app is next started) is nudged; no other is. */
119
+ let nudged = false;
120
+
121
+ /**
122
+ * A bar's scrolling: how far it's scrolled (for its fades), back to its start for each new selection, and the nudge —
123
+ * the first bar of a session that doesn't fit slides NUDGE along and back, unless Reduce Motion is on. `max`: how far
124
+ * it scrolls.
125
+ */
126
+ function useBarScroll(over: boolean, max: number, forKey: string) {
127
+ const ref = useRef<ComponentRef<typeof ScrollView>>(null), x = useRef(new Animated.Value(0)).current;
128
+ const back = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
129
+ const onScroll = useMemo(() => Animated.event([{ nativeEvent: { contentOffset: { x } } }], { useNativeDriver: NATIVE }), [x]);
130
+ // (and one that's come to fit has only its start)
131
+ useEffect(() => { ref.current?.scrollTo({ x: 0, animated: false }); x.setValue(0); }, [forKey, over, x]);
132
+ useEffect(() => {
133
+ if (!over || nudged || max <= 0 || reducedMotion()) return;
134
+ // (once what's in it has stopped changing: its buttons' widths come in one by one)
135
+ const t = setTimeout(() => {
136
+ nudged = true;
137
+ ref.current?.scrollTo({ x: Math.min(NUDGE, max), animated: true });
138
+ back.current = setTimeout(() => ref.current?.scrollTo({ x: 0, animated: true }), NUDGE_HOLD);
139
+ }, NUDGE_AFTER);
140
+ return () => clearTimeout(t);
141
+ }, [over, max]);
142
+ // a finger dragging it mid-nudge keeps it where it's taken
143
+ const stop = useCallback(() => clearTimeout(back.current), []);
144
+ useEffect(() => stop, [stop]);
145
+ return { ref, x, onScroll, stop };
146
+ }
147
+
148
+ const FADE_START = fadeSvg('left'), FADE_END = fadeSvg('right');
149
+
150
+ /**
151
+ * A scrolling bar's fades, over its scroller: at its start once it's scrolled from there, and at its end (`end` along
152
+ * it) while there's more past it. They come and go as it's scrolled, on the native driver; touches go through them.
153
+ */
154
+ function Fades({ x, end, max }: { x: Animated.Value; end: number; max: number }) {
155
+ const [start, more] = useMemo(() => {
156
+ const ramp = Math.max(1, Math.min(FADE_RAMP, max));
157
+ return [
158
+ x.interpolate({ inputRange: [0, ramp], outputRange: [0, 1], extrapolate: 'clamp' }),
159
+ x.interpolate({ inputRange: [max - ramp, max], outputRange: [1, 0], extrapolate: 'clamp' }),
160
+ ];
161
+ }, [x, max]);
162
+ return (
163
+ <>
164
+ <Animated.View testID="barFadeStart" style={[s.fade, { left: 0, opacity: start }]}><RawSvg xml={FADE_START} width={FADE} height={BTN_H} /></Animated.View>
165
+ <Animated.View testID="barFadeEnd" style={[s.fade, { left: end - FADE, opacity: more }]}><RawSvg xml={FADE_END} width={FADE} height={BTN_H} /></Animated.View>
166
+ </>
167
+ );
168
+ }
169
+
99
170
  /**
100
171
  * 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
172
  * 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 +184,24 @@ function buttonsOnly(items: ReturnType<typeof barItems>): ReturnType<typeof barI
113
184
 
114
185
  /**
115
186
  * 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.
187
+ * is, as it measures whether they fit. Each says how wide it is (`onWidth`, by its place; a divider's is fixed).
117
188
  */
118
- const BarButtons = memo(function BarButtons({ phone }: { k: string; phone: boolean }) {
189
+ const BarButtons = memo(function BarButtons({ phone, onWidth }: { k: string; phone: boolean; onWidth: (i: number, w: number) => void }) {
119
190
  return (
120
191
  <>
121
192
  {shown(phone).map((it, i) => {
122
193
  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>;
194
+ if ('lbl' in it) return <Text key={'l' + i} style={s.lbl} onLayout={e => onWidth(i, e.nativeEvent.layout.width)}>{it.lbl}</Text>;
124
195
  const fg = it.on ? C.dark : it.locked ? C.red : '#fff';
125
196
  return (
126
- <Tap key={it.act + i} accessibilityRole="button" accessibilityLabel={it.label}
197
+ <Tap key={it.act + i} onLayout={e => onWidth(i, e.nativeEvent.layout.width)} accessibilityRole="button" accessibilityLabel={it.label}
127
198
  accessibilityState={it.on != null || it.locked != null ? { selected: !!(it.on || it.locked) } : undefined}
128
199
  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,
200
+ 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') ? { backgroundColor: 'rgba(255,255,255,0.14)' } : null,
130
201
  it.on ? { backgroundColor: '#fff' } : hovered(st) && { backgroundColor: 'rgba(255,255,255,0.08)' }]}>
131
202
  <Icon name={it.ic} color={fg} />
132
- <Text style={[s.cbText, { color: fg }]}>{it.label}</Text>
203
+ {/* (the bar's lead — Lock, or Open — and Lock wherever it is: its icon alone, its name said, not shown) */}
204
+ {!iconAlone(it) && <Text style={[s.cbText, { color: fg }]}>{it.label}</Text>}
133
205
  </Tap>
134
206
  );
135
207
  })}
@@ -259,6 +331,12 @@ export function SpokenHint() {
259
331
 
260
332
  /** 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
333
  const TRASH_ROOM = 72;
334
+ /**
335
+ * The bar's measures, which its room is worked out from as well as drawn with: its height and its buttons', its padding
336
+ * at each end, the yellow + and the gap before it, a divider and the margin at each side of it, and the gap between
337
+ * its buttons.
338
+ */
339
+ const BAR_H = 60, BTN_H = 54, PAD = 6, ADD = 48, ADD_GAP = 4, SEP_W = 1, SEP_M = 4, GAP = 2;
262
340
 
263
341
  const s = createSheet({
264
342
  bottom: { pointerEvents: 'box-none', position: 'absolute', bottom: 14, zIndex: 24, flexDirection: 'row', alignItems: 'center' },
@@ -266,16 +344,23 @@ const s = createSheet({
266
344
  phoneRow: { left: 12, right: 12 },
267
345
  springL: { flexGrow: 1, flexShrink: 1000 },
268
346
  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
347
+ // 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
348
+ // from its top and bottom, 6 from its ends, its ends round
270
349
  // (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 },
350
+ 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 },
351
+ clip: { flexGrow: 0, flexShrink: 1, borderRadius: BTN_H / 2, overflow: 'hidden' },
352
+ scroller: { flexGrow: 0, flexShrink: 1 },
353
+ items: { flexDirection: 'row', alignItems: 'center', gap: GAP },
354
+ // a scrolling bar's fade, over an end of its scroller
355
+ fade: { position: 'absolute', top: 0, bottom: 0, width: FADE, pointerEvents: 'none' },
356
+ cb: { minWidth: 58, height: BTN_H, borderRadius: BTN_H / 2, alignItems: 'center', justifyContent: 'center', gap: 3, paddingHorizontal: 8 },
357
+ // the lead: a 48 round button, its icon alone
358
+ lead: { minWidth: 48, width: 48, height: 48, borderRadius: 24, paddingHorizontal: 0 },
274
359
  cbText: { fontFamily: FONT, fontSize: 10.5, fontWeight: '500' },
275
- sep: { width: 1, height: 32, backgroundColor: C.g3, marginHorizontal: 4 },
360
+ sep: { width: SEP_W, height: 32, backgroundColor: C.g3, marginHorizontal: SEP_M },
276
361
  lbl: { fontFamily: FONT, fontSize: 12, color: C.lg1, paddingHorizontal: 10 },
277
362
  // the slider sits just above the bar, centred on it
278
- popWrap: { position: 'absolute', left: 0, right: 0, bottom: 76, alignItems: 'center' },
363
+ popWrap: { position: 'absolute', left: 0, right: 0, bottom: 70, alignItems: 'center' },
279
364
  pop: { width: 400, maxWidth: '100%', flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: C.g2, borderRadius: 999, paddingHorizontal: 16, paddingVertical: 4, ...shadow },
280
365
  popLabel: { fontFamily: FONT, fontSize: 12.5, fontWeight: '600', color: C.lg2 },
281
366
  popValue: { fontFamily: FONT, fontSize: 12.5, fontWeight: '600', color: '#fff', width: 48, textAlign: 'right' },
@@ -286,8 +371,8 @@ const s = createSheet({
286
371
  popReset: { paddingRight: 4 },
287
372
  reset: { height: 36, paddingHorizontal: 16, borderRadius: 18, backgroundColor: C.g3, justifyContent: 'center' },
288
373
  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 },
374
+ // (6 in from the bar's end, top and bottom: concentric with it)
375
+ add: { width: ADD, height: ADD, borderRadius: ADD / 2, backgroundColor: C.yellow, alignItems: 'center', justifyContent: 'center', marginLeft: ADD_GAP },
291
376
  // BOLDR's delete FAB (the annotation editors' DeleteFab): a 60 pt solid red circle 12 from the right and 14 from the
292
377
  // bottom, level with the bar beside it, on every screen size
293
378
  trash: {
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, type ComponentRef } from 'react';
2
2
  import {
3
3
  Animated, Easing, Keyboard, PanResponder, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useWindowDimensions, View,
4
4
  } from 'react-native';
@@ -7,6 +7,7 @@ import {
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';
@@ -60,7 +61,7 @@ export function Drawer({ phone = false }: { phone?: boolean }) {
60
61
  const slide = useMemo(() => x.interpolate({ inputRange: [0, 1], outputRange: [0, (phone ? travel ?? height : w) + 40] }), [x, w, height, phone, travel]);
61
62
  const fade = useMemo(() => x.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }), [x]);
62
63
  // 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);
64
+ const drag = useRef(new Animated.Value(0)).current, openedAt = useRef(0), sheet = useRef<ComponentRef<typeof View>>(null), [lift, setLift] = useState(0);
64
65
  const dropped = useMemo(() => Animated.add(slide, drag), [slide, drag]);
65
66
  useEffect(() => {
66
67
  if (open) { setPresent(true); openedAt.current = Date.now(); }
@@ -151,7 +152,7 @@ export function Drawer({ phone = false }: { phone?: boolean }) {
151
152
  function NameField({ value, onCommit, style, label, autoFocus, onFocus }: {
152
153
  value: string; onCommit: (v: string) => void; style?: object; label: string; autoFocus?: boolean; onFocus?: () => void;
153
154
  }) {
154
- const [text, setText] = useState(value), editing = useRef(false), ref = useRef<TextInput>(null);
155
+ const [text, setText] = useState(value), editing = useRef(false), ref = useRef<ComponentRef<typeof TextInput>>(null);
155
156
  useEffect(() => { if (!editing.current) setText(value); }, [value]);
156
157
  useEffect(() => { if (autoFocus) setTimeout(() => ref.current?.focus(), 320); }, [autoFocus]);
157
158
  return (
@@ -215,26 +216,29 @@ function Pages({ version, thumbs }: { version: number; thumbs: boolean }) {
215
216
  }
216
217
 
217
218
  function Spaces() {
218
- const sched = spaceSchedule();
219
+ const sched = spaceSchedule(), sketchOnly = noScaleYet();
219
220
  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
221
  return (
221
222
  <>
222
223
  {sched.rows.map(r => (
223
224
  <View key={r.id} style={s.roomRow}>
224
225
  <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>
226
+ {!sketchOnly && (
227
+ <View style={s.nums}>
228
+ <Text style={s.num}><Text style={s.numB}>{fmtArea(r.area)}</Text> floor</Text>
229
+ <Text style={s.num}><Text style={s.numB}>{fmtLen(r.per)}</Text> perimeter</Text>
230
+ </View>
231
+ )}
229
232
  </View>
230
233
  ))}
231
- <View style={s.row}>
234
+ {/* (a sketch's sizes are placeholders until its first length: no areas until then) */}
235
+ {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
236
  <View style={{ flex: 1 }}>
233
237
  <Text style={[s.rowText, { fontWeight: '700' }]}>Total</Text>
234
238
  <Text style={s.rowSub}>Measured to inside wall faces</Text>
235
239
  </View>
236
240
  <Text style={[s.rowText, { fontWeight: '700' }]}>{fmtArea(sched.total)}</Text>
237
- </View>
241
+ </View>}
238
242
  </>
239
243
  );
240
244
  }
@@ -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 },
@@ -1,6 +1,6 @@
1
- import { Canvas, Group, Picture, Skia, type SkPicture, type Transforms3d } from '@shopify/react-native-skia';
2
- import { memo, useEffect, useMemo, useRef } from 'react';
3
- import { View, type LayoutChangeEvent } from 'react-native';
1
+ import { Canvas, Group, Path, Picture, RoundedRect, Skia, type SkPicture, type Transforms3d } from '@shopify/react-native-skia';
2
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useWindowDimensions, View, type LayoutChangeEvent } from 'react-native';
4
4
  import { Gesture, GestureDetector, PointerType, type GestureTouchEvent, type TouchData } from 'react-native-gesture-handler';
5
5
  import { runOnJS, useDerivedValue, useSharedValue } from 'react-native-reanimated';
6
6
  import { ed } from '../app/state';
@@ -14,6 +14,9 @@ import {
14
14
  } from '../render/view';
15
15
  import { page } from '../model/store';
16
16
  import { recordLayers, skiaImageLoader, skiaTextMeasure } from '../skia/host';
17
+ import { LENS_ARM, LENS_R, LENS_ZOOM, lensNow, type Lens } from '../render/lens';
18
+ import { PHONE_MAX } from './theme';
19
+ import { TABS_TOP, topLayout } from './topRow';
17
20
 
18
21
  /*
19
22
  The plan on a device. Pan and zoom never wait for the JavaScript thread: the fingers are followed on the UI thread
@@ -29,10 +32,15 @@ import { recordLayers, skiaImageLoader, skiaTextMeasure } from '../skia/host';
29
32
  As a tool follows the pointer, only the moving parts (preview, snap marker, selection box) are drawn again; the
30
33
  drawing is kept until it changes. Touches still reach editor/gestures.ts for the tools, Apple Pencil, and two- and
31
34
  three-finger taps (undo, redo); it leaves the view to this canvas (setHostNavigation).
35
+
36
+ While a finger or Pencil places or drags something, the lens (render/lens.ts) shows the drawing around it twice as
37
+ close in a top corner: the same pictures again, through its zoom, clipped to it — nothing more is drawn for it.
32
38
  */
33
39
 
34
40
  /** How far past each edge of the screen a picture of the drawing reaches, as a share of the screen's longer side. */
35
41
  const OVERSCAN = 0.5;
42
+ /** A tablet's rail's right edge (Rail.tsx: 12 in, its buttons 44 at most and 6 in from its sides); its top row's buttons' height. */
43
+ const RAIL_RIGHT = 12 + 44 + 2 * 6, TOP_BUTTON = 46;
36
44
  const EMPTY = Skia.Picture.MakePicture(null)!;
37
45
 
38
46
  /** What the JavaScript thread drew last, and the view it drew it at. */
@@ -50,6 +58,9 @@ type Packed = [id: number, x: number, y: number, ax: number, ay: number];
50
58
  /** The latest of the UI thread's views this thread has taken; each frame says which it includes. */
51
59
  let hostSeq = 0;
52
60
 
61
+ /** Where the lens is now (render/lens.ts), told to the UI thread: after each touch, and each frame. */
62
+ let placeLens: (() => void) | null = null;
63
+
53
64
  /**
54
65
  * Touches from the UI thread: the view first, if the fingers moved it, then each touch to the editor's gestures (as a
55
66
  * pointer lands, `down` is every one still down: any other's lift was lost).
@@ -63,6 +74,7 @@ function touchesOnJS(type: number, kind: string, ts: Packed[], view: UiView | nu
63
74
  // it reached the screen a frame behind the finger. Only when nothing but those moving parts changed — the drawing
64
75
  // itself, and the view, are drawn on the frame as before.
65
76
  if (!view && !drawingChanged() && !imagesChanged()) paintPending();
77
+ placeLens?.();
66
78
  }
67
79
 
68
80
  const kindOf = (t: PointerType): string => {
@@ -81,6 +93,14 @@ export const PlanCanvas = memo(function PlanCanvas() {
81
93
  // the UI thread's own: the fingers down, whether they're navigating and where they were last, a pen seen
82
94
  const nav = useSharedValue<NavState>({ fingers: {}, navigating: false, last: null, pen: false });
83
95
  const readOnly = useSharedValue(false);
96
+ // the lens, and whether it's up (mounted only then: nothing drawn twice for it otherwise)
97
+ const lens = useSharedValue<Lens | null>(null);
98
+ const [lensUp, setLensUp] = useState(false);
99
+ // what the chrome leaves it: a phone's all (its chrome steps aside as a finger moves: Duck); a tablet's, right of
100
+ // the rail and under the top row, and under the tabs where they've a row of their own
101
+ const { width, height } = useWindowDimensions(), phone = width < PHONE_MAX;
102
+ const room = useRef({ left: 0, top: 0 });
103
+ room.current = phone ? { left: 0, top: 0 } : { left: RAIL_RIGHT, top: (topLayout(width, height, false).below ? TABS_TOP.below : TABS_TOP.row) + TOP_BUTTON };
84
104
 
85
105
  // the last pictures, carried from the view they were drawn at to the view the fingers have reached since
86
106
  const shift = useDerivedValue<Transforms3d>(() => {
@@ -92,6 +112,19 @@ export const PlanCanvas = memo(function PlanCanvas() {
92
112
  const overlay = useDerivedValue(() => frame.value?.overlay ?? EMPTY);
93
113
  const baseFixed = useDerivedValue(() => frame.value?.baseFixed ?? EMPTY);
94
114
  const overlayFixed = useDerivedValue(() => frame.value?.overlayFixed ?? EMPTY);
115
+ // the lens: its square, the finger at its middle LENS_ZOOM times closer, and its cross
116
+ const lensClip = useDerivedValue(() => { const l = lens.value; return Skia.RRectXY(Skia.XYWHRect(l?.x ?? 0, l?.y ?? 0, l?.size ?? 0, l?.size ?? 0), LENS_R, LENS_R); });
117
+ const lensZoom = useDerivedValue<Transforms3d>(() => {
118
+ const l = lens.value;
119
+ return l ? [{ translateX: l.x + l.size / 2 }, { translateY: l.y + l.size / 2 }, { scale: LENS_ZOOM }, { translateX: -l.fx }, { translateY: -l.fy }] : [];
120
+ });
121
+ const lensCross = useDerivedValue(() => {
122
+ const l = lens.value, b = Skia.PathBuilder.Make();
123
+ if (l) { b.moveTo(l.cx - LENS_ARM, l.cy); b.lineTo(l.cx + LENS_ARM, l.cy); b.moveTo(l.cx, l.cy - LENS_ARM); b.lineTo(l.cx, l.cy + LENS_ARM); }
124
+ return b.build();
125
+ });
126
+ const lensX = useDerivedValue(() => lens.value?.x ?? 0), lensY = useDerivedValue(() => lens.value?.y ?? 0);
127
+ const lensSize = useDerivedValue(() => lens.value?.size ?? 0), lensInk = useDerivedValue(() => lens.value?.ink ?? '#000');
95
128
 
96
129
  useEffect(() => {
97
130
  setImageLoader(skiaImageLoader);
@@ -104,7 +137,12 @@ export const PlanCanvas = memo(function PlanCanvas() {
104
137
  * not every wall and dimension over them (a frame's worth of the JavaScript thread on a big plan).
105
138
  */
106
139
  let kept: { under: SkPicture; base: SkPicture; fixed: SkPicture; T: Xform; W: number; H: number } | null = null;
107
- let wasReadOnly: boolean | null = null;
140
+ let wasReadOnly: boolean | null = null, lensShown = false;
141
+ placeLens = () => {
142
+ const l = lensNow(cvs.W, cvs.H, room.current);
143
+ if (l || lensShown) lens.value = l;
144
+ if (!!l !== lensShown) { lensShown = !!l; setLensUp(lensShown); }
145
+ };
108
146
  setFrameRenderer(() => {
109
147
  const { W, H } = cvs;
110
148
  if (!W || !H) return;
@@ -133,9 +171,10 @@ export const PlanCanvas = memo(function PlanCanvas() {
133
171
  under: kept.under, base: kept.base, overlay: over.moving, baseFixed: kept.fixed, overlayFixed: over.fixed,
134
172
  s: T.s, ox: T.ox, oy: T.oy, W, H, view: { s: v.s, cx: v.cx, cy: v.cy }, seq: hostSeq,
135
173
  };
174
+ placeLens?.();
136
175
  });
137
- return () => { setFrameRenderer(null); setImageLoader(null); setTextMeasure(null); setHostNavigation(false); };
138
- }, [frame, readOnly]);
176
+ return () => { setFrameRenderer(null); setImageLoader(null); setTextMeasure(null); setHostNavigation(false); placeLens = null; };
177
+ }, [frame, readOnly, lens]);
139
178
 
140
179
  const onLayout = (e: LayoutChangeEvent) => {
141
180
  const { width, height } = e.nativeEvent.layout;
@@ -193,6 +232,21 @@ export const PlanCanvas = memo(function PlanCanvas() {
193
232
  </Group>
194
233
  <Picture picture={baseFixed} />
195
234
  <Picture picture={overlayFixed} />
235
+ {lensUp && (
236
+ <Group>
237
+ <Group clip={lensClip}>
238
+ <Group transform={lensZoom}>
239
+ <Group transform={shift}>
240
+ <Picture picture={under} />
241
+ <Picture picture={base} />
242
+ <Picture picture={overlay} />
243
+ </Group>
244
+ </Group>
245
+ <Path path={lensCross} color={lensInk} style="stroke" strokeWidth={1.5} />
246
+ </Group>
247
+ <RoundedRect x={lensX} y={lensY} width={lensSize} height={lensSize} r={LENS_R} color={lensInk} style="stroke" strokeWidth={2} />
248
+ </Group>
249
+ )}
196
250
  </Canvas>
197
251
  </View>
198
252
  </GestureDetector>
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState, type ReactNode } from 'react';
1
+ import { useEffect, useRef, useState, type ComponentRef, type ReactNode } from 'react';
2
2
  import { Animated, Text, TextInput, View, type ViewStyle } from 'react-native';
3
3
  import { disarmReset, exportDxf, exportPdf, importPagesText, openProjectText, resetArmed, resetToDemo, saveProjectFile } from '../editor/files';
4
4
  import {
@@ -28,14 +28,24 @@ function MenuLink({ label, onPress, danger }: { label: string; onPress: () => vo
28
28
  );
29
29
  }
30
30
 
31
- function Seg<T extends string | number>({ value, opts, onPick }: { value: T; opts: [T, string][]; onPick: (v: T) => void }) {
32
- return <Segmented value={value} options={opts} onPick={onPick} style={s.seg} optionStyle={s.segBtn} textStyle={s.segText} onText={C.dark} offText={C.lg1} />;
31
+ /** A setting's choices; `fill`: across the whole width it's given (MStack), each choice growing into the room. */
32
+ function Seg<T extends string | number>({ value, opts, onPick, fill = false }: { value: T; opts: [T, string][]; onPick: (v: T) => void; fill?: boolean }) {
33
+ return <Segmented value={value} options={opts} onPick={onPick} style={fill ? [s.seg, s.segFill] : s.seg} optionStyle={fill ? [s.segBtn, s.segBtnFill] : s.segBtn}
34
+ textStyle={s.segText} onText={C.dark} offText={C.lg1} />;
33
35
  }
34
36
 
35
37
  const MRow = ({ label, children }: { label: string; children: ReactNode }) => (
36
38
  <View style={s.mrow}><Text style={s.rowText}>{label}</Text>{children}</View>
37
39
  );
38
40
 
41
+ /**
42
+ * A setting whose choices need the menu's whole width, its label above them. Units has up to seven (six, and the
43
+ * host's Account): beside its label they ran off the menu's edge, and the last of them (m) couldn't be picked at all.
44
+ */
45
+ const MStack = ({ label, children }: { label: string; children: ReactNode }) => (
46
+ <View style={s.mstack}><Text style={s.rowText}>{label}</Text>{children}</View>
47
+ );
48
+
39
49
  /**
40
50
  * The top bar's menus: Layers, or ••• (on a phone Fit to screen, then exports, opening a file, settings). `accountUnits`: the host formats
41
51
  * lengths (BOLDR: the person's account settings), offered as Units → Account beside the editor's own units.
@@ -63,7 +73,7 @@ export function Menu({ accountUnits = false, devTools = true, themeChoice = true
63
73
 
64
74
  /** ••• : the exports, opening a file, and the settings. */
65
75
  function MoreMenu({ open, style, accountUnits, devTools, themeChoice, pickText, readOnly, phone }: {
66
- open: boolean; style: Animated.WithAnimatedObject<ViewStyle>;
76
+ open: boolean; style: Animated.WithAnimatedValue<ViewStyle>;
67
77
  accountUnits: boolean; devTools: boolean; themeChoice: boolean; pickText: () => Promise<string | null>; readOnly: boolean; phone: boolean;
68
78
  }) {
69
79
  const run = (fn: () => void) => () => { closeMenu(); fn(); };
@@ -87,13 +97,13 @@ function MoreMenu({ open, style, accountUnits, devTools, themeChoice, pickText,
87
97
  <View style={s.hr} />
88
98
  </>
89
99
  )}
90
- <MRow label="Units">
91
- <Seg<UnitChoice>
100
+ <MStack label="Units">
101
+ <Seg<UnitChoice> fill
92
102
  value={accountUnits && followingHostUnits() ? 'account' : settings.unit}
93
103
  opts={[...(accountUnits ? [['account', 'Account'] as [UnitChoice, string]] : []), ['ftin', 'ft-in'], ['in', 'in'], ['ft', 'ft'], ['mm', 'mm'], ['cm', 'cm'], ['m', 'm']]}
94
104
  onPick={setUnits}
95
105
  />
96
- </MRow>
106
+ </MStack>
97
107
  {!(accountUnits && followingHostUnits()) && showFractions() && (
98
108
  <MRow label="Precision"><Seg value={settings.denom} opts={[[16, '1/16"'], [8, '1/8"'], [4, '1/4"']]} onPick={setPrecision} /></MRow>
99
109
  )}
@@ -139,7 +149,7 @@ function LayerRow({ label, sub, value, onChange }: { label: string; sub?: string
139
149
 
140
150
  /** The Layers menu: what's shown — the photos and plans under the drawing, then each layer. (An image's own lock, scale and
141
151
  * opacity are in its bar when it's selected.) */
142
- function LayersMenu({ open, style }: { open: boolean; style: Animated.WithAnimatedObject<ViewStyle> }) {
152
+ function LayersMenu({ open, style }: { open: boolean; style: Animated.WithAnimatedValue<ViewStyle> }) {
143
153
  const ims = page().underlays, n = ims.length;
144
154
  return (
145
155
  <Animated.View style={[s.menu, s.layers, style]} pointerEvents={open ? 'auto' : 'none'} accessibilityLabel="Layers" testID={open ? 'layersMenu' : undefined}>
@@ -157,7 +167,7 @@ function LayersMenu({ open, style }: { open: boolean; style: Animated.WithAnimat
157
167
  /** Typing a note where the text tool was tapped: Enter places it, Esc cancels, leaving the field places it too. */
158
168
  export function NoteInput() {
159
169
  useEditorKey(() => (ui.note ? String(editorVersion()) : ''));
160
- const n = ui.note, [text, setText] = useState(''), ref = useRef<TextInput>(null), done = useRef(false);
170
+ const n = ui.note, [text, setText] = useState(''), ref = useRef<ComponentRef<typeof TextInput>>(null), done = useRef(false);
161
171
  useEffect(() => { setText(n?.initial ?? ''); done.current = false; if (n) setTimeout(() => ref.current?.focus(), 30); }, [n]);
162
172
  if (!n) return null;
163
173
  const finish = (commit: boolean) => {
@@ -181,11 +191,16 @@ const s = createSheet({
181
191
  linkText: { fontFamily: FONT, fontSize: 14, color: '#fff' },
182
192
  hr: { height: 1, backgroundColor: C.g2, marginVertical: 6 },
183
193
  mrow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8, paddingHorizontal: 16, gap: 10 },
194
+ mstack: { paddingVertical: 8, paddingHorizontal: 16, gap: 8 },
184
195
  layers: { width: 340 },
185
196
  rowSub: { fontFamily: FONT, fontSize: 12, color: C.g4, marginTop: 2 },
186
197
  rowText: { fontFamily: FONT, fontSize: 14, color: '#fff', flexShrink: 1 },
187
198
  seg: { flexDirection: 'row', backgroundColor: C.dark, borderRadius: 999, padding: 3 },
188
199
  segBtn: { paddingVertical: 5, paddingHorizontal: 10, borderRadius: 999 },
200
+ // (the track a column, so its row of choices runs the track's whole width; each choice grows into the room there, and
201
+ // shrinks rather than running off the menu when the text is set large — its label then cut short, never wrapped)
202
+ segFill: { flexDirection: 'column' },
203
+ segBtnFill: { flexGrow: 1, flexShrink: 1, minWidth: 0, paddingHorizontal: 6 },
189
204
  segText: { fontFamily: FONT, fontSize: 12.5, fontWeight: '600', color: C.lg1 },
190
205
  note: { position: 'absolute', zIndex: 40, minWidth: 220, height: 36, paddingHorizontal: 10, borderRadius: 8, backgroundColor: '#fff', color: C.dark, fontFamily: FONT, fontSize: 14, borderWidth: 2, borderColor: C.purple },
191
206
  });
@@ -299,7 +299,7 @@ export function Rail({ phone, onAddImage }: { phone: boolean; onAddImage?: () =>
299
299
  };
300
300
  return (
301
301
  // (on a phone the bar runs under it, so it stops above; elsewhere the bar is centred and the rail runs to the bottom)
302
- <View style={[s.rail, phone && { top: 162, bottom: 84 }]} onLayout={e => {
302
+ <View style={[s.rail, phone && { top: 162, bottom: 78 }]} onLayout={e => {
303
303
  const { height: h, y: top } = e.nativeEvent.layout, w = Dimensions.get('window');
304
304
  setRoom({ height: h, y: top, winW: w.width, winH: w.height });
305
305
  }}>
@@ -19,7 +19,7 @@ const GHOST_MS = 450;
19
19
  const SHEET_IN = 200, SHEET_OUT = 160, SHEET_EASE_OUT = Easing.bezier(0.4, 0, 1, 1);
20
20
  /** how far it rises on a phone, and a card on a wide screen (as a popup does) */
21
21
  const RISE_BY = 80, CARD_RISE = 8;
22
- export function useSheetMotion(shown: boolean, wide: boolean): { present: boolean; motion: Animated.WithAnimatedObject<Pick<ViewStyle, 'transform' | 'opacity'>> } {
22
+ export function useSheetMotion(shown: boolean, wide: boolean): { present: boolean; motion: Animated.WithAnimatedValue<Pick<ViewStyle, 'transform' | 'opacity'>> } {
23
23
  const x = useRef(new Animated.Value(1)).current, [present, setPresent] = useState(shown);
24
24
  const motion = useMemo(() => {
25
25
  const opacity = x.interpolate({ inputRange: [0, 1], outputRange: [1, 0] });
@@ -29,7 +29,7 @@ export function useSheetMotion(shown: boolean, wide: boolean): { present: boolea
29
29
  opacity, transformOrigin: 'bottom right',
30
30
  transform: [{ translateY: x.interpolate({ inputRange: [0, 1], outputRange: [0, CARD_RISE] }) }, { scale: x.interpolate({ inputRange: [0, 1], outputRange: [1, 0.96] }) }],
31
31
  }
32
- : { opacity, transform: [{ translateY: x.interpolate({ inputRange: [0, 1], outputRange: [0, RISE_BY] }) }] }) as Animated.WithAnimatedObject<Pick<ViewStyle, 'transform' | 'opacity'>>;
32
+ : { opacity, transform: [{ translateY: x.interpolate({ inputRange: [0, 1], outputRange: [0, RISE_BY] }) }] }) as Animated.WithAnimatedValue<Pick<ViewStyle, 'transform' | 'opacity'>>;
33
33
  }, [wide, x]);
34
34
  useEffect(() => {
35
35
  if (shown) setPresent(true);
@@ -59,7 +59,7 @@ export function Backdrop({ wide, onClose }: { wide: boolean; onClose: () => void
59
59
  */
60
60
  export function Sheet({ wide, children, label, testID, motion, live = true }: {
61
61
  wide: boolean; children: ReactNode; label: string; testID?: string;
62
- motion?: Animated.WithAnimatedObject<Pick<ViewStyle, 'transform' | 'opacity'>>; live?: boolean;
62
+ motion?: Animated.WithAnimatedValue<Pick<ViewStyle, 'transform' | 'opacity'>>; live?: boolean;
63
63
  }) {
64
64
  return (
65
65
  <Animated.View accessibilityLabel={label} testID={testID} pointerEvents={live ? 'auto' : 'none'} style={[s.sheet, wide ? s.card : s.bottom, motion]}>