Files
webmail/lib/email-fit-width.ts
T

125 lines
5.0 KiB
TypeScript

/**
* 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.
*/
/** 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.
*/
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);
}
/**
* Fit the document body into its iframe, returning the scale that was applied
* (1 = untouched). Idempotent: every call re-measures from the unscaled layout,
* so it can be re-run as images load and the content reflows.
*
* The caller owns the iframe height - multiply the measured (unscaled) content
* height by the returned scale, since a transform does not change layout size.
*/
export function fitEmailBodyWidth(doc: Document, options: FitOptions = {}): number {
const body = doc.body;
if (!body) return 1;
// Measure the intrinsic layout, not the one a previous call left behind.
clearFit(body);
if (options.enabled === false) return 1;
const viewport = doc.documentElement.clientWidth;
let content = measureContentWidth(doc);
let scale = computeFitScale(content, viewport, options);
if (scale === 1) return 1;
// border-box, so the width we set (measured as a scrollWidth, which already
// covers the body's own padding) doesn't add that padding a second time on
// every pass and creep wider - which scales the mail down further than it
// needs to be and leaves a gap down the side.
body.style.boxSizing = 'border-box';
// Widening the body to its content width can reveal more width: percentage-
// sized children re-lay out against the new containing block, and a table
// with `width: 100%` next to a fixed-width sibling grows with it. Settle over
// a couple of passes rather than scaling against a stale measurement.
for (let pass = 0; pass < 3; pass++) {
body.style.width = `${content}px`;
const grown = measureContentWidth(doc);
if (grown <= content + 1) break;
content = grown;
scale = computeFitScale(content, viewport, options);
if (scale === 1) {
// Grew past the shrink floor - back out and leave it scrolling.
clearFit(body);
return 1;
}
}
// Anchor the shrink at the inline start so the content stays flush with the
// edge the reader's eye starts from.
const rtl = doc.defaultView?.getComputedStyle(body).direction === 'rtl';
body.style.transformOrigin = rtl ? 'top right' : 'top left';
body.style.transform = `scale(${scale})`;
return scale;
}