feat(scenarios-stage-tamagotchi-browser): anchor to, and dock avoiding
This commit is contained in:
+13
-1
@@ -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',
|
||||
|
||||
+8
@@ -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')
|
||||
+130
-1
@@ -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"
|
||||
|
||||
+84
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
+159
@@ -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`,
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -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'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user