diff --git a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx index 1cd18bcf..a8e30232 100644 --- a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx +++ b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx @@ -120,6 +120,10 @@ export default function EmailContentEditor({ // notes below the editor come from it, and an author writing markup // is exactly who needs them while they write. if (tab !== "preview" && !code) return; + // Switching to Preview is a deliberate act and should feel immediate. + // Writing markup is not: the request carries the whole body, which for + // a designed email is tens of kilobytes, so it waits for a real pause. + const settle = tab === "preview" ? 250 : 800; // Responses can land out of order; only the newest request may paint. let active = true; const t = setTimeout(() => { @@ -140,7 +144,7 @@ export default function EmailContentEditor({ .catch(() => { if (active) setServerPreview(null); }); - }, 250); + }, settle); return () => { active = false; clearTimeout(t); diff --git a/web/src/components/app/unibox/EmailBody.tsx b/web/src/components/app/unibox/EmailBody.tsx index 4f1dfdf0..508d3a8b 100644 --- a/web/src/components/app/unibox/EmailBody.tsx +++ b/web/src/components/app/unibox/EmailBody.tsx @@ -50,7 +50,7 @@ const DOCUMENT_CSS = ` // designed newsletter) must not be nested inside another one: the doctype and // the would land in the body, and the frame would preview something the // recipient will never see. Its own gets our shell instead. -const DOCUMENT_ROOT = /^\s*(?:])/i; +const DOCUMENT_ROOT = /^\s*(?:\s*)*(?:])/i; const HEAD_OPEN = /]*>/i; const HTML_OPEN = /]*>/i; diff --git a/web/src/lib/email/pastedEmail.test.ts b/web/src/lib/email/pastedEmail.test.ts index 5d4c7621..b382bb54 100644 --- a/web/src/lib/email/pastedEmail.test.ts +++ b/web/src/lib/email/pastedEmail.test.ts @@ -34,6 +34,12 @@ describe("detectPastedEmail", () => { expect(detectPastedEmail(clipboard({ "text/plain": " { + const doc = "

Hi

"; + expect(detectPastedEmail(clipboard({ "text/html": doc }))).toBe(doc); + }); + it("is null for an empty clipboard", () => { expect(detectPastedEmail(null)).toBeNull(); expect(detectPastedEmail(clipboard({}))).toBeNull(); @@ -46,6 +52,10 @@ describe("isDocumentBody", () => { expect(isDocumentBody('

x

')).toBe(true); }); + it("sees a document behind a leading comment", () => { + expect(isDocumentBody("x")).toBe(true); + }); + it("is false for an ordinary campaign body", () => { expect(isDocumentBody("

Hi {{.FirstName}}, do you have ten minutes?

")).toBe(false); expect(isDocumentBody('
Hi
')).toBe(false); diff --git a/web/src/lib/email/pastedEmail.ts b/web/src/lib/email/pastedEmail.ts index 91df9fc9..ff845fc5 100644 --- a/web/src/lib/email/pastedEmail.ts +++ b/web/src/lib/email/pastedEmail.ts @@ -14,7 +14,10 @@ // a live selection was copied out of a rendered page, not a file of markup. const FRAGMENT_MARKER = //i; -const DOCUMENT_ROOT = /^\s*(?:])/i; +// Leading comments are skipped: a saved email opens with the tool that wrote +// it ("") before its doctype, and treating that as an +// ordinary fragment would nest one document inside another. +const DOCUMENT_ROOT = /^\s*(?:\s*)*(?:])/i; const STYLE_BLOCK = /]/i; // Tags that open a paste which is markup someone copied as text: the source of