mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: start new messages in the default compose font and size
This commit is contained in:
@@ -444,3 +444,30 @@ describe('confirm before sending (MO-16)', () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('default compose font (CO-16)', () => {
|
||||
const READY = { ...DRAFT, to: 'bob@example.com' };
|
||||
|
||||
it('starts the editor in the chosen font and sends it with the mail', async () => {
|
||||
settings.composeFontFamily = 'serif';
|
||||
settings.composeFontSize = '18px';
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={READY} onSend={onSend} />);
|
||||
expect(JSON.parse(screen.getByTestId('rich-text-editor').getAttribute('data-default-style')!))
|
||||
.toEqual({ fontFamily: 'serif', fontSize: '18px' });
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
const sent = onSend.mock.calls[0][0] as { htmlBody?: string };
|
||||
expect(sent.htmlBody).toMatch(/^<div style="font-family: serif; font-size: 18px">/);
|
||||
});
|
||||
|
||||
it('leaves the mail as it was without the setting, and ignores values that are not a font', async () => {
|
||||
settings.composeFontFamily = 'serif"><script>';
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={READY} onSend={onSend} />);
|
||||
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
|
||||
expect((onSend.mock.calls[0][0] as { htmlBody?: string }).htmlBody).toMatch(/^<div>/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,3 +126,11 @@ describe('RichTextEditor formatting toolbar (SR-08)', () => {
|
||||
expect(document.activeElement).toBe(item('font-size'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('RichTextEditor default text style (CO-16)', () => {
|
||||
it('shows the text in the chosen font and size', async () => {
|
||||
const { editor } = await setup({ defaultTextStyle: { fontFamily: 'monospace', fontSize: '20px' } });
|
||||
expect(editor.view.dom.style.fontFamily).toBe('monospace');
|
||||
expect(editor.view.dom.style.fontSize).toBe('20px');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,6 +138,7 @@ import {
|
||||
import type { ComposePrefill } from "@/lib/plugin-compose-gate";
|
||||
import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks";
|
||||
import { isValidEmail } from "@/lib/validation";
|
||||
import { composeTextStyle, composeTextStyleCss } from "@/components/email/font-size";
|
||||
import { buildMentionCandidates, type MentionCandidate } from "@/lib/recipient-mentions";
|
||||
import { describeRecipientChanges, recipientDisplayName, type RecipientChange, type RecipientFields } from "@/lib/recipient-announcements";
|
||||
import { normalizeCid } from "@/lib/attachment-visibility";
|
||||
@@ -496,6 +497,11 @@ export function EmailComposer({
|
||||
const placeholderWarningEnabled = useSettingsStore((state) => state.placeholderWarningEnabled);
|
||||
// "Confirm before sending" (an admin can lock it; the store applies the lock).
|
||||
const sendConfirmation = useSettingsStore((state) => state.sendConfirmation);
|
||||
// The font and size messages start in (Settings > Accessibility); the
|
||||
// sent HTML carries them, so recipients see them too.
|
||||
const composeFontFamily = useSettingsStore((state) => state.composeFontFamily);
|
||||
const composeFontSize = useSettingsStore((state) => state.composeFontSize);
|
||||
const composeStyle = useMemo(() => composeTextStyle(composeFontFamily, composeFontSize), [composeFontFamily, composeFontSize]);
|
||||
const recipientMentionsEnabled = useSettingsStore((state) => state.recipientMentionsEnabled);
|
||||
const mentionAddsRecipientTo = useSettingsStore((state) => state.mentionAddsRecipientTo);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
@@ -3475,11 +3481,12 @@ export function EmailComposer({
|
||||
}
|
||||
|
||||
const rewritten = plainTextMode ? null : rewriteInlineImages(body);
|
||||
const composeStyleCss = composeTextStyleCss(composeStyle);
|
||||
// What the send-pipeline finalisers add (organisation signature) goes in
|
||||
// here, on the send only: drafts never get it. The `last` stage (the
|
||||
// disclaimer) runs on what goes out once plugins had their say, below.
|
||||
const { html: finalHtmlBody, text: finalBody } = finalizeOutgoing({
|
||||
html: plainTextMode ? undefined : `<div>${rewritten!.html}</div>${buildSignatureHtml()}`,
|
||||
html: plainTextMode ? undefined : `${composeStyleCss ? `<div style="${composeStyleCss}">` : '<div>'}${rewritten!.html}</div>${buildSignatureHtml()}`,
|
||||
text: builtTextBody,
|
||||
}, outgoingContext);
|
||||
const lastStage = (html: string | undefined, text: string) =>
|
||||
@@ -4453,6 +4460,7 @@ export function EmailComposer({
|
||||
ariaLabel={t('body_label')}
|
||||
ariaInvalid={validationErrors.body}
|
||||
ariaDescribedBy={`${fieldIdBase}-body-error`}
|
||||
defaultTextStyle={composeStyle}
|
||||
collapseToolbarOnMobile={!showFormattingOnMobile}
|
||||
toolbarPlacement={mvWindow ? (mvShowFormatting ? 'bottom' : 'hidden') : 'top'}
|
||||
onEditorReady={handleEditorReady}
|
||||
|
||||
@@ -77,3 +77,32 @@ declare module "@tiptap/core" {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** The font and size a message starts with (Settings > Accessibility). */
|
||||
export interface ComposeTextStyle {
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
}
|
||||
|
||||
// The settings sync from the server: only plain family lists and lengths
|
||||
// go into a style attribute.
|
||||
const SAFE_FONT_FAMILY = /^[a-z0-9 ,'-]{1,200}$/i;
|
||||
const SAFE_FONT_SIZE = /^\d{1,3}(\.\d{1,2})?(px|pt|em|rem|%)$/;
|
||||
|
||||
/** The composeFontFamily / composeFontSize settings, checked; empty means the default. */
|
||||
export function composeTextStyle(fontFamily: string | undefined, fontSize: string | undefined): ComposeTextStyle {
|
||||
const style: ComposeTextStyle = {};
|
||||
const family = fontFamily?.trim();
|
||||
const size = fontSize?.trim();
|
||||
if (family && SAFE_FONT_FAMILY.test(family)) style.fontFamily = family;
|
||||
if (size && SAFE_FONT_SIZE.test(size)) style.fontSize = size;
|
||||
return style;
|
||||
}
|
||||
|
||||
/** The style as CSS declarations for an inline style attribute ("" when there is none). */
|
||||
export function composeTextStyleCss(style: ComposeTextStyle): string {
|
||||
return [
|
||||
style.fontFamily ? `font-family: ${style.fontFamily}` : '',
|
||||
style.fontSize ? `font-size: ${style.fontSize}` : '',
|
||||
].filter(Boolean).join('; ');
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { PlainTextPaste } from "@/components/email/plain-text-paste";
|
||||
import { pastedImageFiles } from "@/components/email/paste-images";
|
||||
import { TextStyle, BackgroundColor } from "@tiptap/extension-text-style";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import { FontSize, FONT_SIZES } from "@/components/email/font-size";
|
||||
import { FontSize, FONT_SIZES, composeTextStyleCss, type ComposeTextStyle } from "@/components/email/font-size";
|
||||
import { ResizableImage } from "@/components/email/resizable-image";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
@@ -132,12 +132,16 @@ interface RichTextEditorProps {
|
||||
ariaInvalid?: boolean;
|
||||
/** The id of the text that explains the error. */
|
||||
ariaDescribedBy?: string;
|
||||
/** The font and size the text starts in (the sent mail carries them too). */
|
||||
defaultTextStyle?: ComposeTextStyle;
|
||||
}
|
||||
|
||||
/** What the editing area tells assistive technology, besides TipTap's role=textbox. */
|
||||
export function editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy }: Pick<RichTextEditorProps, 'ariaLabel' | 'ariaInvalid' | 'ariaDescribedBy'>): Record<string, string> {
|
||||
export function editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle }: Pick<RichTextEditorProps, 'ariaLabel' | 'ariaInvalid' | 'ariaDescribedBy' | 'defaultTextStyle'>): Record<string, string> {
|
||||
const style = defaultTextStyle ? composeTextStyleCss(defaultTextStyle) : "";
|
||||
return {
|
||||
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
|
||||
...(style ? { style } : {}),
|
||||
role: "textbox",
|
||||
"aria-multiline": "true",
|
||||
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
|
||||
@@ -362,6 +366,7 @@ export function RichTextEditor({
|
||||
ariaLabel,
|
||||
ariaInvalid,
|
||||
ariaDescribedBy,
|
||||
defaultTextStyle,
|
||||
}: RichTextEditorProps) {
|
||||
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
|
||||
const tComposer = useTranslations("email_composer");
|
||||
@@ -470,7 +475,7 @@ export function RichTextEditor({
|
||||
},
|
||||
content,
|
||||
editorProps: {
|
||||
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy }),
|
||||
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle }),
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (event.altKey && event.key === "F10" && focusToolbarRef.current?.()) {
|
||||
event.preventDefault();
|
||||
@@ -550,10 +555,10 @@ export function RichTextEditor({
|
||||
editor.setOptions({
|
||||
editorProps: {
|
||||
...editor.options.editorProps,
|
||||
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy }),
|
||||
attributes: editorA11yAttributes({ ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle }),
|
||||
},
|
||||
});
|
||||
}, [editor, ariaLabel, ariaInvalid, ariaDescribedBy]);
|
||||
}, [editor, ariaLabel, ariaInvalid, ariaDescribedBy, defaultTextStyle]);
|
||||
|
||||
// Expose the editor instance once it's ready so parents can target
|
||||
// specific nodes (e.g. swap the embedded signature on identity change).
|
||||
|
||||
Reference in New Issue
Block a user