reusable media utils

This commit is contained in:
Philipinho
2026-03-04 12:49:37 +00:00
parent b4f009513e
commit 3ae39b522d
5 changed files with 105 additions and 252 deletions
+8 -62
View File
@@ -1,7 +1,12 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core"; import type { ResizableNodeViewDirection } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils"; import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "./media-utils";
export type DrawioResizeOptions = { export type DrawioResizeOptions = {
enabled: boolean; enabled: boolean;
@@ -205,22 +210,7 @@ export const Drawio = Node.create<DrawioOptions>({
const { node, getPos, HTMLAttributes, editor } = props; const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) { if (!node.attrs.src) {
editor.isInitialized = true; return createPlaceholderView(this.options.view, props);
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
} }
const el = document.createElement("img"); const el = document.createElement("img");
@@ -291,54 +281,10 @@ export const Drawio = Node.create<DrawioOptions>({
}, },
}); });
const dom = nodeView.dom as HTMLElement; setupMediaLoading(nodeView.dom as HTMLElement, el, node);
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
return nodeView; return nodeView;
}; };
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+8 -62
View File
@@ -1,7 +1,12 @@
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import type { ResizableNodeViewDirection } from "@tiptap/core"; import type { ResizableNodeViewDirection } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { normalizeFileUrl } from "./media-utils"; import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "./media-utils";
export type ExcalidrawResizeOptions = { export type ExcalidrawResizeOptions = {
enabled: boolean; enabled: boolean;
@@ -205,22 +210,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
const { node, getPos, HTMLAttributes, editor } = props; const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) { if (!node.attrs.src) {
editor.isInitialized = true; return createPlaceholderView(this.options.view, props);
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
} }
const el = document.createElement("img"); const el = document.createElement("img");
@@ -291,54 +281,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
}, },
}); });
const dom = nodeView.dom as HTMLElement; setupMediaLoading(nodeView.dom as HTMLElement, el, node);
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
return nodeView; return nodeView;
}; };
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
+8 -66
View File
@@ -6,7 +6,12 @@ import {
Range, Range,
ResizableNodeView, ResizableNodeView,
} from "@tiptap/core"; } from "@tiptap/core";
import { normalizeFileUrl } from "../media-utils"; import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core"; import type { ResizableNodeViewDirection } from "@tiptap/core";
export type ImageResizeOptions = { export type ImageResizeOptions = {
@@ -216,25 +221,8 @@ export const TiptapImage = Image.extend<ImageOptions>({
return (props) => { return (props) => {
const { node, getPos, HTMLAttributes, editor } = props; const { node, getPos, HTMLAttributes, editor } = props;
// If no src yet (placeholder/uploading), use React view for loading UI
if (!HTMLAttributes.src) { if (!HTMLAttributes.src) {
editor.isInitialized = true; return createPlaceholderView(this.options.view, props);
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
// When the node gets a src, return false from update to force rebuild
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
} }
// Has src — use ResizableNodeView // Has src — use ResizableNodeView
@@ -331,56 +319,10 @@ export const TiptapImage = Image.extend<ImageOptions>({
}, },
}); });
const dom = nodeView.dom as HTMLElement; setupMediaLoading(nodeView.dom as HTMLElement, el, node);
// Apply initial alignment
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
// Defer conversion until we can measure the container
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while image loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onload = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
return nodeView; return nodeView;
}; };
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
@@ -1,4 +1,5 @@
import { Editor } from "@tiptap/core"; import { Editor } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
export function normalizeFileUrl(src: string): string { export function normalizeFileUrl(src: string): string {
if (src && src.startsWith("/files/")) { if (src && src.startsWith("/files/")) {
@@ -7,6 +8,78 @@ export function normalizeFileUrl(src: string): string {
return src || ""; return src || "";
} }
export function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}
export function createPlaceholderView(viewComponent: any, props: any) {
const { node, editor } = props;
editor.isInitialized = true;
const reactView = ReactNodeViewRenderer(viewComponent);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode: any, decorations: any, innerDecorations: any) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
}
export function setupMediaLoading(
dom: HTMLElement,
el: HTMLElement,
node: any,
loadEvent: "load" | "loadedmetadata" = "load",
) {
applyAlignment(dom, node.attrs.align || "center");
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(containerWidth * (pctValue / 100));
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.addEventListener(
loadEvent,
() => {
dom.style.pointerEvents = "";
dom.style.background = "";
},
{ once: true },
);
}
export type UploadFn = ( export type UploadFn = (
file: File, file: File,
editor: Editor, editor: Editor,
+8 -62
View File
@@ -1,6 +1,11 @@
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from "@tiptap/react";
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
import { normalizeFileUrl } from "../media-utils"; import {
normalizeFileUrl,
applyAlignment,
createPlaceholderView,
setupMediaLoading,
} from "../media-utils";
import type { ResizableNodeViewDirection } from "@tiptap/core"; import type { ResizableNodeViewDirection } from "@tiptap/core";
export type VideoResizeOptions = { export type VideoResizeOptions = {
@@ -205,22 +210,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
const { node, getPos, HTMLAttributes, editor } = props; const { node, getPos, HTMLAttributes, editor } = props;
if (!node.attrs.src) { if (!node.attrs.src) {
editor.isInitialized = true; return createPlaceholderView(this.options.view, props);
const reactView = ReactNodeViewRenderer(this.options.view);
const view = reactView(props);
const originalUpdate = view.update?.bind(view);
view.update = (updatedNode, decorations, innerDecorations) => {
if (updatedNode.attrs.src && !node.attrs.src) {
return false;
}
if (originalUpdate) {
return originalUpdate(updatedNode, decorations, innerDecorations);
}
return true;
};
return view;
} }
const el = document.createElement("video"); const el = document.createElement("video");
@@ -299,54 +289,10 @@ export const TiptapVideo = Node.create<VideoOptions>({
}, },
}); });
const dom = nodeView.dom as HTMLElement; setupMediaLoading(nodeView.dom as HTMLElement, el, node, "loadedmetadata");
applyAlignment(dom, node.attrs.align || "center");
// Handle percentage width backward compat
const widthAttr = node.attrs.width;
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
requestAnimationFrame(() => {
const parentEl = dom.parentElement;
if (parentEl) {
const containerWidth = parentEl.clientWidth;
const pctValue = parseInt(widthAttr, 10);
if (!isNaN(pctValue) && containerWidth > 0) {
const pxWidth = Math.round(
containerWidth * (pctValue / 100),
);
el.style.width = `${pxWidth}px`;
if (node.attrs.aspectRatio) {
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
}
}
}
dom.style.visibility = "";
dom.style.pointerEvents = "";
});
}
// Show skeleton background while video loads from server
dom.style.pointerEvents = "none";
dom.style.background =
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))";
el.onloadedmetadata = () => {
dom.style.pointerEvents = "";
dom.style.background = "";
};
return nodeView; return nodeView;
}; };
}, },
}); });
function applyAlignment(container: HTMLElement, align: string) {
if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";
} else {
container.style.justifyContent = "center";
}
}