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:
Philip Okugbe
2026-02-24 15:22:37 +00:00
committed by GitHub
parent c172d3bd5e
commit ef87210b3d
42 changed files with 3082 additions and 533 deletions
@@ -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>
);
}