Add document highlight colors to highlight menu (#11345)

* Initial plan

* Add document highlight colors to highlight menu

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Add tests for getDocumentHighlightColors function

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* Optimize performance based on code review feedback

Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>

* perf: Prevent calculation on every selection change

* Add the same logic for table background colors

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Copilot
2026-02-04 18:42:27 -05:00
committed by GitHub
parent d2f54502f3
commit 7b2cfbde5b
7 changed files with 309 additions and 117 deletions
+1 -1
View File
@@ -276,7 +276,7 @@ export function SelectionToolbar(props: Props) {
items = filterExcessSeparators(items);
items = items.map((item) => {
if (item.children) {
if (item.children && Array.isArray(item.children)) {
item.children = item.children.map((child) => {
if (child.name === "editImageUrl") {
child.onClick = () => {
+15 -4
View File
@@ -44,6 +44,10 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
}, []);
const items: TMenuItem[] = useMemo(() => {
if (!isOpen) {
return [];
}
const handleClick = (menuItem: MenuItem) => () => {
if (!menuItem.name) {
return;
@@ -60,6 +64,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
}
};
const resolveChildren = (
children: MenuItem[] | (() => MenuItem[]) | undefined
): MenuItem[] | undefined =>
typeof children === "function" ? children() : children;
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
@@ -72,8 +81,9 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
content: child.content,
};
}
if (child.children) {
const childWithPreventClose = child.children.find(
const resolvedChildren = resolveChildren(child.children);
if (resolvedChildren) {
const childWithPreventClose = resolvedChildren.find(
(c) => "preventCloseCondition" in c
);
return {
@@ -82,7 +92,7 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
icon: child.icon,
visible: child.visible,
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(child.children),
items: mapChildren(resolvedChildren),
};
}
return {
@@ -97,7 +107,8 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
};
});
return item.children ? mapChildren(item.children) : [];
const resolvedItemChildren = resolveChildren(item.children);
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
+153 -111
View File
@@ -27,6 +27,7 @@ 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";
@@ -42,6 +43,7 @@ import {
} from "@shared/utils/browser";
import {
getColorSetForSelectedCells,
getDocumentTableBackgroundColors,
hasNodeAttrMarkCellSelection,
hasNodeAttrMarkWithAttrsCellSelection,
isMergedCellSelection,
@@ -132,66 +134,85 @@ export default function formattingMenuItems(
<PaletteIcon />
),
visible: !isCode && (!isMobile || !isEmpty) && isTableCell,
children: [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (cellSelectionHasBackground ? false : true),
attrs: { color: null },
},
...TableCell.presetColors.map((preset) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () =>
hasNodeAttrMarkWithAttrsCellSelection(
state.selection as CellSelection,
"background",
{ color: preset.hex }
),
attrs: { color: preset.hex },
})),
...(selectedCellsColorSet.size === 1 &&
!TableCell.isPresetColor(selectedCellsColorSet.values().next().value)
? [
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)
);
return [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (cellSelectionHasBackground ? false : true),
attrs: { color: null },
},
...TableCell.presetColors.map((preset) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: () =>
hasNodeAttrMarkWithAttrsCellSelection(
state.selection as CellSelection,
"background",
{ color: preset.hex }
),
attrs: { color: preset.hex },
})),
...(selectedCellsColorSet.size === 1 &&
!TableCell.isPresetColor(selectedCellsColorSet.values().next().value)
? [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: selectedCellsColorSet.values().next().value,
icon: (
<CircleIcon
retainColor
color={selectedCellsColorSet.values().next().value}
/>
),
active: () => true,
attrs: { color: selectedCellsColorSet.values().next().value },
},
]
: []),
// Add all other document table background colors
...nonPresetDocumentColors.map((color: string) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => selectedCellsColorSet.has(color),
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: selectedCellsColorSet.values().next().value,
icon: (
<CircleIcon
retainColor
color={selectedCellsColorSet.values().next().value}
content: (
<CellBackgroundColorPicker
command="toggleCellSelectionBackground"
activeColor={
selectedCellsColorSet.size === 1
? selectedCellsColorSet.values().next().value
: ""
}
/>
),
active: () => true,
attrs: { color: selectedCellsColorSet.values().next().value },
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
command="toggleCellSelectionBackground"
activeColor={
selectedCellsColorSet.size === 1
? selectedCellsColorSet.values().next().value
: ""
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
],
},
];
},
},
{
tooltip: dictionary.mark,
@@ -205,65 +226,86 @@ export default function formattingMenuItems(
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
children: [
...(highlight
? [
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) =>
!Highlight.isPresetColor(color) && color !== currentHighlightColor
);
return [
...(highlight
? [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
...Highlight.presetColors.map((preset) => ({
name: "highlight",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: isMarkActive(schema.marks.highlight, { color: preset.hex }),
attrs: { color: preset.hex },
})),
...(highlight &&
highlight.mark.attrs.color &&
!Highlight.isPresetColor(highlight.mark.attrs.color)
? [
{
name: "highlight",
label: highlight.mark.attrs.color,
icon: (
<CircleIcon
retainColor
color={highlight.mark.attrs.color}
/>
),
active: isMarkActive(schema.marks.highlight, {
color: highlight.mark.attrs.color,
}),
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
// Add all other document highlight colors
...nonPresetDocumentColors.map((color: string) => ({
name: "highlight",
label: color,
icon: <CircleIcon retainColor color={color} />,
active: () => currentHighlightColor === color,
attrs: { color },
})),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
name: "highlight",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => false,
attrs: { color: highlight.mark.attrs.color },
},
]
: []),
...Highlight.presetColors.map((preset) => ({
name: "highlight",
label: preset.name,
icon: <CircleIcon retainColor color={preset.hex} />,
active: isMarkActive(schema.marks.highlight, { color: preset.hex }),
attrs: { color: preset.hex },
})),
...(highlight &&
highlight.mark.attrs.color &&
!Highlight.isPresetColor(highlight.mark.attrs.color)
? [
{
name: "highlight",
label: highlight.mark.attrs.color,
icon: (
<CircleIcon
retainColor
color={highlight.mark.attrs.color}
content: (
<HighlightColorPicker
activeColor={
highlight?.mark.attrs.color ||
Highlight.presetColors[0].hex
}
/>
),
active: isMarkActive(schema.marks.highlight, {
color: highlight.mark.attrs.color,
}),
attrs: { color: highlight.mark.attrs.color },
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<HighlightColorPicker
activeColor={
highlight?.mark.attrs.color || Highlight.presetColors[0].hex
}
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
],
},
];
},
},
{
name: "code_inline",
@@ -0,0 +1,94 @@
import { schema, createEditorState, doc, p } from "@shared/test/editor";
import { getDocumentHighlightColors } from "./getDocumentHighlightColors";
describe("getDocumentHighlightColors", () => {
it("returns empty array when no highlights exist", () => {
const testDoc = doc([p("Plain text without highlights")]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toEqual([]);
});
it("returns unique highlight colors from document", () => {
// Create text with highlight marks
const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" });
const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" });
const text1 = schema.text("Highlighted text 1", [highlightMark1]);
const text2 = schema.text(" and more ", [highlightMark2]);
const text3 = schema.text("and again", [highlightMark1]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [text1, text2, text3])
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(2);
expect(colors).toContain("#FDEA9B");
expect(colors).toContain("#FED46A");
});
it("deduplicates colors used multiple times", () => {
const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" });
const text1 = schema.text("First highlight", [highlightMark]);
const text2 = schema.text("Second highlight", [highlightMark]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [text1]),
schema.nodes.paragraph.create(null, [text2])
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(1);
expect(colors).toContain("#FDEA9B");
});
it("returns multiple different colors from multiple paragraphs", () => {
const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" });
const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" });
const highlightMark3 = schema.marks.highlight.create({ color: "#FA551E" });
const text1 = schema.text("First paragraph", [highlightMark1]);
const text2 = schema.text("Second paragraph", [highlightMark2]);
const text3 = schema.text("Third paragraph", [highlightMark3]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [text1]),
schema.nodes.paragraph.create(null, [text2]),
schema.nodes.paragraph.create(null, [text3])
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(3);
expect(colors).toContain("#FDEA9B");
expect(colors).toContain("#FED46A");
expect(colors).toContain("#FA551E");
});
it("ignores text with other marks but no highlight", () => {
const boldMark = schema.marks.strong.create();
const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" });
const boldText = schema.text("Bold text", [boldMark]);
const highlightedText = schema.text("Highlighted text", [highlightMark]);
const testDoc = doc([
schema.nodes.paragraph.create(null, [boldText, highlightedText])
]);
const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(1);
expect(colors).toContain("#FDEA9B");
});
});
@@ -0,0 +1,22 @@
import type { EditorState } from "prosemirror-state";
/**
* Get all unique highlight colors used in the document.
*
* @param state The editor state.
* @returns An array of unique hex color strings used for highlights in the document.
*/
export function getDocumentHighlightColors(state: EditorState): string[] {
const colors = new Set<string>();
state.doc.descendants((node) => {
if (node.isText) {
const highlightMark = node.marks.find((mark) => mark.type.name === "highlight");
if (highlightMark?.attrs.color) {
colors.add(highlightMark.attrs.color);
}
}
});
return Array.from(colors);
}
+23
View File
@@ -492,6 +492,29 @@ export function getColorSetForSelectedCells(selection: Selection): Set<string> {
return colors;
}
/**
* Get all unique background colors used in table cells across the entire document.
*
* @param state The editor state.
* @returns An array of unique hex color strings used for table cell backgrounds in the document.
*/
export function getDocumentTableBackgroundColors(state: EditorState): string[] {
const colors = new Set<string>();
state.doc.descendants((node) => {
if (node.type.name === "td" || node.type.name === "th") {
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
}
});
return Array.from(colors);
}
/**
* Returns true if any cell in the selection has a mark of the given type
* with matching attributes.
+1 -1
View File
@@ -32,7 +32,7 @@ export type MenuItem = {
dangerous?: boolean;
/** Higher number is higher in results, default is 0. */
priority?: number;
children?: MenuItem[];
children?: MenuItem[] | (() => MenuItem[]);
defaultHidden?: boolean;
attrs?:
| Record<string, Primitive | null>