Compare commits

...

18 Commits

Author SHA1 Message Date
Salihu 6f06eda36b minor fixes 2026-02-26 22:48:29 +01:00
Salihu 2dcfe4be0c minor fixes 2026-02-26 18:16:59 +01:00
Salihu 8b56b47eb0 fix mobile menu closing functionality 2026-02-24 18:50:02 +01:00
Salihu b20f70da42 add comment 2026-02-24 14:10:41 +01:00
Salihu 315992d55b some changes 2026-02-23 22:52:21 +01:00
Salihu 8427778c46 nested submenu 2026-02-22 22:16:36 +01:00
Salihu fd4dab23f2 nested submenu 2026-02-22 22:13:37 +01:00
Salihu edcdb6f8c0 minor fix 2026-02-22 16:19:56 +01:00
Salihu 41a5097240 revert unnecessary lint changes 2026-02-22 15:53:58 +01:00
Salihu bc248dc190 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 15:28:07 +01:00
Salihu f3eec09125 Merge remote-tracking branch 'upstream/main' into feat/inline-menu 2026-02-22 00:38:25 +01:00
Salihu afb849ac98 improve menu positioning 2026-02-21 21:58:13 +01:00
Salihu 9b67d55f76 add submenu 2026-02-20 16:33:15 +01:00
Salihu b792945d01 table mens should be inline menus 2026-02-13 21:54:19 +01:00
Salihu 7c8ba7d2c1 render inline menu just outside the table 2026-02-10 15:49:48 +01:00
Salihu 54a90b05a8 separate inline menu from floating toolbar 2026-02-10 00:43:46 +01:00
Salihu 3e38164366 minor fixes 2026-01-07 17:41:57 +01:00
Salihu f28ce8f0cd inline menu 2026-01-06 23:26:59 +01:00
16 changed files with 1002 additions and 300 deletions
+3
View File
@@ -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}
>
+13 -6
View File
@@ -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`
+79 -5
View File
@@ -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>;
}
+418 -25
View File
@@ -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`
+16 -10
View File
@@ -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(
+110
View File
@@ -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;
+31 -1
View File
@@ -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;
}
`;
+75 -60
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+57 -54
View File
@@ -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,
+2
View File
@@ -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"),
+2
View File
@@ -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;
+7
View File
@@ -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",