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

@blocksuite/store

Package Overview
Dependencies
Maintainers
5
Versions
1277
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@blocksuite/store - npm Package Compare versions

Comparing version 0.2.24 to 0.3.0-20221218000328-e1b2a90

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

42

CHANGELOG.md
# @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 =

@@ -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

SocketSocket SOC 2 Logo

Product

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

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc