mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
reusable media utils
This commit is contained in:
@@ -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<DrawioOptions>({
|
||||
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<DrawioOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ExcalidrawOptions>({
|
||||
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<ExcalidrawOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ImageOptions>({
|
||||
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<ImageOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<VideoOptions>({
|
||||
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<VideoOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user