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:
Philip Okugbe
2026-05-08 13:23:16 +01:00
committed by GitHub
parent c9fa6e20b3
commit de60aa7e61
64 changed files with 4388 additions and 105 deletions
+1
View File
@@ -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);
},
});