Skip to content

Commit

Permalink
Implement widget decorations
Browse files Browse the repository at this point in the history
  • Loading branch information
smoores-dev committed Jul 24, 2023
1 parent 97e7506 commit 2d8fc2b
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 27 deletions.
18 changes: 16 additions & 2 deletions demo/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@ import React, {
HTMLAttributes,
Ref,
forwardRef,
useState,
} from "react";
import { createRoot } from "react-dom/client";

import {
EditorView,
NodeViewComponentProps,
} from "../src/components/EditorView.js";
import { widget } from "../src/decorations/ReactWidgetType.js";

import "./main.css";

Expand Down Expand Up @@ -102,6 +102,20 @@ const Paragraph = forwardRef(function Paragraph(
);
});

function TestWidget() {
return (
<span
style={{
display: "inline-block",
padding: "0.75rem 1rem",
border: "solid thin black",
}}
>
Widget
</span>
);
}

function DemoEditor() {
return (
<main>
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 86 additions & 25 deletions src/components/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -42,8 +43,12 @@ function makeCuts(cuts: number[], node: Node) {
let curr = 0;
for (const cut of sortedCuts) {
const lastNode = nodes.pop()!;

Check warning on line 45 in src/components/EditorView.tsx

View workflow job for this annotation

GitHub Actions / check

Forbidden non-null assertion
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;
Expand Down Expand Up @@ -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(
<TextNodeWrapper pos={pos + offset + subOffset + 1}>
<TextNodeWrapper pos={textNodeStart}>
{/* Text nodes always have text */}
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
{textNode.text!}
Expand All @@ -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(<decorationType.Component />);
});

subOffset += textNode.nodeSize;
}

Expand Down Expand Up @@ -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(<decorationType.Component />);
});

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 = <NodeWrapper pos={pos}>{decorated}</NodeWrapper>;

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(<decorationType.Component />);

array.splice(index, 1);
});

return <NodeWrapper pos={pos}>{decorated}</NodeWrapper>;
return <>{elements}</>;
}

const content = buildReactTree(
Expand Down
17 changes: 17 additions & 0 deletions src/decorations/DecorationType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Mappable } from "prosemirror-transform";
import { Decoration } from "prosemirror-view";

import { DOMNode } from "../dom.js";

export interface DecorationType {
spec: any;

Check warning on line 7 in src/decorations/DecorationType.ts

View workflow job for this annotation

GitHub Actions / check

Unexpected any. Specify a different type
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;
}
79 changes: 79 additions & 0 deletions src/decorations/ReactWidgetType.ts
Original file line number Diff line number Diff line change
@@ -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));
}
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 2d8fc2b

Please sign in to comment.