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"