@blocksuite/store
Advanced tools
Comparing version 0.2.24 to 0.3.0-20221218000328-e1b2a90
# @blocksuite/store | ||
## 0.2.23 | ||
### Patch Changes | ||
- 6b2da60: Add hover tips on link popover | ||
- 6f3d8c3: Send toast when copy link | ||
## 0.2.22 | ||
### Patch Changes | ||
- 9927578: fix hotkey on windows | ||
## 0.2.19 | ||
### Patch Changes | ||
- d8c1df4: refactor: redesign create editor API | ||
## 0.2.16 | ||
### Patch Changes | ||
- 8ca8550: | ||
## 0.2.14 | ||
### Patch Changes | ||
- b53f050: - feat: support edgeless mode click state | ||
- fest: markdown import improvement | ||
- fix: icon size | ||
- fix: native text range selection improvement | ||
- fix: quote format import support | ||
- fix: page title enter in middle | ||
## 0.2.9 | ||
### Patch Changes | ||
- 1b720cf: add changesets | ||
TODO |
@@ -0,4 +1,4 @@ | ||
import type { RelativePosition } from 'yjs'; | ||
import type { Awareness } from 'y-protocols/awareness.js'; | ||
import { RelativePosition } from 'yjs'; | ||
import type { Store } from './store'; | ||
import type { Space } from './space'; | ||
import { Signal } from './utils/signal'; | ||
@@ -25,3 +25,3 @@ export interface SelectionRange { | ||
export declare class AwarenessAdapter { | ||
readonly store: Store; | ||
readonly space: Space; | ||
readonly awareness: Awareness; | ||
@@ -31,3 +31,3 @@ readonly signals: { | ||
}; | ||
constructor(store: Store, awareness: Awareness); | ||
constructor(space: Space, awareness: Awareness); | ||
setLocalCursor(range: SelectionRange): void; | ||
@@ -34,0 +34,0 @@ getLocalCursor(): SelectionRange | undefined; |
import * as Y from 'yjs'; | ||
import { Signal } from './utils/signal'; | ||
export class AwarenessAdapter { | ||
constructor(store, awareness) { | ||
constructor(space, awareness) { | ||
this.signals = { | ||
@@ -40,3 +40,3 @@ update: new Signal(), | ||
}; | ||
this.store = store; | ||
this.space = space; | ||
this.awareness = awareness; | ||
@@ -58,8 +58,8 @@ this.awareness.on('change', this._onAwarenessChange); | ||
_resetRemoteCursor() { | ||
this.store.richTextAdapters.forEach(textAdapter => textAdapter.quillCursors.clearCursors()); | ||
this.space.richTextAdapters.forEach(textAdapter => textAdapter.quillCursors.clearCursors()); | ||
this.getStates().forEach((awState, clientId) => { | ||
if (clientId !== this.awareness.clientID && awState.cursor) { | ||
const anchor = Y.createAbsolutePositionFromRelativePosition(awState.cursor.anchor, this.store.doc); | ||
const focus = Y.createAbsolutePositionFromRelativePosition(awState.cursor.focus, this.store.doc); | ||
const textAdapter = this.store.richTextAdapters.get(awState.cursor.id || ''); | ||
const anchor = Y.createAbsolutePositionFromRelativePosition(awState.cursor.anchor, this.space.doc); | ||
const focus = Y.createAbsolutePositionFromRelativePosition(awState.cursor.focus, this.space.doc); | ||
const textAdapter = this.space.richTextAdapters.get(awState.cursor.id || ''); | ||
if (anchor && focus && textAdapter) { | ||
@@ -79,10 +79,10 @@ const user = awState.user || {}; | ||
updateLocalCursor() { | ||
const localCursor = this.store.awareness.getLocalCursor(); | ||
const localCursor = this.space.awareness.getLocalCursor(); | ||
if (!localCursor) { | ||
return; | ||
} | ||
const anchor = Y.createAbsolutePositionFromRelativePosition(localCursor.anchor, this.store.doc); | ||
const focus = Y.createAbsolutePositionFromRelativePosition(localCursor.focus, this.store.doc); | ||
const anchor = Y.createAbsolutePositionFromRelativePosition(localCursor.anchor, this.space.doc); | ||
const focus = Y.createAbsolutePositionFromRelativePosition(localCursor.focus, this.space.doc); | ||
if (anchor && focus) { | ||
const textAdapter = this.store.richTextAdapters.get(localCursor.id || ''); | ||
const textAdapter = this.space.richTextAdapters.get(localCursor.id || ''); | ||
textAdapter?.quill.setSelection(anchor.index, focus.index - anchor.index); | ||
@@ -89,0 +89,0 @@ } |
@@ -1,2 +0,2 @@ | ||
import type { Store } from './store'; | ||
import type { Page } from './workspace'; | ||
import type { TextType } from './text-adapter'; | ||
@@ -12,3 +12,4 @@ import { Signal } from './utils/signal'; | ||
export declare class BaseBlockModel implements IBaseBlockProps { | ||
store: Store; | ||
static version: [number, number]; | ||
page: Page; | ||
propsUpdated: Signal<void>; | ||
@@ -22,3 +23,4 @@ childrenUpdated: Signal<void>; | ||
text?: TextType; | ||
constructor(store: Store, props: Partial<IBaseBlockProps>); | ||
sourceId?: string; | ||
constructor(page: Page, props: Partial<IBaseBlockProps>); | ||
firstChild(): BaseBlockModel | null; | ||
@@ -28,5 +30,5 @@ lastChild(): BaseBlockModel | null; | ||
block2Text(childText: string, begin?: number, end?: number): string; | ||
private _deltaLeaf2Html; | ||
_deltaLeaf2Html(deltaLeaf: Record<string, unknown>): unknown; | ||
dispose(): void; | ||
} | ||
//# sourceMappingURL=base.d.ts.map |
import { Signal } from './utils/signal'; | ||
export class BaseBlockModel { | ||
constructor(store, props) { | ||
constructor(page, props) { | ||
this.propsUpdated = new Signal(); | ||
this.childrenUpdated = new Signal(); | ||
this.childMap = new Map(); | ||
this.store = store; | ||
this.page = page; | ||
this.id = props.id; | ||
@@ -36,3 +36,3 @@ this.children = []; | ||
_deltaLeaf2Html(deltaLeaf) { | ||
const text = deltaLeaf.insert; | ||
let text = deltaLeaf.insert; | ||
const attributes = deltaLeaf.attributes; | ||
@@ -42,19 +42,19 @@ if (!attributes) { | ||
} | ||
if (attributes.code) { | ||
text = `<code>${text}</code>`; | ||
} | ||
if (attributes.bold) { | ||
return `<strong>${text}</strong>`; | ||
text = `<strong>${text}</strong>`; | ||
} | ||
if (attributes.italic) { | ||
return `<em>${text}</em>`; | ||
text = `<em>${text}</em>`; | ||
} | ||
if (attributes.underline) { | ||
return `<u>${text}</u>`; | ||
text = `<u>${text}</u>`; | ||
} | ||
if (attributes.code) { | ||
return `<code>${text}</code>`; | ||
} | ||
if (attributes.strikethrough) { | ||
return `<s>${text}</s>`; | ||
text = `<s>${text}</s>`; | ||
} | ||
if (attributes.link) { | ||
return `<a href='${attributes.link}'>${text}</a>`; | ||
text = `<a href='${attributes.link}'>${text}</a>`; | ||
} | ||
@@ -61,0 +61,0 @@ return text; |
@@ -0,9 +1,13 @@ | ||
export * from './space'; | ||
export * from './store'; | ||
export * from './base'; | ||
export * from './awareness'; | ||
export * from './blob'; | ||
export * from './text-adapter'; | ||
export * from './utils/signal'; | ||
export * from './utils/disposable'; | ||
export * from './utils/utils'; | ||
export * from './providers'; | ||
export * from './doc-providers'; | ||
export * from './workspace'; | ||
export * as Utils from './utils/utils'; | ||
export * from './utils/id-generator'; | ||
//# sourceMappingURL=index.d.ts.map |
@@ -0,9 +1,13 @@ | ||
export * from './space'; | ||
export * from './store'; | ||
export * from './base'; | ||
export * from './awareness'; | ||
export * from './blob'; | ||
export * from './text-adapter'; | ||
export * from './utils/signal'; | ||
export * from './utils/disposable'; | ||
export * from './utils/utils'; | ||
export * from './providers'; | ||
export * from './doc-providers'; | ||
export * from './workspace'; | ||
export * as Utils from './utils/utils'; | ||
export * from './utils/id-generator'; | ||
const env = typeof globalThis !== 'undefined' | ||
@@ -10,0 +14,0 @@ ? globalThis |
@@ -1,101 +0,53 @@ | ||
import Quill from 'quill'; | ||
import type { Space } from './space'; | ||
import type { IdGenerator } from './utils/id-generator'; | ||
import { Awareness } from 'y-protocols/awareness.js'; | ||
import * as Y from 'yjs'; | ||
import { AwarenessAdapter, SelectionRange } from './awareness'; | ||
import { BaseBlockModel } from './base'; | ||
import { Provider, ProviderFactory } from './providers'; | ||
import { PrelimText, RichTextAdapter, Text, TextType } from './text-adapter'; | ||
import { Signal } from './utils/signal'; | ||
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 { DocProvider, DocProviderConstructor } from './doc-providers'; | ||
export interface SerializedStore { | ||
blocks: { | ||
[key: string]: PrefixedBlockProps; | ||
[key: string]: { | ||
[key: string]: unknown; | ||
}; | ||
} | ||
export interface StackItem { | ||
meta: Map<'cursor-location', SelectionRange | undefined>; | ||
type: 'undo' | 'redo'; | ||
export declare enum Generator { | ||
/** | ||
* Default mode, generator for the unpredictable id | ||
*/ | ||
UUIDv4 = "uuidV4", | ||
/** | ||
* This generator is trying to fix the real-time collaboration on debug mode. | ||
* This will make generator predictable and won't make conflict | ||
* @link https://docs.yjs.dev/api/faq#i-get-a-new-clientid-for-every-session-is-there-a-way-to-make-it-static-for-a-peer-accessing-the-doc | ||
*/ | ||
AutoIncrementByClientId = "autoIncrementByClientId", | ||
/** | ||
* **Warning**: This generator mode will crash the collaborative feature | ||
* if multiple clients are adding new blocks. | ||
* Use this mode only if you know what you're doing. | ||
*/ | ||
AutoIncrement = "autoIncrement" | ||
} | ||
export interface StoreOptions { | ||
room?: string; | ||
providers?: ProviderFactory[]; | ||
providers?: DocProviderConstructor[]; | ||
awareness?: Awareness; | ||
idGenerator?: Generator; | ||
} | ||
export declare class Store { | ||
readonly doc: Y.Doc; | ||
readonly providers: Provider[]; | ||
readonly awareness: AwarenessAdapter; | ||
readonly richTextAdapters: Map<string, RichTextAdapter>; | ||
readonly signals: { | ||
historyUpdated: Signal<void>; | ||
rootAdded: Signal<BaseBlockModel>; | ||
rootDeleted: Signal<string>; | ||
textUpdated: Signal<Y.YTextEvent>; | ||
updated: Signal<void>; | ||
}; | ||
private _i; | ||
private _history; | ||
private _root; | ||
private _flavourMap; | ||
private _blockMap; | ||
private _splitSet; | ||
private _ignoredKeys; | ||
constructor({ room, providers, awareness, }?: StoreOptions); | ||
/** key-value store of blocks */ | ||
private get _yBlocks(); | ||
get root(): BaseBlockModel | null; | ||
get isEmpty(): boolean; | ||
get canUndo(): boolean; | ||
get canRedo(): boolean; | ||
undo(): void; | ||
redo(): void; | ||
/** Capture current operations to undo stack synchronously. */ | ||
captureSync(): void; | ||
resetHistory(): void; | ||
transact(fn: () => void): void; | ||
register(blockSchema: Record<string, typeof BaseBlockModel>): this; | ||
getBlockById(id: string): BaseBlockModel | null; | ||
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; | ||
get Text(): typeof Text; | ||
/** 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; | ||
private _createId; | ||
private _getYBlock; | ||
private _historyAddObserver; | ||
private _historyPopObserver; | ||
private _historyObserver; | ||
private _createBlockModel; | ||
private _handleYBlockAdd; | ||
private _handleYBlockDelete; | ||
private _handleYBlockUpdate; | ||
private _handleYEvent; | ||
private _yBlocksObserver; | ||
readonly providers: DocProvider[]; | ||
readonly spaces: Map<string, Space>; | ||
readonly awareness: Awareness; | ||
readonly idGenerator: IdGenerator; | ||
constructor({ room, providers, awareness, idGenerator, }?: StoreOptions); | ||
addSpace(space: Space): void; | ||
removeSpace(space: Space): void; | ||
/** | ||
* @internal Only for testing | ||
*/ | ||
serializeDoc(): SerializedStore; | ||
/** | ||
* @internal Only for testing, 'page0' should be replaced by props 'spaceId' | ||
*/ | ||
toJSXElement(id?: string): import("./utils/jsx").JSXElement | null; | ||
} | ||
//# sourceMappingURL=store.d.ts.map |
@@ -1,399 +0,75 @@ | ||
/// <reference types="vite/client" /> | ||
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 { blockRecordToJSXNode } from './utils/jsx'; | ||
import { Signal } from './utils/signal'; | ||
import { assertValidChildren, initSysProps, syncBlockProps, toBlockProps, trySyncTextProp, } from './utils/utils'; | ||
// Workaround | ||
const IS_WEB = typeof window !== 'undefined'; | ||
function createChildMap(yChildIds) { | ||
return new Map(yChildIds.map((child, index) => [child, index])); | ||
} | ||
import { serializeYDoc, yDocToJSXNode } from './utils/jsx'; | ||
import { createAutoIncrementIdGenerator, createAutoIncrementIdGeneratorByClientId, uuidv4, } from './utils/id-generator'; | ||
export var Generator; | ||
(function (Generator) { | ||
/** | ||
* Default mode, generator for the unpredictable id | ||
*/ | ||
Generator["UUIDv4"] = "uuidV4"; | ||
/** | ||
* This generator is trying to fix the real-time collaboration on debug mode. | ||
* This will make generator predictable and won't make conflict | ||
* @link https://docs.yjs.dev/api/faq#i-get-a-new-clientid-for-every-session-is-there-a-way-to-make-it-static-for-a-peer-accessing-the-doc | ||
*/ | ||
Generator["AutoIncrementByClientId"] = "autoIncrementByClientId"; | ||
/** | ||
* **Warning**: This generator mode will crash the collaborative feature | ||
* if multiple clients are adding new blocks. | ||
* Use this mode only if you know what you're doing. | ||
*/ | ||
Generator["AutoIncrement"] = "autoIncrement"; | ||
})(Generator || (Generator = {})); | ||
const DEFAULT_ROOM = 'virgo-default'; | ||
export class Store { | ||
constructor({ room = DEFAULT_ROOM, providers = [], awareness, } = {}) { | ||
// TODO: The user cursor should be spread by the spaceId in awareness | ||
constructor({ room = DEFAULT_ROOM, providers = [], awareness, idGenerator, } = {}) { | ||
this.doc = new Y.Doc(); | ||
this.providers = []; | ||
this.richTextAdapters = new Map(); | ||
this.signals = { | ||
historyUpdated: new Signal(), | ||
rootAdded: new Signal(), | ||
rootDeleted: new Signal(), | ||
textUpdated: new Signal(), | ||
updated: new Signal(), | ||
}; | ||
this._i = 0; | ||
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.spaces = new Map(); | ||
this.awareness = awareness ?? new Awareness(this.doc); | ||
switch (idGenerator) { | ||
case Generator.AutoIncrement: { | ||
this.idGenerator = createAutoIncrementIdGenerator(); | ||
break; | ||
} | ||
this._historyObserver(); | ||
}; | ||
this._historyPopObserver = (event) => { | ||
const cursor = event.stackItem.meta.get('cursor-location'); | ||
if (!cursor) { | ||
return; | ||
case Generator.AutoIncrementByClientId: { | ||
this.idGenerator = createAutoIncrementIdGeneratorByClientId(this.doc.clientID); | ||
break; | ||
} | ||
this.awareness.setLocalCursor(cursor); | ||
this._historyObserver(); | ||
}; | ||
this._historyObserver = () => { | ||
this.signals.historyUpdated.emit(); | ||
}; | ||
this._yBlocksObserver = (events) => { | ||
for (const event of events) { | ||
this._handleYEvent(event); | ||
case Generator.UUIDv4: | ||
default: { | ||
this.idGenerator = uuidv4; | ||
break; | ||
} | ||
this.signals.updated.emit(); | ||
}; | ||
const aware = awareness ?? new Awareness(this.doc); | ||
this.providers = | ||
providers.map(Provider => new Provider(room, this.doc, { awareness: aware })) ?? []; | ||
this.awareness = new AwarenessAdapter(this, aware); | ||
this._yBlocks.observeDeep(this._yBlocksObserver); | ||
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('blocks'); | ||
} | ||
get root() { | ||
return this._root; | ||
} | ||
get isEmpty() { | ||
return this._yBlocks.size === 0; | ||
} | ||
get canUndo() { | ||
return this._history.canUndo(); | ||
} | ||
get canRedo() { | ||
return this._history.canRedo(); | ||
} | ||
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; | ||
} | ||
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; | ||
this.providers = providers.map(ProviderConstructor => new ProviderConstructor(room, this.doc, { awareness: this.awareness })); | ||
} | ||
getParent(block) { | ||
if (!this._root) | ||
return null; | ||
return this.getParentById(this._root.id, block); | ||
addSpace(space) { | ||
this.spaces.set(space.id, space); | ||
} | ||
getPreviousSibling(block) { | ||
const parent = this.getParent(block); | ||
const index = parent?.children.indexOf(block) ?? -1; | ||
return parent?.children[index - 1] ?? null; | ||
removeSpace(space) { | ||
this.spaces.delete(space.id); | ||
} | ||
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 = clonedProps.id ? clonedProps.id : this._createId(); | ||
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.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); | ||
} | ||
} | ||
}); | ||
} | ||
get Text() { | ||
return Text; | ||
} | ||
/** 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); | ||
} | ||
_createId() { | ||
return (this._i++).toString(); | ||
} | ||
_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 | ||
(model.flavour === 'paragraph' || model.flavour === '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(); | ||
} | ||
} | ||
} | ||
} | ||
/** | ||
* @internal Only for testing | ||
*/ | ||
serializeDoc() { | ||
return serializeYDoc(this.doc); | ||
} | ||
/** | ||
* @internal Only for testing, 'page0' should be replaced by props 'spaceId' | ||
*/ | ||
toJSXElement(id = '0') { | ||
const json = this.doc.toJSON(); | ||
if (!('blocks' in json)) { | ||
throw new Error("Failed to convert to JSX: 'blocks' not found"); | ||
const json = this.serializeDoc(); | ||
if (!('space:page0' in json)) { | ||
throw new Error("Failed to convert to JSX: 'space:page0' not found"); | ||
} | ||
if (!json.blocks[id]) { | ||
if (!json['space:page0'][id]) { | ||
return null; | ||
} | ||
return blockRecordToJSXNode(json.blocks, id); | ||
return yDocToJSXNode(json['space:page0'], id); | ||
} | ||
} | ||
//# sourceMappingURL=store.js.map |
import * as Y from 'yjs'; | ||
import { AwarenessAdapter } from './awareness'; | ||
import type { AwarenessAdapter } from './awareness'; | ||
import type { DeltaOperation, Quill } from 'quill'; | ||
import { Store } from './store'; | ||
import type { Space } from './space'; | ||
declare type PrelimTextType = 'splitLeft' | 'splitRight'; | ||
@@ -21,2 +21,3 @@ export declare type TextType = PrelimText | Text; | ||
delete(): void; | ||
replace(): void; | ||
format(): void; | ||
@@ -26,8 +27,33 @@ applyDelta(): void; | ||
} | ||
declare module 'yjs' { | ||
interface Text { | ||
/** | ||
* Specific addition used by @blocksuite/store | ||
* When set, we know it hasn't been applied to quill. | ||
* When specified, we call this a "controlled operation". | ||
* | ||
* Consider renaming this to closer indicate this is simply a "controlled operation", | ||
* since we may not actually use this information. | ||
*/ | ||
meta?: { | ||
split: true; | ||
} | { | ||
join: true; | ||
} | { | ||
format: true; | ||
} | { | ||
delete: true; | ||
} | { | ||
clear: true; | ||
} | { | ||
replace: true; | ||
}; | ||
} | ||
} | ||
export declare class Text { | ||
private _store; | ||
private _space; | ||
private _yText; | ||
private _shouldTransact; | ||
constructor(store: Store, input: Y.Text | string); | ||
static fromDelta(store: Store, delta: DeltaOperation[]): Text; | ||
constructor(space: Space, input: Y.Text | string); | ||
static fromDelta(space: Space, delta: DeltaOperation[]): Text; | ||
get length(): number; | ||
@@ -37,3 +63,3 @@ private _transact; | ||
split(index: number): [PrelimText, PrelimText]; | ||
insert(content: string, index: number, attributes?: Object): void; | ||
insert(content: string, index: number, attributes?: Record<string, unknown>): void; | ||
insertList(insertTexts: Record<string, unknown>[], index: number): void; | ||
@@ -43,2 +69,3 @@ join(other: Text): void; | ||
delete(index: number, length: number): void; | ||
replace(index: number, length: number, content: string, attributes?: Record<string, unknown>): void; | ||
clear(): void; | ||
@@ -51,3 +78,3 @@ applyDelta(delta: any): void; | ||
export declare class RichTextAdapter { | ||
readonly store: Store; | ||
readonly space: Space; | ||
readonly doc: Y.Doc; | ||
@@ -59,3 +86,3 @@ readonly yText: Y.Text; | ||
private _negatedUsedFormats; | ||
constructor(store: Store, yText: Y.Text, quill: Quill); | ||
constructor(space: Space, yText: Y.Text, quill: Quill); | ||
private _yObserver; | ||
@@ -62,0 +89,0 @@ private _quillObserver; |
@@ -56,2 +56,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
} | ||
replace() { | ||
throw new Error(UNSUPPORTED_MSG + 'replace'); | ||
} | ||
format() { | ||
@@ -68,6 +71,6 @@ throw new Error(UNSUPPORTED_MSG + 'format'); | ||
export class Text { | ||
constructor(store, input) { | ||
constructor(space, input) { | ||
// TODO toggle transact by options | ||
this._shouldTransact = true; | ||
this._store = store; | ||
this._space = space; | ||
if (typeof input === 'string') { | ||
@@ -80,4 +83,4 @@ this._yText = new Y.Text(input); | ||
} | ||
static fromDelta(store, delta) { | ||
const result = new Text(store, ''); | ||
static fromDelta(space, delta) { | ||
const result = new Text(space, ''); | ||
result.applyDelta(delta); | ||
@@ -90,7 +93,7 @@ return result; | ||
_transact(callback) { | ||
const { _store, _shouldTransact: _shouldTransact } = this; | ||
_shouldTransact ? _store.transact(callback) : callback(); | ||
const { _space, _shouldTransact } = this; | ||
_shouldTransact ? _space.transact(callback) : callback(); | ||
} | ||
clone() { | ||
return new Text(this._store, this._yText.clone()); | ||
return new Text(this._space, this._yText.clone()); | ||
} | ||
@@ -103,7 +106,5 @@ split(index) { | ||
} | ||
// eslint-disable-next-line @typescript-eslint/ban-types | ||
insert(content, index, attributes) { | ||
this._transact(() => { | ||
this._yText.insert(index, content, attributes); | ||
// @ts-ignore | ||
this._yText.meta = { split: true }; | ||
@@ -119,3 +120,2 @@ }); | ||
} | ||
// @ts-ignore | ||
this._yText.meta = { split: true }; | ||
@@ -130,3 +130,2 @@ }); | ||
this._yText.applyDelta(delta); | ||
// @ts-ignore | ||
this._yText.meta = { join: true }; | ||
@@ -138,3 +137,2 @@ }); | ||
this._yText.format(index, length, format); | ||
// @ts-ignore | ||
this._yText.meta = { format: true }; | ||
@@ -146,10 +144,15 @@ }); | ||
this._yText.delete(index, length); | ||
// @ts-ignore | ||
this._yText.meta = { delete: true }; | ||
}); | ||
} | ||
replace(index, length, content, attributes) { | ||
this._transact(() => { | ||
this._yText.delete(index, length); | ||
this._yText.insert(index, content, attributes); | ||
this._yText.meta = { replace: true }; | ||
}); | ||
} | ||
clear() { | ||
this._transact(() => { | ||
this._yText.delete(0, this._yText.length); | ||
// @ts-ignore | ||
this._yText.meta = { clear: true }; | ||
@@ -160,3 +163,3 @@ }); | ||
this._transact(() => { | ||
this._yText.applyDelta(delta); | ||
this._yText?.applyDelta(delta); | ||
}); | ||
@@ -210,10 +213,10 @@ } | ||
export class RichTextAdapter { | ||
constructor(store, yText, quill) { | ||
constructor(space, yText, quill) { | ||
this._yObserver = (event) => { | ||
const isFromLocal = event.transaction.origin === this.doc.clientID; | ||
const isFromRemote = !isFromLocal; | ||
// @ts-ignore | ||
const isControlledOperation = !!event.target?.meta; | ||
// update quill if the change is from remote or using controlled operation | ||
if (isFromRemote || isControlledOperation) { | ||
const quillMustApplyUpdate = isFromRemote || isControlledOperation; | ||
if (quillMustApplyUpdate) { | ||
const eventDelta = event.delta; | ||
@@ -244,3 +247,3 @@ // We always explicitly set attributes, otherwise concurrent edits may | ||
}; | ||
this._quillObserver = (_eventType, delta, _state, origin) => { | ||
this._quillObserver = (eventType, delta, state, origin) => { | ||
const { yText } = this; | ||
@@ -260,3 +263,3 @@ if (delta && delta.ops) { | ||
if (origin === 'user') { | ||
this.store.transact(() => { | ||
this.space.transact(() => { | ||
yText.applyDelta(ops); | ||
@@ -267,7 +270,7 @@ }); | ||
}; | ||
this.store = store; | ||
this.space = space; | ||
this.yText = yText; | ||
this.doc = store.doc; | ||
this.doc = space.doc; | ||
this.quill = quill; | ||
this.awareness = store.awareness; | ||
this.awareness = space.awareness; | ||
const quillCursors = quill.getModule('cursors') || null; | ||
@@ -274,0 +277,0 @@ this.quillCursors = quillCursors; |
@@ -0,8 +1,12 @@ | ||
import { Doc } from 'yjs'; | ||
export interface JSXElement { | ||
$$typeof: symbol | 0xea71357; | ||
type: string; | ||
props?: Record<string, unknown>; | ||
props: { | ||
'prop:text'?: string | JSXElement; | ||
} & Record<string, unknown>; | ||
children?: null | (JSXElement | string | number)[]; | ||
} | ||
export declare const blockRecordToJSXNode: (docRecord: Record<string, unknown>, nodeId: string) => JSXElement; | ||
export declare const yDocToJSXNode: (serializedDoc: Record<string, unknown>, nodeId: string) => JSXElement; | ||
export declare const serializeYDoc: (doc: Doc) => Record<string, unknown>; | ||
//# sourceMappingURL=jsx.d.ts.map |
@@ -0,1 +1,5 @@ | ||
import { AbstractType, Map, Text, Array } from 'yjs'; | ||
// Ad-hoc for `ReactTestComponent` identify. | ||
// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L26-L29 | ||
const testSymbol = Symbol.for('react.test.json'); | ||
const isValidRecord = (data) => { | ||
@@ -9,7 +13,7 @@ if (typeof data !== 'object' || data === null) { | ||
const IGNORE_PROPS = ['sys:id', 'sys:flavour', 'sys:children']; | ||
export const blockRecordToJSXNode = (docRecord, nodeId) => { | ||
if (!isValidRecord(docRecord)) { | ||
export const yDocToJSXNode = (serializedDoc, nodeId) => { | ||
if (!isValidRecord(serializedDoc)) { | ||
throw new Error('Failed to parse doc record! Invalid data.'); | ||
} | ||
const node = docRecord[nodeId]; | ||
const node = serializedDoc[nodeId]; | ||
if (!node) { | ||
@@ -23,11 +27,76 @@ throw new Error(`Failed to parse doc record! Node not found! id: ${nodeId}.`); | ||
const props = Object.fromEntries(Object.entries(node).filter(([key]) => !IGNORE_PROPS.includes(key))); | ||
if ('prop:text' in props) { | ||
props['prop:text'] = parseDelta(props['prop:text']); | ||
} | ||
return { | ||
// Ad-hoc for `ReactTestComponent` identify. | ||
// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L26-L29 | ||
$$typeof: Symbol.for('react.test.json'), | ||
$$typeof: testSymbol, | ||
type: flavour, | ||
props, | ||
children: children?.map(id => blockRecordToJSXNode(docRecord, id)) ?? [], | ||
children: children?.map(id => yDocToJSXNode(serializedDoc, id)) ?? [], | ||
}; | ||
}; | ||
export const serializeYDoc = (doc) => { | ||
const json = {}; | ||
doc.share.forEach((value, key) => { | ||
if (value instanceof Map) { | ||
json[key] = serializeYMap(value); | ||
} | ||
else { | ||
json[key] = value.toJSON(); | ||
} | ||
}); | ||
return json; | ||
}; | ||
const serializeYMap = (map) => { | ||
const json = {}; | ||
map.forEach((value, key) => { | ||
if (value instanceof Map) { | ||
json[key] = serializeYMap(value); | ||
} | ||
else if (value instanceof Text) { | ||
json[key] = serializeYText(value); | ||
} | ||
else if (value instanceof Array) { | ||
json[key] = value.toJSON(); | ||
} | ||
else if (value instanceof AbstractType) { | ||
json[key] = value.toJSON(); | ||
} | ||
else { | ||
json[key] = value; | ||
} | ||
}); | ||
return json; | ||
}; | ||
const serializeYText = (text) => { | ||
const delta = text.toDelta(); | ||
return delta; | ||
}; | ||
const parseDelta = (text) => { | ||
if (!text.length) { | ||
return undefined; | ||
} | ||
if (text.length === 1 && !text[0].attributes) { | ||
// just plain text | ||
return text[0].insert; | ||
} | ||
return { | ||
// The `Symbol.for('react.fragment')` will render as `<React.Fragment>` | ||
// so we use a empty string to render it as `<>`. | ||
// But it will empty children ad `< />` | ||
// so we return `undefined` directly if not delta text. | ||
$$typeof: testSymbol, | ||
type: '', | ||
props: {}, | ||
children: text?.map(({ insert, attributes }) => ({ | ||
$$typeof: testSymbol, | ||
type: 'text', | ||
props: { | ||
// Not place at `children` to avoid the trailing whitespace be trim by formatter. | ||
insert, | ||
...attributes, | ||
}, | ||
})), | ||
}; | ||
}; | ||
//# sourceMappingURL=jsx.js.map |
@@ -1,3 +0,7 @@ | ||
import type { BlockProps, PrefixedBlockProps, YBlock, YBlocks } from '../store'; | ||
import type { BaseBlockModel } from '../base'; | ||
import type { BlockProps, PrefixedBlockProps, YBlock, YBlocks } from '../workspace/page'; | ||
import { PrelimText, Text, TextType } from '../text-adapter'; | ||
export declare function assertExists<T>(val: T | null | undefined): asserts val is T; | ||
export declare function assertFlavours(model: BaseBlockModel, allowed: string[]): void; | ||
export declare function matchFlavours(model: BaseBlockModel, expected: string[]): boolean; | ||
export declare function assertValidChildren(yBlocks: YBlocks, props: Partial<BlockProps>): void; | ||
@@ -4,0 +8,0 @@ export declare function initSysProps(yBlock: YBlock, props: Partial<BlockProps>): void; |
@@ -8,2 +8,15 @@ import * as Y from 'yjs'; | ||
} | ||
export function assertExists(val) { | ||
if (val === null || val === undefined) { | ||
throw new Error('val does not exist'); | ||
} | ||
} | ||
export function assertFlavours(model, allowed) { | ||
if (!allowed.includes(model.flavour)) { | ||
throw new Error(`model flavour ${model.flavour} is not allowed`); | ||
} | ||
} | ||
export function matchFlavours(model, expected) { | ||
return expected.includes(model.flavour); | ||
} | ||
export function assertValidChildren(yBlocks, props) { | ||
@@ -43,3 +56,3 @@ if (!Array.isArray(props.children)) | ||
// TODO use schema | ||
if (props.flavour === 'paragraph' && | ||
if (props.flavour === 'affine:paragraph' && | ||
!props.type && | ||
@@ -49,11 +62,31 @@ !yBlock.has('prop:type')) { | ||
} | ||
if (props.flavour === 'list' && !yBlock.has('prop:type')) { | ||
if (props.flavour === 'affine:list' && !yBlock.has('prop:type')) { | ||
yBlock.set('prop:type', props.type ?? 'bulleted'); | ||
} | ||
if (props.flavour === 'list' && !yBlock.has('prop:checked')) { | ||
if (props.flavour === 'affine:list' && !yBlock.has('prop:checked')) { | ||
yBlock.set('prop:checked', props.checked ?? false); | ||
} | ||
if (props.flavour === 'group' && !yBlock.has('prop:xywh')) { | ||
if (props.flavour === 'affine:group' && !yBlock.has('prop:xywh')) { | ||
yBlock.set('prop:xywh', props.xywh ?? '[0,0,720,480]'); | ||
} | ||
if (props.flavour === 'affine:embed' && !yBlock.has('prop:width')) { | ||
yBlock.set('prop:width', props.width ?? 20); | ||
} | ||
if (props.flavour === 'affine:embed' && !yBlock.has('prop:sourceId')) { | ||
yBlock.set('prop:sourceId', props.sourceId ?? ''); | ||
} | ||
if (props.flavour === 'affine:embed' && !yBlock.has('prop:caption')) { | ||
yBlock.set('prop:caption', props.caption ?? ''); | ||
} | ||
if (props.flavour === 'affine:shape') { | ||
if (!yBlock.has('prop:xywh')) { | ||
yBlock.set('prop:xywh', props.xywh ?? '[0,0,50,50]'); | ||
} | ||
if (!yBlock.has('prop:type')) { | ||
yBlock.set('prop:type', props.type ?? 'rectangle'); | ||
} | ||
if (!yBlock.has('prop:color')) { | ||
yBlock.set('prop:color', props.color ?? 'black'); | ||
} | ||
} | ||
} | ||
@@ -60,0 +93,0 @@ export function trySyncTextProp(splitSet, yBlock, text) { |
{ | ||
"name": "@blocksuite/store", | ||
"version": "0.2.24", | ||
"version": "0.3.0-20221218000328-e1b2a90", | ||
"description": "BlockSuite data store built for general purpose state management.", | ||
@@ -10,9 +10,19 @@ "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", | ||
"y-protocols": "^1.0.5", | ||
"y-webrtc": "^10.2.3", | ||
"y-websocket": "^1.4.5", | ||
"yjs": "^13.5.41" | ||
}, | ||
"devDependencies": { | ||
"@types/quill": "^2.0.9" | ||
"@types/flexsearch": "^0.7.3", | ||
"@types/quill": "^2.0.9", | ||
"cross-env": "^7.0.3", | ||
"lit": "^2.3.1" | ||
}, | ||
@@ -27,7 +37,10 @@ "exports": { | ||
"scripts": { | ||
"serve": "PORT=4444 node node_modules/y-webrtc/bin/server.js", | ||
"serve": "cross-env PORT=4444 node node_modules/y-webrtc/bin/server.js", | ||
"serve:websocket": "cross-env HOST=localhost PORT=1234 npx y-websocket", | ||
"build": "tsc", | ||
"test": "vitest --run" | ||
"test:unit": "vitest --run", | ||
"test:e2e": "playwright test", | ||
"test": "pnpm test:unit && pnpm test:e2e" | ||
}, | ||
"types": "dist/index.d.ts" | ||
} |
import * as Y from 'yjs'; | ||
import type { RelativePosition } from 'yjs'; | ||
import type { Awareness } from 'y-protocols/awareness.js'; | ||
import { RelativePosition } from 'yjs'; | ||
import type { Store } from './store'; | ||
import type { Space } from './space'; | ||
import { Signal } from './utils/signal'; | ||
@@ -31,3 +31,3 @@ | ||
export class AwarenessAdapter { | ||
readonly store: Store; | ||
readonly space: Space; | ||
readonly awareness: Awareness; | ||
@@ -39,4 +39,4 @@ | ||
constructor(store: Store, awareness: Awareness) { | ||
this.store = store; | ||
constructor(space: Space, awareness: Awareness) { | ||
this.space = space; | ||
this.awareness = awareness; | ||
@@ -100,3 +100,3 @@ this.awareness.on('change', this._onAwarenessChange); | ||
private _resetRemoteCursor() { | ||
this.store.richTextAdapters.forEach(textAdapter => | ||
this.space.richTextAdapters.forEach(textAdapter => | ||
textAdapter.quillCursors.clearCursors() | ||
@@ -108,9 +108,9 @@ ); | ||
awState.cursor.anchor, | ||
this.store.doc | ||
this.space.doc | ||
); | ||
const focus = Y.createAbsolutePositionFromRelativePosition( | ||
awState.cursor.focus, | ||
this.store.doc | ||
this.space.doc | ||
); | ||
const textAdapter = this.store.richTextAdapters.get( | ||
const textAdapter = this.space.richTextAdapters.get( | ||
awState.cursor.id || '' | ||
@@ -137,3 +137,3 @@ ); | ||
public updateLocalCursor() { | ||
const localCursor = this.store.awareness.getLocalCursor(); | ||
const localCursor = this.space.awareness.getLocalCursor(); | ||
if (!localCursor) { | ||
@@ -144,10 +144,10 @@ return; | ||
localCursor.anchor, | ||
this.store.doc | ||
this.space.doc | ||
); | ||
const focus = Y.createAbsolutePositionFromRelativePosition( | ||
localCursor.focus, | ||
this.store.doc | ||
this.space.doc | ||
); | ||
if (anchor && focus) { | ||
const textAdapter = this.store.richTextAdapters.get(localCursor.id || ''); | ||
const textAdapter = this.space.richTextAdapters.get(localCursor.id || ''); | ||
textAdapter?.quill.setSelection(anchor.index, focus.index - anchor.index); | ||
@@ -154,0 +154,0 @@ } |
@@ -1,2 +0,2 @@ | ||
import type { Store } from './store'; | ||
import type { Page } from './workspace'; | ||
import type { TextType } from './text-adapter'; | ||
@@ -16,3 +16,5 @@ import { Signal } from './utils/signal'; | ||
export class BaseBlockModel implements IBaseBlockProps { | ||
store: Store; | ||
static version: [number, number]; | ||
page: Page; | ||
propsUpdated = new Signal(); | ||
@@ -28,5 +30,6 @@ childrenUpdated = new Signal(); | ||
text?: TextType; | ||
sourceId?: string; | ||
constructor(store: Store, props: Partial<IBaseBlockProps>) { | ||
this.store = store; | ||
constructor(page: Page, props: Partial<IBaseBlockProps>) { | ||
this.page = page; | ||
this.id = props.id as string; | ||
@@ -70,4 +73,4 @@ this.children = []; | ||
private _deltaLeaf2Html(deltaLeaf: Record<string, unknown>) { | ||
const text = deltaLeaf.insert; | ||
_deltaLeaf2Html(deltaLeaf: Record<string, unknown>) { | ||
let text = deltaLeaf.insert; | ||
const attributes: Record<string, boolean> = deltaLeaf.attributes as Record< | ||
@@ -80,19 +83,19 @@ string, | ||
} | ||
if (attributes.code) { | ||
text = `<code>${text}</code>`; | ||
} | ||
if (attributes.bold) { | ||
return `<strong>${text}</strong>`; | ||
text = `<strong>${text}</strong>`; | ||
} | ||
if (attributes.italic) { | ||
return `<em>${text}</em>`; | ||
text = `<em>${text}</em>`; | ||
} | ||
if (attributes.underline) { | ||
return `<u>${text}</u>`; | ||
text = `<u>${text}</u>`; | ||
} | ||
if (attributes.code) { | ||
return `<code>${text}</code>`; | ||
} | ||
if (attributes.strikethrough) { | ||
return `<s>${text}</s>`; | ||
text = `<s>${text}</s>`; | ||
} | ||
if (attributes.link) { | ||
return `<a href='${attributes.link}'>${text}</a>`; | ||
text = `<a href='${attributes.link}'>${text}</a>`; | ||
} | ||
@@ -99,0 +102,0 @@ return text; |
@@ -0,9 +1,13 @@ | ||
export * from './space'; | ||
export * from './store'; | ||
export * from './base'; | ||
export * from './awareness'; | ||
export * from './blob'; | ||
export * from './text-adapter'; | ||
export * from './utils/signal'; | ||
export * from './utils/disposable'; | ||
export * from './utils/utils'; | ||
export * from './providers'; | ||
export * from './doc-providers'; | ||
export * from './workspace'; | ||
export * as Utils from './utils/utils'; | ||
export * from './utils/id-generator'; | ||
@@ -10,0 +14,0 @@ const env = |
554
src/store.ts
@@ -1,59 +0,43 @@ | ||
/// <reference types="vite/client" /> | ||
import Quill from 'quill'; | ||
import type { Space } from './space'; | ||
import type { IdGenerator } from './utils/id-generator'; | ||
import { Awareness } from 'y-protocols/awareness.js'; | ||
import * as Y from 'yjs'; | ||
import { AwarenessAdapter, SelectionRange } from './awareness'; | ||
import { BaseBlockModel } from './base'; | ||
import { Provider, ProviderFactory } from './providers'; | ||
import { PrelimText, RichTextAdapter, Text, TextType } from './text-adapter'; | ||
import { blockRecordToJSXNode } from './utils/jsx'; | ||
import { Signal } from './utils/signal'; | ||
import type { DocProvider, DocProviderConstructor } from './doc-providers'; | ||
import { serializeYDoc, yDocToJSXNode } from './utils/jsx'; | ||
import { | ||
assertValidChildren, | ||
initSysProps, | ||
syncBlockProps, | ||
toBlockProps, | ||
trySyncTextProp, | ||
} from './utils/utils'; | ||
createAutoIncrementIdGenerator, | ||
createAutoIncrementIdGeneratorByClientId, | ||
uuidv4, | ||
} from './utils/id-generator'; | ||
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 SerializedStore { | ||
blocks: { | ||
[key: string]: PrefixedBlockProps; | ||
[key: string]: { | ||
[key: string]: unknown; | ||
}; | ||
} | ||
export interface StackItem { | ||
meta: Map<'cursor-location', SelectionRange | undefined>; | ||
type: 'undo' | 'redo'; | ||
export enum Generator { | ||
/** | ||
* Default mode, generator for the unpredictable id | ||
*/ | ||
UUIDv4 = 'uuidV4', | ||
/** | ||
* This generator is trying to fix the real-time collaboration on debug mode. | ||
* This will make generator predictable and won't make conflict | ||
* @link https://docs.yjs.dev/api/faq#i-get-a-new-clientid-for-every-session-is-there-a-way-to-make-it-static-for-a-peer-accessing-the-doc | ||
*/ | ||
AutoIncrementByClientId = 'autoIncrementByClientId', | ||
/** | ||
* **Warning**: This generator mode will crash the collaborative feature | ||
* if multiple clients are adding new blocks. | ||
* Use this mode only if you know what you're doing. | ||
*/ | ||
AutoIncrement = 'autoIncrement', | ||
} | ||
// Workaround | ||
const IS_WEB = typeof window !== 'undefined'; | ||
function createChildMap(yChildIds: Y.Array<string>) { | ||
return new Map(yChildIds.map((child, index) => [child, index])); | ||
} | ||
export interface StoreOptions { | ||
room?: string; | ||
providers?: ProviderFactory[]; | ||
providers?: DocProviderConstructor[]; | ||
awareness?: Awareness; | ||
idGenerator?: Generator; | ||
} | ||
@@ -65,26 +49,8 @@ | ||
readonly doc = new Y.Doc(); | ||
readonly providers: Provider[] = []; | ||
readonly awareness!: AwarenessAdapter; | ||
readonly richTextAdapters = new Map<string, RichTextAdapter>(); | ||
readonly providers: DocProvider[] = []; | ||
readonly spaces = new Map<string, Space>(); | ||
readonly awareness: Awareness; | ||
readonly idGenerator: IdGenerator; | ||
readonly signals = { | ||
historyUpdated: new Signal(), | ||
rootAdded: new Signal<BaseBlockModel>(), | ||
rootDeleted: new Signal<string>(), | ||
textUpdated: new Signal<Y.YTextEvent>(), | ||
updated: new Signal(), | ||
}; | ||
private _i = 0; | ||
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, {})) | ||
); | ||
// TODO: The user cursor should be spread by the spaceId in awareness | ||
constructor({ | ||
@@ -94,440 +60,56 @@ room = DEFAULT_ROOM, | ||
awareness, | ||
idGenerator, | ||
}: StoreOptions = {}) { | ||
const aware = awareness ?? new Awareness(this.doc); | ||
this.providers = | ||
providers.map( | ||
Provider => new Provider(room, this.doc, { awareness: aware }) | ||
) ?? []; | ||
this.awareness = new AwarenessAdapter(this, aware); | ||
this._yBlocks.observeDeep(this._yBlocksObserver); | ||
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('blocks') 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(); | ||
} | ||
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; | ||
} | ||
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 = clonedProps.id ? clonedProps.id : this._createId(); | ||
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); | ||
this.awareness = awareness ?? new Awareness(this.doc); | ||
switch (idGenerator) { | ||
case Generator.AutoIncrement: { | ||
this.idGenerator = createAutoIncrementIdGenerator(); | ||
break; | ||
} | ||
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]); | ||
case Generator.AutoIncrementByClientId: { | ||
this.idGenerator = createAutoIncrementIdGeneratorByClientId( | ||
this.doc.clientID | ||
); | ||
break; | ||
} | ||
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); | ||
case Generator.UUIDv4: | ||
default: { | ||
this.idGenerator = uuidv4; | ||
break; | ||
} | ||
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.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); | ||
} | ||
} | ||
}); | ||
this.providers = providers.map( | ||
ProviderConstructor => | ||
new ProviderConstructor(room, this.doc, { awareness: this.awareness }) | ||
); | ||
} | ||
get Text() { | ||
return Text; | ||
addSpace(space: Space) { | ||
this.spaces.set(space.id, space); | ||
} | ||
/** 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 }); | ||
}); | ||
removeSpace(space: Space) { | ||
this.spaces.delete(space.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); | ||
/** | ||
* @internal Only for testing | ||
*/ | ||
serializeDoc() { | ||
return serializeYDoc(this.doc) as unknown as SerializedStore; | ||
} | ||
markTextSplit(base: Text, left: PrelimText, right: PrelimText) { | ||
this._splitSet.add(base).add(left).add(right); | ||
} | ||
private _createId(): string { | ||
return (this._i++).toString(); | ||
} | ||
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 | ||
(model.flavour === 'paragraph' || model.flavour === '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(); | ||
} | ||
} | ||
} | ||
} | ||
private _yBlocksObserver = (events: Y.YEvent<YBlock | Y.Text>[]) => { | ||
for (const event of events) { | ||
this._handleYEvent(event); | ||
} | ||
this.signals.updated.emit(); | ||
}; | ||
/** | ||
* @internal Only for testing | ||
* @internal Only for testing, 'page0' should be replaced by props 'spaceId' | ||
*/ | ||
toJSXElement(id = '0') { | ||
const json = this.doc.toJSON(); | ||
if (!('blocks' in json)) { | ||
throw new Error("Failed to convert to JSX: 'blocks' not found"); | ||
const json = this.serializeDoc(); | ||
if (!('space:page0' in json)) { | ||
throw new Error("Failed to convert to JSX: 'space:page0' not found"); | ||
} | ||
if (!json.blocks[id]) { | ||
if (!json['space:page0'][id]) { | ||
return null; | ||
} | ||
return blockRecordToJSXNode(json.blocks, id); | ||
return yDocToJSXNode(json['space:page0'], id); | ||
} | ||
} |
/* eslint-disable @typescript-eslint/no-explicit-any */ | ||
import * as Y from 'yjs'; | ||
import { AwarenessAdapter } from './awareness'; | ||
import type { AwarenessAdapter } from './awareness'; | ||
import type { DeltaOperation, Quill } from 'quill'; | ||
import { Store } from './store'; | ||
import type { Space } from './space'; | ||
@@ -79,2 +79,6 @@ type PrelimTextType = 'splitLeft' | 'splitRight'; | ||
replace() { | ||
throw new Error(UNSUPPORTED_MSG + 'replace'); | ||
} | ||
format() { | ||
@@ -93,4 +97,24 @@ throw new Error(UNSUPPORTED_MSG + 'format'); | ||
declare module 'yjs' { | ||
interface Text { | ||
/** | ||
* Specific addition used by @blocksuite/store | ||
* When set, we know it hasn't been applied to quill. | ||
* When specified, we call this a "controlled operation". | ||
* | ||
* Consider renaming this to closer indicate this is simply a "controlled operation", | ||
* since we may not actually use this information. | ||
*/ | ||
meta?: | ||
| { split: true } | ||
| { join: true } | ||
| { format: true } | ||
| { delete: true } | ||
| { clear: true } | ||
| { replace: true }; | ||
} | ||
} | ||
export class Text { | ||
private _store: Store; | ||
private _space: Space; | ||
private _yText: Y.Text; | ||
@@ -101,4 +125,4 @@ | ||
constructor(store: Store, input: Y.Text | string) { | ||
this._store = store; | ||
constructor(space: Space, input: Y.Text | string) { | ||
this._space = space; | ||
if (typeof input === 'string') { | ||
@@ -111,4 +135,4 @@ this._yText = new Y.Text(input); | ||
static fromDelta(store: Store, delta: DeltaOperation[]) { | ||
const result = new Text(store, ''); | ||
static fromDelta(space: Space, delta: DeltaOperation[]) { | ||
const result = new Text(space, ''); | ||
result.applyDelta(delta); | ||
@@ -123,8 +147,8 @@ return result; | ||
private _transact(callback: () => void) { | ||
const { _store, _shouldTransact: _shouldTransact } = this; | ||
_shouldTransact ? _store.transact(callback) : callback(); | ||
const { _space, _shouldTransact } = this; | ||
_shouldTransact ? _space.transact(callback) : callback(); | ||
} | ||
clone() { | ||
return new Text(this._store, this._yText.clone()); | ||
return new Text(this._space, this._yText.clone()); | ||
} | ||
@@ -139,7 +163,5 @@ | ||
// eslint-disable-next-line @typescript-eslint/ban-types | ||
insert(content: string, index: number, attributes?: Object) { | ||
insert(content: string, index: number, attributes?: Record<string, unknown>) { | ||
this._transact(() => { | ||
this._yText.insert(index, content, attributes); | ||
// @ts-ignore | ||
this._yText.meta = { split: true }; | ||
@@ -159,3 +181,2 @@ }); | ||
} | ||
// @ts-ignore | ||
this._yText.meta = { split: true }; | ||
@@ -171,3 +192,2 @@ }); | ||
this._yText.applyDelta(delta); | ||
// @ts-ignore | ||
this._yText.meta = { join: true }; | ||
@@ -180,3 +200,2 @@ }); | ||
this._yText.format(index, length, format); | ||
// @ts-ignore | ||
this._yText.meta = { format: true }; | ||
@@ -189,3 +208,2 @@ }); | ||
this._yText.delete(index, length); | ||
// @ts-ignore | ||
this._yText.meta = { delete: true }; | ||
@@ -195,6 +213,18 @@ }); | ||
replace( | ||
index: number, | ||
length: number, | ||
content: string, | ||
attributes?: Record<string, unknown> | ||
) { | ||
this._transact(() => { | ||
this._yText.delete(index, length); | ||
this._yText.insert(index, content, attributes); | ||
this._yText.meta = { replace: true }; | ||
}); | ||
} | ||
clear() { | ||
this._transact(() => { | ||
this._yText.delete(0, this._yText.length); | ||
// @ts-ignore | ||
this._yText.meta = { clear: true }; | ||
@@ -206,3 +236,3 @@ }); | ||
this._transact(() => { | ||
this._yText.applyDelta(delta); | ||
this._yText?.applyDelta(delta); | ||
}); | ||
@@ -260,3 +290,3 @@ } | ||
export class RichTextAdapter { | ||
readonly store: Store; | ||
readonly space: Space; | ||
readonly doc: Y.Doc; | ||
@@ -269,9 +299,9 @@ readonly yText: Y.Text; | ||
constructor(store: Store, yText: Y.Text, quill: Quill) { | ||
this.store = store; | ||
constructor(space: Space, yText: Y.Text, quill: Quill) { | ||
this.space = space; | ||
this.yText = yText; | ||
this.doc = store.doc; | ||
this.doc = space.doc; | ||
this.quill = quill; | ||
this.awareness = store.awareness; | ||
this.awareness = space.awareness; | ||
const quillCursors = quill.getModule('cursors') || null; | ||
@@ -293,7 +323,8 @@ this.quillCursors = quillCursors; | ||
const isFromRemote = !isFromLocal; | ||
// @ts-ignore | ||
const isControlledOperation = !!event.target?.meta; | ||
// update quill if the change is from remote or using controlled operation | ||
if (isFromRemote || isControlledOperation) { | ||
const quillMustApplyUpdate = isFromRemote || isControlledOperation; | ||
if (quillMustApplyUpdate) { | ||
const eventDelta = event.delta; | ||
@@ -332,5 +363,5 @@ // We always explicitly set attributes, otherwise concurrent edits may | ||
private _quillObserver = ( | ||
_eventType: string, | ||
eventType: string, | ||
delta: any, | ||
_state: any, | ||
state: any, | ||
origin: any | ||
@@ -353,3 +384,3 @@ ) => { | ||
if (origin === 'user') { | ||
this.store.transact(() => { | ||
this.space.transact(() => { | ||
yText.applyDelta(ops); | ||
@@ -356,0 +387,0 @@ }); |
@@ -1,2 +0,3 @@ | ||
import type { PrefixedBlockProps } from '../store'; | ||
import { AbstractType, Doc, Map, Text, Array } from 'yjs'; | ||
import type { PrefixedBlockProps } from '../workspace/page'; | ||
@@ -15,6 +16,10 @@ type DocRecord = { | ||
type: string; | ||
props?: Record<string, unknown>; | ||
props: { 'prop:text'?: string | JSXElement } & Record<string, unknown>; | ||
children?: null | (JSXElement | string | number)[]; | ||
} | ||
// Ad-hoc for `ReactTestComponent` identify. | ||
// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L26-L29 | ||
const testSymbol = Symbol.for('react.test.json'); | ||
const isValidRecord = (data: unknown): data is DocRecord => { | ||
@@ -30,10 +35,10 @@ if (typeof data !== 'object' || data === null) { | ||
export const blockRecordToJSXNode = ( | ||
docRecord: Record<string, unknown>, | ||
export const yDocToJSXNode = ( | ||
serializedDoc: Record<string, unknown>, | ||
nodeId: string | ||
): JSXElement => { | ||
if (!isValidRecord(docRecord)) { | ||
if (!isValidRecord(serializedDoc)) { | ||
throw new Error('Failed to parse doc record! Invalid data.'); | ||
} | ||
const node = docRecord[nodeId]; | ||
const node = serializedDoc[nodeId]; | ||
if (!node) { | ||
@@ -52,10 +57,80 @@ throw new Error( | ||
if ('prop:text' in props) { | ||
props['prop:text'] = parseDelta(props['prop:text'] as DeltaText); | ||
} | ||
return { | ||
// Ad-hoc for `ReactTestComponent` identify. | ||
// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L26-L29 | ||
$$typeof: Symbol.for('react.test.json'), | ||
$$typeof: testSymbol, | ||
type: flavour, | ||
props, | ||
children: children?.map(id => blockRecordToJSXNode(docRecord, id)) ?? [], | ||
children: children?.map(id => yDocToJSXNode(serializedDoc, id)) ?? [], | ||
}; | ||
}; | ||
export const serializeYDoc = (doc: Doc) => { | ||
const json: Record<string, unknown> = {}; | ||
doc.share.forEach((value, key) => { | ||
if (value instanceof Map) { | ||
json[key] = serializeYMap(value); | ||
} else { | ||
json[key] = value.toJSON(); | ||
} | ||
}); | ||
return json; | ||
}; | ||
const serializeYMap = (map: Map<unknown>): unknown => { | ||
const json: Record<string, unknown> = {}; | ||
map.forEach((value, key) => { | ||
if (value instanceof Map) { | ||
json[key] = serializeYMap(value); | ||
} else if (value instanceof Text) { | ||
json[key] = serializeYText(value); | ||
} else if (value instanceof Array) { | ||
json[key] = value.toJSON(); | ||
} else if (value instanceof AbstractType) { | ||
json[key] = value.toJSON(); | ||
} else { | ||
json[key] = value; | ||
} | ||
}); | ||
return json; | ||
}; | ||
type DeltaText = { | ||
insert: string; | ||
attributes?: { [format: string]: unknown }; | ||
}[]; | ||
const serializeYText = (text: Text): DeltaText => { | ||
const delta = text.toDelta(); | ||
return delta; | ||
}; | ||
const parseDelta = (text: DeltaText) => { | ||
if (!text.length) { | ||
return undefined; | ||
} | ||
if (text.length === 1 && !text[0].attributes) { | ||
// just plain text | ||
return text[0].insert; | ||
} | ||
return { | ||
// The `Symbol.for('react.fragment')` will render as `<React.Fragment>` | ||
// so we use a empty string to render it as `<>`. | ||
// But it will empty children ad `< />` | ||
// so we return `undefined` directly if not delta text. | ||
$$typeof: testSymbol, // Symbol.for('react.element'), | ||
type: '', // Symbol.for('react.fragment'), | ||
props: {}, | ||
children: text?.map(({ insert, attributes }) => ({ | ||
$$typeof: testSymbol, | ||
type: 'text', | ||
props: { | ||
// Not place at `children` to avoid the trailing whitespace be trim by formatter. | ||
insert, | ||
...attributes, | ||
}, | ||
})), | ||
}; | ||
}; |
import * as Y from 'yjs'; | ||
import type { BlockProps, PrefixedBlockProps, YBlock, YBlocks } from '../store'; | ||
import type { BaseBlockModel } from '../base'; | ||
import type { | ||
BlockProps, | ||
PrefixedBlockProps, | ||
YBlock, | ||
YBlocks, | ||
} from '../workspace/page'; | ||
import { PrelimText, Text, TextType } from '../text-adapter'; | ||
@@ -14,2 +20,18 @@ | ||
export function assertExists<T>(val: T | null | undefined): asserts val is T { | ||
if (val === null || val === undefined) { | ||
throw new Error('val does not exist'); | ||
} | ||
} | ||
export function assertFlavours(model: BaseBlockModel, allowed: string[]) { | ||
if (!allowed.includes(model.flavour)) { | ||
throw new Error(`model flavour ${model.flavour} is not allowed`); | ||
} | ||
} | ||
export function matchFlavours(model: BaseBlockModel, expected: string[]) { | ||
return expected.includes(model.flavour); | ||
} | ||
export function assertValidChildren( | ||
@@ -62,3 +84,3 @@ yBlocks: YBlocks, | ||
if ( | ||
props.flavour === 'paragraph' && | ||
props.flavour === 'affine:paragraph' && | ||
!props.type && | ||
@@ -69,11 +91,32 @@ !yBlock.has('prop:type') | ||
} | ||
if (props.flavour === 'list' && !yBlock.has('prop:type')) { | ||
if (props.flavour === 'affine:list' && !yBlock.has('prop:type')) { | ||
yBlock.set('prop:type', props.type ?? 'bulleted'); | ||
} | ||
if (props.flavour === 'list' && !yBlock.has('prop:checked')) { | ||
if (props.flavour === 'affine:list' && !yBlock.has('prop:checked')) { | ||
yBlock.set('prop:checked', props.checked ?? false); | ||
} | ||
if (props.flavour === 'group' && !yBlock.has('prop:xywh')) { | ||
if (props.flavour === 'affine:group' && !yBlock.has('prop:xywh')) { | ||
yBlock.set('prop:xywh', props.xywh ?? '[0,0,720,480]'); | ||
} | ||
if (props.flavour === 'affine:embed' && !yBlock.has('prop:width')) { | ||
yBlock.set('prop:width', props.width ?? 20); | ||
} | ||
if (props.flavour === 'affine:embed' && !yBlock.has('prop:sourceId')) { | ||
yBlock.set('prop:sourceId', props.sourceId ?? ''); | ||
} | ||
if (props.flavour === 'affine:embed' && !yBlock.has('prop:caption')) { | ||
yBlock.set('prop:caption', props.caption ?? ''); | ||
} | ||
if (props.flavour === 'affine:shape') { | ||
if (!yBlock.has('prop:xywh')) { | ||
yBlock.set('prop:xywh', props.xywh ?? '[0,0,50,50]'); | ||
} | ||
if (!yBlock.has('prop:type')) { | ||
yBlock.set('prop:type', props.type ?? 'rectangle'); | ||
} | ||
if (!yBlock.has('prop:color')) { | ||
yBlock.set('prop:color', props.color ?? 'black'); | ||
} | ||
} | ||
} | ||
@@ -80,0 +123,0 @@ |
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
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
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
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
331424
141
6354
11
4
2
+ Addedbuffer@^6.0.3
+ Addedflexsearch@0.7.21
+ Addedidb-keyval@^6.2.0
+ Addedky@^0.32.2
+ Addedsha3@^2.1.4
+ Addedy-indexeddb@^9.0.9
+ Addedy-websocket@^1.4.5
+ Addedabstract-leveldown@6.2.3(transitive)
+ Addedasync-limiter@1.0.1(transitive)
+ Addedbuffer@5.7.1(transitive)
+ Addeddeferred-leveldown@5.3.0(transitive)
+ Addedencoding-down@6.3.0(transitive)
+ Addederrno@0.1.8(transitive)
+ Addedflexsearch@0.7.21(transitive)
+ Addedidb-keyval@6.2.1(transitive)
+ Addedimmediate@3.3.0(transitive)
+ Addedky@0.32.2(transitive)
+ Addedlevel@6.0.1(transitive)
+ Addedlevel-codec@9.0.2(transitive)
+ Addedlevel-concat-iterator@2.0.1(transitive)
+ Addedlevel-errors@2.0.1(transitive)
+ Addedlevel-iterator-stream@4.0.2(transitive)
+ Addedlevel-js@5.0.2(transitive)
+ Addedlevel-packager@5.1.1(transitive)
+ Addedlevel-supports@1.0.1(transitive)
+ Addedleveldown@5.6.0(transitive)
+ Addedlevelup@4.4.0(transitive)
+ Addedlodash.debounce@4.0.8(transitive)
+ Addedltgt@2.2.1(transitive)
+ Addednapi-macros@2.0.0(transitive)
+ Addednode-gyp-build@4.1.1(transitive)
+ Addedprr@1.0.1(transitive)
+ Addedsha3@2.1.4(transitive)
+ Addedws@6.2.3(transitive)
+ Addedxtend@4.0.2(transitive)
+ Addedy-indexeddb@9.0.12(transitive)
+ Addedy-leveldb@0.1.2(transitive)
+ Addedy-websocket@1.5.4(transitive)