mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 00:14:10 +08:00
feat: synced blocks (transclusion) (#2163)
* feat: synced blocks (transclusion) * fix:remove name * make placeholders smaller * feat: enforce strict transclusion schema * fix: scope synced blocks to workspace, gate unsync on edit permission * fix collab module error
This commit is contained in:
@@ -21,6 +21,7 @@ export * from "./lib/markdown";
|
||||
export * from "./lib/search-and-replace";
|
||||
export * from "./lib/embed-provider";
|
||||
export * from "./lib/subpages";
|
||||
export * from "./lib/transclusion";
|
||||
export * from "./lib/highlight";
|
||||
export * from "./lib/heading/heading";
|
||||
export * from "./lib/unique-id";
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Top-level block node types allowed inside a `transclusionSource`.
|
||||
* Notably excludes:
|
||||
* - `transclusionSource` — sync blocks cannot wrap other sync blocks (sources are leaves).
|
||||
* - `transclusionReference` — sync blocks cannot transclude other sync blocks,
|
||||
* which keeps the transclusion graph acyclic and lets the renderer skip
|
||||
* cycle-aware traversal entirely.
|
||||
*
|
||||
* Also excludes child-only nodes (`listItem`, `tableRow`, `column`, etc.)
|
||||
* — they're already constrained by their parent containers.
|
||||
*/
|
||||
export const TRANSCLUSION_SOURCE_ALLOWED_NODE_TYPES = [
|
||||
'paragraph',
|
||||
'heading',
|
||||
'blockquote',
|
||||
'codeBlock',
|
||||
'horizontalRule',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'taskList',
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'attachment',
|
||||
'callout',
|
||||
'details',
|
||||
'embed',
|
||||
'mathBlock',
|
||||
'table',
|
||||
'drawio',
|
||||
'excalidraw',
|
||||
'pdf',
|
||||
'subpages',
|
||||
'columns',
|
||||
'youtube',
|
||||
] as const;
|
||||
|
||||
export type TransclusionSourceAllowedNodeType =
|
||||
(typeof TRANSCLUSION_SOURCE_ALLOWED_NODE_TYPES)[number];
|
||||
|
||||
export const TRANSCLUSION_SOURCE_CONTENT_EXPRESSION = `(${TRANSCLUSION_SOURCE_ALLOWED_NODE_TYPES.join(' | ')})+`;
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./constants";
|
||||
export * from "./transclusion-source";
|
||||
export * from "./transclusion-reference";
|
||||
@@ -0,0 +1,92 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface TransclusionReferenceOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface TransclusionReferenceAttributes {
|
||||
sourcePageId?: string | null;
|
||||
transclusionId?: string | null;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
transclusionReference: {
|
||||
insertTransclusionReference: (
|
||||
attributes: TransclusionReferenceAttributes,
|
||||
) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TransclusionReference = Node.create<TransclusionReferenceOptions>({
|
||||
name: "transclusionReference",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
sourcePageId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-source-page-id"),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.sourcePageId
|
||||
? { "data-source-page-id": attrs.sourcePageId }
|
||||
: {},
|
||||
},
|
||||
transclusionId: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-transclusion-id"),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.transclusionId
|
||||
? { "data-transclusion-id": attrs.transclusionId }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertTransclusionReference:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (!this.options.view) return null;
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { TRANSCLUSION_SOURCE_CONTENT_EXPRESSION } from "./constants";
|
||||
|
||||
export interface TransclusionSourceOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
view: any;
|
||||
}
|
||||
|
||||
export interface TransclusionSourceAttributes {
|
||||
id?: string | null;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
transclusionSource: {
|
||||
insertTransclusionSource: (
|
||||
attributes?: TransclusionSourceAttributes,
|
||||
) => ReturnType;
|
||||
toggleTransclusionSource: () => ReturnType;
|
||||
unsyncTransclusionSource: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TransclusionSource = Node.create<TransclusionSourceOptions>({
|
||||
name: "transclusionSource",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
group: "block",
|
||||
// Schema-enforced allow-list. Excludes `transclusionSource` (no nesting)
|
||||
content: TRANSCLUSION_SOURCE_CONTENT_EXPRESSION,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute("data-id"),
|
||||
renderHTML: (attrs) =>
|
||||
attrs.id ? { "data-id": attrs.id } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertTransclusionSource:
|
||||
(attributes) =>
|
||||
({ commands, state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
for (let depth = $from.depth; depth > 0; depth -= 1) {
|
||||
if ($from.node(depth).type.name === this.name) return false;
|
||||
}
|
||||
|
||||
const node = {
|
||||
type: this.name,
|
||||
attrs: attributes ?? {},
|
||||
content: [{ type: "paragraph" }],
|
||||
};
|
||||
|
||||
const parent = $from.parent;
|
||||
const isEmptyParagraph =
|
||||
parent.type.name === "paragraph" && parent.content.size === 0;
|
||||
|
||||
if (isEmptyParagraph) {
|
||||
return chain()
|
||||
.insertContentAt(
|
||||
{ from: $from.before(), to: $from.after() },
|
||||
node,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
return commands.insertContent(node);
|
||||
},
|
||||
toggleTransclusionSource:
|
||||
() =>
|
||||
({ commands }) =>
|
||||
commands.toggleWrap(this.name),
|
||||
unsyncTransclusionSource:
|
||||
() =>
|
||||
({ state, tr, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
// Walk up to the nearest source wrapper.
|
||||
let depth = $from.depth;
|
||||
while (depth > 0 && $from.node(depth).type.name !== this.name) {
|
||||
depth -= 1;
|
||||
}
|
||||
if (depth === 0) return false;
|
||||
|
||||
const node = $from.node(depth);
|
||||
const start = $from.before(depth);
|
||||
const end = start + node.nodeSize;
|
||||
|
||||
if (dispatch) {
|
||||
tr.replaceWith(start, end, node.content);
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
if (!this.options.view) return null;
|
||||
// Force the react node view to render immediately using flush sync
|
||||
this.editor.isInitialized = true;
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user