mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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<TOptions extends object = object> {
|
||||
}): Record<string, CommandFactory> | 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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: <HorizontalRuleIcon />,
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
tooltip: t("Page break"),
|
||||
attrs: { markup: "***" },
|
||||
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
|
||||
icon: <PageBreakIcon />,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
keys({ type }: { type: NodeType }): Record<string, Command> {
|
||||
return {
|
||||
"Mod-_": (state, dispatch) => {
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user