mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: pad attachment preview overlays for the iOS PWA safe area #936
This commit is contained in:
@@ -7,6 +7,7 @@ import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { applyNewTabToAnchor, escapeHtml, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
|
||||
import { useCopyLink } from "@/hooks/use-copy-link";
|
||||
@@ -2144,6 +2145,13 @@ export function EmailViewer({
|
||||
// Iframe for rendering HTML emails true-to-life
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Scale-to-fit is a phone behaviour: on a desktop reading pane a wide mail is
|
||||
// legible at 1:1 and scrolls. Held in a ref because the measurement runs from
|
||||
// observers wired up once per document - a plain closure would keep fitting
|
||||
// (or not) across a rotation or a pane resize that crossed the breakpoint.
|
||||
const fitBodyToWidthRef = useRef(isMobile);
|
||||
fitBodyToWidthRef.current = isMobile;
|
||||
|
||||
// Detect if the email HTML has native dark mode support
|
||||
const emailHasNativeDarkMode = useMemo(() => {
|
||||
if (!effectiveEmailContent.isHtml) return false;
|
||||
@@ -2264,7 +2272,10 @@ export function EmailViewer({
|
||||
overflow-x is auto on the body so an intrinsically wide table (e.g. a
|
||||
20-column data table) can scroll horizontally instead of being crushed to
|
||||
fit - the latter wraps header text to one character per line, which reads
|
||||
as 90deg-rotated vertical headers (issue #409). */
|
||||
as 90deg-rotated vertical headers (issue #409). On a phone that scroll is
|
||||
the wrong answer for ordinary fixed-width mail, so fitEmailBodyWidth()
|
||||
shrinks the whole body to the screen width after load and only content too
|
||||
wide to stay legible when scaled keeps scrolling. */
|
||||
html { overflow: hidden; height: auto !important; }
|
||||
/* Some emails put height:100% on a full-bleed wrapper table/div (not html/body),
|
||||
which - with body's overflow:hidden - clips the content to a sliver, and the
|
||||
@@ -2316,6 +2327,12 @@ export function EmailViewer({
|
||||
// be torn down. contentDocument keeps pointing at it until the browser swaps
|
||||
// the new srcDoc in, so the poll skips it to avoid wiring up stale content.
|
||||
const staleDocRef = useRef<Document | null>(null);
|
||||
// Host-side observer on the iframe box itself: the in-document one watches
|
||||
// body, whose width scale-to-fit pins to the content width, so a viewport
|
||||
// change (rotation, pane resize) would otherwise never re-fit. Kept in a ref
|
||||
// so each srcDoc replaces the previous one instead of stacking observers.
|
||||
const frameSizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
useEffect(() => () => frameSizeObserverRef.current?.disconnect(), []);
|
||||
useLayoutEffect(() => {
|
||||
setIframeReady(false);
|
||||
initializedDocRef.current = null;
|
||||
@@ -2349,12 +2366,31 @@ export function EmailViewer({
|
||||
// documentElement.scrollHeight short while the real content lives in body.
|
||||
const applyHeight = () => {
|
||||
if (iframe.contentDocument !== doc) return; // navigated away; stale
|
||||
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
|
||||
// Shrink desktop-width mail to fit a phone screen first: it changes
|
||||
// the layout height, and the transform doesn't shrink the measured
|
||||
// (layout) height, so the iframe box has to be scaled by hand.
|
||||
const scale = fitEmailBodyWidth(doc, { enabled: fitBodyToWidthRef.current });
|
||||
const height = Math.ceil(
|
||||
Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight) * scale
|
||||
);
|
||||
iframe.style.height = height + 'px';
|
||||
lastBodyHeightRef.current = height;
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(applyHeight);
|
||||
resizeObserver.observe(doc.body);
|
||||
// Re-fit when the iframe itself changes width. Height-only changes are
|
||||
// our own applyHeight writing back, so they're ignored - re-entering on
|
||||
// those would spin the observer.
|
||||
let lastFrameWidth = iframe.clientWidth;
|
||||
frameSizeObserverRef.current?.disconnect();
|
||||
const frameObserver = new ResizeObserver(() => {
|
||||
if (iframe.contentDocument !== doc) return;
|
||||
if (iframe.clientWidth === lastFrameWidth) return;
|
||||
lastFrameWidth = iframe.clientWidth;
|
||||
applyHeight();
|
||||
});
|
||||
frameObserver.observe(iframe);
|
||||
frameSizeObserverRef.current = frameObserver;
|
||||
applyHeight();
|
||||
// The ResizeObserver only fires on body's border box; a content overflow
|
||||
// that grows scrollHeight without resizing that box (e.g. a height:100%
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -448,8 +449,12 @@ function EmailCard({
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
<style>
|
||||
html, body { overflow: hidden; }
|
||||
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
/* overflow-y hidden keeps the scrollHeight measurement below honest. overflow-x
|
||||
is auto so intrinsically wide content can pan instead of being clipped outright;
|
||||
on a phone fitEmailBodyWidth() shrinks it to the screen first, so the pan is
|
||||
only left for content too wide to stay legible when scaled. */
|
||||
html { overflow: hidden; }
|
||||
body { overflow-x: auto; overflow-y: hidden; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||
img { max-width: 100% !important; height: auto !important; }
|
||||
a { color: #1a73e8; }
|
||||
table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; }
|
||||
@@ -471,7 +476,11 @@ function EmailCard({
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
});
|
||||
const resize = () => {
|
||||
iframe.style.height = doc.documentElement.scrollHeight + 'px';
|
||||
// Fit desktop-width mail to the screen before measuring: the transform
|
||||
// leaves the layout height untouched, so scale the box by hand.
|
||||
const scale = fitEmailBodyWidth(doc);
|
||||
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
|
||||
iframe.style.height = Math.ceil(height * scale) + 'px';
|
||||
};
|
||||
resize();
|
||||
const ro = new ResizeObserver(resize);
|
||||
|
||||
@@ -292,7 +292,13 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
{/* The dialog is `fixed inset-0` under `viewport-fit=cover`, so in an
|
||||
installed iOS PWA (no browser chrome above it) the header would sit
|
||||
underneath the status bar and its buttons - including the close
|
||||
button - became unreachable (#936). Pad the bar itself so its
|
||||
background still bleeds behind the status bar / notch. The insets are
|
||||
physical, so pl/pr (not ps/pe) is correct in RTL too. */}
|
||||
<div className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{objectUrl && canOpenInNewTab && (
|
||||
@@ -316,7 +322,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-4">
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
|
||||
@@ -65,7 +65,10 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between px-4 py-3 bg-gradient-to-b from-black/60 to-transparent z-10">
|
||||
{/* Same safe-area handling as FilePreviewModal: full-screen overlay +
|
||||
`viewport-fit=cover` puts this bar under the iOS status bar in an
|
||||
installed PWA (#936). */}
|
||||
<div className="absolute top-0 left-0 right-0 flex items-center justify-between pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-gradient-to-b from-black/60 to-transparent z-10">
|
||||
<span className="text-white text-sm font-medium truncate max-w-[50%]">{name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-white hover:bg-white/20" onClick={(e) => { e.stopPropagation(); setZoom((z) => Math.min(z + 0.25, 5)); }}>
|
||||
@@ -87,7 +90,7 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl }: Im
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex items-center justify-center w-full h-full p-16" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center w-full h-full p-16 pt-[calc(4rem+env(safe-area-inset-top))] pb-[calc(4rem+env(safe-area-inset-bottom))]" onClick={(e) => e.stopPropagation()}>
|
||||
{loading && (
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { computeFitScale, fitEmailBodyWidth, FIT_MIN_SCALE } from '@/lib/email-fit-width';
|
||||
|
||||
/** jsdom does no layout, so the widths the fit reads are stubbed by hand. */
|
||||
function stubWidths(opts: { viewport: number; content: number | (() => number) }) {
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', {
|
||||
configurable: true,
|
||||
get: () => opts.viewport,
|
||||
});
|
||||
const content = typeof opts.content === 'function' ? opts.content : () => opts.content as number;
|
||||
// Only body carries the width: the helper reads both and takes the max, and a
|
||||
// shared getter would advance a scripted sequence twice per measurement.
|
||||
Object.defineProperty(document.body, 'scrollWidth', { configurable: true, get: content });
|
||||
Object.defineProperty(document.documentElement, 'scrollWidth', { configurable: true, get: () => 0 });
|
||||
}
|
||||
|
||||
describe('computeFitScale', () => {
|
||||
it('shrinks desktop-width mail to a phone screen', () => {
|
||||
expect(computeFitScale(780, 390)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('leaves content that already fits alone', () => {
|
||||
expect(computeFitScale(390, 390)).toBe(1);
|
||||
// Sub-pixel rounding routinely reports a scrollWidth a hair over the client
|
||||
// width on content that visually fits.
|
||||
expect(computeFitScale(390.5, 390)).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves a desktop reading pane at 1:1', () => {
|
||||
expect(computeFitScale(900, 720)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the horizontal scroll for content too wide to stay legible', () => {
|
||||
// 390 / 2000 is far below the floor: scaled, this is unreadable, so panning
|
||||
// at full size is the better of the two bad options.
|
||||
expect(computeFitScale(2000, 390)).toBe(1);
|
||||
expect(computeFitScale(390 / FIT_MIN_SCALE - 10, 390)).toBeGreaterThan(FIT_MIN_SCALE);
|
||||
});
|
||||
|
||||
it('ignores nonsense measurements', () => {
|
||||
expect(computeFitScale(0, 390)).toBe(1);
|
||||
expect(computeFitScale(780, 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fitEmailBodyWidth', () => {
|
||||
beforeEach(() => {
|
||||
document.body.removeAttribute('style');
|
||||
document.body.removeAttribute('dir');
|
||||
});
|
||||
|
||||
it('lays the body out at its content width and scales it down', () => {
|
||||
stubWidths({ viewport: 390, content: 780 });
|
||||
const scale = fitEmailBodyWidth(document);
|
||||
|
||||
expect(scale).toBeCloseTo(0.5);
|
||||
expect(document.body.style.width).toBe('780px');
|
||||
expect(document.body.style.boxSizing).toBe('border-box');
|
||||
expect(document.body.style.transform).toBe('scale(0.5)');
|
||||
expect(document.body.style.transformOrigin).toBe('top left');
|
||||
});
|
||||
|
||||
it('anchors the shrink at the right edge in an RTL body', () => {
|
||||
stubWidths({ viewport: 390, content: 780 });
|
||||
document.body.style.direction = 'rtl';
|
||||
fitEmailBodyWidth(document);
|
||||
|
||||
expect(document.body.style.transformOrigin).toBe('top right');
|
||||
});
|
||||
|
||||
it('touches nothing when the mail already fits', () => {
|
||||
stubWidths({ viewport: 390, content: 380 });
|
||||
|
||||
expect(fitEmailBodyWidth(document)).toBe(1);
|
||||
expect(document.body.getAttribute('style')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('undoes a previous fit when the mail reflows to fit', () => {
|
||||
stubWidths({ viewport: 390, content: 780 });
|
||||
fitEmailBodyWidth(document);
|
||||
expect(document.body.style.transform).toBe('scale(0.5)');
|
||||
|
||||
// e.g. a blocked image was loaded and the layout collapsed to one column.
|
||||
stubWidths({ viewport: 390, content: 300 });
|
||||
|
||||
expect(fitEmailBodyWidth(document)).toBe(1);
|
||||
expect(document.body.style.transform).toBe('');
|
||||
expect(document.body.style.width).toBe('');
|
||||
expect(document.body.style.boxSizing).toBe('');
|
||||
});
|
||||
|
||||
it('undoes a previous fit when disabled', () => {
|
||||
stubWidths({ viewport: 390, content: 780 });
|
||||
fitEmailBodyWidth(document);
|
||||
|
||||
expect(fitEmailBodyWidth(document, { enabled: false })).toBe(1);
|
||||
expect(document.body.style.transform).toBe('');
|
||||
expect(document.body.style.width).toBe('');
|
||||
});
|
||||
|
||||
it('re-measures when widening the body reveals more width', () => {
|
||||
// Percentage-width children can grow with the body they were just given.
|
||||
const widths = [700, 900, 900];
|
||||
let call = 0;
|
||||
stubWidths({ viewport: 390, content: () => widths[Math.min(call++, widths.length - 1)] });
|
||||
|
||||
const scale = fitEmailBodyWidth(document);
|
||||
|
||||
expect(document.body.style.width).toBe('900px');
|
||||
expect(scale).toBeCloseTo(390 / 900);
|
||||
});
|
||||
|
||||
it('backs out of a fit that settles past the shrink floor', () => {
|
||||
const widths = [700, 3000, 3000];
|
||||
let call = 0;
|
||||
stubWidths({ viewport: 390, content: () => widths[Math.min(call++, widths.length - 1)] });
|
||||
|
||||
expect(fitEmailBodyWidth(document)).toBe(1);
|
||||
expect(document.body.style.width).toBe('');
|
||||
expect(document.body.style.transform).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user