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[]; +}