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>
This commit is contained in:
Tom Moor
2026-05-27 20:28:17 -04:00
committed by GitHub
parent e9e13c4819
commit c3ba14f069
18 changed files with 416 additions and 218 deletions
+10 -64
View File
@@ -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<HTMLDivElement | null>(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;
@@ -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);
+6 -1
View File
@@ -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<string, CommandFactory>;
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() {
+11 -8
View File
@@ -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,
});
+10 -6
View File
@@ -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
-33
View File
@@ -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: <HorizontalRuleIcon />,
},
{
name: "hr",
tooltip: t("Page break"),
attrs: { markup: "***" },
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
icon: <PageBreakIcon />,
},
];
}
+34 -46
View File
@@ -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: <BoldIcon />,
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: <ItalicIcon />,
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: <StrikethroughIcon />,
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
visible: canFormatInline,
},
{
tooltip: t("Background color"),
@@ -133,12 +127,10 @@ export default function formattingMenuItems(
) : (
<PaletteIcon />
),
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(
<HighlightIcon />
),
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: <CodeIcon />,
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: <Heading1Icon />,
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: <Heading2Icon />,
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: <Heading3Icon />,
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: <BlockQuoteIcon />,
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: <TodoListIcon />,
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: <BulletedListIcon />,
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: <OrderedListIcon />,
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: <LinkIcon />,
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: <CommentIcon />,
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: <CopyIcon />,
tooltip: t("Copy"),
shortcut: `${metaDisplay}+C`,
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
visible: isInCode && !isInCodeBlock && (!isMobile || !isEmpty),
},
];
}
+11 -8
View File
@@ -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",
});
+11 -8
View File
@@ -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: <ExpandedIcon />,
children: [
+13 -9
View File
@@ -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"),
+11 -8
View File
@@ -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,
+20 -14
View File
@@ -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<string> {
const colors = new Set<string>();
@@ -55,21 +59,23 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
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)) {
+17 -11
View File
@@ -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<string> {
const colors = new Set<string>();
@@ -46,19 +50,21 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
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)) {