@pascal-app/editor 1.0.7 → 1.1.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 (136) hide show
  1. package/package.json +7 -7
  2. package/src/components/editor/custom-camera-controls.tsx +90 -34
  3. package/src/components/editor/duplicate-node.ts +2 -1
  4. package/src/components/editor/first-person-controls.tsx +5 -7
  5. package/src/components/editor/fit-scene-framing.test.ts +318 -0
  6. package/src/components/editor/fit-scene-framing.ts +142 -0
  7. package/src/components/editor/floating-action-menu.tsx +4 -2
  8. package/src/components/editor/floor-openings-overlay.tsx +1 -1
  9. package/src/components/editor/floor-region-controls.tsx +2 -2
  10. package/src/components/editor/floorplan-panel.tsx +173 -8
  11. package/src/components/editor/floorplan-wall-typing-keys.test.tsx +152 -0
  12. package/src/components/editor/floorplan-wall-typing-keys.ts +79 -0
  13. package/src/components/editor/footprint-height-handle.tsx +1 -1
  14. package/src/components/editor/group-transform-shared.ts +3 -1
  15. package/src/components/editor/index.tsx +20 -0
  16. package/src/components/editor/measurement-pill.tsx +1 -1
  17. package/src/components/editor/node-arrow-handles.tsx +1 -1
  18. package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -2
  19. package/src/components/editor/open-wall-ends-hint.tsx +1 -1
  20. package/src/components/editor/quick-measurement-card.tsx +1 -1
  21. package/src/components/editor/room-controls.tsx +3 -2
  22. package/src/components/editor/room-handles-2d.tsx +2 -1
  23. package/src/components/editor/room-handles.tsx +3 -3
  24. package/src/components/editor/room-labels.tsx +4 -2
  25. package/src/components/editor/site-edge-labels.tsx +6 -2
  26. package/src/components/editor/wall-measurement-label.tsx +30 -14
  27. package/src/components/editor/wall-move-side-handles.tsx +10 -6
  28. package/src/components/editor/wall-region-handles.tsx +2 -2
  29. package/src/components/editor/wall-snap-beacon-layer.tsx +53 -32
  30. package/src/components/editor-2d/floorplan-group-move.tsx +1 -1
  31. package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +12 -12
  32. package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -2
  33. package/src/components/editor-2d/floorplan-registry-action-menu.tsx +2 -1
  34. package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +22 -4
  35. package/src/components/editor-2d/renderers/floorplan-draft-measurement.tsx +7 -1
  36. package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +13 -1
  37. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.test.tsx +35 -0
  38. package/src/components/editor-2d/renderers/floorplan-placement-typing-hud.tsx +65 -0
  39. package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +22 -0
  40. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +14 -2
  41. package/src/components/systems/roof/roof-edit-system.test.tsx +15 -1
  42. package/src/components/systems/roof/roof-edit-system.tsx +30 -19
  43. package/src/components/tools/distribution/distribution-run-tool.test.tsx +371 -3
  44. package/src/components/tools/distribution/distribution-run-tool.tsx +150 -32
  45. package/src/components/tools/item/move-tool.tsx +1 -6
  46. package/src/components/tools/item/use-placement-coordinator.tsx +1 -1
  47. package/src/components/tools/paint-region/wall-paint-region-tool.tsx +1 -1
  48. package/src/components/tools/registry/move-registry-node-tool.test.ts +42 -1
  49. package/src/components/tools/registry/move-registry-node-tool.tsx +38 -14
  50. package/src/components/tools/shared/placement-box.tsx +1 -5
  51. package/src/components/tools/shared/placement-coordinate-input.tsx +94 -0
  52. package/src/components/tools/shared/segment-angle.ts +0 -4
  53. package/src/components/tools/tool-manager.tsx +1 -24
  54. package/src/components/tools/wall/wall-typed-entry.test.ts +99 -0
  55. package/src/components/tools/wall/wall-typed-entry.ts +70 -0
  56. package/src/components/ui/controls/slider-control.tsx +18 -6
  57. package/src/components/ui/controls/tool-options-panel.tsx +74 -44
  58. package/src/components/ui/helpers/helper-manager.test.tsx +53 -0
  59. package/src/components/ui/helpers/helper-manager.tsx +54 -0
  60. package/src/components/ui/panels/floor-foundation-panel.tsx +1 -1
  61. package/src/components/ui/panels/floor-opening-panel.tsx +1 -1
  62. package/src/components/ui/panels/mobile-selection-bar.tsx +28 -24
  63. package/src/components/ui/panels/multi-height-mode.tsx +3 -3
  64. package/src/components/ui/panels/multi-selection-panel.tsx +1 -1
  65. package/src/components/ui/panels/panel-manager.tsx +4 -2
  66. package/src/components/ui/panels/panel-wrapper.tsx +41 -18
  67. package/src/components/ui/panels/room-construction-rows.tsx +1 -1
  68. package/src/components/ui/panels/room-openings-field.tsx +1 -1
  69. package/src/components/ui/panels/room-panel.tsx +1 -1
  70. package/src/components/ui/right-stack.test.ts +13 -6
  71. package/src/components/ui/right-stack.tsx +21 -10
  72. package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -5
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +1 -1
  74. package/src/components/ui/sidebar/panels/site-panel/index.tsx +1 -8
  75. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +1 -1
  76. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +1 -1
  77. package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
  78. package/src/components/viewer/viewer-rooms.tsx +1 -1
  79. package/src/i18n/locales/en/messages.po +856 -124
  80. package/src/i18n/locales/en/messages.ts +1 -1
  81. package/src/i18n/locales/en-XA/messages.ts +1 -1
  82. package/src/i18n/locales/es/messages.po +2087 -257
  83. package/src/i18n/locales/fr/messages.po +2087 -257
  84. package/src/i18n/locales/ja/messages.po +2087 -257
  85. package/src/i18n/locales/zh-CN/messages.po +2087 -257
  86. package/src/index.tsx +74 -47
  87. package/src/lib/contextual-help.ts +6 -0
  88. package/src/lib/elevation-guides.ts +21 -6
  89. package/src/lib/elevator-support.ts +1 -72
  90. package/src/lib/floor-footprints.test.ts +1 -1
  91. package/src/lib/floor-footprints.ts +1 -9
  92. package/src/lib/floor-foundation-support.test.ts +8 -3
  93. package/src/lib/floor-opening-draft.ts +1 -1
  94. package/src/lib/floor-region-session.ts +1 -1
  95. package/src/lib/floorplan/open-wall-ends.ts +2 -1
  96. package/src/lib/gesture-lifecycle.ts +3 -1
  97. package/src/lib/interaction/scope.ts +1 -0
  98. package/src/lib/layer-thickness.ts +1 -1
  99. package/src/lib/level-selection.test.ts +18 -2
  100. package/src/lib/level-selection.ts +31 -8
  101. package/src/lib/linear-display.ts +1 -1
  102. package/src/lib/live-gesture-round-trip.test.ts +1 -1
  103. package/src/lib/measurement-label.test.ts +1 -1
  104. package/src/lib/measurements.test.ts +8 -7
  105. package/src/lib/measurements.ts +1 -218
  106. package/src/lib/node-action-movement.test.tsx +115 -0
  107. package/src/lib/node-action-movement.ts +2 -0
  108. package/src/lib/placement-metadata.ts +4 -0
  109. package/src/lib/point-ask/describe-target.ts +6 -8
  110. package/src/lib/point-ask/region.ts +1 -1
  111. package/src/lib/print-shell-compiler.ts +12 -1
  112. package/src/lib/print-wall-solids.test.ts +11 -0
  113. package/src/lib/print-wall-solids.ts +9 -0
  114. package/src/lib/room-assembly-overlay.ts +1 -1
  115. package/src/lib/room-built-on.test.ts +2 -5
  116. package/src/lib/room-built-on.ts +1 -15
  117. package/src/lib/room-construction-commands.ts +3 -2
  118. package/src/lib/room-controls-phase6.test.ts +1 -1
  119. package/src/lib/room-handle-drag.ts +6 -25
  120. package/src/lib/room-push-dimensions.test.ts +1 -1
  121. package/src/lib/scene-bounds.test.ts +144 -0
  122. package/src/lib/scene-bounds.ts +79 -15
  123. package/src/lib/scene-clipboard.ts +2 -1
  124. package/src/lib/snapping-mode.test.ts +23 -1
  125. package/src/lib/viewer-selection.ts +1 -1
  126. package/src/lib/wall-region-list.ts +3 -1
  127. package/src/lib/window-interaction.test.ts +4 -2
  128. package/src/store/use-editor.tsx +5 -0
  129. package/src/store/use-placement-typing.test.ts +165 -0
  130. package/src/store/use-placement-typing.ts +186 -0
  131. package/src/components/tools/elevator/elevator-defaults.ts +0 -8
  132. package/src/components/tools/elevator/elevator-tool.tsx +0 -372
  133. package/src/components/tools/elevator/move-elevator-tool.tsx +0 -262
  134. package/src/lib/door-interaction.ts +0 -102
  135. package/src/lib/measurement-label.ts +0 -68
  136. package/src/lib/window-interaction.ts +0 -142
@@ -36,7 +36,7 @@ import {
36
36
  THICK_FLOOR_HINT,
37
37
  thickFloorAdvice,
38
38
  } from '../../../lib/floor-footprints'
39
- import { formatLinearMeasurement } from '../../../lib/measurements'
39
+ import { formatLinearMeasurement } from '@pascal-app/core'
40
40
  import { FinishSwatch } from '../controls/finish-swatch'
41
41
  import { MetricControl } from '../controls/metric-control'
42
42
  import { SegmentedControl } from '../controls/segmented-control'
@@ -14,7 +14,7 @@ import {
14
14
  finishFloorOpening,
15
15
  openingRoom,
16
16
  } from '../../../lib/floor-edit-session'
17
- import { formatAreaLabel } from '../../../lib/measurements'
17
+ import { formatAreaLabel } from '@pascal-app/core'
18
18
  import { Button } from '../primitives/button'
19
19
  import { Switch } from '../primitives/switch'
20
20
  import { PanelWrapper } from './panel-wrapper'
@@ -12,8 +12,8 @@ interface MobileSelectionBarProps {
12
12
  node: AnyNode | null
13
13
  label?: string
14
14
  icon?: string
15
- onMove: () => void
16
- onDuplicate: () => void
15
+ onMove?: () => void
16
+ onDuplicate?: () => void
17
17
  onDelete: () => void
18
18
  onEdit: () => void
19
19
  }
@@ -58,28 +58,32 @@ export function MobileSelectionBar({
58
58
  </button>
59
59
 
60
60
  <div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
61
- <button
62
- aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.move', message: 'Move' })}
63
- className={ACTION_BTN}
64
- onClick={(e) => {
65
- stop(e)
66
- onMove()
67
- }}
68
- type="button"
69
- >
70
- <Move className="h-4 w-4" />
71
- </button>
72
- <button
73
- aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.duplicate', message: 'Duplicate' })}
74
- className={ACTION_BTN}
75
- onClick={(e) => {
76
- stop(e)
77
- onDuplicate()
78
- }}
79
- type="button"
80
- >
81
- <Copy className="h-4 w-4" />
82
- </button>
61
+ {onMove && (
62
+ <button
63
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.move', message: 'Move' })}
64
+ className={ACTION_BTN}
65
+ onClick={(e) => {
66
+ stop(e)
67
+ onMove()
68
+ }}
69
+ type="button"
70
+ >
71
+ <Move className="h-4 w-4" />
72
+ </button>
73
+ )}
74
+ {onDuplicate && (
75
+ <button
76
+ aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.duplicate', message: 'Duplicate' })}
77
+ className={ACTION_BTN}
78
+ onClick={(e) => {
79
+ stop(e)
80
+ onDuplicate()
81
+ }}
82
+ type="button"
83
+ >
84
+ <Copy className="h-4 w-4" />
85
+ </button>
86
+ )}
83
87
  <button
84
88
  aria-label={i18n._({ id: 'editor.panels.mobileSelectionBar.delete', message: 'Delete' })}
85
89
  className={cn(
@@ -16,7 +16,7 @@ import {
16
16
  import { useViewer } from '@pascal-app/viewer'
17
17
  import { useCallback } from 'react'
18
18
  import { useShallow } from 'zustand/react/shallow'
19
- import { formatLinearMeasurement } from '../../../lib/measurements'
19
+ import { formatLinearMeasurement } from '@pascal-app/core'
20
20
  import { SegmentedControl } from '../controls/segmented-control'
21
21
  import { SliderControl } from '../controls/slider-control'
22
22
  import {
@@ -42,7 +42,7 @@ function wallFollowsLevelPatch(wall: WallNode, nodes: Record<string, AnyNode>):
42
42
  }
43
43
 
44
44
  function effectiveHeight(node: AnyNode, nodes: Record<string, AnyNode>): number {
45
- if (node.type === 'wall') return getWallEffectiveHeightForNodes(node, nodes)
45
+ if (node.type === 'wall') return getWallEffectiveHeightForNodes(node, nodes, 0)
46
46
  if (node.type === 'ceiling') return resolveCeilingHeight(node, nodes)
47
47
  const height = (node as { height?: number }).height
48
48
  return typeof height === 'number' ? height : 0
@@ -72,7 +72,7 @@ export function applyMultiHeightMode(
72
72
  if (isCustom) return {}
73
73
  if (node.type === 'ceiling') return { height: ceilingCustomHeight(node, nodes) }
74
74
  if (node.type === 'wall') {
75
- return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes)) }
75
+ return { height: Math.max(0.1, getWallEffectiveHeightForNodes(node, nodes, 0)) }
76
76
  }
77
77
  return {}
78
78
  }
@@ -14,7 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
14
14
  import { Building2, Copy, Group, Merge, Split, Trash2, Ungroup } from 'lucide-react'
15
15
  import { useMemo } from 'react'
16
16
  import { deleteSelection, duplicateSelectionAndPickUp } from '../../editor/group-actions'
17
- import { formatLinearMeasurement } from '../../../lib/measurements'
17
+ import { formatLinearMeasurement } from '@pascal-app/core'
18
18
  import { triggerSFX } from '../../../lib/sfx-bus'
19
19
  import { writeWindowChanges } from '../../../lib/window-changes'
20
20
  import { collectSelectableCandidateIds } from '../../tools/select/select-candidates'
@@ -11,6 +11,8 @@ import {
11
11
  type DormerNode,
12
12
  type ElevatorNode,
13
13
  type FenceNode,
14
+ isMoveLocked,
15
+ isNodeDuplicable,
14
16
  type ItemNode,
15
17
  type RoofNode,
16
18
  type RoofSegmentNode,
@@ -155,9 +157,9 @@ function MobilePanelLayer({
155
157
  <MobileSelectionBar
156
158
  node={node}
157
159
  onDelete={handleDelete}
158
- onDuplicate={handleDuplicate}
160
+ onDuplicate={isNodeDuplicable(node) ? handleDuplicate : undefined}
159
161
  onEdit={() => setIsSheetOpen((v) => !v)}
160
- onMove={handleMove}
162
+ onMove={isMoveLocked(node) ? undefined : handleMove}
161
163
  />
162
164
  )}
163
165
  <MobilePanelSheet
@@ -45,7 +45,7 @@ function clamp(value: number, min: number, max: number): number {
45
45
  * `data-viewer-bounds`) so it can't slide under the sidebar or top bar.
46
46
  * Falls back to the viewport when the marker isn't found.
47
47
  */
48
- function getDragBounds(el: HTMLElement | null): {
48
+ function getDragBounds(el: HTMLElement | null, inStack = false): {
49
49
  left: number
50
50
  top: number
51
51
  right: number
@@ -56,7 +56,15 @@ function getDragBounds(el: HTMLElement | null): {
56
56
  if (!rect) {
57
57
  return { left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight }
58
58
  }
59
- return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
59
+ const stack = inStack ? el?.closest('[data-right-stack]') : null
60
+ const stackRect = stack?.getBoundingClientRect()
61
+ const cardRect = stack?.querySelector('[data-right-stack-card]')?.getBoundingClientRect()
62
+ return {
63
+ left: rect.left,
64
+ top: stackRect ? stackRect.top - DRAG_MARGIN : rect.top,
65
+ right: rect.right,
66
+ bottom: cardRect && cardRect.height > 0 ? cardRect.top : rect.bottom,
67
+ }
60
68
  }
61
69
 
62
70
  /**
@@ -187,7 +195,7 @@ export function PanelWrapper({
187
195
  if ((e.target as HTMLElement).closest('button, input, label')) return
188
196
  const rect = panelRef.current?.getBoundingClientRect()
189
197
  if (!rect) return
190
- const bounds = getDragBounds(panelRef.current)
198
+ const bounds = getDragBounds(panelRef.current, inStack)
191
199
  const base = offset ?? { x: 0, y: 0 }
192
200
  dragRef.current = {
193
201
  startX: e.clientX,
@@ -207,7 +215,7 @@ export function PanelWrapper({
207
215
  setIsDragging(true)
208
216
  e.currentTarget.setPointerCapture(e.pointerId)
209
217
  },
210
- [offset],
218
+ [offset, inStack],
211
219
  )
212
220
 
213
221
  const handleHeaderPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
@@ -238,22 +246,37 @@ export function PanelWrapper({
238
246
  [handleCardToggle],
239
247
  )
240
248
 
241
- // Expanding can grow the panel past an edge if it was dragged there while
242
- // collapsed — nudge it back inside the viewer bounds.
249
+ // Keep repositioned panels inside the available space as the card or viewer resizes.
243
250
  useLayoutEffect(() => {
244
- if (isMobile || collapsed) return
251
+ if (isMobile) return
245
252
  const el = panelRef.current
246
253
  if (!el) return
247
- const rect = el.getBoundingClientRect()
248
- const bounds = getDragBounds(el)
249
- const left = clamp(rect.left, bounds.left + DRAG_MARGIN, bounds.right - rect.width - DRAG_MARGIN)
250
- const top = clamp(rect.top, bounds.top + DRAG_MARGIN, bounds.bottom - rect.height - DRAG_MARGIN)
251
- const dx = left - rect.left
252
- const dy = top - rect.top
253
- if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
254
- setOffset((prev) => ({ x: (prev?.x ?? 0) + dx, y: (prev?.y ?? 0) + dy }))
254
+ const fit = () => {
255
+ const rect = el.getBoundingClientRect()
256
+ const bounds = getDragBounds(el, inStack)
257
+ const left = clamp(rect.left, bounds.left + DRAG_MARGIN, bounds.right - rect.width - DRAG_MARGIN)
258
+ const top = clamp(rect.top, bounds.top + DRAG_MARGIN, bounds.bottom - rect.height - DRAG_MARGIN)
259
+ const dx = left - rect.left
260
+ const dy = top - rect.top
261
+ if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
262
+ setOffset((prev) => ({ x: (prev?.x ?? 0) + dx, y: (prev?.y ?? 0) + dy }))
263
+ }
264
+ }
265
+ fit()
266
+ const resize = new ResizeObserver(fit)
267
+ resize.observe(el)
268
+ const stack = el.closest('[data-right-stack]')
269
+ if (stack) {
270
+ resize.observe(stack)
271
+ const card = stack.querySelector('[data-right-stack-card]')
272
+ if (card) resize.observe(card)
273
+ }
274
+ window.addEventListener('resize', fit)
275
+ return () => {
276
+ resize.disconnect()
277
+ window.removeEventListener('resize', fit)
255
278
  }
256
- }, [collapsed, isMobile])
279
+ }, [collapsed, isMobile, inStack])
257
280
 
258
281
  return (
259
282
  <div
@@ -264,7 +287,7 @@ export function PanelWrapper({
264
287
  : inStack
265
288
  ? // In the shared right column: it grows down and scrolls on itself,
266
289
  // leaving the shortcuts card below it its room (`right-stack.tsx`).
267
- 'pointer-events-auto flex max-h-full min-h-0 flex-[0_1_auto] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground'
290
+ 'pointer-events-auto flex max-h-[var(--inspector-available-height)] min-h-0 flex-[0_1_auto] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground'
268
291
  // Cap height at `100dvh - 154px` so a tall panel's bottom edge
269
292
  // aligns flush with the top of the floating bottom action bar.
270
293
  // Combined with `top-20` (80px), the panel's bottom sits at
@@ -291,7 +314,7 @@ export function PanelWrapper({
291
314
  {!isMobile && (
292
315
  <div
293
316
  className={cn(
294
- 'relative flex select-none items-center justify-between px-3 py-3',
317
+ 'relative flex shrink-0 select-none items-center justify-between px-3 py-3',
295
318
  !collapsed && 'border-border/50 border-b',
296
319
  isDragging ? 'cursor-grabbing' : 'cursor-grab',
297
320
  )}
@@ -34,7 +34,7 @@ import {
34
34
  roomFloorOwner,
35
35
  roomFootprint,
36
36
  } from '../../../lib/floor-footprints'
37
- import { formatLinearMeasurement } from '../../../lib/measurements'
37
+ import { formatLinearMeasurement } from '@pascal-app/core'
38
38
  import { startMezzanineDraft } from '../../../lib/mezzanine-draft'
39
39
  import { resolveRoomAssemblyHeights } from '../../../lib/room-assembly-overlay'
40
40
  import { type FloorChoice, roomBuiltOn, separateFloorSummary } from '../../../lib/room-built-on'
@@ -11,7 +11,7 @@ import {
11
11
  useFloorEditSession,
12
12
  } from '../../../lib/floor-edit-session'
13
13
  import { type OpeningSurface, startOpeningDraft } from '../../../lib/floor-opening-draft'
14
- import { formatAreaLabel } from '../../../lib/measurements'
14
+ import { formatAreaLabel } from '@pascal-app/core'
15
15
  import { pill } from './room-construction-rows'
16
16
  import { ShapeChoice } from './shape-choice'
17
17
 
@@ -17,7 +17,7 @@ import { type KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from
17
17
  import { startCeilingEdit } from '../../../lib/ceiling-edit-session'
18
18
  import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
19
19
  import { finishScriptCode } from '../../../lib/geometry-script/finish-scripts'
20
- import { formatAreaLabel, formatLinearMeasurement } from '../../../lib/measurements'
20
+ import { formatAreaLabel, formatLinearMeasurement } from '@pascal-app/core'
21
21
  import { addMezzanineStairs } from '../../../lib/mezzanine-stairs'
22
22
  import { roomFloorBase, roomRelativeFloorHeight } from '../../../lib/room-construction-commands'
23
23
  import { mezzanineElevationBounds } from '../../../lib/room-handle-drag'
@@ -14,16 +14,23 @@ test('the inspector keeps 180 px, or its own height if shorter, above the full c
14
14
  ).toEqual({ inspector: 0, card: 240 })
15
15
  })
16
16
 
17
- test('on a short window the inspector gives way first, down to its header', () => {
17
+ test('on a short window the help card scrolls to preserve editing space', () => {
18
18
  expect(rightStackFloors({ ...tall, naturalHeight: 500, stackHeight: 400 })).toEqual({
19
- inspector: 152,
20
- card: 240,
19
+ inspector: 180,
20
+ card: 212,
21
21
  })
22
22
  })
23
23
 
24
- test('on a very short window the inspector keeps its header and the card scrolls', () => {
24
+ test('expanded hints cannot squeeze the inspector down to just its header', () => {
25
25
  expect(rightStackFloors({ ...tall, naturalHeight: 500, stackHeight: 258 })).toEqual({
26
- inspector: 52,
27
- card: 198,
26
+ inspector: 180,
27
+ card: 70,
28
+ })
29
+ })
30
+
31
+ test('a column shorter than the inspector minimum still bounds both cards', () => {
32
+ expect(rightStackFloors({ ...tall, naturalHeight: 500, stackHeight: 100 })).toEqual({
33
+ inspector: 92,
34
+ card: 0,
28
35
  })
29
36
  })
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
3
+ import { createContext, type ReactNode, useContext, useLayoutEffect, useRef } from 'react'
4
4
  import { useIsMobile } from '../../hooks/use-mobile'
5
5
  import { cn } from '../../lib/utils'
6
6
  import { TOP_MENU_CLEARANCE } from './action-menu/placement'
@@ -20,9 +20,8 @@ const GAP = 8
20
20
 
21
21
  /**
22
22
  * The heights neither panel shrinks below. The inspector keeps 180 px (or its
23
- * own height, if shorter) while the full card fits under it, and never less
24
- * than its header row. The card shows in full whenever the column allows; on a
25
- * very short window it gives way and scrolls on itself — both stay usable.
23
+ * own height, if shorter) and never less than its header row when the column allows.
24
+ * The card uses the remaining room and scrolls on itself on a short window — both stay usable.
26
25
  */
27
26
  export function rightStackFloors(args: {
28
27
  naturalHeight: number
@@ -33,9 +32,10 @@ export function rightStackFloors(args: {
33
32
  const { naturalHeight, headerHeight, cardHeight, stackHeight } = args
34
33
  const gap = naturalHeight > 0 && cardHeight > 0 ? GAP : 0
35
34
  const header = Math.min(headerHeight, naturalHeight)
36
- const inspector = Math.max(
37
- header,
38
- Math.min(INSPECTOR_MIN_HEIGHT, naturalHeight, stackHeight - cardHeight - gap),
35
+ const inspector = Math.min(
36
+ naturalHeight,
37
+ stackHeight,
38
+ Math.max(header, Math.min(INSPECTOR_MIN_HEIGHT, stackHeight - gap)),
39
39
  )
40
40
  const card = Math.max(0, Math.min(cardHeight, stackHeight - gap - inspector))
41
41
  return { inspector, card }
@@ -78,7 +78,7 @@ export function RightStack({
78
78
 
79
79
  // `min(180px, its own height)` has no CSS form for a flex item that scrolls,
80
80
  // so both floors are measured (`rightStackFloors`).
81
- useEffect(() => {
81
+ useLayoutEffect(() => {
82
82
  if (isMobile) return
83
83
  const stack = stackRef.current
84
84
  const slot = inspectorRef.current
@@ -88,18 +88,29 @@ export function RightStack({
88
88
  const fit = () => {
89
89
  frame = 0
90
90
  const { natural, header } = inspectorHeights(slot)
91
+ const cardHeight = Array.from(card.children).reduce(
92
+ (height, child) => height + child.scrollHeight + (child instanceof HTMLElement ? child.offsetHeight - child.clientHeight : 0),
93
+ 0,
94
+ )
91
95
  const floors = rightStackFloors({
92
96
  naturalHeight: natural,
93
97
  headerHeight: header,
94
- cardHeight: card.scrollHeight,
98
+ cardHeight,
95
99
  stackHeight: stack.clientHeight,
96
100
  })
97
101
  const inspectorMin = `${floors.inspector}px`
98
102
  const cardMin = `${floors.card}px`
103
+ // Custom panels may wrap PanelWrapper in a non-flex element. Give the
104
+ // card an explicit inherited limit so those wrappers cannot bypass it.
105
+ const available = Math.max(0, stack.clientHeight - floors.card - (natural > 0 && floors.card > 0 ? GAP : 0))
106
+ const limit = `${available}px`
107
+ if (slot.style.getPropertyValue('--inspector-available-height') !== limit) {
108
+ slot.style.setProperty('--inspector-available-height', limit)
109
+ }
99
110
  if (slot.style.minHeight !== inspectorMin) slot.style.minHeight = inspectorMin
100
111
  if (card.style.minHeight !== cardMin) card.style.minHeight = cardMin
101
112
  // The card is click-through; it takes the pointer only when it has to scroll.
102
- const pointer = floors.card < card.scrollHeight ? 'auto' : ''
113
+ const pointer = floors.card < cardHeight ? 'auto' : ''
103
114
  if (card.style.pointerEvents !== pointer) card.style.pointerEvents = pointer
104
115
  }
105
116
  const schedule = () => {
@@ -23,11 +23,7 @@ import {
23
23
  DialogHeader,
24
24
  DialogTitle,
25
25
  } from '../../../../../components/ui/primitives/dialog'
26
- import {
27
- getAreaUnitLabel,
28
- type LinearUnit,
29
- squareMetersToAreaUnit,
30
- } from '../../../../../lib/measurements'
26
+ import { getAreaUnitLabel, type LinearUnit, squareMetersToAreaUnit } from '@pascal-app/core'
31
27
 
32
28
  export type PendingImport = {
33
29
  fileName: string
@@ -4,7 +4,7 @@ import Image from 'next/image'
4
4
  import { memo, useCallback, useEffect, useState } from 'react'
5
5
  import { useShallow } from 'zustand/react/shallow'
6
6
  import { startCeilingEditFromTreeSelection } from './../../../../../lib/ceiling-edit-session'
7
- import { formatAreaLabel } from './../../../../../lib/measurements'
7
+ import { formatAreaLabel } from '@pascal-app/core'
8
8
  import { InlineRenameInput } from './inline-rename-input'
9
9
  import { focusTreeNode, handleTreeSelection, TreeNode, TreeNodeWrapper } from './tree-node'
10
10
  import { TreeNodeActions } from './tree-node-actions'
@@ -60,14 +60,7 @@ import {
60
60
  import { addStoreyOnTop } from './../../../../../lib/add-storey'
61
61
  import { offerExteriorWallCopy } from './../../../../../components/ui/level-wall-copy-prompt'
62
62
  import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
63
- import {
64
- formatAreaLabel,
65
- getAreaUnitLabel,
66
- getLinearUnitLabel,
67
- linearUnitToMeters,
68
- metersToLinearUnit,
69
- squareMetersToAreaUnit,
70
- } from './../../../../../lib/measurements'
63
+ import { formatAreaLabel, getAreaUnitLabel, getLinearUnitLabel, linearUnitToMeters, metersToLinearUnit, squareMetersToAreaUnit } from '@pascal-app/core'
71
64
  import { createLocalGuideImage, createLocalScan } from './../../../../../lib/local-guide-image'
72
65
  import { editorHostTreeChildrenRegistry } from './../../../../../lib/host-tree-children'
73
66
  import { requestRoomDeletion } from './../../../../../lib/room-structure-commands'
@@ -2,7 +2,7 @@ import { type AnyNodeId, type SlabNode, usePascalI18n, useScene } from '@pascal-
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import Image from 'next/image'
4
4
  import { memo, useCallback, useState } from 'react'
5
- import { formatAreaLabel } from './../../../../../lib/measurements'
5
+ import { formatAreaLabel } from '@pascal-app/core'
6
6
  import { InlineRenameInput } from './inline-rename-input'
7
7
  import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
8
8
  import { TreeNodeActions } from './tree-node-actions'
@@ -2,7 +2,7 @@ import { useScene, type ZoneNode, usePascalI18n} from '@pascal-app/core'
2
2
  import { useViewer } from '@pascal-app/viewer'
3
3
  import { memo, useCallback, useState } from 'react'
4
4
  import { ColorDot } from './../../../../../components/ui/primitives/color-dot'
5
- import { formatAreaLabel } from './../../../../../lib/measurements'
5
+ import { formatAreaLabel } from '@pascal-app/core'
6
6
  import {
7
7
  selectZoneOrRoom,
8
8
  useZoneSelected,
@@ -27,7 +27,7 @@ import { useViewer } from '@pascal-app/viewer'
27
27
  import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react'
28
28
  import { useEffect, useState } from 'react'
29
29
  import { formatLayerThickness, parseLayerThickness } from '../../../lib/layer-thickness'
30
- import type { LinearUnit } from '../../../lib/measurements'
30
+ import type { LinearUnit } from '@pascal-app/core'
31
31
  import { cn } from '../../../lib/utils'
32
32
  import {
33
33
  saveProjectWallType,
@@ -4,6 +4,7 @@ import {
4
4
  type AnyNode,
5
5
  buildUnitReport,
6
6
  polygonInteriorPoint,
7
+ roomFloorElevation,
7
8
  sceneRegistry,
8
9
  useLocale,
9
10
  usePascalI18n,
@@ -15,7 +16,6 @@ import { useCallback, useEffect, useMemo } from 'react'
15
16
  import { Vector3 } from 'three'
16
17
  import { useShallow } from 'zustand/react/shallow'
17
18
  import { resolveRoomAssemblyHeights } from '../../lib/room-assembly-overlay'
18
- import { roomFloorElevation } from '../../lib/room-handle-drag'
19
19
  import { describeZoneOutline, type RoomSelectionRecord } from '../../lib/room-selection'
20
20
  import {
21
21
  selectViewerRoom,