From 11b5a9f4a9d0eef137ecefc10193b6fdb677f543 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 11:42:48 +0800 Subject: [PATCH 01/13] feat: add loro --- packages/blocky-core/src/block/titleBlock.ts | 2 +- packages/blocky-core/src/data/tree.ts | 14 +- packages/blocky-core/src/view/renderer.ts | 29 ++-- .../blocky-example/app/loro/loroExample.tsx | 144 ++++++++++++++++++ packages/blocky-example/app/loro/page.tsx | 35 +++++ .../blocky-example/components/sidebar.tsx | 1 + packages/blocky-example/next.config.js | 7 + packages/blocky-example/package.json | 1 + pnpm-lock.yaml | 13 ++ 9 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 packages/blocky-example/app/loro/loroExample.tsx create mode 100644 packages/blocky-example/app/loro/page.tsx diff --git a/packages/blocky-core/src/block/titleBlock.ts b/packages/blocky-core/src/block/titleBlock.ts index 7a8770b..f901322 100644 --- a/packages/blocky-core/src/block/titleBlock.ts +++ b/packages/blocky-core/src/block/titleBlock.ts @@ -36,7 +36,7 @@ export class TitleBlock extends Block { changeset.forceUpdate = true; } - this.editor.textInput.next( + this.editor.textInput$.next( new TextInputEvent(beforeDelta, diff, blockElement) ); } diff --git a/packages/blocky-core/src/data/tree.ts b/packages/blocky-core/src/data/tree.ts index 2c35248..dbac21e 100644 --- a/packages/blocky-core/src/data/tree.ts +++ b/packages/blocky-core/src/data/tree.ts @@ -208,6 +208,11 @@ export class DataBaseElement implements DataBaseNode { const oldValue = this.#attributes[name]; this.#attributes[name] = value; + if (value instanceof DataBaseElement) { + value.parent = this; + this.doc?.reportBlockyNodeInserted(value); + } + if (typeof value === "object" && typeof value.t === "string") { this.__backMap.set(value, name); } @@ -686,6 +691,7 @@ export class BlockyDocument extends DataElement { readonly blockElementRemoved = new Subject(); constructor(props?: Partial) { + super("document", undefined, []); let title: BlockDataElement | undefined; if (!isUndefined(props?.title)) { if (props?.title instanceof BlockDataElement) { @@ -701,16 +707,13 @@ export class BlockyDocument extends DataElement { const body = props?.body ?? new DataElement("body", undefined, props?.bodyChildren ?? []); - super("document", undefined, []); this.__setAttribute("title", title); this.__setAttribute("body", body); if (this.title) { - this.__symInsertAfter(this.title); this.reportBlockyNodeInserted(this.title); } - this.__symInsertAfter(this.body); this.reportBlockyNodeInserted(this.body); } @@ -762,6 +765,11 @@ export function traverseNode( fun(node); if (node instanceof DataElement) { + for (const value of Object.values(node.getAttributes())) { + if (value instanceof DataBaseElement) { + traverseNode(value, fun); + } + } let ptr = node.firstChild; while (ptr) { traverseNode(ptr, fun); diff --git a/packages/blocky-core/src/view/renderer.ts b/packages/blocky-core/src/view/renderer.ts index 5ecd290..e76cd1f 100644 --- a/packages/blocky-core/src/view/renderer.ts +++ b/packages/blocky-core/src/view/renderer.ts @@ -12,6 +12,7 @@ import { } from "@pkg/data"; import type { Editor } from "@pkg/view/editor"; import { TextBlock } from "@pkg/block/textBlock"; +import { TitleBlock } from "@pkg/block/titleBlock"; function ensureChild( dom: HTMLElement, @@ -133,20 +134,24 @@ export class DocRenderer { const state = this.editor.state; const blockElement = state.getBlockElementById(operation.id); if (!blockElement) { - // has been deleted? + console.warn("blockElement not found", operation.id); return; } - if (blockElement.t !== TextBlock.Name) { - return; - } - const block = state.blocks.get(operation.id); - const dom = state.domMap.get(operation.id); - if (block && dom) { - block.render?.(dom as HTMLElement, { - changeset, - operation, - flags: RenderFlag.Incremental, - }); + if (blockElement.t === TextBlock.Name) { + const block = state.blocks.get(operation.id); + const dom = state.domMap.get(operation.id); + if (block && dom) { + block.render?.(dom as HTMLElement, { + changeset, + operation, + flags: RenderFlag.Incremental, + }); + } + } else if (blockElement.t === TitleBlock.Name) { + const dom = state.domMap.get(operation.id); + if (dom) { + this.renderTitle(dom as HTMLElement, blockElement); + } } } diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx new file mode 100644 index 0000000..7592be4 --- /dev/null +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -0,0 +1,144 @@ +import { useRef } from "react"; +import { + BlockyEditor, + makeReactToolbar, + makeImageBlockPlugin, + useBlockyController, + DefaultToolbarMenu, +} from "blocky-react"; +import { + BlockyTextModel, + DataBaseElement, + EditorController, + IPlugin, +} from "blocky-core"; +import ImagePlaceholder from "@pkg/components/imagePlaceholder"; +import { makeCommandPanelPlugin } from "@pkg/app/plugins/commandPanel"; +import { makeAtPanelPlugin } from "@pkg/app/plugins/atPanel"; +import { Loro, LoroMap } from "loro-crdt"; +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 childText = loroMap.setContainer(key, "Text"); + childText.applyDelta(value.delta.ops); + } 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.childrenLength == 0) { + return; + } + 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++; + } +} + +function makeLoroPlugin(): IPlugin { + return { + name: "rolo", + onInitialized(context) { + const loro = new Loro(); + + const state = context.editor.state; + + const documentMap = loro.getMap("document"); + syncDocumentToLoro(state.document, documentMap); + + console.log("loro:", loro.toJson()); + console.log("doc:", state.document); + + state.changesetApplied2$ + .pipe(takeUntil(context.dispose$)) + .subscribe((changeset) => { + console.log("changeset", changeset); + }); + }, + }; +} + +function makeEditorPlugins(): IPlugin[] { + return [ + makeImageBlockPlugin({ + placeholder: ({ setSrc }) => , + }), + makeCommandPanelPlugin(), + makeAtPanelPlugin(), + makeLoroPlugin(), + ]; +} + +function makeController(userId: string): EditorController { + return new EditorController(userId, { + title: "Loro", + /** + * Define the plugins to implement customize features. + */ + plugins: makeEditorPlugins(), + /** + * Tell the editor how to render the banner. + * We use a toolbar written in Preact here. + */ + toolbarFactory: makeReactToolbar((editorController: EditorController) => { + return ; + }), + + spellcheck: false, + }); +} + +function LoroExample() { + const containerRef = useRef(null); + + const controller = useBlockyController(() => { + return makeController("user"); + }, []); + + return ( +
+ +
+ ); +} + +export default LoroExample; diff --git a/packages/blocky-example/app/loro/page.tsx b/packages/blocky-example/app/loro/page.tsx new file mode 100644 index 0000000..a8070d1 --- /dev/null +++ b/packages/blocky-example/app/loro/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState, useEffect, lazy, Suspense } from "react"; +import Sidebar from "@pkg/components/sidebar"; +import "blocky-core/css/blocky-core.css"; +import { ThemeProvider } from "../themeSwitch"; +import Navbar from "@pkg/components/navbar"; + +const LoroExample = lazy(() => import("./loroExample")); + +function NoTitlePage() { + const [isClient, setIsCient] = useState(false); + useEffect(() => { + setIsCient(true); + }, []); + return ( + +
+ +
+ +
+ {isClient && ( + + + + )} +
+
+
+
+ ); +} + +export default NoTitlePage; diff --git a/packages/blocky-example/components/sidebar.tsx b/packages/blocky-example/components/sidebar.tsx index 55cbb2c..c44743d 100644 --- a/packages/blocky-example/components/sidebar.tsx +++ b/packages/blocky-example/components/sidebar.tsx @@ -30,6 +30,7 @@ const Sidebar = memo(() => { Api

Examples

Editor without title + Loro CRDT ); }); diff --git a/packages/blocky-example/next.config.js b/packages/blocky-example/next.config.js index 0cf3cf7..30f9a21 100644 --- a/packages/blocky-example/next.config.js +++ b/packages/blocky-example/next.config.js @@ -2,6 +2,13 @@ const nextConfig = { /* config options here */ reactStrictMode: false, + webpack: function (config) { + config.experiments = { + layers: true, + asyncWebAssembly: true, + }; + return config; + }, }; module.exports = nextConfig; diff --git a/packages/blocky-example/package.json b/packages/blocky-example/package.json index 946aadd..8934aa7 100644 --- a/packages/blocky-example/package.json +++ b/packages/blocky-example/package.json @@ -21,6 +21,7 @@ "highlight.js": "^11.6.0", "is-hotkey": "^0.2.0", "lodash-es": "^4.17.21", + "loro-crdt": "^0.4.3", "marked": "^4.0.18", "next": "^13.5.6", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a96abf8..a89a873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: lodash-es: specifier: ^4.17.21 version: 4.17.21 + loro-crdt: + specifier: ^0.4.3 + version: 0.4.3 marked: specifier: ^4.0.18 version: 4.0.18 @@ -2186,6 +2189,16 @@ packages: js-tokens: 4.0.0 dev: false + /loro-crdt@0.4.3: + resolution: {integrity: sha512-ofUEXrieS2py9t0Ee+7ebgCCRwbiQthP9SuQ/J1AmUsOQl2+2feYqPlTYFKdP1BvAnus1SCNLNng4jRJlv/ydg==} + dependencies: + loro-wasm: 0.4.3 + dev: false + + /loro-wasm@0.4.3: + resolution: {integrity: sha512-k9Nr2wlRCX59ar4o0CU840vabeB+NOEkkrGG02hWsT2MaLGJ5IeKrAK22DpMrdhDgX1F4L8A6L+cL6vDlpirRA==} + dev: false + /loupe@2.3.4: resolution: {integrity: sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==} dependencies: From 87bcd74a6efdff392be56e2270d47507d95374f3 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 11:57:58 +0800 Subject: [PATCH 02/13] feat: sync from blocky to loro --- packages/blocky-core/src/data/tree.ts | 10 +++- .../blocky-example/app/loro/loroExample.tsx | 57 ++++++++++++++----- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/blocky-core/src/data/tree.ts b/packages/blocky-core/src/data/tree.ts index dbac21e..2d597d6 100644 --- a/packages/blocky-core/src/data/tree.ts +++ b/packages/blocky-core/src/data/tree.ts @@ -10,7 +10,8 @@ import type { ElementChangedEvent } from "./events"; export interface DeltaChangedEvent { oldDelta: Delta; - newDelta: Delta; + newDelta?: Delta; + apply: Delta; } export interface AttributesObject { @@ -52,6 +53,7 @@ export class BlockyTextModel { #delta = new Delta(); #cachedString: string | undefined; #cachedLength: number | undefined; + changed$ = new Subject(); constructor(delta?: Delta) { this.#delta = delta ?? new Delta(); @@ -100,6 +102,12 @@ export class BlockyTextModel { this.#delta = newDelta; this.#cachedString = undefined; this.#cachedLength = undefined; + + this.changed$.next({ + oldDelta, + newDelta, + apply: v, + }); } toString(): string { diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index 7592be4..b37fafd 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -9,8 +9,10 @@ import { import { BlockyTextModel, DataBaseElement, + DataElement, EditorController, IPlugin, + PluginContext, } from "blocky-core"; import ImagePlaceholder from "@pkg/components/imagePlaceholder"; import { makeCommandPanelPlugin } from "@pkg/app/plugins/commandPanel"; @@ -26,7 +28,11 @@ function isPrimitive(value: any) { ); } -function syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { +function syncDocumentToLoro( + ctx: PluginContext, + doc: DataBaseElement, + loroMap: LoroMap +) { const attribs = doc.getAttributes(); loroMap.set("t", doc.t); @@ -43,10 +49,15 @@ function syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { } } else if (value instanceof DataBaseElement) { const childLoroMap = loroMap.setContainer(key, "Map"); - syncDocumentToLoro(value, childLoroMap); + syncDocumentToLoro(ctx, value, childLoroMap); } else if (value instanceof BlockyTextModel) { - const childText = loroMap.setContainer(key, "Text"); - childText.applyDelta(value.delta.ops); + const loroText = loroMap.setContainer(key, "Text"); + loroText.applyDelta(value.delta.ops); + + value.changed$.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { + // console.log("content:", loroText.toString()); + loroText.applyDelta(evt.apply.ops); + }); } else if (typeof value === "object") { const childLoroMap = loroMap.setContainer(key, "Map"); for (const [childKey, childValue] of Object.entries(value)) { @@ -55,7 +66,7 @@ function syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { } } - if (doc.childrenLength == 0) { + if (!(doc instanceof DataElement)) { return; } const children = loroMap.setContainer("children", "List"); @@ -63,11 +74,37 @@ function syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { let counter = 0; while (ptr) { const subDoc = children.insertContainer(counter, "Map"); - syncDocumentToLoro(ptr as DataBaseElement, subDoc); + syncDocumentToLoro(ctx, ptr as DataBaseElement, subDoc); ptr = ptr.nextSibling; counter++; } + + doc.changed.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { + switch (evt.type) { + case "element-insert-child": { + const children = loroMap.setContainer("children", "List"); + const loroChild = children.insertContainer(evt.index, "Map"); + syncDocumentToLoro(ctx, evt.child as DataBaseElement, loroChild); + break; + } + + case "element-remove-child": { + const children = loroMap.setContainer("children", "List"); + 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; + } + } + }); } function makeLoroPlugin(): IPlugin { @@ -79,16 +116,10 @@ function makeLoroPlugin(): IPlugin { const state = context.editor.state; const documentMap = loro.getMap("document"); - syncDocumentToLoro(state.document, documentMap); + syncDocumentToLoro(context, state.document, documentMap); console.log("loro:", loro.toJson()); console.log("doc:", state.document); - - state.changesetApplied2$ - .pipe(takeUntil(context.dispose$)) - .subscribe((changeset) => { - console.log("changeset", changeset); - }); }, }; } From bc711573b06a4c5a70bfde3102c6307349a9e9c7 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 12:32:19 +0800 Subject: [PATCH 03/13] fix: search context --- packages/blocky-core/src/data/change.spec.ts | 2 +- packages/blocky-core/src/model/searchContext.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/blocky-core/src/data/change.spec.ts b/packages/blocky-core/src/data/change.spec.ts index da4ce16..da3de99 100644 --- a/packages/blocky-core/src/data/change.spec.ts +++ b/packages/blocky-core/src/data/change.spec.ts @@ -175,7 +175,7 @@ describe("merge", () => { }); const state = new State("User-1", document); const change = new Changeset(state); - change.deleteChildrenAt(document, 0, 1); + change.deleteChildrenAt(document.body, 0, 1); change.textEdit(textBlock2, "textContent", () => new Delta().insert("a")); const finalizedChangeset = change.finalize(); expect(finalizedChangeset.operations.length).toBe(2); diff --git a/packages/blocky-core/src/model/searchContext.ts b/packages/blocky-core/src/model/searchContext.ts index add60d6..3d2b0bd 100644 --- a/packages/blocky-core/src/model/searchContext.ts +++ b/packages/blocky-core/src/model/searchContext.ts @@ -1,7 +1,7 @@ import { IDisposable } from "blocky-common/es"; import { Subject } from "rxjs"; import { elem, removeNode, ContainerWithCoord } from "blocky-common/es/dom"; -import { BlockDataElement, DataBaseNode } from "@pkg/data"; +import { BlockDataElement, DataBaseElement, DataBaseNode } from "@pkg/data"; import { isString, isObject } from "lodash-es"; import { Editor } from "@pkg/view/editor"; @@ -210,6 +210,13 @@ export class SearchContext implements IDisposable { } #iterateNode(node: DataBaseNode) { + if (node instanceof DataBaseElement) { + for (const value of Object.values(node.getAttributes())) { + if (value instanceof DataBaseElement) { + this.#iterateNode(value); + } + } + } if (node instanceof BlockDataElement) { this.#searchBlockElement(node); } From 07adb7d9a8ecf27e565e0ae3e58e7f109e280123 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 12:59:06 +0800 Subject: [PATCH 04/13] fix: sync states --- .../app/loro/loroExample.module.scss | 13 ++++++ .../blocky-example/app/loro/loroExample.tsx | 42 ++++++++++++------- 2 files changed, 39 insertions(+), 16 deletions(-) create mode 100644 packages/blocky-example/app/loro/loroExample.module.scss diff --git a/packages/blocky-example/app/loro/loroExample.module.scss b/packages/blocky-example/app/loro/loroExample.module.scss new file mode 100644 index 0000000..c58a1cf --- /dev/null +++ b/packages/blocky-example/app/loro/loroExample.module.scss @@ -0,0 +1,13 @@ + +.editorContainer { + width: 100%; + display: flex; + + :global(.blocky-editor-container) { + flex: 1; + } + + :global(.blocky-documents) { + width: 580px; + } +} \ No newline at end of file diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index b37fafd..7e8bf20 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -5,6 +5,9 @@ import { makeImageBlockPlugin, useBlockyController, DefaultToolbarMenu, + DefaultSpannerMenu, + makeReactSpanner, + type SpannerRenderProps, } from "blocky-react"; import { BlockyTextModel, @@ -19,6 +22,7 @@ import { makeCommandPanelPlugin } from "@pkg/app/plugins/commandPanel"; import { makeAtPanelPlugin } from "@pkg/app/plugins/atPanel"; import { Loro, LoroMap } from "loro-crdt"; import { takeUntil } from "rxjs"; +import styles from "./loroExample.module.scss"; function isPrimitive(value: any) { return ( @@ -83,14 +87,12 @@ function syncDocumentToLoro( doc.changed.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { switch (evt.type) { case "element-insert-child": { - const children = loroMap.setContainer("children", "List"); const loroChild = children.insertContainer(evt.index, "Map"); syncDocumentToLoro(ctx, evt.child as DataBaseElement, loroChild); break; } case "element-remove-child": { - const children = loroMap.setContainer("children", "List"); children.delete(evt.index, 1); break; } @@ -107,21 +109,21 @@ function syncDocumentToLoro( }); } -function makeLoroPlugin(): IPlugin { - return { - name: "rolo", - onInitialized(context) { - const loro = new Loro(); +class LoroPlugin implements IPlugin { + name = "loro"; + loro: Loro> | undefined; - const state = context.editor.state; + onInitialized(context: PluginContext) { + const loro = new Loro(); + const state = context.editor.state; + this.loro = loro; - const documentMap = loro.getMap("document"); - syncDocumentToLoro(context, state.document, documentMap); + const documentMap = loro.getMap("document"); + syncDocumentToLoro(context, state.document, documentMap); - console.log("loro:", loro.toJson()); - console.log("doc:", state.document); - }, - }; + console.log("loro:", loro.toJson()); + console.log("doc:", state.document); + } } function makeEditorPlugins(): IPlugin[] { @@ -131,7 +133,7 @@ function makeEditorPlugins(): IPlugin[] { }), makeCommandPanelPlugin(), makeAtPanelPlugin(), - makeLoroPlugin(), + new LoroPlugin(), ]; } @@ -142,6 +144,14 @@ function makeController(userId: string): EditorController { * Define the plugins to implement customize features. */ plugins: makeEditorPlugins(), + spannerFactory: makeReactSpanner( + ({ editorController, focusedNode }: SpannerRenderProps) => ( + + ) + ), /** * Tell the editor how to render the banner. * We use a toolbar written in Preact here. @@ -162,7 +172,7 @@ function LoroExample() { }, []); return ( -
+
Date: Mon, 27 Nov 2023 14:05:02 +0800 Subject: [PATCH 05/13] feat: async get loro --- packages/blocky-core/src/block/textBlock.ts | 2 +- packages/blocky-example/app/app.scss | 2 +- .../blocky-example/app/loro/loroExample.tsx | 125 ++---------------- .../blocky-example/app/loro/loroPlugin.ts | 113 ++++++++++++++++ .../components/navbar.module.scss | 4 + packages/blocky-react/src/editor.tsx | 27 +++- 6 files changed, 154 insertions(+), 119 deletions(-) create mode 100644 packages/blocky-example/app/loro/loroPlugin.ts diff --git a/packages/blocky-core/src/block/textBlock.ts b/packages/blocky-core/src/block/textBlock.ts index 2ca5e3d..028ccc5 100644 --- a/packages/blocky-core/src/block/textBlock.ts +++ b/packages/blocky-core/src/block/textBlock.ts @@ -251,7 +251,7 @@ export class TextBlock extends ContentBlock { } if (textType === TextType.Normal) { - return { x: 0, y: 2 }; + return { x: 0, y: -2 }; } return { x: 0, y: 0 }; diff --git a/packages/blocky-example/app/app.scss b/packages/blocky-example/app/app.scss index 443ef40..d302201 100644 --- a/packages/blocky-example/app/app.scss +++ b/packages/blocky-example/app/app.scss @@ -111,7 +111,7 @@ body { p { margin-left: 10px; - font-size: 13px; + font-size: 12px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; color: var(--primary-text-color); } diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index 7e8bf20..4b76b4a 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -9,123 +9,13 @@ import { makeReactSpanner, type SpannerRenderProps, } from "blocky-react"; -import { - BlockyTextModel, - DataBaseElement, - DataElement, - EditorController, - IPlugin, - PluginContext, -} from "blocky-core"; +import { EditorController, IPlugin } from "blocky-core"; import ImagePlaceholder from "@pkg/components/imagePlaceholder"; import { makeCommandPanelPlugin } from "@pkg/app/plugins/commandPanel"; import { makeAtPanelPlugin } from "@pkg/app/plugins/atPanel"; -import { Loro, LoroMap } from "loro-crdt"; -import { takeUntil } from "rxjs"; +import LoroPlugin from "./loroPlugin"; import styles from "./loroExample.module.scss"; -function isPrimitive(value: any) { - return ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ); -} - -function syncDocumentToLoro( - ctx: PluginContext, - 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(ctx, value, childLoroMap); - } else if (value instanceof BlockyTextModel) { - const loroText = loroMap.setContainer(key, "Text"); - loroText.applyDelta(value.delta.ops); - - value.changed$.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { - // console.log("content:", loroText.toString()); - loroText.applyDelta(evt.apply.ops); - }); - } 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; - } - const children = loroMap.setContainer("children", "List"); - let ptr = doc.firstChild; - let counter = 0; - while (ptr) { - const subDoc = children.insertContainer(counter, "Map"); - syncDocumentToLoro(ctx, ptr as DataBaseElement, subDoc); - - ptr = ptr.nextSibling; - counter++; - } - - doc.changed.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { - switch (evt.type) { - case "element-insert-child": { - const loroChild = children.insertContainer(evt.index, "Map"); - syncDocumentToLoro(ctx, 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; - } - } - }); -} - -class LoroPlugin implements IPlugin { - name = "loro"; - loro: Loro> | undefined; - - onInitialized(context: PluginContext) { - const loro = new Loro(); - const state = context.editor.state; - this.loro = loro; - - const documentMap = loro.getMap("document"); - syncDocumentToLoro(context, state.document, documentMap); - - console.log("loro:", loro.toJson()); - console.log("doc:", state.document); - } -} - function makeEditorPlugins(): IPlugin[] { return [ makeImageBlockPlugin({ @@ -168,7 +58,16 @@ function LoroExample() { const containerRef = useRef(null); const controller = useBlockyController(() => { - return makeController("user"); + const controller = makeController("user"); + + 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.` + ); + + return controller; }, []); return ( diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts new file mode 100644 index 0000000..d06bae6 --- /dev/null +++ b/packages/blocky-example/app/loro/loroPlugin.ts @@ -0,0 +1,113 @@ +import { + type IPlugin, + PluginContext, + DataBaseElement, + BlockyTextModel, + DataElement, +} from "blocky-core"; +import { Loro, LoroMap } from "loro-crdt"; +import { takeUntil } from "rxjs"; + +function isPrimitive(value: any) { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function syncDocumentToLoro( + ctx: PluginContext, + 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(ctx, value, childLoroMap); + } else if (value instanceof BlockyTextModel) { + const loroText = loroMap.setContainer(key, "Text"); + loroText.applyDelta(value.delta.ops); + + value.changed$.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { + // console.log("content:", loroText.toString()); + loroText.applyDelta(evt.apply.ops); + }); + } 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; + } + const children = loroMap.setContainer("children", "List"); + let ptr = doc.firstChild; + let counter = 0; + while (ptr) { + const subDoc = children.insertContainer(counter, "Map"); + syncDocumentToLoro(ctx, ptr as DataBaseElement, subDoc); + + ptr = ptr.nextSibling; + counter++; + } + + doc.changed.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { + switch (evt.type) { + case "element-insert-child": { + const loroChild = children.insertContainer(evt.index, "Map"); + syncDocumentToLoro(ctx, 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; + } + } + }); +} + +class LoroPlugin implements IPlugin { + name = "loro"; + loro: Loro> | undefined; + + onInitialized(context: PluginContext) { + const loro = new Loro(); + const state = context.editor.state; + this.loro = loro; + + const documentMap = loro.getMap("document"); + syncDocumentToLoro(context, state.document, documentMap); + + console.log("loro:", loro.toJson()); + console.log("doc:", state.document); + } +} + +export default LoroPlugin; diff --git a/packages/blocky-example/components/navbar.module.scss b/packages/blocky-example/components/navbar.module.scss index 0178e84..40216e2 100644 --- a/packages/blocky-example/components/navbar.module.scss +++ b/packages/blocky-example/components/navbar.module.scss @@ -28,4 +28,8 @@ a { margin-left: 16px; } + + :global(.theme-switch-wrapper) { + transform: translateY(-2px); + } } \ No newline at end of file diff --git a/packages/blocky-react/src/editor.tsx b/packages/blocky-react/src/editor.tsx index cd133f1..3aedd9c 100644 --- a/packages/blocky-react/src/editor.tsx +++ b/packages/blocky-react/src/editor.tsx @@ -2,17 +2,36 @@ import React, { useEffect, useState, useRef, RefObject } from "react"; import { Editor, EditorController, CursorState } from "blocky-core"; export function useBlockyController( - generator: () => EditorController, + generator: () => EditorController | Promise, deps?: React.DependencyList | undefined ): EditorController | null { const [controller, setController] = useState(null); useEffect(() => { - const controller = generator(); - setController(controller); + let closed = false; + const controllerGetter = generator(); + let editorController: EditorController | undefined; + if (controllerGetter instanceof Promise) { + controllerGetter + .then((c) => { + if (closed) { + c.dispose(); + return; + } + editorController = c; + setController(editorController); + }) + .catch((err) => { + console.error(err); + }); + } else { + editorController = controllerGetter; + setController(editorController); + } return () => { - controller.dispose(); + closed = true; + editorController?.dispose(); }; }, deps); From ae44f804794e2281ec5b83c7a13d22446da58da5 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 14:52:30 +0800 Subject: [PATCH 06/13] feat: introduce idb --- packages/blocky-example/app/loro/loroPlugin.ts | 17 +++++++++++++---- packages/blocky-example/package.json | 1 + pnpm-lock.yaml | 7 +++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts index d06bae6..b187b3d 100644 --- a/packages/blocky-example/app/loro/loroPlugin.ts +++ b/packages/blocky-example/app/loro/loroPlugin.ts @@ -6,7 +6,7 @@ import { DataElement, } from "blocky-core"; import { Loro, LoroMap } from "loro-crdt"; -import { takeUntil } from "rxjs"; +import { take, takeUntil } from "rxjs"; function isPrimitive(value: any) { return ( @@ -104,9 +104,18 @@ class LoroPlugin implements IPlugin { const documentMap = loro.getMap("document"); syncDocumentToLoro(context, state.document, documentMap); - - console.log("loro:", loro.toJson()); - console.log("doc:", state.document); + 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); + }); } } diff --git a/packages/blocky-example/package.json b/packages/blocky-example/package.json index 8934aa7..f801302 100644 --- a/packages/blocky-example/package.json +++ b/packages/blocky-example/package.json @@ -19,6 +19,7 @@ "blocky-core": "workspace:*", "blocky-react": "workspace:*", "highlight.js": "^11.6.0", + "idb": "^7.1.1", "is-hotkey": "^0.2.0", "lodash-es": "^4.17.21", "loro-crdt": "^0.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a89a873..f131acf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: highlight.js: specifier: ^11.6.0 version: 11.6.0 + idb: + specifier: ^7.1.1 + version: 7.1.1 is-hotkey: specifier: ^0.2.0 version: 0.2.0 @@ -1958,6 +1961,10 @@ packages: safer-buffer: 2.1.2 dev: true + /idb@7.1.1: + resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} + dev: false + /ignore@5.2.0: resolution: {integrity: sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==} engines: {node: '>= 4'} From f6e709d06020088883110e4b606e78ebafb21f60 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 15:12:17 +0800 Subject: [PATCH 07/13] feat: write data to store --- .../blocky-example/app/loro/loroExample.tsx | 130 ++++++++++++++++-- .../blocky-example/app/loro/loroPlugin.ts | 87 +++++++++++- 2 files changed, 198 insertions(+), 19 deletions(-) diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index 4b76b4a..1c57cdc 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -9,11 +9,13 @@ import { makeReactSpanner, type SpannerRenderProps, } from "blocky-react"; -import { EditorController, IPlugin } from "blocky-core"; +import { BlockyDocument, EditorController, IPlugin } 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 styles from "./loroExample.module.scss"; function makeEditorPlugins(): IPlugin[] { @@ -23,17 +25,18 @@ function makeEditorPlugins(): IPlugin[] { }), makeCommandPanelPlugin(), makeAtPanelPlugin(), - new LoroPlugin(), ]; } -function makeController(userId: string): EditorController { +function makeController( + userId: string, + plugins: IPlugin[], + doc?: BlockyDocument +): EditorController { return new EditorController(userId, { - title: "Loro", - /** - * Define the plugins to implement customize features. - */ - plugins: makeEditorPlugins(), + title: doc ? undefined : "Loro", + document: doc, + plugins, spannerFactory: makeReactSpanner( ({ editorController, focusedNode }: SpannerRenderProps) => ( > | undefined> { + let snapshot: any; + let versions: 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"); + + versions = await tx.objectStore("versions").index("loroId").getAll(); + + tx.commit(); + + if (snapshot || versions.length > 0) { + const loro = new Loro(); + if (snapshot) { + loro.import(snapshot.data); + } + + loro.importUpdateBatch(versions.map((v: any) => v.data)); + + return loro; + } +} + function LoroExample() { const containerRef = useRef(null); - const controller = useBlockyController(() => { - const controller = makeController("user"); + 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); + + let changeCounter = 0; + + const loroPlugin = new LoroPlugin(loro); + 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(); - controller.pasteHTMLAtCursor( - `Loro is a high-performance CRDTs library. It's written in Rust and introduced to the browser via WASM, offering incredible performance. + await tx.done; + + lastVersion = undefined; + changeCounter = 0; + return; + } + const versions = loroPlugin.loro.version(); + const data = loroPlugin.loro.exportFrom(lastVersion); + await db.add("versions", { + loroId: evt.id.toString(), + version: versions, + data, + createdAt: new Date(), + }); + lastVersion = versions; + changeCounter++; + }); + + const initDoc = loroPlugin.getInitDocumentByLoro(); + const controller = makeController( + "user", + [...makeEditorPlugins(), loroPlugin], + initDoc + ); + + if (!loro) { + 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.` - ); + ); + } return controller; }, []); diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts index b187b3d..9ecfe95 100644 --- a/packages/blocky-example/app/loro/loroPlugin.ts +++ b/packages/blocky-example/app/loro/loroPlugin.ts @@ -4,8 +4,10 @@ import { DataBaseElement, BlockyTextModel, DataElement, + BlockyDocument, + BlockDataElement, } from "blocky-core"; -import { Loro, LoroMap } from "loro-crdt"; +import { Loro, LoroMap, LoroText } from "loro-crdt"; import { take, takeUntil } from "rxjs"; function isPrimitive(value: any) { @@ -93,18 +95,91 @@ function syncDocumentToLoro( }); } +// 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; + } + console.log("key:", key, "value:", value, typeof value); + if (value instanceof LoroText) { + const text = new BlockyTextModel(value.toDelta() as any); + result.__setAttribute(key, text); + } else if (value instanceof LoroMap) { + result.__setAttribute(key, blockyElementFromLoroMap(value)); + } else { + result.__setAttribute(key, value); + } + } + + 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(); + if (title) { + doc.__setAttribute("title", blockyElementFromLoroMap(title)); + } + doc.__setAttribute("body", blockyElementFromLoroMap(body)); + + console.log("doc:", doc); + + return doc; +} + class LoroPlugin implements IPlugin { name = "loro"; - loro: Loro> | undefined; + loro: Loro>; + needsInit = true; + + constructor(loro?: Loro) { + if (loro) { + this.needsInit = false; + } + this.loro = loro ?? new Loro(); + } + + getInitDocumentByLoro() { + const loro = this.loro; + const loroMap = loro.getMap("document"); + + return documentFromLoroMap(loroMap); + } onInitialized(context: PluginContext) { - const loro = new Loro(); + const loro = this.loro; const state = context.editor.state; - this.loro = loro; const documentMap = loro.getMap("document"); - syncDocumentToLoro(context, state.document, documentMap); - loro.commit(); + + if (this.needsInit) { + syncDocumentToLoro(context, state.document, documentMap); + loro.commit(); + } state.changesetApplied2$.pipe(takeUntil(context.dispose$)).subscribe(() => { loro.commit(); From 706022408dd316a6edad2fb1f7e5f063d5487625 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 17:13:12 +0800 Subject: [PATCH 08/13] feat: add active state --- packages/blocky-example/components/sidebar.module.scss | 6 +++++- packages/blocky-example/components/sidebar.tsx | 9 +++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/blocky-example/components/sidebar.module.scss b/packages/blocky-example/components/sidebar.module.scss index a1c4b3f..89faa3e 100644 --- a/packages/blocky-example/components/sidebar.module.scss +++ b/packages/blocky-example/components/sidebar.module.scss @@ -8,11 +8,15 @@ color: var(--primary-text-color); text-decoration: none; font-family: var(--blocky-example-font); - font-size: 14px; + font-size: 13px; &:hover { background-color: rgba(0, 0, 0, 0.1); } + + &:global(.active) { + background-color: rgba(0, 0, 0, 0.05); + } } .content { diff --git a/packages/blocky-example/components/sidebar.tsx b/packages/blocky-example/components/sidebar.tsx index c44743d..e4ddda9 100644 --- a/packages/blocky-example/components/sidebar.tsx +++ b/packages/blocky-example/components/sidebar.tsx @@ -1,4 +1,4 @@ -import React, { memo } from "react"; +import React, { memo, useEffect, useState } from "react"; import Link from "next/link"; import styles from "./sidebar.module.scss"; import { IoHomeOutline } from "react-icons/io5"; @@ -12,8 +12,13 @@ interface SidebarItemProps { function SidebarItem(props: SidebarItemProps) { const { icon, children, href } = props; + const [active, setActive] = useState(false); + useEffect(() => { + const path = window.location.pathname; + setActive(path === href); + }, []); return ( - + {icon ?? }
{children}
From 6f208895845b60baf0ac5311451ca1063f8940e9 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 17:15:51 +0800 Subject: [PATCH 09/13] feat: adjust color --- packages/blocky-example/app/app.scss | 5 +++-- packages/blocky-example/components/navbar.module.scss | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/blocky-example/app/app.scss b/packages/blocky-example/app/app.scss index d302201..70e7f97 100644 --- a/packages/blocky-example/app/app.scss +++ b/packages/blocky-example/app/app.scss @@ -21,7 +21,7 @@ max-width: 260px; border-right-style: solid; border-right-width: 1px; - border-right-color: grey; + border-right-color: var(--border-color); flex-shrink: 0; padding-top: 1rem; @@ -36,7 +36,6 @@ } } - .blocky-example-container { height: 100%; overflow-x: hidden; @@ -60,12 +59,14 @@ body { :root { --bg-color: white; + --border-color: #e0e0e0; --primary-text-color: black; --danger-color: rgb(201, 56, 56); } [data-theme="dark"] { --bg-color: #161625; + --border-color: #1d1d2b; --primary-text-color: #c3c3bf; } diff --git a/packages/blocky-example/components/navbar.module.scss b/packages/blocky-example/components/navbar.module.scss index 40216e2..c149a7c 100644 --- a/packages/blocky-example/components/navbar.module.scss +++ b/packages/blocky-example/components/navbar.module.scss @@ -6,7 +6,7 @@ padding-left: 1.3rem; padding-right: 1.3rem; display: flex; - border-bottom: 1px solid gray; + border-bottom: 1px solid var(--border-color); background-color: var(--bg-color); } From 134621d703ebdb9452c8bf7fc18e02e67cb914b2 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 17:34:23 +0800 Subject: [PATCH 10/13] refactor: themeSwitch --- packages/blocky-example/app/app.scss | 15 +------ .../app/themeSwitch.module.scss | 25 +++++++++++ packages/blocky-example/app/themeSwitch.tsx | 44 +++++++++++-------- .../components/sidebar.module.scss | 2 +- 4 files changed, 53 insertions(+), 33 deletions(-) create mode 100644 packages/blocky-example/app/themeSwitch.module.scss diff --git a/packages/blocky-example/app/app.scss b/packages/blocky-example/app/app.scss index 70e7f97..cba0e4f 100644 --- a/packages/blocky-example/app/app.scss +++ b/packages/blocky-example/app/app.scss @@ -60,6 +60,7 @@ body { :root { --bg-color: white; --border-color: #e0e0e0; + --hover-color: rgba(0, 0, 0, 0.1); --primary-text-color: black; --danger-color: rgb(201, 56, 56); } @@ -67,6 +68,7 @@ body { [data-theme="dark"] { --bg-color: #161625; --border-color: #1d1d2b; + --hover-color: rgba(255, 255, 255, 0.1); --primary-text-color: #c3c3bf; } @@ -105,19 +107,6 @@ body { } } -/*Simple css to style it like a toggle switch*/ -.theme-switch-wrapper { - display: flex; - align-items: center; - - p { - margin-left: 10px; - font-size: 12px; - font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - color: var(--primary-text-color); - } -} - .theme-switch { display: inline-block; height: 20px; diff --git a/packages/blocky-example/app/themeSwitch.module.scss b/packages/blocky-example/app/themeSwitch.module.scss new file mode 100644 index 0000000..4fc52ea --- /dev/null +++ b/packages/blocky-example/app/themeSwitch.module.scss @@ -0,0 +1,25 @@ + +.container { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 4px; + transform: translateY(-1px); + + svg { + width: 20px; + height: 20px; + } + + &:hover { + background-color: var(--hover-color); + } + + &:global(.dark) { + svg { + fill: white; + } + } +} \ No newline at end of file diff --git a/packages/blocky-example/app/themeSwitch.tsx b/packages/blocky-example/app/themeSwitch.tsx index 14ed5f5..4e4dc85 100644 --- a/packages/blocky-example/app/themeSwitch.tsx +++ b/packages/blocky-example/app/themeSwitch.tsx @@ -1,6 +1,14 @@ "use client"; -import React, { createContext, useEffect, useState } from "react"; +import React, { + createContext, + useContext, + useEffect, + useState, + memo, +} from "react"; +import { CiLight, CiDark } from "react-icons/ci"; +import styles from "./themeSwitch.module.scss"; export interface ThemeContext { darkMode: boolean; @@ -50,23 +58,21 @@ export function ThemeProvider(props: ThemeProviderProps) { ); } -export function ThemeSwitch() { +const ThemeSwitch = memo(() => { + const theme = useContext(Theme); return ( - - {(theme) => ( -
- -

Enable Dark Mode

-
- )} -
+
{ + e.preventDefault(); + theme.toggle(); + }} + > + {theme.darkMode ? : } +
); -} +}); + +ThemeSwitch.displayName = "ThemeSwitch"; + +export { ThemeSwitch }; diff --git a/packages/blocky-example/components/sidebar.module.scss b/packages/blocky-example/components/sidebar.module.scss index 89faa3e..15a6503 100644 --- a/packages/blocky-example/components/sidebar.module.scss +++ b/packages/blocky-example/components/sidebar.module.scss @@ -11,7 +11,7 @@ font-size: 13px; &:hover { - background-color: rgba(0, 0, 0, 0.1); + background-color: var(--hover-color); } &:global(.active) { From 0d60c87e925f8219ffb00136699045f2fa21cb03 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 20:30:40 +0800 Subject: [PATCH 11/13] feat: upgrade loro for new data structures --- .../blocky-example/app/loro/loroExample.tsx | 2 +- .../blocky-example/app/loro/loroPlugin.ts | 26 ++++++++++++++----- packages/blocky-example/package.json | 2 +- pnpm-lock.yaml | 14 +++++----- 4 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index 1c57cdc..ece848f 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -155,7 +155,7 @@ function LoroExample() { changeCounter++; }); - const initDoc = loroPlugin.getInitDocumentByLoro(); + const initDoc = loro ? LoroPlugin.getInitDocumentByLoro(loro) : undefined; const controller = makeController( "user", [...makeEditorPlugins(), loroPlugin], diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts index 9ecfe95..d795802 100644 --- a/packages/blocky-example/app/loro/loroPlugin.ts +++ b/packages/blocky-example/app/loro/loroPlugin.ts @@ -7,7 +7,8 @@ import { BlockyDocument, BlockDataElement, } from "blocky-core"; -import { Loro, LoroMap, LoroText } from "loro-crdt"; +import { Loro, LoroMap, LoroText, LoroList } from "loro-crdt"; +import { Delta } from "blocky-core"; import { take, takeUntil } from "rxjs"; function isPrimitive(value: any) { @@ -59,6 +60,11 @@ function syncDocumentToLoro( 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; @@ -123,9 +129,8 @@ function blockyElementFromLoroMap(loroMap: LoroMap): DataElement { if (key === "id" || key === "t" || key === "children") { continue; } - console.log("key:", key, "value:", value, typeof value); if (value instanceof LoroText) { - const text = new BlockyTextModel(value.toDelta() as any); + const text = new BlockyTextModel(new Delta(value.toDelta())); result.__setAttribute(key, text); } else if (value instanceof LoroMap) { result.__setAttribute(key, blockyElementFromLoroMap(value)); @@ -134,6 +139,16 @@ function blockyElementFromLoroMap(loroMap: LoroMap): DataElement { } } + 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.__insertChildAt(i, blockyElementFromLoroMap(child)); + } + } + } + return result; } @@ -146,8 +161,6 @@ function documentFromLoroMap(loroMap: LoroMap): BlockyDocument { } doc.__setAttribute("body", blockyElementFromLoroMap(body)); - console.log("doc:", doc); - return doc; } @@ -163,8 +176,7 @@ class LoroPlugin implements IPlugin { this.loro = loro ?? new Loro(); } - getInitDocumentByLoro() { - const loro = this.loro; + static getInitDocumentByLoro(loro: Loro) { const loroMap = loro.getMap("document"); return documentFromLoroMap(loroMap); diff --git a/packages/blocky-example/package.json b/packages/blocky-example/package.json index f801302..92f3878 100644 --- a/packages/blocky-example/package.json +++ b/packages/blocky-example/package.json @@ -22,7 +22,7 @@ "idb": "^7.1.1", "is-hotkey": "^0.2.0", "lodash-es": "^4.17.21", - "loro-crdt": "^0.4.3", + "loro-crdt": "^0.5.0", "marked": "^4.0.18", "next": "^13.5.6", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f131acf..c12c712 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,8 +122,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 loro-crdt: - specifier: ^0.4.3 - version: 0.4.3 + specifier: ^0.5.0 + version: 0.5.0 marked: specifier: ^4.0.18 version: 4.0.18 @@ -2196,14 +2196,14 @@ packages: js-tokens: 4.0.0 dev: false - /loro-crdt@0.4.3: - resolution: {integrity: sha512-ofUEXrieS2py9t0Ee+7ebgCCRwbiQthP9SuQ/J1AmUsOQl2+2feYqPlTYFKdP1BvAnus1SCNLNng4jRJlv/ydg==} + /loro-crdt@0.5.0: + resolution: {integrity: sha512-O7vBYOI1bGQZKlF2UabGVMbIqNtyHKsFUfz3gAy65EzNhFdxFV5BMlm5ViUMd2RAjKHQ8VKuTabV7zeim18cOw==} dependencies: - loro-wasm: 0.4.3 + loro-wasm: 0.5.0 dev: false - /loro-wasm@0.4.3: - resolution: {integrity: sha512-k9Nr2wlRCX59ar4o0CU840vabeB+NOEkkrGG02hWsT2MaLGJ5IeKrAK22DpMrdhDgX1F4L8A6L+cL6vDlpirRA==} + /loro-wasm@0.5.0: + resolution: {integrity: sha512-IvKvlo7cGJo/sR8loy45fugYko3rCGtanphT7dHD9cYJLiOHeKQqTlXYVPmLmfED6ALqfNxpvRWQvLglMW6EtQ==} dev: false /loupe@2.3.4: From c60ccbe7a712be6562a7a6e78acab461cc722154 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 20:42:09 +0800 Subject: [PATCH 12/13] feat: bind text events --- .../blocky-example/app/loro/loroExample.tsx | 2 + .../blocky-example/app/loro/loroPlugin.ts | 63 +++++++++++-------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index ece848f..cb0fe08 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -156,6 +156,7 @@ function LoroExample() { }); const initDoc = loro ? LoroPlugin.getInitDocumentByLoro(loro) : undefined; + console.log("initDoc", initDoc); const controller = makeController( "user", [...makeEditorPlugins(), loroPlugin], @@ -163,6 +164,7 @@ function LoroExample() { ); if (!loro) { + console.log("paste"); 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. diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts index d795802..ce5625e 100644 --- a/packages/blocky-example/app/loro/loroPlugin.ts +++ b/packages/blocky-example/app/loro/loroPlugin.ts @@ -19,11 +19,7 @@ function isPrimitive(value: any) { ); } -function syncDocumentToLoro( - ctx: PluginContext, - doc: DataBaseElement, - loroMap: LoroMap -) { +function syncDocumentToLoro(doc: DataBaseElement, loroMap: LoroMap) { const attribs = doc.getAttributes(); loroMap.set("t", doc.t); @@ -40,15 +36,12 @@ function syncDocumentToLoro( } } else if (value instanceof DataBaseElement) { const childLoroMap = loroMap.setContainer(key, "Map"); - syncDocumentToLoro(ctx, value, childLoroMap); + syncDocumentToLoro(value, childLoroMap); } else if (value instanceof BlockyTextModel) { const loroText = loroMap.setContainer(key, "Text"); loroText.applyDelta(value.delta.ops); - value.changed$.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { - // console.log("content:", loroText.toString()); - loroText.applyDelta(evt.apply.ops); - }); + bindTextModelToLoroText(value, loroText); } else if (typeof value === "object") { const childLoroMap = loroMap.setContainer(key, "Map"); for (const [childKey, childValue] of Object.entries(value)) { @@ -70,17 +63,31 @@ function syncDocumentToLoro( let counter = 0; while (ptr) { const subDoc = children.insertContainer(counter, "Map"); - syncDocumentToLoro(ctx, ptr as DataBaseElement, subDoc); + syncDocumentToLoro(ptr as DataBaseElement, subDoc); ptr = ptr.nextSibling; counter++; } - doc.changed.pipe(takeUntil(ctx.dispose$)).subscribe((evt) => { + 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(ctx, evt.child as DataBaseElement, loroChild); + syncDocumentToLoro(evt.child as DataBaseElement, loroChild); break; } @@ -132,6 +139,7 @@ function blockyElementFromLoroMap(loroMap: LoroMap): DataElement { 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 { @@ -144,22 +152,25 @@ function blockyElementFromLoroMap(loroMap: LoroMap): DataElement { for (let i = 0, len = children.length; i < len; i++) { const child = children.get(i); if (child instanceof LoroMap) { - result.__insertChildAt(i, blockyElementFromLoroMap(child)); + 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(); - if (title) { - doc.__setAttribute("title", blockyElementFromLoroMap(title)); - } - doc.__setAttribute("body", blockyElementFromLoroMap(body)); + const doc = new BlockyDocument({ + title: title + ? (blockyElementFromLoroMap(title) as BlockDataElement) + : undefined, + body: blockyElementFromLoroMap(body), + }); return doc; } @@ -189,7 +200,7 @@ class LoroPlugin implements IPlugin { const documentMap = loro.getMap("document"); if (this.needsInit) { - syncDocumentToLoro(context, state.document, documentMap); + syncDocumentToLoro(state.document, documentMap); loro.commit(); } @@ -197,12 +208,12 @@ class LoroPlugin implements IPlugin { loro.commit(); }); - const sub = loro.subscribe((evt) => { - console.log("loro evt:", evt, "version:", loro.frontiers()); - }); - context.dispose$.pipe(take(1)).subscribe(() => { - loro.unsubscribe(sub); - }); + // const sub = loro.subscribe((evt) => { + // console.log("loro evt:", evt, "version:", loro.frontiers()); + // }); + // context.dispose$.pipe(take(1)).subscribe(() => { + // loro.unsubscribe(sub); + // }); } } From 2749becb6fbeaae63f18faf13516cce484149cf2 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Mon, 27 Nov 2023 21:30:01 +0800 Subject: [PATCH 13/13] fix: build errors --- packages/blocky-example/app/loro/loroExample.tsx | 3 +-- packages/blocky-example/app/loro/loroPlugin.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index cb0fe08..17b4ba6 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -61,7 +61,6 @@ async function tryReadLoroFromIdb( db: IDBPDatabase ): Promise> | undefined> { let snapshot: any; - let versions: any; { const tx = db.transaction("snapshot", "readonly"); @@ -78,7 +77,7 @@ async function tryReadLoroFromIdb( const tx = db.transaction("versions", "readonly"); - versions = await tx.objectStore("versions").index("loroId").getAll(); + const versions = await tx.objectStore("versions").index("loroId").getAll(); tx.commit(); diff --git a/packages/blocky-example/app/loro/loroPlugin.ts b/packages/blocky-example/app/loro/loroPlugin.ts index ce5625e..224f2b8 100644 --- a/packages/blocky-example/app/loro/loroPlugin.ts +++ b/packages/blocky-example/app/loro/loroPlugin.ts @@ -9,7 +9,7 @@ import { } from "blocky-core"; import { Loro, LoroMap, LoroText, LoroList } from "loro-crdt"; import { Delta } from "blocky-core"; -import { take, takeUntil } from "rxjs"; +import { takeUntil } from "rxjs"; function isPrimitive(value: any) { return (