mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "diagrams",
|
||||
"name": "Diagrams.net",
|
||||
"priority": 45,
|
||||
"description": "Configure a custom Diagrams.net installation URL for embedding diagrams."
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -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==";
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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=="
|
||||
|
||||
Reference in New Issue
Block a user