From 3ae39b522d1207a046e8f430520c731b87566153 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:49:37 +0000 Subject: [PATCH] reusable media utils --- packages/editor-ext/src/lib/drawio.ts | 70 +++----------------- packages/editor-ext/src/lib/excalidraw.ts | 70 +++----------------- packages/editor-ext/src/lib/image/image.ts | 74 +++------------------- packages/editor-ext/src/lib/media-utils.ts | 73 +++++++++++++++++++++ packages/editor-ext/src/lib/video/video.ts | 70 +++----------------- 5 files changed, 105 insertions(+), 252 deletions(-) diff --git a/packages/editor-ext/src/lib/drawio.ts b/packages/editor-ext/src/lib/drawio.ts index d3b69c72..aee048b2 100644 --- a/packages/editor-ext/src/lib/drawio.ts +++ b/packages/editor-ext/src/lib/drawio.ts @@ -1,7 +1,12 @@ import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { normalizeFileUrl } from "./media-utils"; +import { + normalizeFileUrl, + applyAlignment, + createPlaceholderView, + setupMediaLoading, +} from "./media-utils"; export type DrawioResizeOptions = { enabled: boolean; @@ -205,22 +210,7 @@ export const Drawio = Node.create({ const { node, getPos, HTMLAttributes, editor } = props; if (!node.attrs.src) { - editor.isInitialized = true; - const reactView = ReactNodeViewRenderer(this.options.view); - const view = reactView(props); - - const originalUpdate = view.update?.bind(view); - view.update = (updatedNode, decorations, innerDecorations) => { - if (updatedNode.attrs.src && !node.attrs.src) { - return false; - } - if (originalUpdate) { - return originalUpdate(updatedNode, decorations, innerDecorations); - } - return true; - }; - - return view; + return createPlaceholderView(this.options.view, props); } const el = document.createElement("img"); @@ -291,54 +281,10 @@ export const Drawio = Node.create({ }, }); - const dom = nodeView.dom as HTMLElement; - - applyAlignment(dom, node.attrs.align || "center"); - - // Handle percentage width backward compat - const widthAttr = node.attrs.width; - if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { - requestAnimationFrame(() => { - const parentEl = dom.parentElement; - if (parentEl) { - const containerWidth = parentEl.clientWidth; - const pctValue = parseInt(widthAttr, 10); - if (!isNaN(pctValue) && containerWidth > 0) { - const pxWidth = Math.round( - containerWidth * (pctValue / 100), - ); - el.style.width = `${pxWidth}px`; - if (node.attrs.aspectRatio) { - el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; - } - } - } - dom.style.visibility = ""; - dom.style.pointerEvents = ""; - }); - } - - // Show skeleton background while image loads from server - dom.style.pointerEvents = "none"; - dom.style.background = - "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))"; - - el.onload = () => { - dom.style.pointerEvents = ""; - dom.style.background = ""; - }; + setupMediaLoading(nodeView.dom as HTMLElement, el, node); 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"; - } -} diff --git a/packages/editor-ext/src/lib/excalidraw.ts b/packages/editor-ext/src/lib/excalidraw.ts index 45142776..141dcaaa 100644 --- a/packages/editor-ext/src/lib/excalidraw.ts +++ b/packages/editor-ext/src/lib/excalidraw.ts @@ -1,7 +1,12 @@ import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core"; import type { ResizableNodeViewDirection } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { normalizeFileUrl } from "./media-utils"; +import { + normalizeFileUrl, + applyAlignment, + createPlaceholderView, + setupMediaLoading, +} from "./media-utils"; export type ExcalidrawResizeOptions = { enabled: boolean; @@ -205,22 +210,7 @@ export const Excalidraw = Node.create({ const { node, getPos, HTMLAttributes, editor } = props; if (!node.attrs.src) { - editor.isInitialized = true; - const reactView = ReactNodeViewRenderer(this.options.view); - const view = reactView(props); - - const originalUpdate = view.update?.bind(view); - view.update = (updatedNode, decorations, innerDecorations) => { - if (updatedNode.attrs.src && !node.attrs.src) { - return false; - } - if (originalUpdate) { - return originalUpdate(updatedNode, decorations, innerDecorations); - } - return true; - }; - - return view; + return createPlaceholderView(this.options.view, props); } const el = document.createElement("img"); @@ -291,54 +281,10 @@ export const Excalidraw = Node.create({ }, }); - const dom = nodeView.dom as HTMLElement; - - applyAlignment(dom, node.attrs.align || "center"); - - // Handle percentage width backward compat - const widthAttr = node.attrs.width; - if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { - requestAnimationFrame(() => { - const parentEl = dom.parentElement; - if (parentEl) { - const containerWidth = parentEl.clientWidth; - const pctValue = parseInt(widthAttr, 10); - if (!isNaN(pctValue) && containerWidth > 0) { - const pxWidth = Math.round( - containerWidth * (pctValue / 100), - ); - el.style.width = `${pxWidth}px`; - if (node.attrs.aspectRatio) { - el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; - } - } - } - dom.style.visibility = ""; - dom.style.pointerEvents = ""; - }); - } - - // Show skeleton background while image loads from server - dom.style.pointerEvents = "none"; - dom.style.background = - "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))"; - - el.onload = () => { - dom.style.pointerEvents = ""; - dom.style.background = ""; - }; + setupMediaLoading(nodeView.dom as HTMLElement, el, node); 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"; - } -} diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 8631d7fe..400906d3 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -6,7 +6,12 @@ import { Range, ResizableNodeView, } from "@tiptap/core"; -import { normalizeFileUrl } from "../media-utils"; +import { + normalizeFileUrl, + applyAlignment, + createPlaceholderView, + setupMediaLoading, +} from "../media-utils"; import type { ResizableNodeViewDirection } from "@tiptap/core"; export type ImageResizeOptions = { @@ -216,25 +221,8 @@ export const TiptapImage = Image.extend({ return (props) => { const { node, getPos, HTMLAttributes, editor } = props; - // If no src yet (placeholder/uploading), use React view for loading UI if (!HTMLAttributes.src) { - editor.isInitialized = true; - const reactView = ReactNodeViewRenderer(this.options.view); - const view = reactView(props); - - // When the node gets a src, return false from update to force rebuild - const originalUpdate = view.update?.bind(view); - view.update = (updatedNode, decorations, innerDecorations) => { - if (updatedNode.attrs.src && !node.attrs.src) { - return false; - } - if (originalUpdate) { - return originalUpdate(updatedNode, decorations, innerDecorations); - } - return true; - }; - - return view; + return createPlaceholderView(this.options.view, props); } // Has src — use ResizableNodeView @@ -331,56 +319,10 @@ export const TiptapImage = Image.extend({ }, }); - const dom = nodeView.dom as HTMLElement; - - // Apply initial alignment - applyAlignment(dom, node.attrs.align || "center"); - - // Handle percentage width backward compat - const widthAttr = node.attrs.width; - if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { - // Defer conversion until we can measure the container - requestAnimationFrame(() => { - const parentEl = dom.parentElement; - if (parentEl) { - const containerWidth = parentEl.clientWidth; - const pctValue = parseInt(widthAttr, 10); - if (!isNaN(pctValue) && containerWidth > 0) { - const pxWidth = Math.round( - containerWidth * (pctValue / 100), - ); - el.style.width = `${pxWidth}px`; - if (node.attrs.aspectRatio) { - el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; - } - } - } - dom.style.visibility = ""; - dom.style.pointerEvents = ""; - }); - } - - // Show skeleton background while image loads from server - dom.style.pointerEvents = "none"; - dom.style.background = - "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))"; - - el.onload = () => { - dom.style.pointerEvents = ""; - dom.style.background = ""; - }; + setupMediaLoading(nodeView.dom as HTMLElement, el, node); 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"; - } -} diff --git a/packages/editor-ext/src/lib/media-utils.ts b/packages/editor-ext/src/lib/media-utils.ts index d18bcb69..81946b71 100644 --- a/packages/editor-ext/src/lib/media-utils.ts +++ b/packages/editor-ext/src/lib/media-utils.ts @@ -1,4 +1,5 @@ import { Editor } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; export function normalizeFileUrl(src: string): string { if (src && src.startsWith("/files/")) { @@ -7,6 +8,78 @@ export function normalizeFileUrl(src: string): string { 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 = ( file: File, editor: Editor, diff --git a/packages/editor-ext/src/lib/video/video.ts b/packages/editor-ext/src/lib/video/video.ts index 75bc3910..1f5d1a62 100644 --- a/packages/editor-ext/src/lib/video/video.ts +++ b/packages/editor-ext/src/lib/video/video.ts @@ -1,6 +1,11 @@ import { ReactNodeViewRenderer } from "@tiptap/react"; 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"; export type VideoResizeOptions = { @@ -205,22 +210,7 @@ export const TiptapVideo = Node.create({ const { node, getPos, HTMLAttributes, editor } = props; if (!node.attrs.src) { - editor.isInitialized = true; - const reactView = ReactNodeViewRenderer(this.options.view); - const view = reactView(props); - - const originalUpdate = view.update?.bind(view); - view.update = (updatedNode, decorations, innerDecorations) => { - if (updatedNode.attrs.src && !node.attrs.src) { - return false; - } - if (originalUpdate) { - return originalUpdate(updatedNode, decorations, innerDecorations); - } - return true; - }; - - return view; + return createPlaceholderView(this.options.view, props); } const el = document.createElement("video"); @@ -299,54 +289,10 @@ export const TiptapVideo = Node.create({ }, }); - const dom = nodeView.dom as HTMLElement; - - applyAlignment(dom, node.attrs.align || "center"); - - // Handle percentage width backward compat - const widthAttr = node.attrs.width; - if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { - requestAnimationFrame(() => { - const parentEl = dom.parentElement; - if (parentEl) { - const containerWidth = parentEl.clientWidth; - const pctValue = parseInt(widthAttr, 10); - if (!isNaN(pctValue) && containerWidth > 0) { - const pxWidth = Math.round( - containerWidth * (pctValue / 100), - ); - el.style.width = `${pxWidth}px`; - if (node.attrs.aspectRatio) { - el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; - } - } - } - dom.style.visibility = ""; - dom.style.pointerEvents = ""; - }); - } - - // Show skeleton background while video loads from server - dom.style.pointerEvents = "none"; - dom.style.background = - "light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))"; - - el.onloadedmetadata = () => { - dom.style.pointerEvents = ""; - dom.style.background = ""; - }; + setupMediaLoading(nodeView.dom as HTMLElement, el, node, "loadedmetadata"); 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"; - } -}