feat: table background color, cell header and align (#1352)

* feat: add toggle header cell button to table cell menu

Added ability to toggle header cells directly from the table cell menu. This enhancement includes:
- New toggle header cell button with IconTableRow icon
- Consistent UI/UX with existing table menu patterns
- Proper internationalization support

* fix: typo in aria-label for toggle header cell button

* feat: add table cell background color picker

- Extended TableCell and TableHeader to support backgroundColor attribute
- Created TableBackgroundColor component with 21 color options
- Integrated color picker into table cell menu using Mantine UI
- Added support for both regular cells and header cells
- Updated imports to use custom TableHeader from @docmost/editor-ext

* feat: add text alignment to table cell menu

- Created TableTextAlignment component with left, center, and right alignment options
- Integrated alignment selector into table cell menu
- Shows current alignment icon in the button
- Displays checkmark next to active alignment in dropdown

* background colors

* table background color in dark mode

* add bg color name

* rename color attribute

* increase minimum table width
This commit is contained in:
Philip Okugbe
2025-07-15 06:27:48 +01:00
committed by GitHub
parent 9f39987404
commit 90488a95b1
9 changed files with 401 additions and 10 deletions

View File

@ -0,0 +1,145 @@
import React, { FC } from "react";
import { IconCheck, IconPalette } from "@tabler/icons-react";
import {
ActionIcon,
ColorSwatch,
Popover,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
export interface TableColorItem {
name: string;
color: string;
}
interface TableBackgroundColorProps {
editor: ReturnType<typeof useEditor>;
}
const TABLE_COLORS: TableColorItem[] = [
{ name: "Default", color: "" },
{ name: "Blue", color: "#b4d5ff" },
{ name: "Green", color: "#acf5d2" },
{ name: "Yellow", color: "#fef1b4" },
{ name: "Red", color: "#ffbead" },
{ name: "Pink", color: "#ffc7fe" },
{ name: "Gray", color: "#eaecef" },
{ name: "Purple", color: "#c1b7f2" },
];
export const TableBackgroundColor: FC<TableBackgroundColorProps> = ({
editor,
}) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const setTableCellBackground = (color: string, colorName: string) => {
editor
.chain()
.focus()
.updateAttributes("tableCell", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
})
.updateAttributes("tableHeader", {
backgroundColor: color || null,
backgroundColorName: color ? colorName : null
})
.run();
setOpened(false);
};
// Get current cell's background color
const getCurrentColor = () => {
if (editor.isActive("tableCell")) {
const attrs = editor.getAttributes("tableCell");
return attrs.backgroundColor || "";
}
if (editor.isActive("tableHeader")) {
const attrs = editor.getAttributes("tableHeader");
return attrs.backgroundColor || "";
}
return "";
};
const currentColor = getCurrentColor();
return (
<Popover
width={200}
position="bottom"
opened={opened}
onChange={setOpened}
withArrow
transitionProps={{ transition: "pop" }}
>
<Popover.Target>
<Tooltip label={t("Background color")} withArrow>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Background color")}
onClick={() => setOpened(!opened)}
>
<IconPalette size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t("Background color")}
</Text>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: "8px",
}}
>
{TABLE_COLORS.map((item, index) => (
<UnstyledButton
key={index}
onClick={() => setTableCellBackground(item.color, item.name)}
style={{
position: "relative",
width: "24px",
height: "24px",
}}
title={t(item.name)}
>
<ColorSwatch
color={item.color || "#ffffff"}
size={24}
style={{
border: item.color === "" ? "1px solid #e5e7eb" : undefined,
cursor: "pointer",
}}
>
{currentColor === item.color && (
<IconCheck
size={18}
style={{
color:
item.color === "" || item.color.startsWith("#F")
? "#000000"
: "#ffffff",
}}
/>
)}
</ColorSwatch>
</UnstyledButton>
))}
</div>
</Stack>
</Popover.Dropdown>
</Popover>
);
};

View File

@ -12,8 +12,11 @@ import {
IconColumnRemove,
IconRowRemove,
IconSquareToggle,
IconTableRow,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { TableBackgroundColor } from "./table-background-color";
import { TableTextAlignment } from "./table-text-alignment";
export const TableCellMenu = React.memo(
({ editor, appendTo }: EditorMenuProps): JSX.Element => {
@ -45,6 +48,10 @@ export const TableCellMenu = React.memo(
editor.chain().focus().deleteRow().run();
}, [editor]);
const toggleHeaderCell = useCallback(() => {
editor.chain().focus().toggleHeaderCell().run();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
@ -60,6 +67,9 @@ export const TableCellMenu = React.memo(
shouldShow={shouldShow}
>
<ActionIcon.Group>
<TableBackgroundColor editor={editor} />
<TableTextAlignment editor={editor} />
<Tooltip position="top" label={t("Merge cells")}>
<ActionIcon
onClick={mergeCells}
@ -103,6 +113,17 @@ export const TableCellMenu = React.memo(
<IconRowRemove size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header cell")}>
<ActionIcon
onClick={toggleHeaderCell}
variant="default"
size="lg"
aria-label={t("Toggle header cell")}
>
<IconTableRow size={18} />
</ActionIcon>
</Tooltip>
</ActionIcon.Group>
</BaseBubbleMenu>
);

View File

@ -0,0 +1,109 @@
import React, { FC } from "react";
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconCheck,
} from "@tabler/icons-react";
import {
ActionIcon,
Button,
Popover,
rem,
ScrollArea,
Tooltip,
} from "@mantine/core";
import { useEditor } from "@tiptap/react";
import { useTranslation } from "react-i18next";
interface TableTextAlignmentProps {
editor: ReturnType<typeof useEditor>;
}
interface AlignmentItem {
name: string;
icon: React.ElementType;
command: () => void;
isActive: () => boolean;
value: string;
}
export const TableTextAlignment: FC<TableTextAlignmentProps> = ({ editor }) => {
const { t } = useTranslation();
const [opened, setOpened] = React.useState(false);
const items: AlignmentItem[] = [
{
name: "Align left",
value: "left",
isActive: () => editor.isActive({ textAlign: "left" }),
command: () => editor.chain().focus().setTextAlign("left").run(),
icon: IconAlignLeft,
},
{
name: "Align center",
value: "center",
isActive: () => editor.isActive({ textAlign: "center" }),
command: () => editor.chain().focus().setTextAlign("center").run(),
icon: IconAlignCenter,
},
{
name: "Align right",
value: "right",
isActive: () => editor.isActive({ textAlign: "right" }),
command: () => editor.chain().focus().setTextAlign("right").run(),
icon: IconAlignRight,
},
];
const activeItem = items.find((item) => item.isActive()) || items[0];
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
transitionProps={{ transition: 'pop' }}
>
<Popover.Target>
<Tooltip label={t("Text alignment")} withArrow>
<ActionIcon
variant="default"
size="lg"
aria-label={t("Text alignment")}
onClick={() => setOpened(!opened)}
>
<activeItem.icon size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<ScrollArea.Autosize type="scroll" mah={300}>
<Button.Group orientation="vertical">
{items.map((item, index) => (
<Button
key={index}
variant="default"
leftSection={<item.icon size={16} />}
rightSection={
item.isActive() && <IconCheck size={16} />
}
justify="left"
fullWidth
onClick={() => {
item.command();
setOpened(false);
}}
style={{ border: "none" }}
>
{t(item.name)}
</Button>
))}
</Button.Group>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
};

View File

@ -11,7 +11,6 @@ import { Typography } from "@tiptap/extension-typography";
import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@ -25,6 +24,7 @@ import {
MathInline,
TableCell,
TableRow,
TableHeader,
TrailingNode,
TiptapImage,
Callout,

View File

@ -4,6 +4,7 @@
overflow-x: auto;
& table {
overflow-x: hidden;
min-width: 700px !important;
}
}
@ -66,8 +67,54 @@
position: absolute;
z-index: 2;
}
}
}
/* Table cell background colors with dark mode support */
.ProseMirror {
table {
@mixin dark {
/* Blue */
td[data-background-color="#b4d5ff"],
th[data-background-color="#b4d5ff"] {
background-color: #1a3a5c !important;
}
/* Green */
td[data-background-color="#acf5d2"],
th[data-background-color="#acf5d2"] {
background-color: #1a4d3a !important;
}
/* Yellow */
td[data-background-color="#fef1b4"],
th[data-background-color="#fef1b4"] {
background-color: #7c5014 !important;
}
/* Red */
td[data-background-color="#ffbead"],
th[data-background-color="#ffbead"] {
background-color: #5c2a23 !important;
}
/* Pink */
td[data-background-color="#ffc7fe"],
th[data-background-color="#ffc7fe"] {
background-color: #4d2a4d !important;
}
/* Gray */
td[data-background-color="#eaecef"],
th[data-background-color="#eaecef"] {
background-color: #2a2e33 !important;
}
/* Purple */
td[data-background-color="#c1b7f2"],
th[data-background-color="#c1b7f2"] {
background-color: #3a2f5c !important;
}
}
}
}

View File

@ -11,7 +11,6 @@ import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import { Youtube } from '@tiptap/extension-youtube';
import Table from '@tiptap/extension-table';
import TableHeader from '@tiptap/extension-table-header';
import {
Callout,
Comment,
@ -22,6 +21,7 @@ import {
LinkExtension,
MathBlock,
MathInline,
TableHeader,
TableCell,
TableRow,
TiptapImage,
@ -31,7 +31,7 @@ import {
Drawio,
Excalidraw,
Embed,
Mention
Mention,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
@ -46,7 +46,7 @@ export const tiptapExtensions = [
codeBlock: false,
}),
Comment,
TextAlign.configure({ types: ["heading", "paragraph"] }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
TaskItem.configure({
nested: true,
@ -66,9 +66,9 @@ export const tiptapExtensions = [
DetailsContent,
DetailsSummary,
Table,
TableHeader,
TableRow,
TableCell,
TableRow,
TableHeader,
Youtube,
TiptapImage,
TiptapVideo,
@ -78,7 +78,7 @@ export const tiptapExtensions = [
Drawio,
Excalidraw,
Embed,
Mention
Mention,
] as any;
export function jsonToHtml(tiptapJson: any) {

View File

@ -3,4 +3,35 @@ import { TableCell as TiptapTableCell } from "@tiptap/extension-table-cell";
export const TableCell = TiptapTableCell.extend({
name: "tableCell",
content: "paragraph+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
};
},
},
};
},
});

View File

@ -0,0 +1,37 @@
import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table-header";
export const TableHeader = TiptapTableHeader.extend({
name: "tableHeader",
content: "paragraph+",
addAttributes() {
return {
...this.parent?.(),
backgroundColor: {
default: null,
parseHTML: (element) => element.style.backgroundColor || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColor) {
return {};
}
return {
style: `background-color: ${attributes.backgroundColor}`,
'data-background-color': attributes.backgroundColor,
};
},
},
backgroundColorName: {
default: null,
parseHTML: (element) => element.getAttribute('data-background-color-name') || null,
renderHTML: (attributes) => {
if (!attributes.backgroundColorName) {
return {};
}
return {
'data-background-color-name': attributes.backgroundColorName.toLowerCase(),
};
},
},
};
},
});

View File

@ -1,2 +1,3 @@
export * from "./row";
export * from "./cell";
export * from "./header";