Files
outline/app/editor/components/SelectionToolbar.tsx
T
Tom Moor c3ba14f069 chore: Refactor SelectionToolbar to menu registry (#12439)
* refactor: introduce declarative menu registry for selection toolbar

Replace the hard-coded if-else chain in SelectionToolbar with a
priority-based menu registry system. Extensions can now declare
selection toolbar menus via `selectionToolbarMenus()`, following the
same pattern as `commands()` and `keys()`.

Key changes:
- Add SelectionContext interface computed once per toolbar render
- Add SelectionToolbarMenuDescriptor for declarative menu registration
- Add selectionToolbarMenus() to Extension base class
- Add buildSelectionContext() utility to eliminate repeated state queries
- ExtensionManager collects and sorts menus from all extensions
- SelectionToolbarExtension registers all 10 existing menus
- All menu functions now accept SelectionContext instead of raw state
- SelectionToolbar uses registry lookup instead of if-else chain

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: import t directly from i18next in menu functions

Remove the `t: TFunction` parameter from all menu functions and the
`SelectionToolbarMenuDescriptor.getItems` signature. Each menu file
now imports `t` directly from i18next, matching the pattern used
throughout the rest of the codebase (e.g. Image.tsx, Link.tsx).

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: move divider menu into HorizontalRule node extension

The divider selection toolbar menu is now declared via
selectionToolbarMenus() on the HorizontalRule node class, co-locating
the menu with the node that owns it. Delete the standalone
app/editor/menus/divider.tsx file and remove the entry from
SelectionToolbarExtension.

This is the first menu migrated from the centralized toolbar extension
to an individual node extension, demonstrating the pattern for the
remaining menus.

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

* refactor: check readOnly in matches predicate for divider menu

https://claude.ai/code/session_01MRyFysrGM9d8NhbAs7nrtU

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-27 20:28:17 -04:00

309 lines
9.0 KiB
TypeScript

import type { EditorState, Selection } from "prosemirror-state";
import Suggestion from "~/editor/extensions/Suggestion";
import { NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { buildSelectionContext } from "@shared/editor/lib/buildSelectionContext";
import {
getMarkRange,
getMarkRangeNodeSelection,
} from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInNotice } from "@shared/editor/queries/isInNotice";
import type { MenuItem } from "@shared/editor/types";
import useBoolean from "~/hooks/useBoolean";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import {
columnDragPluginKey,
rowDragPluginKey,
} from "@shared/editor/plugins/TableDragState";
import { useEditor } from "./EditorContext";
import { MediaLinkEditor } from "./MediaLinkEditor";
import FloatingToolbar from "./FloatingToolbar";
import LinkEditor from "./LinkEditor";
import ToolbarMenu from "./ToolbarMenu";
import { isModKey } from "@shared/utils/keyboard";
type Props = {
/** Whether the text direction is right-to-left */
rtl: boolean;
/** Whether the current document is a template */
isTemplate: boolean;
/** Whether the toolbar is currently active/visible */
isActive: boolean;
/** The current selection */
selection?: Selection;
/** Whether the editor is in read-only mode */
readOnly?: boolean;
/** Whether the user has permission to add comments */
canComment?: boolean;
/** Whether the user has permission to update the document */
canUpdate?: boolean;
};
function useIsDragging(state: EditorState) {
const [isDragging, setDragging, setNotDragging] = useBoolean();
useEventListener("dragstart", setDragging);
useEventListener("dragend", setNotDragging);
useEventListener("drop", setNotDragging);
const columnDragState = columnDragPluginKey.getState(state);
const rowDragState = rowDragPluginKey.getState(state);
const isTableDragging =
columnDragState?.isDragging || rowDragState?.isDragging;
return isDragging || isTableDragging;
}
enum Toolbar {
Link = "link",
Media = "media",
Menu = "menu",
}
export function SelectionToolbar(props: Props) {
const { readOnly = false } = props;
const { view, extensions, commands, selectionToolbarMenus } = useEditor();
const menuRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useMobile();
const isActive = props.isActive || isMobile;
const { state } = view;
const [autoFocusLinkInput, setAutoFocusLinkInput] = React.useState(false);
const isDragging = useIsDragging(state);
const { selection } = state;
const [activeToolbar, setActiveToolbar] = React.useState<Toolbar | null>(
null
);
const linkMark =
selection instanceof NodeSelection
? getMarkRangeNodeSelection(selection, state.schema.marks.link)
: getMarkRange(selection.$from, state.schema.marks.link);
const isEmbedSelection =
selection instanceof NodeSelection && selection.node.type.name === "embed";
const isCodeSelection = isInCode(state, { onlyBlock: true });
const isNoticeSelection = isInNotice(state);
React.useLayoutEffect(() => {
if (!isActive) {
setActiveToolbar(null);
return;
}
if (isEmbedSelection && !readOnly) {
setActiveToolbar(Toolbar.Media);
} else if (
linkMark &&
(activeToolbar === null || activeToolbar === Toolbar.Link) &&
!readOnly
) {
setActiveToolbar(Toolbar.Link);
} else if (isCodeSelection) {
setActiveToolbar(Toolbar.Menu);
} else if (!selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (isNoticeSelection && selection.empty) {
setActiveToolbar(Toolbar.Menu);
} else if (selection.empty) {
setActiveToolbar(null);
}
}, [
readOnly,
isActive,
selection,
linkMark,
isEmbedSelection,
isCodeSelection,
isNoticeSelection,
]);
React.useLayoutEffect(() => {
if (autoFocusLinkInput && activeToolbar !== Toolbar.Link) {
setAutoFocusLinkInput(false);
}
}, [activeToolbar]);
const prevActiveToolbar = React.useRef(activeToolbar);
React.useLayoutEffect(() => {
if (
prevActiveToolbar.current === Toolbar.Link &&
activeToolbar !== Toolbar.Link &&
!readOnly &&
isActive
) {
view.focus();
}
prevActiveToolbar.current = activeToolbar;
}, [activeToolbar, readOnly, isActive, view]);
React.useLayoutEffect(() => {
const handleClickOutside = (ev: MouseEvent): void => {
if (
ev.target instanceof HTMLElement &&
menuRef.current &&
menuRef.current.contains(ev.target)
) {
return;
}
if (view.dom.contains(ev.target as HTMLElement)) {
return;
}
if (!isActive || document.activeElement?.tagName === "INPUT") {
return;
}
const isSuggestionMenuOpen = extensions.extensions.some(
(ext) => ext instanceof Suggestion && ext.isOpen
);
if (isSuggestionMenuOpen) {
return;
}
if (!window.getSelection()?.isCollapsed) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(
TextSelection.near(view.state.doc.resolve(0))
)
);
};
window.addEventListener("mouseup", handleClickOutside);
return () => {
window.removeEventListener("mouseup", handleClickOutside);
};
}, [isActive, readOnly, view]);
useEventListener(
"keydown",
(ev: KeyboardEvent) => {
if (
isModKey(ev) &&
ev.key.toLowerCase() === "k" &&
!view.state.selection.empty
) {
ev.preventDefault();
ev.stopPropagation();
setAutoFocusLinkInput(true);
setActiveToolbar(
activeToolbar === Toolbar.Link ? Toolbar.Menu : Toolbar.Link
);
}
},
view.dom,
{ capture: true }
);
if (isDragging) {
return null;
}
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
// Build selection context once, shared across all menu matchers
const ctx = buildSelectionContext(state, { readOnly, isTemplate, rtl });
// Find the first matching menu from the registry (sorted by priority)
const matched = selectionToolbarMenus.find((menu) => menu.matches(ctx));
let items: MenuItem[] = matched ? matched.getItems(ctx) : [];
const align = matched?.align ?? "center";
// Filter out items for disabled extensions or invisible items
items = items.filter((item) => {
if (item.name === "separator") {
return true;
}
if (item.name === "dimensions") {
return item.visible ?? false;
}
if (item.name && !commands[item.name]) {
return false;
}
if (item.visible === false) {
return false;
}
return true;
});
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children && Array.isArray(item.children)) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
setActiveToolbar(Toolbar.Media);
};
}
return child;
});
}
if (item.name === "linkOnImage" || item.name === "addLink") {
item.onClick = () => {
setAutoFocusLinkInput(true);
setActiveToolbar(Toolbar.Link);
};
}
return item;
});
const handleClickOutsideLinkEditor = (ev: MouseEvent | TouchEvent) => {
if (ev.target instanceof Element && ev.target.closest(".image-wrapper")) {
return;
}
setActiveToolbar(null);
};
return (
<FloatingToolbar
align={align}
active={isActive}
ref={menuRef}
width={
activeToolbar === Toolbar.Link || activeToolbar === Toolbar.Media
? 336
: undefined
}
>
{activeToolbar === Toolbar.Link ? (
<LinkEditor
key={`link-${selection.anchor}`}
autoFocus={autoFocusLinkInput}
view={view}
mark={linkMark ? linkMark.mark : undefined}
onLinkAdd={() => setActiveToolbar(null)}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
onClickBack={() => setActiveToolbar(Toolbar.Menu)}
/>
) : activeToolbar === Toolbar.Media ? (
<MediaLinkEditor
key={`embed-${selection.anchor}`}
node={
"node" in selection ? (selection as NodeSelection).node : undefined
}
view={view}
onLinkUpdate={() => setActiveToolbar(null)}
onLinkRemove={() => setActiveToolbar(null)}
onEscape={() => setActiveToolbar(Toolbar.Menu)}
onClickOutside={handleClickOutsideLinkEditor}
/>
) : activeToolbar === Toolbar.Menu && items.length ? (
<ToolbarMenu items={items} {...rest} />
) : null}
</FloatingToolbar>
);
}