Compare commits

..

1 Commits

Author SHA1 Message Date
Philipinho 3ae39b522d reusable media utils 2026-03-04 12:49:37 +00:00
24 changed files with 376 additions and 859 deletions
@@ -24,12 +24,7 @@ function CommentActions({
</Button>
)}
<Button
size="compact-sm"
loading={isLoading}
onClick={onSave}
onMouseDown={(e) => e.preventDefault()}
>
<Button size="compact-sm" loading={isLoading} onClick={onSave}>
{t("Save")}
</Button>
</Group>
@@ -27,9 +27,6 @@ import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() {
const { t } = useTranslation();
@@ -348,7 +345,6 @@ const PageCommentInput = ({ onSave, isLoading }) => {
const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null);
const [currentUser] = useAtom(currentUserAtom);
const handleSave = useCallback(() => {
onSave(null, content);
@@ -367,30 +363,19 @@ const PageCommentInput = ({ onSave, isLoading }) => {
position: "relative",
}}
>
<Group wrap="nowrap" align="flex-start" gap="xs">
<CustomAvatar
size="sm"
avatarUrl={currentUser?.user?.avatarUrl}
name={currentUser?.user?.name}
style={{ flexShrink: 0, marginTop: 10 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
</div>
</Group>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
{focused && (
<ActionIcon
variant="filled"
radius="xl"
size="sm"
onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }}
>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
@@ -9,7 +9,6 @@ import {
import {
ActionIcon,
Modal,
Text,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
@@ -30,12 +29,10 @@ import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventExport,
EventSave,
} from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -44,8 +41,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const editorState = useEditorState({
editor,
@@ -136,13 +131,33 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection();
}, [editor]);
const saveData = useCallback(async (svgXml: string) => {
if (isSavingRef.current) return;
isSavingRef.current = true;
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const svgString = decodeBase64ToSvgString(svgXml);
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -164,85 +179,10 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
});
}, [close, t]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
isDirtyRef.current = false;
open();
}
}, [editorState?.src, open]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 60_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
},
[editor, editorState?.attachmentId, close],
);
return (
<>
@@ -336,7 +276,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div>
</BaseBubbleMenu>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
@@ -345,7 +285,6 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
autosave
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
@@ -357,19 +296,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") {
return;
}
saveData(data.xml).then(() => close()).catch(() => {});
handleSave(data);
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
close();
}}
/>
</div>
@@ -6,7 +6,7 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl } from "@/lib/config.ts";
@@ -14,7 +14,6 @@ import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventExport,
EventSave,
} from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types";
@@ -22,7 +21,6 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation();
@@ -32,108 +30,42 @@ export default function DrawioView(props: NodeViewProps) {
const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
isDirtyRef.current = false;
open();
};
const saveData = async (svgXml: string, updateSrc = true) => {
if (isSavingRef.current) return;
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
isSavingRef.current = true;
//@ts-ignore
const pageId = editor.storage?.pageId;
try {
const svgString = decodeBase64ToSvgString(svgXml);
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
}, [close, t]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 30_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
close();
};
return (
<NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body>
@@ -142,7 +74,6 @@ export default function DrawioView(props: NodeViewProps) {
ref={drawioRef}
xml={initialXML}
baseUrl={getDrawioUrl()}
autosave
urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true,
@@ -154,19 +85,13 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") {
return;
}
saveData(data.xml, true).then(() => close()).catch(() => {});
handleSave(data);
}}
onClose={(data: EventExit) => {
if (data.parentEvent) {
return;
}
handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data, false).catch(() => {});
close();
}}
/>
</div>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { lazy, Suspense, useCallback, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
@@ -10,11 +10,9 @@ import {
ActionIcon,
Button,
Group,
Text,
Tooltip,
useComputedColorScheme,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx";
import {
@@ -54,10 +52,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
});
const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const editorState = useEditorState({
editor,
@@ -166,109 +160,57 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) {
console.error(err);
} finally {
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open();
}
}, [editorState?.src, open]);
const saveData = useCallback(async () => {
if (!excalidrawAPI || isSavingRef.current) {
const handleSave = useCallback(async () => {
if (!excalidrawAPI) {
return;
}
isSavingRef.current = true;
const { exportToSvg } = await import("@excalidraw/excalidraw");
try {
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, excalidrawAPI, editorState?.attachmentId]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
// save failed, modal stays open
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
}, [close, t]);
useEffect(() => {
if (!opened) return;
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
return () => clearInterval(interval);
}, [opened, saveData]);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
return (
<>
@@ -375,7 +317,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200,
}}
isOpen={opened}
onRequestClose={handleClose}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
@@ -390,10 +332,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}>
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
@@ -401,18 +343,6 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<Suspense fallback={null}>
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{
...excalidrawData,
scrollToContent: true,
@@ -7,14 +7,7 @@ import {
Text,
useComputedColorScheme,
} from "@mantine/core";
import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { lazy, Suspense, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks";
@@ -27,7 +20,6 @@ import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { modals } from "@mantine/modals";
const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({
@@ -50,122 +42,59 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open();
};
const saveData = useCallback(async (updateSrc = true) => {
if (!excalidrawAPI || isSavingRef.current) {
const handleSave = async () => {
if (!excalidrawAPI) {
return;
}
isSavingRef.current = true;
const { exportToSvg } = await import("@excalidraw/excalidraw");
try {
const { exportToSvg } = await import("@excalidraw/excalidraw");
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
}
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
}, [close, t]);
useEffect(() => {
if (!opened) return;
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData(false).catch(() => {});
}
}, 30_000);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
return () => clearInterval(interval);
}, [opened, saveData]);
const fileName = "diagram.excalidraw.svg";
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
// @ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
};
return (
<NodeViewWrapper data-drag-handle>
@@ -176,7 +105,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200,
}}
isOpen={opened}
onRequestClose={handleClose}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
@@ -191,10 +120,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSaveAndExit} size={"compact-sm"}>
<Button onClick={handleSave} size={"compact-sm"}>
{t("Save & Exit")}
</Button>
<Button onClick={handleClose} color="red" size={"compact-sm"}>
<Button onClick={close} color="red" size={"compact-sm"}>
{t("Exit")}
</Button>
</Group>
@@ -202,18 +131,6 @@ export default function ExcalidrawView(props: NodeViewProps) {
<Suspense fallback={null}>
<ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{
...excalidrawData,
scrollToContent: true,
@@ -3,7 +3,6 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import {
buildPageUrl,
buildSharedPageUrl,
@@ -14,23 +13,17 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) {
const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams();
const navigate = useNavigate();
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const {
data: page,
isLoading,
isError,
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
} = usePageQuery({ pageId: entityType === "page" ? slugId : null });
const { data: sharedPage } = useSharePageQuery({
pageId: isPageMention && isShareRoute ? slugId : undefined,
});
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId;
@@ -46,12 +39,10 @@ export default function MentionView(props: NodeViewProps) {
}
};
const sharePageTitle = sharedPage?.page?.title || label;
const shareSlugUrl = buildSharedPageUrl({
shareId,
pageSlugId: slugId,
pageTitle: sharePageTitle,
pageTitle: label,
anchorId,
});
@@ -63,59 +54,21 @@ export default function MentionView(props: NodeViewProps) {
</Text>
)}
{isPageMention && isShareRoute && (
<Anchor
component={Link}
fw={500}
to={shareSlugUrl}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{sharePageTitle}
</span>
</Anchor>
{entityType === "page" && isError && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{isPageMention && !isShareRoute && isError && (
{entityType === "page" && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{label}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
to={
isShareRoute
? shareSlugUrl
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)
}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
// Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery(
shareId ? null : { pageId: currentPageId },
);
const { data, isLoading, error } = useGetSidebarPagesQuery({
pageId: currentPageId,
});
const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages
-2
View File
@@ -25,7 +25,6 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
const enterpriseModules = [];
try {
@@ -48,7 +47,6 @@ try {
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
+11
View File
@@ -20,6 +20,10 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
import {
AUDIT_SERVICE,
NoopAuditService,
} from '../integrations/audit/audit.service';
import { ClsMiddleware } from 'nestjs-cls';
@Module({
@@ -39,6 +43,13 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule,
WatcherModule,
],
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
@@ -1,14 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -23,7 +23,19 @@ export const CommentCreateEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -23,7 +23,19 @@ export const CommentMentionEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -23,7 +23,19 @@ export const CommentResolvedEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
inviteLink: string;
@@ -17,7 +17,19 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
Please click the button below to accept this invitation.
</Text>
</Section>
<EmailButton href={inviteLink}>Accept Invite</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={inviteLink} style={button}>
Accept Invite
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -19,7 +19,19 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,7 +1,7 @@
import { Section, Text } from '@react-email/components';
import { Section, Text, Button } from '@react-email/components';
import * as React from 'react';
import { content, paragraph } from '../css/styles';
import { EmailButton, MailBody } from '../partials/partials';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
actorName: string;
@@ -25,7 +25,19 @@ export const PermissionGrantedEmail = ({
<strong>{pageTitle}</strong>.
</Text>
</Section>
<EmailButton href={pageUrl}>View</EmailButton>
<Section
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody>
);
};
@@ -1,4 +1,4 @@
import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles';
import { container, footer, h1, logo, main } from '../css/styles';
import {
Body,
Container,
@@ -35,47 +35,6 @@ export function MailHeader() {
);
}
interface EmailButtonProps {
href: string;
children: React.ReactNode;
}
export function EmailButton({ href, children }: EmailButtonProps) {
return (
<table
role="presentation"
cellPadding="0"
cellSpacing="0"
style={{ margin: '0 0 15px 15px' }}
>
<tr>
<td
style={{
backgroundColor: buttonStyle.backgroundColor,
borderRadius: buttonStyle.borderRadius,
textAlign: 'center' as const,
}}
>
<a
href={href}
target="_blank"
style={{
color: buttonStyle.color,
fontFamily: buttonStyle.fontFamily,
fontSize: buttonStyle.fontSize,
textDecoration: 'none',
display: 'inline-block',
padding: '8px 16px',
}}
>
{children}
</a>
</td>
</tr>
</table>
);
}
export function MailFooter() {
return (
<Section style={footer}>
+8 -62
View File
@@ -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";
}
}
+8 -62
View File
@@ -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";
}
}
+8 -66
View File
@@ -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,
+8 -62
View File
@@ -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";
}
}