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);
+ }}
+ />
+ )}
+
+ )}
+
+