@signal9/era-ui 3.15.0 → 3.17.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/apps/notes/editor/bubble-toolbar.svelte +8 -2
- package/dist/index.d.ts +1 -1
- package/dist/os/index.d.ts +3 -1
- package/dist/os/index.js +3 -1
- package/dist/os/wm.svelte.d.ts +109 -0
- package/dist/os/wm.svelte.js +166 -1
- package/dist/os/workspace-storage.svelte.d.ts +25 -0
- package/dist/os/workspace-storage.svelte.js +118 -0
- package/package.json +1 -1
|
@@ -23,10 +23,16 @@
|
|
|
23
23
|
|
|
24
24
|
<!-- md with xxs buttons, matching the app's other bars. It was sm + size="icon"
|
|
25
25
|
— a 22px bar of 14px buttons around 10px glyphs, the smallest combination in
|
|
26
|
-
the ladder, which is why the formatting controls read as unhittable.
|
|
26
|
+
the ladder, which is why the formatting controls read as unhittable.
|
|
27
|
+
|
|
28
|
+
The border is a deliberate exception to "shadow, not border" (design rule 7):
|
|
29
|
+
this panel floats over the DOCUMENT, not over chrome, so on a light theme its
|
|
30
|
+
elevated fill sits on a page of nearly the same value and the shadow alone
|
|
31
|
+
leaves no discernible edge. border-divider-faded is the token'd hairline, so
|
|
32
|
+
it still tracks the surface rather than being a drawn-on line. -->
|
|
27
33
|
<Bar
|
|
28
34
|
size="md"
|
|
29
|
-
class="w-max gap-(--era-xxs-inset-md) bg-elevated px-(--era-xxs-inset-md) shadow-lg glass-blur"
|
|
35
|
+
class="w-max gap-(--era-xxs-inset-md) border border-divider-faded bg-elevated px-(--era-xxs-inset-md) shadow-lg glass-blur"
|
|
30
36
|
>
|
|
31
37
|
{#each items as item (item.mark)}
|
|
32
38
|
{@const Icon = item.icon}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export * from './ui/index.js';
|
|
2
2
|
export * as OS from './os/index.js';
|
|
3
3
|
export * as AI from './ai/index.js';
|
|
4
|
-
export type { Point, Size, Rect, Insets, Frame, Workspace, WindowState, SnapZone, AppWindow, AppDefinition, AppComponent, AppProps } from './os/index.js';
|
|
4
|
+
export type { Point, Size, Rect, Insets, Frame, Workspace, WindowState, JsonValue, WindowSnapshot, WorkspaceSnapshot, SnapZone, AppWindow, AppDefinition, AppComponent, AppProps } from './os/index.js';
|
|
5
5
|
export { cn, tv, hotkeys, keys, parseKeybinding, matchKeyBindingPress, createKeybindingsHandler, formatKeybinding, isApplePlatform, modKey, type KeyBindingMap, type KeyBindingPress, type KeyBindingOptions, type PartProps, type VariantProps } from './utils/index.js';
|
package/dist/os/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export { WindowManager, getWindowManager, setWindowManagerContext, type Point, type Size, type Rect, type Insets, type Frame, type AppDefinition, type AppWindow, type AppComponent, type AppProps, type WindowState, type
|
|
1
|
+
export { WindowManager, getWindowManager, setWindowManagerContext, type Point, type Size, type Rect, type Insets, type Frame, type AppDefinition, type AppWindow, type AppComponent, type AppProps, type WindowState, type JsonValue, type WindowSnapshot, type WorkspaceSnapshot, SNAPSHOT_VERSION, readSnapshot } from './wm.svelte.js';
|
|
2
|
+
export { persistWorkspace, loadWorkspace, saveWorkspace, clearWorkspace, WORKSPACE_STORAGE_KEY } from './workspace-storage.svelte.js';
|
|
3
|
+
export { type SnapZone, type Workspace } from './wm.svelte.js';
|
|
2
4
|
export { NotificationService, getNotifications, setNotificationContext, type AppNotification, type NotifyOptions, type NotificationAction, type NotificationTone } from './notifications.svelte.js';
|
|
3
5
|
export { LAYER } from './layers.js';
|
|
4
6
|
export { default as Desktop } from './desktop.svelte';
|
package/dist/os/index.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Two stores (window manager + notifications) shared via context, and the
|
|
3
3
|
// components that render them: Desktop host, Window, Taskbar (with its
|
|
4
4
|
// built-in command bar), Toaster, NotificationCenter.
|
|
5
|
-
export { WindowManager, getWindowManager, setWindowManagerContext } from './wm.svelte.js';
|
|
5
|
+
export { WindowManager, getWindowManager, setWindowManagerContext, SNAPSHOT_VERSION, readSnapshot } from './wm.svelte.js';
|
|
6
|
+
export { persistWorkspace, loadWorkspace, saveWorkspace, clearWorkspace, WORKSPACE_STORAGE_KEY } from './workspace-storage.svelte.js';
|
|
7
|
+
export {} from './wm.svelte.js';
|
|
6
8
|
export { NotificationService, getNotifications, setNotificationContext } from './notifications.svelte.js';
|
|
7
9
|
export { LAYER } from './layers.js';
|
|
8
10
|
export { default as Desktop } from './desktop.svelte';
|
package/dist/os/wm.svelte.d.ts
CHANGED
|
@@ -27,6 +27,87 @@ export interface Frame {
|
|
|
27
27
|
pos: Point;
|
|
28
28
|
size: Size | null;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Anything that survives `JSON.parse(JSON.stringify(x))` unchanged.
|
|
32
|
+
*
|
|
33
|
+
* This is the whole serialization contract, enforced by the compiler instead of
|
|
34
|
+
* by a runtime schema. A `Date`, a `Map`, a function, `undefined` inside an
|
|
35
|
+
* array — none of them type-check here, so a snapshot that compiles is a
|
|
36
|
+
* snapshot that round-trips. It costs nothing at runtime and adds no dependency,
|
|
37
|
+
* which matters for a UI library: a schema package would land in every
|
|
38
|
+
* consumer's bundle to validate data era itself produced.
|
|
39
|
+
*
|
|
40
|
+
* The untrusted direction still needs real checking — see `readSnapshot`, which
|
|
41
|
+
* is where JSON from a file or localStorage is actually interrogated.
|
|
42
|
+
*/
|
|
43
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
44
|
+
[k: string]: JsonValue;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Bumped whenever a snapshot's shape changes in a way older data cannot satisfy.
|
|
48
|
+
* `readSnapshot` refuses anything it does not recognise rather than guessing —
|
|
49
|
+
* a half-understood layout is worse than a fresh one.
|
|
50
|
+
*/
|
|
51
|
+
export declare const SNAPSHOT_VERSION = 1;
|
|
52
|
+
/**
|
|
53
|
+
* One window, reduced to what a reload cannot reconstruct.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately NOT a partial AppWindow. A live window holds a `component` and an
|
|
56
|
+
* `icon`, which are functions; those come from the app registry and are looked
|
|
57
|
+
* up again on restore by `appId`. Storing an id instead of a reference is also
|
|
58
|
+
* what lets a snapshot outlive a refactor of the app itself.
|
|
59
|
+
*/
|
|
60
|
+
export interface WindowSnapshot {
|
|
61
|
+
/** The registered app this window rehydrates from. */
|
|
62
|
+
appId: string;
|
|
63
|
+
/** The workspace it belongs to. */
|
|
64
|
+
workspaceId: string;
|
|
65
|
+
/** Placement when `state` is 'normal'. */
|
|
66
|
+
frame: Frame;
|
|
67
|
+
state: WindowState;
|
|
68
|
+
/** Where to land on un-maximize / un-snap; null if it was never moved. */
|
|
69
|
+
restore: Frame | null;
|
|
70
|
+
/** Stacking order within its workspace. */
|
|
71
|
+
z: number;
|
|
72
|
+
/** Only when the window was retitled away from its app's default. */
|
|
73
|
+
title?: string;
|
|
74
|
+
/**
|
|
75
|
+
* App-specific state — the note being edited, a scroll offset, a filter.
|
|
76
|
+
*
|
|
77
|
+
* The escape hatch every windowing system needs: era cannot know what an app
|
|
78
|
+
* considers worth keeping, so it carries the value verbatim and hands it back
|
|
79
|
+
* on restore. Typed JsonValue rather than `unknown`, so an app cannot stash
|
|
80
|
+
* something that silently fails to survive the round trip.
|
|
81
|
+
*/
|
|
82
|
+
props?: JsonValue;
|
|
83
|
+
}
|
|
84
|
+
/** A whole desktop, exportable as one JSON document. */
|
|
85
|
+
export interface WorkspaceSnapshot {
|
|
86
|
+
version: number;
|
|
87
|
+
/**
|
|
88
|
+
* Epoch milliseconds. A number, not an ISO string: timezone-free, sortable,
|
|
89
|
+
* and it cannot drift on a round trip through a formatter. Never used for
|
|
90
|
+
* logic — it exists so a human can tell two exports apart.
|
|
91
|
+
*/
|
|
92
|
+
savedAt: number;
|
|
93
|
+
activeWorkspaceId: string;
|
|
94
|
+
workspaces: Workspace[];
|
|
95
|
+
windows: WindowSnapshot[];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse untrusted JSON into a snapshot, or return null.
|
|
99
|
+
*
|
|
100
|
+
* The counterpart to JsonValue. The compiler guarantees what era WRITES; this
|
|
101
|
+
* guards what it reads, which may be a hand-edited file, a stale localStorage
|
|
102
|
+
* entry, or a snapshot from a future version. It checks shape rather than
|
|
103
|
+
* trusting `as`, and refuses an unknown `version` outright — silently importing
|
|
104
|
+
* a layout you only half understand is worse than declining it.
|
|
105
|
+
*
|
|
106
|
+
* Individual windows are filtered, not fatal: one malformed entry should not
|
|
107
|
+
* cost the user the rest of the layout. applySnapshot() drops unknown appIds the
|
|
108
|
+
* same way, for the same reason.
|
|
109
|
+
*/
|
|
110
|
+
export declare function readSnapshot(json: string): WorkspaceSnapshot | null;
|
|
30
111
|
export type WindowState = 'normal' | 'minimized' | 'maximized';
|
|
31
112
|
/** Snap targets: the four edges (halves) or four corners (quadrants). */
|
|
32
113
|
export type SnapZone = 'left' | 'right' | 'top' | 'bottom' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
|
@@ -57,6 +138,12 @@ export interface AppDefinition {
|
|
|
57
138
|
/** Extra classes for the window body (Pane.Content) — e.g. `p-(--era-gap)`
|
|
58
139
|
* for a body of controls, or `p-0` for a full-bleed app. */
|
|
59
140
|
bodyClass?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Starting app state for every window spawned from this app — the value a
|
|
143
|
+
* window carries in its snapshot's `props`. An app reads it back off
|
|
144
|
+
* `window.props` (AppProps hands the whole record over).
|
|
145
|
+
*/
|
|
146
|
+
props?: JsonValue;
|
|
60
147
|
/** Extra title-bar controls, rendered immediately LEFT of the close button.
|
|
61
148
|
* For per-window affordances that belong on the chrome rather than in the
|
|
62
149
|
* body — a help/usage launcher, a pin, a view toggle. Assignable on the
|
|
@@ -86,6 +173,8 @@ export interface AppWindow {
|
|
|
86
173
|
minimizable: boolean;
|
|
87
174
|
bodyClass?: string;
|
|
88
175
|
actions?: Snippet;
|
|
176
|
+
/** App-specific state, carried verbatim through a snapshot. */
|
|
177
|
+
props?: JsonValue;
|
|
89
178
|
}
|
|
90
179
|
/** A named virtual desktop. Every window belongs to exactly one; only the
|
|
91
180
|
* active workspace's windows are shown, the rest keep their geometry and
|
|
@@ -158,6 +247,26 @@ export declare class WindowManager {
|
|
|
158
247
|
* The Desktop calls this whenever the work area changes; chrome itself
|
|
159
248
|
* (the taskbar) never resizes with the viewport. */
|
|
160
249
|
relayout(): void;
|
|
250
|
+
/**
|
|
251
|
+
* The whole desktop as one JSON-safe document.
|
|
252
|
+
*
|
|
253
|
+
* Reads every workspace and every window across ALL of them, not just the
|
|
254
|
+
* active one — a snapshot that only captured what was on screen would quietly
|
|
255
|
+
* drop the other workspaces the moment it was restored.
|
|
256
|
+
*/
|
|
257
|
+
snapshot(): WorkspaceSnapshot;
|
|
258
|
+
/**
|
|
259
|
+
* Replace the desktop with a snapshot.
|
|
260
|
+
*
|
|
261
|
+
* Named applySnapshot, not restore: `restore(id)` already means "un-maximize
|
|
262
|
+
* this window", and one verb cannot mean both.
|
|
263
|
+
*
|
|
264
|
+
* Windows whose `appId` is no longer registered are SKIPPED rather than
|
|
265
|
+
* failing the whole restore: an app can be removed between export and import,
|
|
266
|
+
* and losing one window beats losing the layout. Same for a window pointing at
|
|
267
|
+
* a workspace the snapshot does not contain.
|
|
268
|
+
*/
|
|
269
|
+
applySnapshot(snapshot: WorkspaceSnapshot): void;
|
|
161
270
|
/** Half / quadrant tiling against the work area. */
|
|
162
271
|
snap(id: string, zone: SnapZone): void;
|
|
163
272
|
/** Create a workspace (optionally switching to it) and return its id. */
|
package/dist/os/wm.svelte.js
CHANGED
|
@@ -1,4 +1,80 @@
|
|
|
1
1
|
import { getContext, setContext } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Bumped whenever a snapshot's shape changes in a way older data cannot satisfy.
|
|
4
|
+
* `readSnapshot` refuses anything it does not recognise rather than guessing —
|
|
5
|
+
* a half-understood layout is worse than a fresh one.
|
|
6
|
+
*/
|
|
7
|
+
export const SNAPSHOT_VERSION = 1;
|
|
8
|
+
/**
|
|
9
|
+
* Parse untrusted JSON into a snapshot, or return null.
|
|
10
|
+
*
|
|
11
|
+
* The counterpart to JsonValue. The compiler guarantees what era WRITES; this
|
|
12
|
+
* guards what it reads, which may be a hand-edited file, a stale localStorage
|
|
13
|
+
* entry, or a snapshot from a future version. It checks shape rather than
|
|
14
|
+
* trusting `as`, and refuses an unknown `version` outright — silently importing
|
|
15
|
+
* a layout you only half understand is worse than declining it.
|
|
16
|
+
*
|
|
17
|
+
* Individual windows are filtered, not fatal: one malformed entry should not
|
|
18
|
+
* cost the user the rest of the layout. applySnapshot() drops unknown appIds the
|
|
19
|
+
* same way, for the same reason.
|
|
20
|
+
*/
|
|
21
|
+
export function readSnapshot(json) {
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = JSON.parse(json);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (typeof raw !== 'object' || raw === null)
|
|
30
|
+
return null;
|
|
31
|
+
const o = raw;
|
|
32
|
+
if (o.version !== SNAPSHOT_VERSION)
|
|
33
|
+
return null;
|
|
34
|
+
if (typeof o.activeWorkspaceId !== 'string')
|
|
35
|
+
return null;
|
|
36
|
+
if (!Array.isArray(o.workspaces) || !Array.isArray(o.windows))
|
|
37
|
+
return null;
|
|
38
|
+
const isPoint = (v) => typeof v === 'object' &&
|
|
39
|
+
v !== null &&
|
|
40
|
+
typeof v.x === 'number' &&
|
|
41
|
+
typeof v.y === 'number';
|
|
42
|
+
const isSize = (v) => typeof v === 'object' &&
|
|
43
|
+
v !== null &&
|
|
44
|
+
typeof v.width === 'number' &&
|
|
45
|
+
typeof v.height === 'number';
|
|
46
|
+
const isFrame = (v) => {
|
|
47
|
+
if (typeof v !== 'object' || v === null)
|
|
48
|
+
return false;
|
|
49
|
+
const f = v;
|
|
50
|
+
return isPoint(f.pos) && (f.size === null || isSize(f.size));
|
|
51
|
+
};
|
|
52
|
+
const workspaces = o.workspaces.filter((w) => typeof w === 'object' &&
|
|
53
|
+
w !== null &&
|
|
54
|
+
typeof w.id === 'string' &&
|
|
55
|
+
typeof w.name === 'string');
|
|
56
|
+
if (workspaces.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
const states = ['normal', 'minimized', 'maximized'];
|
|
59
|
+
const windows = o.windows.filter((w) => {
|
|
60
|
+
if (typeof w !== 'object' || w === null)
|
|
61
|
+
return false;
|
|
62
|
+
const x = w;
|
|
63
|
+
return (typeof x.appId === 'string' &&
|
|
64
|
+
typeof x.workspaceId === 'string' &&
|
|
65
|
+
typeof x.z === 'number' &&
|
|
66
|
+
states.includes(x.state) &&
|
|
67
|
+
isFrame(x.frame) &&
|
|
68
|
+
(x.restore === null || isFrame(x.restore)));
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
version: SNAPSHOT_VERSION,
|
|
72
|
+
savedAt: typeof o.savedAt === 'number' ? o.savedAt : 0,
|
|
73
|
+
activeWorkspaceId: o.activeWorkspaceId,
|
|
74
|
+
workspaces,
|
|
75
|
+
windows
|
|
76
|
+
};
|
|
77
|
+
}
|
|
2
78
|
const DEFAULT_SIZE = { width: 480, height: 320 };
|
|
3
79
|
/**
|
|
4
80
|
* The source of truth for the desktop: which workspaces and windows exist,
|
|
@@ -106,7 +182,8 @@ export class WindowManager {
|
|
|
106
182
|
resizable: overrides?.resizable ?? app.resizable ?? true,
|
|
107
183
|
minimizable: app.minimizable ?? true,
|
|
108
184
|
bodyClass: app.bodyClass,
|
|
109
|
-
actions: app.actions
|
|
185
|
+
actions: app.actions,
|
|
186
|
+
props: app.props
|
|
110
187
|
};
|
|
111
188
|
this.windows.push(win);
|
|
112
189
|
return id;
|
|
@@ -205,6 +282,94 @@ export class WindowManager {
|
|
|
205
282
|
}
|
|
206
283
|
}
|
|
207
284
|
}
|
|
285
|
+
/* -------------------------------------------------------------- */
|
|
286
|
+
/* Snapshot / restore */
|
|
287
|
+
/* -------------------------------------------------------------- */
|
|
288
|
+
/**
|
|
289
|
+
* The whole desktop as one JSON-safe document.
|
|
290
|
+
*
|
|
291
|
+
* Reads every workspace and every window across ALL of them, not just the
|
|
292
|
+
* active one — a snapshot that only captured what was on screen would quietly
|
|
293
|
+
* drop the other workspaces the moment it was restored.
|
|
294
|
+
*/
|
|
295
|
+
snapshot() {
|
|
296
|
+
return {
|
|
297
|
+
version: SNAPSHOT_VERSION,
|
|
298
|
+
savedAt: Date.now(),
|
|
299
|
+
activeWorkspaceId: this.activeWorkspaceId,
|
|
300
|
+
workspaces: this.workspaces.map((w) => ({ ...w })),
|
|
301
|
+
windows: this.windows.map((w) => {
|
|
302
|
+
const app = this.apps[w.appId];
|
|
303
|
+
const snap = {
|
|
304
|
+
appId: w.appId,
|
|
305
|
+
workspaceId: w.workspaceId,
|
|
306
|
+
frame: { pos: { ...w.pos }, size: w.size ? { ...w.size } : null },
|
|
307
|
+
state: w.state,
|
|
308
|
+
restore: w.restore
|
|
309
|
+
? { pos: { ...w.restore.pos }, size: w.restore.size ? { ...w.restore.size } : null }
|
|
310
|
+
: null,
|
|
311
|
+
z: w.z
|
|
312
|
+
};
|
|
313
|
+
// Only record a title the user changed. Storing the app's own default
|
|
314
|
+
// would pin it, so renaming the app in code would not reach any
|
|
315
|
+
// window restored from an older snapshot.
|
|
316
|
+
if (app && w.title !== app.title)
|
|
317
|
+
snap.title = w.title;
|
|
318
|
+
if (w.props !== undefined)
|
|
319
|
+
snap.props = w.props;
|
|
320
|
+
return snap;
|
|
321
|
+
})
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Replace the desktop with a snapshot.
|
|
326
|
+
*
|
|
327
|
+
* Named applySnapshot, not restore: `restore(id)` already means "un-maximize
|
|
328
|
+
* this window", and one verb cannot mean both.
|
|
329
|
+
*
|
|
330
|
+
* Windows whose `appId` is no longer registered are SKIPPED rather than
|
|
331
|
+
* failing the whole restore: an app can be removed between export and import,
|
|
332
|
+
* and losing one window beats losing the layout. Same for a window pointing at
|
|
333
|
+
* a workspace the snapshot does not contain.
|
|
334
|
+
*/
|
|
335
|
+
applySnapshot(snapshot) {
|
|
336
|
+
this.windows = [];
|
|
337
|
+
this.workspaces = snapshot.workspaces.map((w) => ({ ...w }));
|
|
338
|
+
if (this.workspaces.length === 0)
|
|
339
|
+
this.workspaces = [{ id: 'w1', name: '1' }];
|
|
340
|
+
// A plain array, not a Set: there are a handful of workspaces, and a Set in
|
|
341
|
+
// a .svelte.ts file is a reactivity trap the lint rightly flags.
|
|
342
|
+
const known = this.workspaces.map((w) => w.id);
|
|
343
|
+
this.activeWorkspaceId = known.includes(snapshot.activeWorkspaceId)
|
|
344
|
+
? snapshot.activeWorkspaceId
|
|
345
|
+
: this.workspaces[0].id;
|
|
346
|
+
let z = 0;
|
|
347
|
+
for (const s of snapshot.windows) {
|
|
348
|
+
const app = this.apps[s.appId];
|
|
349
|
+
if (!app || !known.includes(s.workspaceId))
|
|
350
|
+
continue;
|
|
351
|
+
this.windows.push({
|
|
352
|
+
id: `${s.appId}#${++this.#seq}`,
|
|
353
|
+
appId: s.appId,
|
|
354
|
+
workspaceId: s.workspaceId,
|
|
355
|
+
title: s.title ?? app.title,
|
|
356
|
+
icon: app.icon,
|
|
357
|
+
pos: { ...s.frame.pos },
|
|
358
|
+
size: s.frame.size ? { ...s.frame.size } : null,
|
|
359
|
+
z: (z = Math.max(z, s.z)),
|
|
360
|
+
state: s.state,
|
|
361
|
+
restore: s.restore,
|
|
362
|
+
component: app.component,
|
|
363
|
+
resizable: app.resizable ?? true,
|
|
364
|
+
minimizable: app.minimizable ?? true,
|
|
365
|
+
bodyClass: app.bodyClass,
|
|
366
|
+
actions: app.actions,
|
|
367
|
+
props: s.props ?? app.props
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
this.#z = z;
|
|
371
|
+
this.focusedId = this.activeWindows.at(-1)?.id ?? null;
|
|
372
|
+
}
|
|
208
373
|
/** Half / quadrant tiling against the work area. */
|
|
209
374
|
snap(id, zone) {
|
|
210
375
|
const w = this.#find(id);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type WindowManager } from './wm.svelte.js';
|
|
2
|
+
export declare const WORKSPACE_STORAGE_KEY = "era-ui:workspace";
|
|
3
|
+
/** Read a saved snapshot and apply it. Returns whether anything was restored. */
|
|
4
|
+
export declare function loadWorkspace(wm: WindowManager, key?: string): boolean;
|
|
5
|
+
/** Write the current desktop. Returns false when storage rejected it. */
|
|
6
|
+
export declare function saveWorkspace(wm: WindowManager, key?: string): boolean;
|
|
7
|
+
export declare function clearWorkspace(key?: string): void;
|
|
8
|
+
/**
|
|
9
|
+
* Restore on setup, then save whenever the desktop changes.
|
|
10
|
+
*
|
|
11
|
+
* Call from a component's script (it registers an `$effect`, so it needs a
|
|
12
|
+
* component context). The load is synchronous and happens immediately, before
|
|
13
|
+
* the first paint.
|
|
14
|
+
*
|
|
15
|
+
* Writes are debounced: dragging a window mutates `pos` on every pointer move,
|
|
16
|
+
* and serialising the whole desktop per frame would be pointless work. The
|
|
17
|
+
* trailing write always lands because the effect's teardown flushes a pending
|
|
18
|
+
* timer.
|
|
19
|
+
*/
|
|
20
|
+
export declare function persistWorkspace(wm: WindowManager, options?: {
|
|
21
|
+
key?: string;
|
|
22
|
+
debounce?: number;
|
|
23
|
+
}): {
|
|
24
|
+
restored: boolean;
|
|
25
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisting the desktop across reloads.
|
|
3
|
+
*
|
|
4
|
+
* WHY localStorage, and not something newer
|
|
5
|
+
* -----------------------------------------
|
|
6
|
+
* The payload is a few kB of JSON that must be read BEFORE the first paint. That
|
|
7
|
+
* one requirement rules out every modern alternative, because they are all async:
|
|
8
|
+
*
|
|
9
|
+
* IndexedDB async. Large, structured, transactional — none of which this
|
|
10
|
+
* needs, and the desktop would paint empty and then pop windows
|
|
11
|
+
* in a frame later.
|
|
12
|
+
* OPFS async, and built for large or binary files with real
|
|
13
|
+
* read/write handles. A 2kB JSON blob is not that.
|
|
14
|
+
* Storage Buckets async, and still Chromium-only. Its actual feature is
|
|
15
|
+
* per-bucket eviction and quota policy, which matters when you
|
|
16
|
+
* have several independent stores to prioritise between. One
|
|
17
|
+
* layout snapshot is not that either.
|
|
18
|
+
*
|
|
19
|
+
* localStorage is synchronous, which here is the feature rather than the
|
|
20
|
+
* compromise: the snapshot is read and applied during setup, so the desktop's
|
|
21
|
+
* first paint is already the restored layout — no flash of an empty workspace.
|
|
22
|
+
*
|
|
23
|
+
* The genuinely modern piece worth adopting IS used: navigator.storage.persist()
|
|
24
|
+
* asks the browser to exempt this origin from eviction under storage pressure.
|
|
25
|
+
* Without it localStorage is "best effort" and can be cleared when the device is
|
|
26
|
+
* low on space. It is requested once, non-blocking, and its refusal is harmless.
|
|
27
|
+
*/
|
|
28
|
+
import { untrack } from 'svelte';
|
|
29
|
+
import { readSnapshot } from './wm.svelte.js';
|
|
30
|
+
export const WORKSPACE_STORAGE_KEY = 'era-ui:workspace';
|
|
31
|
+
/** Read a saved snapshot and apply it. Returns whether anything was restored. */
|
|
32
|
+
export function loadWorkspace(wm, key = WORKSPACE_STORAGE_KEY) {
|
|
33
|
+
if (typeof localStorage === 'undefined')
|
|
34
|
+
return false;
|
|
35
|
+
let raw = null;
|
|
36
|
+
try {
|
|
37
|
+
raw = localStorage.getItem(key);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false; // private mode, or storage disabled
|
|
41
|
+
}
|
|
42
|
+
if (!raw)
|
|
43
|
+
return false;
|
|
44
|
+
// readSnapshot is the gate: this string is untrusted (hand-edited, stale, or
|
|
45
|
+
// written by a newer version), so it is validated rather than cast.
|
|
46
|
+
const snapshot = readSnapshot(raw);
|
|
47
|
+
if (!snapshot)
|
|
48
|
+
return false;
|
|
49
|
+
wm.applySnapshot(snapshot);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
/** Write the current desktop. Returns false when storage rejected it. */
|
|
53
|
+
export function saveWorkspace(wm, key = WORKSPACE_STORAGE_KEY) {
|
|
54
|
+
if (typeof localStorage === 'undefined')
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem(key, JSON.stringify(wm.snapshot()));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Quota exceeded or private mode. A layout is convenience state, so this
|
|
62
|
+
// stays silent — unlike the Notes store, where a failed save loses writing
|
|
63
|
+
// and the user has to be told.
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function clearWorkspace(key = WORKSPACE_STORAGE_KEY) {
|
|
68
|
+
try {
|
|
69
|
+
localStorage?.removeItem(key);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
/* nothing to do — the goal was for it to be gone */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Restore on setup, then save whenever the desktop changes.
|
|
77
|
+
*
|
|
78
|
+
* Call from a component's script (it registers an `$effect`, so it needs a
|
|
79
|
+
* component context). The load is synchronous and happens immediately, before
|
|
80
|
+
* the first paint.
|
|
81
|
+
*
|
|
82
|
+
* Writes are debounced: dragging a window mutates `pos` on every pointer move,
|
|
83
|
+
* and serialising the whole desktop per frame would be pointless work. The
|
|
84
|
+
* trailing write always lands because the effect's teardown flushes a pending
|
|
85
|
+
* timer.
|
|
86
|
+
*/
|
|
87
|
+
export function persistWorkspace(wm, options = {}) {
|
|
88
|
+
const key = options.key ?? WORKSPACE_STORAGE_KEY;
|
|
89
|
+
const wait = options.debounce ?? 400;
|
|
90
|
+
const restored = loadWorkspace(wm, key);
|
|
91
|
+
// Ask for durable storage. Fire-and-forget: a refusal (or an engine without
|
|
92
|
+
// the API) just leaves the default best-effort behaviour.
|
|
93
|
+
void navigator?.storage?.persist?.().catch(() => { });
|
|
94
|
+
$effect(() => {
|
|
95
|
+
// Touch what a snapshot is made of, so the effect re-runs when any of it
|
|
96
|
+
// changes. Reading these is the subscription.
|
|
97
|
+
wm.windows.forEach((w) => {
|
|
98
|
+
void w.pos.x;
|
|
99
|
+
void w.pos.y;
|
|
100
|
+
void w.size?.width;
|
|
101
|
+
void w.size?.height;
|
|
102
|
+
void w.state;
|
|
103
|
+
void w.z;
|
|
104
|
+
void w.title;
|
|
105
|
+
void w.workspaceId;
|
|
106
|
+
void w.props;
|
|
107
|
+
});
|
|
108
|
+
void wm.workspaces.length;
|
|
109
|
+
void wm.activeWorkspaceId;
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
// untrack: snapshot() reads the same state this effect depends on, and
|
|
112
|
+
// re-reading it inside would re-subscribe on every write.
|
|
113
|
+
untrack(() => saveWorkspace(wm, key));
|
|
114
|
+
}, wait);
|
|
115
|
+
return () => clearTimeout(timer);
|
|
116
|
+
});
|
|
117
|
+
return { restored };
|
|
118
|
+
}
|