From c3ba14f069b8a6825e6edccbeb7b735dbebcbc1d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 27 May 2026 20:28:17 -0400 Subject: [PATCH] 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 --- app/editor/components/SelectionToolbar.tsx | 74 +++-------------- app/editor/extensions/SelectionToolbar.tsx | 77 ++++++++++++++++++ app/editor/index.tsx | 7 +- app/editor/menus/attachment.tsx | 19 +++-- app/editor/menus/code.tsx | 16 ++-- app/editor/menus/divider.tsx | 33 -------- app/editor/menus/formatting.tsx | 80 ++++++++----------- app/editor/menus/image.tsx | 19 +++-- app/editor/menus/notice.tsx | 19 +++-- app/editor/menus/readOnly.tsx | 22 ++--- app/editor/menus/table.tsx | 19 +++-- app/editor/menus/tableCol.tsx | 34 ++++---- app/editor/menus/tableRow.tsx | 28 ++++--- shared/editor/lib/Extension.ts | 12 +++ shared/editor/lib/ExtensionManager.ts | 12 +++ shared/editor/lib/buildSelectionContext.ts | 53 ++++++++++++ .../{HorizontalRule.ts => HorizontalRule.tsx} | 32 ++++++++ shared/editor/types/index.ts | 78 +++++++++++++++++- 18 files changed, 416 insertions(+), 218 deletions(-) delete mode 100644 app/editor/menus/divider.tsx create mode 100644 shared/editor/lib/buildSelectionContext.ts rename shared/editor/nodes/{HorizontalRule.ts => HorizontalRule.tsx} (65%) diff --git a/app/editor/components/SelectionToolbar.tsx b/app/editor/components/SelectionToolbar.tsx index 7a25587329..7c65936369 100644 --- a/app/editor/components/SelectionToolbar.tsx +++ b/app/editor/components/SelectionToolbar.tsx @@ -2,34 +2,19 @@ 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 { useTranslation } from "react-i18next"; + 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 { isNodeActive } from "@shared/editor/queries/isNodeActive"; -import { - getColumnIndex, - getRowIndex, - isTableSelected, -} from "@shared/editor/queries/table"; import type { MenuItem } from "@shared/editor/types"; import useBoolean from "~/hooks/useBoolean"; import useEventListener from "~/hooks/useEventListener"; import useMobile from "~/hooks/useMobile"; -import getAttachmentMenuItems from "../menus/attachment"; -import getCodeMenuItems from "../menus/code"; -import getDividerMenuItems from "../menus/divider"; -import getFormattingMenuItems from "../menus/formatting"; -import getImageMenuItems from "../menus/image"; -import getNoticeMenuItems from "../menus/notice"; -import getReadOnlyMenuItems from "../menus/readOnly"; -import getTableMenuItems from "../menus/table"; -import getTableColMenuItems from "../menus/tableCol"; -import getTableRowMenuItems from "../menus/tableRow"; import { columnDragPluginKey, rowDragPluginKey, @@ -64,7 +49,6 @@ function useIsDragging(state: EditorState) { useEventListener("dragend", setNotDragging); useEventListener("drop", setNotDragging); - // Check if table row or column is being dragged const columnDragState = columnDragPluginKey.getState(state); const rowDragState = rowDragPluginKey.getState(state); const isTableDragging = @@ -81,8 +65,7 @@ enum Toolbar { export function SelectionToolbar(props: Props) { const { readOnly = false } = props; - const { view, extensions, commands } = useEditor(); - const { t } = useTranslation(); + const { view, extensions, commands, selectionToolbarMenus } = useEditor(); const menuRef = React.useRef(null); const isMobile = useMobile(); const isActive = props.isActive || isMobile; @@ -144,7 +127,6 @@ export function SelectionToolbar(props: Props) { } }, [activeToolbar]); - // Refocus the editor when the link toolbar closes to prevent focus loss const prevActiveToolbar = React.useRef(activeToolbar); React.useLayoutEffect(() => { if ( @@ -175,7 +157,6 @@ export function SelectionToolbar(props: Props) { return; } - // Don't collapse selection if any suggestion menu is open const isSuggestionMenuOpen = extensions.extensions.some( (ext) => ext instanceof Suggestion && ext.isOpen ); @@ -228,51 +209,16 @@ export function SelectionToolbar(props: Props) { const { isTemplate, rtl, canComment, canUpdate, ...rest } = props; - const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state); - const colIndex = getColumnIndex(state); - const rowIndex = getRowIndex(state); - const isImageSelection = - selection instanceof NodeSelection && selection.node.type.name === "image"; - const isAttachmentSelection = - selection instanceof NodeSelection && - selection.node.type.name === "attachment"; + // Build selection context once, shared across all menu matchers + const ctx = buildSelectionContext(state, { readOnly, isTemplate, rtl }); - let items: MenuItem[] = []; - let align: "center" | "start" | "end" = "center"; + // Find the first matching menu from the registry (sorted by priority) + const matched = selectionToolbarMenus.find((menu) => menu.matches(ctx)); - if ( - isCodeSelection && - (selection.empty || selection instanceof NodeSelection) - ) { - items = getCodeMenuItems(state, readOnly, t); - align = "end"; - } else if (isTableSelected(state)) { - items = getTableMenuItems(state, readOnly, t); - } else if (colIndex !== undefined) { - items = getTableColMenuItems(state, readOnly, t, { - index: colIndex, - rtl, - }); - } else if (rowIndex !== undefined) { - items = getTableRowMenuItems(state, readOnly, t, { - index: rowIndex, - }); - } else if (isImageSelection) { - items = getImageMenuItems(state, readOnly, t); - } else if (isAttachmentSelection) { - items = getAttachmentMenuItems(state, readOnly, t); - } else if (isDividerSelection) { - items = getDividerMenuItems(state, readOnly, t); - } else if (readOnly) { - items = getReadOnlyMenuItems(state, !!canUpdate, t); - } else if (isNoticeSelection && selection.empty) { - items = getNoticeMenuItems(state, readOnly, t); - align = "end"; - } else { - items = getFormattingMenuItems(state, isTemplate, t); - } + let items: MenuItem[] = matched ? matched.getItems(ctx) : []; + const align = matched?.align ?? "center"; - // Some extensions may be disabled, remove corresponding items + // Filter out items for disabled extensions or invisible items items = items.filter((item) => { if (item.name === "separator") { return true; diff --git a/app/editor/extensions/SelectionToolbar.tsx b/app/editor/extensions/SelectionToolbar.tsx index be4c63e470..c08f8815d9 100644 --- a/app/editor/extensions/SelectionToolbar.tsx +++ b/app/editor/extensions/SelectionToolbar.tsx @@ -7,7 +7,18 @@ import Extension from "@shared/editor/lib/Extension"; import { isInNotice } from "@shared/editor/queries/isInNotice"; import { isMarkActive } from "@shared/editor/queries/isMarkActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; +import type { SelectionToolbarMenuDescriptor } from "@shared/editor/types"; import { SelectionToolbar } from "../components/SelectionToolbar"; +import getAttachmentMenuItems from "../menus/attachment"; +import getCodeMenuItems from "../menus/code"; + +import getFormattingMenuItems from "../menus/formatting"; +import getImageMenuItems from "../menus/image"; +import getNoticeMenuItems from "../menus/notice"; +import getReadOnlyMenuItems from "../menus/readOnly"; +import getTableMenuItems from "../menus/table"; +import getTableColMenuItems from "../menus/tableCol"; +import getTableRowMenuItems from "../menus/tableRow"; export default class SelectionToolbarExtension extends Extension { get name() { @@ -31,6 +42,72 @@ export default class SelectionToolbarExtension extends Extension { @observable state: Selection | boolean = false; + /** + * Returns all selection toolbar menu descriptors. Each descriptor declares + * when it matches (via a predicate on SelectionContext) and what items to + * show. The toolbar evaluates them in priority order and uses the first + * match. + * + * @returns an array of selection toolbar menu descriptors. + */ + selectionToolbarMenus(): SelectionToolbarMenuDescriptor[] { + return [ + { + priority: 100, + align: "end", + matches: (ctx) => + ctx.isInCodeBlock && + (ctx.isEmpty || ctx.selectedNodeType !== undefined), + getItems: (ctx) => getCodeMenuItems(ctx), + }, + { + priority: 90, + matches: (ctx) => ctx.isTableSelected, + getItems: (ctx) => getTableMenuItems(ctx), + }, + { + priority: 85, + matches: (ctx) => ctx.colIndex !== undefined, + getItems: (ctx) => getTableColMenuItems(ctx), + }, + { + priority: 80, + matches: (ctx) => ctx.rowIndex !== undefined, + getItems: (ctx) => getTableRowMenuItems(ctx), + }, + { + priority: 50, + matches: (ctx) => ctx.selectedNodeType === "image", + getItems: (ctx) => getImageMenuItems(ctx), + }, + { + priority: 50, + matches: (ctx) => ctx.selectedNodeType === "attachment", + getItems: (ctx) => getAttachmentMenuItems(ctx), + }, + { + priority: 30, + matches: (ctx) => ctx.readOnly, + getItems: (ctx) => + getReadOnlyMenuItems( + ctx, + this.editor.props.canUpdate ?? false + ), + }, + { + priority: 20, + align: "end", + matches: (ctx) => ctx.isInNotice && ctx.isEmpty, + getItems: (ctx) => getNoticeMenuItems(ctx), + }, + { + priority: 0, + matches: () => true, + getItems: (ctx) => getFormattingMenuItems(ctx), + }, + ]; + } + private handleUpdate = action((view: EditorView) => { const { state } = view; this.state = this.calculateState(state); diff --git a/app/editor/index.tsx b/app/editor/index.tsx index f3e2027b40..0b0af48cdd 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -35,7 +35,10 @@ import type { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer" import textBetween from "@shared/editor/lib/textBetween"; import { basicExtensions as extensions } from "@shared/editor/nodes"; import type ReactNode from "@shared/editor/nodes/ReactNode"; -import type { ComponentProps } from "@shared/editor/types"; +import type { + ComponentProps, + SelectionToolbarMenuDescriptor, +} from "@shared/editor/types"; import type { ProsemirrorData, ProsemirrorMark, @@ -228,6 +231,7 @@ export class Editor extends React.PureComponent< nodes: { [name: string]: NodeSpec }; marks: { [name: string]: MarkSpec }; commands: Record; + selectionToolbarMenus: SelectionToolbarMenuDescriptor[]; rulePlugins: PluginSimple[]; events = new EventEmitter(); mutationObserver?: MutationObserver; @@ -341,6 +345,7 @@ export class Editor extends React.PureComponent< this.view = this.createView(); this.commands = this.createCommands(); + this.selectionToolbarMenus = this.extensions.selectionToolbarMenus; } private createExtensions() { diff --git a/app/editor/menus/attachment.tsx b/app/editor/menus/attachment.tsx index 359e40171b..4d1c9a0f93 100644 --- a/app/editor/menus/attachment.tsx +++ b/app/editor/menus/attachment.tsx @@ -1,19 +1,22 @@ -import type { TFunction } from "i18next"; +import { t } from "i18next"; import { TrashIcon, DownloadIcon, ReplaceIcon, PDFIcon } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; -import type { MenuItem } from "@shared/editor/types"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; +/** + * Returns menu items for the attachment selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function attachmentMenuItems( - state: EditorState, - readOnly: boolean, - t: TFunction + ctx: SelectionContext ): MenuItem[] { - if (readOnly) { + if (ctx.readOnly) { return []; } - const { schema } = state; + const { schema, state } = ctx; const isAttachmentWithPreview = isNodeActive(schema.nodes.attachment, { preview: true, }); diff --git a/app/editor/menus/code.tsx b/app/editor/menus/code.tsx index bc123fb81b..bd5a145cbc 100644 --- a/app/editor/menus/code.tsx +++ b/app/editor/menus/code.tsx @@ -1,7 +1,6 @@ import { CopyIcon, EditIcon, ExpandedIcon, TextWrapIcon } from "outline-icons"; import type { Node as ProseMirrorNode } from "prosemirror-model"; import { NodeSelection } from "prosemirror-state"; -import type { EditorState } from "prosemirror-state"; import { pluginKey as mermaidPluginKey, type MermaidState, @@ -12,15 +11,20 @@ import { getLabelForLanguage, } from "@shared/editor/lib/code"; import { isMermaid } from "@shared/editor/lib/isCode"; -import type { TFunction } from "i18next"; -import type { MenuItem } from "@shared/editor/types"; +import { t } from "i18next"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; import { metaDisplay } from "@shared/utils/keyboard"; +/** + * Returns menu items for the code block selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function codeMenuItems( - state: EditorState, - readOnly: boolean | undefined, - t: TFunction + ctx: SelectionContext ): MenuItem[] { + const { state, readOnly } = ctx; const node = state.selection instanceof NodeSelection ? state.selection.node diff --git a/app/editor/menus/divider.tsx b/app/editor/menus/divider.tsx deleted file mode 100644 index be3cff8ea5..0000000000 --- a/app/editor/menus/divider.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import type { TFunction } from "i18next"; -import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; -import { isNodeActive } from "@shared/editor/queries/isNodeActive"; -import type { MenuItem } from "@shared/editor/types"; - -export default function dividerMenuItems( - state: EditorState, - readOnly: boolean, - t: TFunction -): MenuItem[] { - if (readOnly) { - return []; - } - const { schema } = state; - - return [ - { - name: "hr", - tooltip: t("Divider"), - attrs: { markup: "---" }, - active: isNodeActive(schema.nodes.hr, { markup: "---" }), - icon: , - }, - { - name: "hr", - tooltip: t("Page break"), - attrs: { markup: "***" }, - active: isNodeActive(schema.nodes.hr, { markup: "***" }), - icon: , - }, - ]; -} diff --git a/app/editor/menus/formatting.tsx b/app/editor/menus/formatting.tsx index c8ad424f45..2fdc8d53e2 100644 --- a/app/editor/menus/formatting.tsx +++ b/app/editor/menus/formatting.tsx @@ -25,22 +25,16 @@ import { import { v4 as uuidv4 } from "uuid"; import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker"; import HighlightColorPicker from "../components/HighlightColorPicker"; -import type { EditorState } from "prosemirror-state"; import { getDocumentHighlightColors } from "@shared/editor/queries/getDocumentHighlightColors"; import { getMarksBetween } from "@shared/editor/queries/getMarksBetween"; -import { isInCode } from "@shared/editor/queries/isInCode"; import { isInList } from "@shared/editor/queries/isInList"; import { isMarkActive } from "@shared/editor/queries/isMarkActive"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; -import type { MenuItem } from "@shared/editor/types"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; import { metaDisplay } from "@shared/utils/keyboard"; -import type { TFunction } from "i18next"; +import { t } from "i18next"; import CircleIcon from "~/components/Icons/CircleIcon"; -import { - isMobile as isMobileDevice, - isTouchDevice, -} from "@shared/utils/browser"; import { getColorSetForSelectedCells, getDocumentTableBackgroundColors, @@ -49,24 +43,21 @@ import { isMergedCellSelection, isMultipleCellSelection, } from "@shared/editor/queries/table"; -import { CellSelection } from "prosemirror-tables"; +import type { CellSelection } from "prosemirror-tables"; import TableCell from "@shared/editor/nodes/TableCell"; import Highlight from "@shared/editor/marks/Highlight"; import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon"; +/** + * Returns menu items for the default formatting selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function formattingMenuItems( - state: EditorState, - isTemplate: boolean, - t: TFunction + ctx: SelectionContext ): MenuItem[] { - const { schema } = state; - const isCode = isInCode(state); - const isCodeBlock = isInCode(state, { onlyBlock: true }); - const isEmpty = state.selection.empty; - const isMobile = isMobileDevice(); - const isTouch = isTouchDevice(); - const isList = isInList(state); - const isTableCell = state.selection instanceof CellSelection; + const { schema, state, isTemplate, isMobile, isTouch, isEmpty, isInCode, isInCodeBlock, isInList: isList, isTableCell } = ctx; const highlight = getMarksBetween( state.selection.from, @@ -83,6 +74,9 @@ export default function formattingMenuItems( const selectedCellsColorSet = getColorSetForSelectedCells(state.selection); + const canFormatInline = !isInCodeBlock && (!isMobile || !isEmpty); + const canFormatBlock = !isInCodeBlock && (!isMobile || isEmpty); + return [ { name: "placeholder", @@ -101,7 +95,7 @@ export default function formattingMenuItems( shortcut: `${metaDisplay}+B`, icon: , active: isMarkActive(schema.marks.strong), - visible: !isCodeBlock && (!isMobile || !isEmpty), + visible: canFormatInline, }, { name: "em", @@ -109,7 +103,7 @@ export default function formattingMenuItems( shortcut: `${metaDisplay}+I`, icon: , active: isMarkActive(schema.marks.em), - visible: !isCodeBlock && (!isMobile || !isEmpty), + visible: canFormatInline, }, { name: "strikethrough", @@ -117,7 +111,7 @@ export default function formattingMenuItems( shortcut: `${metaDisplay}+D`, icon: , active: isMarkActive(schema.marks.strikethrough), - visible: !isCodeBlock && (!isMobile || !isEmpty), + visible: canFormatInline, }, { tooltip: t("Background color"), @@ -133,12 +127,10 @@ export default function formattingMenuItems( ) : ( ), - visible: !isCode && (!isMobile || !isEmpty) && isTableCell, + visible: !isInCode && (!isMobile || !isEmpty) && isTableCell, children: (): MenuItem[] => { - // Get all unique background colors used in table cells (lazily computed when menu opens) const documentTableColors = getDocumentTableBackgroundColors(state); - // Filter out preset colors and currently selected colors const nonPresetDocumentColors = documentTableColors.filter( (color: string) => !TableCell.isPresetColor(color) && !selectedCellsColorSet.has(color) @@ -181,7 +173,6 @@ export default function formattingMenuItems( }, ] : []), - // Add all other document table background colors ...nonPresetDocumentColors.map((color: string) => ({ name: "toggleCellSelectionBackgroundAndCollapseSelection", label: color, @@ -225,12 +216,10 @@ export default function formattingMenuItems( ), active: () => !!highlight, - visible: !isCode && (!isMobile || !isEmpty) && !isTableCell, + visible: !isInCode && (!isMobile || !isEmpty) && !isTableCell, children: (): MenuItem[] => { - // Get all unique highlight colors used in the document (lazily computed when menu opens) const documentHighlightColors = getDocumentHighlightColors(state); - // Filter out preset colors and the currently selected color const currentHighlightColor = highlight?.mark.attrs.color; const nonPresetDocumentColors = documentHighlightColors.filter( (color: string) => @@ -276,7 +265,6 @@ export default function formattingMenuItems( }, ] : []), - // Add all other document highlight colors ...nonPresetDocumentColors.map((color: string) => ({ name: "highlight", label: color, @@ -313,11 +301,11 @@ export default function formattingMenuItems( shortcut: `${metaDisplay}+E`, icon: , active: isMarkActive(schema.marks.code_inline), - visible: !isCodeBlock && (!isMobile || !isEmpty), + visible: canFormatInline, }, { name: "separator", - visible: !isCodeBlock, + visible: !isInCodeBlock, }, { name: "heading", @@ -326,7 +314,7 @@ export default function formattingMenuItems( icon: , active: isNodeActive(schema.nodes.heading, { level: 1 }), attrs: { level: 1 }, - visible: !isCodeBlock && (!isMobile || isEmpty), + visible: canFormatBlock, }, { name: "heading", @@ -335,7 +323,7 @@ export default function formattingMenuItems( icon: , active: isNodeActive(schema.nodes.heading, { level: 2 }), attrs: { level: 2 }, - visible: !isCodeBlock && (!isMobile || isEmpty), + visible: canFormatBlock, }, { name: "heading", @@ -344,7 +332,7 @@ export default function formattingMenuItems( icon: , active: isNodeActive(schema.nodes.heading, { level: 3 }), attrs: { level: 3 }, - visible: !isCodeBlock && (!isMobile || isEmpty), + visible: canFormatBlock, }, { name: "blockquote", @@ -353,7 +341,7 @@ export default function formattingMenuItems( icon: , active: isNodeActive(schema.nodes.blockquote), attrs: { level: 2 }, - visible: !isCodeBlock && !isTableCell && (!isMobile || isEmpty), + visible: !isInCodeBlock && !isTableCell && (!isMobile || isEmpty), }, { name: "separator", @@ -376,7 +364,7 @@ export default function formattingMenuItems( tooltip: t("Toggle block"), active: isNodeActive(schema.nodes.container_toggle), attrs: { id: uuidv4() }, - visible: !isCodeBlock && (!isMobile || isEmpty), + visible: canFormatBlock, }, { name: "separator", @@ -388,7 +376,7 @@ export default function formattingMenuItems( icon: , keywords: "checklist checkbox task", active: isNodeActive(schema.nodes.checkbox_list), - visible: !isCodeBlock && !isTableCell && (!isList || !isTouch), + visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch), }, { name: "bullet_list", @@ -396,7 +384,7 @@ export default function formattingMenuItems( shortcut: `⇧+Ctrl+8`, icon: , active: isNodeActive(schema.nodes.bullet_list), - visible: !isCodeBlock && !isTableCell && (!isList || !isTouch), + visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch), }, { name: "ordered_list", @@ -404,7 +392,7 @@ export default function formattingMenuItems( shortcut: `⇧+Ctrl+9`, icon: , active: isNodeActive(schema.nodes.ordered_list), - visible: !isCodeBlock && !isTableCell && (!isList || !isTouch), + visible: !isInCodeBlock && !isTableCell && (!isList || !isTouch), }, { name: "outdentList", @@ -436,7 +424,7 @@ export default function formattingMenuItems( }, { name: "separator", - visible: !isCodeBlock, + visible: !isInCodeBlock, }, { name: "addLink", @@ -445,14 +433,14 @@ export default function formattingMenuItems( icon: , attrs: { href: "" }, active: isMarkActive(schema.marks.link, undefined, { exact: true }), - visible: !isCodeBlock && (!isMobile || !isEmpty), + visible: canFormatInline, }, { name: "comment", tooltip: t("Comment"), shortcut: `${metaDisplay}+⌥+M`, icon: , - label: isCodeBlock ? t("Comment") : undefined, + label: isInCodeBlock ? t("Comment") : undefined, active: isMarkActive( schema.marks.comment, { resolved: false }, @@ -462,14 +450,14 @@ export default function formattingMenuItems( }, { name: "separator", - visible: isCode && !isCodeBlock && (!isMobile || !isEmpty), + visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty), }, { name: "copyToClipboard", icon: , tooltip: t("Copy"), shortcut: `${metaDisplay}+C`, - visible: isCode && !isCodeBlock && (!isMobile || !isEmpty), + visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty), }, ]; } diff --git a/app/editor/menus/image.tsx b/app/editor/menus/image.tsx index 8b78571346..3f567828cd 100644 --- a/app/editor/menus/image.tsx +++ b/app/editor/menus/image.tsx @@ -10,24 +10,27 @@ import { CommentIcon, LinkIcon, } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; -import type { TFunction } from "i18next"; -import type { MenuItem } from "@shared/editor/types"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; import { metaDisplay } from "@shared/utils/keyboard"; import { ImageSource } from "@shared/editor/lib/FileHelper"; import Desktop from "~/utils/Desktop"; import { isMarkActive } from "@shared/editor/queries/isMarkActive"; +import { t } from "i18next"; +/** + * Returns menu items for the image selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function imageMenuItems( - state: EditorState, - readOnly: boolean, - t: TFunction + ctx: SelectionContext ): MenuItem[] { - if (readOnly) { + if (ctx.readOnly) { return []; } - const { schema } = state; + const { schema, state } = ctx; const isLeftAligned = isNodeActive(schema.nodes.image, { layoutClass: "left-50", }); diff --git a/app/editor/menus/notice.tsx b/app/editor/menus/notice.tsx index 75d52dd1bd..d665de2eac 100644 --- a/app/editor/menus/notice.tsx +++ b/app/editor/menus/notice.tsx @@ -1,4 +1,4 @@ -import type { TFunction } from "i18next"; +import { t } from "i18next"; import { DoneIcon, ExpandedIcon, @@ -6,16 +6,19 @@ import { StarredIcon, WarningIcon, } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; import { NoticeTypes } from "@shared/editor/nodes/Notice"; -import type { MenuItem } from "@shared/editor/types"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; +/** + * Returns menu items for the notice/callout selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function noticeMenuItems( - state: EditorState, - readOnly: boolean | undefined, - t: TFunction + ctx: SelectionContext ): MenuItem[] { - const node = state.selection.$from.node(-1); + const node = ctx.selection.$from.node(-1); const currentStyle = node?.attrs.style as NoticeTypes; const mapping = { @@ -28,7 +31,7 @@ export default function noticeMenuItems( return [ { name: "container_notice", - visible: !readOnly, + visible: !ctx.readOnly, label: mapping[currentStyle], icon: , children: [ diff --git a/app/editor/menus/readOnly.tsx b/app/editor/menus/readOnly.tsx index c29142f666..ac3706fc40 100644 --- a/app/editor/menus/readOnly.tsx +++ b/app/editor/menus/readOnly.tsx @@ -1,20 +1,24 @@ -import type { TFunction } from "i18next"; +import { t } from "i18next"; import { CommentIcon } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; import { isMarkActive } from "@shared/editor/queries/isMarkActive"; -import type { MenuItem } from "@shared/editor/types"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; +/** + * Returns menu items for the read-only selection toolbar. + * + * @param ctx - the current selection context. + * @param canUpdate - whether the user has permission to update the document. + * @returns an array of menu items. + */ export default function readOnlyMenuItems( - state: EditorState, - canUpdate: boolean, - t: TFunction + ctx: SelectionContext, + canUpdate: boolean ): MenuItem[] { - const { schema } = state; - const isEmpty = state.selection.empty; + const { schema } = ctx; return [ { - visible: canUpdate && !isEmpty, + visible: canUpdate && !ctx.isEmpty, name: "comment", tooltip: t("Comment"), label: t("Comment"), diff --git a/app/editor/menus/table.tsx b/app/editor/menus/table.tsx index 9cd4e2bcde..4db3a46fb9 100644 --- a/app/editor/menus/table.tsx +++ b/app/editor/menus/table.tsx @@ -4,21 +4,24 @@ import { TableColumnsDistributeIcon, TrashIcon, } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; -import type { TFunction } from "i18next"; -import type { MenuItem } from "@shared/editor/types"; +import { t } from "i18next"; +import type { MenuItem, SelectionContext } from "@shared/editor/types"; import { TableLayout } from "@shared/editor/types"; +/** + * Returns menu items for the table selection toolbar (full table selected). + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function tableMenuItems( - state: EditorState, - readOnly: boolean, - t: TFunction + ctx: SelectionContext ): MenuItem[] { - if (readOnly) { + if (ctx.readOnly) { return []; } - const { schema } = state; + const { schema, state } = ctx; const isFullWidth = isNodeActive(schema.nodes.table, { layout: TableLayout.fullWidth, diff --git a/app/editor/menus/tableCol.tsx b/app/editor/menus/tableCol.tsx index 1180466c0c..017f88e1f8 100644 --- a/app/editor/menus/tableCol.tsx +++ b/app/editor/menus/tableCol.tsx @@ -14,7 +14,6 @@ import { SortDescendingIcon, TableColumnsDistributeIcon, } from "outline-icons"; -import type { EditorState } from "prosemirror-state"; import { CellSelection, selectedRect } from "prosemirror-tables"; import { isNodeActive } from "@shared/editor/queries/isNodeActive"; import { @@ -24,16 +23,21 @@ import { isMultipleCellSelection, tableHasRowspan, } from "@shared/editor/queries/table"; -import type { TFunction } from "i18next"; -import type { MenuItem, NodeAttrMark } from "@shared/editor/types"; +import { t } from "i18next"; +import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types"; import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon"; import CircleIcon from "~/components/Icons/CircleIcon"; import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker"; import TableCell from "@shared/editor/nodes/TableCell"; import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon"; +import type { EditorState } from "prosemirror-state"; /** - * Get the set of background colors used in a column + * Get the set of background colors used in a column. + * + * @param state - the current editor state. + * @param colIndex - the column index. + * @returns a set of hex color strings. */ function getColumnColors(state: EditorState, colIndex: number): Set { const colors = new Set(); @@ -55,21 +59,23 @@ function getColumnColors(state: EditorState, colIndex: number): Set { return colors; } +/** + * Returns menu items for the table column selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function tableColMenuItems( - state: EditorState, - readOnly: boolean, - t: TFunction, - options: { - index: number; - rtl: boolean; - } + ctx: SelectionContext ): MenuItem[] { - if (readOnly) { + if (ctx.readOnly) { return []; } - const { index, rtl } = options; - const { schema, selection } = state; + const index = ctx.colIndex!; + const rtl = ctx.rtl; + const { schema, state } = ctx; + const { selection } = state; const selectedCols = getAllSelectedColumns(state); if (!(selection instanceof CellSelection)) { diff --git a/app/editor/menus/tableRow.tsx b/app/editor/menus/tableRow.tsx index c0e8542840..f9c2e2e36f 100644 --- a/app/editor/menus/tableRow.tsx +++ b/app/editor/menus/tableRow.tsx @@ -15,8 +15,8 @@ import { isMergedCellSelection, isMultipleCellSelection, } from "@shared/editor/queries/table"; -import type { TFunction } from "i18next"; -import type { MenuItem, NodeAttrMark } from "@shared/editor/types"; +import { t } from "i18next"; +import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types"; import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon"; import CircleIcon from "~/components/Icons/CircleIcon"; import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker"; @@ -24,7 +24,11 @@ import TableCell from "@shared/editor/nodes/TableCell"; import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon"; /** - * Get the set of background colors used in a row + * Get the set of background colors used in a row. + * + * @param state - the current editor state. + * @param rowIndex - the row index. + * @returns a set of hex color strings. */ function getRowColors(state: EditorState, rowIndex: number): Set { const colors = new Set(); @@ -46,19 +50,21 @@ function getRowColors(state: EditorState, rowIndex: number): Set { return colors; } +/** + * Returns menu items for the table row selection toolbar. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ export default function tableRowMenuItems( - state: EditorState, - readOnly: boolean, - t: TFunction, - options: { - index: number; - } + ctx: SelectionContext ): MenuItem[] { - if (readOnly) { + if (ctx.readOnly) { return []; } - const { index } = options; + const index = ctx.rowIndex!; + const { state } = ctx; const { selection } = state; if (!(selection instanceof CellSelection)) { diff --git a/shared/editor/lib/Extension.ts b/shared/editor/lib/Extension.ts index 2639e2501b..4eefcbac48 100644 --- a/shared/editor/lib/Extension.ts +++ b/shared/editor/lib/Extension.ts @@ -3,6 +3,7 @@ import type { InputRule } from "prosemirror-inputrules"; import type { NodeType, MarkType, Schema } from "prosemirror-model"; import type { Command, Plugin, Selection } from "prosemirror-state"; import type { Editor } from "../../../app/editor"; +import type { SelectionToolbarMenuDescriptor } from "../types"; export type CommandFactory = (attrs?: unknown, options?: unknown) => Command; @@ -108,4 +109,15 @@ export default class Extension { }): Record | CommandFactory | undefined { return {}; } + + /** + * Declares selection toolbar menus contributed by this extension. When + * the user selects content or clicks a node, the toolbar evaluates all + * registered menus in priority order and displays the first match. + * + * @returns an array of menu descriptors, or an empty array if this extension does not contribute menus. + */ + selectionToolbarMenus(): SelectionToolbarMenuDescriptor[] { + return []; + } } diff --git a/shared/editor/lib/ExtensionManager.ts b/shared/editor/lib/ExtensionManager.ts index 28172b71fd..c02f7821e4 100644 --- a/shared/editor/lib/ExtensionManager.ts +++ b/shared/editor/lib/ExtensionManager.ts @@ -8,6 +8,7 @@ import type { Primitive } from "utility-types"; import type { Editor } from "~/editor"; import type Mark from "../marks/Mark"; import type Node from "../nodes/Node"; +import type { SelectionToolbarMenuDescriptor } from "../types"; import type { CommandFactory, WidgetProps } from "./Extension"; import type Extension from "./Extension"; import type { AnyExtension, AnyExtensionClass } from "./types"; @@ -235,6 +236,17 @@ export default class ExtensionManager { ); } + /** + * Collects selection toolbar menu descriptors from all extensions and returns + * them sorted by priority (highest first). The toolbar evaluates these in + * order and displays the first match. + */ + get selectionToolbarMenus(): SelectionToolbarMenuDescriptor[] { + return this.extensions + .flatMap((extension) => extension.selectionToolbarMenus()) + .sort((a, b) => b.priority - a.priority); + } + commands({ schema, view }: { schema: Schema; view: EditorView }) { return this.extensions .filter((extension) => extension.commands) diff --git a/shared/editor/lib/buildSelectionContext.ts b/shared/editor/lib/buildSelectionContext.ts new file mode 100644 index 0000000000..b9c49e8b75 --- /dev/null +++ b/shared/editor/lib/buildSelectionContext.ts @@ -0,0 +1,53 @@ +import type { EditorState } from "prosemirror-state"; +import { NodeSelection } from "prosemirror-state"; +import { CellSelection } from "prosemirror-tables"; +import { isInCode } from "../queries/isInCode"; +import { isInList } from "../queries/isInList"; +import { isInNotice } from "../queries/isInNotice"; +import { + getColumnIndex, + getRowIndex, + isTableSelected, +} from "../queries/table"; +import { isMobile as isMobileDevice, isTouchDevice } from "../../utils/browser"; +import type { SelectionContext } from "../types"; + +/** + * Build a SelectionContext from the current editor state and options. This + * object is computed once per toolbar render and shared across all menu + * functions, eliminating repeated queries against the same state. + * + * @param state - the current prosemirror editor state. + * @param options - additional context not derivable from editor state. + * @returns a frozen selection context. + */ +export function buildSelectionContext( + state: EditorState, + options: { readOnly: boolean; isTemplate: boolean; rtl: boolean } +): SelectionContext { + const { selection, schema } = state; + + return { + state, + schema, + selection, + isEmpty: selection.empty, + isMobile: isMobileDevice(), + isTouch: isTouchDevice(), + readOnly: options.readOnly, + isTemplate: options.isTemplate, + rtl: options.rtl, + isInCode: isInCode(state), + isInCodeBlock: isInCode(state, { onlyBlock: true }), + isInList: isInList(state), + isInNotice: isInNotice(state), + isTableCell: selection instanceof CellSelection, + isTableSelected: isTableSelected(state), + selectedNodeType: + selection instanceof NodeSelection + ? selection.node.type.name + : undefined, + colIndex: getColumnIndex(state), + rowIndex: getRowIndex(state), + }; +} diff --git a/shared/editor/nodes/HorizontalRule.ts b/shared/editor/nodes/HorizontalRule.tsx similarity index 65% rename from shared/editor/nodes/HorizontalRule.ts rename to shared/editor/nodes/HorizontalRule.tsx index e72ba415af..86862a6987 100644 --- a/shared/editor/nodes/HorizontalRule.ts +++ b/shared/editor/nodes/HorizontalRule.tsx @@ -1,3 +1,4 @@ +import { t } from "i18next"; import type Token from "markdown-it/lib/token.mjs"; import { InputRule } from "prosemirror-inputrules"; import type { @@ -6,8 +7,11 @@ import type { Node as ProsemirrorNode, } from "prosemirror-model"; import type { Command } from "prosemirror-state"; +import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons"; import type { Primitive } from "utility-types"; +import { isNodeActive } from "../queries/isNodeActive"; import type { MarkdownSerializerState } from "../lib/markdown/serializer"; +import type { SelectionToolbarMenuDescriptor } from "../types"; import Node from "./Node"; export default class HorizontalRule extends Node { @@ -41,6 +45,34 @@ export default class HorizontalRule extends Node { }; } + selectionToolbarMenus(): SelectionToolbarMenuDescriptor[] { + return [ + { + priority: 50, + matches: (ctx) => ctx.selectedNodeType === "hr" && !ctx.readOnly, + getItems: (ctx) => { + const { schema } = ctx; + return [ + { + name: "hr", + tooltip: t("Divider"), + attrs: { markup: "---" }, + active: isNodeActive(schema.nodes.hr, { markup: "---" }), + icon: , + }, + { + name: "hr", + tooltip: t("Page break"), + attrs: { markup: "***" }, + active: isNodeActive(schema.nodes.hr, { markup: "***" }), + icon: , + }, + ]; + }, + }, + ]; + } + keys({ type }: { type: NodeType }): Record { return { "Mod-_": (state, dispatch) => { diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index fed42810ab..2e5fa73848 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -1,6 +1,6 @@ import type { TFunction } from "i18next"; -import type { Node as ProsemirrorNode } from "prosemirror-model"; -import type { EditorState } from "prosemirror-state"; +import type { Node as ProsemirrorNode, Schema } from "prosemirror-model"; +import type { EditorState, Selection } from "prosemirror-state"; import type { Decoration, EditorView } from "prosemirror-view"; import * as React from "react"; import type { DefaultTheme } from "styled-components"; @@ -74,3 +74,77 @@ export interface NodeAttrMark { type: NodeAttrMarkName; attrs?: Record; } + +/** + * Cached selection state computed once per editor update and shared across + * all menu functions. Avoids repeated queries against the same EditorState. + */ +export interface SelectionContext { + /** The current editor state. */ + state: EditorState; + /** The editor schema. */ + schema: Schema; + /** The current selection. */ + selection: Selection; + /** Whether the selection is empty (cursor with no range). */ + isEmpty: boolean; + /** Whether the device is a mobile device. */ + isMobile: boolean; + /** Whether the device supports touch input. */ + isTouch: boolean; + /** Whether the editor is in read-only mode. */ + readOnly: boolean; + /** Whether the document is a template. */ + isTemplate: boolean; + /** Whether text direction is right-to-left. */ + rtl: boolean; + /** Whether the selection is inside inline or block code. */ + isInCode: boolean; + /** Whether the selection is inside a code block (not inline code). */ + isInCodeBlock: boolean; + /** Whether the selection is inside a list. */ + isInList: boolean; + /** Whether the selection is inside a notice/callout block. */ + isInNotice: boolean; + /** Whether the selection is a table cell selection. */ + isTableCell: boolean; + /** Whether the entire table is selected. */ + isTableSelected: boolean; + /** The node type name when a NodeSelection is active, otherwise undefined. */ + selectedNodeType: string | undefined; + /** The selected column index when a column drag handle is active. */ + colIndex: number | undefined; + /** The selected row index when a row drag handle is active. */ + rowIndex: number | undefined; +} + +/** + * Describes a selection toolbar menu contributed by an extension. Extensions + * return this from their `selectionToolbarMenu()` method so the toolbar can + * pick the right menu for the current selection. + */ +export interface SelectionToolbarMenuDescriptor { + /** + * Predicate that returns true when this menu should be shown for the + * current selection. The first matching menu (by priority) wins. + * + * @param ctx - the current selection context. + * @returns whether this menu matches. + */ + matches: (ctx: SelectionContext) => boolean; + /** + * Higher-priority menus are checked first. Built-in menus use priorities + * 0–100. Extensions should use values above 100 to override, or negative + * values to act as fallbacks. + */ + priority: number; + /** Toolbar alignment when this menu is active. Defaults to "center". */ + align?: "center" | "start" | "end"; + /** + * Returns the menu items to display for the current selection. + * + * @param ctx - the current selection context. + * @returns an array of menu items. + */ + getItems: (ctx: SelectionContext) => MenuItem[]; +}