@blocksuite/store
Advanced tools
Comparing version 0.3.0-alpha.4 to 0.3.0-alpha.5
// 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 |
@@ -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'; |
512
src/space.ts
@@ -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
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
309580
141
5892
11
4
2
+ Addedbuffer@^6.0.3
+ Addedky@^0.32.2
+ Addedsha3@^2.1.4
+ Addedky@0.32.2(transitive)
+ Addedsha3@2.1.4(transitive)