Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -148,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/reply-identity', () => ({
|
||||
resolveReplyFrom: () => null,
|
||||
findComposeIdentityId: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
@@ -227,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' });
|
||||
expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 });
|
||||
});
|
||||
|
||||
it('keeps a display name with a comma in a single chip (array model)', async () => {
|
||||
@@ -241,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(chipSpan, { dataTransfer: dt });
|
||||
const payload = JSON.parse(dt.getData('application/x-recipient-chip'));
|
||||
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' });
|
||||
expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 });
|
||||
});
|
||||
|
||||
it('onDragEnd clears the opacity class on the chip', async () => {
|
||||
@@ -340,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => {
|
||||
const ccLabel = await screen.findByText('cc_label');
|
||||
expect(ccLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ─── Reordering within / across fields (#593) ─────────────────────────────────
|
||||
// jsdom ignores `clientX` in fireEvent's init for drag events (it's a
|
||||
// read-only MouseEvent getter) and gives every element a zero-size rect at
|
||||
// (0,0). So we dispatch events with `clientX` forced via defineProperty; with
|
||||
// the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE.
|
||||
|
||||
const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' };
|
||||
|
||||
const chipByText = async (text: string) =>
|
||||
(await screen.findByText(text)).closest('[draggable]') as HTMLElement;
|
||||
|
||||
/** Dispatch a drag event with a real clientX (fireEvent init drops it). */
|
||||
const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(e, 'clientX', { value: clientX });
|
||||
Object.defineProperty(e, 'dataTransfer', { value: dt });
|
||||
act(() => { fireEvent(el, e); });
|
||||
};
|
||||
const BEFORE = -100;
|
||||
const AFTER = 100;
|
||||
|
||||
/** Ordered chip labels of the field-container that holds `anchorText`. */
|
||||
const orderIn = (anchorText: string) => {
|
||||
const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]'));
|
||||
const c = containers.find(el =>
|
||||
Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText))
|
||||
) as HTMLElement;
|
||||
return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? '');
|
||||
};
|
||||
|
||||
/** All draggable chips (across fields) whose label contains `text`. */
|
||||
const draggableChipsWith = (text: string) =>
|
||||
Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text));
|
||||
|
||||
it('reorders a chip to the end of the same field (drop after the last chip)', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
const alice = await chipByText('alice@example.com');
|
||||
const carol = await chipByText('carol@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0
|
||||
fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3
|
||||
fireDnd('drop', carol, dt, AFTER);
|
||||
|
||||
expect(orderIn('bob@example.com')).toEqual([
|
||||
'bob@example.com', 'carol@example.com', 'alice@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reorders a chip to the front of the same field (drop before the first chip)', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('carol@example.com');
|
||||
const carol = await chipByText('carol@example.com');
|
||||
const alice = await chipByText('alice@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2
|
||||
fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0
|
||||
fireDnd('drop', alice, dt, BEFORE);
|
||||
|
||||
expect(orderIn('alice@example.com')).toEqual([
|
||||
'carol@example.com', 'alice@example.com', 'bob@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('dropping a chip onto its own position leaves the order unchanged', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('bob@example.com');
|
||||
const bob = await chipByText('bob@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1
|
||||
fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op)
|
||||
fireDnd('drop', bob, dt, BEFORE);
|
||||
|
||||
expect(orderIn('bob@example.com')).toEqual([
|
||||
'alice@example.com', 'bob@example.com', 'carol@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves a chip into another field at the drop position (cross-field reorder)', async () => {
|
||||
render(<EmailComposer initialData={{ ...BASE_DATA, to: 'alice@example.com, ', cc: 'x@example.com, y@example.com, ' }} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
const alice = await chipByText('alice@example.com'); // To
|
||||
const y = await chipByText('y@example.com'); // Cc
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||
fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc
|
||||
fireDnd('drop', y, dt, BEFORE);
|
||||
|
||||
// alice lands between x and y; To no longer holds it (count only real chips,
|
||||
// not the leftover jsdom drag-preview element)
|
||||
expect(orderIn('x@example.com')).toEqual([
|
||||
'x@example.com', 'alice@example.com', 'y@example.com',
|
||||
]);
|
||||
expect(draggableChipsWith('alice@example.com')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows a drop caret only while a chip is dragged over the field', async () => {
|
||||
render(<EmailComposer initialData={THREE} />);
|
||||
await screen.findByText('alice@example.com');
|
||||
const alice = await chipByText('alice@example.com');
|
||||
const bob = await chipByText('bob@example.com');
|
||||
|
||||
const dt = new MockDataTransfer();
|
||||
fireEvent.dragStart(alice, { dataTransfer: dt });
|
||||
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||
|
||||
fireDnd('dragover', bob, dt, BEFORE);
|
||||
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull();
|
||||
|
||||
fireEvent.dragEnd(alice);
|
||||
expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,7 +147,10 @@ vi.mock('@/lib/email-sanitization', () => ({
|
||||
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null }));
|
||||
vi.mock('@/lib/reply-identity', () => ({
|
||||
resolveReplyFrom: () => null,
|
||||
findComposeIdentityId: () => null,
|
||||
}));
|
||||
vi.mock('@/lib/email-threading', () => ({
|
||||
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,7 @@ import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
@@ -823,7 +823,7 @@ export function EmailComposer({
|
||||
}, [composerClient, plainTextMode, mode]);
|
||||
|
||||
const composerSignatureHtml = signatureIdentity?.htmlSignature
|
||||
? `<div>${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}</div>`
|
||||
? `<div>${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}</div>`
|
||||
: signatureIdentity?.textSignature
|
||||
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div>`
|
||||
: '';
|
||||
@@ -936,7 +936,10 @@ export function EmailComposer({
|
||||
}
|
||||
}, [plainTextMode]);
|
||||
|
||||
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => {
|
||||
// Move a chip from one recipient field to another. `toIndex`, when given,
|
||||
// inserts at that position in the destination (drag-and-drop reordering,
|
||||
// #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button).
|
||||
const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => {
|
||||
if (fromField === toField) return;
|
||||
const setters = { to: setTo, cc: setCc, bcc: setBcc };
|
||||
const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : '';
|
||||
@@ -946,7 +949,13 @@ export function EmailComposer({
|
||||
const idx = prev.findIndex(r => sameRecipient(r, recipient));
|
||||
return idx === -1 ? prev : prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]);
|
||||
setters[toField](prev => {
|
||||
if (prev.some(r => sameRecipient(r, recipient))) return prev;
|
||||
const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length));
|
||||
const next = [...prev];
|
||||
next.splice(at, 0, recipient);
|
||||
return next;
|
||||
});
|
||||
if (toField === 'cc') setShowCc(true);
|
||||
if (toField === 'bcc') setShowBcc(true);
|
||||
}, [setTo, setCc, setBcc, setShowCc, setShowBcc]);
|
||||
@@ -2041,7 +2050,7 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
|
||||
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
@@ -2099,6 +2108,7 @@ export function EmailComposer({
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
data-testid="composer-send"
|
||||
className="md:hidden h-9 px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
@@ -2133,6 +2143,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
) : identities.length > 1 ? (
|
||||
<select
|
||||
data-testid="composer-from"
|
||||
value={selectedIdentityId || primaryIdentity?.id || ''}
|
||||
onChange={(e) => setSelectedIdentityId(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
|
||||
@@ -2164,7 +2175,7 @@ export function EmailComposer({
|
||||
})}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-foreground flex-1 truncate">
|
||||
<span data-testid="composer-from" className="text-sm text-foreground flex-1 truncate">
|
||||
{subAddressTag ? (
|
||||
<span className="font-mono">
|
||||
{generateSubAddress(primaryIdentity?.email || '', subAddressTag, subAddressDelimiter)}
|
||||
@@ -2227,7 +2238,7 @@ export function EmailComposer({
|
||||
</div>
|
||||
|
||||
{/* To field */}
|
||||
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<div data-testid="composer-to" className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
|
||||
<RecipientChipInput
|
||||
chips={to}
|
||||
@@ -2372,6 +2383,7 @@ export function EmailComposer({
|
||||
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
|
||||
<Input
|
||||
ref={subjectInputRef}
|
||||
data-testid="composer-subject"
|
||||
type="text"
|
||||
placeholder={t('subject_placeholder')}
|
||||
value={subject}
|
||||
@@ -2594,6 +2606,7 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
className="rounded-e-none border-e border-primary-foreground/20"
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
@@ -2632,6 +2645,7 @@ export function EmailComposer({
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
data-testid="composer-send"
|
||||
className="hidden md:inline-flex"
|
||||
>
|
||||
<Send className="w-4 h-4 me-2" />
|
||||
@@ -2895,7 +2909,7 @@ function RecipientChipInput({
|
||||
validationError?: boolean;
|
||||
validationMessage?: string;
|
||||
onTab?: () => void;
|
||||
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void;
|
||||
onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void;
|
||||
}) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -2904,6 +2918,9 @@ function RecipientChipInput({
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
// Gap (0..chips.length) a dragged chip would drop into; drives the insertion
|
||||
// caret and positional drop for reordering (#593). null when not dragging.
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Focus edit input when editing starts
|
||||
@@ -3060,27 +3077,81 @@ function RecipientChipInput({
|
||||
onAutoBlur(e, field);
|
||||
};
|
||||
|
||||
const isChipDrag = (e: React.DragEvent) =>
|
||||
e.dataTransfer.types.includes('application/x-recipient-chip');
|
||||
|
||||
// Dragging over empty container space (past the last chip / over the input)
|
||||
// targets the end of the list.
|
||||
const handleContainerDragOver = (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return;
|
||||
if (!isChipDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setIsDragOver(true);
|
||||
setDropIndex(chips.length);
|
||||
};
|
||||
|
||||
const handleContainerDragLeave = (e: React.DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Dragging over a chip picks the gap before or after it based on which half
|
||||
// the pointer is in (mirrored for RTL). stopPropagation keeps the container
|
||||
// handler from overriding this finer target.
|
||||
const handleChipDragOver = (e: React.DragEvent, index: number) => {
|
||||
if (!isChipDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const rtl = typeof window !== 'undefined' &&
|
||||
getComputedStyle(e.currentTarget as Element).direction === 'rtl';
|
||||
const past = rtl
|
||||
? e.clientX < rect.left + rect.width / 2
|
||||
: e.clientX > rect.left + rect.width / 2;
|
||||
setIsDragOver(true);
|
||||
setDropIndex(past ? index + 1 : index);
|
||||
};
|
||||
|
||||
// Insert the dragged chip at `target`. Same-field is a local reorder;
|
||||
// cross-field routes through onMoveChip with the destination index (#593).
|
||||
const performDrop = (e: React.DragEvent, target: number) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
setDropIndex(null);
|
||||
setDraggingIndex(null);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
if (!raw) return;
|
||||
let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number };
|
||||
try {
|
||||
payload = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { recipient, fromField, fromIndex } = payload;
|
||||
const to = Math.max(0, Math.min(target, chips.length));
|
||||
|
||||
if (fromField === field) {
|
||||
const from = typeof fromIndex === 'number'
|
||||
? fromIndex
|
||||
: chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? ''));
|
||||
if (from < 0 || from >= chips.length) return;
|
||||
// Removing the source before `to` shifts the target left by one.
|
||||
const insertAt = to > from ? to - 1 : to;
|
||||
if (insertAt === from) return; // dropped onto its own position
|
||||
const next = [...chips];
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(insertAt, 0, moved);
|
||||
onChipsChange(next);
|
||||
} else {
|
||||
onMoveChip(recipient, fromField, field, to);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const raw = e.dataTransfer.getData('application/x-recipient-chip');
|
||||
if (!raw) return;
|
||||
const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' };
|
||||
if (fromField === field) return;
|
||||
onMoveChip(recipient, fromField, field);
|
||||
performDrop(e, dropIndex ?? chips.length);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -3100,20 +3171,29 @@ function RecipientChipInput({
|
||||
const isEditing = editingChip?.index === i;
|
||||
const chipDisplay = formatChipDisplay(chip);
|
||||
return (
|
||||
<React.Fragment key={`${chip.email}-${i}`}>
|
||||
{dropIndex === i && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="recipient-drop-caret"
|
||||
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
key={`${chip.email}-${i}`}
|
||||
draggable={!isEditing}
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field }));
|
||||
e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i }));
|
||||
// Show the address while dragging, matching the email-list drag preview.
|
||||
const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
requestAnimationFrame(() => dragPreview.remove());
|
||||
setDraggingIndex(i);
|
||||
}}
|
||||
onDragEnd={() => setDraggingIndex(null)}
|
||||
onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }}
|
||||
onDragOver={(e) => handleChipDragOver(e, i)}
|
||||
onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors",
|
||||
isEditing
|
||||
@@ -3179,8 +3259,16 @@ function RecipientChipInput({
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{dropIndex === chips.length && chips.length > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="recipient-drop-caret"
|
||||
className="w-0.5 self-stretch min-h-[20px] rounded-full bg-primary pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
{!editingChip && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1175,6 +1175,7 @@ export function EmailViewer({
|
||||
id: email.id,
|
||||
contentType,
|
||||
bodyStructure: email.bodyStructure,
|
||||
bodyValues: email.bodyValues,
|
||||
attachments: email.attachments,
|
||||
blobId: email.blobId,
|
||||
from: email.from,
|
||||
|
||||
@@ -10,6 +10,10 @@ import { buildSignatureBlock } from "@/components/email/signature-block";
|
||||
// HTML, so parseHTML can recognise it on the way back in.
|
||||
export const QUOTED_HTML_MARKER = "data-quoted-html";
|
||||
|
||||
// Reusable style for the quote bar when quoting email text (like in a reply).
|
||||
const QUOTE_BAR_STYLE =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
|
||||
/**
|
||||
* QuotedHtml — an atomic block node that carries the *verbatim* HTML of a
|
||||
* quoted/forwarded original email. The HTML is stored in the `html` attribute
|
||||
@@ -68,8 +72,7 @@ export const QuotedHtml = TiptapNode.create({
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute(QUOTED_HTML_MARKER, "");
|
||||
dom.className = "quoted-html-island";
|
||||
dom.style.cssText =
|
||||
"border-left:2px solid #c5c5c5;padding-left:12px;margin-top:8px;";
|
||||
dom.style.cssText = QUOTE_BAR_STYLE;
|
||||
|
||||
// CRITICAL: render the quoted email inside a Shadow Root. The app's
|
||||
// global CSS (Tailwind preflight, .tiptap table/td rules, box-sizing
|
||||
@@ -192,5 +195,5 @@ export function serializeEditorContent(editor: Editor): string {
|
||||
* must be what serializeEditorContent emits too (round-trip consistency).
|
||||
*/
|
||||
export function buildQuotedHtmlBlock(sanitizedInnerHtml: string): string {
|
||||
return `<div ${QUOTED_HTML_MARKER}>${sanitizedInnerHtml}</div>`;
|
||||
return `<div ${QUOTED_HTML_MARKER} style="${QUOTE_BAR_STYLE}">${sanitizedInnerHtml}</div>`;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Table as TableIcon,
|
||||
Baseline,
|
||||
Trash2,
|
||||
Rows3,
|
||||
Columns3,
|
||||
@@ -143,6 +144,14 @@ function ToolbarSeparator() {
|
||||
const TABLE_PICKER_ROWS = 6;
|
||||
const TABLE_PICKER_COLS = 8;
|
||||
|
||||
// Preset text colours (2 x 8). Inline `style="color: …"` survives email
|
||||
// round-trips; the TextStyle/Color extensions are already registered to
|
||||
// preserve pasted colours - this palette just adds a UI to set them.
|
||||
const TEXT_COLORS = [
|
||||
"#000000", "#5f6368", "#9aa0a6", "#c5221f", "#e8710a", "#f9ab00", "#188038", "#1967d2",
|
||||
"#7627bb", "#c2185b", "#795548", "#fa5252", "#fd7e14", "#40c057", "#4dabf7", "#e64980",
|
||||
];
|
||||
|
||||
function TableSizePicker({ onPick }: { onPick: (rows: number, cols: number) => void }) {
|
||||
const [hover, setHover] = useState<{ r: number; c: number } | null>(null);
|
||||
return (
|
||||
@@ -336,6 +345,19 @@ export function RichTextEditor({
|
||||
|
||||
const [tableMenuOpen, setTableMenuOpen] = useState(false);
|
||||
const tableWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [colorMenuOpen, setColorMenuOpen] = useState(false);
|
||||
const colorWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorMenuOpen) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (colorWrapperRef.current && !colorWrapperRef.current.contains(e.target as Node)) {
|
||||
setColorMenuOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [colorMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableMenuOpen) return;
|
||||
@@ -386,6 +408,49 @@ export function RichTextEditor({
|
||||
>
|
||||
<Strikethrough className="w-4 h-4" />
|
||||
</ToolbarButton>
|
||||
<div ref={colorWrapperRef} className="relative">
|
||||
<ToolbarButton
|
||||
active={!!editor.getAttributes("textStyle").color}
|
||||
onClick={() => setColorMenuOpen((v) => !v)}
|
||||
title="Text color"
|
||||
>
|
||||
{/* The icon itself previews the active colour - no layout shift. */}
|
||||
<Baseline className="w-4 h-4" style={{ color: editor.getAttributes("textStyle").color || undefined }} />
|
||||
</ToolbarButton>
|
||||
{colorMenuOpen && (
|
||||
<div className="absolute z-50 top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-2">
|
||||
<div className="grid gap-0.5" style={{ gridTemplateColumns: "repeat(8, 1fr)" }}>
|
||||
{TEXT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-4 h-4 border border-border/60 rounded-[2px] transition-transform hover:scale-110",
|
||||
editor.getAttributes("textStyle").color === color && "ring-1 ring-ring ring-offset-1"
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-px bg-border my-1.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 px-2 py-1 text-sm rounded hover:bg-accent text-start w-full"
|
||||
onClick={() => {
|
||||
editor.chain().focus().unsetColor().run();
|
||||
setColorMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<RemoveFormatting className="w-4 h-4" /> Remove color
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToolbarSeparator />
|
||||
|
||||
|
||||
@@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
|
||||
// so parseHTML can recognise it on the way back in (initial content, drafts).
|
||||
export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node";
|
||||
|
||||
/**
|
||||
* Force every link in the rendered signature to open in a new tab.
|
||||
*
|
||||
* Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is
|
||||
* what serializeEditorContent emits into the sent message, and the recipient's
|
||||
* copy should stay exactly as the user wrote it. Without this the composer's
|
||||
* signature is a set of live, target-less anchors in the main document (the
|
||||
* message body gets a sandboxed iframe; this does not), so one stray click
|
||||
* navigates the whole app away and takes the unsent draft with it.
|
||||
*/
|
||||
function forceLinksToNewTab(root: HTMLElement): void {
|
||||
root.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SignatureBlock — an atomic, NON-editable block node that carries the
|
||||
* *verbatim* HTML of the user's identity signature in its `html` attribute.
|
||||
@@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({
|
||||
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
|
||||
dom.className = "signature-block-island";
|
||||
|
||||
|
||||
// CRITICAL: render the signature inside a Shadow Root. The app's global
|
||||
// CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets)
|
||||
// would otherwise cascade INTO the signature and destroy its layout -
|
||||
@@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({
|
||||
const inner = document.createElement("div");
|
||||
// Read-only: a signature is inserted/removed as a unit, not edited inline.
|
||||
inner.contentEditable = "false";
|
||||
inner.innerHTML = node.attrs.html || "";
|
||||
// Track what we were given, not what's in the DOM: forceLinksToNewTab
|
||||
// rewrites the markup, so inner.innerHTML no longer round-trips against
|
||||
// attrs.html and comparing the two would rewrite on every transaction.
|
||||
let appliedHtml = node.attrs.html || "";
|
||||
inner.innerHTML = appliedHtml;
|
||||
forceLinksToNewTab(inner);
|
||||
shadow.appendChild(inner);
|
||||
|
||||
return {
|
||||
@@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({
|
||||
stopEvent: () => false,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== "signatureBlock") return false;
|
||||
if (inner.innerHTML !== (updatedNode.attrs.html || "")) {
|
||||
inner.innerHTML = updatedNode.attrs.html || "";
|
||||
const nextHtml = updatedNode.attrs.html || "";
|
||||
if (nextHtml !== appliedHtml) {
|
||||
appliedHtml = nextHtml;
|
||||
inner.innerHTML = nextHtml;
|
||||
forceLinksToNewTab(inner);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,12 @@ declare module "@tiptap/core" {
|
||||
|
||||
/**
|
||||
* Adds a `dir` attribute to block nodes so the composer can mark individual
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
|
||||
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing).
|
||||
*
|
||||
* The default is `"auto"`: each block detects its own direction from its first
|
||||
* strong character, so a paragraph typed in English renders LTR and one typed
|
||||
* in Hebrew renders RTL, per block, as you type. The toolbar toggle still pins
|
||||
* an explicit `ltr`/`rtl` when you want to override the auto-detection, and the
|
||||
* attribute round-trips to HTML so the direction is preserved in the sent mail.
|
||||
*/
|
||||
export const TextDirection = Extension.create({
|
||||
@@ -29,10 +34,10 @@ export const TextDirection = Extension.create({
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
dir: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("dir") || null,
|
||||
default: "auto",
|
||||
parseHTML: (element) => element.getAttribute("dir") || "auto",
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dir ? { dir: attributes.dir } : {},
|
||||
attributes.dir ? { dir: attributes.dir } : { dir: "auto" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -166,6 +166,10 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
ref={ref}
|
||||
{...dragHandlers}
|
||||
{...longPressHandlers}
|
||||
data-testid="email-list-item"
|
||||
data-email-id={email.id}
|
||||
data-subject={email.subject || ''}
|
||||
data-unread={isUnread ? 'true' : 'false'}
|
||||
className={cn(
|
||||
"relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden",
|
||||
resolvedColorTag ? resolvedColorTag : (
|
||||
@@ -660,7 +664,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onToggle={toggleThreadSelection}
|
||||
selectLabel={tBatch('select')}
|
||||
/>
|
||||
{!isMobile && !isFocusedMailLayout && (
|
||||
{!isMobile && (
|
||||
<button
|
||||
data-expand-toggle
|
||||
onClick={(e) => {
|
||||
@@ -892,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && !isMobile && !isFocusedMailLayout && (
|
||||
{isExpanded && !isMobile && (
|
||||
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
|
||||
{isLoading ? (
|
||||
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user