feat: Diagrams/Draw.io integration (#10707)

* wip

* wip

* tsc

* lint

* Detect imported Draw.io

* Add empty diagram placeholder

* fix: Do not close editor on save
fix: Account for nodes moving / multiplayer

* fix: Reduce image menu for diagrams

* Add custom server settings page

* refactor

* sp

* Move edit button
This commit is contained in:
Tom Moor
2025-11-29 21:02:08 +01:00
committed by GitHub
parent d9c50edd98
commit ac06a06a66
25 changed files with 967 additions and 69 deletions
+20 -1
View File
@@ -3,8 +3,27 @@ import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
import { IntegrationType } from "@shared/types";
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
export const disconnectIntegrationFactory = (integration?: Integration) =>
createAction({
name: ({ t }) => t("Disconnect"),
analyticsName: "Disconnect integration",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: async ({ event }) => {
event?.preventDefault();
event?.stopPropagation();
await integration?.delete();
history.push(settingsPath("integrations"));
},
});
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration<IntegrationType.Analytics>
+37 -7
View File
@@ -25,6 +25,7 @@ import {
NextIcon,
ZoomInIcon,
ZoomOutIcon,
EditIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
@@ -50,6 +51,9 @@ import {
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
import { useEditor } from "~/editor/components/EditorContext";
import { NodeSelection } from "prosemirror-state";
import { ImageSource } from "@shared/editor/lib/FileHelper";
export enum LightboxStatus {
READY_TO_OPEN,
@@ -86,7 +90,7 @@ const ANIMATION_DURATION = 0.3 * Second.ms;
type Props = {
/** List of allowed images */
images: LightboxImage[];
/** The position of the currently active image in the document */
/** The currently active image in the document */
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
@@ -225,10 +229,11 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
height: number;
} | null>(null);
const zoomPanPinchRef = useRef<ReactZoomPanPinchRef>(null);
const editor = useEditor();
const currentImageIndex = findIndex(
images,
(img) => img.getPos() === activeImage.getPos()
(img) => img.pos === activeImage.pos
);
// Debugging status changes
@@ -617,9 +622,9 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
URL.revokeObjectURL(imageURL);
};
const download = useCallback(() => {
const handleDownload = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.getSrc(), activeImage.getAlt());
void downloadImage(activeImage.src, activeImage.alt);
}
}, [activeImage, status.lightbox]);
@@ -670,6 +675,17 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
};
const handleEditDiagram = () => {
const { state, dispatch } = editor.view;
// Select the node at the position
const tr = state.tr.setSelection(
NodeSelection.create(state.doc, activeImage.pos)
);
dispatch(tr);
editor.commands.editDiagram();
};
return (
<Dialog.Root open={true}>
<Dialog.Portal>
@@ -745,7 +761,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={download}
onClick={handleDownload}
aria-label={t("Download")}
size={32}
icon={<DownloadIcon />}
@@ -753,6 +769,20 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
neutral
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={handleEditDiagram}
aria-label={t("Edit diagram")}
size={32}
icon={<EditIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
<Separator />
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
@@ -792,8 +822,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
>
<Image
ref={imgRef}
src={activeImage.getSrc()}
alt={activeImage.getAlt()}
src={activeImage.src}
alt={activeImage.alt}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
+2
View File
@@ -3,6 +3,7 @@ import Mark from "@shared/editor/marks/Mark";
import Node from "@shared/editor/nodes/Node";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
import DiagramsExtension from "@shared/editor/extensions/Diagrams";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
@@ -26,6 +27,7 @@ export const withUIExtensions = (nodes: Nodes) => [
FindAndReplaceExtension,
HoverPreviewsExtension,
SelectionToolbarExtension,
DiagramsExtension,
// Order these default key handlers last
PreventTab,
Keys,
+6
View File
@@ -223,5 +223,11 @@ export default function blockMenuItems(
keywords: "diagram flowchart",
attrs: { language: "mermaidjs" },
},
{
name: "editDiagram",
title: "Diagrams.net Diagram",
icon: <Img src="/images/diagrams.png" alt="Diagrams.net Diagram" />,
keywords: "diagram flowchart draw.io",
},
];
}
+22 -2
View File
@@ -6,6 +6,7 @@ import {
AlignImageRightIcon,
AlignImageCenterIcon,
AlignFullWidthIcon,
EditIcon,
CommentIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
@@ -13,6 +14,7 @@ import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
import { metaDisplay } from "@shared/utils/keyboard";
import { ImageSource } from "@shared/editor/lib/FileHelper";
export default function imageMenuItems(
state: EditorState,
@@ -32,6 +34,13 @@ export default function imageMenuItems(
const isFullWidthAligned = isNodeActive(schema.nodes.image, {
layoutClass: "full-width",
});
const isDiagram = isNodeActive(schema.nodes.image, {
source: ImageSource.DiagramsNet,
});
const isEmptyDiagram = isNodeActive(schema.nodes.image, {
source: ImageSource.DiagramsNet,
src: "",
});
return [
{
@@ -39,6 +48,7 @@ export default function imageMenuItems(
tooltip: dictionary.alignLeft,
icon: <AlignImageLeftIcon />,
active: isLeftAligned,
visible: !isEmptyDiagram(state),
},
{
name: "alignCenter",
@@ -49,18 +59,21 @@ export default function imageMenuItems(
!isLeftAligned(state) &&
!isRightAligned(state) &&
!isFullWidthAligned(state),
visible: !isEmptyDiagram(state),
},
{
name: "alignRight",
tooltip: dictionary.alignRight,
icon: <AlignImageRightIcon />,
active: isRightAligned,
visible: !isEmptyDiagram(state),
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
active: isFullWidthAligned,
visible: !isEmptyDiagram(state),
},
{
name: "separator",
@@ -68,21 +81,28 @@ export default function imageMenuItems(
{
name: "dimensions",
tooltip: dictionary.dimensions,
visible: !isFullWidthAligned(state),
visible: !isFullWidthAligned(state) && !isEmptyDiagram(state),
skipIcon: true,
},
{
name: "separator",
},
{
name: "editDiagram",
tooltip: "Edit diagram",
icon: <EditIcon />,
visible: isDiagram(state),
},
{
name: "downloadImage",
tooltip: dictionary.downloadImage,
icon: <DownloadIcon />,
visible: !!fetch,
visible: !!fetch && !isEmptyDiagram(state),
},
{
tooltip: dictionary.replaceImage,
icon: <ReplaceIcon />,
visible: !isDiagram(state),
children: [
{
name: "replaceImage",
+1
View File
@@ -184,6 +184,7 @@
"octokit": "^3.2.2",
"outline-icons": "^3.13.1",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-oauth2": "^1.8.0",
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path d="M19.2721 13.2836H16.8902L14.3683 8.99625C14.9287 8.88416 15.349 8.37977 15.349 7.79131V4.5548C15.349 3.86826 14.8026 3.32184 14.1161 3.32184H9.9128C9.22627 3.32184 8.67984 3.86826 8.67984 4.5548V7.79131C8.67984 8.39378 9.10017 8.88416 9.64659 8.99625L7.12464 13.2976H4.74278C4.05625 13.2976 3.50983 13.844 3.50983 14.5305V17.7671C3.50983 18.4536 4.05625 19 4.74278 19H8.94605C9.63258 19 10.179 18.4536 10.179 17.7671V14.5305C10.179 13.844 9.63258 13.2976 8.94605 13.2976H8.53973L11.0337 9.03828H12.9812L15.4891 13.2976H15.0688C14.3823 13.2976 13.8358 13.844 13.8358 14.5305V17.7671C13.8358 18.4536 14.3823 19 15.0688 19H19.2721C19.9586 19 20.505 18.4536 20.505 17.7671V14.5305C20.505 13.844 19.9586 13.2836 19.2721 13.2836Z" />
<path d="M19.2721 13.9617H16.8902L14.3683 9.67441C14.9287 9.56232 15.349 9.05793 15.349 8.46947V5.23296C15.349 4.54642 14.8026 4 14.1161 4H9.9128C9.22627 4 8.67984 4.54642 8.67984 5.23296V8.46947C8.67984 9.07194 9.10017 9.56232 9.64659 9.67441L7.12464 13.9758H4.74278C4.05625 13.9758 3.50983 14.5222 3.50983 15.2087V18.4452C3.50983 19.1318 4.05625 19.6782 4.74278 19.6782H8.94605C9.63258 19.6782 10.179 19.1318 10.179 18.4452V15.2087C10.179 14.5222 9.63258 13.9758 8.94605 13.9758H8.53973L11.0337 9.71644H12.9812L15.4891 13.9758H15.0688C14.3823 13.9758 13.8358 14.5222 13.8358 15.2087V18.4452C13.8358 19.1318 14.3823 19.6782 15.0688 19.6782H19.2721C19.9586 19.6782 20.505 19.1318 20.505 18.4452V15.2087C20.505 14.5222 19.9586 13.9617 19.2721 13.9617Z" />
</svg>
);
}
+134
View File
@@ -0,0 +1,134 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
import Flex from "~/components/Flex";
import styled from "styled-components";
import { disconnectIntegrationFactory } from "~/actions/definitions/integrations";
type FormData = {
url: string;
};
function DiagramsNet() {
const { integrations } = useStores();
const { t } = useTranslation();
const integration = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const url = integration?.settings.diagrams?.url;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
url,
},
});
React.useEffect(() => {
reset({
url,
});
}, [reset, url]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await integrations.save({
id: integration?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
diagrams: {
url: data.url.replace(/\/?$/, "/"),
},
} as Integration<IntegrationType.Embed>["settings"],
});
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integration, t]
);
return (
<IntegrationScene title="Diagrams.net" icon={<Icon />}>
<Heading>Diagrams.net</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a custom Diagrams.net installation URL to use your own
self-hosted instance for embedding diagrams in your documents.
</Trans>
</Text>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Installation URL")}
name="url"
description={t(
"The URL of your Diagrams.net installation. Leave empty to use the cloud hosted app.diagrams.net"
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
{...register("url", { required: false })}
/>
</SettingRow>
<Actions reverse justify="end" gap={8}>
<StyledSubmit
type="submit"
disabled={
!formState.isDirty || !formState.isValid || formState.isSubmitting
}
>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</StyledSubmit>
<Button
action={disconnectIntegrationFactory(integration)}
disabled={formState.isSubmitting}
neutral
hideIcon
hideOnActionDisabled
>
{t("Disconnect")}
</Button>
</Actions>
</form>
</IntegrationScene>
);
}
const Actions = styled(Flex)`
margin-top: 8px;
`;
const StyledSubmit = styled(Button)`
width: 80px;
`;
export default observer(DiagramsNet);
+20
View File
@@ -0,0 +1,20 @@
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Configure a custom Diagrams.net installation URL to use your own hosted instance for embedding diagrams in your documents.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "diagrams",
"name": "Diagrams.net",
"priority": 45,
"description": "Configure a custom Diagrams.net installation URL for embedding diagrams."
}
+1
View File
@@ -95,6 +95,7 @@ router.get(
"application/octet-stream";
ctx.set("Accept-Ranges", "bytes");
ctx.set("Access-Control-Allow-Origin", "*");
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
ctx.set("Content-Security-Policy", "sandbox");
+10
View File
@@ -58,6 +58,11 @@ export const IntegrationsCreateSchema = BaseSchema.extend({
scriptName: z.string().optional(),
})
)
.or(
z.object({
diagrams: z.object({ url: z.string().url() }),
})
)
.or(z.object({ serviceTeamId: z.string() }))
.optional(),
}),
@@ -87,6 +92,11 @@ export const IntegrationsUpdateSchema = BaseSchema.extend({
scriptName: z.string().optional(),
})
)
.or(
z.object({
diagrams: z.object({ url: z.string().url() }),
})
)
.or(z.object({ serviceTeamId: z.string() }))
.optional(),
+2
View File
@@ -74,6 +74,7 @@ const insertFiles = async function (
return {
id: `upload-${uuidv4()}`,
dimensions: await getDimensions?.(file),
source: await FileHelper.getImageSourceAttr(file),
isImage,
isVideo,
file,
@@ -122,6 +123,7 @@ const insertFiles = async function (
to || from,
schema.nodes.image.create({
src,
source: upload.source,
...(upload.dimensions ?? {}),
...options.attrs,
})
@@ -0,0 +1,38 @@
import styled from "styled-components";
import { ComponentProps } from "../types";
import { s } from "../../styles";
import { useTranslation } from "react-i18next";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import Text from "../../components/Text";
type Props = ComponentProps & {
/** Callback for double click event */
onDoubleClick: () => void;
};
export const DiagramPlaceholder = ({ isSelected, onDoubleClick }: Props) => {
const { t } = useTranslation();
return (
<Placeholder
className={isSelected ? "ProseMirror-selectednode" : ""}
onDoubleClick={onDoubleClick}
>
<Text size="small" type="secondary" as="p">
{t("Empty diagram")}
</Text>{" "}
<Text size="small" type="tertiary" italic>
{t("Double click to edit")}
</Text>
</Placeholder>
);
};
const Placeholder = styled.div`
border: 2px dashed ${s("inputBorder")};
background: ${s("backgroundSecondary")};
border-radius: ${EditorStyleHelper.blockRadius};
padding: 16px;
text-align: center;
cursor: var(--pointer);
`;
+16 -5
View File
@@ -15,23 +15,22 @@ type Props = ComponentProps & {
/** Callback triggered when the image is clicked */
onClick: () => void;
/** Callback triggered when the download button is clicked */
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
/** Callback triggered when the image is resized */
onChangeSize?: (props: { width: number; height?: number }) => void;
/** The editor view */
view: EditorView;
children?: React.ReactElement;
isDownloading?: boolean;
};
const Image = (props: Props) => {
const { isSelected, node, isEditable, onChangeSize, onClick, isDownloading } =
props;
const { isSelected, node, isEditable, onChangeSize, onClick } = props;
const { src, layoutClass } = node.attrs;
const { t } = useTranslation();
const className = layoutClass ? `image image-${layoutClass}` : "image";
const [loaded, setLoaded] = React.useState(false);
const [error, setError] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
const lastTapTimeRef = React.useRef(0);
@@ -95,6 +94,18 @@ const Image = (props: Props) => {
}
};
const handleDownload = async (ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
if (props.onDownload) {
setIsDownloading(true);
try {
await props.onDownload(ev);
} finally {
setIsDownloading(false);
}
}
};
return (
<div contentEditable={false} className={className} ref={ref}>
<ImageWrapper
@@ -114,7 +125,7 @@ const Image = (props: Props) => {
</Button>
)}
<Button
onClick={props.onDownload}
onClick={handleDownload}
aria-label={t("Download")}
disabled={isDownloading}
>
+1
View File
@@ -247,6 +247,7 @@ const embeds: EmbedDescriptor[] = [
regexMatch: [/^https:\/\/viewer\.diagrams\.net\/(?!proxy).*(title=\\w+)?/],
icon: <Img src="/images/diagrams.png" alt="Diagrams.net" />,
component: Diagrams,
visible: false,
}),
new EmbedDescriptor({
title: "Descript",
+224
View File
@@ -0,0 +1,224 @@
import { Command, EditorState, NodeSelection } from "prosemirror-state";
import Extension, { CommandFactory } from "../lib/Extension";
import FileHelper, { ImageSource } from "../lib/FileHelper";
import { IntegrationService } from "../../types";
import { NodeWithPos } from "../types";
import {
DiagramsNetClient,
EMPTY_DIAGRAM_IMAGE,
} from "../lib/DiagramsNetClient";
import { Node } from "prosemirror-model";
/**
* An editor extension that adds commands to insert and edit diagrams using diagrams.net.
*
* This extension provides a command to open the diagrams.net editor for creating
* and editing diagrams. Diagrams are stored as PNG images with embedded XML data
* that allows them to be re-edited later.
*/
export default class Diagrams extends Extension {
get name() {
return "diagrams";
}
commands(): Record<string, CommandFactory> {
return {
editDiagram: (): Command => (state, dispatch) => {
const selectedNode = this.getSelectedImageNode(state);
if (!selectedNode) {
this.insertEmptyDiagram(state, dispatch);
}
this.openDiagramEditor(selectedNode);
return true;
},
};
}
/**
* Gets the currently selected image node if it exists.
*
* @param state - the editor state.
* @returns the selected image node or undefined.
*/
private getSelectedImageNode(state: EditorState) {
if (state.selection instanceof NodeSelection) {
const node = state.selection.node;
if (node.type.name === "image") {
return node;
}
}
return;
}
/**
* Inserts an empty diagram placeholder at the current cursor position.
*
* @param state - the editor state.
* @param dispatch - the dispatch function.
*/
private insertEmptyDiagram(state: EditorState, dispatch?: any) {
const type = this.editor.schema.nodes.image;
const { tr } = state;
const transaction = tr.insert(
state.selection.from,
type.create({
src: "",
source: ImageSource.DiagramsNet,
})
);
dispatch?.(transaction);
}
/**
* Opens the diagram editor for creating or editing a diagram.
*
* @param node - the selected image node, if any.
*/
private openDiagramEditor(node?: Node) {
this.currentNodeSrc = node?.attrs.src ?? "";
const sourceUrl = this.currentNodeSrc || EMPTY_DIAGRAM_IMAGE;
// Clean up any existing client
if (this.client) {
this.client.close();
}
// Create new client with callbacks
this.client = new DiagramsNetClient(
() => this.onDiagramReady(sourceUrl),
(base64Data) => this.onDiagramExported(base64Data)
);
this.client.open(this.getDiagramsNetUrl());
}
/**
* Called when the diagram editor is ready to receive commands.
*
* @param client - the diagrams.net client.
* @param sourceUrl - the URL of the diagram to load.
*/
private async onDiagramReady(sourceUrl: string) {
let base64Data: string;
if (sourceUrl === EMPTY_DIAGRAM_IMAGE) {
base64Data = EMPTY_DIAGRAM_IMAGE;
} else {
base64Data = await FileHelper.urlToBase64(sourceUrl);
}
this.client.loadDiagram(base64Data);
}
/**
* Called when a diagram has been exported from the editor.
*
* @param base64Data - the exported diagram as base64 encoded PNG.
*/
private async onDiagramExported(base64Data: string) {
const file = FileHelper.base64ToFile(
base64Data,
"diagram.png",
"image/png"
);
const dimensions = await FileHelper.getImageDimensions(file);
const uploadedUrl = await this.uploadDiagramFile(file);
this.updateDiagramInDocument(uploadedUrl, dimensions || {});
this.currentNodeSrc = uploadedUrl;
}
/**
* Uploads the diagram file using the editor's upload handler.
*
* @param file - the diagram file to upload.
* @returns promise resolving to the uploaded file URL.
* @throws Error if no upload handler is configured.
*/
private async uploadDiagramFile(file: File): Promise<string> {
const { uploadFile } = this.editor.props;
if (!uploadFile) {
throw new Error("No upload handler configured");
}
return uploadFile(file);
}
/**
* Updates or inserts the diagram image in the document.
*
* @param uploadedUrl - the URL of the uploaded diagram.
* @param dimensions - the image dimensions.
*/
private updateDiagramInDocument(
uploadedUrl: string,
dimensions: { width?: number; height?: number }
) {
const { state } = this.editor.view;
const { dispatch } = this.editor.view;
const imageType = this.editor.schema.nodes.image;
// Try to find and update existing node
const existingNode = this.findImageNodeBySrc(state, this.currentNodeSrc);
const attrs = {
...dimensions,
src: uploadedUrl,
source: ImageSource.DiagramsNet,
};
if (existingNode) {
dispatch(
state.tr.setNodeMarkup(existingNode.pos, undefined, {
...existingNode.node.attrs,
...attrs,
})
);
} else {
const imageNode = imageType.create(attrs);
const transaction = state.tr.insert(state.selection.from, imageNode);
dispatch(transaction);
}
}
/**
* Finds an image node in the document by its src attribute.
*
* @param state - the editor state.
* @param src - the image source URL to search for.
* @returns the node and its position, or undefined.
*/
private findImageNodeBySrc(
state: EditorState,
src: string
): NodeWithPos | undefined {
let foundNode: NodeWithPos | undefined;
state.doc.descendants((node, pos) => {
if (node.attrs.src === src && node.type.name === "image") {
foundNode = { node, pos };
return false; // Stop searching
}
return true;
});
return foundNode;
}
/**
* Gets the configured diagrams.net URL or returns the default.
*
* @returns the diagrams.net editor URL.
*/
private getDiagramsNetUrl(): string {
const integration = this.editor.props.embeds?.find(
(integ) => integ.name === IntegrationService.Diagrams
);
return (
(integration?.settings?.diagrams?.url ?? "https://embed.diagrams.net/") +
"?embed=1&ui=atlas&spin=1&modified=unsavedChanges&proto=json"
);
}
private client: DiagramsNetClient;
private currentNodeSrc: string = "";
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Events sent by diagrams.net to the parent window.
*/
export enum DiagramsNetEvent {
/** Editor is ready to receive commands. */
Init = "init",
/** User clicked the save button. */
Save = "save",
/** Diagram has been exported. */
Export = "export",
/** Editor is closing. */
Exit = "exit",
}
/**
* Actions that can be sent to diagrams.net.
*/
export enum DiagramsNetAction {
/** Load a diagram from base64 encoded PNG with embedded XML. */
Load = "load",
/** Export the current diagram. */
Export = "export",
}
/**
* Message format for communication with diagrams.net.
*/
export interface DiagramsNetMessage {
/** Event type from diagrams.net. */
event?: string;
/** Action to perform in diagrams.net. */
action?: string;
/** Export format (e.g., "xmlpng"). */
format?: string;
/** Base64 encoded data. */
data?: string;
/** Base64 encoded PNG with embedded XML for loading. */
xmlpng?: string;
/** Loading spinner key. */
spinKey?: string;
}
/**
* Handles communication with the diagrams.net editor window.
*
* This class manages the lifecycle of the diagrams.net popup window and
* implements the message-passing protocol for loading and exporting diagrams.
*/
export class DiagramsNetClient {
private window: Window | null = null;
/**
* Creates a new DiagramsNetClient instance.
*
* @param onDiagramReady - callback when the editor is ready to receive commands.
* @param onDiagramExported - callback when a diagram has been exported.
*/
constructor(
private onDiagramReady: (client: DiagramsNetClient) => void,
private onDiagramExported: (base64Data: string) => void
) {}
/**
* Opens the diagrams.net editor in a new window.
*
* @param url - the diagrams.net editor URL.
*/
open(url: string): void {
if (this.window) {
this.close();
}
window.addEventListener("message", this.handleMessage);
this.window = window.open(url);
}
/**
* Closes the editor window and cleans up event listeners.
*/
close(): void {
window.removeEventListener("message", this.handleMessage);
if (this.window) {
this.window.close();
this.window = null;
}
}
/**
* Loads a diagram from a base64 encoded PNG with embedded XML.
*
* @param base64Png - base64 encoded PNG data containing the diagram.
*/
loadDiagram = (base64Png: string) => {
this.sendMessage({
action: DiagramsNetAction.Load,
xmlpng: base64Png,
});
};
/**
* Requests an export of the current diagram as a PNG with embedded XML.
*/
exportDiagram = () => {
this.sendMessage({
action: DiagramsNetAction.Export,
format: "xmlpng",
spinKey: "saving",
});
};
/**
* Sends a message to the diagrams.net window.
*
* @param message - the message to send.
* @throws Error if the window is not open.
*/
private sendMessage = (message: DiagramsNetMessage) => {
if (!this.window) {
throw new Error("Diagrams.net window is not open");
}
this.window.postMessage(JSON.stringify(message), "*");
};
/**
* Handles incoming messages from the diagrams.net window.
*
* @param event - the message event.
*/
private handleMessage = (event: MessageEvent) => {
if (!event.data.length || event.source !== this.window) {
return;
}
const message = JSON.parse(event.data) as DiagramsNetMessage;
switch (message.event) {
case DiagramsNetEvent.Init:
this.onDiagramReady(this);
break;
case DiagramsNetEvent.Save:
this.exportDiagram();
break;
case DiagramsNetEvent.Export:
if (message.data) {
this.onDiagramExported(message.data);
}
break;
case DiagramsNetEvent.Exit:
this.close();
break;
}
};
}
// Base64 encoded empty diagram image (1x1 transparent PNG with embedded diagrams.net metadata)
export const EMPTY_DIAGRAM_IMAGE =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAADz3RFWHRteGZpbGUAJTNDbXhmaWxlJTIwaG9zdCUzRCUyMmFwcC5kaWFncmFtcy5uZXQlMjIlMjBhZ2VudCUzRCUyMk1vemlsbGElMkY1LjAlMjAoTWFjaW50b3NoJTNCJTIwSW50ZWwlMjBNYWMlMjBPUyUyMFglMjAxMF8xNV83KSUyMEFwcGxlV2ViS2l0JTJGNTM3LjM2JTIwKEtIVE1MJTJDJTIwbGlrZSUyMEdlY2tvKSUyMENocm9tZSUyRjEzOS4wLjAuMCUyMFNhZmFyaSUyRjUzNy4zNiUyMiUyMHZlcnNpb24lM0QlMjIyOC4yLjglMjIlMjBzY2FsZSUzRCUyMjElMjIlMjBib3JkZXIlM0QlMjIwJTIyJTNFJTBBJTIwJTIwJTNDZGlhZ3JhbSUyMG5hbWUlM0QlMjJQYWdlLTElMjIlMjBpZCUzRCUyMloxN1hHdVRjUnQteXp1N2xJbm1ZJTIyJTNFJTBBJTIwJTIwJTIwJTIwJTNDbXhHcmFwaE1vZGVsJTIwZHglM0QlMjIxMjE2JTIyJTIwZHklM0QlMjI3NzIlMjIlMjBncmlkJTNEJTIyMSUyMiUyMGdyaWRTaXplJTNEJTIyMTAlMjIlMjBndWlkZXMlM0QlMjIxJTIyJTIwdG9vbHRpcHMlM0QlMjIxJTIyJTIwY29ubmVjdCUzRCUyMjElMjIlMjBhcnJvd3MlM0QlMjIxJTIyJTIwZm9sZCUzRCUyMjElMjIlMjBwYWdlJTNEJTIyMSUyMiUyMHBhZ2VTY2FsZSUzRCUyMjElMjIlMjBwYWdlV2lkdGglM0QlMjI4NTAlMjIlMjBwYWdlSGVpZ2h0JTNEJTIyMTEwMCUyMiUyMG1hdGglM0QlMjIwJTIyJTIwc2hhZG93JTNEJTIyMCUyMiUzRSUwQSUyMCUyMCUyMCUyMCUyMCUyMCUzQ3Jvb3QlM0UlMEElMjAlMjAlMjAlMjAlMjAlMjAlMjAlMjAlM0NteENlbGwlMjBpZCUzRCUyMjAlMjIlMjAlMkYlM0UlMEElMjAlMjAlMjAlMjAlMjAlMjAlMjAlMjAlM0NteENlbGwlMjBpZCUzRCUyMjElMjIlMjBwYXJlbnQlM0QlMjIwJTIyJTIwJTJGJTNFJTBBJTIwJTIwJTIwJTIwJTIwJTIwJTNDJTJGcm9vdCUzRSUwQSUyMCUyMCUyMCUyMCUzQyUyRm14R3JhcGhNb2RlbCUzRSUwQSUyMCUyMCUzQyUyRmRpYWdyYW0lM0UlMEElM0MlMkZteGZpbGUlM0UlMEGDoGKLAAAADUlEQVR4AWJiYGRkAAAAAP//LRIDJAAAAAZJREFUAwAAFAAF3SeUTQAAAABJRU5ErkJggg==";
+183
View File
@@ -1,5 +1,10 @@
import { inflate } from "pako";
import extract from "png-chunks-extract";
export enum ImageSource {
DiagramsNet = "diagrams.net",
}
export default class FileHelper {
/**
* Checks if a file is an image.
@@ -130,4 +135,182 @@ export default class FileHelper {
img.src = URL.createObjectURL(file);
});
}
/**
* Determines the source of an image file, if known.
*
* @param file The image file to check
* @returns The image source, or undefined if unknown
*/
static async getImageSourceAttr(
file: File
): Promise<ImageSource | undefined> {
if (await this.isDiagramsNetImage(file)) {
return ImageSource.DiagramsNet;
}
return undefined;
}
/**
* Reads a PNG file (as ArrayBuffer) and detects if it contains embedded Draw.io/Diagrams.net data.
*
* @param file The PNG file to check
* @returns True if the file contains Draw.io/Diagrams.net data
*/
private static async isDiagramsNetImage(file: File): Promise<boolean> {
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);
// Validate PNG signature
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
if (!pngSignature.every((b, i) => bytes[i] === b)) {
return false;
}
let pos = 8; // skip PNG header
while (pos < bytes.length) {
// Read chunk length and type
const length =
(bytes[pos] << 24) |
(bytes[pos + 1] << 16) |
(bytes[pos + 2] << 8) |
bytes[pos + 3];
const type = String.fromCharCode(...bytes.slice(pos + 4, pos + 8));
const data = bytes.slice(pos + 8, pos + 8 + length);
pos += 12 + length; // advance to next chunk
// Check for text chunks where Draw.io embeds metadata
if (type === "tEXt" || type === "zTXt" || type === "iTXt") {
const nullIndex = data.indexOf(0);
if (nullIndex < 0) {
continue;
}
const keyword = new TextDecoder().decode(data.slice(0, nullIndex));
if (keyword.includes("mxfile")) {
try {
let textContent: string;
if (type === "tEXt") {
// tEXt: keyword + null + uncompressed text
textContent = new TextDecoder().decode(data.slice(nullIndex + 1));
} else if (type === "zTXt") {
// zTXt: keyword + null + compression method + compressed text
const compressionMethod = data[nullIndex + 1];
if (compressionMethod === 0) {
// 0 = deflate compression
const compressed = data.slice(nullIndex + 2);
textContent = inflate(compressed, { to: "string" });
} else {
continue; // Unsupported compression method
}
} else {
// iTXt
// iTXt: keyword + null + compression flag + compression method + language tag + null + translated keyword + null + text
const compressionFlag = data[nullIndex + 1];
if (compressionFlag === 0) {
// Uncompressed iTXt
let pos = nullIndex + 3; // Skip null, compression flag, and compression method
// Skip language tag
while (pos < data.length && data[pos] !== 0) {
pos++;
}
pos++; // Skip null after language tag
// Skip translated keyword
while (pos < data.length && data[pos] !== 0) {
pos++;
}
pos++; // Skip null after translated keyword
textContent = new TextDecoder().decode(data.slice(pos));
} else if (compressionFlag === 1) {
// Compressed iTXt
let pos = nullIndex + 3; // Skip null, compression flag, and compression method
// Skip language tag
while (pos < data.length && data[pos] !== 0) {
pos++;
}
pos++; // Skip null after language tag
// Skip translated keyword
while (pos < data.length && data[pos] !== 0) {
pos++;
}
pos++; // Skip null after translated keyword
const compressed = data.slice(pos);
textContent = inflate(compressed, { to: "string" });
} else {
continue; // Invalid compression flag
}
}
if (textContent.includes("%3Cmxfile")) {
return true;
}
} catch (_err) {
// Ignore decompression errors and continue
}
}
}
}
return false;
}
/**
* Converts an image URL to base64 encoded data.
*
* @param url - the URL of the image to convert.
* @returns promise resolving to base64 string (without data URI prefix).
* @throws Error if the image cannot be fetched or converted.
*/
static async urlToBase64(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result as string;
// Extract just the base64 portion (remove "data:image/png;base64," prefix)
const base64 = base64data.split(",")[1];
resolve(base64);
};
reader.onerror = () => {
reject(new Error("Failed to read image as base64"));
};
reader.readAsDataURL(blob);
});
}
/**
* Converts base64 encoded data to a File object.
*
* @param base64Data - base64 encoded string, optionally with data URI prefix.
* @param filename - name for the file.
* @param mimeType - MIME type for the file.
* @returns File object containing the decoded data.
*/
static base64ToFile(
base64Data: string,
filename: string,
mimeType: string
): File {
// Extract base64 portion if it includes data URI prefix
const base64 = base64Data.includes(",")
? base64Data.split(",")[1]
: base64Data;
// Decode base64 to binary
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new File([bytes], filename, { type: mimeType });
}
}
+6 -30
View File
@@ -4,17 +4,16 @@ import { EditorView } from "prosemirror-view";
import { sanitizeUrl } from "@shared/utils/urls";
export abstract class LightboxImage {
protected pos: number;
public pos: number;
public src: string;
public alt: string;
public source: string;
protected element: Element;
protected src: string;
protected alt: string;
constructor() {}
public abstract getElement(): Element | null | undefined;
public abstract getSrc(): string;
public abstract getAlt(): string;
public abstract getPos(): number;
}
class LightboxRegularImage extends LightboxImage {
@@ -24,24 +23,13 @@ class LightboxRegularImage extends LightboxImage {
const node = view.state.doc.nodeAt(pos);
this.src = sanitizeUrl(node?.attrs.src) ?? "";
this.alt = node?.attrs.alt ?? "";
this.source = node?.attrs.source;
this.element = view.nodeDOM(pos) as HTMLSpanElement;
}
getElement() {
return this.element.querySelector("img");
}
getSrc() {
return this.src;
}
getAlt() {
return this.alt;
}
getPos() {
return this.pos;
}
}
class LightboxMermaidImage extends LightboxImage {
@@ -73,18 +61,6 @@ class LightboxMermaidImage extends LightboxImage {
getElement() {
return this.element.nextElementSibling?.firstElementChild;
}
getSrc() {
return this.src;
}
getAlt() {
return this.alt;
}
getPos() {
return this.pos;
}
}
export class LightboxImageFactory {
+37 -19
View File
@@ -11,12 +11,14 @@ import * as React from "react";
import { sanitizeUrl } from "../../utils/urls";
import Caption from "../components/Caption";
import ImageComponent from "../components/Image";
import { addComment } from "../commands/comment";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { ComponentProps } from "../types";
import SimpleImage from "./SimpleImage";
import { LightboxImageFactory } from "../lib/Lightbox";
import { addComment } from "../commands/comment";
import { ImageSource } from "../lib/FileHelper";
import { DiagramPlaceholder } from "../components/DiagramPlaceholder";
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
@@ -107,6 +109,10 @@ export default class Image extends SimpleImage {
default: null,
validate: "string|null",
},
source: {
default: null,
validate: "string|null",
},
layoutClass: {
default: null,
validate: "string|null",
@@ -334,30 +340,42 @@ export default class Image extends SimpleImage {
);
};
handleDownload =
({ node }: ComponentProps) =>
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
return downloadImageNode(node);
};
handleEditDiagram =
({ getPos, view }: ComponentProps) =>
() => {
const { commands } = this.editor;
const pos = getPos();
const $pos = view.state.doc.resolve(pos);
view.dispatch(view.state.tr.setSelection(new NodeSelection($pos)));
commands.editDiagram();
};
component = (props: ComponentProps) => {
const [isDownloading, setIsDownloading] = React.useState(false);
const handleDownload = React.useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (isDownloading) {
return;
}
setIsDownloading(true);
await downloadImageNode(props.node);
setIsDownloading(false);
},
[isDownloading, props]
);
if (
props.node.attrs.source === ImageSource.DiagramsNet &&
!props.node.attrs.src
) {
return (
<DiagramPlaceholder
onDoubleClick={this.handleEditDiagram(props)}
{...props}
/>
);
}
return (
<ImageComponent
{...props}
isDownloading={isDownloading}
onClick={this.handleClick(props)}
onDownload={handleDownload}
onDownload={this.handleDownload(props)}
onChangeSize={this.handleChangeSize(props)}
>
<Caption
+10 -4
View File
@@ -113,6 +113,7 @@
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"Apply template": "Apply template",
"Disconnect": "Disconnect",
"Disconnect analytics": "Disconnect analytics",
"Home": "Home",
"Drafts": "Drafts",
@@ -213,10 +214,6 @@
"Collections could not be loaded, please reload the app": "Collections could not be loaded, please reload the app",
"Start view": "Start view",
"Install now": "Install now",
"Disconnect": "Disconnect",
"Disconnecting": "Disconnecting",
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
"Deleted Collection": "Deleted Collection",
"Untitled": "Untitled",
"Unpin": "Unpin",
@@ -325,6 +322,7 @@
"View, navigate, or download images in the document": "View, navigate, or download images in the document",
"Zoom in": "Zoom in",
"Zoom out": "Zoom out",
"Edit diagram": "Edit diagram",
"Close": "Close",
"Previous": "Previous",
"Next": "Next",
@@ -979,6 +977,9 @@
"Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?",
"Disconnect integration": "Disconnect integration",
"Connected": "Connected",
"Disconnecting": "Disconnecting",
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
"Allowed domains": "Allowed domains",
"The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts.": "The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts.",
"Remove domain": "Remove domain",
@@ -1220,6 +1221,9 @@
"Expires today": "Expires today",
"Expires tomorrow": "Expires tomorrow",
"Expires {{ date }}": "Expires {{ date }}",
"Configure a custom Diagrams.net installation URL to use your own self-hosted instance for embedding diagrams in your documents.": "Configure a custom Diagrams.net installation URL to use your own self-hosted instance for embedding diagrams in your documents.",
"Installation URL": "Installation URL",
"The URL of your Diagrams.net installation. Leave empty to use the cloud hosted app.diagrams.net": "The URL of your Diagrams.net installation. Leave empty to use the cloud hosted app.diagrams.net",
"New attribute": "New attribute",
"Paper size": "Paper size",
"Ask AI \"{{question}}\"": "Ask AI \"{{question}}\"",
@@ -1369,6 +1373,8 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Caption": "Caption",
"Empty diagram": "Empty diagram",
"Double click to edit": "Double click to edit",
"Open": "Open",
"Error loading data": "Error loading data"
}
+6
View File
@@ -196,6 +196,9 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
linear?: {
workspace: { id: string; name: string; key: string; logoUrl?: string };
};
diagrams?: {
url: string;
};
}
: T extends IntegrationType.Analytics
? { measurementId: string; instanceUrl?: string; scriptName?: string }
@@ -220,6 +223,9 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
};
};
};
diagrams?: {
url: string;
};
}
| { url: string; channel: string; channelId: string }
| { serviceTeamId: string }
+1 -1
View File
@@ -11802,7 +11802,7 @@ package-manager-detector@^1.3.0:
resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.3.0.tgz#b42d641c448826e03c2b354272456a771ce453c0"
integrity sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==
pako@^2.0.4:
pako@^2.0.4, pako@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
integrity "sha1-JmzDf5jH2INUXREzXAD71AYsmoY= sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug=="