Skip to content

Commit

Permalink
feat: Deleted docs are disconnected/tombstoned
Browse files Browse the repository at this point in the history
They can be connected again and restored by a call to replace().
  • Loading branch information
dstoc committed Oct 17, 2024
1 parent d1cf2ae commit f2b355d
Show file tree
Hide file tree
Showing 3 changed files with 56 additions and 9 deletions.
5 changes: 4 additions & 1 deletion src/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ describe('Editor', () => {
const editor = new Editor();
document.body.textContent = '';
document.body.appendChild(editor);
const metadata = {
state: 'active',
};
const tree = new MarkdownTree({
type: 'document',
children: [
Expand All @@ -22,7 +25,7 @@ describe('Editor', () => {
],
});
const name = 'index';
const doc = {name, tree} as Document;
const doc = {name, tree, metadata} as Document;
const library: Library = {
getDocumentByTree(_tree: MarkdownTree) {
return doc;
Expand Down
13 changes: 9 additions & 4 deletions src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,8 @@ class IdbDocument implements Document {
state: StoredDocument,
) {
this.metadata = state.metadata;
this.tree = new MarkdownTree(cast(state.root), state.caches, library);
const root = this.metadata.state === 'deleted' ? undefined : state.root;
this.tree = new MarkdownTree(root, state.caches, library);
this.tree.addEventListener('tree-change', (e) => {
this.treeChanged(e.detail);
});
Expand Down Expand Up @@ -378,18 +379,22 @@ class IdbDocument implements Document {
root: DocumentNode | undefined,
updater: (metadata: DocumentMetadata) => boolean,
) {
if (root) {
this.updateMetadata(updater, false);
if (this.metadata.state === 'deleted') {
this.tree.disconnect();
} else if (root) {
this.tree.connect();
this.tree.setRoot(this.tree.add<DocumentNode>(root), false);
}
this.updateMetadata(updater, false);
noAwait(this.markDirty());
}
async save() {
const {root, caches} = this.tree.serializeWithCaches();
assert(root.type === 'document');
const content: StoredDocument = {
root,
caches,
caches:
caches?.size && this.metadata.state !== 'deleted' ? caches : undefined,
metadata: this.metadata,
};
await wrap(
Expand Down
47 changes: 43 additions & 4 deletions src/markdown/view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import {viewModel} from './view-model-node.js';
import {batch, signal} from '@preact/signals-core';
import {TypedCustomEvent, TypedEventTargetConstructor} from '../event-utils.js';

function emptyDocument(): DocumentNode {
return {type: 'document', children: [{type: 'paragraph', content: ''}]};
}

let sequence = 0;
export class ViewModel {
constructor(
Expand Down Expand Up @@ -375,12 +379,20 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
MarkdownTreeEventMap
>) {
constructor(
root: DocumentNode,
root: DocumentNode | undefined,
caches?: Map<MarkdownNode, Caches>,
private readonly delegate?: MarkdownTreeDelegate,
) {
super();
this.root = this.addDom<DocumentNode>(root);
if (!root) {
this.#disconnected = true;
if (caches) console.warn('disconnected but caches present');
caches = undefined;
root = emptyDocument();
} else {
this.#disconnected = false;
}
this.root = this.addDom(root);
this.caches = caches ?? new Map<MarkdownNode, Caches>();
this.setRoot(this.root);
}
Expand All @@ -394,9 +406,27 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
root: ViewModelNode & DocumentNode;
removed = new Set<ViewModelNode>();

// Effectively a tombstone state.
#disconnected: boolean;
get disconnected() {
return this.#disconnected;
}

private undoStack: OpBatch[] = [];
private redoStack: OpBatch[] = [];

disconnect() {
if (this.#disconnected) return;
this.caches.clear();
this.#disconnected = true;
this.setRoot(this.addDom(emptyDocument()));
}

connect() {
if (!this.#disconnected) return;
this.#disconnected = false;
}

setRoot(node: DocumentNode & ViewModelNode, fireTreeEditEvent = false) {
assert(node[viewModel].tree === this);
assert(!node[viewModel].parent);
Expand All @@ -423,6 +453,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<

undo(root: ViewModelNode) {
assert(this.state === 'idle');
assert(!this.#disconnected);
let batch: OpBatch | undefined = undefined;
for (let i = this.undoStack.length - 1; i >= 0; i--) {
const classification = classify(root, this.undoStack[i]);
Expand Down Expand Up @@ -450,6 +481,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<

redo(root: ViewModelNode) {
assert(this.state === 'idle');
assert(!this.#disconnected);
let batch: OpBatch | undefined = undefined;
for (let i = this.redoStack.length - 1; i >= 0; i--) {
const classification = classify(root, this.redoStack[i]);
Expand Down Expand Up @@ -499,6 +531,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
assert(node[viewModel].version === version);
assert(node[viewModel].connected);
assert(node[viewModel].tree === this);
assert(!this.#disconnected);
let cache = this.caches.get(node);
if (value !== undefined) {
if (!cache) {
Expand All @@ -521,6 +554,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
) {
assert(this.state === 'post-edit');
assert(node[viewModel].tree === this);
assert(!this.#disconnected);
this.editChangedCaches = true;
let cache = this.caches.get(node);
if (value !== undefined) {
Expand All @@ -542,6 +576,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<

record(op: Op) {
assert(this.state === 'editing');
assert(!this.#disconnected);
this.editOperations.push(op);
}

Expand Down Expand Up @@ -575,15 +610,19 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
)) {
if (!node[viewModel].connected) {
node[viewModel].connect();
this.delegate?.postEditUpdate(node, 'connected');
if (!this.#disconnected) {
this.delegate?.postEditUpdate(node, 'connected');
}
} else {
assert(node[viewModel].version > this.editStartVersion);
// TODO: Sometimes it could be safe to keep the cache.
// e.g. if a node has moved it's content can be unchanged.
// Could move the clearing responsibility to the impls,
// otherwise they may need to build other optimizations.
this.caches.delete(node);
this.delegate?.postEditUpdate(node, 'changed');
if (!this.#disconnected) {
this.delegate?.postEditUpdate(node, 'changed');
}
}
}

Expand Down

0 comments on commit f2b355d

Please sign in to comment.