Appearance
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:
- Builder — embedded in the visuals tab alongside configuration panels
- Standalone preview — full-page preview opened via
public_previewroute (CreativePreview.vue) - 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.vueKey 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-centerscenterPreview(hPadding, vPadding, scale)— repositions panzoom to center the creativeautoScale(delay, force)— debounced (150ms) wrapper aroundresetZoomhandleWheel(e)— handles scroll/zoom gestures over the previewapplyZoom(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:
| Modifier | Action | Convention |
|---|---|---|
| None | Pan (only in grey area or at 2.5x+ zoom) | -- |
| Cmd (Meta) | Slow zoom | Figma |
| Ctrl | Slow zoom (also handles trackpad pinch) | Figma |
| Alt/Option | Fast zoom | Photoshop |
| Space+scroll | Pan (over iframe) | Figma hand tool |
| Presentation mode | Ignored 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:
shouldBlockIframeInteraction— setspointer-events: noneon 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.postMessage forwarding (
preview-iframe.ts) — the iframe forwards modifier-key wheel events to the parent viawindow.parent.postMessage({ type: 'preview-wheel', ... }). This handles the timing race where the user presses a modifier key and scrolls before Vue's DOM update appliespointer-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) * zoomVertical Padding
previewVerticalPadding adjusts the centering calculation to account for overlapping UI elements:
| Context | Non-mobile | Mobile (phone frame) | Mobile (no frame) |
|---|---|---|---|
| Builder | 150px | 400px | 200px |
| Standalone/Version History | 150px + 80px | 400px + 80px | 200px + 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:
- Format buttons — one per child creative in a mass-format, clicking triggers
changePreviewFormat - Size label — current dimensions, shown only for fullscreen format (fixed formats already show their size in the button label)
- Device selector — phone/tablet/laptop/fullHD/responsive presets, shown only for fullscreen format
Key Files
| File | Role |
|---|---|
Preview/PreviewPanel.vue | Main preview container with panzoom, autoscale, format/device switching |
mixins/panZoom.ts | PanZoom mixin: resetZoom, centerPreview, zoom/pan methods |
Preview/PreviewIframe.vue | Standalone preview iframe wrapper, handles creative loading |
Preview/LocalBuildPreview.vue | Builder preview using local build output |
Preview/MobilePreviewContainer.vue | Phone/tablet frame with drag-resize handles |
Preview/PreviewResizer.vue | Resize handle UI component |
Preview/PreviewFormatSelector.vue | Format tabs + device selector bar (standalone/version history) |
Preview/PreviewDeviceSelector.vue | Device preset dropdown |
MobileOptionsBar.vue | Top bar with device select + dimension inputs (builder only) |
common/HoverPreview.vue | Quick preview popup with zoom scaling and format tabs |
FormatsColumn.vue | Creative list format column with hover preview trigger |
store/modules/preview.ts | Vuex module for preview state |
store/modules/builder.ts | Builder state (device preset, phone frame, presentation mode) |
Gotchas
autoScaleis debounced (150ms) — don't use it when you need immediate centering (e.g., after load or device switch). CallresetZoomdirectly.iFrameLoadedmust be set tofalsebefore dimension changes — otherwise the user sees the creative at the wrong position before it re-centers.$nextTickforiFrameLoaded = truemust come AFTERresetZoom— socenterPreview(insideresetZoom.$nextTick) runs first.- SCSS size variables are in increments of 5 —
$size-5,$size-10,$size-15, etc. No$size-4,$size-8. user-select: none— all interactive preview controls (format buttons, device selector, options bar) should prevent text selection.- PreviewFormatSelector width is
100%, not100vw— using100vwinside a flex child causes horizontal overflow. - Never use
.preventon@wheelin PreviewPanel — it blocks scroll passthrough to config panels.handleWheelcallspreventDefault()manually when it handles the event. - Shift+scroll must pass through — users expect to scroll config panels while hovering the preview. The panZoom mixin's
handleWheelreturns early one.shiftKeywithout callingpreventDefault(). - CSS
transform-origin: 50% 50% !important— the panZoomContainer has this for centering logic. The panzoom library tries to set0 0 0on every transform but the!importantwins. This meanszoomAbs(x, y, scale)coordinate math is offset from what the library expects. Zoom-towards-cursor requires compensating by subtractingcontainerWidth/2andcontainerHeight/2from the cursor offset. Current implementation uses fixed-point zoom to avoid this complexity. - postMessage
preview-wheelevents —preview-iframe.tsforwards wheel events with modifier keys. The parent handler in PreviewPanel'smounted()must mirror the same keybind logic ashandleWheel. Keep them in sync. - Space state inside iframe —
preview-iframe.tstracks its ownspaceHeldvariable because the parent can't detect keydown inside a cross-document iframe. This state is sent with forwarded wheel events.