mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
Zoomable frame (#1029)
* add zoomable frame component; #986 * remove viewport demo component * update changelog * fix code demos * update zoomable iframes with theme changes
This commit is contained in:
@@ -125,6 +125,7 @@
|
||||
"noreferrer",
|
||||
"novalidate",
|
||||
"Numberish",
|
||||
"nums",
|
||||
"oklab",
|
||||
"oklch",
|
||||
"onscrollend",
|
||||
@@ -139,6 +140,7 @@
|
||||
"progressbar",
|
||||
"radiogroup",
|
||||
"Railsbyte",
|
||||
"referrerpolicy",
|
||||
"remixicon",
|
||||
"reregister",
|
||||
"resizer",
|
||||
@@ -165,6 +167,7 @@
|
||||
"slotchange",
|
||||
"smartquotes",
|
||||
"spacebar",
|
||||
"srcdoc",
|
||||
"stylesheet",
|
||||
"svgs",
|
||||
"Tabbable",
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<wa-viewport-demo viewport="1000">
|
||||
<iframe srcdoc="" id="page_slots_iframe"></iframe>
|
||||
</wa-viewport-demo>
|
||||
|
||||
<wa-zoomable-frame srcdoc="" zoom="0.5" id="page_slots_iframe"></wa-zoomable-frame>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
<li><a href="/docs/components/tooltip/">Tooltip</a></li>
|
||||
<li><a href="/docs/components/tree/">Tree</a></li>
|
||||
<li><a href="/docs/components/tree-item/">Tree Item</a></li>
|
||||
<li><a href="/docs/components/zoomable-frame">Zoomable Frame</a></li>
|
||||
{# PLOP_NEW_COMPONENT_PLACEHOLDER #}
|
||||
</ul>
|
||||
</wa-details>
|
||||
|
||||
@@ -25,7 +25,6 @@ export function codeExamplesPlugin(options = {}) {
|
||||
const pre = code.closest('pre');
|
||||
const hasButtons = !code.classList.contains('no-buttons');
|
||||
const isOpen = code.classList.contains('open') || !hasButtons;
|
||||
const isViewportDemo = code.classList.contains('viewport');
|
||||
const noEdit = code.classList.contains('no-edit');
|
||||
const id = `code-example-${uuid().slice(-12)}`;
|
||||
let preview = pre.textContent;
|
||||
@@ -35,29 +34,10 @@ export function codeExamplesPlugin(options = {}) {
|
||||
root.querySelectorAll('script').forEach(script => script.setAttribute('type', 'module'));
|
||||
preview = root.toString();
|
||||
|
||||
const escapedHtml = markdown.utils.escapeHtml(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Web Awesome Demo</title>
|
||||
<link rel="stylesheet" href="https://early.webawesome.com/webawesome@[version]/dist/styles/themes/default.css" />
|
||||
<link rel="stylesheet" href="https://early.webawesome.com/webawesome@[version]/dist/styles/webawesome.css" />
|
||||
<script type="module" src="https://early.webawesome.com/webawesome@[version]/dist/webawesome.loader.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
${preview}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const codeExample = parse(`
|
||||
<div class="code-example ${isOpen ? 'open' : ''} ${isViewportDemo ? 'is-viewport-demo' : ''}">
|
||||
<div class="code-example ${isOpen ? 'open' : ''}">
|
||||
<div class="code-example-preview">
|
||||
|
||||
${isViewportDemo ? ` <wa-viewport-demo><iframe srcdoc="${escapedHtml}"></iframe></wa-viewport-demo>` : preview}
|
||||
|
||||
${preview}
|
||||
<div class="code-example-resizer" aria-hidden="true">
|
||||
<wa-icon name="grip-lines-vertical"></wa-icon>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { domChange, nextFrame, ThemeAspect } from './theme-picker.js';
|
||||
import { domChange, ThemeAspect } from './theme-picker.js';
|
||||
|
||||
const presetTheme = new ThemeAspect({
|
||||
defaultValue: 'default',
|
||||
@@ -33,7 +33,7 @@ const presetTheme = new ThemeAspect({
|
||||
|
||||
if (instant) {
|
||||
// If no VT, delay by 1 frame to make it smoother
|
||||
await nextFrame();
|
||||
await new Promise(requestAnimationFrame);
|
||||
}
|
||||
|
||||
oldStylesheet.remove();
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { domChange } from './util/dom-change.js';
|
||||
export { domChange };
|
||||
|
||||
export function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
export class ThemeAspect {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.set();
|
||||
this.syncIframes();
|
||||
|
||||
// Update when local storage changes.
|
||||
// That way changes in one window will propagate to others (including iframes).
|
||||
@@ -67,6 +64,30 @@ export class ThemeAspect {
|
||||
this.syncUI();
|
||||
}
|
||||
|
||||
async syncIframes() {
|
||||
await customElements.whenDefined('wa-zoomable-frame');
|
||||
await new Promise(requestAnimationFrame);
|
||||
|
||||
// Sync to wa-zoomable-frame iframes
|
||||
let dark = this.computedValue === 'dark';
|
||||
for (let zoomableEl of document.querySelectorAll('wa-zoomable-frame')) {
|
||||
const iframe = zoomableEl.iframe;
|
||||
|
||||
const applyToIframe = () => {
|
||||
try {
|
||||
iframe.contentDocument.documentElement.classList.toggle('wa-dark', dark);
|
||||
} catch (e) {
|
||||
// Silently handle access issues
|
||||
}
|
||||
};
|
||||
|
||||
// Try immediately
|
||||
applyToIframe();
|
||||
// Also listen for load in case it wasn't ready
|
||||
iframe.addEventListener('load', applyToIframe, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
syncUI(container = document) {
|
||||
for (let picker of container.querySelectorAll(this.picker)) {
|
||||
picker.setAttribute('value', this.value);
|
||||
@@ -87,27 +108,22 @@ const colorScheme = new ThemeAspect({
|
||||
},
|
||||
|
||||
applyChange() {
|
||||
// Toggle the dark mode class
|
||||
domChange(() => {
|
||||
// Toggle the dark mode class with view transition
|
||||
const updateTheme = () => {
|
||||
let dark = this.computedValue === 'dark';
|
||||
document.documentElement.classList.toggle(`wa-dark`, dark);
|
||||
document.documentElement.dispatchEvent(new CustomEvent('wa-color-scheme-change', { detail: { dark } }));
|
||||
syncViewportDemoColorSchemes();
|
||||
});
|
||||
this.syncIframes();
|
||||
};
|
||||
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(() => domChange(updateTheme));
|
||||
} else {
|
||||
domChange(updateTheme);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function syncViewportDemoColorSchemes() {
|
||||
const isDark = document.documentElement.classList.contains('wa-dark');
|
||||
|
||||
// Update viewport demo color schemes in code examples
|
||||
document.querySelectorAll('.code-example.is-viewport-demo wa-viewport-demo').forEach(demo => {
|
||||
demo.querySelectorAll('iframe').forEach(iframe => {
|
||||
iframe.contentWindow.document.documentElement?.classList?.toggle('wa-dark', isDark);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update the color scheme when the preference changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => colorScheme.set());
|
||||
|
||||
@@ -121,12 +137,3 @@ document.addEventListener('keydown', event => {
|
||||
colorScheme.set(colorScheme.get() === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
// When rendering a code example with a viewport demo, set the theme to match initially
|
||||
document.querySelectorAll('.code-example.is-viewport-demo wa-viewport-demo iframe').forEach(iframe => {
|
||||
const isDark = document.documentElement.classList.contains('wa-dark');
|
||||
|
||||
iframe.addEventListener('load', () => {
|
||||
iframe.contentWindow.document.documentElement?.classList?.toggle('wa-dark', isDark);
|
||||
});
|
||||
});
|
||||
|
||||
79
packages/webawesome/docs/docs/components/zoomable-frame.md
Normal file
79
packages/webawesome/docs/docs/components/zoomable-frame.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Zoomable Frame
|
||||
layout: component
|
||||
---
|
||||
|
||||
```html {.example}
|
||||
<wa-zoomable-frame src="https://backers.webawesome.com/" zoom="0.5">
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Loading external content
|
||||
|
||||
Use the `src` attribute to embed external websites or resources. The URL must be accessible, and cross-origin restrictions may apply due to the Same-Origin Policy, potentially limiting access to the iframe's content.
|
||||
|
||||
```html
|
||||
<wa-zoomable-frame src="https://example.com/">
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
|
||||
The zoomable frame fills 100% width by default with a 16:9 aspect ratio. Customize this using the `aspect-ratio` CSS property.
|
||||
|
||||
```html
|
||||
<wa-zoomable-frame src="https://example.com/" style="aspect-ratio: 4/3;">
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
|
||||
Use the `srcdoc` attribute or property to display custom HTML content directly within the iframe, perfect for rendering inline content without external resources.
|
||||
|
||||
```html
|
||||
<wa-zoomable-frame srcdoc="<html><body><h1>Hello, World!</h1><p>This is inline content.</p></body></html>">
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
|
||||
:::info
|
||||
When both `src` and `srcdoc` are specified, `srcdoc` takes precedence.
|
||||
:::
|
||||
|
||||
### Controlling zoom behavior
|
||||
|
||||
Set the `zoom` attribute to control the frame's zoom level. Use `1` for 100%, `2` for 200%, `0.5` for 50%, and so on.
|
||||
|
||||
Define specific zoom increments with the `zoom-levels` attribute using space-separated percentages and decimal values like `zoom-levels="0.25 0.5 75% 100%"`.
|
||||
|
||||
```html {.example}
|
||||
<wa-zoomable-frame
|
||||
src="https://backers.webawesome.com/"
|
||||
zoom="0.5"
|
||||
zoom-levels="50% 0.75 100%"
|
||||
>
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
|
||||
### Hiding zoom controls
|
||||
|
||||
Add the `without-controls` attribute to hide the zoom control interface from the frame.
|
||||
|
||||
```html {.example}
|
||||
<wa-zoomable-frame
|
||||
src="https://backers.webawesome.com/"
|
||||
without-controls
|
||||
zoom="0.5"
|
||||
>
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
|
||||
### Preventing user interaction
|
||||
|
||||
Apply the `without-interaction` attribute to make the frame non-interactive. Note that this prevents keyboard navigation into the frame, which may impact accessibility for some users.
|
||||
|
||||
```html {.example}
|
||||
<wa-zoomable-frame
|
||||
src="https://backers.webawesome.com/"
|
||||
zoom="0.5"
|
||||
without-interaction
|
||||
>
|
||||
</wa-zoomable-frame>
|
||||
```
|
||||
@@ -40,6 +40,7 @@ During the alpha period, things might break! We take breaking changes very serio
|
||||
- 🚨 BREAKING: removed the `hint` property and slot from `<wa-radio>`; please apply hints directly to `<wa-radio-group>` instead
|
||||
- 🚨 BREAKING: removed `<wa-icon-button>`; use `<wa-button><wa-icon name="..." label="..."></wa-icon></wa-button>` instead
|
||||
- Added a new free component: `<wa-popover>` (#2 of 14 per stretch goals)
|
||||
- Added a new free component: `<wa-zoomable-frame>` (#3 of 14 per stretch goals)
|
||||
- Added a `min-block-size` to `<wa-divider orientation="vertical">` to ensure the divider is visible regardless of container height [issue:675]
|
||||
- Added support for `name` in `<wa-details>` for exclusively opening one in a group
|
||||
- Added `--checked-icon-scale` to `<wa-checkbox>`
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
:host {
|
||||
--viewport-background-color: var(--wa-color-surface-default, canvas);
|
||||
--viewport-resize: both;
|
||||
--viewport-min-width: 10em;
|
||||
--viewport-min-height: 5em;
|
||||
--viewport-max-width: 100%;
|
||||
--viewport-padding: var(--wa-space-2xl, 2rem);
|
||||
--viewport-initial-aspect-ratio: 16 / 9;
|
||||
--viewport-bezel-width: 0.25em;
|
||||
|
||||
display: block;
|
||||
/* Needed for measuring the available space */
|
||||
contain: inline-size;
|
||||
container-type: inline-size;
|
||||
container-name: host;
|
||||
}
|
||||
|
||||
[part~='frame'] {
|
||||
--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: start;
|
||||
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
|
||||
/* Style frame like a window */
|
||||
border: var(--viewport-bezel-width) solid transparent;
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
|
||||
/* Window-like frame styling */
|
||||
--button-params: 0.4em / 0.5em 0.5em border-box;
|
||||
background:
|
||||
radial-gradient(circle closest-side, var(--wa-color-red-60) 80%, var(--wa-color-red-50) 98%, transparent) 0.4em
|
||||
var(--button-params),
|
||||
radial-gradient(circle closest-side, var(--wa-color-yellow-80) 80%, var(--wa-color-yellow-70) 98%, transparent)
|
||||
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),
|
||||
var(--wa-color-gray-95);
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&.resized {
|
||||
aspect-ratio: var(--iframe-manual-aspect-ratio);
|
||||
}
|
||||
|
||||
background-color: var(--wa-color-neutral-fill-normal);
|
||||
|
||||
/* User has not yet resized the viewport */
|
||||
&: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;
|
||||
flex: auto;
|
||||
scale: var(--zoom);
|
||||
transform-origin: top left;
|
||||
resize: var(--viewport-resize);
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
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);
|
||||
}
|
||||
|
||||
[part~='controls'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
gap: 0.3em;
|
||||
margin-top: -0.2em;
|
||||
font-size: var(--wa-font-size-xs);
|
||||
padding-block-end: 0.25em;
|
||||
padding-inline: 1em 0.2em;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Until we can implement info that is not lying, we don’t show it when it's lying */
|
||||
.needs-internal-zoom & > * {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dimensions {
|
||||
word-spacing: -0.15em;
|
||||
margin-inline-end: 1em;
|
||||
}
|
||||
|
||||
wa-icon {
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
wa-button {
|
||||
line-height: 1;
|
||||
|
||||
&::part(base) {
|
||||
padding: 0;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
[part~='zoom-in'],
|
||||
[part~='zoom-in']::part(base) {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
[part~='zoom-out'],
|
||||
[part~='zoom-out']::part(base) {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
}
|
||||
@@ -1,432 +0,0 @@
|
||||
import type { PropertyValues } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { getComputedStyle } from '../../internal/computed-style.js';
|
||||
import { watch } from '../../internal/watch.js';
|
||||
import WebAwesomeElement from '../../internal/webawesome-element.js';
|
||||
import '../button/button.js';
|
||||
import styles from './viewport-demo.css';
|
||||
|
||||
export interface ViewportDimensions {
|
||||
width: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function isViewportDimensions(
|
||||
viewport: boolean | ViewportDimensions | undefined,
|
||||
): viewport is ViewportDimensions {
|
||||
return Boolean(viewport) && typeof viewport === 'object' && 'width' in viewport;
|
||||
}
|
||||
|
||||
export const viewportPropertyConverter = {
|
||||
fromAttribute(value: string | null) {
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
if (value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [width, height] = value.trim().split(/\s*x\s*/);
|
||||
const ret: ViewportDimensions = { width: parseFloat(width) };
|
||||
if (height) {
|
||||
ret.height = parseFloat(height);
|
||||
}
|
||||
return ret;
|
||||
},
|
||||
toAttribute(value: boolean | ViewportDimensions) {
|
||||
if (value === false) {
|
||||
return null;
|
||||
}
|
||||
if (value === true) {
|
||||
return '';
|
||||
}
|
||||
return `${value.width} x ${value.height}`;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Viewport demos can be used to display an iframe as a resizable, zoomable preview.
|
||||
* @documentation https://backers.webawesome.com/docs/components/viewport-demo
|
||||
* @status experimental
|
||||
* @since 3.0
|
||||
*
|
||||
* @dependency wa-button
|
||||
*
|
||||
* @slot - The iframe (usually an `<iframe>` element).
|
||||
*
|
||||
* @csspart frame - The visible frame around the viewport.
|
||||
*
|
||||
* @cssproperty --viewport-initial-aspect-ratio - The initial aspect ratio of the viewport, when the `viewport` attribute is used. Defaults to `16 / 9`.
|
||||
* @cssproperty --viewport-bezel-width - The width of the bezel around the viewport. Defaults to `0.25em`.
|
||||
* @cssproperty --viewport-background-color - The background color of the viewport. Defaults to `var(--wa-color-surface-default, canvas)`.
|
||||
* @cssproperty --viewport-resize - The resize behavior of the viewport. Defaults to `both`.
|
||||
* @cssproperty --viewport-min-width - The minimum width of the viewport. Defaults to `2em`.
|
||||
* @cssproperty --viewport-max-width - The maximum width of the viewport. Defaults to `100%`. Anything over 100% will be clipped.
|
||||
* @cssproperty --viewport-padding - The padding of the viewport. Defaults to `var(--wa-space-2xl, 2rem)`.
|
||||
*
|
||||
*/
|
||||
@customElement('wa-viewport-demo')
|
||||
export default class WaViewportDemo extends WebAwesomeElement {
|
||||
static css = styles;
|
||||
|
||||
@query('[part~=frame]')
|
||||
private viewportElement: HTMLElement;
|
||||
|
||||
/** Renders in an iframe */
|
||||
@property({
|
||||
reflect: true,
|
||||
converter: {
|
||||
fromAttribute(value: string | null) {
|
||||
if (value === null) {
|
||||
return false;
|
||||
}
|
||||
if (value === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [width, height] = value.trim().split(/\s*x\s*/);
|
||||
const ret: ViewportDimensions = { width: parseFloat(width) };
|
||||
if (height) {
|
||||
ret.height = parseFloat(height);
|
||||
}
|
||||
return ret;
|
||||
},
|
||||
toAttribute(value: boolean | ViewportDimensions) {
|
||||
if (value === false) {
|
||||
return null;
|
||||
}
|
||||
if (value === true) {
|
||||
return '';
|
||||
}
|
||||
return `${value.width} x ${value.height}`;
|
||||
},
|
||||
},
|
||||
})
|
||||
viewport?: boolean | ViewportDimensions;
|
||||
|
||||
@state()
|
||||
initialAspectRatio = 16 / 9;
|
||||
|
||||
@property()
|
||||
zoom: number = 1;
|
||||
|
||||
@state()
|
||||
public defaultZoom: number = 1;
|
||||
|
||||
/** Number of steps zoomed in/out */
|
||||
@state()
|
||||
private zoomLevel: number = 0;
|
||||
|
||||
/** Actual final applied zoom */
|
||||
@state()
|
||||
public computedZoom: number = 1;
|
||||
|
||||
@state()
|
||||
private iframe: HTMLIFrameElement;
|
||||
|
||||
@state()
|
||||
private innerWidth: number = 0;
|
||||
|
||||
@state()
|
||||
private innerHeight: number = 0;
|
||||
|
||||
@state()
|
||||
private offsetInline: number = 0;
|
||||
|
||||
@state()
|
||||
private availableWidth = 0;
|
||||
|
||||
@state()
|
||||
private contentWindow: Window | null;
|
||||
|
||||
@state()
|
||||
private iframeManualWidth: number | undefined;
|
||||
|
||||
@state()
|
||||
private iframeManualHeight: number | undefined;
|
||||
|
||||
private resizeObserver: ResizeObserver;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.handleViewportChange();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unobserveResize();
|
||||
}
|
||||
|
||||
private observeResize() {
|
||||
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.updateZoom();
|
||||
|
||||
this.handleViewportResize();
|
||||
this.contentWindow.addEventListener('resize', () => this.handleViewportResize());
|
||||
}
|
||||
}
|
||||
|
||||
private updateAvailableWidth() {
|
||||
// This is only needed for isolated demos
|
||||
if (this.viewport && globalThis.window && this.iframe) {
|
||||
const offsets = {
|
||||
host: getHorizontalOffsets(getComputedStyle(this)),
|
||||
frame: getHorizontalOffsets(getComputedStyle(this.viewportElement)),
|
||||
iframe: getHorizontalOffsets(getComputedStyle(this.iframe)),
|
||||
};
|
||||
|
||||
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(records: ResizeObserverEntry[]) {
|
||||
// This is only needed for isolated demos
|
||||
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++;
|
||||
}
|
||||
|
||||
/** Zoom out by one step */
|
||||
public zoomOut() {
|
||||
this.zoomLevel--;
|
||||
}
|
||||
|
||||
private updateZoom() {
|
||||
const usesDefaultZoom = this.zoom === this.defaultZoom && !this.hasAttribute('zoom');
|
||||
|
||||
if (isViewportDimensions(this.viewport)) {
|
||||
if (!this.availableWidth) {
|
||||
this.updateAvailableWidth();
|
||||
}
|
||||
|
||||
// Zoom level = available width / virtual width
|
||||
if (!this.availableWidth) {
|
||||
// Abort mission
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultZoom = this.availableWidth / this.viewport.width;
|
||||
this.updateComplete.then(() => this.handleViewportResize());
|
||||
} else {
|
||||
this.defaultZoom = 1;
|
||||
}
|
||||
|
||||
if (usesDefaultZoom) {
|
||||
this.zoom = this.defaultZoom;
|
||||
}
|
||||
|
||||
if (this.zoomLevel === 0) {
|
||||
this.computedZoom = this.zoom;
|
||||
} else {
|
||||
const zoom = Number(this.zoom.toPrecision(2));
|
||||
this.computedZoom = zoom + 0.1 * this.zoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
private handleViewportResize() {
|
||||
this.innerWidth = this.iframe.clientWidth;
|
||||
this.innerHeight = this.iframe.clientHeight;
|
||||
}
|
||||
|
||||
@watch('viewport')
|
||||
handleViewportChange() {
|
||||
if (this.viewport) {
|
||||
if (isViewportDimensions(this.viewport)) {
|
||||
this.initialAspectRatio = this.viewport.height ? this.viewport.width / this.viewport.height : 16 / 9;
|
||||
}
|
||||
this.observeResize();
|
||||
} else {
|
||||
this.unobserveResize();
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('iframe' as keyof WaViewportDemo)) {
|
||||
this.observeResize();
|
||||
}
|
||||
|
||||
if (['zoomLevel', 'availableWidth', 'viewport'].some(p => changedProperties.has(p as keyof WaViewportDemo))) {
|
||||
this.updateZoom();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const width = this.innerWidth || (isViewportDimensions(this.viewport) ? this.viewport.width : 0);
|
||||
const height = this.innerHeight || (isViewportDimensions(this.viewport) ? this.viewport.height : 0);
|
||||
const dimensions = width && height ? html`<span class="dimensions">${width} × ${height}</span>` : '';
|
||||
|
||||
const viewportStyle: Record<string, string | number> = {
|
||||
'--zoom': this.computedZoom,
|
||||
'--offset-inline': this.offsetInline + 'px',
|
||||
};
|
||||
const resized = Boolean(this.iframeManualWidth || this.iframeManualHeight);
|
||||
const viewportClasses = {
|
||||
'resized-width': Boolean(this.iframeManualWidth),
|
||||
'resized-height': Boolean(this.iframeManualHeight),
|
||||
resized,
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if (this.viewport.height) {
|
||||
viewportStyle['--viewport-height-px'] = this.viewport.height;
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<div id="viewport" part="frame" style=${styleMap(viewportStyle)} class=${classMap(viewportClasses)}>
|
||||
<span part="controls">
|
||||
${resized
|
||||
? html`<wa-button
|
||||
appearance="plain"
|
||||
@click=${() => this.iframe.removeAttribute('style')}
|
||||
part="undo button"
|
||||
>
|
||||
<wa-icon name="arrow-rotate-left" variant="regular" label="Revert resizing"></wa-icon>
|
||||
</wa-button>`
|
||||
: ''}
|
||||
${dimensions}
|
||||
<span class="zoom">
|
||||
<wa-button appearance="plain" @click=${() => this.zoomOut()} part="zoom-out button">
|
||||
<wa-icon name="square-minus" variant="regular" label="Zoom out"></wa-icon>
|
||||
</wa-button>
|
||||
<span class="zoom-level"> ${Math.round(this.computedZoom * 100)}% </span>
|
||||
<wa-button appearance="plain" @click=${() => this.zoomIn()} part="zoom-in button">
|
||||
<wa-icon name="square-plus" variant="regular" label="Zoom in"></wa-icon>
|
||||
</wa-button>
|
||||
</span>
|
||||
</span>
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleSlotChange(event: Event) {
|
||||
const slot = event.target as HTMLSlotElement;
|
||||
|
||||
this.iframe = slot.assignedElements()[0] as HTMLIFrameElement;
|
||||
|
||||
if (this.iframe) {
|
||||
this.handleIframeLoad();
|
||||
this.iframe.addEventListener('load', () => this.handleIframeLoad());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wa-viewport-demo': WaViewportDemo;
|
||||
}
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
/**
|
||||
* Parse a string into a number, or return 0 if it's not a number
|
||||
*/
|
||||
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): HorizontalOffsets {
|
||||
if (!cs) {
|
||||
return noOffsets;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--wa-border-radius-m);
|
||||
}
|
||||
|
||||
#frame-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% / var(--zoom));
|
||||
height: calc(100% / var(--zoom));
|
||||
transform: scale(var(--zoom));
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
#iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: inherit;
|
||||
/* Prevent the iframe from being selected, e.g. by a double click. Doesn't affect selection withing the iframe. */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
#controls {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0.5em;
|
||||
align-items: center;
|
||||
font-weight: var(--wa-font-weight-semibold);
|
||||
padding: 0.25em 0.5em;
|
||||
gap: 0.5em;
|
||||
border-radius: var(--wa-border-radius-s);
|
||||
background: #000b;
|
||||
color: white;
|
||||
font-size: min(12px, 0.75em);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&:dir(ltr) {
|
||||
right: 0.5em;
|
||||
}
|
||||
|
||||
&:dir(rtl) {
|
||||
left: 0.5em;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25em;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--wa-focus-ring);
|
||||
outline-offset: var(--wa-focus-ring-offset);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 4.5ch; /* extra space so numbers don't shift */
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
|
||||
describe('<wa-zoomable-frame>', () => {
|
||||
it('should render a component', async () => {
|
||||
const el = await fixture(html` <wa-zoomable-frame></wa-zoomable-frame> `);
|
||||
|
||||
expect(el).to.exist;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { PropertyValues } from 'lit';
|
||||
import { html } from 'lit';
|
||||
import { customElement, property, query } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { parseSpaceDelimitedTokens } from '../../internal/parse.js';
|
||||
import WebAwesomeElement from '../../internal/webawesome-element.js';
|
||||
import { LocalizeController } from '../../utilities/localize.js';
|
||||
import styles from './zoomable-frame.css';
|
||||
|
||||
/**
|
||||
* @summary Zoomable frames render iframe content with zoom and interaction controls.
|
||||
* @documentation https://backers.webawesome.com/docs/components/zoomable-frame
|
||||
* @status stable
|
||||
* @since 3.0
|
||||
*
|
||||
* @dependency wa-icon
|
||||
*
|
||||
* @slot zoom-in-icon - The slot that contains the zoom in icon.
|
||||
* @slot zoom-out-icon - The slot that contains the zoom out icon.
|
||||
*
|
||||
* @event load - Emitted when the internal iframe when it finishes loading.
|
||||
* @event error - Emitted from the internal iframe when it fails to load.
|
||||
*
|
||||
* @csspart iframe - The internal `<iframe>` element.
|
||||
* @csspart controls - The container that surrounds zoom control buttons.
|
||||
* @csspart zoom-in-button - The zoom in button.
|
||||
* @csspart zoom-out-button - The zoom out button.
|
||||
*
|
||||
* @cssproperty [--aspect-ratio=16/9] - The aspect ratio of the frame.
|
||||
*/
|
||||
@customElement('wa-zoomable-frame')
|
||||
export default class WaZoomableFrame extends WebAwesomeElement {
|
||||
static css = styles;
|
||||
|
||||
private readonly localize = new LocalizeController(this);
|
||||
private availableZoomLevels: number[] = [];
|
||||
|
||||
@query('#iframe') iframe: HTMLIFrameElement;
|
||||
|
||||
/** The URL of the content to display. */
|
||||
@property() src: string;
|
||||
|
||||
/** Inline HTML to display. */
|
||||
@property() srcdoc: string;
|
||||
|
||||
/** Allows fullscreen mode. */
|
||||
@property({ type: Boolean }) allowfullscreen = false;
|
||||
|
||||
/** Controls iframe loading behavior. */
|
||||
@property() loading: 'eager' | 'lazy' = 'eager';
|
||||
|
||||
/** Controls referrer information. */
|
||||
@property() referrerpolicy: string;
|
||||
|
||||
/** Security restrictions for the iframe. */
|
||||
@property() sandbox: string;
|
||||
|
||||
/** The current zoom of the frame, e.g. 0 = 0% and 1 = 100%. */
|
||||
@property({ type: Number, reflect: true }) zoom = 1;
|
||||
|
||||
/**
|
||||
* The zoom levels to step through when using zoom controls. This does not restrict programmatic changes to the zoom.
|
||||
*/
|
||||
@property({ attribute: 'zoom-levels' }) zoomLevels = '25% 50% 75% 100% 125% 150% 175% 200%';
|
||||
|
||||
/** Removes the zoom controls. */
|
||||
@property({ type: Boolean, attribute: 'without-controls', reflect: true }) withoutControls = false;
|
||||
|
||||
/** Disables interaction when present. */
|
||||
@property({ type: Boolean, attribute: 'without-interaction', reflect: true }) withoutInteraction = false;
|
||||
|
||||
/** Returns the internal iframe's `window` object. (Readonly property) */
|
||||
public get contentWindow(): Window | null {
|
||||
return this.iframe?.contentWindow || null;
|
||||
}
|
||||
|
||||
/** Returns the internal iframe's `document` object. (Readonly property) */
|
||||
public get contentDocument(): Document | null {
|
||||
return this.iframe?.contentDocument || null;
|
||||
}
|
||||
|
||||
private parseZoomLevels(zoomLevelsString: string): number[] {
|
||||
const tokens = parseSpaceDelimitedTokens(zoomLevelsString);
|
||||
const levels: number[] = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
let value: number;
|
||||
|
||||
if (token.endsWith('%')) {
|
||||
// Parse percentage and convert to 0-1 scale
|
||||
const percentage = parseFloat(token.slice(0, -1));
|
||||
if (!isNaN(percentage)) {
|
||||
value = Math.max(0, percentage / 100); // Min 0, no max
|
||||
} else {
|
||||
continue; // Skip invalid values
|
||||
}
|
||||
} else {
|
||||
// Parse as number (0-1 scale)
|
||||
value = parseFloat(token);
|
||||
if (!isNaN(value)) {
|
||||
value = Math.max(0, value); // Min 0, no max
|
||||
} else {
|
||||
continue; // Skip invalid values
|
||||
}
|
||||
}
|
||||
|
||||
levels.push(value);
|
||||
}
|
||||
|
||||
// Sort levels and remove duplicates
|
||||
return [...new Set(levels)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private getCurrentZoomIndex(): number {
|
||||
if (this.availableZoomLevels.length === 0) return -1;
|
||||
|
||||
// Find the closest zoom level index
|
||||
let closestIndex = 0;
|
||||
let closestDiff = Math.abs(this.availableZoomLevels[0] - this.zoom);
|
||||
|
||||
for (let i = 1; i < this.availableZoomLevels.length; i++) {
|
||||
const diff = Math.abs(this.availableZoomLevels[i] - this.zoom);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
private isZoomInDisabled(): boolean {
|
||||
if (this.availableZoomLevels.length === 0) return false;
|
||||
const currentIndex = this.getCurrentZoomIndex();
|
||||
return currentIndex >= this.availableZoomLevels.length - 1;
|
||||
}
|
||||
|
||||
private isZoomOutDisabled(): boolean {
|
||||
if (this.availableZoomLevels.length === 0) return false;
|
||||
const currentIndex = this.getCurrentZoomIndex();
|
||||
return currentIndex <= 0;
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has('zoom')) {
|
||||
this.style.setProperty('--zoom', `${this.zoom}`);
|
||||
}
|
||||
|
||||
if (changedProperties.has('zoomLevels')) {
|
||||
this.availableZoomLevels = this.parseZoomLevels(this.zoomLevels);
|
||||
|
||||
// If current zoom is not in the available levels, snap to the closest one
|
||||
if (this.availableZoomLevels.length > 0) {
|
||||
const currentIndex = this.getCurrentZoomIndex();
|
||||
if (Math.abs(this.availableZoomLevels[currentIndex] - this.zoom) > 0.001) {
|
||||
this.zoom = this.availableZoomLevels[currentIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Zooms in to the next available zoom level. */
|
||||
public zoomIn() {
|
||||
if (this.availableZoomLevels.length === 0) {
|
||||
// Fallback to original behavior if no zoom levels defined
|
||||
this.zoom = Math.min(this.zoom + 0.05, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = this.getCurrentZoomIndex();
|
||||
if (currentIndex < this.availableZoomLevels.length - 1) {
|
||||
this.zoom = this.availableZoomLevels[currentIndex + 1];
|
||||
}
|
||||
}
|
||||
|
||||
/** Zooms out to the previous available zoom level. */
|
||||
public zoomOut() {
|
||||
if (this.availableZoomLevels.length === 0) {
|
||||
// Fallback to original behavior if no zoom levels defined
|
||||
this.zoom = Math.max(this.zoom - 0.05, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = this.getCurrentZoomIndex();
|
||||
if (currentIndex > 0) {
|
||||
this.zoom = this.availableZoomLevels[currentIndex - 1];
|
||||
}
|
||||
}
|
||||
|
||||
private handleLoad() {
|
||||
this.dispatchEvent(new Event('load', { bubbles: false, cancelable: false, composed: true }));
|
||||
}
|
||||
|
||||
private handleError() {
|
||||
this.dispatchEvent(new Event('error', { bubbles: false, cancelable: false, composed: true }));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div id="frame-container">
|
||||
<iframe
|
||||
id="iframe"
|
||||
part="iframe"
|
||||
?inert=${this.withoutInteraction}
|
||||
?allowfullscreen=${this.allowfullscreen}
|
||||
loading=${this.loading}
|
||||
referrerpolicy=${this.referrerpolicy}
|
||||
sandbox=${ifDefined((this.sandbox as any) ?? undefined)}
|
||||
src=${ifDefined(this.src ?? undefined)}
|
||||
srcdoc=${ifDefined(this.srcdoc ?? undefined)}
|
||||
@load=${this.handleLoad}
|
||||
@error=${this.handleError}
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
${!this.withoutControls
|
||||
? html`
|
||||
<div id="controls" part="controls">
|
||||
<button
|
||||
part="zoom-out-button"
|
||||
aria-label=${this.localize.term('zoomOut')}
|
||||
@click=${this.zoomOut}
|
||||
?disabled=${this.isZoomOutDisabled()}
|
||||
>
|
||||
<slot name="zoom-out-icon">
|
||||
<wa-icon name="minus" label="Zoom out"></wa-icon>
|
||||
</slot>
|
||||
</button>
|
||||
<span>${this.localize.number(this.zoom, { style: 'percent', maximumFractionDigits: 1 })}</span>
|
||||
<button
|
||||
part="zoom-in-button"
|
||||
aria-label=${this.localize.term('zoomIn')}
|
||||
@click=${this.zoomIn}
|
||||
?disabled=${this.isZoomInDisabled()}
|
||||
>
|
||||
<slot name="zoom-in-icon">
|
||||
<wa-icon name="plus" label="Zoom in"></wa-icon>
|
||||
</slot>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'wa-zoomable-frame': WaZoomableFrame;
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,7 @@
|
||||
table,
|
||||
ul,
|
||||
video,
|
||||
wa-callout,
|
||||
wa-viewport-demo {
|
||||
wa-callout {
|
||||
&:has(+ *) {
|
||||
margin: 0 0 var(--wa-space-xl) 0;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ const translation: Translation = {
|
||||
showPassword: 'عرض كلمة المرور',
|
||||
slideNum: slide => `شريحة ${slide}`,
|
||||
toggleColorFormat: 'تغيير صيغة عرض اللون',
|
||||
zoomIn: 'تكبير',
|
||||
zoomOut: 'تصغير',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Zobrazit heslo',
|
||||
slideNum: slide => `Slide ${slide}`,
|
||||
toggleColorFormat: 'Přepnout formát barvy',
|
||||
zoomIn: 'Přiblížit',
|
||||
zoomOut: 'Oddálit',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Vis adgangskode',
|
||||
slideNum: slide => `Slide ${slide}`,
|
||||
toggleColorFormat: 'Skift farveformat',
|
||||
zoomIn: 'Zoom ind',
|
||||
zoomOut: 'Zoom ud',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Passwort anzeigen',
|
||||
slideNum: slide => `Folie ${slide}`,
|
||||
toggleColorFormat: 'Farbformat umschalten',
|
||||
zoomIn: 'Hineinzoomen',
|
||||
zoomOut: 'Herauszoomen',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Show password',
|
||||
slideNum: slide => `Slide ${slide}`,
|
||||
toggleColorFormat: 'Toggle color format',
|
||||
zoomIn: 'Zoom in',
|
||||
zoomOut: 'Zoom out',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Mostrar contraseña',
|
||||
slideNum: slide => `Diapositiva ${slide}`,
|
||||
toggleColorFormat: 'Alternar formato de color',
|
||||
zoomIn: 'Acercar',
|
||||
zoomOut: 'Alejar',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'نمایش رمز',
|
||||
slideNum: slide => `اسلاید ${slide}`,
|
||||
toggleColorFormat: 'تغییر قالب رنگ',
|
||||
zoomIn: 'بزرگنمایی',
|
||||
zoomOut: 'کوچکنمایی',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Näytä salasana',
|
||||
slideNum: slide => `Dia ${slide}`,
|
||||
toggleColorFormat: 'Vaihda väriformaattia',
|
||||
zoomIn: 'Lähennä',
|
||||
zoomOut: 'Loitonna',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Montrer le mot de passe',
|
||||
slideNum: slide => `Diapositive ${slide}`,
|
||||
toggleColorFormat: 'Changer le format de couleur',
|
||||
zoomIn: 'Zoomer',
|
||||
zoomOut: 'Dézoomer',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'הראה סיסמה',
|
||||
slideNum: slide => `שקופית ${slide}`,
|
||||
toggleColorFormat: 'החלף פורמט צבע',
|
||||
zoomIn: 'התקרב',
|
||||
zoomOut: 'התרחק',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Pokaži lozinku',
|
||||
slideNum: slide => `Slajd ${slide}`,
|
||||
toggleColorFormat: 'Zamijeni format boje',
|
||||
zoomIn: 'Povećaj',
|
||||
zoomOut: 'Smanji',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Jelszó megjelenítése',
|
||||
slideNum: slide => `${slide}. dia`,
|
||||
toggleColorFormat: 'Színformátum változtatása',
|
||||
zoomIn: 'Nagyítás',
|
||||
zoomOut: 'Kicsinyítés',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Tampilkan sandi',
|
||||
slideNum: slide => `Slide ${slide}`,
|
||||
toggleColorFormat: 'Beralih format warna',
|
||||
zoomIn: 'Perbesar',
|
||||
zoomOut: 'Perkecil',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Mostra password',
|
||||
slideNum: slide => `Diapositiva ${slide}`,
|
||||
toggleColorFormat: 'Cambia formato colore',
|
||||
zoomIn: 'Ingrandire',
|
||||
zoomOut: 'Rimpicciolire',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -32,6 +32,8 @@ const translation: Translation = {
|
||||
showPassword: 'パスワードを表示',
|
||||
slideNum: slide => `スライド ${slide}`,
|
||||
toggleColorFormat: '色のフォーマットを切り替える',
|
||||
zoomIn: 'ズームイン',
|
||||
zoomOut: 'ズームアウト',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Vis passord',
|
||||
slideNum: slide => `Visning ${slide}`,
|
||||
toggleColorFormat: 'Bytt fargeformat',
|
||||
zoomIn: 'Zoom inn',
|
||||
zoomOut: 'Zoom ut',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Laat wachtwoord zien',
|
||||
slideNum: slide => `Schuif ${slide}`,
|
||||
toggleColorFormat: 'Wissel kleurnotatie',
|
||||
zoomIn: 'Inzoomen',
|
||||
zoomOut: 'Uitzoomen',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Vis passord',
|
||||
slideNum: slide => `Visning ${slide}`,
|
||||
toggleColorFormat: 'Byt fargeformat',
|
||||
zoomIn: 'Zoom inn',
|
||||
zoomOut: 'Zoom ut',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Pokaż hasło',
|
||||
slideNum: slide => `Slajd ${slide}`,
|
||||
toggleColorFormat: 'Przełącz format',
|
||||
zoomIn: 'Powiększ',
|
||||
zoomOut: 'Pomniejsz',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Mostrar senha',
|
||||
slideNum: slide => `Slide ${slide}`,
|
||||
toggleColorFormat: 'Trocar o formato de cor',
|
||||
zoomIn: 'Aumentar zoom',
|
||||
zoomOut: 'Diminuir zoom',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Показать пароль',
|
||||
slideNum: slide => `Слайд ${slide}`,
|
||||
toggleColorFormat: 'Переключить цветовую модель',
|
||||
zoomIn: 'Увеличить',
|
||||
zoomOut: 'Уменьшить',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -35,6 +35,8 @@ const translation: Translation = {
|
||||
showPassword: 'Prikaži geslo',
|
||||
slideNum: slide => `Diapozitiv ${slide}`,
|
||||
toggleColorFormat: 'Preklopi format barve',
|
||||
zoomIn: 'Povečaj',
|
||||
zoomOut: 'Pomanjšaj',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Visa lösenord',
|
||||
slideNum: slide => `Bild ${slide}`,
|
||||
toggleColorFormat: 'Växla färgformat',
|
||||
zoomIn: 'Zooma in',
|
||||
zoomOut: 'Zooma ut',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: 'Şifreyi göster',
|
||||
slideNum: slide => `Slayt ${slide}`,
|
||||
toggleColorFormat: 'Renk biçimini değiştir',
|
||||
zoomIn: 'Yakınlaştır',
|
||||
zoomOut: 'Uzaklaştır',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -35,6 +35,8 @@ const translation: Translation = {
|
||||
showPassword: 'Показати пароль',
|
||||
slideNum: slide => `Слайд ${slide}`,
|
||||
toggleColorFormat: 'Переключити кольорову модель',
|
||||
zoomIn: 'Збільшити',
|
||||
zoomOut: 'Зменшити',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: '显示密码',
|
||||
slideNum: slide => `幻灯片 ${slide}`,
|
||||
toggleColorFormat: '切换颜色模式',
|
||||
zoomIn: '放大',
|
||||
zoomOut: '缩小',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -33,6 +33,8 @@ const translation: Translation = {
|
||||
showPassword: '顯示密碼',
|
||||
slideNum: slide => `幻燈片 ${slide}`,
|
||||
toggleColorFormat: '切換顏色格式',
|
||||
zoomIn: '放大',
|
||||
zoomOut: '縮小',
|
||||
};
|
||||
|
||||
registerTranslation(translation);
|
||||
|
||||
@@ -4,10 +4,10 @@ import en from '../translations/en.js'; // Register English as the default/fallb
|
||||
|
||||
// Extend the controller and apply our own translation interface for better typings
|
||||
export class LocalizeController extends DefaultLocalizationController<Translation> {
|
||||
// Technicallly '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the
|
||||
// translations don't get bundled as expected resulting in `no translation found` errors.
|
||||
// This is basically some extra assurance that our translations get registered prior to our localizer connecting in a component
|
||||
// and we don't rely on implicit import ordering.
|
||||
// Technically '../translations/en.js' is supposed to work via side-effects. However, by some mystery sometimes the
|
||||
// translations don't get bundled as expected resulting in `no translation found` errors. This is basically some extra
|
||||
// assurance that our translations get registered prior to our localizer connecting in a component and we don't rely
|
||||
// on implicit import ordering.
|
||||
static {
|
||||
registerTranslation(en);
|
||||
}
|
||||
@@ -44,4 +44,6 @@ export interface Translation extends DefaultTranslation {
|
||||
showPassword: string;
|
||||
slideNum: (slide: number) => string;
|
||||
toggleColorFormat: string;
|
||||
zoomIn: string;
|
||||
zoomOut: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user