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:
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user