diff --git a/app/globals.css b/app/globals.css index 795a05a9..4ee13ebd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -705,3 +705,54 @@ body { .tiptap .ProseMirror-selectednode img { 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; +} diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index bacb966a..ec6afbe5 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -820,19 +820,27 @@ export function EmailComposer({ attachments: Array<{ blobId: string; name: string; type: string; size: number; disposition: 'inline'; cid: string }>; } => { const known = inlineImagesRef.current; - if (known.length === 0) return { html, attachments: [] }; - const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); const used = new Map(); - doc.querySelectorAll('img[data-cid]').forEach((img) => { - const cid = img.getAttribute('data-cid'); - if (!cid) return; - const entry = known.find((e) => e.cid === cid); - if (!entry) return; - img.setAttribute('src', `cid:${cid}`); - img.removeAttribute('data-cid'); - used.set(cid, entry); + if (known.length > 0) { + doc.querySelectorAll('img[data-cid]').forEach((img) => { + const cid = img.getAttribute('data-cid'); + if (!cid) return; + const entry = known.find((e) => e.cid === cid); + if (!entry) return; + img.setAttribute('src', `cid:${cid}`); + img.removeAttribute('data-cid'); + used.set(cid, entry); + }); + } + + // Recipient mail clients apply default

margins inside table cells, + // inflating row height. Tiptap wraps cell text in

, 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 { diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index a30a7c82..fba66c01 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useCallback } from "react"; +import React, { useEffect, useCallback, useState, useRef } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Underline from "@tiptap/extension-underline"; @@ -10,6 +10,10 @@ import { TextStyle } from "@tiptap/extension-text-style"; import Color from "@tiptap/extension-color"; import { ResizableImage } from "@/components/email/resizable-image"; 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 { Bold, @@ -29,6 +33,10 @@ import { RemoveFormatting, Heading1, Heading2, + Table as TableIcon, + Trash2, + Rows3, + Columns3, } from "lucide-react"; export interface InlineImageUpload { @@ -79,6 +87,43 @@ function ToolbarSeparator() { return

; } +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 ( +
+
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 ( +
+
+ {hover ? `${hover.r + 1} × ${hover.c + 1}` : "Pick size"} +
+
+ ); +} + export function RichTextEditor({ content, onChange, @@ -109,6 +154,27 @@ export function RichTextEditor({ Placeholder.configure({ 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, editorProps: { @@ -188,6 +254,20 @@ export function RichTextEditor({ .run(); }, [editor]); + const [tableMenuOpen, setTableMenuOpen] = useState(false); + const tableWrapperRef = useRef(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) { return (
@@ -309,6 +389,89 @@ export function RichTextEditor({ +
+ setTableMenuOpen((v) => !v)} + title="Table" + > + + + {tableMenuOpen && ( +
+ {editor.isActive("table") ? ( +
+ + + + +
+ + + +
+ +
+ ) : ( + { + editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run(); + setTableMenuOpen(false); + }} + /> + )} +
+ )} +
+