mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat: editor UI refresh and enhancements (#1968)
* feat: new image menu * switch to resizable side handles * use pixels * refactor excalidraw and drawio menu * support image resize undo * video resize * callout menu refresh * refresh table menus * fix color scheme * fix: patch @tiptap/core ResizableNodeView to prevent resize sticking after mouseup * feat: columns * notes callout * focus on first column * capture tab key in column * fix print * hide columns menu when some nodes are focused * fix print * fix columns * selective placeholder * fix blockquote * quote * fix callout in columns
This commit is contained in:
@@ -274,6 +274,7 @@
|
||||
"Add row below": "Add row below",
|
||||
"Delete table": "Delete table",
|
||||
"Info": "Info",
|
||||
"Note": "Note",
|
||||
"Success": "Success",
|
||||
"Warning": "Warning",
|
||||
"Danger": "Danger",
|
||||
@@ -363,6 +364,15 @@
|
||||
"Heading {{level}}": "Heading {{level}}",
|
||||
"Toggle title": "Toggle title",
|
||||
"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",
|
||||
"Today, {{time}}": "Today, {{time}}",
|
||||
"Yesterday, {{time}}": "Yesterday, {{time}}",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,16 +7,19 @@ import {
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconInfoCircleFilled,
|
||||
IconMoodSmile,
|
||||
IconNotes,
|
||||
} from "@tabler/icons-react";
|
||||
import { CalloutType } from "@docmost/editor-ext";
|
||||
import { CalloutType, isTextSelected } from "@docmost/editor-ext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,6 +29,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
if (isTextSelected(editor)) return false;
|
||||
|
||||
return editor.isActive("callout");
|
||||
},
|
||||
@@ -42,6 +46,7 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
return {
|
||||
isCallout: ctx.editor.isActive("callout"),
|
||||
isInfo: ctx.editor.isActive("callout", { type: "info" }),
|
||||
isNote: ctx.editor.isActive("callout", { type: "note" }),
|
||||
isSuccess: ctx.editor.isActive("callout", { type: "success" }),
|
||||
isWarning: ctx.editor.isActive("callout", { type: "warning" }),
|
||||
isDanger: ctx.editor.isActive("callout", { type: "danger" }),
|
||||
@@ -126,15 +131,31 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group className="actionIconGroup">
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Info")}>
|
||||
<ActionIcon
|
||||
onClick={() => setCalloutType("info")}
|
||||
size="lg"
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
@@ -143,9 +164,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
onClick={() => setCalloutType("success")}
|
||||
size="lg"
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
@@ -154,9 +179,13 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
onClick={() => setCalloutType("warning")}
|
||||
size="lg"
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
@@ -165,9 +194,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
onClick={() => setCalloutType("danger")}
|
||||
size="lg"
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
@@ -178,11 +208,10 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
icon={currentIcon || <IconMoodSmile size={18} />}
|
||||
actionIconProps={{
|
||||
size: "lg",
|
||||
variant: "default",
|
||||
c: undefined,
|
||||
variant: "subtle",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon.Group>
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IconCircleCheckFilled,
|
||||
IconCircleXFilled,
|
||||
IconInfoCircleFilled,
|
||||
IconNotes,
|
||||
} from "@tabler/icons-react";
|
||||
import { Alert } from "@mantine/core";
|
||||
import classes from "./callout.module.css";
|
||||
@@ -22,6 +23,7 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
icon={getCalloutIcon(type, icon)}
|
||||
p="xs"
|
||||
classNames={{
|
||||
root: classes.root,
|
||||
message: classes.message,
|
||||
icon: classes.icon,
|
||||
}}
|
||||
@@ -34,12 +36,14 @@ export default function CalloutView(props: NodeViewProps) {
|
||||
|
||||
function getCalloutIcon(type: CalloutType, customIcon?: string) {
|
||||
if (customIcon && customIcon.trim() !== "") {
|
||||
return <span style={{ fontSize: '18px' }}>{customIcon}</span>;
|
||||
return <span style={{ fontSize: "18px" }}>{customIcon}</span>;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "info":
|
||||
return <IconInfoCircleFilled />;
|
||||
case "note":
|
||||
return <IconNotes />;
|
||||
case "success":
|
||||
return <IconCircleCheckFilled />;
|
||||
case "warning":
|
||||
@@ -55,6 +59,8 @@ function getCalloutColor(type: CalloutType) {
|
||||
switch (type) {
|
||||
case "info":
|
||||
return "blue";
|
||||
case "note":
|
||||
return "grape";
|
||||
case "success":
|
||||
return "green";
|
||||
case "warning":
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
.root {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-inline-end: var(--mantine-spacing-md);
|
||||
margin-inline-end: var(--mantine-spacing-xs);
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -11,18 +15,8 @@
|
||||
.message {
|
||||
font-size: var(--mantine-font-size-md);
|
||||
color: var(--mantine-color-default-color);
|
||||
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
word-break: 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,267 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Node as PMNode } from "prosemirror-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,
|
||||
} 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 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 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>
|
||||
</BaseBubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnsMenu;
|
||||
@@ -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 { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} 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) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return editor.isActive("drawio") && editor.getAttributes("drawio")?.src;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [initialXML, setInitialXML] = useState<string>("");
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -30,11 +47,26 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const drawioAttr = ctx.editor.getAttributes("drawio");
|
||||
return {
|
||||
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(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
@@ -57,38 +89,218 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes("drawio", { width: `${value}%` });
|
||||
const alignLeft = useCallback(() => {
|
||||
editor
|
||||
.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 (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
<>
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
<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>
|
||||
|
||||
<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 {
|
||||
ActionIcon,
|
||||
Card,
|
||||
Image,
|
||||
Modal,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
import { useRef, useState } from "react";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||
import { getDrawioUrl } from "@/lib/config.ts";
|
||||
import {
|
||||
DrawIoEmbed,
|
||||
DrawIoEmbedRef,
|
||||
@@ -26,7 +25,7 @@ import { useTranslation } from "react-i18next";
|
||||
export default function DrawioView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
const { attachmentId } = node.attrs;
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
const [initialXML, setInitialXML] = useState<string>("");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
@@ -36,33 +35,11 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSave = async (data: EventSave) => {
|
||||
const svgString = decodeBase64ToSvgString(data.xml);
|
||||
|
||||
const fileName = "diagram.drawio.svg";
|
||||
const drawioSVGFile = await svgStringToFile(svgString, fileName);
|
||||
|
||||
@@ -70,7 +47,6 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
const pageId = editor.storage?.pageId;
|
||||
|
||||
let attachment: IAttachment = null;
|
||||
|
||||
if (attachmentId) {
|
||||
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
|
||||
} else {
|
||||
@@ -106,14 +82,12 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
noSaveBtn: true,
|
||||
}}
|
||||
onSave={(data: EventSave) => {
|
||||
// If the save is triggered by another event, then do nothing
|
||||
if (data.parentEvent !== "save") {
|
||||
return;
|
||||
}
|
||||
handleSave(data);
|
||||
}}
|
||||
onClose={(data: EventExit) => {
|
||||
// If the exit is triggered by another event, then do nothing
|
||||
if (data.parentEvent) {
|
||||
return;
|
||||
}
|
||||
@@ -125,62 +99,28 @@ export default function DrawioView(props: NodeViewProps) {
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? "ProseMirror-selectednode" : "",
|
||||
"alignCenter",
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{selected && editor.isEditable && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Double-click to edit Draw.io diagram")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
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 {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} 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) {
|
||||
const shouldShow = useCallback(
|
||||
({ state }: ShouldShowProps) => {
|
||||
if (!state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
editor.isActive("excalidraw") && editor.getAttributes("excalidraw")?.src
|
||||
);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
useHandleLibrary({
|
||||
excalidrawAPI,
|
||||
adapter: localStorageLibraryAdapter,
|
||||
});
|
||||
const [excalidrawData, setExcalidrawData] = useState<any>(null);
|
||||
const computedColorScheme = useComputedColorScheme();
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -32,11 +63,29 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const excalidrawAttr = ctx.editor.getAttributes("excalidraw");
|
||||
return {
|
||||
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(() => {
|
||||
if (!editor) return;
|
||||
const { selection } = editor.state;
|
||||
@@ -59,38 +108,248 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor.commands.updateAttributes("excalidraw", { width: `${value}%` });
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const alignLeft = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.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 (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<div
|
||||
<>
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`excalidraw-menu`}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "top",
|
||||
offset: 8,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<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={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
padding: 0,
|
||||
zIndex: 200,
|
||||
}}
|
||||
isOpen={opened}
|
||||
onRequestClose={close}
|
||||
disableCloseOnBgClick={true}
|
||||
contentProps={{
|
||||
style: {
|
||||
padding: 0,
|
||||
width: "90vw",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
</div>
|
||||
</BaseBubbleMenu>
|
||||
<Group
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
bg="var(--mantine-color-body)"
|
||||
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,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Text,
|
||||
useComputedColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { uploadFile } from "@/features/page/services/page-service.ts";
|
||||
import { svgStringToFile } from "@/lib";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
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 clsx from "clsx";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { lazy } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
|
||||
|
||||
const Excalidraw = lazy(() =>
|
||||
const ExcalidrawComponent = lazy(() =>
|
||||
import("@excalidraw/excalidraw").then((module) => ({
|
||||
default: module.Excalidraw,
|
||||
})),
|
||||
@@ -34,7 +30,7 @@ const Excalidraw = lazy(() =>
|
||||
export default function ExcalidrawView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, updateAttributes, editor, selected } = props;
|
||||
const { src, title, width, attachmentId } = node.attrs;
|
||||
const { attachmentId } = node.attrs;
|
||||
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
@@ -50,25 +46,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
open();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -151,7 +129,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
</Group>
|
||||
<div style={{ height: "90vh" }}>
|
||||
<Suspense fallback={null}>
|
||||
<Excalidraw
|
||||
<ExcalidrawComponent
|
||||
excalidrawAPI={(api) => setExcalidrawAPI(api)}
|
||||
initialData={{
|
||||
...excalidrawData,
|
||||
@@ -163,62 +141,28 @@ export default function ExcalidrawView(props: NodeViewProps) {
|
||||
</div>
|
||||
</ReactClearModal>
|
||||
|
||||
{src ? (
|
||||
<div style={{ position: "relative" }}>
|
||||
<Image
|
||||
onClick={(e) => e.detail === 2 && handleOpen()}
|
||||
radius="md"
|
||||
fit="contain"
|
||||
w={width}
|
||||
src={getFileUrl(src)}
|
||||
alt={title}
|
||||
className={clsx(
|
||||
selected ? "ProseMirror-selectednode" : "",
|
||||
"alignCenter",
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{selected && editor.isEditable && (
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
variant="default"
|
||||
color="gray"
|
||||
mx="xs"
|
||||
className="print-hide"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text component="span" size="lg" c="dimmed">
|
||||
{t("Double-click to edit Excalidraw diagram")}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
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 {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||
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();
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setImageWidth(value)
|
||||
.run();
|
||||
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 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],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.commands.deleteSelection();
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
@@ -118,13 +148,14 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group className="actionIconGroup">
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<ActionIcon
|
||||
onClick={alignImageLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
@@ -135,7 +166,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
onClick={alignImageCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
@@ -146,16 +178,56 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
onClick={alignImageRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
<div className={classes.divider} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
IconCalendar,
|
||||
IconAppWindow,
|
||||
IconSitemap,
|
||||
IconColumns3,
|
||||
IconColumns2,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -31,6 +33,8 @@ import { uploadAttachmentAction } from "@/features/editor/components/attachment/
|
||||
import IconExcalidraw from "@/components/icons/icon-excalidraw";
|
||||
import IconMermaid from "@/components/icons/icon-mermaid";
|
||||
import IconDrawio from "@/components/icons/icon-drawio";
|
||||
import { IconColumns4 } from "@/components/icons/icon-columns-4";
|
||||
import { IconColumns5 } from "@/components/icons/icon-columns-5";
|
||||
import {
|
||||
AirtableIcon,
|
||||
FigmaIcon,
|
||||
@@ -390,6 +394,58 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
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",
|
||||
description: "Embed any Iframe",
|
||||
|
||||
@@ -95,7 +95,7 @@ export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Background color")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Background color")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TableBackgroundColor } from "./table-background-color";
|
||||
import { TableTextAlignment } from "./table-text-alignment";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export const TableCellMenu = React.memo(
|
||||
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
|
||||
@@ -69,14 +70,16 @@ export const TableCellMenu = React.memo(
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<div className={classes.toolbar}>
|
||||
<TableBackgroundColor editor={editor} />
|
||||
<TableTextAlignment editor={editor} />
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Merge cells")}>
|
||||
<ActionIcon
|
||||
onClick={mergeCells}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Merge cells")}
|
||||
>
|
||||
@@ -87,7 +90,7 @@ export const TableCellMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Split cell")}>
|
||||
<ActionIcon
|
||||
onClick={splitCell}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Split cell")}
|
||||
>
|
||||
@@ -95,10 +98,12 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Delete column")}>
|
||||
<ActionIcon
|
||||
onClick={deleteColumn}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete column")}
|
||||
>
|
||||
@@ -109,7 +114,7 @@ export const TableCellMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Delete row")}>
|
||||
<ActionIcon
|
||||
onClick={deleteRow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete row")}
|
||||
>
|
||||
@@ -117,17 +122,19 @@ export const TableCellMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header cell")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderCell}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header cell")}
|
||||
>
|
||||
<IconTableRow size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
IconTrashX,
|
||||
} from "@tabler/icons-react";
|
||||
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 classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export const TableMenu = React.memo(
|
||||
({ editor }: EditorMenuProps): JSX.Element => {
|
||||
@@ -30,6 +31,7 @@ export const TableMenu = React.memo(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTextSelected(editor)) return false;
|
||||
return editor.isActive("table") && !isCellSelection(state.selection);
|
||||
},
|
||||
[editor]
|
||||
@@ -118,11 +120,11 @@ export const TableMenu = React.memo(
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group>
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Add left column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnLeft}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add left column")}
|
||||
>
|
||||
@@ -133,7 +135,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Add right column")}>
|
||||
<ActionIcon
|
||||
onClick={addColumnRight}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add right column")}
|
||||
>
|
||||
@@ -144,7 +146,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Delete column")}>
|
||||
<ActionIcon
|
||||
onClick={deleteColumn}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete column")}
|
||||
>
|
||||
@@ -152,10 +154,12 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Add row above")}>
|
||||
<ActionIcon
|
||||
onClick={addRowAbove}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add row above")}
|
||||
>
|
||||
@@ -166,7 +170,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Add row below")}>
|
||||
<ActionIcon
|
||||
onClick={addRowBelow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Add row below")}
|
||||
>
|
||||
@@ -177,7 +181,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Delete row")}>
|
||||
<ActionIcon
|
||||
onClick={deleteRow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Delete row")}
|
||||
>
|
||||
@@ -185,10 +189,12 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Toggle header row")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderRow}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header row")}
|
||||
>
|
||||
@@ -199,7 +205,7 @@ export const TableMenu = React.memo(
|
||||
<Tooltip position="top" label={t("Toggle header column")}>
|
||||
<ActionIcon
|
||||
onClick={toggleHeaderColumn}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Toggle header column")}
|
||||
>
|
||||
@@ -207,18 +213,19 @@ export const TableMenu = React.memo(
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Delete table")}>
|
||||
<ActionIcon
|
||||
onClick={deleteTable}
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
color="red"
|
||||
aria-label={t("Delete table")}
|
||||
>
|
||||
<IconTrashX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text alignment")} withArrow>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("Text alignment")}
|
||||
onClick={() => setOpened(!opened)}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { Node as PMNode } from "prosemirror-model";
|
||||
import {
|
||||
EditorMenuProps,
|
||||
ShouldShowProps,
|
||||
} from "@/features/editor/components/table/types/types.ts";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
IconLayoutAlignCenter,
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -32,7 +36,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
isAlignLeft: ctx.editor.isActive("video", { align: "left" }),
|
||||
isAlignCenter: ctx.editor.isActive("video", { align: "center" }),
|
||||
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]);
|
||||
|
||||
const alignVideoLeft = useCallback(() => {
|
||||
const alignLeft = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
@@ -78,7 +82,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoCenter = useCallback(() => {
|
||||
const alignCenter = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
@@ -86,7 +90,7 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const alignVideoRight = useCallback(() => {
|
||||
const alignRight = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
@@ -94,16 +98,18 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
.run();
|
||||
}, [editor]);
|
||||
|
||||
const onWidthChange = useCallback(
|
||||
(value: number) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.setVideoWidth(value)
|
||||
.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]);
|
||||
|
||||
return (
|
||||
<BaseBubbleMenu
|
||||
@@ -118,13 +124,14 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
<ActionIcon.Group className="actionIconGroup">
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Align left")}>
|
||||
<ActionIcon
|
||||
onClick={alignVideoLeft}
|
||||
onClick={alignLeft}
|
||||
size="lg"
|
||||
aria-label={t("Align left")}
|
||||
variant={editorState?.isAlignLeft ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignLeft })}
|
||||
>
|
||||
<IconLayoutAlignLeft size={18} />
|
||||
</ActionIcon>
|
||||
@@ -132,10 +139,11 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<Tooltip position="top" label={t("Align center")}>
|
||||
<ActionIcon
|
||||
onClick={alignVideoCenter}
|
||||
onClick={alignCenter}
|
||||
size="lg"
|
||||
aria-label={t("Align center")}
|
||||
variant={editorState?.isAlignCenter ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignCenter })}
|
||||
>
|
||||
<IconLayoutAlignCenter size={18} />
|
||||
</ActionIcon>
|
||||
@@ -143,19 +151,40 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<Tooltip position="top" label={t("Align right")}>
|
||||
<ActionIcon
|
||||
onClick={alignVideoRight}
|
||||
onClick={alignRight}
|
||||
size="lg"
|
||||
aria-label={t("Align right")}
|
||||
variant={editorState?.isAlignRight ? "light" : "default"}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isAlignRight })}
|
||||
>
|
||||
<IconLayoutAlignRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon.Group>
|
||||
|
||||
{editorState?.width && (
|
||||
<NodeWidthResize onChange={onWidthChange} value={editorState.width} />
|
||||
)}
|
||||
<div className={classes.divider} />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
Highlight,
|
||||
UniqueID,
|
||||
SharedStorage,
|
||||
Columns,
|
||||
Column,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -52,6 +54,14 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.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 VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
@@ -91,6 +101,7 @@ lowlight.register("fortran", fortran);
|
||||
lowlight.register("haskell", haskell);
|
||||
lowlight.register("scala", scala);
|
||||
|
||||
// @ts-ignore
|
||||
export const mainExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
@@ -115,7 +126,7 @@ export const mainExtensions = [
|
||||
filterTransaction: (transaction) => !isChangeOrigin(transaction),
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
placeholder: ({ editor, node, pos }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return i18n.t("Heading {{level}}", { level: node.attrs.level });
|
||||
}
|
||||
@@ -123,6 +134,17 @@ export const mainExtensions = [
|
||||
return i18n.t("Toggle title");
|
||||
}
|
||||
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');
|
||||
}
|
||||
},
|
||||
@@ -200,9 +222,29 @@ export const mainExtensions = [
|
||||
TiptapImage.configure({
|
||||
view: ImageView,
|
||||
allowBase64: false,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createImageHandle,
|
||||
className: imageResizeClasses,
|
||||
},
|
||||
}),
|
||||
TiptapVideo.configure({
|
||||
view: VideoView,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-video"),
|
||||
},
|
||||
}),
|
||||
Callout.configure({
|
||||
view: CalloutView,
|
||||
@@ -221,9 +263,29 @@ export const mainExtensions = [
|
||||
}),
|
||||
Drawio.configure({
|
||||
view: DrawioView,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-drawio"),
|
||||
},
|
||||
}),
|
||||
Excalidraw.configure({
|
||||
view: ExcalidrawView,
|
||||
resize: {
|
||||
enabled: true,
|
||||
directions: ["left", "right"],
|
||||
minWidth: 80,
|
||||
minHeight: 40,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
//@ts-ignore
|
||||
createCustomHandle: createResizeHandle,
|
||||
className: buildResizeClasses("node-excalidraw"),
|
||||
},
|
||||
}),
|
||||
Embed.configure({
|
||||
view: EmbedView,
|
||||
@@ -253,6 +315,8 @@ export const mainExtensions = [
|
||||
};
|
||||
},
|
||||
}).configure(),
|
||||
Columns,
|
||||
Column,
|
||||
] as 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 { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
|
||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -416,6 +417,7 @@ export default function PageEditor({
|
||||
<SubpagesMenu editor={editor} />
|
||||
<ExcalidrawMenu editor={editor} />
|
||||
<DrawioMenu editor={editor} />
|
||||
<ColumnsMenu editor={editor} />
|
||||
<LinkMenu editor={editor} appendTo={menuContainerRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
div[data-type="columns"] {
|
||||
display: flex;
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
div[data-type="columns"] > div[data-type="column"] {
|
||||
flex: 1;
|
||||
min-width: 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"] {
|
||||
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: 820px) {
|
||||
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 {
|
||||
padding-left: 25px;
|
||||
padding-right: 25px;
|
||||
border-left: 2px solid var(--mantine-color-gray-6);
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-0),
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid
|
||||
light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -126,13 +122,14 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.node-callout {
|
||||
div[style*="white-space: inherit;"] {
|
||||
> :first-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.react-renderer.node-callout div[style*="white-space: inherit;"] > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.react-renderer.node-callout + .react-renderer.node-callout {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.selection {
|
||||
|
||||
@@ -13,3 +13,4 @@
|
||||
@import "./mention.css";
|
||||
@import "./ordered-list.css";
|
||||
@import "./highlight.css";
|
||||
@import "./columns.css";
|
||||
|
||||
@@ -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(
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
UniqueID,
|
||||
addUniqueIdsToDoc,
|
||||
htmlToMarkdown,
|
||||
Columns,
|
||||
Column,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -91,6 +93,8 @@ export const tiptapExtensions = [
|
||||
Embed,
|
||||
Mention,
|
||||
Subpages,
|
||||
Columns,
|
||||
Column,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
Reference in New Issue
Block a user