mirror of
https://github.com/docmost/docmost.git
synced 2026-05-14 04:24:04 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0df96d4bb | |||
| 22f33bab7c | |||
| e0a8521566 | |||
| b5803f42da | |||
| 5de1c8e3ed | |||
| ef87210b3d |
@@ -59,7 +59,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
"@tanstack/eslint-plugin-query": "^5.62.1",
|
"@tanstack/eslint-plugin-query": "^5.91.4",
|
||||||
"@types/blueimp-load-image": "^5.16.0",
|
"@types/blueimp-load-image": "^5.16.0",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
|||||||
@@ -274,6 +274,7 @@
|
|||||||
"Add row below": "Add row below",
|
"Add row below": "Add row below",
|
||||||
"Delete table": "Delete table",
|
"Delete table": "Delete table",
|
||||||
"Info": "Info",
|
"Info": "Info",
|
||||||
|
"Note": "Note",
|
||||||
"Success": "Success",
|
"Success": "Success",
|
||||||
"Warning": "Warning",
|
"Warning": "Warning",
|
||||||
"Danger": "Danger",
|
"Danger": "Danger",
|
||||||
@@ -363,6 +364,15 @@
|
|||||||
"Heading {{level}}": "Heading {{level}}",
|
"Heading {{level}}": "Heading {{level}}",
|
||||||
"Toggle title": "Toggle title",
|
"Toggle title": "Toggle title",
|
||||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||||
|
"Write...": "Write...",
|
||||||
|
"Column count": "Column count",
|
||||||
|
"{{count}} Columns": "{{count}} Columns",
|
||||||
|
"Equal columns": "Equal columns",
|
||||||
|
"Left sidebar": "Left sidebar",
|
||||||
|
"Right sidebar": "Right sidebar",
|
||||||
|
"Wide center": "Wide center",
|
||||||
|
"Left wide": "Left wide",
|
||||||
|
"Right wide": "Right wide",
|
||||||
"Names do not match": "Names do not match",
|
"Names do not match": "Names do not match",
|
||||||
"Today, {{time}}": "Today, {{time}}",
|
"Today, {{time}}": "Today, {{time}}",
|
||||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import SpaceTrash from "@/pages/space/space-trash.tsx";
|
|||||||
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
|
||||||
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
|
||||||
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
|
||||||
import Integrations from "@/features/integration/pages/integrations.tsx";
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -103,7 +102,6 @@ export default function App() {
|
|||||||
<Route path={"sharing"} element={<Shares />} />
|
<Route path={"sharing"} element={<Shares />} />
|
||||||
<Route path={"security"} element={<Security />} />
|
<Route path={"security"} element={<Security />} />
|
||||||
<Route path={"ai"} element={<AiSettings />} />
|
<Route path={"ai"} element={<AiSettings />} />
|
||||||
<Route path={"integrations"} element={<Integrations />} />
|
|
||||||
{!isCloud() && <Route path={"license"} element={<License />} />}
|
{!isCloud() && <Route path={"license"} element={<License />} />}
|
||||||
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
{isCloud() && <Route path={"billing"} element={<Billing />} />}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns4({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M7.5 3v18" />
|
||||||
|
<path d="M12 3v18" />
|
||||||
|
<path d="M16.5 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { rem } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
size?: number | string;
|
||||||
|
stroke?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IconColumns5({ size = 24, stroke = 2 }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={rem(size)}
|
||||||
|
height={rem(size)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M3 4a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-16" />
|
||||||
|
<path d="M6.6 3v18" />
|
||||||
|
<path d="M10.2 3v18" />
|
||||||
|
<path d="M13.8 3v18" />
|
||||||
|
<path d="M17.4 3v18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
IconKey,
|
IconKey,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconSparkles,
|
IconSparkles,
|
||||||
IconPlug,
|
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import classes from "./settings.module.css";
|
import classes from "./settings.module.css";
|
||||||
@@ -117,12 +116,6 @@ const groupedData: DataGroup[] = [
|
|||||||
path: "/settings/ai",
|
path: "/settings/ai",
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Integrations",
|
|
||||||
icon: IconPlug,
|
|
||||||
path: "/settings/integrations",
|
|
||||||
isAdmin: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({
|
|||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
|
<Tooltip label={t("Add link")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangleFilled,
|
IconAlertTriangleFilled,
|
||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
IconMoodSmile,
|
IconMoodSmile,
|
||||||
|
IconNotes,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { CalloutType } from "@docmost/editor-ext";
|
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
if (!state) {
|
if (!state) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (isTextSelected(editor)) return false;
|
||||||
|
|
||||||
return editor.isActive("callout");
|
return editor.isActive("callout");
|
||||||
},
|
},
|
||||||
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
return {
|
return {
|
||||||
isCallout: ctx.editor.isActive("callout"),
|
isCallout: ctx.editor.isActive("callout"),
|
||||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||||
|
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
||||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||||
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
||||||
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
||||||
@@ -126,15 +131,31 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Info")}>
|
<Tooltip position="top" label={t("Info")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => setCalloutType("info")}
|
onClick={() => setCalloutType("info")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Info")}
|
aria-label={t("Info")}
|
||||||
variant={editorState?.isInfo ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isInfo })}
|
||||||
>
|
>
|
||||||
<IconInfoCircleFilled size={18} />
|
<IconInfoCircleFilled
|
||||||
|
size={18}
|
||||||
|
color="var(--mantine-color-blue-5)"
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Note")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => setCalloutType("note")}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Note")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isNote })}
|
||||||
|
>
|
||||||
|
<IconNotes size={18} color="var(--mantine-color-grape-5)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -143,9 +164,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("success")}
|
onClick={() => setCalloutType("success")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Success")}
|
aria-label={t("Success")}
|
||||||
variant={editorState?.isSuccess ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isSuccess })}
|
||||||
>
|
>
|
||||||
<IconCircleCheckFilled size={18} />
|
<IconCircleCheckFilled
|
||||||
|
size={18}
|
||||||
|
color="var(--mantine-color-green-5)"
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -154,9 +179,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("warning")}
|
onClick={() => setCalloutType("warning")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Warning")}
|
aria-label={t("Warning")}
|
||||||
variant={editorState?.isWarning ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isWarning })}
|
||||||
>
|
>
|
||||||
<IconAlertTriangleFilled size={18} />
|
<IconAlertTriangleFilled
|
||||||
|
size={18}
|
||||||
|
color="var(--mantine-color-orange-5)"
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -165,9 +194,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={() => setCalloutType("danger")}
|
onClick={() => setCalloutType("danger")}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Danger")}
|
aria-label={t("Danger")}
|
||||||
variant={editorState?.isDanger ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isDanger })}
|
||||||
>
|
>
|
||||||
<IconCircleXFilled size={18} />
|
<IconCircleXFilled size={18} color="var(--mantine-color-red-5)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
|||||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||||
actionIconProps={{
|
actionIconProps={{
|
||||||
size: "lg",
|
size: "lg",
|
||||||
variant: "default",
|
variant: "subtle",
|
||||||
c: undefined,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ActionIcon.Group>
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IconCircleCheckFilled,
|
IconCircleCheckFilled,
|
||||||
IconCircleXFilled,
|
IconCircleXFilled,
|
||||||
IconInfoCircleFilled,
|
IconInfoCircleFilled,
|
||||||
|
IconNotes,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { Alert } from "@mantine/core";
|
import { Alert } from "@mantine/core";
|
||||||
import classes from "./callout.module.css";
|
import classes from "./callout.module.css";
|
||||||
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
icon={getCalloutIcon(type, icon)}
|
icon={getCalloutIcon(type, icon)}
|
||||||
p="xs"
|
p="xs"
|
||||||
classNames={{
|
classNames={{
|
||||||
|
root: classes.root,
|
||||||
message: classes.message,
|
message: classes.message,
|
||||||
icon: classes.icon,
|
icon: classes.icon,
|
||||||
}}
|
}}
|
||||||
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
|
|||||||
|
|
||||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||||
if (customIcon && customIcon.trim() !== "") {
|
if (customIcon && customIcon.trim() !== "") {
|
||||||
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return <IconInfoCircleFilled />;
|
return <IconInfoCircleFilled />;
|
||||||
|
case "note":
|
||||||
|
return <IconNotes />;
|
||||||
case "success":
|
case "success":
|
||||||
return <IconCircleCheckFilled />;
|
return <IconCircleCheckFilled />;
|
||||||
case "warning":
|
case "warning":
|
||||||
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "info":
|
case "info":
|
||||||
return "blue";
|
return "blue";
|
||||||
|
case "note":
|
||||||
|
return "grape";
|
||||||
case "success":
|
case "success":
|
||||||
return "green";
|
return "green";
|
||||||
case "warning":
|
case "warning":
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
.root {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-inline-end: var(--mantine-spacing-md);
|
margin-inline-end: var(--mantine-spacing-xs);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -11,18 +15,8 @@
|
|||||||
.message {
|
.message {
|
||||||
font-size: var(--mantine-font-size-md);
|
font-size: var(--mantine-font-size-md);
|
||||||
color: var(--mantine-color-default-color);
|
color: var(--mantine-color-default-color);
|
||||||
|
overflow: visible;
|
||||||
white-space: nowrap;
|
text-overflow: unset;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
@mixin where-light {
|
|
||||||
color: var(--mantine-color-default-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
@mixin where-dark {
|
|
||||||
color: var(--mantine-color-default-color);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
|
import { DOMSerializer, Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import {
|
||||||
|
EditorMenuProps,
|
||||||
|
ShouldShowProps,
|
||||||
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
|
import { ActionIcon, Tooltip, Popover, Button } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconChevronDown,
|
||||||
|
IconCheck,
|
||||||
|
IconColumns2,
|
||||||
|
IconColumns3,
|
||||||
|
IconLayoutSidebar,
|
||||||
|
IconLayoutSidebarRight,
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconCopy,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { isTextSelected } from "@docmost/editor-ext";
|
||||||
|
import type { WidthMode, ColumnsLayout } from "@docmost/editor-ext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
type LayoutPreset = {
|
||||||
|
layout: ColumnsLayout;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const twoColumnPresets: LayoutPreset[] = [
|
||||||
|
{ layout: "two_equal", label: "Equal columns", icon: IconColumns2 },
|
||||||
|
{
|
||||||
|
layout: "two_left_sidebar",
|
||||||
|
label: "Left sidebar",
|
||||||
|
icon: IconLayoutSidebar,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: "two_right_sidebar",
|
||||||
|
label: "Right sidebar",
|
||||||
|
icon: IconLayoutSidebarRight,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const threeColumnPresets: LayoutPreset[] = [
|
||||||
|
{ layout: "three_equal", label: "Equal columns", icon: IconColumns3 },
|
||||||
|
{
|
||||||
|
layout: "three_with_sidebars",
|
||||||
|
label: "Wide center",
|
||||||
|
icon: IconLayoutAlignCenter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout: "three_left_wide",
|
||||||
|
label: "Left wide",
|
||||||
|
icon: IconLayoutSidebarRight,
|
||||||
|
},
|
||||||
|
{ layout: "three_right_wide", label: "Right wide", icon: IconLayoutSidebar },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getPresetsForCount(count: number): LayoutPreset[] {
|
||||||
|
if (count === 2) return twoColumnPresets;
|
||||||
|
if (count === 3) return threeColumnPresets;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCountOpen, setIsCountOpen] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const nodesWithMenus = [
|
||||||
|
"callout",
|
||||||
|
"image",
|
||||||
|
"video",
|
||||||
|
"drawio",
|
||||||
|
"excalidraw",
|
||||||
|
"table",
|
||||||
|
];
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) return false;
|
||||||
|
if (!editor.isActive("columns")) return false;
|
||||||
|
if (isTextSelected(editor)) return false;
|
||||||
|
if (nodesWithMenus.some((name) => editor.isActive(name))) return false;
|
||||||
|
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node: PMNode) => node.type.name === "columns",
|
||||||
|
)(state.selection);
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
const dom = editor.view.nodeDOM(parent.pos) as HTMLElement;
|
||||||
|
if (!dom) return false;
|
||||||
|
|
||||||
|
const rect = dom.getBoundingClientRect();
|
||||||
|
return rect.bottom > 0 && rect.top < window.innerHeight;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editorState = useEditorState({
|
||||||
|
editor,
|
||||||
|
selector: (ctx) => {
|
||||||
|
if (!ctx.editor) return null;
|
||||||
|
|
||||||
|
const { selection } = ctx.editor.state;
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node: PMNode) => node.type.name === "columns",
|
||||||
|
)(selection);
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnCount: parent?.node.childCount || 2,
|
||||||
|
layout: (parent?.node.attrs.layout as ColumnsLayout) || "two_equal",
|
||||||
|
isNormal: ctx.editor.isActive("columns", { widthMode: "normal" }),
|
||||||
|
isWide: ctx.editor.isActive("columns", { widthMode: "wide" }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||||
|
const parent = findParentNode(predicate)(selection);
|
||||||
|
|
||||||
|
if (parent) {
|
||||||
|
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
|
||||||
|
const domRect = dom.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Columns entirely out of viewport — return real rect so menu goes off-screen
|
||||||
|
if (domRect.bottom <= 0 || domRect.top >= window.innerHeight) {
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp bottom so menu stays within viewport when columns extend below it
|
||||||
|
// 55px = 15px offset + ~40px menu height
|
||||||
|
const maxBottom = window.innerHeight - 55;
|
||||||
|
if (domRect.bottom > maxBottom) {
|
||||||
|
const clamped = new DOMRect(
|
||||||
|
domRect.x,
|
||||||
|
domRect.y,
|
||||||
|
domRect.width,
|
||||||
|
maxBottom - domRect.y,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => clamped,
|
||||||
|
getClientRects: () => [clamped],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
|
||||||
|
return {
|
||||||
|
getBoundingClientRect: () => domRect,
|
||||||
|
getClientRects: () => [domRect],
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const setColumnCount = useCallback(
|
||||||
|
(count: number) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setColumnCount(count)
|
||||||
|
.run();
|
||||||
|
setIsCountOpen(false);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setLayout = useCallback(
|
||||||
|
(layout: ColumnsLayout) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setColumnsLayout(layout)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
const { state } = editor;
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node: PMNode) => node.type.name === "columns",
|
||||||
|
)(state.selection);
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
|
const serializer = DOMSerializer.fromSchema(state.schema);
|
||||||
|
const dom = serializer.serializeNode(parent.node);
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.appendChild(dom);
|
||||||
|
|
||||||
|
const onSuccess = () => {
|
||||||
|
clearTimeout(copyTimerRef.current);
|
||||||
|
setCopied(true);
|
||||||
|
copyTimerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (navigator.clipboard?.write) {
|
||||||
|
navigator.clipboard
|
||||||
|
.write([
|
||||||
|
new ClipboardItem({
|
||||||
|
"text/html": new Blob([wrapper.innerHTML], { type: "text/html" }),
|
||||||
|
"text/plain": new Blob([parent.node.textContent], {
|
||||||
|
type: "text/plain",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(onSuccess)
|
||||||
|
.catch(execCommandFallback);
|
||||||
|
} else {
|
||||||
|
execCommandFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
function execCommandFallback() {
|
||||||
|
wrapper.style.position = "fixed";
|
||||||
|
wrapper.style.left = "-9999px";
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(wrapper);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand("copy");
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
document.body.removeChild(wrapper);
|
||||||
|
editor.view.focus();
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node: PMNode) => node.type.name === "columns",
|
||||||
|
)(editor.state.selection);
|
||||||
|
if (!parent) return;
|
||||||
|
editor.chain().focus().setNodeSelection(parent.pos).deleteSelection().run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const columnCount = editorState?.columnCount || 2;
|
||||||
|
const currentLayout = editorState?.layout || "two_equal";
|
||||||
|
const presets = getPresetsForCount(columnCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseBubbleMenu
|
||||||
|
editor={editor}
|
||||||
|
pluginKey="columns-menu"
|
||||||
|
updateDelay={0}
|
||||||
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
|
options={{
|
||||||
|
placement: "bottom",
|
||||||
|
offset: {
|
||||||
|
mainAxis: 5,
|
||||||
|
},
|
||||||
|
flip: false,
|
||||||
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Popover opened={isCountOpen} onChange={setIsCountOpen} withArrow>
|
||||||
|
<Popover.Target>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="dark"
|
||||||
|
size="compact-sm"
|
||||||
|
rightSection={<IconChevronDown size={12} />}
|
||||||
|
onClick={() => setIsCountOpen(!isCountOpen)}
|
||||||
|
aria-label={t("Column count")}
|
||||||
|
>
|
||||||
|
{t("{{count}} Columns", { count: columnCount })}
|
||||||
|
</Button>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown p={4}>
|
||||||
|
<Button.Group orientation="vertical">
|
||||||
|
{[2, 3, 4, 5].map((n) => (
|
||||||
|
<Button
|
||||||
|
key={n}
|
||||||
|
variant={n === columnCount ? "light" : "subtle"}
|
||||||
|
color={n === columnCount ? "blue" : "dark"}
|
||||||
|
justify="space-between"
|
||||||
|
fullWidth
|
||||||
|
rightSection={
|
||||||
|
n === columnCount ? <IconCheck size={14} /> : null
|
||||||
|
}
|
||||||
|
onClick={() => setColumnCount(n)}
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
{t("{{count}} Columns", { count: n })}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Button.Group>
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{presets.length > 0 && <div className={classes.divider} />}
|
||||||
|
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<Tooltip key={preset.layout} position="top" label={t(preset.label)}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => setLayout(preset.layout)}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t(preset.label)}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: currentLayout === preset.layout,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<preset.icon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
position="top"
|
||||||
|
label={copied ? t("Copied") : t("Copy")}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleCopy}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Copy")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<IconCheck size={18} color="var(--mantine-color-green-6)" />
|
||||||
|
) : (
|
||||||
|
<IconCopy size={18} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ColumnsMenu;
|
||||||
@@ -4,7 +4,6 @@ import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
|||||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { matchIntegrationLink } from "@docmost/editor-ext";
|
|
||||||
|
|
||||||
export const handlePaste = (
|
export const handlePaste = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
@@ -14,21 +13,6 @@ export const handlePaste = (
|
|||||||
) => {
|
) => {
|
||||||
const clipboardData = event.clipboardData.getData("text/plain");
|
const clipboardData = event.clipboardData.getData("text/plain");
|
||||||
|
|
||||||
const integrationMatch = matchIntegrationLink(clipboardData.trim());
|
|
||||||
if (integrationMatch && editor.state.selection.empty) {
|
|
||||||
event.preventDefault();
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.setIntegrationLink({
|
|
||||||
url: clipboardData.trim(),
|
|
||||||
provider: integrationMatch.provider,
|
|
||||||
status: "pending",
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
if (INTERNAL_LINK_REGEX.test(clipboardData)) {
|
||||||
// we have to do this validation here to allow the default link extension to takeover if needs be
|
// we have to do this validation here to allow the default link extension to takeover if needs be
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
import classes from "./node-resize.module.css";
|
||||||
|
|
||||||
|
export function createResizeHandle(
|
||||||
|
direction: ResizableNodeViewDirection,
|
||||||
|
): HTMLElement {
|
||||||
|
const handle = document.createElement("div");
|
||||||
|
handle.dataset.resizeHandle = direction;
|
||||||
|
handle.style.position = "absolute";
|
||||||
|
handle.className = classes.handle;
|
||||||
|
|
||||||
|
if (direction === "left") {
|
||||||
|
handle.style.left = "-8px";
|
||||||
|
handle.style.top = "0";
|
||||||
|
handle.style.bottom = "0";
|
||||||
|
} else if (direction === "right") {
|
||||||
|
handle.style.right = "-8px";
|
||||||
|
handle.style.top = "0";
|
||||||
|
handle.style.bottom = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = classes.handleBar;
|
||||||
|
handle.appendChild(bar);
|
||||||
|
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildResizeClasses(nodeClass: string) {
|
||||||
|
return {
|
||||||
|
container: `${classes.container} ${nodeClass}`,
|
||||||
|
wrapper: classes.wrapper,
|
||||||
|
resizing: classes.resizing,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper img,
|
||||||
|
.wrapper video {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="left"] {
|
||||||
|
left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="right"] {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handleBar {
|
||||||
|
width: 4px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||||
|
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||||
|
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar :global(.mantine-ActionIcon-root) {
|
||||||
|
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
|
||||||
|
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .active {
|
||||||
|
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
|
||||||
|
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 2px;
|
||||||
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
||||||
|
}
|
||||||
@@ -1,24 +1,41 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import { ActionIcon, Modal, Tooltip, useComputedColorScheme } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconLayoutAlignLeft,
|
||||||
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import {
|
||||||
|
DrawIoEmbed,
|
||||||
|
DrawIoEmbedRef,
|
||||||
|
EventExit,
|
||||||
|
EventSave,
|
||||||
|
} from "react-drawio";
|
||||||
|
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||||
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function DrawioMenu({ editor }: EditorMenuProps) {
|
export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const { t } = useTranslation();
|
||||||
({ state }: ShouldShowProps) => {
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
if (!state) {
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
return false;
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
}
|
const computedColorScheme = useComputedColorScheme();
|
||||||
|
|
||||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
|
||||||
},
|
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
const drawioAttr = ctx.editor.getAttributes("drawio");
|
const drawioAttr = ctx.editor.getAttributes("drawio");
|
||||||
return {
|
return {
|
||||||
isDrawio: ctx.editor.isActive("drawio"),
|
isDrawio: ctx.editor.isActive("drawio"),
|
||||||
width: drawioAttr?.width ? parseInt(drawioAttr.width) : null,
|
isAlignLeft: ctx.editor.isActive("drawio", { align: "left" }),
|
||||||
|
isAlignCenter: ctx.editor.isActive("drawio", { align: "center" }),
|
||||||
|
isAlignRight: ctx.editor.isActive("drawio", { align: "right" }),
|
||||||
|
src: drawioAttr?.src || null,
|
||||||
|
attachmentId: drawioAttr?.attachmentId || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -57,38 +89,218 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const alignLeft = useCallback(() => {
|
||||||
(value: number) => {
|
editor
|
||||||
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("left")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignCenter = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("center")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setDrawioAlign("right")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(async () => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
const blob = await request.blob();
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
reader.onloadend = () => {
|
||||||
|
const base64data = (reader.result || "") as string;
|
||||||
|
setInitialXML(base64data);
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}, [editorState?.src, open]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: EventSave) => {
|
||||||
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
const fileName = "diagram.drawio.svg";
|
||||||
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
const attachmentId = editorState?.attachmentId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(drawioSVGFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.updateAttributes("drawio", {
|
||||||
|
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
},
|
},
|
||||||
[editor],
|
[editor, editorState?.attachmentId, close],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<>
|
||||||
editor={editor}
|
<BaseBubbleMenu
|
||||||
pluginKey={`drawio-menu`}
|
editor={editor}
|
||||||
updateDelay={0}
|
pluginKey={`drawio-menu`}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
updateDelay={0}
|
||||||
options={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
placement: "top",
|
options={{
|
||||||
offset: 8,
|
placement: "top",
|
||||||
flip: false,
|
offset: 8,
|
||||||
}}
|
flip: false,
|
||||||
shouldShow={shouldShow}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
}}
|
||||||
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
{editorState?.width && (
|
<div className={classes.toolbar}>
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
)}
|
<ActionIcon
|
||||||
</div>
|
onClick={alignLeft}
|
||||||
</BaseBubbleMenu>
|
size="lg"
|
||||||
|
aria-label={t("Align left")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignCenter}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align center")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignCenter size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align right")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Edit")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
|
||||||
|
<Modal.Root opened={opened} onClose={close} fullScreen>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Body>
|
||||||
|
<div style={{ height: "100vh" }}>
|
||||||
|
<DrawIoEmbed
|
||||||
|
ref={drawioRef}
|
||||||
|
xml={initialXML}
|
||||||
|
baseUrl={getDrawioUrl()}
|
||||||
|
urlParameters={{
|
||||||
|
ui: computedColorScheme === "light" ? "kennedy" : "dark",
|
||||||
|
spin: true,
|
||||||
|
libraries: true,
|
||||||
|
saveAndExit: true,
|
||||||
|
noSaveBtn: true,
|
||||||
|
}}
|
||||||
|
onSave={(data: EventSave) => {
|
||||||
|
if (data.parentEvent !== "save") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSave(data);
|
||||||
|
}}
|
||||||
|
onClose={(data: EventExit) => {
|
||||||
|
if (data.parentEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
|||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Card,
|
Card,
|
||||||
Image,
|
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
import { getDrawioUrl } from "@/lib/config.ts";
|
||||||
import {
|
import {
|
||||||
DrawIoEmbed,
|
DrawIoEmbed,
|
||||||
DrawIoEmbedRef,
|
DrawIoEmbedRef,
|
||||||
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
export default function DrawioView(props: NodeViewProps) {
|
export default function DrawioView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { attachmentId } = node.attrs;
|
||||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||||
const [initialXML, setInitialXML] = useState<string>("");
|
const [initialXML, setInitialXML] = useState<string>("");
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
open();
|
||||||
try {
|
|
||||||
if (src) {
|
|
||||||
const url = getFileUrl(src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
const blob = await request.blob();
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
reader.onloadend = () => {
|
|
||||||
const base64data = (reader.result || "") as string;
|
|
||||||
setInitialXML(base64data);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (data: EventSave) => {
|
const handleSave = async (data: EventSave) => {
|
||||||
const svgString = decodeBase64ToSvgString(data.xml);
|
const svgString = decodeBase64ToSvgString(data.xml);
|
||||||
|
|
||||||
const fileName = "diagram.drawio.svg";
|
const fileName = "diagram.drawio.svg";
|
||||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
const pageId = editor.storage?.pageId;
|
const pageId = editor.storage?.pageId;
|
||||||
|
|
||||||
let attachment: IAttachment = null;
|
let attachment: IAttachment = null;
|
||||||
|
|
||||||
if (attachmentId) {
|
if (attachmentId) {
|
||||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||||
} else {
|
} else {
|
||||||
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
noSaveBtn: true,
|
noSaveBtn: true,
|
||||||
}}
|
}}
|
||||||
onSave={(data: EventSave) => {
|
onSave={(data: EventSave) => {
|
||||||
// If the save is triggered by another event, then do nothing
|
|
||||||
if (data.parentEvent !== "save") {
|
if (data.parentEvent !== "save") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleSave(data);
|
handleSave(data);
|
||||||
}}
|
}}
|
||||||
onClose={(data: EventExit) => {
|
onClose={(data: EventExit) => {
|
||||||
// If the exit is triggered by another event, then do nothing
|
|
||||||
if (data.parentEvent) {
|
if (data.parentEvent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
|
|||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
{src ? (
|
<Card
|
||||||
<div style={{ position: "relative" }}>
|
radius="md"
|
||||||
<Image
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
p="xs"
|
||||||
radius="md"
|
style={{
|
||||||
fit="contain"
|
display: "flex",
|
||||||
w={width}
|
justifyContent: "center",
|
||||||
src={getFileUrl(src)}
|
alignItems: "center",
|
||||||
alt={title}
|
}}
|
||||||
className={clsx(
|
withBorder
|
||||||
selected ? "ProseMirror-selectednode" : "",
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
"alignCenter",
|
>
|
||||||
)}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
/>
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
<Text component="span" size="lg" c="dimmed">
|
||||||
<ActionIcon
|
{t("Double-click to edit Draw.io diagram")}
|
||||||
onClick={handleOpen}
|
</Text>
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
mx="xs"
|
|
||||||
className="print-hide"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</Card>
|
||||||
<Card
|
|
||||||
radius="md"
|
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
withBorder
|
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<ActionIcon variant="transparent" color="gray">
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
|
||||||
{t("Double-click to edit Draw.io diagram")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,57 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import { useCallback } from "react";
|
import { lazy, Suspense, useCallback, useState } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import {
|
||||||
|
IconLayoutAlignCenter,
|
||||||
|
IconLayoutAlignLeft,
|
||||||
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
|
import { svgStringToFile } from "@/lib";
|
||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
|
import ReactClearModal from "react-clear-modal";
|
||||||
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
|
const ExcalidrawComponent = lazy(() =>
|
||||||
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
|
default: module.Excalidraw,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||||
const shouldShow = useCallback(
|
const { t } = useTranslation();
|
||||||
({ state }: ShouldShowProps) => {
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
if (!state) {
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
return false;
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
}
|
useHandleLibrary({
|
||||||
|
excalidrawAPI,
|
||||||
return (
|
adapter: localStorageLibraryAdapter,
|
||||||
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
});
|
||||||
);
|
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||||
},
|
const computedColorScheme = useComputedColorScheme();
|
||||||
[editor],
|
|
||||||
);
|
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||||
return {
|
return {
|
||||||
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
isExcalidraw: ctx.editor.isActive("excalidraw"),
|
||||||
width: excalidrawAttr?.width ? parseInt(excalidrawAttr.width) : null,
|
isAlignLeft: ctx.editor.isActive("excalidraw", { align: "left" }),
|
||||||
|
isAlignCenter: ctx.editor.isActive("excalidraw", { align: "center" }),
|
||||||
|
isAlignRight: ctx.editor.isActive("excalidraw", { align: "right" }),
|
||||||
|
src: excalidrawAttr?.src || null,
|
||||||
|
attachmentId: excalidrawAttr?.attachmentId || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shouldShow = useCallback(
|
||||||
|
({ state }: ShouldShowProps) => {
|
||||||
|
if (!state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
editor.isActive("excalidraw") &&
|
||||||
|
editor.getAttributes("excalidraw")?.src
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[editor],
|
||||||
|
);
|
||||||
|
|
||||||
const getReferencedVirtualElement = useCallback(() => {
|
const getReferencedVirtualElement = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
@@ -59,38 +108,248 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const alignLeft = useCallback(() => {
|
||||||
(value: number) => {
|
editor
|
||||||
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
.chain()
|
||||||
},
|
.focus(undefined, { scrollIntoView: false })
|
||||||
[editor],
|
.setExcalidrawAlign("left")
|
||||||
);
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignCenter = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setExcalidrawAlign("center")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const alignRight = useCallback(() => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus(undefined, { scrollIntoView: false })
|
||||||
|
.setExcalidrawAlign("right")
|
||||||
|
.run();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "";
|
||||||
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(async () => {
|
||||||
|
if (!editorState?.src) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = getFileUrl(editorState.src);
|
||||||
|
const request = await fetch(url, {
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
||||||
|
const data = await loadFromBlob(await request.blob(), null, null);
|
||||||
|
setExcalidrawData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}, [editorState?.src, open]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!excalidrawAPI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { exportToSvg } = await import("@excalidraw/excalidraw");
|
||||||
|
|
||||||
|
const svg = await exportToSvg({
|
||||||
|
elements: excalidrawAPI?.getSceneElements(),
|
||||||
|
appState: {
|
||||||
|
exportEmbedScene: true,
|
||||||
|
exportWithDarkMode: false,
|
||||||
|
},
|
||||||
|
files: excalidrawAPI?.getFiles(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializer = new XMLSerializer();
|
||||||
|
let svgString = serializer.serializeToString(svg);
|
||||||
|
|
||||||
|
svgString = svgString.replace(
|
||||||
|
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
|
||||||
|
"https://unpkg.com/@excalidraw/excalidraw@latest",
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileName = "diagram.excalidraw.svg";
|
||||||
|
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
const attachmentId = editorState?.attachmentId;
|
||||||
|
|
||||||
|
let attachment: IAttachment = null;
|
||||||
|
if (attachmentId) {
|
||||||
|
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
|
||||||
|
} else {
|
||||||
|
attachment = await uploadFile(excalidrawSvgFile, pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.commands.updateAttributes("excalidraw", {
|
||||||
|
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
|
||||||
|
title: attachment.fileName,
|
||||||
|
size: attachment.fileSize,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
close();
|
||||||
|
}, [editor, excalidrawAPI, editorState?.attachmentId, close]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<>
|
||||||
editor={editor}
|
<BaseBubbleMenu
|
||||||
pluginKey={`excalidraw-menu`}
|
editor={editor}
|
||||||
updateDelay={0}
|
pluginKey={`excalidraw-menu`}
|
||||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
updateDelay={0}
|
||||||
options={{
|
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||||
placement: "top",
|
options={{
|
||||||
offset: 8,
|
placement: "top",
|
||||||
flip: false,
|
offset: 8,
|
||||||
}}
|
flip: false,
|
||||||
shouldShow={shouldShow}
|
}}
|
||||||
>
|
shouldShow={shouldShow}
|
||||||
<div
|
>
|
||||||
|
<div className={classes.toolbar}>
|
||||||
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignLeft}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align left")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignLeft,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignLeft size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignCenter}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align center")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignCenter,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignCenter size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={alignRight}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Align right")}
|
||||||
|
variant="subtle"
|
||||||
|
className={clsx({
|
||||||
|
[classes.active]: editorState?.isAlignRight,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<IconLayoutAlignRight size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Edit")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleOpen}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Edit")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</BaseBubbleMenu>
|
||||||
|
|
||||||
|
<ReactClearModal
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
flexDirection: "column",
|
padding: 0,
|
||||||
alignItems: "center",
|
zIndex: 200,
|
||||||
|
}}
|
||||||
|
isOpen={opened}
|
||||||
|
onRequestClose={close}
|
||||||
|
disableCloseOnBgClick={true}
|
||||||
|
contentProps={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
width: "90vw",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editorState?.width && (
|
<Group
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
justify="flex-end"
|
||||||
)}
|
wrap="nowrap"
|
||||||
</div>
|
bg="var(--mantine-color-body)"
|
||||||
</BaseBubbleMenu>
|
p="xs"
|
||||||
|
>
|
||||||
|
<Button onClick={handleSave} size={"compact-sm"}>
|
||||||
|
{t("Save & Exit")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={close} color="red" size={"compact-sm"}>
|
||||||
|
{t("Exit")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<div style={{ height: "90vh" }}>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ExcalidrawComponent
|
||||||
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
|
initialData={{
|
||||||
|
...excalidrawData,
|
||||||
|
scrollToContent: true,
|
||||||
|
}}
|
||||||
|
theme={computedColorScheme}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</ReactClearModal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,28 +4,24 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Image,
|
|
||||||
Text,
|
Text,
|
||||||
useComputedColorScheme,
|
useComputedColorScheme,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||||
import { svgStringToFile } from "@/lib";
|
import { svgStringToFile } from "@/lib";
|
||||||
import { useDisclosure } from "@mantine/hooks";
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { getFileUrl } from "@/lib/config.ts";
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
import "@excalidraw/excalidraw/index.css";
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||||
import ReactClearModal from "react-clear-modal";
|
import ReactClearModal from "react-clear-modal";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { IconEdit } from "@tabler/icons-react";
|
import { IconEdit } from "@tabler/icons-react";
|
||||||
import { lazy } from "react";
|
|
||||||
import { Suspense } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||||
|
|
||||||
const Excalidraw = lazy(() =>
|
const ExcalidrawComponent = lazy(() =>
|
||||||
import("@excalidraw/excalidraw").then((module) => ({
|
import("@excalidraw/excalidraw").then((module) => ({
|
||||||
default: module.Excalidraw,
|
default: module.Excalidraw,
|
||||||
})),
|
})),
|
||||||
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
|
|||||||
export default function ExcalidrawView(props: NodeViewProps) {
|
export default function ExcalidrawView(props: NodeViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { node, updateAttributes, editor, selected } = props;
|
const { node, updateAttributes, editor, selected } = props;
|
||||||
const { src, title, width, attachmentId } = node.attrs;
|
const { attachmentId } = node.attrs;
|
||||||
|
|
||||||
const [excalidrawAPI, setExcalidrawAPI] =
|
const [excalidrawAPI, setExcalidrawAPI] =
|
||||||
useState<ExcalidrawImperativeAPI>(null);
|
useState<ExcalidrawImperativeAPI>(null);
|
||||||
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
if (!editor.isEditable) {
|
if (!editor.isEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
open();
|
||||||
try {
|
|
||||||
if (src) {
|
|
||||||
const url = getFileUrl(src);
|
|
||||||
const request = await fetch(url, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { loadFromBlob } = await import("@excalidraw/excalidraw");
|
|
||||||
|
|
||||||
const data = await loadFromBlob(await request.blob(), null, null);
|
|
||||||
setExcalidrawData(data);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
} finally {
|
|
||||||
open();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
<div style={{ height: "90vh" }}>
|
<div style={{ height: "90vh" }}>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Excalidraw
|
<ExcalidrawComponent
|
||||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||||
initialData={{
|
initialData={{
|
||||||
...excalidrawData,
|
...excalidrawData,
|
||||||
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ReactClearModal>
|
</ReactClearModal>
|
||||||
|
|
||||||
{src ? (
|
<Card
|
||||||
<div style={{ position: "relative" }}>
|
radius="md"
|
||||||
<Image
|
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
p="xs"
|
||||||
radius="md"
|
style={{
|
||||||
fit="contain"
|
display: "flex",
|
||||||
w={width}
|
justifyContent: "center",
|
||||||
src={getFileUrl(src)}
|
alignItems: "center",
|
||||||
alt={title}
|
}}
|
||||||
className={clsx(
|
withBorder
|
||||||
selected ? "ProseMirror-selectednode" : "",
|
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
||||||
"alignCenter",
|
>
|
||||||
)}
|
<div style={{ display: "flex", alignItems: "center" }}>
|
||||||
/>
|
<ActionIcon variant="transparent" color="gray">
|
||||||
|
<IconEdit size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
{selected && editor.isEditable && (
|
<Text component="span" size="lg" c="dimmed">
|
||||||
<ActionIcon
|
{t("Double-click to edit Excalidraw diagram")}
|
||||||
onClick={handleOpen}
|
</Text>
|
||||||
variant="default"
|
|
||||||
color="gray"
|
|
||||||
mx="xs"
|
|
||||||
className="print-hide"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</Card>
|
||||||
<Card
|
|
||||||
radius="md"
|
|
||||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
withBorder
|
|
||||||
className={clsx(selected ? "ProseMirror-selectednode" : "")}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center" }}>
|
|
||||||
<ActionIcon variant="transparent" color="gray">
|
|
||||||
<IconEdit size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<Text component="span" size="lg" c="dimmed">
|
|
||||||
{t("Double-click to edit Excalidraw diagram")}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback, useRef } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconRefresh,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const editorState = useEditorState({
|
const editorState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
||||||
width: imageAttrs?.width ? parseInt(imageAttrs.width) : null,
|
src: imageAttrs?.src || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -94,17 +101,40 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const handleDownload = useCallback(() => {
|
||||||
(value: number) => {
|
if (!editorState?.src) return;
|
||||||
editor
|
const url = getFileUrl(editorState.src);
|
||||||
.chain()
|
const a = document.createElement("a");
|
||||||
.focus(undefined, { scrollIntoView: false })
|
a.href = url;
|
||||||
.setImageWidth(value)
|
a.download = "";
|
||||||
.run();
|
a.click();
|
||||||
|
}, [editorState?.src]);
|
||||||
|
|
||||||
|
const handleReplace = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const pageId = editor.storage?.pageId;
|
||||||
|
if (pageId) {
|
||||||
|
const pos = editor.state.selection.from;
|
||||||
|
uploadImageAction(file, editor, pos, pageId);
|
||||||
|
}
|
||||||
|
// Reset so the same file can be selected again
|
||||||
|
e.target.value = "";
|
||||||
},
|
},
|
||||||
[editor],
|
[editor],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
editor={editor}
|
editor={editor}
|
||||||
@@ -118,13 +148,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignImageLeft}
|
onClick={alignImageLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -135,7 +166,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageCenter}
|
onClick={alignImageCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -146,16 +178,56 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
onClick={alignImageRight}
|
onClick={alignImageRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
|
||||||
|
|
||||||
{editorState?.width && (
|
<div className={classes.divider} />
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
|
||||||
)}
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Replace image")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleReplace}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Replace image")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconRefresh size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {
|
||||||
|
createResizeHandle,
|
||||||
|
buildResizeClasses,
|
||||||
|
} from "../common/node-resize-handles";
|
||||||
|
|
||||||
|
export const createImageHandle = createResizeHandle;
|
||||||
|
export const imageResizeClasses = buildResizeClasses("node-image");
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper img {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: ew-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="left"] {
|
||||||
|
left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle[data-resize-handle="right"] {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper:hover .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handleBar {
|
||||||
|
width: 4px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle:hover .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.resizing .handleBar {
|
||||||
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
|
}
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
.card {
|
|
||||||
max-width: 100%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 150ms ease;
|
|
||||||
margin: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
border-color: var(--mantine-color-blue-4);
|
|
||||||
}
|
|
||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
import { NodeViewWrapper } from "@tiptap/react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
Group,
|
|
||||||
Text,
|
|
||||||
Badge,
|
|
||||||
Avatar,
|
|
||||||
Skeleton,
|
|
||||||
Anchor,
|
|
||||||
Stack,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useEffect, useCallback, memo } from "react";
|
|
||||||
import { unfurlUrl } from "@/features/integration/services/integration-service";
|
|
||||||
import classes from "./integration-link-view.module.css";
|
|
||||||
|
|
||||||
const providerIcons: Record<string, string> = {
|
|
||||||
github: "https://github.githubassets.com/favicons/favicon-dark.svg",
|
|
||||||
gitlab: "https://gitlab.com/assets/favicon-72a2cad5025aa931d6ea56c3201d1f18e68a8571da3c2571592f63571e0c5571.png",
|
|
||||||
jira: "https://wac-cdn.atlassian.com/assets/img/favicons/atlassian/favicon.png",
|
|
||||||
linear: "https://linear.app/favicon.ico",
|
|
||||||
google_docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
|
|
||||||
figma: "https://static.figma.com/app/icon/1/favicon.png",
|
|
||||||
};
|
|
||||||
|
|
||||||
function IntegrationLinkView(props: any) {
|
|
||||||
const { node, updateAttributes, editor } = props;
|
|
||||||
const { url, provider, unfurlData, status } = node.attrs;
|
|
||||||
|
|
||||||
const doUnfurl = useCallback(async () => {
|
|
||||||
if (status !== "pending" || !url) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await unfurlUrl({ url });
|
|
||||||
if (result) {
|
|
||||||
updateAttributes({
|
|
||||||
unfurlData: result,
|
|
||||||
status: "loaded",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateAttributes({ status: "error" });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
updateAttributes({ status: "error" });
|
|
||||||
}
|
|
||||||
}, [url, status, updateAttributes]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "pending") {
|
|
||||||
doUnfurl();
|
|
||||||
}
|
|
||||||
}, [status, doUnfurl]);
|
|
||||||
|
|
||||||
if (status === "pending") {
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper data-drag-handle="">
|
|
||||||
<Card className={classes.card} withBorder padding="sm" radius="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Skeleton circle height={24} />
|
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
|
||||||
<Skeleton height={14} width="60%" />
|
|
||||||
<Skeleton height={10} width="80%" />
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "error" || !unfurlData) {
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper data-drag-handle="">
|
|
||||||
<Card className={classes.card} withBorder padding="sm" radius="sm">
|
|
||||||
<Anchor href={url} target="_blank" rel="noopener" size="sm">
|
|
||||||
{url}
|
|
||||||
</Anchor>
|
|
||||||
</Card>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconUrl = providerIcons[provider] ?? undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper data-drag-handle="">
|
|
||||||
<Card
|
|
||||||
className={classes.card}
|
|
||||||
withBorder
|
|
||||||
padding="sm"
|
|
||||||
radius="sm"
|
|
||||||
component="a"
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
style={{ textDecoration: "none", color: "inherit" }}
|
|
||||||
>
|
|
||||||
<Group gap="sm" wrap="nowrap">
|
|
||||||
{unfurlData.authorAvatarUrl ? (
|
|
||||||
<Avatar src={unfurlData.authorAvatarUrl} size={28} radius="xl" />
|
|
||||||
) : iconUrl ? (
|
|
||||||
<Avatar src={iconUrl} size={28} radius="sm" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text size="sm" fw={600} truncate>
|
|
||||||
{unfurlData.title}
|
|
||||||
</Text>
|
|
||||||
{unfurlData.status && (
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
color={unfurlData.statusColor ?? "gray"}
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{unfurlData.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{unfurlData.description && (
|
|
||||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
|
||||||
{unfurlData.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group gap="xs">
|
|
||||||
{iconUrl && (
|
|
||||||
<Avatar src={iconUrl} size={14} radius="sm" />
|
|
||||||
)}
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{unfurlData.provider}
|
|
||||||
</Text>
|
|
||||||
{unfurlData.author && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
· {unfurlData.author}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(IntegrationLinkView);
|
|
||||||
@@ -27,7 +27,7 @@ export const LinkPreviewPanel = ({
|
|||||||
<>
|
<>
|
||||||
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
<Card withBorder radius="md" padding="xs" bg="var(--mantine-color-body)">
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<Tooltip label={url} withArrow withinPortal={false}>
|
<Tooltip label={url}>
|
||||||
<Anchor
|
<Anchor
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
IconCalendar,
|
IconCalendar,
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
|
IconColumns3,
|
||||||
|
IconColumns2,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
CommandProps,
|
CommandProps,
|
||||||
@@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
|||||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||||
import IconDrawio from "@/components/icons/icon-drawio";
|
import IconDrawio from "@/components/icons/icon-drawio";
|
||||||
|
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||||
|
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||||
import {
|
import {
|
||||||
AirtableIcon,
|
AirtableIcon,
|
||||||
FigmaIcon,
|
FigmaIcon,
|
||||||
@@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
editor.chain().focus().deleteRange(range).insertSubpages().run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "2 Columns",
|
||||||
|
description: "Split content into two columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split", "side"],
|
||||||
|
icon: IconColumns2,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "two_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "3 Columns",
|
||||||
|
description: "Split content into three columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split", "triple"],
|
||||||
|
icon: IconColumns3,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "three_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "4 Columns",
|
||||||
|
description: "Split content into four columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split"],
|
||||||
|
icon: IconColumns4,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "four_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "5 Columns",
|
||||||
|
description: "Split content into five columns.",
|
||||||
|
searchTerms: ["columns", "layout", "split"],
|
||||||
|
icon: IconColumns5,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertColumns({ layout: "five_equal" })
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Iframe embed",
|
title: "Iframe embed",
|
||||||
description: "Embed any Iframe",
|
description: "Embed any Iframe",
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Background color")} withArrow>
|
<Tooltip label={t("Background color")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Background color")}
|
aria-label={t("Background color")}
|
||||||
onClick={() => setOpened(!opened)}
|
onClick={() => setOpened(!opened)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TableBackgroundColor } from "./table-background-color";
|
import { TableBackgroundColor } from "./table-background-color";
|
||||||
import { TableTextAlignment } from "./table-text-alignment";
|
import { TableTextAlignment } from "./table-text-alignment";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export const TableCellMenu = React.memo(
|
export const TableCellMenu = React.memo(
|
||||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<div className={classes.toolbar}>
|
||||||
<TableBackgroundColor editor={editor} />
|
<TableBackgroundColor editor={editor} />
|
||||||
<TableTextAlignment editor={editor} />
|
<TableTextAlignment editor={editor} />
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Merge cells")}>
|
<Tooltip position="top" label={t("Merge cells")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={mergeCells}
|
onClick={mergeCells}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Merge cells")}
|
aria-label={t("Merge cells")}
|
||||||
>
|
>
|
||||||
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Split cell")}>
|
<Tooltip position="top" label={t("Split cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={splitCell}
|
onClick={splitCell}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Split cell")}
|
aria-label={t("Split cell")}
|
||||||
>
|
>
|
||||||
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete column")}>
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete column")}
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete row")}>
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete row")}
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderCell}
|
onClick={toggleHeaderCell}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header cell")}
|
aria-label={t("Toggle header cell")}
|
||||||
>
|
>
|
||||||
<IconTableRow size={18} />
|
<IconTableRow size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
IconTrashX,
|
IconTrashX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { BubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu } from "@tiptap/react/menus";
|
||||||
import { isCellSelection } from "@docmost/editor-ext";
|
import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export const TableMenu = React.memo(
|
export const TableMenu = React.memo(
|
||||||
({ editor }: EditorMenuProps): JSX.Element => {
|
({ editor }: EditorMenuProps): JSX.Element => {
|
||||||
@@ -30,6 +31,7 @@ export const TableMenu = React.memo(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTextSelected(editor)) return false;
|
||||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||||
},
|
},
|
||||||
[editor]
|
[editor]
|
||||||
@@ -118,11 +120,11 @@ export const TableMenu = React.memo(
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group>
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Add left column")}>
|
<Tooltip position="top" label={t("Add left column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnLeft}
|
onClick={addColumnLeft}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add left column")}
|
aria-label={t("Add left column")}
|
||||||
>
|
>
|
||||||
@@ -133,7 +135,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Add right column")}>
|
<Tooltip position="top" label={t("Add right column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addColumnRight}
|
onClick={addColumnRight}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add right column")}
|
aria-label={t("Add right column")}
|
||||||
>
|
>
|
||||||
@@ -144,7 +146,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete column")}>
|
<Tooltip position="top" label={t("Delete column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteColumn}
|
onClick={deleteColumn}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete column")}
|
aria-label={t("Delete column")}
|
||||||
>
|
>
|
||||||
@@ -152,10 +154,12 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Add row above")}>
|
<Tooltip position="top" label={t("Add row above")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowAbove}
|
onClick={addRowAbove}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add row above")}
|
aria-label={t("Add row above")}
|
||||||
>
|
>
|
||||||
@@ -166,7 +170,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Add row below")}>
|
<Tooltip position="top" label={t("Add row below")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={addRowBelow}
|
onClick={addRowBelow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Add row below")}
|
aria-label={t("Add row below")}
|
||||||
>
|
>
|
||||||
@@ -177,7 +181,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Delete row")}>
|
<Tooltip position="top" label={t("Delete row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteRow}
|
onClick={deleteRow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Delete row")}
|
aria-label={t("Delete row")}
|
||||||
>
|
>
|
||||||
@@ -185,10 +189,12 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Toggle header row")}>
|
<Tooltip position="top" label={t("Toggle header row")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderRow}
|
onClick={toggleHeaderRow}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header row")}
|
aria-label={t("Toggle header row")}
|
||||||
>
|
>
|
||||||
@@ -199,7 +205,7 @@ export const TableMenu = React.memo(
|
|||||||
<Tooltip position="top" label={t("Toggle header column")}>
|
<Tooltip position="top" label={t("Toggle header column")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={toggleHeaderColumn}
|
onClick={toggleHeaderColumn}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Toggle header column")}
|
aria-label={t("Toggle header column")}
|
||||||
>
|
>
|
||||||
@@ -207,18 +213,19 @@ export const TableMenu = React.memo(
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
<div className={classes.divider} />
|
||||||
|
|
||||||
<Tooltip position="top" label={t("Delete table")}>
|
<Tooltip position="top" label={t("Delete table")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={deleteTable}
|
onClick={deleteTable}
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
color="red"
|
|
||||||
aria-label={t("Delete table")}
|
aria-label={t("Delete table")}
|
||||||
>
|
>
|
||||||
<IconTrashX size={18} />
|
<IconTrashX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
</div>
|
||||||
</BubbleMenu>
|
</BubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
|||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={t("Text alignment")} withArrow>
|
<Tooltip label={t("Text alignment")} withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Text alignment")}
|
aria-label={t("Text alignment")}
|
||||||
onClick={() => setOpened(!opened)}
|
onClick={() => setOpened(!opened)}
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||||
import React, { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Node as PMNode } from "prosemirror-model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import {
|
import {
|
||||||
EditorMenuProps,
|
EditorMenuProps,
|
||||||
ShouldShowProps,
|
ShouldShowProps,
|
||||||
} from "@/features/editor/components/table/types/types.ts";
|
} from "@/features/editor/components/table/types/types.ts";
|
||||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
|
IconDownload,
|
||||||
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getFileUrl } from "@/lib/config.ts";
|
||||||
|
import classes from "../common/toolbar-menu.module.css";
|
||||||
|
|
||||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
isAlignRight: ctx.editor.isActive("video", { align: "right" }),
|
||||||
width: videoAttrs?.width ? parseInt(videoAttrs.width) : null,
|
src: videoAttrs?.src || null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -70,7 +74,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoLeft = useCallback(() => {
|
const alignLeft = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoCenter = useCallback(() => {
|
const alignCenter = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const alignVideoRight = useCallback(() => {
|
const alignRight = useCallback(() => {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus(undefined, { scrollIntoView: false })
|
.focus(undefined, { scrollIntoView: false })
|
||||||
@@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
const onWidthChange = useCallback(
|
const handleDownload = useCallback(() => {
|
||||||
(value: number) => {
|
if (!editorState?.src) return;
|
||||||
editor
|
const url = getFileUrl(editorState.src);
|
||||||
.chain()
|
const a = document.createElement("a");
|
||||||
.focus(undefined, { scrollIntoView: false })
|
a.href = url;
|
||||||
.setVideoWidth(value)
|
a.download = "";
|
||||||
.run();
|
a.click();
|
||||||
},
|
}, [editorState?.src]);
|
||||||
[editor],
|
|
||||||
);
|
const handleDelete = useCallback(() => {
|
||||||
|
editor.commands.deleteSelection();
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseBubbleMenu
|
<BaseBubbleMenu
|
||||||
@@ -118,13 +124,14 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
}}
|
}}
|
||||||
shouldShow={shouldShow}
|
shouldShow={shouldShow}
|
||||||
>
|
>
|
||||||
<ActionIcon.Group className="actionIconGroup">
|
<div className={classes.toolbar}>
|
||||||
<Tooltip position="top" label={t("Align left")}>
|
<Tooltip position="top" label={t("Align left")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoLeft}
|
onClick={alignLeft}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align left")}
|
aria-label={t("Align left")}
|
||||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignLeft size={18} />
|
<IconLayoutAlignLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -132,10 +139,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<Tooltip position="top" label={t("Align center")}>
|
<Tooltip position="top" label={t("Align center")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoCenter}
|
onClick={alignCenter}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align center")}
|
aria-label={t("Align center")}
|
||||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignCenter size={18} />
|
<IconLayoutAlignCenter size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -143,19 +151,40 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
|||||||
|
|
||||||
<Tooltip position="top" label={t("Align right")}>
|
<Tooltip position="top" label={t("Align right")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={alignVideoRight}
|
onClick={alignRight}
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={t("Align right")}
|
aria-label={t("Align right")}
|
||||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
variant="subtle"
|
||||||
|
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||||
>
|
>
|
||||||
<IconLayoutAlignRight size={18} />
|
<IconLayoutAlignRight size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ActionIcon.Group>
|
|
||||||
|
|
||||||
{editorState?.width && (
|
<div className={classes.divider} />
|
||||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
|
||||||
)}
|
<Tooltip position="top" label={t("Download")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Download")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconDownload size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip position="top" label={t("Delete")}>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={handleDelete}
|
||||||
|
size="lg"
|
||||||
|
aria-label={t("Delete")}
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconTrash size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</BaseBubbleMenu>
|
</BaseBubbleMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { markInputRule } from "@tiptap/core";
|
||||||
import { StarterKit } from "@tiptap/starter-kit";
|
import { StarterKit } from "@tiptap/starter-kit";
|
||||||
|
import { Code } from "@tiptap/extension-code";
|
||||||
import { TextAlign } from "@tiptap/extension-text-align";
|
import { TextAlign } from "@tiptap/extension-text-align";
|
||||||
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
import { TaskList, TaskItem } from "@tiptap/extension-list";
|
||||||
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
import { Placeholder, CharacterCount } from "@tiptap/extensions";
|
||||||
@@ -43,7 +45,8 @@ import {
|
|||||||
Highlight,
|
Highlight,
|
||||||
UniqueID,
|
UniqueID,
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
IntegrationLink,
|
Columns,
|
||||||
|
Column,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
randomElement,
|
randomElement,
|
||||||
@@ -53,6 +56,14 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
|||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
|
import {
|
||||||
|
createImageHandle,
|
||||||
|
imageResizeClasses,
|
||||||
|
} from "@/features/editor/components/image/image-resize-handles.ts";
|
||||||
|
import {
|
||||||
|
createResizeHandle,
|
||||||
|
buildResizeClasses,
|
||||||
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
@@ -61,7 +72,6 @@ import DrawioView from "../components/drawio/drawio-view";
|
|||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||||
import IntegrationLinkView from "@/features/editor/components/integration-link/integration-link-view.tsx";
|
|
||||||
import { common, createLowlight } from "lowlight";
|
import { common, createLowlight } from "lowlight";
|
||||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||||
import powershell from "highlight.js/lib/languages/powershell";
|
import powershell from "highlight.js/lib/languages/powershell";
|
||||||
@@ -93,6 +103,7 @@ lowlight.register("fortran", fortran);
|
|||||||
lowlight.register("haskell", haskell);
|
lowlight.register("haskell", haskell);
|
||||||
lowlight.register("scala", scala);
|
lowlight.register("scala", scala);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
export const mainExtensions = [
|
export const mainExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: false,
|
heading: false,
|
||||||
@@ -104,10 +115,24 @@ export const mainExtensions = [
|
|||||||
color: "#70CFF8",
|
color: "#70CFF8",
|
||||||
},
|
},
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
code: {
|
code: false,
|
||||||
HTMLAttributes: {
|
}),
|
||||||
spellcheck: false,
|
// Override TipTap's Code extension to fix the inline code input rule.
|
||||||
},
|
// The upstream regex /(^|[^`])`([^`]+)`(?!`)$/ captures the character
|
||||||
|
// before the opening backtick as part of the match, causing markInputRule
|
||||||
|
// to delete it. Using a lookbehind avoids including it in the match.
|
||||||
|
Code.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
spellcheck: false,
|
||||||
|
},
|
||||||
|
}).extend({
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
markInputRule({
|
||||||
|
find: /(?:^|(?<=[^`]))`([^`]+)`(?!`)$/,
|
||||||
|
type: this.type,
|
||||||
|
}),
|
||||||
|
];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
SharedStorage,
|
SharedStorage,
|
||||||
@@ -117,7 +142,7 @@ export const mainExtensions = [
|
|||||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ editor, node, pos }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||||
}
|
}
|
||||||
@@ -125,6 +150,17 @@ export const mainExtensions = [
|
|||||||
return i18n.t("Toggle title");
|
return i18n.t("Toggle title");
|
||||||
}
|
}
|
||||||
if (node.type.name === "paragraph") {
|
if (node.type.name === "paragraph") {
|
||||||
|
const $pos = editor.state.doc.resolve(pos);
|
||||||
|
const parentName = $pos.parent.type.name;
|
||||||
|
if (
|
||||||
|
parentName === "column" ||
|
||||||
|
parentName === "tableCell" ||
|
||||||
|
parentName === "tableHeader" ||
|
||||||
|
parentName === "callout" ||
|
||||||
|
parentName === "blockquote"
|
||||||
|
) {
|
||||||
|
return i18n.t("Write...");
|
||||||
|
}
|
||||||
return i18n.t('Write anything. Enter "/" for commands');
|
return i18n.t('Write anything. Enter "/" for commands');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -202,9 +238,29 @@ export const mainExtensions = [
|
|||||||
TiptapImage.configure({
|
TiptapImage.configure({
|
||||||
view: ImageView,
|
view: ImageView,
|
||||||
allowBase64: false,
|
allowBase64: false,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createImageHandle,
|
||||||
|
className: imageResizeClasses,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
TiptapVideo.configure({
|
TiptapVideo.configure({
|
||||||
view: VideoView,
|
view: VideoView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-video"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Callout.configure({
|
Callout.configure({
|
||||||
view: CalloutView,
|
view: CalloutView,
|
||||||
@@ -223,9 +279,29 @@ export const mainExtensions = [
|
|||||||
}),
|
}),
|
||||||
Drawio.configure({
|
Drawio.configure({
|
||||||
view: DrawioView,
|
view: DrawioView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-drawio"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Excalidraw.configure({
|
Excalidraw.configure({
|
||||||
view: ExcalidrawView,
|
view: ExcalidrawView,
|
||||||
|
resize: {
|
||||||
|
enabled: true,
|
||||||
|
directions: ["left", "right"],
|
||||||
|
minWidth: 80,
|
||||||
|
minHeight: 40,
|
||||||
|
alwaysPreserveAspectRatio: true,
|
||||||
|
//@ts-ignore
|
||||||
|
createCustomHandle: createResizeHandle,
|
||||||
|
className: buildResizeClasses("node-excalidraw"),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
Embed.configure({
|
Embed.configure({
|
||||||
view: EmbedView,
|
view: EmbedView,
|
||||||
@@ -233,9 +309,6 @@ export const mainExtensions = [
|
|||||||
Subpages.configure({
|
Subpages.configure({
|
||||||
view: SubpagesView,
|
view: SubpagesView,
|
||||||
}),
|
}),
|
||||||
IntegrationLink.configure({
|
|
||||||
view: IntegrationLinkView,
|
|
||||||
}),
|
|
||||||
MarkdownClipboard.configure({
|
MarkdownClipboard.configure({
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
@@ -258,6 +331,8 @@ export const mainExtensions = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}).configure(),
|
}).configure(),
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import { jwtDecode } from "jwt-decode";
|
|||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||||
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
|||||||
<SubpagesMenu editor={editor} />
|
<SubpagesMenu editor={editor} />
|
||||||
<ExcalidrawMenu editor={editor} />
|
<ExcalidrawMenu editor={editor} />
|
||||||
<DrawioMenu editor={editor} />
|
<DrawioMenu editor={editor} />
|
||||||
|
<ColumnsMenu editor={editor} />
|
||||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
div[data-type="columns"] {
|
||||||
|
display: flex;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"]:last-child {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
padding-left: 1rem;
|
||||||
|
transition: border 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"]:hover
|
||||||
|
> div[data-type="column"]
|
||||||
|
+ div[data-type="column"],
|
||||||
|
div[data-type="columns"].has-focus
|
||||||
|
> div[data-type="column"]
|
||||||
|
+ div[data-type="column"] {
|
||||||
|
border-left: 1px solid
|
||||||
|
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-7));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confluence layout types */
|
||||||
|
div[data-type="columns"][data-layout="two_left_sidebar"]
|
||||||
|
> div[data-type="column"]:first-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="two_left_sidebar"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="two_right_sidebar"]
|
||||||
|
> div[data-type="column"]:first-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="two_right_sidebar"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_left_wide"]
|
||||||
|
> div[data-type="column"]:first-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_right_wide"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||||
|
> div[data-type="column"]:first-child,
|
||||||
|
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||||
|
> div[data-type="column"]:last-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-layout="three_with_sidebars"]
|
||||||
|
> div[data-type="column"]:nth-child(2) {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack columns vertically on small viewports */
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
div[data-type="columns"] {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"]:hover
|
||||||
|
> div[data-type="column"]
|
||||||
|
+ div[data-type="column"] {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wide width mode — extends columns to full container width */
|
||||||
|
div[data-type="columns"][data-width-mode="wide"] {
|
||||||
|
margin-left: -3rem;
|
||||||
|
margin-right: -3rem;
|
||||||
|
width: calc(100% + 6rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
div[data-type="columns"][data-width-mode="wide"] {
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
width: calc(100% + 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
div[data-type="columns"] {
|
||||||
|
flex-direction: row !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"] > div[data-type="column"] + div[data-type="column"] {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-type="columns"][data-width-mode="wide"] {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,13 +82,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
padding-left: 25px;
|
padding-left: 1rem;
|
||||||
padding-right: 25px;
|
border-left: 3px solid
|
||||||
border-left: 2px solid var(--mantine-color-gray-6);
|
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||||
background-color: light-dark(
|
|
||||||
var(--mantine-color-gray-0),
|
|
||||||
var(--mantine-color-dark-8)
|
|
||||||
);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,13 +122,14 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.node-callout {
|
}
|
||||||
div[style*="white-space: inherit;"] {
|
|
||||||
> :first-child {
|
.react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
|
||||||
margin: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
.react-renderer.node-callout + .react-renderer.node-callout {
|
||||||
|
margin-top: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selection {
|
.selection {
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
@import "./mention.css";
|
@import "./mention.css";
|
||||||
@import "./ordered-list.css";
|
@import "./ordered-list.css";
|
||||||
@import "./highlight.css";
|
@import "./highlight.css";
|
||||||
|
@import "./columns.css";
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Card, Group, Text, Badge, Button, Stack, Switch } from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconBrandGithub,
|
|
||||||
IconBrandSlack,
|
|
||||||
IconBrandGitlab,
|
|
||||||
IconPuzzle,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
IntegrationDefinition,
|
|
||||||
Integration,
|
|
||||||
} from "../types/integration.types";
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = {
|
|
||||||
github: IconBrandGithub,
|
|
||||||
slack: IconBrandSlack,
|
|
||||||
gitlab: IconBrandGitlab,
|
|
||||||
};
|
|
||||||
|
|
||||||
type IntegrationCardProps = {
|
|
||||||
definition: IntegrationDefinition;
|
|
||||||
installation?: Integration;
|
|
||||||
onInstall: (type: string) => void;
|
|
||||||
onUninstall: (integrationId: string) => void;
|
|
||||||
onConfigure: (integration: Integration) => void;
|
|
||||||
onToggle: (integration: Integration, enabled: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function IntegrationCard({
|
|
||||||
definition,
|
|
||||||
installation,
|
|
||||||
onInstall,
|
|
||||||
onUninstall,
|
|
||||||
onConfigure,
|
|
||||||
onToggle,
|
|
||||||
}: IntegrationCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const Icon = iconMap[definition.icon] ?? IconPuzzle;
|
|
||||||
const isInstalled = !!installation;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card withBorder padding="lg" radius="md">
|
|
||||||
<Group justify="space-between" mb="sm">
|
|
||||||
<Group gap="sm">
|
|
||||||
<Icon size={28} stroke={1.5} />
|
|
||||||
<div>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{definition.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{definition.description}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xs" mb="md">
|
|
||||||
{definition.capabilities.map((cap) => (
|
|
||||||
<Badge key={cap} size="xs" variant="light">
|
|
||||||
{cap}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{isInstalled ? (
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Switch
|
|
||||||
label={t("Enabled")}
|
|
||||||
checked={installation.isEnabled}
|
|
||||||
onChange={(e) => onToggle(installation, e.currentTarget.checked)}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => onConfigure(installation)}
|
|
||||||
>
|
|
||||||
{t("Configure")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={() => onUninstall(installation.id)}
|
|
||||||
>
|
|
||||||
{t("Uninstall")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={() => onInstall(definition.type)}
|
|
||||||
>
|
|
||||||
{t("Install")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Modal, Button, Group, Stack, TextInput, Text } from "@mantine/core";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Integration, ConnectionStatus } from "../types/integration.types";
|
|
||||||
import {
|
|
||||||
useConnectionStatus,
|
|
||||||
useDisconnectIntegration,
|
|
||||||
} from "../queries/integration-query";
|
|
||||||
import * as integrationService from "../services/integration-service";
|
|
||||||
|
|
||||||
type IntegrationSettingsModalProps = {
|
|
||||||
integration: Integration | null;
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function IntegrationSettingsModal({
|
|
||||||
integration,
|
|
||||||
opened,
|
|
||||||
onClose,
|
|
||||||
}: IntegrationSettingsModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: connectionStatus } = useConnectionStatus(integration?.id);
|
|
||||||
const disconnectMutation = useDisconnectIntegration();
|
|
||||||
|
|
||||||
if (!integration) return null;
|
|
||||||
|
|
||||||
const handleConnect = async () => {
|
|
||||||
try {
|
|
||||||
const result = await integrationService.getOAuthAuthorizeUrl({
|
|
||||||
integrationId: integration.id,
|
|
||||||
});
|
|
||||||
window.location.href = result.authorizationUrl;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to start OAuth connection"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisconnect = async () => {
|
|
||||||
await disconnectMutation.mutateAsync({
|
|
||||||
integrationId: integration.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasOAuth = true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
opened={opened}
|
|
||||||
onClose={onClose}
|
|
||||||
title={`${integration.type.charAt(0).toUpperCase() + integration.type.slice(1)} ${t("Settings")}`}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<Stack gap="md">
|
|
||||||
{hasOAuth && (
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={600} mb="xs">
|
|
||||||
{t("Connection")}
|
|
||||||
</Text>
|
|
||||||
{connectionStatus?.connected ? (
|
|
||||||
<Group gap="sm">
|
|
||||||
<Text size="sm" c="green">
|
|
||||||
{t("Connected")}
|
|
||||||
{connectionStatus.providerUserId &&
|
|
||||||
` (${connectionStatus.providerUserId})`}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="red"
|
|
||||||
onClick={handleDisconnect}
|
|
||||||
loading={disconnectMutation.isPending}
|
|
||||||
>
|
|
||||||
{t("Disconnect")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Button size="xs" variant="light" onClick={handleConnect}>
|
|
||||||
{t("Connect")} {integration.type}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { SimpleGrid, Text, Loader, Center, Alert } from "@mantine/core";
|
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useState, useCallback } from "react";
|
|
||||||
import { getAppName } from "@/lib/config";
|
|
||||||
import SettingsTitle from "@/components/settings/settings-title";
|
|
||||||
import IntegrationCard from "../components/integration-card";
|
|
||||||
import IntegrationSettingsModal from "../components/integration-settings-modal";
|
|
||||||
import {
|
|
||||||
useAvailableIntegrations,
|
|
||||||
useInstalledIntegrations,
|
|
||||||
useInstallIntegration,
|
|
||||||
useUninstallIntegration,
|
|
||||||
useUpdateIntegrationSettings,
|
|
||||||
} from "../queries/integration-query";
|
|
||||||
import { Integration } from "../types/integration.types";
|
|
||||||
|
|
||||||
export default function Integrations() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { data: available, isLoading: loadingAvailable } =
|
|
||||||
useAvailableIntegrations();
|
|
||||||
const { data: installed, isLoading: loadingInstalled } =
|
|
||||||
useInstalledIntegrations();
|
|
||||||
const installMutation = useInstallIntegration();
|
|
||||||
const uninstallMutation = useUninstallIntegration();
|
|
||||||
const updateMutation = useUpdateIntegrationSettings();
|
|
||||||
|
|
||||||
const [configuring, setConfiguring] = useState<Integration | null>(null);
|
|
||||||
|
|
||||||
const handleInstall = useCallback(
|
|
||||||
(type: string) => {
|
|
||||||
installMutation.mutate({ type });
|
|
||||||
},
|
|
||||||
[installMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUninstall = useCallback(
|
|
||||||
(integrationId: string) => {
|
|
||||||
uninstallMutation.mutate({ integrationId });
|
|
||||||
},
|
|
||||||
[uninstallMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfigure = useCallback((integration: Integration) => {
|
|
||||||
setConfiguring(integration);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(integration: Integration, enabled: boolean) => {
|
|
||||||
updateMutation.mutate({
|
|
||||||
integrationId: integration.id,
|
|
||||||
isEnabled: enabled,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLoading = loadingAvailable || loadingInstalled;
|
|
||||||
const error = new URLSearchParams(window.location.search).get("error");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Helmet>
|
|
||||||
<title>
|
|
||||||
{t("Integrations")} - {getAppName()}
|
|
||||||
</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<SettingsTitle title={t("Integrations")} />
|
|
||||||
|
|
||||||
{error === "oauth_failed" && (
|
|
||||||
<Alert color="red" mb="md">
|
|
||||||
{t("OAuth connection failed. Please try again.")}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Center py="xl">
|
|
||||||
<Loader />
|
|
||||||
</Center>
|
|
||||||
) : !available?.length ? (
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
{t("No integrations available.")}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
|
||||||
{available.map((def) => {
|
|
||||||
const installation = installed?.find((i) => i.type === def.type);
|
|
||||||
return (
|
|
||||||
<IntegrationCard
|
|
||||||
key={def.type}
|
|
||||||
definition={def}
|
|
||||||
installation={installation}
|
|
||||||
onInstall={handleInstall}
|
|
||||||
onUninstall={handleUninstall}
|
|
||||||
onConfigure={handleConfigure}
|
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<IntegrationSettingsModal
|
|
||||||
integration={configuring}
|
|
||||||
opened={!!configuring}
|
|
||||||
onClose={() => setConfiguring(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
|
||||||
import * as integrationService from "../services/integration-service";
|
|
||||||
|
|
||||||
export function useAvailableIntegrations() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["available-integrations"],
|
|
||||||
queryFn: integrationService.getAvailableIntegrations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInstalledIntegrations() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["installed-integrations"],
|
|
||||||
queryFn: integrationService.getInstalledIntegrations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInstallIntegration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.installIntegration,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Integration installed successfully") });
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to install integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUninstallIntegration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.uninstallIntegration,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({
|
|
||||||
message: t("Integration uninstalled successfully"),
|
|
||||||
});
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to uninstall integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateIntegrationSettings() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.updateIntegrationSettings,
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: t("Integration updated successfully") });
|
|
||||||
qc.invalidateQueries({ queryKey: ["installed-integrations"] });
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to update integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useConnectionStatus(integrationId: string | undefined) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["integration-connection", integrationId],
|
|
||||||
queryFn: () =>
|
|
||||||
integrationService.getConnectionStatus({
|
|
||||||
integrationId: integrationId!,
|
|
||||||
}),
|
|
||||||
enabled: !!integrationId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDisconnectIntegration() {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: integrationService.disconnectIntegration,
|
|
||||||
onSuccess: (_data, variables) => {
|
|
||||||
notifications.show({ message: t("Integration disconnected") });
|
|
||||||
qc.invalidateQueries({
|
|
||||||
queryKey: ["integration-connection", variables.integrationId],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
const errorMessage = error["response"]?.data?.message;
|
|
||||||
notifications.show({
|
|
||||||
message: errorMessage || t("Failed to disconnect integration"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import api from "@/lib/api-client";
|
|
||||||
import {
|
|
||||||
IntegrationDefinition,
|
|
||||||
Integration,
|
|
||||||
ConnectionStatus,
|
|
||||||
UnfurlResult,
|
|
||||||
} from "../types/integration.types";
|
|
||||||
|
|
||||||
export async function getAvailableIntegrations(): Promise<
|
|
||||||
IntegrationDefinition[]
|
|
||||||
> {
|
|
||||||
const req = await api.post<IntegrationDefinition[]>(
|
|
||||||
"/integrations/available",
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getInstalledIntegrations(): Promise<Integration[]> {
|
|
||||||
const req = await api.post<Integration[]>("/integrations/list");
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installIntegration(data: {
|
|
||||||
type: string;
|
|
||||||
}): Promise<Integration> {
|
|
||||||
const req = await api.post<Integration>("/integrations/install", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uninstallIntegration(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await api.post("/integrations/uninstall", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateIntegrationSettings(data: {
|
|
||||||
integrationId: string;
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
isEnabled?: boolean;
|
|
||||||
}): Promise<Integration> {
|
|
||||||
const req = await api.post<Integration>("/integrations/update", data);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getConnectionStatus(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<ConnectionStatus> {
|
|
||||||
const req = await api.post<ConnectionStatus>(
|
|
||||||
"/integrations/connection/status",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOAuthAuthorizeUrl(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<{ authorizationUrl: string }> {
|
|
||||||
const req = await api.post<{ authorizationUrl: string }>(
|
|
||||||
"/integrations/oauth/authorize",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disconnectIntegration(data: {
|
|
||||||
integrationId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
await api.post("/integrations/oauth/disconnect", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function unfurlUrl(data: {
|
|
||||||
url: string;
|
|
||||||
}): Promise<UnfurlResult | null> {
|
|
||||||
const req = await api.post<{ data: UnfurlResult | null }>(
|
|
||||||
"/integrations/unfurl",
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return req.data.data;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export type IntegrationCapability = "oauth" | "unfurl" | "actions" | "webhooks";
|
|
||||||
|
|
||||||
export type IntegrationDefinition = {
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
capabilities: IntegrationCapability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Integration = {
|
|
||||||
id: string;
|
|
||||||
workspaceId: string;
|
|
||||||
type: string;
|
|
||||||
isEnabled: boolean;
|
|
||||||
settings: Record<string, any> | null;
|
|
||||||
installedById: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConnectionStatus = {
|
|
||||||
connected: boolean;
|
|
||||||
providerUserId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlResult = {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
url: string;
|
|
||||||
provider: string;
|
|
||||||
providerIcon?: string;
|
|
||||||
status?: string;
|
|
||||||
statusColor?: string;
|
|
||||||
author?: string;
|
|
||||||
authorAvatarUrl?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
};
|
|
||||||
@@ -25,7 +25,6 @@ const APP_ROUTE = {
|
|||||||
SPACES: "/settings/spaces",
|
SPACES: "/settings/spaces",
|
||||||
BILLING: "/settings/billing",
|
BILLING: "/settings/billing",
|
||||||
SECURITY: "/settings/security",
|
SECURITY: "/settings/security",
|
||||||
INTEGRATIONS: "/settings/integrations",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ if (isCloud() && isPostHogEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
|
||||||
document.getElementById("root") as HTMLElement,
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -33,9 +33,9 @@
|
|||||||
"@ai-sdk/google": "^3.0.29",
|
"@ai-sdk/google": "^3.0.29",
|
||||||
"@ai-sdk/openai": "^3.0.29",
|
"@ai-sdk/openai": "^3.0.29",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||||
"@aws-sdk/client-s3": "3.982.0",
|
"@aws-sdk/client-s3": "3.998.0",
|
||||||
"@aws-sdk/lib-storage": "3.982.0",
|
"@aws-sdk/lib-storage": "3.998.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.982.0",
|
"@aws-sdk/s3-request-presigner": "3.998.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
@@ -43,18 +43,18 @@
|
|||||||
"@langchain/textsplitters": "1.0.1",
|
"@langchain/textsplitters": "1.0.1",
|
||||||
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/common": "^11.1.11",
|
"@nestjs/common": "^11.1.14",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.1.13",
|
"@nestjs/core": "^11.1.14",
|
||||||
"@nestjs/event-emitter": "^3.0.1",
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/jwt": "11.0.0",
|
"@nestjs/jwt": "11.0.0",
|
||||||
"@nestjs/mapped-types": "^2.1.0",
|
"@nestjs/mapped-types": "^2.1.0",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-fastify": "^11.1.13",
|
"@nestjs/platform-fastify": "^11.1.14",
|
||||||
"@nestjs/platform-socket.io": "^11.1.13",
|
"@nestjs/platform-socket.io": "^11.1.14",
|
||||||
"@nestjs/schedule": "^6.1.0",
|
"@nestjs/schedule": "^6.1.0",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.1.13",
|
"@nestjs/websockets": "^11.1.14",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@react-email/components": "1.0.7",
|
"@react-email/components": "1.0.7",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/render": "2.0.4",
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ import {
|
|||||||
UniqueID,
|
UniqueID,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
htmlToMarkdown,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
@@ -91,6 +93,8 @@ export const tiptapExtensions = [
|
|||||||
Embed,
|
Embed,
|
||||||
Mention,
|
Mention,
|
||||||
Subpages,
|
Subpages,
|
||||||
|
Columns,
|
||||||
|
Column,
|
||||||
] as any;
|
] as any;
|
||||||
|
|
||||||
export function jsonToHtml(tiptapJson: any) {
|
export function jsonToHtml(tiptapJson: any) {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { DomainMiddleware } from '../common/middlewares/domain.middleware';
|
|||||||
import { ShareModule } from './share/share.module';
|
import { ShareModule } from './share/share.module';
|
||||||
import { NotificationModule } from './notification/notification.module';
|
import { NotificationModule } from './notification/notification.module';
|
||||||
import { WatcherModule } from './watcher/watcher.module';
|
import { WatcherModule } from './watcher/watcher.module';
|
||||||
import { IntegrationModule } from './integration/integration.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -35,7 +34,6 @@ import { IntegrationModule } from './integration/integration.module';
|
|||||||
ShareModule,
|
ShareModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
WatcherModule,
|
WatcherModule,
|
||||||
IntegrationModule,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreModule implements NestModule {
|
export class CoreModule implements NestModule {
|
||||||
@@ -47,7 +45,6 @@ export class CoreModule implements NestModule {
|
|||||||
{ path: 'health', method: RequestMethod.GET },
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
{ path: 'health/live', method: RequestMethod.GET },
|
{ path: 'health/live', method: RequestMethod.GET },
|
||||||
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
{ path: 'billing/stripe/webhook', method: RequestMethod.POST },
|
||||||
{ path: 'integrations/oauth/*/callback', method: RequestMethod.GET },
|
|
||||||
)
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export enum IntegrationType {
|
|
||||||
SLACK = 'slack',
|
|
||||||
GITHUB = 'github',
|
|
||||||
GITLAB = 'gitlab',
|
|
||||||
JIRA = 'jira',
|
|
||||||
LINEAR = 'linear',
|
|
||||||
GOOGLE_DOCS = 'google_docs',
|
|
||||||
FIGMA = 'figma',
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
function deriveEncryptionKey(appSecret: string): Buffer {
|
|
||||||
return crypto.createHash('sha256').update(appSecret).digest();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function encryptToken(token: string, appSecret: string): string {
|
|
||||||
const algorithm = 'aes-256-gcm';
|
|
||||||
const key = deriveEncryptionKey(appSecret);
|
|
||||||
const iv = crypto.randomBytes(16);
|
|
||||||
|
|
||||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
|
||||||
let encrypted = cipher.update(token, 'utf8', 'hex');
|
|
||||||
encrypted += cipher.final('hex');
|
|
||||||
|
|
||||||
const authTag = cipher.getAuthTag();
|
|
||||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptToken(encryptedToken: string, appSecret: string): string {
|
|
||||||
const algorithm = 'aes-256-gcm';
|
|
||||||
const key = deriveEncryptionKey(appSecret);
|
|
||||||
|
|
||||||
const parts = encryptedToken.split(':');
|
|
||||||
const iv = Buffer.from(parts[0], 'hex');
|
|
||||||
const authTag = Buffer.from(parts[1], 'hex');
|
|
||||||
const encrypted = parts[2];
|
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
|
||||||
decipher.setAuthTag(authTag);
|
|
||||||
|
|
||||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
||||||
decrypted += decipher.final('utf8');
|
|
||||||
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const slackSettingsSchema = z.object({
|
|
||||||
channelId: z.string().min(1),
|
|
||||||
channelName: z.string().optional(),
|
|
||||||
notifyOn: z
|
|
||||||
.array(z.enum(['page.created', 'page.updated', 'page.deleted']))
|
|
||||||
.default(['page.created']),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const githubSettingsSchema = z.object({
|
|
||||||
baseUrl: z.string().url().optional(),
|
|
||||||
org: z.string().optional(),
|
|
||||||
defaultRepo: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const gitlabSettingsSchema = z.object({
|
|
||||||
baseUrl: z.string().url().optional(),
|
|
||||||
group: z.string().optional(),
|
|
||||||
defaultProject: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const jiraSettingsSchema = z.object({
|
|
||||||
baseUrl: z.string().url().optional(),
|
|
||||||
cloudId: z.string().optional(),
|
|
||||||
siteName: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const linearSettingsSchema = z.object({
|
|
||||||
teamId: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const integrationSettingsSchemas: Record<string, z.ZodType> = {
|
|
||||||
slack: slackSettingsSchema,
|
|
||||||
github: githubSettingsSchema,
|
|
||||||
gitlab: gitlabSettingsSchema,
|
|
||||||
jira: jiraSettingsSchema,
|
|
||||||
linear: linearSettingsSchema,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateIntegrationSettings(
|
|
||||||
type: string,
|
|
||||||
settings: unknown,
|
|
||||||
): { success: true; data: Record<string, any> } | { success: false; error: string } {
|
|
||||||
const schema = integrationSettingsSchemas[type];
|
|
||||||
if (!schema) {
|
|
||||||
if (settings && typeof settings === 'object') {
|
|
||||||
return { success: true, data: settings as Record<string, any> };
|
|
||||||
}
|
|
||||||
return { success: true, data: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = schema.safeParse(settings);
|
|
||||||
if (!result.success) {
|
|
||||||
const messages = result.error.issues.map(
|
|
||||||
(i) => `${i.path.join('.')}: ${i.message}`,
|
|
||||||
);
|
|
||||||
return { success: false, error: messages.join(', ') };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: result.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SlackSettings = z.infer<typeof slackSettingsSchema>;
|
|
||||||
export type GithubSettings = z.infer<typeof githubSettingsSchema>;
|
|
||||||
export type GitlabSettings = z.infer<typeof gitlabSettingsSchema>;
|
|
||||||
export type JiraSettings = z.infer<typeof jiraSettingsSchema>;
|
|
||||||
export type LinearSettings = z.infer<typeof linearSettingsSchema>;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class InstallIntegrationDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UninstallIntegrationDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateIntegrationDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsObject()
|
|
||||||
settings?: Record<string, any>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class IntegrationIdDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UnfurlDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthAuthorizeDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OAuthDisconnectDto {
|
|
||||||
@IsNotEmpty()
|
|
||||||
@IsString()
|
|
||||||
integrationId: string;
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationConnectionService {
|
|
||||||
constructor(
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getConnectionStatus(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<{ connected: boolean; providerUserId?: string }> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await this.connectionRepo.findByIntegrationAndUser(
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
connected: !!connection,
|
|
||||||
providerUserId: connection?.providerUserId ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegrationAndUser(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
return this.connectionRepo.findByIntegrationAndUser(integrationId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByWorkspaceTypeAndUser(
|
|
||||||
workspaceId: string,
|
|
||||||
integrationType: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
return this.connectionRepo.findByWorkspaceTypeAndUser(
|
|
||||||
workspaceId,
|
|
||||||
integrationType,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async disconnect(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.connectionRepo.deleteByIntegrationAndUser(
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { IntegrationService } from './integration.service';
|
|
||||||
import { IntegrationConnectionService } from './integration-connection.service';
|
|
||||||
import {
|
|
||||||
InstallIntegrationDto,
|
|
||||||
UninstallIntegrationDto,
|
|
||||||
UpdateIntegrationDto,
|
|
||||||
IntegrationIdDto,
|
|
||||||
} from './dto/integration.dto';
|
|
||||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
|
||||||
import {
|
|
||||||
WorkspaceCaslAction,
|
|
||||||
WorkspaceCaslSubject,
|
|
||||||
} from '../casl/interfaces/workspace-ability.type';
|
|
||||||
|
|
||||||
@Controller('integrations')
|
|
||||||
export class IntegrationController {
|
|
||||||
constructor(
|
|
||||||
private readonly integrationService: IntegrationService,
|
|
||||||
private readonly connectionService: IntegrationConnectionService,
|
|
||||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('available')
|
|
||||||
async getAvailableIntegrations() {
|
|
||||||
return this.integrationService.getAvailableIntegrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('list')
|
|
||||||
async getInstalledIntegrations(
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
return this.integrationService.getInstalledIntegrations(workspace.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('install')
|
|
||||||
async install(
|
|
||||||
@Body() dto: InstallIntegrationDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationService.install(dto.type, workspace.id, user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('uninstall')
|
|
||||||
async uninstall(
|
|
||||||
@Body() dto: UninstallIntegrationDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.integrationService.uninstall(dto.integrationId, workspace.id);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('update')
|
|
||||||
async update(
|
|
||||||
@Body() dto: UpdateIntegrationDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
|
||||||
if (
|
|
||||||
ability.cannot(
|
|
||||||
WorkspaceCaslAction.Manage,
|
|
||||||
WorkspaceCaslSubject.Settings,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new ForbiddenException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationService.update(dto.integrationId, workspace.id, {
|
|
||||||
settings: dto.settings,
|
|
||||||
isEnabled: dto.isEnabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('connection/status')
|
|
||||||
async getConnectionStatus(
|
|
||||||
@Body() dto: IntegrationIdDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
return this.connectionService.getConnectionStatus(
|
|
||||||
dto.integrationId,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
|
||||||
import { Queue } from 'bullmq';
|
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
|
||||||
import { EventName } from '../../common/events/event.contants';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationListener {
|
|
||||||
constructor(
|
|
||||||
@InjectQueue(QueueName.INTEGRATION_QUEUE)
|
|
||||||
private readonly integrationQueue: Queue,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_CREATED)
|
|
||||||
async onPageCreated(payload: any) {
|
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
|
||||||
eventName: EventName.PAGE_CREATED,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_UPDATED)
|
|
||||||
async onPageUpdated(payload: any) {
|
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
|
||||||
eventName: EventName.PAGE_UPDATED,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_DELETED)
|
|
||||||
async onPageDeleted(payload: any) {
|
|
||||||
await this.integrationQueue.add(QueueJob.INTEGRATION_EVENT, {
|
|
||||||
eventName: EventName.PAGE_DELETED,
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
|
||||||
import { IntegrationService } from './integration.service';
|
|
||||||
import { IntegrationConnectionService } from './integration-connection.service';
|
|
||||||
import { IntegrationController } from './integration.controller';
|
|
||||||
import { OAuthController } from './oauth/oauth.controller';
|
|
||||||
import { OAuthService } from './oauth/oauth.service';
|
|
||||||
import { UnfurlController } from './unfurl/unfurl.controller';
|
|
||||||
import { UnfurlService } from './unfurl/unfurl.service';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
|
||||||
import { IntegrationWebhookRepo } from './repos/integration-webhook.repo';
|
|
||||||
import { IntegrationListener } from './integration.listener';
|
|
||||||
import { IntegrationProcessor } from './integration.processor';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [IntegrationController, OAuthController, UnfurlController],
|
|
||||||
providers: [
|
|
||||||
IntegrationRegistry,
|
|
||||||
IntegrationService,
|
|
||||||
IntegrationConnectionService,
|
|
||||||
OAuthService,
|
|
||||||
UnfurlService,
|
|
||||||
IntegrationRepo,
|
|
||||||
IntegrationConnectionRepo,
|
|
||||||
IntegrationWebhookRepo,
|
|
||||||
IntegrationListener,
|
|
||||||
IntegrationProcessor,
|
|
||||||
],
|
|
||||||
exports: [
|
|
||||||
IntegrationRegistry,
|
|
||||||
IntegrationService,
|
|
||||||
IntegrationConnectionService,
|
|
||||||
OAuthService,
|
|
||||||
IntegrationRepo,
|
|
||||||
IntegrationConnectionRepo,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class IntegrationModule {}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Job } from 'bullmq';
|
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants/queue.constants';
|
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationConnectionRepo } from './repos/integration-connection.repo';
|
|
||||||
import { OAuthService } from './oauth/oauth.service';
|
|
||||||
|
|
||||||
@Processor(QueueName.INTEGRATION_QUEUE)
|
|
||||||
export class IntegrationProcessor extends WorkerHost {
|
|
||||||
private readonly logger = new Logger(IntegrationProcessor.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
private readonly oauthService: OAuthService,
|
|
||||||
) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async process(job: Job): Promise<void> {
|
|
||||||
switch (job.name) {
|
|
||||||
case QueueJob.INTEGRATION_EVENT:
|
|
||||||
await this.handleIntegrationEvent(job);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger.warn(`Unknown job: ${job.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleIntegrationEvent(job: Job): Promise<void> {
|
|
||||||
const { eventName, workspaceId, ...payload } = job.data;
|
|
||||||
|
|
||||||
if (!workspaceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations =
|
|
||||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
|
||||||
|
|
||||||
for (const integration of integrations) {
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider?.handleEvent) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const connections = await this.connectionRepo.findByIntegration(
|
|
||||||
integration.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const connection = connections[0];
|
|
||||||
let accessToken: string | undefined;
|
|
||||||
|
|
||||||
if (connection) {
|
|
||||||
accessToken = await this.oauthService.getValidAccessToken(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
await provider.handleEvent({
|
|
||||||
eventName,
|
|
||||||
payload,
|
|
||||||
integration: {
|
|
||||||
id: integration.id,
|
|
||||||
type: integration.type,
|
|
||||||
settings: integration.settings as Record<string, any> | null,
|
|
||||||
},
|
|
||||||
connection: connection
|
|
||||||
? { accessToken, userId: connection.userId }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Integration event handler failed for ${integration.type}: ${(err as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { IntegrationRepo } from './repos/integration.repo';
|
|
||||||
import { IntegrationRegistry } from './registry/integration-registry';
|
|
||||||
import { Integration } from '@docmost/db/types/entity.types';
|
|
||||||
import { validateIntegrationSettings } from './dto/integration-settings.schema';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationService {
|
|
||||||
constructor(
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getAvailableIntegrations() {
|
|
||||||
return this.registry.getAvailableIntegrations();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInstalledIntegrations(workspaceId: string): Promise<Integration[]> {
|
|
||||||
return this.integrationRepo.findAllByWorkspace(workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(integrationId: string): Promise<Integration | undefined> {
|
|
||||||
return this.integrationRepo.findById(integrationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async install(
|
|
||||||
type: string,
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const provider = this.registry.getProvider(type);
|
|
||||||
if (!provider) {
|
|
||||||
throw new BadRequestException(`Unknown integration type: ${type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await this.integrationRepo.findByWorkspaceAndType(
|
|
||||||
workspaceId,
|
|
||||||
type,
|
|
||||||
);
|
|
||||||
if (existing) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Integration "${type}" is already installed`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationRepo.insertOrRestore({
|
|
||||||
type,
|
|
||||||
workspaceId,
|
|
||||||
installedById: userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async uninstall(integrationId: string, workspaceId: string): Promise<void> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
await this.integrationRepo.softDelete(integrationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
integrationId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
data: { settings?: Record<string, any>; isEnabled?: boolean },
|
|
||||||
): Promise<Integration> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.settings !== undefined) {
|
|
||||||
const validation = validateIntegrationSettings(
|
|
||||||
integration.type,
|
|
||||||
data.settings,
|
|
||||||
);
|
|
||||||
if (validation.success === false) {
|
|
||||||
throw new BadRequestException(`Invalid settings: ${validation.error}`);
|
|
||||||
}
|
|
||||||
data.settings = validation.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.integrationRepo.update(integrationId, {
|
|
||||||
...(data.settings !== undefined && { settings: data.settings }),
|
|
||||||
...(data.isEnabled !== undefined && { isEnabled: data.isEnabled }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Logger,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FastifyReply } from 'fastify';
|
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
|
||||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { OAuthService } from './oauth.service';
|
|
||||||
import { OAuthAuthorizeDto, OAuthDisconnectDto } from '../dto/integration.dto';
|
|
||||||
import { IntegrationConnectionService } from '../integration-connection.service';
|
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
@Controller('integrations/oauth')
|
|
||||||
export class OAuthController {
|
|
||||||
private readonly logger = new Logger(OAuthController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly oauthService: OAuthService,
|
|
||||||
private readonly connectionService: IntegrationConnectionService,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('authorize')
|
|
||||||
async authorize(
|
|
||||||
@Body() dto: OAuthAuthorizeDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const { authorizationUrl } = await this.oauthService.getAuthorizationUrl(
|
|
||||||
dto.integrationId,
|
|
||||||
workspace.id,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return { authorizationUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':type/callback')
|
|
||||||
async callback(
|
|
||||||
@Param('type') type: string,
|
|
||||||
@Query('code') code: string,
|
|
||||||
@Query('state') state: string,
|
|
||||||
@Res() res: FastifyReply,
|
|
||||||
) {
|
|
||||||
if (!code || !state) {
|
|
||||||
throw new BadRequestException('Missing code or state parameter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const statePayload = this.oauthService.verifySignedState(state);
|
|
||||||
if (!statePayload) {
|
|
||||||
throw new BadRequestException('Invalid or expired OAuth state');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.oauthService.exchangeCodeForTokens(
|
|
||||||
type,
|
|
||||||
code,
|
|
||||||
statePayload.integrationId,
|
|
||||||
statePayload.userId,
|
|
||||||
statePayload.workspaceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appUrl = this.environmentService.getAppUrl();
|
|
||||||
return res.redirect(`${appUrl}/settings/integrations`, 302).send();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`OAuth callback error for ${type}: ${(err as Error).message}`);
|
|
||||||
const appUrl = this.environmentService.getAppUrl();
|
|
||||||
return res.redirect(`${appUrl}/settings/integrations?error=oauth_failed`, 302).send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('disconnect')
|
|
||||||
async disconnect(
|
|
||||||
@Body() dto: OAuthDisconnectDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
await this.connectionService.disconnect(
|
|
||||||
dto.integrationId,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
|
||||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
|
||||||
import { IntegrationRepo } from '../repos/integration.repo';
|
|
||||||
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
|
|
||||||
import { encryptToken, decryptToken } from '../crypto/token-crypto';
|
|
||||||
import { IntegrationConnection } from '@docmost/db/types/entity.types';
|
|
||||||
import { OAuthConfig } from '../registry/integration-provider.interface';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
type OAuthTokenResponse = {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
expires_in?: number;
|
|
||||||
token_type?: string;
|
|
||||||
scope?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OAuthStatePayload = {
|
|
||||||
integrationId: string;
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
exp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OAuthService {
|
|
||||||
private readonly logger = new Logger(OAuthService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getAuthorizationUrl(
|
|
||||||
integrationId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
userId: string,
|
|
||||||
): Promise<{ authorizationUrl: string }> {
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
if (!integration || integration.workspaceId !== workspaceId) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider || !provider.definition.oauth) {
|
|
||||||
throw new BadRequestException('Integration does not support OAuth');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauthConfig = provider.getOAuthConfig
|
|
||||||
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
|
|
||||||
: provider.definition.oauth;
|
|
||||||
|
|
||||||
const callbackUrl = this.buildCallbackUrl(integration.type);
|
|
||||||
|
|
||||||
const state = this.createSignedState({
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
exp: Date.now() + 10 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: this.getClientId(integration.type),
|
|
||||||
redirect_uri: callbackUrl,
|
|
||||||
response_type: 'code',
|
|
||||||
state,
|
|
||||||
});
|
|
||||||
|
|
||||||
const scope = oauthConfig.scopes
|
|
||||||
.map((s) => encodeURIComponent(s))
|
|
||||||
.join('%20');
|
|
||||||
|
|
||||||
return {
|
|
||||||
authorizationUrl: `${oauthConfig.authUrl}?${params.toString()}&scope=${scope}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
verifySignedState(state: string): OAuthStatePayload | null {
|
|
||||||
const dotIndex = state.lastIndexOf('.');
|
|
||||||
if (dotIndex === -1) return null;
|
|
||||||
|
|
||||||
const data = state.substring(0, dotIndex);
|
|
||||||
const signature = state.substring(dotIndex + 1);
|
|
||||||
|
|
||||||
const secret = this.environmentService.getAppSecret();
|
|
||||||
const expected = crypto
|
|
||||||
.createHmac('sha256', secret)
|
|
||||||
.update(data)
|
|
||||||
.digest('base64url');
|
|
||||||
|
|
||||||
if (signature !== expected) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: OAuthStatePayload = JSON.parse(
|
|
||||||
Buffer.from(data, 'base64url').toString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (payload.exp < Date.now()) return null;
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async exchangeCodeForTokens(
|
|
||||||
type: string,
|
|
||||||
code: string,
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<IntegrationConnection> {
|
|
||||||
const provider = this.registry.getProvider(type);
|
|
||||||
if (!provider || !provider.definition.oauth) {
|
|
||||||
throw new BadRequestException('Integration does not support OAuth');
|
|
||||||
}
|
|
||||||
|
|
||||||
const integration = await this.integrationRepo.findById(integrationId);
|
|
||||||
const settings = (integration?.settings as Record<string, any>) ?? {};
|
|
||||||
|
|
||||||
const oauthConfig = provider.getOAuthConfig
|
|
||||||
? provider.getOAuthConfig(settings)
|
|
||||||
: provider.definition.oauth;
|
|
||||||
|
|
||||||
const tokenResponse = await this.requestTokens(
|
|
||||||
oauthConfig,
|
|
||||||
type,
|
|
||||||
code,
|
|
||||||
);
|
|
||||||
|
|
||||||
const appSecret = this.environmentService.getAppSecret();
|
|
||||||
const encryptedAccessToken = encryptToken(
|
|
||||||
tokenResponse.access_token,
|
|
||||||
appSecret,
|
|
||||||
);
|
|
||||||
const encryptedRefreshToken = tokenResponse.refresh_token
|
|
||||||
? encryptToken(tokenResponse.refresh_token, appSecret)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const tokenExpiresAt = tokenResponse.expires_in
|
|
||||||
? new Date(Date.now() + tokenResponse.expires_in * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const connection = await this.connectionRepo.upsert({
|
|
||||||
integrationId,
|
|
||||||
userId,
|
|
||||||
workspaceId,
|
|
||||||
accessToken: encryptedAccessToken,
|
|
||||||
refreshToken: encryptedRefreshToken,
|
|
||||||
tokenExpiresAt,
|
|
||||||
scopes: tokenResponse.scope ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (provider.onConnected) {
|
|
||||||
await provider.onConnected({
|
|
||||||
accessToken: tokenResponse.access_token,
|
|
||||||
refreshToken: tokenResponse.refresh_token,
|
|
||||||
providerUserId: '',
|
|
||||||
metadata: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getValidAccessToken(
|
|
||||||
connection: IntegrationConnection,
|
|
||||||
): Promise<string> {
|
|
||||||
const appSecret = this.environmentService.getAppSecret();
|
|
||||||
const accessToken = decryptToken(connection.accessToken, appSecret);
|
|
||||||
|
|
||||||
const needsRefresh =
|
|
||||||
connection.tokenExpiresAt &&
|
|
||||||
connection.refreshToken &&
|
|
||||||
new Date(connection.tokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000;
|
|
||||||
|
|
||||||
if (!needsRefresh) {
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.refreshAccessToken(connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshAccessToken(
|
|
||||||
connection: IntegrationConnection,
|
|
||||||
): Promise<string> {
|
|
||||||
const appSecret = this.environmentService.getAppSecret();
|
|
||||||
const refreshToken = decryptToken(connection.refreshToken, appSecret);
|
|
||||||
|
|
||||||
const integration = await this.integrationRepo.findById(
|
|
||||||
connection.integrationId,
|
|
||||||
);
|
|
||||||
if (!integration) {
|
|
||||||
throw new NotFoundException('Integration not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider || !provider.definition.oauth) {
|
|
||||||
throw new BadRequestException('Integration does not support OAuth');
|
|
||||||
}
|
|
||||||
|
|
||||||
const oauthConfig = provider.getOAuthConfig
|
|
||||||
? provider.getOAuthConfig((integration.settings as Record<string, any>) ?? {})
|
|
||||||
: provider.definition.oauth;
|
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
client_id: this.getClientId(integration.type),
|
|
||||||
client_secret: this.getClientSecret(integration.type),
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(oauthConfig.tokenUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
this.logger.error(
|
|
||||||
`Token refresh failed for ${integration.type}: ${response.status}`,
|
|
||||||
);
|
|
||||||
throw new BadRequestException('Token refresh failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: OAuthTokenResponse = await response.json();
|
|
||||||
const encryptedAccessToken = encryptToken(data.access_token, appSecret);
|
|
||||||
const encryptedRefreshToken = data.refresh_token
|
|
||||||
? encryptToken(data.refresh_token, appSecret)
|
|
||||||
: connection.refreshToken;
|
|
||||||
const tokenExpiresAt = data.expires_in
|
|
||||||
? new Date(Date.now() + data.expires_in * 1000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await this.connectionRepo.update(connection.id, {
|
|
||||||
accessToken: encryptedAccessToken,
|
|
||||||
refreshToken: encryptedRefreshToken,
|
|
||||||
tokenExpiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return data.access_token;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Token refresh error: ${(err as Error).message}`);
|
|
||||||
throw new BadRequestException('Failed to refresh token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestTokens(
|
|
||||||
oauthConfig: OAuthConfig,
|
|
||||||
type: string,
|
|
||||||
code: string,
|
|
||||||
): Promise<OAuthTokenResponse> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
client_id: this.getClientId(type),
|
|
||||||
client_secret: this.getClientSecret(type),
|
|
||||||
code,
|
|
||||||
redirect_uri: this.buildCallbackUrl(type),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(oauthConfig.tokenUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text();
|
|
||||||
this.logger.error(`Token exchange failed for ${type}: ${response.status} ${body}`);
|
|
||||||
throw new BadRequestException('OAuth token exchange failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
buildCallbackUrl(type: string): string {
|
|
||||||
const appUrl = this.environmentService.getAppUrl();
|
|
||||||
return `${appUrl}/api/integrations/oauth/${type}/callback`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createSignedState(payload: OAuthStatePayload): string {
|
|
||||||
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
||||||
const secret = this.environmentService.getAppSecret();
|
|
||||||
const signature = crypto
|
|
||||||
.createHmac('sha256', secret)
|
|
||||||
.update(data)
|
|
||||||
.digest('base64url');
|
|
||||||
return `${data}.${signature}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClientId(type: string): string {
|
|
||||||
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_ID`;
|
|
||||||
const value = process.env[envKey];
|
|
||||||
if (!value) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Missing environment variable: ${envKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getClientSecret(type: string): string {
|
|
||||||
const envKey = `INTEGRATION_${type.toUpperCase()}_CLIENT_SECRET`;
|
|
||||||
const value = process.env[envKey];
|
|
||||||
if (!value) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Missing environment variable: ${envKey}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
export type IntegrationCapability = 'oauth' | 'unfurl' | 'actions' | 'webhooks';
|
|
||||||
|
|
||||||
export type OAuthConfig = {
|
|
||||||
authUrl: string;
|
|
||||||
tokenUrl: string;
|
|
||||||
scopes: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlPattern = {
|
|
||||||
regex: RegExp;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlResult = {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
url: string;
|
|
||||||
provider: string;
|
|
||||||
providerIcon?: string;
|
|
||||||
status?: string;
|
|
||||||
statusColor?: string;
|
|
||||||
author?: string;
|
|
||||||
authorAvatarUrl?: string;
|
|
||||||
metadata?: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IntegrationDefinition = {
|
|
||||||
type: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
capabilities: IntegrationCapability[];
|
|
||||||
oauth?: OAuthConfig;
|
|
||||||
unfurlPatterns?: UnfurlPattern[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConnectedEvent = {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
providerUserId: string;
|
|
||||||
metadata: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HandleEventOpts = {
|
|
||||||
eventName: string;
|
|
||||||
payload: Record<string, any>;
|
|
||||||
integration: {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
settings: Record<string, any> | null;
|
|
||||||
};
|
|
||||||
connection?: {
|
|
||||||
accessToken: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnfurlOpts = {
|
|
||||||
url: string;
|
|
||||||
accessToken: string;
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
patternType: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export abstract class IntegrationProvider {
|
|
||||||
abstract definition: IntegrationDefinition;
|
|
||||||
|
|
||||||
getOAuthConfig?(
|
|
||||||
workspaceSettings: Record<string, any>,
|
|
||||||
): OAuthConfig;
|
|
||||||
|
|
||||||
getUnfurlPatterns?(
|
|
||||||
workspaceSettings: Record<string, any>,
|
|
||||||
): UnfurlPattern[];
|
|
||||||
|
|
||||||
onConnected?(opts: ConnectedEvent): Promise<void>;
|
|
||||||
|
|
||||||
unfurl?(opts: UnfurlOpts): Promise<UnfurlResult>;
|
|
||||||
|
|
||||||
handleEvent?(opts: HandleEventOpts): Promise<void>;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
IntegrationDefinition,
|
|
||||||
IntegrationProvider,
|
|
||||||
} from './integration-provider.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationRegistry {
|
|
||||||
private providers = new Map<string, IntegrationProvider>();
|
|
||||||
|
|
||||||
register(provider: IntegrationProvider): void {
|
|
||||||
this.providers.set(provider.definition.type, provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
getProvider(type: string): IntegrationProvider | undefined {
|
|
||||||
return this.providers.get(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAllProviders(): IntegrationProvider[] {
|
|
||||||
return Array.from(this.providers.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
getAvailableIntegrations(): IntegrationDefinition[] {
|
|
||||||
return this.getAllProviders().map((p) => p.definition);
|
|
||||||
}
|
|
||||||
|
|
||||||
findUnfurlProvider(
|
|
||||||
url: string,
|
|
||||||
): {
|
|
||||||
provider: IntegrationProvider;
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
patternType: string;
|
|
||||||
} | null {
|
|
||||||
for (const provider of this.providers.values()) {
|
|
||||||
if (!provider.definition.unfurlPatterns) continue;
|
|
||||||
for (const pattern of provider.definition.unfurlPatterns) {
|
|
||||||
const match = url.match(pattern.regex);
|
|
||||||
if (match) {
|
|
||||||
return { provider, match, patternType: pattern.type };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import {
|
|
||||||
IntegrationConnection,
|
|
||||||
InsertableIntegrationConnection,
|
|
||||||
UpdatableIntegrationConnection,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationConnectionRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
connectionId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', connectionId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegrationAndUser(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.selectAll()
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.where('userId', '=', userId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByWorkspaceTypeAndUser(
|
|
||||||
workspaceId: string,
|
|
||||||
integrationType: string,
|
|
||||||
userId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.innerJoin(
|
|
||||||
'integrations',
|
|
||||||
'integrations.id',
|
|
||||||
'integrationConnections.integrationId',
|
|
||||||
)
|
|
||||||
.selectAll('integrationConnections')
|
|
||||||
.where('integrations.workspaceId', '=', workspaceId)
|
|
||||||
.where('integrations.type', '=', integrationType)
|
|
||||||
.where('integrations.deletedAt', 'is', null)
|
|
||||||
.where('integrationConnections.userId', '=', userId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationConnections')
|
|
||||||
.selectAll()
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsert(
|
|
||||||
connection: InsertableIntegrationConnection,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrationConnections')
|
|
||||||
.values(connection)
|
|
||||||
.onConflict((oc) =>
|
|
||||||
oc.columns(['integrationId', 'userId']).doUpdateSet({
|
|
||||||
accessToken: connection.accessToken,
|
|
||||||
refreshToken: connection.refreshToken,
|
|
||||||
tokenExpiresAt: connection.tokenExpiresAt,
|
|
||||||
scopes: connection.scopes,
|
|
||||||
providerUserId: connection.providerUserId,
|
|
||||||
metadata: connection.metadata,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
connectionId: string,
|
|
||||||
data: UpdatableIntegrationConnection,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationConnection> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('integrationConnections')
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where('id', '=', connectionId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByIntegrationAndUser(
|
|
||||||
integrationId: string,
|
|
||||||
userId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationConnections')
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.where('userId', '=', userId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationConnections')
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import {
|
|
||||||
IntegrationWebhook,
|
|
||||||
InsertableIntegrationWebhook,
|
|
||||||
UpdatableIntegrationWebhook,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationWebhookRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
webhookId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationWebhooks')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', webhookId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationWebhooks')
|
|
||||||
.selectAll()
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findEnabledByEvent(
|
|
||||||
workspaceId: string,
|
|
||||||
eventType: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrationWebhooks')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('eventType', '=', eventType)
|
|
||||||
.where('isEnabled', '=', true)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insert(
|
|
||||||
webhook: InsertableIntegrationWebhook,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrationWebhooks')
|
|
||||||
.values(webhook)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
webhookId: string,
|
|
||||||
data: UpdatableIntegrationWebhook,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<IntegrationWebhook> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('integrationWebhooks')
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where('id', '=', webhookId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(
|
|
||||||
webhookId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationWebhooks')
|
|
||||||
.where('id', '=', webhookId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByIntegration(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.deleteFrom('integrationWebhooks')
|
|
||||||
.where('integrationId', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import {
|
|
||||||
Integration,
|
|
||||||
InsertableIntegration,
|
|
||||||
UpdatableIntegration,
|
|
||||||
} from '@docmost/db/types/entity.types';
|
|
||||||
import { dbOrTx } from '@docmost/db/utils';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IntegrationRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
|
|
||||||
async findById(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('id', '=', integrationId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByWorkspaceAndType(
|
|
||||||
workspaceId: string,
|
|
||||||
type: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration | undefined> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('type', '=', type)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findEnabledByWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('isEnabled', '=', true)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllByWorkspace(
|
|
||||||
workspaceId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration[]> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.selectFrom('integrations')
|
|
||||||
.selectAll()
|
|
||||||
.where('workspaceId', '=', workspaceId)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insert(
|
|
||||||
integration: InsertableIntegration,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrations')
|
|
||||||
.values(integration)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertOrRestore(
|
|
||||||
integration: InsertableIntegration,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.insertInto('integrations')
|
|
||||||
.values(integration)
|
|
||||||
.onConflict((oc) =>
|
|
||||||
oc.columns(['type', 'workspaceId']).doUpdateSet({
|
|
||||||
deletedAt: null,
|
|
||||||
isEnabled: true,
|
|
||||||
installedById: integration.installedById,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
|
||||||
integrationId: string,
|
|
||||||
data: UpdatableIntegration,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<Integration> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
return db
|
|
||||||
.updateTable('integrations')
|
|
||||||
.set({ ...data, updatedAt: new Date() })
|
|
||||||
.where('id', '=', integrationId)
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async softDelete(
|
|
||||||
integrationId: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const db = dbOrTx(this.db, trx);
|
|
||||||
await db
|
|
||||||
.updateTable('integrations')
|
|
||||||
.set({ deletedAt: new Date() })
|
|
||||||
.where('id', '=', integrationId)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
|
||||||
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
|
||||||
import { AuthWorkspace } from '../../../common/decorators/auth-workspace.decorator';
|
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
import { UnfurlService } from './unfurl.service';
|
|
||||||
import { UnfurlDto } from '../dto/integration.dto';
|
|
||||||
|
|
||||||
@Controller('integrations')
|
|
||||||
export class UnfurlController {
|
|
||||||
constructor(private readonly unfurlService: UnfurlService) {}
|
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('unfurl')
|
|
||||||
async unfurl(
|
|
||||||
@Body() dto: UnfurlDto,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
) {
|
|
||||||
const result = await this.unfurlService.unfurl(
|
|
||||||
dto.url,
|
|
||||||
user.id,
|
|
||||||
workspace.id,
|
|
||||||
);
|
|
||||||
return { data: result };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { IntegrationRegistry } from '../registry/integration-registry';
|
|
||||||
import { IntegrationConnectionRepo } from '../repos/integration-connection.repo';
|
|
||||||
import { IntegrationRepo } from '../repos/integration.repo';
|
|
||||||
import { OAuthService } from '../oauth/oauth.service';
|
|
||||||
import {
|
|
||||||
UnfurlResult,
|
|
||||||
IntegrationProvider,
|
|
||||||
} from '../registry/integration-provider.interface';
|
|
||||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
|
||||||
import type { Redis } from 'ioredis';
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
const UNFURL_CACHE_TTL = 300; // 5 minutes
|
|
||||||
const UNFURL_CACHE_PREFIX = 'unfurl:';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UnfurlService {
|
|
||||||
private readonly logger = new Logger(UnfurlService.name);
|
|
||||||
private readonly redis: Redis;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly registry: IntegrationRegistry,
|
|
||||||
private readonly integrationRepo: IntegrationRepo,
|
|
||||||
private readonly connectionRepo: IntegrationConnectionRepo,
|
|
||||||
private readonly oauthService: OAuthService,
|
|
||||||
private readonly redisService: RedisService,
|
|
||||||
) {
|
|
||||||
this.redis = this.redisService.getOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
async unfurl(
|
|
||||||
url: string,
|
|
||||||
userId: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<UnfurlResult | null> {
|
|
||||||
const cacheKey = this.buildCacheKey(workspaceId, url);
|
|
||||||
const cached = await this.redis.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return JSON.parse(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await this.resolveProvider(url, workspaceId);
|
|
||||||
|
|
||||||
if (!resolved) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { provider, match, patternType, integration } = resolved;
|
|
||||||
|
|
||||||
if (!provider.unfurl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connection = await this.connectionRepo.findByIntegrationAndUser(
|
|
||||||
integration.id,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!connection) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessToken =
|
|
||||||
await this.oauthService.getValidAccessToken(connection);
|
|
||||||
|
|
||||||
const unfurlResult = await provider.unfurl({
|
|
||||||
url,
|
|
||||||
accessToken,
|
|
||||||
match,
|
|
||||||
patternType,
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.redis.set(
|
|
||||||
cacheKey,
|
|
||||||
JSON.stringify(unfurlResult),
|
|
||||||
'EX',
|
|
||||||
UNFURL_CACHE_TTL,
|
|
||||||
);
|
|
||||||
|
|
||||||
return unfurlResult;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(`Unfurl failed for ${url}: ${(err as Error).message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveProvider(
|
|
||||||
url: string,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<{
|
|
||||||
provider: IntegrationProvider;
|
|
||||||
match: RegExpMatchArray;
|
|
||||||
patternType: string;
|
|
||||||
integration: { id: string; isEnabled: boolean; type: string };
|
|
||||||
} | null> {
|
|
||||||
const staticResult = this.registry.findUnfurlProvider(url);
|
|
||||||
if (staticResult) {
|
|
||||||
const integration = await this.integrationRepo.findByWorkspaceAndType(
|
|
||||||
workspaceId,
|
|
||||||
staticResult.provider.definition.type,
|
|
||||||
);
|
|
||||||
if (integration && integration.isEnabled) {
|
|
||||||
return { ...staticResult, integration };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const integrations =
|
|
||||||
await this.integrationRepo.findEnabledByWorkspace(workspaceId);
|
|
||||||
|
|
||||||
for (const integration of integrations) {
|
|
||||||
const provider = this.registry.getProvider(integration.type);
|
|
||||||
if (!provider?.getUnfurlPatterns || !provider.unfurl) continue;
|
|
||||||
|
|
||||||
const settings = (integration.settings as Record<string, any>) ?? {};
|
|
||||||
const patterns = provider.getUnfurlPatterns(settings);
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = url.match(pattern.regex);
|
|
||||||
if (match) {
|
|
||||||
return { provider, match, patternType: pattern.type, integration };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCacheKey(workspaceId: string, url: string): string {
|
|
||||||
const hash = crypto
|
|
||||||
.createHash('sha256')
|
|
||||||
.update(url)
|
|
||||||
.digest('hex')
|
|
||||||
.slice(0, 16);
|
|
||||||
return `${UNFURL_CACHE_PREFIX}${workspaceId}:${hash}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { type Kysely, sql } from 'kysely';
|
|
||||||
|
|
||||||
export async function up(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema
|
|
||||||
.createTable('integrations')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('type', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
|
||||||
.addColumn('settings', 'jsonb')
|
|
||||||
.addColumn('installed_by_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('set null'),
|
|
||||||
)
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('deleted_at', 'timestamptz')
|
|
||||||
.addUniqueConstraint('uq_integrations_workspace_type', [
|
|
||||||
'workspace_id',
|
|
||||||
'type',
|
|
||||||
])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable('integration_connections')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('integration_id', 'uuid', (col) =>
|
|
||||||
col.references('integrations.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('user_id', 'uuid', (col) =>
|
|
||||||
col.references('users.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('provider_user_id', 'text')
|
|
||||||
.addColumn('access_token', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('refresh_token', 'text')
|
|
||||||
.addColumn('token_expires_at', 'timestamptz')
|
|
||||||
.addColumn('scopes', 'text')
|
|
||||||
.addColumn('metadata', 'jsonb')
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addUniqueConstraint('uq_integration_connections_integration_user', [
|
|
||||||
'integration_id',
|
|
||||||
'user_id',
|
|
||||||
])
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createTable('integration_webhooks')
|
|
||||||
.addColumn('id', 'uuid', (col) =>
|
|
||||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
|
||||||
)
|
|
||||||
.addColumn('integration_id', 'uuid', (col) =>
|
|
||||||
col.references('integrations.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('workspace_id', 'uuid', (col) =>
|
|
||||||
col.references('workspaces.id').onDelete('cascade').notNull(),
|
|
||||||
)
|
|
||||||
.addColumn('event_type', 'text', (col) => col.notNull())
|
|
||||||
.addColumn('webhook_url', 'text')
|
|
||||||
.addColumn('secret', 'text')
|
|
||||||
.addColumn('is_enabled', 'boolean', (col) => col.notNull().defaultTo(true))
|
|
||||||
.addColumn('created_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.addColumn('updated_at', 'timestamptz', (col) =>
|
|
||||||
col.notNull().defaultTo(sql`now()`),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db.schema
|
|
||||||
.createIndex('idx_integration_webhooks_integration_event')
|
|
||||||
.on('integration_webhooks')
|
|
||||||
.columns(['integration_id', 'event_type'])
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function down(db: Kysely<any>): Promise<void> {
|
|
||||||
await db.schema.dropTable('integration_webhooks').execute();
|
|
||||||
await db.schema.dropTable('integration_connections').execute();
|
|
||||||
await db.schema.dropTable('integrations').execute();
|
|
||||||
}
|
|
||||||
-42
@@ -390,45 +390,6 @@ export interface Watchers {
|
|||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Integrations {
|
|
||||||
id: Generated<string>;
|
|
||||||
workspaceId: string;
|
|
||||||
type: string;
|
|
||||||
isEnabled: Generated<boolean>;
|
|
||||||
settings: Json | null;
|
|
||||||
installedById: string | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrationConnections {
|
|
||||||
id: Generated<string>;
|
|
||||||
integrationId: string;
|
|
||||||
userId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
providerUserId: string | null;
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string | null;
|
|
||||||
tokenExpiresAt: Timestamp | null;
|
|
||||||
scopes: string | null;
|
|
||||||
metadata: Json | null;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrationWebhooks {
|
|
||||||
id: Generated<string>;
|
|
||||||
integrationId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
eventType: string;
|
|
||||||
webhookUrl: string | null;
|
|
||||||
secret: string | null;
|
|
||||||
isEnabled: Generated<boolean>;
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
apiKeys: ApiKeys;
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
@@ -440,9 +401,6 @@ export interface DB {
|
|||||||
fileTasks: FileTasks;
|
fileTasks: FileTasks;
|
||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
integrationConnections: IntegrationConnections;
|
|
||||||
integrationWebhooks: IntegrationWebhooks;
|
|
||||||
integrations: Integrations;
|
|
||||||
notifications: Notifications;
|
notifications: Notifications;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
|
||||||
import {
|
|
||||||
Integrations,
|
|
||||||
IntegrationConnections,
|
|
||||||
IntegrationWebhooks,
|
|
||||||
} from '@docmost/db/types/db';
|
|
||||||
|
|
||||||
export interface DbInterface extends DB {
|
export interface DbInterface extends DB {
|
||||||
pageEmbeddings: PageEmbeddings;
|
pageEmbeddings: PageEmbeddings;
|
||||||
integrations: Integrations;
|
|
||||||
integrationConnections: IntegrationConnections;
|
|
||||||
integrationWebhooks: IntegrationWebhooks;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import {
|
|||||||
Attachments,
|
Attachments,
|
||||||
Comments,
|
Comments,
|
||||||
Groups,
|
Groups,
|
||||||
Integrations as _Integrations,
|
|
||||||
IntegrationConnections as _IntegrationConnections,
|
|
||||||
IntegrationWebhooks as _IntegrationWebhooks,
|
|
||||||
Notifications,
|
Notifications,
|
||||||
Pages,
|
Pages,
|
||||||
Spaces,
|
Spaces,
|
||||||
@@ -146,23 +143,3 @@ export type UpdatableNotification = Updateable<Omit<Notifications, 'id'>>;
|
|||||||
export type Watcher = Selectable<Watchers>;
|
export type Watcher = Selectable<Watchers>;
|
||||||
export type InsertableWatcher = Insertable<Watchers>;
|
export type InsertableWatcher = Insertable<Watchers>;
|
||||||
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
export type UpdatableWatcher = Updateable<Omit<Watchers, 'id'>>;
|
||||||
|
|
||||||
// Integration
|
|
||||||
export type Integration = Selectable<_Integrations>;
|
|
||||||
export type InsertableIntegration = Insertable<_Integrations>;
|
|
||||||
export type UpdatableIntegration = Updateable<Omit<_Integrations, 'id'>>;
|
|
||||||
|
|
||||||
// Integration Connection
|
|
||||||
export type IntegrationConnection = Selectable<_IntegrationConnections>;
|
|
||||||
export type InsertableIntegrationConnection =
|
|
||||||
Insertable<_IntegrationConnections>;
|
|
||||||
export type UpdatableIntegrationConnection = Updateable<
|
|
||||||
Omit<_IntegrationConnections, 'id'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
// Integration Webhook
|
|
||||||
export type IntegrationWebhook = Selectable<_IntegrationWebhooks>;
|
|
||||||
export type InsertableIntegrationWebhook = Insertable<_IntegrationWebhooks>;
|
|
||||||
export type UpdatableIntegrationWebhook = Updateable<
|
|
||||||
Omit<_IntegrationWebhooks, 'id'>
|
|
||||||
>;
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 010a833e1a...71b4323d1b
@@ -50,6 +50,7 @@ export async function formatImportHtml(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notionFormatter($, $root);
|
notionFormatter($, $root);
|
||||||
|
xwikiFormatter($, $root);
|
||||||
defaultHtmlFormatter($, $root);
|
defaultHtmlFormatter($, $root);
|
||||||
|
|
||||||
const backlinks = await rewriteInternalLinksToMentionHtml(
|
const backlinks = await rewriteInternalLinksToMentionHtml(
|
||||||
@@ -69,6 +70,14 @@ export async function formatImportHtml(opts: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function xwikiFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
|
const $content = $root.find('#xwikicontent');
|
||||||
|
if ($content.length) {
|
||||||
|
$root.children().remove();
|
||||||
|
$root.append($content.contents());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
export function defaultHtmlFormatter($: CheerioAPI, $root: Cheerio<any>) {
|
||||||
$root.find('a[href]').each((_, el) => {
|
$root.find('a[href]').each((_, el) => {
|
||||||
const $el = $(el);
|
const $el = $(el);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export enum QueueName {
|
|||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HISTORY_QUEUE = '{history-queue}',
|
HISTORY_QUEUE = '{history-queue}',
|
||||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||||
INTEGRATION_QUEUE = '{integration-queue}',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJob {
|
export enum QueueJob {
|
||||||
@@ -68,6 +67,4 @@ export enum QueueJob {
|
|||||||
COMMENT_NOTIFICATION = 'comment-notification',
|
COMMENT_NOTIFICATION = 'comment-notification',
|
||||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||||
|
|
||||||
INTEGRATION_EVENT = 'integration-event',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,14 +84,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
|||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueName.NOTIFICATION_QUEUE,
|
name: QueueName.NOTIFICATION_QUEUE,
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.INTEGRATION_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: { count: 50 },
|
|
||||||
attempts: 3,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
providers: [GeneralQueueProcessor],
|
providers: [GeneralQueueProcessor],
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ async function bootstrap() {
|
|||||||
'/api/sso/google',
|
'/api/sso/google',
|
||||||
'/api/workspace/create',
|
'/api/workspace/create',
|
||||||
'/api/workspace/joined',
|
'/api/workspace/joined',
|
||||||
'/api/integrations/oauth'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
+8
-7
@@ -76,17 +76,16 @@
|
|||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.29",
|
"yjs": "^13.6.29"
|
||||||
"zod": "^3.25.76"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "22.5.0",
|
"@nx/js": "22.5.2",
|
||||||
"@types/bytes": "^3.1.5",
|
"@types/bytes": "^3.1.5",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.2.1",
|
||||||
"nx": "22.5.0",
|
"nx": "22.5.2",
|
||||||
"tsx": "^4.19.3"
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": [
|
"packages": [
|
||||||
@@ -97,7 +96,8 @@
|
|||||||
"packageManager": "pnpm@10.4.0",
|
"packageManager": "pnpm@10.4.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch"
|
"react-arborist@3.4.0": "patches/react-arborist@3.4.0.patch",
|
||||||
|
"@tiptap/core": "patches/@tiptap__core.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"jsdom": "25.0.1",
|
"jsdom": "25.0.1",
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
"tmp": "0.2.5",
|
"tmp": "0.2.5",
|
||||||
"lodash-es": "4.17.23",
|
"lodash-es": "4.17.23",
|
||||||
"markdown-it": "14.1.1",
|
"markdown-it": "14.1.1",
|
||||||
|
"ajv": "8.18.0",
|
||||||
"@tiptap/core": "3.17.1",
|
"@tiptap/core": "3.17.1",
|
||||||
"@tiptap/pm": "3.17.1",
|
"@tiptap/pm": "3.17.1",
|
||||||
"@tiptap/starter-kit": "3.17.1",
|
"@tiptap/starter-kit": "3.17.1",
|
||||||
|
|||||||
@@ -25,4 +25,4 @@ export * from "./lib/heading/heading";
|
|||||||
export * from "./lib/unique-id";
|
export * from "./lib/unique-id";
|
||||||
export * from "./lib/shared-storage";
|
export * from "./lib/shared-storage";
|
||||||
export * from "./lib/recreate-transform";
|
export * from "./lib/recreate-transform";
|
||||||
export * from "./lib/integration-link";
|
export * from "./lib/columns";
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
export type CalloutType = "default" | "info" | "success" | "warning" | "danger";
|
export type CalloutType =
|
||||||
const validCalloutTypes = ["default", "info", "success", "warning", "danger"];
|
| 'default'
|
||||||
|
| 'info'
|
||||||
|
| 'note'
|
||||||
|
| 'success'
|
||||||
|
| 'warning'
|
||||||
|
| 'danger';
|
||||||
|
const validCalloutTypes = [
|
||||||
|
'default',
|
||||||
|
'info',
|
||||||
|
'note',
|
||||||
|
'success',
|
||||||
|
'warning',
|
||||||
|
'danger',
|
||||||
|
];
|
||||||
|
|
||||||
export function getValidCalloutType(value: string): string {
|
export function getValidCalloutType(value: string): string {
|
||||||
if (value) {
|
if (value) {
|
||||||
return validCalloutTypes.includes(value) ? value : "info";
|
return validCalloutTypes.includes(value) ? value : 'info';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||||
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
|
|
||||||
|
export interface ColumnOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnAttributes {
|
||||||
|
width?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
column: {
|
||||||
|
setColumnWidth: (width: number | null) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Column = Node.create<ColumnOptions>({
|
||||||
|
name: "column",
|
||||||
|
group: "block",
|
||||||
|
content: "block+",
|
||||||
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
|
selectable: false,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
width: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const value = element.getAttribute("data-width");
|
||||||
|
return value ? parseFloat(value) : null;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ColumnAttributes) => {
|
||||||
|
if (!attributes.width) return {};
|
||||||
|
return {
|
||||||
|
"data-width": attributes.width,
|
||||||
|
style: `flex: ${attributes.width}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
const jumpToColumn = (direction: 1 | -1) => () => {
|
||||||
|
const { state, dispatch } = this.editor.view;
|
||||||
|
|
||||||
|
const columns = findParentNode(
|
||||||
|
(node) => node.type.name === "columns",
|
||||||
|
)(state.selection);
|
||||||
|
if (!columns) return false;
|
||||||
|
|
||||||
|
const column = findParentNode(
|
||||||
|
(node) => node.type.name === "column",
|
||||||
|
)(state.selection);
|
||||||
|
if (!column) return false;
|
||||||
|
|
||||||
|
let currentIndex = -1;
|
||||||
|
columns.node.forEach((_child, offset, index) => {
|
||||||
|
if (columns.pos + 1 + offset === column.pos) {
|
||||||
|
currentIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const targetIndex = currentIndex + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= columns.node.childCount) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
for (let j = 0; j < targetIndex; j++) {
|
||||||
|
offset += columns.node.child(j).nodeSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPos = columns.pos + 1 + offset + 1 + 1;
|
||||||
|
if (dispatch) {
|
||||||
|
dispatch(
|
||||||
|
state.tr.setSelection(TextSelection.create(state.doc, targetPos)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Tab: jumpToColumn(1),
|
||||||
|
"Shift-Tab": jumpToColumn(-1),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setColumnWidth:
|
||||||
|
(width) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("column", { width }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { Node, mergeAttributes, findParentNode } from "@tiptap/core";
|
||||||
|
import { Fragment, Node as PMNode } from "@tiptap/pm/model";
|
||||||
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
export type ColumnsLayout =
|
||||||
|
| "two_equal"
|
||||||
|
| "two_left_sidebar"
|
||||||
|
| "two_right_sidebar"
|
||||||
|
| "three_equal"
|
||||||
|
| "three_left_wide"
|
||||||
|
| "three_right_wide"
|
||||||
|
| "three_with_sidebars"
|
||||||
|
| "four_equal"
|
||||||
|
| "five_equal";
|
||||||
|
|
||||||
|
export interface ColumnsOptions {
|
||||||
|
HTMLAttributes: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidthMode = "normal" | "wide";
|
||||||
|
|
||||||
|
export interface ColumnsAttributes {
|
||||||
|
layout?: ColumnsLayout;
|
||||||
|
widthMode?: WidthMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
columns: {
|
||||||
|
insertColumns: (attributes?: ColumnsAttributes) => ReturnType;
|
||||||
|
setColumnsWidthMode: (widthMode: WidthMode) => ReturnType;
|
||||||
|
setColumnCount: (count: number) => ReturnType;
|
||||||
|
setColumnsLayout: (layout: ColumnsLayout) => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnCountFromLayout(layout: string): number {
|
||||||
|
if (layout.startsWith("five")) return 5;
|
||||||
|
if (layout.startsWith("four")) return 4;
|
||||||
|
if (layout.startsWith("three")) return 3;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLayoutForCount(count: number): ColumnsLayout {
|
||||||
|
if (count === 3) return "three_equal";
|
||||||
|
if (count === 4) return "four_equal";
|
||||||
|
if (count === 5) return "five_equal";
|
||||||
|
return "two_equal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Columns = Node.create<ColumnsOptions>({
|
||||||
|
name: "columns",
|
||||||
|
group: "block",
|
||||||
|
content: "column+",
|
||||||
|
defining: true,
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
layout: {
|
||||||
|
default: "two_equal",
|
||||||
|
parseHTML: (element) => element.getAttribute("data-layout"),
|
||||||
|
renderHTML: (attributes: ColumnsAttributes) => ({
|
||||||
|
"data-layout": attributes.layout,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
widthMode: {
|
||||||
|
default: "normal",
|
||||||
|
parseHTML: (element) =>
|
||||||
|
element.getAttribute("data-width-mode") || "normal",
|
||||||
|
renderHTML: (attributes: ColumnsAttributes) => {
|
||||||
|
if (!attributes.widthMode || attributes.widthMode === "normal")
|
||||||
|
return {};
|
||||||
|
return { "data-width-mode": attributes.widthMode };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
"div",
|
||||||
|
mergeAttributes(
|
||||||
|
{ "data-type": this.name },
|
||||||
|
this.options.HTMLAttributes,
|
||||||
|
HTMLAttributes,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertColumns:
|
||||||
|
(attributes) =>
|
||||||
|
({ tr, state, dispatch }) => {
|
||||||
|
const layout = attributes?.layout || "two_equal";
|
||||||
|
const count = columnCountFromLayout(layout);
|
||||||
|
|
||||||
|
const columnType = state.schema.nodes.column;
|
||||||
|
const paraType = state.schema.nodes.paragraph;
|
||||||
|
const children = Array.from({ length: count }, () =>
|
||||||
|
columnType.create(null, paraType.create()),
|
||||||
|
);
|
||||||
|
const columnsNode = this.type.create(
|
||||||
|
attributes,
|
||||||
|
Fragment.from(children),
|
||||||
|
);
|
||||||
|
|
||||||
|
const stepsBefore = tr.steps.length;
|
||||||
|
tr.replaceSelectionWith(columnsNode);
|
||||||
|
|
||||||
|
if (tr.steps.length > stepsBefore) {
|
||||||
|
const stepMap = tr.steps[tr.steps.length - 1].getMap();
|
||||||
|
let insertStart = 0;
|
||||||
|
stepMap.forEach((_from, _to, newFrom) => {
|
||||||
|
insertStart = newFrom;
|
||||||
|
});
|
||||||
|
tr.setSelection(
|
||||||
|
TextSelection.near(tr.doc.resolve(insertStart + 1), 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) dispatch(tr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setColumnsWidthMode:
|
||||||
|
(widthMode) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("columns", { widthMode }),
|
||||||
|
|
||||||
|
setColumnCount:
|
||||||
|
(count: number) =>
|
||||||
|
({ tr, state }) => {
|
||||||
|
const predicate = (node: PMNode) => node.type.name === "columns";
|
||||||
|
const parent = findParentNode(predicate)(state.selection);
|
||||||
|
if (!parent) return false;
|
||||||
|
|
||||||
|
const { node: columnsNode, pos: parentPos } = parent;
|
||||||
|
const currentCount = columnsNode.childCount;
|
||||||
|
if (count === currentCount || count < 2 || count > 5) return false;
|
||||||
|
|
||||||
|
const columnType = state.schema.nodes.column;
|
||||||
|
const paraType = state.schema.nodes.paragraph;
|
||||||
|
const newChildren: PMNode[] = [];
|
||||||
|
|
||||||
|
if (count > currentCount) {
|
||||||
|
for (let i = 0; i < currentCount; i++) {
|
||||||
|
newChildren.push(columnsNode.child(i));
|
||||||
|
}
|
||||||
|
for (let i = currentCount; i < count; i++) {
|
||||||
|
newChildren.push(columnType.create(null, paraType.create()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < count - 1; i++) {
|
||||||
|
newChildren.push(columnsNode.child(i));
|
||||||
|
}
|
||||||
|
let mergedContent = columnsNode.child(count - 1).content;
|
||||||
|
for (let j = count; j < currentCount; j++) {
|
||||||
|
const col = columnsNode.child(j);
|
||||||
|
const nonEmpty: PMNode[] = [];
|
||||||
|
col.content.forEach((child) => {
|
||||||
|
if (
|
||||||
|
child.type.name !== "paragraph" ||
|
||||||
|
child.content.size > 0
|
||||||
|
) {
|
||||||
|
nonEmpty.push(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (nonEmpty.length > 0) {
|
||||||
|
mergedContent = mergedContent.append(
|
||||||
|
Fragment.from(nonEmpty),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newChildren.push(columnType.create(null, mergedContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLayout = defaultLayoutForCount(count);
|
||||||
|
const newNode = columnsNode.type.create(
|
||||||
|
{ ...columnsNode.attrs, layout: newLayout },
|
||||||
|
Fragment.from(newChildren),
|
||||||
|
);
|
||||||
|
tr.replaceWith(parentPos, parentPos + columnsNode.nodeSize, newNode);
|
||||||
|
tr.setSelection(
|
||||||
|
TextSelection.near(tr.doc.resolve(parentPos + 1), 1),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
setColumnsLayout:
|
||||||
|
(layout) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("columns", { layout }),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey("columnsFocus"),
|
||||||
|
props: {
|
||||||
|
decorations: (state) => {
|
||||||
|
const parent = findParentNode(
|
||||||
|
(node) => node.type.name === "columns",
|
||||||
|
)(state.selection);
|
||||||
|
if (!parent) return DecorationSet.empty;
|
||||||
|
return DecorationSet.create(state.doc, [
|
||||||
|
Decoration.node(
|
||||||
|
parent.pos,
|
||||||
|
parent.pos + parent.node.nodeSize,
|
||||||
|
{ class: "has-focus" },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { Columns } from "./columns";
|
||||||
|
export type { ColumnsOptions, ColumnsAttributes, ColumnsLayout, WidthMode } from "./columns";
|
||||||
|
export { Column } from "./column";
|
||||||
|
export type { ColumnOptions, ColumnAttributes } from "./column";
|
||||||
@@ -1,15 +1,35 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type DrawioResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface DrawioOptions {
|
export interface DrawioOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: DrawioResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DrawioAttributes {
|
export interface DrawioAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: string;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
drawio: {
|
drawio: {
|
||||||
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
setDrawio: (attributes?: DrawioAttributes) => ReturnType;
|
||||||
|
setDrawioAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
|
setDrawioSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +57,7 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -55,12 +78,30 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: DrawioAttributes) => ({
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
"data-width": attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
|
"data-height": attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -68,6 +109,13 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: DrawioAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -99,7 +147,7 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"img",
|
"img",
|
||||||
@@ -122,13 +170,172 @@ export const Drawio = Node.create<DrawioOptions>({
|
|||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setDrawioAlign:
|
||||||
|
(align) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("drawio", { align }),
|
||||||
|
|
||||||
|
setDrawioSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("drawio", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
if (!node.attrs.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("img");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.alt = node.attrs.title || "";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
import { Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
|
||||||
|
export type ExcalidrawResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ExcalidrawOptions {
|
export interface ExcalidrawOptions {
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: ExcalidrawResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExcalidrawAttributes {
|
export interface ExcalidrawAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: string;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
|
aspectRatio?: number;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
}
|
}
|
||||||
@@ -18,6 +38,8 @@ declare module "@tiptap/core" {
|
|||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
excalidraw: {
|
excalidraw: {
|
||||||
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
setExcalidraw: (attributes?: ExcalidrawAttributes) => ReturnType;
|
||||||
|
setExcalidrawAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
|
setExcalidrawSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,8 +57,10 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
return {
|
return {
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: {
|
||||||
@@ -54,12 +78,30 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
"data-width": attributes.width,
|
"data-width": attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("data-height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
|
"data-height": attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -67,6 +109,13 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
"data-size": attributes.size,
|
"data-size": attributes.size,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
aspectRatio: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => element.getAttribute("data-aspect-ratio"),
|
||||||
|
renderHTML: (attributes: ExcalidrawAttributes) => ({
|
||||||
|
"data-aspect-ratio": attributes.aspectRatio,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -98,7 +147,7 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
mergeAttributes(
|
mergeAttributes(
|
||||||
{ "data-type": this.name },
|
{ "data-type": this.name },
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
HTMLAttributes
|
HTMLAttributes,
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
"img",
|
"img",
|
||||||
@@ -121,13 +170,172 @@ export const Excalidraw = Node.create<ExcalidrawOptions>({
|
|||||||
attrs: attrs,
|
attrs: attrs,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setExcalidrawAlign:
|
||||||
|
(align) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("excalidraw", { align }),
|
||||||
|
|
||||||
|
setExcalidrawSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("excalidraw", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
if (!node.attrs.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("img");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.alt = node.attrs.title || "";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import TiptapHeading, {
|
|||||||
HeadingOptions as TiptapHeadingOptions,
|
HeadingOptions as TiptapHeadingOptions,
|
||||||
} from "@tiptap/extension-heading";
|
} from "@tiptap/extension-heading";
|
||||||
import { mergeAttributes } from "@tiptap/react";
|
import { mergeAttributes } from "@tiptap/react";
|
||||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||||
import { Plugin } from "prosemirror-state";
|
import { Plugin } from "@tiptap/pm/state";
|
||||||
import { copyToClipboard } from "../utils";
|
import { copyToClipboard } from "../utils";
|
||||||
|
|
||||||
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
|
const copyIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M10.616 16.077H7.077q-1.692 0-2.884-1.192T3 12t1.193-2.885t2.884-1.193h3.539v1H7.077q-1.27 0-2.173.904Q4 10.731 4 12t.904 2.173t2.173.904h3.539zM8.5 12.5v-1h7v1zm4.885 3.577v-1h3.538q1.27 0 2.173-.904Q20 13.269 20 12t-.904-2.173t-2.173-.904h-3.538v-1h3.538q1.692 0 2.885 1.192T21 12t-1.193 2.885t-2.884 1.193z"/></svg>`;
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image";
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { mergeAttributes, Range } from "@tiptap/core";
|
import {
|
||||||
|
mergeAttributes,
|
||||||
|
Range,
|
||||||
|
ResizableNodeView,
|
||||||
|
} from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
|
||||||
|
export type ImageResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface ImageOptions extends DefaultImageOptions {
|
export interface ImageOptions extends DefaultImageOptions {
|
||||||
view: any;
|
view: any;
|
||||||
|
resize: ImageResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAttributes {
|
export interface ImageAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,10 +48,11 @@ declare module "@tiptap/core" {
|
|||||||
imageBlock: {
|
imageBlock: {
|
||||||
setImage: (attributes: ImageAttributes) => ReturnType;
|
setImage: (attributes: ImageAttributes) => ReturnType;
|
||||||
setImageAt: (
|
setImageAt: (
|
||||||
attributes: ImageAttributes & { pos: number | Range }
|
attributes: ImageAttributes & { pos: number | Range },
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
setImageWidth: (width: number) => ReturnType;
|
setImageWidth: (width: number) => ReturnType;
|
||||||
|
setImageSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +70,7 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
view: null,
|
view: null,
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -59,12 +84,30 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: ImageAttributes) => ({
|
renderHTML: (attributes: ImageAttributes) => ({
|
||||||
width: attributes.width,
|
width: attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: ImageAttributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
align: {
|
align: {
|
||||||
default: "center",
|
default: "center",
|
||||||
parseHTML: (element) => element.getAttribute("data-align"),
|
parseHTML: (element) => element.getAttribute("data-align"),
|
||||||
@@ -142,16 +185,192 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
setImageWidth:
|
setImageWidth:
|
||||||
(width) =>
|
(width) =>
|
||||||
({ commands }) =>
|
({ commands }) =>
|
||||||
commands.updateAttributes("image", {
|
commands.updateAttributes("image", { width }),
|
||||||
width: `${Math.max(0, Math.min(100, width))}%`,
|
|
||||||
}),
|
setImageSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("image", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
// Fallback to React node view (existing behavior)
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
// If no src yet (placeholder/uploading), use React view for loading UI
|
||||||
|
if (!HTMLAttributes.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
// When the node gets a src, return false from update to force rebuild
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has src — use ResizableNodeView
|
||||||
|
const el = document.createElement("img");
|
||||||
|
|
||||||
|
Object.entries(HTMLAttributes).forEach(([key, value]) => {
|
||||||
|
if (value != null) {
|
||||||
|
switch (key) {
|
||||||
|
case "width":
|
||||||
|
case "height":
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
el.setAttribute(key, String(value));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
el.src = HTMLAttributes.src;
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.alt !== currentNode.attrs.alt) {
|
||||||
|
el.alt = updatedNode.attrs.alt || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update alignment on container
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
// Apply initial alignment
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
// Defer conversion until we can measure the container
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until image loads (official TipTap pattern)
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onload = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
export { IntegrationLink } from "./integration-link";
|
|
||||||
export type {
|
|
||||||
IntegrationLinkOptions,
|
|
||||||
IntegrationLinkAttributes,
|
|
||||||
} from "./integration-link";
|
|
||||||
export {
|
|
||||||
integrationLinkPatterns,
|
|
||||||
matchIntegrationLink,
|
|
||||||
} from "./integration-link-patterns";
|
|
||||||
export type { IntegrationLinkPattern } from "./integration-link-patterns";
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
export type IntegrationLinkPattern = {
|
|
||||||
provider: string;
|
|
||||||
regex: RegExp;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const integrationLinkPatterns: IntegrationLinkPattern[] = [
|
|
||||||
// GitHub PR commit (must be before generic PR pattern)
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)\/commits\/([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitHub PR (with optional /checks, /commits, /files sub-pages)
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitHub issue
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitHub commit
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/commits?\/([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitHub file/blob
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+?)(?:#L(\d+)(?:-L(\d+))?)?$/,
|
|
||||||
},
|
|
||||||
// GitHub pulls list
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/pulls(?:\/.*)?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitHub releases list
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/releases(?:\/.*)?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitHub issues list
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([^\/]+)\/([^\/]+)\/issues(?:\/(?:created_by|assigned)\/[\w.\/-]+)?\/?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitHub repo
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_.]+)\/?$/,
|
|
||||||
},
|
|
||||||
// GitLab commit in MR diff (must be before generic MR pattern)
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)\/diffs\?.*commit_id=([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitLab merge request
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitLab issue
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/(\d+)/,
|
|
||||||
},
|
|
||||||
// GitLab commit
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/commits?\/([a-f0-9]+)/,
|
|
||||||
},
|
|
||||||
// GitLab issues list
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/issues\/?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitLab merge requests list
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/(.+)\/-\/merge_requests\/?(?:\?.*)?$/,
|
|
||||||
},
|
|
||||||
// GitLab project
|
|
||||||
{
|
|
||||||
provider: "gitlab",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/[^\/]+\/([a-zA-Z0-9\-_.]+)\/([a-zA-Z0-9\-_]+)\/?$/,
|
|
||||||
},
|
|
||||||
// Google Docs
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/document\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Sheets
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/spreadsheets\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Slides
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/presentation\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Forms
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/docs\.google\.com\/forms\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Google Drive file
|
|
||||||
{
|
|
||||||
provider: "google_docs",
|
|
||||||
regex: /^https?:\/\/drive\.google\.com\/file\/d\/([\w-]+)/,
|
|
||||||
},
|
|
||||||
// Figma file (design, file, proto, board)
|
|
||||||
{
|
|
||||||
provider: "figma",
|
|
||||||
regex:
|
|
||||||
/^https?:\/\/([\w.-]+\.)?figma\.com\/(file|proto|board|design)\/([0-9a-zA-Z]{22,128})/,
|
|
||||||
},
|
|
||||||
// Jira (cloud + server): /browse/KEY-123
|
|
||||||
{
|
|
||||||
provider: "jira",
|
|
||||||
regex: /^https?:\/\/[^\/]+\/browse\/([A-Z][A-Z0-9]+-\d+)/,
|
|
||||||
},
|
|
||||||
// Linear issue: /team/issue/KEY-123(/:title-slug)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/issue\/([A-Z]+-\d+)/,
|
|
||||||
},
|
|
||||||
// Linear project: /team/project/:slug(/:tab)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/project\/([^\/]+)/,
|
|
||||||
},
|
|
||||||
// Linear initiative: /team/initiative/:slug(/:tab)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/initiative\/([^\/]+)/,
|
|
||||||
},
|
|
||||||
// Linear view: /team/view/:id(/:tab)?
|
|
||||||
{
|
|
||||||
provider: "linear",
|
|
||||||
regex: /^https?:\/\/linear\.app\/([^\/]+)\/view\/([^\/]+)/,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function matchIntegrationLink(
|
|
||||||
url: string,
|
|
||||||
): { provider: string; match: RegExpMatchArray } | null {
|
|
||||||
for (const pattern of integrationLinkPatterns) {
|
|
||||||
const match = url.match(pattern.regex);
|
|
||||||
if (match) {
|
|
||||||
return { provider: pattern.provider, match };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Node, mergeAttributes } from "@tiptap/core";
|
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
import { sanitizeUrl } from "../utils";
|
|
||||||
|
|
||||||
export interface IntegrationLinkOptions {
|
|
||||||
HTMLAttributes: Record<string, any>;
|
|
||||||
view: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IntegrationLinkAttributes {
|
|
||||||
url: string;
|
|
||||||
provider: string;
|
|
||||||
unfurlData: Record<string, any> | null;
|
|
||||||
status: "pending" | "loaded" | "error";
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
|
||||||
interface Commands<ReturnType> {
|
|
||||||
integrationLink: {
|
|
||||||
setIntegrationLink: (
|
|
||||||
attributes: Partial<IntegrationLinkAttributes>,
|
|
||||||
) => ReturnType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IntegrationLink = Node.create<IntegrationLinkOptions>({
|
|
||||||
name: "integrationLink",
|
|
||||||
inline: false,
|
|
||||||
group: "block",
|
|
||||||
isolating: true,
|
|
||||||
atom: true,
|
|
||||||
defining: true,
|
|
||||||
draggable: true,
|
|
||||||
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
HTMLAttributes: {},
|
|
||||||
view: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
url: {
|
|
||||||
default: "",
|
|
||||||
parseHTML: (element) => {
|
|
||||||
const url = element.getAttribute("data-url");
|
|
||||||
return sanitizeUrl(url);
|
|
||||||
},
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-url": sanitizeUrl(attributes.url),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
default: "",
|
|
||||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-provider": attributes.provider,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
unfurlData: {
|
|
||||||
default: null,
|
|
||||||
parseHTML: (element) => {
|
|
||||||
const data = element.getAttribute("data-unfurl");
|
|
||||||
if (!data) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-unfurl": attributes.unfurlData
|
|
||||||
? JSON.stringify(attributes.unfurlData)
|
|
||||||
: null,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
default: "pending",
|
|
||||||
parseHTML: (element) => element.getAttribute("data-status") ?? "pending",
|
|
||||||
renderHTML: (attributes: IntegrationLinkAttributes) => ({
|
|
||||||
"data-status": attributes.status,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: `div[data-type="${this.name}"]`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
const url = HTMLAttributes["data-url"];
|
|
||||||
const safeUrl = sanitizeUrl(url);
|
|
||||||
|
|
||||||
return [
|
|
||||||
"div",
|
|
||||||
mergeAttributes(
|
|
||||||
{ "data-type": this.name },
|
|
||||||
this.options.HTMLAttributes,
|
|
||||||
HTMLAttributes,
|
|
||||||
),
|
|
||||||
["a", { href: safeUrl, target: "_blank", rel: "noopener" }, safeUrl],
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addCommands() {
|
|
||||||
return {
|
|
||||||
setIntegrationLink:
|
|
||||||
(attrs) =>
|
|
||||||
({ commands }) => {
|
|
||||||
return commands.insertContent({
|
|
||||||
type: this.name,
|
|
||||||
attrs: {
|
|
||||||
...attrs,
|
|
||||||
url: sanitizeUrl(attrs.url),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
addNodeView() {
|
|
||||||
this.editor.isInitialized = true;
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
import { Range, Node } from "@tiptap/core";
|
import { Range, Node, mergeAttributes, ResizableNodeView } from "@tiptap/core";
|
||||||
|
import type { ResizableNodeViewDirection } from "@tiptap/core";
|
||||||
|
|
||||||
|
export type VideoResizeOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
directions?: ResizableNodeViewDirection[];
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
alwaysPreserveAspectRatio?: boolean;
|
||||||
|
createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement;
|
||||||
|
className?: {
|
||||||
|
container?: string;
|
||||||
|
wrapper?: string;
|
||||||
|
handle?: string;
|
||||||
|
resizing?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface VideoOptions {
|
export interface VideoOptions {
|
||||||
view: any;
|
view: any;
|
||||||
HTMLAttributes: Record<string, any>;
|
HTMLAttributes: Record<string, any>;
|
||||||
|
resize: VideoResizeOptions | false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VideoAttributes {
|
export interface VideoAttributes {
|
||||||
src?: string;
|
src?: string;
|
||||||
align?: string;
|
align?: string;
|
||||||
attachmentId?: string;
|
attachmentId?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number | string;
|
||||||
|
height?: number;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,6 +46,7 @@ declare module "@tiptap/core" {
|
|||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
|
setVideoAlign: (align: "left" | "center" | "right") => ReturnType;
|
||||||
setVideoWidth: (width: number) => ReturnType;
|
setVideoWidth: (width: number) => ReturnType;
|
||||||
|
setVideoSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +64,7 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
return {
|
return {
|
||||||
view: null,
|
view: null,
|
||||||
HTMLAttributes: {},
|
HTMLAttributes: {},
|
||||||
|
resize: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -64,12 +85,30 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
default: "100%",
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("width"),
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("width");
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.endsWith("%")) return raw;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
renderHTML: (attributes: VideoAttributes) => ({
|
renderHTML: (attributes: VideoAttributes) => ({
|
||||||
width: attributes.width,
|
width: attributes.width,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const raw = element.getAttribute("height");
|
||||||
|
if (!raw) return null;
|
||||||
|
const num = parseFloat(raw);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
},
|
||||||
|
renderHTML: (attributes: VideoAttributes) => ({
|
||||||
|
height: attributes.height,
|
||||||
|
}),
|
||||||
|
},
|
||||||
size: {
|
size: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: (element) => element.getAttribute("data-size"),
|
parseHTML: (element) => element.getAttribute("data-size"),
|
||||||
@@ -136,13 +175,168 @@ export const TiptapVideo = Node.create<VideoOptions>({
|
|||||||
commands.updateAttributes("video", {
|
commands.updateAttributes("video", {
|
||||||
width: `${Math.max(0, Math.min(100, width))}%`,
|
width: `${Math.max(0, Math.min(100, width))}%`,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setVideoSize:
|
||||||
|
(width, height) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.updateAttributes("video", { width, height }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
// Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191)
|
const resize = this.options.resize;
|
||||||
this.editor.isInitialized = true;
|
|
||||||
|
|
||||||
return ReactNodeViewRenderer(this.options.view);
|
if (!resize || !resize.enabled) {
|
||||||
|
this.editor.isInitialized = true;
|
||||||
|
return ReactNodeViewRenderer(this.options.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
directions,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
alwaysPreserveAspectRatio,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
} = resize;
|
||||||
|
|
||||||
|
return (props) => {
|
||||||
|
const { node, getPos, HTMLAttributes, editor } = props;
|
||||||
|
|
||||||
|
if (!node.attrs.src) {
|
||||||
|
editor.isInitialized = true;
|
||||||
|
const reactView = ReactNodeViewRenderer(this.options.view);
|
||||||
|
const view = reactView(props);
|
||||||
|
|
||||||
|
const originalUpdate = view.update?.bind(view);
|
||||||
|
view.update = (updatedNode, decorations, innerDecorations) => {
|
||||||
|
if (updatedNode.attrs.src && !node.attrs.src) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalUpdate) {
|
||||||
|
return originalUpdate(updatedNode, decorations, innerDecorations);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = document.createElement("video");
|
||||||
|
el.src = node.attrs.src;
|
||||||
|
el.controls = true;
|
||||||
|
el.preload = "metadata";
|
||||||
|
el.style.display = "block";
|
||||||
|
el.style.maxWidth = "100%";
|
||||||
|
el.style.borderRadius = "8px";
|
||||||
|
|
||||||
|
let currentNode = node;
|
||||||
|
|
||||||
|
const nodeView = new ResizableNodeView({
|
||||||
|
element: el,
|
||||||
|
editor,
|
||||||
|
node,
|
||||||
|
getPos,
|
||||||
|
onResize: (w, h) => {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
},
|
||||||
|
onCommit: () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos === undefined) return;
|
||||||
|
|
||||||
|
this.editor
|
||||||
|
.chain()
|
||||||
|
.setNodeSelection(pos)
|
||||||
|
.updateAttributes(this.name, {
|
||||||
|
width: Math.round(el.offsetWidth),
|
||||||
|
height: Math.round(el.offsetHeight),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
onUpdate: (updatedNode, _decorations, _innerDecorations) => {
|
||||||
|
if (updatedNode.type !== currentNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedNode.attrs.src !== currentNode.attrs.src) {
|
||||||
|
el.src = updatedNode.attrs.src || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = updatedNode.attrs.width;
|
||||||
|
const h = updatedNode.attrs.height;
|
||||||
|
if (w != null) {
|
||||||
|
el.style.width = `${w}px`;
|
||||||
|
}
|
||||||
|
if (h != null) {
|
||||||
|
el.style.height = `${h}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const align = updatedNode.attrs.align || "center";
|
||||||
|
const container = nodeView.dom as HTMLElement;
|
||||||
|
applyAlignment(container, align);
|
||||||
|
|
||||||
|
currentNode = updatedNode;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
directions,
|
||||||
|
min: {
|
||||||
|
width: minWidth,
|
||||||
|
height: minHeight,
|
||||||
|
},
|
||||||
|
preserveAspectRatio: alwaysPreserveAspectRatio === true,
|
||||||
|
createCustomHandle,
|
||||||
|
className,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dom = nodeView.dom as HTMLElement;
|
||||||
|
|
||||||
|
applyAlignment(dom, node.attrs.align || "center");
|
||||||
|
|
||||||
|
// Handle percentage width backward compat
|
||||||
|
const widthAttr = node.attrs.width;
|
||||||
|
if (typeof widthAttr === "string" && widthAttr.endsWith("%")) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const parentEl = dom.parentElement;
|
||||||
|
if (parentEl) {
|
||||||
|
const containerWidth = parentEl.clientWidth;
|
||||||
|
const pctValue = parseInt(widthAttr, 10);
|
||||||
|
if (!isNaN(pctValue) && containerWidth > 0) {
|
||||||
|
const pxWidth = Math.round(
|
||||||
|
containerWidth * (pctValue / 100),
|
||||||
|
);
|
||||||
|
el.style.width = `${pxWidth}px`;
|
||||||
|
if (node.attrs.aspectRatio) {
|
||||||
|
el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide until video metadata loads
|
||||||
|
dom.style.visibility = "hidden";
|
||||||
|
dom.style.pointerEvents = "none";
|
||||||
|
el.onloadedmetadata = () => {
|
||||||
|
dom.style.visibility = "";
|
||||||
|
dom.style.pointerEvents = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return nodeView;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
|
if (align === "left") {
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "right") {
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else {
|
||||||
|
container.style.justifyContent = "center";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||||
|
index 01d6999642c5ae990083798a1bf0ef87068e4192..891b13c6901f28a6ab413c6dbae0ea726a76a196 100644
|
||||||
|
--- a/dist/index.cjs
|
||||||
|
+++ b/dist/index.cjs
|
||||||
|
@@ -5463,7 +5463,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
};
|
||||||
|
@@ -5593,7 +5596,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
this.isResizing = false;
|
||||||
|
@@ -5796,6 +5802,8 @@ var ResizableNodeView = class {
|
||||||
|
document.addEventListener("mousemove", this.handleMouseMove);
|
||||||
|
document.addEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.addEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.addEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.addEventListener("blur", this.handleMouseUp);
|
||||||
|
document.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.addEventListener("keyup", this.handleKeyUp);
|
||||||
|
}
|
||||||
|
diff --git a/dist/index.js b/dist/index.js
|
||||||
|
index 6f357a03b038abeb5ed86967b7fc7c3e5eb1d2d6..2d2742532860821984e1ba82625821504538ebbe 100644
|
||||||
|
--- a/dist/index.js
|
||||||
|
+++ b/dist/index.js
|
||||||
|
@@ -5330,7 +5330,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
};
|
||||||
|
@@ -5460,7 +5463,10 @@ var ResizableNodeView = class {
|
||||||
|
this.container.classList.remove(this.classNames.resizing);
|
||||||
|
}
|
||||||
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
|
+ document.removeEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.removeEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.removeEventListener("blur", this.handleMouseUp);
|
||||||
|
document.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.removeEventListener("keyup", this.handleKeyUp);
|
||||||
|
this.isResizing = false;
|
||||||
|
@@ -5663,6 +5669,8 @@ var ResizableNodeView = class {
|
||||||
|
document.addEventListener("mousemove", this.handleMouseMove);
|
||||||
|
document.addEventListener("touchmove", this.handleTouchMove);
|
||||||
|
document.addEventListener("mouseup", this.handleMouseUp);
|
||||||
|
+ document.addEventListener("touchend", this.handleMouseUp);
|
||||||
|
+ window.addEventListener("blur", this.handleMouseUp);
|
||||||
|
document.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
document.addEventListener("keyup", this.handleKeyUp);
|
||||||
|
}
|
||||||
|
diff --git a/src/lib/ResizableNodeView.ts b/src/lib/ResizableNodeView.ts
|
||||||
|
index f13e210b0aa46aefe7c31105deee3d2aa8a26cd5..9bac138dbf17c6ae6c3c129cbedb3a81bd39b60c 100644
|
||||||
|
--- a/src/lib/ResizableNodeView.ts
|
||||||
|
+++ b/src/lib/ResizableNodeView.ts
|
||||||
|
@@ -523,7 +523,10 @@ export class ResizableNodeView {
|
||||||
|
}
|
||||||
|
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||||
|
+ document.removeEventListener('touchmove', this.handleTouchMove)
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp)
|
||||||
|
+ document.removeEventListener('touchend', this.handleMouseUp)
|
||||||
|
+ window.removeEventListener('blur', this.handleMouseUp)
|
||||||
|
document.removeEventListener('keydown', this.handleKeyDown)
|
||||||
|
document.removeEventListener('keyup', this.handleKeyUp)
|
||||||
|
this.isResizing = false
|
||||||
|
@@ -774,6 +777,8 @@ export class ResizableNodeView {
|
||||||
|
document.addEventListener('mousemove', this.handleMouseMove)
|
||||||
|
document.addEventListener('touchmove', this.handleTouchMove)
|
||||||
|
document.addEventListener('mouseup', this.handleMouseUp)
|
||||||
|
+ document.addEventListener('touchend', this.handleMouseUp)
|
||||||
|
+ window.addEventListener('blur', this.handleMouseUp)
|
||||||
|
document.addEventListener('keydown', this.handleKeyDown)
|
||||||
|
document.addEventListener('keyup', this.handleKeyUp)
|
||||||
|
}
|
||||||
|
@@ -859,7 +864,10 @@ export class ResizableNodeView {
|
||||||
|
|
||||||
|
// Clean up document-level listeners
|
||||||
|
document.removeEventListener('mousemove', this.handleMouseMove)
|
||||||
|
+ document.removeEventListener('touchmove', this.handleTouchMove)
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseUp)
|
||||||
|
+ document.removeEventListener('touchend', this.handleMouseUp)
|
||||||
|
+ window.removeEventListener('blur', this.handleMouseUp)
|
||||||
|
document.removeEventListener('keydown', this.handleKeyDown)
|
||||||
|
document.removeEventListener('keyup', this.handleKeyUp)
|
||||||
|
}
|
||||||
Generated
+1538
-1570
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user