mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +207,9 @@ export default class TableHeader extends Node {
|
||||
rowspan: { default: 1 },
|
||||
alignment: { default: null },
|
||||
colwidth: { default: null },
|
||||
marks: {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const presetColors = [
|
||||
"#FDEA9B",
|
||||
"#FED46A",
|
||||
"#FA551E",
|
||||
"#B4DC19",
|
||||
"#C8AFF0",
|
||||
"#3CBEFC",
|
||||
];
|
||||
|
||||
export const presetColorNames = [
|
||||
"Coral",
|
||||
"Apricot",
|
||||
"Sunset",
|
||||
"Smoothie",
|
||||
"Bubblegum",
|
||||
"Neon",
|
||||
];
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user