mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"] }
|
||||
: {},
|
||||
},
|
||||
};
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "إيصال القراءة مطلوب (انقر للتعطيل)",
|
||||
"read_receipt_off": "طلب إيصال قراءة",
|
||||
"new_message": "رسالة جديدة",
|
||||
"signature_edit_hint": "انقر نقرًا مزدوجًا لتحرير التوقيع",
|
||||
"reply": "رد",
|
||||
"reply_all": "الرد على الجميع",
|
||||
"forward": "إعادة توجيه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "درخواست تأیید خواندن فعال (کلیک برای غیرفعال کردن)",
|
||||
"read_receipt_off": "درخواست تأیید خواندن",
|
||||
"new_message": "پیام جدید",
|
||||
"signature_edit_hint": "برای ویرایش امضا دوبار کلیک کنید",
|
||||
"reply": "پاسخ",
|
||||
"reply_all": "پاسخ به همه",
|
||||
"forward": "ارسال مجدد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -537,6 +537,7 @@
|
||||
},
|
||||
"email_composer": {
|
||||
"new_message": "הודעה חדשה",
|
||||
"signature_edit_hint": "לחץ לחיצה כפולה לעריכת החתימה",
|
||||
"reply": "השב",
|
||||
"reply_all": "השב לכולם",
|
||||
"forward": "קדימה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "開封確認を要求中(クリックで無効化)",
|
||||
"read_receipt_off": "開封確認を要求する",
|
||||
"new_message": "新規メッセージ",
|
||||
"signature_edit_hint": "ダブルクリックで署名を編集",
|
||||
"reply": "返信",
|
||||
"reply_all": "全員に返信",
|
||||
"forward": "転送",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "읽음 확인 요청됨 (클릭하여 해제)",
|
||||
"read_receipt_off": "읽음 확인 요청",
|
||||
"new_message": "새 메시지",
|
||||
"signature_edit_hint": "서명을 편집하려면 두 번 클릭하세요",
|
||||
"reply": "답장",
|
||||
"reply_all": "전체 답장",
|
||||
"forward": "전달",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "Уншсан баримт хүссэн (идэвхгүй болгохын тулд дарна уу)",
|
||||
"read_receipt_off": "Уншсан баримт хүсэх",
|
||||
"new_message": "Шинэ мессеж",
|
||||
"signature_edit_hint": "Гарын үсгийг засахын тулд давхар товшино уу",
|
||||
"reply": "Хариулах",
|
||||
"reply_all": "Бүгдэд нь хариулах",
|
||||
"forward": "Урагшаа",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "Запрошено уведомление о прочтении (нажмите, чтобы отключить)",
|
||||
"read_receipt_off": "Запросить уведомление о прочтении",
|
||||
"new_message": "Новое письмо",
|
||||
"signature_edit_hint": "Дважды щёлкните, чтобы изменить подпись",
|
||||
"reply": "Ответить",
|
||||
"reply_all": "Ответить всем",
|
||||
"forward": "Переслать",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "Запитано сповіщення про прочитання (натисніть, щоб вимкнути)",
|
||||
"read_receipt_off": "Запитати сповіщення про прочитання",
|
||||
"new_message": "Нове повідомлення",
|
||||
"signature_edit_hint": "Двічі клацніть, щоб редагувати підпис",
|
||||
"reply": "Відповісти",
|
||||
"reply_all": "Відповісти всім",
|
||||
"forward": "вперед",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"read_receipt_on": "已请求已读回执(点击以关闭)",
|
||||
"read_receipt_off": "请求已读回执",
|
||||
"new_message": "新邮件",
|
||||
"signature_edit_hint": "双击以编辑签名",
|
||||
"reply": "回复",
|
||||
"reply_all": "全部回复",
|
||||
"forward": "转发",
|
||||
|
||||
Reference in New Issue
Block a user