/** * Scale-to-fit for email bodies rendered inside the sandboxed viewer iframes. * * A lot of mail is authored at a fixed desktop width (the 600-800px table is * the newsletter default). On a phone that content is wider than the iframe, * so the reader has to swipe sideways through every message - or, where the * iframe clips instead of scrolling, never sees the right-hand half at all. * * Native mail clients solve this by shrinking the whole body to the screen * width, which is what these helpers do: lay the body out at its intrinsic * width and then `transform: scale()` it down. Layout is preserved exactly * (unlike forcing everything to `width: 100%`, which crushes wide data tables * into one-character columns - issue #409), and the reader can still pinch-zoom * to read the fine print. * * Only narrow viewports are scaled. On a desktop reading pane a wide table * still scrolls horizontally inside the body, which is the better trade-off * there: the content is legible at 1:1 and there is a mouse to scroll with. * * Before any of that, tables whose own fixed width is wider than the body are * let go to reflow (see releaseFixedWidthTables) - scaling is for content that * can't reflow, not for text the sender merely boxed in at 800px. */ /** Widest iframe that still counts as "a phone" for scale-to-fit purposes. */ export const FIT_VIEWPORT_MAX = 640; /** * Never shrink past this. An email that would need more (a 2000px data table * on a 390px screen) is unreadable when scaled, so it keeps the horizontal * scroll instead and the reader pans through it at a legible size. */ export const FIT_MIN_SCALE = 0.4; export interface FitOptions { /** * Set false on a desktop layout: a wide mail there is legible at 1:1 and the * reading pane can scroll. Any fit already applied is undone. Fixed-width * tables are released either way - that is the viewer stylesheet's table cap * doing its job, not a phone behaviour. */ enabled?: boolean; /** Iframes at least this wide are left at 1:1. */ viewportMax?: number; /** Lower bound on the scale factor. */ minScale?: number; } /** * The factor a body of `contentWidth` must be scaled by to fit `viewportWidth`, * or 1 when it should be left alone (already fits, not a narrow viewport, or * would have to shrink past `minScale`). */ export function computeFitScale( contentWidth: number, viewportWidth: number, { viewportMax = FIT_VIEWPORT_MAX, minScale = FIT_MIN_SCALE }: FitOptions = {}, ): number { if (!(contentWidth > 0) || !(viewportWidth > 0)) return 1; if (viewportWidth > viewportMax) return 1; // 1px of slack: sub-pixel layout rounding routinely reports a scrollWidth a // hair over clientWidth on content that visually fits. if (contentWidth <= viewportWidth + 1) return 1; const scale = viewportWidth / contentWidth; return scale < minScale ? 1 : scale; } /** Undo a fit, leaving the body laid out at the iframe's own width. */ function clearFit(body: HTMLElement): void { body.style.transform = ''; body.style.width = ''; body.style.boxSizing = ''; } /** Widest thing in the document. */ function measureContentWidth(doc: Document): number { return Math.max(doc.body.scrollWidth, doc.documentElement.scrollWidth); } const RELEASED_ATTR = 'data-bulwark-fit-released'; /** * The width a table pins itself to - a `width="800"` attribute or an inline * `width: 700px` - or null for auto/percentage widths, which already give way. * Inline style wins over the attribute, as it does in the cascade. */ function fixedTableWidth(table: HTMLTableElement): string | null { const inline = table.style.width.trim(); if (inline) return /^\d*\.?\d+[a-z]+$/i.test(inline) ? inline : null; const attr = /^\s*(\d*\.?\d+)(?:px)?\s*$/i.exec(table.getAttribute('width') ?? ''); return attr ? `${attr[1]}px` : null; } /** * Let fixed-width tables that are wider than the body shrink to it, returning * how many were released. * * The viewer stylesheet caps tables at `max-width: 100%`, but WebKit ignores * that cap for a table with a fixed width: it treats the fixed width as the * table's minimum. So on iOS every mail wrapped in `