entangled-worlds-orbiters-shared 2.9.0 → 2.10.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/README.md +16 -0
- package/dist/capabilities/errors.d.ts +79 -0
- package/dist/capabilities/errors.js +87 -0
- package/dist/capabilities/index.d.ts +9 -0
- package/dist/capabilities/index.js +9 -0
- package/dist/capabilities/registry.d.ts +106 -0
- package/dist/capabilities/registry.js +215 -0
- package/dist/capabilities/schema.d.ts +52 -0
- package/dist/capabilities/schema.js +139 -0
- package/dist/studio/autosaveSession.d.ts +56 -0
- package/dist/studio/autosaveSession.js +138 -0
- package/dist/studio/headerBroadcast.d.ts +9 -0
- package/dist/studio/headerBroadcast.js +37 -0
- package/dist/studio/history.d.ts +37 -0
- package/dist/studio/history.js +50 -0
- package/dist/studio/hostTransport.d.ts +20 -0
- package/dist/studio/hostTransport.js +61 -0
- package/dist/studio/index.d.ts +6 -0
- package/dist/studio/index.js +9 -0
- package/dist/studio/patchStore.d.ts +7 -0
- package/dist/studio/patchStore.js +30 -0
- package/dist/studio/statusChannel.d.ts +12 -0
- package/dist/studio/statusChannel.js +14 -0
- package/dist/uploads/index.d.ts +34 -0
- package/dist/uploads/index.js +49 -0
- package/package.json +20 -4
- package/src/capabilities/errors.ts +115 -0
- package/src/capabilities/index.ts +22 -0
- package/src/capabilities/registry.ts +307 -0
- package/src/capabilities/schema.ts +164 -0
- package/src/loaders/cubeMapLoader.js +37 -15
- package/src/studio/autosaveSession.ts +186 -0
- package/src/studio/headerBroadcast.ts +52 -0
- package/src/studio/history.ts +91 -0
- package/src/studio/hostTransport.ts +70 -0
- package/src/studio/index.ts +30 -0
- package/src/studio/patchStore.ts +38 -0
- package/src/studio/statusChannel.ts +29 -0
- package/src/uploads/index.ts +93 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny JSON-Schema-subset validator (shared machinery, TypeScript).
|
|
3
|
+
*
|
|
4
|
+
* The ONE validator both registries use (supersedes the duplicated
|
|
5
|
+
* `studio/capabilities/schema.js` + `core/edit/capabilities/schema.ts`). Deliberately NOT a
|
|
6
|
+
* dependency (no zod/ajv) — the app is bundle-weight sensitive and the house style is
|
|
7
|
+
* lightweight guard clauses. Each capability carries an `inputSchema` literal in this subset;
|
|
8
|
+
* the SAME literal is what the future agent layer hands the model as a tool definition, so
|
|
9
|
+
* there is no second schema language to keep in sync.
|
|
10
|
+
*
|
|
11
|
+
* Supported keywords (a pragmatic subset of JSON Schema draft-07):
|
|
12
|
+
* type 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
|
13
|
+
* (or an array of those — value matches if it satisfies any)
|
|
14
|
+
* properties per-key subschemas (objects)
|
|
15
|
+
* required array of keys that must be present and not undefined (objects)
|
|
16
|
+
* items subschema applied to every array element
|
|
17
|
+
* enum value must be one of the listed values
|
|
18
|
+
* const value must deep-equal the given value
|
|
19
|
+
* nullable when true, null always passes regardless of type
|
|
20
|
+
* minimum / maximum numbers
|
|
21
|
+
* minLength / maxLength strings
|
|
22
|
+
* minItems / maxItems arrays
|
|
23
|
+
* pattern string regex source (tested with RegExp)
|
|
24
|
+
*
|
|
25
|
+
* A schema with no `type` accepts any value (still honouring enum/const/properties if set).
|
|
26
|
+
* Unknown keywords are ignored — forward-compatible, never throws on shape it doesn't know.
|
|
27
|
+
*/
|
|
28
|
+
import { ValidationError } from './errors.js';
|
|
29
|
+
const TYPE_CHECKS = {
|
|
30
|
+
object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
|
|
31
|
+
array: (v) => Array.isArray(v),
|
|
32
|
+
string: (v) => typeof v === 'string',
|
|
33
|
+
number: (v) => typeof v === 'number' && Number.isFinite(v),
|
|
34
|
+
integer: (v) => typeof v === 'number' && Number.isInteger(v),
|
|
35
|
+
boolean: (v) => typeof v === 'boolean',
|
|
36
|
+
null: (v) => v === null,
|
|
37
|
+
};
|
|
38
|
+
function deepEqual(a, b) {
|
|
39
|
+
if (a === b)
|
|
40
|
+
return true;
|
|
41
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object')
|
|
42
|
+
return false;
|
|
43
|
+
const ka = Object.keys(a);
|
|
44
|
+
const kb = Object.keys(b);
|
|
45
|
+
if (ka.length !== kb.length)
|
|
46
|
+
return false;
|
|
47
|
+
return ka.every((k) => deepEqual(a[k], b[k]));
|
|
48
|
+
}
|
|
49
|
+
function checkType(schema, value) {
|
|
50
|
+
if (schema.type == null)
|
|
51
|
+
return true;
|
|
52
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
53
|
+
return types.some((t) => (TYPE_CHECKS[t] ? TYPE_CHECKS[t](value) : true));
|
|
54
|
+
}
|
|
55
|
+
function describeType(value) {
|
|
56
|
+
if (value === null)
|
|
57
|
+
return 'null';
|
|
58
|
+
if (Array.isArray(value))
|
|
59
|
+
return 'array';
|
|
60
|
+
return typeof value;
|
|
61
|
+
}
|
|
62
|
+
function validateNode(schema, value, path, issues) {
|
|
63
|
+
if (!schema || typeof schema !== 'object')
|
|
64
|
+
return;
|
|
65
|
+
const at = path || '(root)';
|
|
66
|
+
if (schema.nullable && value === null)
|
|
67
|
+
return;
|
|
68
|
+
if (!checkType(schema, value)) {
|
|
69
|
+
const want = Array.isArray(schema.type) ? schema.type.join('|') : schema.type;
|
|
70
|
+
issues.push({ path: at, message: `expected type ${want}, got ${describeType(value)}` });
|
|
71
|
+
return; // type is the gate; downstream checks would be noise
|
|
72
|
+
}
|
|
73
|
+
if ('const' in schema && !deepEqual(value, schema.const)) {
|
|
74
|
+
issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((e) => deepEqual(e, value))) {
|
|
77
|
+
issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
|
|
78
|
+
}
|
|
79
|
+
if (typeof value === 'number') {
|
|
80
|
+
if (typeof schema.minimum === 'number' && value < schema.minimum) {
|
|
81
|
+
issues.push({ path: at, message: `must be >= ${schema.minimum}` });
|
|
82
|
+
}
|
|
83
|
+
if (typeof schema.maximum === 'number' && value > schema.maximum) {
|
|
84
|
+
issues.push({ path: at, message: `must be <= ${schema.maximum}` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === 'string') {
|
|
88
|
+
if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
|
|
89
|
+
issues.push({ path: at, message: `must be at least ${schema.minLength} chars` });
|
|
90
|
+
}
|
|
91
|
+
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
|
|
92
|
+
issues.push({ path: at, message: `must be at most ${schema.maxLength} chars` });
|
|
93
|
+
}
|
|
94
|
+
if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern).test(value)) {
|
|
95
|
+
issues.push({ path: at, message: `must match /${schema.pattern}/` });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
100
|
+
issues.push({ path: at, message: `must have at least ${schema.minItems} items` });
|
|
101
|
+
}
|
|
102
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
103
|
+
issues.push({ path: at, message: `must have at most ${schema.maxItems} items` });
|
|
104
|
+
}
|
|
105
|
+
if (schema.items) {
|
|
106
|
+
value.forEach((el, i) => validateNode(schema.items, el, `${path}[${i}]`, issues));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (TYPE_CHECKS.object(value)) {
|
|
110
|
+
const obj = value;
|
|
111
|
+
if (Array.isArray(schema.required)) {
|
|
112
|
+
for (const key of schema.required) {
|
|
113
|
+
if (obj[key] === undefined)
|
|
114
|
+
issues.push({ path: path ? `${path}.${key}` : key, message: 'is required' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (schema.properties) {
|
|
118
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
119
|
+
if (obj[key] !== undefined)
|
|
120
|
+
validateNode(sub, obj[key], path ? `${path}.${key}` : key, issues);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Returns { ok, issues }. Never throws. */
|
|
126
|
+
export function validate(schema, value) {
|
|
127
|
+
const issues = [];
|
|
128
|
+
validateNode(schema ?? {}, value, '', issues);
|
|
129
|
+
return { ok: issues.length === 0, issues };
|
|
130
|
+
}
|
|
131
|
+
/** Throws ValidationError when invalid; returns the value otherwise. Used by the registry. */
|
|
132
|
+
export function assertValid(schema, value, capability = null) {
|
|
133
|
+
const { ok, issues } = validate(schema, value);
|
|
134
|
+
if (!ok) {
|
|
135
|
+
const summary = issues.map((i) => `${i.path}: ${i.message}`).join('; ');
|
|
136
|
+
throw new ValidationError(`Invalid input${capability ? ` for "${capability}"` : ''}: ${summary}`, { capability, issues });
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type AutosaveTone = 'default' | 'busy' | 'warn' | 'ok' | 'error';
|
|
2
|
+
export interface AutosaveState {
|
|
3
|
+
ready: boolean;
|
|
4
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
5
|
+
dirty: boolean;
|
|
6
|
+
inFlight: boolean;
|
|
7
|
+
queued: boolean;
|
|
8
|
+
lastSavedDraftKey: string;
|
|
9
|
+
pendingHostDraftKey: string;
|
|
10
|
+
pendingHostDraftManual: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface AutosaveCtrl {
|
|
13
|
+
autosave: AutosaveState;
|
|
14
|
+
}
|
|
15
|
+
export interface AutosaveDeps {
|
|
16
|
+
flushAutosave?: () => unknown;
|
|
17
|
+
setHeaderStatus?: (arg: {
|
|
18
|
+
tone: AutosaveTone;
|
|
19
|
+
} | null) => void;
|
|
20
|
+
shouldDefer?: () => boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface AutosaveSession {
|
|
23
|
+
markDirty(): void;
|
|
24
|
+
schedule(opts?: {
|
|
25
|
+
delayMs?: number;
|
|
26
|
+
}): void;
|
|
27
|
+
clearTimer(): void;
|
|
28
|
+
syncStatus(): void;
|
|
29
|
+
setStatus(text?: string, tone?: AutosaveTone): void;
|
|
30
|
+
arm(baselineKey: string): void;
|
|
31
|
+
tryBegin(opts: {
|
|
32
|
+
manual: boolean;
|
|
33
|
+
}): boolean;
|
|
34
|
+
checkDedupAndBegin(opts: {
|
|
35
|
+
manual: boolean;
|
|
36
|
+
currentDraftKey: string;
|
|
37
|
+
}): {
|
|
38
|
+
proceed: boolean;
|
|
39
|
+
result: boolean;
|
|
40
|
+
};
|
|
41
|
+
markSaved(draftKey: string): void;
|
|
42
|
+
beginHostAck(opts: {
|
|
43
|
+
manual: boolean;
|
|
44
|
+
currentDraftKey: string;
|
|
45
|
+
}): void;
|
|
46
|
+
settleAfterApi(opts: {
|
|
47
|
+
manual: boolean;
|
|
48
|
+
}): void;
|
|
49
|
+
resolveHostAck(opts: {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
}): {
|
|
52
|
+
wasManual: boolean;
|
|
53
|
+
};
|
|
54
|
+
settleHostAck(): void;
|
|
55
|
+
}
|
|
56
|
+
export declare function createAutosaveSession(ctrl: AutosaveCtrl, deps?: AutosaveDeps): AutosaveSession;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// AutosaveSession — the single owner of a studio's autosave state slice.
|
|
2
|
+
//
|
|
3
|
+
// Owns STATE TRANSITIONS only: the debounce timer and the
|
|
4
|
+
// dirty/inFlight/queued/lastSavedDraftKey/pendingHost* transitions. The real I/O (building the
|
|
5
|
+
// draft, the API call, the host postMessage, toasts, analytics) stays in the app's host bridge,
|
|
6
|
+
// which drives this session through methods and never writes the slice directly. No DOM: the
|
|
7
|
+
// header save-status dot is rendered through the injected `setHeaderStatus` callback.
|
|
8
|
+
//
|
|
9
|
+
// Convention: a `*Session` owns a mutable state slice (no DOM); `createX(holder, deps)`.
|
|
10
|
+
export function createAutosaveSession(ctrl, deps = {}) {
|
|
11
|
+
ctrl.autosave = {
|
|
12
|
+
ready: false,
|
|
13
|
+
timer: null,
|
|
14
|
+
dirty: false,
|
|
15
|
+
inFlight: false,
|
|
16
|
+
queued: false,
|
|
17
|
+
lastSavedDraftKey: '',
|
|
18
|
+
pendingHostDraftKey: '',
|
|
19
|
+
pendingHostDraftManual: false,
|
|
20
|
+
};
|
|
21
|
+
const state = ctrl.autosave;
|
|
22
|
+
const api = {
|
|
23
|
+
setStatus(text = '', tone = 'default') {
|
|
24
|
+
deps.setHeaderStatus?.(text ? { tone } : null);
|
|
25
|
+
},
|
|
26
|
+
syncStatus() {
|
|
27
|
+
if (state.inFlight) {
|
|
28
|
+
api.setStatus('Saving...', 'busy');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (state.dirty) {
|
|
32
|
+
api.setStatus('Unsaved changes', 'warn');
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (state.lastSavedDraftKey) {
|
|
36
|
+
api.setStatus('All changes saved', 'ok');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
api.setStatus('');
|
|
40
|
+
},
|
|
41
|
+
clearTimer() {
|
|
42
|
+
if (!state.timer)
|
|
43
|
+
return;
|
|
44
|
+
clearTimeout(state.timer);
|
|
45
|
+
state.timer = null;
|
|
46
|
+
},
|
|
47
|
+
schedule({ delayMs = 2000 } = {}) {
|
|
48
|
+
if (!state.ready)
|
|
49
|
+
return;
|
|
50
|
+
api.clearTimer();
|
|
51
|
+
if (!state.dirty) {
|
|
52
|
+
api.syncStatus();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
api.syncStatus();
|
|
56
|
+
state.timer = setTimeout(() => {
|
|
57
|
+
state.timer = null;
|
|
58
|
+
// Reschedule (don't flush) while the app says a save would be rejected.
|
|
59
|
+
if (deps.shouldDefer?.()) {
|
|
60
|
+
api.schedule({ delayMs: 2000 });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
void deps.flushAutosave?.();
|
|
64
|
+
}, delayMs);
|
|
65
|
+
},
|
|
66
|
+
markDirty() {
|
|
67
|
+
if (!state.ready)
|
|
68
|
+
return;
|
|
69
|
+
state.dirty = true;
|
|
70
|
+
if (state.inFlight) {
|
|
71
|
+
state.queued = true;
|
|
72
|
+
api.syncStatus();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
api.schedule();
|
|
76
|
+
},
|
|
77
|
+
arm(baselineKey) {
|
|
78
|
+
state.lastSavedDraftKey = baselineKey;
|
|
79
|
+
state.ready = true;
|
|
80
|
+
},
|
|
81
|
+
tryBegin({ manual }) {
|
|
82
|
+
api.clearTimer();
|
|
83
|
+
if (!manual && (!state.ready || !state.dirty))
|
|
84
|
+
return false;
|
|
85
|
+
if (state.inFlight) {
|
|
86
|
+
state.queued = true;
|
|
87
|
+
api.syncStatus();
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
checkDedupAndBegin({ manual, currentDraftKey }) {
|
|
93
|
+
if (!manual && currentDraftKey && currentDraftKey === state.lastSavedDraftKey) {
|
|
94
|
+
state.dirty = false;
|
|
95
|
+
api.syncStatus();
|
|
96
|
+
return { proceed: false, result: true };
|
|
97
|
+
}
|
|
98
|
+
state.inFlight = true;
|
|
99
|
+
api.syncStatus();
|
|
100
|
+
return { proceed: true, result: false };
|
|
101
|
+
},
|
|
102
|
+
markSaved(draftKey) {
|
|
103
|
+
// An edit that landed while this save was in flight is queued and NOT in what was saved —
|
|
104
|
+
// stay dirty so the queued reschedule writes it.
|
|
105
|
+
state.dirty = state.queued;
|
|
106
|
+
state.lastSavedDraftKey = draftKey;
|
|
107
|
+
},
|
|
108
|
+
beginHostAck({ manual, currentDraftKey }) {
|
|
109
|
+
state.pendingHostDraftKey = currentDraftKey;
|
|
110
|
+
state.pendingHostDraftManual = manual === true;
|
|
111
|
+
},
|
|
112
|
+
settleAfterApi({ manual }) {
|
|
113
|
+
state.inFlight = false;
|
|
114
|
+
api.syncStatus();
|
|
115
|
+
if (state.queued) {
|
|
116
|
+
state.queued = false;
|
|
117
|
+
api.schedule({ delayMs: manual ? 300 : 1200 });
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
resolveHostAck({ ok }) {
|
|
121
|
+
const wasManual = state.pendingHostDraftManual === true;
|
|
122
|
+
if (ok)
|
|
123
|
+
api.markSaved(state.pendingHostDraftKey || state.lastSavedDraftKey);
|
|
124
|
+
return { wasManual };
|
|
125
|
+
},
|
|
126
|
+
settleHostAck() {
|
|
127
|
+
state.pendingHostDraftKey = '';
|
|
128
|
+
state.pendingHostDraftManual = false;
|
|
129
|
+
state.inFlight = false;
|
|
130
|
+
api.syncStatus();
|
|
131
|
+
if (state.queued) {
|
|
132
|
+
state.queued = false;
|
|
133
|
+
api.schedule({ delayMs: 1200 });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return api;
|
|
138
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type Setter = (arg: never) => void;
|
|
2
|
+
/** A header sink implements any subset of the setters. */
|
|
3
|
+
export type HeaderSinkOf<K extends string> = Partial<Record<K, Setter>>;
|
|
4
|
+
export type HeaderBroadcast<Shell extends object, K extends string> = Shell & {
|
|
5
|
+
/** Push the last value of every header setter into a freshly registered sink. */
|
|
6
|
+
replayInto(sink: HeaderSinkOf<K> | null | undefined): void;
|
|
7
|
+
};
|
|
8
|
+
export declare function makeHeaderBroadcast<Shell extends object, K extends string>(shell: Shell, getSink: () => HeaderSinkOf<K> | null | undefined, setters: readonly K[]): HeaderBroadcast<Shell, K>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Header-protocol broadcast (the "header sink" seam).
|
|
2
|
+
//
|
|
3
|
+
// A studio controller drives its header imperatively (`shell.setUndoRedoState(...)`,
|
|
4
|
+
// `shell.setHeaderStatus(...)`, …). When a React header mounts over the stage it must show the
|
|
5
|
+
// SAME state, driven by the SAME controller logic — no duplicated per-step rules, one source of
|
|
6
|
+
// truth.
|
|
7
|
+
//
|
|
8
|
+
// `makeHeaderBroadcast` wraps the shell so every listed header setter ALSO fans out to a sink
|
|
9
|
+
// (read live through `getSink`, so a late registration is picked up) and retains its last value,
|
|
10
|
+
// so a sink that registers after the shell was driven can be replayed race-free via `replayInto`.
|
|
11
|
+
// Every other member of the shell passes through untouched.
|
|
12
|
+
export function makeHeaderBroadcast(shell, getSink, setters) {
|
|
13
|
+
// One slot per setter (all single-arg, last write wins) is the full retained header model.
|
|
14
|
+
const lastArg = new Map();
|
|
15
|
+
const call = (fn, self, arg) => {
|
|
16
|
+
if (typeof fn === 'function')
|
|
17
|
+
fn.call(self, arg);
|
|
18
|
+
};
|
|
19
|
+
// Spread carries the shell's own members (data props and non-header methods). Shell methods
|
|
20
|
+
// are expected to be closures (no `this`), so copying the references is safe.
|
|
21
|
+
const wrapper = { ...shell };
|
|
22
|
+
for (const key of setters) {
|
|
23
|
+
const original = shell[key];
|
|
24
|
+
wrapper[key] = (arg) => {
|
|
25
|
+
call(original, shell, arg);
|
|
26
|
+
lastArg.set(key, arg);
|
|
27
|
+
call(getSink()?.[key], undefined, arg);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
wrapper.replayInto = (sink) => {
|
|
31
|
+
if (!sink)
|
|
32
|
+
return;
|
|
33
|
+
for (const [key, arg] of lastArg)
|
|
34
|
+
call(sink[key], undefined, arg);
|
|
35
|
+
};
|
|
36
|
+
return wrapper;
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface HistoryStacks<S> {
|
|
2
|
+
undo: S[];
|
|
3
|
+
redo: S[];
|
|
4
|
+
}
|
|
5
|
+
export type HistoryAction = 'push' | 'undo' | 'redo' | 'clear';
|
|
6
|
+
export interface HistoryState {
|
|
7
|
+
canUndo: boolean;
|
|
8
|
+
canRedo: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface HistoryDeps<S> {
|
|
11
|
+
/** Capture the current editable state. Must return a detached copy (deep clone). */
|
|
12
|
+
snapshot: () => S;
|
|
13
|
+
/** Put a previously captured snapshot back as the current state. */
|
|
14
|
+
restore: (snapshot: S) => void;
|
|
15
|
+
/** Return false to skip a push (e.g. nothing is loaded yet). */
|
|
16
|
+
canPush?: () => boolean;
|
|
17
|
+
/** Oldest entries drop off beyond this many undo steps. */
|
|
18
|
+
maxHistory?: number;
|
|
19
|
+
/** Reuse existing stacks instead of fresh ones. */
|
|
20
|
+
stacks?: HistoryStacks<S>;
|
|
21
|
+
/** Fires after every change of the stacks, with the action that caused it. */
|
|
22
|
+
onChange?: (state: HistoryState, action: HistoryAction) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface StudioHistory<S> {
|
|
25
|
+
readonly stacks: HistoryStacks<S>;
|
|
26
|
+
/** Record the current state as an undo step. Call BEFORE applying a reversible edit. */
|
|
27
|
+
push(): void;
|
|
28
|
+
undo(): boolean;
|
|
29
|
+
redo(): boolean;
|
|
30
|
+
clear(): void;
|
|
31
|
+
canUndo(): boolean;
|
|
32
|
+
canRedo(): boolean;
|
|
33
|
+
getState(): HistoryState;
|
|
34
|
+
}
|
|
35
|
+
export declare function createHistory<S>(deps: HistoryDeps<S>): StudioHistory<S>;
|
|
36
|
+
/** Deep clone for JSON-shaped studio state — the usual `snapshot()` building block. */
|
|
37
|
+
export declare function cloneJson<T>(value: T): T;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Snapshot-based undo/redo for a studio.
|
|
2
|
+
//
|
|
3
|
+
// The history knows nothing about what it stores: the app injects `snapshot()` (capture the
|
|
4
|
+
// editable state, deep-cloned) and `restore(s)` (put a snapshot back). `onChange` fires after every
|
|
5
|
+
// push/undo/redo/clear so the app can mark its draft dirty, rebuild derived views, and refresh the
|
|
6
|
+
// header's undo/redo buttons. The stacks can be supplied by the caller so an existing controller
|
|
7
|
+
// that already exposes `history.undo` / `history.redo` arrays keeps reading the same arrays.
|
|
8
|
+
export function createHistory(deps) {
|
|
9
|
+
const { snapshot, restore, canPush, maxHistory = 60, onChange } = deps;
|
|
10
|
+
const stacks = deps.stacks ?? { undo: [], redo: [] };
|
|
11
|
+
const getState = () => ({
|
|
12
|
+
canUndo: stacks.undo.length > 0,
|
|
13
|
+
canRedo: stacks.redo.length > 0,
|
|
14
|
+
});
|
|
15
|
+
const notify = (action) => onChange?.(getState(), action);
|
|
16
|
+
function step(from, to, action) {
|
|
17
|
+
if (!from.length)
|
|
18
|
+
return false;
|
|
19
|
+
to.push(snapshot());
|
|
20
|
+
restore(from.pop());
|
|
21
|
+
notify(action);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
stacks,
|
|
26
|
+
push() {
|
|
27
|
+
if (canPush && !canPush())
|
|
28
|
+
return;
|
|
29
|
+
stacks.undo.push(snapshot());
|
|
30
|
+
if (stacks.undo.length > maxHistory)
|
|
31
|
+
stacks.undo.shift();
|
|
32
|
+
stacks.redo.length = 0;
|
|
33
|
+
notify('push');
|
|
34
|
+
},
|
|
35
|
+
undo: () => step(stacks.undo, stacks.redo, 'undo'),
|
|
36
|
+
redo: () => step(stacks.redo, stacks.undo, 'redo'),
|
|
37
|
+
clear() {
|
|
38
|
+
stacks.undo.length = 0;
|
|
39
|
+
stacks.redo.length = 0;
|
|
40
|
+
notify('clear');
|
|
41
|
+
},
|
|
42
|
+
canUndo: () => stacks.undo.length > 0,
|
|
43
|
+
canRedo: () => stacks.redo.length > 0,
|
|
44
|
+
getState,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Deep clone for JSON-shaped studio state — the usual `snapshot()` building block. */
|
|
48
|
+
export function cloneJson(value) {
|
|
49
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
50
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface WindowLike {
|
|
2
|
+
location: {
|
|
3
|
+
origin: string;
|
|
4
|
+
};
|
|
5
|
+
parent: WindowLike | null;
|
|
6
|
+
postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* True for the studio's own origin, a local dev host, or an https plantasia.space origin
|
|
10
|
+
* (apex or any subdomain).
|
|
11
|
+
*/
|
|
12
|
+
export declare function isTrustedHostOrigin(origin: string | null | undefined, win?: WindowLike | null): boolean;
|
|
13
|
+
/** True when running inside a parent frame. */
|
|
14
|
+
export declare function isEmbedded(win?: WindowLike | null): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Post to the parent frame. Returns false (and sends nothing) when there is no recorded host
|
|
17
|
+
* origin or no parent — never falls back to a '*' broadcast for payloads.
|
|
18
|
+
*/
|
|
19
|
+
export declare function postToHost(message: unknown, targetOrigin: string | null | undefined, transfer?: Transferable[], win?: WindowLike | null): boolean;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The generic half of a studio's host bridge: who may act as the host, and how to talk to it.
|
|
2
|
+
//
|
|
3
|
+
// A studio runs embedded in an iframe of the platform dashboard (or a local dev server). Drafts
|
|
4
|
+
// and captured media are posted back to whichever origin claims the host role, so an arbitrary
|
|
5
|
+
// embedding page must never pass the trust gate, and nothing is ever broadcast to '*' except the
|
|
6
|
+
// initial "ready" ping (which carries no payload).
|
|
7
|
+
//
|
|
8
|
+
// The app-specific half — what a draft looks like, which API saves it, which message types exist
|
|
9
|
+
// — stays in each app and drives an AutosaveSession.
|
|
10
|
+
const TRUSTED_DEV_HOSTNAMES = new Set(['localhost', '127.0.0.1']);
|
|
11
|
+
const TRUSTED_DOMAIN = 'plantasia.space';
|
|
12
|
+
const currentWindow = () => typeof window === 'undefined' ? null : window;
|
|
13
|
+
/**
|
|
14
|
+
* True for the studio's own origin, a local dev host, or an https plantasia.space origin
|
|
15
|
+
* (apex or any subdomain).
|
|
16
|
+
*/
|
|
17
|
+
export function isTrustedHostOrigin(origin, win = currentWindow()) {
|
|
18
|
+
if (!origin || origin === 'null')
|
|
19
|
+
return false;
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(origin);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (win && url.origin === win.location.origin)
|
|
28
|
+
return true;
|
|
29
|
+
const hostname = url.hostname.toLowerCase();
|
|
30
|
+
if (TRUSTED_DEV_HOSTNAMES.has(hostname))
|
|
31
|
+
return true;
|
|
32
|
+
return url.protocol === 'https:' && (hostname === TRUSTED_DOMAIN || hostname.endsWith(`.${TRUSTED_DOMAIN}`));
|
|
33
|
+
}
|
|
34
|
+
/** True when running inside a parent frame. */
|
|
35
|
+
export function isEmbedded(win = currentWindow()) {
|
|
36
|
+
try {
|
|
37
|
+
return Boolean(win?.parent && win.parent !== win);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Post to the parent frame. Returns false (and sends nothing) when there is no recorded host
|
|
45
|
+
* origin or no parent — never falls back to a '*' broadcast for payloads.
|
|
46
|
+
*/
|
|
47
|
+
export function postToHost(message, targetOrigin, transfer = [], win = currentWindow()) {
|
|
48
|
+
try {
|
|
49
|
+
if (!targetOrigin || !win)
|
|
50
|
+
return false;
|
|
51
|
+
if (isEmbedded(win)) {
|
|
52
|
+
;
|
|
53
|
+
win.parent.postMessage(message, targetOrigin, transfer);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.warn('[Studio] postMessage to host failed', err);
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createHistory, cloneJson, type StudioHistory, type HistoryDeps, type HistoryStacks, type HistoryState, type HistoryAction, } from './history.js';
|
|
2
|
+
export { createAutosaveSession, type AutosaveSession, type AutosaveState, type AutosaveCtrl, type AutosaveDeps, type AutosaveTone, } from './autosaveSession.js';
|
|
3
|
+
export { createPatchStore, type PatchStore } from './patchStore.js';
|
|
4
|
+
export { makeHeaderBroadcast, type HeaderBroadcast, type HeaderSinkOf } from './headerBroadcast.js';
|
|
5
|
+
export { createStatusChannel, type StatusChannel, type StatusSink, type StatusShowOptions, } from './statusChannel.js';
|
|
6
|
+
export { isTrustedHostOrigin, isEmbedded, postToHost } from './hostTransport.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Studio session machinery shared by the Entangled World and Orbiter studios: undo/redo, the
|
|
2
|
+
// autosave state machine, the header mirror (patch store + broadcast/replay), the status line
|
|
3
|
+
// seam, and the host-frame transport. Framework-free: no React, no DOM beyond postMessage.
|
|
4
|
+
export { createHistory, cloneJson, } from './history.js';
|
|
5
|
+
export { createAutosaveSession, } from './autosaveSession.js';
|
|
6
|
+
export { createPatchStore } from './patchStore.js';
|
|
7
|
+
export { makeHeaderBroadcast } from './headerBroadcast.js';
|
|
8
|
+
export { createStatusChannel, } from './statusChannel.js';
|
|
9
|
+
export { isTrustedHostOrigin, isEmbedded, postToHost } from './hostTransport.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface PatchStore<T extends object> {
|
|
2
|
+
subscribe(listener: () => void): () => void;
|
|
3
|
+
getSnapshot(): T;
|
|
4
|
+
/** Shallow-merge `next` into a new snapshot and notify. No-op when nothing changes. */
|
|
5
|
+
patch(next: Partial<T>): void;
|
|
6
|
+
}
|
|
7
|
+
export declare function createPatchStore<T extends object>(initial: T): PatchStore<T>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A tiny external store: an immutable snapshot that is patched and broadcast. Compatible with
|
|
2
|
+
// React's `useSyncExternalStore(store.subscribe, store.getSnapshot)` without importing React.
|
|
3
|
+
// A studio's header store is one of these plus named setters — the mirror of controller state,
|
|
4
|
+
// never its source.
|
|
5
|
+
export function createPatchStore(initial) {
|
|
6
|
+
let state = initial;
|
|
7
|
+
const listeners = new Set();
|
|
8
|
+
return {
|
|
9
|
+
subscribe(listener) {
|
|
10
|
+
listeners.add(listener);
|
|
11
|
+
return () => {
|
|
12
|
+
listeners.delete(listener);
|
|
13
|
+
};
|
|
14
|
+
},
|
|
15
|
+
getSnapshot: () => state,
|
|
16
|
+
patch(next) {
|
|
17
|
+
let changed = false;
|
|
18
|
+
for (const key of Object.keys(next)) {
|
|
19
|
+
if (!Object.is(state[key], next[key])) {
|
|
20
|
+
changed = true;
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (!changed)
|
|
25
|
+
return;
|
|
26
|
+
state = { ...state, ...next };
|
|
27
|
+
listeners.forEach((l) => l());
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface StatusShowOptions {
|
|
2
|
+
/** Hide after this many ms; 0 keeps it until replaced. */
|
|
3
|
+
autoHideMs?: number;
|
|
4
|
+
}
|
|
5
|
+
export interface StatusSink {
|
|
6
|
+
show(text: string, opts: Required<StatusShowOptions>): void;
|
|
7
|
+
}
|
|
8
|
+
export interface StatusChannel {
|
|
9
|
+
setSink(sink: StatusSink | null | undefined): void;
|
|
10
|
+
show(text: unknown, opts?: StatusShowOptions): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function createStatusChannel(): StatusChannel;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Studio status line ("Saving…", "Saved", errors). Studio core boots before its UI, so it stays
|
|
2
|
+
// UI-free: it calls `show()`, and the UI layer (a toaster) registers itself as the sink on mount.
|
|
3
|
+
// Calls made while no sink is registered are dropped — status is transient by nature.
|
|
4
|
+
export function createStatusChannel() {
|
|
5
|
+
let sink = null;
|
|
6
|
+
return {
|
|
7
|
+
setSink(next) {
|
|
8
|
+
sink = next || null;
|
|
9
|
+
},
|
|
10
|
+
show(text, { autoHideMs = 0 } = {}) {
|
|
11
|
+
sink?.show(String(text ?? ''), { autoHideMs });
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|