feat(scenarios-stage-tamagotchi-browser): anchor to, and dock avoiding

This commit is contained in:
Neko Ayaka
2026-04-04 02:59:17 +08:00
parent fd50db60dc
commit 404b7371fa
8 changed files with 437 additions and 6 deletions
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Ref } from 'vue'
import { computed, provide, readonly, ref } from 'vue'
import { injectPlatformLayout } from './constants'
import { Appearance, Dock, MenuBar } from './ui'
const props = withDefaults(defineProps<{
@@ -21,10 +24,19 @@ const aspectRatio = computed(() => {
return props.aspectRatio.split(':').map(Number).reduce((a, b) => a / b)
})
const platformRoot = ref<HTMLElement | null>(null)
const dockRoot = ref<HTMLElement | null>(null)
provide(injectPlatformLayout, {
dock: dockRoot,
root: readonly(platformRoot) as Readonly<Ref<HTMLElement | null>>,
})
</script>
<template>
<div
ref="platformRoot"
:class="[
'relative overflow-hidden',
'font-macos',
@@ -0,0 +1,8 @@
import type { InjectionKey, Ref } from 'vue'
export interface PlatformLayoutContext {
dock: Ref<HTMLElement | null>
root: Readonly<Ref<HTMLElement | null>>
}
export const injectPlatformLayout: InjectionKey<PlatformLayoutContext> = Symbol('vishot:platforms:macos-26:layout')
@@ -1,4 +1,11 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { injectPlatformLayout } from '../../constants'
import { computeElementAnchorStyle, createContainerAnchorStyle, createWorkAreaRect } from './window-anchor'
const props = withDefaults(defineProps<{
title?: string
focus?: boolean
@@ -6,24 +13,146 @@ const props = withDefaults(defineProps<{
transparent?: boolean
titleBarStyle?: 'default' | 'hidden' | 'hiddenInset' | 'customButtonsOnHover'
hasShadow?: boolean
alignTo?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'center'
anchorTo?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'center'
anchorEl?: HTMLElement | SVGElement | null
anchorBounds?: 'platform' | 'workarea'
}>(), {
anchorBounds: 'platform',
focus: true,
frame: true,
transparent: false,
titleBarStyle: 'default',
hasShadow: true,
})
const platformLayout = inject(injectPlatformLayout, null)
const windowRoot = ref<HTMLElement | null>(null)
const anchorStyle = shallowRef<CSSProperties | undefined>()
let resizeObserver: ResizeObserver | null = null
let animationFrameId: number | null = null
function cancelPendingAnchorUpdate() {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
function updateAnchorStyle() {
if (!props.anchorTo) {
anchorStyle.value = undefined
return
}
const currentPlatformRoot = platformLayout?.root.value ?? null
const currentWindowRoot = windowRoot.value
const currentAnchorEl = props.anchorEl instanceof HTMLElement || props.anchorEl instanceof SVGElement
? props.anchorEl
: null
// Element anchoring is the more specific mode: align this window to the
// measured target element inside the platform rather than to a platform edge.
if (currentAnchorEl && currentPlatformRoot && currentWindowRoot) {
anchorStyle.value = computeElementAnchorStyle({
anchor: props.anchorTo,
anchorRect: currentAnchorEl.getBoundingClientRect(),
platformRect: currentPlatformRoot.getBoundingClientRect(),
windowRect: currentWindowRoot.getBoundingClientRect(),
})
return
}
const platformRect = currentPlatformRoot?.getBoundingClientRect()
// When anchoring against the container, we can optionally shrink the usable
// bounds to the platform work area so windows avoid overlapping the dock.
const workAreaRect = platformRect
? createWorkAreaRect({
dockRect: props.anchorBounds === 'workarea' ? platformLayout?.dock.value?.getBoundingClientRect() ?? null : null,
platformRect,
})
: undefined
anchorStyle.value = createContainerAnchorStyle(
props.anchorTo,
workAreaRect,
platformRect
? {
width: platformRect.width,
height: platformRect.height,
}
: undefined,
)
}
function queueAnchorUpdate() {
cancelPendingAnchorUpdate()
// Layout reads happen on the next animation frame so repeated prop/observer
// changes collapse into one measurement pass.
animationFrameId = requestAnimationFrame(() => {
animationFrameId = null
updateAnchorStyle()
})
}
function refreshResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === 'undefined') {
return
}
resizeObserver = new ResizeObserver(() => {
queueAnchorUpdate()
})
// The computed anchor can change when the platform, dock, window itself, or
// an explicit anchor element resizes, so all of them feed the same update path.
if (platformLayout?.root.value) {
resizeObserver.observe(platformLayout.root.value)
}
if (platformLayout?.dock.value) {
resizeObserver.observe(platformLayout.dock.value)
}
if (windowRoot.value) {
resizeObserver.observe(windowRoot.value)
}
if (props.anchorEl instanceof HTMLElement || props.anchorEl instanceof SVGElement) {
resizeObserver.observe(props.anchorEl)
}
}
onMounted(async () => {
await nextTick()
queueAnchorUpdate()
refreshResizeObserver()
})
watch(() => [props.anchorBounds, props.anchorTo, props.anchorEl], async () => {
await nextTick()
queueAnchorUpdate()
refreshResizeObserver()
})
onBeforeUnmount(() => {
cancelPendingAnchorUpdate()
resizeObserver?.disconnect()
})
</script>
<template>
<div
ref="windowRoot"
:class="[
'absolute',
'flex flex-col',
'rounded-2xl overflow-hidden',
props.hasShadow ? 'shadow-xl' : '',
]"
:style="anchorStyle"
>
<div
v-if="!!props.frame"
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest'
import { computeElementAnchorStyle, createContainerAnchorStyle, createWorkAreaRect } from './window-anchor'
describe('createContainerAnchorStyle', () => {
it('anchors a window to the platform top-right corner', () => {
expect(createContainerAnchorStyle('top-right')).toEqual({
right: '0px',
top: '0px',
})
})
it('centers a window in the platform', () => {
expect(createContainerAnchorStyle('center')).toEqual({
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
})
})
it('anchors a window to the usable area left of a vertical dock', () => {
expect(createContainerAnchorStyle('bottom-right', {
left: 0,
top: 0,
width: 1720,
height: 1080,
}, {
width: 1920,
height: 1080,
})).toEqual({
bottom: '0px',
right: '200px',
})
})
})
describe('createWorkAreaRect', () => {
it('shrinks the work area from the right edge for a vertical dock', () => {
expect(createWorkAreaRect({
platformRect: {
left: 0,
top: 0,
width: 1920,
height: 1080,
},
dockRect: {
left: 1720,
top: 240,
width: 160,
height: 600,
},
})).toEqual({
left: 0,
top: 0,
width: 1720,
height: 1080,
})
})
})
describe('computeElementAnchorStyle', () => {
it('anchors the same window corner to an element corner inside the platform', () => {
expect(computeElementAnchorStyle({
anchor: 'bottom-right',
anchorRect: {
left: 210,
top: 140,
width: 120,
height: 40,
},
platformRect: {
left: 100,
top: 50,
},
windowRect: {
width: 80,
height: 30,
},
})).toEqual({
left: '150px',
top: '100px',
})
})
})
@@ -0,0 +1,159 @@
import type { CSSProperties } from 'vue'
export type WindowAnchor = 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right' | 'center'
type RectPosition = Pick<DOMRect, 'left' | 'top'>
type RectSize = Pick<DOMRect, 'width' | 'height'>
type RectLike = RectPosition & RectSize
type RelativeRect = RectLike
function toRelativeRect(rect: RectLike, platformRect: RectPosition): RelativeRect {
// All anchor math is done in the platform's own coordinate system so callers
// can mix measured DOM rects with the logical scene layout consistently.
return {
left: rect.left - platformRect.left,
top: rect.top - platformRect.top,
width: rect.width,
height: rect.height,
}
}
function resolveAnchorPoint(rect: RectLike, anchor: WindowAnchor) {
switch (anchor) {
case 'top-left':
return { x: rect.left, y: rect.top }
case 'top-right':
return { x: rect.left + rect.width, y: rect.top }
case 'bottom-left':
return { x: rect.left, y: rect.top + rect.height }
case 'bottom-right':
return { x: rect.left + rect.width, y: rect.top + rect.height }
case 'center':
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
}
export function createContainerAnchorStyle(
anchor: WindowAnchor,
boundsRect?: RelativeRect,
platformSize?: RectSize,
): CSSProperties {
// `boundsRect` describes the usable anchoring area inside the platform. When
// it is omitted we anchor against the full platform origin, matching the
// original behavior before dock-aware work areas existed.
const left = boundsRect?.left ?? 0
const top = boundsRect?.top ?? 0
const right = boundsRect && platformSize
? platformSize.width - (boundsRect.left + boundsRect.width)
: 0
const bottom = boundsRect && platformSize
? platformSize.height - (boundsRect.top + boundsRect.height)
: 0
switch (anchor) {
case 'top-left':
return {
left: `${left}px`,
top: `${top}px`,
}
case 'top-right':
return {
right: `${right}px`,
top: `${top}px`,
}
case 'bottom-left':
return {
bottom: `${bottom}px`,
left: `${left}px`,
}
case 'bottom-right':
return {
bottom: `${bottom}px`,
right: `${right}px`,
}
case 'center':
return {
left: boundsRect && platformSize ? `${left + boundsRect.width / 2}px` : '50%',
top: boundsRect && platformSize ? `${top + boundsRect.height / 2}px` : '50%',
transform: 'translate(-50%, -50%)',
}
}
}
export function createWorkAreaRect(options: {
dockRect?: RectLike | null
platformRect: RectLike
}): RelativeRect {
if (!options.dockRect) {
return {
left: 0,
top: 0,
width: options.platformRect.width,
height: options.platformRect.height,
}
}
const dockRect = toRelativeRect(options.dockRect, options.platformRect)
// A tall dock behaves like a left/right sidebar; a wide dock behaves like a
// top/bottom shelf. We trim the usable work area from the nearest edge so
// bottom/right anchors can stay clear of the dock without hard-coded offsets.
const isVerticalDock = dockRect.height >= dockRect.width
const distances = {
bottom: options.platformRect.height - (dockRect.top + dockRect.height),
left: dockRect.left,
right: options.platformRect.width - (dockRect.left + dockRect.width),
top: dockRect.top,
}
const workArea = {
left: 0,
top: 0,
width: options.platformRect.width,
height: options.platformRect.height,
}
if (isVerticalDock) {
if (distances.left <= distances.right) {
const inset = dockRect.left + dockRect.width
workArea.left = inset
workArea.width = options.platformRect.width - inset
}
else {
workArea.width = dockRect.left
}
}
else if (distances.top <= distances.bottom) {
const inset = dockRect.top + dockRect.height
workArea.top = inset
workArea.height = options.platformRect.height - inset
}
else {
workArea.height = dockRect.top
}
return workArea
}
export function computeElementAnchorStyle(options: {
anchor: WindowAnchor
anchorRect: RectLike
platformRect: RectPosition
windowRect: RectSize
}): CSSProperties {
const relativeAnchorRect = toRelativeRect(options.anchorRect, options.platformRect)
const anchorPoint = resolveAnchorPoint(relativeAnchorRect, options.anchor)
// We place the window by subtracting its own anchor point from the target's
// anchor point. That keeps "bottom-right to bottom-right" and similar cases
// aligned without requiring separate formulas per anchor variant.
const windowPoint = resolveAnchorPoint({
left: 0,
top: 0,
width: options.windowRect.width,
height: options.windowRect.height,
}, options.anchor)
return {
left: `${anchorPoint.x - windowPoint.x}px`,
top: `${anchorPoint.y - windowPoint.y}px`,
}
}
@@ -1,4 +1,7 @@
<script setup lang="ts">
import { inject, onMounted, ref, watchEffect } from 'vue'
import { injectPlatformLayout } from '../../constants'
import { Application, DockDivider, DockRoot } from '../../containers/dock'
import { Refractive } from '../../graphics'
import { Apps, Finder, TrashFull } from '../../icons/applications'
@@ -9,10 +12,25 @@ const props = withDefaults(defineProps<{
}>(), {
size: 2,
})
const dockRoot = ref<HTMLElement | null>(null)
const platformLayout = inject(injectPlatformLayout, null)
watchEffect(() => {
if (platformLayout) {
platformLayout.dock.value = dockRoot.value
}
})
onMounted(() => {
if (platformLayout) {
platformLayout.dock.value = dockRoot.value
}
})
</script>
<template>
<div class="absolute right-2 top-1/2 z-1000 translate-y--1/2">
<div ref="dockRoot" class="absolute right-2 top-1/2 z-1000 translate-y--1/2">
<DockRoot :size="props.size">
<Refractive
:refraction="{
@@ -20,8 +20,8 @@ import { WindowRoot } from '../components/platforms/macos-26/containers/window'
* than reinterpreting each translate against a resized responsive container.
*/
const stageWindowStyle = {
left: '1200px',
top: '400px',
right: '0px',
bottom: '0px',
}
const websocketWindowStyle = {
@@ -60,7 +60,13 @@ onMounted(async () => {
<ScenarioCaptureRoot name="intro-chat-window">
<PlatformRoot :dock-size="1.5">
<template #windows>
<WindowRoot :style="stageWindowStyle" :frame="false" :has-shadow="false">
<WindowRoot
:style="stageWindowStyle"
anchor-to="bottom-right"
anchor-bounds="workarea"
:frame="false"
:has-shadow="false"
>
<img :src="stageShot" class="w-95">
</WindowRoot>
<WindowRoot :style="websocketWindowStyle">
@@ -0,0 +1,15 @@
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
import { defineConfig } from 'vitest/config'
export default defineConfig({
root: import.meta.dirname,
plugins: [
Vue(),
Unocss(),
],
test: {
include: ['src/**/*.test.ts'],
},
})