mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 03:32:15 +10:00
merge main
This commit is contained in:
@@ -9,5 +9,7 @@
|
||||
"main": "dist/index.js",
|
||||
"module": "./src/index.ts",
|
||||
"types": "dist/index.d.ts",
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"marked": "17.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 [
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user