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) { > {activeImage.getAlt()} 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: Diagrams.net Diagram, + 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. + + +
+ + + + + + + {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} + + + + +
+
+ ); +} + +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 (
{ )}