Skip to content

Commit d653650

Browse files
authored
refactor: support markviews (#1437)
* support markviews * clean * fix * tmp * fix case
1 parent 67d08a8 commit d653650

File tree

10 files changed

+306
-7
lines changed

10 files changed

+306
-7
lines changed

packages/core/src/editor/BlockNoteExtensions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,9 @@ const getTipTapExtensions = <
174174
protocols: VALID_LINK_PROTOCOLS,
175175
}),
176176
...Object.values(opts.styleSpecs).map((styleSpec) => {
177-
return styleSpec.implementation.mark;
177+
return styleSpec.implementation.mark.configure({
178+
editor: opts.editor as any,
179+
});
178180
}),
179181

180182
TextColorExtension,

packages/core/src/editor/BlockNoteTipTapEditor.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,23 @@ export class BlockNoteTipTapEditor extends TiptapEditor {
152152
private createViewAlternative(contentComponent?: any) {
153153
(this as any).contentComponent = contentComponent;
154154

155+
const markViews: any = {};
156+
this.extensionManager.extensions.forEach((extension) => {
157+
if (extension.type === "mark" && extension.config.addMarkView) {
158+
// Note: migrate to using `addMarkView` from tiptap as soon as this lands
159+
// (currently tiptap doesn't support markviews)
160+
markViews[extension.name] = extension.config.addMarkView;
161+
}
162+
});
163+
155164
this.view = new EditorView(
156165
{ mount: this.options.element as any }, // use mount option so that we reuse the existing element instead of creating a new one
157166
{
158167
...this.options.editorProps,
159168
// @ts-ignore
160169
dispatchTransaction: this.dispatchTransaction.bind(this),
161170
state: this.state,
171+
markViews,
162172
}
163173
);
164174

packages/react/src/schema/@util/ReactRenderUtil.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,17 @@ export function renderToDOMSpec(
1919
div
2020
);
2121
} else {
22-
// If no editor is provided, use a temporary root. This is currently only used for Styles (see ReactStyleSpec).
23-
// In this case, react context etc. won't be available inside `fc`
24-
25-
// We also use this if _tiptapEditor or _tiptapEditor.contentComponent is undefined, use a temporary root.
26-
// This is actually a fallback / temporary fix, as normally this shouldn't happen (see #755). TODO: find cause
22+
// If no editor is provided, use a temporary root.
23+
// This is currently only used for when we use ServerBlockNoteEditor (@blocknote/server-util)
24+
// and without using `withReactContext`
25+
26+
if (!editor?.headless) {
27+
throw new Error(
28+
"elementRenderer not available, expected headless editor"
29+
);
30+
}
2731
root = createRoot(div);
32+
2833
flushSync(() => {
2934
root!.render(fc((el) => (contentDOM = el || undefined)));
3035
});

packages/react/src/schema/ReactStyleSpec.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { Mark } from "@tiptap/react";
99
import { FC } from "react";
1010
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";
11+
import { ReactMarkView } from "./markviews/ReactMarkViewRenderer.js";
1112

1213
// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
1314

@@ -43,9 +44,10 @@ export function createReactStyleSpec<T extends StyleConfig>(
4344
}
4445

4546
const Content = styleImplementation.render;
47+
4648
const renderResult = renderToDOMSpec(
4749
(refCB) => <Content {...props} contentRef={refCB} />,
48-
undefined
50+
this.options.editor
4951
);
5052

5153
return addStyleAttributes(
@@ -57,6 +59,26 @@ export function createReactStyleSpec<T extends StyleConfig>(
5759
},
5860
});
5961

62+
const markType = mark;
63+
64+
// this is a bit of a hack to register an `addMarkView` function on the mark type
65+
//
66+
// we can clean this once MarkViews land in tiptap
67+
(markType as any).config.addMarkView = (mark: any, view: any) => {
68+
const markView = new ReactMarkView({
69+
editor: markType.child?.options.editor,
70+
inline: true,
71+
mark,
72+
options: {
73+
component: styleImplementation.render,
74+
contentAs: "span",
75+
},
76+
view,
77+
});
78+
markView.render();
79+
return markView;
80+
};
81+
6082
return createInternalStyleSpec(styleConfig, {
6183
mark,
6284
});
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type { Mark } from "prosemirror-model";
2+
import type {
3+
EditorView,
4+
MarkView,
5+
ViewMutationRecord,
6+
} from "prosemirror-view";
7+
8+
import type { BlockNoteEditor } from "@blocknote/core";
9+
import type {
10+
CoreMarkViewSpec,
11+
CoreMarkViewUserOptions,
12+
MarkViewDOMSpec,
13+
} from "./CoreMarkViewOptions.js";
14+
15+
/* eslint-disable curly */
16+
17+
export class CoreMarkView<ComponentType> implements MarkView {
18+
dom: HTMLElement;
19+
contentDOM: HTMLElement | undefined;
20+
mark: Mark;
21+
view: EditorView;
22+
inline: boolean;
23+
options: CoreMarkViewUserOptions<ComponentType>;
24+
editor: BlockNoteEditor<any, any, any>;
25+
26+
#createElement(as?: MarkViewDOMSpec) {
27+
const { inline, mark } = this;
28+
return as == null
29+
? document.createElement(inline ? "span" : "div")
30+
: as instanceof HTMLElement
31+
? as
32+
: as instanceof Function
33+
? as(mark)
34+
: document.createElement(as);
35+
}
36+
37+
createDOM(as?: MarkViewDOMSpec) {
38+
return this.#createElement(as);
39+
}
40+
41+
createContentDOM(as?: MarkViewDOMSpec) {
42+
return this.#createElement(as);
43+
}
44+
45+
constructor({
46+
mark,
47+
view,
48+
inline,
49+
options,
50+
editor, // BlockNote specific
51+
}: CoreMarkViewSpec<ComponentType>) {
52+
this.mark = mark;
53+
this.view = view;
54+
this.inline = inline;
55+
this.options = options;
56+
this.editor = editor;
57+
58+
this.dom = this.createDOM(options.as);
59+
this.contentDOM = options.contentAs
60+
? this.createContentDOM(options.contentAs)
61+
: undefined;
62+
this.dom.setAttribute("data-mark-view-root", "true");
63+
if (this.contentDOM) {
64+
this.contentDOM.setAttribute("data-mark-view-content", "true");
65+
this.contentDOM.style.whiteSpace = "inherit";
66+
}
67+
}
68+
69+
get component() {
70+
return this.options.component;
71+
}
72+
73+
shouldIgnoreMutation: (mutation: ViewMutationRecord) => boolean = (
74+
mutation
75+
) => {
76+
if (!this.dom || !this.contentDOM) return true;
77+
78+
if (mutation.type === "selection") return false;
79+
80+
if (this.contentDOM === mutation.target && mutation.type === "attributes")
81+
return true;
82+
83+
if (this.contentDOM.contains(mutation.target)) return false;
84+
85+
return true;
86+
};
87+
88+
ignoreMutation: (mutation: ViewMutationRecord) => boolean = (mutation) => {
89+
if (!this.dom || !this.contentDOM) return true;
90+
91+
let result;
92+
93+
const userIgnoreMutation = this.options.ignoreMutation;
94+
95+
if (userIgnoreMutation) result = userIgnoreMutation(mutation);
96+
97+
if (typeof result !== "boolean")
98+
result = this.shouldIgnoreMutation(mutation);
99+
100+
return result;
101+
};
102+
103+
public destroy() {
104+
this.options.destroy?.();
105+
this.dom.remove();
106+
this.contentDOM?.remove();
107+
}
108+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { BlockNoteEditor } from "@blocknote/core";
2+
import type { Mark } from "prosemirror-model";
3+
import type { EditorView, ViewMutationRecord } from "prosemirror-view";
4+
5+
export type MarkViewDOMSpec =
6+
| string
7+
| HTMLElement
8+
| ((mark: Mark) => HTMLElement);
9+
10+
export interface CoreMarkViewUserOptions<Component> {
11+
// DOM
12+
as?: MarkViewDOMSpec;
13+
contentAs?: MarkViewDOMSpec;
14+
15+
// Component
16+
component: Component;
17+
18+
// Overrides
19+
ignoreMutation?: (mutation: ViewMutationRecord) => boolean | void;
20+
destroy?: () => void;
21+
}
22+
23+
export interface CoreMarkViewSpec<Component> {
24+
mark: Mark;
25+
view: EditorView;
26+
inline: boolean;
27+
28+
options: CoreMarkViewUserOptions<Component>;
29+
30+
// BlockNote specific
31+
editor: BlockNoteEditor<any, any, any>;
32+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The implementation of MarkViews in this directory is based on `prosemirror-adapter`. We might want to migrate to the Tiptap implementation of MarkViews once this lands in Tiptap v3.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { FC } from "react";
2+
import type {
3+
CoreMarkViewSpec,
4+
CoreMarkViewUserOptions,
5+
} from "./CoreMarkViewOptions.js";
6+
7+
// export type ReactMarkViewComponent = ComponentType<Record<string, never>>;
8+
9+
export type ReactMarkViewComponent =
10+
| FC<{ contentRef: (el: HTMLElement | null) => void }>
11+
| FC<{ contentRef: (el: HTMLElement | null) => void; value: string }>;
12+
13+
export type ReactMarkViewSpec = CoreMarkViewSpec<ReactMarkViewComponent>;
14+
15+
export type ReactMarkViewUserOptions =
16+
CoreMarkViewUserOptions<ReactMarkViewComponent>;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { CoreMarkView } from "./CoreMarkView.js";
2+
import type { MarkViewContext } from "./markViewContext.js";
3+
import type { ReactMarkViewComponent } from "./ReactMarkViewOptions.js";
4+
5+
/* eslint-disable curly */
6+
7+
export class ReactMarkView extends CoreMarkView<ReactMarkViewComponent> {
8+
// implements ReactRenderer<MarkViewContext>
9+
// key: string = nanoid();
10+
id = Math.floor(Math.random() * 0xffffffff).toString();
11+
12+
context: MarkViewContext = {
13+
contentRef: (element) => {
14+
if (element && this.contentDOM && element.firstChild !== this.contentDOM)
15+
element.appendChild(this.contentDOM);
16+
},
17+
view: this.view,
18+
19+
mark: this.mark,
20+
};
21+
22+
updateContext = () => {
23+
Object.assign(this.context, {
24+
mark: this.mark,
25+
});
26+
};
27+
28+
// render = () => {
29+
// const UserComponent = this.component;
30+
31+
// return createPortal(
32+
// <markViewContext.Provider value={this.context}>
33+
// <UserComponent />
34+
// </markViewContext.Provider>,
35+
// this.dom,
36+
// this.key
37+
// );
38+
// };
39+
40+
render = () => {
41+
this.editor._tiptapEditor.contentComponent.setRenderer(
42+
this.id,
43+
this.renderer()
44+
);
45+
};
46+
47+
destroy = () => {
48+
super.destroy();
49+
this.editor._tiptapEditor.contentComponent.removeRenderer(this.id);
50+
};
51+
52+
renderer = () => {
53+
const UserComponent = this.component;
54+
55+
const props: any = {};
56+
57+
if (this.mark.attrs.stringValue) {
58+
props.value = this.mark.attrs.stringValue;
59+
}
60+
61+
return {
62+
reactElement: (
63+
// <markViewContext.Provider value={this.context}>
64+
<UserComponent contentRef={this.context.contentRef} {...props} />
65+
// </markViewContext.Provider>
66+
),
67+
element: this.dom,
68+
};
69+
};
70+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Mark } from "prosemirror-model";
2+
import type { EditorView, MarkViewConstructor } from "prosemirror-view";
3+
import { createContext, useContext } from "react";
4+
import type { ReactMarkViewUserOptions } from "./ReactMarkViewOptions.js";
5+
6+
export type MarkViewContentRef = (node: HTMLElement | null) => void;
7+
8+
export interface MarkViewContext {
9+
// won't change
10+
contentRef: MarkViewContentRef;
11+
view: EditorView;
12+
mark: Mark;
13+
}
14+
15+
export const markViewContext = createContext<MarkViewContext>({
16+
contentRef: () => {
17+
// nothing to do
18+
},
19+
view: null as never,
20+
mark: null as never,
21+
});
22+
23+
export const useMarkViewContext = () => useContext(markViewContext);
24+
25+
export const createMarkViewContext = createContext<
26+
(options: ReactMarkViewUserOptions) => MarkViewConstructor
27+
>((_options) => {
28+
throw new Error(
29+
"No ProsemirrorAdapterProvider detected, maybe you need to wrap the component with the Editor with ProsemirrorAdapterProvider?"
30+
);
31+
});
32+
33+
export const useMarkViewFactory = () => useContext(createMarkViewContext);

0 commit comments

Comments
 (0)