Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@blocksuite/store

Package Overview
Dependencies
Maintainers
4
Versions
1243
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@blocksuite/store - npm Package Compare versions

Comparing version 0.3.0-alpha.4 to 0.3.0-alpha.5

dist/__tests__/test-utils-dom.d.ts

59

dist/blob/__tests__/test-entry.js
// Test page entry located in playground/examples/blob/index.html
import { BlobStorage, IndexedDBBlobProvider } from '..';
import { testSerial, runOnce, loadTestImageBlob, loadImage, assertColor, assertExists, disableButtonsAfterClick, } from './test-utils';
import { testSerial, runOnce, loadTestImageBlob, loadImage, assertColor, assertExists, disableButtonsAfterClick, } from '../../__tests__/test-utils-dom';
async function testBasic() {
const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init();
const provider = await IndexedDBBlobProvider.init('test');
storage.addProvider(provider);
const blob = await loadTestImageBlob('test-card-1');
const id = await storage.set(blob);
let id = undefined;
// @ts-ignore

@@ -16,2 +16,4 @@ window.storage = storage;

testSerial('can store image', async () => {
id = await storage.set(blob);
console.log(id);
const url = await storage.get(id);

@@ -40,2 +42,3 @@ assertExists(url);

testSerial('can delete image', async () => {
assertExists(id);
await storage.delete(id);

@@ -59,3 +62,3 @@ const url = await storage.get(id);

const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init();
const provider = await IndexedDBBlobProvider.init('test');
storage.addProvider(provider);

@@ -71,3 +74,3 @@ testSerial('can set blob', async () => {

const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init();
const provider = await IndexedDBBlobProvider.init('test');
storage.addProvider(provider);

@@ -88,9 +91,43 @@ testSerial('can get saved blob', async () => {

return new Promise(resolve => {
const request = indexedDB.deleteDatabase('keyval-store');
const request = indexedDB.deleteDatabase('test_blob');
request.onsuccess = () => {
console.log('IndexedDB cleared');
resolve();
console.log('IndexedDB test_blob cleared');
const request = indexedDB.deleteDatabase('test_pending');
request.onsuccess = () => {
console.log('IndexedDB test_pending cleared');
resolve();
};
};
});
}
async function testCloudSyncBefore() {
clearIndexedDB();
const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init('test', 'http://localhost:3000/api/blobs');
storage.addProvider(provider);
testSerial('can set blob', async () => {
const blob = await loadTestImageBlob('test-card-2');
const id = await storage.set(blob);
console.log(id);
return id !== null && storage.blobs.has(id);
});
await runOnce();
}
async function testCloudSyncAfter() {
clearIndexedDB();
const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init('test', 'http://localhost:3000/api/blobs');
storage.addProvider(provider);
testSerial('can get saved blob', async () => {
// the test-card-2's hash
const url = await storage.get('WgdXT3DKV2HwV5SqePRHuw');
assertExists(url);
const img = await loadImage(url);
document.body.appendChild(img);
const isCorrectColor = assertColor(img, 100, 100, [193, 193, 193]);
return storage.blobs.size === 1 && isCorrectColor;
});
await runOnce();
clearIndexedDB();
}
document.getElementById('test-basic')?.addEventListener('click', testBasic);

@@ -106,3 +143,9 @@ document

?.addEventListener('click', clearIndexedDB);
document
.getElementById('cloud-sync-before')
?.addEventListener('click', testCloudSyncBefore);
document
.getElementById('cloud-sync-after')
?.addEventListener('click', testCloudSyncAfter);
disableButtonsAfterClick();
//# sourceMappingURL=test-entry.js.map

4

dist/blob/index.d.ts

@@ -1,3 +0,3 @@

export { BlobStorage } from './blob-storage';
export * from './blob-providers';
export { BlobStorage } from './storage';
export { IndexedDBBlobProvider } from './providers';
//# sourceMappingURL=index.d.ts.map

@@ -1,3 +0,3 @@

export { BlobStorage } from './blob-storage';
export * from './blob-providers';
export { BlobStorage } from './storage';
export { IndexedDBBlobProvider } from './providers';
//# sourceMappingURL=index.js.map

@@ -1,22 +0,6 @@

import type Quill from 'quill';
import type * as Y from 'yjs';
import { Awareness } from 'y-protocols/awareness.js';
import * as Y from 'yjs';
import { AwarenessAdapter, SelectionRange } from './awareness';
import { BaseBlockModel } from './base';
import { PrelimText, RichTextAdapter, Text, TextType } from './text-adapter';
import { Signal } from './utils/signal';
import type { IdGenerator } from './utils/id-generator';
export declare type YBlock = Y.Map<unknown>;
export declare type YBlocks = Y.Map<YBlock>;
/** JSON-serializable properties of a block */
export declare type BlockProps = Record<string, any> & {
id: string;
flavour: string;
text?: void | TextType;
children?: BaseBlockModel[];
};
export declare type PrefixedBlockProps = Record<string, unknown> & {
'sys:id': string;
'sys:flavour': string;
};
import type { BaseBlockModel } from './base';
import type { RichTextAdapter } from './text-adapter';
export interface StackItem {

@@ -26,3 +10,3 @@ meta: Map<'cursor-location', SelectionRange | undefined>;

}
export declare class Space {
export declare class Space<IBlockSchema extends Record<string, typeof BaseBlockModel> = any> {
readonly id: string;

@@ -32,58 +16,5 @@ readonly doc: Y.Doc;

readonly richTextAdapters: Map<string, RichTextAdapter>;
readonly signals: {
historyUpdated: Signal<void>;
rootAdded: Signal<BaseBlockModel>;
rootDeleted: Signal<string>;
textUpdated: Signal<Y.YTextEvent>;
updated: Signal<void>;
};
private _idGenerator;
private _history;
private _root;
private _flavourMap;
private _blockMap;
private _splitSet;
private _ignoredKeys;
constructor(id: string, doc: Y.Doc, awareness: Awareness, idGenerator?: IdGenerator);
/** key-value store of blocks */
private get _yBlocks();
get root(): BaseBlockModel | null;
get isEmpty(): boolean;
get canUndo(): boolean;
get canRedo(): boolean;
get Text(): typeof Text;
undo(): void;
redo(): void;
/** Capture current operations to undo stack synchronously. */
captureSync(): void;
resetHistory(): void;
constructor(id: string, doc: Y.Doc, awareness: Awareness);
transact(fn: () => void): void;
register(blockSchema: Record<string, typeof BaseBlockModel>): this;
getBlockById(id: string): BaseBlockModel | null;
getBlockByFlavour(blockFlavour: string): BaseBlockModel[];
getParentById(rootId: string, target: BaseBlockModel): BaseBlockModel | null;
getParent(block: BaseBlockModel): BaseBlockModel | null;
getPreviousSibling(block: BaseBlockModel): BaseBlockModel | null;
getNextSibling(block: BaseBlockModel): BaseBlockModel | null;
addBlock<T extends BlockProps>(blockProps: Partial<T>, parent?: BaseBlockModel | string, parentIndex?: number): string;
updateBlockById(id: string, props: Partial<BlockProps>): void;
updateBlock<T extends Partial<BlockProps>>(model: BaseBlockModel, props: T): void;
deleteBlockById(id: string): void;
deleteBlock(model: BaseBlockModel): void;
/** Connect a rich text editor instance with a YText instance. */
attachRichText(id: string, quill: Quill): void;
/** Cancel the connection between the rich text editor instance and YText. */
detachRichText(id: string): void;
markTextSplit(base: Text, left: PrelimText, right: PrelimText): void;
dispose(): void;
private _getYBlock;
private _historyAddObserver;
private _historyPopObserver;
private _historyObserver;
private _createBlockModel;
private _handleYBlockAdd;
private _handleYBlockDelete;
private _handleYBlockUpdate;
private _handleYEvent;
}
//# sourceMappingURL=space.d.ts.map
import { Awareness } from 'y-protocols/awareness.js';
import * as Y from 'yjs';
import { AwarenessAdapter } from './awareness';
import { BaseBlockModel } from './base';
import { PrelimText, RichTextAdapter, Text } from './text-adapter';
import { Signal } from './utils/signal';
import { assertValidChildren, initSysProps, matchFlavours, syncBlockProps, toBlockProps, trySyncTextProp, } from './utils/utils';
import { uuidv4 } from './utils/id-generator';
// Workaround
const IS_WEB = typeof window !== 'undefined';
function createChildMap(yChildIds) {
return new Map(yChildIds.map((child, index) => [child, index]));
}
export class Space {
constructor(id, doc, awareness, idGenerator = uuidv4) {
constructor(id, doc, awareness) {
this.richTextAdapters = new Map();
this.signals = {
historyUpdated: new Signal(),
rootAdded: new Signal(),
rootDeleted: new Signal(),
textUpdated: new Signal(),
updated: new Signal(),
};
this._root = null;
this._flavourMap = new Map();
this._blockMap = new Map();
this._splitSet = new Set();
// TODO use schema
this._ignoredKeys = new Set(Object.keys(new BaseBlockModel(this, {})));
this._historyAddObserver = (event) => {
if (IS_WEB) {
event.stackItem.meta.set('cursor-location', this.awareness.getLocalCursor());
}
this._historyObserver();
};
this._historyPopObserver = (event) => {
const cursor = event.stackItem.meta.get('cursor-location');
if (!cursor) {
return;
}
this.awareness.setLocalCursor(cursor);
this._historyObserver();
};
this._historyObserver = () => {
this.signals.historyUpdated.emit();
};
this.id = id;
this.doc = doc;
this._idGenerator = idGenerator;
const aware = awareness ?? new Awareness(this.doc);
this.awareness = new AwarenessAdapter(this, aware);
// Handle all the events that happen at _any_ level (potentially deep inside the structure).
// So, we apply a listener at the top level for the flat structure of the current
// page/space container.
const handleYEvents = (events) => {
for (const event of events) {
this._handleYEvent(event);
}
this.signals.updated.emit();
};
// Consider if we need to expose the ability to temporarily unobserve this._yBlocks.
// "unobserve" is potentially necessary to make sure we don't create
// an infinite loop when sync to remote then back to client.
// `action(a) -> YDoc' -> YEvents(a) -> YRemoteDoc' -> YEvents(a) -> YDoc'' -> ...`
// We could unobserve in order to short circuit by ignoring the sync of remote
// events we actually generated locally.
// this._yBlocks.unobserveDeep(handleYEvents);
this._yBlocks.observeDeep(handleYEvents);
this._history = new Y.UndoManager([this._yBlocks], {
trackedOrigins: new Set([this.doc.clientID]),
doc: this.doc,
});
this._history.on('stack-cleared', this._historyObserver);
this._history.on('stack-item-added', this._historyAddObserver);
this._history.on('stack-item-popped', this._historyPopObserver);
this._history.on('stack-item-updated', this._historyObserver);
}
/** key-value store of blocks */
get _yBlocks() {
return this.doc.getMap(this.id);
}
get root() {
return this._root;
}
get isEmpty() {
return this._yBlocks.size === 0;
}
get canUndo() {
return this._history.canUndo();
}
get canRedo() {
return this._history.canRedo();
}
get Text() {
return Text;
}
undo() {
this._history.undo();
}
redo() {
this._history.redo();
}
/** Capture current operations to undo stack synchronously. */
captureSync() {
this._history.stopCapturing();
}
resetHistory() {
this._history.clear();
}
transact(fn) {
this.doc.transact(fn, this.doc.clientID);
}
register(blockSchema) {
Object.keys(blockSchema).forEach(key => {
this._flavourMap.set(key, blockSchema[key]);
});
return this;
}
getBlockById(id) {
return this._blockMap.get(id) ?? null;
}
getBlockByFlavour(blockFlavour) {
return [...this._blockMap.values()].filter(({ flavour }) => blockFlavour === flavour);
}
getParentById(rootId, target) {
if (rootId === target.id)
return null;
const root = this._blockMap.get(rootId);
if (!root)
return null;
for (const [childId] of root.childMap) {
if (childId === target.id)
return root;
const parent = this.getParentById(childId, target);
if (parent !== null)
return parent;
}
return null;
}
getParent(block) {
if (!this._root)
return null;
return this.getParentById(this._root.id, block);
}
getPreviousSibling(block) {
const parent = this.getParent(block);
const index = parent?.children.indexOf(block) ?? -1;
return parent?.children[index - 1] ?? null;
}
getNextSibling(block) {
const parent = this.getParent(block);
const index = parent?.children.indexOf(block) ?? -1;
if (index === -1) {
return null;
}
return parent?.children[index + 1] ?? null;
}
addBlock(blockProps, parent, parentIndex) {
if (!blockProps.flavour) {
throw new Error('Block props must contain flavour');
}
const clonedProps = { ...blockProps };
const id = this._idGenerator();
clonedProps.id = id;
this.transact(() => {
const yBlock = new Y.Map();
assertValidChildren(this._yBlocks, clonedProps);
initSysProps(yBlock, clonedProps);
syncBlockProps(yBlock, clonedProps, this._ignoredKeys);
trySyncTextProp(this._splitSet, yBlock, clonedProps.text);
if (typeof parent === 'string') {
parent = this._blockMap.get(parent);
}
const parentId = parent?.id ?? this._root?.id;
if (parentId) {
const yParent = this._yBlocks.get(parentId);
const yChildren = yParent.get('sys:children');
const index = parentIndex ?? yChildren.length;
yChildren.insert(index, [id]);
}
this._yBlocks.set(id, yBlock);
});
return id;
}
updateBlockById(id, props) {
const model = this._blockMap.get(id);
this.updateBlock(model, props);
}
updateBlock(model, props) {
const yBlock = this._yBlocks.get(model.id);
this.transact(() => {
if (props.text instanceof PrelimText) {
props.text.ready = true;
}
else if (props.text instanceof Text) {
model.text = props.text;
// @ts-ignore
yBlock.set('prop:text', props.text._yText);
}
syncBlockProps(yBlock, props, this._ignoredKeys);
});
}
deleteBlockById(id) {
const model = this._blockMap.get(id);
this.deleteBlock(model);
}
deleteBlock(model) {
const parent = this.getParent(model);
const index = parent?.children.indexOf(model) ?? -1;
if (index > -1) {
parent?.children.splice(parent.children.indexOf(model), 1);
}
this._blockMap.delete(model.id);
this.transact(() => {
this._yBlocks.delete(model.id);
model.dispose();
if (parent) {
const yParent = this._yBlocks.get(parent.id);
const yChildren = yParent.get('sys:children');
if (index > -1) {
yChildren.delete(index, 1);
}
}
});
}
/** Connect a rich text editor instance with a YText instance. */
attachRichText(id, quill) {
const yBlock = this._getYBlock(id);
const yText = yBlock.get('prop:text');
if (!yText) {
throw new Error(`Block "${id}" does not have text`);
}
const adapter = new RichTextAdapter(this, yText, quill);
this.richTextAdapters.set(id, adapter);
quill.on('selection-change', () => {
const cursor = adapter.getCursor();
if (!cursor)
return;
this.awareness.setLocalCursor({ ...cursor, id });
});
}
/** Cancel the connection between the rich text editor instance and YText. */
detachRichText(id) {
const adapter = this.richTextAdapters.get(id);
adapter?.destroy();
this.richTextAdapters.delete(id);
}
markTextSplit(base, left, right) {
this._splitSet.add(base).add(left).add(right);
}
dispose() {
this.signals.historyUpdated.dispose();
this.signals.rootAdded.dispose();
this.signals.rootDeleted.dispose();
this.signals.textUpdated.dispose();
this.signals.updated.dispose();
this._yBlocks.forEach((_, key) => {
this.deleteBlockById(key);
});
}
_getYBlock(id) {
const yBlock = this._yBlocks.get(id);
if (!yBlock) {
throw new Error(`Block with id ${id} does not exist`);
}
return yBlock;
}
_createBlockModel(props) {
const BlockModelCtor = this._flavourMap.get(props.flavour);
if (!BlockModelCtor) {
throw new Error(`Block flavour ${props.flavour} is not registered`);
}
const blockModel = new BlockModelCtor(this, props);
return blockModel;
}
_handleYBlockAdd(visited, id) {
const yBlock = this._getYBlock(id);
const isRoot = this._blockMap.size === 0;
const prefixedProps = yBlock.toJSON();
const props = toBlockProps(prefixedProps);
const model = this._createBlockModel({ ...props, id });
this._blockMap.set(props.id, model);
if (
// TODO use schema
matchFlavours(model, ['affine:paragraph', 'affine:list']) &&
!yBlock.get('prop:text')) {
this.transact(() => yBlock.set('prop:text', new Y.Text()));
}
const yText = yBlock.get('prop:text');
const text = new Text(this, yText);
model.text = text;
const yChildren = yBlock.get('sys:children');
if (yChildren instanceof Y.Array) {
model.childMap = createChildMap(yChildren);
yChildren.forEach((id) => {
const index = model.childMap.get(id);
if (Number.isInteger(index)) {
const hasChild = this._blockMap.has(id);
if (!hasChild) {
visited.add(id);
this._handleYBlockAdd(visited, id);
}
const child = this._blockMap.get(id);
model.children[index] = child;
}
});
}
if (isRoot) {
this._root = model;
this.signals.rootAdded.emit(model);
}
else {
const parent = this.getParent(model);
const index = parent?.childMap.get(model.id);
if (parent && index !== undefined) {
parent.children[index] = model;
parent.childrenUpdated.emit();
}
}
}
_handleYBlockDelete(id) {
const model = this._blockMap.get(id);
if (model === this._root) {
this.signals.rootDeleted.emit(id);
}
else {
// TODO dispatch model delete event
}
this._blockMap.delete(id);
}
_handleYBlockUpdate(event) {
const id = event.target.get('sys:id');
const model = this.getBlockById(id);
if (!model)
return;
const props = {};
for (const key of event.keysChanged) {
// TODO use schema
if (key === 'prop:text')
continue;
props[key.replace('prop:', '')] = event.target.get(key);
}
Object.assign(model, props);
model.propsUpdated.emit();
}
_handleYEvent(event) {
// event on top-level block store
if (event.target === this._yBlocks) {
const visited = new Set();
event.keys.forEach((value, id) => {
if (value.action === 'add') {
// Here the key is the id of the blocks.
// Generally, the key that appears earlier corresponds to the block added earlier,
// and it won't refer to subsequent keys.
// However, when redo the operation that adds multiple blocks at once,
// the earlier block may have children pointing to subsequent blocks.
// In this case, although the yjs-side state is correct, the BlockModel instance may not exist yet.
// Therefore, at this point we synchronize the referenced block first,
// then mark it in `visited` so that they can be skipped.
if (visited.has(id))
return;
visited.add(id);
this._handleYBlockAdd(visited, id);
}
else if (value.action === 'delete') {
this._handleYBlockDelete(id);
}
else {
// fires when undoing delete-and-add operation on a block
// console.warn('update action on top-level block store', event);
}
});
}
// event on single block
else if (event.target.parent === this._yBlocks) {
if (event instanceof Y.YTextEvent) {
this.signals.textUpdated.emit(event);
}
else if (event instanceof Y.YMapEvent) {
this._handleYBlockUpdate(event);
}
}
// event on block field
else if (event.target.parent instanceof Y.Map &&
event.target.parent.has('sys:id')) {
if (event instanceof Y.YArrayEvent) {
const id = event.target.parent.get('sys:id');
const model = this._blockMap.get(id);
if (!model) {
throw new Error(`Block with id ${id} does not exist`);
}
const key = event.path[event.path.length - 1];
if (key === 'sys:children') {
const childIds = event.target.toArray();
model.children = childIds.map(id => this._blockMap.get(id));
model.childMap = createChildMap(event.target);
model.childrenUpdated.emit();
}
}
}
}
}
//# sourceMappingURL=space.js.map

@@ -1,2 +0,2 @@

import type { PrefixedBlockProps, Space } from './space';
import type { Space } from './space';
import type { IdGenerator } from './utils/id-generator';

@@ -8,3 +8,3 @@ import { Awareness } from 'y-protocols/awareness.js';

[key: string]: {
[key: string]: PrefixedBlockProps;
[key: string]: unknown;
};

@@ -21,3 +21,3 @@ }

readonly providers: DocProvider[];
readonly spaces: Map<string, Space>;
readonly spaces: Map<string, Space<any>>;
readonly awareness: Awareness;

@@ -24,0 +24,0 @@ readonly idGenerator: IdGenerator;

@@ -20,3 +20,2 @@ import { Awareness } from 'y-protocols/awareness.js';

removeSpace(space) {
space.dispose();
this.spaces.delete(space.id);

@@ -23,0 +22,0 @@ }

@@ -154,3 +154,3 @@ /* eslint-disable @typescript-eslint/no-explicit-any */

this._transact(() => {
this._yText.applyDelta(delta);
this._yText?.applyDelta(delta);
});

@@ -157,0 +157,0 @@ }

export declare type IdGenerator = () => string;
export declare const createAutoIncrement: () => IdGenerator;
export declare const uuidv4: IdGenerator;
export declare function createAutoIncrementIdGenerator(): IdGenerator;
export declare function uuidv4(): any;
//# sourceMappingURL=id-generator.d.ts.map

@@ -1,9 +0,9 @@

import { uuidv4 as uuidv4WithoutType } from 'lib0/random';
export const createAutoIncrement = () => {
import { uuidv4 as uuidv4IdGenerator } from 'lib0/random';
export function createAutoIncrementIdGenerator() {
let i = 0;
return function autoIncrement() {
return (i++).toString();
};
};
export const uuidv4 = () => uuidv4WithoutType();
return () => (i++).toString();
}
export function uuidv4() {
return uuidv4IdGenerator();
}
//# sourceMappingURL=id-generator.js.map
import type { BaseBlockModel } from '../base';
import type { BlockProps, PrefixedBlockProps, YBlock, YBlocks } from '../space';
import type { BlockProps, PrefixedBlockProps, YBlock, YBlocks } from '../workspace/page';
import { PrelimText, Text, TextType } from '../text-adapter';

@@ -4,0 +4,0 @@ export declare function assertExists<T>(val: T | null | undefined): asserts val is T;

{
"name": "@blocksuite/store",
"version": "0.3.0-alpha.4",
"version": "0.3.0-alpha.5",
"description": "BlockSuite data store built for general purpose state management.",

@@ -10,5 +10,8 @@ "main": "dist/index.js",

"dependencies": {
"buffer": "^6.0.3",
"flexsearch": "0.7.21",
"idb-keyval": "^6.2.0",
"ky": "^0.32.2",
"lib0": "^0.2.52",
"sha3": "^2.1.4",
"y-indexeddb": "^9.0.9",

@@ -23,3 +26,4 @@ "y-protocols": "^1.0.5",

"@types/quill": "^2.0.9",
"cross-env": "^7.0.3"
"cross-env": "^7.0.3",
"lit": "^2.3.1"
},

@@ -26,0 +30,0 @@ "exports": {

import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
testDir: 'src/blob/__tests__',
fullyParallel: false,
testDir: 'src/',
testIgnore: ['**.unit.spec.ts'],
workers: 1,
use: {

@@ -7,0 +8,0 @@ browserName: 'chromium',

@@ -1,23 +0,4 @@

import { test, expect, Page } from '@playwright/test';
import type { TestResult } from './test-utils';
import { test } from '@playwright/test';
import { collectTestResult } from '../../__tests__/test-utils-node';
declare global {
interface WindowEventMap {
'test-result': CustomEvent<TestResult>;
}
const testBasic: () => void;
}
async function collectTestResult(page: Page) {
const result = await page.evaluate(() => {
return new Promise<TestResult>(resolve => {
window.addEventListener('test-result', ({ detail }) => resolve(detail));
});
});
const messages = result.messages.join('\n');
expect(result.success, messages).toEqual(true);
console.log(messages);
}
// checkout test-entry.ts for actual test cases

@@ -24,0 +5,0 @@ const blobExamplePage = 'http://localhost:5173/examples/blob/';

@@ -11,11 +11,11 @@ // Test page entry located in playground/examples/blob/index.html

disableButtonsAfterClick,
} from './test-utils';
} from '../../__tests__/test-utils-dom';
async function testBasic() {
const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init();
const provider = await IndexedDBBlobProvider.init('test');
storage.addProvider(provider);
const blob = await loadTestImageBlob('test-card-1');
const id = await storage.set(blob);
let id: string | undefined = undefined;

@@ -30,2 +30,5 @@ // @ts-ignore

testSerial('can store image', async () => {
id = await storage.set(blob);
console.log(id);
const url = await storage.get(id);

@@ -62,4 +65,7 @@ assertExists(url);

testSerial('can delete image', async () => {
assertExists(id);
await storage.delete(id);
const url = await storage.get(id);
return url === null;

@@ -85,3 +91,3 @@ });

const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init();
const provider = await IndexedDBBlobProvider.init('test');
storage.addProvider(provider);

@@ -100,3 +106,3 @@

const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init();
const provider = await IndexedDBBlobProvider.init('test');
storage.addProvider(provider);

@@ -122,6 +128,12 @@

return new Promise<void>(resolve => {
const request = indexedDB.deleteDatabase('keyval-store');
const request = indexedDB.deleteDatabase('test_blob');
request.onsuccess = () => {
console.log('IndexedDB cleared');
resolve();
console.log('IndexedDB test_blob cleared');
const request = indexedDB.deleteDatabase('test_pending');
request.onsuccess = () => {
console.log('IndexedDB test_pending cleared');
resolve();
};
};

@@ -131,2 +143,46 @@ });

async function testCloudSyncBefore() {
clearIndexedDB();
const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init(
'test',
'http://localhost:3000/api/blobs'
);
storage.addProvider(provider);
testSerial('can set blob', async () => {
const blob = await loadTestImageBlob('test-card-2');
const id = await storage.set(blob);
console.log(id);
return id !== null && storage.blobs.has(id);
});
await runOnce();
}
async function testCloudSyncAfter() {
clearIndexedDB();
const storage = new BlobStorage();
const provider = await IndexedDBBlobProvider.init(
'test',
'http://localhost:3000/api/blobs'
);
storage.addProvider(provider);
testSerial('can get saved blob', async () => {
// the test-card-2's hash
const url = await storage.get('WgdXT3DKV2HwV5SqePRHuw');
assertExists(url);
const img = await loadImage(url);
document.body.appendChild(img);
const isCorrectColor = assertColor(img, 100, 100, [193, 193, 193]);
return storage.blobs.size === 1 && isCorrectColor;
});
await runOnce();
clearIndexedDB();
}
document.getElementById('test-basic')?.addEventListener('click', testBasic);

@@ -142,3 +198,9 @@ document

?.addEventListener('click', clearIndexedDB);
document
.getElementById('cloud-sync-before')
?.addEventListener('click', testCloudSyncBefore);
document
.getElementById('cloud-sync-after')
?.addEventListener('click', testCloudSyncAfter);
disableButtonsAfterClick();

@@ -1,2 +0,2 @@

export { BlobStorage } from './blob-storage';
export * from './blob-providers';
export { BlobStorage } from './storage';
export { IndexedDBBlobProvider } from './providers';

@@ -1,36 +0,7 @@

import type Quill from 'quill';
import type * as Y from 'yjs';
import { Awareness } from 'y-protocols/awareness.js';
import * as Y from 'yjs';
import { AwarenessAdapter, SelectionRange } from './awareness';
import { BaseBlockModel } from './base';
import { PrelimText, RichTextAdapter, Text, TextType } from './text-adapter';
import { Signal } from './utils/signal';
import {
assertValidChildren,
initSysProps,
matchFlavours,
syncBlockProps,
toBlockProps,
trySyncTextProp,
} from './utils/utils';
import { uuidv4 } from './utils/id-generator';
import type { IdGenerator } from './utils/id-generator';
import type { BaseBlockModel } from './base';
import type { RichTextAdapter } from './text-adapter';
export type YBlock = Y.Map<unknown>;
export type YBlocks = Y.Map<YBlock>;
/** JSON-serializable properties of a block */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type BlockProps = Record<string, any> & {
id: string;
flavour: string;
text?: void | TextType;
children?: BaseBlockModel[];
};
export type PrefixedBlockProps = Record<string, unknown> & {
'sys:id': string;
'sys:flavour': string;
};
export interface StackItem {

@@ -41,10 +12,6 @@ meta: Map<'cursor-location', SelectionRange | undefined>;

// Workaround
const IS_WEB = typeof window !== 'undefined';
function createChildMap(yChildIds: Y.Array<string>) {
return new Map(yChildIds.map((child, index) => [child, index]));
}
export class Space {
export class Space<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
IBlockSchema extends Record<string, typeof BaseBlockModel> = any
> {
readonly id: string;

@@ -55,476 +22,13 @@ readonly doc: Y.Doc;

readonly signals = {
historyUpdated: new Signal(),
rootAdded: new Signal<BaseBlockModel>(),
rootDeleted: new Signal<string>(),
textUpdated: new Signal<Y.YTextEvent>(),
updated: new Signal(),
};
private _idGenerator: IdGenerator;
private _history: Y.UndoManager;
private _root: BaseBlockModel | null = null;
private _flavourMap = new Map<string, typeof BaseBlockModel>();
private _blockMap = new Map<string, BaseBlockModel>();
private _splitSet = new Set<Text | PrelimText>();
// TODO use schema
private _ignoredKeys = new Set<string>(
Object.keys(new BaseBlockModel(this, {}))
);
constructor(
id: string,
doc: Y.Doc,
awareness: Awareness,
idGenerator: IdGenerator = uuidv4
) {
constructor(id: string, doc: Y.Doc, awareness: Awareness) {
this.id = id;
this.doc = doc;
this._idGenerator = idGenerator;
const aware = awareness ?? new Awareness(this.doc);
this.awareness = new AwarenessAdapter(this, aware);
// Handle all the events that happen at _any_ level (potentially deep inside the structure).
// So, we apply a listener at the top level for the flat structure of the current
// page/space container.
const handleYEvents = (events: Y.YEvent<YBlock | Y.Text>[]) => {
for (const event of events) {
this._handleYEvent(event);
}
this.signals.updated.emit();
};
// Consider if we need to expose the ability to temporarily unobserve this._yBlocks.
// "unobserve" is potentially necessary to make sure we don't create
// an infinite loop when sync to remote then back to client.
// `action(a) -> YDoc' -> YEvents(a) -> YRemoteDoc' -> YEvents(a) -> YDoc'' -> ...`
// We could unobserve in order to short circuit by ignoring the sync of remote
// events we actually generated locally.
// this._yBlocks.unobserveDeep(handleYEvents);
this._yBlocks.observeDeep(handleYEvents);
this._history = new Y.UndoManager([this._yBlocks], {
trackedOrigins: new Set([this.doc.clientID]),
doc: this.doc,
});
this._history.on('stack-cleared', this._historyObserver);
this._history.on('stack-item-added', this._historyAddObserver);
this._history.on('stack-item-popped', this._historyPopObserver);
this._history.on('stack-item-updated', this._historyObserver);
}
/** key-value store of blocks */
private get _yBlocks() {
return this.doc.getMap(this.id) as YBlocks;
}
get root() {
return this._root;
}
get isEmpty() {
return this._yBlocks.size === 0;
}
get canUndo() {
return this._history.canUndo();
}
get canRedo() {
return this._history.canRedo();
}
get Text() {
return Text;
}
undo() {
this._history.undo();
}
redo() {
this._history.redo();
}
/** Capture current operations to undo stack synchronously. */
captureSync() {
this._history.stopCapturing();
}
resetHistory() {
this._history.clear();
}
transact(fn: () => void) {
this.doc.transact(fn, this.doc.clientID);
}
register(blockSchema: Record<string, typeof BaseBlockModel>) {
Object.keys(blockSchema).forEach(key => {
this._flavourMap.set(key, blockSchema[key]);
});
return this;
}
getBlockById(id: string) {
return this._blockMap.get(id) ?? null;
}
getBlockByFlavour(blockFlavour: string) {
return [...this._blockMap.values()].filter(
({ flavour }) => blockFlavour === flavour
);
}
getParentById(rootId: string, target: BaseBlockModel): BaseBlockModel | null {
if (rootId === target.id) return null;
const root = this._blockMap.get(rootId);
if (!root) return null;
for (const [childId] of root.childMap) {
if (childId === target.id) return root;
const parent = this.getParentById(childId, target);
if (parent !== null) return parent;
}
return null;
}
getParent(block: BaseBlockModel) {
if (!this._root) return null;
return this.getParentById(this._root.id, block);
}
getPreviousSibling(block: BaseBlockModel) {
const parent = this.getParent(block);
const index = parent?.children.indexOf(block) ?? -1;
return parent?.children[index - 1] ?? null;
}
getNextSibling(block: BaseBlockModel) {
const parent = this.getParent(block);
const index = parent?.children.indexOf(block) ?? -1;
if (index === -1) {
return null;
}
return parent?.children[index + 1] ?? null;
}
addBlock<T extends BlockProps>(
blockProps: Partial<T>,
parent?: BaseBlockModel | string,
parentIndex?: number
): string {
if (!blockProps.flavour) {
throw new Error('Block props must contain flavour');
}
const clonedProps = { ...blockProps };
const id = this._idGenerator();
clonedProps.id = id;
this.transact(() => {
const yBlock = new Y.Map() as YBlock;
assertValidChildren(this._yBlocks, clonedProps);
initSysProps(yBlock, clonedProps);
syncBlockProps(yBlock, clonedProps, this._ignoredKeys);
trySyncTextProp(this._splitSet, yBlock, clonedProps.text);
if (typeof parent === 'string') {
parent = this._blockMap.get(parent);
}
const parentId = parent?.id ?? this._root?.id;
if (parentId) {
const yParent = this._yBlocks.get(parentId) as YBlock;
const yChildren = yParent.get('sys:children') as Y.Array<string>;
const index = parentIndex ?? yChildren.length;
yChildren.insert(index, [id]);
}
this._yBlocks.set(id, yBlock);
});
return id;
}
updateBlockById(id: string, props: Partial<BlockProps>) {
const model = this._blockMap.get(id) as BaseBlockModel;
this.updateBlock(model, props);
}
updateBlock<T extends Partial<BlockProps>>(model: BaseBlockModel, props: T) {
const yBlock = this._yBlocks.get(model.id) as YBlock;
this.transact(() => {
if (props.text instanceof PrelimText) {
props.text.ready = true;
} else if (props.text instanceof Text) {
model.text = props.text;
// @ts-ignore
yBlock.set('prop:text', props.text._yText);
}
syncBlockProps(yBlock, props, this._ignoredKeys);
});
}
deleteBlockById(id: string) {
const model = this._blockMap.get(id) as BaseBlockModel;
this.deleteBlock(model);
}
deleteBlock(model: BaseBlockModel) {
const parent = this.getParent(model);
const index = parent?.children.indexOf(model) ?? -1;
if (index > -1) {
parent?.children.splice(parent.children.indexOf(model), 1);
}
this._blockMap.delete(model.id);
this.transact(() => {
this._yBlocks.delete(model.id);
model.dispose();
if (parent) {
const yParent = this._yBlocks.get(parent.id) as YBlock;
const yChildren = yParent.get('sys:children') as Y.Array<string>;
if (index > -1) {
yChildren.delete(index, 1);
}
}
});
}
/** Connect a rich text editor instance with a YText instance. */
attachRichText(id: string, quill: Quill) {
const yBlock = this._getYBlock(id);
const yText = yBlock.get('prop:text') as Y.Text | null;
if (!yText) {
throw new Error(`Block "${id}" does not have text`);
}
const adapter = new RichTextAdapter(this, yText, quill);
this.richTextAdapters.set(id, adapter);
quill.on('selection-change', () => {
const cursor = adapter.getCursor();
if (!cursor) return;
this.awareness.setLocalCursor({ ...cursor, id });
});
}
/** Cancel the connection between the rich text editor instance and YText. */
detachRichText(id: string) {
const adapter = this.richTextAdapters.get(id);
adapter?.destroy();
this.richTextAdapters.delete(id);
}
markTextSplit(base: Text, left: PrelimText, right: PrelimText) {
this._splitSet.add(base).add(left).add(right);
}
dispose() {
this.signals.historyUpdated.dispose();
this.signals.rootAdded.dispose();
this.signals.rootDeleted.dispose();
this.signals.textUpdated.dispose();
this.signals.updated.dispose();
this._yBlocks.forEach((_, key) => {
this.deleteBlockById(key);
});
}
private _getYBlock(id: string): YBlock {
const yBlock = this._yBlocks.get(id) as YBlock | undefined;
if (!yBlock) {
throw new Error(`Block with id ${id} does not exist`);
}
return yBlock;
}
private _historyAddObserver = (event: { stackItem: StackItem }) => {
if (IS_WEB) {
event.stackItem.meta.set(
'cursor-location',
this.awareness.getLocalCursor()
);
}
this._historyObserver();
};
private _historyPopObserver = (event: { stackItem: StackItem }) => {
const cursor = event.stackItem.meta.get('cursor-location');
if (!cursor) {
return;
}
this.awareness.setLocalCursor(cursor);
this._historyObserver();
};
private _historyObserver = () => {
this.signals.historyUpdated.emit();
};
private _createBlockModel(props: Omit<BlockProps, 'children'>) {
const BlockModelCtor = this._flavourMap.get(props.flavour);
if (!BlockModelCtor) {
throw new Error(`Block flavour ${props.flavour} is not registered`);
}
const blockModel = new BlockModelCtor(this, props);
return blockModel;
}
private _handleYBlockAdd(visited: Set<string>, id: string) {
const yBlock = this._getYBlock(id);
const isRoot = this._blockMap.size === 0;
const prefixedProps = yBlock.toJSON() as PrefixedBlockProps;
const props = toBlockProps(prefixedProps) as BlockProps;
const model = this._createBlockModel({ ...props, id });
this._blockMap.set(props.id, model);
if (
// TODO use schema
matchFlavours(model, ['affine:paragraph', 'affine:list']) &&
!yBlock.get('prop:text')
) {
this.transact(() => yBlock.set('prop:text', new Y.Text()));
}
const yText = yBlock.get('prop:text') as Y.Text;
const text = new Text(this, yText);
model.text = text;
const yChildren = yBlock.get('sys:children');
if (yChildren instanceof Y.Array) {
model.childMap = createChildMap(yChildren);
yChildren.forEach((id: string) => {
const index = model.childMap.get(id);
if (Number.isInteger(index)) {
const hasChild = this._blockMap.has(id);
if (!hasChild) {
visited.add(id);
this._handleYBlockAdd(visited, id);
}
const child = this._blockMap.get(id) as BaseBlockModel;
model.children[index as number] = child;
}
});
}
if (isRoot) {
this._root = model;
this.signals.rootAdded.emit(model);
} else {
const parent = this.getParent(model);
const index = parent?.childMap.get(model.id);
if (parent && index !== undefined) {
parent.children[index] = model;
parent.childrenUpdated.emit();
}
}
}
private _handleYBlockDelete(id: string) {
const model = this._blockMap.get(id);
if (model === this._root) {
this.signals.rootDeleted.emit(id);
} else {
// TODO dispatch model delete event
}
this._blockMap.delete(id);
}
private _handleYBlockUpdate(event: Y.YMapEvent<unknown>) {
const id = event.target.get('sys:id') as string;
const model = this.getBlockById(id);
if (!model) return;
const props: Partial<BlockProps> = {};
for (const key of event.keysChanged) {
// TODO use schema
if (key === 'prop:text') continue;
props[key.replace('prop:', '')] = event.target.get(key);
}
Object.assign(model, props);
model.propsUpdated.emit();
}
private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) {
// event on top-level block store
if (event.target === this._yBlocks) {
const visited = new Set<string>();
event.keys.forEach((value, id) => {
if (value.action === 'add') {
// Here the key is the id of the blocks.
// Generally, the key that appears earlier corresponds to the block added earlier,
// and it won't refer to subsequent keys.
// However, when redo the operation that adds multiple blocks at once,
// the earlier block may have children pointing to subsequent blocks.
// In this case, although the yjs-side state is correct, the BlockModel instance may not exist yet.
// Therefore, at this point we synchronize the referenced block first,
// then mark it in `visited` so that they can be skipped.
if (visited.has(id)) return;
visited.add(id);
this._handleYBlockAdd(visited, id);
} else if (value.action === 'delete') {
this._handleYBlockDelete(id);
} else {
// fires when undoing delete-and-add operation on a block
// console.warn('update action on top-level block store', event);
}
});
}
// event on single block
else if (event.target.parent === this._yBlocks) {
if (event instanceof Y.YTextEvent) {
this.signals.textUpdated.emit(event);
} else if (event instanceof Y.YMapEvent) {
this._handleYBlockUpdate(event);
}
}
// event on block field
else if (
event.target.parent instanceof Y.Map &&
event.target.parent.has('sys:id')
) {
if (event instanceof Y.YArrayEvent) {
const id = event.target.parent.get('sys:id') as string;
const model = this._blockMap.get(id);
if (!model) {
throw new Error(`Block with id ${id} does not exist`);
}
const key = event.path[event.path.length - 1];
if (key === 'sys:children') {
const childIds = event.target.toArray();
model.children = childIds.map(
id => this._blockMap.get(id) as BaseBlockModel
);
model.childMap = createChildMap(event.target);
model.childrenUpdated.emit();
}
}
}
}
}

@@ -1,2 +0,2 @@

import type { PrefixedBlockProps, Space } from './space';
import type { Space } from './space';
import type { IdGenerator } from './utils/id-generator';

@@ -11,3 +11,3 @@ import { Awareness } from 'y-protocols/awareness.js';

[key: string]: {
[key: string]: PrefixedBlockProps;
[key: string]: unknown;
};

@@ -52,3 +52,2 @@ }

removeSpace(space: Space) {
space.dispose();
this.spaces.delete(space.id);

@@ -55,0 +54,0 @@ }

@@ -225,3 +225,3 @@ /* eslint-disable @typescript-eslint/no-explicit-any */

this._transact(() => {
this._yText.applyDelta(delta);
this._yText?.applyDelta(delta);
});

@@ -228,0 +228,0 @@ }

@@ -1,13 +0,12 @@

import { uuidv4 as uuidv4WithoutType } from 'lib0/random';
import { uuidv4 as uuidv4IdGenerator } from 'lib0/random';
export type IdGenerator = () => string;
export const createAutoIncrement = (): IdGenerator => {
export function createAutoIncrementIdGenerator(): IdGenerator {
let i = 0;
return () => (i++).toString();
}
return function autoIncrement(): string {
return (i++).toString();
};
};
export const uuidv4: IdGenerator = () => uuidv4WithoutType();
export function uuidv4() {
return uuidv4IdGenerator();
}
import { AbstractType, Doc, Map, Text, Array } from 'yjs';
import type { PrefixedBlockProps } from '../space';
import type { PrefixedBlockProps } from '../workspace/page';

@@ -4,0 +4,0 @@ type DocRecord = {

import * as Y from 'yjs';
import type { BaseBlockModel } from '../base';
import type { BlockProps, PrefixedBlockProps, YBlock, YBlocks } from '../space';
import type {
BlockProps,
PrefixedBlockProps,
YBlock,
YBlocks,
} from '../workspace/page';
import { PrelimText, Text, TextType } from '../text-adapter';

@@ -87,2 +92,3 @@

}
if (props.flavour === 'affine:list' && !yBlock.has('prop:checked')) {

@@ -89,0 +95,0 @@ yBlock.set('prop:checked', props.checked ?? false);

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc