mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f06eda36b | |||
| 2dcfe4be0c | |||
| 8b56b47eb0 | |||
| b20f70da42 | |||
| 315992d55b | |||
| 8427778c46 | |||
| fd4dab23f2 | |||
| edcdb6f8c0 | |||
| 41a5097240 | |||
| bc248dc190 | |||
| f3eec09125 | |||
| afb849ac98 | |||
| 9b67d55f76 | |||
| b792945d01 | |||
| 7c8ba7d2c1 | |||
| 54a90b05a8 | |||
| 3e38164366 | |||
| f28ce8f0cd |
@@ -42,6 +42,7 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
id={item.id}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
@@ -94,11 +95,13 @@ export function toMenuItems(items: MenuItem[]) {
|
||||
return (
|
||||
<SubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<SubMenuTrigger
|
||||
id={item.id}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<SubMenuContent
|
||||
id={item.id}
|
||||
ref={parentRef}
|
||||
onFocusOutside={preventCloseHandler}
|
||||
>
|
||||
|
||||
@@ -23,18 +23,23 @@ const DrawerHandle = DrawerPrimitive.Handle;
|
||||
/** Drawer's content - renders the overlay and the actual content. */
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
|
||||
$hidden?: boolean;
|
||||
}
|
||||
>((props, ref) => {
|
||||
const { children, ...rest } = props;
|
||||
const { children, $hidden, ...rest } = props;
|
||||
const [measureRef, bounds] = useMeasure();
|
||||
|
||||
return (
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
{!$hidden && (
|
||||
<DrawerPrimitive.Overlay asChild>
|
||||
<Overlay />
|
||||
</DrawerPrimitive.Overlay>
|
||||
)}
|
||||
<DrawerPrimitive.Content ref={ref} asChild>
|
||||
<StyledContent
|
||||
$hidden={$hidden}
|
||||
animate={{
|
||||
height: bounds.height,
|
||||
transition: { bounce: 0, duration: 0.2 },
|
||||
@@ -76,7 +81,7 @@ const DrawerTitle = React.forwardRef<
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
/** Styled components. */
|
||||
const StyledContent = styled(m.div)`
|
||||
const StyledContent = styled(m.div)<{ $hidden?: boolean }>`
|
||||
z-index: ${depths.menu};
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -90,6 +95,8 @@ const StyledContent = styled(m.div)`
|
||||
border-radius: 6px;
|
||||
|
||||
background: ${s("menuBackground")};
|
||||
|
||||
${({ $hidden }) => $hidden && "display: none;"}
|
||||
`;
|
||||
|
||||
const StyledInnerContent = styled.div`
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
type MenuVariant = "dropdown" | "context";
|
||||
type MenuVariant = "dropdown" | "context" | "inline";
|
||||
|
||||
const MenuContext = createContext<{
|
||||
type MenuContextType = {
|
||||
variant: MenuVariant;
|
||||
}>({
|
||||
activeSubmenu: string | null;
|
||||
setActiveSubmenu: (id: string | null) => void;
|
||||
submenuTriggerRefs: Record<string, RefObject<HTMLDivElement>>;
|
||||
addSubmenuTriggerRef: (id: string, ref: RefObject<HTMLDivElement>) => void;
|
||||
submenuContentRefs: Record<string, RefObject<HTMLDivElement | null>>;
|
||||
addSubmenuContentRef: (
|
||||
id: string,
|
||||
ref: RefObject<HTMLDivElement | null>
|
||||
) => void;
|
||||
mainMenuRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
const MenuContext = createContext<MenuContextType>({
|
||||
variant: "dropdown",
|
||||
activeSubmenu: null,
|
||||
setActiveSubmenu: () => {},
|
||||
submenuTriggerRefs: {},
|
||||
addSubmenuTriggerRef: () => {},
|
||||
submenuContentRefs: {},
|
||||
addSubmenuContentRef: () => {},
|
||||
mainMenuRef: { current: null },
|
||||
});
|
||||
|
||||
export function MenuProvider({
|
||||
@@ -15,7 +42,54 @@ export function MenuProvider({
|
||||
variant: MenuVariant;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ctx = useMemo(() => ({ variant }), [variant]);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const [submenuTriggerRefs, setSubmenuTriggerRefs] = useState<
|
||||
Record<string, RefObject<HTMLDivElement>>
|
||||
>({});
|
||||
const [submenuContentRefs, setSubmenuContentRefs] = useState<
|
||||
Record<string, RefObject<HTMLDivElement | null>>
|
||||
>({});
|
||||
const mainMenuRef = useRef<HTMLDivElement>(null);
|
||||
const addSubmenuTriggerRef = useCallback(
|
||||
(key: string, ref: RefObject<HTMLDivElement>) => {
|
||||
setSubmenuTriggerRefs((prevRefs) => ({
|
||||
...prevRefs,
|
||||
[key]: ref,
|
||||
}));
|
||||
},
|
||||
[setSubmenuTriggerRefs]
|
||||
);
|
||||
const addSubmenuContentRef = useCallback(
|
||||
(key: string, ref: RefObject<HTMLDivElement | null>) => {
|
||||
setSubmenuContentRefs((prevRefs) => ({
|
||||
...prevRefs,
|
||||
[key]: ref,
|
||||
}));
|
||||
},
|
||||
[setSubmenuContentRefs]
|
||||
);
|
||||
|
||||
const ctx = useMemo(
|
||||
() => ({
|
||||
variant,
|
||||
activeSubmenu,
|
||||
setActiveSubmenu,
|
||||
submenuTriggerRefs,
|
||||
addSubmenuTriggerRef,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
mainMenuRef,
|
||||
}),
|
||||
[
|
||||
variant,
|
||||
activeSubmenu,
|
||||
mainMenuRef,
|
||||
submenuTriggerRefs,
|
||||
addSubmenuTriggerRef,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
]
|
||||
);
|
||||
|
||||
return <MenuContext.Provider value={ctx}>{children}</MenuContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,31 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import * as Components from "../components/Menu";
|
||||
import type { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { useMenuContext } from "./MenuContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { Drawer, DrawerContent } from "../Drawer";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Portal as ReactPortal } from "~/components/Portal";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { MenuType } from "@shared/editor/types";
|
||||
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
|
||||
import { useEditor } from "~/editor/components/EditorContext";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
|
||||
type MenuProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Root>;
|
||||
|
||||
const Menu = ({ children, ...rest }: MenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const Root =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Root
|
||||
@@ -31,6 +44,10 @@ type SubMenuProps = React.ComponentPropsWithoutRef<
|
||||
const SubMenu = ({ children, ...rest }: SubMenuProps) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
const Sub =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Sub
|
||||
@@ -68,16 +85,77 @@ MenuTrigger.displayName = "MenuTrigger";
|
||||
type ContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Content
|
||||
> &
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>;
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
|
||||
pos?: {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
};
|
||||
|
||||
const MenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Content>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Content>
|
||||
| HTMLDivElement,
|
||||
ContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { variant, mainMenuRef, activeSubmenu } = useMenuContext();
|
||||
const isMobile = useMobile();
|
||||
const { view } = useEditor();
|
||||
|
||||
const { children, ...rest } = props;
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
const contentProps = {
|
||||
maxHeightVar: "--radix-dropdown-menu-content-available-height",
|
||||
transformOriginVar: "--radix-dropdown-menu-content-transform-origin",
|
||||
};
|
||||
const { pos } = props;
|
||||
|
||||
return isMobile ? (
|
||||
<Drawer
|
||||
open={true}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeMenu(view);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DrawerContent $hidden={!!activeSubmenu} {...rest}>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<ReactPortal>
|
||||
<InlineMenuContentWrapper
|
||||
ref={(node) => {
|
||||
// Set the main menu ref for submenu positioning
|
||||
if (mainMenuRef) {
|
||||
(
|
||||
mainMenuRef as React.MutableRefObject<HTMLElement | null>
|
||||
).current = node;
|
||||
}
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
{...contentProps}
|
||||
{...rest}
|
||||
hiddenScrollbars
|
||||
style={{
|
||||
top: pos?.top,
|
||||
left: pos?.left,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InlineMenuContentWrapper>
|
||||
</ReactPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Portal
|
||||
@@ -120,11 +198,45 @@ type SubMenuTriggerProps = BaseItemProps &
|
||||
|
||||
const SubMenuTrigger = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>
|
||||
| HTMLDivElement,
|
||||
SubMenuTriggerProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { label, icon, disabled, ...rest } = props;
|
||||
const { variant, setActiveSubmenu, addSubmenuTriggerRef } = useMenuContext();
|
||||
const { label, icon, disabled, id, ...rest } = props;
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id && triggerRef.current) {
|
||||
addSubmenuTriggerRef(id, triggerRef);
|
||||
}
|
||||
}, [triggerRef, id, addSubmenuTriggerRef]);
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return (
|
||||
<Components.MenuSubTrigger
|
||||
ref={triggerRef}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && id && isMobile) {
|
||||
setActiveSubmenu(id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (!disabled && id && !isMobile) {
|
||||
setActiveSubmenu(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel style={{ marginRight: 20 }}>
|
||||
{label}
|
||||
</Components.MenuLabel>
|
||||
<Components.MenuDisclosure />
|
||||
</Components.MenuSubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const Trigger =
|
||||
variant === "dropdown"
|
||||
@@ -143,6 +255,12 @@ const SubMenuTrigger = React.forwardRef<
|
||||
});
|
||||
SubMenuTrigger.displayName = "SubMenuTrigger";
|
||||
|
||||
const MARGIN_RIGHT_FOR_UX = 20; // Margin for better UX
|
||||
const NESTED_OFFSET_LEFT = 95; // Offset for nested submenu when it renders on the left
|
||||
const TOP_OFFSET_LEFT = 75; // Offset for top submenu when it renders on the left
|
||||
const NESTED_OFFSET_RIGHT = 75; // Offset for nested submenu when it renders on the right
|
||||
const TOP_OFFSET_RIGHT = 65; // Offset for top submenu when it renders on the right
|
||||
|
||||
type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> &
|
||||
@@ -150,11 +268,166 @@ type SubMenuContentProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const SubMenuContent = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.SubContent>
|
||||
| HTMLDivElement,
|
||||
SubMenuContentProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { children, ...rest } = props;
|
||||
const submenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const {
|
||||
variant,
|
||||
activeSubmenu,
|
||||
submenuTriggerRefs,
|
||||
submenuContentRefs,
|
||||
addSubmenuContentRef,
|
||||
mainMenuRef,
|
||||
setActiveSubmenu,
|
||||
} = useMenuContext();
|
||||
const { children, id, ...rest } = props;
|
||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
||||
const isMobile = useMobile();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (id) {
|
||||
addSubmenuContentRef(id, submenuRef);
|
||||
}
|
||||
}, [id, addSubmenuContentRef]);
|
||||
|
||||
const handleClickOutside = React.useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const isInsideDescendant =
|
||||
id &&
|
||||
Object.entries(submenuContentRefs).some(
|
||||
([refId, contentRef]) =>
|
||||
refId !== id &&
|
||||
refId.startsWith(id + "-") &&
|
||||
contentRef.current?.contains(event.target as Node)
|
||||
);
|
||||
if (isInsideDescendant) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk up the id hierarchy to find the deepest ancestor submenu containing the click.
|
||||
let targetSubmenu: string | null = null;
|
||||
if (id) {
|
||||
const parts = id.split("-");
|
||||
for (let len = parts.length - 1; len >= 2; len--) {
|
||||
const ancestorId = parts.slice(0, len).join("-");
|
||||
const ancestorRef = submenuContentRefs[ancestorId];
|
||||
if (ancestorRef?.current?.contains(event.target as Node)) {
|
||||
targetSubmenu = ancestorId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSubmenu(targetSubmenu);
|
||||
},
|
||||
[id, submenuContentRefs, setActiveSubmenu]
|
||||
);
|
||||
|
||||
// the submenu drawer handles its own click outside logic
|
||||
useOnClickOutside(submenuRef, isMobile ? undefined : handleClickOutside);
|
||||
|
||||
React.useEffect(() => {
|
||||
const trigger = submenuTriggerRefs[id ?? ""];
|
||||
|
||||
if (trigger?.current) {
|
||||
const triggerRect = trigger.current.getBoundingClientRect();
|
||||
const parentId = id ? getParentSubmenuId(id) : null;
|
||||
const anchorRect = (
|
||||
parentId ? submenuContentRefs[parentId]?.current : mainMenuRef.current
|
||||
)?.getBoundingClientRect();
|
||||
const subMenuRect = submenuRef.current?.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
|
||||
const spaceOnRight = viewportWidth - triggerRect.right;
|
||||
const anchorWidth = anchorRect?.width;
|
||||
const submenuWidth = subMenuRect?.width;
|
||||
|
||||
const offsetLeft = parentId ? NESTED_OFFSET_LEFT : TOP_OFFSET_LEFT;
|
||||
const offsetRight = parentId ? NESTED_OFFSET_RIGHT : TOP_OFFSET_RIGHT;
|
||||
|
||||
let left = triggerRect.left - offsetLeft;
|
||||
|
||||
// Check if there's enough space on the right
|
||||
if (
|
||||
submenuWidth &&
|
||||
anchorWidth &&
|
||||
spaceOnRight < submenuWidth + MARGIN_RIGHT_FOR_UX
|
||||
) {
|
||||
left = triggerRect.left - submenuWidth - anchorWidth - offsetRight;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: triggerRect.top,
|
||||
left,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
variant,
|
||||
activeSubmenu,
|
||||
submenuTriggerRefs,
|
||||
mainMenuRef,
|
||||
id,
|
||||
submenuContentRefs,
|
||||
]);
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
const isVisible =
|
||||
activeSubmenu === id ||
|
||||
(id !== undefined && activeSubmenu?.startsWith(id + "-"));
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentProps = {
|
||||
maxHeightVar: "--inline-menu-max-height",
|
||||
transformOriginVar: "--inline-menu-transform-origin",
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (activeSubmenu !== id) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuDrawer
|
||||
setActiveSubmenu={setActiveSubmenu}
|
||||
submenuRef={submenuRef}
|
||||
forwardedRef={ref}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</SubMenuDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactPortal>
|
||||
<InlineMenuContentWrapper
|
||||
ref={(node) => {
|
||||
submenuRef.current = node;
|
||||
if (typeof ref === "function") {
|
||||
ref(node);
|
||||
} else if (ref) {
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current =
|
||||
node;
|
||||
}
|
||||
}}
|
||||
{...contentProps}
|
||||
{...rest}
|
||||
hiddenScrollbars
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
zIndex: 1001,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InlineMenuContentWrapper>
|
||||
</ReactPortal>
|
||||
);
|
||||
}
|
||||
|
||||
const Portal =
|
||||
variant === "dropdown"
|
||||
@@ -203,7 +476,8 @@ type MenuGroupProps = {
|
||||
|
||||
const MenuGroup = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Group>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Group>
|
||||
| HTMLDivElement,
|
||||
MenuGroupProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
@@ -224,6 +498,7 @@ const MenuGroup = React.forwardRef<
|
||||
MenuGroup.displayName = "MenuGroup";
|
||||
|
||||
type BaseItemProps = {
|
||||
id?: string;
|
||||
label: string;
|
||||
icon?: React.ReactElement;
|
||||
disabled?: boolean;
|
||||
@@ -248,7 +523,9 @@ const MenuButton = React.forwardRef<
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
MenuButtonProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
const { variant, activeSubmenu, setActiveSubmenu } = useMenuContext();
|
||||
const { view } = useEditor();
|
||||
const [active, setActive] = React.useState(false);
|
||||
const {
|
||||
label,
|
||||
icon,
|
||||
@@ -260,28 +537,63 @@ const MenuButton = React.forwardRef<
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const Item =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Item
|
||||
: ContextMenuPrimitive.Item;
|
||||
|
||||
const button = (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
const handleMouseEnter = React.useCallback(() => {
|
||||
setActive(true);
|
||||
if (props.id) {
|
||||
// Close any nested submenu that is deeper than this button's parent level.
|
||||
const parentId = getParentSubmenuId(props.id);
|
||||
if (activeSubmenu && activeSubmenu !== parentId) {
|
||||
setActiveSubmenu(parentId);
|
||||
}
|
||||
} else if (activeSubmenu) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, [setActive, props.id, activeSubmenu, setActiveSubmenu]);
|
||||
|
||||
const button =
|
||||
variant === MenuType.inline ? (
|
||||
<Components.MenuButton
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
$active={active}
|
||||
onClick={(e) => {
|
||||
onClick(e);
|
||||
closeMenu(view);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setActive(false)}
|
||||
>
|
||||
{icon}
|
||||
<Components.MenuLabel>{label}</Components.MenuLabel>
|
||||
{selected !== undefined && (
|
||||
<Components.SelectedIconWrapper aria-hidden>
|
||||
{selected ? <CheckmarkIcon size={18} /> : null}
|
||||
</Components.SelectedIconWrapper>
|
||||
)}
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
) : (
|
||||
<Item ref={ref} disabled={disabled} {...rest} asChild>
|
||||
<Components.MenuButton
|
||||
disabled={disabled}
|
||||
$dangerous={dangerous}
|
||||
onClick={onClick}
|
||||
>
|
||||
{buttonContent}
|
||||
</Components.MenuButton>
|
||||
</Item>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<Tooltip content={tooltip} placement="bottom">
|
||||
@@ -375,11 +687,16 @@ type MenuSeparatorProps = React.ComponentPropsWithoutRef<
|
||||
|
||||
const MenuSeparator = React.forwardRef<
|
||||
| React.ElementRef<typeof DropdownMenuPrimitive.Separator>
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
| React.ElementRef<typeof ContextMenuPrimitive.Separator>
|
||||
| HTMLDivElement,
|
||||
MenuSeparatorProps
|
||||
>((props, ref) => {
|
||||
const { variant } = useMenuContext();
|
||||
|
||||
if (variant === MenuType.inline) {
|
||||
return <Components.MenuSeparator ref={ref as React.Ref<HTMLHRElement>} />;
|
||||
}
|
||||
|
||||
const Separator =
|
||||
variant === "dropdown"
|
||||
? DropdownMenuPrimitive.Separator
|
||||
@@ -419,6 +736,82 @@ const MenuLabel = React.forwardRef<
|
||||
});
|
||||
MenuLabel.displayName = "MenuLabel";
|
||||
|
||||
const DRAWER_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
type SubMenuDrawerProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
setActiveSubmenu: (id: string | null) => void;
|
||||
submenuRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
forwardedRef: React.ForwardedRef<HTMLDivElement>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const SubMenuDrawer = ({
|
||||
setActiveSubmenu,
|
||||
submenuRef,
|
||||
forwardedRef,
|
||||
children,
|
||||
...rest
|
||||
}: SubMenuDrawerProps) => {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const { view } = useEditor();
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
// Let slide-down animation play out before tearing down the tree.
|
||||
setTimeout(() => {
|
||||
setActiveSubmenu(null);
|
||||
closeMenu(view);
|
||||
}, DRAWER_ANIMATION_DURATION_MS);
|
||||
}
|
||||
},
|
||||
[setActiveSubmenu, view]
|
||||
);
|
||||
|
||||
useOnClickOutside(submenuRef, () => handleOpenChange(false));
|
||||
|
||||
return (
|
||||
<Drawer open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent
|
||||
ref={(node) => {
|
||||
submenuRef.current = node;
|
||||
if (typeof forwardedRef === "function") {
|
||||
forwardedRef(node);
|
||||
} else if (forwardedRef) {
|
||||
(
|
||||
forwardedRef as React.MutableRefObject<HTMLDivElement | null>
|
||||
).current = node;
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<StyledScrollable hiddenScrollbars>{children}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
const getParentSubmenuId = (id: string): string | null => {
|
||||
const parts = id.split("-");
|
||||
return parts.length > 2 ? parts.slice(0, -1).join("-") : null;
|
||||
};
|
||||
|
||||
const closeMenu = (view: EditorView) => {
|
||||
collapseSelection()(view.state, view.dispatch);
|
||||
};
|
||||
|
||||
const InlineMenuContentWrapper = styled(Components.MenuContent)`
|
||||
position: absolute;
|
||||
height: fit-content;
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
// Styled scrollable for mobile drawer content
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export {
|
||||
Menu,
|
||||
MenuTrigger,
|
||||
|
||||
@@ -107,6 +107,25 @@ export const MenuExternalLink = styled.a`
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
|
||||
${(props) =>
|
||||
!props.disabled &&
|
||||
`
|
||||
&:hover {
|
||||
color: ${props.theme.accentText};
|
||||
background: ${props.$dangerous ? props.theme.danger : props.theme.accent};
|
||||
outline-color: ${
|
||||
props.$dangerous ? props.theme.danger : props.theme.accent
|
||||
};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg:not([data-fixed-color]) {
|
||||
color: ${props.theme.accentText};
|
||||
fill: ${props.theme.accentText};
|
||||
}
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
|
||||
@@ -36,14 +36,16 @@ const defaultPosition = {
|
||||
visible: false,
|
||||
};
|
||||
|
||||
function usePosition({
|
||||
export function usePosition({
|
||||
menuRef,
|
||||
active,
|
||||
align = "center",
|
||||
inline = false,
|
||||
}: {
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
active?: boolean;
|
||||
align?: Props["align"];
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const { view } = useEditor();
|
||||
const { selection } = view.state;
|
||||
@@ -120,13 +122,14 @@ function usePosition({
|
||||
selection instanceof ColumnSelection && selection.isColSelection();
|
||||
const isRowSelection =
|
||||
selection instanceof RowSelection && selection.isRowSelection();
|
||||
let colWidth = 0;
|
||||
|
||||
if (isTableSelected(view.state)) {
|
||||
const rect = selectedRect(view.state);
|
||||
const table = view.domAtPos(rect.tableStart);
|
||||
const bounds = (table.node as HTMLElement).getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.top = bounds.top - (inline ? 160 : 16);
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
} else if (isColSelection) {
|
||||
const rect = selectedRect(view.state);
|
||||
@@ -136,6 +139,7 @@ function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
colWidth = bounds.width;
|
||||
selectionBounds.top = bounds.top - 16;
|
||||
selectionBounds.left = bounds.left;
|
||||
selectionBounds.right = bounds.right;
|
||||
@@ -148,8 +152,8 @@ function usePosition({
|
||||
);
|
||||
if (element instanceof HTMLElement) {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
selectionBounds.top = bounds.top;
|
||||
selectionBounds.left = bounds.left - 10;
|
||||
selectionBounds.top = bounds.top + (inline ? 55 : 0);
|
||||
selectionBounds.left = bounds.left - (inline ? 410 : 10);
|
||||
selectionBounds.right = bounds.left - 10;
|
||||
}
|
||||
}
|
||||
@@ -198,11 +202,13 @@ function usePosition({
|
||||
),
|
||||
Math.max(
|
||||
Math.max(offsetParent.x, margin),
|
||||
align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
isColSelection && colWidth < 300
|
||||
? selectionBounds.right + margin
|
||||
: align === "center"
|
||||
? centerOfSelection - menuWidth / 2
|
||||
: align === "start"
|
||||
? selectionBounds.left
|
||||
: selectionBounds.right
|
||||
)
|
||||
);
|
||||
const top = Math.max(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Portal } from "~/components/Portal";
|
||||
import { Menu } from "~/components/primitives/Menu";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuContent } from "~/components/primitives/Menu";
|
||||
import { toMenuItems } from "~/components/Menu/transformer";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import { mapMenuItems } from "./ToolbarMenu";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePosition } from "./FloatingToolbar";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/*
|
||||
* Renders an inline menu in the floating toolbar, which does not require a trigger.
|
||||
*/
|
||||
const InlineMenu: React.FC<Props> = ({ items, containerRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commands, view } = useEditor();
|
||||
const fallbackRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuRef = containerRef || fallbackRef;
|
||||
const isMobile = useMobile();
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
const position = usePosition({
|
||||
menuRef,
|
||||
active: true,
|
||||
inline: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const menuRect = menuRef.current?.getBoundingClientRect();
|
||||
|
||||
let left = position.left;
|
||||
if (menuRef.current && menuRect) {
|
||||
const spaceOnRight = viewportWidth - left;
|
||||
if (spaceOnRight < menuRect.right) {
|
||||
left = left - spaceOnRight; // double the space on the right
|
||||
}
|
||||
}
|
||||
|
||||
setPos((prevPos) => {
|
||||
if (prevPos.top !== position.top || prevPos.left !== left) {
|
||||
return {
|
||||
top: position.top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
return prevPos;
|
||||
});
|
||||
}, [menuRef, position]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
ev.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
const mappedItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const children =
|
||||
typeof item.children === "function" ? item.children() : item.children;
|
||||
|
||||
return {
|
||||
...item,
|
||||
children: children
|
||||
? mapMenuItems(children, commands, view.state)
|
||||
: [],
|
||||
};
|
||||
}),
|
||||
[items, commands, view.state]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<MenuProvider variant="inline">
|
||||
<Menu>
|
||||
<MenuContent
|
||||
pos={pos}
|
||||
align="end"
|
||||
aria-label={t("Options")}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<EventBoundary>
|
||||
{mappedItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{toMenuItems(item.children || [])}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</EventBoundary>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</MenuProvider>
|
||||
);
|
||||
|
||||
return isMobile ? content : <Portal>{content}</Portal>;
|
||||
};
|
||||
|
||||
export default InlineMenu;
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
getRowIndex,
|
||||
isTableSelected,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -40,6 +40,9 @@ import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import InlineMenu from "./InlineMenu";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** Whether the text direction is right-to-left */
|
||||
@@ -269,6 +272,8 @@ export function SelectionToolbar(props: Props) {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
}
|
||||
|
||||
const isInline = items[0].type === MenuType.inline;
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
items = items.filter((item) => {
|
||||
if (item.name === "separator") {
|
||||
@@ -315,6 +320,14 @@ export function SelectionToolbar(props: Props) {
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
if (isInline && items.length) {
|
||||
return (
|
||||
<InlineMenuWrapper ref={menuRef}>
|
||||
<InlineMenu items={items} containerRef={menuRef} />
|
||||
</InlineMenuWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
@@ -359,3 +372,20 @@ export function SelectionToolbar(props: Props) {
|
||||
</FloatingToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
const InlineMenuWrapper = styled.div`
|
||||
position: absolute;
|
||||
z-index: ${depths.editorToolbar};
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -48,67 +48,10 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
return resolvedItemChildren
|
||||
? mapMenuItems(resolvedItemChildren, commands, state)
|
||||
: [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
@@ -220,6 +163,78 @@ function ToolbarMenu(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
export const mapMenuItems = (
|
||||
children: MenuItem[],
|
||||
commands: Record<string, Function>,
|
||||
state: any,
|
||||
parentId = "0"
|
||||
): TMenuItem[] => {
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return children.map((child, idx) => {
|
||||
const id = `${parentId}-${idx}`;
|
||||
|
||||
if (child.name === "separator") {
|
||||
return { id, type: "separator", visible: child.visible };
|
||||
}
|
||||
|
||||
if ("content" in child) {
|
||||
return {
|
||||
id,
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
id,
|
||||
type: "submenu",
|
||||
title: child.label || child.tooltip,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapMenuItems(resolvedChildren, commands, state, id),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected: child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const FlexibleWrapper = styled.div`
|
||||
color: ${s("textSecondary")};
|
||||
overflow: hidden;
|
||||
|
||||
+37
-31
@@ -7,7 +7,7 @@ import {
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { TableLayout } from "@shared/editor/types";
|
||||
import { MenuType, TableLayout } from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function tableMenuItems(
|
||||
@@ -26,36 +26,42 @@ export default function tableMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
tooltip: isFullWidth
|
||||
? dictionary.alignDefaultWidth
|
||||
: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
tooltip: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: dictionary.exportAsCSV,
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
label: isFullWidth
|
||||
? dictionary.alignDefaultWidth
|
||||
: dictionary.alignFullWidth,
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth
|
||||
? { layout: null }
|
||||
: { layout: TableLayout.fullWidth },
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
label: dictionary.distributeColumns,
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
label: dictionary.exportAsCSV,
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
dangerous: true,
|
||||
label: dictionary.deleteTable,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+130
-108
@@ -5,7 +5,6 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -24,7 +23,11 @@ import {
|
||||
isMultipleCellSelection,
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -88,119 +91,138 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignLeft,
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignCenter,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: dictionary.alignRight,
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.align,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignLeft,
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignCenter,
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: dictionary.alignRight,
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sort,
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
children: [
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sortAsc,
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: dictionary.sortDesc,
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : colColors.size === 1 ? (
|
||||
<CircleIcon color={colColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => colColors.size === 1 && colColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleColumnBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -15,7 +14,11 @@ import {
|
||||
isMergedCellSelection,
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type MenuItem,
|
||||
type NodeAttrMark,
|
||||
} from "@shared/editor/types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
@@ -77,66 +80,66 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
type: MenuType.inline,
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
label: dictionary.background,
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
) : rowColors.size === 1 ? (
|
||||
<CircleIcon color={rowColors.values().next().value} />
|
||||
) : (
|
||||
<PaletteIcon />
|
||||
),
|
||||
children: [
|
||||
...[
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
label: dictionary.none,
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
],
|
||||
...TableCell.presetColors.map((preset) => ({
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: preset.name,
|
||||
icon: <CircleIcon retainColor color={preset.hex} />,
|
||||
active: () => rowColors.size === 1 && rowColors.has(preset.hex),
|
||||
attrs: { color: preset.hex },
|
||||
})),
|
||||
...(customColor
|
||||
? [
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: customColor,
|
||||
icon: <CircleIcon retainColor color={customColor} />,
|
||||
active: () => true,
|
||||
attrs: { color: customColor },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
icon: <CircleIcon retainColor color="rainbow" />,
|
||||
label: "Custom",
|
||||
children: [
|
||||
{
|
||||
content: (
|
||||
<CellBackgroundColorPicker
|
||||
activeColor={activeColor}
|
||||
command="toggleRowBackground"
|
||||
/>
|
||||
),
|
||||
preventCloseCondition: () =>
|
||||
!!document.activeElement?.matches(
|
||||
".ProseMirror.ProseMirror-focused"
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: dictionary.toggleHeader,
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function useDictionary() {
|
||||
moveColumnRight: t("Move right"),
|
||||
addRowAfter: t("Insert after"),
|
||||
addRowBefore: t("Insert before"),
|
||||
align: t("Align"),
|
||||
alignCenter: t("Align center"),
|
||||
alignLeft: t("Align left"),
|
||||
alignRight: t("Align right"),
|
||||
@@ -93,6 +94,7 @@ export default function useDictionary() {
|
||||
strikethrough: t("Strikethrough"),
|
||||
strong: t("Bold"),
|
||||
subheading: t("Subheading"),
|
||||
sort: t("Sort"),
|
||||
sortAsc: t("Sort ascending"),
|
||||
sortDesc: t("Sort descending"),
|
||||
table: t("Table"),
|
||||
|
||||
@@ -21,6 +21,7 @@ export type PartialExcept<T, K extends keyof T> = Partial<Omit<T, K>> &
|
||||
Required<Pick<T, K>>;
|
||||
|
||||
export type MenuItemButton = {
|
||||
id?: string;
|
||||
type: "button";
|
||||
title: React.ReactNode;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
|
||||
@@ -33,6 +34,7 @@ export type MenuItemButton = {
|
||||
};
|
||||
|
||||
export type MenuItemWithChildren = {
|
||||
id?: string;
|
||||
type: "submenu";
|
||||
title: React.ReactNode;
|
||||
visible?: boolean;
|
||||
|
||||
@@ -17,12 +17,19 @@ export enum TableLayout {
|
||||
fullWidth = "full-width",
|
||||
}
|
||||
|
||||
export enum MenuType {
|
||||
inline = "inline",
|
||||
toolbar = "toolbar",
|
||||
}
|
||||
|
||||
type Section = ({ t }: { t: TFunction }) => string;
|
||||
|
||||
export type MenuItem = {
|
||||
id?: string;
|
||||
icon?: React.ReactNode;
|
||||
name?: string;
|
||||
title?: string;
|
||||
type?: MenuType;
|
||||
section?: Section;
|
||||
subtitle?: React.ReactNode;
|
||||
shortcut?: string;
|
||||
|
||||
@@ -541,6 +541,7 @@
|
||||
"Replacement": "Replacement",
|
||||
"Replace": "Replace",
|
||||
"Replace all": "Replace all",
|
||||
"Options": "Options",
|
||||
"Image width": "Image width",
|
||||
"Width": "Width",
|
||||
"Image height": "Image height",
|
||||
@@ -561,6 +562,7 @@
|
||||
"Move down": "Move down",
|
||||
"Move left": "Move left",
|
||||
"Move right": "Move right",
|
||||
"Align": "Align",
|
||||
"Align center": "Align center",
|
||||
"Align left": "Align left",
|
||||
"Align right": "Align right",
|
||||
@@ -622,6 +624,7 @@
|
||||
"Strikethrough": "Strikethrough",
|
||||
"Bold": "Bold",
|
||||
"Subheading": "Subheading",
|
||||
"Sort": "Sort",
|
||||
"Sort ascending": "Sort ascending",
|
||||
"Sort descending": "Sort descending",
|
||||
"Table": "Table",
|
||||
|
||||
Reference in New Issue
Block a user