@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.
Files changed (73) hide show
  1. package/package.json +7 -6
  2. package/src/components/editor/alignment-3d-guide-layer.tsx +12 -3
  3. package/src/components/editor/editor-layout-v2.tsx +8 -3
  4. package/src/components/editor/elevation-3d-guide-layer.tsx +3 -2
  5. package/src/components/editor/first-person-controls.tsx +2 -45
  6. package/src/components/editor/floating-action-menu.tsx +13 -4
  7. package/src/components/editor/floorplan-background-selection.test.ts +31 -4
  8. package/src/components/editor/floorplan-background-selection.ts +27 -7
  9. package/src/components/editor/floorplan-panel.tsx +5 -45
  10. package/src/components/editor/group-floating-action-menu.tsx +30 -15
  11. package/src/components/editor/index.tsx +112 -25
  12. package/src/components/editor/measurement-pill.tsx +12 -12
  13. package/src/components/editor/node-action-menu.tsx +29 -1
  14. package/src/components/editor/opening-guides-3d-layer.tsx +13 -4
  15. package/src/components/editor/selection-manager.tsx +28 -5
  16. package/src/components/editor/site-edge-labels.tsx +2 -1
  17. package/src/components/editor/snapshot-capture-overlay.tsx +11 -2
  18. package/src/components/editor/thumbnail-generator.tsx +27 -11
  19. package/src/components/editor/wall-measurement-label.tsx +3 -2
  20. package/src/components/editor-2d/floorplan-group-action-menu.tsx +23 -16
  21. package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +26 -84
  22. package/src/components/tools/item/placement-strategies.test.ts +167 -0
  23. package/src/components/tools/item/placement-strategies.ts +57 -22
  24. package/src/components/tools/item/use-placement-coordinator.tsx +19 -27
  25. package/src/components/tools/shared/placement-box.tsx +9 -4
  26. package/src/components/tools/site/terrain-brush-cursor.tsx +2 -1
  27. package/src/components/tools/wall/wall-drafting.test.ts +207 -2
  28. package/src/components/tools/wall/wall-drafting.ts +84 -400
  29. package/src/components/ui/command-palette/editor-commands.tsx +3 -1
  30. package/src/components/ui/floating-level-selector.tsx +1 -1
  31. package/src/components/ui/panels/multi-selection-panel.tsx +54 -13
  32. package/src/components/ui/panels/panel-wrapper.tsx +228 -12
  33. package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +7 -2
  34. package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +11 -0
  35. package/src/components/ui/sidebar/panels/site-panel/index.tsx +12 -0
  36. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +8 -1
  37. package/src/components/ui/sidebar/tab-bar.tsx +7 -1
  38. package/src/components/viewer/floorplan-compass-button.tsx +50 -0
  39. package/src/components/viewer/floorplan-preview-geometry.test.ts +107 -0
  40. package/src/components/viewer/floorplan-preview-geometry.ts +281 -0
  41. package/src/components/viewer/floorplan-preview-navigation.test.ts +43 -0
  42. package/src/components/viewer/floorplan-preview-navigation.ts +62 -0
  43. package/src/components/viewer/floorplan-preview.tsx +1069 -0
  44. package/src/components/viewer/use-viewer-camera-navigation-sync.ts +140 -0
  45. package/src/components/viewer/viewer-stage-modes.test.ts +38 -0
  46. package/src/components/viewer/viewer-stage-modes.ts +49 -0
  47. package/src/components/viewer/viewer-stage-switcher.tsx +95 -0
  48. package/src/components/viewer/viewer-stage.test.tsx +47 -0
  49. package/src/components/viewer/viewer-stage.tsx +218 -0
  50. package/src/components/viewer-overlay.tsx +12 -8
  51. package/src/hooks/use-auto-save.test.ts +50 -1
  52. package/src/hooks/use-auto-save.ts +44 -9
  53. package/src/hooks/use-keyboard.ts +35 -1
  54. package/src/index.tsx +27 -0
  55. package/src/lib/contextual-help.test.ts +8 -0
  56. package/src/lib/contextual-help.ts +8 -0
  57. package/src/lib/floorplan/floorplan-readonly.test.ts +47 -0
  58. package/src/lib/floorplan/floorplan-readonly.ts +84 -0
  59. package/src/lib/history.test.ts +75 -10
  60. package/src/lib/history.ts +58 -13
  61. package/src/lib/inspector-card-mode.test.ts +92 -0
  62. package/src/lib/inspector-card-mode.ts +60 -0
  63. package/src/lib/measurement-parser.ts +3 -0
  64. package/src/lib/measurements.test.ts +4 -0
  65. package/src/lib/paint-scope.test.ts +58 -2
  66. package/src/lib/paint-scope.ts +8 -2
  67. package/src/lib/selection-routing.test.ts +20 -5
  68. package/src/lib/selection-routing.ts +15 -1
  69. package/src/lib/session-groups.test.ts +88 -0
  70. package/src/lib/session-groups.ts +201 -0
  71. package/src/lib/structured-clone-fallback.test.ts +45 -0
  72. package/src/lib/structured-clone-fallback.ts +24 -0
  73. 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