@pascal-app/editor 1.0.0-beta.3 → 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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
const native = globalThis.structuredClone
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
globalThis.structuredClone = native
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
async function installFallback() {
|
|
10
|
+
// Fresh module instance each time — the shim is a module side effect.
|
|
11
|
+
await import(`./structured-clone-fallback?${Math.random()}`)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('structuredClone fallback', () => {
|
|
15
|
+
test('leaves a native implementation alone', async () => {
|
|
16
|
+
await installFallback()
|
|
17
|
+
expect(globalThis.structuredClone).toBe(native)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('installs a JSON-based clone when the global is missing', async () => {
|
|
21
|
+
// @ts-expect-error — simulating a browser without the global.
|
|
22
|
+
globalThis.structuredClone = undefined
|
|
23
|
+
await installFallback()
|
|
24
|
+
|
|
25
|
+
const source = { units: [{ factor: 1, id: 'm' }], kind: 'length' }
|
|
26
|
+
const copy = structuredClone(source)
|
|
27
|
+
|
|
28
|
+
expect(copy).toEqual(source)
|
|
29
|
+
expect(copy).not.toBe(source)
|
|
30
|
+
expect(copy.units).not.toBe(source.units)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('lets lingo build its registry without the global', async () => {
|
|
34
|
+
// @ts-expect-error — simulating a browser without the global.
|
|
35
|
+
globalThis.structuredClone = undefined
|
|
36
|
+
await installFallback()
|
|
37
|
+
|
|
38
|
+
// The actual regression: lingo clones its kind table at module-eval time,
|
|
39
|
+
// so this import throws on a browser lacking structuredClone.
|
|
40
|
+
const { parseQuantity } = await import('@pascal-app/lingo')
|
|
41
|
+
const result = parseQuantity('180cm', { kind: 'length', unit: 'm' })
|
|
42
|
+
expect(result.ok).toBe(true)
|
|
43
|
+
if (result.ok) expect(result.quantity.to('m').value).toBeCloseTo(1.8, 6)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@pascal-app/lingo` builds its unit registry at module-eval time, and
|
|
3
|
+
* `registerKind` deep-copies each kind definition with `structuredClone`. On a
|
|
4
|
+
* browser without that global (Chromium <98 — reported from Honor Browser 9.8
|
|
5
|
+
* as `ReferenceError: structuredClone is not defined`) the throw happens while
|
|
6
|
+
* the module graph is still evaluating, so a component-level guard can never
|
|
7
|
+
* run in time and the whole editor bundle fails to load.
|
|
8
|
+
*
|
|
9
|
+
* This has to be imported for its side effect from the module that pulls lingo
|
|
10
|
+
* in, so it is installed wherever `@pascal-app/editor` is loaded — the OSS app,
|
|
11
|
+
* the hosted app, and any npm consumer. An app-level polyfill would only cover
|
|
12
|
+
* the app that declares it.
|
|
13
|
+
*
|
|
14
|
+
* Scoped deliberately narrowly: the kind definitions lingo clones are plain
|
|
15
|
+
* JSON (strings, numbers, arrays of those), so a JSON round-trip is sufficient
|
|
16
|
+
* and avoids a dependency. This is NOT a spec-compliant `structuredClone` — it
|
|
17
|
+
* has no support for Map/Set/Date/ArrayBuffer/cycles and throws a `TypeError`
|
|
18
|
+
* rather than a `DataCloneError`. Anything needing real structured-clone
|
|
19
|
+
* semantics must not rely on this shim.
|
|
20
|
+
*/
|
|
21
|
+
if (typeof globalThis.structuredClone !== 'function') {
|
|
22
|
+
globalThis.structuredClone = (<T>(value: T): T =>
|
|
23
|
+
JSON.parse(JSON.stringify(value)) as T) as typeof structuredClone
|
|
24
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|
2
|
+
import { useViewer } from '@pascal-app/viewer'
|
|
3
|
+
import { create } from 'zustand'
|
|
4
|
+
import {
|
|
5
|
+
canCreateSessionGroup,
|
|
6
|
+
createSessionGroup,
|
|
7
|
+
expandSessionGroupMembers,
|
|
8
|
+
type SessionSelectionGroup,
|
|
9
|
+
selectionIntersectsSessionGroup,
|
|
10
|
+
selectionMatchesSessionGroup,
|
|
11
|
+
ungroupSessionSelection,
|
|
12
|
+
} from '../lib/session-groups'
|
|
13
|
+
import { sfxEmitter } from '../lib/sfx-bus'
|
|
14
|
+
|
|
15
|
+
type SessionGroupsState = {
|
|
16
|
+
groups: SessionSelectionGroup[]
|
|
17
|
+
setGroups: (groups: SessionSelectionGroup[]) => void
|
|
18
|
+
clearGroups: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const useSessionGroups = create<SessionGroupsState>((set) => ({
|
|
22
|
+
groups: [],
|
|
23
|
+
setGroups: (groups) => set({ groups }),
|
|
24
|
+
clearGroups: () => set({ groups: [] }),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
function liveNodeIdSet(): Set<string> {
|
|
28
|
+
return new Set(Object.keys(useScene.getState().nodes))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Membership is never rewritten when a member is deleted — every read filters
|
|
33
|
+
* against the live scene instead. Deleting a member and undoing must restore it
|
|
34
|
+
* to its group, and a destructive prune would have already dropped it (or the
|
|
35
|
+
* whole group, once it fell under the two-member floor).
|
|
36
|
+
*/
|
|
37
|
+
export function expandSessionSelectionForNode(nodeId: string): string[] | null {
|
|
38
|
+
const groups = useSessionGroups.getState().groups
|
|
39
|
+
if (groups.length === 0) return null
|
|
40
|
+
return expandSessionGroupMembers(groups, nodeId, liveNodeIdSet())
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Ctrl/Cmd+G — create session group from multi-selection. */
|
|
44
|
+
export function groupCurrentSelection(): boolean {
|
|
45
|
+
const selectedIds = useViewer.getState().selection.selectedIds as string[]
|
|
46
|
+
if (selectedIds.length < 2) return false
|
|
47
|
+
|
|
48
|
+
const liveIds = liveNodeIdSet()
|
|
49
|
+
const { groups, created } = createSessionGroup(useSessionGroups.getState().groups, selectedIds, {
|
|
50
|
+
liveIds,
|
|
51
|
+
})
|
|
52
|
+
useSessionGroups.getState().setGroups(groups)
|
|
53
|
+
if (!created) return false
|
|
54
|
+
|
|
55
|
+
useViewer.getState().setSelection({
|
|
56
|
+
selectedIds: created.memberIds as AnyNodeId[],
|
|
57
|
+
})
|
|
58
|
+
sfxEmitter.emit('sfx:menu-click')
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Ctrl/Cmd+Shift+G — dissolve session groups intersecting selection. */
|
|
63
|
+
export function ungroupCurrentSelection(): boolean {
|
|
64
|
+
const selectedIds = useViewer.getState().selection.selectedIds as string[]
|
|
65
|
+
if (selectedIds.length === 0) return false
|
|
66
|
+
|
|
67
|
+
const liveIds = liveNodeIdSet()
|
|
68
|
+
const { groups, dissolved } = ungroupSessionSelection(
|
|
69
|
+
useSessionGroups.getState().groups,
|
|
70
|
+
selectedIds,
|
|
71
|
+
liveIds,
|
|
72
|
+
)
|
|
73
|
+
if (dissolved.length === 0) return false
|
|
74
|
+
useSessionGroups.getState().setGroups(groups)
|
|
75
|
+
sfxEmitter.emit('sfx:menu-click')
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function currentSelectionMatchesSessionGroup(): SessionSelectionGroup | null {
|
|
80
|
+
return selectionMatchesSessionGroup(
|
|
81
|
+
useSessionGroups.getState().groups,
|
|
82
|
+
useViewer.getState().selection.selectedIds as string[],
|
|
83
|
+
liveNodeIdSet(),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function currentSelectionIntersectsSessionGroup(): boolean {
|
|
88
|
+
return selectionIntersectsSessionGroup(
|
|
89
|
+
useSessionGroups.getState().groups,
|
|
90
|
+
useViewer.getState().selection.selectedIds as string[],
|
|
91
|
+
liveNodeIdSet(),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function currentSelectionCanCreateSessionGroup(): boolean {
|
|
96
|
+
return canCreateSessionGroup(
|
|
97
|
+
useSessionGroups.getState().groups,
|
|
98
|
+
useViewer.getState().selection.selectedIds as string[],
|
|
99
|
+
liveNodeIdSet(),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default useSessionGroups
|