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:
Philip Okugbe
2026-01-24 20:41:08 +00:00
committed by GitHub
parent 98f71c95fe
commit 657fdf8cb7
74 changed files with 2532 additions and 2370 deletions
@@ -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 {
+28 -18
View File
@@ -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);
},
});
+26 -23
View File
@@ -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);
},
});
+34 -24
View File
@@ -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);
},
});
+123 -105
View File
@@ -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 };
+19 -10
View File
@@ -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);
},
+2 -16
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -2
View File
@@ -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 -1
View File
@@ -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",
// well 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 doesnt 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;
},
},
// well 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) => {
// dont 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,
);
},
},
}),
];
},
});
+4 -12
View File
@@ -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;
}
+144 -107
View File
@@ -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 };
+22 -13
View File
@@ -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",
}),
];
},
});