616 lines
23 KiB
JavaScript
616 lines
23 KiB
JavaScript
|
|
/** 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 };
|