Skip to content
File

Blob: src/client/components/editor/extensions/page-mention/node.ts

typescript73 lines
1import { ReactNodeViewRenderer } from "@tiptap/react";
2import { Plugin, PluginKey } from "@tiptap/pm/state";
3import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
4import { SharedPageMentionNode } from "@/shared/editor/schema";
5import type { EditorAffordance } from "@/client/lib/affordance/editor";
6import { PageMentionView } from "./node-view";
7 
8interface PageMentionNodeOptions {
9 getAffordance: () => EditorAffordance | null;
10}
11 
12export const PageMentionNode = SharedPageMentionNode.extend<PageMentionNodeOptions>({
13 addOptions() {
14 return {
15 getAffordance: () => null,
16 };
17 },
18 
19 addProseMirrorPlugins() {
20 const getAffordance = this.options.getAffordance;
21 return [
22 new Plugin({
23 key: new PluginKey("pageMentionPasteGuard"),
24 props: {
25 // Persisted documents still need to parse and render mentions, so we
26 // keep `parseHTML` permissive. The clipboard ingress is closed here:
27 // when the live affordance denies mention insertion (shared surface,
28 // read-only, or missing workspace context), strip pageMention nodes
29 // from the pasted slice before they enter the doc/Yjs.
30 transformPasted(slice) {
31 const affordance = getAffordance();
32 if (!affordance || affordance.canInsertPageMentions) return slice;
33 return stripPageMentions(slice);
34 },
35 },
36 }),
37 ];
38 },
39 
40 addNodeView() {
41 return ReactNodeViewRenderer(PageMentionView);
42 },
43});
44 
45export function stripPageMentions(slice: Slice): Slice {
46 const stripped = stripFragment(slice.content);
47 if (stripped === slice.content) return slice;
48 return new Slice(stripped, slice.openStart, slice.openEnd);
49}
50 
51function stripFragment(fragment: Fragment): Fragment {
52 const kept: PmNode[] = [];
53 let changed = false;
54 fragment.forEach((node) => {
55 if (node.type.name === "pageMention") {
56 changed = true;
57 return;
58 }
59 if (node.content.size === 0) {
60 kept.push(node);
61 return;
62 }
63 const innerStripped = stripFragment(node.content);
64 if (innerStripped === node.content) {
65 kept.push(node);
66 } else {
67 changed = true;
68 kept.push(node.copy(innerStripped));
69 }
70 });
71 return changed ? Fragment.fromArray(kept) : fragment;
72}