From 0d035a40efb6b2a2c22994544d29bb699419ec26 Mon Sep 17 00:00:00 2001 From: Shane Friedman Date: Tue, 1 Aug 2023 01:30:57 -0400 Subject: [PATCH] Implement decorations --- demo/main.tsx | 29 +++- src/components/DocNodeView.tsx | 54 +++++-- src/components/NodeView.tsx | 152 +++++++++++++----- src/components/NodeViewComponentProps.tsx | 3 +- src/components/OutputSpec.tsx | 4 +- src/components/TextNodeView.tsx | 32 +++- src/descriptors/ViewDesc.ts | 108 ++++++++++++- .../DecorationInternal.ts | 13 +- 8 files changed, 324 insertions(+), 71 deletions(-) diff --git a/demo/main.tsx b/demo/main.tsx index ec7abaa5..1ad8f5f7 100644 --- a/demo/main.tsx +++ b/demo/main.tsx @@ -20,6 +20,7 @@ import React, { HTMLAttributes, Ref, forwardRef, + useState, } from "react"; import { createRoot } from "react-dom/client"; @@ -68,7 +69,7 @@ const editorState = EditorState.create({ schema, doc: schema.nodes.doc.create({}, [ schema.nodes.paragraph.create({}, [ - schema.text("This", [schema.marks.em.create(), schema.marks.em.create()]), + schema.text("This", [schema.marks.em.create()]), schema.text(" is the first paragraph"), ]), schema.nodes.paragraph.create( @@ -122,16 +123,30 @@ function TestWidget() { } function DemoEditor() { + const [state, setState] = useState(editorState); + + const decorations = [Decoration.inline(5, 15, { class: "inline-deco" })]; + state.doc.forEach((node, offset, index) => { + if (index === 1) { + decorations.push( + Decoration.node(offset, offset + node.nodeSize, { + nodeName: "div", + class: "node-deco", + }) + ); + } + if (index === 3) { + decorations.push(widget(offset + 10, TestWidget, { side: 0 })); + } + }); + return (

React ProseMirror Demo

setState((prev) => prev.apply(tr))} + decorations={DecorationSet.create(state.doc, decorations)} keymap={{ "Mod-i": toggleMark(schema.marks.em), Backspace: chainCommands( diff --git a/src/components/DocNodeView.tsx b/src/components/DocNodeView.tsx index b4baef07..3f4e1f60 100644 --- a/src/components/DocNodeView.tsx +++ b/src/components/DocNodeView.tsx @@ -5,6 +5,7 @@ import React, { ForwardedRef, HTMLAttributes, ReactNode, + createElement, forwardRef, useContext, useLayoutEffect, @@ -13,10 +14,12 @@ import React, { import { ChildDescriptorsContext } from "../contexts/ChildDescriptorsContext.js"; import { NodeViewContext } from "../contexts/NodeViewContext.js"; -import { NodeViewDesc, ViewDesc } from "../descriptors/ViewDesc.js"; +import { ReactWidgetType } from "../decorations/ReactWidgetType.js"; +import { NodeViewDesc, ViewDesc, iterDeco } from "../descriptors/ViewDesc.js"; import { DecorationSourceInternal } from "../prosemirror-internal/DecorationInternal.js"; import { NodeView } from "./NodeView.js"; +import { TextNodeView } from "./TextNodeView.js"; type Props = { node: Node; @@ -61,17 +64,44 @@ export const DocNodeView = forwardRef(function DocNodeView( const children: ReactNode[] = []; const innerPos = 0; - node.content.forEach((childNode, offset) => { - const childPos = innerPos + offset; - children.push( - - ); - }); + iterDeco( + node, + decorations, + (widget, offset, index) => { + children.push( + createElement((widget.type as ReactWidgetType).Component, { + key: `${innerPos + offset}-${index}`, + }) + ); + }, + (childNode, outerDeco, innerDeco, offset) => { + const childPos = innerPos + offset; + if (childNode.isText) { + children.push( + + {(siblingDescriptors) => ( + + )} + + ); + } else { + children.push( + + ); + } + } + ); return (
{ - const childPos = innerPos + offset; - if (childNode.isText) { + iterDeco( + node, + innerDecorations, + (widget, offset, index) => { content.push( - - {(siblingDescriptors) => ( - - )} - - ); - } else { - content.push( - + createElement((widget.type as ReactWidgetType).Component, { + key: `${innerPos + offset}-${index}`, + }) ); + }, + (childNode, outerDeco, innerDeco, offset) => { + const childPos = innerPos + offset; + if (childNode.isText) { + content.push( + + {(siblingDescriptors) => ( + + )} + + ); + } else { + content.push( + + ); + } } - }); + ); if (!content.length) { content.push(); @@ -99,6 +126,8 @@ export function NodeView({ node, pos, decorations }: Props) { ); + let element: JSX.Element | null = null; + const Component: | ForwardRefExoticComponent< NodeViewComponentProps & RefAttributes @@ -106,21 +135,18 @@ export function NodeView({ node, pos, decorations }: Props) { | undefined = nodeViews[node.type.name]; if (Component) { - return node.marks.reduce( - (element, mark) => ( - - {element} - - ), + element = ( {children} @@ -130,17 +156,61 @@ export function NodeView({ node, pos, decorations }: Props) { const outputSpec: DOMOutputSpec | undefined = node.type.spec.toDOM?.(node); if (outputSpec) { - return node.marks.reduce( - (element, mark) => ( - - {element} - - ), - + element = ( + {children} ); } - throw new Error(`Node spec for ${node.type.name} is missing toDOM`); + if (!element) { + throw new Error(`Node spec for ${node.type.name} is missing toDOM`); + } + + const wrapDecorations: DecorationInternal[] = []; + for (const decoration of decorations) { + if ((decoration.type as NonWidgetType).attrs.nodeName) { + wrapDecorations.push(decoration); + } else { + const { + class: className, + style: _, + ...attrs + } = (decoration.type as NonWidgetType).attrs; + element = cloneElement(element, { + className, + ...attrs, + }); + } + } + + return wrapDecorations.reduce( + (element, deco) => { + const { + nodeName, + class: className, + style: _, + ...attrs + } = (deco.type as NonWidgetType).attrs; + + return createElement( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + nodeName!, + { + className, + ...attrs, + }, + element + ); + }, + + node.marks.reduce( + (element, mark) => ( + + {element} + + ), + element + ) + ); } diff --git a/src/components/NodeViewComponentProps.tsx b/src/components/NodeViewComponentProps.tsx index b9104b17..98602ae4 100644 --- a/src/components/NodeViewComponentProps.tsx +++ b/src/components/NodeViewComponentProps.tsx @@ -1,9 +1,10 @@ import { Node } from "prosemirror-model"; -import { Decoration } from "prosemirror-view"; +import { Decoration, DecorationSource } from "prosemirror-view"; import { ReactNode } from "react"; export type NodeViewComponentProps = { decorations: readonly Decoration[]; + innerDecorations: DecorationSource; node: Node; children?: ReactNode | ReactNode[]; isSelected: boolean; diff --git a/src/components/OutputSpec.tsx b/src/components/OutputSpec.tsx index b5c4e034..23f9a282 100644 --- a/src/components/OutputSpec.tsx +++ b/src/components/OutputSpec.tsx @@ -7,7 +7,7 @@ type Props = { }; export const OutputSpec = forwardRef(function OutputSpec( - { outputSpec, children }: Props, + { outputSpec, children, ...initialProps }: Props, ref ) { if (typeof outputSpec === "string") { @@ -23,7 +23,7 @@ export const OutputSpec = forwardRef(function OutputSpec( const tagName = tagSpec.replace(" ", ":"); const attrs = outputSpec[1]; - const props: Record = { ref }; + const props: Record = { ...initialProps, ref }; let start = 1; if ( attrs && diff --git a/src/components/TextNodeView.tsx b/src/components/TextNodeView.tsx index b00a2aa6..bab20a5d 100644 --- a/src/components/TextNodeView.tsx +++ b/src/components/TextNodeView.tsx @@ -1,6 +1,6 @@ import { Node } from "prosemirror-model"; import { DecorationSet } from "prosemirror-view"; -import React, { Component } from "react"; +import React, { Component, createElement } from "react"; import { findDOMNode } from "react-dom"; import { @@ -8,6 +8,10 @@ import { NodeViewContextValue, } from "../contexts/NodeViewContext.js"; import { TextViewDesc, ViewDesc } from "../descriptors/ViewDesc.js"; +import { + DecorationInternal, + NonWidgetType, +} from "../prosemirror-internal/DecorationInternal.js"; import { MarkView } from "./MarkView.js"; @@ -15,6 +19,7 @@ type Props = { node: Node; pos: number; siblingDescriptors: ViewDesc[]; + decorations: readonly DecorationInternal[]; }; export class TextNodeView extends Component { @@ -75,9 +80,28 @@ export class TextNodeView extends Component { } render() { - return this.props.node.marks.reduce( - (children, mark) => {children}, - <>{this.props.node.text} + return this.props.decorations.reduce( + (element, deco) => { + const { + nodeName, + class: className, + style: _, + ...attrs + } = (deco.type as NonWidgetType).attrs; + + return createElement( + nodeName ?? "span", + { + className, + ...attrs, + }, + element + ); + }, + this.props.node.marks.reduce( + (children, mark) => {children}, + <>{this.props.node.text} + ) ); } } diff --git a/src/descriptors/ViewDesc.ts b/src/descriptors/ViewDesc.ts index c99667d8..86a33dfb 100644 --- a/src/descriptors/ViewDesc.ts +++ b/src/descriptors/ViewDesc.ts @@ -2,8 +2,10 @@ import { Fragment, Mark, Node, ParseRule } from "prosemirror-model"; import { DecorationSource } from "prosemirror-view"; +import { ReactWidgetType } from "../decorations/ReactWidgetType.js"; import { DecorationInternal, + DecorationSourceInternal, ReactWidgetDecoration, } from "../prosemirror-internal/DecorationInternal.js"; import * as browser from "../prosemirror-internal/browser.js"; @@ -863,13 +865,12 @@ export class NodeViewDesc extends ViewDesc { matchesNode( node: Node, outerDeco: readonly DecorationInternal[], - innerDeco: DecorationSource + innerDeco: DecorationSourceInternal ): boolean { return ( this.dirty == NOT_DIRTY && node.eq(this.node) && sameOuterDeco(outerDeco, this.outerDeco) && - // @ts-expect-error .eq is private? innerDeco.eq(this.innerDeco) ); } @@ -970,3 +971,106 @@ export class TextViewDesc extends NodeViewDesc { return false; } } + +function compareSide(a: DecorationInternal, b: DecorationInternal) { + return (a.type as ReactWidgetType).side - (b.type as ReactWidgetType).side; +} + +// This function abstracts iterating over the nodes and decorations in +// a fragment. Calls `onNode` for each node, with its local and child +// decorations. Splits text nodes when there is a decoration starting +// or ending inside of them. Calls `onWidget` for each widget. +export function iterDeco( + parent: Node, + deco: DecorationSourceInternal, + // Callbacks have been slightly modified to pass + // the offset, so that we can pass the position as + // a prop to components + onWidget: ( + widget: DecorationInternal, + offset: number, + index: number, + insideNode: boolean + ) => void, + onNode: ( + node: Node, + outerDeco: readonly DecorationInternal[], + innerDeco: DecorationSourceInternal, + offset: number, + index: number + ) => void +) { + const locals = deco.locals(parent); + let offset = 0; + // Simple, cheap variant for when there are no local decorations + if (locals.length == 0) { + for (let i = 0; i < parent.childCount; i++) { + const child = parent.child(i); + onNode(child, locals, deco.forChild(offset, child), offset, i); + offset += child.nodeSize; + } + return; + } + + let decoIndex = 0; + const active = []; + let restNode = null; + for (let parentIndex = 0; ; ) { + if (decoIndex < locals.length && locals[decoIndex]!.to == offset) { + const widget = locals[decoIndex++]!; + let widgets; + while (decoIndex < locals.length && locals[decoIndex]!.to == offset) + (widgets || (widgets = [widget])).push(locals[decoIndex++]!); + if (widgets) { + widgets.sort(compareSide); + for (let i = 0; i < widgets.length; i++) + onWidget(widgets[i]!, offset, parentIndex, !!restNode); + } else { + onWidget(widget, offset, parentIndex, !!restNode); + } + } + + let child, index; + if (restNode) { + index = -1; + child = restNode; + restNode = null; + } else if (parentIndex < parent.childCount) { + index = parentIndex; + child = parent.child(parentIndex++); + } else { + break; + } + + for (let i = 0; i < active.length; i++) + if (active[i]!.to <= offset) active.splice(i--, 1); + while ( + decoIndex < locals.length && + locals[decoIndex]!.from <= offset && + locals[decoIndex]!.to > offset + ) + active.push(locals[decoIndex++]!); + + let end = offset + child.nodeSize; + if (child.isText) { + let cutAt = end; + if (decoIndex < locals.length && locals[decoIndex]!.from < cutAt) + cutAt = locals[decoIndex]!.from; + for (let i = 0; i < active.length; i++) + if (active[i]!.to < cutAt) cutAt = active[i]!.to; + if (cutAt < end) { + restNode = child.cut(cutAt - offset); + child = child.cut(0, cutAt - offset); + end = cutAt; + index = -1; + } + } + + const outerDeco = + child.isInline && !child.isLeaf + ? active.filter((d) => !d.inline) + : active.slice(); + onNode(child, outerDeco, deco.forChild(offset, child), offset, index); + offset = end; + } +} diff --git a/src/prosemirror-internal/DecorationInternal.ts b/src/prosemirror-internal/DecorationInternal.ts index d71cb1e8..08b0f88c 100644 --- a/src/prosemirror-internal/DecorationInternal.ts +++ b/src/prosemirror-internal/DecorationInternal.ts @@ -3,8 +3,17 @@ import { Decoration, DecorationSource } from "prosemirror-view"; import { DecorationType } from "../decorations/DecorationType"; import { ReactWidgetType } from "../decorations/ReactWidgetType"; +export interface NonWidgetType extends DecorationType { + attrs: { + nodeName?: string; + class?: string; + style?: string; + [attr: string]: string | undefined; + } +} + export interface DecorationInternal extends Decoration { - type: DecorationType + type: NonWidgetType | ReactWidgetType inline: boolean } @@ -14,7 +23,7 @@ export interface ReactWidgetDecoration extends Decoration { } export interface DecorationSourceInternal extends DecorationSource { - locals(node: Node): readonly Decoration[] + locals(node: Node): readonly DecorationInternal[] forChild(offset: number, child: Node): DecorationSourceInternal eq(other: DecorationSource): boolean }