();
20 | const pluginKey = nanoid();
21 |
22 | onMount(() => {
23 | const { editor, shouldShow, tippyOptions } = props;
24 | const container = getContainer();
25 |
26 | if (container) {
27 | editor.registerPlugin(
28 | BubbleMenuPlugin({
29 | editor,
30 | pluginKey,
31 | shouldShow: (props) => {
32 | if (shouldShow) {
33 | return shouldShow(props);
34 | }
35 |
36 | return false;
37 | },
38 | element: container,
39 | tippyOptions,
40 | })
41 | );
42 | }
43 | });
44 |
45 | return (
46 |
51 | {props.children}
52 |
53 | );
54 | };
55 |
56 | export { BubbleMenuWrapper };
57 | export type { BubbleMenuWrapperProps };
58 |
--------------------------------------------------------------------------------
/src/editor-content.tsx:
--------------------------------------------------------------------------------
1 | import { SolidEditor } from "./editor";
2 | import { SolidRenderer } from "./solid-renderer";
3 | import { createRef } from "./ref";
4 | import { Component, For, createEffect, on, onCleanup, JSX, splitProps } from "solid-js";
5 | import { Dynamic, Portal } from "solid-js/web";
6 |
7 | interface PortalsProps {
8 | renderers: SolidRenderer[];
9 | }
10 |
11 | const Portals: Component = (props) => {
12 | return (
13 |
14 | {(renderer) => {
15 | return (
16 |
17 |
18 |
19 | );
20 | }}
21 |
22 | );
23 | };
24 |
25 | interface SolidEditorContentProps extends JSX.HTMLAttributes {
26 | editor: SolidEditor;
27 | }
28 |
29 | const SolidEditorContent: Component = (props) => {
30 | const [getEditorContentContainer, setEditorContentContainer] = createRef();
31 | const [, passedProps] = splitProps(props, ["editor"]);
32 |
33 | createEffect(
34 | on([() => props.editor], () => {
35 | const { editor } = props;
36 |
37 | if (editor && editor.options.element) {
38 | const editorContentContainer = getEditorContentContainer();
39 |
40 | if (editorContentContainer) {
41 | editorContentContainer.append(...editor.options.element.childNodes);
42 | editor.setOptions({
43 | element: editorContentContainer
44 | });
45 | }
46 |
47 | setTimeout(() => {
48 | if (!editor.isDestroyed) {
49 | editor.createNodeViews();
50 | }
51 | }, 0);
52 | }
53 | })
54 | );
55 | onCleanup(() => {
56 | const { editor } = props;
57 |
58 | if (!editor) {
59 | return;
60 | }
61 |
62 | if (!editor.isDestroyed) {
63 | editor.view.setProps({
64 | nodeViews: {}
65 | });
66 | }
67 |
68 | if (!editor.options.element.firstChild) {
69 | return;
70 | }
71 |
72 | const newElement = document.createElement("div");
73 |
74 | newElement.append(...editor.options.element.childNodes);
75 | editor.setOptions({
76 | element: newElement
77 | });
78 | });
79 |
80 | return (
81 | <>
82 |
83 |
84 | >
85 | );
86 | };
87 |
88 | export { SolidEditorContent };
89 | export type { SolidEditorContentProps };
90 |
--------------------------------------------------------------------------------
/src/editor.ts:
--------------------------------------------------------------------------------
1 | import { SolidRenderer } from "./solid-renderer";
2 | import { Editor, EditorOptions } from "@tiptap/core";
3 | import { Accessor, createSignal, Setter } from "solid-js";
4 |
5 | class SolidEditor extends Editor {
6 | public declare renderers: Accessor;
7 |
8 | public declare setRenderers: Setter;
9 |
10 | public constructor(options?: Partial) {
11 | const [renderers, setRenderers] = createSignal([]);
12 |
13 | super(options);
14 | this.renderers = renderers;
15 | this.setRenderers = setRenderers;
16 | }
17 | }
18 |
19 | export { SolidEditor };
20 |
--------------------------------------------------------------------------------
/src/floating-menu-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { createRef } from "./ref";
2 | import { Component, JSX, onMount } from "solid-js";
3 | import { FloatingMenuPlugin, FloatingMenuPluginProps } from "@tiptap/extension-floating-menu";
4 | import { nanoid } from "nanoid";
5 |
6 | type FloatingMenuWrapperProps = Omit<
7 | FloatingMenuPluginProps,
8 | "element" | "pluginKey" | "shouldShow"
9 | > & {
10 | class?: string;
11 | shouldShow?: FloatingMenuPluginProps["shouldShow"];
12 | children?: JSX.Element;
13 | };
14 |
15 | const FloatingMenuWrapper: Component = (props) => {
16 | const [getContainer, setContainer] = createRef();
17 | const pluginKey = nanoid();
18 |
19 | onMount(() => {
20 | const { editor, shouldShow, tippyOptions } = props;
21 | const container = getContainer();
22 |
23 | if (container) {
24 | editor.registerPlugin(
25 | FloatingMenuPlugin({
26 | editor,
27 | pluginKey,
28 | shouldShow: shouldShow || null,
29 | element: container,
30 | tippyOptions
31 | })
32 | );
33 | }
34 | });
35 |
36 | return (
37 |
38 | {props.children}
39 |
40 | );
41 | };
42 |
43 | export { FloatingMenuWrapper };
44 | export type { FloatingMenuWrapperProps };
45 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./bubble-menu-wrapper";
2 | export * from "./editor-content";
3 | export * from "./editor";
4 | export * from "./floating-menu-wrapper";
5 | export * from "./node-view-content";
6 | export * from "./node-view-wrapper";
7 | export * from "./solid-node-view-renderer";
8 | export * from "./solid-renderer";
9 | export * from "./use-editor";
10 | export * from "./use-solid-node-view";
11 |
--------------------------------------------------------------------------------
/src/node-view-content.tsx:
--------------------------------------------------------------------------------
1 | import { Ref } from "./ref";
2 | import { Component, JSX, splitProps } from "solid-js";
3 | import { Dynamic } from "solid-js/web";
4 |
5 | interface NodeViewContentProps {
6 | [key: string]: unknown;
7 | style?: JSX.CSSProperties;
8 | ref?: Ref;
9 | as?: string | Component>;
10 | }
11 |
12 | const NodeViewContent: Component = (props) => {
13 | const [local, otherProps] = splitProps(props, ["ref"]);
14 |
15 | return (
16 |
26 | );
27 | };
28 |
29 | export { NodeViewContent };
30 | export type { NodeViewContentProps };
31 |
--------------------------------------------------------------------------------
/src/node-view-wrapper.tsx:
--------------------------------------------------------------------------------
1 | import { SolidNodeViewContextProps, useSolidNodeView, Attrs } from "./use-solid-node-view";
2 | import { Ref } from "./ref";
3 | import { Component, JSX, splitProps } from "solid-js";
4 | import { Dynamic } from "solid-js/web";
5 |
6 | interface NodeViewWrapperProps {
7 | [key: string]: unknown;
8 | style?: JSX.CSSProperties;
9 | ref?: Ref;
10 | as?: string | Component>;
11 | }
12 |
13 | const NodeViewWrapper: Component = (props) => {
14 | const { state } = useSolidNodeView() as SolidNodeViewContextProps;
15 | const [local, otherProps] = splitProps(props, ["ref"]);
16 |
17 | return (
18 |
29 | );
30 | };
31 |
32 | export { NodeViewWrapper };
33 | export type { NodeViewWrapperProps };
34 |
--------------------------------------------------------------------------------
/src/ref.ts:
--------------------------------------------------------------------------------
1 | type Ref = [() => V | null, (value: V) => void];
2 |
3 | const createRef = (): Ref => {
4 | let ref: V | null = null;
5 |
6 | return [
7 | () => ref,
8 | (value) => {
9 | ref = value;
10 | }
11 | ];
12 | };
13 |
14 | export { createRef };
15 | export type { Ref };
16 |
--------------------------------------------------------------------------------
/src/solid-node-view-renderer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | SolidNodeViewContext,
3 | SolidNodeViewProps,
4 | } from "./use-solid-node-view";
5 | import { SolidEditor } from "./editor";
6 | import { SolidRenderer } from "./solid-renderer";
7 | import { Decoration, NodeView as ProseMirrorNodeView } from "@tiptap/pm/view";
8 | import {
9 | DecorationWithType,
10 | NodeView,
11 | NodeViewRenderer,
12 | NodeViewRendererOptions,
13 | NodeViewRendererProps,
14 | } from "@tiptap/core";
15 | import { Component, createMemo } from "solid-js";
16 | import { Dynamic } from "solid-js/web";
17 | import { Node as ProseMirrorNode } from "@tiptap/pm/model";
18 |
19 | interface SolidNodeViewRendererOptions extends NodeViewRendererOptions {
20 | setSelection:
21 | | ((anchor: number, head: number, root: Document | ShadowRoot) => void)
22 | | null;
23 | update:
24 | | ((props: {
25 | oldNode: ProseMirrorNode;
26 | oldDecorations: Decoration[];
27 | newNode: ProseMirrorNode;
28 | newDecorations: Decoration[];
29 | updateProps: () => void;
30 | }) => boolean)
31 | | null;
32 | }
33 |
34 | type SetSelectionListener = (
35 | anchor: number,
36 | head: number,
37 | root: Document | ShadowRoot
38 | ) => void;
39 |
40 | class SolidNodeView extends NodeView<
41 | Component,
42 | SolidEditor,
43 | SolidNodeViewRendererOptions
44 | > {
45 | public setSelectionListeners: SetSelectionListener[] = [];
46 |
47 | public declare contentDOMElement: HTMLElement | null;
48 |
49 | public declare renderer: SolidRenderer;
50 |
51 | public get dom(): HTMLElement {
52 | const portalContainer = this.renderer.element.firstElementChild;
53 |
54 | if (
55 | portalContainer &&
56 | !portalContainer.firstElementChild?.hasAttribute("data-node-view-wrapper")
57 | ) {
58 | throw new Error(
59 | "Please use the NodeViewWrapper component for your node view."
60 | );
61 | }
62 |
63 | return this.renderer.element as HTMLElement;
64 | }
65 |
66 | public get contentDOM(): HTMLElement | null {
67 | if (this.node.isLeaf) {
68 | return null;
69 | }
70 |
71 | this.maybeMoveContentDOM();
72 |
73 | return this.contentDOMElement;
74 | }
75 |
76 | public mount(): void {
77 | const state: SolidNodeViewProps = {
78 | editor: this.editor,
79 | node: this.node,
80 | decorations: this.decorations,
81 | selected: false,
82 | extension: this.extension,
83 | getPos: () => this.getPos(),
84 | updateAttributes: (attributes = {}) => this.updateAttributes(attributes),
85 | deleteNode: () => this.deleteNode(),
86 | };
87 | const SolidNodeViewProvider: Component<{ state: SolidNodeViewProps }> = (
88 | props
89 | ) => {
90 | const component = createMemo(() => this.component);
91 | const context = {
92 | state: createMemo(() => ({
93 | onDragStart: this.onDragStart.bind(this),
94 | ...props.state,
95 | })),
96 | };
97 |
98 | return (
99 |
100 |
101 |
102 | );
103 | };
104 |
105 | if (this.node.isLeaf) {
106 | this.contentDOMElement = null;
107 | } else {
108 | this.contentDOMElement = document.createElement(
109 | this.node.isInline ? "span" : "div"
110 | );
111 | }
112 |
113 | if (this.contentDOMElement) {
114 | this.contentDOMElement.style.whiteSpace = "inherit";
115 | }
116 |
117 | this.renderer = new SolidRenderer(SolidNodeViewProvider, {
118 | editor: this.editor,
119 | state,
120 | as: this.node.isInline ? "span" : "div",
121 | });
122 | }
123 |
124 | public maybeMoveContentDOM(): void {
125 | const contentElement = this.dom.querySelector("[data-node-view-content]");
126 |
127 | if (
128 | this.contentDOMElement &&
129 | contentElement &&
130 | !contentElement.contains(this.contentDOMElement)
131 | ) {
132 | contentElement.append(this.contentDOMElement);
133 | }
134 | }
135 |
136 | public update(
137 | node: ProseMirrorNode,
138 | decorations: DecorationWithType[]
139 | ): boolean {
140 | if (node.type !== this.node.type) {
141 | return false;
142 | }
143 |
144 | if (typeof this.options.update === "function") {
145 | const oldNode = this.node;
146 | const oldDecorations = this.decorations;
147 |
148 | this.node = node;
149 | this.decorations = decorations;
150 |
151 | return this.options.update({
152 | oldNode,
153 | oldDecorations,
154 | newNode: node,
155 | newDecorations: decorations,
156 | updateProps: () => this.updateProps({ node, decorations }),
157 | });
158 | }
159 |
160 | if (node === this.node && this.decorations === decorations) {
161 | return true;
162 | }
163 |
164 | this.node = node;
165 | this.decorations = decorations;
166 | this.updateProps({ node, decorations });
167 |
168 | return true;
169 | }
170 |
171 | public setSelection(
172 | anchor: number,
173 | head: number,
174 | root: Document | ShadowRoot
175 | ): void {
176 | this.options.setSelection?.(anchor, head, root);
177 | }
178 |
179 | public selectNode(): void {
180 | this.renderer.setState?.((state) => ({ ...state, selected: true }));
181 | }
182 |
183 | public deselectNode(): void {
184 | this.renderer.setState?.((state) => ({ ...state, selected: false }));
185 | }
186 |
187 | public destroy(): void {
188 | this.renderer.destroy();
189 | this.contentDOMElement = null;
190 | }
191 |
192 | private updateProps(props: Partial): void {
193 | this.renderer.setState?.((state) => ({ ...state, ...props }));
194 | this.maybeMoveContentDOM();
195 | }
196 | }
197 |
198 | const SolidNodeViewRenderer = (
199 | component: Component,
200 | options?: Partial
201 | ): NodeViewRenderer => {
202 | return (props: NodeViewRendererProps) => {
203 | const { renderers, setRenderers } = props.editor as SolidEditor;
204 |
205 | if (!renderers || !setRenderers) {
206 | return {};
207 | }
208 |
209 | return new SolidNodeView(
210 | component,
211 | props,
212 | options
213 | ) as unknown as ProseMirrorNodeView;
214 | };
215 | };
216 |
217 | export { SolidNodeViewRenderer };
218 | export type { SolidNodeViewRendererOptions };
219 |
--------------------------------------------------------------------------------
/src/solid-renderer.tsx:
--------------------------------------------------------------------------------
1 | import { SolidEditor } from "./editor";
2 | import { Attrs, SolidNodeViewProps } from "./use-solid-node-view";
3 | import { Accessor, Component, createSignal, Setter } from "solid-js";
4 | import { nanoid } from "nanoid";
5 |
6 | interface SolidRendererOptions {
7 | editor: SolidEditor;
8 | state: S;
9 | as?: string;
10 | }
11 |
12 | class SolidRenderer {
13 | public declare state: Accessor;
14 |
15 | public declare setState: Setter;
16 |
17 | public declare id: string;
18 |
19 | public declare element: Element;
20 |
21 | public declare component: Component<{ state: S }>;
22 |
23 | private declare editor: SolidEditor;
24 |
25 | public constructor(
26 | component: Component<{ state: S }>,
27 | { editor, state: initialState, as = "div" }: SolidRendererOptions
28 | ) {
29 | const [state, setState] = createSignal(initialState);
30 | const element = document.createElement(as);
31 |
32 | this.setState = setState;
33 | this.state = state;
34 | this.element = element;
35 | this.component = component;
36 | this.id = nanoid();
37 | this.editor = editor;
38 | this.editor.setRenderers([
39 | ...this.editor.renderers(),
40 | this as unknown as SolidRenderer>,
41 | ]);
42 | }
43 |
44 | public destroy(): void {
45 | this.editor.setRenderers((renderers) => {
46 | return renderers.filter((renderer) => renderer.id !== this.id);
47 | });
48 | }
49 | }
50 |
51 | export { SolidRenderer };
52 | export type { SolidRendererOptions };
53 |
--------------------------------------------------------------------------------
/src/use-editor.ts:
--------------------------------------------------------------------------------
1 | import { SolidEditor } from "./editor";
2 | import { createSignal, onCleanup } from "solid-js";
3 | import { EditorOptions } from "@tiptap/core";
4 |
5 | const useForceUpdate = (): (() => void) => {
6 | const [, setValue] = createSignal(0);
7 |
8 | return () => setValue((value) => value + 1);
9 | };
10 | const useEditor = (options: Partial = {}): (() => SolidEditor) => {
11 | const [getEditor] = createSignal(new SolidEditor(options));
12 | const forceUpdate = useForceUpdate();
13 |
14 | getEditor().on("transaction", forceUpdate);
15 | onCleanup(() => {
16 | getEditor().destroy();
17 | });
18 |
19 | return getEditor;
20 | };
21 |
22 | export { useEditor };
23 |
--------------------------------------------------------------------------------
/src/use-solid-node-view.tsx:
--------------------------------------------------------------------------------
1 | import { SolidEditor } from "./editor";
2 | import { Accessor, Context, createContext, useContext } from "solid-js";
3 | import { NodeViewProps } from "@tiptap/core";
4 | import { Node as ProseMirrorNode } from "@tiptap/pm/model";
5 |
6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
7 | type Attrs = Record;
8 |
9 | interface SolidNodeViewProps extends NodeViewProps {
10 | node: ProseMirrorNode & { attrs: A };
11 | editor: SolidEditor;
12 | }
13 | interface SolidNodeViewContextProps {
14 | state: Accessor<
15 | SolidNodeViewProps & {
16 | onDragStart?(event: DragEvent): void;
17 | }
18 | >;
19 | }
20 |
21 | const SolidNodeViewContext = createContext();
22 | const useSolidNodeView = (): SolidNodeViewContextProps => {
23 | return useContext(SolidNodeViewContext as Context>);
24 | };
25 |
26 | export { SolidNodeViewContext, useSolidNodeView };
27 | export type { SolidNodeViewContextProps, SolidNodeViewProps, Attrs };
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
4 | "module": "ESNext",
5 | "moduleResolution": "node",
6 | "resolveJsonModule": true,
7 | "esModuleInterop": true,
8 | "outDir": "dist",
9 | "strict": true,
10 | "declaration": true,
11 | "isolatedModules": true,
12 | "jsx": "preserve",
13 | "jsxImportSource": "solid-js"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------