mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: Tiptap V3 migration (#1854)
* Tiptap3 migration - WIP * fix collaboration * remove unused code * fix flicker * disable duplicate extensions * update tiptap version * Switch to useEditorState - Set shouldRerenderOnTransaction to false * fix editable state * add tippyoptions for reference * merge main * tiptap 3.6.1 * fix bubble menu * fix converter * fix menus * fix collaboration caret css * fix: Set `isInitialized` to force immediate react node view rendering * feat: Migrate tippy.js menus to Floating UI * feat: Update collaboration connection for HocusPocus v3 * fix: Connect/disconnect websocketProvider * cleanup * cleanup * feat: Improved placeholder and upload handling for images * feat: Improved placeholder and upload handling for videos * refactor: Image node and view clean-up * feat: Improved placeholder and upload handling for attachments * fix: Video view styles * fix: Transaction handling on asset upload * fix: Use imageDimensionsFromStream * feat: Multiple file upload, improved placeholders, local previews * fix: Drag & drop, paste upload * fix: Allow media as attachment * * add skeleton pulse animation * add translation strings * fix attachment view responsiveness * fix collab connection status display * Tiptap v3.17.0 * fix suggestion menu exit bug * fix search shortcut * fix history editor css * tiptap 3.17.1 --------- Co-authored-by: Arek Nawo <areknawo@areknawo.com>
This commit is contained in:
@@ -1,126 +1,125 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import {
|
||||
insertTrailingNode,
|
||||
MediaUploadOptions,
|
||||
UploadFn,
|
||||
} from "../media-utils";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { IAttachment } from "../types";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { Command } from "@tiptap/core";
|
||||
|
||||
const uploadKey = new PluginKey("attachment-upload");
|
||||
const findAttachmentNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
export const AttachmentUploadPlugin = ({
|
||||
placeholderClass,
|
||||
}: {
|
||||
placeholderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, fileName } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", placeholderClass);
|
||||
|
||||
const uploadingText = document.createElement("span");
|
||||
uploadingText.setAttribute("class", "uploading-text");
|
||||
uploadingText.textContent = `Uploading ${fileName}`;
|
||||
|
||||
placeholder.appendChild(uploadingText);
|
||||
|
||||
const realPos = pos + 1;
|
||||
const deco = Decoration.widget(realPos, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === "attachment" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleAttachmentUpload =
|
||||
return result;
|
||||
};
|
||||
const handleAttachmentUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId, allowMedia) => {
|
||||
async (file, editor, pos, pageId, allowMedia) => {
|
||||
const validated = validateFn?.(file, allowMedia);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
const placeholderId = generateNodeId();
|
||||
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
fileName: file.name,
|
||||
},
|
||||
});
|
||||
let placeholderInserted = false;
|
||||
|
||||
insertTrailingNode(tr, pos, view);
|
||||
view.dispatch(tr);
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.attachment?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
},
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (pos == null) return;
|
||||
if (isEmptyTextBlock) {
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
if (!attachment) return;
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithAttachment = (
|
||||
attachment: IAttachment,
|
||||
): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
const node = schema.nodes.attachment?.create({
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return false;
|
||||
|
||||
// Update the placeholder node with the actual attachment data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
url: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
name: attachment.fileName,
|
||||
mime: attachment.mimeType,
|
||||
size: attachment.fileSize,
|
||||
attachmentId: attachment.id,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findAttachmentNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
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(replacePlaceholderWithAttachment(attachment));
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithAttachment(attachment))
|
||||
.run();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
}
|
||||
};
|
||||
|
||||
export { handleAttachmentUpload };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { AttachmentUploadPlugin } from "./attachment-upload";
|
||||
|
||||
export interface AttachmentOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -13,6 +12,7 @@ export interface AttachmentAttributes {
|
||||
mime?: string; // e.g. application/zip
|
||||
size?: number;
|
||||
attachmentId?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -75,6 +75,10 @@ export const Attachment = Node.create<AttachmentOptions>({
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -120,14 +124,9 @@ export const Attachment = Node.create<AttachmentOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
AttachmentUploadPlugin({
|
||||
placeholderClass: "attachment-placeholder",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
0,
|
||||
];
|
||||
@@ -130,6 +130,9 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
@@ -193,7 +196,7 @@ export const Callout = Node.create<CalloutOptions>({
|
||||
|
||||
tr.delete(pos, pos + nodeSize);
|
||||
tr.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(previousPosition - 1)),
|
||||
TextSelection.near(tr.doc.resolve(previousPosition - 1))
|
||||
);
|
||||
tr.insert(previousPosition - 1, content);
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import CodeBlockLowlight, {
|
||||
CodeBlockLowlightOptions,
|
||||
} from "@tiptap/extension-code-block-lowlight";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface CustomCodeBlockOptions extends CodeBlockLowlightOptions {
|
||||
view: any;
|
||||
}
|
||||
|
||||
const TAB_CHAR = "\u00A0\u00A0";
|
||||
|
||||
export const CustomCodeBlock = CodeBlockLowlight.extend<CustomCodeBlockOptions>(
|
||||
{
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
this.editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.insertText(TAB_CHAR);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
"Mod-a": () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
const { state } = this.editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
let codeBlockNode = null;
|
||||
let codeBlockPos = null;
|
||||
let depth = 0;
|
||||
|
||||
for (depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "codeBlock") {
|
||||
codeBlockNode = node;
|
||||
codeBlockPos = $from.start(depth) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockNode && codeBlockPos !== null) {
|
||||
const codeBlockStart = codeBlockPos;
|
||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||
|
||||
const contentStart = codeBlockStart + 1;
|
||||
const contentEnd = codeBlockEnd - 1;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from: contentStart,
|
||||
to: contentEnd,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CodeBlockOptions } from "@tiptap/extension-code-block";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
|
||||
import { LowlightPlugin } from "./lowlight-plugin.js";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface CodeBlockLowlightOptions extends CodeBlockOptions {
|
||||
/**
|
||||
* The lowlight instance.
|
||||
*/
|
||||
lowlight: any;
|
||||
view: any;
|
||||
}
|
||||
|
||||
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>({
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
lowlight: {},
|
||||
languageClassPrefix: "language-",
|
||||
exitOnTripleEnter: true,
|
||||
exitOnArrowDown: true,
|
||||
defaultLanguage: null,
|
||||
HTMLAttributes: {},
|
||||
view: null,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
this.editor
|
||||
.chain()
|
||||
.command(({ tr }) => {
|
||||
tr.insertText(TAB_CHAR);
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
}
|
||||
},
|
||||
"Mod-a": () => {
|
||||
if (this.editor.isActive("codeBlock")) {
|
||||
const { state } = this.editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
let codeBlockNode = null;
|
||||
let codeBlockPos = null;
|
||||
let depth = 0;
|
||||
|
||||
for (depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type.name === "codeBlock") {
|
||||
codeBlockNode = node;
|
||||
codeBlockPos = $from.start(depth) - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (codeBlockNode && codeBlockPos !== null) {
|
||||
const codeBlockStart = codeBlockPos;
|
||||
const codeBlockEnd = codeBlockPos + codeBlockNode.nodeSize;
|
||||
|
||||
const contentStart = codeBlockStart + 1;
|
||||
const contentEnd = codeBlockEnd - 1;
|
||||
|
||||
this.editor.commands.setTextSelection({
|
||||
from: contentStart,
|
||||
to: contentEnd,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...(this.parent?.() || []),
|
||||
LowlightPlugin({
|
||||
name: this.name,
|
||||
lowlight: this.options.lowlight,
|
||||
defaultLanguage: this.options.defaultLanguage,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { CustomCodeBlock } from "./custom-code-block";
|
||||
@@ -0,0 +1,159 @@
|
||||
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'
|
||||
|
||||
function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] {
|
||||
return nodes
|
||||
.map(node => {
|
||||
const classes = [...className, ...(node.properties ? node.properties.className : [])]
|
||||
|
||||
if (node.children) {
|
||||
return parseNodes(node.children, classes)
|
||||
}
|
||||
|
||||
return {
|
||||
text: node.value,
|
||||
classes,
|
||||
}
|
||||
})
|
||||
.flat()
|
||||
}
|
||||
|
||||
function getHighlightNodes(result: any) {
|
||||
// `.value` for lowlight v1, `.children` for lowlight v2
|
||||
return result.value || result.children || []
|
||||
}
|
||||
|
||||
function registered(aliasOrLanguage: string) {
|
||||
return Boolean(highlight.getLanguage(aliasOrLanguage))
|
||||
}
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
doc: ProsemirrorNode
|
||||
name: string
|
||||
lowlight: any
|
||||
defaultLanguage: string | null | undefined
|
||||
}) {
|
||||
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()
|
||||
|
||||
const nodes =
|
||||
language && (languages.includes(language) || registered(language) || lowlight.registered?.(language))
|
||||
? getHighlightNodes(lowlight.highlight(language, block.node.textContent))
|
||||
: getHighlightNodes(lowlight.highlightAuto(block.node.textContent))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
from = to
|
||||
})
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
export function LowlightPlugin({
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
}: {
|
||||
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')
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
key: new PluginKey('lowlight'),
|
||||
|
||||
state: {
|
||||
init: (_, { doc }) =>
|
||||
getDecorations({
|
||||
doc,
|
||||
name,
|
||||
lowlight,
|
||||
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)
|
||||
|
||||
if (
|
||||
transaction.docChanged &&
|
||||
// Apply decorations if:
|
||||
// selection includes named node,
|
||||
([oldNodeName, newNodeName].includes(name) ||
|
||||
// OR transaction adds/removes named node,
|
||||
newNodes.length !== oldNodes.length ||
|
||||
// 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 => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
// @ts-ignore
|
||||
step.from !== undefined &&
|
||||
// @ts-ignore
|
||||
step.to !== undefined &&
|
||||
oldNodes.some(node => {
|
||||
// @ts-ignore
|
||||
return (
|
||||
// @ts-ignore
|
||||
node.pos >= step.from &&
|
||||
// @ts-ignore
|
||||
node.pos + node.node.nodeSize <= step.to
|
||||
)
|
||||
})
|
||||
)
|
||||
}))
|
||||
) {
|
||||
return getDecorations({
|
||||
doc: transaction.doc,
|
||||
name,
|
||||
lowlight,
|
||||
defaultLanguage,
|
||||
})
|
||||
}
|
||||
|
||||
return decorationSet.map(transaction.mapping, transaction.doc)
|
||||
},
|
||||
},
|
||||
|
||||
props: {
|
||||
decorations(state) {
|
||||
return lowlightPlugin.getState(state)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return lowlightPlugin
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const Details = Node.create<DetailsOptions>({
|
||||
content: "detailsSummary detailsContent",
|
||||
defining: true,
|
||||
isolating: true,
|
||||
// @ts-ignore
|
||||
allowGapCursor: false,
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -41,45 +41,45 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-src': attributes.src,
|
||||
"data-src": attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-title': attributes.title,
|
||||
"data-title": attributes.title,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
default: "100%",
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-size'),
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-size': attributes.size,
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: DrawioAttributes) => ({
|
||||
'data-attachment-id': attributes.attachmentId,
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -95,13 +95,20 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ 'data-type': this.name },
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
||||
[
|
||||
"img",
|
||||
{
|
||||
src: HTMLAttributes["data-src"],
|
||||
alt: HTMLAttributes["data-title"],
|
||||
width: HTMLAttributes["data-width"],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
@@ -119,6 +126,9 @@ export const Drawio = Node.create<DrawioOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { sanitizeUrl } from './utils';
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { sanitizeUrl } from "./utils";
|
||||
|
||||
export interface EmbedOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -14,7 +14,7 @@ export interface EmbedAttributes {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
embeds: {
|
||||
setEmbed: (attributes?: EmbedAttributes) => ReturnType;
|
||||
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
|
||||
}
|
||||
|
||||
export const Embed = Node.create<EmbedOptions>({
|
||||
name: 'embed',
|
||||
name: "embed",
|
||||
inline: false,
|
||||
group: 'block',
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
@@ -40,41 +40,41 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
default: "",
|
||||
parseHTML: (element) => {
|
||||
const src = element.getAttribute('data-src');
|
||||
const src = element.getAttribute("data-src");
|
||||
return sanitizeUrl(src);
|
||||
},
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-src': sanitizeUrl(attributes.src),
|
||||
"data-src": sanitizeUrl(attributes.src),
|
||||
}),
|
||||
},
|
||||
provider: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-provider'),
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-provider': attributes.provider,
|
||||
"data-provider": attributes.provider,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: 640,
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
default: 480,
|
||||
parseHTML: (element) => element.getAttribute('data-height'),
|
||||
parseHTML: (element) => element.getAttribute("data-height"),
|
||||
renderHTML: (attributes: EmbedAttributes) => ({
|
||||
'data-height': attributes.height,
|
||||
"data-height": attributes.height,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -91,13 +91,13 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const src = HTMLAttributes["data-src"];
|
||||
const safeHref = sanitizeUrl(src);
|
||||
|
||||
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
[
|
||||
"a",
|
||||
@@ -120,9 +120,9 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
...attrs,
|
||||
src: sanitizeUrl(attrs.src),
|
||||
};
|
||||
|
||||
|
||||
return commands.insertContent({
|
||||
type: 'embed',
|
||||
type: "embed",
|
||||
attrs: validatedAttrs,
|
||||
});
|
||||
},
|
||||
@@ -130,6 +130,9 @@ export const Embed = Node.create<EmbedOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
export interface ExcalidrawOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -14,7 +14,7 @@ export interface ExcalidrawAttributes {
|
||||
attachmentId?: string;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
excalidraw: {
|
||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||
@@ -23,9 +23,9 @@ declare module '@tiptap/core' {
|
||||
}
|
||||
|
||||
export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
name: 'excalidraw',
|
||||
name: "excalidraw",
|
||||
inline: false,
|
||||
group: 'block',
|
||||
group: "block",
|
||||
isolating: true,
|
||||
atom: true,
|
||||
defining: true,
|
||||
@@ -40,45 +40,45 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-src'),
|
||||
default: "",
|
||||
parseHTML: (element) => element.getAttribute("data-src"),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-src': attributes.src,
|
||||
"data-src": attributes.src,
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-title'),
|
||||
parseHTML: (element) => element.getAttribute("data-title"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-title': attributes.title,
|
||||
"data-title": attributes.title,
|
||||
}),
|
||||
},
|
||||
width: {
|
||||
default: '100%',
|
||||
parseHTML: (element) => element.getAttribute('data-width'),
|
||||
default: "100%",
|
||||
parseHTML: (element) => element.getAttribute("data-width"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-width': attributes.width,
|
||||
"data-width": attributes.width,
|
||||
}),
|
||||
},
|
||||
size: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-size'),
|
||||
parseHTML: (element) => element.getAttribute("data-size"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-size': attributes.size,
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
align: {
|
||||
default: 'center',
|
||||
parseHTML: (element) => element.getAttribute('data-align'),
|
||||
default: "center",
|
||||
parseHTML: (element) => element.getAttribute("data-align"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-align': attributes.align,
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
attachmentId: {
|
||||
default: undefined,
|
||||
parseHTML: (element) => element.getAttribute('data-attachment-id'),
|
||||
parseHTML: (element) => element.getAttribute("data-attachment-id"),
|
||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||
'data-attachment-id': attributes.attachmentId,
|
||||
"data-attachment-id": attributes.attachmentId,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -94,13 +94,20 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'div',
|
||||
"div",
|
||||
mergeAttributes(
|
||||
{ 'data-type': this.name },
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
['img', { src: HTMLAttributes['data-src'], alt: HTMLAttributes['data-title'], width: HTMLAttributes['data-width'] }],
|
||||
[
|
||||
"img",
|
||||
{
|
||||
src: HTMLAttributes["data-src"],
|
||||
alt: HTMLAttributes["data-title"],
|
||||
width: HTMLAttributes["data-width"],
|
||||
},
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
@@ -110,7 +117,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
(attrs: ExcalidrawAttributes) =>
|
||||
({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: 'excalidraw',
|
||||
type: "excalidraw",
|
||||
attrs: attrs,
|
||||
});
|
||||
},
|
||||
@@ -118,6 +125,9 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,127 +1,145 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { insertTrailingNode, MediaUploadOptions, UploadFn } from "../media-utils";
|
||||
import { imageDimensionsFromStream } from "image-dimensions";
|
||||
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 uploadKey = new PluginKey("image-upload");
|
||||
const findImageNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
export const ImageUploadPlugin = ({
|
||||
placeholderClass,
|
||||
}: {
|
||||
placeholderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, src } = action.add;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "img-placeholder");
|
||||
const image = document.createElement("img");
|
||||
image.setAttribute("class", placeholderClass);
|
||||
image.src = src;
|
||||
placeholder.appendChild(image);
|
||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
if (
|
||||
node.type.name === "image" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
|
||||
export const handleImageUpload =
|
||||
return result;
|
||||
};
|
||||
const handleImageUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId) => {
|
||||
async (file, editor, pos, pageId) => {
|
||||
// check if the file is an image
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const tr = view.state.tr;
|
||||
// Replace the selection with a placeholder
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const imageDimensions = await imageDimensionsFromStream(file.stream());
|
||||
const placeholderId = generateNodeId();
|
||||
const aspectRatio = imageDimensions
|
||||
? imageDimensions.width / imageDimensions.height
|
||||
: undefined;
|
||||
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
src: reader.result,
|
||||
},
|
||||
});
|
||||
let placeholderInserted = false;
|
||||
|
||||
insertTrailingNode(tr, pos, view);
|
||||
view.dispatch(tr);
|
||||
editor.storage.shared.imagePreviews =
|
||||
editor.storage.shared.imagePreviews || {};
|
||||
editor.storage.shared.imagePreviews[placeholderId] = objectUrl;
|
||||
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.image?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
name: file.name,
|
||||
},
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
// Replace e.g. empty paragraph with the image
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithImage = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return false;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
|
||||
// If the content around the placeholder has been deleted, drop
|
||||
// the image
|
||||
if (pos == null) return;
|
||||
|
||||
// Otherwise, insert it at the placeholder's position, and remove
|
||||
// the placeholder
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
const node = schema.nodes.image?.create({
|
||||
// Update the placeholder node with the actual image data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
aspectRatio,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the image placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findImageNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
// Remove the placeholder node
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
const disposePreviewFile = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
if (editor.storage.shared.imagePreviews) {
|
||||
delete editor.storage.shared.imagePreviews[placeholderId];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithImage(attachment));
|
||||
disposePreviewFile();
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithImage(attachment))
|
||||
.run();
|
||||
disposePreviewFile();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
disposePreviewFile();
|
||||
}
|
||||
};
|
||||
|
||||
export { handleImageUpload };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { ImageUploadPlugin } from "./image-upload";
|
||||
import { mergeAttributes, Range } from "@tiptap/core";
|
||||
|
||||
export interface ImageOptions extends DefaultImageOptions {
|
||||
@@ -10,11 +9,15 @@ export interface ImageOptions extends DefaultImageOptions {
|
||||
export interface ImageAttributes {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
aspectRatio?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -90,6 +93,17 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
"data-size": attributes.size,
|
||||
}),
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||
renderHTML: (attributes: ImageAttributes) => ({
|
||||
"data-aspect-ratio": attributes.aspectRatio,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -135,14 +149,9 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
ImageUploadPlugin({
|
||||
placeholderClass: "image-upload",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +63,9 @@ export const MathBlock = Node.create({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ export const MathInline = Node.create<MathInlineOption>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { Transaction } from "@tiptap/pm/state";
|
||||
import { Editor } from "@tiptap/core";
|
||||
|
||||
export type UploadFn = (
|
||||
file: File,
|
||||
view: EditorView,
|
||||
editor: Editor,
|
||||
pos: number,
|
||||
pageId: string,
|
||||
// only applicable to file attachments
|
||||
@@ -14,16 +13,3 @@ export interface MediaUploadOptions {
|
||||
validateFn?: (file: File, allowMedia?: boolean) => void;
|
||||
onUpload: (file: File, pageId: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export function insertTrailingNode(
|
||||
tr: Transaction,
|
||||
pos: number,
|
||||
view: EditorView,
|
||||
) {
|
||||
// create trailing node after decoration
|
||||
// if decoration is at the last node
|
||||
const currentDocSize = view.state.doc.content.size;
|
||||
if (pos + 1 === currentDocSize) {
|
||||
tr.insert(currentDocSize, view.state.schema.nodes.paragraph.create());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
import { Node as PMNode, Mark } from "@tiptap/pm/model";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Storage {
|
||||
searchAndReplace: SearchAndReplaceStorage;
|
||||
}
|
||||
interface Commands<ReturnType> {
|
||||
search: {
|
||||
/**
|
||||
@@ -184,21 +187,21 @@ const replace = (
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
|
||||
|
||||
// Get all marks that span the text being replaced
|
||||
const marksSet = new Set<Mark>();
|
||||
state.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.isText && node.marks) {
|
||||
node.marks.forEach(mark => marksSet.add(mark));
|
||||
node.marks.forEach((mark) => marksSet.add(mark));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const marks = Array.from(marksSet);
|
||||
|
||||
|
||||
// Delete the old text and insert new text with preserved marks
|
||||
tr.delete(from, to);
|
||||
tr.insert(from, state.schema.text(replaceTerm, marks));
|
||||
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
};
|
||||
@@ -215,17 +218,17 @@ const replaceAll = (
|
||||
// Process replacements in reverse order to avoid position shifting issues
|
||||
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
|
||||
const { from, to } = resultsCopy[i];
|
||||
|
||||
|
||||
// Get all marks that span the text being replaced
|
||||
const marksSet = new Set<Mark>();
|
||||
tr.doc.nodesBetween(from, to, (node) => {
|
||||
if (node.isText && node.marks) {
|
||||
node.marks.forEach(mark => marksSet.add(mark));
|
||||
node.marks.forEach((mark) => marksSet.add(mark));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const marks = Array.from(marksSet);
|
||||
|
||||
|
||||
// Delete and insert with preserved marks
|
||||
tr.delete(from, to);
|
||||
tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
|
||||
@@ -352,10 +355,17 @@ export const SearchAndReplace = Extension.create<
|
||||
// The results will be recalculated by the plugin, but we need to ensure
|
||||
// the index doesn't exceed the new bounds
|
||||
setTimeout(() => {
|
||||
const newResultsLength = editor.storage.searchAndReplace.results.length;
|
||||
if (newResultsLength > 0 && editor.storage.searchAndReplace.resultIndex >= newResultsLength) {
|
||||
const newResultsLength =
|
||||
editor.storage.searchAndReplace.results.length;
|
||||
if (
|
||||
newResultsLength > 0 &&
|
||||
editor.storage.searchAndReplace.resultIndex >= newResultsLength
|
||||
) {
|
||||
// Keep the same position if possible, otherwise go to the last result
|
||||
editor.storage.searchAndReplace.resultIndex = Math.min(resultIndex, newResultsLength - 1);
|
||||
editor.storage.searchAndReplace.resultIndex = Math.min(
|
||||
resultIndex,
|
||||
newResultsLength - 1,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SharedStorage } from "./shared-storage";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Storage {
|
||||
shared: Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
const SharedStorage = Extension.create({
|
||||
name: "shared",
|
||||
|
||||
addStorage() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
export { SharedStorage };
|
||||
@@ -44,7 +44,7 @@ export const Subpages = Node.create<SubpagesOptions>({
|
||||
mergeAttributes(
|
||||
{ "data-type": this.name },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
HTMLAttributes
|
||||
),
|
||||
];
|
||||
},
|
||||
@@ -63,6 +63,9 @@ export const Subpages = Node.create<SubpagesOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
|
||||
import { TableCell as TiptapTableCell } from "@tiptap/extension-table";
|
||||
|
||||
export const TableCell = TiptapTableCell.extend({
|
||||
name: "tableCell",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Editor, Extension } from "@tiptap/core";
|
||||
import { PluginKey, Plugin, PluginSpec } from "@tiptap/pm/state";
|
||||
import { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||
import { DraggingDOMs, getDndRelatedDOMs, getHoveringCell, HoveringCellInfo } from "./utils";
|
||||
import {
|
||||
DraggingDOMs,
|
||||
getDndRelatedDOMs,
|
||||
getHoveringCell,
|
||||
HoveringCellInfo,
|
||||
} from "./utils";
|
||||
import { getDragOverColumn, getDragOverRow } from "./calc-drag-over";
|
||||
import { moveColumn, moveRow } from "../utils";
|
||||
import { PreviewController } from "./preview/preview-controller";
|
||||
@@ -10,268 +15,302 @@ 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 const TableDndKey = new PluginKey("table-drag-and-drop");
|
||||
|
||||
class TableDragHandlePluginSpec implements PluginSpec<void> {
|
||||
key = TableDndKey
|
||||
props: EditorProps<Plugin<void>>
|
||||
key = TableDndKey;
|
||||
props: EditorProps<Plugin<void>>;
|
||||
|
||||
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 _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;
|
||||
|
||||
constructor(public editor: Editor) {
|
||||
this.props = {
|
||||
handleDOMEvents: {
|
||||
pointerover: this._pointerOver,
|
||||
}
|
||||
}
|
||||
constructor(public editor: Editor) {
|
||||
this.props = {
|
||||
handleDOMEvents: {
|
||||
pointerover: this._pointerOver,
|
||||
},
|
||||
};
|
||||
|
||||
this._dragHandleController = new DragHandleController();
|
||||
this._colDragHandle = this._dragHandleController.colDragHandle;
|
||||
this._rowDragHandle = this._dragHandleController.rowDragHandle;
|
||||
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._previewController = new PreviewController();
|
||||
this._dropIndicatorController = new DropIndicatorController();
|
||||
this._emptyImageController = new EmptyImageController();
|
||||
|
||||
this._autoScrollController = new AutoScrollController();
|
||||
this._autoScrollController = new AutoScrollController();
|
||||
|
||||
this._bindDragEvents();
|
||||
this._bindDragEvents();
|
||||
}
|
||||
|
||||
view = () => {
|
||||
const wrapper = this.editor.options.element;
|
||||
//@ts-ignore
|
||||
wrapper.appendChild(this._colDragHandle);
|
||||
//@ts-ignore
|
||||
wrapper.appendChild(this._rowDragHandle);
|
||||
//@ts-ignore
|
||||
wrapper.appendChild(this._previewController.previewRoot);
|
||||
//@ts-ignore
|
||||
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot);
|
||||
|
||||
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());
|
||||
};
|
||||
|
||||
private _pointerOver = (view: EditorView, event: PointerEvent) => {
|
||||
if (this._dragging) return;
|
||||
|
||||
// Don't show drag handles in readonly mode
|
||||
if (!this.editor.isEditable) {
|
||||
this._dragHandleController.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
view = () => {
|
||||
const wrapper = this.editor.options.element;
|
||||
wrapper.appendChild(this._colDragHandle)
|
||||
wrapper.appendChild(this._rowDragHandle)
|
||||
wrapper.appendChild(this._previewController.previewRoot)
|
||||
wrapper.appendChild(this._dropIndicatorController.dropIndicatorRoot)
|
||||
const hoveringCell = getHoveringCell(view, event);
|
||||
this._hoveringCell = hoveringCell;
|
||||
if (!hoveringCell) {
|
||||
this._dragHandleController.hide();
|
||||
} else {
|
||||
this._dragHandleController.show(this.editor, hoveringCell);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
update: this.update,
|
||||
destroy: this.destroy,
|
||||
}
|
||||
private _onDragColStart = (event: DragEvent) => {
|
||||
this._onDragStart(event, "col");
|
||||
};
|
||||
|
||||
private _onDraggingCol = (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,
|
||||
"col",
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
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 = () => {
|
||||
if (!this._dragging) return;
|
||||
const direction = this._draggingDirection;
|
||||
const from = this._draggingIndex;
|
||||
const to = this._droppingIndex;
|
||||
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) {
|
||||
this.editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
update = () => {}
|
||||
if (direction === "row") {
|
||||
const canMove = moveRow({
|
||||
tr,
|
||||
originIndex: from,
|
||||
targetIndex: to,
|
||||
select: true,
|
||||
pos,
|
||||
});
|
||||
if (canMove) {
|
||||
this.editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private _pointerOver = (view: EditorView, event: PointerEvent) => {
|
||||
if (this._dragging) return;
|
||||
|
||||
// Don't show drag handles in readonly mode
|
||||
if (!this.editor.isEditable) {
|
||||
this._dragHandleController.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const hoveringCell = getHoveringCell(view, event)
|
||||
this._hoveringCell = hoveringCell;
|
||||
if (!hoveringCell) {
|
||||
this._dragHandleController.hide();
|
||||
} else {
|
||||
this._dragHandleController.show(this.editor, hoveringCell);
|
||||
}
|
||||
}
|
||||
|
||||
private _onDragColStart = (event: DragEvent) => {
|
||||
this._onDragStart(event, 'col');
|
||||
}
|
||||
|
||||
private _onDraggingCol = (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, 'col');
|
||||
|
||||
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);
|
||||
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 = () => {
|
||||
if (!this._dragging) return;
|
||||
const direction = this._draggingDirection;
|
||||
const from = this._draggingIndex;
|
||||
const to = this._droppingIndex;
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const TableDndExtension = Extension.create({
|
||||
name: 'table-drag-and-drop',
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor
|
||||
name: "table-drag-and-drop",
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
|
||||
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor)
|
||||
const dragHandlePlugin = new Plugin(dragHandlePluginSpec)
|
||||
const dragHandlePluginSpec = new TableDragHandlePluginSpec(editor);
|
||||
const dragHandlePlugin = new Plugin(dragHandlePluginSpec);
|
||||
|
||||
return [dragHandlePlugin]
|
||||
}
|
||||
})
|
||||
return [dragHandlePlugin];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header";
|
||||
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table";
|
||||
|
||||
export const TableHeader = TiptapTableHeader.extend({
|
||||
name: "tableHeader",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import TiptapTableRow from "@tiptap/extension-table-row";
|
||||
import { TableRow as TiptapTableRow } from "@tiptap/extension-table";
|
||||
|
||||
export const TableRow = TiptapTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
content: "(tableCell | tableHeader)*",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Table from "@tiptap/extension-table";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { DOMOutputSpec } from "@tiptap/pm/model";
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { removeDuplicates } from './removeDuplicates.js'
|
||||
|
||||
/**
|
||||
* Returns a list of duplicated items within an array.
|
||||
*/
|
||||
export function findDuplicates(items: any[]): any[] {
|
||||
const filtered = items.filter((el, index) => items.indexOf(el) !== index)
|
||||
const duplicates = removeDuplicates(filtered)
|
||||
|
||||
return duplicates
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Removes duplicated values within an array.
|
||||
* Supports numbers, strings and objects.
|
||||
*/
|
||||
export function removeDuplicates<T>(array: T[], by = JSON.stringify): T[] {
|
||||
const seen: Record<any, any> = {}
|
||||
|
||||
return array.filter(item => {
|
||||
const key = by(item)
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(seen, key)
|
||||
? false
|
||||
: (seen[key] = true)
|
||||
})
|
||||
}
|
||||
@@ -1,386 +1,11 @@
|
||||
import {
|
||||
combineTransactionSteps,
|
||||
Extension,
|
||||
findChildren,
|
||||
findChildrenInRange,
|
||||
getChangedRanges,
|
||||
} from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Fragment, Slice } from "@tiptap/pm/model";
|
||||
import type { Transaction } from "@tiptap/pm/state";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
import { findDuplicates } from "./helpers/findDuplicates.js";
|
||||
import { generateNodeId } from "../utils";
|
||||
import { UniqueID as TiptapUniqueID } from "@tiptap/extension-unique-id";
|
||||
|
||||
export type UniqueIDGenerationContext = {
|
||||
node: ProseMirrorNode;
|
||||
pos: number;
|
||||
};
|
||||
|
||||
export interface UniqueIDOptions {
|
||||
/**
|
||||
* The name of the attribute to add the unique ID to.
|
||||
* @default "id"
|
||||
*/
|
||||
attributeName: string;
|
||||
/**
|
||||
* The types of nodes to add unique IDs to.
|
||||
* @default []
|
||||
*/
|
||||
types: string[];
|
||||
/**
|
||||
* The function that generates the unique ID. By default, a UUID v4 is
|
||||
* generated. However, you can provide your own function to generate the
|
||||
* unique ID based on the node type and the position.
|
||||
*/
|
||||
generateID: (ctx: UniqueIDGenerationContext) => any;
|
||||
/**
|
||||
* Ignore some mutations, for example applied from other users through the collaboration plugin.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
filterTransaction: ((transaction: Transaction) => boolean) | null;
|
||||
/**
|
||||
* Whether to update the document by adding unique IDs to the nodes. Set this
|
||||
* property to `false` if the document is in `readonly` mode, is immutable, or
|
||||
* you don't want it to be modified.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
updateDocument: boolean;
|
||||
}
|
||||
|
||||
export const UniqueID = Extension.create<UniqueIDOptions>({
|
||||
name: "uniqueID",
|
||||
|
||||
// we’ll set a very high priority to make sure this runs first
|
||||
// and is compatible with `appendTransaction` hooks of other extensions
|
||||
priority: 10000,
|
||||
|
||||
export const UniqueID = TiptapUniqueID.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
attributeName: "id",
|
||||
types: [],
|
||||
...this.parent?.(),
|
||||
generateID: () => generateNodeId(),
|
||||
filterTransaction: null,
|
||||
updateDocument: true,
|
||||
};
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
[this.options.attributeName]: {
|
||||
default: null,
|
||||
parseHTML: (element) =>
|
||||
element.getAttribute(`data-${this.options.attributeName}`),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes[this.options.attributeName]) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
[`data-${this.options.attributeName}`]:
|
||||
attributes[this.options.attributeName],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// check initial content for missing ids
|
||||
onCreate() {
|
||||
if (!this.options.updateDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collaboration = this.editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "collaboration",
|
||||
);
|
||||
const collaborationCursor = this.editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === "collaborationCursor",
|
||||
);
|
||||
|
||||
const collabExtensions = [collaboration, collaborationCursor].filter(
|
||||
Boolean,
|
||||
);
|
||||
const collab = collabExtensions.find((ext) => ext?.options?.provider);
|
||||
const provider = collab?.options?.provider;
|
||||
|
||||
const createIds = () => {
|
||||
const { view, state } = this.editor;
|
||||
const { tr, doc } = state;
|
||||
const { types, attributeName, generateID } = this.options;
|
||||
const nodesWithoutId = findChildren(doc, (node) => {
|
||||
return (
|
||||
types.includes(node.type.name) && node.attrs[attributeName] === null
|
||||
);
|
||||
});
|
||||
|
||||
nodesWithoutId.forEach(({ node, pos }) => {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
});
|
||||
});
|
||||
|
||||
tr.setMeta("addToHistory", false);
|
||||
|
||||
view.dispatch(tr);
|
||||
|
||||
if (provider) {
|
||||
provider.off("synced", createIds);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* We need to handle collaboration a bit different here
|
||||
* because we can't automatically add IDs when the provider is not yet synced
|
||||
* otherwise we end up with empty paragraphs
|
||||
*/
|
||||
if (collab) {
|
||||
if (!provider) {
|
||||
return createIds();
|
||||
}
|
||||
|
||||
provider.on("synced", createIds);
|
||||
} else {
|
||||
return createIds();
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
if (!this.options.updateDocument) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let dragSourceElement: Element | null = null;
|
||||
let transformPasted = false;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("uniqueID"),
|
||||
|
||||
appendTransaction: (transactions, oldState, newState) => {
|
||||
const hasDocChanges =
|
||||
transactions.some((transaction) => transaction.docChanged) &&
|
||||
!oldState.doc.eq(newState.doc);
|
||||
const filterTransactions =
|
||||
this.options.filterTransaction &&
|
||||
transactions.some((tr) => !this.options.filterTransaction?.(tr));
|
||||
|
||||
const isCollabTransaction = transactions.find((tr) =>
|
||||
tr.getMeta("y-sync$"),
|
||||
);
|
||||
|
||||
if (isCollabTransaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasDocChanges || filterTransactions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { tr } = newState;
|
||||
|
||||
const { types, attributeName, generateID } = this.options;
|
||||
const transform = combineTransactionSteps(
|
||||
oldState.doc,
|
||||
transactions as Transaction[],
|
||||
);
|
||||
const { mapping } = transform;
|
||||
|
||||
// get changed ranges based on the old state
|
||||
const changes = getChangedRanges(transform);
|
||||
|
||||
changes.forEach(({ newRange }) => {
|
||||
const newNodes = findChildrenInRange(
|
||||
newState.doc,
|
||||
newRange,
|
||||
(node) => {
|
||||
return types.includes(node.type.name);
|
||||
},
|
||||
);
|
||||
|
||||
const newIds = newNodes
|
||||
.map(({ node }) => node.attrs[attributeName])
|
||||
.filter((id) => id !== null);
|
||||
|
||||
newNodes.forEach(({ node, pos }, i) => {
|
||||
// instead of checking `node.attrs[attributeName]` directly
|
||||
// we look at the current state of the node within `tr.doc`.
|
||||
// this helps to prevent adding new ids to the same node
|
||||
// if the node changed multiple times within one transaction
|
||||
const id = tr.doc.nodeAt(pos)?.attrs[attributeName];
|
||||
|
||||
if (id === null) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const nextNode = newNodes[i + 1];
|
||||
|
||||
if (nextNode && node.content.size === 0) {
|
||||
tr.setNodeMarkup(nextNode.pos, undefined, {
|
||||
...nextNode.node.attrs,
|
||||
[attributeName]: id,
|
||||
});
|
||||
newIds[i + 1] = id;
|
||||
|
||||
if (nextNode.node.attrs[attributeName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generatedId = generateID({ node, pos });
|
||||
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generatedId,
|
||||
});
|
||||
newIds[i] = generatedId;
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
const duplicatedNewIds = findDuplicates(newIds);
|
||||
|
||||
// check if the node doesn’t exist in the old state
|
||||
const { deleted } = mapping.invert().mapResult(pos);
|
||||
|
||||
const newNode = deleted && duplicatedNewIds.includes(id);
|
||||
|
||||
if (newNode) {
|
||||
tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
[attributeName]: generateID({ node, pos }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!tr.steps.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `tr.setNodeMarkup` resets the stored marks
|
||||
// so we'll restore them if they exist
|
||||
tr.setStoredMarks(newState.tr.storedMarks);
|
||||
|
||||
// Mark this transaction as coming from UniqueID
|
||||
// to prevent infinite loops with other extensions (e.g., TrailingNode)
|
||||
tr.setMeta("__uniqueIDTransaction", true);
|
||||
|
||||
return tr;
|
||||
},
|
||||
|
||||
// we register a global drag handler to track the current drag source element
|
||||
view(view) {
|
||||
const handleDragstart = (event: DragEvent) => {
|
||||
dragSourceElement = view.dom.parentElement?.contains(
|
||||
event.target as Element,
|
||||
)
|
||||
? view.dom.parentElement
|
||||
: null;
|
||||
};
|
||||
|
||||
window.addEventListener("dragstart", handleDragstart);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener("dragstart", handleDragstart);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
props: {
|
||||
// `handleDOMEvents` is called before `transformPasted`
|
||||
// so we can do some checks before
|
||||
handleDOMEvents: {
|
||||
// only create new ids for dropped content
|
||||
// or dropped content while holding `alt`
|
||||
// or content is dragged from another editor
|
||||
drop: (view, event) => {
|
||||
if (
|
||||
dragSourceElement !== view.dom.parentElement ||
|
||||
event.dataTransfer?.effectAllowed === "copyMove" ||
|
||||
event.dataTransfer?.effectAllowed === "copy"
|
||||
) {
|
||||
dragSourceElement = null;
|
||||
transformPasted = true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// always create new ids on pasted content
|
||||
paste: () => {
|
||||
transformPasted = true;
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
|
||||
// we’ll remove ids for every pasted node
|
||||
// so we can create a new one within `appendTransaction`
|
||||
transformPasted: (slice) => {
|
||||
if (!transformPasted) {
|
||||
return slice;
|
||||
}
|
||||
|
||||
const { types, attributeName } = this.options;
|
||||
const removeId = (fragment: Fragment): Fragment => {
|
||||
const list: ProseMirrorNode[] = [];
|
||||
|
||||
fragment.forEach((node) => {
|
||||
// don’t touch text nodes
|
||||
if (node.isText) {
|
||||
list.push(node);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// check for any other child nodes
|
||||
if (!types.includes(node.type.name)) {
|
||||
list.push(node.copy(removeId(node.content)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// remove id
|
||||
const nodeWithoutId = node.type.create(
|
||||
{
|
||||
...node.attrs,
|
||||
[attributeName]: null,
|
||||
},
|
||||
removeId(node.content),
|
||||
node.marks,
|
||||
);
|
||||
|
||||
list.push(nodeWithoutId);
|
||||
});
|
||||
|
||||
return Fragment.from(list);
|
||||
};
|
||||
|
||||
// reset check
|
||||
transformPasted = false;
|
||||
|
||||
return new Slice(
|
||||
removeId(slice.content),
|
||||
slice.openStart,
|
||||
slice.openEnd,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { Editor, findParentNode, isTextSelection } from "@tiptap/core";
|
||||
import { Selection, Transaction } from "@tiptap/pm/state";
|
||||
import { EditorState, Selection, Transaction } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
import { Node, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||
@@ -287,11 +287,7 @@ export const isColumnGripSelected = ({
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (
|
||||
!editor.isActive("table") ||
|
||||
!node ||
|
||||
isTableSelected(state.selection)
|
||||
) {
|
||||
if (!editor.isActive("table") || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -324,11 +320,7 @@ export const isRowGripSelected = ({
|
||||
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (
|
||||
!editor.isActive(Table.name) ||
|
||||
!node ||
|
||||
isTableSelected(state.selection)
|
||||
) {
|
||||
if (!editor.isActive("table") || !node || isTableSelected(state.selection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,132 +1,169 @@
|
||||
import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import {
|
||||
insertTrailingNode,
|
||||
MediaUploadOptions,
|
||||
UploadFn,
|
||||
} from "../media-utils";
|
||||
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 uploadKey = new PluginKey("video-upload");
|
||||
const findVideoNodeByPlaceholderId = (
|
||||
doc: Node,
|
||||
placeholderId: string,
|
||||
): { node: Node; pos: number } | null => {
|
||||
let result: { node: Node; pos: number } | null = null;
|
||||
|
||||
export const VideoUploadPlugin = ({
|
||||
placeholderClass,
|
||||
}: {
|
||||
placeholderClass: string;
|
||||
}) =>
|
||||
new Plugin({
|
||||
key: uploadKey,
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set) {
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
// See if the transaction adds or removes any placeholders
|
||||
//@-ts-expect-error - not yet sure what the type I need here
|
||||
const action = tr.getMeta(this);
|
||||
if (action?.add) {
|
||||
const { id, pos, src } = action.add;
|
||||
doc.descendants((node, pos) => {
|
||||
if (result) return false;
|
||||
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "video-placeholder");
|
||||
const video = document.createElement("video");
|
||||
video.setAttribute("class", placeholderClass);
|
||||
video.src = src;
|
||||
placeholder.appendChild(video);
|
||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
||||
id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
} else if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id == action.remove.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
if (
|
||||
node.type.name === "video" &&
|
||||
node.attrs.placeholder?.id === placeholderId
|
||||
) {
|
||||
result = { node, pos };
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
function findPlaceholder(state: EditorState, id: {}) {
|
||||
const decos = uploadKey.getState(state) as DecorationSet;
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id == id);
|
||||
return found.length ? found[0]?.from : null;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const getVideoDimensions = (
|
||||
url: string,
|
||||
): Promise<
|
||||
{ width: number; height: number; aspectRatio: number } | undefined
|
||||
> => {
|
||||
return new Promise<
|
||||
{ width: number; height: number; aspectRatio: number } | undefined
|
||||
>((resolve) => {
|
||||
const video = document.createElement("video");
|
||||
|
||||
export const handleVideoUpload =
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => {
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
const aspectRatio = height > 0 ? width / height : 1;
|
||||
|
||||
resolve({ width, height, aspectRatio });
|
||||
};
|
||||
video.onerror = () => {
|
||||
resolve(undefined);
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
};
|
||||
const handleVideoUpload =
|
||||
({ validateFn, onUpload }: MediaUploadOptions): UploadFn =>
|
||||
async (file, view, pos, pageId) => {
|
||||
// check if the file is an image
|
||||
async (file, editor, pos, pageId) => {
|
||||
// check if the file is valid
|
||||
const validated = validateFn?.(file);
|
||||
// @ts-ignore
|
||||
if (!validated) return;
|
||||
// A fresh object to act as the ID for this upload
|
||||
const id = {};
|
||||
|
||||
// Replace the selection with a placeholder
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const videoDimensions = await getVideoDimensions(objectUrl);
|
||||
const placeholderId = generateNodeId();
|
||||
const aspectRatio = videoDimensions.aspectRatio;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const tr = view.state.tr;
|
||||
if (!tr.selection.empty) tr.deleteSelection();
|
||||
let placeholderInserted = false;
|
||||
|
||||
tr.setMeta(uploadKey, {
|
||||
add: {
|
||||
id,
|
||||
pos,
|
||||
src: reader.result,
|
||||
},
|
||||
});
|
||||
editor.storage.shared.videoPreviews =
|
||||
editor.storage.shared.videoPreviews || {};
|
||||
editor.storage.shared.videoPreviews[placeholderId] = objectUrl;
|
||||
|
||||
insertTrailingNode(tr, pos, view);
|
||||
view.dispatch(tr);
|
||||
const insertPlaceholder = (): Command => {
|
||||
return ({ tr, state }) => {
|
||||
const initialPlaceholderNode = state.schema.nodes.video?.create({
|
||||
placeholder: {
|
||||
id: placeholderId,
|
||||
name: file.name,
|
||||
},
|
||||
aspectRatio,
|
||||
});
|
||||
|
||||
if (!initialPlaceholderNode) return false;
|
||||
|
||||
const { parent } = tr.doc.resolve(pos);
|
||||
const isEmptyTextBlock = parent.isTextblock && !parent.childCount;
|
||||
|
||||
if (isEmptyTextBlock) {
|
||||
// Replace e.g. empty paragraph with the video
|
||||
tr.replaceRangeWith(pos - 1, pos + 1, initialPlaceholderNode);
|
||||
} else {
|
||||
tr.insert(pos, initialPlaceholderNode);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const replacePlaceholderWithVideo = (attachment: IAttachment): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
await onUpload(file, pageId).then(
|
||||
(attachment: IAttachment) => {
|
||||
const { schema } = view.state;
|
||||
// If the placeholder is not found or attachment is missing, abort the process
|
||||
if (currentPos === null || !attachment) return;
|
||||
|
||||
const pos = findPlaceholder(view.state, id);
|
||||
|
||||
// If the content around the placeholder has been deleted, drop
|
||||
// the image
|
||||
if (pos == null) return;
|
||||
|
||||
// Otherwise, insert it at the placeholder's position, and remove
|
||||
// the placeholder
|
||||
|
||||
if (!attachment) return;
|
||||
|
||||
const node = schema.nodes.video?.create({
|
||||
// Update the placeholder node with the actual video data
|
||||
tr.setNodeMarkup(currentPos, undefined, {
|
||||
src: `/api/files/${attachment.id}/${attachment.fileName}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.fileName,
|
||||
size: attachment.fileSize,
|
||||
aspectRatio,
|
||||
});
|
||||
if (!node) return;
|
||||
|
||||
const transaction = view.state.tr
|
||||
.replaceWith(pos, pos, node)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
() => {
|
||||
// Deletes the image placeholder on error
|
||||
const transaction = view.state.tr
|
||||
.delete(pos, pos)
|
||||
.setMeta(uploadKey, { remove: { id } });
|
||||
view.dispatch(transaction);
|
||||
},
|
||||
);
|
||||
return true;
|
||||
};
|
||||
};
|
||||
const removePlaceholder = (): Command => {
|
||||
return ({ tr }) => {
|
||||
const { pos: currentPos = null } =
|
||||
findVideoNodeByPlaceholderId(tr.doc, placeholderId) || {};
|
||||
|
||||
if (currentPos === null) return false;
|
||||
|
||||
tr.delete(currentPos, currentPos + 2);
|
||||
|
||||
return true;
|
||||
};
|
||||
};
|
||||
|
||||
// Only show the placeholder if the upload takes more than 250ms
|
||||
const insertPlaceholderTimeout = setTimeout(() => {
|
||||
editor.commands.command(insertPlaceholder());
|
||||
placeholderInserted = true;
|
||||
}, 250);
|
||||
const disposePreviewFile = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
if (editor.storage.shared.videoPreviews) {
|
||||
delete editor.storage.shared.videoPreviews[placeholderId];
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const attachment: IAttachment = await onUpload(file, pageId);
|
||||
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
if (placeholderInserted) {
|
||||
setTimeout(() => {
|
||||
editor.commands.command(replacePlaceholderWithVideo(attachment));
|
||||
disposePreviewFile();
|
||||
}, 100);
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.command(insertPlaceholder())
|
||||
.command(replacePlaceholderWithVideo(attachment))
|
||||
.run();
|
||||
disposePreviewFile();
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(insertPlaceholderTimeout);
|
||||
|
||||
editor.commands.command(removePlaceholder());
|
||||
disposePreviewFile();
|
||||
}
|
||||
};
|
||||
|
||||
export { handleVideoUpload };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { VideoUploadPlugin } from "./video-upload";
|
||||
import { mergeAttributes, Range, Node, nodeInputRule } from "@tiptap/core";
|
||||
import { Range, Node } from "@tiptap/core";
|
||||
|
||||
export interface VideoOptions {
|
||||
view: any;
|
||||
@@ -8,11 +7,15 @@ export interface VideoOptions {
|
||||
}
|
||||
export interface VideoAttributes {
|
||||
src?: string;
|
||||
title?: string;
|
||||
align?: string;
|
||||
attachmentId?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
aspectRatio?: number;
|
||||
placeholder?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -81,15 +84,26 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
||||
"data-align": attributes.align,
|
||||
}),
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||
renderHTML: (attributes: VideoAttributes) => ({
|
||||
"data-aspect-ratio": attributes.aspectRatio,
|
||||
}),
|
||||
},
|
||||
placeholder: {
|
||||
default: null,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'video',
|
||||
tag: "video",
|
||||
},
|
||||
]
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
@@ -126,14 +140,9 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
return ReactNodeViewRenderer(this.options.view);
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
VideoUploadPlugin({
|
||||
placeholderClass: "video-upload",
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user