Skip to content

Preview Panel System

Overview

The preview panel (PreviewPanel.vue) is the central component for displaying creative previews in the builder. It handles pan/zoom navigation, device presets, format switching, and responsive centering. It's used in three contexts:

  1. Builder — embedded in the visuals tab alongside configuration panels
  2. Standalone preview — full-page preview opened via public_preview route (CreativePreview.vue)
  3. Version history — preview with version comparison, same component in presentation mode

Architecture

PreviewPanel.vue
├── MobileOptionsBar.vue         (top bar: device selector + dimension inputs — builder only)
├── panZoomContainer
│   ├── PreviewIframe.vue        (standalone preview)
│   │   └── MobilePreviewContainer.vue
│   │       ├── IPhoneFrame.vue
│   │       ├── PreviewResizer.vue
│   │       └── <iframe>         (creative)
│   └── LocalBuildPreview.vue    (builder preview — uses local build)
│       └── MobilePreviewContainer.vue
│           └── ...
├── ZoomControls (bottom-row)
└── PreviewFormatSelector.vue    (bottom bar: format tabs + device selector — standalone/version history)
    └── PreviewDeviceSelector.vue

Key Concepts

PanZoom System

Uses the panzoom npm library via panZoomMixin (src/mixins/panZoom.ts).

The panzoom container is 8x the creative dimensions to provide ample space for panning. The creative iframe is positioned absolutely at the center of this oversized container.

Key methods:

  • resetZoom(hPadding, vPadding) — calculates fit scale and re-centers
  • centerPreview(hPadding, vPadding, scale) — repositions panzoom to center the creative
  • autoScale(delay, force) — debounced (150ms) wrapper around resetZoom
  • handleWheel(e) — handles scroll/zoom gestures over the preview
  • applyZoom(direction, speed) — applies zoom step in given direction

Wheel Event Handling

PreviewPanel listens to @wheel on the panzoom wrapper. handleWheel in the panZoom mixin decides what to do:

ModifierActionConvention
NonePan (only in grey area or at 2.5x+ zoom)--
Cmd (Meta)Slow zoomFigma
CtrlSlow zoom (also handles trackpad pinch)Figma
Alt/OptionFast zoomPhotoshop
Space+scrollPan (over iframe)Figma hand tool
Presentation modeIgnored entirely--

e.preventDefault() is called inside handleWheel when the mixin handles the event.

Important: Do NOT use Vue's .prevent modifier on the @wheel binding. It would block all wheel events before the handler can decide whether to pass them through.

Cross-Document Wheel Event Problem

Wheel events over the iframe go INTO the iframe's document (cross-document boundary) and never reach the parent's handleWheel. Two mechanisms solve this:

  1. shouldBlockIframeInteraction — sets pointer-events: none on the iframe via inline style when modifier keys are held or zoom >= 2.5x. This makes wheel events pass through to the container behind it.

  2. postMessage forwarding (preview-iframe.ts) — the iframe forwards modifier-key wheel events to the parent via window.parent.postMessage({ type: 'preview-wheel', ... }). This handles the timing race where the user presses a modifier key and scrolls before Vue's DOM update applies pointer-events: none.

The iframe also tracks Space key state internally and forwards wheel events during Space+scroll for panning.

Why both mechanisms? pointer-events: none alone has a race condition: Vue 2 batches DOM updates asynchronously, so the first wheel event after a keydown may fire before pointer-events: none is applied. The postMessage approach guarantees delivery regardless of DOM state.

Iframe Interaction Blocking

The iframe's pointer-events is controlled by the iframeStyle computed (inline style), based on shouldBlockIframeInteraction:

js
shouldBlockIframeInteraction() {
  return (this.modifierKeyPressed && (this.isPanning || this.isZooming)) || this.currentZoom >= 2.5
}

At normal zoom (< 2.5x), the iframe is fully interactive (clickable preview). Holding Space, Cmd, Alt, or Ctrl disables it for pan/zoom.

Note: The CSS classes .panning and .zooming on .preview-iframe also set pointer-events: none, but these are overridden by the inline style. The inline style is the authoritative source.

isPanning State and onPanEnd

isPanning serves two purposes: tracks Space-held state for iframe blocking, and indicates active drag-panning.

Critical: onPanEnd() must NOT unconditionally clear isPanning. If Space is still held (modifierKeyPressed === true), clearing it would re-enable the iframe mid-pan. Instead, isPanning is only cleared when:

  • Space key is released (onKeyUp)
  • Middle mouse button is released (onMouseUp)
  • Window loses focus (onWindowBlur)

When Space is released, keepPreviewInView() runs to snap the creative back if panned too far.

Centering Formula

centerPreview() uses the panzoom container's center as reference and calculates the offset needed to center the creative iframe:

js
const ox = container.clientWidth / 2
const oy = container.clientHeight / 2
const iframeCenterX = element.offsetLeft + element.clientWidth / 2
const iframeCenterY = element.offsetTop + element.clientHeight / 2

const x = -(iframeCenterX - ox) * zoom
const y = -(iframeCenterY - oy) * zoom

Vertical Padding

previewVerticalPadding adjusts the centering calculation to account for overlapping UI elements:

ContextNon-mobileMobile (phone frame)Mobile (no frame)
Builder150px400px200px
Standalone/Version History150px + 80px400px + 80px200px + 80px

The extra 80px in standalone/version history accounts for the PreviewFormatSelector bar (40px height × 2 for breathing room).

iFrameLoaded Flag

Controls opacity of .preview-iframe to prevent the visible "jump" when the creative loads or repositions.

Pattern: Hide → recenter → show (after centering completes)

js
// On load:
onLoaded() {
  this.resetZoom(hp, vp)        // center directly (no debounce)
  this.$nextTick(() => {
    this.iFrameLoaded = true    // show after centering
  })
}

// On device switch:
selectedDevicePreset(device) {
  const wasLoaded = this.iFrameLoaded
  this.iFrameLoaded = false     // hide immediately
  // ... update dimensions ...
  if (wasLoaded) {
    this.$nextTick(() => {
      this.resetZoom(hp, vp)
      this.$nextTick(() => {
        this.iFrameLoaded = true  // show after centering
      })
    })
  }
}

CSS enables a smooth fade: transition: opacity 0.15s ease on .preview-iframe.

Why $nextTick ordering works: Vue 2 runs $nextTick callbacks in registration order. resetZoom registers its own $nextTick for centerPreview, which runs before the caller's $nextTick that sets iFrameLoaded = true.

Resize Centering

During drag-resize of the creative (via PreviewResizer handles), the creative stays centered by watching potentialWidth/potentialHeight:

js
potentialWidth() {
  if (this.previewResizing) {
    this.$nextTick(() => this.centerPreview())
  }
}

MobilePreviewContainer emits update:potentialWidth/update:potentialHeight on every mouse move (throttled 20ms). On mouse up, it emits update:width/update:height to commit the final dimensions.

Quick Preview (HoverPreview)

HoverPreview.vue is a hover popup used in the creative list (FormatsColumn.vue) to preview creatives without opening the full builder.

Zoom-Out Scaling

The popup renders the full standalone preview page inside a scaled-down iframe:

scss
.hover-preview-content {
  width: 100%;
  height: 500px;
  overflow: hidden;
}

.hover-preview-iframe {
  width: 200%;          // virtual 1120px viewport
  height: 200%;         // virtual 1000px viewport
  transform: scale(0.5);
  transform-origin: top left;
}

This gives a 2x virtual viewport (1120×1000 inside a 560×500 container), allowing the creative to render at a reasonable size and scale down to fit.

Format Tabs

For mass-format creatives, HoverPreview accepts a formats prop with per-format preview URLs. FormatsColumn generates these by replacing the route creative ID in the parent's preview URL:

js
previewFormats() {
  const parentRouteId = this.previewUrl.match(/creatives\/([^/]+)/)?.[1]
  return this.formats.map((f) => ({
    label: f.format,
    url: this.previewUrl.replace(`creatives/${parentRouteId}`, `creatives/${f.routeCreativeId}`),
  }))
}

Interactivity

The iframe has full pointer events enabled. mouseleave doesn't fire when the cursor moves from the popup container to the child iframe (since the iframe is within the parent's bounding box).

PreviewFormatSelector

Bottom bar shown in standalone preview and version history mode. Contains:

  1. Format buttons — one per child creative in a mass-format, clicking triggers changePreviewFormat
  2. Size label — current dimensions, shown only for fullscreen format (fixed formats already show their size in the button label)
  3. Device selector — phone/tablet/laptop/fullHD/responsive presets, shown only for fullscreen format

Key Files

FileRole
Preview/PreviewPanel.vueMain preview container with panzoom, autoscale, format/device switching
mixins/panZoom.tsPanZoom mixin: resetZoom, centerPreview, zoom/pan methods
Preview/PreviewIframe.vueStandalone preview iframe wrapper, handles creative loading
Preview/LocalBuildPreview.vueBuilder preview using local build output
Preview/MobilePreviewContainer.vuePhone/tablet frame with drag-resize handles
Preview/PreviewResizer.vueResize handle UI component
Preview/PreviewFormatSelector.vueFormat tabs + device selector bar (standalone/version history)
Preview/PreviewDeviceSelector.vueDevice preset dropdown
MobileOptionsBar.vueTop bar with device select + dimension inputs (builder only)
common/HoverPreview.vueQuick preview popup with zoom scaling and format tabs
FormatsColumn.vueCreative list format column with hover preview trigger
store/modules/preview.tsVuex module for preview state
store/modules/builder.tsBuilder state (device preset, phone frame, presentation mode)

Gotchas

  1. autoScale is debounced (150ms) — don't use it when you need immediate centering (e.g., after load or device switch). Call resetZoom directly.
  2. iFrameLoaded must be set to false before dimension changes — otherwise the user sees the creative at the wrong position before it re-centers.
  3. $nextTick for iFrameLoaded = true must come AFTER resetZoom — so centerPreview (inside resetZoom.$nextTick) runs first.
  4. SCSS size variables are in increments of 5$size-5, $size-10, $size-15, etc. No $size-4, $size-8.
  5. user-select: none — all interactive preview controls (format buttons, device selector, options bar) should prevent text selection.
  6. PreviewFormatSelector width is 100%, not 100vw — using 100vw inside a flex child causes horizontal overflow.
  7. Never use .prevent on @wheel in PreviewPanel — it blocks scroll passthrough to config panels. handleWheel calls preventDefault() manually when it handles the event.
  8. Shift+scroll must pass through — users expect to scroll config panels while hovering the preview. The panZoom mixin's handleWheel returns early on e.shiftKey without calling preventDefault().
  9. CSS transform-origin: 50% 50% !important — the panZoomContainer has this for centering logic. The panzoom library tries to set 0 0 0 on every transform but the !important wins. This means zoomAbs(x, y, scale) coordinate math is offset from what the library expects. Zoom-towards-cursor requires compensating by subtracting containerWidth/2 and containerHeight/2 from the cursor offset. Current implementation uses fixed-point zoom to avoid this complexity.
  10. postMessage preview-wheel eventspreview-iframe.ts forwards wheel events with modifier keys. The parent handler in PreviewPanel's mounted() must mirror the same keybind logic as handleWheel. Keep them in sync.
  11. Space state inside iframepreview-iframe.ts tracks its own spaceHeld variable because the parent can't detect keydown inside a cross-document iframe. This state is sent with forwarded wheel events.

Internal documentation