diff --git a/demo/main.tsx b/demo/main.tsx index ff505b80..1043ab52 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -20,7 +20,6 @@ import React, { HTMLAttributes, Ref, forwardRef, - useState, } from "react"; import { createRoot } from "react-dom/client"; @@ -28,6 +27,7 @@ import { EditorView, NodeViewComponentProps, } from "../src/components/EditorView.js"; +import { widget } from "../src/decorations/ReactWidgetType.js"; import "./main.css"; @@ -102,6 +102,20 @@ const Paragraph = forwardRef(function Paragraph( ); }); +function TestWidget() { + return ( + + Widget + + ); +} + function DemoEditor() { return (
@@ -111,7 +125,7 @@ function DemoEditor() { decorations={DecorationSet.create(editorState.doc, [ Decoration.inline(5, 15, { class: "inline-deco" }), Decoration.node(29, 59, { class: "node-deco" }), - Decoration.widget(40, () => document.createElement("div")), + widget(40, TestWidget, { side: 0 }), ])} keymap={{ "Mod-i": toggleMark(schema.marks.em), diff --git a/package.json b/package.json index f363774c..84e2f96d 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "prosemirror-keymap": "^1.2.1", "prosemirror-model": "^1.18.3", "prosemirror-state": "^1.4.2", + "prosemirror-transform": "^1.7.3", "prosemirror-view": "^1.29.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index c341aa65..2ec364c3 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -23,6 +23,7 @@ import React, { import { EditorViewContext } from "../contexts/EditorViewContext.js"; import { LayoutGroup } from "../contexts/LayoutGroup.js"; import { NodeViewPositionsContext } from "../contexts/NodeViewPositionsContext.js"; +import { ReactWidgetType } from "../decorations/ReactWidgetType.js"; import { DOMNode } from "../dom.js"; import { useContentEditable } from "../hooks/useContentEditable.js"; import { useSyncSelection } from "../hooks/useSyncSelection.js"; @@ -42,8 +43,12 @@ function makeCuts(cuts: number[], node: Node) { let curr = 0; for (const cut of sortedCuts) { const lastNode = nodes.pop()!; - nodes.push(lastNode.cut(0, cut - curr)); - nodes.push(lastNode.cut(cut - curr)); + if (cut - curr !== 0) { + nodes.push(lastNode.cut(0, cut - curr)); + } + if (lastNode.nodeSize > cut - curr) { + nodes.push(lastNode.cut(cut - curr)); + } curr = cut; } return nodes; @@ -135,24 +140,39 @@ export function EditorView({ } node.forEach((childNode, offset) => { if (childNode.isText) { - const inlineDecorations = decorations - .find(pos + offset + 1, pos + offset + 1 + childNode.nodeSize) - .filter( - (decoration) => - (decoration as Decoration & { inline: boolean }).inline - ); + const localDecorations = decorations.find( + pos + offset + 1, + pos + offset + 1 + childNode.nodeSize + ); + const inlineDecorations = localDecorations.filter( + (decoration) => + (decoration as Decoration & { inline: boolean }).inline + ); + const widgetDecorations = localDecorations.filter( + (decoration) => + (decoration as Decoration & { type: ReactWidgetType }) + .type instanceof ReactWidgetType + ); const textNodes: Node[] = makeCuts( - inlineDecorations.flatMap((decoration) => [ - decoration.from - (pos + offset + 1), - decoration.to - (pos + offset + 1), - ]), + inlineDecorations + .flatMap((decoration) => [ + decoration.from - (pos + offset + 1), + decoration.to - (pos + offset + 1), + ]) + .concat( + widgetDecorations.map( + (decoration) => decoration.from - (pos + offset + 1) + ) + ), childNode ); let subOffset = 0; for (const textNode of textNodes) { + const textNodeStart = pos + offset + subOffset + 1; + const textNodeEnd = pos + offset + subOffset + 1 + textNode.nodeSize; const marked = wrapInMarks( - + {/* Text nodes always have text */} {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} {textNode.text!} @@ -163,13 +183,23 @@ export function EditorView({ const decorated = wrapInDecorations( marked, inlineDecorations.filter( - (deco) => - deco.from <= pos + offset + 1 + subOffset && - deco.to >= pos + offset + 1 + subOffset + textNode.nodeSize + (deco) => deco.from <= textNodeStart && deco.to >= textNodeEnd ), true ); + childElements.push(decorated); + + widgetDecorations.forEach((decoration) => { + if (decoration.from !== textNodeEnd) return; + + const decorationType = ( + decoration as Decoration & { type: ReactWidgetType } + ).type; + + childElements.push(); + }); + subOffset += textNode.nodeSize; } @@ -213,20 +243,51 @@ export function EditorView({ return isValidElement(element); }); + const localDecorations = decorations.find(pos, pos + node.nodeSize); + const nodeDecorations = localDecorations.filter( + (decoration) => + !(decoration as Decoration & { inline: boolean }).inline && + pos === decoration.from && + pos + node.nodeSize === decoration.to + ); + const widgetDecorations = localDecorations.filter( + (decoration) => + (decoration as Decoration & { type: ReactWidgetType }).type instanceof + ReactWidgetType + ); + + widgetDecorations.forEach((decoration) => { + if (!node.isLeaf && decoration.from !== pos + 1) return; + + const decorationType = ( + decoration as Decoration & { type: ReactWidgetType } + ).type; + + childElements.unshift(); + }); + const element = cloneElement(parentElement, undefined, ...childElements); const marked = wrapInMarks(element, node.marks, node.isInline); - const nodeDecorations = decorations - .find(pos, pos + node.nodeSize) - .filter( - (decoration) => - !(decoration as Decoration & { inline: boolean }).inline && - pos === decoration.from && - pos + node.nodeSize === decoration.to - ); + const decorated = wrapInDecorations(marked, nodeDecorations, false); + const wrapped = {decorated}; + + const elements = [wrapped]; + + widgetDecorations.forEach((decoration, index, array) => { + if (decoration.from !== pos + node.nodeSize) return; + + const decorationType = ( + decoration as Decoration & { type: ReactWidgetType } + ).type; + + elements.push(); + + array.splice(index, 1); + }); - return {decorated}; + return <>{elements}; } const content = buildReactTree( diff --git a/src/decorations/DecorationType.ts b/src/decorations/DecorationType.ts new file mode 100644 index 00000000..96b76513 --- /dev/null +++ b/src/decorations/DecorationType.ts @@ -0,0 +1,17 @@ +import { Mappable } from "prosemirror-transform"; +import { Decoration } from "prosemirror-view"; + +import { DOMNode } from "../dom.js"; + +export interface DecorationType { + spec: any; + map( + mapping: Mappable, + span: Decoration, + offset: number, + oldOffset: number + ): Decoration | null; + valid(node: Node, span: Decoration): boolean; + eq(other: DecorationType): boolean; + destroy(dom: DOMNode): void; +} diff --git a/src/decorations/ReactWidgetType.ts b/src/decorations/ReactWidgetType.ts new file mode 100644 index 00000000..a4a34f4a --- /dev/null +++ b/src/decorations/ReactWidgetType.ts @@ -0,0 +1,79 @@ +import { Mark } from "prosemirror-model"; +import { Mappable } from "prosemirror-transform"; +import { Decoration } from "prosemirror-view"; +import { ComponentType } from "react"; + +import { DecorationType } from "./DecorationType.js"; + +function compareObjs( + a: { [prop: string]: unknown }, + b: { [prop: string]: unknown } +) { + if (a == b) return true; + for (const p in a) if (a[p] !== b[p]) return false; + for (const p in b) if (!(p in a)) return false; + return true; +} + +type ReactWidgetSpec = { + side: number; + marks?: readonly Mark[]; + stopEvent?: (event: Event) => boolean; + ignoreSelection?: boolean; + key?: string; +}; + +const noSpec = {}; + +export class ReactWidgetType implements DecorationType { + // TODO: implement side affinity? + side: number; + spec: ReactWidgetSpec; + + constructor(public Component: ComponentType, spec: ReactWidgetSpec) { + this.spec = spec ?? noSpec; + this.side = this.spec.side ?? 0; + } + + map( + mapping: Mappable, + span: Decoration, + offset: number, + oldOffset: number + ): Decoration | null { + const { pos, deleted } = mapping.mapResult( + span.from + oldOffset, + this.side < 0 ? -1 : 1 + ); + return deleted + ? null + : // @ts-expect-error Decoration constructor args are internal + new Decoration(pos - offset, pos - offset, this); + } + + valid(): boolean { + return true; + } + + eq(other: DecorationType): boolean { + return ( + this == other || + (other instanceof ReactWidgetType && + ((this.spec.key && this.spec.key == other.spec.key) || + (this.Component == other.Component && + compareObjs(this.spec, other.spec)))) + ); + } + destroy(): void { + // Can be implemented with React effect hooks + } +} + +export function widget( + pos: number, + component: ComponentType, + spec: ReactWidgetSpec +) { + // @ts-expect-error Decoration constructor args are internal + return new Decoration(pos, pos, new ReactWidgetType(component, spec)); +} diff --git a/yarn.lock b/yarn.lock index f0984c3b..b3d4d49d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1184,6 +1184,7 @@ __metadata: prosemirror-keymap: ^1.2.1 prosemirror-model: ^1.18.3 prosemirror-state: ^1.4.2 + prosemirror-transform: ^1.7.3 prosemirror-view: ^1.29.1 react: ^18.2.0 react-dom: ^18.2.0 @@ -6914,6 +6915,15 @@ __metadata: languageName: node linkType: hard +"prosemirror-transform@npm:^1.7.3": + version: 1.7.3 + resolution: "prosemirror-transform@npm:1.7.3" + dependencies: + prosemirror-model: ^1.0.0 + checksum: dbafa4cee8a0ea3ff0a27eda27e3b5ff21f8b39619b09894963c68f5fb2454b67bd2206fb18fdb4176f65fb4f72f307f8a41ddbb4fa35a6feb83675902cc2889 + languageName: node + linkType: hard + "prosemirror-view@npm:^1.27.0, prosemirror-view@npm:^1.29.1": version: 1.30.1 resolution: "prosemirror-view@npm:1.30.1"