diff --git a/blocksuite/docs/api/@blocksuite/store/README.md b/blocksuite/docs/api/@blocksuite/store/README.md index 8c2e86d0bd..4ceae56619 100644 --- a/blocksuite/docs/api/@blocksuite/store/README.md +++ b/blocksuite/docs/api/@blocksuite/store/README.md @@ -11,6 +11,11 @@ - [Extension](classes/Extension.md) - [StoreExtension](classes/StoreExtension.md) +## Reactive + +- [Boxed](classes/Boxed.md) +- [Text](classes/Text.md) + ## Store - [Store](classes/Store.md) diff --git a/blocksuite/docs/api/@blocksuite/store/classes/Boxed.md b/blocksuite/docs/api/@blocksuite/store/classes/Boxed.md new file mode 100644 index 0000000000..c60c56f98f --- /dev/null +++ b/blocksuite/docs/api/@blocksuite/store/classes/Boxed.md @@ -0,0 +1,139 @@ +[**@blocksuite/store**](../../../@blocksuite/store/README.md) + +*** + +[BlockSuite API Documentation](../../../README.md) / [@blocksuite/store](../README.md) / Boxed + +# Class: Boxed\ + +Boxed is to store raw data in Yjs. +By default, store will try to convert a object to a Y.Map. +If you want to store a raw object for you want to manipulate the Y.Map directly, you can use Boxed. + +> [!NOTE] +> Please notice that the data will be stored in Y.Map anyway so it can not hold data structure like function. + +## Example + +```ts +const boxedObject = new Boxed({ a: 1, b: 2 }); +const boxedYMap = new Boxed(new Y.Map()); +boxedObject.getValue().a; // 1 +boxedYMap.getValue().set('a', 1); +boxedObject.setValue({ foo: 'bar' }); +``` + +## Type Param + +The type of the value stored in the Boxed. + +## Type Parameters + +### Value + +`Value` = `unknown` + +## Methods + +### getValue() + +> **getValue**(): `undefined` \| `Value` + +Get the current value of the Boxed. + +#### Returns + +`undefined` \| `Value` + +*** + +### setValue() + +> **setValue**(`value`): `Value` + +Replace the current value of the Boxed. + +#### Parameters + +##### value + +`Value` + +The new value to set. + +#### Returns + +`Value` + +*** + +### from() + +> `static` **from**\<`Value`\>(`map`): `Boxed`\<`Value`\> + +Create a Boxed from a Y.Map. +It is useful when you sync a Y.Map from remote. + +#### Type Parameters + +##### Value + +`Value` + +The type of the value. + +#### Parameters + +##### map + +`YMap`\<`unknown`\> + +#### Returns + +`Boxed`\<`Value`\> + +#### Example + +```ts +const doc1 = new Y.Doc(); +const doc2 = new Y.Doc(); +keepSynced(doc1, doc2); + +const data1 = doc1.getMap('data'); +const boxed1 = new Boxed({ a: 1, b: 2 }); +data1.set('boxed', boxed1.yMap); + +const data2 = doc2.getMap('data'); +const boxed2 = Boxed.from<{ a: number; b: number }>(data2.get('boxed')); +``` + +*** + +### is() + +> `static` **is**(`value`): `value is Boxed` + +Check if a value is a Boxed. + +#### Parameters + +##### value + +`unknown` + +#### Returns + +`value is Boxed` + +#### Example + +```ts +const doc = new Y.Doc(); + +const data = doc.getMap('data'); +const boxed = new Boxed({ a: 1, b: 2 }); +Boxed.is(boxed); // true + +data.set('boxed', boxed.yMap); +Boxed.is(data.get('boxed)); // true +``` diff --git a/blocksuite/docs/api/@blocksuite/store/classes/Text.md b/blocksuite/docs/api/@blocksuite/store/classes/Text.md new file mode 100644 index 0000000000..666e09fd81 --- /dev/null +++ b/blocksuite/docs/api/@blocksuite/store/classes/Text.md @@ -0,0 +1,392 @@ +[**@blocksuite/store**](../../../@blocksuite/store/README.md) + +*** + +[BlockSuite API Documentation](../../../README.md) / [@blocksuite/store](../README.md) / Text + +# Class: Text + +Text is an abstraction of Y.Text. +It provides useful methods to manipulate the text content. + +## Example + +```ts +const text = new Text('Hello, world!'); +text.insert(' blocksuite', 7); +text.delete(7, 1); +text.format(7, 1, { bold: true }); +text.join(new Text(' blocksuite')); +text.split(7, 1); +``` + +Text [delta](https://docs.yjs.dev/api/delta-format) is a format from Y.js. + +## Constructors + +### new Text() + +> **new Text**(`input`?): `Text` + +#### Parameters + +##### input? + +The input can be a string, a Y.Text instance, or an array of DeltaInsert. + +`string` | `YText` | `DeltaInsert`[] + +#### Returns + +`Text` + +## Accessors + +### deltas$ + +#### Get Signature + +> **get** **deltas$**(): `Signal`\<`DeltaOperation`[]\> + +Get the text delta as a signal. + +##### Returns + +`Signal`\<`DeltaOperation`[]\> + +## Methods + +### applyDelta() + +> **applyDelta**(`delta`): `void` + +Apply a delta to the text. + +#### Parameters + +##### delta + +`DeltaOperation`[] + +The delta to apply. + +#### Returns + +`void` + +#### Example + +```ts +const text = new Text('Hello, world!'); +text.applyDelta([{insert: ' blocksuite', attributes: { bold: true }}]); +``` + +*** + +### clear() + +> **clear**(): `void` + +Clear the text content. + +#### Returns + +`void` + +*** + +### clone() + +> **clone**(): `Text` + +Clone the text to a new Text instance. + +#### Returns + +`Text` + +A new Text instance. + +*** + +### delete() + +> **delete**(`index`, `length`): `void` + +Delete the text content. + +#### Parameters + +##### index + +`number` + +The index to delete. + +##### length + +`number` + +The length to delete. + +#### Returns + +`void` + +*** + +### format() + +> **format**(`index`, `length`, `format`): `void` + +Format the text content. + +#### Parameters + +##### index + +`number` + +The index to format. + +##### length + +`number` + +The length to format. + +##### format + +`Record`\<`string`, `unknown`\> + +The format to apply. + +#### Returns + +`void` + +#### Example + +```ts +const text = new Text('Hello, world!'); +text.format(7, 1, { bold: true }); +``` + +*** + +### insert() + +> **insert**(`content`, `index`, `attributes`?): `void` + +Insert content at the specified index. + +#### Parameters + +##### content + +`string` + +The content to insert. + +##### index + +`number` + +The index to insert. + +##### attributes? + +`Record`\<`string`, `unknown`\> + +#### Returns + +`void` + +#### Example + +```ts +const text = new Text('Hello, world!'); +text.insert(' blocksuite', 7); +``` + +*** + +### join() + +> **join**(`other`): `void` + +Join current text with another text. + +#### Parameters + +##### other + +`Text` + +The other text to join. + +#### Returns + +`void` + +#### Example + +```ts +const text = new Text('Hello, world!'); +const other = new Text(' blocksuite'); +text.join(other); +``` + +*** + +### replace() + +> **replace**(`index`, `length`, `content`, `attributes`?): `void` + +Replace the text content with a new content. + +#### Parameters + +##### index + +`number` + +The index to replace. + +##### length + +`number` + +The length to replace. + +##### content + +`string` + +The content to replace. + +##### attributes? + +`Record`\<`string`, `unknown`\> + +The attributes to replace. + +#### Returns + +`void` + +#### Example + +```ts +const text = new Text('Hello, world!'); +text.replace(7, 1, ' blocksuite'); +``` + +*** + +### sliceToDelta() + +> **sliceToDelta**(`begin`, `end`?): `DeltaOperation`[] + +Slice the text to a delta. + +#### Parameters + +##### begin + +`number` + +The begin index. + +##### end? + +`number` + +The end index. + +#### Returns + +`DeltaOperation`[] + +The delta of the sliced text. + +*** + +### split() + +> **split**(`index`, `length`): `Text` + +Split the text into another Text. + +#### Parameters + +##### index + +`number` + +The index to split. + +##### length + +`number` = `0` + +The length to split. + +#### Returns + +`Text` + +The right part of the text. + +#### Example + +```ts +const text = new Text('Hello, world!'); +text.split(7, 1); +``` + +NOTE: The string included in [index, index + length) will be deleted. + +Here are three cases for point position(index + length): + +``` +[{insert: 'abc', ...}, {insert: 'def', ...}, {insert: 'ghi', ...}] +1. abc|de|fghi + left: [{insert: 'abc', ...}] + right: [{insert: 'f', ...}, {insert: 'ghi', ...}] +2. abc|def|ghi + left: [{insert: 'abc', ...}] + right: [{insert: 'ghi', ...}] +3. abc|defg|hi + left: [{insert: 'abc', ...}] + right: [{insert: 'hi', ...}] +``` + +*** + +### toDelta() + +> **toDelta**(): `DeltaOperation`[] + +Get the text delta. + +#### Returns + +`DeltaOperation`[] + +The delta of the text. + +*** + +### toString() + +> **toString**(): `string` + +Get the text content as a string. +In most cases, you should not use this method. It will lose the delta attributes information. + +#### Returns + +`string` + +The text content. diff --git a/blocksuite/docs/api/@blocksuite/store/interfaces/StoreSlots.md b/blocksuite/docs/api/@blocksuite/store/interfaces/StoreSlots.md index 8b869a5bce..46c8c16a8f 100644 --- a/blocksuite/docs/api/@blocksuite/store/interfaces/StoreSlots.md +++ b/blocksuite/docs/api/@blocksuite/store/interfaces/StoreSlots.md @@ -23,6 +23,13 @@ You can also use rxjs operators to handle the events. > **blockUpdated**: `Subject`\<`StoreBlockUpdatedPayloads`\> +This fires when a block is updated via API call or has just been updated from existing ydoc. + +The payload can have three types: +- add: When a new block is added +- delete: When a block is removed +- update: When a block's properties are modified + *** ### historyUpdated diff --git a/blocksuite/framework/store/src/model/store/store.ts b/blocksuite/framework/store/src/model/store/store.ts index 22ce5261e5..8045723d9d 100644 --- a/blocksuite/framework/store/src/model/store/store.ts +++ b/blocksuite/framework/store/src/model/store/store.ts @@ -151,8 +151,6 @@ export type StoreSlots = Doc['slots'] & { */ rootDeleted: Subject; /** - * - * @summary * This fires when a block is updated via API call or has just been updated from existing ydoc. * * The payload can have three types: diff --git a/blocksuite/framework/store/src/reactive/boxed.ts b/blocksuite/framework/store/src/reactive/boxed.ts index 11f56a8733..9f0adf875b 100644 --- a/blocksuite/framework/store/src/reactive/boxed.ts +++ b/blocksuite/framework/store/src/reactive/boxed.ts @@ -4,35 +4,107 @@ import { NATIVE_UNIQ_IDENTIFIER } from '../consts.js'; export type OnBoxedChange = (data: unknown, isLocal: boolean) => void; -export class Boxed { - static from = (map: Y.Map, onChange?: OnBoxedChange): Boxed => { - return new Boxed(map.get('value') as T, onChange); +/** + * Boxed is to store raw data in Yjs. + * By default, store will try to convert a object to a Y.Map. + * If you want to store a raw object for you want to manipulate the Y.Map directly, you can use Boxed. + * + * > [!NOTE] + * > Please notice that the data will be stored in Y.Map anyway so it can not hold data structure like function. + * + * @example + * ```ts + * const boxedObject = new Boxed({ a: 1, b: 2 }); + * const boxedYMap = new Boxed(new Y.Map()); + * boxedObject.getValue().a; // 1 + * boxedYMap.getValue().set('a', 1); + * boxedObject.setValue({ foo: 'bar' }); + * ``` + * + * @typeParam T - The type of the value stored in the Boxed. + * + * @category Reactive + */ +export class Boxed { + /** + * Create a Boxed from a Y.Map. + * It is useful when you sync a Y.Map from remote. + * + * @typeParam Value - The type of the value. + * + * @example + * ```ts + * const doc1 = new Y.Doc(); + * const doc2 = new Y.Doc(); + * keepSynced(doc1, doc2); + * + * const data1 = doc1.getMap('data'); + * const boxed1 = new Boxed({ a: 1, b: 2 }); + * data1.set('boxed', boxed1.yMap); + * + * const data2 = doc2.getMap('data'); + * const boxed2 = Boxed.from<{ a: number; b: number }>(data2.get('boxed')); + * ``` + */ + static from = ( + map: Y.Map, + /** @internal */ + onChange?: OnBoxedChange + ): Boxed => { + const boxed = new Boxed(map.get('value') as Value); + if (onChange) { + boxed.bind(onChange); + } + return boxed; }; + /** + * Check if a value is a Boxed. + * + * @example + * ```ts + * const doc = new Y.Doc(); + * + * const data = doc.getMap('data'); + * const boxed = new Boxed({ a: 1, b: 2 }); + * Boxed.is(boxed); // true + * + * data.set('boxed', boxed.yMap); + * Boxed.is(data.get('boxed)); // true + * ``` + */ static is = (value: unknown): value is Boxed => { return ( value instanceof Y.Map && value.get('type') === NATIVE_UNIQ_IDENTIFIER ); }; - private readonly _map: Y.Map; + private readonly _map: Y.Map; private _onChange?: OnBoxedChange; + /** + * Get the current value of the Boxed. + */ getValue = () => { return this._map.get('value'); }; - setValue = (value: T) => { + /** + * Replace the current value of the Boxed. + * + * @param value - The new value to set. + */ + setValue = (value: Value) => { return this._map.set('value', value); }; + /** @internal */ get yMap() { return this._map; } - constructor(value: T, onChange?: OnBoxedChange) { - this._onChange = onChange; + constructor(value: Value) { if ( value instanceof Y.Map && value.doc && @@ -41,7 +113,7 @@ export class Boxed { this._map = value; } else { this._map = new Y.Map(); - this._map.set('type', NATIVE_UNIQ_IDENTIFIER as T); + this._map.set('type', NATIVE_UNIQ_IDENTIFIER as Value); this._map.set('value', value); } this._map.observeDeep(events => { @@ -58,6 +130,7 @@ export class Boxed { }); } + /** @internal */ bind(onChange: OnBoxedChange) { this._onChange = onChange; } diff --git a/blocksuite/framework/store/src/reactive/text.ts b/blocksuite/framework/store/src/reactive/text.ts index 2bf3300452..1e49f14d21 100644 --- a/blocksuite/framework/store/src/reactive/text.ts +++ b/blocksuite/framework/store/src/reactive/text.ts @@ -1,5 +1,5 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/inline'; +import type { DeltaInsert } from '@blocksuite/inline'; import { type Signal, signal } from '@preact/signals-core'; import * as Y from 'yjs'; @@ -15,6 +15,24 @@ export type DeltaOperation = { export type OnTextChange = (data: Y.Text, isLocal: boolean) => void; +/** + * Text is an abstraction of Y.Text. + * It provides useful methods to manipulate the text content. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * text.insert(' blocksuite', 7); + * text.delete(7, 1); + * text.format(7, 1, { bold: true }); + * text.join(new Text(' blocksuite')); + * text.split(7, 1); + * ``` + * + * Text {@link https://docs.yjs.dev/api/delta-format delta} is a format from Y.js. + * + * @category Reactive + */ export class Text { private readonly _deltas$: Signal; @@ -24,6 +42,9 @@ export class Text { private readonly _yText: Y.Text; + /** + * Get the text delta as a signal. + */ get deltas$() { return this._deltas$; } @@ -36,11 +57,10 @@ export class Text { return this._yText; } - constructor( - input?: Y.Text | string | DeltaInsert[], - onChange?: OnTextChange - ) { - this._onChange = onChange; + /** + * @param input - The input can be a string, a Y.Text instance, or an array of DeltaInsert. + */ + constructor(input?: Y.Text | string | DeltaInsert[]) { let length = 0; if (typeof input === 'string') { const text = input.replaceAll('\r\n', '\n'); @@ -94,16 +114,33 @@ export class Text { }, doc.clientID); } + /** + * Apply a delta to the text. + * + * @param delta - The delta to apply. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * text.applyDelta([{insert: ' blocksuite', attributes: { bold: true }}]); + * ``` + */ applyDelta(delta: DeltaOperation[]) { this._transact(() => { this._yText?.applyDelta(delta); }); } + /** + * @internal + */ bind(onChange?: OnTextChange) { this._onChange = onChange; } + /** + * Clear the text content. + */ clear() { if (!this._yText.length) { return; @@ -113,10 +150,23 @@ export class Text { }); } + /** + * Clone the text to a new Text instance. + * + * @returns A new Text instance. + */ clone() { - return new Text(this._yText.clone(), this._onChange); + const text = new Text(this._yText.clone()); + text.bind(this._onChange); + return text; } + /** + * Delete the text content. + * + * @param index - The index to delete. + * @param length - The length to delete. + */ delete(index: number, length: number) { if (length === 0) { return; @@ -137,7 +187,20 @@ export class Text { }); } - format(index: number, length: number, format: any) { + /** + * Format the text content. + * + * @param index - The index to format. + * @param length - The length to format. + * @param format - The format to apply. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * text.format(7, 1, { bold: true }); + * ``` + */ + format(index: number, length: number, format: Record) { if (length === 0) { return; } @@ -157,6 +220,18 @@ export class Text { }); } + /** + * Insert content at the specified index. + * + * @param content - The content to insert. + * @param index - The index to insert. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * text.insert(' blocksuite', 7); + * ``` + */ insert(content: string, index: number, attributes?: Record) { if (!content.length) { return; @@ -177,6 +252,18 @@ export class Text { }); } + /** + * Join current text with another text. + * + * @param other - The other text to join. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * const other = new Text(' blocksuite'); + * text.join(other); + * ``` + */ join(other: Text) { if (!other || !other.toDelta().length) { return; @@ -189,11 +276,25 @@ export class Text { }); } + /** + * Replace the text content with a new content. + * + * @param index - The index to replace. + * @param length - The length to replace. + * @param content - The content to replace. + * @param attributes - The attributes to replace. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * text.replace(7, 1, ' blocksuite'); + * ``` + */ replace( index: number, length: number, content: string, - attributes?: BaseTextAttributes + attributes?: Record ) { if (index < 0 || length < 0 || index + length > this._yText.length) { throw new BlockSuiteError( @@ -213,6 +314,14 @@ export class Text { }); } + /** + * Slice the text to a delta. + * + * @param begin - The begin index. + * @param end - The end index. + * + * @returns The delta of the sliced text. + */ sliceToDelta(begin: number, end?: number): DeltaOperation[] { const result: DeltaOperation[] = []; if (end && begin >= end) { @@ -269,9 +378,24 @@ export class Text { } /** + * Split the text into another Text. + * + * @param index - The index to split. + * @param length - The length to split. + * + * @returns The right part of the text. + * + * @example + * ```ts + * const text = new Text('Hello, world!'); + * text.split(7, 1); + * ``` + * * NOTE: The string included in [index, index + length) will be deleted. * * Here are three cases for point position(index + length): + * + * ``` * [{insert: 'abc', ...}, {insert: 'def', ...}, {insert: 'ghi', ...}] * 1. abc|de|fghi * left: [{insert: 'abc', ...}] @@ -282,6 +406,7 @@ export class Text { * 3. abc|defg|hi * left: [{insert: 'abc', ...}] * right: [{insert: 'hi', ...}] + * ``` */ split(index: number, length = 0): Text { if (index < 0 || length < 0 || index + length > this._yText.length) { @@ -328,15 +453,27 @@ export class Text { this.delete(index, this.length - index); const rightYText = new Y.Text(); rightYText.applyDelta(rightDeltas); - const rightText = new Text(rightYText, this._onChange); + const rightText = new Text(rightYText); + rightText.bind(this._onChange); return rightText; } + /** + * Get the text delta. + * + * @returns The delta of the text. + */ toDelta(): DeltaOperation[] { return this._yText?.toDelta() || []; } + /** + * Get the text content as a string. + * In most cases, you should not use this method. It will lose the delta attributes information. + * + * @returns The text content. + */ toString() { return this._yText?.toString() || ''; }