@exegia/corpora-ui 0.22.0 → 0.24.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.
- package/dist-lib/components/blocks/auth/__tests__/auth-flow-block.test.d.ts +1 -0
- package/dist-lib/components/blocks/auth/auth-flow-block.d.ts +123 -0
- package/dist-lib/components/blocks/scaffold/__tests__/scaffold-atom.test.d.ts +1 -0
- package/dist-lib/components/blocks/scaffold/index.d.ts +2 -0
- package/dist-lib/components/blocks/scaffold/scaffold-atom.d.ts +97 -0
- package/dist-lib/components/blocks/scaffold/scaffold-context.d.ts +3 -1
- package/dist-lib/components/blocks/scaffold/scaffold-root.d.ts +12 -3
- package/dist-lib/components/blocks/scaffold/type.d.ts +58 -18
- package/dist-lib/components/blocks/scaffold/use-scaffold-state.d.ts +26 -0
- package/dist-lib/components/blocks/scaffold/use-scaffold.d.ts +7 -1
- package/dist-lib/components/blocks/scaffold/utils.d.ts +11 -1
- package/dist-lib/components/ui/switch.d.ts +4 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.js +1683 -1495
- package/dist-lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/beste/piece/browser-frame.tsx +1 -1
- package/src/components/beste/piece/utils.ts +4 -4
- package/src/components/blocks/auth/__tests__/auth-flow-block.test.tsx +183 -0
- package/src/components/blocks/auth/auth-flow-block.tsx +296 -0
- package/src/components/blocks/scaffold/__tests__/scaffold-atom.test.tsx +210 -0
- package/src/components/blocks/scaffold/index.ts +15 -0
- package/src/components/blocks/scaffold/scaffold-actions.tsx +5 -1
- package/src/components/blocks/scaffold/scaffold-atom.ts +383 -0
- package/src/components/blocks/scaffold/scaffold-canvas.tsx +12 -4
- package/src/components/blocks/scaffold/scaffold-context.ts +3 -1
- package/src/components/blocks/scaffold/scaffold-inspector.tsx +9 -2
- package/src/components/blocks/scaffold/scaffold-panel.tsx +24 -10
- package/src/components/blocks/scaffold/scaffold-root.tsx +64 -32
- package/src/components/blocks/scaffold/scaffold-sub-panel.tsx +12 -10
- package/src/components/blocks/scaffold/scaffold-tab.tsx +13 -9
- package/src/components/blocks/scaffold/type.ts +64 -18
- package/src/components/blocks/scaffold/use-scaffold-state.ts +58 -0
- package/src/components/blocks/scaffold/use-scaffold.ts +30 -19
- package/src/components/blocks/scaffold/utils.ts +45 -1
- package/src/components/blocks/shell/shell-layout.tsx +8 -3
- package/src/components/docs/component-preview.tsx +7 -2
- package/src/components/docs/demo-controls.tsx +32 -23
- package/src/components/ui/switch.tsx +30 -0
- package/src/index.ts +1 -0
- package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +0 -19
- package/src/components/blocks/scaffold/use-panel-visibility.ts +0 -180
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test"
|
|
2
|
+
import { createStore } from "jotai"
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
measureScaffoldCanvasAtom,
|
|
6
|
+
projectScaffoldPropsAtom,
|
|
7
|
+
registerScaffoldPanelIdsAtom,
|
|
8
|
+
removeScaffoldInstance,
|
|
9
|
+
resetScaffoldAtom,
|
|
10
|
+
scaffoldHiddenPanelIdsAtom,
|
|
11
|
+
scaffoldHoveredPanelIdAtom,
|
|
12
|
+
scaffoldInspectorOpenAtom,
|
|
13
|
+
scaffoldPanelCapacityAtom,
|
|
14
|
+
scaffoldPanelDimmedAtom,
|
|
15
|
+
scaffoldPanelHiddenAtom,
|
|
16
|
+
scaffoldStateAtom,
|
|
17
|
+
scaffoldVisibilityAtom,
|
|
18
|
+
setScaffoldHandlersAtom,
|
|
19
|
+
setScaffoldInspectorOpenAtom,
|
|
20
|
+
setScaffoldPanelHoveredAtom,
|
|
21
|
+
toggleScaffoldInspectorAtom,
|
|
22
|
+
toggleScaffoldPanelAtom,
|
|
23
|
+
} from "../scaffold-atom"
|
|
24
|
+
|
|
25
|
+
type Store = ReturnType<typeof createStore>
|
|
26
|
+
|
|
27
|
+
let keys = 0
|
|
28
|
+
/** A fresh instance per test — ids never collide, no cross-test state. */
|
|
29
|
+
const instance = () => `scaffold-test-${keys++}`
|
|
30
|
+
|
|
31
|
+
/** Register three panels on a canvas wide enough for all of them. */
|
|
32
|
+
function mountThree(store: Store, id: string) {
|
|
33
|
+
store.set(registerScaffoldPanelIdsAtom(id), ["one", "two", "three"])
|
|
34
|
+
store.set(measureScaffoldCanvasAtom(id), 1200)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("scaffold panel visibility atoms", () => {
|
|
38
|
+
test("narrowing evicts the least-recently-activated panel; widening restores it", () => {
|
|
39
|
+
const store = createStore()
|
|
40
|
+
const id = instance()
|
|
41
|
+
mountThree(store, id)
|
|
42
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
|
|
43
|
+
|
|
44
|
+
// Two panels' worth of room.
|
|
45
|
+
store.set(measureScaffoldCanvasAtom(id), 700)
|
|
46
|
+
expect(store.get(scaffoldPanelCapacityAtom(id))).toBe(2)
|
|
47
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["one"])
|
|
48
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, "one"))).toBe(true)
|
|
49
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, "two"))).toBe(false)
|
|
50
|
+
|
|
51
|
+
// Room returns — the auto-hidden panel comes back by itself.
|
|
52
|
+
store.set(measureScaffoldCanvasAtom(id), 1200)
|
|
53
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("showing a hidden panel past capacity trades places with the oldest visible one", () => {
|
|
57
|
+
const store = createStore()
|
|
58
|
+
const id = instance()
|
|
59
|
+
mountThree(store, id)
|
|
60
|
+
store.set(measureScaffoldCanvasAtom(id), 700)
|
|
61
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["one"])
|
|
62
|
+
|
|
63
|
+
store.set(toggleScaffoldPanelAtom(id), "one")
|
|
64
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, "one"))).toBe(false)
|
|
65
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, "two"))).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("user-hidden panels stay hidden when the canvas widens; a press shows them again", () => {
|
|
69
|
+
const store = createStore()
|
|
70
|
+
const id = instance()
|
|
71
|
+
mountThree(store, id)
|
|
72
|
+
|
|
73
|
+
store.set(toggleScaffoldPanelAtom(id), "two")
|
|
74
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["two"])
|
|
75
|
+
|
|
76
|
+
store.set(measureScaffoldCanvasAtom(id), 1400)
|
|
77
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["two"])
|
|
78
|
+
|
|
79
|
+
store.set(toggleScaffoldPanelAtom(id), "two")
|
|
80
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("the last visible panel refuses to hide; unknown ids are inert", () => {
|
|
84
|
+
const store = createStore()
|
|
85
|
+
const id = instance()
|
|
86
|
+
mountThree(store, id)
|
|
87
|
+
store.set(measureScaffoldCanvasAtom(id), 400)
|
|
88
|
+
expect(store.get(scaffoldPanelCapacityAtom(id))).toBe(1)
|
|
89
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, "three"))).toBe(false)
|
|
90
|
+
|
|
91
|
+
store.set(toggleScaffoldPanelAtom(id), "three")
|
|
92
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, "three"))).toBe(false)
|
|
93
|
+
|
|
94
|
+
store.set(toggleScaffoldPanelAtom(id), "nonexistent")
|
|
95
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual(["one", "two"])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test("departed panels are pruned from every list on re-registration", () => {
|
|
99
|
+
const store = createStore()
|
|
100
|
+
const id = instance()
|
|
101
|
+
mountThree(store, id)
|
|
102
|
+
store.set(toggleScaffoldPanelAtom(id), "two")
|
|
103
|
+
|
|
104
|
+
store.set(registerScaffoldPanelIdsAtom(id), ["one", "three"])
|
|
105
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("a resize that lands on the same capacity leaves the visibility untouched", () => {
|
|
109
|
+
const store = createStore()
|
|
110
|
+
const id = instance()
|
|
111
|
+
mountThree(store, id)
|
|
112
|
+
const before = store.get(scaffoldVisibilityAtom(id))
|
|
113
|
+
|
|
114
|
+
// 1200 → 1000 both fit three panels.
|
|
115
|
+
store.set(measureScaffoldCanvasAtom(id), 1000)
|
|
116
|
+
expect(store.get(scaffoldVisibilityAtom(id))).toBe(before)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("scaffold hover atoms", () => {
|
|
121
|
+
test("hover spotlights one panel; a stale leave never clears a newer hover", () => {
|
|
122
|
+
const store = createStore()
|
|
123
|
+
const id = instance()
|
|
124
|
+
mountThree(store, id)
|
|
125
|
+
|
|
126
|
+
store.set(setScaffoldPanelHoveredAtom(id), "one", true)
|
|
127
|
+
expect(store.get(scaffoldPanelDimmedAtom(id, "one"))).toBe(false)
|
|
128
|
+
expect(store.get(scaffoldPanelDimmedAtom(id, "two"))).toBe(true)
|
|
129
|
+
|
|
130
|
+
// The pointer moved on before "one"'s leave fired.
|
|
131
|
+
store.set(setScaffoldPanelHoveredAtom(id), "two", true)
|
|
132
|
+
store.set(setScaffoldPanelHoveredAtom(id), "one", false)
|
|
133
|
+
expect(store.get(scaffoldHoveredPanelIdAtom(id))).toBe("two")
|
|
134
|
+
|
|
135
|
+
store.set(setScaffoldPanelHoveredAtom(id), "two", false)
|
|
136
|
+
expect(store.get(scaffoldHoveredPanelIdAtom(id))).toBe(null)
|
|
137
|
+
expect(store.get(scaffoldPanelDimmedAtom(id, "one"))).toBe(false)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("the empty-key sentinel reads false however the scaffold moves", () => {
|
|
141
|
+
const store = createStore()
|
|
142
|
+
const id = instance()
|
|
143
|
+
store.set(setScaffoldPanelHoveredAtom(id), "one", true)
|
|
144
|
+
expect(store.get(scaffoldPanelDimmedAtom(id, ""))).toBe(false)
|
|
145
|
+
expect(store.get(scaffoldPanelHiddenAtom(id, ""))).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe("scaffold inspector atoms", () => {
|
|
150
|
+
test("set and toggle report through the handler and write the store when uncontrolled", () => {
|
|
151
|
+
const store = createStore()
|
|
152
|
+
const id = instance()
|
|
153
|
+
const onInspectorOpenChange = mock(() => {})
|
|
154
|
+
store.set(setScaffoldHandlersAtom(id), { onInspectorOpenChange })
|
|
155
|
+
|
|
156
|
+
store.set(setScaffoldInspectorOpenAtom(id), true)
|
|
157
|
+
expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(true)
|
|
158
|
+
expect(onInspectorOpenChange).toHaveBeenCalledWith(true)
|
|
159
|
+
|
|
160
|
+
// Setting the value it already holds is silent.
|
|
161
|
+
store.set(setScaffoldInspectorOpenAtom(id), true)
|
|
162
|
+
expect(onInspectorOpenChange).toHaveBeenCalledTimes(1)
|
|
163
|
+
|
|
164
|
+
store.set(toggleScaffoldInspectorAtom(id))
|
|
165
|
+
expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(false)
|
|
166
|
+
expect(onInspectorOpenChange).toHaveBeenLastCalledWith(false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("a controlled inspector gates store writes; actions only report", () => {
|
|
170
|
+
const store = createStore()
|
|
171
|
+
const id = instance()
|
|
172
|
+
const onInspectorOpenChange = mock(() => {})
|
|
173
|
+
store.set(setScaffoldHandlersAtom(id), { onInspectorOpenChange })
|
|
174
|
+
store.set(projectScaffoldPropsAtom(id), { controlsInspector: true }, false)
|
|
175
|
+
|
|
176
|
+
store.set(setScaffoldInspectorOpenAtom(id), true)
|
|
177
|
+
expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(false)
|
|
178
|
+
expect(onInspectorOpenChange).toHaveBeenCalledWith(true)
|
|
179
|
+
|
|
180
|
+
// The prop coming back around through the projection is what moves it.
|
|
181
|
+
store.set(projectScaffoldPropsAtom(id), { controlsInspector: true }, true)
|
|
182
|
+
expect(store.get(scaffoldInspectorOpenAtom(id))).toBe(true)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
describe("scaffold instance lifecycle", () => {
|
|
187
|
+
test("reset restores every field; remove drops the id from every family", () => {
|
|
188
|
+
const store = createStore()
|
|
189
|
+
const id = instance()
|
|
190
|
+
mountThree(store, id)
|
|
191
|
+
store.set(measureScaffoldCanvasAtom(id), 700)
|
|
192
|
+
store.set(setScaffoldInspectorOpenAtom(id), true)
|
|
193
|
+
store.set(setScaffoldPanelHoveredAtom(id), "two", true)
|
|
194
|
+
|
|
195
|
+
store.set(resetScaffoldAtom(id))
|
|
196
|
+
expect(store.get(scaffoldStateAtom(id))).toEqual({
|
|
197
|
+
inspectorOpen: false,
|
|
198
|
+
panelCapacity: 3,
|
|
199
|
+
hiddenPanelIds: [],
|
|
200
|
+
hoveredPanelId: null,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// A removed id reads fresh atoms — state from before is gone.
|
|
204
|
+
mountThree(store, id)
|
|
205
|
+
store.set(measureScaffoldCanvasAtom(id), 700)
|
|
206
|
+
removeScaffoldInstance(id)
|
|
207
|
+
expect(store.get(scaffoldPanelCapacityAtom(id))).toBe(3)
|
|
208
|
+
expect(store.get(scaffoldHiddenPanelIdsAtom(id))).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -12,6 +12,21 @@ import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-pane
|
|
|
12
12
|
|
|
13
13
|
export { useScaffoldContext } from "./scaffold-context"
|
|
14
14
|
export { useScaffold } from "./use-scaffold"
|
|
15
|
+
export { useScaffoldActions, useScaffoldState } from "./use-scaffold-state"
|
|
16
|
+
export {
|
|
17
|
+
removeScaffoldInstance,
|
|
18
|
+
resetScaffoldAtom,
|
|
19
|
+
scaffoldHiddenPanelIdsAtom,
|
|
20
|
+
scaffoldHoveredPanelIdAtom,
|
|
21
|
+
scaffoldInspectorOpenAtom,
|
|
22
|
+
scaffoldPanelCapacityAtom,
|
|
23
|
+
scaffoldPanelDimmedAtom,
|
|
24
|
+
scaffoldPanelHiddenAtom,
|
|
25
|
+
scaffoldStateAtom,
|
|
26
|
+
setScaffoldInspectorOpenAtom,
|
|
27
|
+
toggleScaffoldInspectorAtom,
|
|
28
|
+
toggleScaffoldPanelAtom,
|
|
29
|
+
} from "./scaffold-atom"
|
|
15
30
|
export type * from "./type"
|
|
16
31
|
export * from "./constants"
|
|
17
32
|
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
|
|
4
4
|
import * as React from "react"
|
|
5
5
|
|
|
6
|
+
import { useAtomValue } from "jotai"
|
|
7
|
+
|
|
6
8
|
import { cn } from "@/lib/utils"
|
|
7
9
|
import { SCAFFOLD_EASE, SCAFFOLD_EDGE_GUTTER, SCAFFOLD_MORPH_DURATION } from "./constants"
|
|
10
|
+
import { scaffoldInspectorOpenAtom } from "./scaffold-atom"
|
|
8
11
|
import { useScaffoldContext } from "./scaffold-context"
|
|
9
12
|
import type { ScaffoldActionsProps } from "./type"
|
|
10
13
|
import { segmentVariants } from "./utils"
|
|
@@ -28,7 +31,8 @@ export function ScaffoldActions({
|
|
|
28
31
|
children,
|
|
29
32
|
...rest
|
|
30
33
|
}: ScaffoldActionsProps): React.ReactElement {
|
|
31
|
-
const {
|
|
34
|
+
const { scaffoldId, inspectorWidth } = useScaffoldContext()
|
|
35
|
+
const inspectorOpen = useAtomValue(scaffoldInspectorOpenAtom(scaffoldId))
|
|
32
36
|
const reducedMotion = useReducedMotion()
|
|
33
37
|
const transition = reducedMotion ? { duration: 0 } : SPRING_LAYOUT
|
|
34
38
|
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffold state — Jotai atom families keyed by a scaffold id.
|
|
3
|
+
*
|
|
4
|
+
* Same layout as the tree (`components/composed/tree/tree-atom.ts`), the
|
|
5
|
+
* shell (`blocks/shell/shell-fit-atom.ts`) and the profile card: in-house
|
|
6
|
+
* `keyed()` families per field, write-only action atoms, a composed
|
|
7
|
+
* read-only state atom, and `removeScaffoldInstance` to drop an id from
|
|
8
|
+
* every family.
|
|
9
|
+
*
|
|
10
|
+
* The visibility bookkeeping (`visibleOrder` / `autoHidden` / `userHidden`)
|
|
11
|
+
* stays one atom on purpose — `reconcileVisibility` always settles the three
|
|
12
|
+
* lists together, so splitting them would only invite half-written states.
|
|
13
|
+
* The reconcile rule itself lives in `utils.ts`, and every action that moves
|
|
14
|
+
* an input (ids, capacity, a toggle) settles the visibility before it
|
|
15
|
+
* returns, so readers never see an interim state — the render-time settling
|
|
16
|
+
* the old `usePanelVisibility` hook needed has no equivalent here.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { atom } from "jotai"
|
|
20
|
+
import type { Atom, Getter, Setter } from "jotai"
|
|
21
|
+
|
|
22
|
+
import { SCAFFOLD_PANEL_CAPACITY } from "./constants"
|
|
23
|
+
import type {
|
|
24
|
+
ScaffoldConfig,
|
|
25
|
+
ScaffoldHandlers,
|
|
26
|
+
ScaffoldInstanceId,
|
|
27
|
+
ScaffoldPanelVisibility,
|
|
28
|
+
ScaffoldState,
|
|
29
|
+
} from "./type"
|
|
30
|
+
import {
|
|
31
|
+
EMPTY_SCAFFOLD_VISIBILITY,
|
|
32
|
+
getPanelCapacity,
|
|
33
|
+
reconcileVisibility,
|
|
34
|
+
} from "./utils"
|
|
35
|
+
|
|
36
|
+
const DEFAULT_CONFIG: ScaffoldConfig = { controlsInspector: false }
|
|
37
|
+
const NO_HANDLERS: ScaffoldHandlers = {}
|
|
38
|
+
const NO_PANEL_IDS: readonly string[] = []
|
|
39
|
+
|
|
40
|
+
// ── families ───────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
type Family<AtomType> = ((id: ScaffoldInstanceId) => AtomType) & {
|
|
43
|
+
remove: (id: ScaffoldInstanceId) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const families: { remove: (id: ScaffoldInstanceId) => void }[] = []
|
|
47
|
+
|
|
48
|
+
function keyed<AtomType>(create: (id: ScaffoldInstanceId) => AtomType) {
|
|
49
|
+
const cache = new Map<ScaffoldInstanceId, AtomType>()
|
|
50
|
+
const family = ((id: ScaffoldInstanceId) => {
|
|
51
|
+
let instance = cache.get(id)
|
|
52
|
+
if (instance === undefined) {
|
|
53
|
+
instance = create(id)
|
|
54
|
+
cache.set(id, instance)
|
|
55
|
+
}
|
|
56
|
+
return instance
|
|
57
|
+
}) as Family<AtomType>
|
|
58
|
+
family.remove = (id: ScaffoldInstanceId) => {
|
|
59
|
+
cache.delete(id)
|
|
60
|
+
}
|
|
61
|
+
families.push(family)
|
|
62
|
+
return family
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stateFamily<Value>(name: string, initialValue: Value) {
|
|
66
|
+
return keyed((id) => {
|
|
67
|
+
const instance = atom(initialValue)
|
|
68
|
+
instance.debugLabel = `scaffold/${id}/${name}`
|
|
69
|
+
return instance
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readFamily<Value>(
|
|
74
|
+
name: string,
|
|
75
|
+
read: (get: Getter, id: ScaffoldInstanceId) => Value
|
|
76
|
+
) {
|
|
77
|
+
return keyed((id) => {
|
|
78
|
+
const instance = atom((get) => read(get, id))
|
|
79
|
+
instance.debugLabel = `scaffold/${id}/${name}`
|
|
80
|
+
return instance
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function actionFamily<Args extends unknown[]>(
|
|
85
|
+
name: string,
|
|
86
|
+
write: (
|
|
87
|
+
get: Getter,
|
|
88
|
+
set: Setter,
|
|
89
|
+
id: ScaffoldInstanceId,
|
|
90
|
+
...args: Args
|
|
91
|
+
) => void
|
|
92
|
+
) {
|
|
93
|
+
return keyed((id) => {
|
|
94
|
+
const instance = atom(null, (get, set, ...args: Args) =>
|
|
95
|
+
write(get, set, id, ...args)
|
|
96
|
+
)
|
|
97
|
+
instance.debugLabel = `scaffold/${id}/${name}`
|
|
98
|
+
return instance
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A family keyed by scaffold AND panel, so a tab or a panel subscribes to
|
|
103
|
+
* its own boolean and sits still when a sibling's changes — the per-node
|
|
104
|
+
* rule from the tree, at panel scale. Removal drops a whole scaffold. */
|
|
105
|
+
function panelFamily<Value>(
|
|
106
|
+
name: string,
|
|
107
|
+
read: (get: Getter, id: ScaffoldInstanceId, panelId: string) => Value
|
|
108
|
+
) {
|
|
109
|
+
const cache = new Map<ScaffoldInstanceId, Map<string, Atom<Value>>>()
|
|
110
|
+
const family = (id: ScaffoldInstanceId, panelId: string): Atom<Value> => {
|
|
111
|
+
let panels = cache.get(id)
|
|
112
|
+
if (panels === undefined) {
|
|
113
|
+
panels = new Map()
|
|
114
|
+
cache.set(id, panels)
|
|
115
|
+
}
|
|
116
|
+
let instance = panels.get(panelId)
|
|
117
|
+
if (instance === undefined) {
|
|
118
|
+
const created = atom((get) => read(get, id, panelId))
|
|
119
|
+
created.debugLabel = `scaffold/${id}/${panelId}/${name}`
|
|
120
|
+
panels.set(panelId, created)
|
|
121
|
+
instance = created
|
|
122
|
+
}
|
|
123
|
+
return instance
|
|
124
|
+
}
|
|
125
|
+
families.push({
|
|
126
|
+
remove: (id: ScaffoldInstanceId) => {
|
|
127
|
+
cache.delete(id)
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
return family
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── primitives ─────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export const scaffoldInspectorOpenAtom = stateFamily<boolean>(
|
|
136
|
+
"inspectorOpen",
|
|
137
|
+
false
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
/** @internal The canvas's ordered panel ids, as registered each render. */
|
|
141
|
+
export const scaffoldPanelIdsAtom = stateFamily<readonly string[]>(
|
|
142
|
+
"panelIds",
|
|
143
|
+
NO_PANEL_IDS
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
/** @internal The capacity derived from the canvas's measured width — the
|
|
147
|
+
* derived number is stored, not the raw width, so resize storms only touch
|
|
148
|
+
* the store when a panel actually gains or loses room. */
|
|
149
|
+
export const scaffoldMeasuredCapacityAtom = stateFamily<number | null>(
|
|
150
|
+
"measuredCapacity",
|
|
151
|
+
null
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
/** @internal The three visibility lists, settled as one value. */
|
|
155
|
+
export const scaffoldVisibilityAtom = stateFamily<ScaffoldPanelVisibility>(
|
|
156
|
+
"visibility",
|
|
157
|
+
EMPTY_SCAFFOLD_VISIBILITY
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
export const scaffoldHoveredPanelIdAtom = stateFamily<string | null>(
|
|
161
|
+
"hoveredPanelId",
|
|
162
|
+
null
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
/** @internal */
|
|
166
|
+
export const scaffoldConfigAtom = stateFamily<ScaffoldConfig>(
|
|
167
|
+
"config",
|
|
168
|
+
DEFAULT_CONFIG
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/** @internal */
|
|
172
|
+
export const scaffoldHandlersAtom = stateFamily<ScaffoldHandlers>(
|
|
173
|
+
"handlers",
|
|
174
|
+
NO_HANDLERS
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// ── derived ────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/** How many panels fit side by side; the cap while the canvas is unmeasured. */
|
|
180
|
+
export const scaffoldPanelCapacityAtom = readFamily(
|
|
181
|
+
"panelCapacity",
|
|
182
|
+
(get, id) =>
|
|
183
|
+
get(scaffoldMeasuredCapacityAtom(id)) ?? SCAFFOLD_PANEL_CAPACITY
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
/** Ids of panels currently hidden (auto + user), for tabs to reflect. */
|
|
187
|
+
export const scaffoldHiddenPanelIdsAtom = readFamily<readonly string[]>(
|
|
188
|
+
"hiddenPanelIds",
|
|
189
|
+
(get, id) => {
|
|
190
|
+
const visibility = get(scaffoldVisibilityAtom(id))
|
|
191
|
+
return [...visibility.autoHidden, ...visibility.userHidden]
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
/** Whether one panel is hidden. A tab subscribes here and sits still while
|
|
196
|
+
* its siblings toggle. Empty `panelId` (a tab without one) reads false. */
|
|
197
|
+
export const scaffoldPanelHiddenAtom = panelFamily(
|
|
198
|
+
"hidden",
|
|
199
|
+
(get, id, panelId) => {
|
|
200
|
+
if (panelId === "") return false
|
|
201
|
+
const visibility = get(scaffoldVisibilityAtom(id))
|
|
202
|
+
return (
|
|
203
|
+
visibility.autoHidden.includes(panelId) ||
|
|
204
|
+
visibility.userHidden.includes(panelId)
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
/** Whether one panel fades back while another panel's tab holds the
|
|
210
|
+
* spotlight. Moving the hover between two tabs re-renders those panels
|
|
211
|
+
* only — everyone else's boolean holds. */
|
|
212
|
+
export const scaffoldPanelDimmedAtom = panelFamily(
|
|
213
|
+
"dimmed",
|
|
214
|
+
(get, id, panelId) => {
|
|
215
|
+
if (panelId === "") return false
|
|
216
|
+
const hovered = get(scaffoldHoveredPanelIdAtom(id))
|
|
217
|
+
return hovered !== null && hovered !== panelId
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
/** The whole state of one scaffold. This changes on every hover move, so a
|
|
222
|
+
* component that reads one field should subscribe to that field's atom
|
|
223
|
+
* instead: `useAtomValue(scaffoldInspectorOpenAtom("workspace"))`. */
|
|
224
|
+
export const scaffoldStateAtom = readFamily<ScaffoldState>(
|
|
225
|
+
"state",
|
|
226
|
+
(get, id) => ({
|
|
227
|
+
inspectorOpen: get(scaffoldInspectorOpenAtom(id)),
|
|
228
|
+
panelCapacity: get(scaffoldPanelCapacityAtom(id)),
|
|
229
|
+
hiddenPanelIds: get(scaffoldHiddenPanelIdsAtom(id)),
|
|
230
|
+
hoveredPanelId: get(scaffoldHoveredPanelIdAtom(id)),
|
|
231
|
+
})
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// ── visibility settling ────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** Re-settle the lists after any input moved. Inert when nothing changes —
|
|
237
|
+
* `reconcileVisibility` returns `prev` untouched then. */
|
|
238
|
+
function settleVisibility(get: Getter, set: Setter, id: ScaffoldInstanceId) {
|
|
239
|
+
const prev = get(scaffoldVisibilityAtom(id))
|
|
240
|
+
const settled = reconcileVisibility(
|
|
241
|
+
prev,
|
|
242
|
+
get(scaffoldPanelIdsAtom(id)),
|
|
243
|
+
get(scaffoldPanelCapacityAtom(id))
|
|
244
|
+
)
|
|
245
|
+
if (settled !== prev) set(scaffoldVisibilityAtom(id), settled)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── actions: inspector ─────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
/** Reports through `onInspectorOpenChange` always; writes the store only
|
|
251
|
+
* when no `inspectorOpen` prop controls the root — the prop is truth then. */
|
|
252
|
+
export const setScaffoldInspectorOpenAtom = actionFamily<[open: boolean]>(
|
|
253
|
+
"setInspectorOpen",
|
|
254
|
+
(get, set, id, open) => {
|
|
255
|
+
if (get(scaffoldInspectorOpenAtom(id)) === open) return
|
|
256
|
+
if (!get(scaffoldConfigAtom(id)).controlsInspector)
|
|
257
|
+
set(scaffoldInspectorOpenAtom(id), open)
|
|
258
|
+
get(scaffoldHandlersAtom(id)).onInspectorOpenChange?.(open)
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
export const toggleScaffoldInspectorAtom = actionFamily<[]>(
|
|
263
|
+
"toggleInspector",
|
|
264
|
+
(get, set, id) => {
|
|
265
|
+
set(setScaffoldInspectorOpenAtom(id), !get(scaffoldInspectorOpenAtom(id)))
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// ── actions: panels ────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** Show/hide an id'd panel. Showing past capacity auto-hides the
|
|
272
|
+
* least-recently-activated visible panel; the last visible one never hides. */
|
|
273
|
+
export const toggleScaffoldPanelAtom = actionFamily<[panelId: string]>(
|
|
274
|
+
"togglePanel",
|
|
275
|
+
(get, set, id, panelId) => {
|
|
276
|
+
const ids = get(scaffoldPanelIdsAtom(id))
|
|
277
|
+
if (!ids.includes(panelId)) return
|
|
278
|
+
const prev = get(scaffoldVisibilityAtom(id))
|
|
279
|
+
const hidden =
|
|
280
|
+
prev.autoHidden.includes(panelId) || prev.userHidden.includes(panelId)
|
|
281
|
+
if (!hidden && prev.visibleOrder.length <= 1) return
|
|
282
|
+
|
|
283
|
+
const next: ScaffoldPanelVisibility = hidden
|
|
284
|
+
? {
|
|
285
|
+
// Showing: newest activation — reconcile evicts the oldest
|
|
286
|
+
// visible panel if the canvas can't fit one more.
|
|
287
|
+
visibleOrder: [...prev.visibleOrder, panelId],
|
|
288
|
+
autoHidden: prev.autoHidden.filter((other) => other !== panelId),
|
|
289
|
+
userHidden: prev.userHidden.filter((other) => other !== panelId),
|
|
290
|
+
}
|
|
291
|
+
: {
|
|
292
|
+
visibleOrder: prev.visibleOrder.filter((other) => other !== panelId),
|
|
293
|
+
autoHidden: prev.autoHidden,
|
|
294
|
+
userHidden: [...prev.userHidden, panelId],
|
|
295
|
+
}
|
|
296
|
+
set(
|
|
297
|
+
scaffoldVisibilityAtom(id),
|
|
298
|
+
reconcileVisibility(next, ids, get(scaffoldPanelCapacityAtom(id)))
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
/** @internal Tabs report pointer enter/leave on their label. Leaving clears
|
|
304
|
+
* only its own id — a tab that unmounts mid-hover must not wipe out a hover
|
|
305
|
+
* the pointer has already moved on to. */
|
|
306
|
+
export const setScaffoldPanelHoveredAtom = actionFamily<
|
|
307
|
+
[panelId: string, hovered: boolean]
|
|
308
|
+
>("setPanelHovered", (get, set, id, panelId, hovered) => {
|
|
309
|
+
const prev = get(scaffoldHoveredPanelIdAtom(id))
|
|
310
|
+
if (hovered) {
|
|
311
|
+
if (prev !== panelId) set(scaffoldHoveredPanelIdAtom(id), panelId)
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if (prev === panelId) set(scaffoldHoveredPanelIdAtom(id), null)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
/** @internal Canvas reports its ordered panel ids each render. Inert when
|
|
318
|
+
* the ids did not move; otherwise the visibility settles in the same write. */
|
|
319
|
+
export const registerScaffoldPanelIdsAtom = actionFamily<
|
|
320
|
+
[ids: readonly string[]]
|
|
321
|
+
>("registerPanelIds", (get, set, id, ids) => {
|
|
322
|
+
const prev = get(scaffoldPanelIdsAtom(id))
|
|
323
|
+
if (prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
|
|
324
|
+
set(scaffoldPanelIdsAtom(id), [...ids])
|
|
325
|
+
settleVisibility(get, set, id)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
/** @internal Canvas reports its measured width. Only the derived capacity is
|
|
329
|
+
* stored, so resize storms settle to at most one visibility change. */
|
|
330
|
+
export const measureScaffoldCanvasAtom = actionFamily<[width: number]>(
|
|
331
|
+
"measureCanvas",
|
|
332
|
+
(get, set, id, width) => {
|
|
333
|
+
const capacity = getPanelCapacity(width)
|
|
334
|
+
if (get(scaffoldMeasuredCapacityAtom(id)) === capacity) return
|
|
335
|
+
set(scaffoldMeasuredCapacityAtom(id), capacity)
|
|
336
|
+
settleVisibility(get, set, id)
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
// ── internal projections ───────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** @internal Projection of the mounted root's controlled props. */
|
|
343
|
+
export const projectScaffoldPropsAtom = actionFamily<
|
|
344
|
+
[config: ScaffoldConfig, inspectorOpen: boolean | undefined]
|
|
345
|
+
>("projectProps", (_get, set, id, config, inspectorOpen) => {
|
|
346
|
+
set(scaffoldConfigAtom(id), config)
|
|
347
|
+
if (config.controlsInspector && inspectorOpen !== undefined)
|
|
348
|
+
set(scaffoldInspectorOpenAtom(id), inspectorOpen)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
/** @internal */
|
|
352
|
+
export const setScaffoldHandlersAtom = actionFamily<
|
|
353
|
+
[handlers: ScaffoldHandlers]
|
|
354
|
+
>("setHandlers", (_get, set, id, handlers) => {
|
|
355
|
+
set(scaffoldHandlersAtom(id), handlers)
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
/** @internal Seed the uncontrolled inspector on mount without firing
|
|
359
|
+
* `onInspectorOpenChange`. */
|
|
360
|
+
export const seedScaffoldInspectorAtom = actionFamily<[open: boolean]>(
|
|
361
|
+
"seedInspector",
|
|
362
|
+
(_get, set, id, open) => {
|
|
363
|
+
set(scaffoldInspectorOpenAtom(id), open)
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
// ── reset ──────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
export const resetScaffoldAtom = actionFamily<[]>("reset", (_get, set, id) => {
|
|
370
|
+
set(scaffoldInspectorOpenAtom(id), false)
|
|
371
|
+
set(scaffoldPanelIdsAtom(id), NO_PANEL_IDS)
|
|
372
|
+
set(scaffoldMeasuredCapacityAtom(id), null)
|
|
373
|
+
set(scaffoldVisibilityAtom(id), EMPTY_SCAFFOLD_VISIBILITY)
|
|
374
|
+
set(scaffoldHoveredPanelIdAtom(id), null)
|
|
375
|
+
set(scaffoldConfigAtom(id), DEFAULT_CONFIG)
|
|
376
|
+
set(scaffoldHandlersAtom(id), NO_HANDLERS)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
/** Forget a scaffold entirely — every family drops the key so the store can
|
|
380
|
+
* release its state. Call on teardown of a named instance. */
|
|
381
|
+
export function removeScaffoldInstance(id: ScaffoldInstanceId): void {
|
|
382
|
+
for (const family of families) family.remove(id)
|
|
383
|
+
}
|
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { AnimatePresence } from "motion/react"
|
|
4
4
|
import * as React from "react"
|
|
5
|
+
import { useAtomValue, useSetAtom } from "jotai"
|
|
5
6
|
|
|
6
7
|
import { cn } from "@/lib/utils"
|
|
8
|
+
import {
|
|
9
|
+
measureScaffoldCanvasAtom,
|
|
10
|
+
registerScaffoldPanelIdsAtom,
|
|
11
|
+
scaffoldHiddenPanelIdsAtom,
|
|
12
|
+
} from "./scaffold-atom"
|
|
7
13
|
import { useScaffoldContext } from "./scaffold-context"
|
|
8
14
|
import type { ScaffoldCanvasProps, ScaffoldPanelProps } from "./type"
|
|
9
15
|
|
|
@@ -26,19 +32,21 @@ export function ScaffoldCanvas({
|
|
|
26
32
|
children,
|
|
27
33
|
...rest
|
|
28
34
|
}: ScaffoldCanvasProps): React.ReactElement {
|
|
29
|
-
const {
|
|
30
|
-
|
|
35
|
+
const { scaffoldId } = useScaffoldContext()
|
|
36
|
+
const hiddenPanelIds = useAtomValue(scaffoldHiddenPanelIdsAtom(scaffoldId))
|
|
37
|
+
const registerPanelIds = useSetAtom(registerScaffoldPanelIdsAtom(scaffoldId))
|
|
38
|
+
const measureCanvas = useSetAtom(measureScaffoldCanvasAtom(scaffoldId))
|
|
31
39
|
const ref = React.useRef<HTMLDivElement>(null)
|
|
32
40
|
|
|
33
41
|
React.useLayoutEffect(() => {
|
|
34
42
|
const element = ref.current
|
|
35
43
|
if (!element || typeof ResizeObserver === "undefined") return
|
|
36
44
|
const observer = new ResizeObserver(([entry]) =>
|
|
37
|
-
|
|
45
|
+
measureCanvas(entry.contentRect.width)
|
|
38
46
|
)
|
|
39
47
|
observer.observe(element)
|
|
40
48
|
return () => observer.disconnect()
|
|
41
|
-
}, [
|
|
49
|
+
}, [measureCanvas])
|
|
42
50
|
|
|
43
51
|
const childArray = React.Children.toArray(children)
|
|
44
52
|
const panelIds = childArray
|
|
@@ -8,7 +8,9 @@ export const ScaffoldContext = React.createContext<ScaffoldContextValue | null>(
|
|
|
8
8
|
null
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
-
/** Read the scaffold's
|
|
11
|
+
/** Read the scaffold's identity (`scaffoldId`, `inspectorWidth`); must run
|
|
12
|
+
* under `Scaffold.Root`. State itself lives in the store — subscribe to the
|
|
13
|
+
* atoms, or reach them by id through `useScaffoldState` / `useScaffoldActions`. */
|
|
12
14
|
export function useScaffoldContext(): ScaffoldContextValue {
|
|
13
15
|
const context = React.useContext(ScaffoldContext)
|
|
14
16
|
if (!context) {
|