fix(vishot-runtime): missing scale and coordinate relocate
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user