From 690a5f5a9f1a28bf59a5ba05bcc0ec0282eb4efc Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Thu, 23 May 2024 22:16:58 +0200 Subject: [PATCH] refactor: Element custom views, collaboration improvements (#75) --- apps/web/package.json | 1 + .../block-action-menu/component.tsx | 224 +-- .../extensions/block-action-menu/options.tsx | 163 +++ .../extensions/block-action-menu/plugin.tsx | 29 +- .../lib/editor/extensions/collab-cursor.tsx | 36 +- .../index.ts} | 4 +- .../extensions/collaboration/sync-plugin.js | 1222 +++++++++++++++++ .../extensions/custom-node-menu/component.tsx | 108 -- .../extensions/custom-node-menu/index.ts | 1 - .../extensions/custom-node-menu/plugin.tsx | 123 -- .../extensions/element/custom-node-view.tsx | 86 +- .../lib/editor/extensions/element/index.ts | 1 + .../lib/editor/extensions/element/node.tsx | 478 ++++--- .../editor/extensions/element/selection.ts | 21 +- .../lib/editor/extensions/element/utils.ts | 30 +- .../editor/extensions/element/view-manager.ts | 54 + .../extensions/element/xml-node-view.tsx | 68 +- apps/web/src/lib/editor/extensions/index.ts | 1 - .../src/lib/editor/extensions/shortcuts.ts | 1 + .../extensions/xml-node-menu/component.tsx | 39 +- .../extensions/xml-node-menu/editor.tsx | 114 +- .../extensions/xml-node-menu/plugin.tsx | 5 +- apps/web/src/lib/extensions/sandbox.ts | 4 + apps/web/src/lib/utils/selection.tsx | 34 +- apps/web/src/styles/index.ts | 2 +- apps/web/src/styles/styles.scss | 16 +- apps/web/src/views/editor/editor.tsx | 2 - packages/components/src/primitives/button.tsx | 8 +- pnpm-lock.yaml | 19 +- 29 files changed, 2210 insertions(+), 684 deletions(-) create mode 100644 apps/web/src/lib/editor/extensions/block-action-menu/options.tsx rename apps/web/src/lib/editor/extensions/{collaboration.ts => collaboration/index.ts} (91%) create mode 100644 apps/web/src/lib/editor/extensions/collaboration/sync-plugin.js delete mode 100644 apps/web/src/lib/editor/extensions/custom-node-menu/component.tsx delete mode 100644 apps/web/src/lib/editor/extensions/custom-node-menu/index.ts delete mode 100644 apps/web/src/lib/editor/extensions/custom-node-menu/plugin.tsx create mode 100644 apps/web/src/lib/editor/extensions/element/view-manager.ts diff --git a/apps/web/package.json b/apps/web/package.json index f55c1422..b52d5574 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -53,6 +53,7 @@ "clsx": "^2.1.0", "dayjs": "^1.11.10", "dompurify": "^3.0.8", + "lib0": "^0.2.94", "marked": "^12.0.0", "minisearch": "^6.3.0", "monaco-editor": "^0.45.0", diff --git a/apps/web/src/lib/editor/extensions/block-action-menu/component.tsx b/apps/web/src/lib/editor/extensions/block-action-menu/component.tsx index ed7af8ef..6bad36b7 100644 --- a/apps/web/src/lib/editor/extensions/block-action-menu/component.tsx +++ b/apps/web/src/lib/editor/extensions/block-action-menu/component.tsx @@ -1,3 +1,4 @@ +import { OptionsDropdown } from "./options"; import { debounce } from "@solid-primitives/scheduled"; import { Range, createNodeFromContent, generateJSON } from "@tiptap/core"; import { Node as PMNode } from "@tiptap/pm/model"; @@ -26,6 +27,7 @@ interface BlockActionMenuProps { editor: SolidEditor; range: Range | null; node: PMNode | null; + pos: number | null; repositionMenu: () => void; }; } @@ -64,12 +66,12 @@ const BlockActionMenu: Component = (props) => { const { notify } = useNotifications(); const { installedExtensions } = useExtensions(); const { storage } = useLocalStorage(); - const [computeDropdownPosition, setComputeDropdownPosition] = createRef(() => {}); const [containerRef, setContainerRef] = createRef(null); const [range, setRange] = createSignal(props.state.range); const [node, setNode] = createSignal(props.state.node); const [locked, setLocked] = createSignal(false); const [opened, setOpened] = createSignal(false); + const replaceContentCallbacks: Array<() => void> = []; const { repositionMenu } = props.state; const unlock = debounce(() => { setLocked(false); @@ -99,6 +101,9 @@ const BlockActionMenu: Component = (props) => { return blockActions; }); + const topLevelNode = (): boolean => { + return props.state.editor.state.doc.resolve(props.state.pos || 0).depth <= 1; + }; const usableEnvData = (): { content: JSONContent } => { return { content: node()?.toJSON() || { type: "doc", content: [] } }; }; @@ -143,113 +148,136 @@ const BlockActionMenu: Component = (props) => { )} ref={setContainerRef} > - - {({ blockAction, extension }) => { - const [scrollableContainerRef, setScrollableContainerRef] = createRef( - null - ); + + { + replaceContentCallbacks.push(callback); + onCleanup(() => { + replaceContentCallbacks.splice(replaceContentCallbacks.indexOf(callback), 1); + }); + }} + /> + + + + {({ blockAction, extension }) => { + const [scrollableContainerRef, setScrollableContainerRef] = + createRef(null); - return ( - { - return node()?.type.name === props.state.editor.schema.nodes[block].name; - }) && - "hidden" - )} - cardProps={{ class: "p-0 m-0 -ml-1 pr-1.5 p-3" }} - overlayProps={{ - onOverlayClick: () => { - if (!locked()) { - setOpened(false); + return ( + { + return node()?.type.name === props.state.editor.schema.nodes[block].name; + }) && + "hidden" + )} + cardProps={{ class: "p-0 m-0 -ml-1 pr-1.5 p-3" }} + overlayProps={{ + onOverlayClick: () => { + if (!locked()) { + setOpened(false); + } } - } - }} - opened={opened()} - setOpened={setOpened} - activatorButton={(props) => { - setComputeDropdownPosition(props.computeDropdownPosition); + }} + opened={opened()} + setOpened={setOpened} + activatorButton={(props) => { + replaceContentCallbacks.push(props.computeDropdownPosition); + onCleanup(() => { + replaceContentCallbacks.splice( + replaceContentCallbacks.indexOf(props.computeDropdownPosition), + 1 + ); + }); - return ( - - - - ); - }} - > -
+ + + ); + }} > - - - extension={extension} - ctx={{ - contextFunctions: ["notify", "replaceContent", "refreshContent"], - usableEnv: { readable: ["content"], writable: [] }, - config: extension.config || {} - }} - func={{ - notify, - refreshContent: () => { - setRange(props.state.range); - setNode(props.state.node); - }, - replaceContent(content) { - unlock.clear(); - setLocked(true); +
+ + + extension={extension} + ctx={{ + contextFunctions: ["notify", "replaceContent", "refreshContent"], + usableEnv: { readable: ["content"], writable: [] }, + config: extension.config || {} + }} + func={{ + notify, + refreshContent: () => { + setRange(props.state.range); + setNode(props.state.node); + }, + replaceContent(content) { + unlock.clear(); + setLocked(true); - if (range()) { - let size = 0; + if (range()) { + let size = 0; - const nodeOrFragment = createNodeFromContent( - content, - props.state.editor.schema - ); + const nodeOrFragment = createNodeFromContent( + content, + props.state.editor.schema + ); - if (nodeOrFragment instanceof PMNode) { - size = nodeOrFragment.nodeSize; - } else { - size = nodeOrFragment.size || 0; + if (nodeOrFragment instanceof PMNode) { + size = nodeOrFragment.nodeSize; + } else { + size = nodeOrFragment.size || 0; + } + + props.state.editor + .chain() + .focus() + .insertContentAt( + range()!, + generateJSON(content, props.state.editor.extensionManager.extensions) + ) + .scrollIntoView() + .focus() + .run(); + setRange({ from: range()!.from, to: range()!.from + size - 1 }); + replaceContentCallbacks.forEach((callback) => { + callback(); + }); } - props.state.editor - .chain() - .focus() - .insertContentAt( - range()!, - generateJSON(content, props.state.editor.extensionManager.extensions) - ) - .scrollIntoView() - .focus() - .run(); - setRange({ from: range()!.from, to: range()!.from + size - 1 }); - computeDropdownPosition()(); + unlock(); } - - unlock(); - } - }} - viewId={blockAction.view} - usableEnvData={usableEnvData()} - /> -
- - ); - }} - + }} + viewId={blockAction.view} + usableEnvData={usableEnvData()} + /> +
+
+ ); + }} +
+
); }; diff --git a/apps/web/src/lib/editor/extensions/block-action-menu/options.tsx b/apps/web/src/lib/editor/extensions/block-action-menu/options.tsx new file mode 100644 index 00000000..c64e54a7 --- /dev/null +++ b/apps/web/src/lib/editor/extensions/block-action-menu/options.tsx @@ -0,0 +1,163 @@ +import { mdiCubeOutline, mdiDotsVertical, mdiTrashCanOutline, mdiXml } from "@mdi/js"; +import { Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"; +import { SolidEditor } from "@vrite/tiptap-solid"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { Range } from "@tiptap/core"; +import { Dropdown, Tooltip, IconButton } from "#components/primitives"; + +interface OptionsDropdownProps { + editor: SolidEditor; + range: Range; + node: PMNode; + pos: number; + onReplaceContent(callback: () => void): void; +} +interface OptionProps { + editor: SolidEditor; + range: Range; + node: PMNode; + pos: number; +} + +const showCustomElementOption = (props: OptionProps): boolean => { + const element = props.editor.view.nodeDOM(props.pos); + + if (element instanceof HTMLElement) { + const uid = element.getAttribute("data-uid") || ""; + + return Boolean(uid); + } + + return false; +}; +const options: Record< + string, + Array<{ + color?: "danger" | "success"; + show?: (props: OptionProps) => boolean; + icon: string | ((props: OptionProps) => string); + label: string | ((props: OptionProps) => string); + onClick(props: OptionProps): void; + }> +> = { + element: [ + { + icon: (props) => { + const element = props.editor.view.nodeDOM(props.pos) as HTMLElement; + const customView = element.getAttribute("data-custom-view") === "true"; + + return customView ? mdiXml : mdiCubeOutline; + }, + show: showCustomElementOption, + label: (props) => { + const element = props.editor.view.nodeDOM(props.pos) as HTMLElement; + const customView = element.getAttribute("data-custom-view") === "true"; + + return customView ? "Raw view" : "Custom view"; + }, + onClick(props) { + props.editor + .chain() + .command(({ tr, dispatch }) => { + if (!dispatch) return false; + + if (typeof props.pos !== "number" || props.pos > props.editor.state.doc.nodeSize) { + return false; + } + + const element = props.editor.view.nodeDOM(props.pos) as HTMLElement; + const uid = element?.getAttribute("data-uid") || ""; + + if (props.node && props.node.type.name === "element") { + tr.setMeta("addToHistory", false).setMeta("elementViewTypeData", { + uid, + view: "raw", + pos: props.pos, + node: props.node + }); + } + + return true; + }) + .setElementSelection(props.pos) + .run(); + } + }, + { + color: "danger", + icon: mdiTrashCanOutline, + label: "Remove element", + onClick(props) { + props.editor.commands.deleteRange({ + from: props.pos, + to: props.pos + props.node.nodeSize + }); + }, + show: showCustomElementOption + } + ] +}; +const OptionsDropdown: Component = (props) => { + const [opened, setOpened] = createSignal(false); + const availableOptions = createMemo(() => { + opened; + + return ( + options[props.node.type.name]?.filter((option) => { + return !option.show || option.show(props); + }) || [] + ); + }); + + createEffect(() => { + if (!availableOptions().length) { + setOpened(false); + } + }); + + return ( + + { + props.onReplaceContent(() => { + buttonProps.computeDropdownPosition(); + }); + + return ( + + + + ); + }} + > +
+ + {(option) => { + return ( + { + option.onClick(props); + setOpened(false); + }} + /> + ); + }} + +
+
+
+ ); +}; + +export { OptionsDropdown }; diff --git a/apps/web/src/lib/editor/extensions/block-action-menu/plugin.tsx b/apps/web/src/lib/editor/extensions/block-action-menu/plugin.tsx index 2413c973..c2c926bd 100644 --- a/apps/web/src/lib/editor/extensions/block-action-menu/plugin.tsx +++ b/apps/web/src/lib/editor/extensions/block-action-menu/plugin.tsx @@ -1,7 +1,7 @@ import { BlockActionMenu } from "./component"; import { Extension, Range } from "@tiptap/core"; import { SolidEditor, SolidRenderer } from "@vrite/tiptap-solid"; -import { TextSelection } from "@tiptap/pm/state"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; import { ResolvedPos, Node as PMNode } from "@tiptap/pm/model"; import { debounce } from "@solid-primitives/scheduled"; @@ -11,6 +11,7 @@ let component: SolidRenderer<{ editor: SolidEditor; node: PMNode | null; range: Range | null; + pos: number | null; repositionMenu: () => void; }> | null = null; @@ -27,24 +28,12 @@ const findParentAtDepth = ( node }; }; -const getBlockParent = (node: Node): HTMLElement | null => { - let currentNode: HTMLElement | null = - node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; - - while (currentNode) { - if (currentNode.parentElement?.classList.contains("ProseMirror")) { - return currentNode; - } - - currentNode = currentNode.parentElement; - } - - return null; -}; const repositionMenu = (editor: SolidEditor): void => { const { selection } = editor.state; const isTextSelection = selection instanceof TextSelection; - const selectedNode = selection.$from.node(1) || selection.$from.nodeAfter; + const isNodeSelection = selection instanceof NodeSelection; + const selectedNode = isNodeSelection ? selection.node : selection.$from.parent; + const selectedPos = selection.$from.pos - (isNodeSelection ? 0 : selection.$from.parentOffset); if (!selectedNode) { box.style.display = "none"; @@ -60,7 +49,7 @@ const repositionMenu = (editor: SolidEditor): void => { if (!node) return; - const blockParent = getBlockParent(node); + const blockParent = node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; const parentPos = document.getElementById("pm-container")?.getBoundingClientRect(); const childPos = blockParent?.getBoundingClientRect(); @@ -70,7 +59,7 @@ const repositionMenu = (editor: SolidEditor): void => { top: childPos.top - parentPos.top, right: childPos.right - parentPos.right, bottom: childPos.bottom - parentPos.bottom, - left: childPos.left - parentPos.left + left: 0 }; let rangeFrom = selection.$from.pos; @@ -82,7 +71,7 @@ const repositionMenu = (editor: SolidEditor): void => { if (isTextSelection) { try { - const p = findParentAtDepth(selection.$from, 1); + const p = findParentAtDepth(selection.$from, selection.$from.depth - 1); rangeFrom = p.start - 1; rangeTo = p.start + p.node.nodeSize - 1; @@ -97,6 +86,7 @@ const repositionMenu = (editor: SolidEditor): void => { to: rangeTo }, node: selectedNode, + pos: selectedPos, editor, repositionMenu: component.state().repositionMenu || (() => {}) }); @@ -116,6 +106,7 @@ const BlockActionMenuPlugin = Extension.create({ editor: this.editor as SolidEditor, node: null as PMNode | null, range: null as Range | null, + pos: null as number | null, repositionMenu: () => { box.style.display = "none"; debouncedRepositionMenu(); diff --git a/apps/web/src/lib/editor/extensions/collab-cursor.tsx b/apps/web/src/lib/editor/extensions/collab-cursor.tsx index 376a3207..0943741f 100644 --- a/apps/web/src/lib/editor/extensions/collab-cursor.tsx +++ b/apps/web/src/lib/editor/extensions/collab-cursor.tsx @@ -1,6 +1,16 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; -import { Accessor, createMemo, createSignal, JSX, onMount, Setter, Show } from "solid-js"; +import { + Accessor, + createEffect, + createMemo, + createSignal, + JSX, + on, + onMount, + Setter, + Show +} from "solid-js"; import { render } from "solid-js/web"; import { Extension } from "@tiptap/core"; import { yCursorPlugin } from "y-prosemirror"; @@ -102,7 +112,23 @@ const CollabCursor = (provider: HocuspocusProvider): Extension => { return state.id === user.id && state.clientId !== provider.awareness?.clientID; }); }); - + const isGapCursor = (): boolean => userState()?.fields.gapcursor; + + createEffect( + on(isGapCursor, () => { + if (isGapCursor()) { + container.classList.add( + "ProseMirror-gapcursor", + ...selectionClasses[color].gapCursor.split(" ") + ); + } else { + container.classList.remove( + "ProseMirror-gapcursor", + ...selectionClasses[color].gapCursor.split(" ") + ); + } + }) + ); onMount(() => { if ( container.parentElement?.classList.contains("ProseMirror") || @@ -169,7 +195,10 @@ const CollabCursor = (provider: HocuspocusProvider): Extension => { > {(blockSelection) => { return ( - + } + >
{ top: `${blockSelection.top}px`, display: blockSelection.display }} + data-collab-cursor="true" class={clsx( "absolute border-2 rounded-[18px] pointer-events-none", selectionClasses[color].outline diff --git a/apps/web/src/lib/editor/extensions/collaboration.ts b/apps/web/src/lib/editor/extensions/collaboration/index.ts similarity index 91% rename from apps/web/src/lib/editor/extensions/collaboration.ts rename to apps/web/src/lib/editor/extensions/collaboration/index.ts index 7a1e300d..0020adb3 100644 --- a/apps/web/src/lib/editor/extensions/collaboration.ts +++ b/apps/web/src/lib/editor/extensions/collaboration/index.ts @@ -1,5 +1,7 @@ +// @ts-ignore +import { ySyncPlugin } from "./sync-plugin"; import Collaboration from "@tiptap/extension-collaboration"; -import { yUndoPlugin, yUndoPluginKey, ySyncPlugin } from "y-prosemirror"; +import { yUndoPlugin, yUndoPluginKey } from "y-prosemirror"; const Collab = Collaboration.extend({ addProseMirrorPlugins() { diff --git a/apps/web/src/lib/editor/extensions/collaboration/sync-plugin.js b/apps/web/src/lib/editor/extensions/collaboration/sync-plugin.js new file mode 100644 index 00000000..83566d06 --- /dev/null +++ b/apps/web/src/lib/editor/extensions/collaboration/sync-plugin.js @@ -0,0 +1,1222 @@ +/** + * @module bindings/prosemirror + */ + +import { createMutex } from "lib0/mutex"; +import * as PModel from "@tiptap/pm/model"; +import { Plugin, TextSelection } from "@tiptap/pm/state"; // eslint-disable-line +import * as math from "lib0/math"; +import * as object from "lib0/object"; +import * as set from "lib0/set"; +import { simpleDiff } from "lib0/diff"; +import * as error from "lib0/error"; +import * as Y from "yjs"; +import * as random from "lib0/random"; +import * as environment from "lib0/environment"; +import * as dom from "lib0/dom"; +import * as eventloop from "lib0/eventloop"; +import { + absolutePositionToRelativePosition, + relativePositionToAbsolutePosition, + ySyncPluginKey, + yUndoPluginKey +} from "y-prosemirror"; + +/** + * @param {Y.Item} item + * @param {Y.Snapshot} [snapshot] + */ +export const isVisible = (item, snapshot) => + snapshot === undefined + ? !item.deleted + : snapshot.sv.has(item.id.client) && + /** @type {number} */ + snapshot.sv.get(item.id.client) > item.id.clock && + !Y.isDeleted(snapshot.ds, item.id); + +/** + * Either a node if type is YXmlElement or an Array of text nodes if YXmlText + * @typedef {Map, PModel.Node | Array>} ProsemirrorMapping + */ + +/** + * @typedef {Object} ColorDef + * @property {string} ColorDef.light + * @property {string} ColorDef.dark + */ + +/** + * @typedef {Object} YSyncOpts + * @property {Array} [YSyncOpts.colors] + * @property {Map} [YSyncOpts.colorMapping] + * @property {Y.PermanentUserData|null} [YSyncOpts.permanentUserData] + * @property {function} [YSyncOpts.onFirstRender] Fired when the content from Yjs is initially rendered to ProseMirror + */ + +/** + * @type {Array} + */ +const defaultColors = [{ light: "#ecd44433", dark: "#ecd444" }]; +/** + * @param {Map} colorMapping + * @param {Array} colors + * @param {string} user + * @return {ColorDef} + */ +const getUserColor = (colorMapping, colors, user) => { + // @todo do not hit the same color twice if possible + if (!colorMapping.has(user)) { + if (colorMapping.size < colors.length) { + const usedColors = set.create(); + + colorMapping.forEach((color) => usedColors.add(color)); + colors = colors.filter((color) => !usedColors.has(color)); + } + + colorMapping.set(user, random.oneOf(colors)); + } + + return /** @type {ColorDef} */ colorMapping.get(user); +}; + +/** + * This plugin listens to changes in prosemirror view and keeps yXmlState and view in sync. + * + * This plugin also keeps references to the type and the shared document so other plugins can access it. + * @param {Y.XmlFragment} yXmlFragment + * @param {YSyncOpts} opts + * @return {any} Returns a prosemirror plugin that binds to this type + */ +export const ySyncPlugin = ( + yXmlFragment, + { + colors = defaultColors, + colorMapping = new Map(), + permanentUserData = null, + onFirstRender = () => {} + } = {} +) => { + let initialContentChanged = false; + + const plugin = new Plugin({ + props: { + editable: (state) => { + const syncState = ySyncPluginKey.getState(state); + + return syncState.snapshot == null && syncState.prevSnapshot == null; + } + }, + key: ySyncPluginKey, + state: { + init: (_initargs, _state) => { + return { + type: yXmlFragment, + doc: yXmlFragment.doc, + binding: null, + snapshot: null, + prevSnapshot: null, + isChangeOrigin: false, + isUndoRedoOperation: false, + addToHistory: true, + colors, + colorMapping, + permanentUserData + }; + }, + apply: (tr, pluginState) => { + const change = tr.getMeta(ySyncPluginKey); + + if (change !== undefined) { + pluginState = { ...pluginState }; + + for (const key in change) { + pluginState[key] = change[key]; + } + } + + pluginState.addToHistory = tr.getMeta("addToHistory") !== false; + // always set isChangeOrigin. If undefined, this is not change origin. + pluginState.isChangeOrigin = change !== undefined && !!change.isChangeOrigin; + pluginState.isUndoRedoOperation = + change !== undefined && !!change.isChangeOrigin && !!change.isUndoRedoOperation; + + if (pluginState.binding !== null) { + if (change !== undefined && (change.snapshot != null || change.prevSnapshot != null)) { + // snapshot changed, rerender next + eventloop.timeout(0, () => { + if (pluginState.binding == null || pluginState.binding.isDestroyed) { + return; + } + + if (change.restore == null) { + pluginState.binding._renderSnapshot( + change.snapshot, + change.prevSnapshot, + pluginState + ); + } else { + pluginState.binding._renderSnapshot(change.snapshot, change.snapshot, pluginState); + // reset to current prosemirror state + delete pluginState.restore; + delete pluginState.snapshot; + delete pluginState.prevSnapshot; + pluginState.binding.mux(() => { + pluginState.binding._prosemirrorChanged( + pluginState.binding.prosemirrorView.state.doc + ); + }); + } + }); + } + } + + return pluginState; + } + }, + view: (view) => { + const binding = new ProsemirrorBinding(yXmlFragment, view); + + binding._forceRerender(); + onFirstRender(); + + return { + update: () => { + const pluginState = plugin.getState(view.state); + + if (pluginState.snapshot == null && pluginState.prevSnapshot == null) { + if ( + // If the content doesn't change initially, we don't render anything to Yjs + // If the content was cleared by a user action, we want to catch the change and + // represent it in Yjs + initialContentChanged || + view.state.doc.content.findDiffStart(view.state.doc.type.createAndFill().content) !== + null + ) { + initialContentChanged = true; + + if (pluginState.addToHistory === false && !pluginState.isChangeOrigin) { + const yUndoPluginState = yUndoPluginKey.getState(view.state); + /** + * @type {Y.UndoManager} + */ + const um = yUndoPluginState && yUndoPluginState.undoManager; + + if (um) { + um.stopCapturing(); + } + } + + binding.mux(() => { + pluginState.doc.transact((tr) => { + tr.meta.set("addToHistory", pluginState.addToHistory); + binding._prosemirrorChanged(view.state.doc); + }, ySyncPluginKey); + }); + } + } + }, + destroy: () => { + binding.destroy(); + } + }; + } + }); + + return plugin; +}; + +/** + * @param {any} tr + * @param {RecoverableSelection} recoverableSel + * @param {ProsemirrorBinding} binding + */ +const restoreRelativeSelection = (tr, recoverableSel, binding) => { + if (recoverableSel !== null && recoverableSel.valid()) { + const selection = recoverableSel.restore(binding, tr.doc); + + tr = tr.setSelection(selection); + } +}; + +export const getRelativeSelection = (pmbinding, state) => ({ + anchor: absolutePositionToRelativePosition( + state.selection.anchor, + pmbinding.type, + pmbinding.mapping + ), + head: absolutePositionToRelativePosition(state.selection.head, pmbinding.type, pmbinding.mapping) +}); +export const createRecoverableSelection = (pmbinding, state) => { + const sel = new RecoverableSelection(pmbinding, state.selection); + + state.selection.map(state.doc, sel); + + return sel; +}; +export class RecoverableSelection { + constructor(pmbinding, selection, recoverMode = false) { + this.records = []; + this.pmbinding = pmbinding; + this.selection = selection; + this.recoverMode = recoverMode; + } + + restore(pmbinding, doc) { + return this.selection.map(doc, new RecoveryMapping(pmbinding, this.records)); + } + + valid() { + return !!this.records.length && this.records.every((r) => r.relPos); + } + + map(pos) { + const relPos = absolutePositionToRelativePosition( + pos, + this.pmbinding.type, + this.pmbinding.mapping + ); + + this.records.push({ pos, relPos }); + + return pos; + } + + mapResult(pos) { + return { deleted: false, pos: this.map(pos) }; + } +} +export class RecoveryMapping { + constructor(pmbinding, records) { + this.pmbinding = pmbinding; + this.records = records; + } + + map(pos) { + return this.mapResult(pos).pos; + } + + mapResult(pos) { + for (const rec of this.records) { + if (rec.pos === pos) { + const mappedPos = relativePositionToAbsolutePosition( + this.pmbinding.doc, + this.pmbinding.type, + rec.relPos, + this.pmbinding.mapping + ); + + if (mappedPos === null) { + return { deleted: true, pos }; + } + + return { deleted: false, pos: mappedPos }; + } + } + + throw new Error("not recorded"); + } +} +/** + * Binding for prosemirror. + * + * @protected + */ +export class ProsemirrorBinding { + /** + * @param {Y.XmlFragment} yXmlFragment The bind source + * @param {any} prosemirrorView The target binding + */ + constructor(yXmlFragment, prosemirrorView) { + this.type = yXmlFragment; + this.prosemirrorView = prosemirrorView; + this.mux = createMutex(); + this.isDestroyed = false; + /** + * @type {ProsemirrorMapping} + */ + this.mapping = new Map(); + this._observeFunction = this._typeChanged.bind(this); + /** + * @type {Y.Doc} + */ + // @ts-ignore + this.doc = yXmlFragment.doc; + /** + * current selection as relative positions in the Yjs model + */ + this.beforeTransactionSelection = null; + this.beforeAllTransactions = () => { + if (this.beforeTransactionSelection === null) { + this.beforeTransactionSelection = createRecoverableSelection(this, prosemirrorView.state); + } + }; + this.afterAllTransactions = () => { + this.beforeTransactionSelection = null; + }; + this.doc.on("beforeAllTransactions", this.beforeAllTransactions); + this.doc.on("afterAllTransactions", this.afterAllTransactions); + yXmlFragment.observeDeep(this._observeFunction); + this._domSelectionInView = null; + } + + /** + * Create a transaction for changing the prosemirror state. + * + * @returns + */ + get _tr() { + return this.prosemirrorView.state.tr.setMeta("addToHistory", false); + } + + _isLocalCursorInView() { + if (!this.prosemirrorView.hasFocus()) return false; + + if (environment.isBrowser && this._domSelectionInView === null) { + // Calculate the domSelectionInView and clear by next tick after all events are finished + eventloop.timeout(0, () => { + this._domSelectionInView = null; + }); + this._domSelectionInView = this._isDomSelectionInView(); + } + + return this._domSelectionInView; + } + + _isDomSelectionInView() { + const selection = this.prosemirrorView._root.getSelection(); + const range = this.prosemirrorView._root.createRange(); + + range.setStart(selection.anchorNode, selection.anchorOffset); + range.setEnd(selection.focusNode, selection.focusOffset); + + /* This is a workaround for an edgecase where getBoundingClientRect will + return zero values if the selection is collapsed at the start of a newline + see reference here: https://stackoverflow.com/a/59780954 */ + const rects = range.getClientRects(); + + if (rects.length === 0) { + // probably buggy newline behavior, explicitly select the node contents + if (range.startContainer && range.collapsed) { + range.selectNodeContents(range.startContainer); + } + } + + const bounding = range.getBoundingClientRect(); + const { documentElement } = dom.doc; + + return ( + bounding.bottom >= 0 && + bounding.right >= 0 && + bounding.left <= (window.innerWidth || documentElement.clientWidth || 0) && + bounding.top <= (window.innerHeight || documentElement.clientHeight || 0) + ); + } + + renderSnapshot(snapshot, prevSnapshot) { + if (!prevSnapshot) { + prevSnapshot = Y.createSnapshot(Y.createDeleteSet(), new Map()); + } + + this.prosemirrorView.dispatch(this._tr.setMeta(ySyncPluginKey, { snapshot, prevSnapshot })); + } + + unrenderSnapshot() { + this.mapping = new Map(); + this.mux(() => { + const fragmentContent = this.type + .toArray() + .map((t) => + createNodeFromYElement( + /** @type {Y.XmlElement} */ t, + this.prosemirrorView.state.schema, + this.mapping + ) + ) + .filter((n) => n !== null); + // @ts-ignore + const tr = this._tr.replace( + 0, + this.prosemirrorView.state.doc.content.size, + new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) + ); + + tr.setMeta(ySyncPluginKey, { snapshot: null, prevSnapshot: null }); + this.prosemirrorView.dispatch(tr); + }); + } + + _forceRerender() { + this.mapping = new Map(); + this.mux(() => { + const fragmentContent = this.type + .toArray() + .map((t) => + createNodeFromYElement( + /** @type {Y.XmlElement} */ t, + this.prosemirrorView.state.schema, + this.mapping + ) + ) + .filter((n) => n !== null); + // @ts-ignore + const tr = this._tr.replace( + 0, + this.prosemirrorView.state.doc.content.size, + new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) + ); + + this.prosemirrorView.dispatch( + tr.setMeta(ySyncPluginKey, { isChangeOrigin: true, binding: this }) + ); + }); + } + + /** + * @param {Y.Snapshot} snapshot + * @param {Y.Snapshot} prevSnapshot + * @param {Object} pluginState + */ + _renderSnapshot(snapshot, prevSnapshot, pluginState) { + if (!snapshot) { + snapshot = Y.snapshot(this.doc); + } + + // clear mapping because we are going to rerender + this.mapping = new Map(); + this.mux(() => { + this.doc.transact((transaction) => { + /* before rendering, we are going to sanitize ops and split deleted ops + if they were deleted by seperate users. */ + const pud = pluginState.permanentUserData; + + if (pud) { + pud.dss.forEach((ds) => { + Y.iterateDeletedStructs(transaction, ds, (_item) => {}); + }); + } + + /** + * @param {'removed'|'added'} type + * @param {Y.ID} id + */ + const computeYChange = (type, id) => { + const user = + type === "added" ? pud.getUserByClientId(id.client) : pud.getUserByDeletedId(id); + + return { + user, + type, + color: getUserColor(pluginState.colorMapping, pluginState.colors, user) + }; + }; + // Create document fragment and render + const fragmentContent = Y.typeListToArraySnapshot( + this.type, + new Y.Snapshot(prevSnapshot.ds, snapshot.sv) + ) + .map((t) => { + if ( + !t._item.deleted || + isVisible(t._item, snapshot) || + isVisible(t._item, prevSnapshot) + ) { + return createNodeFromYElement( + t, + this.prosemirrorView.state.schema, + new Map(), + snapshot, + prevSnapshot, + computeYChange + ); + } else { + /* No need to render elements that are not visible by either snapshot. + If a client adds and deletes content in the same snapshot the element is not visible by either snapshot. */ + return null; + } + }) + .filter((n) => n !== null); + // @ts-ignore + const tr = this._tr.replace( + 0, + this.prosemirrorView.state.doc.content.size, + new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) + ); + + this.prosemirrorView.dispatch(tr.setMeta(ySyncPluginKey, { isChangeOrigin: true })); + }, ySyncPluginKey); + }); + } + + /** + * @param {Array>} events + * @param {Y.Transaction} transaction + */ + _typeChanged(events, transaction) { + const syncState = ySyncPluginKey.getState(this.prosemirrorView.state); + + if (events.length === 0 || syncState.snapshot != null || syncState.prevSnapshot != null) { + // drop out if snapshot is active + this.renderSnapshot(syncState.snapshot, syncState.prevSnapshot); + + return; + } + + this.mux(() => { + /** + * @param {any} _ + * @param {Y.AbstractType} type + */ + const delType = (_, type) => this.mapping.delete(type); + + Y.iterateDeletedStructs(transaction, transaction.deleteSet, (struct) => { + if (struct.constructor === Y.Item) { + const type = /** @type {Y.ContentType} */ (/** @type {Y.Item} */ (struct).content).type; + type && this.mapping.delete(type); + } + }); + transaction.changed.forEach(delType); + transaction.changedParentTypes.forEach(delType); + + const fragmentContent = this.type + .toArray() + .map((t) => + createNodeIfNotExists( + /** @type {Y.XmlElement | Y.XmlHook} */ t, + this.prosemirrorView.state.schema, + this.mapping + ) + ) + .filter((n) => n !== null); + + // @ts-ignore + let tr = this._tr.replace( + 0, + this.prosemirrorView.state.doc.content.size, + new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0) + ); + + restoreRelativeSelection(tr, this.beforeTransactionSelection, this); + tr = tr.setMeta(ySyncPluginKey, { + isChangeOrigin: true, + isUndoRedoOperation: transaction.origin instanceof Y.UndoManager + }); + + if ( + this.beforeTransactionSelection !== null && + this._isLocalCursorInView() && + this.beforeTransactionSelection instanceof RecoverableSelection && + this.beforeTransactionSelection.selection instanceof TextSelection + ) { + tr.scrollIntoView(); + } + + this.prosemirrorView.dispatch(tr); + }); + } + + _prosemirrorChanged(doc) { + this.doc.transact((tr) => { + updateYFragment(this.doc, this.type, doc, this.mapping); + this.beforeTransactionSelection = createRecoverableSelection( + this, + this.prosemirrorView.state + ); + }, ySyncPluginKey); + } + + destroy() { + this.isDestroyed = true; + this.type.unobserveDeep(this._observeFunction); + this.doc.off("beforeAllTransactions", this.beforeAllTransactions); + this.doc.off("afterAllTransactions", this.afterAllTransactions); + } +} + +/** + * @private + * @param {Y.XmlElement | Y.XmlHook} el + * @param {PModel.Schema} schema + * @param {ProsemirrorMapping} mapping + * @param {Y.Snapshot} [snapshot] + * @param {Y.Snapshot} [prevSnapshot] + * @param {function('removed' | 'added', Y.ID):any} [computeYChange] + * @return {PModel.Node | null} + */ +const createNodeIfNotExists = (el, schema, mapping, snapshot, prevSnapshot, computeYChange) => { + const node = /** @type {PModel.Node} */ mapping.get(el); + + if (node === undefined) { + if (el instanceof Y.XmlElement) { + return createNodeFromYElement(el, schema, mapping, snapshot, prevSnapshot, computeYChange); + } else { + throw error.methodUnimplemented(); // we are currently not handling hooks + } + } + + return node; +}; +/** + * @private + * @param {Y.XmlElement} el + * @param {any} schema + * @param {ProsemirrorMapping} mapping + * @param {Y.Snapshot} [snapshot] + * @param {Y.Snapshot} [prevSnapshot] + * @param {function('removed' | 'added', Y.ID):any} [computeYChange] + * @return {PModel.Node | null} Returns node if node could be created. Otherwise it deletes the yjs type and returns null + */ +export const createNodeFromYElement = ( + el, + schema, + mapping, + snapshot, + prevSnapshot, + computeYChange +) => { + const children = []; + const createChildren = (type) => { + if (type.constructor === Y.XmlElement) { + const n = createNodeIfNotExists( + type, + schema, + mapping, + snapshot, + prevSnapshot, + computeYChange + ); + + if (n !== null) { + children.push(n); + } + } else { + const ns = createTextNodesFromYText( + type, + schema, + mapping, + snapshot, + prevSnapshot, + computeYChange + ); + + if (ns !== null) { + ns.forEach((textchild) => { + if (textchild !== null) { + children.push(textchild); + } + }); + } + } + }; + + if (snapshot === undefined || prevSnapshot === undefined) { + el.toArray().forEach(createChildren); + } else { + Y.typeListToArraySnapshot(el, new Y.Snapshot(prevSnapshot.ds, snapshot.sv)).forEach( + createChildren + ); + } + + try { + const attrs = el.getAttributes(snapshot); + + if (snapshot !== undefined) { + if (!isVisible(/** @type {Y.Item} */ el._item, snapshot)) { + attrs.ychange = computeYChange + ? computeYChange("removed", /** @type {Y.Item} */ el._item.id) + : { type: "removed" }; + } else if (!isVisible(/** @type {Y.Item} */ el._item, prevSnapshot)) { + attrs.ychange = computeYChange + ? computeYChange("added", /** @type {Y.Item} */ el._item.id) + : { type: "added" }; + } + } + + const node = schema.node(el.nodeName, attrs, children); + + mapping.set(el, node); + + return node; + } catch (e) { + // an error occured while creating the node. This is probably a result of a concurrent action. + /** @type {Y.Doc} */ el.doc.transact((transaction) => { + /** @type {Y.Item} */ el._item.delete(transaction); + }, ySyncPluginKey); + mapping.delete(el); + + return null; + } +}; +/** + * @private + * @param {Y.XmlText} text + * @param {any} schema + * @param {ProsemirrorMapping} mapping + * @param {Y.Snapshot} [snapshot] + * @param {Y.Snapshot} [prevSnapshot] + * @param {function('removed' | 'added', Y.ID):any} [computeYChange] + * @return {Array|null} + */ +const createTextNodesFromYText = ( + text, + schema, + mapping, + snapshot, + prevSnapshot, + computeYChange +) => { + const nodes = []; + const deltas = text.toDelta(snapshot, prevSnapshot, computeYChange); + + try { + for (let i = 0; i < deltas.length; i++) { + const delta = deltas[i]; + const marks = []; + + for (const markName in delta.attributes) { + marks.push(schema.mark(markName, delta.attributes[markName])); + } + + nodes.push(schema.text(delta.insert, marks)); + } + } catch (e) { + // an error occured while creating the node. This is probably a result of a concurrent action. + /** @type {Y.Doc} */ text.doc.transact((transaction) => { + /** @type {Y.Item} */ text._item.delete(transaction); + }, ySyncPluginKey); + + return null; + } + + // @ts-ignore + return nodes; +}; +/** + * @private + * @param {Array} nodes prosemirror node + * @param {ProsemirrorMapping} mapping + * @return {Y.XmlText} + */ +const createTypeFromTextNodes = (nodes, mapping) => { + const type = new Y.XmlText(); + const delta = nodes.map((node) => ({ + // @ts-ignore + insert: node.text, + attributes: marksToAttributes(node.marks) + })); + + type.applyDelta(delta); + mapping.set(type, nodes); + + return type; +}; +/** + * @private + * @param {any} node prosemirror node + * @param {ProsemirrorMapping} mapping + * @return {Y.XmlElement} + */ +const createTypeFromElementNode = (node, mapping) => { + const type = new Y.XmlElement(node.type.name); + + for (const key in node.attrs) { + const val = node.attrs[key]; + + if (val !== null && key !== "ychange") { + type.setAttribute(key, val); + } + } + + type.insert( + 0, + normalizePNodeContent(node).map((n) => createTypeFromTextOrElementNode(n, mapping)) + ); + mapping.set(type, node); + + return type; +}; +/** + * @private + * @param {PModel.Node|Array} node prosemirror text node + * @param {ProsemirrorMapping} mapping + * @return {Y.XmlElement|Y.XmlText} + */ +const createTypeFromTextOrElementNode = (node, mapping) => + node instanceof Array + ? createTypeFromTextNodes(node, mapping) + : createTypeFromElementNode(node, mapping); +const isObject = (val) => typeof val === "object" && val !== null; +const equalAttrs = (pattrs, yattrs) => { + const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null); + + let eq = keys.length === Object.keys(yattrs).filter((key) => yattrs[key] !== null).length; + + for (let i = 0; i < keys.length && eq; i++) { + const key = keys[i]; + const l = pattrs[key]; + const r = yattrs[key]; + + eq = key === "ychange" || l === r || (isObject(l) && isObject(r) && equalAttrs(l, r)); + } + + return eq; +}; + +/** + * @typedef {Array|PModel.Node>} NormalizedPNodeContent + */ + +/** + * @param {any} pnode + * @return {NormalizedPNodeContent} + */ +const normalizePNodeContent = (pnode) => { + const c = pnode.content.content; + const res = []; + + for (let i = 0; i < c.length; i++) { + const n = c[i]; + + if (n.isText) { + const textNodes = []; + + for (let tnode = c[i]; i < c.length && tnode.isText; tnode = c[++i]) { + textNodes.push(tnode); + } + + i--; + res.push(textNodes); + } else { + res.push(n); + } + } + + return res; +}; +/** + * @param {Y.XmlText} ytext + * @param {Array} ptexts + */ +const equalYTextPText = (ytext, ptexts) => { + const delta = ytext.toDelta(); + + return ( + delta.length === ptexts.length && + delta.every( + (d, i) => + d.insert === /** @type {any} */ ptexts[i].text && + object.keys(d.attributes || {}).length === ptexts[i].marks.length && + ptexts[i].marks.every((mark) => equalAttrs(d.attributes[mark.type.name] || {}, mark.attrs)) + ) + ); +}; +/** + * @param {Y.XmlElement|Y.XmlText|Y.XmlHook} ytype + * @param {any|Array} pnode + */ +const equalYTypePNode = (ytype, pnode) => { + if (ytype instanceof Y.XmlElement && !(pnode instanceof Array) && matchNodeName(ytype, pnode)) { + const normalizedContent = normalizePNodeContent(pnode); + + return ( + ytype._length === normalizedContent.length && + equalAttrs(ytype.getAttributes(), pnode.attrs) && + ytype.toArray().every((ychild, i) => equalYTypePNode(ychild, normalizedContent[i])) + ); + } + + return ytype instanceof Y.XmlText && pnode instanceof Array && equalYTextPText(ytype, pnode); +}; +/** + * @param {PModel.Node | Array | undefined} mapped + * @param {PModel.Node | Array} pcontent + */ +const mappedIdentity = (mapped, pcontent) => + mapped === pcontent || + (mapped instanceof Array && + pcontent instanceof Array && + mapped.length === pcontent.length && + mapped.every((a, i) => pcontent[i] === a)); +/** + * @param {Y.XmlElement} ytype + * @param {PModel.Node} pnode + * @param {ProsemirrorMapping} mapping + * @return {{ foundMappedChild: boolean, equalityFactor: number }} + */ +const computeChildEqualityFactor = (ytype, pnode, mapping) => { + const yChildren = ytype.toArray(); + const pChildren = normalizePNodeContent(pnode); + const pChildCnt = pChildren.length; + const yChildCnt = yChildren.length; + const minCnt = math.min(yChildCnt, pChildCnt); + + let left = 0; + let right = 0; + let foundMappedChild = false; + + for (; left < minCnt; left++) { + const leftY = yChildren[left]; + const leftP = pChildren[left]; + + if (mappedIdentity(mapping.get(leftY), leftP)) { + foundMappedChild = true; // definite (good) match! + } else if (!equalYTypePNode(leftY, leftP)) { + break; + } + } + + for (; left + right < minCnt; right++) { + const rightY = yChildren[yChildCnt - right - 1]; + const rightP = pChildren[pChildCnt - right - 1]; + + if (mappedIdentity(mapping.get(rightY), rightP)) { + foundMappedChild = true; + } else if (!equalYTypePNode(rightY, rightP)) { + break; + } + } + + return { + equalityFactor: left + right, + foundMappedChild + }; +}; +const ytextTrans = (ytext) => { + let str = ""; + /** + * @type {Y.Item|null} + */ + let n = ytext._start; + + const nAttrs = {}; + + while (n !== null) { + if (!n.deleted) { + if (n.countable && n.content instanceof Y.ContentString) { + str += n.content.str; + } else if (n.content instanceof Y.ContentFormat) { + nAttrs[n.content.key] = null; + } + } + + n = n.right; + } + + return { + str, + nAttrs + }; +}; +/** + * @todo test this more + * + * @param {Y.Text} ytext + * @param {Array} ptexts + * @param {ProsemirrorMapping} mapping + */ +const updateYText = (ytext, ptexts, mapping) => { + mapping.set(ytext, ptexts); + + const { nAttrs, str } = ytextTrans(ytext); + const content = ptexts.map((p) => ({ + insert: /** @type {any} */ p.text, + attributes: { ...nAttrs, ...marksToAttributes(p.marks) } + })); + const { insert, remove, index } = simpleDiff(str, content.map((c) => c.insert).join("")); + + ytext.delete(index, remove); + ytext.insert(index, insert); + ytext.applyDelta(content.map((c) => ({ retain: c.insert.length, attributes: c.attributes }))); +}; +const marksToAttributes = (marks) => { + const pattrs = {}; + + marks.forEach((mark) => { + if (mark.type.name !== "ychange") { + pattrs[mark.type.name] = mark.attrs; + } + }); + + return pattrs; +}; + +/** + * Update a yDom node by syncing the current content of the prosemirror node. + * + * This is a y-prosemirror internal feature that you can use at your own risk. + * + * @private + * @unstable + * + * @param {{transact: Function}} y + * @param {Y.XmlFragment} yDomFragment + * @param {any} pNode + * @param {ProsemirrorMapping} mapping + */ +export const updateYFragment = (y, yDomFragment, pNode, mapping) => { + if (yDomFragment instanceof Y.XmlElement && yDomFragment.nodeName !== pNode.type.name) { + throw new Error("node name mismatch!"); + } + + mapping.set(yDomFragment, pNode); + + // update attributes + if (yDomFragment instanceof Y.XmlElement) { + const yDomAttrs = yDomFragment.getAttributes(); + const pAttrs = pNode.attrs; + + for (const key in pAttrs) { + if (pAttrs[key] !== null) { + if (yDomAttrs[key] !== pAttrs[key] && key !== "ychange") { + yDomFragment.setAttribute(key, pAttrs[key]); + } + } else { + yDomFragment.removeAttribute(key); + } + } + + // remove all keys that are no longer in pAttrs + for (const key in yDomAttrs) { + if (pAttrs[key] === undefined) { + yDomFragment.removeAttribute(key); + } + } + } + + // update children + const pChildren = normalizePNodeContent(pNode); + const pChildCnt = pChildren.length; + const yChildren = yDomFragment.toArray(); + const yChildCnt = yChildren.length; + const minCnt = math.min(pChildCnt, yChildCnt); + + let left = 0; + let right = 0; + + // find number of matching elements from left + for (; left < minCnt; left++) { + const leftY = yChildren[left]; + const leftP = pChildren[left]; + + if (!mappedIdentity(mapping.get(leftY), leftP)) { + if (equalYTypePNode(leftY, leftP)) { + // update mapping + mapping.set(leftY, leftP); + } else { + break; + } + } + } + + // find number of matching elements from right + for (; right + left + 1 < minCnt; right++) { + const rightY = yChildren[yChildCnt - right - 1]; + const rightP = pChildren[pChildCnt - right - 1]; + + if (!mappedIdentity(mapping.get(rightY), rightP)) { + if (equalYTypePNode(rightY, rightP)) { + // update mapping + mapping.set(rightY, rightP); + } else { + break; + } + } + } + + y.transact(() => { + // try to compare and update + while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) { + const leftY = yChildren[left]; + const leftP = pChildren[left]; + const rightY = yChildren[yChildCnt - right - 1]; + const rightP = pChildren[pChildCnt - right - 1]; + + if (leftY instanceof Y.XmlText && leftP instanceof Array) { + if (!equalYTextPText(leftY, leftP)) { + updateYText(leftY, leftP, mapping); + } + + left += 1; + } else { + let updateLeft = leftY instanceof Y.XmlElement && matchNodeName(leftY, leftP); + let updateRight = rightY instanceof Y.XmlElement && matchNodeName(rightY, rightP); + + if (updateLeft && updateRight) { + // decide which which element to update + const equalityLeft = computeChildEqualityFactor( + /** @type {Y.XmlElement} */ leftY, + /** @type {PModel.Node} */ leftP, + mapping + ); + const equalityRight = computeChildEqualityFactor( + /** @type {Y.XmlElement} */ rightY, + /** @type {PModel.Node} */ rightP, + mapping + ); + + if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) { + updateRight = false; + } else if (!equalityLeft.foundMappedChild && equalityRight.foundMappedChild) { + updateLeft = false; + } else if (equalityLeft.equalityFactor < equalityRight.equalityFactor) { + updateLeft = false; + } else { + updateRight = false; + } + } + + if (updateLeft) { + updateYFragment( + y, + /** @type {Y.XmlFragment} */ leftY, + /** @type {PModel.Node} */ leftP, + mapping + ); + left += 1; + } else if (updateRight) { + updateYFragment( + y, + /** @type {Y.XmlFragment} */ rightY, + /** @type {PModel.Node} */ rightP, + mapping + ); + right += 1; + } else { + mapping.delete(yDomFragment.get(left)); + yDomFragment.delete(left, 1); + yDomFragment.insert(left, [createTypeFromTextOrElementNode(leftP, mapping)]); + left += 1; + } + } + } + + const yDelLen = yChildCnt - left - right; + + if (yChildCnt === 1 && pChildCnt === 0 && yChildren[0] instanceof Y.XmlText) { + mapping.delete(yChildren[0]); + /* Edge case handling https://github.com/yjs/y-prosemirror/issues/108 + Only delete the content of the Y.Text to retain remote changes on the same Y.Text object */ + yChildren[0].delete(0, yChildren[0].length); + } else if (yDelLen > 0) { + yDomFragment.slice(left, left + yDelLen).forEach((type) => mapping.delete(type)); + yDomFragment.delete(left, yDelLen); + } + + if (left + right < pChildCnt) { + const ins = []; + + for (let i = left; i < pChildCnt - right; i++) { + ins.push(createTypeFromTextOrElementNode(pChildren[i], mapping)); + } + + yDomFragment.insert(left, ins); + } + }, ySyncPluginKey); +}; + +/** + * @function + * @param {Y.XmlElement} yElement + * @param {any} pNode Prosemirror Node + */ +const matchNodeName = (yElement, pNode) => + !(pNode instanceof Array) && yElement.nodeName === pNode.type.name; diff --git a/apps/web/src/lib/editor/extensions/custom-node-menu/component.tsx b/apps/web/src/lib/editor/extensions/custom-node-menu/component.tsx deleted file mode 100644 index 8eef3a77..00000000 --- a/apps/web/src/lib/editor/extensions/custom-node-menu/component.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { getCustomElements } from "../element/utils"; -import { mdiKeyboardCloseOutline, mdiTrashCanOutline } from "@mdi/js"; -import { SolidEditor } from "@vrite/tiptap-solid"; -import { Component, For, Show } from "solid-js"; -import clsx from "clsx"; -import { Node as PMNode } from "@tiptap/pm/model"; -import { Card, IconButton, Tooltip } from "#components/primitives"; -import { breakpoints } from "#lib/utils"; -import { useExtensions } from "#context"; - -interface CustomNodeMenuProps { - state: { - container: HTMLElement | null; - editor: SolidEditor; - }; -} - -const CustomNodeMenu: Component = (props) => { - const { installedExtensions } = useExtensions(); - const customElements = getCustomElements(installedExtensions); - - return ( - - - { - const { selection } = editor.state; - const currentDepth = selection.$from.depth; - - let node: PMNode | null = null; - let pos: number | null = null; - - for (let i = currentDepth; i >= 0; i--) { - const currentNode = selection.$from.node(i); - - if ( - currentNode.type.name === "element" && - customElements[currentNode.attrs.type.toLowerCase()] - ) { - node = currentNode; - pos = i > 0 ? selection.$from.before(i) : 0; - break; - } - } - - if (!node || pos === null) return false; - - tr.delete(pos, pos + node.nodeSize); - - return true; - }) - .focus() - .run(); - } - }, - ...((!breakpoints.md() && [ - { - icon: mdiKeyboardCloseOutline, - tooltip: "Close keyboard", - async onClick() { - props.state.editor.commands.blur(); - } - } - ]) || - []) - ]} - > - {(menuItem) => { - const button = ( - - ); - - if (menuItem.tooltip) { - return ( - - {button} - - ); - } - - return button; - }} - - - - ); -}; - -export { CustomNodeMenu }; diff --git a/apps/web/src/lib/editor/extensions/custom-node-menu/index.ts b/apps/web/src/lib/editor/extensions/custom-node-menu/index.ts deleted file mode 100644 index 39b9a61b..00000000 --- a/apps/web/src/lib/editor/extensions/custom-node-menu/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./plugin"; diff --git a/apps/web/src/lib/editor/extensions/custom-node-menu/plugin.tsx b/apps/web/src/lib/editor/extensions/custom-node-menu/plugin.tsx deleted file mode 100644 index 77a29b34..00000000 --- a/apps/web/src/lib/editor/extensions/custom-node-menu/plugin.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { CustomNodeMenu } from "./component"; -import { isElementSelection } from "../element/selection"; -import { Extension } from "@tiptap/core"; -import { SolidEditor, SolidRenderer } from "@vrite/tiptap-solid"; -import { breakpoints } from "#lib/utils"; - -const generalMenuContainer = document.createElement("div"); - -let generalMenu: SolidRenderer<{ - editor: SolidEditor; - container: HTMLElement | null; -}> | null = null; - -const getTableParent = (node: Node): HTMLElement | null => { - let currentNode: HTMLElement | null = - node.nodeType === 1 ? (node as HTMLElement) : node.parentElement; - - while (currentNode) { - if (currentNode.getAttribute("data-custom-node-view")) { - return currentNode; - } - - currentNode = currentNode.parentElement; - } - - return null; -}; -const handleUpdate = (editor: SolidEditor): void => { - const { selection } = editor.state; - const selectedNode = selection.$from.node(1) || selection.$from.nodeAfter; - - if (!selectedNode || !editor.isActive("element") || isElementSelection(selection)) { - generalMenuContainer.style.display = "none"; - - return; - } - - const { view } = editor; - const node = - view.nodeDOM(selection.$from.pos) || - view.nodeDOM(selection.$from.pos - selection.$from.parentOffset) || - view.domAtPos(selection.$from.pos)?.node; - - if (!node) return; - - const blockParent = getTableParent(node); - const parentPos = document.getElementById("pm-container")?.getBoundingClientRect(); - const childPos = blockParent?.getBoundingClientRect(); - const tablePos = blockParent?.getBoundingClientRect(); - - if (!parentPos || !childPos) return; - - const relativePos = { - top: childPos.top - parentPos.top, - right: childPos.right - parentPos.right, - bottom: childPos.bottom - parentPos.bottom, - left: childPos.left - parentPos.left - }; - - generalMenuContainer.style.top = `${relativePos.top + (tablePos?.height || 0)}px`; - generalMenuContainer.style.transform = `translate(${ - (tablePos?.width || 0) > 250 ? "-50%" : "0" - },0.75rem)`; - - if ((tablePos?.width || 0) > 250 && breakpoints.md()) { - generalMenuContainer.style.left = `${ - relativePos.left + Math.min(tablePos?.width || parentPos.width, parentPos.width) / 2 - }px`; - } else if (breakpoints.md()) { - generalMenuContainer.style.left = "0"; - } else { - generalMenuContainer.style.left = "-0.25rem"; - } - - generalMenuContainer.style.display = "block"; - generalMenu?.setState({ - node: selectedNode, - container: blockParent, - editor - }); -}; -const CustomNodeMenuPlugin = Extension.create({ - name: "customNodeMenu", - onCreate() { - generalMenu = new SolidRenderer(CustomNodeMenu, { - editor: this.editor as SolidEditor, - state: { - container: null as HTMLElement | null, - editor: this.editor as SolidEditor - } - }); - generalMenuContainer.style.position = "absolute"; - generalMenuContainer.style.top = "-100vh"; - generalMenuContainer.style.left = "-100vw"; - generalMenuContainer.appendChild(generalMenu.element); - document.getElementById("pm-container")?.appendChild(generalMenuContainer); - }, - onBlur() { - const dropdownOpened = document.documentElement.classList.contains("dropdown-opened"); - - if ( - (document.activeElement?.contains(generalMenuContainer) || dropdownOpened) && - breakpoints.md() - ) { - return; - } - - generalMenuContainer.style.display = "none"; - }, - onFocus() { - if (this.editor.isActive("element") && !isElementSelection(this.editor.state.selection)) { - generalMenuContainer.style.display = "block"; - } - }, - onUpdate() { - handleUpdate(this.editor as SolidEditor); - }, - onSelectionUpdate() { - handleUpdate(this.editor as SolidEditor); - } -}); - -export { CustomNodeMenuPlugin }; diff --git a/apps/web/src/lib/editor/extensions/element/custom-node-view.tsx b/apps/web/src/lib/editor/extensions/element/custom-node-view.tsx index 22beb2c2..88ca75fa 100644 --- a/apps/web/src/lib/editor/extensions/element/custom-node-view.tsx +++ b/apps/web/src/lib/editor/extensions/element/custom-node-view.tsx @@ -1,19 +1,21 @@ +import { ElementDisplay } from "./view-manager"; import { NodeViewRendererProps } from "@tiptap/core"; import { SolidEditor, SolidRenderer } from "@vrite/tiptap-solid"; import { ExtensionElementViewContext, ExtensionElement } from "@vrite/sdk/extensions"; -import { NodeView as PMNodeView } from "@tiptap/pm/view"; import clsx from "clsx"; +import { render } from "solid-js/web"; +import { mdiPlus } from "@mdi/js"; +import { TextSelection } from "@tiptap/pm/state"; import { useNotifications } from "#context"; import { ExtensionDetails, ExtensionViewRenderer } from "#lib/extensions"; +import { IconButton } from "#components/primitives"; -const customSubTrees = new Map>(); const customNodeView = ({ props, editor, extension, uid, view, - top, contentWrapper, wrapper, updateProps, @@ -23,14 +25,13 @@ const customNodeView = ({ editor: SolidEditor; uid: string; view: ExtensionElement; - top?: boolean; extension: ExtensionDetails; contentWrapper: HTMLElement; wrapper: HTMLElement; updateProps(newProps: Record): void; getProps(): Record; -}): Partial => { - const component = new SolidRenderer( +}): ElementDisplay => { + const renderer = new SolidRenderer( () => { const { notify } = useNotifications(); @@ -59,35 +60,76 @@ const customNodeView = ({ /> ); }, - { - editor, - state: {} - } + { editor, state: props } ); - const contentWrapperParent = component.element.querySelector("[data-content=true]"); + const contentHole = renderer.element.querySelector("[data-content=true]"); - contentWrapperParent?.append(contentWrapper); + contentHole?.append(contentWrapper); + wrapper.append(renderer.element); + wrapper.setAttribute("data-custom-view", "true"); + wrapper.setAttribute("class", "!m-0 rounded-2xl"); contentWrapper.setAttribute( "class", - clsx(":base: relative", "content", contentWrapperParent?.getAttribute("data-class")) + clsx(":base: relative", "content", contentHole?.getAttribute("data-class")) ); - wrapper.setAttribute("class", "!m-0 rounded-2xl"); - wrapper.setAttribute("data-uid", uid); - wrapper.setAttribute("data-initialized", "true"); - wrapper.append(component.element); - if (top) { - wrapper.setAttribute("data-custom-node-view", "true"); + if (!props.node.content.size && contentHole) { + const buttonContainer = document.createElement("div"); + + buttonContainer.setAttribute("contentEditable", "false"); + buttonContainer.setAttribute("class", "min-h-[35px] flex items-center"); + render( + () => ( + { + editor + .chain() + .command(({ tr }) => { + if (typeof props.getPos !== "function") return false; + + const lastPos = props.getPos(); + + tr.replaceWith( + lastPos + 1, + lastPos + 1, + editor.schema.node("paragraph") + ).setSelection(TextSelection.create(tr.doc, lastPos + 1)); + + return true; + }) + .focus() + .run(); + event.preventDefault(); + event.stopPropagation(); + }} + /> + ), + buttonContainer + ); + contentHole.append(buttonContainer); } return { - selectNode() { + onSelect() { wrapper.classList.add("ring", "ring-primary", "ring-2"); }, - deselectNode() { + onDeselect() { wrapper.classList.remove("ring", "ring-primary", "ring-2"); + }, + unmount() { + wrapper.removeAttribute("class"); + wrapper.removeAttribute("data-custom-view"); + contentWrapper.removeAttribute("class"); + contentHole?.removeChild(contentWrapper); + renderer.destroy(); } }; }; -export { customNodeView, customSubTrees }; +export { customNodeView }; diff --git a/apps/web/src/lib/editor/extensions/element/index.ts b/apps/web/src/lib/editor/extensions/element/index.ts index 7f6c2c4f..46e8b6fc 100644 --- a/apps/web/src/lib/editor/extensions/element/index.ts +++ b/apps/web/src/lib/editor/extensions/element/index.ts @@ -1 +1,2 @@ export * from "./node"; +export * from "./view-manager"; diff --git a/apps/web/src/lib/editor/extensions/element/node.tsx b/apps/web/src/lib/editor/extensions/element/node.tsx index 58d8d4a8..2d62575e 100644 --- a/apps/web/src/lib/editor/extensions/element/node.tsx +++ b/apps/web/src/lib/editor/extensions/element/node.tsx @@ -2,24 +2,22 @@ import { ElementSelection, isElementSelection } from "./selection"; import { xmlNodeView } from "./xml-node-view"; import { customNodeView } from "./custom-node-view"; import { - CustomView, applyStructure, createCustomView, getCustomElements, getElementPath, updateElementProps } from "./utils"; +import { ElementDisplay, createLoader, customViews, emitter, getTreeUID } from "./view-manager"; import { Element as BaseElement, ElementAttributes } from "@vrite/editor"; import { SolidEditor } from "@vrite/tiptap-solid"; import { NodeView } from "@tiptap/core"; -import { keymap } from "@tiptap/pm/keymap"; import { Node } from "@tiptap/pm/model"; -import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from "@tiptap/pm/state"; -import { NodeView as PMNodeView } from "@tiptap/pm/view"; -import { nanoid } from "nanoid"; +import { NodeSelection, Plugin, PluginKey, Selection, TextSelection } from "@tiptap/pm/state"; import { GapCursor } from "@tiptap/pm/gapcursor"; import { createSignal } from "solid-js"; -import { ExtensionsContextData } from "#context"; +import { ExtensionElementSpec } from "@vrite/sdk/extensions"; +import { ExtensionDetails, ExtensionsContextData } from "#context"; declare module "@tiptap/core" { interface Commands { @@ -29,14 +27,30 @@ declare module "@tiptap/core" { } } -const customViews = new Map(); -const loaders = new Map>(); -const Element = BaseElement.extend>({ +const Element = BaseElement.extend< + Partial, + { + customElements: Record< + string, + { + element: ExtensionElementSpec; + extension: ExtensionDetails; + } + >; + } +>({ addOptions() { return {}; }, - addProseMirrorPlugins() { - const handleDeleteElement = (state: EditorState): boolean => { + addStorage() { + { + return { customElements: {} }; + } + }, + addKeyboardShortcuts() { + const handleDeleteElement = (): boolean => { + const { state } = this.editor; + if (this.editor.isActive("element")) { const currentDepth = state.selection.$from.depth; @@ -53,9 +67,14 @@ const Element = BaseElement.extend>({ } } + if (!node || !pos) return false; + + const element = this.editor.view.nodeDOM(pos) as HTMLElement; + const uid = element?.getAttribute?.("data-uid") || ""; + if ( - node && !node.textContent && + !uid && node.content.childCount === 1 && node.content.firstChild?.type.name === "paragraph" && typeof pos === "number" @@ -82,117 +101,230 @@ const Element = BaseElement.extend>({ return false; }; - const { installedExtensions } = this.options; - const editor = this.editor as SolidEditor; - - let customElements = getCustomElements(installedExtensions); - requestAnimationFrame(() => { - customElements = getCustomElements(installedExtensions); - }); + return { + Delete: handleDeleteElement, + Backspace: handleDeleteElement + }; + }, + addProseMirrorPlugins() { + const { storage, editor } = this; return [ - keymap({ - Delete: handleDeleteElement, - Backspace: handleDeleteElement - }), new Plugin({ - filterTransaction(tr) { - if (!tr.docChanged || tr.getMeta("customView")) return true; + key: new PluginKey("element"), + state: { + init() { + return {}; + }, + apply(tr, previousValue) { + const elementViewTypeData = tr.getMeta("elementViewTypeData"); - const entries: Array<{ node: Node; pos: number }> = []; + if (!elementViewTypeData) return previousValue; - tr.doc.descendants((node, pos) => { - if (node.type.name === "element" && customElements[node.attrs.type.toLowerCase()]) { - entries.push({ - node, - pos - }); + const customView = customViews.get(elementViewTypeData.uid); - return true; + if (customView) { + customView.rawView = !customView.rawView; + emitter.emit(`update:${elementViewTypeData.uid}`); } - }); - - for (const entry of entries) { - const activeElementNode = entry.node; - const activePos = entry.pos; - // TODO: Stop relying on the view - const getCustomView = (): CustomView | null => { - const element = editor.view.nodeDOM(activePos) as HTMLElement | null; - const elementNext = editor.view.nodeDOM(activePos + 1) as HTMLElement | null; - const uid = element instanceof HTMLElement ? element?.getAttribute("data-uid") : null; - const uidNextPos = - elementNext instanceof HTMLElement ? elementNext?.getAttribute("data-uid") : null; - - if (uid) { - const customView = customViews.get(uid); - - if (customView?.type.toLowerCase() === entry.node.attrs.type.toLowerCase()) { - return customView || null; - } - } - if (uidNextPos) { - const customView = customViews.get(uidNextPos); + return previousValue; + } + }, + props: { + handleClick(_, pos, event) { + const { target } = event; - if (customView?.type.toLowerCase() === entry.node.attrs.type.toLowerCase()) { - return customView || null; - } - } + if (!(target instanceof HTMLElement)) return; - return null; - }; - const customView = getCustomView(); + if (target.matches("p,div.content,p *")) { + editor.commands.command(({ tr }) => { + tr.setSelection(TextSelection.near(editor.state.doc.resolve(pos), 1)); - if (!customView) continue; + return true; + }); + + return true; + } if ( - JSON.stringify(applyStructure(activeElementNode, customView?.structure!)) !== - JSON.stringify(activeElementNode.toJSON()) + event.target instanceof HTMLElement && + event.target.getAttribute("data-element-code") ) { - return false; + return true; } } - - return true; }, appendTransaction(_, oldState, newState) { - const setTextSelection = (tr: Transaction, position: number, dir = 1): Transaction => { - const nextSelection = TextSelection.findFrom(tr.doc.resolve(position), dir, true); + const { customElements } = storage; + + if (oldState.selection.eq(newState.selection)) return; + + const insideCustomView = (() => { + const { selection } = newState; - if (nextSelection) { - return tr.setSelection(nextSelection); + for (let { depth } = selection.$from; depth >= 0; depth--) { + const node = selection.$from.node(depth); + + if (node.type.name === "element" && customElements[node.attrs.type.toLowerCase()]) { + return true; + } } - return tr; - }; + return false; + })(); + const moved = oldState.selection.$to.pos >= newState.selection.$to.pos ? "up" : "down"; - if ( - newState.selection instanceof NodeSelection && - !isElementSelection(newState.selection) - ) { - const node = newState.selection.$from.nodeAfter; - - if (node?.type.name === "element" && !customElements[node.attrs.type.toLowerCase()]) { - if (node?.content.size) { - if (oldState.tr.selection.$to.pos >= newState.selection.$to.pos) { - return setTextSelection( - newState.tr, - newState.selection.$from.pos + (node.content.size || 0), - -1 - ); + if (!insideCustomView) { + if ( + newState.selection instanceof NodeSelection && + newState.selection.node.type.name === "element" && + !isElementSelection(newState.selection) + ) { + return newState.tr.setSelection( + ElementSelection.create(newState.tr.doc, newState.selection.$from.pos) + ); + } + + return; + } + + const processSelection = (selection: Selection): Selection | null => { + const isGapCursor = selection instanceof GapCursor; + const isNodeSelection = selection instanceof NodeSelection; + + if ( + isGapCursor && + selection.$from.parent.type.name === "element" && + customElements[selection.$from.parent.attrs.type.toLowerCase()] + ) { + const { nodeAfter, nodeBefore } = selection.$from; + + if (!nodeBefore || !nodeAfter) { + for (let { depth } = selection.$from; depth >= 0; depth--) { + const node = selection.$from.node(depth); + const pos = selection.$from.before(depth); + + if ( + node.type.name === "element" && + customElements[node.attrs.type.toLowerCase()] + ) { + return ElementSelection.create(newState.tr.doc, pos); + } + } + } else if (moved === "up" && nodeBefore) { + return NodeSelection.create( + newState.tr.doc, + selection.$from.pos - nodeBefore.nodeSize + ); + } else if (moved === "down" && nodeAfter) { + return NodeSelection.create(newState.tr.doc, selection.$from.pos); + } + } + + if (isNodeSelection) { + const { nodeAfter, nodeBefore } = selection.$from; + const { node } = selection; + const element = editor.view.nodeDOM(selection.$from.pos) as HTMLElement; + const uid = element?.getAttribute?.("data-uid") || ""; + + if (node?.type.name === "element" && node.content.size && uid) { + const nodePos = selection.$from.pos; + const textPos = nodePos + 2 + (moved === "up" ? node.nodeSize - 4 : 0); + const textSelection = TextSelection.create(newState.tr.doc, textPos, textPos); + const textSelectionParent = textSelection.$from.parent; + const textSelectionParentPos = + textSelection.$from.pos - textSelection.$from.parentOffset - 1; + + if (textSelectionParent.inlineContent) { + return textSelection; } else { - return setTextSelection(newState.tr, newState.selection.$from.pos + 2); + if ( + textSelectionParent.type.name === "element" && + customElements[textSelectionParent.attrs.type.toLowerCase()] + ) { + return new GapCursor(newState.tr.doc.resolve(textSelectionParentPos)); + } + + let newTextSelection: TextSelection | null = null; + + textSelectionParent.descendants((node, pos) => { + const absolutePos = textSelectionParentPos + pos; + + if (newTextSelection) return false; + + if (node.inlineContent) { + newTextSelection = TextSelection.create( + newState.tr.doc, + absolutePos + 2 + (moved === "up" ? node.nodeSize - 2 : 0), + absolutePos + 2 + (moved === "up" ? node.nodeSize - 2 : 0) + ); + + return false; + } else { + return true; + } + }); + + if (newTextSelection) { + return newTextSelection; + } + } + } + + if (!nodeBefore || !nodeAfter) { + for (let { depth } = selection.$from; depth >= 0; depth--) { + const node = selection.$from.node(depth); + const pos = selection.$from.before(depth); + + if ( + node.type.name === "element" && + customElements[node.attrs.type.toLowerCase()] + ) { + return ElementSelection.create(newState.tr.doc, pos); + } } - } else if (oldState.selection.$to.pos === newState.selection.$to.pos) { - return newState.tr.setSelection(new GapCursor(newState.selection.$from)); - } else { - return newState.tr.setSelection(new GapCursor(newState.selection.$to)); + } else if (moved === "up" && nodeBefore) { + return NodeSelection.create( + newState.tr.doc, + selection.$from.pos - nodeBefore.nodeSize + ); + } else if (moved === "down" && nodeAfter) { + const pos = selection.$from.pos + nodeAfter.nodeSize; + const node = newState.tr.doc.nodeAt(pos); + + if (!node) return new GapCursor(newState.tr.doc.resolve(pos)); + + return NodeSelection.create(newState.tr.doc, pos); } } + + return null; + }; + + let selection = newState.selection as Selection | null; + let i = 0; + + while ( + selection && + ((selection instanceof NodeSelection && !isElementSelection(selection)) || + selection instanceof GapCursor) + ) { + if (i > 100) break; + + const newSelection = processSelection(selection); + + selection = newSelection || selection; + i += 1; + if (!newSelection) break; } - return null; + if (selection) { + return newState.tr.setSelection(selection).scrollIntoView(); + } else { + return newState.tr.scrollIntoView(); + } } }) ]; @@ -215,55 +347,37 @@ const Element = BaseElement.extend>({ } }; }, + onCreate() { + this.storage.customElements = getCustomElements(this.options.installedExtensions?.() || []); + }, addNodeView() { + const editor = this.editor as SolidEditor; + const { storage } = this; + return (props) => { const [elementPropsJSON, setElementPropsJSON] = createSignal( JSON.stringify(props.node.attrs.props || {}) ); - - let { node } = props; - const referenceView = new NodeView(() => {}, props); const wrapper = document.createElement("div"); const contentWrapper = document.createElement("div"); - const loadingId = nanoid(); + const loaded = createLoader(wrapper); - let view: Partial | null = null; - let resolveLoader = (): void => {}; + let { node } = props; + let view: ElementDisplay | null = null; + let removeListener = (): void => {}; wrapper.setAttribute("data-element", "true"); - wrapper.setAttribute("data-loading-id", loadingId); - loaders.set( - loadingId, - new Promise((resolve) => { - resolveLoader = resolve; - }).then(() => { - loaders.delete(loadingId); - wrapper.removeAttribute("data-loading-id"); - }) - ); requestAnimationFrame(async () => { - if (typeof props.getPos !== "function") return; + if (typeof props.getPos !== "function" || typeof props.getPos() !== "number") return; - const { installedExtensions } = this.options; - const editor = this.editor as SolidEditor; + const { customElements } = this.storage; const customNodeType = node.attrs.type.toLowerCase(); - const customElements = getCustomElements(installedExtensions); const customElement = customNodeType ? customElements[customNodeType] : null; - const resolvedPos = props.editor.state.doc.resolve(props.getPos()); - const parentPos = props.getPos() - resolvedPos.parentOffset - 1; + const resolvedPos = editor.state.doc.resolve(props.getPos()); const path = getElementPath(resolvedPos, customElements); - if (parentPos >= 0) { - const parentElement = props.editor.view.nodeDOM(parentPos) as HTMLElement; - const parentLoadingId = parentElement.getAttribute("data-loading-id"); - - if (parentLoadingId) { - await loaders.get(parentLoadingId); - } - } - - let uid = ""; + let uid = await getTreeUID(editor, props.getPos()); if (customElement) { const customView = await createCustomView( @@ -277,57 +391,63 @@ const Element = BaseElement.extend>({ ); if (customView) { - uid = uid || customView.uid; + uid = customView.uid || uid; customViews.set(customView.uid, customView); } const content = applyStructure(node, customView?.structure!); - editor - .chain() - .setMeta("customView", true) - .insertContentAt( - { from: props.getPos() + 1, to: props.getPos() + node.content.size + 1 }, - content.content || [] - ) - .run(); - } else if (parentPos >= 0) { - const parentElement = props.editor.view.nodeDOM(parentPos) as HTMLElement; - - uid = parentElement?.getAttribute?.("data-uid") || ""; + if (node.content.size <= 2) { + editor + .chain() + .insertContentAt( + { from: props.getPos() + 1, to: props.getPos() + node.content.size + 1 }, + content.content || [] + ) + .run(); + } } - if (uid) { - const customView = customViews.get(uid); - const matchedView = customView?.views.find((view) => { - return view.path.join(".") === path.join("."); - }); - - if (customView && matchedView) { - view = customNodeView({ - props, - editor, - uid, - view: matchedView?.view!, - top: matchedView.top, - extension: customView.extension, - contentWrapper, - wrapper, - getProps() { - return JSON.parse(elementPropsJSON()); - }, - updateProps(newProps) { - updateElementProps(newProps, editor, customView); - } + const loadView = (): void => { + if (uid && !customViews.get(uid)?.rawView) { + const customView = customViews.get(uid); + const matchedView = customView?.views.find((view) => { + return view.path.join(".") === path.join("."); }); - resolveLoader(); - return; + if (customView && matchedView) { + if (uid) wrapper.setAttribute("data-uid", uid); + + view = customNodeView({ + props, + editor, + uid, + view: matchedView?.view!, + extension: customView.extension, + contentWrapper, + wrapper, + getProps() { + return JSON.parse(elementPropsJSON()); + }, + updateProps(newProps) { + updateElementProps(newProps, editor, customView); + } + }); + loaded(); + + return; + } } - } - view = xmlNodeView({ props, editor, wrapper, contentWrapper }); - resolveLoader(); + view = xmlNodeView({ props, editor, wrapper, contentWrapper }); + loaded(); + }; + + removeListener = emitter.on(`update:${uid}`, (uid) => { + view?.unmount(); + loadView(); + }); + loadView(); }); return { @@ -341,10 +461,13 @@ const Element = BaseElement.extend>({ return referenceView.ignoreMutation(mutation); }, selectNode() { - view?.selectNode?.(); + view?.onSelect?.(); }, deselectNode() { - view?.deselectNode?.(); + view?.onDeselect?.(); + }, + destroy() { + removeListener(); }, stopEvent(event) { if ( @@ -356,11 +479,22 @@ const Element = BaseElement.extend>({ return false; }, - update(newNode, decorations, innerDecorations) { - view?.update?.(newNode, decorations, innerDecorations); + update(newNode) { + if (typeof props.getPos !== "function" || typeof props.getPos() !== "number") { + return false; + } + if (newNode.type.name !== "element") return false; - if (newNode.attrs.type !== node.attrs.type) return false; - if (Boolean(newNode.content.size) !== Boolean(node.content.size)) return false; + + view?.onUpdate?.(newNode); + + if ( + newNode.attrs.type !== node.attrs.type && + storage.customElements[newNode.attrs.type.toLowerCase()] !== + storage.customElements[node.attrs.type.toLowerCase()] + ) { + return false; + } setElementPropsJSON(JSON.stringify(newNode.attrs.props || {})); node = newNode; diff --git a/apps/web/src/lib/editor/extensions/element/selection.ts b/apps/web/src/lib/editor/extensions/element/selection.ts index 21384607..ba11c8c8 100644 --- a/apps/web/src/lib/editor/extensions/element/selection.ts +++ b/apps/web/src/lib/editor/extensions/element/selection.ts @@ -1,5 +1,6 @@ -import { ResolvedPos } from "@tiptap/pm/model"; +import { Node, ResolvedPos } from "@tiptap/pm/model"; import { NodeSelection, Selection } from "@tiptap/pm/state"; +import { Mappable } from "@tiptap/pm/transform"; const elementSelectionActive = Symbol("elementSelectionActive"); @@ -14,6 +15,24 @@ class ElementSelection extends NodeSelection { public static create(doc: any, from: number, active?: boolean): ElementSelection { return new ElementSelection(doc.resolve(from), active); } + + public map(doc: Node, mapping: Mappable): Selection { + const mapped = super.map(doc, mapping); + + try { + return ElementSelection.create(doc, mapped.from, this[elementSelectionActive]); + } catch (e) { + return super.map(doc, mapping); + } + } + + public eq(other: Selection): boolean { + return ( + super.eq(other) && + other instanceof ElementSelection && + this[elementSelectionActive] === other[elementSelectionActive] + ); + } } const isElementSelection = (selection: Selection): selection is ElementSelection => { diff --git a/apps/web/src/lib/editor/extensions/element/utils.ts b/apps/web/src/lib/editor/extensions/element/utils.ts index 926d8a5f..0c1ec6d2 100644 --- a/apps/web/src/lib/editor/extensions/element/utils.ts +++ b/apps/web/src/lib/editor/extensions/element/utils.ts @@ -18,12 +18,13 @@ type CustomView = { extension: ExtensionDetails; views: Array<{ path: string[]; view: ExtensionElement; top?: boolean }>; structure: StructureNode; + rawView?: boolean; getPos(): number; node(): PMNode; }; const getCustomElements = ( - installedExtensions?: () => ExtensionDetails[] + installedExtensions: ExtensionDetails[] ): Record< string, { @@ -39,7 +40,7 @@ const getCustomElements = ( } > = {}; - installedExtensions?.().forEach((extension) => { + installedExtensions.forEach((extension) => { if (!extension.id) return; const spec = extension.sandbox?.spec; @@ -68,7 +69,7 @@ const getElementPath = ( ): string[] => { let path: string[] = []; - const appendToPath = (node: PMNode, index: number): void => { + const appendToPath = (node: PMNode): void => { if (node.type.name === "element") { const type = `${node.attrs.type || "element"}`.toLowerCase(); @@ -77,19 +78,18 @@ const getElementPath = ( } else if (customElements[type]) { path = [type]; } else { - path.push(`${type}#${index}`); + path.push(`${type}`); } } }; for (let i = 1; i <= resolvedPos.depth; i++) { const node = resolvedPos.node(i); - const index = resolvedPos.index(i - 1); - appendToPath(node, index); + appendToPath(node); } - appendToPath(resolvedPos.nodeAfter!, resolvedPos.index()); + appendToPath(resolvedPos.nodeAfter!); return path; }; @@ -111,7 +111,7 @@ const updateElementProps = ( } if (node && node.type.name === "element") { - tr.setNodeMarkup(pos, node.type, { + tr.setMeta("addToHistory", false).setNodeMarkup(pos, node.type, { props: { ...newProps }, type: node.attrs.type }); @@ -128,7 +128,7 @@ const createCustomView = async ( ): Promise => { const uid = nanoid(); - await extension.sandbox?.setEnvDataAsync((envData) => { + extension.sandbox?.setLocalEnvData((envData) => { return { ...envData, [uid]: { @@ -161,22 +161,18 @@ const createCustomView = async ( const processElementTree = ( parentPath: string[], parentStructureNode: StructureNode, - element: ExtensionElement, - index?: number + element: ExtensionElement ): void => { const processSlot = (parentPath: string[], parentStructureNode: StructureNode): void => { - element.slot?.forEach((childElement, index) => { + element.slot?.forEach((childElement) => { if (typeof childElement === "object") { - processElementTree(parentPath, parentStructureNode, childElement, index); + processElementTree(parentPath, parentStructureNode, childElement); } }); }; if (element.component === "Element") { - const path = [ - ...parentPath, - `${element.props?.type || ""}${typeof index === "number" ? `#${index}` : ""}`.toLowerCase() - ]; + const path = [...parentPath, `${element.props?.type || ""}`.toLowerCase()]; const view = { path, view: { component: "Fragment", slot: element.slot } }; const structure = { element: `${element.props?.type || ""}` }; diff --git a/apps/web/src/lib/editor/extensions/element/view-manager.ts b/apps/web/src/lib/editor/extensions/element/view-manager.ts new file mode 100644 index 00000000..dd098caf --- /dev/null +++ b/apps/web/src/lib/editor/extensions/element/view-manager.ts @@ -0,0 +1,54 @@ +import { CustomView } from "./utils"; +import { nanoid } from "nanoid"; +import { createNanoEvents } from "nanoevents"; +import { SolidEditor } from "@vrite/tiptap-solid"; +import { Node } from "@tiptap/pm/model"; + +interface ElementDisplay { + onSelect(): void; + onDeselect(): void; + onUpdate?(node: Node): void; + unmount(): void; +} + +const customViews = new Map(); +const loaders = new Map>(); +const emitter = createNanoEvents(); +const createLoader = (element: HTMLElement): (() => void) => { + const loadingId = nanoid(); + + let loaded = (): void => {}; + + element.setAttribute("data-loader-id", loadingId); + loaders.set( + loadingId, + new Promise((resolve) => { + loaded = resolve; + }).then(() => { + loaders.delete(loadingId); + element.removeAttribute("data-loader-id"); + }) + ); + + return loaded; +}; +const getTreeUID = async (editor: SolidEditor, pos: number): Promise => { + const resolvedPos = editor.state.doc.resolve(pos); + const parentPos = pos - resolvedPos.parentOffset - 1; + + if (parentPos >= 0) { + const parentElement = editor.view.nodeDOM(parentPos) as HTMLElement; + const parentLoadingId = parentElement.getAttribute("data-loader-id"); + + if (parentLoadingId) { + await loaders.get(parentLoadingId); + } + + return parentElement?.getAttribute?.("data-uid") || null; + } + + return null; +}; + +export { createLoader, getTreeUID, customViews, loaders, emitter }; +export type { ElementDisplay }; diff --git a/apps/web/src/lib/editor/extensions/element/xml-node-view.tsx b/apps/web/src/lib/editor/extensions/element/xml-node-view.tsx index ed14829a..07d1cc4b 100644 --- a/apps/web/src/lib/editor/extensions/element/xml-node-view.tsx +++ b/apps/web/src/lib/editor/extensions/element/xml-node-view.tsx @@ -1,10 +1,10 @@ import { isElementSelection, isElementSelectionActive } from "./selection"; -import { Editor, NodeView, NodeViewRendererProps } from "@tiptap/core"; +import { ElementDisplay } from "./view-manager"; +import { Editor, NodeViewRendererProps } from "@tiptap/core"; import { Node as PMNode } from "@tiptap/pm/model"; -import { NodeView as PMNodeView } from "@tiptap/pm/view"; import { SolidEditor } from "@vrite/tiptap-solid"; import { Transaction } from "@tiptap/pm/state"; -import { wrap } from "module"; +import { active } from "sortablejs"; import { formatCode } from "#lib/code-editor"; const getOpeningTag = async (node: PMNode): Promise => { @@ -36,9 +36,7 @@ const xmlNodeView = ({ editor: SolidEditor; wrapper: HTMLElement; contentWrapper: HTMLElement; -}): Partial => { - const referenceView = new NodeView(() => {}, props); - +}): ElementDisplay => { let node = props.node as PMNode; const contentContainer = document.createElement("div"); @@ -47,6 +45,7 @@ const xmlNodeView = ({ const bottomCodeStart = document.createElement("span"); const bottomCodeKey = document.createElement("span"); const bottomCodeEnd = document.createElement("span"); + const selectionBackground = document.createElement("div"); const handleCodeClick = (event: MouseEvent): void => { if (typeof props.getPos === "function") { editor.commands.setElementSelection(props.getPos(), true); @@ -70,10 +69,16 @@ const xmlNodeView = ({ "class", "!whitespace-pre-wrap select-none leading-[26px] min-h-6.5 block w-full !p-0 !bg-transparent !rounded-0 !text-gray-400 !dark:text-gray-400 cursor-pointer" ); + code.setAttribute("data-element-code", "true"); bottomCode.setAttribute( "class", "block w-full !p-0 leading-[26px] min-h-6.5 !rounded-0 !bg-transparent !text-gray-400 !dark:text-gray-400 cursor-pointer select-none" ); + bottomCode.setAttribute("data-element-code", "true"); + selectionBackground.setAttribute( + "class", + "absolute -top-1 -left-1 w-[calc(100%+0.25rem)] h-[calc(100%+0.5rem)] bg-primary opacity-20 -z-1 rounded-lg" + ); code.contentEditable = "false"; bottomCode.contentEditable = "false"; bottomCode.append(bottomCodeStart, bottomCodeKey, bottomCodeEnd); @@ -91,35 +96,44 @@ const xmlNodeView = ({ editor: Editor; transaction?: Transaction; force?: "select" | "deselect"; - }): void => { + }): { selected: boolean; active: boolean } => { const pos = typeof props.getPos === "function" ? props.getPos() : null; const { selection } = editor.state; const selectionPos = selection.$from.pos; - if (pos === null) return; + if (pos === null) return { selected: false, active: false }; if ( pos === selectionPos && isElementSelection(selection) && isElementSelectionActive(selection) ) { + selectionBackground.remove(); + contentContainer.classList.remove("!border-primary"); code.classList.add("selected-element-code"); bottomCodeKey.classList.add("selected-element-bottom-code"); bottomCode.classList.remove("!text-gray-400", "!dark:text-gray-400"); bottomCode.classList.add("!text-[#000000]", "!dark:text-[#DCDCDC]"); bottomCodeKey.classList.add("!text-[#008080]", "!dark:text-[#3dc9b0]"); + + return { selected: true, active: true }; } else if ( pos === selectionPos && isElementSelection(selection) && !isElementSelectionActive(selection) ) { + if (!node.content.size) code.append(selectionBackground); + contentContainer.classList.add("!border-primary"); code.classList.remove("selected-element-code"); bottomCodeKey.classList.remove("selected-element-bottom-code"); bottomCode.classList.add("!text-gray-400", "!dark:text-gray-400"); bottomCode.classList.remove("!text-[#000000]", "!dark:text-[#DCDCDC]"); bottomCodeKey.classList.remove("!text-[#008080]", "!dark:text-[#3dc9b0]"); + + return { selected: true, active: false }; } else { + selectionBackground.remove(); contentContainer.classList.remove("!border-primary"); code.classList.remove("selected-element-code"); bottomCodeKey.classList.remove("selected-element-bottom-code"); @@ -127,25 +141,35 @@ const xmlNodeView = ({ bottomCode.classList.remove("!text-[#000000]", "!dark:text-[#DCDCDC]"); bottomCodeKey.classList.remove("!text-[#008080]", "!dark:text-[#3dc9b0]"); } + + return { selected: false, active: false }; }; + const initialState = update({ editor }); + + if (initialState.selected) { + editor.on("transaction", update); + } return { - selectNode() { - editor.on("update", update); - editor.on("selectionUpdate", update); + onSelect() { + editor.on("transaction", update); }, - deselectNode() { + onDeselect() { update({ editor, force: "deselect" }); - editor.off("update", update); - editor.off("selectionUpdate", update); + editor.off("transaction", update); }, - update(newNode) { + onUpdate(newNode) { if (newNode.type.name !== "element") return false; - if (newNode.attrs.type !== node.attrs.type) return false; node = newNode; - getOpeningTag(node).then((openingTag) => (code.textContent = openingTag)); - bottomCodeKey.textContent = getClosingTag(node); + + if ( + !isElementSelection(editor.state.selection) || + !isElementSelectionActive(editor.state.selection) + ) { + getOpeningTag(node).then((openingTag) => (code.textContent = openingTag)); + bottomCodeKey.textContent = getClosingTag(node); + } if (node.content.size) { bottomCode.classList.remove("!hidden"); @@ -156,6 +180,14 @@ const xmlNodeView = ({ } return true; + }, + unmount() { + wrapper.removeAttribute("class"); + contentWrapper.removeAttribute("class"); + wrapper.removeChild(code); + wrapper.removeChild(contentContainer); + wrapper.removeChild(bottomCode); + contentContainer.removeChild(contentWrapper); } }; }; diff --git a/apps/web/src/lib/editor/extensions/index.ts b/apps/web/src/lib/editor/extensions/index.ts index 835242db..c96697f4 100644 --- a/apps/web/src/lib/editor/extensions/index.ts +++ b/apps/web/src/lib/editor/extensions/index.ts @@ -16,5 +16,4 @@ export * from "./table-menu"; export * from "./element"; export * from "./auto-dir"; export * from "./xml-node-menu"; -export * from "./custom-node-menu"; export * from "./autocomplete"; diff --git a/apps/web/src/lib/editor/extensions/shortcuts.ts b/apps/web/src/lib/editor/extensions/shortcuts.ts index 74a9c6d7..dbd720e0 100644 --- a/apps/web/src/lib/editor/extensions/shortcuts.ts +++ b/apps/web/src/lib/editor/extensions/shortcuts.ts @@ -7,6 +7,7 @@ import { useNotifications } from "#context"; const Shortcuts = Extension.create({ priority: 10000, + name: "shortcuts", addKeyboardShortcuts() { const { notify } = useNotifications(); const { editor } = this; diff --git a/apps/web/src/lib/editor/extensions/xml-node-menu/component.tsx b/apps/web/src/lib/editor/extensions/xml-node-menu/component.tsx index bbd135f5..3dcd6aa3 100644 --- a/apps/web/src/lib/editor/extensions/xml-node-menu/component.tsx +++ b/apps/web/src/lib/editor/extensions/xml-node-menu/component.tsx @@ -3,6 +3,7 @@ import { ElementSelection, isElementSelection } from "../element/selection"; import { SolidEditor } from "@vrite/tiptap-solid"; import { Component, Show, createEffect, createSignal } from "solid-js"; import { Node as PMNode } from "@tiptap/pm/model"; +import { Selection } from "@tiptap/pm/state"; interface ElementMenuState { pos: number; @@ -51,30 +52,38 @@ const ElementMenu: Component = (props) => { contentSize: props.state.node?.content.size || 0, removeElement() { - props.state.editor.commands.command(({ tr, dispatch }) => { - if (!dispatch) return false; + props.state.editor + .chain() + .command(({ tr, dispatch }) => { + if (!dispatch) return false; - const lastPos = props.state.pos; + const lastPos = props.state.pos; - if (typeof lastPos === "number" && props.state.node) { - tr.delete(lastPos, lastPos + props.state.node.nodeSize); + if (typeof lastPos === "number" && props.state.node) { + tr.delete(lastPos, lastPos + props.state.node.nodeSize).setSelection( + Selection.near(tr.doc.resolve(lastPos)) + ); - return true; - } + return true; + } - return false; - }); + return false; + }) + .focus() + .run(); }, setElement(element) { props.state.editor.commands.command(({ tr, dispatch }) => { if (!dispatch) return false; - const lastSelection = props.state.editor.state.selection; const lastPos = props.state.pos; if (lastPos !== null) { - tr.setNodeAttribute(lastPos, "type", element.type); - tr.setNodeAttribute(lastPos, "props", element.props); + tr.setNodeAttribute(lastPos, "type", element.type).setNodeAttribute( + lastPos, + "props", + element.props + ); if (element.content && !props.state.node?.content.size) { tr.replaceWith( @@ -86,12 +95,6 @@ const ElementMenu: Component = (props) => { tr.delete(lastPos + 1, lastPos + props.state.node!.content.size + 1); } - if (isElementSelection(lastSelection) && props.state.editor.isFocused) { - tr.setSelection( - ElementSelection.create(tr.doc, lastSelection.$from.pos, false) - ); - } - return true; } diff --git a/apps/web/src/lib/editor/extensions/xml-node-menu/editor.tsx b/apps/web/src/lib/editor/extensions/xml-node-menu/editor.tsx index 25b1e27f..3bff0d58 100644 --- a/apps/web/src/lib/editor/extensions/xml-node-menu/editor.tsx +++ b/apps/web/src/lib/editor/extensions/xml-node-menu/editor.tsx @@ -1,4 +1,8 @@ -import { isElementSelection, isElementSelectionActive } from "../element/selection"; +import { + ElementSelection, + isElementSelection, + isElementSelectionActive +} from "../element/selection"; import { createEffect, createMemo, @@ -84,7 +88,7 @@ const ElementMenuEditor = lazy(async () => { const [coords, setCoords] = createSignal({ x: 0, y: 0 }); const [visible, setVisible] = createSignal(true); const type = createMemo(() => props.state.type); - const processCode = async (code: string): Promise => { + const processCode = async (code: string, selfClosing?: boolean): Promise => { const codeTagClosed = code?.trim().replace(/>$/, "/>") || ""; const formattedCode = await formatCode(codeTagClosed, "typescript", { printWidth: 60, @@ -92,7 +96,7 @@ const ElementMenuEditor = lazy(async () => { singleQuote: false }); - return formattedCode.replace(/ *?\/>;/gm, props.state.contentSize ? ">" : "/>").trim(); + return formattedCode.replace(/ *?\/>;/gm, selfClosing ? "/>" : ">").trim(); }; const propsValue = createMemo((previous) => { if (!previous || JSON.stringify(previous) !== JSON.stringify(props.state.props)) { @@ -118,7 +122,7 @@ const ElementMenuEditor = lazy(async () => { const saveLastCoords = (event: MouseEvent): void => { setCoords({ x: event.clientX, y: event.clientY }); }; - const onSave = async (code: string): Promise => { + const onSave = async (code: string, state: ElementMenuState): Promise => { const tagRegex = /^<(\w+?)(?:\s|\n|\/|>)/; const attributeRegex = /\s(\w+?)(?:=(?:(?:{((?:.|\n|\s)+?)})|(?:"((?:.|\n|\s)+?)")|(?:'((?:.|\n|\s)+?)')))?(?=(?:(?:\s|\n)+\w+=?)|(?:(?:\s|\n)*\/?>))/g; @@ -142,10 +146,10 @@ const ElementMenuEditor = lazy(async () => { try { attributes[key] = JSON.parse(value); } catch (e) { - if (!props.state.props[key] || typeof props.state.props[key] === "string") { + if (!state.props[key] || typeof state.props[key] === "string") { attributes[key] = value; } else { - attributes[key] = props.state.props[key]; + attributes[key] = state.props[key]; } } } @@ -156,13 +160,13 @@ const ElementMenuEditor = lazy(async () => { await processAttributes(); if (tag && tag !== "undefined") { - props.state.setElement({ + state.setElement({ type: tag, props: attributes, content: !code.endsWith("/>") }); } else { - props.state.removeElement(); + state.removeElement(); } }; @@ -183,6 +187,20 @@ const ElementMenuEditor = lazy(async () => { height: contentHeight }); }; + const elementSelectionActive = createMemo<{ active: boolean; pos: number }>((previous) => { + const active = + isElementSelection(props.state.editor.state.selection) && + isElementSelectionActive(props.state.editor.state.selection); + const { pos } = props.state.editor.state.selection.$from; + + if (previous && active === previous.active && pos === previous?.pos) { + return previous; + } + + return { active, pos }; + }); + + let ignoreNextBlur = false; codeEditor.onDidContentSizeChange(updateEditorHeight); codeEditor.onDidChangeModelContent(() => { @@ -190,9 +208,18 @@ const ElementMenuEditor = lazy(async () => { const bottomCode = document.querySelector(".selected-element-bottom-code") as HTMLElement; const value = codeEditor.getValue() || ""; + if (!value || value.match(/^)?$/)) { + ignoreNextBlur = true; + props.state.removeElement(); + + return; + } + if (code) { code.textContent = value; - code.style.minHeight = `${codeEditor.getContentHeight()}px`; + requestAnimationFrame(() => { + code.style.minHeight = `${codeEditor.getContentHeight()}px`; + }); } if (bottomCode) { @@ -202,9 +229,15 @@ const ElementMenuEditor = lazy(async () => { } }); codeEditor.onDidBlurEditorText(async () => { + if (ignoreNextBlur) { + ignoreNextBlur = false; + + return; + } + const code = document.querySelector(".selected-element-code") as HTMLElement; const bottomCode = document.querySelector(".selected-element-bottom-code") as HTMLElement; - const value = await processCode(editorCode()); + const value = await processCode(editorCode(), !props.state.contentSize); if (code) { code.textContent = value; @@ -216,7 +249,7 @@ const ElementMenuEditor = lazy(async () => { bottomCode.textContent = tag || ""; } - await onSave(codeEditor.getValue()); + await onSave(codeEditor.getValue(), props.state); }); codeEditor.addAction({ id: "save", @@ -242,26 +275,20 @@ const ElementMenuEditor = lazy(async () => { monaco.editor.setTheme(codeEditorTheme()); }); createEffect( - on( - () => { - return ( - isElementSelection(props.state.editor.state.selection) && - isElementSelectionActive(props.state.editor.state.selection) - ); - }, - (active) => { - setVisible(false); - setTimeout(() => { - const element: HTMLElement | null = document.querySelector( - ".selected-element-code" - ) as HTMLElement; - - if (!element) return; - - if (active) { - codeEditor.setValue(element?.textContent || ""); - setVisible(true); + on(elementSelectionActive, ({ active }) => { + setVisible(false); + requestAnimationFrame(() => { + const element: HTMLElement | null = document.querySelector( + ".selected-element-code" + ) as HTMLElement; + + if (!element) return; + element.style.minHeight = "unset"; + + if (active) { + codeEditor.setValue(element?.textContent || ""); + requestAnimationFrame(() => { const { position } = codeEditor?.getTargetAtClientPoint(coords().x, coords().y) || {}; @@ -270,24 +297,22 @@ const ElementMenuEditor = lazy(async () => { } codeEditor?.focus(); - element.style.minHeight = `${codeEditor.getContentHeight()}px`; - } else { - element.style.minHeight = "unset"; - } - }, 0); - } - ) + requestAnimationFrame(() => { + element.style.minHeight = `${codeEditor.getContentHeight()}px`; + setVisible(true); + }); + }); + } + }); + + return props.state.editor.state.selection as ElementSelection; + }) ); onCleanup(() => { codeEditor.getModel()?.dispose(); codeEditor.dispose(); }); }); - createRenderEffect( - on(type, (type, previousType) => { - if (type !== previousType) setVisible(false); - }) - ); window.addEventListener("pointerdown", saveLastCoords); onCleanup(() => { window.removeEventListener("pointerdown", saveLastCoords); @@ -295,10 +320,7 @@ const ElementMenuEditor = lazy(async () => { return (
diff --git a/apps/web/src/lib/editor/extensions/xml-node-menu/plugin.tsx b/apps/web/src/lib/editor/extensions/xml-node-menu/plugin.tsx index 8fb2d4b3..5fd9aeaf 100644 --- a/apps/web/src/lib/editor/extensions/xml-node-menu/plugin.tsx +++ b/apps/web/src/lib/editor/extensions/xml-node-menu/plugin.tsx @@ -104,10 +104,7 @@ const ElementMenuPlugin = Extension.create({ generalMenu?.setState((state) => ({ ...state, active: true })); } }, - onUpdate() { - handleUpdate(this.editor as SolidEditor); - }, - onSelectionUpdate() { + onTransaction() { handleUpdate(this.editor as SolidEditor); } }); diff --git a/apps/web/src/lib/extensions/sandbox.ts b/apps/web/src/lib/extensions/sandbox.ts index ae85dd9d..fbcf802e 100644 --- a/apps/web/src/lib/extensions/sandbox.ts +++ b/apps/web/src/lib/extensions/sandbox.ts @@ -60,6 +60,7 @@ interface ExtensionSandbox { spec: ExtensionSpec & ExtensionRuntimeSpec; envData: Accessor; setEnvData: Setter; + setLocalEnvData: Setter; setEnvDataAsync: AsyncSetter; destroy(): void; generateView( @@ -137,6 +138,9 @@ const loadExtensionSandbox = async ( setEnvData(...args); sandbox.connection?.remote.updateEnvData(JSON.parse(JSON.stringify(envData()))); }, + setLocalEnvData(...args: Parameters) { + setEnvData(...args); + }, async setEnvDataAsync(...args: Parameters) { setEnvData(...args); await sandbox.connection?.remote.updateEnvData(JSON.parse(JSON.stringify(envData()))); diff --git a/apps/web/src/lib/utils/selection.tsx b/apps/web/src/lib/utils/selection.tsx index 2855b88b..ceea2f26 100644 --- a/apps/web/src/lib/utils/selection.tsx +++ b/apps/web/src/lib/utils/selection.tsx @@ -17,62 +17,74 @@ const selectionClasses: Record< cursor: string; outline: string; label: string; + gapCursor: string; } > = { red: { cursor: "ring-red-500", outline: "border-red-500", - label: "bg-red-500 dark:bg-red-500" + label: "bg-red-500 dark:bg-red-500", + gapCursor: "!after:border-red-500 !dark:after:border-red-500" }, orange: { cursor: "ring-orange-500", outline: "border-orange-500", - label: "bg-orange-500 dark:bg-orange-500" + label: "bg-orange-500 dark:bg-orange-500", + gapCursor: "!after:border-orange-500 !dark:after:border-orange-500" }, amber: { cursor: "ring-amber-500", outline: "border-amber-500", - label: "bg-amber-500 dark:bg-amber-500" + label: "bg-amber-500 dark:bg-amber-500", + gapCursor: "!after:border-amber-500 !dark:after:border-amber-500" }, purple: { cursor: "ring-purple-500", outline: "border-purple-500", - label: "bg-purple-500 dark:bg-purple-500" + label: "bg-purple-500 dark:bg-purple-500", + gapCursor: "!after:border-purple-500 !dark:after:border-purple-500" }, indigo: { cursor: "ring-indigo-500", outline: "border-indigo-500", - label: "bg-indigo-500 dark:bg-indigo-500" + label: "bg-indigo-500 dark:bg-indigo-500", + gapCursor: "!after:border-indigo-500 !dark:after:border-indigo-500" }, blue: { cursor: "ring-blue-500", outline: "border-blue-500", - label: "bg-blue-500 dark:bg-blue-500" + label: "bg-blue-500 dark:bg-blue-500", + gapCursor: "!after:border-blue-500 !dark:after:border-blue-500" }, cyan: { cursor: "ring-cyan-500", outline: "border-cyan-500", - label: "bg-cyan-500 dark:bg-cyan-500" + label: "bg-cyan-500 dark:bg-cyan-500", + gapCursor: "!after:border-cyan-500 !dark:after:border-cyan-500" }, green: { cursor: "ring-green-500", outline: "border-green-500", - label: "bg-green-500 dark:bg-green-500" + label: "bg-green-500 dark:bg-green-500", + gapCursor: "!after:border-green-500 !dark:after:border-green-500" }, teal: { cursor: "ring-teal-500", outline: "border-teal-500", - label: "bg-teal-500 dark:bg-teal-500" + label: "bg-teal-500 dark:bg-teal-500", + gapCursor: "!after:border-teal-500 !dark:after:border-teal-500" }, lime: { cursor: "ring-lime-500", outline: "border-lime-500", - label: "bg-lime-500 dark:bg-lime-500" + label: "bg-lime-500 dark:bg-lime-500", + gapCursor: "!after:border-lime-500 !dark:after:border-lime-500" }, emerald: { cursor: "ring-emerald-500", outline: "border-emerald-500", - label: "bg-emerald-500 dark:bg-emerald-500" + label: "bg-emerald-500 dark:bg-emerald-500", + gapCursor: "!after:border-emerald-500 !dark:after:border-emerald-500" } }; const getSelectionColor = (): (typeof selectionColors)[number] => { diff --git a/apps/web/src/styles/index.ts b/apps/web/src/styles/index.ts index 01f90afe..34a267db 100644 --- a/apps/web/src/styles/index.ts +++ b/apps/web/src/styles/index.ts @@ -1,4 +1,4 @@ import "@unocss/reset/tailwind.css"; +import "./styles.css"; import "virtual:uno.css"; import "./styles.scss"; -import "./styles.css"; diff --git a/apps/web/src/styles/styles.scss b/apps/web/src/styles/styles.scss index d4cebe21..045e1309 100644 --- a/apps/web/src/styles/styles.scss +++ b/apps/web/src/styles/styles.scss @@ -301,30 +301,34 @@ kbd { > :where(div, ul, ol, p, blockquote, hr):not(.ProseMirror-gapcursor) + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not( [data-element="true"], - .ProseMirror-widget + .ProseMirror-widget:not(:has([data-collab-cursor])) ), & > :where(div, ul, ol, p, blockquote, hr):not( [data-element="true"], .ProseMirror-gapcursor, - .ProseMirror-widget + .ProseMirror-widget:not(:has([data-collab-cursor])) ) - + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not(.ProseMirror-widget), + + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not( + .ProseMirror-widget:not(:has([data-collab-cursor])) + ), & > :where(div, ul, ol, p, blockquote, hr):not(.ProseMirror-gapcursor) + .ProseMirror-gapcursor + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not( [data-element="true"], - .ProseMirror-widget + .ProseMirror-widget:not(:has([data-collab-cursor])) ), & > :where(div, ul, ol, p, blockquote, hr):not( [data-element="true"], .ProseMirror-gapcursor, - .ProseMirror-widget + .ProseMirror-widget:not(:has([data-collab-cursor])) ) + .ProseMirror-gapcursor - + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not(.ProseMirror-widget) { + + :where(div, ul, ol, p, blockquote, hr, h1, h2, h3, h4, h5, h6):not( + .ProseMirror-widget:not(:has([data-collab-cursor])) + ) { @apply mt-5; } } diff --git a/apps/web/src/views/editor/editor.tsx b/apps/web/src/views/editor/editor.tsx index 83e9da69..037aee8a 100644 --- a/apps/web/src/views/editor/editor.tsx +++ b/apps/web/src/views/editor/editor.tsx @@ -45,7 +45,6 @@ import { CommentMenuPlugin, AutoDir, Shortcuts, - CustomNodeMenuPlugin, AutocompletePlugin } from "#lib/editor"; import { @@ -176,7 +175,6 @@ const Editor: Component = (props) => { menuItems: workspaceSettings() ? createBlockMenuOptions(workspaceSettings()!) : [] }), hostConfig.extensions && BlockActionMenuPlugin, - CustomNodeMenuPlugin, TableMenuPlugin, ElementMenuPlugin, CommentMenuPlugin, diff --git a/packages/components/src/primitives/button.tsx b/packages/components/src/primitives/button.tsx index 235ade57..bd4e5169 100644 --- a/packages/components/src/primitives/button.tsx +++ b/packages/components/src/primitives/button.tsx @@ -36,14 +36,14 @@ const buttonVariants = { solid: "" }; const buttonColorsVariants = { - primaryText: ` :base-2: font-bold text-transparent bg-clip-text dark:text-transparent dark:bg-clip-text`, + primaryText: ` :base-2: text-transparent bg-clip-text dark:text-transparent dark:bg-clip-text`, dangerText: ` :base-2: text-red-500 dark:text-red-500`, successText: ` :base-2: text-green-500 dark:text-green-500` }; const buttonColorsVariantsHover = { - primaryText: ` :base-2: @hover-font-medium @hover-text-current @hover-bg-clip-border @hover-text-white dark:@hover-text-current dark:@hover-bg-clip-border dark:@hover-text-white`, - dangerText: ` :base-2: @hover-font-medium @hover-text-white @hover-bg-red-600 dark:@hover-bg-red-600 dark:@hover-text-white`, - successText: ` :base-2: @hover-font-medium @hover-text-white @hover-bg-green-600 dark:@hover-bg-green-600 dark:@hover-text-white` + primaryText: ` :base-2: @hover-text-current @hover-bg-clip-border @hover-text-white dark:@hover-text-current dark:@hover-bg-clip-border dark:@hover-text-white`, + dangerText: ` :base-2: @hover-text-white @hover-bg-red-600 dark:@hover-bg-red-600 dark:@hover-text-white`, + successText: ` :base-2: @hover-text-white @hover-bg-green-600 dark:@hover-bg-green-600 dark:@hover-text-white` }; const iconButtonSizes = { small: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20b8e632..8b80f2b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -622,6 +622,9 @@ importers: dompurify: specifier: ^3.0.8 version: 3.0.8 + lib0: + specifier: ^0.2.94 + version: 0.2.94 marked: specifier: ^12.0.0 version: 12.0.0 @@ -3807,7 +3810,7 @@ packages: /@hocuspocus/common@2.11.3: resolution: {integrity: sha512-w3UZpW6ZVYIHPEFzZJV3yn1d3EZaXf2m2zU53pwj0AyTBmVD7kB9ZiD6twc9A7NNB1dkqD8c58PbD42+pnNiKQ==} dependencies: - lib0: 0.2.89 + lib0: 0.2.94 dev: false /@hocuspocus/extension-database@2.11.3(y-protocols@1.0.6)(yjs@13.6.11): @@ -3851,7 +3854,7 @@ packages: dependencies: '@hocuspocus/common': 2.11.3 '@lifeomic/attempt': 3.0.3 - lib0: 0.2.89 + lib0: 0.2.94 ws: 8.16.0 y-protocols: 1.0.6(yjs@13.6.11) yjs: 13.6.11 @@ -3869,7 +3872,7 @@ packages: '@hocuspocus/common': 2.11.3 async-lock: 1.4.1 kleur: 4.1.5 - lib0: 0.2.89 + lib0: 0.2.94 uuid: 9.0.1 ws: 8.16.0 y-protocols: 1.0.6(yjs@13.6.11) @@ -11937,8 +11940,8 @@ packages: resolution: {integrity: sha512-K1B/Yr/gIU0wm68hk/yB0p/mv6xM3ShD5aci42vOwcjof8slG8Kpo3Q7+1WTv7DaRHKWRgLPqrFDt+4GtuFAtA==} dev: false - /lib0@0.2.89: - resolution: {integrity: sha512-5j19vcCjsQhvLG6mcDD+nprtJUCbmqLz5Hzt5xgi9SV6RIW/Dty7ZkVZHGBuPOADMKjQuKDvuQTH495wsmw8DQ==} + /lib0@0.2.94: + resolution: {integrity: sha512-hZ3p54jL4Wpu7IOg26uC7dnEWiMyNlUrb9KoG7+xYs45WkQwpVvKFndVq2+pqLYKe1u8Fp3+zAfZHVvTK34PvQ==} engines: {node: '>=16'} hasBin: true dependencies: @@ -17632,7 +17635,7 @@ packages: y-protocols: ^1.0.1 yjs: ^13.5.38 dependencies: - lib0: 0.2.89 + lib0: 0.2.94 prosemirror-model: 1.19.4 prosemirror-state: 1.4.3 prosemirror-view: 1.33.1 @@ -17646,7 +17649,7 @@ packages: peerDependencies: yjs: ^13.0.0 dependencies: - lib0: 0.2.89 + lib0: 0.2.94 yjs: 13.6.11 dev: false @@ -17694,7 +17697,7 @@ packages: resolution: {integrity: sha512-FvRRJKX9u270dOLkllGF/UDCWwmIv2Z+ucM4v1QO1TuxdmoiMnSUXH1HAcOKOrkBEhQtPTkxep7tD2DrQB+l0g==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} dependencies: - lib0: 0.2.89 + lib0: 0.2.94 dev: false /yocto-queue@0.1.0: