Choose table cell background (#10930)

* feat: table cell bgcolor

* fix: review

* fix: cleanup

* fix: new color picker

* fix: transparentize bg preset colors

* fix: show selected color in color list

* fix: pass active color to picker

* fix: make color picker command agnostic

* fix: tsc

* fix: table row and col background menu

* getColorSetForSelectedCells

* toggleCellBackground to toggleCellSelectionBackground

* cellHasBackground to cellSelectionHasBackground

* useless spread

* presetColors

* get rid of hasMultipleColors

* get rid of customColor

* be explicit in passing color

* alpha controls

* remove new highligh command

* DRY DottedCircleIcon

* restore ff fix

* merge createCellBackground into updateCelllBackground

* default color

* Merge

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Apoorv Mishra
2026-01-25 08:43:00 +05:30
committed by GitHub
parent 4b146de583
commit abd7abcc18
29 changed files with 1028 additions and 74 deletions
+21 -3
View File
@@ -13,17 +13,35 @@ export default function CircleIcon({
retainColor,
...rest
}: Props) {
const isGradient = color === "rainbow";
const fillValue = isGradient ? "url(#circleIconGradient)" : color;
return (
<svg
fill={color}
fill={fillValue}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: color } : undefined}
style={retainColor ? { fill: fillValue } : undefined}
{...rest}
>
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
{isGradient && (
<defs>
<linearGradient
id="circleIconGradient"
x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop offset="0%" stopColor="#ff5858" />
<stop offset="50%" stopColor="#fbcc34" />
<stop offset="100%" stopColor="#00c6ff" />
</linearGradient>
</defs>
)}
<circle cx="12" cy="12" r="8" />
</svg>
);
}
@@ -0,0 +1,9 @@
import styled from "styled-components";
import CircleIcon from "./CircleIcon";
export const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+18 -1
View File
@@ -27,6 +27,7 @@ export function toMenuItems(items: MenuItem[]) {
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -84,6 +85,12 @@ export function toMenuItems(items: MenuItem[]) {
return null;
}
const preventCloseHandler = (ev: Event) => {
if (item.preventCloseCondition && item.preventCloseCondition()) {
ev.preventDefault();
}
};
return (
<SubMenu key={`${item.type}-${item.title}-${index}`}>
<SubMenuTrigger
@@ -91,7 +98,10 @@ export function toMenuItems(items: MenuItem[]) {
icon={icon}
disabled={item.disabled}
/>
<SubMenuContent ref={parentRef}>
<SubMenuContent
ref={parentRef}
onFocusOutside={preventCloseHandler}
>
<MouseSafeArea parentRef={parentRef} />
{submenuItems}
</SubMenuContent>
@@ -118,6 +128,9 @@ export function toMenuItems(items: MenuItem[]) {
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
@@ -140,6 +153,7 @@ export function toMobileMenuItems(
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
item.type !== "custom" &&
!!item.icon
);
@@ -249,6 +263,9 @@ export function toMobileMenuItems(
case "separator":
return <Components.MenuSeparator key={`${item.type}-${index}`} />;
case "custom":
return <div key={`${item.type}-${index}`}>{item.content}</div>;
default:
return null;
}
@@ -0,0 +1,26 @@
import { useCallback } from "react";
import ColorPicker from "./ColorPicker";
import { useEditor } from "./EditorContext";
type Props = {
/** The currently active color */
activeColor: string;
command: string;
};
function CellBackgroundColorPicker({ activeColor, command }: Props) {
const { commands } = useEditor();
const handleSelect = useCallback(
(color: string) => {
if (commands[command]) {
commands[command]({ color });
}
},
[commands, command]
);
return <ColorPicker activeColor={activeColor} onSelect={handleSelect} />;
}
export default CellBackgroundColorPicker;
+166
View File
@@ -0,0 +1,166 @@
import copy from "copy-to-clipboard";
import debounce from "lodash/debounce";
import { CheckmarkIcon, CopyIcon } from "outline-icons";
import { useMemo, useState, useEffect, useRef, useCallback } from "react";
import { HexColorInput, HexAlphaColorPicker } from "react-colorful";
import styled, { useTheme } from "styled-components";
import { s } from "@shared/styles";
import { darken } from "polished";
type Props = {
onSelect: (color: string) => void;
/** The currently active color */
activeColor?: string | null;
};
const DEFAULT_COLOR = "#7e3d3db3";
function ColorPicker({ activeColor, onSelect }: Props) {
const [color, setColor] = useState(activeColor || DEFAULT_COLOR);
const [copied, setCopied] = useState(false);
const theme = useTheme();
const wrapperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const applyColor = useCallback(
(newColor: string) => {
const activeElement = document.activeElement as HTMLElement | null;
const hadFocusInside = wrapperRef.current?.contains(activeElement);
onSelect(newColor);
if (hadFocusInside && activeElement) {
activeElement.focus();
}
},
[onSelect]
);
const debouncedApplyColor = useMemo(
() => debounce(applyColor, 250),
[applyColor]
);
useEffect(
() => () => {
debouncedApplyColor.cancel();
},
[debouncedApplyColor]
);
const handleColorChangePicker = (newColor: string) => {
setColor(newColor);
debouncedApplyColor(newColor);
};
const handleColorChangeInput = (newColor: string) => {
setColor(newColor);
applyColor(newColor);
};
const handleCopy = useCallback(() => {
copy(color);
buttonRef.current?.focus();
setCopied(true);
setTimeout(() => setCopied(false), 500);
}, [color]);
return (
<Wrapper ref={wrapperRef} tabIndex={-1}>
<StyledHexAlphaColorPicker
color={color}
onChange={handleColorChangePicker}
/>
<InputRow>
<StyledHexColorInput
color={color}
onChange={handleColorChangeInput}
prefixed
alpha
/>
<CopyButton ref={buttonRef} onClick={handleCopy} type="button">
{copied ? (
<CheckmarkIcon size={16} color={darken(0.2, theme.brand.green)} />
) : (
<CopyIcon size={16} />
)}
</CopyButton>
</InputRow>
</Wrapper>
);
}
const Wrapper = styled.div`
padding: 8px;
display: flex;
flex-direction: column;
gap: 16px;
`;
const StyledHexAlphaColorPicker = styled(HexAlphaColorPicker)`
&.react-colorful {
width: auto;
& > .react-colorful__saturation {
border-radius: 4px 4px 0 0;
}
& .react-colorful__pointer {
width: 14px;
height: 14px;
}
& .react-colorful__interactive:focus .react-colorful__pointer {
transform: translate(-50%, -50%) scale(1.25);
}
& > .react-colorful__hue {
height: 8px;
border-radius: 0 0 4px 4px;
margin-bottom: 16px;
}
& > .react-colorful__alpha {
height: 8px;
border-radius: 4px;
}
}
`;
const InputRow = styled.div`
display: flex;
gap: 4px;
`;
const StyledHexColorInput = styled(HexColorInput)`
padding: 4px 6px;
border: 1px solid ${s("inputBorder")};
border-radius: 4px;
font-size: 12px;
background: ${s("background")};
color: ${s("text")};
&:focus {
outline: none;
border-color: ${s("accent")};
}
`;
const CopyButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: 1px solid ${s("inputBorder")};
border-radius: 4px;
background: ${s("background")};
color: ${s("textSecondary")};
cursor: pointer;
&:hover {
background: ${s("backgroundSecondary")};
color: ${s("text")};
}
`;
export default ColorPicker;
+42 -17
View File
@@ -35,9 +35,13 @@ type ToolbarDropdownProps = {
function ToolbarDropdown(props: ToolbarDropdownProps) {
const { commands, view } = useEditor();
const { t } = useTranslation();
const { item, tooltip, shortcut } = props;
const { item, shortcut, tooltip } = props;
const { state } = view;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const handleOpenChange = useCallback((open: boolean) => {
setIsOpen(open);
}, []);
const items: TMenuItem[] = useMemo(() => {
const handleClick = (menuItem: MenuItem) => () => {
@@ -56,33 +60,54 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
}
};
return item.children
? item.children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
children.map((child) => {
if (child.name === "separator") {
return { type: "separator", visible: child.visible };
}
if ("content" in child) {
return {
type: "button",
type: "custom",
visible: child.visible,
content: child.content,
};
}
if (child.children) {
const childWithPreventClose = child.children.find(
(c) => "preventCloseCondition" in c
);
return {
type: "submenu",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
items: mapChildren(child.children),
};
})
: [];
}, [item.children, commands, state]);
}
return {
type: "button",
title: child.label,
icon: child.icon,
dangerous: child.dangerous,
visible: child.visible,
selected:
child.active !== undefined ? child.active(state) : undefined,
onClick: handleClick(child),
};
});
return item.children ? mapChildren(item.children) : [];
}, [isOpen, commands]);
const handleCloseAutoFocus = useCallback((ev: Event) => {
ev.stopImmediatePropagation();
}, []);
return (
<Tooltip shortcut={shortcut} content={tooltip} disabled={isMenuOpen}>
<Tooltip shortcut={shortcut} content={tooltip} disabled={isOpen}>
<MenuProvider variant="dropdown">
<Menu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<Menu open={isOpen} onOpenChange={handleOpenChange}>
<MenuTrigger>
<ToolbarButton aria-label={item.label ? undefined : item.tooltip}>
{item.label && <Label>{item.label}</Label>}
+94 -10
View File
@@ -19,9 +19,10 @@ import {
Heading3Icon,
TableMergeCellsIcon,
TableSplitCellsIcon,
PaletteIcon,
} from "outline-icons";
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
import type { EditorState } from "prosemirror-state";
import styled from "styled-components";
import Highlight from "@shared/editor/marks/Highlight";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import { isInCode } from "@shared/editor/queries/isInCode";
@@ -37,10 +38,15 @@ import {
isTouchDevice,
} from "@shared/utils/browser";
import {
getColorSetForSelectedCells,
hasNodeAttrMarkCellSelection,
hasNodeAttrMarkWithAttrsCellSelection,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import { CellSelection } from "prosemirror-tables";
import TableCell from "@shared/editor/nodes/TableCell";
import { DottedCircleIcon } from "~/components/Icons/DottedCircleIcon";
export default function formattingMenuItems(
state: EditorState,
@@ -60,7 +66,16 @@ export default function formattingMenuItems(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
).find(({ mark }) => mark.type === state.schema.marks.highlight);
const cellSelectionHasBackground = isTableCell
? hasNodeAttrMarkCellSelection(
state.selection as CellSelection,
"background"
)
: false;
const selectedCellsColorSet = getColorSetForSelectedCells(state.selection);
return [
{
@@ -98,6 +113,82 @@ export default function formattingMenuItems(
active: isMarkActive(schema.marks.strikethrough),
visible: !isCodeBlock && (!isMobile || !isEmpty),
},
{
tooltip: dictionary.background,
icon:
getColorSetForSelectedCells(state.selection).size > 1 ? (
<CircleIcon color="rainbow" />
) : getColorSetForSelectedCells(state.selection).size === 1 ? (
<CircleIcon
color={
getColorSetForSelectedCells(state.selection).values().next().value
}
/>
) : (
<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((color, index) => ({
name: "toggleCellSelectionBackgroundAndCollapseSelection",
label: TableCell.presetColorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: () =>
hasNodeAttrMarkWithAttrsCellSelection(
state.selection as CellSelection,
"background",
{ color }
),
attrs: { color },
})),
...(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 },
},
]
: []),
{
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,
shortcut: `${metaDisplay}+⇧+H`,
@@ -107,7 +198,7 @@ export default function formattingMenuItems(
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty),
visible: !isCode && (!isMobile || !isEmpty) && !isTableCell,
children: [
...(highlight
? [
@@ -287,10 +378,3 @@ export default function formattingMenuItems(
},
];
}
const DottedCircleIcon = styled(CircleIcon)`
circle {
stroke: ${(props) => props.theme.textSecondary};
stroke-dasharray: 2, 2;
}
`;
+96 -1
View File
@@ -6,6 +6,7 @@ import {
InsertLeftIcon,
InsertRightIcon,
MoreIcon,
PaletteIcon,
TableHeaderColumnIcon,
TableMergeCellsIcon,
TableSplitCellsIcon,
@@ -18,12 +19,40 @@ import { CellSelection, selectedRect } from "prosemirror-tables";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import {
getAllSelectedColumns,
getCellsInColumn,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
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";
/**
* Get the set of background colors used in a column
*/
function getColumnColors(state: EditorState, colIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInColumn(colIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableColMenuItems(
state: EditorState,
@@ -47,6 +76,14 @@ export default function tableColMenuItems(
}
const tableMap = selectedRect(state);
const colColors = getColumnColors(state, index);
const hasBackground = colColors.size > 0;
const activeColor =
colColors.size === 1 ? colColors.values().next().value : null;
const customColor =
colColors.size === 1 && !TableCell.presetColors.includes(activeColor)
? activeColor
: undefined;
return [
{
@@ -100,6 +137,64 @@ export default function tableColMenuItems(
{
name: "separator",
},
{
tooltip: dictionary.background,
icon:
colColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : colColors.size === 1 ? (
<CircleIcon color={colColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((color, colorIndex) => ({
name: "toggleColumnBackgroundAndCollapseSelection",
label: TableCell.presetColorNames[colorIndex],
icon: <CircleIcon retainColor color={color} />,
active: () => colColors.size === 1 && colColors.has(color),
attrs: { color },
})),
...(customColor
? [
{
name: "toggleColumnBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleColumnBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+96 -1
View File
@@ -3,6 +3,7 @@ import {
InsertAboveIcon,
InsertBelowIcon,
MoreIcon,
PaletteIcon,
TableHeaderRowIcon,
TableSplitCellsIcon,
TableMergeCellsIcon,
@@ -10,12 +11,40 @@ import {
import type { EditorState } from "prosemirror-state";
import { CellSelection, selectedRect } from "prosemirror-tables";
import {
getCellsInRow,
isMergedCellSelection,
isMultipleCellSelection,
} from "@shared/editor/queries/table";
import type { MenuItem } from "@shared/editor/types";
import type { MenuItem, NodeAttrMark } from "@shared/editor/types";
import type { Dictionary } from "~/hooks/useDictionary";
import { ArrowDownIcon, ArrowUpIcon } 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";
/**
* Get the set of background colors used in a row
*/
function getRowColors(state: EditorState, rowIndex: number): Set<string> {
const colors = new Set<string>();
const cells = getCellsInRow(rowIndex)(state) || [];
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const backgroundMark = (node.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
export default function tableRowMenuItems(
state: EditorState,
@@ -37,8 +66,74 @@ export default function tableRowMenuItems(
}
const tableMap = selectedRect(state);
const rowColors = getRowColors(state, index);
const hasBackground = rowColors.size > 0;
const activeColor =
rowColors.size === 1 ? rowColors.values().next().value : null;
const customColor =
rowColors.size === 1
? [...rowColors].find((c) => !TableCell.presetColors.includes(c))
: undefined;
return [
{
tooltip: dictionary.background,
icon:
rowColors.size > 1 ? (
<CircleIcon color="rainbow" />
) : rowColors.size === 1 ? (
<CircleIcon color={rowColors.values().next().value} />
) : (
<PaletteIcon />
),
children: [
...[
{
name: "toggleRowBackgroundAndCollapseSelection",
label: dictionary.none,
icon: <DottedCircleIcon retainColor color="transparent" />,
active: () => (hasBackground ? false : true),
attrs: { color: null },
},
],
...TableCell.presetColors.map((color, colorIndex) => ({
name: "toggleRowBackgroundAndCollapseSelection",
label: TableCell.presetColorNames[colorIndex],
icon: <CircleIcon retainColor color={color} />,
active: () => rowColors.size === 1 && rowColors.has(color),
attrs: { color },
})),
...(customColor
? [
{
name: "toggleRowBackgroundAndCollapseSelection",
label: customColor,
icon: <CircleIcon retainColor color={customColor} />,
active: () => true,
attrs: { color: customColor },
},
]
: []),
{
icon: <CircleIcon retainColor color="rainbow" />,
label: "Custom",
children: [
{
content: (
<CellBackgroundColorPicker
activeColor={activeColor}
command="toggleRowBackground"
/>
),
preventCloseCondition: () =>
!!document.activeElement?.matches(
".ProseMirror.ProseMirror-focused"
),
},
],
},
],
},
{
icon: <MoreIcon />,
children: [
+1
View File
@@ -69,6 +69,7 @@ export default function useDictionary() {
link: t("Link"),
linkCopied: t("Link copied to clipboard"),
mark: t("Highlight"),
background: t("Background color"),
newLineEmpty: `${t("Type '/' to insert")}`,
newLineWithSlash: `${t("Keep typing to filter")}`,
noResults: t("No results"),
+10 -2
View File
@@ -37,7 +37,8 @@ export type MenuItemWithChildren = {
disabled?: boolean;
style?: React.CSSProperties;
hover?: boolean;
/** Condition to check before preventing the submenu from closing */
preventCloseCondition?: () => boolean;
items: MenuItem[];
icon?: React.ReactNode;
};
@@ -82,6 +83,12 @@ export type MenuGroup = {
items: MenuItem[];
};
export type MenuCustomContent = {
type: "custom";
visible?: boolean;
content: React.ReactNode;
};
export type MenuItem =
| MenuInternalLink
| MenuItemButton
@@ -89,7 +96,8 @@ export type MenuItem =
| MenuItemWithChildren
| MenuSeparator
| MenuHeading
| MenuGroup;
| MenuGroup
| MenuCustomContent;
export type ActionContext = {
isMenu: boolean;
+1
View File
@@ -218,6 +218,7 @@
"react": "^17.0.2",
"react-avatar-editor": "^13.0.2",
"react-color": "^2.17.3",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
+5 -5
View File
@@ -6,7 +6,7 @@ import { getMarkRange } from "../queries/getMarkRange";
import { toast } from "sonner";
import { sanitizeUrl } from "@shared/utils/urls";
import { getMarkRangeNodeSelection } from "../queries/getMarkRange";
import type { NodeMarkAttr } from "@shared/editor/types";
import type { NodeAttrMark } from "@shared/editor/types";
const addLinkTextSelection =
(attrs: Attrs): Command =>
@@ -85,7 +85,7 @@ const openLinkNodeSelection =
}
const marks = state.selection.node.attrs.marks ?? [];
const linkMark = marks.find((mark: NodeMarkAttr) => mark.type === "link");
const linkMark = marks.find((mark: NodeAttrMark) => mark.type === "link");
if (!linkMark) {
return false;
}
@@ -139,7 +139,7 @@ const updateLinkNodeSelection =
}
const existingMarks = state.selection.node.attrs.marks ?? [];
const updatedMarks = existingMarks.map((mark: NodeMarkAttr) =>
const updatedMarks = existingMarks.map((mark: NodeAttrMark) =>
mark.type === "link"
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
@@ -189,7 +189,7 @@ const removeLinkNodeSelection = (): Command => (state, dispatch) => {
const existingMarks = state.selection.node.attrs.marks ?? [];
const updatedMarks = existingMarks.filter(
(mark: NodeMarkAttr) => mark.type !== "link"
(mark: NodeAttrMark) => mark.type !== "link"
);
const nextValidSelection =
@@ -222,7 +222,7 @@ const toggleLinkNodeSelection =
const existingMarks = state.selection.node.attrs.marks ?? [];
const linkMark = existingMarks.find(
(mark: NodeMarkAttr) => mark.type === "link"
(mark: NodeAttrMark) => mark.type === "link"
);
if (linkMark) {
return removeLinkNodeSelection()(state, dispatch);
+184 -1
View File
@@ -29,11 +29,16 @@ import {
isTableSelected,
getWidthFromDom,
getWidthFromNodes,
getRowIndex,
getColumnIndex,
} from "../queries/table";
import { TableLayout } from "../types";
import { type NodeAttrMark, TableLayout } from "../types";
import { collapseSelection } from "./collapseSelection";
import { RowSelection } from "../selection/RowSelection";
import { ColumnSelection } from "../selection/ColumnSelection";
import type { Attrs } from "prosemirror-model";
import isUndefined from "lodash/isUndefined";
import find from "lodash/find";
export function createTable({
rowsCount,
@@ -872,3 +877,181 @@ function addRowWithAlignment(
export function mergeCellsAndCollapse(): Command {
return chainTransactions(mergeCells, collapseSelection());
}
const updateCellBackground = (
cell: Node,
pos: number,
attrs: Attrs,
tr: Transaction
): Transaction => {
const existingMarks = cell.attrs.marks ?? [];
const backgroundMark = find(existingMarks, (m) => m.type === "background");
const updatedMarks = !backgroundMark
? [...existingMarks, { type: "background", attrs }]
: existingMarks.map((mark: NodeAttrMark) =>
mark.type === "background"
? { ...mark, attrs: { ...mark.attrs, ...attrs } }
: mark
);
return tr.setNodeAttribute(pos, "marks", updatedMarks);
};
const removeCellBackground = (
cell: Node,
pos: number,
tr: Transaction
): Transaction => {
const existingMarks = cell.attrs.marks ?? [];
const updatedMarks = existingMarks.filter(
(mark: NodeAttrMark) => mark.type !== "background"
);
return tr.setNodeAttribute(pos, "marks", updatedMarks);
};
export const toggleCellSelectionBackgroundAndCollapseSelection = ({
color,
}: {
color: string | null;
}) =>
chainTransactions(
toggleCellSelectionBackground({ color }),
collapseSelection()
);
export const toggleRowBackgroundAndCollapseSelection = ({
color,
}: {
color: string | null;
}) => chainTransactions(toggleRowBackground({ color }), collapseSelection());
export const toggleColumnBackgroundAndCollapseSelection = ({
color,
}: {
color: string | null;
}) => chainTransactions(toggleColumnBackground({ color }), collapseSelection());
export const toggleCellSelectionBackground =
({ color }: { color: string | null }): Command =>
(state, dispatch) => {
if (!(state.selection instanceof CellSelection)) {
return false;
}
let tr = state.tr;
state.selection.forEachCell((cell, pos) => {
if (color === null) {
tr = removeCellBackground(cell, pos, tr);
} else {
tr = updateCellBackground(cell, pos, { color }, tr);
}
});
dispatch?.(tr);
return true;
};
/**
* Set background color on all cells in a row.
*
* @param color The background color to set, or null to remove
* @returns The command
*/
export function toggleRowBackground({
color,
}: {
color: string | null;
}): Command {
return (state, dispatch) => {
if (!isInTable(state)) {
return false;
}
const rowIndex = getRowIndex(state);
if (isUndefined(rowIndex)) {
return false;
}
if (dispatch) {
const cells = getCellsInRow(rowIndex)(state) || [];
let tr = state.tr;
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
if (color === null) {
tr = removeCellBackground(node, pos, tr);
} else {
tr = updateCellBackground(node, pos, { color }, tr);
}
});
// It was noticed that the selection went to the last table cell of the
// row after command execution.
// Instead, we want to preserve the original row selection so that the color
// picker can be prevented from closing.
const rect = selectedRect(state);
const pos = rect.map.positionAt(rowIndex, 0, rect.table);
const $pos = tr.doc.resolve(rect.tableStart + pos);
tr.setSelection(RowSelection.rowSelection($pos, $pos, rowIndex));
dispatch(tr);
}
return true;
};
}
/**
* Set background color on all cells in a column.
*
* @param color The background color to set, or null to remove
* @returns The command
*/
export function toggleColumnBackground({
color,
}: {
color: string | null;
}): Command {
return (state, dispatch) => {
if (!isInTable(state)) {
return false;
}
const colIndex = getColumnIndex(state);
if (isUndefined(colIndex)) {
return false;
}
if (dispatch) {
const cells = getCellsInColumn(colIndex)(state) || [];
let tr = state.tr;
cells.forEach((pos) => {
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
if (color === null) {
tr = removeCellBackground(node, pos, tr);
} else {
tr = updateCellBackground(node, pos, { color }, tr);
}
});
// It was noticed that the selection went to the last table cell of the column
// after command execution.
// Instead, we want to preserve the original column selection so that the color
// picker can be prevented from closing
const rect = selectedRect(state);
const pos = rect.map.positionAt(0, colIndex, rect.table);
const $pos = tr.doc.resolve(rect.tableStart + pos);
tr.setSelection(ColumnSelection.colSelection($pos));
dispatch(tr);
}
return true;
};
}
+20 -4
View File
@@ -1891,13 +1891,29 @@ table {
padding: 4px 0;
}
td[data-bgcolor] {
color: var(--cell-text-color);
p, a, p a {
color: var(--cell-text-color, inherit);
}
a, p a {
text-decoration: underline;
text-decoration-color: var(--cell-text-color, inherit);
}
}
.selectedCell {
background: ${
props.readOnly ? "inherit" : props.theme.tableSelectedBackground
};
${
props.readOnly
? "background: inherit;"
: `/* Using box-shadow inset instead of background to allow overlay on cell background colors */
box-shadow: inset 0 0 0 9999px ${props.theme.tableSelectedBackground};`
}
/* fixes Firefox background color painting over border:
* https://bugzilla.mozilla.org/show_bug.cgi?id=688556 */
* https://bugzilla.mozilla.org/show_bug.cgi?id=688556 */
background-clip: padding-box;
}
+14 -6
View File
@@ -41,12 +41,16 @@ export default class TrailingNode extends Extension {
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild;
// If paragraph has no text (only images/media), add trailing node
if (lastNode?.type.name === "paragraph" && lastNode.content.size > 0 && lastNode.textContent.length === 0) {
if (
lastNode?.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
},
apply: (tr, value) => {
@@ -55,12 +59,16 @@ export default class TrailingNode extends Extension {
}
const lastNode = tr.doc.lastChild;
// If paragraph has no text (only images/media), add trailing node
if (lastNode?.type.name === "paragraph" && lastNode.content.size > 0 && lastNode.textContent.length === 0) {
if (
lastNode?.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
},
},
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NodeType, MarkType, Schema } from "prosemirror-model";
import type { Command, Plugin, Selection } from "prosemirror-state";
import type { Editor } from "../../../app/editor";
export type CommandFactory = (attrs?: unknown) => Command;
export type CommandFactory = (attrs?: unknown, options?: unknown) => Command;
export type WidgetProps = {
rtl: boolean;
+24 -1
View File
@@ -1,7 +1,8 @@
import type { Attrs, Node } from "prosemirror-model";
import type { MutableAttrs } from "prosemirror-tables";
import { isBrowser } from "../../utils/browser";
import type { TableLayout } from "../types";
import type { TableLayout, NodeAttrMark } from "../types";
import { readableColor } from "polished";
export interface TableAttrs {
layout: TableLayout | null;
@@ -12,6 +13,7 @@ export interface CellAttrs {
rowspan: number;
colwidth: number[] | null;
alignment: "center" | "left" | "right" | null;
marks?: NodeAttrMark[];
}
/**
@@ -42,6 +44,16 @@ export function getCellAttrs(dom: HTMLElement | string): Attrs {
: dom.style.textAlign === "right"
? "right"
: null,
marks: dom.getAttribute("data-bgcolor")
? [
{
type: "background",
attrs: {
color: dom.getAttribute("data-bgcolor"),
},
},
]
: undefined,
} satisfies CellAttrs;
}
@@ -70,6 +82,17 @@ export function setCellAttrs(node: Node): Attrs {
(attrs.style ?? "") + `min-width: ${Number(node.attrs.colwidth[0])}px;`;
}
}
if (node.attrs.marks) {
const backgroundMark = node.attrs.marks.find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark) {
attrs["data-bgcolor"] = backgroundMark.attrs.color;
attrs.style =
(attrs.style ?? "") +
`background-color: ${backgroundMark.attrs.color}; --cell-text-color: ${readableColor(backgroundMark.attrs.color)};`;
}
}
return attrs;
}
+3 -16
View File
@@ -4,27 +4,14 @@ import type { MarkSpec, MarkType } from "prosemirror-model";
import { markInputRuleForPattern } from "../lib/markInputRule";
import markRule from "../rules/mark";
import Mark from "./Mark";
import { presetColorNames, presetColors } from "../presetColors";
export default class Highlight extends Mark {
/** The colors that can be used for highlighting */
static colors = [
"#FDEA9B",
"#FED46A",
"#FA551E",
"#B4DC19",
"#C8AFF0",
"#3CBEFC",
];
static colors = presetColors;
/** The names of the colors that can be used for highlighting, must match length of array above */
static colorNames = [
"Coral",
"Apricot",
"Sunset",
"Smoothie",
"Bubblegum",
"Neon",
];
static colorNames = presetColorNames;
/** The default opacity of the highlight */
static opacity = 0.4;
+12
View File
@@ -33,6 +33,12 @@ import {
deleteTableIfSelected,
splitCellAndCollapse,
mergeCellsAndCollapse,
toggleColumnBackground,
toggleRowBackground,
toggleCellSelectionBackground,
toggleCellSelectionBackgroundAndCollapseSelection,
toggleRowBackgroundAndCollapseSelection,
toggleColumnBackgroundAndCollapseSelection,
} from "../commands/table";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { FixTablesPlugin } from "../plugins/FixTablesPlugin";
@@ -100,6 +106,12 @@ export default class Table extends Node {
toggleHeaderRow: () => toggleHeader("row"),
mergeCells: () => mergeCellsAndCollapse(),
splitCell: () => splitCellAndCollapse(),
toggleRowBackground,
toggleRowBackgroundAndCollapseSelection,
toggleColumnBackground,
toggleColumnBackgroundAndCollapseSelection,
toggleCellSelectionBackground,
toggleCellSelectionBackgroundAndCollapseSelection,
};
}
+17
View File
@@ -10,8 +10,22 @@ import { Decoration, DecorationSet } from "prosemirror-view";
import { TableMap } from "prosemirror-tables";
import { getCellAttrs, setCellAttrs } from "../lib/table";
import Node from "./Node";
import { presetColorNames, presetColors } from "../presetColors";
import { parseToRgb, transparentize } from "polished";
import { rgbaToHex } from "@shared/utils/color";
import type { RgbaColor } from "polished/lib/types/color";
export default class TableCell extends Node {
static presetColors = presetColors.map((color) =>
rgbaToHex(parseToRgb(transparentize(0.3, color)) as RgbaColor)
);
static presetColorNames = presetColorNames;
static isPresetColor(color: string) {
return TableCell.presetColors.includes(color);
}
get name() {
return "td";
}
@@ -31,6 +45,9 @@ export default class TableCell extends Node {
rowspan: { default: 1 },
alignment: { default: null },
colwidth: { default: null },
marks: {
default: undefined,
},
},
};
}
+3
View File
@@ -207,6 +207,9 @@ export default class TableHeader extends Node {
rowspan: { default: 1 },
alignment: { default: null },
colwidth: { default: null },
marks: {
default: undefined,
},
},
};
}
+17
View File
@@ -0,0 +1,17 @@
export const presetColors = [
"#FDEA9B",
"#FED46A",
"#FA551E",
"#B4DC19",
"#C8AFF0",
"#3CBEFC",
];
export const presetColorNames = [
"Coral",
"Apricot",
"Sunset",
"Smoothie",
"Bubblegum",
"Neon",
];
+2 -2
View File
@@ -1,4 +1,4 @@
import type { NodeMarkAttr } from "@shared/editor/types";
import type { NodeAttrMark } from "@shared/editor/types";
import type { ResolvedPos, MarkType } from "prosemirror-model";
import type { NodeSelection } from "prosemirror-state";
@@ -62,7 +62,7 @@ export function getMarkRangeNodeSelection(
type: MarkType
) {
const mark = (selection.node.attrs.marks ?? []).find(
(mark: NodeMarkAttr) => mark.type === type.name
(mark: NodeAttrMark) => mark.type === type.name
);
if (!mark) {
+75
View File
@@ -4,6 +4,9 @@ import { CellSelection, isInTable, selectedRect } from "prosemirror-tables";
import { ColumnSelection } from "../selection/ColumnSelection";
import { RowSelection } from "../selection/RowSelection";
import type { EditorView } from "prosemirror-view";
import type { NodeAttrMark, NodeAttrMarkName } from "../types";
import type { Node } from "prosemirror-model";
import type { Selection } from "prosemirror-state";
/**
* Checks if the current selection is a column selection.
@@ -410,3 +413,75 @@ export function getWidthFromNodes({
return total + (colwidth?.[0] ?? 0);
}, 0);
}
const getCellAttrMark = (cell: Node, type: NodeAttrMarkName) => {
const mark = (cell.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === type
);
return mark;
};
export const hasNodeAttrMarkCellSelection = (
selection: CellSelection,
type: NodeAttrMarkName
) => {
let hasMark = false;
selection.forEachCell((cell) => {
if (!hasMark) {
hasMark = !!getCellAttrMark(cell, type);
}
});
return hasMark;
};
/**
* Returns the set of background colors applied to selected cells.
*
* @param selection - The current selection.
* @returns A set of color strings from background marks on selected cells.
*/
export function getColorSetForSelectedCells(selection: Selection): Set<string> {
const colors = new Set<string>();
if (!(selection instanceof CellSelection)) {
// If not a CellSelection, return empty set
return colors;
}
selection.forEachCell((cell) => {
const backgroundMark = (cell.attrs.marks ?? []).find(
(mark: NodeAttrMark) => mark.type === "background"
);
if (backgroundMark && backgroundMark.attrs.color) {
colors.add(backgroundMark.attrs.color);
}
});
return colors;
}
/**
* Returns true if any cell in the selection has a mark of the given type
* with matching attributes.
*
* @param selection The CellSelection to check.
* @param type The mark type to look for.
* @param attrs The attributes to match against.
* @returns True if any cell has the mark with matching attributes.
*/
export const hasNodeAttrMarkWithAttrsCellSelection = (
selection: CellSelection,
type: NodeAttrMarkName,
attrs: Record<string, unknown>
) => {
let attrsMatch = true;
selection.forEachCell((cell) => {
const cellMark = getCellAttrMark(cell, type);
attrsMatch &&=
!!cellMark &&
Object.entries(attrs).every(
([key, value]) => (cellMark.attrs ?? {})[key] === value
);
});
return attrsMatch;
};
+18 -3
View File
@@ -43,6 +43,10 @@ export type MenuItem = {
skipIcon?: boolean;
disabled?: boolean;
onClick?: () => void;
/** Custom React content to render instead of a standard menu item */
content?: React.ReactNode;
/** Condition to check before preventing the submenu from closing */
preventCloseCondition?: () => boolean;
};
export type ComponentProps = {
@@ -55,7 +59,18 @@ export type ComponentProps = {
decorations: Decoration[];
};
export interface NodeMarkAttr {
type: string;
[key: string]: any;
export type NodeAttrMarkName =
| "strong"
| "em"
| "code_inline"
| "link"
| "background"
| "strikethrough"
| "underline"
| "placeholder"
| "comment";
export interface NodeAttrMark {
type: NodeAttrMarkName;
attrs?: Record<string, unknown>;
}
@@ -584,6 +584,7 @@
"Info notice": "Info notice",
"Link": "Link",
"Highlight": "Highlight",
"Background color": "Background color",
"Type '/' to insert": "Type '/' to insert",
"Keep typing to filter": "Keep typing to filter",
"Open link": "Open link",
+41
View File
@@ -1,6 +1,7 @@
import md5 from "crypto-js/md5";
import { darken, parseToRgb } from "polished";
import theme from "../styles/theme";
import type { RgbaColor } from "polished/lib/types/color";
export const palette = [
theme.brand.red,
@@ -49,3 +50,43 @@ export const getTextColor = (background: string) => {
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 128 ? "black" : "white";
};
const round = (
number: number,
digits = 0,
base = Math.pow(10, digits)
): number => Math.round(base * number) / base;
const toHex = (number: number) => {
const hex = number.toString(16);
return hex.length < 2 ? "0" + hex : hex;
};
export const rgbaToHex = ({ red, green, blue, alpha }: RgbaColor): string => {
const alphaHex = alpha < 1 ? toHex(round(alpha * 255)) : "";
return "#" + toHex(red) + toHex(green) + toHex(blue) + alphaHex;
};
export const hexToRgba = (hex: string): RgbaColor => {
if (hex[0] === "#") {
hex = hex.substring(1);
}
if (hex.length < 6) {
return {
red: parseInt(hex[0] + hex[0], 16),
green: parseInt(hex[1] + hex[1], 16),
blue: parseInt(hex[2] + hex[2], 16),
alpha:
hex.length === 4 ? round(parseInt(hex[3] + hex[3], 16) / 255, 2) : 1,
};
}
return {
red: parseInt(hex.substring(0, 2), 16),
green: parseInt(hex.substring(2, 4), 16),
blue: parseInt(hex.substring(4, 6), 16),
alpha:
hex.length === 8 ? round(parseInt(hex.substring(6, 8), 16) / 255, 2) : 1,
};
};
+11
View File
@@ -17716,6 +17716,7 @@ __metadata:
react: "npm:^17.0.2"
react-avatar-editor: "npm:^13.0.2"
react-color: "npm:^2.17.3"
react-colorful: "npm:^5.6.1"
react-day-picker: "npm:^8.10.1"
react-dnd: "npm:^16.0.1"
react-dnd-html5-backend: "npm:^16.0.1"
@@ -19058,6 +19059,16 @@ __metadata:
languageName: node
linkType: hard
"react-colorful@npm:^5.6.1":
version: 5.6.1
resolution: "react-colorful@npm:5.6.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10c0/48eb73cf71e10841c2a61b6b06ab81da9fffa9876134c239bfdebcf348ce2a47e56b146338e35dfb03512c85966bfc9a53844fc56bc50154e71f8daee59ff6f0
languageName: node
linkType: hard
"react-day-picker@npm:^8.10.1":
version: 8.10.1
resolution: "react-day-picker@npm:8.10.1"