From 10eceaf9b18b86b88adf9567f877a5cfdc913e91 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 6 Dec 2023 13:12:45 +0800 Subject: [PATCH] Feat/loro-2 (#99) * feat: handle redo/undo events * subscribe loro events * feat: submit changeset by loro * feat: loro map * feat: remove useless code * feat: add loro block * feat: boardcast message * feat: flush all data to snapshot * feat: integrate new version loro-wasm * feat: add idb dao * feat: introduce dao * fix: margin of menu * feat: add toolbar menu items * refactor: separate bindings * fix: delete cursor error --- packages/blocky-core/src/data/change.ts | 1 + packages/blocky-core/src/data/events.ts | 3 + packages/blocky-core/src/data/state.ts | 41 ++- packages/blocky-core/src/data/tree.ts | 32 +- packages/blocky-core/src/helper/idHelper.ts | 5 +- packages/blocky-core/src/view/controller.ts | 19 +- packages/blocky-example/app/loro/idbDao.ts | 88 +++++ .../blocky-example/app/loro/loroBinding.tsx | 300 ++++++++++++++++++ .../app/loro/loroBlock.module.scss | 28 ++ .../blocky-example/app/loro/loroBlock.tsx | 54 ++++ .../blocky-example/app/loro/loroExample.tsx | 218 +++++++++---- .../blocky-example/app/loro/loroPlugin.ts | 221 ------------- .../blocky-example/app/loro/loroPlugin.tsx | 131 ++++++++ packages/blocky-example/package.json | 5 +- packages/blocky-example/public/LORO.svg | 54 ++++ .../src/components/dropdown/dropdown.tsx | 4 + .../defaultSpannerMenu/defaultSpannerMenu.tsx | 109 +++++-- .../src/defaultSpannerMenu/index.ts | 4 +- packages/blocky-react/src/index.ts | 1 + packages/blocky-react/src/spanner.tsx | 9 +- pnpm-lock.yaml | 296 ++++++++++++++++- 21 files changed, 1260 insertions(+), 363 deletions(-) create mode 100644 packages/blocky-example/app/loro/idbDao.ts create mode 100644 packages/blocky-example/app/loro/loroBinding.tsx create mode 100644 packages/blocky-example/app/loro/loroBlock.module.scss create mode 100644 packages/blocky-example/app/loro/loroBlock.tsx delete mode 100644 packages/blocky-example/app/loro/loroPlugin.ts create mode 100644 packages/blocky-example/app/loro/loroPlugin.tsx create mode 100644 packages/blocky-example/public/LORO.svg diff --git a/packages/blocky-core/src/data/change.ts b/packages/blocky-core/src/data/change.ts index 630b5ee4..cb65b2fb 100644 --- a/packages/blocky-core/src/data/change.ts +++ b/packages/blocky-core/src/data/change.ts @@ -29,6 +29,7 @@ export interface ChangesetApplyOptions { ignoreCursor: boolean; record: ChangesetRecordOption; refreshCursor: boolean; + source?: string; } const defaultApplyOptions: ChangesetApplyOptions = { diff --git a/packages/blocky-core/src/data/events.ts b/packages/blocky-core/src/data/events.ts index 11da15ad..8a75919c 100644 --- a/packages/blocky-core/src/data/events.ts +++ b/packages/blocky-core/src/data/events.ts @@ -5,6 +5,7 @@ export interface ElementSetAttributeEvent { key: string; value: any; oldValue?: any; + source?: string; } export interface ElementRemoveChildEvent { @@ -12,6 +13,7 @@ export interface ElementRemoveChildEvent { parent: DataBaseNode; child: DataBaseNode; index: number; + source?: string; } export interface ElementInsertChildEvent { @@ -19,6 +21,7 @@ export interface ElementInsertChildEvent { parent: DataBaseNode; child: DataBaseNode; index: number; + source?: string; } export type ElementChangedEvent = diff --git a/packages/blocky-core/src/data/state.ts b/packages/blocky-core/src/data/state.ts index 6340ad5f..c0b7e182 100644 --- a/packages/blocky-core/src/data/state.ts +++ b/packages/blocky-core/src/data/state.ts @@ -135,23 +135,24 @@ export class State implements ChangesetStateLogger { return false; } this.beforeChangesetApply.next(changeset); + const options = changeset.options; for (const operation of changeset.operations) { switch (operation.op) { case "insert-nodes": { - this.#applyInsertOperation(operation); + this.#applyInsertOperation(operation, options); break; } case "update-attributes": { - this.#applyUpdateOperation(operation); + this.#applyUpdateOperation(operation, options); break; } case "remove-nodes": { - this.#applyRemoveOperation(operation); + this.#applyRemoveOperation(operation, options); break; } case "text-edit": { - this.#applyTextEditOperation(operation); + this.#applyTextEditOperation(operation, options); break; } } @@ -214,7 +215,10 @@ export class State implements ChangesetStateLogger { return rebasedChange.finalize(options); } - #applyInsertOperation(insertOperation: InsertNodeOperation) { + #applyInsertOperation( + insertOperation: InsertNodeOperation, + options: ChangesetApplyOptions + ) { const { location, children } = insertOperation; const parentLoc = location.slice(0, location.length - 1); let index = location.last; @@ -223,7 +227,11 @@ export class State implements ChangesetStateLogger { // TODO: optimize insert for (const child of children) { if (parent instanceof DataElement) { - parent.__insertChildAt(index++, blockyNodeFromJsonNode(child)); + parent.__insertChildAt( + index++, + blockyNodeFromJsonNode(child), + options.source + ); } } return; @@ -232,23 +240,29 @@ export class State implements ChangesetStateLogger { throw new Error(`can not insert node at: ${location.toString()}`); } - #applyUpdateOperation(updateOperation: UpdateNodeOperation) { + #applyUpdateOperation( + updateOperation: UpdateNodeOperation, + options: ChangesetApplyOptions + ) { const { location, attributes } = updateOperation; const node = this.findNodeByLocation(location) as DataBaseElement; for (const key in attributes) { const value = attributes[key]; - node.__setAttribute(key, value); + node.__setAttribute(key, value, options.source); } } - #applyRemoveOperation(removeOperation: RemoveNodeOperation) { + #applyRemoveOperation( + removeOperation: RemoveNodeOperation, + options: ChangesetApplyOptions + ) { const { location, children } = removeOperation; const parentLoc = location.slice(0, location.length - 1); const index = location.last; if (isNumber(index)) { const parent = this.findNodeByLocation(parentLoc) as DataBaseElement; if (parent instanceof DataElement) { - parent.__deleteChildrenAt(index, children.length); + parent.__deleteChildrenAt(index, children.length, options.source); } return; } @@ -256,7 +270,10 @@ export class State implements ChangesetStateLogger { throw new Error(`can not remove node at: ${location.toString()}`); } - #applyTextEditOperation(textEditOperation: TextEditOperation) { + #applyTextEditOperation( + textEditOperation: TextEditOperation, + options: ChangesetApplyOptions + ) { const { location, delta } = textEditOperation; const node = this.findNodeByLocation(location) as DataBaseElement; const textNode = node.getAttribute(textEditOperation.key) as @@ -269,7 +286,7 @@ export class State implements ChangesetStateLogger { }>, by location: ${location.toString()}` ); } - textNode.__applyDelta(delta); + textNode.__applyDelta(delta, options.source); } findNodeByLocation(location: NodeLocation): DataBaseNode { diff --git a/packages/blocky-core/src/data/tree.ts b/packages/blocky-core/src/data/tree.ts index 2d597d62..f3b4c2b7 100644 --- a/packages/blocky-core/src/data/tree.ts +++ b/packages/blocky-core/src/data/tree.ts @@ -12,6 +12,7 @@ export interface DeltaChangedEvent { oldDelta: Delta; newDelta?: Delta; apply: Delta; + source?: string; } export interface AttributesObject { @@ -96,7 +97,7 @@ export class BlockyTextModel { * If you want to modify the state of the document and * notify the editor to update, apply a changeset. */ - __applyDelta(v: Delta) { + __applyDelta(v: Delta, source?: string) { const oldDelta = this.#delta; const newDelta = oldDelta.compose(v); this.#delta = newDelta; @@ -107,6 +108,7 @@ export class BlockyTextModel { oldDelta, newDelta, apply: v, + source, }); } @@ -209,7 +211,7 @@ export class DataBaseElement implements DataBaseNode { * If you want to modify the state of the document and * notify the editor to update, apply a changeset. */ - __setAttribute(name: string, value: any) { + __setAttribute(name: string, value: any, source?: string) { if (bannedAttributesName.has(name)) { throw new Error(`'${name}' is preserved`); } @@ -230,6 +232,7 @@ export class DataBaseElement implements DataBaseNode { key: name, value, oldValue, + source, }); } @@ -391,7 +394,7 @@ export class DataElement extends DataBaseElement implements DataNode { this.childrenLength++; } - #appendChild(node: DataBaseNode) { + #appendChild(node: DataBaseNode, source?: string) { this.#validateChild(node); const insertIndex = this.childrenLength; @@ -404,10 +407,15 @@ export class DataElement extends DataBaseElement implements DataNode { parent: this, child: node, index: insertIndex, + source, }); } - protected __symInsertAfter(node: DataBaseNode, after?: DataBaseNode) { + protected __symInsertAfter( + node: DataBaseNode, + after?: DataBaseNode, + source?: string + ) { if (after && after.parent !== this) { throw new TypeError("after node is a child of this node"); } @@ -458,6 +466,7 @@ export class DataElement extends DataBaseElement implements DataNode { parent: this, child: node, index: cnt, + source, }); } @@ -466,14 +475,14 @@ export class DataElement extends DataBaseElement implements DataNode { * If you want to modify the state of the document and * notify the editor to update, apply a changeset. */ - __insertChildAt(index: number, node: DataBaseNode) { + __insertChildAt(index: number, node: DataBaseNode, source?: string) { if (index === this.childrenLength) { - this.#appendChild(node); + this.#appendChild(node, source); return; } if (index === 0) { - this.__symInsertAfter(node); + this.__symInsertAfter(node, undefined, source); return; } @@ -484,7 +493,7 @@ export class DataElement extends DataBaseElement implements DataNode { ptr = ptr.nextSibling; } - this.__symInsertAfter(node, ptr ?? undefined); + this.__symInsertAfter(node, ptr ?? undefined, source); } /** @@ -492,7 +501,7 @@ export class DataElement extends DataBaseElement implements DataNode { * If you want to modify the state of the document and * notify the editor to update, apply a changeset. */ - __deleteChildrenAt(index: number, count: number) { + __deleteChildrenAt(index: number, count: number, source?: string) { let ptr = this.#firstChild; while (index > 0) { @@ -502,14 +511,14 @@ export class DataElement extends DataBaseElement implements DataNode { while (ptr && count > 0) { const next = ptr.nextSibling; - this.#removeChild(ptr); + this.#removeChild(ptr, source); ptr = next; count--; } } - #removeChild(node: DataBaseNode) { + #removeChild(node: DataBaseNode, source?: string) { const { parent } = node; if (parent !== this) { throw new TypeError("node is not the child of this element"); @@ -549,6 +558,7 @@ export class DataElement extends DataBaseElement implements DataNode { parent: this, child: node, index: ptr, + source, }); } diff --git a/packages/blocky-core/src/helper/idHelper.ts b/packages/blocky-core/src/helper/idHelper.ts index f38effa2..ff5d98ba 100644 --- a/packages/blocky-core/src/helper/idHelper.ts +++ b/packages/blocky-core/src/helper/idHelper.ts @@ -1,4 +1,3 @@ - const a = "a".charCodeAt(0); const z = "z".charCodeAt(0); @@ -75,6 +74,7 @@ export interface IdGenerator { isBlockId: (id: string) => boolean; mkSpanId: () => string; isSpanId: (id: string) => boolean; + mkUserId: () => string; } export function makeDefaultIdGenerator(): IdGenerator { @@ -85,5 +85,6 @@ export function makeDefaultIdGenerator(): IdGenerator { isBlockId, mkSpanId, isSpanId, - } + mkUserId, + }; } diff --git a/packages/blocky-core/src/view/controller.ts b/packages/blocky-core/src/view/controller.ts index cf774b7e..8acf5885 100644 --- a/packages/blocky-core/src/view/controller.ts +++ b/packages/blocky-core/src/view/controller.ts @@ -410,14 +410,25 @@ export class EditorController { if (!blockNode) { return; } + const { nextSibling, prevSibling } = blockNode; if (!isUpperCase(blockNode.t)) { return; } - new Changeset(this.state).removeNode(blockNode).apply({ - refreshCursor: true, - }); + let nextCusorState: CursorState | null = null; + if (nextSibling instanceof BlockDataElement) { + nextCusorState = CursorState.collapse(nextSibling.id, 0); + } else if (prevSibling instanceof BlockDataElement) { + nextCusorState = CursorState.collapse(prevSibling.id, 0); + } + + new Changeset(this.state) + .removeNode(blockNode) + .setCursorState(nextCusorState) + .apply({ + refreshCursor: true, + }); } /** @@ -680,6 +691,8 @@ export class EditorController { }); return pasteHandler.call(blockDef, evt); } + + return new BlockDataElement(dataType, this.idGenerator.mkBlockId()); }; /** diff --git a/packages/blocky-example/app/loro/idbDao.ts b/packages/blocky-example/app/loro/idbDao.ts new file mode 100644 index 00000000..c8743e8a --- /dev/null +++ b/packages/blocky-example/app/loro/idbDao.ts @@ -0,0 +1,88 @@ +import { IDBPDatabase, openDB } from "idb"; + +interface SnapshotAndVersions { + snapshot?: any; + versions: any[]; +} + +export class IdbDao { + static async open(dbName: string): Promise { + const db = await openDB(dbName, 1, { + upgrade(db) { + const store = db.createObjectStore("versions", { + // The 'id' property of the object will be the key. + keyPath: "id", + // If it isn't explicitly set, create a value by auto incrementing. + autoIncrement: true, + }); + store.createIndex("loroId", "loroId"); + const snapshotStore = db.createObjectStore("snapshot", { + // The 'id' property of the object will be the key. + keyPath: "id", + // If it isn't explicitly set, create a value by auto incrementing. + autoIncrement: true, + }); + snapshotStore.createIndex("createdAt", "createdAt"); + }, + }); + + return new IdbDao(db); + } + + constructor(readonly db: IDBPDatabase) {} + + async tryReadLoroFromIdb(): Promise { + let snapshot: any; + { + const tx = this.db.transaction("snapshot", "readonly"); + + // find latest snapshot with creatAt + const snapshotCursor = await tx + .objectStore("snapshot") + .index("createdAt") + .openCursor(null, "prev"); + + snapshot = snapshotCursor?.value; + + tx.commit(); + } + + const tx = this.db.transaction("versions", "readonly"); + + const versions = await tx.objectStore("versions").index("loroId").getAll(); + + tx.commit(); + + return { + snapshot, + versions, + }; + } + + async flushFullSnapshot(userId: string, snapshot: Uint8Array) { + const tx = this.db.transaction(["snapshot", "versions"], "readwrite"); + + const snapshotStore = tx.objectStore("snapshot"); + await snapshotStore.clear(); + + await snapshotStore.add({ + data: snapshot, + userId, + createdAt: new Date(), + }); + + await tx.objectStore("versions").clear(); + + await tx.done; + } + + async wipeAllData() { + const tx = this.db.transaction(["snapshot", "versions"], "readwrite"); + + await tx.objectStore("snapshot").clear(); + + await tx.objectStore("versions").clear(); + + await tx.done; + } +} diff --git a/packages/blocky-example/app/loro/loroBinding.tsx b/packages/blocky-example/app/loro/loroBinding.tsx new file mode 100644 index 00000000..ebc9b1c7 --- /dev/null +++ b/packages/blocky-example/app/loro/loroBinding.tsx @@ -0,0 +1,300 @@ +import { + DataBaseElement, + BlockyTextModel, + DataElement, + BlockyDocument, + BlockDataElement, + EditorState, + Changeset, +} from "blocky-core"; +import { Loro, LoroMap, LoroText, LoroList } from "loro-crdt"; +import { Delta } from "blocky-core"; +import { filter } from "rxjs"; +import { isArray, isNumber, omit } from "lodash-es"; +import { isPrimitive, isUpperCase } from "./loroPlugin"; + +export class LoroBinding { + static source = "loro"; + + editorState: EditorState | null = null; + + constructor(public loro: Loro) {} + + syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { + const attribs = doc.getAttributes(); + + loroMap.set("t", doc.t); + + const entries = Object.entries(attribs); + + for (const [key, value] of entries) { + if (isPrimitive(value)) { + loroMap.set(key, value); + } else if (Array.isArray(value)) { + const arr = loroMap.setContainer(key, "List"); + for (let i = 0, len = value.length; i < len; i++) { + arr.insert(i, value[i]); + } + } else if (value instanceof DataBaseElement) { + const childLoroMap = loroMap.setContainer(key, "Map"); + this.syncDocumentToLoro(value, childLoroMap); + } else if (value instanceof BlockyTextModel) { + const loroText = loroMap.setContainer(key, "Text"); + loroText.applyDelta(value.delta.ops); + this.bindTextModelToLoroText( + doc as BlockDataElement, + key, + value, + loroText + ); + } else if (typeof value === "object") { + console.log("object", key, value); + const childLoroMap = loroMap.setContainer(key, "Map"); + for (const [childKey, childValue] of Object.entries(value)) { + childLoroMap.set(childKey, childValue); + } + } + } + + if (!(doc instanceof DataElement)) { + return; + } + + if (doc instanceof BlockDataElement) { + loroMap.set("id", doc.id); + } + + const children = loroMap.setContainer("children", "List"); + let ptr = doc.firstChild; + let counter = 0; + while (ptr) { + const subDoc = children.insertContainer(counter, "Map"); + this.syncDocumentToLoro(ptr as DataBaseElement, subDoc); + + ptr = ptr.nextSibling; + counter++; + } + + this.bindDataElementToLoroMap(doc, loroMap); + } + + bindTextModelToLoroText( + parent: BlockDataElement, + key: string, + textModel: BlockyTextModel, + loroText: LoroText + ) { + textModel.changed$ + .pipe(filter((evt) => evt.source !== LoroBinding.source)) + .subscribe((evt) => { + console.log("text model changed", evt); + loroText.applyDelta(evt.apply.ops); + }); + const id = parent.id; + loroText.subscribe(this.loro, (evt) => { + if (!(!evt.local || evt.fromCheckout)) { + return; + } + if (!this.editorState) { + return; + } + const diff = evt.diff; + if (diff.type === "text") { + const blockElement = this.editorState.getBlockElementById(id)!; + const changeset = new Changeset(this.editorState).textEdit( + blockElement, + key, + () => new Delta((evt.diff as any).diff) + ); + console.log("changeset", changeset); + changeset.apply({ + source: LoroBinding.source, + }); + } + }); + } + + bindDataElementToLoroMap(doc: DataElement, loroMap: LoroMap) { + const children = loroMap.get("children") as LoroList; + doc.changed + .pipe(filter((evt) => evt.source !== LoroBinding.source)) + .subscribe((evt) => { + switch (evt.type) { + case "element-insert-child": { + const loroChild = children.insertContainer(evt.index, "Map"); + this.syncDocumentToLoro(evt.child as DataBaseElement, loroChild); + break; + } + + case "element-remove-child": { + children.delete(evt.index, 1); + break; + } + + case "element-set-attrib": { + if (evt.value === undefined) { + loroMap.delete(evt.key); + } else { + loroMap.set(evt.key, evt.value); + } + break; + } + } + }); + loroMap.subscribe(this.loro, (evt) => { + if (evt.local && !evt.fromCheckout) { + return; + } + if (!this.editorState) { + return; + } + const diff = evt.diff; + if (diff.type === "list") { + const changeset = new Changeset(this.editorState); + let index = 0; + const insertedIds: string[] = []; + const idToLoroMap: Map = new Map(); + for (const op of diff.diff) { + if (isNumber(op.retain)) { + index += op.retain; + } else if (isArray(op.insert)) { + const children: DataElement[] = []; + for (const val of op.insert) { + if (val instanceof LoroMap) { + const blockElement = + this.blockyElementFromLoroMapWithoutBinding( + val + ) as BlockDataElement; + if (blockElement instanceof BlockDataElement) { + const id = blockElement.id; + insertedIds.push(id); + children.push(blockElement); + idToLoroMap.set(id, val); + } + } + } + try { + changeset.insertChildrenAt(doc, index, children); + } catch (err) { + console.error("insertChildrenAt error", err, op.insert); + throw err; + } + + index += op.insert.length; + } else if (isNumber(op.delete)) { + changeset.deleteChildrenAt(doc, index, op.delete); + } + } + changeset.apply({ + source: LoroBinding.source, + }); + + for (const id of insertedIds) { + const block = this.editorState.getBlockElementById(id); + if (block) { + this.bindDataElementToLoroMap(block, idToLoroMap.get(id)!); + } + } + } else if (diff.type === "map") { + const updated = omit(diff.updated, ["t", "id", "children"]) as any; + + const id = (doc as BlockDataElement).id; + + const changedTuples: { id: string; key: string; val: LoroText }[] = []; + for (const key of Object.keys(updated)) { + const value = updated[key]; + if (value instanceof LoroText) { + const textModel = new BlockyTextModel(new Delta(value.toDelta())); + updated[key] = textModel; + changedTuples.push({ + id, + key, + val: value, + }); + } + } + + new Changeset(this.editorState).updateAttributes(doc, updated).apply({ + source: LoroBinding.source, + }); + + for (const tuple of changedTuples) { + const doc = this.editorState.getBlockElementById(tuple.id); + if (!doc) { + continue; + } + const textModel = doc.getAttribute(tuple.key) as BlockyTextModel; + if (textModel instanceof BlockyTextModel) { + this.bindTextModelToLoroText(doc, tuple.key, textModel, tuple.val); + } + } + } + }); + } + + blockyElementFromLoroMapWithoutBinding(loroMap: LoroMap): DataElement { + const t = loroMap.get("t") as string; + let result: DataElement; + if (isUpperCase(t[0])) { + let id = loroMap.get("id") as string; + if (t === "Title") { + id = "title"; + } + result = new BlockDataElement(t, id); + } else { + result = new DataElement(t); + } + + for (const [key, value] of loroMap.entries()) { + if (key === "id" || key === "t" || key === "children") { + continue; + } + if (value instanceof LoroText) { + const text = new BlockyTextModel(new Delta(value.toDelta())); + result.__setAttribute(key, text); + this.bindTextModelToLoroText( + result as BlockDataElement, + key, + text, + value + ); + } else if (value instanceof LoroMap) { + result.__setAttribute(key, this.blockyElementFromLoroMap(value)); + } else { + result.__setAttribute(key, value); + } + } + + const children = loroMap.get("children") as LoroList | undefined; + if (children) { + for (let i = 0, len = children.length; i < len; i++) { + const child = children.get(i); + if (child instanceof LoroMap) { + result.appendChild(this.blockyElementFromLoroMap(child)); + } + } + } + return result; + } + + blockyElementFromLoroMap(loroMap: LoroMap): DataElement { + const result = this.blockyElementFromLoroMapWithoutBinding(loroMap); + + this.bindDataElementToLoroMap(result, loroMap); + + return result; + } + + documentFromLoroMap(loroMap: LoroMap): BlockyDocument { + const title = loroMap.get("title") as LoroMap | undefined; + const body = loroMap.get("body") as LoroMap; + const doc = new BlockyDocument({ + title: title + ? (this.blockyElementFromLoroMap(title) as BlockDataElement) + : undefined, + body: this.blockyElementFromLoroMap(body), + }); + + return doc; + } +} diff --git a/packages/blocky-example/app/loro/loroBlock.module.scss b/packages/blocky-example/app/loro/loroBlock.module.scss new file mode 100644 index 00000000..ec2eccfc --- /dev/null +++ b/packages/blocky-example/app/loro/loroBlock.module.scss @@ -0,0 +1,28 @@ + +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 240px; + position: relative; +} + +.icon { + width: 64px; + height: 64px; + margin-bottom: 16px; +} + +.buttons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.blur { + position: absolute; + filter: blur(100px); + opacity: .8; + z-index: -1; +} \ No newline at end of file diff --git a/packages/blocky-example/app/loro/loroBlock.tsx b/packages/blocky-example/app/loro/loroBlock.tsx new file mode 100644 index 00000000..a56d5d02 --- /dev/null +++ b/packages/blocky-example/app/loro/loroBlock.tsx @@ -0,0 +1,54 @@ +import styles from "./loroBlock.module.scss"; +import Button from "@mui/material/Button"; +import type LoroPlugin from "./loroPlugin"; +import { useCallback } from "react"; + +export interface LoroBlockProps { + plugin: LoroPlugin; + onWipe?: () => void; +} + +const background = ` + +`; + +function LoroBlock(props: LoroBlockProps) { + const { onWipe } = props; + const handleOpenNewTab = useCallback(() => { + // open the same url in new tab + window.open(window.location.href, "_blank"); + }, []); + return ( +
+
+ +
+
+ + + {/* */} +
+
+
+ ); +} + +export default LoroBlock; diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index fd9a00fd..17b20947 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -6,20 +6,73 @@ import { useBlockyController, DefaultToolbarMenu, makeDefaultReactSpanner, + makeReactBlock, + DefaultBlockOutline, + type MenuCommand, } from "blocky-react"; import { BlockyDocument, EditorController, IPlugin, SpannerPlugin, + TextType, + bky, } from "blocky-core"; import ImagePlaceholder from "@pkg/components/imagePlaceholder"; import { makeCommandPanelPlugin } from "@pkg/app/plugins/commandPanel"; import { makeAtPanelPlugin } from "@pkg/app/plugins/atPanel"; import LoroPlugin from "./loroPlugin"; -import { openDB, IDBPDatabase } from "idb"; import { Loro } from "loro-crdt"; +import { IdbDao } from "./idbDao"; +import LoroBlock from "./loroBlock"; import styles from "./loroExample.module.scss"; +import { + LuType, + LuHeading1, + LuHeading2, + LuHeading3, + LuImage, + LuCheckCircle2, + LuBird, +} from "react-icons/lu"; + +const loroCommands: MenuCommand[] = [ + { + title: "Text", + icon: , + insertText: TextType.Normal, + }, + { + title: "Heading1", + icon: , + insertText: TextType.Heading1, + }, + { + title: "Heading2", + icon: , + insertText: TextType.Heading2, + }, + { + title: "Heading3", + icon: , + insertText: TextType.Heading3, + }, + { + title: "Checkbox", + icon: , + insertText: TextType.Checkbox, + }, + { + title: "Image", + icon: , + insertBlock: () => bky.element(ImageBlockPlugin.Name), + }, + { + title: "Loro", + icon: , + insertBlock: () => bky.element("Loro"), + }, +]; function makeEditorPlugins(): IPlugin[] { return [ @@ -27,7 +80,9 @@ function makeEditorPlugins(): IPlugin[] { placeholder: ({ setSrc }) => , }), new SpannerPlugin({ - factory: makeDefaultReactSpanner(), + factory: makeDefaultReactSpanner({ + commands: loroCommands, + }), }), makeCommandPanelPlugin(), makeAtPanelPlugin(), @@ -52,32 +107,21 @@ function makeController( }), spellcheck: false, + collaborativeCursorFactory: (id: string) => ({ + get name() { + return id; + }, + get color() { + return "rgb(235 100 52)"; + }, + }), }); } async function tryReadLoroFromIdb( - db: IDBPDatabase + dao: IdbDao ): Promise> | undefined> { - let snapshot: any; - { - const tx = db.transaction("snapshot", "readonly"); - - // find latest snapshot with creatAt - const snapshotCursor = await tx - .objectStore("snapshot") - .index("createdAt") - .openCursor(null, "prev"); - - snapshot = snapshotCursor?.value; - - tx.commit(); - } - - const tx = db.transaction("versions", "readonly"); - - const versions = await tx.objectStore("versions").index("loroId").getAll(); - - tx.commit(); + const { snapshot, versions } = await dao.tryReadLoroFromIdb(); if (snapshot || versions.length > 0) { const loro = new Loro(); @@ -95,78 +139,114 @@ function LoroExample() { const containerRef = useRef(null); const controller = useBlockyController(async () => { - const db = await openDB("blocky-loro", 1, { - upgrade(db) { - const store = db.createObjectStore("versions", { - // The 'id' property of the object will be the key. - keyPath: "id", - // If it isn't explicitly set, create a value by auto incrementing. - autoIncrement: true, - }); - store.createIndex("loroId", "loroId"); - const snapshotStore = db.createObjectStore("snapshot", { - // The 'id' property of the object will be the key. - keyPath: "id", - // If it isn't explicitly set, create a value by auto incrementing. - autoIncrement: true, - }); - snapshotStore.createIndex("createdAt", "createdAt"); - }, - }); - - const loro = await tryReadLoroFromIdb(db); + const dao = await IdbDao.open("blocky-loro"); let changeCounter = 0; - const loroPlugin = new LoroPlugin(loro); + const tempLoro = await tryReadLoroFromIdb(dao); + const userId = bky.idGenerator.mkUserId(); + + const loroPlugin = new LoroPlugin(tempLoro); + const bc = new BroadcastChannel("test_channel"); let lastVersion: Uint8Array | undefined; loroPlugin.loro.subscribe(async (evt) => { - if (changeCounter > 20) { - const fullData = loroPlugin.loro.exportFrom(); - console.log("fullData"); - await db.add("snapshot", { - data: fullData, - createdAt: new Date(), - }); - - const tx = db.transaction("versions", "readwrite"); - // delete all versions - - await tx.objectStore("versions").clear(); - - await tx.done; - - lastVersion = undefined; - changeCounter = 0; + if (!evt.local) { return; } const versions = loroPlugin.loro.version(); const data = loroPlugin.loro.exportFrom(lastVersion); - await db.add("versions", { + + bc.postMessage({ + type: "loro", + id: evt.id, + userId, + data, + }); + + await dao.db.add("versions", { loroId: evt.id.toString(), version: versions, data, + userId, createdAt: new Date(), }); lastVersion = versions; changeCounter++; + + if (changeCounter > 20) { + const fullData = loroPlugin.loro.exportFrom(); + + await dao.flushFullSnapshot(userId, fullData); + + lastVersion = undefined; + changeCounter = 0; + return; + } }); - const initDoc = loro ? LoroPlugin.getInitDocumentByLoro(loro) : undefined; - console.log("initDoc", initDoc); + const handleWipteData = async () => { + try { + await dao.wipeAllData(); + bc.postMessage({ + type: "refresh", + }); + window.location.reload(); + } catch (err) { + console.error(err); + } + }; + + const initDoc = loroPlugin.getInitDocumentByLoro(); const controller = makeController( - "user", - [...makeEditorPlugins(), loroPlugin], + userId, + [ + ...makeEditorPlugins(), + loroPlugin, + { + name: "loro-block", + blocks: [ + makeReactBlock({ + name: "Loro", + component: () => ( + + + + ), + }), + ], + }, + ], initDoc ); - if (!loro) { - console.log("paste"); + controller.cursorChanged.subscribe((cursor) => { + bc.postMessage({ + type: "cursor", + userId, + data: cursor, + }); + }); + + bc.onmessage = (evt) => { + if (evt.data.type === "loro") { + loroPlugin.loro.import(evt.data.data); + } else if (evt.data.type === "cursor") { + controller.applyCursorChangedEvent(evt.data.data); + } else if (evt.data.type === "refresh") { + setTimeout(() => { + window.location.reload(); + }, 1000); + } + }; + + if (!initDoc) { + console.log("init doc"); controller.pasteHTMLAtCursor( `Loro is a high-performance CRDTs library. It's written in Rust and introduced to the browser via WASM, offering incredible performance. Blocky can leverage Loro's data syncing capabilities. By using a simple plugin, you can sync the data of the Blocky editor with Loro. You can edit this page, and the data will sync to the browser's storage with Loro’s encoding. -Once you reload the page, the data from the browser will be rendered again.` +Once you reload the page, the data from the browser will be rendered again. +
Loro
` ); } diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts deleted file mode 100644 index 34cda1c9..00000000 --- a/packages/blocky-example/app/loro/loroPlugin.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - type IPlugin, - PluginContext, - DataBaseElement, - BlockyTextModel, - DataElement, - BlockyDocument, - BlockDataElement, -} from "blocky-core"; -import { Loro, LoroMap, LoroText, LoroList } from "loro-crdt"; -import { Delta } from "blocky-core"; -import { takeUntil } from "rxjs"; - -function isPrimitive(value: any) { - return ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ); -} - -function syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { - const attribs = doc.getAttributes(); - - loroMap.set("t", doc.t); - - const entries = Object.entries(attribs); - - for (const [key, value] of entries) { - if (isPrimitive(value)) { - loroMap.set(key, value); - } else if (Array.isArray(value)) { - const arr = loroMap.setContainer(key, "List"); - for (let i = 0, len = value.length; i < len; i++) { - arr.insert(i, value[i]); - } - } else if (value instanceof DataBaseElement) { - const childLoroMap = loroMap.setContainer(key, "Map"); - syncDocumentToLoro(value, childLoroMap); - } else if (value instanceof BlockyTextModel) { - const loroText = loroMap.setContainer(key, "Text"); - loroText.applyDelta(value.delta.ops); - - bindTextModelToLoroText(value, loroText); - } else if (typeof value === "object") { - const childLoroMap = loroMap.setContainer(key, "Map"); - for (const [childKey, childValue] of Object.entries(value)) { - childLoroMap.set(childKey, childValue); - } - } - } - - if (!(doc instanceof DataElement)) { - return; - } - - if (doc instanceof BlockDataElement) { - loroMap.set("id", doc.id); - } - - const children = loroMap.setContainer("children", "List"); - let ptr = doc.firstChild; - let counter = 0; - while (ptr) { - const subDoc = children.insertContainer(counter, "Map"); - syncDocumentToLoro(ptr as DataBaseElement, subDoc); - - ptr = ptr.nextSibling; - counter++; - } - - bindDataElementToLoroMap(doc, loroMap); -} - -function bindTextModelToLoroText( - textModel: BlockyTextModel, - loroText: LoroText -) { - textModel.changed$.subscribe((evt) => { - loroText.applyDelta(evt.apply.ops); - }); -} - -function bindDataElementToLoroMap(doc: DataElement, loroMap: LoroMap) { - const children = loroMap.get("children") as LoroList; - doc.changed.subscribe((evt) => { - switch (evt.type) { - case "element-insert-child": { - const loroChild = children.insertContainer(evt.index, "Map"); - syncDocumentToLoro(evt.child as DataBaseElement, loroChild); - break; - } - - case "element-remove-child": { - children.delete(evt.index, 1); - break; - } - - case "element-set-attrib": { - if (evt.value === undefined) { - loroMap.delete(evt.key); - } else { - loroMap.set(evt.key, evt.value); - } - break; - } - } - }); -} - -// FIXME: import from blocky-common -export function isUpperCase(char: string): boolean { - const codeA = 65; - const codeZ = 90; - if (char.length === 0) { - return false; - } - const code = char.charCodeAt(0); - return code >= codeA && code <= codeZ; -} - -function blockyElementFromLoroMap(loroMap: LoroMap): DataElement { - const t = loroMap.get("t") as string; - let result: DataElement; - if (isUpperCase(t[0])) { - let id = loroMap.get("id") as string; - if (t === "Title") { - id = "title"; - } - result = new BlockDataElement(t, id); - } else { - result = new DataElement(t); - } - - for (const [key, value] of loroMap.entries()) { - if (key === "id" || key === "t" || key === "children") { - continue; - } - if (value instanceof LoroText) { - const text = new BlockyTextModel(new Delta(value.toDelta())); - result.__setAttribute(key, text); - bindTextModelToLoroText(text, value); - } else if (value instanceof LoroMap) { - result.__setAttribute(key, blockyElementFromLoroMap(value)); - } else { - result.__setAttribute(key, value); - } - } - - const children = loroMap.get("children") as LoroList | undefined; - if (children) { - for (let i = 0, len = children.length; i < len; i++) { - const child = children.get(i); - if (child instanceof LoroMap) { - result.appendChild(blockyElementFromLoroMap(child)); - } - } - } - - bindDataElementToLoroMap(result, loroMap); - - return result; -} - -function documentFromLoroMap(loroMap: LoroMap): BlockyDocument { - const title = loroMap.get("title") as LoroMap | undefined; - const body = loroMap.get("body") as LoroMap; - const doc = new BlockyDocument({ - title: title - ? (blockyElementFromLoroMap(title) as BlockDataElement) - : undefined, - body: blockyElementFromLoroMap(body), - }); - - return doc; -} - -class LoroPlugin implements IPlugin { - name = "loro"; - loro: Loro>; - needsInit = true; - - constructor(loro?: Loro) { - if (loro) { - this.needsInit = false; - } - this.loro = loro ?? new Loro(); - } - - static getInitDocumentByLoro(loro: Loro) { - const loroMap = loro.getMap("document"); - - return documentFromLoroMap(loroMap); - } - - onInitialized(context: PluginContext) { - context.editor.controller.pluginRegistry.unload("undo"); - const loro = this.loro; - const state = context.editor.state; - - const documentMap = loro.getMap("document"); - - if (this.needsInit) { - syncDocumentToLoro(state.document, documentMap); - loro.commit(); - } - - state.changesetApplied2$.pipe(takeUntil(context.dispose$)).subscribe(() => { - loro.commit(); - }); - - // const sub = loro.subscribe((evt) => { - // console.log("loro evt:", evt, "version:", loro.frontiers()); - // }); - // context.dispose$.pipe(take(1)).subscribe(() => { - // loro.unsubscribe(sub); - // }); - } -} - -export default LoroPlugin; diff --git a/packages/blocky-example/app/loro/loroPlugin.tsx b/packages/blocky-example/app/loro/loroPlugin.tsx new file mode 100644 index 00000000..f96df70a --- /dev/null +++ b/packages/blocky-example/app/loro/loroPlugin.tsx @@ -0,0 +1,131 @@ +import { type IPlugin, PluginContext, IBlockDefinition } from "blocky-core"; +import { Loro, Frontiers } from "loro-crdt"; +import { takeUntil, filter } from "rxjs"; +import { isHotkey } from "is-hotkey"; +import { LoroBinding } from "./loroBinding"; + +export function isPrimitive(value: any) { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +// FIXME: import from blocky-common +export function isUpperCase(char: string): boolean { + const codeA = 65; + const codeZ = 90; + if (char.length === 0) { + return false; + } + const code = char.charCodeAt(0); + return code >= codeA && code <= codeZ; +} + +class LoroPlugin implements IPlugin { + name = "loro"; + loro: Loro>; + needsInit = true; + undoStack: Frontiers[] = []; + redoStack: Frontiers[] = []; + binding: LoroBinding; + blocks: IBlockDefinition[] = []; + + constructor(loro?: Loro) { + if (loro) { + this.needsInit = false; + } + this.loro = loro ?? new Loro(); + this.binding = new LoroBinding(this.loro); + } + + getInitDocumentByLoro() { + if (this.needsInit) { + return undefined; + } + const loroMap = this.loro.getMap("document"); + + return this.binding.documentFromLoroMap(loroMap); + } + + onInitialized(context: PluginContext) { + const { editor } = context; + this.binding.editorState = editor.state; + editor.controller.pluginRegistry.unload("undo"); // unload the default undo plugin + const loro = this.loro; + const state = context.editor.state; + + const documentMap = loro.getMap("document"); + + if (this.needsInit) { + this.binding.syncDocumentToLoro(state.document, documentMap); + loro.commit(); + } + + state.changesetApplied2$ + .pipe( + takeUntil(context.dispose$), + filter((evt) => evt.options.source !== LoroBinding.source) + ) + .subscribe(() => { + loro.commit(); + }); + + editor.keyDown$ + .pipe( + takeUntil(context.dispose$), + filter((e) => isHotkey("mod+z", e)) + ) + .subscribe((e: KeyboardEvent) => { + e.preventDefault(); + try { + this.undo(); + } catch (err) { + console.error("[Blocky]undo error", err); + editor.controller.options?.onError?.(err); + } + }); + + editor.keyDown$ + .pipe( + takeUntil(context.dispose$), + filter((e) => isHotkey("mod+shift+z", e)) + ) + .subscribe((e: KeyboardEvent) => { + e.preventDefault(); + try { + this.redo(context); + } catch (err) { + console.error("[Blocky]redo error", err); + editor.controller.options?.onError?.(err); + } + }); + + editor.state.beforeChangesetApply + .pipe( + takeUntil(context.dispose$), + filter((evt) => evt.options.source !== LoroBinding.source) + ) + .subscribe(() => { + const frontiers = this.loro.frontiers(); + this.undoStack.push(frontiers); + }); + } + + undo() { + const current = this.loro.frontiers(); + const last = this.undoStack.pop(); + if (last) { + console.log("undo", current, last); + this.loro.checkout(last); + this.redoStack.push(current); + } + } + + redo(context: PluginContext) { + console.log("redo", context); + } +} + +export default LoroPlugin; diff --git a/packages/blocky-example/package.json b/packages/blocky-example/package.json index 92f38786..3af3b2f1 100644 --- a/packages/blocky-example/package.json +++ b/packages/blocky-example/package.json @@ -15,6 +15,9 @@ "author": "", "license": "MIT", "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@mui/material": "^5.14.18", "blocky-common": "workspace:*", "blocky-core": "workspace:*", "blocky-react": "workspace:*", @@ -22,7 +25,7 @@ "idb": "^7.1.1", "is-hotkey": "^0.2.0", "lodash-es": "^4.17.21", - "loro-crdt": "^0.5.0", + "loro-crdt": "^0.6.3", "marked": "^4.0.18", "next": "^13.5.6", "react": "^18.2.0", diff --git a/packages/blocky-example/public/LORO.svg b/packages/blocky-example/public/LORO.svg new file mode 100644 index 00000000..47a5c72a --- /dev/null +++ b/packages/blocky-example/public/LORO.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/blocky-react/src/components/dropdown/dropdown.tsx b/packages/blocky-react/src/components/dropdown/dropdown.tsx index 101868b1..7b1dcb99 100644 --- a/packages/blocky-react/src/components/dropdown/dropdown.tsx +++ b/packages/blocky-react/src/components/dropdown/dropdown.tsx @@ -30,9 +30,13 @@ function fixMenuCoord( const { x, y } = coord; if (x + menuWidth + margin > winWidth) { coord.x = winWidth - menuWidth - margin; + } else if (x < margin) { + coord.x = margin; } if (y + menuHeight + margin > winHeight) { coord.y = winHeight - menuHeight - margin; + } else if (y < margin) { + coord.y = margin; } return coord; } diff --git a/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx b/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx index 3f2f1758..bba7e9b5 100644 --- a/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx +++ b/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx @@ -21,15 +21,62 @@ import { LuCheckCircle2, LuTrash2, } from "react-icons/lu"; +import { isUndefined } from "lodash-es"; + +export interface MenuCommand { + title: string; + icon: React.ReactNode; + insertText?: TextType; + insertBlock?: () => BlockDataElement; +} export interface SpannerProps { editorController: EditorController; focusedNode?: BlockDataElement; uiDelegate: SpannerDelegate; + commands?: MenuCommand[]; } +const defaultCommands: MenuCommand[] = [ + { + title: "Text", + icon: , + insertText: TextType.Normal, + }, + { + title: "Heading1", + icon: , + insertText: TextType.Heading1, + }, + { + title: "Heading2", + icon: , + insertText: TextType.Heading2, + }, + { + title: "Heading3", + icon: , + insertText: TextType.Heading3, + }, + { + title: "Checkbox", + icon: , + insertText: TextType.Checkbox, + }, + { + title: "Image", + icon: , + insertBlock: () => bky.element(ImageBlockPlugin.Name), + }, +]; + function DefaultSpannerMenu(props: SpannerProps) { - const { editorController, focusedNode, uiDelegate } = props; + const { + editorController, + focusedNode, + uiDelegate, + commands = defaultCommands, + } = props; const [showDropdown, setShowDropdown] = useState(false); const [showDelete, setShowDelete] = useState(false); const bannerRef = useRef(null); @@ -70,7 +117,7 @@ function DefaultSpannerMenu(props: SpannerProps) { uiDelegate.alwaysShow = false; }, [uiDelegate]); - const insertText = (textType: TextType) => () => { + const insertText = (textType: TextType) => { if (!focusedNode) { return; } @@ -82,16 +129,6 @@ function DefaultSpannerMenu(props: SpannerProps) { }); }; - const insertImage = () => { - if (!focusedNode) { - return; - } - const imgElement = bky.element(ImageBlockPlugin.Name); - editorController.insertBlockAfterId(imgElement, focusedNode.id, { - autoFocus: true, - }); - }; - const deleteBlock = () => { if (!focusedNode) { return; @@ -102,27 +139,33 @@ function DefaultSpannerMenu(props: SpannerProps) { const renderMenu = () => { return ( - } onClick={insertText(TextType.Normal)}> - Text - - } onClick={insertText(TextType.Heading1)}> - Heading1 - - } onClick={insertText(TextType.Heading2)}> - Heading2 - - } onClick={insertText(TextType.Heading3)}> - Heading3 - - } - onClick={insertText(TextType.Checkbox)} - > - Checkbox - - } onClick={insertImage}> - Image - + {commands.map((command, index) => { + return ( + { + if (!isUndefined(command.insertText)) { + insertText(command.insertText); + } else if (command.insertBlock) { + if (!focusedNode) { + return; + } + const imgElement = command.insertBlock(); + editorController.insertBlockAfterId( + imgElement, + focusedNode.id, + { + autoFocus: true, + } + ); + } + }} + > + {command.title} + + ); + })} {showDelete && ( <> diff --git a/packages/blocky-react/src/defaultSpannerMenu/index.ts b/packages/blocky-react/src/defaultSpannerMenu/index.ts index d30028fc..e06a9ca6 100644 --- a/packages/blocky-react/src/defaultSpannerMenu/index.ts +++ b/packages/blocky-react/src/defaultSpannerMenu/index.ts @@ -1,3 +1,3 @@ -import DefaultSpannerMenu from "./defaultSpannerMenu"; +import DefaultSpannerMenu, { type MenuCommand } from "./defaultSpannerMenu"; -export { DefaultSpannerMenu }; +export { DefaultSpannerMenu, type MenuCommand }; diff --git a/packages/blocky-react/src/index.ts b/packages/blocky-react/src/index.ts index 84647375..baa74cd0 100644 --- a/packages/blocky-react/src/index.ts +++ b/packages/blocky-react/src/index.ts @@ -11,6 +11,7 @@ export * from "./blockActiveDetector"; export * from "./defaultToolbar"; export * from "./defaultSpannerMenu"; export * from "./components/tooltip"; +export * from "./components/menu"; export * from "./reactTheme"; export { makeReactSpanner, diff --git a/packages/blocky-react/src/spanner.tsx b/packages/blocky-react/src/spanner.tsx index 8900a1f4..ef459a40 100644 --- a/packages/blocky-react/src/spanner.tsx +++ b/packages/blocky-react/src/spanner.tsx @@ -8,7 +8,7 @@ import type { SpannerDelegate, } from "blocky-core"; import { once } from "lodash-es"; -import { DefaultSpannerMenu } from "./defaultSpannerMenu"; +import { DefaultSpannerMenu, MenuCommand } from "./defaultSpannerMenu"; import { ThemeWrapper } from "./reactTheme"; export interface RenderProps { @@ -50,13 +50,18 @@ export function makeReactSpanner(renderer: Renderer): SpannerFactory { }; } -export function makeDefaultReactSpanner() { +export interface DefaultSpannerOptions { + commands?: MenuCommand[]; +} + +export function makeDefaultReactSpanner(options?: DefaultSpannerOptions) { return makeReactSpanner(({ editorController, focusedNode, uiDelegate }) => { return ( ); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84860dc8..f9b382f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,15 @@ importers: packages/blocky-example: dependencies: + '@emotion/react': + specifier: ^11.11.1 + version: 11.11.1(@types/react@18.2.36)(react@18.2.0) + '@emotion/styled': + specifier: ^11.11.0 + version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.36)(react@18.2.0) + '@mui/material': + specifier: ^5.14.18 + version: 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.36)(react-dom@18.2.0)(react@18.2.0) blocky-common: specifier: workspace:* version: link:../blocky-common @@ -122,8 +131,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 loro-crdt: - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.6.3 + version: 0.6.3 marked: specifier: ^4.0.18 version: 4.0.18 @@ -304,6 +313,12 @@ packages: resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} dev: false + /@emotion/is-prop-valid@1.2.1: + resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + /@emotion/memoize@0.8.1: resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} dev: false @@ -343,6 +358,27 @@ packages: resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==} dev: false + /@emotion/styled@11.11.0(@emotion/react@11.11.1)(@types/react@18.2.36)(react@18.2.0): + resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@emotion/babel-plugin': 11.11.0 + '@emotion/is-prop-valid': 1.2.1 + '@emotion/react': 11.11.1(@types/react@18.2.36)(react@18.2.0) + '@emotion/serialize': 1.1.2 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@types/react': 18.2.36 + react: 18.2.0 + dev: false + /@emotion/unitless@0.8.1: resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} dev: false @@ -593,6 +629,34 @@ packages: - supports-color dev: true + /@floating-ui/core@1.5.0: + resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} + dependencies: + '@floating-ui/utils': 0.1.6 + dev: false + + /@floating-ui/dom@1.5.3: + resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==} + dependencies: + '@floating-ui/core': 1.5.0 + '@floating-ui/utils': 0.1.6 + dev: false + + /@floating-ui/react-dom@2.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.5.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@floating-ui/utils@0.1.6: + resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} + dev: false + /@humanwhocodes/config-array@0.9.5: resolution: {integrity: sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==} engines: {node: '>=10.10.0'} @@ -629,6 +693,167 @@ packages: '@jridgewell/sourcemap-codec': 1.4.14 dev: true + /@mui/base@5.0.0-beta.24(@types/react@18.2.36)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bKt2pUADHGQtqWDZ8nvL2Lvg2GNJyd/ZUgZAJoYzRgmnxBL9j36MSlS3+exEdYkikcnvVafcBtD904RypFKb0w==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@floating-ui/react-dom': 2.0.4(react-dom@18.2.0)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.36) + '@mui/utils': 5.14.18(@types/react@18.2.36)(react@18.2.0) + '@popperjs/core': 2.11.8 + '@types/react': 18.2.36 + clsx: 2.0.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@mui/core-downloads-tracker@5.14.18: + resolution: {integrity: sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ==} + dev: false + + /@mui/material@5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.36)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-y3UiR/JqrkF5xZR0sIKj6y7xwuEiweh9peiN3Zfjy1gXWXhz5wjlaLdoxFfKIEBUFfeQALxr/Y8avlHH+B9lpQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + react-dom: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@emotion/react': 11.11.1(@types/react@18.2.36)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.36)(react@18.2.0) + '@mui/base': 5.0.0-beta.24(@types/react@18.2.36)(react-dom@18.2.0)(react@18.2.0) + '@mui/core-downloads-tracker': 5.14.18 + '@mui/system': 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.36)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.36) + '@mui/utils': 5.14.18(@types/react@18.2.36)(react@18.2.0) + '@types/react': 18.2.36 + '@types/react-transition-group': 4.4.9 + clsx: 2.0.0 + csstype: 3.1.2 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.2.0 + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + dev: false + + /@mui/private-theming@5.14.18(@types/react@18.2.36)(react@18.2.0): + resolution: {integrity: sha512-WSgjqRlzfHU+2Rou3HlR2Gqfr4rZRsvFgataYO3qQ0/m6gShJN+lhVEvwEiJ9QYyVzMDvNpXZAcqp8Y2Vl+PAw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@mui/utils': 5.14.18(@types/react@18.2.36)(react@18.2.0) + '@types/react': 18.2.36 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/styled-engine@5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0): + resolution: {integrity: sha512-pW8bpmF9uCB5FV2IPk6mfbQCjPI5vGI09NOLhtGXPeph/4xIfC3JdIX0TILU0WcTs3aFQqo6s2+1SFgIB9rCXA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@emotion/cache': 11.11.0 + '@emotion/react': 11.11.1(@types/react@18.2.36)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.36)(react@18.2.0) + csstype: 3.1.2 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/system@5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.36)(react@18.2.0): + resolution: {integrity: sha512-hSQQdb3KF72X4EN2hMEiv8EYJZSflfdd1TRaGPoR7CIAG347OxCslpBUwWngYobaxgKvq6xTrlIl+diaactVww==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@emotion/react': 11.11.1(@types/react@18.2.36)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.36)(react@18.2.0) + '@mui/private-theming': 5.14.18(@types/react@18.2.36)(react@18.2.0) + '@mui/styled-engine': 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.36) + '@mui/utils': 5.14.18(@types/react@18.2.36)(react@18.2.0) + '@types/react': 18.2.36 + clsx: 2.0.0 + csstype: 3.1.2 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@mui/types@7.2.9(@types/react@18.2.36): + resolution: {integrity: sha512-k1lN/PolaRZfNsRdAqXtcR71sTnv3z/VCCGPxU8HfdftDkzi335MdJ6scZxvofMAd/K/9EbzCZTFBmlNpQVdCg==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.36 + dev: false + + /@mui/utils@5.14.18(@types/react@18.2.36)(react@18.2.0): + resolution: {integrity: sha512-HZDRsJtEZ7WMSnrHV9uwScGze4wM/Y+u6pDVo+grUjt5yXzn+wI8QX/JwTHh9YSw/WpnUL80mJJjgCnWj2VrzQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 + react: ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.4 + '@types/prop-types': 15.7.11 + '@types/react': 18.2.36 + prop-types: 15.8.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + /@next/env@13.5.6: resolution: {integrity: sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==} dev: false @@ -744,6 +969,10 @@ packages: playwright-core: 1.25.2 dev: true + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + dev: false + /@swc/helpers@0.5.2: resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} dependencies: @@ -801,6 +1030,10 @@ packages: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: false + /@types/prop-types@15.7.11: + resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} + dev: false + /@types/prop-types@15.7.9: resolution: {integrity: sha512-n1yyPsugYNSmHgxDFjicaI2+gCNjsBck8UX9kuofAKlc0h1bL+20oSF72KeNaW2DUlesbEVCFgyV2dPGTiY42g==} @@ -810,6 +1043,12 @@ packages: '@types/react': 18.2.36 dev: true + /@types/react-transition-group@4.4.9: + resolution: {integrity: sha512-ZVNmWumUIh5NhH8aMD9CR2hdW0fNuYInlocZHaZ+dgk/1K49j1w/HoAuK1ki+pgscQrOFRTlXeoURtuzEkV3dg==} + dependencies: + '@types/react': 18.2.36 + dev: false + /@types/react@18.2.36: resolution: {integrity: sha512-o9XFsHYLLZ4+sb9CWUYwHqFVoG61SesydF353vFMMsQziiyRu8np4n2OYMUSDZ8XuImxDr9c5tR7gidlH29Vnw==} dependencies: @@ -1181,6 +1420,11 @@ packages: wrap-ansi: 7.0.0 dev: true + /clsx@2.0.0: + resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -1319,6 +1563,13 @@ packages: esutils: 2.0.3 dev: true + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.23.4 + csstype: 3.1.2 + dev: false + /domexception@4.0.0: resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} engines: {node: '>=12'} @@ -2212,14 +2463,14 @@ packages: js-tokens: 4.0.0 dev: false - /loro-crdt@0.5.0: - resolution: {integrity: sha512-O7vBYOI1bGQZKlF2UabGVMbIqNtyHKsFUfz3gAy65EzNhFdxFV5BMlm5ViUMd2RAjKHQ8VKuTabV7zeim18cOw==} + /loro-crdt@0.6.3: + resolution: {integrity: sha512-dK4zDbklR4q4iQDNxoLTMOWtpp26cI4nTedp+agkjxbP6nwQXeRnCSz8UE+3m2ezrndCwbVCaFNg5Q6tfubb5g==} dependencies: - loro-wasm: 0.5.0 + loro-wasm: 0.6.1 dev: false - /loro-wasm@0.5.0: - resolution: {integrity: sha512-IvKvlo7cGJo/sR8loy45fugYko3rCGtanphT7dHD9cYJLiOHeKQqTlXYVPmLmfED6ALqfNxpvRWQvLglMW6EtQ==} + /loro-wasm@0.6.1: + resolution: {integrity: sha512-rpkUMmbHdoDoWGE28p40Q1fxzWSxdefpqirKglHqEqlkmcU5e5aSarLltklPbf1jtT8E988v8Qx0aA98Z47emw==} dev: false /loupe@2.3.4: @@ -2355,6 +2606,11 @@ packages: resolution: {integrity: sha512-JYOWTeFoS0Z93587vRJgASD5Ut11fYl5NyihP3KrYBvMe1FRRs6RN7m20SA/16GM4P6hTnZjT+UmDOt38UeXNg==} dev: true + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + dev: false + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -2493,6 +2749,14 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: false + /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} dev: true @@ -2540,6 +2804,24 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false + /react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + dev: false + + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.23.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'}