@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,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>
@@ -2,8 +2,8 @@ import { useEffect, useRef } from 'react';
2
2
  import { pointerCancel, pointerDown, pointerLeave, pointerMove, pointerUp, wheelZoom, type PointerIn } from '../editor/gestures';
3
3
  import { keyDown, keyUp, type KeyIn } from '../editor/keys';
4
4
  import { page } from '../model/store';
5
- import { fitView, initCanvas, resize, setFrameRenderer } from '../render/view';
6
- import { PAGE_LOCK_ROOT } from './pageLock';
5
+ import { cvs, fitView, initCanvas, resize, setFrameRenderer } from '../render/view';
6
+ import { BESIDE, PAGE_LOCK_ROOT } from './pageLock';
7
7
  import { setCursorTarget } from './ports';
8
8
 
9
9
  /*
@@ -14,14 +14,19 @@ import { setCursorTarget } from './ports';
14
14
 
15
15
  const keyIn = (e: KeyboardEvent): KeyIn => ({ key: e.key, shift: e.shiftKey, mod: e.metaKey || e.ctrlKey, alt: e.altKey });
16
16
 
17
+ /** The host's pane beside the editor that the last press on the page was in (BESIDE), if it was in one. */
18
+ let pressedBeside: Element | null = null;
19
+
17
20
  /**
18
21
  * Whether a key is the editor's: pressed in it, or with nothing focused — not typed into a field, nor pressed in the
19
22
  * host's own UI over the drawing (a picker, a measurement's detail), which is outside the editor's root. Delete there
20
- * would otherwise delete what's selected on the drawing.
23
+ * would otherwise delete what's selected on the drawing. With nothing focused it's the editor's unless the last press
24
+ * was in a pane beside it (a split view) that's still open: a click on its page, where nothing takes focus, leaves the
25
+ * focus on the page itself.
21
26
  */
22
27
  function forEditor(e: KeyboardEvent): boolean {
23
28
  const t = e.target instanceof HTMLElement ? e.target : null;
24
- if (!t || t === document.body || t === document.documentElement) return true;
29
+ if (!t || t === document.body || t === document.documentElement) return !pressedBeside?.isConnected;
25
30
  if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable) return false;
26
31
  return !!t.closest('#' + PAGE_LOCK_ROOT);
27
32
  }
@@ -36,7 +41,9 @@ export function PlanCanvas() {
36
41
  let first = true;
37
42
  const ro = new ResizeObserver(() => {
38
43
  resize();
39
- if (first && (!page().view || page()._fit !== false)) fitView();
44
+ // fitted once, at its first real size (not while a pane opening round it is still a sliver)
45
+ if (!first || cvs.W < 40 || cvs.H < 40) return;
46
+ if (!page().view || page()._fit !== false) fitView();
40
47
  first = false;
41
48
  });
42
49
  ro.observe(cv);
@@ -60,6 +67,11 @@ export function PlanCanvas() {
60
67
  // shift+wheel arrives as deltaX on some systems
61
68
  wheelZoom(e.clientX - r.left, e.clientY - r.top, e.deltaY || e.deltaX, e.ctrlKey || e.metaKey, e.deltaMode !== 0);
62
69
  }, { passive: false, signal: ac.signal });
70
+ // (whether the last press was in a pane beside the editor, for keys pressed with nothing focused: see forEditor)
71
+ pressedBeside = null;
72
+ document.addEventListener('pointerdown', e => {
73
+ pressedBeside = e.target instanceof Element ? e.target.closest(`[${BESIDE}]`) : null;
74
+ }, { capture: true, signal: ac.signal });
63
75
  // capture phase: a key the editor uses (Enter on the keypad, Space to pan, …) must not also press whatever button
64
76
  // last took focus
65
77
  document.addEventListener('keydown', e => {
@@ -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
  }}>
@@ -1,5 +1,5 @@
1
1
  import { useRef } from 'react';
2
- import { Animated, Text, useWindowDimensions, View } from 'react-native';
2
+ import { Animated, Text, View } from 'react-native';
3
3
  import { ed } from '../app/state';
4
4
  import { railIcon, TOOL_GROUPS, toolLabel } from '../editor/mode';
5
5
  import { toolTip } from '../editor/tooltips';
@@ -9,6 +9,7 @@ import { notify, ui, useEditorKey } from './store';
9
9
  import { C, FONT, shadow } from './theme';
10
10
  import { Tap, usePopup } from './motion';
11
11
  import { createSheet } from './corners';
12
+ import { useEditorBox } from './editorBox';
12
13
 
13
14
  const ROW = 44, GAP = 6, PAD = 8;
14
15
  /** A menu's height for `n` rows: the rows, the gaps between them, its padding and border. */
@@ -22,7 +23,7 @@ const heightFor = (n: number) => n * ROW + (n - 1) * GAP + 2 * PAD + 2;
22
23
  */
23
24
  export function ShapeMenu() {
24
25
  useEditorKey(() => (ui.shapeMenu ? [ui.shapeMenu.x, ui.shapeMenu.y, ui.shapeMenu.group, ed.mode, ed.tool].join('|') : ''));
25
- const { height } = useWindowDimensions(), now = ui.shapeMenu, nowGroup = now ? TOOL_GROUPS[now.group] : null;
26
+ const { height } = useEditorBox(), now = ui.shapeMenu, nowGroup = now ? TOOL_GROUPS[now.group] : null;
26
27
  const open = !!now && !!nowGroup && ed.mode === nowGroup.mode;
27
28
  // (it grows out of the rail's place, sideways; as it goes it keeps what it showed)
28
29
  const last = useRef(now), pop = usePopup(open, { x: -8 }, 'left center');
@@ -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]}>
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
- import { Animated, useWindowDimensions, View } from 'react-native';
2
+ import { Animated, View } from 'react-native';
3
3
  import { TIP_TOP, topLayout } from './topRow';
4
4
  import { RichText } from './RichText';
5
5
  import { ui, useEditorKey } from './store';
@@ -7,6 +7,7 @@ import { C, FONT, shadow } from './theme';
7
7
  import { TOGGLE_EASE } from './motion';
8
8
  import { NATIVE } from './easing';
9
9
  import { createSheet } from './corners';
10
+ import { useEditorBox } from './editorBox';
10
11
 
11
12
  /** How long the note stays, and how it comes and goes. */
12
13
  const SHOW_MS = 2600, IN_MS = 160, OUT_MS = 240;
@@ -16,7 +17,7 @@ const SHOW_MS = 2600, IN_MS = 160, OUT_MS = 240;
16
17
  * moment and goes by itself; picking another tool replaces it at once.
17
18
  */
18
19
  export function ToolTip({ phone }: { phone: boolean }) {
19
- const { width, height } = useWindowDimensions();
20
+ const { width, height } = useEditorBox();
20
21
  useEditorKey(() => String(ui.toolTip?.seq ?? '') + '|' + topLayout(width, height, phone).below);
21
22
  const tip = ui.toolTip, v = useRef(new Animated.Value(0)).current, [shown, setShown] = useState<{ text: string; seq: number } | null>(null);
22
23
  const slide = useRef(v.interpolate({ inputRange: [0, 1], outputRange: [-4, 0] })).current;
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
- import { Text, useWindowDimensions, View } from 'react-native';
2
+ import { Text, View } from 'react-native';
3
3
  import { closeElevation, elev, elevOpen, elevTitle, flipElevation } from '../editor/elevation';
4
4
  import { setMode } from '../editor/mode';
5
5
  import { openDrawer } from '../editor/ports';
@@ -17,6 +17,7 @@ import { C, FONT, shadow } from './theme';
17
17
  import { Segmented, Tap } from './motion';
18
18
  import { createSheet } from './corners';
19
19
  import { setTopRoom, TABS_TOP, topKey, topLayout } from './topRow';
20
+ import { useEditorBox } from './editorBox';
20
21
 
21
22
  /**
22
23
  * The top bar: back, the project and page, view controls, the host's own buttons, settings, and Save. In the wall
@@ -25,7 +26,7 @@ import { setTopRoom, TABS_TOP, topKey, topLayout } from './topRow';
25
26
  export function TopBar({ onBack, headerRight, phone, readOnly }: {
26
27
  onBack?: () => void; headerRight?: ReactNode | ((buttonSize: number) => ReactNode); phone: boolean; readOnly?: boolean;
27
28
  }) {
28
- const { width, height } = useWindowDimensions(), size = phone ? 42 : 46;
29
+ const { width, height } = useEditorBox(), size = phone ? 42 : 46;
29
30
  useEditorKey(() => {
30
31
  const w = elevOpen() && !!elev.frame, title = w ? elevTitle() : null;
31
32
  return [w, title?.title, title?.sub, store.project.name, page().name, ui.menu, saveState(), manualSave(), is3d(), topKey(topLayout(width, height, phone, size))].join('|');
@@ -95,7 +96,7 @@ const TABS: [Mode | '3d', string][] = [['annotate', 'Annotate'], ['draft', 'Draf
95
96
 
96
97
  /** Annotate / Draft / 3D: centred in the top row, or on a row of their own under it (see topRow.ts). */
97
98
  export function ModeTabs({ phone }: { phone: boolean }) {
98
- const { width, height } = useWindowDimensions();
99
+ const { width, height } = useEditorBox();
99
100
  useEditorKey(() => (is3d() ? '3d' : ed.mode) + '|' + topKey(topLayout(width, height, phone)));
100
101
  const at = topLayout(width, height, phone);
101
102
  // (straight over: no line under the tabs saying how to get about in 3D)
@@ -0,0 +1,57 @@
1
+ import { C } from './theme';
2
+
3
+ /*
4
+ A bottom bar too long for the screen scrolls, and has to look like it does (the user's call, 2026-10-27: "on mobile
5
+ it is hard to tell that the bottom bar is scrollable"). Three things say so, in react/BottomBar.tsx:
6
+ - a fade at each end that has more of the bar past it, there as it's scrolled;
7
+ - its end always cuts through a button, never at a gap: there, what shows looks like all there is (barCut);
8
+ - the first bar of a session that doesn't fit slides a little way along and back, unless Reduce Motion is on.
9
+ */
10
+
11
+ /** A button's place along the bar's buttons: from their start, and its width. */
12
+ export type Span = readonly [x: number, w: number];
13
+
14
+ /** How much of a button the end leaves showing: what it's cut to, and the shares that already read as cut. */
15
+ export const CUT = 0.6, CUT_MIN = 0.3, CUT_MAX = 0.8;
16
+ /** A fade's width; how far the bar scrolls as one comes or goes. */
17
+ export const FADE = 20, FADE_RAMP = 24;
18
+ /** The nudge: how far along, how long after the bar shows, and how long before it goes back (ms). */
19
+ export const NUDGE = 40, NUDGE_AFTER = 400, NUDGE_HOLD = 450;
20
+
21
+ /**
22
+ * The buttons' places along the bar, from the widths of everything in it in order (a divider's too) and the gap
23
+ * between each: null while any is still unmeasured.
24
+ */
25
+ export function barSpans(items: readonly { w: number | undefined; button: boolean }[], gap: number): Span[] | null {
26
+ const out: Span[] = [];
27
+ let x = 0;
28
+ for (const { w, button } of items) {
29
+ if (w == null) return null;
30
+ if (button) out.push([x, w]);
31
+ x += w + gap;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * How much of a scrolling bar shows, given `room` for it: all of that, if it already ends partway through a button
38
+ * (between CUT_MIN and CUT_MAX of it showing); otherwise less, ending at CUT of the last button that leaves showing.
39
+ */
40
+ export function barCut(spans: readonly Span[], room: number): number {
41
+ let best = 0;
42
+ for (const [x, w] of spans) {
43
+ const f = (room - x) / w;
44
+ if (f >= CUT_MIN && f <= CUT_MAX) return room;
45
+ const c = x + CUT * w;
46
+ if (c <= room && c > best) best = c;
47
+ }
48
+ return best || room;
49
+ }
50
+
51
+ /** A fade, as an SVG: the bar's grey, clear at one side, whole at the other (`to`: the side that's whole). */
52
+ export const fadeSvg = (to: 'left' | 'right'): string => {
53
+ const id = 'barFade' + (to === 'left' ? 'L' : 'R'), [a, b] = to === 'right' ? [0, 1] : [1, 0];
54
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${FADE}" height="54" viewBox="0 0 1 1" preserveAspectRatio="none">` +
55
+ `<defs><linearGradient id="${id}" x1="${a}" y1="0" x2="${b}" y2="0"><stop offset="0" stop-color="${C.g2}" stop-opacity="0"/>` +
56
+ `<stop offset="1" stop-color="${C.g2}" stop-opacity="1"/></linearGradient></defs><rect width="1" height="1" fill="url(#${id})"/></svg>`;
57
+ };
@@ -1,4 +1,4 @@
1
- import { Platform, StyleSheet } from 'react-native';
1
+ import { Platform, StyleSheet, type ImageStyle, type TextStyle, type ViewStyle } from 'react-native';
2
2
  import { CORNER_SMOOTHING } from '../render/squircle';
3
3
 
4
4
  /*
@@ -44,9 +44,12 @@ export function smoothCorners<T extends Style>(st: T): T {
44
44
  return out as T;
45
45
  }
46
46
 
47
+ /** A sheet's styles by name. (React Native 0.88's generated types have no `StyleSheet.NamedStyles`.) */
48
+ type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
49
+
47
50
  /** StyleSheet.create, every style's corners smoothed (smoothCorners). */
48
- export function createSheet<T extends StyleSheet.NamedStyles<T>>(styles: T & StyleSheet.NamedStyles<unknown>): T {
51
+ export function createSheet<T extends NamedStyles<T>>(styles: T): T {
49
52
  const out = {} as Record<string, unknown>;
50
53
  for (const k of Object.keys(styles)) out[k] = smoothCorners((styles as Record<string, Style>)[k]);
51
- return StyleSheet.create(out as T);
54
+ return StyleSheet.create(out as Record<string, ViewStyle | TextStyle | ImageStyle>) as unknown as T;
52
55
  }
@@ -0,0 +1,20 @@
1
+ import { createContext, useContext } from 'react';
2
+ import { useWindowDimensions } from 'react-native';
3
+
4
+ /*
5
+ The editor's own area: what its chrome is laid out in. It's the window when the editor fills it, and less when the
6
+ host puts it in a pane beside something else (a split view, beside the app's sidebar). The phone or tablet layout,
7
+ the top row, the drawer and the menus all go by this, never by the window; the drawing already does, its canvas
8
+ sized by its own layout.
9
+ */
10
+
11
+ export interface EditorBox { width: number; height: number }
12
+
13
+ /** Set by CadEditor from its root's layout; until that first layout, the window stands in. */
14
+ export const EditorBoxContext = createContext<EditorBox | null>(null);
15
+
16
+ /** The editor's area (the window until it's been laid out). */
17
+ export function useEditorBox(): EditorBox {
18
+ const box = useContext(EditorBoxContext), win = useWindowDimensions();
19
+ return box ?? win;
20
+ }
package/src/react/host.ts CHANGED
@@ -12,7 +12,7 @@ import { notify } from './store';
12
12
  /** Whether an editor is on screen (CadEditor sets it): readings that arrive after it closed are left to the host. */
13
13
  export const editorHost = { open: false };
14
14
 
15
- /** A measure connected or disconnected: shows the reading tray and enables the keypad's Laser key. */
15
+ /** A measure connected or disconnected: enables the keypad's Laser key (no tray of readings shows: see applyReading). */
16
16
  export function setLaserConnected(on: boolean): void {
17
17
  laser.on = on;
18
18
  laser.external = on;
@@ -51,7 +51,7 @@ export const reducedMotion = (): boolean => reduceMotion;
51
51
  * device, the wait before it moved). `mounted` says whether to draw it at all; the style hides it when it's away.
52
52
  */
53
53
  export function usePopup(open: boolean, from: { x?: number; y?: number }, origin: string, keep = false): {
54
- present: boolean; mounted: boolean; style: Animated.WithAnimatedObject<ViewStyle>;
54
+ present: boolean; mounted: boolean; style: Animated.WithAnimatedValue<ViewStyle>;
55
55
  } {
56
56
  const v = useRef(new Animated.Value(open ? 1 : 0)).current, [present, setPresent] = useState(open), been = useRef(open);
57
57
  if (open) been.current = true;
@@ -77,9 +77,9 @@ export function usePopup(open: boolean, from: { x?: number; y?: number }, origin
77
77
  { translateY: v.interpolate({ inputRange: [0, 1], outputRange: [fy, 0] }) },
78
78
  { scale: v.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) },
79
79
  ],
80
- }) as Animated.WithAnimatedObject<ViewStyle>, [v, fx, fy, origin]);
80
+ }) as Animated.WithAnimatedValue<ViewStyle>, [v, fx, fy, origin]);
81
81
  const shown = open || present;
82
- const hidden = useMemo(() => [style, { display: 'none' as const }] as unknown as Animated.WithAnimatedObject<ViewStyle>, [style]);
82
+ const hidden = useMemo(() => [style, { display: 'none' as const }] as unknown as Animated.WithAnimatedValue<ViewStyle>, [style]);
83
83
  return { present: shown, mounted: shown || (keep && been.current), style: shown ? style : hidden };
84
84
  }
85
85
 
@@ -185,13 +185,13 @@ function SegOption({ label, on, onPress, onLayout, style, textStyle, onText, off
185
185
  onLayout={e => onLayout(e.nativeEvent.layout)} style={[segCenter, style, { backgroundColor: 'transparent' }]}>
186
186
  {NATIVE ? (
187
187
  <>
188
- <Animated.Text style={[textStyle, { color: offText, opacity: ink }]}>{label}</Animated.Text>
188
+ <Animated.Text numberOfLines={1} style={[textStyle, { color: offText, opacity: ink }]}>{label}</Animated.Text>
189
189
  {/* (the chosen colour's label, exactly over the other) */}
190
190
  <View pointerEvents="none" style={segOver}>
191
- <Animated.Text accessible={false} style={[textStyle, { color: onText, opacity: t }]}>{label}</Animated.Text>
191
+ <Animated.Text numberOfLines={1} accessible={false} style={[textStyle, { color: onText, opacity: t }]}>{label}</Animated.Text>
192
192
  </View>
193
193
  </>
194
- ) : <Animated.Text style={[textStyle, { color: ink }]}>{label}</Animated.Text>}
194
+ ) : <Animated.Text numberOfLines={1} style={[textStyle, { color: ink }]}>{label}</Animated.Text>}
195
195
  </Tap>
196
196
  );
197
197
  }
@@ -1,3 +1,5 @@
1
1
  /** A device screen has no page to zoom or scroll (see pageLock.web.ts). */
2
2
  export const PAGE_LOCK_ROOT = 'cad-root';
3
+ /** (a browser's: see pageLock.web.ts) */
4
+ export const BESIDE = 'data-cad-beside';
3
5
  export function usePageLock(): void {}
@@ -17,6 +17,13 @@ const LOCKED_VIEWPORT = 'width=device-width, initial-scale=1, maximum-scale=1, u
17
17
 
18
18
  export const PAGE_LOCK_ROOT = ROOT;
19
19
 
20
+ /**
21
+ * Put on a host's pane beside the editor (a split view). What starts in it is its own: the lock leaves its pinches and
22
+ * two-finger moves alone, and a key pressed with nothing focused after a press in it isn't the editor's (see
23
+ * PlanCanvas.web.tsx).
24
+ */
25
+ export const BESIDE = 'data-cad-beside';
26
+
20
27
  export function usePageLock(): void {
21
28
  useEffect(() => {
22
29
  if (typeof document === 'undefined') return;
@@ -31,9 +38,11 @@ export function usePageLock(): void {
31
38
  if (!meta) { meta = document.createElement('meta'); meta.name = 'viewport'; document.head.appendChild(meta); }
32
39
  const before = meta.content;
33
40
  meta.content = LOCKED_VIEWPORT;
34
- // Safari's pinch zoom (it ignores user-scalable=no): its gesture events, and two-finger touch moves
35
- const stop = (e: Event) => e.preventDefault();
36
- const pinch = (e: TouchEvent) => { if (e.touches.length > 1) e.preventDefault(); };
41
+ // Safari's pinch zoom (it ignores user-scalable=no): its gesture events, and two-finger touch moves — except in a
42
+ // host's pane beside the editor, which handles its own (BESIDE)
43
+ const own = (e: Event) => e.target instanceof Element && !!e.target.closest(`[${BESIDE}]`);
44
+ const stop = (e: Event) => { if (!own(e)) e.preventDefault(); };
45
+ const pinch = (e: TouchEvent) => { if (e.touches.length > 1 && !own(e)) e.preventDefault(); };
37
46
  const opts: AddEventListenerOptions = { passive: false };
38
47
  const gestures = ['gesturestart', 'gesturechange', 'gestureend'];
39
48
  gestures.forEach(g => document.addEventListener(g, stop, opts));
@@ -12,6 +12,7 @@ import { manualSave, noteEdits, saveDrawing } from './saving';
12
12
  import { requestDraw, requestOverlayDraw } from '../render/view';
13
13
  import { notify, ui } from './store';
14
14
  import { setInteracting } from './Duck';
15
+ import { PAGE_LOCK_ROOT } from './pageLock';
15
16
 
16
17
  /* The editor's ports for the React Native interface: each request updates the interface's state and re-renders. */
17
18
 
@@ -110,8 +111,12 @@ export const reactPorts: EditorPorts = {
110
111
  requestDraw();
111
112
  return;
112
113
  }
113
- // the keypad has no text field: let typed keys reach the editor, not the button that opened it (Enter would press it again)
114
- if (typeof document !== 'undefined') (document.activeElement as HTMLElement | null)?.blur?.();
114
+ // the keypad has no text field: let typed keys reach the editor, not the button that opened it (Enter would press it
115
+ // again). Only a button of the editor's: focus elsewhere on the page (a pane beside it) stays where it is.
116
+ if (typeof document !== 'undefined') {
117
+ const a = document.activeElement as HTMLElement | null;
118
+ if (a?.closest?.('#' + PAGE_LOCK_ROOT)) a.blur?.();
119
+ }
115
120
  keypadStart(spec);
116
121
  ui.keypad = true;
117
122
  sheetOpened();