Skip to content

Commit

Permalink
refactor: Repurpose document.replace
Browse files Browse the repository at this point in the history
This can now be used to safely replace the contents of the document and
associated metadata. It's quite specialized as the normal metadata updates
do not apply.

Additionally, this patch addresses the cases where the root displayed by
Editor is disconnected, or the document deleted.
  • Loading branch information
dstoc committed Aug 16, 2024
1 parent a25bd0b commit 52088af
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 42 deletions.
4 changes: 2 additions & 2 deletions src/debug-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ async function runImport(
const file = await entry.getFile();
const decoder = new TextDecoder();
const text = decoder.decode(await file.arrayBuffer());
await library.import(
parseBlocks(text).node,
await library.newDocument(
entry.name.replace(/\.md$/, ''),
parseBlocks(text).node,
);
} else if (entry.kind === 'directory') {
await runImport(library, entry);
Expand Down
19 changes: 18 additions & 1 deletion src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
import {Focus} from './markdown/view-model-ops.js';
import {findOpenCreateBundle} from './commands/find-open-create.js';
import {CommandContext} from './commands/context.js';
import {sigprop} from './signal-utils.js';

export interface EditorNavigation {
kind: 'navigate' | 'replace';
Expand Down Expand Up @@ -103,7 +104,7 @@ export class Editor extends LitElement {
@property({type: String, reflect: true})
accessor status: 'loading' | 'loaded' | 'error' | undefined;
@state() private accessor document: Document | undefined;
@state() private accessor root: ViewModelNode | undefined;
@sigprop private accessor root: ViewModelNode | undefined;
private name?: string;
@consume({context: libraryContext, subscribe: true})
@state()
Expand Down Expand Up @@ -133,6 +134,22 @@ export class Editor extends LitElement {
constructor() {
super();
}
effect() {
this.root?.[viewModel].renderSignal.value;
// If the document is mutated (including the document root) the root
// we are displaying could become disconnected. If that happens
// navigate to the top/current root.
if (this.root?.[viewModel].connected === false) {
assert(this.document);
// The document could have been deleted:
if (this.document.metadata.state === 'deleted') {
noAwait(this.navigateByName('index', false));
} else {
this.navigate(this.document, this.document.tree.root, false);
}
}
this.requestUpdate();
}
override render() {
return html` <pkm-title .node=${this.root}></pkm-title>
<md-block-render
Expand Down
43 changes: 23 additions & 20 deletions src/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export interface ComponentMetadata {
}

export interface Document {
replace(root: DocumentNode): Promise<void>;
replace(
root: DocumentNode,
updater: (metadata: DocumentMetadata) => boolean,
): void;
save(): Promise<void>;
delete(): Promise<void>;
readonly name: string;
Expand All @@ -63,14 +66,13 @@ interface LibraryEventMap {
export interface Library extends TypedEventTarget<Library, LibraryEventMap> {
// TODO: Does this need to be async? Make iterable?
findAll(name: string): Promise<{document: Document; root: ViewModelNode}[]>;
newDocument(name: string): Promise<Document>;
newDocument(name: string, root?: DocumentNode): Promise<Document>;
getDocumentByTree(tree: MarkdownTree): Document | undefined;
// TODO: Does this need to be async? Make iterable?
getAllNames(): Promise<string[]>;
getAllDocuments(): IterableIterator<Document>;
readonly metadata: Metadata;
restore(): Promise<void>;
import(root: DocumentNode, key: string): Promise<Document>;
readonly ready: Promise<void>;
}

Expand Down Expand Up @@ -204,24 +206,25 @@ export class IdbLibrary
}
return cast(blocks.at(-1));
}
async newDocument(name: string): Promise<Document> {
async newDocument(name: string, root?: DocumentNode): Promise<Document> {
name = resolveDateAlias(name) ?? name;
const now = Date.now();
root ??= {
type: 'document',
children: [{type: 'section', marker: '#', content: name}],
};
name = normalizeName(name);
const content: StoredDocument = {
root: {
type: 'document',
children: [{type: 'section', marker: '#', content: name}],
},
root,
metadata: {
state: 'active',
creationTime: now,
modificationTime: now,
clock: this.clock++,
key: normalizeKey(name),
key: name,
component: {},
},
};
name = normalizeName(name);
let n = 0;
while (true) {
const key = `${normalizeKey(name)}${n > 0 ? '-' + String(n) : ''}`;
Expand Down Expand Up @@ -277,11 +280,6 @@ export class IdbLibrary
this.cache.set(normalizeName(name), result);
return result;
}
async import(root: DocumentNode, key: string) {
const doc = await this.newDocument(key);
await doc.replace(root);
return doc;
}

postEditUpdate(
node: ViewModelNode,
Expand Down Expand Up @@ -323,8 +321,13 @@ class IdbDocument implements Document {
const names = [...this.library.metadata.getNames(this.tree.root)];
return names.length ? names : [this.metadata.key];
}
async replace(root: DocumentNode) {
this.tree.setRoot(this.tree.add<DocumentNode>(root));
replace(
root: DocumentNode,
updater: (metadata: DocumentMetadata) => boolean,
) {
this.tree.setRoot(this.tree.add<DocumentNode>(root), false);
this.updateMetadata(updater, false);
noAwait(this.markDirty());
}
async save() {
if (this.metadata.state !== 'active') return;
Expand Down Expand Up @@ -372,9 +375,6 @@ class IdbDocument implements Document {
}, false);
}
noAwait(this.markDirty());
this.library.dispatchEvent(
new TypedCustomEvent('document-change', {detail: this}),
);
}
private pendingModifications = 0;
private async markDirty() {
Expand All @@ -384,6 +384,9 @@ class IdbDocument implements Document {
const preSave = this.pendingModifications;
// Save immediately on the fist iteration, may help keep tests fast.
await this.save();
this.library.dispatchEvent(
new TypedCustomEvent('document-change', {detail: this}),
);
if (this.pendingModifications === preSave) {
this.pendingModifications = 0;
this.dirty = false;
Expand Down
52 changes: 33 additions & 19 deletions src/markdown/view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ export class ViewModel {
private connected_ = false;
connect() {
this.connected_ = true;
this.renderSignal.value++;
}
disconnect() {
this.connected_ = false;
this.renderSignal.value++;
}
remove() {
assert(this.tree.state === 'editing');
Expand Down Expand Up @@ -389,7 +391,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
private undoStack: OpBatch[] = [];
private redoStack: OpBatch[] = [];

setRoot(node: DocumentNode & ViewModelNode) {
setRoot(node: DocumentNode & ViewModelNode, fireTreeEditEvent = false) {
assert(node[viewModel].tree === this);
assert(!node[viewModel].parent);
this.edit(() => {
Expand All @@ -403,7 +405,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
this.root = node;
}
return {};
});
}, fireTreeEditEvent);
}

add<T extends MarkdownNode>(node: T) {
Expand Down Expand Up @@ -467,13 +469,18 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
return batch.endFocus;
}

edit(editFn: () => {startFocus?: Focus; endFocus?: Focus}): Op[] {
edit(
editFn: () => {startFocus?: Focus; endFocus?: Focus},
fireTreeEditEvent = true,
): Op[] {
assert(this.state === 'idle');
this.editStartVersion = this.root[viewModel].version;
this.state = 'editing';
this.removed.clear();
const {startFocus, endFocus} = batch(editFn);
return this.finishEdit(startFocus, endFocus);
return batch(() => {
const {startFocus, endFocus} = editFn();
return this.finishEdit(startFocus, endFocus, fireTreeEditEvent);
});
}

editCache<K extends keyof Caches>(
Expand Down Expand Up @@ -508,10 +515,14 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
this.editChangedCaches = true;
if (value !== undefined) {
node.caches ??= {};
node.caches[key] = value;
} else if (value === undefined) {
delete node.caches?.[key];
if (node.caches && !Object.keys(node.caches)) {
if (node.caches[key] !== value) {
this.editChangedCaches = true;
node.caches[key] = value;
}
} else if (node.caches?.[key] !== undefined) {
this.editChangedCaches = true;
delete node.caches[key];
if (!Object.keys(node.caches)) {
delete node.caches;
}
}
Expand All @@ -525,6 +536,7 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
private finishEdit(
startFocus: Focus | undefined,
endFocus: Focus | undefined,
fireTreeEditEvent = true,
) {
assert(this.state === 'editing');
if (this.root[viewModel].version > this.editStartVersion) {
Expand Down Expand Up @@ -589,17 +601,19 @@ export class MarkdownTree extends (EventTarget as TypedEventTargetConstructor<
}

this.state = 'idle';
if (
this.root[viewModel].version > this.editStartVersion &&
// setRoot uses -1 to force connection
this.editStartVersion >= 0
) {
this.dispatchEvent(new TypedCustomEvent('tree-change', {detail: 'edit'}));
} else if (this.editChangedCaches) {
if (fireTreeEditEvent) {
if (this.root[viewModel].version > this.editStartVersion) {
this.dispatchEvent(
new TypedCustomEvent('tree-change', {detail: 'edit'}),
);
} else if (this.editChangedCaches) {
this.editChangedCaches = false;
this.dispatchEvent(
new TypedCustomEvent('tree-change', {detail: 'cache'}),
);
}
} else {
this.editChangedCaches = false;
this.dispatchEvent(
new TypedCustomEvent('tree-change', {detail: 'cache'}),
);
}
return result;
}
Expand Down

0 comments on commit 52088af

Please sign in to comment.