forked from CoreBunch/Instatic
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworkspaceLayoutStorage.ts
More file actions
241 lines (219 loc) · 7.99 KB
/
Copy pathworkspaceLayoutStorage.ts
File metadata and controls
241 lines (219 loc) · 7.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import { Type } from '@sinclair/typebox'
import { safeParseJson } from '@core/utils/jsonValidate'
export type PropertiesPanelMode = 'docked' | 'floating'
/**
* Per-workspace editor layout storage.
*
* The persisted shape is namespaced by workspace ('site', 'content', 'data',
* 'media') so each workspace remembers its own sidebar widths and open/closed
* state independently. Switching from a workspace with the right sidebar
* expanded (e.g. `site`) to one with no right panel (e.g. `media`) no longer
* reserves an empty width on the second workspace.
*
* Floating panel positions (drag-and-drop overlays) are kept at the top level
* — each `FloatingPanelId` is unique to a single workspace, so there is no
* cross-workspace collision to namespace around.
*/
export const EDITOR_LAYOUT_STORAGE_KEY = 'instatic-editor-layout-v2'
/**
* 2D position of a draggable / floating panel relative to the viewport.
* Defined here (the storage layer) so the hook layer can import it without
* creating a cycle back to the storage layer.
*/
export interface PanelPosition {
x: number
y: number
}
export type FloatingPanelId =
| 'properties'
| 'site'
| 'selectors'
| 'colors'
| 'typography'
| 'spacing'
| 'media'
| 'dependencies'
| 'codeeditor'
| 'agent'
| 'mediaUploadQueue'
| 'mediaDetachedInspector'
| 'mediaBulkEdit'
/**
* Editor workspaces tracked by the layout persistence layer. These are the
* four canvas-style admin workspaces — Site through `AdminCanvasLayout`, and
* Content / Data / Media through `AdminWorkspaceCanvasLayout`. Other admin
* pages (Plugins, Users, Account, …) render via `AdminPageLayout` and do not
* participate in this persistence.
*/
export type EditorWorkspaceId = 'site' | 'content' | 'data' | 'media'
export interface StoredWorkspaceLayout {
/** Left sidebar pixel width (clamped to SIDEBAR_MIN/MAX_WIDTH on read). */
leftWidth?: number
/** Right sidebar pixel width. */
rightWidth?: number
/** Whether the left sidebar shows a panel (rail expanded). */
leftOpen?: boolean
/** Whether the right sidebar is currently expanded. */
rightOpen?: boolean
/**
* Workspace-specific identifier of the panel that is open in the left
* sidebar. Each workspace uses its own id space:
* - site: 'explorer' | 'selectors' | 'framework' | 'dependencies' | ...
* - content: 'explorer' | 'agent'
* - media: 'folders' | 'storage'
* - data: null (the data workspace has a single toggleable panel)
*/
activeLeftPanel?: string | null
// ── Site-workspace-only fields ────────────────────────────────────────────
/**
* Active tab inside the consolidated Explorer panel
* ('layers' | 'pages' | 'media'). Site/content workspaces only.
*/
explorerPanelTab?: string
/** ID of the file currently open in the floating code editor (site only). */
activeEditorFileId?: string | null
/** Whether the floating code editor is visible (site only). */
codeEditorPanelOpen?: boolean
/** Properties panel docked vs floating (site only). */
propertiesPanelMode?: PropertiesPanelMode
}
interface StoredEditorLayout {
version: 2
/**
* Floating panel positions, keyed by panel id. Each `FloatingPanelId` is
* unique to a single workspace so positions are kept at the top level.
*/
panelPositions?: Partial<Record<FloatingPanelId, PanelPosition>>
/** Per-workspace sidebar / panel state. */
workspaces?: Partial<Record<EditorWorkspaceId, StoredWorkspaceLayout>>
}
// ---------------------------------------------------------------------------
// Storage schema
//
// `additionalProperties: true` so future fields written by other parts of the
// editor (or older versions) don't crash this reader.
const PanelPositionSchema = Type.Object(
{
x: Type.Number(),
y: Type.Number(),
},
{ additionalProperties: true },
)
const StoredWorkspaceLayoutSchema = Type.Object(
{
leftWidth: Type.Optional(Type.Number()),
rightWidth: Type.Optional(Type.Number()),
leftOpen: Type.Optional(Type.Boolean()),
rightOpen: Type.Optional(Type.Boolean()),
activeLeftPanel: Type.Optional(Type.Union([Type.String(), Type.Null()])),
explorerPanelTab: Type.Optional(Type.String()),
activeEditorFileId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
codeEditorPanelOpen: Type.Optional(Type.Boolean()),
// PropertiesPanelMode is a string union; keep loose to avoid coupling to
// its exact membership here.
propertiesPanelMode: Type.Optional(Type.String()),
},
{ additionalProperties: true },
)
const StoredEditorLayoutSchema = Type.Object(
{
version: Type.Literal(2),
panelPositions: Type.Optional(
Type.Record(Type.String(), PanelPositionSchema),
),
workspaces: Type.Optional(
Type.Record(Type.String(), StoredWorkspaceLayoutSchema),
),
},
{ additionalProperties: true },
)
function storageAvailable() {
return typeof localStorage !== 'undefined'
}
function isPanelPosition(value: unknown): value is PanelPosition {
if (!value || typeof value !== 'object') return false
const pos = value as Partial<PanelPosition>
return typeof pos.x === 'number' && Number.isFinite(pos.x)
&& typeof pos.y === 'number' && Number.isFinite(pos.y)
}
export function readEditorLayout(): StoredEditorLayout | null {
if (!storageAvailable()) return null
const raw = localStorage.getItem(EDITOR_LAYOUT_STORAGE_KEY)
if (!raw) return null
const result = safeParseJson(raw, StoredEditorLayoutSchema)
if (!result.ok) return null
return result.value as StoredEditorLayout
}
function writeEditorLayout(layout: StoredEditorLayout) {
if (!storageAvailable()) return
try {
localStorage.setItem(EDITOR_LAYOUT_STORAGE_KEY, JSON.stringify(layout))
} catch {
// Ignore quota/storage errors. Layout persistence is best-effort.
}
}
function updateEditorLayout(
updater: (layout: StoredEditorLayout) => StoredEditorLayout,
) {
const current = readEditorLayout() ?? { version: 2 as const }
writeEditorLayout(updater(current))
}
/**
* Read the stored layout for a single workspace. Returns an empty object when
* no state has been persisted yet — callers should layer their own defaults.
*/
export function readWorkspaceLayout(
workspace: EditorWorkspaceId,
): StoredWorkspaceLayout {
return readEditorLayout()?.workspaces?.[workspace] ?? {}
}
/**
* Merge a partial layout into a workspace's stored layout. Existing fields
* are preserved; pass `undefined` to leave them untouched (a `null` value
* intentionally clears a field for those that accept null).
*/
export function writeWorkspaceLayout(
workspace: EditorWorkspaceId,
partial: Partial<StoredWorkspaceLayout>,
) {
updateEditorLayout((layout) => ({
...layout,
version: 2,
workspaces: {
...layout.workspaces,
[workspace]: {
...layout.workspaces?.[workspace],
...partial,
},
},
}))
}
export function readStoredPanelPosition(panelId: FloatingPanelId): PanelPosition | null {
const position = readEditorLayout()?.panelPositions?.[panelId]
return isPanelPosition(position) ? position : null
}
export function writeStoredPanelPosition(panelId: FloatingPanelId, position: PanelPosition) {
updateEditorLayout((layout) => ({
...layout,
version: 2,
panelPositions: {
...layout.panelPositions,
[panelId]: position,
},
}))
}
/**
* Map a pathname (e.g. `window.location.pathname`) onto one of the editor
* workspaces, or null when the URL does not point at a canvas workspace.
*
* Lives in the storage module (rather than the hook layer) so the synchronous
* hydration in `store.ts` can call it without dragging React into the
* eager bundle.
*/
export function workspaceFromPathname(pathname: string): EditorWorkspaceId | null {
if (pathname.startsWith('/admin/site')) return 'site'
if (pathname.startsWith('/admin/content')) return 'content'
if (pathname.startsWith('/admin/data')) return 'data'
if (pathname.startsWith('/admin/media')) return 'media'
return null
}