diff --git a/app/actions/definitions/integrations.tsx b/app/actions/definitions/integrations.tsx
index c42947637d..e1eb53727e 100644
--- a/app/actions/definitions/integrations.tsx
+++ b/app/actions/definitions/integrations.tsx
@@ -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: ,
+ keywords: "disconnect",
+ visible: () => !!integration,
+ perform: async ({ event }) => {
+ event?.preventDefault();
+ event?.stopPropagation();
+
+ await integration?.delete();
+ history.push(settingsPath("integrations"));
+ },
+ });
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration
diff --git a/app/components/Lightbox.tsx b/app/components/Lightbox.tsx
index dce6864ff1..e1d1594cb4 100644
--- a/app/components/Lightbox.tsx
+++ b/app/components/Lightbox.tsx
@@ -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(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 (
@@ -745,7 +761,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
}
@@ -753,6 +769,20 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
neutral
/>
+ {activeImage.source === ImageSource.DiagramsNet && (
+
+ }
+ borderOnHover
+ neutral
+ />
+
+ )}
@@ -792,8 +822,8 @@ function Lightbox({ images, activeImage, onUpdate, onClose }: Props) {
>
setStatus({
lightbox: status.lightbox,
diff --git a/app/editor/extensions/index.ts b/app/editor/extensions/index.ts
index d095a9407f..f8e10904b4 100644
--- a/app/editor/extensions/index.ts
+++ b/app/editor/extensions/index.ts
@@ -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,
diff --git a/app/editor/menus/block.tsx b/app/editor/menus/block.tsx
index 19d7665800..0c677dd45d 100644
--- a/app/editor/menus/block.tsx
+++ b/app/editor/menus/block.tsx
@@ -223,5 +223,11 @@ export default function blockMenuItems(
keywords: "diagram flowchart",
attrs: { language: "mermaidjs" },
},
+ {
+ name: "editDiagram",
+ title: "Diagrams.net Diagram",
+ icon:
,
+ keywords: "diagram flowchart draw.io",
+ },
];
}
diff --git a/app/editor/menus/image.tsx b/app/editor/menus/image.tsx
index f98dc2ea67..1987bdb4fc 100644
--- a/app/editor/menus/image.tsx
+++ b/app/editor/menus/image.tsx
@@ -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: ,
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: ,
active: isRightAligned,
+ visible: !isEmptyDiagram(state),
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: ,
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: ,
+ visible: isDiagram(state),
+ },
{
name: "downloadImage",
tooltip: dictionary.downloadImage,
icon: ,
- visible: !!fetch,
+ visible: !!fetch && !isEmptyDiagram(state),
},
{
tooltip: dictionary.replaceImage,
icon: ,
+ visible: !isDiagram(state),
children: [
{
name: "replaceImage",
diff --git a/app/components/DisconnectAnalyticsDialog.tsx b/app/scenes/Settings/components/DisconnectAnalyticsDialog.tsx
similarity index 100%
rename from app/components/DisconnectAnalyticsDialog.tsx
rename to app/scenes/Settings/components/DisconnectAnalyticsDialog.tsx
diff --git a/package.json b/package.json
index d9ac9bf258..8d70065500 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/plugins/diagrams/client/Icon.tsx b/plugins/diagrams/client/Icon.tsx
new file mode 100644
index 0000000000..92d8cb5bb7
--- /dev/null
+++ b/plugins/diagrams/client/Icon.tsx
@@ -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 (
+
+ );
+}
diff --git a/plugins/diagrams/client/Settings.tsx b/plugins/diagrams/client/Settings.tsx
new file mode 100644
index 0000000000..a97a62887c
--- /dev/null
+++ b/plugins/diagrams/client/Settings.tsx
@@ -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 | undefined;
+
+ const url = integration?.settings.diagrams?.url;
+
+ const {
+ register,
+ reset,
+ handleSubmit: formHandleSubmit,
+ formState,
+ } = useForm({
+ 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["settings"],
+ });
+
+ toast.success(t("Settings saved"));
+ } catch (err) {
+ toast.error(err.message);
+ }
+ },
+ [integrations, integration, t]
+ );
+
+ return (
+ }>
+ Diagrams.net
+
+
+
+ Configure a custom Diagrams.net installation URL to use your own
+ self-hosted instance for embedding diagrams in your documents.
+
+
+
+
+ );
+}
+
+const Actions = styled(Flex)`
+ margin-top: 8px;
+`;
+
+const StyledSubmit = styled(Button)`
+ width: 80px;
+`;
+
+export default observer(DiagramsNet);
diff --git a/plugins/diagrams/client/index.tsx b/plugins/diagrams/client/index.tsx
new file mode 100644
index 0000000000..76ac8c93f2
--- /dev/null
+++ b/plugins/diagrams/client/index.tsx
@@ -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,
+ },
+ },
+]);
diff --git a/plugins/diagrams/plugin.json b/plugins/diagrams/plugin.json
new file mode 100644
index 0000000000..20544bfb44
--- /dev/null
+++ b/plugins/diagrams/plugin.json
@@ -0,0 +1,6 @@
+{
+ "id": "diagrams",
+ "name": "Diagrams.net",
+ "priority": 45,
+ "description": "Configure a custom Diagrams.net installation URL for embedding diagrams."
+}
diff --git a/plugins/storage/server/api/files.ts b/plugins/storage/server/api/files.ts
index fcb7d3e66b..3cf0536c48 100644
--- a/plugins/storage/server/api/files.ts
+++ b/plugins/storage/server/api/files.ts
@@ -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");
diff --git a/server/routes/api/integrations/schema.ts b/server/routes/api/integrations/schema.ts
index b494255e53..5da920a6cf 100644
--- a/server/routes/api/integrations/schema.ts
+++ b/server/routes/api/integrations/schema.ts
@@ -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(),
diff --git a/shared/editor/commands/insertFiles.ts b/shared/editor/commands/insertFiles.ts
index 77f740deb0..70d864cab0 100644
--- a/shared/editor/commands/insertFiles.ts
+++ b/shared/editor/commands/insertFiles.ts
@@ -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,
})
diff --git a/shared/editor/components/DiagramPlaceholder.tsx b/shared/editor/components/DiagramPlaceholder.tsx
new file mode 100644
index 0000000000..7da7c1513a
--- /dev/null
+++ b/shared/editor/components/DiagramPlaceholder.tsx
@@ -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 (
+
+
+ {t("Empty diagram")}
+ {" "}
+
+ {t("Double click to edit")}
+
+
+ );
+};
+
+const Placeholder = styled.div`
+ border: 2px dashed ${s("inputBorder")};
+ background: ${s("backgroundSecondary")};
+ border-radius: ${EditorStyleHelper.blockRadius};
+ padding: 16px;
+ text-align: center;
+ cursor: var(--pointer);
+`;
diff --git a/shared/editor/components/Image.tsx b/shared/editor/components/Image.tsx
index 9526f8ff94..9809daba2f 100644
--- a/shared/editor/components/Image.tsx
+++ b/shared/editor/components/Image.tsx
@@ -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) => void;
+ onDownload?: (event: React.MouseEvent) => Promise;
/** 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) => {
+ ev.preventDefault();
+ if (props.onDownload) {
+ setIsDownloading(true);
+ try {
+ await props.onDownload(ev);
+ } finally {
+ setIsDownloading(false);
+ }
+ }
+ };
+
return (
{
)}