mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 23:44:24 +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:
@@ -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;
|
||||
Reference in New Issue
Block a user