mirror of
https://github.com/docmost/docmost.git
synced 2026-05-22 10:02:43 +08:00
refactor excalidraw and drawio menu
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
import classes from "./node-resize.module.css";
|
||||||
|
|
||||||
|
export function createResizeHandle(
|
||||||
|
direction: ResizableNodeViewDirection,
|
||||||
|
): HTMLElement {
|
||||||
|
const handle = document.createElement("div");
|
||||||
|
handle.dataset.resizeHandle = direction;
|
||||||
|
handle.style.position = "absolute";
|
||||||
|
handle.className = classes.handle;
|
||||||
|
|
||||||
|
if (direction === "left") {
|
||||||
|
handle.style.left = "-8px";
|
||||||
|
handle.style.top = "0";
|
||||||
|
handle.style.bottom = "0";
|
||||||
|
} else if (direction === "right") {
|
||||||
|
handle.style.right = "-8px";
|
||||||
|
handle.style.top = "0";
|
||||||
|
handle.style.bottom = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = classes.handleBar;
|
||||||
|
handle.appendChild(bar);
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResizeClasses(nodeClass: string) {
|
||||||
|
return {
|
||||||
|
container: `${classes.container} ${nodeClass}`,
|
||||||
|
wrapper: classes.wrapper,
|
||||||
|
resizing: classes.resizing,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper img {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="left"] {
|
||||||
|
left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="right"] {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handleBar {
|
||||||
|
width: 4px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
@@ -1,24 +1,41 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconLayoutAlignLeft,
|
||||||
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import {
|
||||||
|
DrawIoEmbed,
|
||||||
|
DrawIoEmbedRef,
|
||||||
|
EventExit,
|
||||||
|
EventSave,
|
||||||
|
} from "react-drawio";
|
||||||
|
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||||
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const { t } = useTranslation();
|
||||||
({ state }: ShouldShowProps) => {
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
if (!state) {
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
return false;
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
}
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
const drawioAttr = ctx.editor.getAttributes("drawio");
|
const drawioAttr = ctx.editor.getAttributes("drawio");
|
||||||
return {
|
return {
|
||||||
isDrawio: ctx.editor.isActive("drawio"),
|
isDrawio: ctx.editor.isActive("drawio"),
|
||||||
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
|
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
|
||||||
|
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
|
||||||
|
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
|
||||||
|
src: drawioAttr?.src || null,
|
||||||
|
attachmentId: drawioAttr?.attachmentId || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -57,38 +89,224 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const alignLeft = useCallback(() => {
|
||||||
(value: number) => {
|
editor
|
||||||
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("left")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignCenter = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("center")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("right")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
const attachmentId = editorState?.attachmentId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.updateAttributes("drawio", {
|
||||||
|
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
},
|
},
|
||||||
[editor],
|
[editor, editorState?.attachmentId, close],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<>
|
||||||
editor={editor}
|
<BaseBubbleMenu
|
||||||
pluginKey={`drawio-menu`}
|
editor={editor}
|
||||||
updateDelay={0}
|
pluginKey={`drawio-menu`}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
updateDelay={0}
|
||||||
options={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
placement: "top",
|
options={{
|
||||||
offset: 8,
|
placement: "top",
|
||||||
flip: false,
|
offset: 8,
|
||||||
}}
|
flip: false,
|
||||||
shouldShow={shouldShow}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
{editorState?.width && (
|
<div className={classes.toolbar}>
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
)}
|
<ActionIcon
|
||||||
</div>
|
onClick={alignLeft}
|
||||||
</BaseBubbleMenu>
|
size="lg"
|
||||||
|
aria-label={t("Align left")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignCenter}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align center")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignCenter size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align right")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Edit")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
|
||||||
|
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Body>
|
||||||
|
<div style={{ height: "100vh" }}>
|
||||||
|
<DrawIoEmbed
|
||||||
|
ref={drawioRef}
|
||||||
|
xml={initialXML}
|
||||||
|
baseUrl={getDrawioUrl()}
|
||||||
|
urlParameters={{
|
||||||
|
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||||
|
spin: true,
|
||||||
|
libraries: true,
|
||||||
|
saveAndExit: true,
|
||||||
|
noSaveBtn: true,
|
||||||
|
}}
|
||||||
|
onSave={(data: EventSave) => {
|
||||||
|
if (data.parentEvent !== "save") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSave(data);
|
||||||
|
}}
|
||||||
|
onClose={(data: EventExit) => {
|
||||||
|
if (data.parentEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Card,
|
Card,
|
||||||
Image,
|
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
import { getDrawioUrl } from "@/lib/config.ts";
|
||||||
import {
|
import {
|
||||||
DrawIoEmbed,
|
DrawIoEmbed,
|
||||||
DrawIoEmbedRef,
|
DrawIoEmbedRef,
|
||||||
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
export default function DrawioView(props: NodeViewProps) {
|
export default function DrawioView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { attachmentId } = node.attrs;
|
||||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
const [initialXML, setInitialXML] = useState<string>("");
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
open();
|
||||||
try {
|
|
||||||
if (src) {
|
|
||||||
const url = getFileUrl(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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (data: EventSave) => {
|
const handleSave = async (data: EventSave) => {
|
||||||
const svgString = decodeBase64ToSvgString(data.xml);
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
|
||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|
||||||
if (attachmentId) {
|
if (attachmentId) {
|
||||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
} else {
|
} else {
|
||||||
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
}}
|
}}
|
||||||
onSave={(data: EventSave) => {
|
onSave={(data: EventSave) => {
|
||||||
// If the save is triggered by another event, then do nothing
|
|
||||||
if (data.parentEvent !== "save") {
|
if (data.parentEvent !== "save") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleSave(data);
|
handleSave(data);
|
||||||
}}
|
}}
|
||||||
onClose={(data: EventExit) => {
|
onClose={(data: EventExit) => {
|
||||||
// If the exit is triggered by another event, then do nothing
|
|
||||||
if (data.parentEvent) {
|
if (data.parentEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
{src ? (
|
<Card
|
||||||
<div style={{ position: "relative" }}>
|
radius="md"
|
||||||
<Image
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
p="xs"
|
||||||
radius="md"
|
style={{
|
||||||
fit="contain"
|
display: "flex",
|
||||||
w={width}
|
justifyContent: "center",
|
||||||
src={getFileUrl(src)}
|
alignItems: "center",
|
||||||
alt={title}
|
}}
|
||||||
className={clsx(
|
withBorder
|
||||||
selected ? "ProseMirror-selectednode" : "",
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
"alignCenter",
|
>
|
||||||
)}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
/>
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
<Text component="span" size="lg" c="dimmed">
|
||||||
<ActionIcon
|
{t("Double-click to edit Draw.io diagram")}
|
||||||
onClick={handleOpen}
|
</Text>
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
mx="xs"
|
|
||||||
className="print-hide"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</Card>
|
||||||
<Card
|
|
||||||
radius="md"
|
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
withBorder
|
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<ActionIcon variant="transparent" color="gray">
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
|
||||||
{t("Double-click to edit Draw.io diagram")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,57 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { lazy, Suspense, useCallback, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "prosemirror-model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconLayoutAlignLeft,
|
||||||
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { svgStringToFile } from "@/lib";
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
|
import ReactClearModal from "react-clear-modal";
|
||||||
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
const ExcalidrawComponent = lazy(() =>
|
||||||
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
|
default: module.Excalidraw,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const { t } = useTranslation();
|
||||||
({ state }: ShouldShowProps) => {
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
if (!state) {
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
return false;
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
}
|
useHandleLibrary({
|
||||||
|
excalidrawAPI,
|
||||||
return (
|
adapter: localStorageLibraryAdapter,
|
||||||
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
});
|
||||||
);
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
},
|
const computedColorScheme = useComputedColorScheme();
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||||
return {
|
return {
|
||||||
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
||||||
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
|
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
|
||||||
|
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
|
||||||
|
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
|
||||||
|
src: excalidrawAttr?.src || null,
|
||||||
|
attachmentId: excalidrawAttr?.attachmentId || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
editor.isActive("excalidraw") &&
|
||||||
|
editor.getAttributes("excalidraw")?.src
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -59,38 +108,254 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const alignLeft = useCallback(() => {
|
||||||
(value: number) => {
|
editor
|
||||||
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
.chain()
|
||||||
},
|
.focus(undefined, { scrollIntoView: false })
|
||||||
[editor],
|
.setExcalidrawAlign("left")
|
||||||
);
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignCenter = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setExcalidrawAlign("center")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setExcalidrawAlign("right")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
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 { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||||
|
const data = await loadFromBlob(await request.blob(), null, null);
|
||||||
|
setExcalidrawData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}, [editorState?.src, open]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
|
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<>
|
||||||
editor={editor}
|
<BaseBubbleMenu
|
||||||
pluginKey={`excalidraw-menu`}
|
editor={editor}
|
||||||
updateDelay={0}
|
pluginKey={`excalidraw-menu`}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
updateDelay={0}
|
||||||
options={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
placement: "top",
|
options={{
|
||||||
offset: 8,
|
placement: "top",
|
||||||
flip: false,
|
offset: 8,
|
||||||
}}
|
flip: false,
|
||||||
shouldShow={shouldShow}
|
}}
|
||||||
>
|
shouldShow={shouldShow}
|
||||||
<div
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignLeft}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align left")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignLeft,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignCenter}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align center")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignCenter,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignCenter size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align right")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignRight,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Edit")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
c="dark"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
|
||||||
|
<ReactClearModal
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
flexDirection: "column",
|
padding: 0,
|
||||||
alignItems: "center",
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
isOpen={opened}
|
||||||
|
onRequestClose={close}
|
||||||
|
disableCloseOnBgClick={true}
|
||||||
|
contentProps={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
width: "90vw",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editorState?.width && (
|
<Group
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
justify="flex-end"
|
||||||
)}
|
wrap="nowrap"
|
||||||
</div>
|
bg="var(--mantine-color-body)"
|
||||||
</BaseBubbleMenu>
|
p="xs"
|
||||||
|
>
|
||||||
|
<Button onClick={handleSave} size={"compact-sm"}>
|
||||||
|
{t("Save & Exit")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||||
|
{t("Exit")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<div style={{ height: "90vh" }}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ExcalidrawComponent
|
||||||
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
|
initialData={{
|
||||||
|
...excalidrawData,
|
||||||
|
scrollToContent: true,
|
||||||
|
}}
|
||||||
|
theme={computedColorScheme}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</ReactClearModal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,28 +4,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { lazy } from "react";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const ExcalidrawComponent = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
default: module.Excalidraw,
|
default: module.Excalidraw,
|
||||||
})),
|
})),
|
||||||
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
|
|||||||
export default function ExcalidrawView(props: NodeViewProps) {
|
export default function ExcalidrawView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { attachmentId } = node.attrs;
|
||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
open();
|
||||||
try {
|
|
||||||
if (src) {
|
|
||||||
const url = getFileUrl(src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
|
||||||
|
|
||||||
const data = await loadFromBlob(await request.blob(), null, null);
|
|
||||||
setExcalidrawData(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: "90vh" }}>
|
<div style={{ height: "90vh" }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Excalidraw
|
<ExcalidrawComponent
|
||||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
initialData={{
|
initialData={{
|
||||||
...excalidrawData,
|
...excalidrawData,
|
||||||
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ReactClearModal>
|
</ReactClearModal>
|
||||||
|
|
||||||
{src ? (
|
<Card
|
||||||
<div style={{ position: "relative" }}>
|
radius="md"
|
||||||
<Image
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
p="xs"
|
||||||
radius="md"
|
style={{
|
||||||
fit="contain"
|
display: "flex",
|
||||||
w={width}
|
justifyContent: "center",
|
||||||
src={getFileUrl(src)}
|
alignItems: "center",
|
||||||
alt={title}
|
}}
|
||||||
className={clsx(
|
withBorder
|
||||||
selected ? "ProseMirror-selectednode" : "",
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
"alignCenter",
|
>
|
||||||
)}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
/>
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
<Text component="span" size="lg" c="dimmed">
|
||||||
<ActionIcon
|
{t("Double-click to edit Excalidraw diagram")}
|
||||||
onClick={handleOpen}
|
</Text>
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
mx="xs"
|
|
||||||
className="print-hide"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</Card>
|
||||||
<Card
|
|
||||||
radius="md"
|
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
withBorder
|
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<ActionIcon variant="transparent" color="gray">
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
|
||||||
{t("Double-click to edit Excalidraw diagram")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
import classes from "./image-menu.module.css";
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -122,7 +122,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
uploadImageAction(file, editor, pageId);
|
const pos = editor.state.selection.from;
|
||||||
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
}
|
}
|
||||||
// Reset so the same file can be selected again
|
// Reset so the same file can be selected again
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
|
|||||||
@@ -1,33 +1,7 @@
|
|||||||
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
import {
|
||||||
import classes from "./image-resize.module.css";
|
createResizeHandle,
|
||||||
|
buildResizeClasses,
|
||||||
|
} from "../common/node-resize-handles";
|
||||||
|
|
||||||
export function createImageHandle(
|
export const createImageHandle = createResizeHandle;
|
||||||
direction: ResizableNodeViewDirection,
|
export const imageResizeClasses = buildResizeClasses("node-image");
|
||||||
): HTMLElement {
|
|
||||||
const handle = document.createElement("div");
|
|
||||||
handle.dataset.resizeHandle = direction;
|
|
||||||
handle.style.position = "absolute";
|
|
||||||
handle.className = classes.handle;
|
|
||||||
|
|
||||||
if (direction === "left") {
|
|
||||||
handle.style.left = "-8px";
|
|
||||||
handle.style.top = "0";
|
|
||||||
handle.style.bottom = "0";
|
|
||||||
} else if (direction === "right") {
|
|
||||||
handle.style.right = "-8px";
|
|
||||||
handle.style.top = "0";
|
|
||||||
handle.style.bottom = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
const bar = document.createElement("div");
|
|
||||||
bar.className = classes.handleBar;
|
|
||||||
handle.appendChild(bar);
|
|
||||||
|
|
||||||
return handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const imageResizeClasses = {
|
|
||||||
container: `${classes.container} node-image`,
|
|
||||||
wrapper: classes.wrapper,
|
|
||||||
resizing: classes.resizing,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ import {
|
|||||||
createImageHandle,
|
createImageHandle,
|
||||||
imageResizeClasses,
|
imageResizeClasses,
|
||||||
} from "@/features/editor/components/image/image-resize-handles.ts";
|
} from "@/features/editor/components/image/image-resize-handles.ts";
|
||||||
|
import {
|
||||||
|
createResizeHandle,
|
||||||
|
buildResizeClasses,
|
||||||
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
@@ -236,9 +240,29 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
Drawio.configure({
|
Drawio.configure({
|
||||||
view: DrawioView,
|
view: DrawioView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-drawio"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Excalidraw.configure({
|
Excalidraw.configure({
|
||||||
view: ExcalidrawView,
|
view: ExcalidrawView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-excalidraw"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 71b4323d1b...028e31724e
@@ -1,15 +1,35 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type DrawioResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface DrawioOptions {
|
export interface DrawioOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: DrawioResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DrawioAttributes {
|
export interface DrawioAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: string;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
drawio: {
|
drawio: {
|
||||||
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
||||||
|
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
|
setDrawioSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +57,7 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -55,12 +78,30 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
"data-width": attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
|
"data-height": attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -68,6 +109,13 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -99,7 +147,7 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"img",
|
"img",
|
||||||
@@ -122,13 +170,163 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setDrawioAlign:
|
||||||
|
(align) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("drawio", { align }),
|
||||||
|
|
||||||
|
setDrawioSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("drawio", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("img");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.alt = node.attrs.title || "";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
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,15 +1,35 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type ExcalidrawResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawOptions {
|
export interface ExcalidrawOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: ExcalidrawResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExcalidrawAttributes {
|
export interface ExcalidrawAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: string;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
excalidraw: {
|
excalidraw: {
|
||||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||||
|
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
|
setExcalidrawSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,8 +57,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
@@ -54,12 +78,30 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
"data-width": attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
|
"data-height": attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -67,6 +109,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -98,7 +147,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"img",
|
"img",
|
||||||
@@ -121,13 +170,163 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setExcalidrawAlign:
|
||||||
|
(align) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("excalidraw", { align }),
|
||||||
|
|
||||||
|
setExcalidrawSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("excalidraw", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("img");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.alt = node.attrs.title || "";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,20 +212,14 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
className,
|
className,
|
||||||
} = resize;
|
} = resize;
|
||||||
|
|
||||||
return ({ node, getPos, HTMLAttributes, editor }) => {
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
// If no src yet (placeholder/uploading), use React view for loading UI
|
// If no src yet (placeholder/uploading), use React view for loading UI
|
||||||
if (!HTMLAttributes.src) {
|
if (!HTMLAttributes.src) {
|
||||||
editor.isInitialized = true;
|
editor.isInitialized = true;
|
||||||
const reactView = ReactNodeViewRenderer(this.options.view);
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
const view = reactView({
|
const view = reactView(props);
|
||||||
node,
|
|
||||||
getPos,
|
|
||||||
HTMLAttributes,
|
|
||||||
editor,
|
|
||||||
extension: this,
|
|
||||||
decorations: [] as any,
|
|
||||||
innerDecorations: {} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
// When the node gets a src, return false from update to force rebuild
|
// When the node gets a src, return false from update to force rebuild
|
||||||
const originalUpdate = view.update?.bind(view);
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
|||||||
Reference in New Issue
Block a user