merge experimental void app

This commit is contained in:
Stewart Allen 2026-02-14 19:00:48 -05:00
commit 02fb3cd30d
80 changed files with 30437 additions and 813 deletions

616
src/void/api/document.js Normal file
View file

@ -0,0 +1,616 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { space } from '../../moto/space.js';
function createDocumentApi(getApi, cfg) {
const {
DOC_SCHEMA_VERSION,
ADMIN_CURRENT_DOC_KEY,
ADMIN_CURRENT_REV_KEY,
UNDOABLE_OP_TYPES,
idFactory,
revString
} = cfg;
return {
current: null,
isHydrating: false,
_runtimeSaveTimer: null,
_runtimeSavePending: null,
_datumHandlers: new Map(),
_datumRootHandler: null,
_originHandler: null,
_runtimeFlushBound: false,
_redoStack: [],
_atomicEdit: null,
create() {
const api = getApi();
const doc = {
id: idFactory(),
schema_version: DOC_SCHEMA_VERSION,
name: 'Untitled',
created_at: Date.now(),
modified_at: Date.now(),
version: { major: 0, micro: 0 },
head_rev: null,
features: [],
tree: {
folders: [
{ id: 'features', name: 'Features', collapsed: false }
]
},
timeline: {
index: null
},
generated: {
solids: []
},
geometry_store: api.geometryStore?.defaultState?.() || null,
scene: {
datum: api.datum.defaultState(),
origin: api.origin.defaultState()
}
};
this.current = doc;
this._redoStack = [];
return doc;
},
normalizeName(name) {
const clean = String(name || '').trim();
return clean || 'Untitled';
},
migrate(doc) {
const api = getApi();
if (!doc) return doc;
let changed = false;
if (doc.schema_version === undefined) {
doc.schema_version = DOC_SCHEMA_VERSION;
changed = true;
}
if (!doc.created_at && doc.created) {
doc.created_at = doc.created;
changed = true;
}
if (!doc.modified_at && doc.modified) {
doc.modified_at = doc.modified;
changed = true;
}
if (!doc.version) {
doc.version = { major: 0, micro: 0 };
changed = true;
}
if (doc.head_rev === undefined) {
doc.head_rev = null;
changed = true;
}
if (!Array.isArray(doc.features)) {
doc.features = [];
changed = true;
}
if (!doc.tree || !Array.isArray(doc.tree.folders)) {
doc.tree = {
folders: [
{ id: 'features', name: 'Features', collapsed: false }
]
};
changed = true;
}
if (!doc.timeline || typeof doc.timeline !== 'object') {
doc.timeline = { index: null };
changed = true;
}
if (doc.timeline.index !== null && !Number.isFinite(doc.timeline.index)) {
doc.timeline.index = null;
changed = true;
}
if (!doc.generated || typeof doc.generated !== 'object') {
doc.generated = { solids: [] };
changed = true;
}
if (!Array.isArray(doc.generated.solids)) {
doc.generated.solids = [];
changed = true;
}
if (!doc.geometry_store) {
doc.geometry_store = api.geometryStore?.defaultState?.() || null;
changed = true;
}
if (api.geometryStore?.normalize) {
const normalized = api.geometryStore.normalize(doc.geometry_store);
if (JSON.stringify(normalized) !== JSON.stringify(doc.geometry_store)) {
doc.geometry_store = normalized;
changed = true;
}
}
if (!doc.scene) {
doc.scene = {};
changed = true;
}
if (!doc.scene.datum) {
doc.scene.datum = api.datum.defaultState();
changed = true;
}
if (!doc.scene.origin) {
doc.scene.origin = api.origin.defaultState();
changed = true;
}
return { doc, changed };
},
nextRevision(kind = 'micro') {
const current = this.current?.version || { major: 0, micro: 0 };
if (kind === 'major') {
return { major: current.major + 1, micro: 0 };
}
return { major: current.major, micro: current.micro + 1 };
},
revisionKey(docId, rev) {
return `${docId}:${revString(rev)}`;
},
toSnapshot() {
if (!this.current) return null;
this.current.scene = this.captureRuntimeState();
return JSON.parse(JSON.stringify(this.current));
},
captureRuntimeState() {
const api = getApi();
return {
datum: api.datum.toJSON(),
origin: api.origin.toJSON()
};
},
hydrateRuntimeState(doc) {
const api = getApi();
if (!doc) return;
api.geometryStore?.hydrate?.(doc);
const scene = doc.scene || {};
const datumState = scene.datum || api.datum.defaultState();
const originState = scene.origin || api.origin.defaultState();
this.isHydrating = true;
try {
api.origin.applyJSON(originState, false);
api.datum.applyJSON(datumState);
} finally {
this.isHydrating = false;
}
api.datum.updateLabels(api.overlay);
api.sketchRuntime?.sync();
space.update();
},
bindRuntimeObservers() {
const api = getApi();
if (!api.datum || !api.datum.getPlanes) return;
for (const [id, rec] of this._datumHandlers) {
rec.plane.offChange(rec.handler);
}
this._datumHandlers.clear();
if (this._datumRootHandler) {
api.datum.offChange(this._datumRootHandler);
this._datumRootHandler = null;
}
if (this._originHandler) {
api.origin.offChange(this._originHandler);
this._originHandler = null;
}
for (const plane of api.datum.getPlanes()) {
const handler = () => {
this.scheduleRuntimeSave('datum.update', { plane_id: plane.id });
};
this._datumHandlers.set(plane.id, { plane, handler });
plane.onChange(handler);
}
this._datumRootHandler = () => {
this.scheduleRuntimeSave('datum.root.update', { scope: 'datum' });
};
api.datum.onChange(this._datumRootHandler);
this._originHandler = () => {
this.scheduleRuntimeSave('origin.update', { scope: 'origin' });
};
api.origin.onChange(this._originHandler);
if (!this._runtimeFlushBound) {
this._runtimeFlushBound = true;
const flush = () => this.flushRuntimeSave();
window.addEventListener('pagehide', flush);
window.addEventListener('beforeunload', flush);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flush();
}
});
}
},
scheduleRuntimeSave(opType = 'runtime.update', payload = null) {
if (this.isHydrating || !this.current) {
return;
}
this._runtimeSavePending = { opType, payload };
clearTimeout(this._runtimeSaveTimer);
this._runtimeSaveTimer = setTimeout(() => {
this.flushRuntimeSave();
}, 200);
},
flushRuntimeSave() {
if (this.isHydrating || !this.current || !this._runtimeSavePending) {
return Promise.resolve();
}
clearTimeout(this._runtimeSaveTimer);
const pending = this._runtimeSavePending;
this._runtimeSavePending = null;
return this.save({
kind: 'micro',
opType: pending.opType,
payload: pending.payload
});
},
load(id) {
const api = getApi();
return api.db.documents.get(id).then(doc => {
if (doc) {
const migrated = this.migrate(doc);
this.current = migrated.doc;
api.geometryStore?.attachToDocument?.(this.current);
this.current.name = this.normalizeName(this.current.name);
this._redoStack = [];
this.hydrateRuntimeState(this.current);
if (migrated.changed) {
return api.db.documents.put(this.current.id, this.current).then(() => this.current);
}
return this.current;
}
return null;
});
},
save(options = {}) {
const api = getApi();
if (this.current) {
const now = Date.now();
const kind = options.kind || 'micro';
const opType = options.opType || (kind === 'major' ? 'snapshot' : 'delta');
const undoable = options.undoable !== undefined ? !!options.undoable :
(kind === 'major' || UNDOABLE_OP_TYPES.has(opType));
const next = this.nextRevision(kind);
const revId = this.revisionKey(this.current.id, next);
this.current.modified_at = now;
this.current.name = this.normalizeName(this.current.name);
if (!undoable) {
const currentRev = this.current.head_rev || null;
return Promise.all([
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, currentRev)
]);
}
const revision = {
doc_id: this.current.id,
rev: next,
rev_id: revId,
parent_rev: this.current.head_rev || null,
schema_version: DOC_SCHEMA_VERSION,
op_type: opType,
payload: options.payload || null,
snapshot: this.toSnapshot(),
created_at: now
};
this.current.version = next;
this.current.head_rev = revId;
this.current.scene = revision.snapshot.scene;
if (undoable && options.clearRedo !== false) {
this._redoStack = [];
}
return Promise.all([
api.db.versions.put(revId, revision),
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
]);
}
return Promise.resolve();
},
createAndSelect() {
this.create();
this.hydrateRuntimeState(this.current);
return this.save({
kind: 'major',
opType: 'snapshot',
payload: { reason: 'seed' }
}).then(() => this.current);
},
list() {
const api = getApi();
return api.db.documents.iterate().then(entries => {
return entries
.map(({ value }) => value)
.filter(Boolean)
.map(doc => {
const migrated = this.migrate(doc);
migrated.doc.name = this.normalizeName(migrated.doc.name);
return migrated.doc;
})
.sort((a, b) => (b.modified_at || 0) - (a.modified_at || 0));
});
},
select(id) {
const api = getApi();
return this.load(id).then(doc => {
if (!doc) {
return null;
}
this._redoStack = [];
return Promise.all([
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
]).then(() => this.current);
});
},
open(id) {
return this.select(id);
},
rename(name) {
if (!this.current) return Promise.resolve(null);
const nextName = this.normalizeName(name);
if (nextName === this.current.name) {
return Promise.resolve(this.current);
}
const previous = this.current.name;
this.current.name = nextName;
return this.save({
kind: 'micro',
opType: 'doc.rename',
undoable: false,
payload: {
previous,
next: nextName
}
}).then(() => this.current);
},
getRevision(revId) {
const api = getApi();
if (!revId) return Promise.resolve(null);
return api.db.versions.get(revId);
},
applyRevision(revision) {
const api = getApi();
if (!revision || !revision.snapshot) {
return Promise.resolve(null);
}
const previousAtomicEdit = this._atomicEdit ? { ...this._atomicEdit } : null;
const previousDocId = this.current?.id || null;
const preserved = this.current ? {
name: this.current.name,
tree: JSON.parse(JSON.stringify(this.current.tree || { folders: [] }))
} : null;
const migrated = this.migrate(JSON.parse(JSON.stringify(revision.snapshot)));
this.current = migrated.doc;
api.geometryStore?.attachToDocument?.(this.current);
if (previousAtomicEdit && previousDocId && previousDocId === this.current?.id) {
const featureId = previousAtomicEdit.feature_id || null;
const hasFeature = featureId && Array.isArray(this.current?.features)
? this.current.features.some(feature => feature?.id === featureId)
: false;
this._atomicEdit = hasFeature ? previousAtomicEdit : null;
} else {
this._atomicEdit = null;
}
if (preserved) {
this.current.name = this.normalizeName(preserved.name);
this.current.tree = preserved.tree;
}
this.current.version = revision.rev || this.current.version;
this.current.head_rev = revision.rev_id || this.revisionKey(this.current.id, this.current.version);
this.current.modified_at = revision.created_at || this.current.modified_at || Date.now();
this.hydrateRuntimeState(this.current);
api.solids?.scheduleRebuild?.('document.hydrate');
return Promise.all([
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
]).then(() => this.current);
},
canRedo() {
return this._redoStack.length > 0;
},
undo() {
const head = this.current?.head_rev;
if (!head) return Promise.resolve(false);
return this.getRevision(head).then(revision => {
const parent = revision?.parent_rev;
if (!parent) return false;
return this.getRevision(parent).then(parentRevision => {
if (!parentRevision) return false;
this._redoStack.push(head);
return this.applyRevision(parentRevision).then(() => true);
});
});
},
redo() {
if (!this._redoStack.length) return Promise.resolve(false);
const nextRev = this._redoStack.pop();
return this.getRevision(nextRev).then(revision => {
if (!revision) return false;
if (revision.parent_rev && revision.parent_rev !== this.current?.head_rev) {
this._redoStack = [];
return false;
}
return this.applyRevision(revision).then(() => true);
});
},
delete(id) {
const api = getApi();
if (!id) return Promise.resolve(false);
const isCurrent = this.current?.id === id;
const lower = `${id}:`;
const upper = `${id}:\uffff`;
return api.db.versions.iterate({ lower, upper }).then(entries => {
const deletes = entries.map(({ key }) => api.db.versions.remove(key));
deletes.push(api.db.documents.remove(id));
return Promise.all(deletes);
}).then(() => {
if (!isCurrent) {
return true;
}
this._redoStack = [];
return this.list().then(docs => {
const next = docs.find(doc => doc.id !== id);
if (next) {
return this.select(next.id).then(() => true);
}
return this.createAndSelect().then(() => true);
});
});
},
restoreOrCreate() {
const api = getApi();
return api.db.admin.get(ADMIN_CURRENT_DOC_KEY).then(docId => {
if (!docId) {
return this.createAndSelect();
}
return this.select(docId).then(doc => {
if (doc) {
return this.current;
}
return this.list().then(docs => {
if (docs.length) {
return this.select(docs[0].id).then(() => this.current);
}
return this.createAndSelect();
});
});
});
},
isAtomicEditActive() {
return !!this._atomicEdit;
},
getAtomicEditFeatureId() {
return this._atomicEdit?.feature_id || null;
},
beginAtomicEdit(meta = {}) {
this._atomicEdit = {
feature_id: meta.feature_id || null,
feature_type: meta.feature_type || null,
start_rev: this.current?.head_rev || null
};
},
endAtomicEdit(options = {}) {
const api = getApi();
const session = this._atomicEdit;
this._atomicEdit = null;
if (!session) {
return Promise.resolve(false);
}
if (options.commit !== true) {
return Promise.resolve(false);
}
const startRev = session.start_rev || null;
const currentHead = this.current?.head_rev || null;
if (startRev === currentHead) {
return Promise.resolve(false);
}
if (!this.current) return Promise.resolve(false);
const now = Date.now();
const next = this.nextRevision('micro');
const revId = this.revisionKey(this.current.id, next);
const snapshot = this.toSnapshot();
const revision = {
doc_id: this.current.id,
rev: next,
rev_id: revId,
parent_rev: startRev,
schema_version: DOC_SCHEMA_VERSION,
op_type: options.opType || 'feature.atomic.edit',
payload: options.payload || {
feature_id: session.feature_id,
feature_type: session.feature_type
},
snapshot,
created_at: now
};
this.current.version = next;
this.current.head_rev = revId;
this.current.scene = snapshot.scene;
this.current.modified_at = now;
this._redoStack = [];
return Promise.all([
api.db.versions.put(revId, revision),
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
]).then(() => true);
},
getTimelineCount() {
const features = Array.isArray(this.current?.features) ? this.current.features : [];
if (!features.length) return 0;
const rawIndex = this.current?.timeline?.index;
if (rawIndex === null || rawIndex === undefined) {
return features.length;
}
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(rawIndex)));
return index + 1;
},
setTimelineCount(count) {
const api = getApi();
if (!this.current) return Promise.resolve(false);
const features = Array.isArray(this.current.features) ? this.current.features : [];
const maxCount = features.length;
const nextCount = Math.max(0, Math.min(maxCount, Math.floor(Number(count) || 0)));
const prevCount = this.getTimelineCount();
if (nextCount === prevCount) {
return Promise.resolve(false);
}
this.current.timeline = this.current.timeline || { index: null };
this.current.timeline.index = nextCount >= maxCount ? null : (nextCount - 1);
return this.save({
kind: 'micro',
opType: 'timeline.set',
payload: { previous: prevCount, next: nextCount }
}).then(() => {
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('timeline.set');
return true;
});
}
};
}
export { createDocumentApi };