feat: add background colour and the webmail's 16 text colours to the composer

The composer offered 8 text colours and no background colour. It now
has the webmail's 2 x 8 palette (7e0a86a6, 3957b358) for text colour,
and a background colour button with the same palette and a "Remove
background color" action, applied with the editor's `hiliteColor`.

The webmail's font-size picker is not ported: the editor has no mark
model to scope a size to, so it needs a selection-rewriting command in
the page script that can only be checked on a device.
This commit is contained in:
Linus Rath
2026-09-24 19:10:46 +02:00
parent fc00781b47
commit 548b647f15
3 changed files with 44 additions and 11 deletions
+4
View File
@@ -136,6 +136,10 @@
"send_now": "Send now",
"send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.",
"send_timeout_title": "No answer from the server",
"toolbar": {
"background_color": "Background color",
"remove_background_color": "Remove background color"
},
"undo_send_label": "Sending in {seconds}s…",
"uploading": "Uploading...",
"validation": {
+3 -1
View File
@@ -23,7 +23,9 @@ export type RichTextCommand =
| 'undo'
| 'redo'
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
| `foreColor:${string}`;
| `foreColor:${string}`
// Background colour, the same way; 'hiliteColor:transparent' removes it.
| `hiliteColor:${string}`;
export interface RichTextSelectionState {
bold: boolean;
+37 -10
View File
@@ -11,7 +11,7 @@ import {
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type,
Users, Tag, Type, Highlighter,
} from 'lucide-react-native';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker';
@@ -103,7 +103,11 @@ type Field = 'to' | 'cc' | 'bcc';
const URL_RE = /^https?:\/\/.+/i;
const TEXT_COLORS = ['#000000', '#6b7280', '#dc2626', '#ea580c', '#ca8a04', '#16a34a', '#2563eb', '#7c3aed'];
// The webmail's 2 x 8 palette, used for text and background colour alike.
const TEXT_COLORS = [
'#000000', '#5f6368', '#9aa0a6', '#c5221f', '#e8710a', '#f9ab00', '#188038', '#1967d2',
'#7627bb', '#c2185b', '#795548', '#fa5252', '#fd7e14', '#40c057', '#4dabf7', '#e64980',
];
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
@@ -1759,7 +1763,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
// ── Colour / table ───────────────────────────────────────────────────
const [colorPickerOpen, setColorPickerOpen] = React.useState(false);
const [colorTarget, setColorTarget] = React.useState<'text' | 'background' | null>(null);
const [tableMenuOpen, setTableMenuOpen] = React.useState(false);
const insertTable = (rows: number, cols: number) => {
const cell = '<td style="border:1px solid #cccccc;padding:6px;min-width:40px">&nbsp;</td>';
@@ -2437,8 +2441,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
icon={<Underline size={18} color={selState.underline ? c.primary : c.textSecondary} />} />
<ToolbarButton active={selState.strikeThrough} onPress={() => editorRef.current?.exec('strikeThrough')}
icon={<Strikethrough size={18} color={selState.strikeThrough ? c.primary : c.textSecondary} />} />
<ToolbarButton onPress={() => setColorPickerOpen(true)}
<ToolbarButton onPress={() => setColorTarget('text')}
label={t('email_composer.toolbar.text_color', 'Text color')}
icon={<Palette size={18} color={c.textSecondary} />} />
<ToolbarButton onPress={() => setColorTarget('background')}
label={t('email_composer.toolbar.background_color', 'Background color')}
icon={<Highlighter size={18} color={c.textSecondary} />} />
<View style={styles.formatSep} />
@@ -2539,21 +2547,40 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</Modal>
{/* Text colour palette */}
<Modal visible={colorPickerOpen} transparent animationType="fade" onRequestClose={() => setColorPickerOpen(false)}>
<Pressable style={styles.modalBackdrop} onPress={() => setColorPickerOpen(false)}>
{/* Text / background colour palette */}
<Modal visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Pressable style={styles.modalBackdrop} onPress={() => setColorTarget(null)}>
<Pressable style={styles.scheduleCard} onPress={() => {}}>
<Text style={styles.modalTitle}>{t('email_composer.toolbar.text_color', 'Text color')}</Text>
<Text style={styles.modalTitle}>
{colorTarget === 'background'
? t('email_composer.toolbar.background_color', 'Background color')
: t('email_composer.toolbar.text_color', 'Text color')}
</Text>
<View style={styles.swatchRow}>
{TEXT_COLORS.map((color) => (
<Pressable
key={color}
onPress={() => { setColorPickerOpen(false); editorRef.current?.exec(`foreColor:${color}`); }}
accessibilityLabel={color}
onPress={() => {
setColorTarget(null);
editorRef.current?.exec(colorTarget === 'background' ? `hiliteColor:${color}` : `foreColor:${color}`);
}}
style={[styles.swatch, { backgroundColor: color }]}
/>
))}
</View>
<Pressable style={styles.scheduleCancel} onPress={() => setColorPickerOpen(false)}>
{colorTarget === 'background' && (
<Pressable
style={styles.scheduleRow}
onPress={() => { setColorTarget(null); editorRef.current?.exec('hiliteColor:transparent'); }}
>
<RemoveFormatting size={16} color={c.textSecondary} />
<Text style={styles.scheduleRowLabel}>
{t('email_composer.toolbar.remove_background_color', 'Remove background color')}
</Text>
</Pressable>
)}
<Pressable style={styles.scheduleCancel} onPress={() => setColorTarget(null)}>
<Text style={styles.modalCancelText}>{t('email_composer.cancel', 'Cancel')}</Text>
</Pressable>
</Pressable>