Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(store): snapshot import and export #3223

Merged
merged 3 commits into from
Jun 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions packages/playground/src/components/debug-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,13 +288,40 @@ export class DebugMenu extends ShadowlessElement {
this.contentParser.exportPng();
}

private _exportYDoc() {
this.workspace.exportYDoc();
private _exportSnapshot() {
const json = this.workspace.exportPageSnapshot(this.page.id);
const data =
'data:text/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(json, null, 2));
const a = document.createElement('a');
a.setAttribute('href', data);
a.setAttribute('download', `${this.page.id}-snapshot.json`);
a.click();
a.remove();
}

private async _importYDoc() {
await this.workspace.importYDoc();
this.requestUpdate();
private _importSnapshot() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', '.json');
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) {
return;
}
try {
const json = await file.text();
await this.workspace.importPageSnapshot(JSON.parse(json), this.page.id);
this.requestUpdate();
} catch (e) {
console.error('Invalid snapshot.');
console.error(e);
} finally {
input.remove();
}
};
input.click();
}

private async _inspect() {
Expand Down Expand Up @@ -595,11 +622,11 @@ export class DebugMenu extends ShadowlessElement {
<sl-menu-item @click=${this._exportPng}>
Export PNG
</sl-menu-item>
<sl-menu-item @click=${this._exportYDoc}>
Export YDoc
<sl-menu-item @click=${this._exportSnapshot}>
Export Snapshot
</sl-menu-item>
<sl-menu-item @click=${this._importYDoc}>
Import YDoc
<sl-menu-item @click=${this._importSnapshot}>
Import Snapshot
</sl-menu-item>
<sl-menu-item @click=${this._shareUrl}> Share URL</sl-menu-item>
<sl-menu-item @click=${this._toggleStyleDebugMenu}>
Expand Down
27 changes: 25 additions & 2 deletions packages/playground/src/components/start-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,31 @@ export class StartPanel extends LitElement {
<sl-card
class="card"
@click=${() => {
window.workspace.importYDoc();
tryMigrate(window.workspace.doc);
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', '.json');
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) {
return;
}
try {
const json = await file.text();
await window.workspace.importPageSnapshot(
JSON.parse(json),
window.page.id
);
tryMigrate(window.workspace.doc);
this.requestUpdate();
} catch (e) {
console.error('Invalid snapshot.');
console.error(e);
} finally {
input.remove();
}
};
input.click();
}}
>
<div slot="header">Import YDoc</div>
Expand Down
4 changes: 4 additions & 0 deletions packages/store/src/space.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export class Space<
this._loaded = false;
}

clear() {
this._yBlocks.clear();
}

private _initSubDoc = () => {
const prefixedId = this.prefixedId;

Expand Down
31 changes: 20 additions & 11 deletions packages/store/src/utils/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,29 @@ export function serializeYDoc(doc: Y.Doc) {
return json;
}

function serializeY(value: unknown): unknown {
if (value instanceof Y.Doc) {
return serializeYDoc(value);
}
if (value instanceof Y.Map) {
return serializeYMap(value);
}
if (value instanceof Y.Text) {
return serializeYText(value);
}
if (value instanceof Y.Array) {
return value.toArray().map(x => serializeY(x));
}
if (value instanceof Y.AbstractType) {
return value.toJSON();
}
return value;
}

function serializeYMap(map: Y.Map<unknown>) {
const json: Record<string, unknown> = {};
map.forEach((value, key) => {
if (value instanceof Y.Map) {
json[key] = serializeYMap(value);
} else if (value instanceof Y.Text) {
json[key] = serializeYText(value);
} else if (value instanceof Y.Array) {
json[key] = value.toArray();
} else if (value instanceof Y.AbstractType) {
json[key] = value.toJSON();
} else {
json[key] = value;
}
json[key] = serializeY(value);
});
return json;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/workspace/indexer/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export class BlockIndexer {

private _getPage(pageId: PageId): Y.Doc | undefined {
if (pageId.startsWith('space:')) {
console.warn('Unexpected page prefix', pageId);
throw new Error(`Unexpected 'space:' prefix for: ${pageId}`);
}
pageId = `space:${pageId}`;
return this._doc.spaces.get(pageId) as Y.Doc | undefined;
Expand Down
15 changes: 4 additions & 11 deletions packages/store/src/workspace/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,6 @@ export class Page extends Space<FlatBlockMap> {
if (!flavour) {
throw new Error('Block props must contain flavour');
}
if (
!this.awarenessStore.getFlag('enable_database') &&
flavour === 'affine:database'
) {
throw new Error('database is not enabled');
}
const parentModel =
typeof parent === 'string' ? this.getBlockById(parent) : parent;

Expand All @@ -365,14 +359,13 @@ export class Page extends Space<FlatBlockMap> {
assertValidChildren(this._yBlocks, clonedProps);
const schema = this.getSchemaByFlavour(flavour);
assertExists(schema);
initInternalProps(yBlock, clonedProps);

initInternalProps(yBlock, clonedProps);
syncBlockProps(schema, yBlock, clonedProps, this._ignoredKeys);

const parentModel =
typeof parent === 'string' ? this._blockMap.get(parent) : parent;

const parentId = parentModel?.id ?? this._root?.id;
const parentId =
parentModel?.id ??
(schema.model.role === 'root' ? undefined : this._root?.id);

if (parentId) {
const yParent = this._yBlocks.get(parentId) as YBlock;
Expand Down
99 changes: 38 additions & 61 deletions packages/store/src/workspace/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,62 +260,26 @@ export class Workspace {
return this.indexer.search.search(query);
}

/**
* @internal Only for testing
*/
exportYDoc() {
const binary = Y.encodeStateAsUpdate(this.doc);
const file = new Blob([binary], { type: 'application/octet-stream' });
const fileUrl = URL.createObjectURL(file);

const link = document.createElement('a');
link.href = fileUrl;
link.download = 'workspace.ydoc';
link.click();

URL.revokeObjectURL(fileUrl);
}

/** @internal Only for testing */
async importYDoc() {
return new Promise<void>((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.ydoc';
input.multiple = false;
input.onchange = async () => {
const file = input.files?.item(0);
if (!file) {
return reject();
}
const buffer = await file.arrayBuffer();
Y.applyUpdate(this.doc, new Uint8Array(buffer));
resolve();
};
input.onerror = reject;
input.click();
});
}

/**
* @internal
* Import an object expression of a page.
* Specify the page you want to update by passing the `pageId` parameter and it will
* create a new page if it does not exist.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async importPageSnapshot(json: any, pageId: string) {
async importPageSnapshot(json: unknown, pageId: string) {
const unprefix = (str: string) =>
str.replace('sys:', '').replace('prop:', '').replace('space:', '');
const visited = new Set();

let page = this.getPage(pageId);
if (!page) {
if (page) {
await page.waitForLoaded();
page.clear();
} else {
page = this.createPage({ id: pageId });
await page.waitForLoaded();
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sanitize = async (props: any) => {
const sanitize = async (props: Record<string, never>) => {
const result: Record<string, unknown> = {};

// setup embed source
Expand All @@ -340,49 +304,62 @@ export class Workspace {
assertExists(page);
const storage = page.blobs;
assertExists(storage);
const id = await storage.set(imgBlob);
props['prop:sourceId'] = id;
props['prop:sourceId'] = (await storage.set(imgBlob)) as never;
}

for (const key of Object.keys(props)) {
Object.keys(props).forEach(key => {
if (key === 'sys:children' || key === 'sys:flavour') {
continue;
return;
}

result[unprefix(key)] = props[key];

// delta array to Y.Text
if (key === 'prop:text' || key === 'prop:title') {
const yText = new Y.Text();
yText.applyDelta(props[key]);
result[unprefix(key)] = new Text(yText);
}
}
});
return result;
};

const { blocks } = json as Record<string, never>;
assertExists(blocks, 'Snapshot structure is invalid');

const addBlockByProps = async (
page: Page,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
props: any,
parent: string | null
parent?: string
) => {
if (visited.has(props['sys:id'])) return;
const id = props['sys:id'] as string;
if (visited.has(id)) return;
const sanitizedProps = await sanitize(props);
page.addBlock(props['sys:flavour'], sanitizedProps, parent);
for (const id of props['sys:children']) {
addBlockByProps(page, json[id], props['sys:id']);
visited.add(id);
}
await props['sys:children'].reduce(
async (prev: Promise<unknown>, childId: string) => {
await prev;
await addBlockByProps(page, blocks[childId], id);
visited.add(childId);
},
Promise.resolve()
);
};

for (const block of Object.values(json)) {
assertExists(json);
await addBlockByProps(page, block, null);
}
const root = Object.values(blocks).find(block => {
const _block = block as Record<string, unknown>;
const flavour = _block['sys:flavour'] as string;
const schema = this.schema.flavourSchemaMap.get(flavour);
return schema?.model?.role === 'root';
});
await addBlockByProps(page, root);
}

exportPageSnapshot(pageId: string) {
const page = this.getPage(pageId);
assertExists(page, `page ${pageId} not found`);
return serializeYDoc(page.spaceDoc);
}

/** @internal Only for testing */
exportSnapshot() {
return serializeYDoc(this.doc);
}
Expand Down
24 changes: 6 additions & 18 deletions tests/selection/block.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,7 +1324,6 @@ test('click bottom of page and if the last is embed block, editor should insert
page,
}) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
await initImageState(page);

await page.evaluate(async () => {
Expand All @@ -1351,27 +1350,16 @@ test('click bottom of page and if the last is embed block, editor should insert
prop:hidden={false}
prop:index="a0"
>
<affine:image
prop:caption=""
prop:height={0}
prop:sourceId="ejImogf-Tb7AuKY-v94uz1zuOJbClqK-tWBxVr_ksGA="
prop:width={0}
/>
<affine:paragraph
prop:type="text"
/>
</affine:note>
<affine:page>
<affine:note
prop:background="--affine-background-secondary-color"
prop:hidden={false}
prop:index="a0"
>
<affine:image
prop:caption=""
prop:height={0}
prop:sourceId="ejImogf-Tb7AuKY-v94uz1zuOJbClqK-tWBxVr_ksGA="
prop:width={0}
/>
<affine:paragraph
prop:type="text"
/>
</affine:note>
</affine:page>
</affine:page>`
);
});
Expand Down