mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
125 lines
5.0 KiB
TypeScript
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;
|
|
}
|