fix: let a double-click unlock the embedded signature for editing

Since the signature became a verbatim, non-editable atom (#476), there
was no way to edit it in the composer at all (#822) - the styling
guarantee silently cost the editability #329 had established. Keep the
atom as the default so branded signatures still survive 1:1, and let an
explicit gesture opt out: double-clicking the block dissolves it into
regular editable content at the same position. Double-clicks that land
on a link are ignored (the click is working the link), and Enter on the
selected block keeps ProseMirror's default create-paragraph-after
behaviour.

Faithfulness of the dissolved signature:
- Leaf <div> lines become paragraphs; only vertical margins the line
  does not declare are reset to 0 (how a div actually renders), so
  explicit spacing and indents survive. Divs carrying hr/pre and other
  non-line blocks are left to the schema parse.
- Inherited styles (font, color, ...) on wrapper divs - which have no
  schema node and would be dropped - are hoisted onto the generated
  line paragraphs, the line's own declarations winning.
- The content is parsed as a CLOSED document via the schema DOMParser
  and spliced in with one transaction; insertContentAt fits an open
  slice, which can split text runs at hard breaks and turned <br>-lined
  signatures into one spaced paragraph per line.

The bracketing data-signature-block markers live outside the atom and
stay put, so the identity-switch splice and the send path's
already-embedded detection keep working on unlocked content, and
switching identities re-embeds a fresh locked block. Undo restores the
atom. A translated tooltip on the block makes the gesture discoverable.
This commit is contained in:
dealerweb
2026-08-18 17:31:47 +02:00
parent 58ddd01cf7
commit 3d1bdf0943
29 changed files with 399 additions and 31 deletions
@@ -1,15 +1,52 @@
import { describe, expect, it } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Paragraph from '@tiptap/extension-paragraph';
import {
SignatureBlock,
buildSignatureBlock,
containsEmbeddedSignature,
unlockSignatureBlock,
SIGNATURE_BLOCK_MARKER,
SIGNATURE_RANGE_MARKER,
} from '../signature-block';
import { serializeEditorContent } from '../quoted-html';
import { sanitizeSignatureHtml } from '@/lib/email-sanitization';
import { styledBlockAttributes } from '../styled-block-attributes';
// The exact paragraph the composer runs (rich-text-editor.tsx builds
// StyledParagraph from the same styledBlockAttributes), so marker-survival
// and line-spacing assertions test the schema users actually get.
const ComposerParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
...styledBlockAttributes,
};
},
});
const bracketed = (inner: string) =>
`<p>Hi</p><p ${SIGNATURE_RANGE_MARKER}="separator">-- </p>`
+ `${buildSignatureBlock(inner)}<p ${SIGNATURE_RANGE_MARKER}="end"></p>`;
function makeEditor(content: string) {
return new Editor({
element: document.createElement('div'),
extensions: [StarterKit.configure({ paragraph: false }), ComposerParagraph, SignatureBlock],
content,
});
}
function findSignaturePos(editor: Editor): number {
let found = -1;
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'signatureBlock') { found = pos; return false; }
return true;
});
return found;
}
describe('signature-block', () => {
it('buildSignatureBlock wraps html in the marker div', () => {
@@ -81,3 +118,176 @@ describe('signature-block', () => {
}
});
});
describe('unlockSignatureBlock (#822)', () => {
it('dissolves the atom into regular editable content between the markers', () => {
const editor = makeEditor(bracketed('<b>Alice</b><p>CEO</p>'));
try {
const pos = findSignaturePos(editor);
expect(pos).toBeGreaterThanOrEqual(0);
expect(unlockSignatureBlock(editor, pos)).toBe(true);
const out = serializeEditorContent(editor);
// The atom is gone, the signature text is ordinary schema content now.
expect(out).not.toContain(SIGNATURE_BLOCK_MARKER);
expect(out).toContain('Alice');
expect(out).toContain('CEO');
// The bracketing markers survive, so the identity-switch splice and the
// send path's already-embedded detection keep working.
expect(out).toContain(`${SIGNATURE_RANGE_MARKER}="separator"`);
expect(out).toContain(`${SIGNATURE_RANGE_MARKER}="end"`);
expect(containsEmbeddedSignature(out)).toBe(true);
} finally {
editor.destroy();
}
});
it('unlocked content is genuinely editable', () => {
const editor = makeEditor(bracketed('<p>Old signature line</p>'));
try {
unlockSignatureBlock(editor, findSignaturePos(editor));
// Type INSIDE the former signature text - not merely anywhere in the doc.
let textPos = -1;
editor.state.doc.descendants((node, pos) => {
if (node.isText && node.text?.includes('Old signature line')) { textPos = pos; return false; }
return true;
});
expect(textPos).toBeGreaterThanOrEqual(0);
editor.commands.insertContentAt(textPos + 4, 'EDITED ');
expect(serializeEditorContent(editor)).toContain('Old EDITED signature line');
} finally {
editor.destroy();
}
});
it('returns false and leaves the doc alone for a non-signature position', () => {
const editor = makeEditor(bracketed('<b>Alice</b>'));
try {
const before = serializeEditorContent(editor);
expect(unlockSignatureBlock(editor, 0)).toBe(false);
expect(serializeEditorContent(editor)).toBe(before);
} finally {
editor.destroy();
}
});
it('undo restores the locked atom', () => {
const editor = makeEditor(bracketed('<b>Alice</b>'));
try {
unlockSignatureBlock(editor, findSignaturePos(editor));
expect(serializeEditorContent(editor)).not.toContain(SIGNATURE_BLOCK_MARKER);
editor.commands.undo();
expect(serializeEditorContent(editor)).toContain(SIGNATURE_BLOCK_MARKER);
} finally {
editor.destroy();
}
});
it('an empty signature unlocks to an empty paragraph instead of vanishing weirdly', () => {
const editor = makeEditor(bracketed(''));
try {
expect(unlockSignatureBlock(editor, findSignaturePos(editor))).toBe(true);
expect(serializeEditorContent(editor)).not.toContain(SIGNATURE_BLOCK_MARKER);
} finally {
editor.destroy();
}
});
// The classic signature shape: one <div> per line. Divs render margin-less
// in mail clients but have no schema node, so a naive parse re-wraps every
// line into a default-margin paragraph and the signature falls apart into
// spaced paragraphs (second #822 report, live test).
it('keeps tight line spacing for div-per-line signatures', () => {
const editor = makeEditor(bracketed('<div>Jane Doe</div><div>IT-Management</div>'));
try {
unlockSignatureBlock(editor, findSignaturePos(editor));
const out = serializeEditorContent(editor);
// Both lines carry the vertical margin reset, none is a bare default paragraph.
expect(out.match(/margin-top: 0px; margin-bottom: 0px/g)?.length).toBe(2);
} finally {
editor.destroy();
}
});
// Longhand margins must not defeat the reset: only the missing verticals
// are zeroed, explicit spacing and indents survive.
it('keeps indents while resetting missing vertical margins', () => {
const editor = makeEditor(bracketed('<div style="margin-left: 12px">Indented line</div>'));
try {
unlockSignatureBlock(editor, findSignaturePos(editor));
const out = serializeEditorContent(editor);
expect(out).toContain('margin-left: 12px');
expect(out).toContain('margin-top: 0px');
expect(out).toContain('margin-bottom: 0px');
} finally {
editor.destroy();
}
});
// Wrapper divs have no schema node and are dropped by the parse; their
// inherited styles (the font/color the whole signature lives in) must move
// onto the generated line paragraphs, with the line's own styles winning.
it('hoists inherited wrapper styles onto the line paragraphs', () => {
const editor = makeEditor(bracketed(
'<div style="font-family: Arial; color: rgb(51, 51, 51)">'
+ '<div>Name</div><div style="color: rgb(255, 0, 0)">Title</div></div>'
));
try {
unlockSignatureBlock(editor, findSignaturePos(editor));
const out = serializeEditorContent(editor);
expect(out.match(/font-family: Arial/g)?.length).toBe(2);
expect(out).toContain('color: rgb(51, 51, 51)');
expect(out).toContain('color: rgb(255, 0, 0)');
} finally {
editor.destroy();
}
});
it('keeps <br> line runs together and respects explicit margins', () => {
const editor = makeEditor(bracketed(
'<div>Tel: 1 <b>•</b> Fax: 2<br>Mobil: 3</div><p style="margin:4px 0">Legal</p>'
));
try {
unlockSignatureBlock(editor, findSignaturePos(editor));
const out = serializeEditorContent(editor);
// The br stays a line break inside one paragraph, not a paragraph split.
expect(out).toMatch(/Fax: 2<br>Mobil: 3/);
// An explicit margin is not overridden by the margin-0 default.
expect(out).toContain('margin: 4px 0');
} finally {
editor.destroy();
}
});
});
describe('unlockSignatureBlock keeps the classic Outlook-style signature intact', () => {
// One <p> with <br> line runs, font-size on the block, colored bold spans -
// the shape that shipped broken twice: schema parse must keep it a single
// paragraph with hard breaks, through the real pipeline (sanitize -> embed
// -> initial parse -> unlock).
const SIG = `<p style="font-size:9pt;">Mit freundlichem Gruß<br><br>
<span style="color:#1f497d; font-weight:bold">Jane Doe</span><br>
IT-Management<br><br>
Tel: 111 • Fax: 222<br><br>
Der Inhalt dieser E-Mail ist vertraulich.
</p>`;
it('stays one paragraph with all hard breaks after the unlock', () => {
const sanitized = sanitizeSignatureHtml(SIG);
const body = `<p></p><p ${SIGNATURE_RANGE_MARKER}="separator">-- </p>`
+ `${buildSignatureBlock(sanitized)}<p ${SIGNATURE_RANGE_MARKER}="end"></p>`;
const editor = makeEditor(body);
try {
const pos = findSignaturePos(editor);
expect(unlockSignatureBlock(editor, pos)).toBe(true);
const out = serializeEditorContent(editor);
// Leading paragraph + separator + ONE signature paragraph + end marker.
expect((out.match(/<p/g) || []).length).toBe(4);
expect((out.match(/<br/g) || []).length).toBe(7);
expect(out).toContain('font-size: 9pt');
expect(out).toContain('Jane Doe');
} finally {
editor.destroy();
}
});
});
+5 -29
View File
@@ -19,6 +19,7 @@ import { TableHeader } from "@tiptap/extension-table-header";
import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { SignatureBlock } from "@/components/email/signature-block";
import { styledBlockAttributes } from "@/components/email/styled-block-attributes";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useTranslations } from "next-intl";
@@ -53,33 +54,6 @@ export interface InlineImageUpload {
cid?: string;
}
// Pasted email content (signatures, replies, quoted text) commonly carries
// inline styles on block elements. StarterKit's default Paragraph/Heading
// drop unknown attributes; extend them to round-trip `style` and `class` so
// signature formatting survives the editor.
const styledBlockAttributes = {
style: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.style ? { style: attrs.style } : {},
},
class: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.class ? { class: attrs.class } : {},
},
"data-signature-block": {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs["data-signature-block"]
? { "data-signature-block": attrs["data-signature-block"] }
: {},
},
};
const StyledParagraph = Paragraph.extend({
addAttributes() {
return {
@@ -198,6 +172,7 @@ export function RichTextEditor({
onEditorReady,
}: RichTextEditorProps) {
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const tComposer = useTranslations("email_composer");
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
const onEditorReadyRef = React.useRef(onEditorReady);
@@ -253,8 +228,9 @@ export function RichTextEditor({
QuotedHtml,
// Identity signature - held verbatim as a non-editable atomic node so
// rich/branded signatures keep their inline styling in the editor and
// in the sent mail (see signature-block.ts).
SignatureBlock,
// in the sent mail (see signature-block.ts). Double-click unlocks it
// into editable content (#822).
SignatureBlock.configure({ editHint: tComposer('signature_edit_hint') }),
TextDirection,
],
content,
+131 -2
View File
@@ -1,6 +1,9 @@
"use client";
import { Node as TiptapNode, mergeAttributes } from "@tiptap/core";
import { Node as TiptapNode, mergeAttributes, type Editor } from "@tiptap/core";
import { TextSelection } from "@tiptap/pm/state";
import { DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model";
import { parseHtmlSafely } from "@/lib/email-sanitization";
// Marker attribute that identifies the signature wrapper in serialized HTML,
// so parseHTML can recognise it on the way back in (initial content, drafts).
@@ -43,6 +46,14 @@ function forceLinksToNewTab(root: HTMLElement): void {
* Mirrors QuotedHtml, but the inner region is read-only: a signature is meant
* to be inserted/removed as a unit, not edited inline. Select the node and
* press Backspace/Delete to drop the whole signature.
*
* Read-only is the default, not a wall (#822): double-click the block to
* dissolve it into regular editable content via
* {@link unlockSignatureBlock}. That trades the verbatim-styling guarantee
* for editability - the explicit user gesture is what makes the trade
* acceptable, unlike the silent flattening #476 fixed. (Enter on the selected
* block deliberately keeps ProseMirror's default create-paragraph-after
* behaviour - it is a low-intent everyday key.)
*/
export const SignatureBlock = TiptapNode.create({
name: "signatureBlock",
@@ -53,6 +64,15 @@ export const SignatureBlock = TiptapNode.create({
// Isolating keeps selection/gapcursor behaviour sane at the boundary.
isolating: true,
addOptions() {
return {
// Translated tooltip shown on the block ("Double-click to edit the
// signature"). Set via .configure() where translations are available;
// empty means no tooltip.
editHint: "",
};
},
addAttributes() {
return {
html: {
@@ -79,10 +99,25 @@ export const SignatureBlock = TiptapNode.create({
},
addNodeView() {
return ({ node }) => {
return ({ node, editor, getPos }) => {
const dom = document.createElement("div");
dom.setAttribute(SIGNATURE_BLOCK_MARKER, "");
dom.className = "signature-block-island";
if (this.options.editHint) {
dom.title = this.options.editHint;
}
// Double-click dissolves the atom into regular editable content (#822).
// The listener sits on the wrapper; shadow-root events are composed, so
// it fires for the whole island.
dom.addEventListener("dblclick", (event) => {
// A double-click landing on a link is the user working the link (the
// first click already opened it) - not a request to start editing.
if (event.composedPath().some((t) => (t as Element).tagName === "A")) return;
const pos = typeof getPos === "function" ? getPos() : undefined;
if (pos == null) return;
event.preventDefault();
unlockSignatureBlock(editor, pos);
});
// CRITICAL: render the signature inside a Shadow Root. The app's global
@@ -125,6 +160,100 @@ export const SignatureBlock = TiptapNode.create({
},
});
// Block elements that must not end up inside a <p>: a div containing any of
// these is a container, not a text line, and is left for the schema parse.
const NON_LINE_BLOCKS =
"div, p, table, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, hr, pre, address, figure, dl";
// Inherited style properties hoisted from wrapper divs onto the generated
// line paragraphs. Wrappers themselves have no schema node and are dropped by
// the parse, so without hoisting the font/color the whole signature inherits
// from its container would silently vanish on unlock. Non-inherited
// properties (background, border, padding) are deliberately NOT hoisted -
// per-line copies would render differently than the container did.
const INHERITED_STYLE_PROPS = [
"font", "font-family", "font-size", "font-style", "font-weight",
"color", "line-height", "letter-spacing", "text-transform", "direction",
];
/**
* Line-level <div>s render margin-less in mail clients, but the editor schema
* has no div node: parsing would re-wrap their content into default-margin
* paragraphs and the signature's tight line structure blows apart into spaced
* paragraphs. Rewrite leaf divs into explicit margin-0 paragraphs (keeping
* their attributes, inheriting wrapper styles) so the unlocked signature
* keeps the locked rendering's line spacing - the composer's paragraph
* preserves inline styles. Vertical margins are reset only where the line
* does not set them itself; horizontal margins (indented lines) are left
* alone. <br> runs and <p> blocks are untouched (the schema already handles
* those faithfully). Returns the parsed body element, ready for the
* ProseMirror DOM parser.
*/
function normalizeSignatureHtmlForEditing(html: string): HTMLElement {
const doc = parseHtmlSafely(html);
const body = doc.body;
body.querySelectorAll("div").forEach((div) => {
if (div.querySelector(NON_LINE_BLOCKS)) return;
const p = doc.createElement("p");
for (const attr of Array.from(div.attributes)) p.setAttribute(attr.name, attr.value);
// Hoist inherited styles from wrapper divs, outermost first so closer
// wrappers win; the line's own declarations always take precedence.
const wrappers: HTMLElement[] = [];
for (let ancestor = div.parentElement; ancestor && ancestor !== body; ancestor = ancestor.parentElement) {
if (ancestor.tagName === "DIV") wrappers.unshift(ancestor);
}
for (const wrapper of wrappers) {
for (const prop of INHERITED_STYLE_PROPS) {
const value = wrapper.style.getPropertyValue(prop);
if (value && !div.style.getPropertyValue(prop)) p.style.setProperty(prop, value);
}
}
// A div renders with zero margins; reset only the verticals the line
// does not declare itself, keeping explicit spacing and indents intact.
if (!p.style.margin) {
if (!p.style.marginTop) p.style.marginTop = "0";
if (!p.style.marginBottom) p.style.marginBottom = "0";
}
while (div.firstChild) p.appendChild(div.firstChild);
div.replaceWith(p);
});
// An empty signature still needs a block to put the caret into.
if (!body.firstElementChild) body.appendChild(doc.createElement("p"));
return body;
}
/**
* Dissolve the signature atom at `pos` into regular, editable editor content
* (#822). The verbatim HTML is parsed into the schema at the atom's position -
* the pre-#476 representation, with the known consequence that heavily styled
* markup may be normalized by the schema. Leaf <div> lines are rewritten to
* margin-0 paragraphs first so the line spacing survives the parse (see
* normalizeSignatureHtmlForEditing). The bracketing `data-signature-block`
* marker paragraphs live OUTSIDE the atom and are left untouched, so the
* identity-switch splice and the send path's already-embedded detection keep
* working on the unlocked content, and switching identities re-embeds a fresh
* locked block. Undo restores the atom.
*/
export function unlockSignatureBlock(editor: Editor, pos: number): boolean {
if (!editor.isEditable) return false;
const node = editor.state.doc.nodeAt(pos);
if (!node || node.type.name !== "signatureBlock") return false;
const body = normalizeSignatureHtmlForEditing(String(node.attrs.html || ""));
// Parse as a CLOSED document (complete blocks) and splice it in with one
// plain transaction. insertContentAt parses an OPEN slice instead, and
// fitting an open slice may split text runs at hard breaks - which turned a
// <br>-lined signature into one spaced paragraph per line in the browser.
// A closed fragment of complete blocks cannot be refitted that way.
const parsed = ProseMirrorDOMParser.fromSchema(editor.state.schema).parse(body, {
preserveWhitespace: false,
});
const tr = editor.state.tr.replaceWith(pos, pos + node.nodeSize, parsed.content);
tr.setSelection(TextSelection.near(tr.doc.resolve(pos))).scrollIntoView();
editor.view.dispatch(tr);
editor.view.focus();
return true;
}
/**
* Build the editor-content wrapper that embeds the signature as a single
* SignatureBlock node. The inner HTML must be pre-sanitized
@@ -0,0 +1,28 @@
// Pasted email content (signatures, replies, quoted text) commonly carries
// inline styles on block elements. StarterKit's default Paragraph/Heading
// drop unknown attributes; the composer extends them with these so `style`,
// `class` and the signature range marker round-trip the editor. Kept in its
// own module so tests can build the exact composer paragraph instead of
// hand-copying (and silently drifting from) this config.
export const styledBlockAttributes = {
style: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("style"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.style ? { style: attrs.style } : {},
},
class: {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("class"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs.class ? { class: attrs.class } : {},
},
"data-signature-block": {
default: null as string | null,
parseHTML: (el: HTMLElement) => el.getAttribute("data-signature-block"),
renderHTML: (attrs: Record<string, string | null>) =>
attrs["data-signature-block"]
? { "data-signature-block": attrs["data-signature-block"] }
: {},
},
};
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "إيصال القراءة مطلوب (انقر للتعطيل)",
"read_receipt_off": "طلب إيصال قراءة",
"new_message": "رسالة جديدة",
"signature_edit_hint": "انقر نقرًا مزدوجًا لتحرير التوقيع",
"reply": "رد",
"reply_all": "الرد على الجميع",
"forward": "إعادة توجيه",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Confirmació de lectura sol·licitada (feu clic per desactivar-la)",
"read_receipt_off": "Sol·licita una confirmació de lectura",
"new_message": "Missatge nou",
"signature_edit_hint": "Feu doble clic per editar la signatura",
"reply": "Respon",
"reply_all": "Respon a tots",
"forward": "Reenvia",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Vyžádáno potvrzení o přečtení (kliknutím vypnete)",
"read_receipt_off": "Vyžádat potvrzení o přečtení",
"new_message": "Nová zpráva",
"signature_edit_hint": "Dvojklikem upravíte podpis",
"reply": "Odpovědět",
"reply_all": "Odpovědět všem",
"forward": "Přeposlat",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Læsekvittering anmodet (klik for at deaktivere)",
"read_receipt_off": "Anmod om læsekvittering",
"new_message": "Ny besked",
"signature_edit_hint": "Dobbeltklik for at redigere signaturen",
"reply": "Svar",
"reply_all": "Svar alle",
"forward": "Videresend",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Lesebestätigung angefordert (klicken zum Deaktivieren)",
"read_receipt_off": "Lesebestätigung anfordern",
"new_message": "Neue Nachricht",
"signature_edit_hint": "Doppelklick, um die Signatur zu bearbeiten",
"reply": "Antworten",
"reply_all": "Allen antworten",
"forward": "Weiterleiten",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Read receipt requested (click to disable)",
"read_receipt_off": "Request a read receipt",
"new_message": "New Message",
"signature_edit_hint": "Double-click to edit the signature",
"reply": "Reply",
"reply_all": "Reply All",
"forward": "Forward",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Confirmación de lectura solicitada (haz clic para desactivar)",
"read_receipt_off": "Solicitar confirmación de lectura",
"new_message": "Nuevo Mensaje",
"signature_edit_hint": "Haz doble clic para editar la firma",
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Reenviar",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "درخواست تأیید خواندن فعال (کلیک برای غیرفعال کردن)",
"read_receipt_off": "درخواست تأیید خواندن",
"new_message": "پیام جدید",
"signature_edit_hint": "برای ویرایش امضا دوبار کلیک کنید",
"reply": "پاسخ",
"reply_all": "پاسخ به همه",
"forward": "ارسال مجدد",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Accusé de lecture demandé (cliquez pour désactiver)",
"read_receipt_off": "Demander un accusé de lecture",
"new_message": "Nouveau message",
"signature_edit_hint": "Double-cliquez pour modifier la signature",
"reply": "Répondre",
"reply_all": "Répondre à tous",
"forward": "Transférer",
+1
View File
@@ -537,6 +537,7 @@
},
"email_composer": {
"new_message": "הודעה חדשה",
"signature_edit_hint": "לחץ לחיצה כפולה לעריכת החתימה",
"reply": "השב",
"reply_all": "השב לכולם",
"forward": "קדימה",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Olvasási visszaigazolás kérve (kattints a letiltáshoz)",
"read_receipt_off": "Olvasási visszaigazolás kérése",
"new_message": "Új üzenet",
"signature_edit_hint": "Kattintson duplán az aláírás szerkesztéséhez",
"reply": "Válasz",
"reply_all": "Válasz mindenkinek",
"forward": "Továbbítás",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Conferma di lettura richiesta (clicca per disattivare)",
"read_receipt_off": "Richiedi una conferma di lettura",
"new_message": "Nuovo messaggio",
"signature_edit_hint": "Doppio clic per modificare la firma",
"reply": "Rispondi",
"reply_all": "Rispondi a tutti",
"forward": "Inoltra",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
"read_receipt_off": "開封確認を要求する",
"new_message": "新規メッセージ",
"signature_edit_hint": "ダブルクリックで署名を編集",
"reply": "返信",
"reply_all": "全員に返信",
"forward": "転送",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "읽음 확인 요청됨 (클릭하여 해제)",
"read_receipt_off": "읽음 확인 요청",
"new_message": "새 메시지",
"signature_edit_hint": "서명을 편집하려면 두 번 클릭하세요",
"reply": "답장",
"reply_all": "전체 답장",
"forward": "전달",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Pieprasīts lasīšanas apstiprinājums (noklikšķiniet, lai atspējotu)",
"read_receipt_off": "Pieprasīt lasīšanas apstiprinājumu",
"new_message": "Jauns ziņojums",
"signature_edit_hint": "Veiciet dubultklikšķi, lai rediģētu parakstu",
"reply": "Atbildēt",
"reply_all": "Atbildēt visiem",
"forward": "Pārsūtīt",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Уншсан баримт хүссэн (идэвхгүй болгохын тулд дарна уу)",
"read_receipt_off": "Уншсан баримт хүсэх",
"new_message": "Шинэ мессеж",
"signature_edit_hint": "Гарын үсгийг засахын тулд давхар товшино уу",
"reply": "Хариулах",
"reply_all": "Бүгдэд нь хариулах",
"forward": "Урагшаа",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Leesbevestiging aangevraagd (klik om uit te schakelen)",
"read_receipt_off": "Leesbevestiging aanvragen",
"new_message": "Nieuw bericht",
"signature_edit_hint": "Dubbelklik om de handtekening te bewerken",
"reply": "Beantwoorden",
"reply_all": "Allen beantwoorden",
"forward": "Doorsturen",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Zażądano potwierdzenia przeczytania (kliknij, aby wyłączyć)",
"read_receipt_off": "Zażądaj potwierdzenia przeczytania",
"new_message": "Nowa wiadomość",
"signature_edit_hint": "Kliknij dwukrotnie, aby edytować podpis",
"reply": "Odpowiedz",
"reply_all": "Odpowiedz wszystkim",
"forward": "Przekaż dalej",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Confirmação de leitura solicitada (clique para desativar)",
"read_receipt_off": "Solicitar confirmação de leitura",
"new_message": "Nova Mensagem",
"signature_edit_hint": "Clique duas vezes para editar a assinatura",
"reply": "Responder",
"reply_all": "Responder a Todos",
"forward": "Encaminhar",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Solicitare confirmare de citire (faceți clic pentru a dezactiva)",
"read_receipt_off": "Solicitați o confirmare de citire",
"new_message": "Mesaj nou",
"signature_edit_hint": "Dublu clic pentru a edita semnătura",
"reply": "Răspunde",
"reply_all": "Răspunde tuturor",
"forward": "Redirecționează",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Запрошено уведомление о прочтении (нажмите, чтобы отключить)",
"read_receipt_off": "Запросить уведомление о прочтении",
"new_message": "Новое письмо",
"signature_edit_hint": "Дважды щёлкните, чтобы изменить подпись",
"reply": "Ответить",
"reply_all": "Ответить всем",
"forward": "Переслать",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Potvrdenie o prečítaní požiadané (kliknutím vypnete)",
"read_receipt_off": "Požiadať o potvrdenie o prečítaní",
"new_message": "Nová správa",
"signature_edit_hint": "Dvojklikom upravíte podpis",
"reply": "Odpovedať",
"reply_all": "Odpovedať všetkým",
"forward": "Preposlať",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Okundu bilgisi istendi (devre dışı bırakmak için tıklayın)",
"read_receipt_off": "Okundu bilgisi iste",
"new_message": "Yeni İleti",
"signature_edit_hint": "İmzayı düzenlemek için çift tıklayın",
"reply": "Yanıtla",
"reply_all": "Tümünü Yanıtla",
"forward": "İlet",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "Запитано сповіщення про прочитання (натисніть, щоб вимкнути)",
"read_receipt_off": "Запитати сповіщення про прочитання",
"new_message": "Нове повідомлення",
"signature_edit_hint": "Двічі клацніть, щоб редагувати підпис",
"reply": "Відповісти",
"reply_all": "Відповісти всім",
"forward": "вперед",
+1
View File
@@ -574,6 +574,7 @@
"read_receipt_on": "已请求已读回执(点击以关闭)",
"read_receipt_off": "请求已读回执",
"new_message": "新邮件",
"signature_edit_hint": "双击以编辑签名",
"reply": "回复",
"reply_all": "全部回复",
"forward": "转发",