mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
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:
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user