Fix <wa-viewport-demo> zooming in Safari & Firefox

Also some refactoring to use `updated()` rather than `handleXXX()` functions
This commit is contained in:
Lea Verou
2024-12-11 16:09:15 -05:00
parent c02496ff02
commit cbb4aa8be1
2 changed files with 150 additions and 87 deletions

View File

@@ -11,22 +11,31 @@ export default css`
--viewport-initial-aspect-ratio: 16 / 9;
--viewport-bezel-width: 0.25em;
display: contents;
display: block;
/* Needed for measuring the available space */
contain: inline-size;
container-type: inline-size;
container-name: host;
}
[part~='frame'] {
--zoom: 1;
--zoom: 1; /* overridden by JS */
--available-width: calc((100cqw - var(--offset-inline, 0px)));
--iframe-manual-aspect-ratio: calc(var(--iframe-manual-width-px) / var(--iframe-manual-height-px));
--iframe-manual-width: calc(var(--iframe-manual-width-px) * 1px * var(--zoom));
--iframe-manual-height: calc(var(--iframe-manual-height-px) * 1px * var(--zoom));
--width: var(--iframe-manual-width, var(--available-width));
--height-auto: calc(var(--width) / (var(--aspect-ratio)));
--_aspect-ratio: calc(var(--viewport-width-px) / var(--viewport-height-px));
--aspect-ratio: var(--_aspect-ratio, var(--viewport-initial-aspect-ratio));
display: flex;
flex-flow: column;
align-items: end;
width: 100%;
align-items: start;
width: fit-content;
height: fit-content;
min-width: var(--viewport-min-width, 2em);
max-width: min(var(--viewport-max-width), 100%);
min-height: var(--viewport-min-height);
resize: var(--viewport-resize);
overflow: auto;
/* Style frame like a window */
border: var(--viewport-bezel-width) solid transparent;
@@ -41,39 +50,56 @@ export default css`
1.1em var(--button-params),
radial-gradient(circle closest-side, var(--wa-color-green-70) 80%, var(--wa-color-green-60) 98%, transparent)
1.8em var(--button-params),
linear-gradient(to top, var(--viewport-background-color) 60%, transparent 70%) bottom padding-box,
var(--wa-color-gray-95);
background-repeat: no-repeat;
box-shadow:
0 0 0 1px var(--wa-color-gray-90),
var(--wa-shadow-m);
&.resized {
aspect-ratio: var(--iframe-manual-aspect-ratio);
}
/* User has not yet resized the viewport */
&:not([style*='height:']) ::slotted(iframe) {
--_aspect-ratio: calc(var(--viewport-width-px) / var(--viewport-height-px));
--aspect-ratio: var(--_aspect-ratio, var(--viewport-initial-aspect-ratio));
&:not(.resized) ::slotted(iframe),
&:not(.resized) slot {
/* Will only be set if we have BOTH width and height */
aspect-ratio: var(--aspect-ratio);
}
}
slot {
display: block;
overflow: clip;
width: var(--width);
max-width: var(--available-width);
height: var(--iframe-manual-height, var(--height-auto));
}
::slotted(iframe) {
display: block;
width: 100%;
height: 100%;
zoom: var(--zoom);
flex: auto;
scale: var(--zoom);
transform-origin: top left;
resize: var(--viewport-resize);
overflow: auto;
/* The width and height specified here are only applied if the iframe is not manually resized */
width: calc(var(--available-width) / var(--zoom));
height: calc(var(--height-auto) / var(--zoom));
min-width: calc(var(--viewport-min-width, 10em) / var(--zoom));
max-width: calc(var(--available-width) / var(--zoom)) !important;
min-height: calc(var(--viewport-min-height) / var(--zoom));
/* Divide with var(--zoom) to get lengths that stay constant regardless of zoom level */
border: calc(1px / var(--zoom)) solid var(--wa-color-gray-90);
/* If we just set a background-color, Safari will not show the resizer because the iframe is over it.
So instead, we make sure that the bottom of the iframe is transparent, and is covered by a gradient on the parent.
Why not ONLY specify the gradient on the parent? Because there is no flexible way to know how tall it should be.
*/
background: linear-gradient(to bottom, var(--viewport-background-color) 60%, transparent 70%);
background: var(--viewport-background-color);
}
[part~='controls'] {
display: flex;
align-self: end;
margin-top: -0.2em;
font-size: var(--wa-font-size-xs);
padding-block-end: 0.25em;

View File

@@ -135,7 +135,7 @@ export default class WaViewportDemo extends WebAwesomeElement {
private innerHeight: number = 0;
@state()
private iframeHOffset: number = 0;
private offsetInline: number = 0;
@state()
private availableWidth = 0;
@@ -144,7 +144,10 @@ export default class WaViewportDemo extends WebAwesomeElement {
private contentWindow: Window | null;
@state()
private needsInternalZoom: boolean | undefined;
private iframeManualWidth: number | undefined;
@state()
private iframeManualHeight: number | undefined;
private resizeObserver: ResizeObserver;
@@ -159,73 +162,76 @@ export default class WaViewportDemo extends WebAwesomeElement {
}
private observeResize() {
if (this.viewportElement) {
this.resizeObserver ??= new ResizeObserver(() => this.handleResize());
this.updateComplete.then(() => this.resizeObserver.observe(this));
}
this.resizeObserver ??= new ResizeObserver(records => this.handleResize(records));
this.resizeObserver.observe(this);
this.updateComplete.then(() => {
if (this.iframe) {
this.resizeObserver.observe(this.iframe);
}
});
}
private unobserveResize() {
this.resizeObserver?.unobserve(this);
this.resizeObserver?.unobserve(this.iframe);
}
// Called when this.iframe.contentWindow changes
private handleIframeLoad() {
if (this.iframe.contentWindow) {
this.contentWindow = this.iframe.contentWindow;
this.updateCS();
this.updateZoom();
this.handleViewportResize();
this.contentWindow.addEventListener('resize', () => this.handleViewportResize());
if (this.needsInternalZoom === undefined) {
this.updateComplete.then(() => {
const innerWidth = this.contentWindow?.innerWidth || 0;
const availableWidth = Math.round(this.availableWidth);
const ratio = availableWidth / innerWidth;
if (Math.abs(ratio - this.computedZoom) > 0.01) {
// The actual iframe content is not zoomed. This is a known Safari bug.
// We need to zoom the iframe content manually.
this.needsInternalZoom = true;
}
});
}
}
}
private updateCS() {
private updateAvailableWidth() {
// This is only needed for isolated demos
if (this.viewport && globalThis.window) {
if (this.iframe) {
this.iframeHOffset = getHorizontalOffsets(getComputedStyle(this.iframe));
}
if (this.viewport && globalThis.window && this.iframe) {
const offsets = {
host: getHorizontalOffsets(getComputedStyle(this)),
frame: getHorizontalOffsets(getComputedStyle(this.viewportElement)),
iframe: getHorizontalOffsets(getComputedStyle(this.iframe))
};
const width = this.viewportElement.clientWidth;
this.availableWidth = width - this.iframeHOffset;
this.offsetInline = offsets.host.inner + offsets.frame.all + offsets.iframe.all;
this.availableWidth = this.clientWidth - this.offsetInline;
}
}
/** Called when the user resizes the iframe */
private handleIframeResize() {
const { width, height } = this.iframe.style;
this.iframeManualWidth = (width && getNumber(width)) || undefined;
this.iframeManualHeight = (height && getNumber(height)) || undefined;
}
/** Gets called when the host gets resized */
private handleResize() {
private handleResize(records: ResizeObserverEntry[]) {
// This is only needed for isolated demos
if (this.viewport && globalThis.window) {
this.updateCS();
this.updateZoom();
for (const record of records) {
if (record.target === this) {
if (this.viewport && globalThis.window) {
this.updateAvailableWidth();
}
} else if (record.target === this.iframe) {
this.handleIframeResize();
}
}
}
/** Zoom in by one step */
public zoomIn() {
this.zoomLevel++;
this.updateZoom();
}
/** Zoom out by one step */
public zoomOut() {
this.zoomLevel--;
this.updateZoom();
}
private updateZoom() {
@@ -233,7 +239,7 @@ export default class WaViewportDemo extends WebAwesomeElement {
if (isViewportDimensions(this.viewport)) {
if (!this.availableWidth) {
this.updateCS();
this.updateAvailableWidth();
}
// Zoom level = available width / virtual width
@@ -280,24 +286,35 @@ export default class WaViewportDemo extends WebAwesomeElement {
updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
if (
this.contentWindow &&
['computedZoom', 'needsInternalZoom', 'contentWindow'].some(p => changedProperties.has(p))
) {
if (changedProperties.has('computedZoom')) {
this.viewportElement.style.setProperty('--zoom', this.computedZoom + '');
}
if (changedProperties.has('iframe')) {
this.observeResize();
}
if (this.needsInternalZoom) {
const innerWidth = this.contentWindow?.innerWidth || 0;
const availableWidth = Math.round(this.availableWidth);
const ratio = availableWidth / innerWidth;
if (['zoomLevel', 'availableWidth', 'viewport'].some(p => changedProperties.has(p))) {
this.updateZoom();
}
if (Math.abs(ratio - this.computedZoom) > 0.01) {
// The actual iframe content is not zoomed. This is a known Safari bug.
// We need to zoom the iframe content manually.
this.iframe.contentDocument!.documentElement.style.setProperty('zoom', this.computedZoom + '');
if (changedProperties.has('computedZoom')) {
if (this.iframeManualWidth !== undefined || this.iframeManualHeight !== undefined) {
// These inline styles have been created based on the previous zoom level
// We need to convert them manually and reapply them
this.unobserveResize(); // pause the observer
const previousZoom = changedProperties.get('computedZoom') as number;
if (this.iframeManualWidth !== undefined) {
const width = (this.iframeManualWidth * previousZoom) / this.computedZoom;
this.iframe.style.width = width + 'px';
this.iframeManualWidth = width;
}
if (this.iframeManualHeight !== undefined) {
const height = (this.iframeManualHeight * previousZoom) / this.computedZoom;
this.iframe.style.height = height + 'px';
this.iframeManualHeight = height;
}
this.observeResize();
}
}
}
@@ -308,8 +325,22 @@ export default class WaViewportDemo extends WebAwesomeElement {
const dimensions = width && height ? html`<span class="dimensions">${width} × ${height}</span>` : '';
const viewportStyle: Record<string, string | number> = {
'--zoom': this.computedZoom
'--zoom': this.computedZoom,
'--offset-inline': this.offsetInline + 'px'
};
const viewportClasses: Record<string, any> = {
'resized-width': this.iframeManualWidth,
'resized-height': this.iframeManualHeight,
resized: this.iframeManualWidth || this.iframeManualHeight
};
if (this.iframeManualWidth) {
viewportStyle['--iframe-manual-width-px'] = this.iframeManualWidth;
}
if (this.iframeManualHeight) {
viewportStyle['--iframe-manual-height-px'] = this.iframeManualHeight;
}
if (isViewportDimensions(this.viewport)) {
viewportStyle['--viewport-width-px'] = this.viewport.width;
@@ -319,12 +350,7 @@ export default class WaViewportDemo extends WebAwesomeElement {
}
return html`
<div
id="viewport"
part="frame"
style=${styleMap(viewportStyle)}
class=${classMap({ 'needs-internal-zoom': this.needsInternalZoom! })}
>
<div id="viewport" part="frame" style=${styleMap(viewportStyle)} class=${classMap(viewportClasses)}>
<span part="controls">
${dimensions}
<span class="zoom">
@@ -382,18 +408,29 @@ function getNumber(value: string | number): number {
return (typeof value === 'string' ? parseFloat(value) : value) || 0;
}
interface HorizontalOffsets {
padding: number;
border: number;
margin: number;
inner: number;
all: number;
}
const noOffsets: HorizontalOffsets = { padding: 0, border: 0, margin: 0, inner: 0, all: 0 };
/**
* Get the horizontal padding and border widths of an element
*/
function getHorizontalOffsets(cs: CSSStyleDeclaration | null): number {
function getHorizontalOffsets(cs: CSSStyleDeclaration | null): HorizontalOffsets {
if (!cs) {
return 0;
return noOffsets;
}
return (
getNumber(cs.paddingLeft) +
getNumber(cs.paddingRight) +
getNumber(cs.borderLeftWidth) +
getNumber(cs.borderRightWidth)
);
const padding = getNumber(cs.paddingLeft) + getNumber(cs.paddingRight);
const border = getNumber(cs.borderLeftWidth) + getNumber(cs.borderRightWidth);
const margin = getNumber(cs.marginLeft) + getNumber(cs.marginRight);
const inner = padding + border;
const all = inner + margin;
return { padding, border, margin, inner, all };
}