feat: add tables to composer #236
This commit is contained in:
@@ -705,3 +705,54 @@ body {
|
|||||||
.tiptap .ProseMirror-selectednode img {
|
.tiptap .ProseMirror-selectednode img {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiptap table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table td,
|
||||||
|
.tiptap table th {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
min-width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table th {
|
||||||
|
background-color: var(--color-muted) !important;
|
||||||
|
color: var(--color-foreground) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table .selectedCell::after {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap table .column-resize-handle {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
bottom: -2px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap.resize-cursor {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|||||||
@@ -820,19 +820,27 @@ export function EmailComposer({
|
|||||||
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>;
|
||||||
} => {
|
} => {
|
||||||
const known = inlineImagesRef.current;
|
const known = inlineImagesRef.current;
|
||||||
if (known.length === 0) return { html, attachments: [] };
|
|
||||||
|
|
||||||
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
|
||||||
const used = new Map<string, typeof known[number]>();
|
const used = new Map<string, typeof known[number]>();
|
||||||
|
|
||||||
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
if (known.length > 0) {
|
||||||
const cid = img.getAttribute('data-cid');
|
doc.querySelectorAll('img[data-cid]').forEach((img) => {
|
||||||
if (!cid) return;
|
const cid = img.getAttribute('data-cid');
|
||||||
const entry = known.find((e) => e.cid === cid);
|
if (!cid) return;
|
||||||
if (!entry) return;
|
const entry = known.find((e) => e.cid === cid);
|
||||||
img.setAttribute('src', `cid:${cid}`);
|
if (!entry) return;
|
||||||
img.removeAttribute('data-cid');
|
img.setAttribute('src', `cid:${cid}`);
|
||||||
used.set(cid, entry);
|
img.removeAttribute('data-cid');
|
||||||
|
used.set(cid, entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient mail clients apply default <p> margins inside table cells,
|
||||||
|
// inflating row height. Tiptap wraps cell text in <p>, so force margin:0
|
||||||
|
// to match the composer's tight rows.
|
||||||
|
doc.querySelectorAll('td > p, th > p').forEach((p) => {
|
||||||
|
const existing = p.getAttribute('style') || '';
|
||||||
|
p.setAttribute('style', `margin:0;${existing}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useCallback } from "react";
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
||||||
import { useEditor, EditorContent } from "@tiptap/react";
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Underline from "@tiptap/extension-underline";
|
import Underline from "@tiptap/extension-underline";
|
||||||
@@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style";
|
|||||||
import Color from "@tiptap/extension-color";
|
import Color from "@tiptap/extension-color";
|
||||||
import { ResizableImage } from "@/components/email/resizable-image";
|
import { ResizableImage } from "@/components/email/resizable-image";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import { Table } from "@tiptap/extension-table";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
import { TableHeader } from "@tiptap/extension-table-header";
|
||||||
|
import { TableCell } from "@tiptap/extension-table-cell";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Bold,
|
Bold,
|
||||||
@@ -29,6 +33,10 @@ import {
|
|||||||
RemoveFormatting,
|
RemoveFormatting,
|
||||||
Heading1,
|
Heading1,
|
||||||
Heading2,
|
Heading2,
|
||||||
|
Table as TableIcon,
|
||||||
|
Trash2,
|
||||||
|
Rows3,
|
||||||
|
Columns3,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface InlineImageUpload {
|
export interface InlineImageUpload {
|
||||||
@@ -79,6 +87,43 @@ function ToolbarSeparator() {
|
|||||||
return <div className="w-px h-5 bg-border mx-0.5" />;
|
return <div className="w-px h-5 bg-border mx-0.5" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TABLE_PICKER_ROWS = 6;
|
||||||
|
const TABLE_PICKER_COLS = 8;
|
||||||
|
|
||||||
|
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||||
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="grid gap-0.5"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${TABLE_PICKER_COLS}, 1fr)` }}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
{Array.from({ length: TABLE_PICKER_ROWS * TABLE_PICKER_COLS }).map((_, i) => {
|
||||||
|
const r = Math.floor(i / TABLE_PICKER_COLS);
|
||||||
|
const c = i % TABLE_PICKER_COLS;
|
||||||
|
const active = hover && r <= hover.r && c <= hover.c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setHover({ r, c })}
|
||||||
|
onClick={() => onPick(r + 1, c + 1)}
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 border border-border/60 rounded-[2px] transition-colors",
|
||||||
|
active ? "bg-primary border-primary" : "bg-background hover:bg-accent"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1.5 text-center">
|
||||||
|
{hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RichTextEditor({
|
export function RichTextEditor({
|
||||||
content,
|
content,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -109,6 +154,27 @@ export function RichTextEditor({
|
|||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder,
|
placeholder,
|
||||||
}),
|
}),
|
||||||
|
Table.configure({
|
||||||
|
resizable: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
border: "1",
|
||||||
|
cellpadding: "6",
|
||||||
|
cellspacing: "0",
|
||||||
|
width: "100%",
|
||||||
|
style: "width:100%;border-collapse:collapse;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TableRow,
|
||||||
|
TableHeader.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
style: "padding:6px 8px;border:1px solid #ccc;background-color:#f5f5f5;color:#1f2937;text-align:left;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TableCell.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
style: "padding:6px 8px;border:1px solid #ccc;vertical-align:top;",
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
@@ -188,6 +254,20 @@ export function RichTextEditor({
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [editor]);
|
||||||
|
|
||||||
|
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||||
|
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableMenuOpen) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (tableWrapperRef.current && !tableWrapperRef.current.contains(e.target as Node)) {
|
||||||
|
setTableMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [tableMenuOpen]);
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-[100px]", className)} />
|
<div className={cn("min-h-[100px]", className)} />
|
||||||
@@ -309,6 +389,89 @@ export function RichTextEditor({
|
|||||||
<LinkIcon className="w-4 h-4" />
|
<LinkIcon className="w-4 h-4" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div ref={tableWrapperRef} className="relative">
|
||||||
|
<ToolbarButton
|
||||||
|
active={editor.isActive("table")}
|
||||||
|
onClick={() => setTableMenuOpen((v) => !v)}
|
||||||
|
title="Table"
|
||||||
|
>
|
||||||
|
<TableIcon className="w-4 h-4" />
|
||||||
|
</ToolbarButton>
|
||||||
|
{tableMenuOpen && (
|
||||||
|
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2 min-w-[200px]">
|
||||||
|
{editor.isActive("table") ? (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Add row above
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Add row below
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" /> Add column before
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-4 h-4" /> Add column after
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete row
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete column
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
|
||||||
|
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Rows3 className="w-4 h-4" /> Toggle header row
|
||||||
|
</button>
|
||||||
|
<div className="h-px bg-border my-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
|
||||||
|
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TableSizePicker
|
||||||
|
onPick={(rows, cols) => {
|
||||||
|
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
|
||||||
|
setTableMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
|
|||||||
Generated
+57
@@ -14,6 +14,10 @@
|
|||||||
"@tiptap/extension-image": "^3.22.4",
|
"@tiptap/extension-image": "^3.22.4",
|
||||||
"@tiptap/extension-link": "^3.22.4",
|
"@tiptap/extension-link": "^3.22.4",
|
||||||
"@tiptap/extension-placeholder": "^3.22.4",
|
"@tiptap/extension-placeholder": "^3.22.4",
|
||||||
|
"@tiptap/extension-table": "^3.22.4",
|
||||||
|
"@tiptap/extension-table-cell": "^3.22.4",
|
||||||
|
"@tiptap/extension-table-header": "^3.22.4",
|
||||||
|
"@tiptap/extension-table-row": "^3.22.4",
|
||||||
"@tiptap/extension-text-align": "^3.22.4",
|
"@tiptap/extension-text-align": "^3.22.4",
|
||||||
"@tiptap/extension-text-style": "^3.22.4",
|
"@tiptap/extension-text-style": "^3.22.4",
|
||||||
"@tiptap/extension-underline": "^3.22.4",
|
"@tiptap/extension-underline": "^3.22.4",
|
||||||
@@ -3639,6 +3643,59 @@
|
|||||||
"@tiptap/core": "3.22.4"
|
"@tiptap/core": "3.22.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/extension-table": {
|
||||||
|
"version": "3.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.4.tgz",
|
||||||
|
"integrity": "sha512-kjvLv3Z4JI+1tLDqZKa+bKU8VcxY+ZOyMCKWQA7wYmy8nKWkLJ60W+xy8AcXXpHB2goCIgSFLhsTyswx0GXH4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/core": "3.22.4",
|
||||||
|
"@tiptap/pm": "3.22.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-cell": {
|
||||||
|
"version": "3.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.4.tgz",
|
||||||
|
"integrity": "sha512-uvFegCc1UQYK2nfIV2sIHg+hzLIMroJJm00XomzBgC1w/eSO7Ui8APiDh/baBcTPpCSU3SLiQLTgx7AU7oE3pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-table": "3.22.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-header": {
|
||||||
|
"version": "3.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.4.tgz",
|
||||||
|
"integrity": "sha512-V4kLLWeRdc/I+IXiXZZhLAjsaHHiJWuLXTuOtZRDrCxQUiFLi4AgNg1DPQ09JAANkEWDhXq3x6BoUXaFwumbEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-table": "3.22.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tiptap/extension-table-row": {
|
||||||
|
"version": "3.22.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.4.tgz",
|
||||||
|
"integrity": "sha512-9tdS6jgS6DqUu5TpEmNrRoo/DL5Xam0PyrQaUEXUC+ssci+bMRCJ8PAWMcunNsI9NKf/Tb3wYrv6hGFChaT9uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/extension-table": "3.22.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tiptap/extension-text": {
|
"node_modules/@tiptap/extension-text": {
|
||||||
"version": "3.22.4",
|
"version": "3.22.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.4.tgz",
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
"@tiptap/extension-image": "^3.22.4",
|
"@tiptap/extension-image": "^3.22.4",
|
||||||
"@tiptap/extension-link": "^3.22.4",
|
"@tiptap/extension-link": "^3.22.4",
|
||||||
"@tiptap/extension-placeholder": "^3.22.4",
|
"@tiptap/extension-placeholder": "^3.22.4",
|
||||||
|
"@tiptap/extension-table": "^3.22.4",
|
||||||
|
"@tiptap/extension-table-cell": "^3.22.4",
|
||||||
|
"@tiptap/extension-table-header": "^3.22.4",
|
||||||
|
"@tiptap/extension-table-row": "^3.22.4",
|
||||||
"@tiptap/extension-text-align": "^3.22.4",
|
"@tiptap/extension-text-align": "^3.22.4",
|
||||||
"@tiptap/extension-text-style": "^3.22.4",
|
"@tiptap/extension-text-style": "^3.22.4",
|
||||||
"@tiptap/extension-underline": "^3.22.4",
|
"@tiptap/extension-underline": "^3.22.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user