diff --git a/packages/blocky-core/src/index.ts b/packages/blocky-core/src/index.ts index cb86259..675d073 100644 --- a/packages/blocky-core/src/index.ts +++ b/packages/blocky-core/src/index.ts @@ -7,11 +7,6 @@ export * from "./block/basic"; export { ContentBlock } from "./block/contentBlock"; export { CustomBlock } from "./block/customBlock"; export { makeDefaultIdGenerator, type IdGenerator } from "./helper/idHelper"; -export { - type SpannerFactory, - type SpannerInstance, - SpannerDelegate, -} from "./view/spannerDelegate"; export { type ToolbarFactory, type Toolbar } from "./view/toolbarDelegate"; export { FollowerWidget } from "./view/followerWidget"; export { @@ -25,6 +20,7 @@ export { PluginRegistry, PluginContext, } from "./registry/pluginRegistry"; +export * from "./plugins/spannerPlugin"; export { BlockRegistry } from "./registry/blockRegistry"; export { type SpanStyle, SpanRegistry } from "./registry/spanRegistry"; export { diff --git a/packages/blocky-core/src/plugins/spannerPlugin/index.ts b/packages/blocky-core/src/plugins/spannerPlugin/index.ts new file mode 100644 index 0000000..b9e7e1c --- /dev/null +++ b/packages/blocky-core/src/plugins/spannerPlugin/index.ts @@ -0,0 +1,13 @@ +import { SpannerPlugin } from "./spannerPlugin"; +import { + SpannerDelegate, + type SpannerFactory, + type SpannerInstance, +} from "./spannerDelegate"; + +export { + SpannerPlugin, + SpannerDelegate, + type SpannerFactory, + type SpannerInstance, +}; diff --git a/packages/blocky-core/src/view/spannerDelegate.spec.ts b/packages/blocky-core/src/plugins/spannerPlugin/spannerDelegate.spec.ts similarity index 83% rename from packages/blocky-core/src/view/spannerDelegate.spec.ts rename to packages/blocky-core/src/plugins/spannerPlugin/spannerDelegate.spec.ts index 647bbdc..45da105 100644 --- a/packages/blocky-core/src/view/spannerDelegate.spec.ts +++ b/packages/blocky-core/src/plugins/spannerPlugin/spannerDelegate.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { SpannerDelegate, SpannerInstance } from "./spannerDelegate"; -import { EditorController } from "./controller"; -import { BlockDataElement } from ".."; +import { EditorController } from "../../view/controller"; +import { BlockDataElement, Editor } from "../.."; describe("SpannerDelegate", () => { it("focusedNode", () => { @@ -14,7 +14,8 @@ describe("SpannerDelegate", () => { const mount = document.createElement("div"); - const delegate = new SpannerDelegate(editorController, () => { + const editor = Editor.fromController(mount, editorController); + const delegate = new SpannerDelegate(editor, () => { return spannerInstance; }); delegate.mount(mount); diff --git a/packages/blocky-core/src/view/spannerDelegate.ts b/packages/blocky-core/src/plugins/spannerPlugin/spannerDelegate.ts similarity index 80% rename from packages/blocky-core/src/view/spannerDelegate.ts rename to packages/blocky-core/src/plugins/spannerPlugin/spannerDelegate.ts index a971ae4..298d426 100644 --- a/packages/blocky-core/src/view/spannerDelegate.ts +++ b/packages/blocky-core/src/plugins/spannerPlugin/spannerDelegate.ts @@ -1,8 +1,9 @@ import { type IDisposable } from "blocky-common/es"; import type { EditorController } from "@pkg/view/controller"; import type { BlockDataElement } from "@pkg/data"; -import { UIDelegate } from "./uiDelegate"; +import { UIDelegate } from "@pkg/view/uiDelegate"; import { fromEvent, takeUntil } from "rxjs"; +import type { Editor } from "@pkg/view/editor"; export interface SpannerInstance extends IDisposable { onFocusedNodeChanged?(focusedNode: BlockDataElement | undefined): void; @@ -30,14 +31,7 @@ export class SpannerDelegate extends UIDelegate { this.#instance?.onFocusedNodeChanged?.(v); } - get width(): number { - return 28; - } - - constructor( - private editorController: EditorController, - private factory: SpannerFactory - ) { + constructor(public editor: Editor, private factory: SpannerFactory) { super("blocky-editor-spanner-delegate blocky-cm-noselect"); // draggable this.container.setAttribute("draggable", "true"); @@ -49,17 +43,14 @@ export class SpannerDelegate extends UIDelegate { } #handleDragStart() { - const editor = this.editorController.editor; - if (!editor) { - return; - } - editor.darggingNode = this.focusedNode; + this.editor.darggingNode = this.focusedNode; } override mount(parent: HTMLElement): void { super.mount(parent); - this.#instance = this.factory(this.container, this.editorController, this); + const editorController = this.editor.controller; + this.#instance = this.factory(this.container, editorController, this); if (this.#instance) { this.disposables.push(this.#instance); } diff --git a/packages/blocky-core/src/plugins/spannerPlugin/spannerPlugin.ts b/packages/blocky-core/src/plugins/spannerPlugin/spannerPlugin.ts new file mode 100644 index 0000000..df10725 --- /dev/null +++ b/packages/blocky-core/src/plugins/spannerPlugin/spannerPlugin.ts @@ -0,0 +1,90 @@ +import { BlockDataElement, Editor, IPlugin, PluginContext } from "../.."; +import { take, takeUntil, fromEvent } from "rxjs"; +import { SpannerDelegate, SpannerFactory } from "./spannerDelegate"; +import { type Position } from "blocky-common/es"; + +const defaultWidth = 48; + +export interface SpannerPluginOptions { + factory: SpannerFactory; + width?: number; + mountPoint?: HTMLElement; +} + +export class SpannerPlugin implements IPlugin { + deletage: SpannerDelegate | undefined; + name = "spanner"; + + constructor(readonly options: SpannerPluginOptions) {} + + get container(): HTMLElement | undefined { + return this.options.mountPoint ?? this.deletage?.editor.container; + } + + onInitialized(context: PluginContext): void { + const { editor, dispose$ } = context; + this.deletage = new SpannerDelegate(editor, this.options.factory); + + const container = this.options.mountPoint ?? editor.container; + this.deletage.mount(container); + + editor.placeSpannerAt$ + .pipe(takeUntil(dispose$)) + .subscribe(({ blockContainer, node }) => { + this.placeSpannerAt(editor, blockContainer, node); + }); + + fromEvent(container, "mouseleave") + .pipe(takeUntil(dispose$)) + .subscribe(() => { + this.deletage?.hide(); + }); + + dispose$.pipe(take(1)).subscribe(() => { + this.deletage?.dispose(); + this.deletage = undefined; + }); + } + + protected placeSpannerAt( + editor: Editor, + blockContainer: HTMLElement, + node: BlockDataElement + ) { + if (!this.deletage) { + return; + } + const block = editor.state.blocks.get(node.id); + if (!block) { + return; + } + let { x, y } = this.getRelativeOffsetByDom(blockContainer); + const offset = block.getSpannerOffset(); + x += offset.x; + y += offset.y; + x -= this.width; + this.deletage.focusedNode = node; + this.deletage.show(); + this.deletage.setPosition(x, y); + } + + get width(): number { + return this.options.width ?? defaultWidth; + } + + /** + * Get the element's relative position to the container of the editor. + */ + protected getRelativeOffsetByDom(element: HTMLElement): Position { + const container = this.container; + if (!container) { + return { x: 0, y: 0 }; + } + const containerRect = container.getBoundingClientRect(); + const blockRect = element.getBoundingClientRect(); + return { + x: blockRect.x - containerRect.x, + y: blockRect.y - containerRect.y, + }; + } +} diff --git a/packages/blocky-core/src/registry/pluginRegistry.spec.ts b/packages/blocky-core/src/registry/pluginRegistry.spec.ts index ec757bf..cd248c7 100644 --- a/packages/blocky-core/src/registry/pluginRegistry.spec.ts +++ b/packages/blocky-core/src/registry/pluginRegistry.spec.ts @@ -7,10 +7,8 @@ describe("PluginRegistry", () => { const plugin: IPlugin = { name: "test", onInitialized() {}, - onDispose() {}, }; const onInitSpy = vi.spyOn(plugin, "onInitialized"); - const disposeSpy = vi.spyOn(plugin, "onDispose"); const pluginRegistry = new PluginRegistry([plugin]); const editorController = new EditorController("user"); const dom = document.createElement("div"); @@ -18,9 +16,7 @@ describe("PluginRegistry", () => { pluginRegistry.initAllPlugins(editor); expect(onInitSpy).toBeCalledTimes(1); - expect(disposeSpy).toBeCalledTimes(0); pluginRegistry.unload("test"); - expect(disposeSpy).toBeCalledTimes(1); }); }); diff --git a/packages/blocky-core/src/registry/pluginRegistry.ts b/packages/blocky-core/src/registry/pluginRegistry.ts index db10d50..c8ee4de 100644 --- a/packages/blocky-core/src/registry/pluginRegistry.ts +++ b/packages/blocky-core/src/registry/pluginRegistry.ts @@ -44,8 +44,6 @@ export interface IPlugin { */ onInitialized?(context: PluginContext): void; - onDispose?(context: PluginContext): void; - onPaste?(evt: BlockyPasteEvent): void; } @@ -99,7 +97,6 @@ export class PluginRegistry { if (context) { context.dispose(); this.contexts.delete(name); - plugin.onDispose?.(context); } } diff --git a/packages/blocky-core/src/view/controller.ts b/packages/blocky-core/src/view/controller.ts index 8aba047..cf774b7 100644 --- a/packages/blocky-core/src/view/controller.ts +++ b/packages/blocky-core/src/view/controller.ts @@ -21,7 +21,6 @@ import { PluginRegistry, type IPlugin } from "@pkg/registry/pluginRegistry"; import { SpanRegistry } from "@pkg/registry/spanRegistry"; import { EmbedRegistry } from "@pkg/registry/embedRegistry"; import { HTMLConverter } from "@pkg/helper/htmlConverter"; -import { type SpannerFactory } from "@pkg/view/spannerDelegate"; import { type ToolbarFactory } from "@pkg/view/toolbarDelegate"; import { type IdGenerator, makeDefaultIdGenerator } from "@pkg/helper/idHelper"; import { BlockPasteEvent, TryParsePastedDOMEvent } from "@pkg/block/basic"; @@ -89,7 +88,6 @@ export interface IEditorControllerOptions { blockRegistry?: BlockRegistry; embedRegistry?: EmbedRegistry; idGenerator?: IdGenerator; - spannerFactory?: SpannerFactory; toolbarFactory?: ToolbarFactory; /** diff --git a/packages/blocky-core/src/view/editor.ts b/packages/blocky-core/src/view/editor.ts index 712c947..31dbc22 100644 --- a/packages/blocky-core/src/view/editor.ts +++ b/packages/blocky-core/src/view/editor.ts @@ -4,7 +4,6 @@ import { areEqualShallow, flattenDisposable, type IDisposable, - type Position, } from "blocky-common/es"; import { Subject, @@ -48,7 +47,6 @@ import { SpanRegistry } from "@pkg/registry/spanRegistry"; import { BlockRegistry } from "@pkg/registry/blockRegistry"; import { type IdGenerator, makeDefaultIdGenerator } from "@pkg/helper/idHelper"; import { textToDeltaWithURL } from "@pkg/helper/urlHelper"; -import { SpannerDelegate, type SpannerFactory } from "./spannerDelegate"; import { ToolbarDelegate, type ToolbarFactory } from "./toolbarDelegate"; import { TextBlock } from "@pkg/block/textBlock"; import { EditorController } from "./controller"; @@ -85,7 +83,6 @@ export interface IEditorOptions { registry: EditorRegistry; container: HTMLDivElement; idGenerator?: IdGenerator; - spannerFactory?: SpannerFactory; toolbarFactory?: ToolbarFactory; padding?: Partial; collaborativeCursorFactory?: CollaborativeCursorFactory; @@ -142,7 +139,7 @@ const spannerIgnoreBlockTypes = new Set([TitleBlock.Name]); * used by the plugins to do something internally. */ export class Editor { - #container: HTMLDivElement; + readonly container: HTMLDivElement; #renderedDom: HTMLDivElement | undefined; #renderer: DocRenderer; #lastFocusedId: string | undefined; @@ -162,7 +159,7 @@ export class Editor { readonly onEveryBlock: Subject = new Subject(); - readonly spannerDelegate?: SpannerDelegate; + // readonly spannerDelegate?: SpannerDelegate; readonly toolbarDelegate: ToolbarDelegate; idGenerator: IdGenerator; @@ -221,7 +218,6 @@ export class Editor { block: controller.blockRegistry, }, state: controller.state, - spannerFactory: controller.options?.spannerFactory, toolbarFactory: controller.options?.toolbarFactory, padding: controller.options?.padding, collaborativeCursorFactory: @@ -237,14 +233,13 @@ export class Editor { state, registry, idGenerator, - spannerFactory: bannerFactory, toolbarFactory, padding, collaborativeCursorFactory, } = options; this.state = state; this.registry = registry; - this.#container = container; + this.container = container; this.idGenerator = idGenerator ?? makeDefaultIdGenerator(); this.padding = { @@ -255,19 +250,13 @@ export class Editor { this.collaborativeCursorManager = new CollaborativeCursorManager( collaborativeCursorFactory ); - this.collaborativeCursorManager.mount(this.#container); - - if (bannerFactory) { - this.spannerDelegate = new SpannerDelegate(controller, bannerFactory); - this.spannerDelegate.mount(this.#container); - this.disposables.push(this.spannerDelegate); - } + this.collaborativeCursorManager.mount(this.container); this.toolbarDelegate = new ToolbarDelegate({ controller, factory: toolbarFactory, }); - this.toolbarDelegate.mount(this.#container); + this.toolbarDelegate.mount(this.container); this.disposables.push(this.toolbarDelegate); document.addEventListener("selectionchange", this.#selectionChangedHandler); @@ -276,10 +265,6 @@ export class Editor { .pipe(takeUntil(this.dispose$)) .subscribe(this.handleCursorStateChanged); - fromEvent(container, "mouseleave") - .pipe(takeUntil(this.dispose$)) - .subscribe(this.#hideSpanner); - this.registry.plugin.initAllPlugins(this); this.#renderer = new DocRenderer({ @@ -366,7 +351,7 @@ export class Editor { } const cursor = this.collaborativeCursorManager.getOrInit(id); - const containerRect = this.#container.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); if (state.isCollapsed) { this.#indeedDrawCollabCollapsedCursor( id, @@ -529,7 +514,7 @@ export class Editor { const cssVariables = themeDataToCssVariables(themeData); for (const [key, value] of Object.entries(cssVariables)) { - this.#container.style.setProperty(key, value); + this.container.style.setProperty(key, value); } } @@ -537,7 +522,7 @@ export class Editor { try { const newDom = this.#renderer.render(option, this.#renderedDom); if (!this.#renderedDom) { - this.#container.appendChild(newDom); + this.container.appendChild(newDom); newDom.contentEditable = "true"; if (this.controller.options?.spellcheck === false) { newDom.spellcheck = false; @@ -547,7 +532,7 @@ export class Editor { .pipe(takeUntil(this.dispose$)) .subscribe(this.handleThemeChanged.bind(this)); - fromEvent(this.#container, "mousemove") + fromEvent(this.container, "mousemove") .pipe( takeUntil(this.dispose$), filter((e: MouseEvent) => { @@ -757,7 +742,7 @@ export class Editor { const range = sel.getRangeAt(0); const { startContainer, endContainer, startOffset, endOffset } = range; - if (!isContainNode(startContainer, this.#container)) { + if (!isContainNode(startContainer, this.container)) { this.state.__setCursorState(null, reason); return; } @@ -858,7 +843,7 @@ export class Editor { return false; } - const containerRect = this.#container.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); // Do NOT call getBoundingClientRect, we need the first rect // not the rect of all ranges. const rect = range.getClientRects()[0]; @@ -1004,7 +989,7 @@ export class Editor { createSearchContext(content: string): SearchContext { if (!this.#searchContext) { - this.#searchContext = new SearchContext(this.#container, this); + this.#searchContext = new SearchContext(this.container, this); this.#searchContext.dispose$.pipe(take(1)).subscribe(() => { this.#searchContext = undefined; }); @@ -1013,26 +998,20 @@ export class Editor { return this.#searchContext; } - placeSpannerAt(blockContainer: HTMLElement, node: BlockDataElement) { - if (!this.spannerDelegate) { - return; - } - const block = this.state.blocks.get(node.id); - if (!block) { - return; - } - - let { x, y } = this.#getRelativeOffsetByDom(blockContainer); + #placeSpannerAt$ = new Subject<{ + blockContainer: HTMLElement; + node: BlockDataElement; + }>(); - const offset = block.getSpannerOffset(); - x += offset.x; - y += offset.y; - - x -= this.spannerDelegate.width; + get placeSpannerAt$(): Observable<{ + blockContainer: HTMLElement; + node: BlockDataElement; + }> { + return this.#placeSpannerAt$.asObservable(); + } - this.spannerDelegate.focusedNode = node; - this.spannerDelegate.show(); - this.spannerDelegate.setPosition(x, y); + placeSpannerAt(blockContainer: HTMLElement, node: BlockDataElement) { + this.#placeSpannerAt$.next({ blockContainer, node }); } /** @@ -1053,22 +1032,6 @@ export class Editor { removeNode(node); } - /** - * Get the element's relative position to the container of the editor. - */ - #getRelativeOffsetByDom(element: HTMLElement): Position { - const containerRect = this.#container.getBoundingClientRect(); - const blockRect = element.getBoundingClientRect(); - return { - x: blockRect.x - containerRect.x, - y: blockRect.y - containerRect.y, - }; - } - - #hideSpanner = () => { - this.spannerDelegate?.hide(); - }; - #handleCompositionStart = () => { this.composing = true; this.compositionStart$.next(); @@ -1248,7 +1211,7 @@ export class Editor { } const range = selection.getRangeAt(0); - if (!isContainNode(range.startContainer, this.#container)) { + if (!isContainNode(range.startContainer, this.container)) { return; } @@ -1526,7 +1489,7 @@ export class Editor { } const range = sel.getRangeAt(0); const startContainer = range.startContainer; - if (isContainNode(startContainer, this.#container)) { + if (isContainNode(startContainer, this.container)) { sel.removeAllRanges(); } return; @@ -1809,7 +1772,7 @@ export class Editor { insertFollowerWidget(widget: FollowerWidget) { this.#followerWidget?.dispose(); this.#followerWidget = widget; - this.#container.insertBefore(widget.container, this.#container.firstChild); + this.container.insertBefore(widget.container, this.container.firstChild); widget.startCursorState = this.state.cursorState!; widget.dispose$.pipe(take(1)).subscribe(() => { this.#followerWidget = undefined; @@ -1844,7 +1807,7 @@ export class Editor { followWidget ); - const containerRect = this.#container.getBoundingClientRect(); + const containerRect = this.container.getBoundingClientRect(); x -= containerRect.x; y -= containerRect.y; diff --git a/packages/blocky-example/app/app.tsx b/packages/blocky-example/app/app.tsx index 07025bb..c5d7ee6 100644 --- a/packages/blocky-example/app/app.tsx +++ b/packages/blocky-example/app/app.tsx @@ -1,7 +1,12 @@ "use client"; import { Component, RefObject, useEffect, useRef, useState } from "react"; -import { EditorController, darkTheme, type IPlugin } from "blocky-core"; +import { + EditorController, + darkTheme, + type IPlugin, + SpannerPlugin, +} from "blocky-core"; import { BlockyEditor, ImageBlockPlugin, @@ -24,6 +29,13 @@ function makeEditorPlugins(): IPlugin[] { new ImageBlockPlugin({ placeholder: ({ setSrc }) => , }), + /** + * Tell the editor how to render the banner. + * We use a banner written in Preact here. + */ + new SpannerPlugin({ + factory: makeDefaultReactSpanner(), + }), makeCommandPanelPlugin(), makeAtPanelPlugin(), ]; @@ -54,12 +66,6 @@ function makeController(userId: string, title: string): EditorController { */ plugins: makeEditorPlugins(), - /** - * Tell the editor how to render the banner. - * We use a banner written in Preact here. - */ - spannerFactory: makeDefaultReactSpanner(), - /** * Tell the editor how to render the banner. * We use a toolbar written in Preact here. diff --git a/packages/blocky-example/app/loro/loroExample.tsx b/packages/blocky-example/app/loro/loroExample.tsx index 24a3d42..fd9a00f 100644 --- a/packages/blocky-example/app/loro/loroExample.tsx +++ b/packages/blocky-example/app/loro/loroExample.tsx @@ -7,7 +7,12 @@ import { DefaultToolbarMenu, makeDefaultReactSpanner, } from "blocky-react"; -import { BlockyDocument, EditorController, IPlugin } from "blocky-core"; +import { + BlockyDocument, + EditorController, + IPlugin, + SpannerPlugin, +} from "blocky-core"; import ImagePlaceholder from "@pkg/components/imagePlaceholder"; import { makeCommandPanelPlugin } from "@pkg/app/plugins/commandPanel"; import { makeAtPanelPlugin } from "@pkg/app/plugins/atPanel"; @@ -21,6 +26,9 @@ function makeEditorPlugins(): IPlugin[] { new ImageBlockPlugin({ placeholder: ({ setSrc }) => , }), + new SpannerPlugin({ + factory: makeDefaultReactSpanner(), + }), makeCommandPanelPlugin(), makeAtPanelPlugin(), ]; @@ -35,7 +43,6 @@ function makeController( title: doc ? undefined : "Loro", document: doc, plugins, - spannerFactory: makeDefaultReactSpanner(), /** * Tell the editor how to render the banner. * We use a toolbar written in Preact here.