fix(vishot-runtime): missing scale and coordinate relocate

This commit is contained in:
Neko Ayaka
2026-04-04 02:18:28 +08:00
parent d1427e842c
commit fd50db60dc
6 changed files with 207 additions and 5 deletions
@@ -11,6 +11,24 @@ import { PlatformRoot } from '../components/platforms/macos-26'
import { Application } from '../components/platforms/macos-26/containers/dock'
import { WindowRoot } from '../components/platforms/macos-26/containers/window'
/**
* These coordinates are expressed in the logical `1920x1080` canvas provided by
* `ScenarioCanvas`, not in the browser's live viewport.
*
* That is why the windows keep their relative placement when the viewport size
* changes: the browser scales the entire fixed scene surface after layout rather
* than reinterpreting each translate against a resized responsive container.
*/
const stageWindowStyle = {
left: '1200px',
top: '400px',
}
const websocketWindowStyle = {
left: '480px',
top: '120px',
}
async function waitForImageSource(source: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const image = new Image()
@@ -38,14 +56,14 @@ onMounted(async () => {
</script>
<template>
<ScenarioCanvas>
<ScenarioCanvas :width="1920" :height="1080">
<ScenarioCaptureRoot name="intro-chat-window">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot class="translate-x-300 translate-y-100" :frame="false" :has-shadow="false">
<WindowRoot :style="stageWindowStyle" :frame="false" :has-shadow="false">
<img :src="stageShot" class="w-95">
</WindowRoot>
<WindowRoot class="translate-x-120 translate-y-30">
<WindowRoot :style="websocketWindowStyle">
<img :src="websocketSettingsShot" class="w-120">
</WindowRoot>
</template>
@@ -1,9 +1,109 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computeScenarioCanvasScale } from '../runtime/scene-canvas'
const props = withDefaults(defineProps<{
width?: number
height?: number
}>(), {
width: 1920,
height: 1080,
})
const canvasRoot = ref<HTMLElement | null>(null)
const viewportWidth = ref(0)
const viewportHeight = ref(0)
let resizeObserver: ResizeObserver | null = null
/**
* Tracks the actual viewport available to the scenario host.
*
* The important part is that child content does not use these dimensions
* directly for layout. They are only used to compute a single scale factor for
* the fixed logical surface below.
*/
function updateViewportSize(): void {
viewportWidth.value = canvasRoot.value?.clientWidth ?? window.innerWidth
viewportHeight.value = canvasRoot.value?.clientHeight ?? window.innerHeight
}
onMounted(() => {
updateViewportSize()
window.addEventListener('resize', updateViewportSize)
if ('ResizeObserver' in window && canvasRoot.value) {
resizeObserver = new ResizeObserver(() => updateViewportSize())
resizeObserver.observe(canvasRoot.value)
}
})
onUnmounted(() => {
window.removeEventListener('resize', updateViewportSize)
resizeObserver?.disconnect()
})
const scale = computed(() => computeScenarioCanvasScale({
viewportWidth: viewportWidth.value,
viewportHeight: viewportHeight.value,
canvasWidth: props.width,
canvasHeight: props.height,
}))
/**
* The outer wrapper owns centering in real viewport pixels. It uses the scaled
* dimensions so layout centering is based on the final visible box, not the
* unscaled logical canvas size.
*/
const wrapperStyle = computed(() => ({
width: `${props.width * scale.value}px`,
height: `${props.height * scale.value}px`,
transform: 'translate(-50%, -50%)',
}))
/**
* The inner surface always keeps its logical pixel size. Absolute-positioned
* children are laid out against this stable coordinate system, while scaling is
* applied inside the already-centered wrapper.
*
* This avoids the "drifting translate" problem that happens when windows are
* positioned inside a naturally responsive container whose width/height changes
* with the browser viewport.
*
* Slidev uses the same pattern in:
* - `slidev/packages/client/internals/SlideContainer.vue`
* - `slidev/packages/client/internals/SlideWrapper.vue`
*/
const surfaceStyle = computed(() => ({
width: `${props.width}px`,
height: `${props.height}px`,
transform: `scale(${scale.value})`,
}))
</script>
<template>
<main
ref="canvasRoot"
:class="[
'relative min-h-screen w-full overflow-hidden',
'relative h-screen w-full overflow-hidden',
]"
>
<slot />
<div
:class="[
'absolute left-1/2 top-1/2',
]"
:style="wrapperStyle"
>
<div
data-scenario-canvas-surface
:class="[
'origin-top-left',
]"
:style="surfaceStyle"
>
<slot />
</div>
</div>
</main>
</template>
@@ -0,0 +1,31 @@
export interface ScenarioCanvasScaleOptions {
viewportWidth: number
viewportHeight: number
canvasWidth: number
canvasHeight: number
}
/**
* Computes the single scale factor used to fit a fixed logical scene surface into
* the current viewport.
*
* This mirrors the core idea used by Slidev: keep scene coordinates stable by
* defining a fixed inner canvas size, then scale that whole surface as one unit
* instead of positioning content directly in a responsive box.
*
* Slidev references:
* - `slidev/packages/client/internals/SlideContainer.vue`
* - `slidev/packages/client/internals/SlideWrapper.vue`
* - `slidev/packages/client/env.ts`
*/
export function computeScenarioCanvasScale(options: ScenarioCanvasScaleOptions): number {
const { viewportWidth, viewportHeight, canvasWidth, canvasHeight } = options
if (viewportWidth <= 0 || viewportHeight <= 0 || canvasWidth <= 0 || canvasHeight <= 0)
return 1
return Math.min(
viewportWidth / canvasWidth,
viewportHeight / canvasHeight,
)
}
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import ScenarioCanvas from '../components/scenario-canvas.vue'
import { computeScenarioCanvasScale } from '../runtime/scene-canvas'
describe('computeScenarioCanvasScale', () => {
it('fits the canvas to the smallest viewport ratio', () => {
expect(computeScenarioCanvasScale({
viewportWidth: 1440,
viewportHeight: 900,
canvasWidth: 1920,
canvasHeight: 1080,
})).toBe(0.75)
})
it('falls back to 1 when dimensions are missing', () => {
expect(computeScenarioCanvasScale({
viewportWidth: 0,
viewportHeight: 900,
canvasWidth: 1920,
canvasHeight: 1080,
})).toBe(1)
})
it('renders a fixed logical surface for absolute-positioned scene content', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
createApp({
render: () => h(
ScenarioCanvas,
{
width: 1920,
height: 1080,
},
{
default: () => h('div', { id: 'scene-content' }),
},
),
}).mount(host)
await nextTick()
const surface = host.querySelector<HTMLElement>('[data-scenario-canvas-surface]')
expect(surface).not.toBeNull()
expect(surface?.style.width).toBe('1920px')
expect(surface?.style.height).toBe('1080px')
})
})