Files
hyperframes/packages/player/src/iframe-dom.ts
T
Miguel Ángel 0150a0a1b4 fix(studio): canvas zoom improvements — zoom to cursor, reset button, border fix
- Zoom anchors to cursor position instead of always zooming toward center.
  The resolvePreviewWheelZoom function now accepts cursorX/cursorY (offset
  from viewport center) and uses the standard zoom-to-point formula to
  adjust pan so the content point under the cursor stays fixed.

- Add visible "Reset" button (bottom-right) showing current zoom % when
  not at fit zoom. Driven by settledZoom state that updates after the
  200ms settle debounce, so no re-renders during active zoom gestures.

- Fix border-expands-inward bug: scaleIframeToFit in the player now uses
  offsetWidth/offsetHeight instead of getBoundingClientRect. The latter
  returns values inflated by ancestor CSS zoom, causing double-scaling
  that made the iframe appear smaller than its container.

- Fix zoom HUD appearing during pan: split applyZoom (shows HUD) from
  applyPan (silent) so trackpad/middle-mouse panning no longer flashes
  the zoom percentage overlay.

- Fix stale closure performance regression: replace stageSize in effect
  dependency arrays with stageSizeRef pattern. The old deps caused wheel
  and pointer handlers to re-register on every viewport resize.

- Widen pan clamp range (Math.abs instead of Math.max(0,...)) so content
  can float within the viewport when zoomed below fit — required for
  zoom-to-cursor to work correctly at any zoom level.

Closes #900
2026-05-18 13:24:17 -04:00

75 lines
2.4 KiB
TypeScript

/**
* DOM setup helpers for the player's shadow root.
*
* Keeps constructor boilerplate out of the web component class body.
*/
// Cached Constructable Stylesheet shared across all player instances.
let _sharedSheet: CSSStyleSheet | null = null;
/**
* Adopt `cssText` into `shadow` via a shared Constructable Stylesheet when the
* browser supports it, falling back to a `<style>` element injection. The sheet
* is cached on first creation and reused across all player instances.
*/
export function adoptShadowStyles(shadow: ShadowRoot, cssText: string): void {
if (typeof CSSStyleSheet !== "undefined") {
try {
if (!_sharedSheet) {
_sharedSheet = new CSSStyleSheet();
_sharedSheet.replaceSync(cssText);
}
shadow.adoptedStyleSheets = [_sharedSheet];
return;
} catch {
/* fallthrough */
}
}
const style = document.createElement("style");
style.textContent = cssText;
shadow.appendChild(style);
}
/**
* Creates and configures the iframe element that hosts the composition, plus
* the wrapper container div. Returns handles to both so the constructor can
* attach them to the shadow root and track references without inlining the
* boilerplate.
*/
export function createCompositionIframe(): {
container: HTMLDivElement;
iframe: HTMLIFrameElement;
} {
const container = document.createElement("div");
container.className = "hfp-container";
const iframe = document.createElement("iframe");
iframe.className = "hfp-iframe";
iframe.sandbox.add("allow-scripts", "allow-same-origin");
iframe.allow = "autoplay; fullscreen";
iframe.referrerPolicy = "no-referrer";
iframe.title = "HyperFrames Composition";
container.appendChild(iframe);
return { container, iframe };
}
/**
* Scale the iframe so the composition fits inside the player element while
* preserving aspect ratio. No-ops when the player has no painted size yet.
*/
export function scaleIframeToFit(
playerElement: HTMLElement,
iframe: HTMLIFrameElement,
compositionWidth: number,
compositionHeight: number,
): void {
const w = playerElement.offsetWidth;
const h = playerElement.offsetHeight;
if (w === 0 || h === 0) return;
const scale = Math.min(w / compositionWidth, h / compositionHeight);
iframe.style.width = `${compositionWidth}px`;
iframe.style.height = `${compositionHeight}px`;
iframe.style.transform = `translate(-50%, -50%) scale(${scale})`;
}