merge main

This commit is contained in:
Philipinho
2026-05-22 13:56:51 +01:00
811 changed files with 68477 additions and 12951 deletions
+8
View File
@@ -11,6 +11,7 @@ export * from "./lib/media-utils";
export * from "./lib/link";
export * from "./lib/selection";
export * from "./lib/attachment";
export * from "./lib/audio";
export * from "./lib/custom-code-block";
export * from "./lib/drawio";
export * from "./lib/excalidraw";
@@ -20,9 +21,16 @@ 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/indent";
export * from "./lib/heading/heading";
export * from "./lib/unique-id";
export * from "./lib/shared-storage";
export * from "./lib/recreate-transform";
export * from "./lib/integration-link";
export * from "./lib/columns";
export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
@@ -1,5 +1,6 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { sanitizeUrl } from "../utils";
export interface AttachmentOptions {
HTMLAttributes: Record<string, any>;
@@ -42,9 +43,12 @@ export const Attachment = Node.create<AttachmentOptions>({
return {
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
parseHTML: (element) => {
const url = element.getAttribute("data-attachment-url");
return sanitizeUrl(url);
},
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
"data-attachment-url": sanitizeUrl(attributes.url),
}),
},
name: {
@@ -101,7 +105,7 @@ export const Attachment = Node.create<AttachmentOptions>({
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
href: sanitizeUrl(HTMLAttributes["data-attachment-url"]),
class: "attachment",
target: "blank",
},
@@ -0,0 +1,139 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findAudioNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "audio" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handleAudioUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const objectUrl = URL.createObjectURL(file);
const placeholderId = generateNodeId();
let placeholderInserted = false;
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
editor.storage.shared.audioPreviews[placeholderId] = objectUrl;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.audio?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithAudio = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findAudioNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
const disposePreviewFile = () => {
URL.revokeObjectURL(objectUrl);
if (editor.storage.shared.audioPreviews) {
delete editor.storage.shared.audioPreviews[placeholderId];
}
};
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithAudio(attachment));
disposePreviewFile();
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithAudio(attachment))
.run();
disposePreviewFile();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
disposePreviewFile();
}
};
export { handleAudioUpload };
+134
View File
@@ -0,0 +1,134 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "../media-utils";
import { sanitizeUrl, isInternalFileUrl } from "../utils";
export interface AudioOptions {
view: any;
HTMLAttributes: Record<string, any>;
}
export interface AudioAttributes {
src?: string;
attachmentId?: string;
size?: number;
placeholder?: {
id: string;
name: string;
};
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
audioBlock: {
setAudio: (attributes: AudioAttributes) => ReturnType;
};
}
}
export const TiptapAudio = Node.create<AudioOptions>({
name: "audio",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("src");
const sanitized = sanitizeUrl(src);
return isInternalFileUrl(sanitized) ? sanitized : "";
},
renderHTML: (attributes) => ({
src: isInternalFileUrl(attributes.src)
? sanitizeUrl(attributes.src)
: "",
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: AudioAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: AudioAttributes) => ({
"data-size": attributes.size,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: "audio",
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"audio",
mergeAttributes(
{ controls: "true", preload: "metadata" },
this.options.HTMLAttributes,
HTMLAttributes,
),
["source", { src: HTMLAttributes.src }],
];
},
addCommands() {
return {
setAudio:
(attrs: AudioAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "audio",
attrs: attrs,
});
},
};
},
addNodeView() {
if (this.options.view) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
return ({ node, HTMLAttributes }) => {
const dom = document.createElement("div");
const audio = document.createElement("audio");
const src = node.attrs.src;
if (src && isInternalFileUrl(src)) {
audio.src = normalizeFileUrl(src);
}
audio.controls = true;
audio.preload = "metadata";
audio.style.width = "100%";
dom.append(audio);
return { dom };
};
},
});
@@ -0,0 +1,2 @@
export { TiptapAudio } from "./audio";
export * from "./audio-upload";
@@ -53,6 +53,7 @@ export const Callout = Node.create<CalloutOptions>({
content: "block+",
group: "block",
defining: true,
isolating: true,
addAttributes() {
return {
@@ -161,6 +162,28 @@ export const Callout = Node.create<CalloutOptions>({
return false;
}
// Empty callout: delete the whole node so Backspace inside it isn't
// a no-op (isolating: true blocks the default join with the block
// above).
const calloutDepth = $from.depth - 1;
if (calloutDepth >= 0) {
const calloutNode = $from.node(calloutDepth);
if (
calloutNode.type === this.type &&
calloutNode.childCount === 1 &&
calloutNode.firstChild?.content.size === 0
) {
const calloutPos = $from.before(calloutDepth);
const { tr } = state;
tr.delete(calloutPos, calloutPos + calloutNode.nodeSize);
tr.setSelection(
TextSelection.near(tr.doc.resolve(calloutPos), -1),
);
view.dispatch(tr);
return true;
}
}
const previousPosition = $from.before($from.depth) - 1;
// If nothing above to join with
@@ -206,6 +229,56 @@ export const Callout = Node.create<CalloutOptions>({
}
return false;
},
// Exit the callout into a fresh paragraph below when the cursor sits
// in an empty trailing child. An empty callout (single empty
// paragraph) exits on the first Enter and keeps the empty callout
// intact; a callout with content needs the double-Enter pattern
// (first Enter splits, second Enter on the new trailing empty exits
// and removes that trailing paragraph).
Enter: ({ editor }) => {
const { state, view } = editor;
const { selection } = state;
if (!selection.empty) return false;
const { $from } = selection;
const calloutDepth = $from.depth - 1;
if (calloutDepth < 0) return false;
const calloutNode = $from.node(calloutDepth);
if (calloutNode.type !== this.type) return false;
if ($from.parent.content.size !== 0) return false;
if ($from.index(calloutDepth) !== calloutNode.childCount - 1) {
return false;
}
const paragraphType = state.schema.nodes.paragraph;
const containerDepth = calloutDepth - 1;
const container = $from.node(containerDepth);
const indexAfter = $from.indexAfter(containerDepth);
if (
!container.canReplaceWith(indexAfter, indexAfter, paragraphType)
) {
return false;
}
const calloutEnd = $from.after(calloutDepth);
const paragraph = paragraphType.create();
const { tr } = state;
if (calloutNode.childCount === 1) {
tr.insert(calloutEnd, paragraph);
tr.setSelection(TextSelection.create(tr.doc, calloutEnd + 1));
} else {
tr.delete($from.before(), $from.after());
const insertPos = tr.mapping.map(calloutEnd);
tr.insert(insertPos, paragraph);
tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
}
view.dispatch(tr);
return true;
},
};
},
+16 -3
View File
@@ -1,8 +1,21 @@
export type CalloutType = "default" | "info" | "success" | "warning" | "danger";
const validCalloutTypes = ["default", "info", "success", "warning", "danger"];
export type CalloutType =
| 'default'
| 'info'
| 'note'
| 'success'
| 'warning'
| 'danger';
const validCalloutTypes = [
'default',
'info',
'note',
'success',
'warning',
'danger',
];
export function getValidCalloutType(value: string): string {
if (value) {
return validCalloutTypes.includes(value) ? value : "info";
return validCalloutTypes.includes(value) ? value : 'info';
}
}
@@ -0,0 +1,127 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
export interface ColumnOptions {
HTMLAttributes: Record<string, any>;
}
export interface ColumnAttributes {
width?: number | null;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
column: {
setColumnWidth: (width: number | null) => ReturnType;
};
}
}
export const Column = Node.create<ColumnOptions>({
name: "column",
group: "block",
content: "block+",
defining: true,
isolating: true,
selectable: false,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
width: {
default: null,
parseHTML: (element) => {
const value = element.getAttribute("data-width");
return value ? parseFloat(value) : null;
},
renderHTML: (attributes: ColumnAttributes) => {
if (!attributes.width) return {};
return {
"data-width": attributes.width,
style: `flex: ${attributes.width}`,
};
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addKeyboardShortcuts() {
const jumpToColumn = (direction: 1 | -1) => () => {
const { state, dispatch } = this.editor.view;
const columns = findParentNode(
(node) => node.type.name === "columns",
)(state.selection);
if (!columns) return false;
const column = findParentNode(
(node) => node.type.name === "column",
)(state.selection);
if (!column) return false;
let currentIndex = -1;
columns.node.forEach((_child, offset, index) => {
if (columns.pos + 1 + offset === column.pos) {
currentIndex = index;
}
});
const targetIndex = currentIndex + direction;
if (targetIndex < 0 || targetIndex >= columns.node.childCount) {
return true;
}
let offset = 0;
for (let j = 0; j < targetIndex; j++) {
offset += columns.node.child(j).nodeSize;
}
const targetPos = columns.pos + 1 + offset + 1 + 1;
if (dispatch) {
dispatch(
state.tr.setSelection(TextSelection.create(state.doc, targetPos)),
);
}
return true;
};
return {
Tab: jumpToColumn(1),
"Shift-Tab": jumpToColumn(-1),
};
},
addCommands() {
return {
setColumnWidth:
(width) =>
({ commands }) =>
commands.updateAttributes("column", { width }),
};
},
});
@@ -0,0 +1,237 @@
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
import { Fragment, Node as PMNode } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export type ColumnsLayout =
| "two_equal"
| "two_left_sidebar"
| "two_right_sidebar"
| "three_equal"
| "three_left_wide"
| "three_right_wide"
| "three_with_sidebars"
| "four_equal"
| "five_equal";
export interface ColumnsOptions {
HTMLAttributes: Record<string, any>;
}
export type WidthMode = "normal" | "wide";
export interface ColumnsAttributes {
layout?: ColumnsLayout;
widthMode?: WidthMode;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
columns: {
insertColumns: (attributes?: ColumnsAttributes) => ReturnType;
setColumnsWidthMode: (widthMode: WidthMode) => ReturnType;
setColumnCount: (count: number) => ReturnType;
setColumnsLayout: (layout: ColumnsLayout) => ReturnType;
};
}
}
function columnCountFromLayout(layout: string): number {
if (layout.startsWith("five")) return 5;
if (layout.startsWith("four")) return 4;
if (layout.startsWith("three")) return 3;
return 2;
}
function defaultLayoutForCount(count: number): ColumnsLayout {
if (count === 3) return "three_equal";
if (count === 4) return "four_equal";
if (count === 5) return "five_equal";
return "two_equal";
}
export const Columns = Node.create<ColumnsOptions>({
name: "columns",
group: "block",
content: "column+",
defining: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
layout: {
default: "two_equal",
parseHTML: (element) => element.getAttribute("data-layout"),
renderHTML: (attributes: ColumnsAttributes) => ({
"data-layout": attributes.layout,
}),
},
widthMode: {
default: "normal",
parseHTML: (element) =>
element.getAttribute("data-width-mode") || "normal",
renderHTML: (attributes: ColumnsAttributes) => {
if (!attributes.widthMode || attributes.widthMode === "normal")
return {};
return { "data-width-mode": attributes.widthMode };
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
0,
];
},
addCommands() {
return {
insertColumns:
(attributes) =>
({ tr, state, dispatch }) => {
const layout = attributes?.layout || "two_equal";
const count = columnCountFromLayout(layout);
const columnType = state.schema.nodes.column;
const paraType = state.schema.nodes.paragraph;
const children = Array.from({ length: count }, () =>
columnType.create(null, paraType.create()),
);
const columnsNode = this.type.create(
attributes,
Fragment.from(children),
);
const stepsBefore = tr.steps.length;
tr.replaceSelectionWith(columnsNode);
if (tr.steps.length > stepsBefore) {
const stepMap = tr.steps[tr.steps.length - 1].getMap();
let insertStart = 0;
stepMap.forEach((_from, _to, newFrom) => {
insertStart = newFrom;
});
tr.setSelection(
TextSelection.near(tr.doc.resolve(insertStart + 1), 1),
);
}
if (dispatch) dispatch(tr);
return true;
},
setColumnsWidthMode:
(widthMode) =>
({ commands }) =>
commands.updateAttributes("columns", { widthMode }),
setColumnCount:
(count: number) =>
({ tr, state }) => {
const predicate = (node: PMNode) => node.type.name === "columns";
const parent = findParentNode(predicate)(state.selection);
if (!parent) return false;
const { node: columnsNode, pos: parentPos } = parent;
const currentCount = columnsNode.childCount;
if (count === currentCount || count < 2 || count > 5) return false;
const columnType = state.schema.nodes.column;
const paraType = state.schema.nodes.paragraph;
const newChildren: PMNode[] = [];
if (count > currentCount) {
for (let i = 0; i < currentCount; i++) {
newChildren.push(columnsNode.child(i));
}
for (let i = currentCount; i < count; i++) {
newChildren.push(columnType.create(null, paraType.create()));
}
} else {
for (let i = 0; i < count - 1; i++) {
newChildren.push(columnsNode.child(i));
}
let mergedContent = columnsNode.child(count - 1).content;
for (let j = count; j < currentCount; j++) {
const col = columnsNode.child(j);
const nonEmpty: PMNode[] = [];
col.content.forEach((child) => {
if (
child.type.name !== "paragraph" ||
child.content.size > 0
) {
nonEmpty.push(child);
}
});
if (nonEmpty.length > 0) {
mergedContent = mergedContent.append(
Fragment.from(nonEmpty),
);
}
}
newChildren.push(columnType.create(null, mergedContent));
}
const newLayout = defaultLayoutForCount(count);
const newNode = columnsNode.type.create(
{ ...columnsNode.attrs, layout: newLayout },
Fragment.from(newChildren),
);
tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode);
tr.setSelection(
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
);
return true;
},
setColumnsLayout:
(layout) =>
({ commands }) =>
commands.updateAttributes("columns", { layout }),
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("columnsFocus"),
props: {
decorations: (state) => {
const parent = findParentNode(
(node) => node.type.name === "columns",
)(state.selection);
if (!parent) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.node(
parent.pos,
parent.pos + parent.node.nodeSize,
{ class: "has-focus" },
),
]);
},
},
}),
];
},
});
@@ -0,0 +1,4 @@
export { Columns } from "./columns";
export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns";
export { Column } from "./column";
export type { ColumnOptions, ColumnAttributes } from "./column";
@@ -1,8 +1,10 @@
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
import CodeBlock from "@tiptap/extension-code-block";
import type { CodeBlockOptions } from '@tiptap/extension-code-block';
import CodeBlock from '@tiptap/extension-code-block';
import { Plugin, Selection, TextSelection } from '@tiptap/pm/state';
import { GapCursor } from '@tiptap/pm/gapcursor';
import { LowlightPlugin } from "./lowlight-plugin.js";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { LowlightPlugin } from './lowlight-plugin.js';
import { ReactNodeViewRenderer } from '@tiptap/react';
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
/**
@@ -12,20 +14,24 @@ export interface CodeBlockLowlightOptions extends CodeBlockOptions {
view: any;
}
const TAB_CHAR = "\u00A0\u00A0";
const TAB_CHAR = '\u00A0\u00A0';
/**
* This extension allows you to highlight code blocks with lowlight.
* @see https://tiptap.dev/api/nodes/code-block-lowlight
*/
export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
// Run ahead of Gapcursor (100) so the mermaid arrow-into-source plugin
// can intercept before gapcursor takes over.
priority: 101,
selectable: true,
isolating: true,
addOptions() {
return {
...this.parent?.(),
lowlight: {},
languageClassPrefix: "language-",
languageClassPrefix: 'language-',
exitOnTripleEnter: true,
exitOnArrowDown: true,
defaultLanguage: null,
@@ -35,22 +41,88 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
},
addKeyboardShortcuts() {
const isMermaid = (node: any) =>
node?.type === this.type && node.attrs.language === 'mermaid';
return {
...this.parent?.(),
Tab: () => {
if (this.editor.isActive("codeBlock")) {
this.editor
.chain()
.command(({ tr }) => {
tr.insertText(TAB_CHAR);
return true;
})
.run();
return true;
// Stop at the gap (or enter mermaid source) instead of jumping
// straight into the next block, so the user can place a cursor
// between two adjacent isolating blocks.
ArrowDown: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) return false;
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
const after = $from.after();
if (after >= doc.content.size) {
return editor.commands.exitCode();
}
const $after = doc.resolve(after);
const nodeAfter = $after.nodeAfter;
if (isMermaid(nodeAfter)) {
return editor.commands.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, after + 1));
return true;
});
}
if (
nodeAfter?.type.spec.isolating &&
!nodeAfter.type.spec.atom
) {
return editor.commands.command(({ tr }) => {
tr.setSelection(new GapCursor(tr.doc.resolve(after)));
return true;
});
}
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(tr.doc.resolve(after)));
return true;
});
},
"Mod-a": () => {
if (this.editor.isActive("codeBlock")) {
// Mirror of ArrowDown; upstream has no ArrowUp handler.
ArrowUp: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) return false;
if ($from.parentOffset !== 0) return false;
const before = $from.before();
if (before <= 0) return false;
const $before = doc.resolve(before);
const nodeBefore = $before.nodeBefore;
if (isMermaid(nodeBefore)) {
return editor.commands.command(({ tr }) => {
tr.setSelection(TextSelection.create(tr.doc, before - 1));
return true;
});
}
if (
nodeBefore?.type.spec.isolating &&
!nodeBefore.type.spec.atom
) {
return editor.commands.command(({ tr }) => {
tr.setSelection(new GapCursor(tr.doc.resolve(before)));
return true;
});
}
return false;
},
'Mod-a': () => {
if (this.editor.isActive('codeBlock')) {
const { state } = this.editor;
const { $from } = state.selection;
@@ -60,7 +132,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
for (depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type.name === "codeBlock") {
if (node.type.name === 'codeBlock') {
codeBlockNode = node;
codeBlockPos = $from.start(depth) - 1;
break;
@@ -96,6 +168,7 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
},
addProseMirrorPlugins() {
const codeBlockType = this.type;
return [
...(this.parent?.() || []),
LowlightPlugin({
@@ -103,6 +176,60 @@ export const CustomCodeBlock = CodeBlock.extend<CodeBlockLowlightOptions>({
lowlight: this.options.lowlight,
defaultLanguage: this.options.defaultLanguage,
}),
// Mermaid hides its <pre> when unselected, so the browser's native
// vertical caret movement skips past it. Land the cursor inside the
// source explicitly.
new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
return false;
}
const { state } = view;
const { selection } = state;
if (
!selection.empty ||
!(selection instanceof TextSelection)
) {
return false;
}
const { $from } = selection;
if ($from.depth === 0 || $from.parent.type === codeBlockType) {
return false;
}
const dir = event.key === 'ArrowUp' ? 'up' : 'down';
if (!view.endOfTextblock(dir)) return false;
const isMermaid = (node: any) =>
node?.type === codeBlockType && node.attrs.language === 'mermaid';
if (event.key === 'ArrowUp') {
if ($from.parentOffset !== 0) return false;
const beforePos = $from.before();
const prev = state.doc.resolve(beforePos).nodeBefore;
if (!isMermaid(prev)) return false;
const endPos = beforePos - 1;
view.dispatch(
state.tr.setSelection(
TextSelection.create(state.doc, endPos),
),
);
return true;
}
if ($from.parentOffset !== $from.parent.nodeSize - 2) return false;
const afterPos = $from.after();
const next = state.doc.resolve(afterPos).nodeAfter;
if (!isMermaid(next)) return false;
const startPos = afterPos + 1;
view.dispatch(
state.tr.setSelection(
TextSelection.create(state.doc, startPos),
),
);
return true;
},
},
}),
];
},
});
@@ -1,80 +1,111 @@
import { findChildren } from '@tiptap/core'
import type { Node as ProsemirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { findChildren } from '@tiptap/core';
import type { Node as ProsemirrorNode } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
// @ts-ignore
import highlight from 'highlight.js/lib/core'
import highlight from 'highlight.js/lib/core';
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
function parseNodes(
nodes: any[],
className: string[] = [],
): { text: string; classes: string[] }[] {
return nodes
.map(node => {
const classes = [...className, ...(node.properties ? node.properties.className : [])]
.map((node) => {
const classes = [
...className,
...(node.properties ? node.properties.className : []),
];
if (node.children) {
return parseNodes(node.children, classes)
return parseNodes(node.children, classes);
}
return {
text: node.value,
classes,
}
};
})
.flat()
.flat();
}
function getHighlightNodes(result: any) {
// `.value` for lowlight v1, `.children` for lowlight v2
return result.value || result.children || []
return result.value || result.children || [];
}
function registered(aliasOrLanguage: string) {
return Boolean(highlight.getLanguage(aliasOrLanguage))
return Boolean(highlight.getLanguage(aliasOrLanguage));
}
// Max characters to sample for auto-detection to avoid performance issues with large code blocks
const AUTO_DETECT_SAMPLE_SIZE = 3000;
function getDecorations({
doc,
name,
lowlight,
defaultLanguage,
}: {
doc: ProsemirrorNode
name: string
lowlight: any
defaultLanguage: string | null | undefined
doc: ProsemirrorNode;
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
const decorations: Decoration[] = []
const decorations: Decoration[] = [];
findChildren(doc, node => node.type.name === name).forEach(block => {
let from = block.pos + 1
const language = block.node.attrs.language || defaultLanguage
const languages = lowlight.listLanguages()
findChildren(doc, (node) => node.type.name === name).forEach((block) => {
let from = block.pos + 1;
const language = block.node.attrs.language || defaultLanguage;
const languages = lowlight.listLanguages();
const textContent = block.node.textContent;
const nodes =
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
let nodes;
if (
language &&
(languages.includes(language) ||
registered(language) ||
lowlight.registered?.(language))
) {
nodes = getHighlightNodes(lowlight.highlight(language, textContent));
} else {
// For auto-detection, sample a limited portion to detect the language,
// then highlight the full content with the detected language
const sample =
textContent.length > AUTO_DETECT_SAMPLE_SIZE
? textContent.slice(0, AUTO_DETECT_SAMPLE_SIZE)
: textContent;
const autoResult = lowlight.highlightAuto(sample);
const detectedLanguage = autoResult.data?.language;
if (detectedLanguage && textContent.length > AUTO_DETECT_SAMPLE_SIZE) {
nodes = getHighlightNodes(
lowlight.highlight(detectedLanguage, textContent),
);
} else {
nodes = getHighlightNodes(autoResult);
}
}
parseNodes(nodes).forEach(node => {
const to = from + node.text.length
parseNodes(nodes).forEach((node) => {
const to = from + node.text.length;
if (node.classes.length) {
const decoration = Decoration.inline(from, to, {
class: node.classes.join(' '),
})
});
decorations.push(decoration)
decorations.push(decoration);
}
from = to
})
})
from = to;
});
});
return DecorationSet.create(doc, decorations)
return DecorationSet.create(doc, decorations);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
function isFunction(param: any): param is Function {
return typeof param === 'function'
return typeof param === 'function';
}
export function LowlightPlugin({
@@ -82,12 +113,18 @@ export function LowlightPlugin({
lowlight,
defaultLanguage,
}: {
name: string
lowlight: any
defaultLanguage: string | null | undefined
name: string;
lowlight: any;
defaultLanguage: string | null | undefined;
}) {
if (!['highlight', 'highlightAuto', 'listLanguages'].every(api => isFunction(lowlight[api]))) {
throw Error('You should provide an instance of lowlight to use the code-block-lowlight extension')
if (
!['highlight', 'highlightAuto', 'listLanguages'].every((api) =>
isFunction(lowlight[api]),
)
) {
throw Error(
'You should provide an instance of lowlight to use the code-block-lowlight extension',
);
}
const lowlightPlugin: Plugin<any> = new Plugin({
@@ -102,10 +139,16 @@ export function LowlightPlugin({
defaultLanguage,
}),
apply: (transaction, decorationSet, oldState, newState) => {
const oldNodeName = oldState.selection.$head.parent.type.name
const newNodeName = newState.selection.$head.parent.type.name
const oldNodes = findChildren(oldState.doc, node => node.type.name === name)
const newNodes = findChildren(newState.doc, node => node.type.name === name)
const oldNodeName = oldState.selection.$head.parent.type.name;
const newNodeName = newState.selection.$head.parent.type.name;
const oldNodes = findChildren(
oldState.doc,
(node) => node.type.name === name,
);
const newNodes = findChildren(
newState.doc,
(node) => node.type.name === name,
);
if (
transaction.docChanged &&
@@ -117,23 +160,23 @@ export function LowlightPlugin({
// OR transaction has changes that completely encapsulte a node
// (for example, a transaction that affects the entire document).
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(step => {
transaction.steps.some((step) => {
// @ts-ignore
return (
// @ts-ignore
step.from !== undefined &&
// @ts-ignore
step.to !== undefined &&
oldNodes.some(node => {
oldNodes.some((node) => {
// @ts-ignore
return (
// @ts-ignore
node.pos >= step.from &&
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
)
);
})
)
);
}))
) {
return getDecorations({
@@ -141,19 +184,19 @@ export function LowlightPlugin({
name,
lowlight,
defaultLanguage,
})
});
}
return decorationSet.map(transaction.mapping, transaction.doc)
return decorationSet.map(transaction.mapping, transaction.doc);
},
},
props: {
decorations(state) {
return lowlightPlugin.getState(state)
return lowlightPlugin.getState(state);
},
},
})
});
return lowlightPlugin
}
return lowlightPlugin;
}
+234 -8
View File
@@ -1,15 +1,38 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
export type DrawioResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface DrawioOptions {
HTMLAttributes: Record<string, any>;
view: any;
resize: DrawioResizeOptions | false;
}
export interface DrawioAttributes {
src?: string;
title?: string;
alt?: string;
size?: number;
width?: string;
width?: number | string;
height?: number;
aspectRatio?: number;
align?: string;
attachmentId?: string;
}
@@ -18,6 +41,8 @@ declare module "@tiptap/core" {
interface Commands<ReturnType> {
drawio: {
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
setDrawioSize: (width: number, height: number) => ReturnType;
};
}
}
@@ -35,6 +60,7 @@ export const Drawio = Node.create<DrawioOptions>({
return {
HTMLAttributes: {},
view: null,
resize: false,
};
},
@@ -54,13 +80,38 @@ export const Drawio = Node.create<DrawioOptions>({
"data-title": attributes.title,
}),
},
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-alt"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-alt": attributes.alt,
}),
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("data-width"),
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: DrawioAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: DrawioAttributes) => ({
"data-height": attributes.height,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
@@ -68,6 +119,13 @@ export const Drawio = Node.create<DrawioOptions>({
"data-size": attributes.size,
}),
},
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: DrawioAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
@@ -99,13 +157,13 @@ export const Drawio = Node.create<DrawioOptions>({
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
HTMLAttributes,
),
[
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"],
alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
@@ -122,13 +180,181 @@ export const Drawio = Node.create<DrawioOptions>({
attrs: attrs,
});
},
setDrawioAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("drawio", { align }),
setDrawioSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("drawio", { width, height }),
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
const resize = this.options.resize;
return ReactNodeViewRenderer(this.options.view);
if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("img");
el.src = normalizeFileUrl(node.attrs.src);
el.alt = node.attrs.alt || node.attrs.title || "";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (
updatedNode.attrs.alt !== currentNode.attrs.alt ||
updatedNode.attrs.title !== currentNode.attrs.title
) {
el.alt =
updatedNode.attrs.alt || updatedNode.attrs.title || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+2 -2
View File
@@ -64,14 +64,14 @@ export const Embed = Node.create<EmbedOptions>({
}),
},
width: {
default: 640,
default: 800,
parseHTML: (element) => element.getAttribute("data-width"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: 480,
default: 600,
parseHTML: (element) => element.getAttribute("data-height"),
renderHTML: (attributes: EmbedAttributes) => ({
"data-height": attributes.height,
+235 -8
View File
@@ -1,15 +1,38 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "./resizable-nodeview";
import type { ResizableNodeViewDirection } from "./resizable-nodeview";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils";
export type ExcalidrawResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface ExcalidrawOptions {
HTMLAttributes: Record<string, any>;
view: any;
resize: ExcalidrawResizeOptions | false;
}
export interface ExcalidrawAttributes {
src?: string;
title?: string;
alt?: string;
size?: number;
width?: string;
width?: number | string;
height?: number;
aspectRatio?: number;
align?: string;
attachmentId?: string;
}
@@ -18,6 +41,8 @@ declare module "@tiptap/core" {
interface Commands<ReturnType> {
excalidraw: {
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
setExcalidrawSize: (width: number, height: number) => ReturnType;
};
}
}
@@ -35,8 +60,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
return {
HTMLAttributes: {},
view: null,
resize: false,
};
},
addAttributes() {
return {
src: {
@@ -53,13 +80,38 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
"data-title": attributes.title,
}),
},
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-alt"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-alt": attributes.alt,
}),
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("data-width"),
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-width": attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("data-height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-height": attributes.height,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
@@ -67,6 +119,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
"data-size": attributes.size,
}),
},
aspectRatio: {
default: null,
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
renderHTML: (attributes: ExcalidrawAttributes) => ({
"data-aspect-ratio": attributes.aspectRatio,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
@@ -98,13 +157,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes
HTMLAttributes,
),
[
"img",
{
src: HTMLAttributes["data-src"],
alt: HTMLAttributes["data-title"],
alt: HTMLAttributes["data-alt"] || HTMLAttributes["data-title"],
width: HTMLAttributes["data-width"],
},
],
@@ -121,13 +180,181 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
attrs: attrs,
});
},
setExcalidrawAlign:
(align) =>
({ commands }) =>
commands.updateAttributes("excalidraw", { align }),
setExcalidrawSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("excalidraw", { width, height }),
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
const resize = this.options.resize;
return ReactNodeViewRenderer(this.options.view);
if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("img");
el.src = normalizeFileUrl(node.attrs.src);
el.alt = node.attrs.alt || node.attrs.title || "";
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (
updatedNode.attrs.alt !== currentNode.attrs.alt ||
updatedNode.attrs.title !== currentNode.attrs.title
) {
el.alt =
updatedNode.attrs.alt || updatedNode.attrs.title || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
@@ -2,8 +2,8 @@ import TiptapHeading, {
HeadingOptions as TiptapHeadingOptions,
} from "@tiptap/extension-heading";
import { mergeAttributes } from "@tiptap/react";
import { Decoration, DecorationSet } from "prosemirror-view";
import { Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { Plugin } from "@tiptap/pm/state";
import { copyToClipboard } from "../utils";
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
@@ -40,6 +40,8 @@ const handleImageUpload =
);
const placeholderId = generateNodeId();
const width = imageDimensions?.width ?? undefined;
const height = imageDimensions?.height ?? undefined;
const aspectRatio = imageDimensions
? imageDimensions.width / imageDimensions.height
: undefined;
@@ -57,6 +59,8 @@ const handleImageUpload =
id: placeholderId,
name: file.name,
},
width,
height,
aspectRatio,
});
@@ -88,6 +92,8 @@ const handleImageUpload =
src: `/api/files/${attachment.id}/${attachment.fileName}`,
attachmentId: attachment.id,
size: attachment.fileSize,
width,
height,
aspectRatio,
});
+239 -11
View File
@@ -1,18 +1,42 @@
import Image from "@tiptap/extension-image";
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { mergeAttributes, Range } from "@tiptap/core";
import {
mergeAttributes,
Range,
} from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl } from "../media-utils";
export type ImageResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface ImageOptions extends DefaultImageOptions {
view: any;
resize: ImageResizeOptions | false;
}
export interface ImageAttributes {
src?: string;
alt?: string;
align?: string;
attachmentId?: string;
size?: number;
width?: number;
width?: number | string;
height?: number;
aspectRatio?: number;
placeholder?: {
id: string;
@@ -25,10 +49,11 @@ declare module "@tiptap/core" {
imageBlock: {
setImage: (attributes: ImageAttributes) => ReturnType;
setImageAt: (
attributes: ImageAttributes & { pos: number | Range }
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
};
}
}
@@ -46,6 +71,7 @@ export const TiptapImage = Image.extend<ImageOptions>({
return {
...this.parent?.(),
view: null,
resize: false,
};
},
@@ -59,12 +85,30 @@ export const TiptapImage = Image.extend<ImageOptions>({
}),
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("width"),
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ImageAttributes) => ({
width: attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: ImageAttributes) => ({
height: attributes.height,
}),
},
align: {
default: "center",
parseHTML: (element) => element.getAttribute("data-align"),
@@ -142,16 +186,200 @@ export const TiptapImage = Image.extend<ImageOptions>({
setImageWidth:
(width) =>
({ commands }) =>
commands.updateAttributes("image", {
width: `${Math.max(0, Math.min(100, width))}%`,
}),
commands.updateAttributes("image", { width }),
setImageSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("image", { width, height }),
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
const resize = this.options.resize;
return ReactNodeViewRenderer(this.options.view);
if (!resize || !resize.enabled) {
// Fallback to React node view (existing behavior)
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
// If no src yet (placeholder/uploading), use React view for loading UI
if (!HTMLAttributes.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
// When the node gets a src, return false from update to force rebuild
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
// Has src — use ResizableNodeView
const el = document.createElement("img");
Object.entries(HTMLAttributes).forEach(([key, value]) => {
if (value != null) {
switch (key) {
case "width":
case "height":
break;
default:
el.setAttribute(key, String(value));
break;
}
}
});
el.src = normalizeFileUrl(HTMLAttributes.src);
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
if (typeof node.attrs.width === "number" && node.attrs.width > 0) {
el.style.width = `${node.attrs.width}px`;
if (typeof node.attrs.height === "number" && node.attrs.height > 0) {
el.style.height = `${node.attrs.height}px`;
}
}
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
el.alt = updatedNode.attrs.alt || "";
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
// Update alignment on container
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
// Defer conversion until we can measure the container
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onload = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+225
View File
@@ -0,0 +1,225 @@
import { Extension } from '@tiptap/core';
import {
Plugin,
PluginKey,
type EditorState,
type Transaction,
} from '@tiptap/pm/state';
export type IndentOptions = {
types: string[];
min: number;
max: number;
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
indent: {
indent: () => ReturnType;
outdent: () => ReturnType;
};
}
}
// Containers whose descendants must never carry an `indent` attribute. These
// nodes own their own Tab semantics (list nesting, cell navigation, literal
// tab) and visually conflict with our indent padding, so paragraphs and
// headings inside them stay flat
const NON_INDENTABLE_ANCESTORS = new Set([
'listItem',
'taskItem',
'tableCell',
'tableHeader',
'codeBlock',
]);
const clampIndent = (value: number, min: number, max: number): number => {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, Math.trunc(value)));
};
const hasNonIndentableAncestor = (
doc: EditorState['doc'],
pos: number,
): boolean => {
const $pos = doc.resolve(pos);
for (let depth = $pos.depth; depth >= 0; depth--) {
if (NON_INDENTABLE_ANCESTORS.has($pos.node(depth).type.name)) {
return true;
}
}
return false;
};
export const Indent = Extension.create<IndentOptions>({
name: 'indent',
priority: 1000,
addOptions() {
return {
types: ['paragraph', 'heading'],
min: 0,
max: 8,
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
indent: {
default: this.options.min,
keepOnSplit: true,
parseHTML: (element) => {
const raw = element.getAttribute('data-indent');
if (raw === null) return this.options.min;
return clampIndent(
parseInt(raw, 10),
this.options.min,
this.options.max,
);
},
renderHTML: (attributes) => {
const value = attributes.indent;
if (value <= this.options.min) return {};
return { 'data-indent': String(value) };
},
},
},
},
];
},
addCommands() {
return {
indent:
() =>
({ state, tr, dispatch }) => {
return updateIndent(state, tr, dispatch, this.options, +1);
},
outdent:
() =>
({ state, tr, dispatch }) => {
return updateIndent(state, tr, dispatch, this.options, -1);
},
};
},
addKeyboardShortcuts() {
const isInIndentableBlock = (): boolean => {
const { $from } = this.editor.state.selection;
if (!this.options.types.includes($from.parent.type.name)) return false;
for (let depth = $from.depth - 1; depth >= 0; depth--) {
if (NON_INDENTABLE_ANCESTORS.has($from.node(depth).type.name)) {
return false;
}
}
return true;
};
return {
// Return the command's result so Tab falls through to the browser
// (moving focus out of the editor) once the user has reached max
// indent. Without this Tab stays trapped at max depth, failing
// WCAG 2.1.2.
Tab: () => {
if (!isInIndentableBlock()) return false;
return this.editor.commands.indent();
},
'Shift-Tab': () => {
if (!isInIndentableBlock()) return false;
return this.editor.commands.outdent();
},
Backspace: () => {
const { $from, empty } = this.editor.state.selection;
if (!empty) return false;
if ($from.parentOffset !== 0) return false;
if (!isInIndentableBlock()) return false;
if ($from.parent.attrs.indent <= this.options.min) return false;
this.editor.commands.outdent();
return true;
},
};
},
addProseMirrorPlugins() {
const types = new Set(this.options.types);
const min = this.options.min;
return [
new Plugin({
key: new PluginKey('indentNormalizer'),
appendTransaction: (transactions, _oldState, newState) => {
if (!transactions.some((tr) => tr.docChanged)) return null;
const tr = newState.tr;
let modified = false;
newState.doc.descendants((node, pos) => {
// Containers: descend so we can find paragraph/heading children.
if (!types.has(node.type.name)) return true;
if (node.attrs.indent <= min) return false;
if (hasNonIndentableAncestor(newState.doc, pos)) {
tr.setNodeMarkup(
pos,
undefined,
{ ...node.attrs, indent: min },
node.marks,
);
modified = true;
}
// paragraph/heading don't contain other paragraphs/headings —
// never descend into their inline content.
return false;
});
if (!modified) return null;
// Normalisation must not show up as a separate undo step;
// otherwise undo would re-introduce the illegal indent.
return tr.setMeta('addToHistory', false);
},
}),
];
},
});
function updateIndent(
state: EditorState,
tr: Transaction,
dispatch: ((tr: Transaction) => void) | undefined,
options: IndentOptions,
delta: number,
): boolean {
const { selection } = state;
const { from, to } = selection;
const types = new Set(options.types);
let updated = false;
state.doc.nodesBetween(from, to, (node, pos) => {
// Skip non-block nodes (text, inline atoms) up front.
if (!node.type.isBlock) return false;
// Don't descend into containers whose children must stay flat — handles
// multi-block selections that span across e.g. a list-item or table-cell.
if (NON_INDENTABLE_ANCESTORS.has(node.type.name)) return false;
if (!types.has(node.type.name)) return true;
const current = node.attrs.indent as number;
const next = clampIndent(current + delta, options.min, options.max);
if (next === current) return false;
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next });
updated = true;
return false;
});
if (!updated) return false;
if (dispatch) dispatch(tr);
return true;
}
+69 -44
View File
@@ -1,51 +1,21 @@
import { mergeAttributes } from "@tiptap/core";
import TiptapLink from "@tiptap/extension-link";
import { Plugin } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
import TiptapLink from '@tiptap/extension-link';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
export const LinkExtension = TiptapLink.extend({
inclusive: false,
parseHTML() {
return [
{
tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
getAttrs: (element) => {
if (
element
.getAttribute("href")
?.toLowerCase()
.startsWith("javascript:")
) {
return false;
}
return null;
},
addAttributes() {
return {
...this.parent?.(),
internal: {
default: false,
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-internal') === 'true',
renderHTML: (attributes) =>
attributes.internal ? { 'data-internal': 'true' } : {},
},
];
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.href?.toLowerCase().startsWith("javascript:")) {
return [
"a",
mergeAttributes(
this.options.HTMLAttributes,
{ ...HTMLAttributes, href: "" },
{ class: "link" },
),
0,
];
}
return [
"a",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: "link",
}),
0,
];
};
},
addProseMirrorPlugins() {
@@ -58,7 +28,7 @@ export const LinkExtension = TiptapLink.extend({
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
const { selection } = editor.state;
if (event.key === "Escape" && selection.empty !== true) {
if (event.key === 'Escape' && selection.empty !== true) {
editor.commands.focus(selection.to, { scrollIntoView: false });
}
@@ -66,6 +36,61 @@ export const LinkExtension = TiptapLink.extend({
},
},
}),
// Fix for Firefox: when the cursor is at a boundary of a link,
// Firefox's contenteditable inserts new text *inside* the <a> element.
// ProseMirror then rejects the mutation because inclusive is false,
// causing keystrokes to be silently swallowed. Firefox also does not
// fire handleTextInput in this state, so we intercept at handleKeyDown.
// This handles both:
// - right boundary: cursor just after a link (typing appends to link)
// - left boundary: cursor just before a link, e.g. at the start of a
// line (#1748), where Firefox places new text inside the link node
new Plugin({
key: new PluginKey('linkBoundaryInput'),
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
// Only handle single printable characters
if (event.key.length !== 1) return false;
// Don't handle modified keys (shortcuts) or composing (IME)
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.isComposing
)
return false;
const { state } = view;
const linkType = state.schema.marks.link;
if (!linkType) return false;
// Don't interfere if the user has explicitly set storedMarks
if (state.storedMarks !== null) return false;
const { from, to } = state.selection;
const $from = state.doc.resolve(from);
const nodeBefore = $from.nodeBefore;
const nodeAfter = $from.nodeAfter;
const linkBefore = nodeBefore && linkType.isInSet(nodeBefore.marks);
const linkAfter = nodeAfter && linkType.isInSet(nodeAfter.marks);
// If both sides have link marks we're in the middle — don't interfere
if (linkBefore && linkAfter) return false;
// Not at any link boundary — nothing to do
if (!linkBefore && !linkAfter) return false;
// We're at a link boundary (left or right).
// Prevent native input and insert text without the link mark.
event.preventDefault();
const tr = state.tr.insertText(event.key, from, to);
tr.removeMark(from, from + event.key.length, linkType);
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
});
@@ -5,18 +5,23 @@ import { mathInlineExtension } from "./math-inline.marked";
marked.use({
renderer: {
// @ts-ignore
list(body: string, isOrdered: boolean, start: number) {
if (isOrdered) {
const startAttr = start !== 1 ? ` start="${start}"` : "";
return `<ol ${startAttr}>\n${body}</ol>\n`;
list({ ordered, start, items }) {
let body = "";
for (const item of items) {
body += this.listitem(item);
}
const dataType = body.includes(`<input`) ? ' data-type="taskList"' : "";
if (ordered) {
const startAttr = start !== 1 ? ` start="${start}"` : "";
return `<ol${startAttr}>\n${body}</ol>\n`;
}
const isTaskList = items.some((item) => item.task);
const dataType = isTaskList ? ' data-type="taskList"' : "";
return `<ul${dataType}>\n${body}</ul>\n`;
},
// @ts-ignore
listitem({ text, raw, task: isTask, checked: isChecked }): string {
listitem({ tokens, task: isTask, checked: isChecked }) {
const text = this.parser.parse(tokens);
if (!isTask) {
return `<li>${text}</li>\n`;
}
@@ -32,6 +37,8 @@ marked.use({
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
});
marked.setOptions({ breaks: true });
export function markdownToHtml(
markdownInput: string,
): string | Promise<string> {
@@ -41,8 +48,5 @@ export function markdownToHtml(
.replace(YAML_FONT_MATTER_REGEX, "")
.trimStart();
return marked
.options({ breaks: true })
.parse(markdown)
.toString();
return marked.parse(markdown).toString();
}
@@ -5,6 +5,13 @@ import { getBasename } from './basename';
// CJS/ESM interop: .default exists in Vite, not in NestJS
const TurndownService = (_TurndownService as any).default || _TurndownService;
function sanitizeMdLinkText(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/([\[\]!])/g, '\\$1')
.replace(/[\r\n]+/g, ' ');
}
export function htmlToMarkdown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
@@ -21,9 +28,11 @@ export function htmlToMarkdown(html: string): string {
callout,
preserveDetail,
listParagraph,
orderedListItem,
mathInline,
mathBlock,
iframeEmbed,
image,
video,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
@@ -41,6 +50,40 @@ function listParagraph(turndownService: _TurndownService) {
});
}
function orderedListItem(turndownService: _TurndownService) {
turndownService.addRule('orderedListItem', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
},
replacement: (content: string, node: HTMLInputElement, options: any) => {
const parent = node.parentNode as HTMLElement;
if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') {
return content;
}
content = content
.replace(/^\n+/, '')
.replace(/\n+$/, '\n')
.replace(/\n/gm, '\n ');
let prefix: string;
if (parent.nodeName === 'OL') {
const start = parseInt(parent.getAttribute('start') || '1', 10);
const index = Array.prototype.indexOf.call(parent.children, node);
prefix = `${start + index}. `;
} else {
prefix = `${options.bulletListMarker} `;
}
return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
);
},
});
}
function callout(turndownService: _TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
@@ -63,25 +106,17 @@ function taskList(turndownService: _TurndownService) {
node.parentNode.nodeName === 'UL'
);
},
replacement: function (content: string, node: HTMLInputElement) {
const checkbox = node.querySelector(
'input[type="checkbox"]',
) as HTMLInputElement;
const isChecked = checkbox.checked;
replacement: function (_content: string, node: HTMLInputElement) {
const isChecked = node.getAttribute('data-checked') === 'true';
const div = node.querySelector('div');
const text = div ? div.textContent.trim() : node.textContent.trim();
// Process content like regular list items
content = content
.replace(/^\n+/, '') // remove leading newlines
.replace(/\n+$/, '\n') // replace trailing newlines with just a single one
.replace(/\n/gm, '\n '); // indent nested content with 2 spaces
// Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return (
prefix +
content +
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
text +
(node.nextSibling && !/\n$/.test(text) ? '\n' : '')
);
},
});
@@ -154,6 +189,20 @@ function iframeEmbed(turndownService: _TurndownService) {
});
}
function image(turndownService: _TurndownService) {
turndownService.addRule('image', {
filter: 'img',
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
if (!src) return '';
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
const title = node.getAttribute('title') || '';
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
return '![' + alt + '](' + src + titlePart + ')';
},
});
}
function video(turndownService: _TurndownService) {
turndownService.addRule('video', {
filter: function (node: HTMLInputElement) {
@@ -161,7 +210,10 @@ function video(turndownService: _TurndownService) {
},
replacement: function (_content: string, node: HTMLInputElement) {
const src = node.getAttribute('src') || '';
const name = getBasename(src) || src;
const ariaLabel = node.getAttribute('aria-label');
const name = sanitizeMdLinkText(
ariaLabel || getBasename(src) || src,
);
return '[' + name + '](' + src + ')';
},
});
@@ -24,6 +24,7 @@ export const MathBlock = Node.create({
name: "mathBlock",
group: "block",
atom: true,
isolating: true,
addOptions() {
return {
@@ -1,5 +1,12 @@
import { Editor } from "@tiptap/core";
export function normalizeFileUrl(src: string): string {
if (src && src.startsWith("/files/")) {
return "/api" + src;
}
return src || "";
}
export type UploadFn = (
file: File,
editor: Editor,
@@ -0,0 +1 @@
export * from "./page-break";
@@ -0,0 +1,60 @@
import { mergeAttributes, Node } from "@tiptap/core";
export interface PageBreakOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pageBreak: {
setPageBreak: () => ReturnType;
};
}
}
export const PageBreak = Node.create<PageBreakOptions>({
name: "pageBreak",
group: "block",
atom: true,
selectable: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name, class: "page-break" },
this.options.HTMLAttributes,
HTMLAttributes,
),
];
},
addCommands() {
return {
setPageBreak:
() =>
({ chain }) =>
chain()
.insertContent({ type: this.name })
.focus()
.run(),
};
},
});
+2
View File
@@ -0,0 +1,2 @@
export { TiptapPdf } from "./pdf";
export * from "./pdf-upload";
@@ -0,0 +1,123 @@
import { MediaUploadOptions, UploadFn } from "../media-utils";
import { IAttachment } from "../types";
import { generateNodeId } from "../utils";
import { Node } from "@tiptap/pm/model";
import { Command } from "@tiptap/core";
const findPdfNodeByPlaceholderId = (
doc: Node,
placeholderId: string,
): { node: Node; pos: number } | null => {
let result: { node: Node; pos: number } | null = null;
doc.descendants((node, pos) => {
if (result) return false;
if (
node.type.name === "pdf" &&
node.attrs.placeholder?.id === placeholderId
) {
result = { node, pos };
return false;
}
return true;
});
return result;
};
const handlePdfUpload =
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
async (file, editor, pos, pageId) => {
const validated = validateFn?.(file);
// @ts-ignore
if (!validated) return;
const placeholderId = generateNodeId();
let placeholderInserted = false;
const insertPlaceholder = (): Command => {
return ({ tr, state }) => {
const initialPlaceholderNode = state.schema.nodes.pdf?.create({
placeholder: {
id: placeholderId,
name: file.name,
},
});
if (!initialPlaceholderNode) return false;
const { parent } = tr.doc.resolve(pos);
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
if (isEmptyTextBlock) {
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
} else {
tr.insert(pos, initialPlaceholderNode);
}
return true;
};
};
const replacePlaceholderWithPdf = (attachment: IAttachment): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null || !attachment) return;
tr.setNodeMarkup(currentPos, undefined, {
src: `/api/files/${attachment.id}/${attachment.fileName}`,
name: attachment.fileName,
attachmentId: attachment.id,
size: attachment.fileSize,
});
return true;
};
};
const removePlaceholder = (): Command => {
return ({ tr }) => {
const { pos: currentPos = null } =
findPdfNodeByPlaceholderId(tr.doc, placeholderId) || {};
if (currentPos === null) return false;
tr.delete(currentPos, currentPos + 2);
return true;
};
};
const insertPlaceholderTimeout = setTimeout(() => {
editor.commands.command(insertPlaceholder());
placeholderInserted = true;
}, 250);
try {
const attachment: IAttachment = await onUpload(file, pageId);
clearTimeout(insertPlaceholderTimeout);
if (placeholderInserted) {
setTimeout(() => {
editor.commands.command(replacePlaceholderWithPdf(attachment));
}, 100);
} else {
editor
.chain()
.command(insertPlaceholder())
.command(replacePlaceholderWithPdf(attachment))
.run();
}
} catch (error) {
clearTimeout(insertPlaceholderTimeout);
editor.commands.command(removePlaceholder());
}
};
export { handlePdfUpload };
+156
View File
@@ -0,0 +1,156 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Node, mergeAttributes } from "@tiptap/core";
import { sanitizeUrl, isInternalFileUrl } from "../utils";
export type PdfOptions = {
view: any;
HTMLAttributes: Record<string, any>;
};
export type PdfAttributes = {
src?: string;
name?: string;
attachmentId?: string;
size?: number;
width?: number;
height?: number;
placeholder?: {
id: string;
name: string;
};
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
pdfBlock: {
setPdf: (attributes: PdfAttributes) => ReturnType;
};
}
}
export const TiptapPdf = Node.create<PdfOptions>({
name: "pdf",
group: "block",
isolating: true,
atom: true,
defining: true,
draggable: true,
addOptions() {
return {
view: null,
HTMLAttributes: {},
};
},
addAttributes() {
return {
src: {
default: "",
parseHTML: (element) => {
const src = element.getAttribute("src");
const sanitized = sanitizeUrl(src);
return isInternalFileUrl(sanitized) ? sanitized : "";
},
renderHTML: (attributes) => ({
src: isInternalFileUrl(attributes.src) ? sanitizeUrl(attributes.src) : "",
}),
},
name: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-name"),
renderHTML: (attributes: PdfAttributes) => ({
"data-name": attributes.name,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
renderHTML: (attributes: PdfAttributes) => ({
"data-attachment-id": attributes.attachmentId,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
renderHTML: (attributes: PdfAttributes) => ({
"data-size": attributes.size,
}),
},
width: {
default: 800,
parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: PdfAttributes) => ({
width: attributes.width,
}),
},
height: {
default: 600,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: PdfAttributes) => ({
height: attributes.height,
}),
},
placeholder: {
default: null,
rendered: false,
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(
{ "data-type": this.name },
this.options.HTMLAttributes,
HTMLAttributes,
),
[
"iframe",
{
src: isInternalFileUrl(HTMLAttributes.src) ? sanitizeUrl(HTMLAttributes.src) : "",
width: HTMLAttributes.width || 800,
height: HTMLAttributes.height || 600,
},
],
];
},
addCommands() {
return {
setPdf:
(attrs: PdfAttributes) =>
({ commands }) => {
return commands.insertContent({
type: "pdf",
attrs,
});
},
};
},
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
});
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
import { Node } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
export type StatusStorage = {
autoOpen: boolean;
};
declare module '@tiptap/core' {
interface Commands<ReturnType> {
status: {
setStatus: (attributes?: { text?: string; color?: string }) => ReturnType;
};
}
interface Storage {
status: StatusStorage;
}
}
export type StatusColor =
| 'gray'
| 'blue'
| 'green'
| 'yellow'
| 'red'
| 'purple';
export interface StatusOption {
HTMLAttributes: Record<string, any>;
view: any;
}
export const Status = Node.create<StatusOption, StatusStorage>({
name: 'status',
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: true,
addOptions() {
return {
HTMLAttributes: {},
view: null,
};
},
addStorage() {
return {
autoOpen: false,
};
},
addAttributes() {
return {
text: {
default: '',
parseHTML: (element: HTMLElement) => element.textContent || '',
},
color: {
default: 'gray',
parseHTML: (element: HTMLElement) =>
element.getAttribute('data-color') || 'gray',
},
};
},
parseHTML() {
return [
{
tag: `span[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
{
'data-type': this.name,
'data-color': HTMLAttributes.color,
},
HTMLAttributes.text,
];
},
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
},
addCommands() {
return {
setStatus:
(attributes) =>
({ commands }) => {
this.storage.autoOpen = true;
return commands.insertContent({
type: this.name,
attrs: {
text: attributes?.text ?? '',
color: attributes?.color || 'gray',
},
});
},
};
},
});
@@ -29,6 +29,7 @@ export const Subpages = Node.create<SubpagesOptions>({
group: "block",
atom: true,
draggable: true,
isolating: true,
parseHTML() {
return [
+1 -1
View File
@@ -3,7 +3,7 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
@@ -1,76 +0,0 @@
import { DraggingDOMs } from "./utils";
const EDGE_THRESHOLD = 100;
const SCROLL_SPEED = 10;
export class AutoScrollController {
private _autoScrollInterval?: number;
checkYAutoScroll = (clientY: number) => {
const scrollContainer = document.documentElement;
if (clientY < 0 + EDGE_THRESHOLD) {
this._startYAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
} else if (clientY > window.innerHeight - EDGE_THRESHOLD) {
this._startYAutoScroll(scrollContainer!, SCROLL_SPEED);
} else {
this._stopYAutoScroll();
}
}
checkXAutoScroll = (clientX: number, draggingDOMs: DraggingDOMs) => {
const table = draggingDOMs?.table;
if (!table) return;
const scrollContainer = table.closest<HTMLElement>('.tableWrapper');
const editorRect = scrollContainer.getBoundingClientRect();
if (!scrollContainer) return;
if (clientX < editorRect.left + EDGE_THRESHOLD) {
this._startXAutoScroll(scrollContainer!, -1 * SCROLL_SPEED);
} else if (clientX > editorRect.right - EDGE_THRESHOLD) {
this._startXAutoScroll(scrollContainer!, SCROLL_SPEED);
} else {
this._stopXAutoScroll();
}
}
stop = () => {
this._stopXAutoScroll();
this._stopYAutoScroll();
}
private _startXAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
this._autoScrollInterval = window.setInterval(() => {
scrollContainer.scrollLeft += speed;
}, 16);
}
private _stopXAutoScroll = () => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = undefined;
}
}
private _startYAutoScroll = (scrollContainer: HTMLElement, speed: number) => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
}
this._autoScrollInterval = window.setInterval(() => {
scrollContainer.scrollTop += speed;
}, 16);
}
private _stopYAutoScroll = () => {
if (this._autoScrollInterval) {
clearInterval(this._autoScrollInterval);
this._autoScrollInterval = undefined;
}
}
}
@@ -1,316 +1,393 @@
import { Editor, Extension } from "@tiptap/core";
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
import { PluginKey, Plugin, PluginSpec, TextSelection, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { EditorProps, EditorView } from "@tiptap/pm/view";
import { columnResizingPluginKey } from "@tiptap/pm/tables";
import { cellAround } from "@tiptap/pm/tables";
import {
cellInfoFromResolvedCell,
DraggingDOMs,
getDndRelatedDOMs,
getHoveringCell,
HoveringCellInfo,
} from "./utils";
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
import { findTable } from "../utils/query";
import { moveColumn, moveRow } from "../utils";
import { PreviewController } from "./preview/preview-controller";
import { DropIndicatorController } from "./preview/drop-indicator-controller";
import { DragHandleController } from "./handle/drag-handle-controller";
import { EmptyImageController } from "./handle/empty-image-controller";
import { AutoScrollController } from "./auto-scroll-controller";
export const TableDndKey = new PluginKey("table-drag-and-drop");
export interface TableHandleState {
hoveringCell: HoveringCellInfo | null;
tableNode: ProseMirrorNode | null;
tablePos: number | null;
dragging: { orientation: "col" | "row"; index: number } | null;
frozen: boolean;
}
class TableDragHandlePluginSpec implements PluginSpec<void> {
const INITIAL_STATE: TableHandleState = {
hoveringCell: null,
tableNode: null,
tablePos: null,
dragging: null,
frozen: false,
};
export const TableDndKey = new PluginKey<TableHandleState>("table-handles");
class TableHandlePluginSpec implements PluginSpec<TableHandleState> {
key = TableDndKey;
props: EditorProps<Plugin<void>>;
props: EditorProps<Plugin<TableHandleState>>;
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
private _hoveringCell?: HoveringCellInfo;
private _disposables: (() => void)[] = [];
private _draggingCoords: { x: number; y: number } = { x: 0, y: 0 };
private _dragging = false;
private _draggingDirection: "col" | "row" = "col";
private _draggingIndex = -1;
private _droppingIndex = -1;
private _draggingDOMs?: DraggingDOMs | undefined;
private _startCoords: { x: number; y: number } = { x: 0, y: 0 };
private _previewController: PreviewController;
private _dropIndicatorController: DropIndicatorController;
private _dragHandleController: DragHandleController;
private _emptyImageController: EmptyImageController;
private _autoScrollController: AutoScrollController;
private _draggingDOMs?: DraggingDOMs;
private _startCoords = { x: 0, y: 0 };
private _dragging = false;
state = {
init: (): TableHandleState => INITIAL_STATE,
apply: (tr: Transaction, prev: TableHandleState): TableHandleState => {
const meta = tr.getMeta(TableDndKey) as Partial<TableHandleState> | null;
if (!meta) return prev;
let changed = false;
for (const key in meta) {
if (!Object.is(prev[key as keyof TableHandleState], meta[key as keyof TableHandleState])) {
changed = true;
break;
}
}
return changed ? { ...prev, ...meta } : prev;
},
};
constructor(public editor: Editor) {
this.props = {
handleDOMEvents: {
pointerover: this._pointerOver,
pointermove: this._pointerMove,
// Force-unfreeze on any pointerdown that lands on the editor.
// Mantine's `Menu.onClose` doesn't always fire on outside click
// (the dropdown vanishes visually but the callback is skipped),
// which would otherwise leave `frozen=true` permanently.
pointerdown: this._pointerDown,
},
};
this._dragHandleController = new DragHandleController();
this._colDragHandle = this._dragHandleController.colDragHandle;
this._rowDragHandle = this._dragHandleController.rowDragHandle;
this._previewController = new PreviewController();
this._dropIndicatorController = new DropIndicatorController();
this._emptyImageController = new EmptyImageController();
this._autoScrollController = new AutoScrollController();
this._bindDragEvents();
}
view = () => {
const wrapper = this.editor.options.element;
//@ts-ignore
wrapper.appendChild(this._colDragHandle);
//@ts-ignore
wrapper.appendChild(this._rowDragHandle);
//@ts-ignore
// @ts-ignore
wrapper.appendChild(this._previewController.previewRoot);
//@ts-ignore
// @ts-ignore
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
// Track the cursor cell so handles follow keyboard nav and clicks too.
this.editor.on("selectionUpdate", this._onSelectionUpdate);
this._disposables.push(() =>
this.editor.off("selectionUpdate", this._onSelectionUpdate),
);
return {
update: this.update,
destroy: this.destroy,
};
};
update = () => {};
destroy = () => {
if (!this.editor.isDestroyed) return;
this._dragHandleController.destroy();
this._emptyImageController.destroy();
this._previewController.destroy();
this._dropIndicatorController.destroy();
this._autoScrollController.stop();
this._disposables.forEach((disposable) => disposable());
this._disposables.forEach((d) => d());
};
private _pointerOver = (view: EditorView, event: PointerEvent) => {
if (this._dragging) return;
private _pointerDown = (view: EditorView, _event: PointerEvent): boolean => {
const current = TableDndKey.getState(view.state);
if (current?.frozen) this.editor.commands.unfreezeHandles();
return false;
};
private _pointerMove = (view: EditorView, event: PointerEvent) => {
const current = TableDndKey.getState(view.state);
if (current?.frozen || current?.dragging) return;
const resizeState = columnResizingPluginKey.getState(view.state);
if (resizeState?.dragging) return;
// Don't show drag handles in readonly mode
if (!this.editor.isEditable) {
this._dragHandleController.hide();
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
return;
}
const hoveringCell = getHoveringCell(view, event);
this._hoveringCell = hoveringCell;
if (!hoveringCell) {
this._dragHandleController.hide();
} else {
this._dragHandleController.show(this.editor, hoveringCell);
if (hoveringCell) {
if (current?.hoveringCell?.cellPos === hoveringCell.cellPos) return;
this._hoveringCell = hoveringCell;
const $cell = view.state.doc.resolve(hoveringCell.cellPos);
const tableInfo = findTable($cell);
this._dispatchMeta({
hoveringCell,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
// Pointer isn't over a cell but may be transiting toward a handle that
// floats outside the cell — fall back to the selection's cell so the
// handles stay visible.
const $cellPos = cellAround(view.state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
return;
}
this._hoveringCell = undefined;
if (current?.hoveringCell == null && current?.tableNode == null && current?.tablePos == null) return;
this._dispatchMeta({ hoveringCell: null, tableNode: null, tablePos: null });
};
private _onDragColStart = (event: DragEvent) => {
this._onDragStart(event, "col");
private _onSelectionUpdate = () => {
if (!this.editor.isEditable) return;
const current = TableDndKey.getState(this.editor.state);
if (current?.frozen || current?.dragging) return;
const $cellPos = cellAround(this.editor.state.selection.$head);
if (!$cellPos) return;
const cellInfo = cellInfoFromResolvedCell($cellPos);
if (current?.hoveringCell?.cellPos === cellInfo.cellPos) return;
this._hoveringCell = cellInfo;
const tableInfo = findTable($cellPos);
this._dispatchMeta({
hoveringCell: cellInfo,
tableNode: tableInfo?.node ?? null,
tablePos: tableInfo?.pos ?? null,
});
};
private _onDraggingCol = (event: DragEvent) => {
private _dispatchMeta = (patch: Partial<TableHandleState>) => {
const tr = this.editor.state.tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
};
// ---- Public API for the React handle layer ----
// Returns true if the drag was set up successfully.
startDragFromHandle = (
orientation: "col" | "row",
clientX: number,
clientY: number,
): boolean => {
if (!this._hoveringCell) return false;
this._dragging = true;
this._draggingDirection = orientation;
this._startCoords = { x: clientX, y: clientY };
const draggingIndex =
(orientation === "col"
? this._hoveringCell.colIndex
: this._hoveringCell.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell.cellPos,
draggingIndex,
orientation,
);
if (!relatedDoms) {
this._dragging = false;
return false;
}
this._draggingDOMs = relatedDoms;
this._previewController.onDragStart(relatedDoms, draggingIndex, orientation);
this._dropIndicatorController.onDragStart(relatedDoms, orientation);
// Park the selection inside the dragged cell unless it's already in the
// same table. PM auto-maps `selection.from` through concurrent remote
// transactions, so commitDrop can resolve the table even if the doc
// shifted mid-drag — same trick the pre-pragmatic-dnd implementation
// relied on.
const state = this.editor.state;
const currentTable = findTable(state.selection.$from);
const hoverTable = (() => {
try {
return findTable(state.doc.resolve(this._hoveringCell.cellPos));
} catch {
return undefined;
}
})();
const tr = state.tr;
if (
hoverTable &&
(!currentTable || currentTable.pos !== hoverTable.pos)
) {
try {
const $inside = state.doc.resolve(this._hoveringCell.cellPos + 1);
tr.setSelection(TextSelection.near($inside, 1));
} catch {}
}
tr.setMeta(TableDndKey, {
dragging: { orientation, index: draggingIndex },
});
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
return true;
};
updateDragPosition = (clientX: number, clientY: number) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
if (!draggingDOMs || !this._dragging) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"col",
);
if (this._draggingDirection === "col") {
this._previewController.onDragging(
draggingDOMs,
clientX,
clientY,
"col",
);
const direction = this._startCoords.x > clientX ? "left" : "right";
const dragOverColumn = getDragOverColumn(draggingDOMs.table, clientX);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
return;
}
this._autoScrollController.checkXAutoScroll(event.clientX, draggingDOMs);
const direction =
this._startCoords.x > this._draggingCoords.x ? "left" : "right";
const dragOverColumn = getDragOverColumn(
draggingDOMs.table,
this._draggingCoords.x,
);
if (!dragOverColumn) return;
const [col, index] = dragOverColumn;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(col, direction, "col");
};
private _onDragRowStart = (event: DragEvent) => {
this._onDragStart(event, "row");
};
private _onDraggingRow = (event: DragEvent) => {
const draggingDOMs = this._draggingDOMs;
if (!draggingDOMs) return;
this._draggingCoords = { x: event.clientX, y: event.clientY };
this._previewController.onDragging(
draggingDOMs,
this._draggingCoords.x,
this._draggingCoords.y,
"row",
);
this._autoScrollController.checkYAutoScroll(event.clientY);
const direction =
this._startCoords.y > this._draggingCoords.y ? "up" : "down";
const dragOverRow = getDragOverRow(
draggingDOMs.table,
this._draggingCoords.y,
);
this._previewController.onDragging(draggingDOMs, clientX, clientY, "row");
const direction = this._startCoords.y > clientY ? "up" : "down";
const dragOverRow = getDragOverRow(draggingDOMs.table, clientY);
if (!dragOverRow) return;
const [row, index] = dragOverRow;
this._droppingIndex = index;
this._dropIndicatorController.onDragging(row, direction, "row");
};
private _onDragEnd = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._autoScrollController.stop();
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
};
private _bindDragEvents = () => {
this._colDragHandle.addEventListener("dragstart", this._onDragColStart);
this._disposables.push(() => {
this._colDragHandle.removeEventListener(
"dragstart",
this._onDragColStart,
);
});
this._colDragHandle.addEventListener("dragend", this._onDragEnd);
this._disposables.push(() => {
this._colDragHandle.removeEventListener("dragend", this._onDragEnd);
});
this._rowDragHandle.addEventListener("dragstart", this._onDragRowStart);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener(
"dragstart",
this._onDragRowStart,
);
});
this._rowDragHandle.addEventListener("dragend", this._onDragEnd);
this._disposables.push(() => {
this._rowDragHandle.removeEventListener("dragend", this._onDragEnd);
});
const ownerDocument = this.editor.view.dom?.ownerDocument;
if (ownerDocument) {
// To make `drop` event work, we need to prevent the default behavior of the
// `dragover` event for drop zone. Here we set the whole document as the
// drop zone so that even the mouse moves outside the editor, the `drop`
// event will still be triggered.
ownerDocument.addEventListener("drop", this._onDrop);
ownerDocument.addEventListener("dragover", this._onDrag);
this._disposables.push(() => {
ownerDocument.removeEventListener("drop", this._onDrop);
ownerDocument.removeEventListener("dragover", this._onDrag);
});
}
};
private _onDragStart = (event: DragEvent, type: "col" | "row") => {
const dataTransfer = event.dataTransfer;
if (dataTransfer) {
dataTransfer.effectAllowed = "move";
this._emptyImageController.hideDragImage(dataTransfer);
}
this._dragging = true;
this._draggingDirection = type;
this._startCoords = { x: event.clientX, y: event.clientY };
const draggingIndex =
(type === "col"
? this._hoveringCell?.colIndex
: this._hoveringCell?.rowIndex) ?? 0;
this._draggingIndex = draggingIndex;
const relatedDoms = getDndRelatedDOMs(
this.editor.view,
this._hoveringCell?.cellPos,
draggingIndex,
type,
);
this._draggingDOMs = relatedDoms;
const index =
type === "col"
? this._hoveringCell?.colIndex
: this._hoveringCell?.rowIndex;
this._previewController.onDragStart(relatedDoms, index, type);
this._dropIndicatorController.onDragStart(relatedDoms, type);
};
private _onDrag = (event: DragEvent) => {
event.preventDefault();
if (!this._dragging) return;
if (this._draggingDirection === "col") {
this._onDraggingCol(event);
} else {
this._onDraggingRow(event);
}
};
private _onDrop = () => {
commitDrop = () => {
if (!this._dragging) return;
const direction = this._draggingDirection;
const from = this._draggingIndex;
const to = this._droppingIndex;
if (from < 0 || to < 0 || from === to) return;
// Use the live (auto-mapped) selection as the table anchor — PM has
// already mapped it through any concurrent remote transactions, so
// it's safe to resolve even if the doc shifted mid-drag.
const tr = this.editor.state.tr;
const pos = this.editor.state.selection.from;
if (direction === "col") {
const canMove = moveColumn({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
});
if (canMove) {
if (moveColumn({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
return;
}
if (direction === "row") {
const canMove = moveRow({
tr,
originIndex: from,
targetIndex: to,
select: true,
pos,
});
if (canMove) {
this.editor.view.dispatch(tr);
}
return;
if (moveRow({ tr, originIndex: from, targetIndex: to, select: true, pos })) {
this.editor.view.dispatch(tr);
}
};
endDrag = () => {
this._dragging = false;
this._draggingIndex = -1;
this._droppingIndex = -1;
this._startCoords = { x: 0, y: 0 };
this._draggingDOMs = undefined;
this._dropIndicatorController.onDragEnd();
this._previewController.onDragEnd();
this._dispatchMeta({ dragging: null });
};
}
export type { TableHandlePluginSpec };
// Resolve via plugin key, not a module singleton — survives StrictMode / HMR.
export function getTableHandlePluginSpec(
editor: Editor,
): TableHandlePluginSpec | null {
const plugin = TableDndKey.get(editor.state);
if (!plugin) return null;
return plugin.spec as unknown as TableHandlePluginSpec;
}
export const TableDndExtension = Extension.create({
name: "table-drag-and-drop",
addProseMirrorPlugins() {
const editor = this.editor;
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor);
const dragHandlePlugin = new Plugin(dragHandlePluginSpec);
return [dragHandlePlugin];
const spec = new TableHandlePluginSpec(editor);
return [new Plugin(spec)];
},
});
export const TableHandleCommandsExtension = Extension.create({
name: "table-handle-commands",
addCommands() {
return {
freezeHandles:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(TableDndKey, { frozen: true });
tr.setMeta("addToHistory", false);
}
return true;
},
unfreezeHandles:
() =>
({ tr, state, dispatch }) => {
if (dispatch) {
// Re-sync `hoveringCell` to the cursor's cell as we unfreeze:
// `selectionUpdate` was gated while frozen, so the stored
// hoveringCell may be stale.
const patch: Partial<TableHandleState> = { frozen: false };
const $cellPos = cellAround(state.selection.$head);
if ($cellPos) {
const cellInfo = cellInfoFromResolvedCell($cellPos);
const tableInfo = findTable($cellPos);
patch.hoveringCell = cellInfo;
patch.tableNode = tableInfo?.node ?? null;
patch.tablePos = tableInfo?.pos ?? null;
} else {
patch.hoveringCell = null;
patch.tableNode = null;
patch.tablePos = null;
}
tr.setMeta(TableDndKey, patch);
tr.setMeta("addToHistory", false);
}
return true;
},
};
},
});
declare module "@tiptap/core" {
interface Commands<ReturnType> {
tableHandleCommands: {
freezeHandles: () => ReturnType;
unfreezeHandles: () => ReturnType;
};
}
}
@@ -1,105 +0,0 @@
import { Editor } from "@tiptap/core";
import { HoveringCellInfo } from "../utils";
import { computePosition, offset } from "@floating-ui/dom";
export class DragHandleController {
private _colDragHandle: HTMLElement;
private _rowDragHandle: HTMLElement;
constructor() {
this._colDragHandle = this._createDragHandleDom('col');
this._rowDragHandle = this._createDragHandleDom('row');
}
get colDragHandle() {
return this._colDragHandle;
}
get rowDragHandle() {
return this._rowDragHandle;
}
show = (editor: Editor, hoveringCell: HoveringCellInfo) => {
this._showColDragHandle(editor, hoveringCell);
this._showRowDragHandle(editor, hoveringCell);
}
hide = () => {
Object.assign(this._colDragHandle.style, {
display: 'none',
left: '-999px',
top: '-999px',
});
Object.assign(this._rowDragHandle.style, {
display: 'none',
left: '-999px',
top: '-999px',
});
}
destroy = () => {
this._colDragHandle.remove()
this._rowDragHandle.remove()
}
private _createDragHandleDom = (type: 'col' | 'row') => {
const dragHandle = document.createElement('div')
dragHandle.classList.add('drag-handle')
dragHandle.setAttribute('draggable', 'true')
dragHandle.setAttribute('data-direction', type === 'col' ? 'horizontal' : 'vertical')
dragHandle.setAttribute('data-drag-handle', '')
Object.assign(dragHandle.style, {
position: 'absolute',
top: '-999px',
left: '-999px',
display: 'none',
})
return dragHandle;
}
private _showColDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
const referenceCell = editor.view.nodeDOM(hoveringCell.colFirstCellPos);
if (!referenceCell) return;
const yOffset = -1 * parseInt(getComputedStyle(this._colDragHandle).height) / 2;
computePosition(
referenceCell as HTMLElement,
this._colDragHandle,
{
placement: 'top',
middleware: [offset(yOffset)]
}
)
.then(({ x, y }) => {
Object.assign(this._colDragHandle.style, {
display: 'block',
top: `${y}px`,
left: `${x}px`,
});
})
}
private _showRowDragHandle(editor: Editor, hoveringCell: HoveringCellInfo) {
const referenceCell = editor.view.nodeDOM(hoveringCell.rowFirstCellPos);
if (!referenceCell) return;
const xOffset = -1 * parseInt(getComputedStyle(this._rowDragHandle).width) / 2;
computePosition(
referenceCell as HTMLElement,
this._rowDragHandle,
{
middleware: [offset(xOffset)],
placement: 'left'
}
)
.then(({ x, y}) => {
Object.assign(this._rowDragHandle.style, {
display: 'block',
top: `${y}px`,
left: `${x}px`,
});
})
}
}
@@ -1,21 +0,0 @@
export class EmptyImageController {
private _emptyImage: HTMLImageElement;
constructor() {
this._emptyImage = new Image(1, 1);
this._emptyImage.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
get emptyImage() {
return this._emptyImage;
}
hideDragImage = (dataTransfer: DataTransfer) => {
dataTransfer.effectAllowed = 'move';
dataTransfer.setDragImage(this._emptyImage, 0, 0);
}
destroy = () => {
this._emptyImage.remove();
}
}
@@ -1 +1,7 @@
export * from './dnd-extension'
export {
TableDndExtension,
TableHandleCommandsExtension,
TableDndKey,
getTableHandlePluginSpec,
} from "./dnd-extension";
export type { TableHandleState, TableHandlePluginSpec } from "./dnd-extension";
@@ -99,4 +99,4 @@ export class DropIndicatorController {
});
}
}
}
@@ -1,4 +1,4 @@
import { computePosition, offset, ReferenceElement } from "@floating-ui/dom";
import { computePosition, offset, shift, ReferenceElement } from "@floating-ui/dom";
import { DraggingDOMs } from "../utils";
import { clearPreviewDOM, createPreviewDOM } from "./render-preview";
@@ -23,7 +23,7 @@ export class PreviewController {
onDragStart = (relatedDoms: DraggingDOMs, index: number | undefined, type: 'col' | 'row') => {
this._initPreviewStyle(relatedDoms.table, relatedDoms.cell, type);
createPreviewDOM(relatedDoms.table, this._preview, index, type)
this._initPreviewPosition(relatedDoms.cell, type);
this._initPreviewPosition(relatedDoms.table, relatedDoms.cell, type);
}
onDragEnd = () => {
@@ -32,7 +32,7 @@ export class PreviewController {
}
onDragging = (relatedDoms: DraggingDOMs, x: number, y: number, type: 'col' | 'row') => {
this._updatePreviewPosition(x, y, relatedDoms.cell, type);
this._updatePreviewPosition(x, y, relatedDoms.table, relatedDoms.cell, type);
}
destroy = () => {
@@ -60,7 +60,7 @@ export class PreviewController {
}
}
private _initPreviewPosition(cell: HTMLElement, type: 'col' | 'row') {
private _initPreviewPosition(table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
void computePosition(cell, this._preview, {
placement: type === 'row' ? 'right' : 'bottom',
middleware: [
@@ -70,6 +70,7 @@ export class PreviewController {
}
return -rects.reference.width
}),
shift({ boundary: table, padding: 0 }),
],
}).then(({ x, y }) => {
Object.assign(this._preview.style, {
@@ -79,11 +80,20 @@ export class PreviewController {
});
}
private _updatePreviewPosition(x: number, y: number, cell: HTMLElement, type: 'col' | 'row') {
// Clamp the preview to within the table's bounds via `shift({ boundary })`
// so it can't track the cursor past the table edge. Without the clamp,
// dragging near the viewport edge pushes the preview's `left` (or `top`)
// beyond the document's natural width/height, the browser extends the
// page to contain it, and the auto-scroll plugin then has a wider area
// to keep scrolling into — a feedback loop that grows the page forever.
private _updatePreviewPosition(x: number, y: number, table: HTMLElement, cell: HTMLElement, type: 'col' | 'row') {
computePosition(
getVirtualElement(cell, x, y),
this._preview,
{ placement: type === 'row' ? 'right' : 'bottom' },
{
placement: type === 'row' ? 'right' : 'bottom',
middleware: [shift({ boundary: table, padding: 0 })],
},
).then(({ x, y }) => {
if (type === 'row') {
Object.assign(this._preview.style, {
+23 -11
View File
@@ -1,4 +1,5 @@
import { cellAround, TableMap } from "@tiptap/pm/tables"
import { ResolvedPos } from "@tiptap/pm/model"
import { EditorView } from "@tiptap/pm/view"
export function getHoveringCell(
@@ -8,19 +9,30 @@ export function getHoveringCell(
const domCell = domCellAround(event.target as HTMLElement | null)
if (!domCell) return
const { left, top, width, height } = domCell.getBoundingClientRect()
const eventPos = view.posAtCoords({
// Use the center coordinates of the cell to ensure we're within the
// selected cell. This prevents potential issues when the mouse is on the
// border of two cells.
left: left + width / 2,
top: top + height / 2,
})
if (!eventPos) return
const $cellPos = cellAround(view.state.doc.resolve(eventPos.pos))
// Resolve directly from the cell DOM rather than via coords. The previous
// center-coords approach broke on tall merged cells — their visual center
// can land in empty space whose closest PM position resolves to an
// adjacent cell. `posAtDOM(td, 0)` is always inside this cell, regardless
// of rowspan/colspan.
let pos: number
try {
pos = view.posAtDOM(domCell, 0)
} catch {
return
}
const $cellPos = cellAround(view.state.doc.resolve(pos))
if (!$cellPos) return
return cellInfoFromResolvedCell($cellPos)
}
/**
* Build HoveringCellInfo from a resolved position whose parent is a
* table cell (i.e. the result of `cellAround` on some inner position).
*/
export function cellInfoFromResolvedCell(
$cellPos: ResolvedPos,
): HoveringCellInfo {
const map = TableMap.get($cellPos.node(-1))
const tableStart = $cellPos.start(-1)
const cellRect = map.findCell($cellPos.pos - tableStart)
@@ -0,0 +1,186 @@
// Per-table header-pin controller: native sticky when table fits its wrapper, transform fallback when it doesn't.
import { computePinTop, pinOffsetWatcher } from './offset';
const WRAPPER_NO_OVERFLOW = 'tableWrapperNoOverflow';
const HEADER_PINNED = 'tableHeaderPinned';
const PIN_OFFSET_VAR = '--table-pin-offset';
type PinMode = 'off' | 'native' | 'fallback';
function firstRowIsAllHeaders(row: HTMLTableRowElement | null): boolean {
if (!row) return false;
const cells = Array.from(row.cells);
return cells.length > 0 && cells.every((c) => c.tagName === 'TH');
}
function isNestedTable(wrapper: HTMLElement): boolean {
return wrapper.closest('table .tableWrapper') !== null;
}
function isLayoutInert(rect: DOMRectReadOnly): boolean {
return rect.width === 0 && rect.height === 0;
}
const fallbackControllers = new Set<TablePinController>();
let fallbackScrollListener: (() => void) | null = null;
let fallbackRafPending = false;
function ensureFallbackListener() {
if (fallbackScrollListener) return;
fallbackScrollListener = () => {
if (fallbackRafPending) return;
fallbackRafPending = true;
requestAnimationFrame(() => {
fallbackRafPending = false;
for (const ctrl of fallbackControllers) ctrl.updateFallbackOffset();
});
};
document.addEventListener('scroll', fallbackScrollListener, {
passive: true,
capture: true,
});
}
function maybeTeardownFallbackListener() {
if (!fallbackScrollListener || fallbackControllers.size > 0) return;
document.removeEventListener('scroll', fallbackScrollListener, {
capture: true,
});
fallbackScrollListener = null;
fallbackRafPending = false;
}
export class TablePinController {
private wrapper: HTMLElement;
private table: HTMLTableElement;
private fitsObserver?: IntersectionObserver;
private mode: PinMode = 'off';
private cachedHeaderRow: HTMLTableRowElement | null = null;
constructor(wrapper: HTMLElement, table: HTMLTableElement) {
this.wrapper = wrapper;
this.table = table;
pinOffsetWatcher.acquire();
this.fitsObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) this.evaluateFit(entry);
},
{ root: this.wrapper, threshold: 1 },
);
this.fitsObserver.observe(this.table);
}
private getHeaderRow(): HTMLTableRowElement | null {
if (this.cachedHeaderRow && this.table.contains(this.cachedHeaderRow)) {
return this.cachedHeaderRow;
}
this.cachedHeaderRow = this.table.querySelector('tr');
return this.cachedHeaderRow;
}
private evaluateFit(entry: IntersectionObserverEntry) {
if (!this.isEligible()) {
this.apply('off');
return;
}
if (isLayoutInert(entry.boundingClientRect)) return;
this.apply(entry.isIntersecting ? 'native' : 'fallback');
}
private isEligible(): boolean {
return (
!isNestedTable(this.wrapper) && firstRowIsAllHeaders(this.getHeaderRow())
);
}
private apply(next: PinMode) {
if (next === this.mode) return;
if (this.mode === 'fallback' && next !== 'fallback') {
fallbackControllers.delete(this);
maybeTeardownFallbackListener();
}
this.mode = next;
const cls = this.wrapper.classList;
if (next === 'off') {
cls.remove(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'native') {
cls.add(HEADER_PINNED);
cls.add(WRAPPER_NO_OVERFLOW);
// Native mode reads --editor-pin-offset from :root; clear stale per-wrapper var from fallback.
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
} else if (next === 'fallback') {
cls.add(HEADER_PINNED);
cls.remove(WRAPPER_NO_OVERFLOW);
fallbackControllers.add(this);
ensureFallbackListener();
// Avoid one stale-frame paint under translateY.
this.updateFallbackOffset();
}
}
updateFallbackOffset() {
const pinTop = computePinTop();
const tableRect = this.table.getBoundingClientRect();
const headerRow = this.getHeaderRow();
if (!headerRow) return;
const rowHeight = headerRow.getBoundingClientRect().height;
const active = tableRect.top < pinTop && tableRect.bottom > pinTop + rowHeight;
if (active) {
const offset = Math.min(pinTop - tableRect.top, tableRect.height - rowHeight);
this.wrapper.style.setProperty(PIN_OFFSET_VAR, `${offset}px`);
} else {
this.wrapper.style.removeProperty(PIN_OFFSET_VAR);
}
}
refresh() {
// The header <tr> may have been replaced by a PM transaction; drop
// the cached reference before checking eligibility.
this.cachedHeaderRow = null;
if (!this.isEligible()) {
this.apply('off');
return;
}
if (this.mode === 'off') {
// Eligibility just flipped back on; re-trigger the observer so it
// emits the current intersection state.
this.fitsObserver?.unobserve(this.table);
this.fitsObserver?.observe(this.table);
}
}
destroy() {
this.fitsObserver?.disconnect();
this.fitsObserver = undefined;
this.apply('off');
pinOffsetWatcher.release();
}
}
const controllers = new WeakMap<HTMLElement, TablePinController>();
export function attach(wrapper: HTMLElement) {
if (controllers.has(wrapper)) return;
const table = wrapper.querySelector(':scope > table') as HTMLTableElement | null;
if (!table) return;
controllers.set(wrapper, new TablePinController(wrapper, table));
}
export function detach(wrapper: HTMLElement) {
const ctrl = controllers.get(wrapper);
if (!ctrl) return;
ctrl.destroy();
controllers.delete(wrapper);
}
export function getController(wrapper: HTMLElement): TablePinController | undefined {
return controllers.get(wrapper);
}
@@ -0,0 +1,78 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { attach, detach, getController } from './controller';
const tableHeaderPinKey = new PluginKey('tableHeaderPin');
export const TableHeaderPin = Extension.create({
name: 'tableHeaderPin',
addProseMirrorPlugins() {
let editorRoot: HTMLElement | null = null;
let domObserver: MutationObserver | null = null;
const tracked = new Set<HTMLElement>();
let rafHandle: number | null = null;
const reconcile = () => {
rafHandle = null;
if (!editorRoot) return;
const current = new Set(
editorRoot.querySelectorAll<HTMLElement>('.tableWrapper'),
);
for (const w of tracked) {
if (!current.has(w)) {
detach(w);
tracked.delete(w);
}
}
for (const w of current) {
if (!tracked.has(w)) {
attach(w);
tracked.add(w);
}
}
};
const schedule = () => {
if (rafHandle !== null) return;
rafHandle = requestAnimationFrame(reconcile);
};
return [
new Plugin({
key: tableHeaderPinKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
schedule();
domObserver = new MutationObserver(schedule);
domObserver.observe(editorRoot, { subtree: true, childList: true });
return {
update(view, prevState) {
if (!editorRoot) return;
if (view.state.doc === prevState.doc) return;
editorRoot
.querySelectorAll<HTMLElement>('.tableWrapper')
.forEach((w) => getController(w)?.refresh());
},
destroy() {
if (rafHandle !== null) {
cancelAnimationFrame(rafHandle);
rafHandle = null;
}
domObserver?.disconnect();
domObserver = null;
for (const w of tracked) detach(w);
tracked.clear();
editorRoot = null;
},
};
},
}),
];
},
});
@@ -0,0 +1 @@
export { TableHeaderPin } from './extension';
@@ -0,0 +1,65 @@
// Pin-offset measurement and watcher used by the table header-pin controller.
// Fallback app-bar height (px) when no fixed surface is mounted; matches global-app-shell.tsx.
const APP_BAR_FALLBACK_HEIGHT = 45;
export const EDITOR_PIN_OFFSET_VAR = '--editor-pin-offset';
// Selectors for fixed surfaces between viewport top and editor content. Use data attributes —
// CSS module classes are build-time hashed and won't match.
const PIN_ANCHOR_SELECTORS = [
'[data-page-header]',
'[data-fixed-toolbar]',
] as const;
export function computePinTop(): number {
let bottom = APP_BAR_FALLBACK_HEIGHT;
for (const sel of PIN_ANCHOR_SELECTORS) {
const el = document.querySelector(sel) as HTMLElement | null;
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0 && rect.bottom > bottom) bottom = rect.bottom;
}
return bottom;
}
// Reference-counted watcher that publishes the editor's top offset to a CSS custom property.
export const pinOffsetWatcher = {
refs: 0,
resizeObserver: null as ResizeObserver | null,
rafPending: false,
lastValue: -1,
acquire() {
if (this.refs++ > 0) return;
this.publish();
const schedule = () => {
if (this.rafPending) return;
this.rafPending = true;
requestAnimationFrame(() => {
this.rafPending = false;
this.publish();
});
};
this.resizeObserver = new ResizeObserver(schedule);
this.resizeObserver.observe(document.body);
},
release() {
if (--this.refs > 0) return;
this.resizeObserver?.disconnect();
this.resizeObserver = null;
document.documentElement.style.removeProperty(EDITOR_PIN_OFFSET_VAR);
this.lastValue = -1;
},
publish() {
const top = computePinTop();
if (top === this.lastValue) return;
this.lastValue = top;
document.documentElement.style.setProperty(
EDITOR_PIN_OFFSET_VAR,
`${top}px`,
);
},
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content:
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | attachment | mathBlock | details | codeBlock)+",
"(paragraph | heading | bulletList | orderedList | taskList | blockquote | callout | image | video | audio | subpages | attachment | mathBlock | details | codeBlock)+",
addAttributes() {
return {
+11 -1
View File
@@ -2,4 +2,14 @@ export * from "./row";
export * from "./cell";
export * from "./header";
export * from "./table";
export * from "./dnd";
export * from "./dnd";
export * from "./table-view";
export * from "./header-pin";
export * from "./table-readonly-sort";
export { moveColumn } from "./utils/move-column";
export type { MoveColumnParams } from "./utils/move-column";
export { moveRow } from "./utils/move-row";
export type { MoveRowParams } from "./utils/move-row";
export { convertTableNodeToArrayOfRows } from "./utils/convert-table-node-to-array-of-rows";
export { convertArrayOfRowsToTableNode } from "./utils/convert-array-of-rows-to-table-node";
export { transpose } from "./utils/transpose";
@@ -0,0 +1,233 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
type SortDirection = 'asc' | 'desc';
type SortState = {
col: number;
direction: SortDirection;
};
const CHEVRON_CLASS = 'tableReadonlySortChevron';
const tableReadonlySortKey = new PluginKey('tableReadonlySort');
const sortStates = new WeakMap<HTMLTableElement, SortState>();
const originalOrders = new WeakMap<HTMLTableElement, HTMLTableRowElement[]>();
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
function getColumnIndex(th: HTMLTableCellElement): number {
const row = th.parentElement as HTMLTableRowElement;
if (!row) return -1;
let col = 0;
for (let i = 0; i < row.cells.length; i++) {
if (row.cells[i] === th) return col;
col += row.cells[i].colSpan ?? 1;
}
return -1;
}
function getHeaderTh(target: EventTarget | null): HTMLTableCellElement | null {
if (!(target instanceof Element)) return null;
const th = target.closest('th') as HTMLTableCellElement | null;
if (!th) return null;
const row = th.parentElement;
if (!row) return null;
const tbody = row.parentElement;
if (!tbody) return null;
const table = tbody.closest('table');
if (!table) return null;
// th must be in the first row of the table (could be in thead or tbody)
const firstRow = table.querySelector('tr');
if (firstRow !== row) return null;
return th;
}
function getCellText(row: HTMLTableRowElement, colIndex: number): string {
let col = 0;
for (let i = 0; i < row.cells.length; i++) {
if (col === colIndex) return row.cells[i].textContent?.trim() ?? '';
col += row.cells[i].colSpan ?? 1;
}
return '';
}
function getOrSaveOriginalOrder(
table: HTMLTableElement,
dataRows: HTMLTableRowElement[],
): HTMLTableRowElement[] {
if (!originalOrders.has(table)) {
originalOrders.set(table, [...dataRows]);
}
return originalOrders.get(table)!;
}
function sortDataRows(
dataRows: HTMLTableRowElement[],
colIndex: number,
direction: SortDirection,
): HTMLTableRowElement[] {
return [...dataRows].sort((a, b) => {
const textA = getCellText(a, colIndex);
const textB = getCellText(b, colIndex);
const emptyA = textA === '';
const emptyB = textB === '';
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
const cmp = collator.compare(textA, textB);
return direction === 'asc' ? cmp : -cmp;
});
}
function applySort(table: HTMLTableElement, colIndex: number): void {
const tbody = table.querySelector('tbody');
if (!tbody) return;
const allRows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>(':scope > tr'));
if (allRows.length === 0) return;
const headerRow = allRows[0];
const dataRows = allRows.slice(1);
if (dataRows.length === 0) return;
const current = sortStates.get(table) ?? null;
const saved = getOrSaveOriginalOrder(table, dataRows);
let next: SortState | null;
if (!current || current.col !== colIndex) {
next = { col: colIndex, direction: 'asc' };
} else if (current.direction === 'asc') {
next = { col: colIndex, direction: 'desc' };
} else {
next = null;
}
if (next === null) {
sortStates.delete(table);
tbody.append(headerRow, ...saved);
} else {
sortStates.set(table, next);
const sorted = sortDataRows(saved, next.col, next.direction);
tbody.append(headerRow, ...sorted);
}
updateChevrons(table);
}
const CHEVRON_SVG =
'<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">' +
'<path d="M2.5 4.5 L6 8 L9.5 4.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />' +
'</svg>';
function ensureChevron(th: HTMLTableCellElement): HTMLSpanElement {
let chevron = th.querySelector<HTMLSpanElement>(`.${CHEVRON_CLASS}`);
if (!chevron) {
chevron = document.createElement('span');
chevron.className = CHEVRON_CLASS;
chevron.setAttribute('aria-hidden', 'true');
chevron.innerHTML = CHEVRON_SVG;
th.appendChild(chevron);
}
return chevron;
}
function updateChevrons(table: HTMLTableElement): void {
const firstRow = table.querySelector('tr');
if (!firstRow) return;
const state = sortStates.get(table) ?? null;
let col = 0;
for (let i = 0; i < firstRow.cells.length; i++) {
const cell = firstRow.cells[i];
if (cell.tagName !== 'TH') {
col += cell.colSpan ?? 1;
continue;
}
const chevron = ensureChevron(cell as HTMLTableCellElement);
let label: string;
if (state && state.col === col) {
chevron.setAttribute('data-sort', state.direction);
label = state.direction === 'asc' ? 'Sort descending' : 'Clear sort';
} else {
chevron.removeAttribute('data-sort');
label = 'Sort ascending';
}
chevron.setAttribute('data-tooltip', label);
chevron.setAttribute('aria-label', label);
chevron.title = label;
col += cell.colSpan ?? 1;
}
}
function addChevronsToAllTables(editorRoot: HTMLElement): void {
const tables = editorRoot.querySelectorAll<HTMLTableElement>('table');
tables.forEach((table) => updateChevrons(table));
}
function removeAllChevrons(editorRoot: HTMLElement): void {
editorRoot
.querySelectorAll<HTMLSpanElement>(`.${CHEVRON_CLASS}`)
.forEach((el) => el.remove());
}
export const TableReadonlySort = Extension.create({
name: 'tableReadonlySort',
addProseMirrorPlugins() {
const editor = this.editor;
let editorRoot: HTMLElement | null = null;
const onClick = (event: MouseEvent) => {
if (editor.isEditable) return;
// Only react to clicks on the chevron, not anywhere else in the header
// cell. This lets the user click into a header to select text without
// accidentally triggering a sort.
if (!(event.target instanceof Element)) return;
const chevron = event.target.closest(`.${CHEVRON_CLASS}`);
if (!chevron) return;
const th = getHeaderTh(chevron);
if (!th) return;
const table = th.closest('table') as HTMLTableElement | null;
if (!table) return;
const colIndex = getColumnIndex(th);
if (colIndex < 0) return;
applySort(table, colIndex);
};
return [
new Plugin({
key: tableReadonlySortKey,
view(editorView) {
editorRoot = editorView.dom as HTMLElement;
editorRoot.addEventListener('click', onClick);
if (!editor.isEditable) {
addChevronsToAllTables(editorRoot);
}
return {
update(view) {
const root = view.dom as HTMLElement;
if (!editor.isEditable) {
addChevronsToAllTables(root);
} else {
removeAllChevrons(root);
}
},
destroy() {
if (editorRoot) {
editorRoot.removeEventListener('click', onClick);
removeAllChevrons(editorRoot);
}
},
};
},
}),
];
},
});
@@ -0,0 +1,158 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import type { NodeView, ViewMutationRecord } from '@tiptap/pm/view';
import { getColStyleDeclaration } from './utils/col-style';
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLTableElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: number,
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild;
const row = node.firstChild;
if (row !== null) {
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col
? overrideValue
: ((colwidth && colwidth[j]) as number | undefined);
const cssWidth = hasWidth ? `${hasWidth}px` : '';
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
const colElement = document.createElement('col');
const [propertyKey, propertyValue] = getColStyleDeclaration(
cellMinWidth,
hasWidth,
);
colElement.style.setProperty(propertyKey, propertyValue);
colgroup.appendChild(colElement);
} else {
if ((nextDOM as HTMLTableColElement).style.width !== cssWidth) {
const [propertyKey, propertyValue] = getColStyleDeclaration(
cellMinWidth,
hasWidth,
);
(nextDOM as HTMLTableColElement).style.setProperty(
propertyKey,
propertyValue,
);
}
nextDOM = nextDOM.nextSibling;
}
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after;
}
const hasUserWidth =
node.attrs.style &&
typeof node.attrs.style === 'string' &&
/\bwidth\s*:/i.test(node.attrs.style);
if (fixedWidth && !hasUserWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = '';
} else {
table.style.width = '';
table.style.minWidth = `${totalWidth}px`;
}
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
dom: HTMLDivElement;
table: HTMLTableElement;
colgroup: HTMLTableColElement;
contentDOM: HTMLTableSectionElement;
constructor(node: ProseMirrorNode, cellMinWidth: number) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.dom = document.createElement('div');
this.dom.className = 'tableWrapper';
this.table = this.dom.appendChild(document.createElement('table'));
if (node.attrs.style) {
this.table.style.cssText = node.attrs.style;
}
this.colgroup = this.table.appendChild(document.createElement('colgroup'));
updateColumns(node, this.colgroup, this.table, cellMinWidth);
this.contentDOM = this.table.appendChild(document.createElement('tbody'));
}
update(node: ProseMirrorNode) {
if (node.type !== this.node.type) return false;
this.node = node;
updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
return true;
}
ignoreMutation(mutation: ViewMutationRecord) {
const target = mutation.target as Node;
const isInsideWrapper = this.dom.contains(target);
const isInsideContent = this.contentDOM.contains(target);
if (isInsideWrapper && !isInsideContent) {
if (
mutation.type === 'attributes' ||
mutation.type === 'childList' ||
mutation.type === 'characterData'
) {
return true;
}
}
// Chevron span (.tableReadonlySortChevron) added/removed by sort plugin.
if (mutation.type === 'childList') {
const nodes = [
...Array.from(mutation.addedNodes),
...Array.from(mutation.removedNodes),
];
if (
nodes.some(
(n) =>
n instanceof Element &&
n.classList.contains('tableReadonlySortChevron'),
)
) {
return true;
}
}
return false;
}
}
@@ -1,6 +1,8 @@
import { Table } from "@tiptap/extension-table";
import { Editor } from "@tiptap/core";
import { DOMOutputSpec } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { cellAround } from "@tiptap/pm/tables";
const LIST_TYPES = ["bulletList", "orderedList", "taskList"];
@@ -32,9 +34,36 @@ function handleListOutdent(editor: Editor): boolean {
}
export const CustomTable = Table.extend({
addKeyboardShortcuts() {
return {
...this.parent?.(),
"Mod-a": () => {
const { state, view } = this.editor;
const { selection, doc } = state;
const $cellPos = cellAround(selection.$anchor);
if (!$cellPos) return false;
const cellNode = doc.nodeAt($cellPos.pos);
// Empty cells have nothing useful to scope to — let the default
// Mod-a fall through and select the whole doc.
if (!cellNode || !cellNode.textContent) return false;
const from = $cellPos.pos + 1;
const to = $cellPos.pos + cellNode.nodeSize - 1;
if (from >= to) return true;
const nextSel = TextSelection.between(
doc.resolve(from),
doc.resolve(to),
1,
);
if (!nextSel || selection.eq(nextSel)) return true;
view.dispatch(state.tr.setSelection(nextSel));
return true;
},
Tab: () => {
// If we're in a list within a table, handle list indentation
if (isInList(this.editor) && this.editor.isActive("table")) {
@@ -0,0 +1,7 @@
export function getColStyleDeclaration(minWidth: number, width: number | undefined): [string, string] {
if (width) {
return ['width', `${Math.max(width, minWidth)}px`]
}
return ['min-width', `${minWidth}px`]
}
@@ -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);
},
});
+15
View File
@@ -338,6 +338,15 @@ export const isRowGripSelected = ({
return !!gripRow;
};
// TipTap's `editor.view` proxy throws if accessed before mount or after destroy.
// Guard floating-menu callbacks (getReferencedVirtualElement, shouldShow) with
// this before touching `editor.view.nodeDOM(...)`.
export function isEditorReady(
editor: Editor | null | undefined,
): editor is Editor {
return !!editor && editor.isInitialized;
}
export function isTextSelected(editor: Editor) {
const {
state: {
@@ -382,6 +391,12 @@ export function sanitizeUrl(url: string | undefined): string {
return sanitized === "about:blank" ? "" : sanitized;
}
export function isInternalFileUrl(url: string | undefined): boolean {
if (!url) return false;
const normalized = url.trim();
return normalized.startsWith("/api/files/") || normalized.startsWith("/files/");
}
const alphabet = "abcdefghijklmnopqrstuvwxyz";
export const generateNodeId = customAlphabet(alphabet, 12);
@@ -61,7 +61,9 @@ const handleVideoUpload =
const objectUrl = URL.createObjectURL(file);
const videoDimensions = await getVideoDimensions(objectUrl);
const placeholderId = generateNodeId();
const aspectRatio = videoDimensions.aspectRatio;
const width = videoDimensions?.width ?? undefined;
const height = videoDimensions?.height ?? undefined;
const aspectRatio = videoDimensions?.aspectRatio;
let placeholderInserted = false;
@@ -76,6 +78,8 @@ const handleVideoUpload =
id: placeholderId,
name: file.name,
},
width,
height,
aspectRatio,
});
@@ -108,6 +112,8 @@ const handleVideoUpload =
attachmentId: attachment.id,
title: attachment.fileName,
size: attachment.fileSize,
width,
height,
aspectRatio,
});
+230 -7
View File
@@ -1,16 +1,38 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Range, Node } from "@tiptap/core";
import { Range, Node, mergeAttributes } from "@tiptap/core";
import { ResizableNodeView } from "../resizable-nodeview";
import type { ResizableNodeViewDirection } from "../resizable-nodeview";
import { normalizeFileUrl } from "../media-utils";
export type VideoResizeOptions = {
enabled: boolean;
directions?: ResizableNodeViewDirection[];
minWidth?: number;
minHeight?: number;
alwaysPreserveAspectRatio?: boolean;
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
className?: {
container?: string;
wrapper?: string;
handle?: string;
resizing?: string;
};
};
export interface VideoOptions {
view: any;
HTMLAttributes: Record<string, any>;
resize: VideoResizeOptions | false;
}
export interface VideoAttributes {
src?: string;
alt?: string;
align?: string;
attachmentId?: string;
size?: number;
width?: number;
width?: number | string;
height?: number;
aspectRatio?: number;
placeholder?: {
id: string;
@@ -27,6 +49,7 @@ declare module "@tiptap/core" {
) => ReturnType;
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
setVideoWidth: (width: number) => ReturnType;
setVideoSize: (width: number, height: number) => ReturnType;
};
}
}
@@ -44,6 +67,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
return {
view: null,
HTMLAttributes: {},
resize: false,
};
},
@@ -56,6 +80,13 @@ export const TiptapVideo = Node.create<VideoOptions>({
src: attributes.src,
}),
},
alt: {
default: undefined,
parseHTML: (element) => element.getAttribute("aria-label"),
renderHTML: (attributes: VideoAttributes) => ({
"aria-label": attributes.alt,
}),
},
attachmentId: {
default: undefined,
parseHTML: (element) => element.getAttribute("data-attachment-id"),
@@ -64,12 +95,30 @@ export const TiptapVideo = Node.create<VideoOptions>({
}),
},
width: {
default: "100%",
parseHTML: (element) => element.getAttribute("width"),
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("width");
if (!raw) return null;
if (raw.endsWith("%")) return raw;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: VideoAttributes) => ({
width: attributes.width,
}),
},
height: {
default: null,
parseHTML: (element) => {
const raw = element.getAttribute("height");
if (!raw) return null;
const num = parseFloat(raw);
return isNaN(num) ? null : num;
},
renderHTML: (attributes: VideoAttributes) => ({
height: attributes.height,
}),
},
size: {
default: null,
parseHTML: (element) => element.getAttribute("data-size"),
@@ -136,13 +185,187 @@ export const TiptapVideo = Node.create<VideoOptions>({
commands.updateAttributes("video", {
width: `${Math.max(0, Math.min(100, width))}%`,
}),
setVideoSize:
(width, height) =>
({ commands }) =>
commands.updateAttributes("video", { width, height }),
};
},
addNodeView() {
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
this.editor.isInitialized = true;
const resize = this.options.resize;
return ReactNodeViewRenderer(this.options.view);
if (!resize || !resize.enabled) {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(this.options.view);
}
const {
directions,
minWidth,
minHeight,
alwaysPreserveAspectRatio,
createCustomHandle,
className,
} = resize;
return (props) => {
const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) {
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
const el = document.createElement("video");
el.src = normalizeFileUrl(node.attrs.src);
el.controls = true;
el.preload = "metadata";
if (node.attrs.alt) {
el.setAttribute("aria-label", node.attrs.alt);
}
el.style.display = "block";
el.style.maxWidth = "100%";
el.style.borderRadius = "8px";
if (typeof node.attrs.width === "number" && node.attrs.width > 0) {
el.style.width = `${node.attrs.width}px`;
if (typeof node.attrs.height === "number" && node.attrs.height > 0) {
el.style.height = `${node.attrs.height}px`;
}
}
let currentNode = node;
const nodeView = new ResizableNodeView({
element: el,
editor,
node,
getPos,
onResize: (w, h) => {
el.style.width = `${w}px`;
el.style.height = `${h}px`;
},
onCommit: () => {
const pos = getPos();
if (pos === undefined) return;
this.editor
.chain()
.setNodeSelection(pos)
.updateAttributes(this.name, {
width: Math.round(el.offsetWidth),
height: Math.round(el.offsetHeight),
})
.run();
},
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
if (updatedNode.type !== currentNode.type) {
return false;
}
if (updatedNode.attrs.src !== currentNode.attrs.src) {
el.src = normalizeFileUrl(updatedNode.attrs.src);
}
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
if (updatedNode.attrs.alt) {
el.setAttribute("aria-label", updatedNode.attrs.alt);
} else {
el.removeAttribute("aria-label");
}
}
const w = updatedNode.attrs.width;
const h = updatedNode.attrs.height;
if (w != null) {
el.style.width = `${w}px`;
}
if (h != null) {
el.style.height = `${h}px`;
}
const align = updatedNode.attrs.align || "center";
const container = nodeView.dom as HTMLElement;
applyAlignment(container, align);
currentNode = updatedNode;
return true;
},
options: {
directions,
min: {
width: minWidth,
height: minHeight,
},
preserveAspectRatio: alwaysPreserveAspectRatio === true,
createCustomHandle,
className,
},
});
const dom = nodeView.dom as HTMLElement;
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while video loads from server
dom.style.pointerEvents = "none";
el.classList.add("media-pulse");
el.onloadedmetadata = () => {
dom.style.pointerEvents = "";
el.classList.remove("media-pulse");
};
return nodeView;
};
},
});
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}