@pascal-app/editor 1.0.0-beta.4 → 1.0.0-beta.5
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.
- package/package.json +7 -6
- package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
- package/src/components/editor/editor-layout-v2.tsx +8 -3
- package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
- package/src/components/editor/first-person-controls.tsx +2 -45
- package/src/components/editor/floating-action-menu.tsx +13 -4
- package/src/components/editor/floorplan-background-selection.test.ts +31 -4
- package/src/components/editor/floorplan-background-selection.ts +27 -7
- package/src/components/editor/floorplan-panel.tsx +5 -45
- package/src/components/editor/group-floating-action-menu.tsx +30 -15
- package/src/components/editor/index.tsx +112 -25
- package/src/components/editor/measurement-pill.tsx +12 -12
- package/src/components/editor/node-action-menu.tsx +29 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
- package/src/components/editor/selection-manager.tsx +28 -5
- package/src/components/editor/site-edge-labels.tsx +2 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
- package/src/components/editor/thumbnail-generator.tsx +27 -11
- package/src/components/editor/wall-measurement-label.tsx +3 -2
- package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
- package/src/components/tools/item/placement-strategies.test.ts +167 -0
- package/src/components/tools/item/placement-strategies.ts +57 -22
- package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
- package/src/components/tools/shared/placement-box.tsx +9 -4
- package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
- package/src/components/tools/wall/wall-drafting.test.ts +207 -2
- package/src/components/tools/wall/wall-drafting.ts +84 -400
- package/src/components/ui/command-palette/editor-commands.tsx +3 -1
- package/src/components/ui/floating-level-selector.tsx +1 -1
- package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
- package/src/components/ui/panels/panel-wrapper.tsx +228 -12
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
- package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
- package/src/components/ui/sidebar/tab-bar.tsx +7 -1
- package/src/components/viewer/floorplan-compass-button.tsx +50 -0
- package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
- package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
- package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
- package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
- package/src/components/viewer/floorplan-preview.tsx +1069 -0
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
- package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
- package/src/components/viewer/viewer-stage-modes.ts +49 -0
- package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
- package/src/components/viewer/viewer-stage.test.tsx +47 -0
- package/src/components/viewer/viewer-stage.tsx +218 -0
- package/src/components/viewer-overlay.tsx +12 -8
- package/src/hooks/use-auto-save.test.ts +50 -1
- package/src/hooks/use-auto-save.ts +44 -9
- package/src/hooks/use-keyboard.ts +35 -1
- package/src/index.tsx +27 -0
- package/src/lib/contextual-help.test.ts +8 -0
- package/src/lib/contextual-help.ts +8 -0
- package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
- package/src/lib/floorplan/floorplan-readonly.ts +84 -0
- package/src/lib/history.test.ts +75 -10
- package/src/lib/history.ts +58 -13
- package/src/lib/inspector-card-mode.test.ts +92 -0
- package/src/lib/inspector-card-mode.ts +60 -0
- package/src/lib/measurement-parser.ts +3 -0
- package/src/lib/measurements.test.ts +4 -0
- package/src/lib/paint-scope.test.ts +58 -2
- package/src/lib/paint-scope.ts +8 -2
- package/src/lib/selection-routing.test.ts +20 -5
- package/src/lib/selection-routing.ts +15 -1
- package/src/lib/session-groups.test.ts +88 -0
- package/src/lib/session-groups.ts +201 -0
- package/src/lib/structured-clone-fallback.test.ts +45 -0
- package/src/lib/structured-clone-fallback.ts +24 -0
- package/src/store/use-session-groups.ts +103 -0
|
@@ -1,17 +1,40 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
+
import { Icon } from '@iconify/react'
|
|
4
|
+
import {
|
|
5
|
+
type AnyNode,
|
|
6
|
+
type AnyNodeId,
|
|
7
|
+
getInspectorExtensions,
|
|
8
|
+
type IconRef,
|
|
9
|
+
type InspectorExtension,
|
|
10
|
+
useRegistryVersion,
|
|
11
|
+
useScene,
|
|
12
|
+
} from '@pascal-app/core'
|
|
13
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
14
|
import { ChevronDown, ChevronLeft, GripHorizontal, RotateCcw, X } from 'lucide-react'
|
|
4
15
|
import Image from 'next/image'
|
|
5
16
|
import {
|
|
17
|
+
type ComponentType,
|
|
6
18
|
createContext,
|
|
19
|
+
lazy,
|
|
20
|
+
Suspense,
|
|
7
21
|
useCallback,
|
|
8
22
|
useContext,
|
|
23
|
+
useEffect,
|
|
9
24
|
useLayoutEffect,
|
|
25
|
+
useMemo,
|
|
10
26
|
useRef,
|
|
11
27
|
useState,
|
|
12
28
|
} from 'react'
|
|
13
29
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
|
30
|
+
import {
|
|
31
|
+
resolveActiveExtension,
|
|
32
|
+
toggleCard,
|
|
33
|
+
toggleExtension,
|
|
34
|
+
} from '../../../lib/inspector-card-mode'
|
|
14
35
|
import { cn } from '../../../lib/utils'
|
|
36
|
+
import { PanelSection } from '../controls/panel-section'
|
|
37
|
+
import { ErrorBoundary } from '../primitives/error-boundary'
|
|
15
38
|
|
|
16
39
|
const DRAG_MARGIN = 8
|
|
17
40
|
// Pointer travel (px) below which a header press is treated as a click
|
|
@@ -92,6 +115,36 @@ export function PanelWrapper({
|
|
|
92
115
|
|
|
93
116
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
94
117
|
|
|
118
|
+
// ── Plugin inspector extensions ────────────────────────────────────
|
|
119
|
+
// The wrapper self-resolves the selected node instead of taking a prop:
|
|
120
|
+
// kind-owned `customPanel`s (wall, slab, …) render their own
|
|
121
|
+
// <PanelWrapper>, so this is the one spot every inspector card flows
|
|
122
|
+
// through. Extensions only apply to a single-node selection.
|
|
123
|
+
const registryVersion = useRegistryVersion()
|
|
124
|
+
const selectedId = useViewer((s) =>
|
|
125
|
+
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : undefined,
|
|
126
|
+
) as AnyNodeId | undefined
|
|
127
|
+
// Subscribe to the selected node's *type* only — a string primitive that
|
|
128
|
+
// doesn't change as fields are edited (same trick as ParametricInspector).
|
|
129
|
+
const selectedType = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
|
130
|
+
const installedPlugins = useScene((s) => s.installedPlugins)
|
|
131
|
+
const extensions = useMemo(() => {
|
|
132
|
+
// re-derive when plugin extensions register after mount (async plugin load)
|
|
133
|
+
void registryVersion
|
|
134
|
+
if (!selectedType) return []
|
|
135
|
+
return getInspectorExtensions(selectedType).filter(
|
|
136
|
+
(extension) => !extension.pluginId || installedPlugins.includes(extension.pluginId),
|
|
137
|
+
)
|
|
138
|
+
}, [selectedType, installedPlugins, registryVersion])
|
|
139
|
+
|
|
140
|
+
// Which extension's content fills the card body (extension mode). The two
|
|
141
|
+
// expanded modes are EITHER/OR: extension mode replaces the regular
|
|
142
|
+
// controls; null shows the regular controls with no extension sections.
|
|
143
|
+
// See `lib/inspector-card-mode.ts` for the transition table.
|
|
144
|
+
const [activeExtensionId, setActiveExtensionId] = useState<string | null>(null)
|
|
145
|
+
// Stale ids (kind changed, plugin gated off) fall back to regular mode.
|
|
146
|
+
const activeExtension = resolveActiveExtension(activeExtensionId, extensions)
|
|
147
|
+
|
|
95
148
|
// The whole panel is collapsed to just its header by default; the chevron
|
|
96
149
|
// expands it to reveal the inspector body. Keep the desktop value shared
|
|
97
150
|
// across inspector swaps (roof ↔ segment, etc.) so navigating between
|
|
@@ -109,6 +162,26 @@ export function PanelWrapper({
|
|
|
109
162
|
[],
|
|
110
163
|
)
|
|
111
164
|
|
|
165
|
+
const applyMode = useCallback(
|
|
166
|
+
(next: { collapsed: boolean; activeExtensionId: string | null }) => {
|
|
167
|
+
setCollapsed(next.collapsed)
|
|
168
|
+
setActiveExtensionId(next.activeExtensionId)
|
|
169
|
+
},
|
|
170
|
+
[setCollapsed],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
// Chevron / header press — collapsed → regular, regular → collapsed,
|
|
174
|
+
// extension mode → regular (exit the extension first, stay expanded).
|
|
175
|
+
const handleCardToggle = useCallback(() => {
|
|
176
|
+
applyMode(toggleCard({ collapsed, activeExtensionId }))
|
|
177
|
+
}, [applyMode, collapsed, activeExtensionId])
|
|
178
|
+
|
|
179
|
+
// Folding the card forgets the active extension — extension mode is a
|
|
180
|
+
// one-shot affordance of the header icon, not sticky panel state.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (collapsed) setActiveExtensionId(null)
|
|
183
|
+
}, [collapsed])
|
|
184
|
+
|
|
112
185
|
// Drag-to-reposition from the header. `offset` is a translation applied on
|
|
113
186
|
// top of the default `top-20 right-4` anchor; null until first dragged.
|
|
114
187
|
// Dragging is clamped so no edge of the panel leaves the viewport.
|
|
@@ -173,15 +246,19 @@ export function PanelWrapper({
|
|
|
173
246
|
setOffset({ x: drag.baseX + (left - drag.rectLeft), y: drag.baseY + (top - drag.rectTop) })
|
|
174
247
|
}, [])
|
|
175
248
|
|
|
176
|
-
const handleHeaderPointerUp = useCallback(
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
249
|
+
const handleHeaderPointerUp = useCallback(
|
|
250
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
251
|
+
const drag = dragRef.current
|
|
252
|
+
if (!drag) return
|
|
253
|
+
dragRef.current = null
|
|
254
|
+
setIsDragging(false)
|
|
255
|
+
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
256
|
+
// A press that never turned into a drag is a click → same mode toggle
|
|
257
|
+
// as the chevron.
|
|
258
|
+
if (!drag.moved) handleCardToggle()
|
|
259
|
+
},
|
|
260
|
+
[handleCardToggle],
|
|
261
|
+
)
|
|
185
262
|
|
|
186
263
|
// Expanding can grow the panel past an edge if it was dragged there while
|
|
187
264
|
// collapsed — nudge it back inside the viewer bounds.
|
|
@@ -278,11 +355,39 @@ export function PanelWrapper({
|
|
|
278
355
|
<RotateCcw className="h-4 w-4" />
|
|
279
356
|
</button>
|
|
280
357
|
)}
|
|
358
|
+
{/* Extension mode buttons — one icon per registered inspector
|
|
359
|
+
extension, left of the chevron. Click swaps the card body to
|
|
360
|
+
ONLY that extension's content (either/or with the regular
|
|
361
|
+
controls); the active icon (highlighted) or the chevron
|
|
362
|
+
returns to the regular controls. */}
|
|
363
|
+
{extensions.map((extension) => {
|
|
364
|
+
const isActive = !collapsed && activeExtensionId === extension.id
|
|
365
|
+
return (
|
|
366
|
+
<button
|
|
367
|
+
aria-label={isActive ? `Close ${extension.title}` : `Open ${extension.title}`}
|
|
368
|
+
aria-pressed={isActive}
|
|
369
|
+
className={cn(
|
|
370
|
+
'flex h-7 w-7 items-center justify-center rounded-md transition-colors',
|
|
371
|
+
isActive
|
|
372
|
+
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30'
|
|
373
|
+
: 'bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
|
374
|
+
)}
|
|
375
|
+
key={extension.id}
|
|
376
|
+
onClick={() =>
|
|
377
|
+
applyMode(toggleExtension({ collapsed, activeExtensionId }, extension.id))
|
|
378
|
+
}
|
|
379
|
+
title={extension.title}
|
|
380
|
+
type="button"
|
|
381
|
+
>
|
|
382
|
+
{renderExtensionIcon(extension.icon)}
|
|
383
|
+
</button>
|
|
384
|
+
)
|
|
385
|
+
})}
|
|
281
386
|
<button
|
|
282
387
|
aria-expanded={!collapsed}
|
|
283
388
|
aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
|
|
284
389
|
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
|
285
|
-
onClick={
|
|
390
|
+
onClick={handleCardToggle}
|
|
286
391
|
type="button"
|
|
287
392
|
>
|
|
288
393
|
<ChevronDown
|
|
@@ -302,9 +407,37 @@ export function PanelWrapper({
|
|
|
302
407
|
</div>
|
|
303
408
|
)}
|
|
304
409
|
|
|
305
|
-
{/* Content — hidden while the panel is collapsed (desktop).
|
|
410
|
+
{/* Content — hidden while the panel is collapsed (desktop). The two
|
|
411
|
+
expanded modes are EITHER/OR: extension mode renders ONLY that
|
|
412
|
+
extension's content; regular mode renders ONLY the kind's own
|
|
413
|
+
controls (`children`). A stale extension id falls back to regular
|
|
414
|
+
via `resolveActiveExtension`. */}
|
|
306
415
|
{!(collapsed && !isMobile) && (
|
|
307
|
-
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
416
|
+
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
417
|
+
{!isMobile && selectedId && activeExtension ? (
|
|
418
|
+
<InspectorExtensionSection
|
|
419
|
+
extension={activeExtension}
|
|
420
|
+
key={activeExtension.id}
|
|
421
|
+
nodeId={selectedId}
|
|
422
|
+
/>
|
|
423
|
+
) : (
|
|
424
|
+
<>
|
|
425
|
+
{children}
|
|
426
|
+
{/* Mobile sheet has no header icons to swap modes — keep the
|
|
427
|
+
plugin sections appended after the kind's controls there. */}
|
|
428
|
+
{isMobile &&
|
|
429
|
+
selectedId &&
|
|
430
|
+
extensions.map((extension) => (
|
|
431
|
+
<InspectorExtensionSection
|
|
432
|
+
defaultExpanded={false}
|
|
433
|
+
extension={extension}
|
|
434
|
+
key={extension.id}
|
|
435
|
+
nodeId={selectedId}
|
|
436
|
+
/>
|
|
437
|
+
))}
|
|
438
|
+
</>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
308
441
|
)}
|
|
309
442
|
|
|
310
443
|
{resolvedFooter && !(collapsed && !isMobile) && (
|
|
@@ -313,3 +446,86 @@ export function PanelWrapper({
|
|
|
313
446
|
</div>
|
|
314
447
|
)
|
|
315
448
|
}
|
|
449
|
+
|
|
450
|
+
// ─── Plugin inspector extensions ──────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/** 16px icon for an extension's header button — mirrors the parametric
|
|
453
|
+
* inspector's `renderIcon` (plain <img> so no next/image server deps). */
|
|
454
|
+
function renderExtensionIcon(ref: IconRef): React.ReactNode {
|
|
455
|
+
if (ref.kind === 'url') {
|
|
456
|
+
return <img alt="" className="h-4 w-4 shrink-0 object-contain" src={ref.src} />
|
|
457
|
+
}
|
|
458
|
+
if (ref.kind === 'iconify') {
|
|
459
|
+
return <Icon height={16} icon={ref.name} width={16} />
|
|
460
|
+
}
|
|
461
|
+
if (ref.kind === 'svg') {
|
|
462
|
+
return (
|
|
463
|
+
<svg height={16} viewBox={ref.viewBox} width={16}>
|
|
464
|
+
<path d={ref.path} fill="currentColor" />
|
|
465
|
+
</svg>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
const LazyIcon = lazy(ref.module)
|
|
469
|
+
return (
|
|
470
|
+
<Suspense fallback={null}>
|
|
471
|
+
<LazyIcon />
|
|
472
|
+
</Suspense>
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// `React.lazy` once per loader so the resolved component keeps a stable
|
|
477
|
+
// identity across renders — same WeakMap pattern as `resolveCustomPanel`
|
|
478
|
+
// in parametric-inspector.tsx.
|
|
479
|
+
const extensionComponentCache = new WeakMap<
|
|
480
|
+
InspectorExtension['component'],
|
|
481
|
+
ComponentType<{ node: AnyNode }>
|
|
482
|
+
>()
|
|
483
|
+
|
|
484
|
+
function resolveExtensionComponent(
|
|
485
|
+
extension: InspectorExtension,
|
|
486
|
+
): ComponentType<{ node: AnyNode }> {
|
|
487
|
+
const cached = extensionComponentCache.get(extension.component)
|
|
488
|
+
if (cached) return cached
|
|
489
|
+
// `LazyComponent` is prop-agnostic; the inspector-extension contract is
|
|
490
|
+
// that the default export accepts `{ node }` (see the type's docs).
|
|
491
|
+
const Comp = lazy(extension.component) as unknown as ComponentType<{ node: AnyNode }>
|
|
492
|
+
extensionComponentCache.set(extension.component, Comp)
|
|
493
|
+
return Comp
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* One plugin-contributed inspector section. Subscribes to the full node —
|
|
498
|
+
* the section body reflects any edit — and hands it to the extension's
|
|
499
|
+
* lazy component inside its own error boundary so a crashing plugin
|
|
500
|
+
* section can't take down the inspector card. On desktop this is the
|
|
501
|
+
* card's SOLE body while its extension is active (either/or with the
|
|
502
|
+
* regular controls); the mobile sheet appends it after them instead.
|
|
503
|
+
*/
|
|
504
|
+
function InspectorExtensionSection({
|
|
505
|
+
defaultExpanded = true,
|
|
506
|
+
extension,
|
|
507
|
+
nodeId,
|
|
508
|
+
}: {
|
|
509
|
+
defaultExpanded?: boolean
|
|
510
|
+
extension: InspectorExtension
|
|
511
|
+
nodeId: AnyNodeId
|
|
512
|
+
}) {
|
|
513
|
+
const node = useScene((s) => s.nodes[nodeId])
|
|
514
|
+
if (!node) return null
|
|
515
|
+
const Extension = resolveExtensionComponent(extension)
|
|
516
|
+
return (
|
|
517
|
+
<PanelSection defaultExpanded={defaultExpanded} title={extension.title}>
|
|
518
|
+
<ErrorBoundary
|
|
519
|
+
fallback={
|
|
520
|
+
<p className="p-1 text-muted-foreground text-xs">
|
|
521
|
+
“{extension.title}” hit an error and was unloaded for this session.
|
|
522
|
+
</p>
|
|
523
|
+
}
|
|
524
|
+
>
|
|
525
|
+
<Suspense fallback={null}>
|
|
526
|
+
<Extension node={node} />
|
|
527
|
+
</Suspense>
|
|
528
|
+
</ErrorBoundary>
|
|
529
|
+
</PanelSection>
|
|
530
|
+
)
|
|
531
|
+
}
|
|
@@ -118,9 +118,14 @@ export function FunctionTreePanel({
|
|
|
118
118
|
<div className="flex h-full flex-col">
|
|
119
119
|
{/* Root nodes as a category grid — icon when available, otherwise a
|
|
120
120
|
two-letter abbreviation, with the full name in a hover tooltip.
|
|
121
|
-
Mirrors the Build tab's tile grid so the two panels read the same.
|
|
121
|
+
Mirrors the Build tab's tile grid so the two panels read the same.
|
|
122
|
+
|
|
123
|
+
`functionTree` is embedder-supplied and unbounded, and these tiles are
|
|
124
|
+
`aspect-square`, so row count grows with it while the item list below is
|
|
125
|
+
the only scroller. Without the cap a large taxonomy pushes the list out
|
|
126
|
+
of the panel entirely and nothing can scroll it back. */}
|
|
122
127
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
|
123
|
-
<div className="grid shrink-0 grid-cols-5 gap-1.5 border-border/70 border-b p-2">
|
|
128
|
+
<div className="grid max-h-[40%] shrink-0 grid-cols-5 gap-1.5 overflow-y-auto border-border/70 border-b p-2">
|
|
124
129
|
{functionTree.map((root) => {
|
|
125
130
|
const isActive = activeRoot?.slug === root.slug
|
|
126
131
|
return (
|
|
@@ -106,6 +106,17 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
|
|
106
106
|
action: 'Rotate a multi-selection ±45° around its center',
|
|
107
107
|
note: 'Also works mid-move while carrying the selection.',
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
keys: ['Cmd/Ctrl', 'G'],
|
|
111
|
+
action: 'Group the multi-selection (session only)',
|
|
112
|
+
note:
|
|
113
|
+
'Editor-only. Plain click a member later to reselect the whole group. Not saved with the project.',
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
keys: ['Cmd/Ctrl', 'Shift', 'G'],
|
|
117
|
+
action: 'Ungroup the session selection',
|
|
118
|
+
note: 'Keeps the current selection; only dissolves the session group.',
|
|
119
|
+
},
|
|
109
120
|
{
|
|
110
121
|
keys: ['Esc'],
|
|
111
122
|
action: 'Clear the selection',
|
|
@@ -51,6 +51,7 @@ import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
|
|
|
51
51
|
import { cn } from './../../../../../lib/utils'
|
|
52
52
|
import useEditor from './../../../../../store/use-editor'
|
|
53
53
|
import { useUploadStore } from '../../../../../store/use-upload'
|
|
54
|
+
import { MetricControl } from '../../../controls/metric-control'
|
|
54
55
|
import { LevelDuplicateDialog } from '../../../level-duplicate-dialog'
|
|
55
56
|
import { InlineRenameInput } from './inline-rename-input'
|
|
56
57
|
import { focusTreeNode, TreeNode } from './tree-node'
|
|
@@ -873,6 +874,17 @@ const LevelItem = memo(function LevelItem({
|
|
|
873
874
|
initial={{ height: 0, opacity: 0 }}
|
|
874
875
|
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
|
|
875
876
|
>
|
|
877
|
+
<div className="relative border-border/50 border-b py-2 pr-3 pl-[60px]">
|
|
878
|
+
<div className="pointer-events-none absolute top-0 bottom-0 left-[45px] z-10 w-px bg-border/50" />
|
|
879
|
+
<MetricControl
|
|
880
|
+
label="Base elevation"
|
|
881
|
+
onChange={(value) => updateNode(level.id, { baseElevation: value })}
|
|
882
|
+
precision={2}
|
|
883
|
+
step={0.05}
|
|
884
|
+
unit="m"
|
|
885
|
+
value={Math.round((level.baseElevation ?? 0) * 100) / 100}
|
|
886
|
+
/>
|
|
887
|
+
</div>
|
|
876
888
|
<LevelReferences
|
|
877
889
|
isLastLevel={isLast}
|
|
878
890
|
levelId={level.id}
|
|
@@ -5,6 +5,7 @@ import { AnimatePresence, motion } from 'motion/react'
|
|
|
5
5
|
import { forwardRef, memo, useEffect, useRef } from 'react'
|
|
6
6
|
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
|
|
7
7
|
import useEditor from '../../../../../store/use-editor'
|
|
8
|
+
import { expandSessionSelectionForNode } from '../../../../../store/use-session-groups'
|
|
8
9
|
|
|
9
10
|
export function handleTreeSelection(
|
|
10
11
|
e: React.MouseEvent,
|
|
@@ -48,7 +49,13 @@ export function handleTreeSelection(
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
if (e.altKey) {
|
|
53
|
+
setSelection({ selectedIds: [nodeId] })
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const expanded = expandSessionSelectionForNode(nodeId)
|
|
58
|
+
setSelection({ selectedIds: expanded && expanded.length > 1 ? expanded : [nodeId] })
|
|
52
59
|
return false
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -13,6 +13,12 @@ export type SidebarTab = {
|
|
|
13
13
|
mobileIcon?: ReactNode
|
|
14
14
|
/** Desktop icon shown in the vertical rail (v2 layout). */
|
|
15
15
|
icon?: ReactNode
|
|
16
|
+
/**
|
|
17
|
+
* Rail entry that drives the stage instead of opening a sidebar panel:
|
|
18
|
+
* activating it hides the panel column (preserving its collapse state) and
|
|
19
|
+
* keeps the icon highlighted regardless of collapse.
|
|
20
|
+
*/
|
|
21
|
+
noPanel?: boolean
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
interface TabBarProps {
|
|
@@ -75,7 +81,7 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
|
|
|
75
81
|
const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
|
|
76
82
|
|
|
77
83
|
const renderTab = (tab: SidebarTab) => {
|
|
78
|
-
const showActive = activeTab === tab.id && !collapsed
|
|
84
|
+
const showActive = activeTab === tab.id && (!collapsed || tab.noPanel === true)
|
|
79
85
|
return (
|
|
80
86
|
<Tooltip key={tab.id}>
|
|
81
87
|
<TooltipTrigger asChild>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type React from 'react'
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
|
5
|
+
|
|
6
|
+
export type FloorplanCompassButtonProps = {
|
|
7
|
+
northRotationDeg: number
|
|
8
|
+
onAlignNorth: () => void
|
|
9
|
+
needleRef?: React.RefObject<SVGSVGElement | null>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FloorplanCompassButton({
|
|
13
|
+
northRotationDeg,
|
|
14
|
+
onAlignNorth,
|
|
15
|
+
needleRef,
|
|
16
|
+
}: FloorplanCompassButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<Tooltip>
|
|
19
|
+
<TooltipTrigger asChild>
|
|
20
|
+
<button
|
|
21
|
+
aria-label="Align view to north"
|
|
22
|
+
className="group pointer-events-auto absolute bottom-3 left-3 z-30 flex h-8 w-8 items-center justify-center rounded-full border border-black/10 bg-white/85 shadow-sm backdrop-blur-md transition hover:bg-white hover:shadow-md dark:border-white/10 dark:bg-neutral-900/85 dark:hover:bg-neutral-900"
|
|
23
|
+
onClick={(event) => {
|
|
24
|
+
event.preventDefault()
|
|
25
|
+
event.stopPropagation()
|
|
26
|
+
onAlignNorth()
|
|
27
|
+
}}
|
|
28
|
+
onPointerDown={(event) => {
|
|
29
|
+
event.stopPropagation()
|
|
30
|
+
}}
|
|
31
|
+
type="button"
|
|
32
|
+
>
|
|
33
|
+
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-[#b8b8b8] shadow-inner dark:bg-neutral-700">
|
|
34
|
+
<svg
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
className="h-6 w-6"
|
|
37
|
+
ref={needleRef}
|
|
38
|
+
style={{ transform: `rotate(${northRotationDeg}deg)` }}
|
|
39
|
+
viewBox="0 0 48 48"
|
|
40
|
+
>
|
|
41
|
+
<path d="M24 4.5 31.5 25 24 21.5 16.5 25Z" fill="#f15b5b" />
|
|
42
|
+
<path d="M24 43.5 16.5 23 24 26.5 31.5 23Z" fill="#ffffff" />
|
|
43
|
+
</svg>
|
|
44
|
+
</span>
|
|
45
|
+
</button>
|
|
46
|
+
</TooltipTrigger>
|
|
47
|
+
<TooltipContent side="right">Align view to north</TooltipContent>
|
|
48
|
+
</Tooltip>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { FloorplanGeometry } from '@pascal-app/core'
|
|
3
|
+
import {
|
|
4
|
+
clientToFloorplanPoint,
|
|
5
|
+
getFloorplanBounds,
|
|
6
|
+
padFloorplanBounds,
|
|
7
|
+
panFloorplanViewBox,
|
|
8
|
+
scaleFloorplanViewBox,
|
|
9
|
+
scaleFloorplanViewBoxBetweenClients,
|
|
10
|
+
} from './floorplan-preview-geometry'
|
|
11
|
+
|
|
12
|
+
describe('floorplan preview bounds', () => {
|
|
13
|
+
test('composes nested translation and rotation', () => {
|
|
14
|
+
const geometry: FloorplanGeometry = {
|
|
15
|
+
kind: 'group',
|
|
16
|
+
transform: { translate: [10, 5], rotate: Math.PI / 2 },
|
|
17
|
+
children: [
|
|
18
|
+
{
|
|
19
|
+
kind: 'rect',
|
|
20
|
+
x: 0,
|
|
21
|
+
y: 0,
|
|
22
|
+
width: 4,
|
|
23
|
+
height: 2,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
expect(getFloorplanBounds([geometry])).toEqual({
|
|
29
|
+
minX: 8,
|
|
30
|
+
minY: 5,
|
|
31
|
+
maxX: 10,
|
|
32
|
+
maxY: 9,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('includes image rotation and dimension baselines', () => {
|
|
37
|
+
const geometries: FloorplanGeometry[] = [
|
|
38
|
+
{
|
|
39
|
+
kind: 'image',
|
|
40
|
+
url: '/symbol.png',
|
|
41
|
+
center: [2, 3],
|
|
42
|
+
width: 4,
|
|
43
|
+
height: 2,
|
|
44
|
+
rotation: Math.PI / 2,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
kind: 'dimension',
|
|
48
|
+
start: [-3, -2],
|
|
49
|
+
end: [3, -2],
|
|
50
|
+
dimensionStart: [-4, -1],
|
|
51
|
+
dimensionEnd: [4, -1],
|
|
52
|
+
offsetNormal: [0, -1],
|
|
53
|
+
offsetDistance: 1,
|
|
54
|
+
extensionOvershoot: 0.2,
|
|
55
|
+
text: '8 m',
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
expect(getFloorplanBounds(geometries)).toEqual({
|
|
60
|
+
minX: -4,
|
|
61
|
+
minY: -2,
|
|
62
|
+
maxX: 4,
|
|
63
|
+
maxY: 5,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('pads narrow plans by a usable minimum', () => {
|
|
68
|
+
expect(padFloorplanBounds({ minX: 0, minY: 0, maxX: 0, maxY: 0 })).toEqual({
|
|
69
|
+
minX: -0.75,
|
|
70
|
+
minY: -0.75,
|
|
71
|
+
maxX: 0.75,
|
|
72
|
+
maxY: 0.75,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('maps clients through letterboxed SVG content', () => {
|
|
77
|
+
const viewBox = { x: 0, y: 0, width: 10, height: 10 }
|
|
78
|
+
const viewport = { left: 0, top: 0, width: 1000, height: 500 }
|
|
79
|
+
|
|
80
|
+
expect(clientToFloorplanPoint(viewBox, viewport, 250, 0)).toEqual([0, 0])
|
|
81
|
+
expect(clientToFloorplanPoint(viewBox, viewport, 750, 500)).toEqual([10, 10])
|
|
82
|
+
expect(panFloorplanViewBox(viewBox, viewport, [500, 250], [550, 250])).toEqual({
|
|
83
|
+
x: -1,
|
|
84
|
+
y: 0,
|
|
85
|
+
width: 10,
|
|
86
|
+
height: 10,
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('keeps the plan point beneath a moving pinch midpoint', () => {
|
|
91
|
+
const viewBox = { x: 0, y: 0, width: 10, height: 10 }
|
|
92
|
+
const viewport = { left: 0, top: 0, width: 1000, height: 500 }
|
|
93
|
+
const next = scaleFloorplanViewBoxBetweenClients(viewBox, 0.5, viewport, [500, 250], [600, 250])
|
|
94
|
+
|
|
95
|
+
expect(next).toEqual({ x: 1.5, y: 2.5, width: 5, height: 5 })
|
|
96
|
+
expect(clientToFloorplanPoint(next, viewport, 600, 250)).toEqual([5, 5])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('clamps zoom without changing the view box aspect ratio', () => {
|
|
100
|
+
expect(scaleFloorplanViewBox({ x: 0, y: 0, width: 100, height: 1 }, 0.01)).toEqual({
|
|
101
|
+
x: 37.5,
|
|
102
|
+
y: 0.375,
|
|
103
|
+
width: 25,
|
|
104
|
+
height: 0.25,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
})
|