feat(stage-tamagotchi): move main-stage window to avoid overlap with onboarding window
This commit is contained in:
@@ -1,16 +1,90 @@
|
||||
import type { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
import type { OnboardingWindowManager } from '../../../windows/onboarding'
|
||||
|
||||
import { defineInvokeHandler } from '@moeru/eventa'
|
||||
import { animate, utils } from 'animejs'
|
||||
import { screen } from 'electron'
|
||||
|
||||
import { electronOpenOnboarding } from '../../../../shared/eventa'
|
||||
import { computeAdjacentPosition } from '../../../windows/shared/display'
|
||||
|
||||
const ANIMATION_DURATION = 350
|
||||
|
||||
function animateWindowPosition(
|
||||
window: BrowserWindow,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
): ReturnType<typeof animate> | undefined {
|
||||
if (window.isDestroyed())
|
||||
return undefined
|
||||
|
||||
const bounds = window.getBounds()
|
||||
const state = { x: bounds.x, y: bounds.y }
|
||||
|
||||
return animate(state, {
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
duration: ANIMATION_DURATION,
|
||||
ease: 'outCubic',
|
||||
modifier: utils.round(0),
|
||||
onRender: () => {
|
||||
if (!window.isDestroyed())
|
||||
window.setPosition(Math.round(state.x), Math.round(state.y))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createOnboardingService(params: {
|
||||
context: ReturnType<typeof createContext>['context']
|
||||
onboardingWindowManager: OnboardingWindowManager
|
||||
mainWindow: BrowserWindow
|
||||
}) {
|
||||
let currentAnimation: ReturnType<typeof animate> | undefined
|
||||
let cleanupOnClosed: (() => void) | undefined
|
||||
|
||||
defineInvokeHandler(params.context, electronOpenOnboarding, async () => {
|
||||
await params.onboardingWindowManager.getAndToggleWindow()
|
||||
const savedBounds = params.mainWindow.getBounds()
|
||||
|
||||
const onboardingWindow = await params.onboardingWindowManager.getAndToggleWindow()
|
||||
const onboardingBounds = onboardingWindow.getBounds()
|
||||
const display = screen.getDisplayMatching(onboardingBounds)
|
||||
|
||||
const newPosition = computeAdjacentPosition(
|
||||
onboardingBounds,
|
||||
{ width: savedBounds.width, height: savedBounds.height },
|
||||
display.workArea,
|
||||
)
|
||||
|
||||
currentAnimation?.pause()
|
||||
currentAnimation = animateWindowPosition(params.mainWindow, newPosition.x, newPosition.y)
|
||||
|
||||
let userMovedManually = false
|
||||
let ignoreNextMoves = true
|
||||
|
||||
const moveListener = () => {
|
||||
if (ignoreNextMoves)
|
||||
return
|
||||
userMovedManually = true
|
||||
}
|
||||
|
||||
params.mainWindow.on('move', moveListener)
|
||||
setTimeout(() => {
|
||||
ignoreNextMoves = false
|
||||
}, ANIMATION_DURATION + 50)
|
||||
|
||||
cleanupOnClosed?.()
|
||||
|
||||
cleanupOnClosed = params.onboardingWindowManager.onClosed(() => {
|
||||
params.mainWindow.removeListener('move', moveListener)
|
||||
|
||||
if (!userMovedManually && !params.mainWindow.isDestroyed()) {
|
||||
currentAnimation?.pause()
|
||||
currentAnimation = animateWindowPosition(params.mainWindow, savedBounds.x, savedBounds.y)
|
||||
}
|
||||
|
||||
cleanupOnClosed = undefined
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function setupMainWindowElectronInvokes(params: {
|
||||
createWidgetsService({ context, widgetsManager: params.widgetsManager, window: params.window })
|
||||
createAutoUpdaterService({ context, window: params.window, service: params.autoUpdater })
|
||||
createMcpServersService({ context, manager: params.mcpStdioManager })
|
||||
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager })
|
||||
createOnboardingService({ context, onboardingWindowManager: params.onboardingWindowManager, mainWindow: params.window })
|
||||
createAuthService({ context, window: params.window, windowAuthManager: params.windowAuthManager })
|
||||
|
||||
defineInvokeHandler(context, electronOpenMainDevtools, () => params.window.webContents.openDevTools({ mode: 'detach' }))
|
||||
|
||||
@@ -22,6 +22,7 @@ import { setupBaseWindowElectronInvokes } from '../shared/window'
|
||||
export interface OnboardingWindowManager {
|
||||
getWindow: () => Promise<BrowserWindow>
|
||||
getAndToggleWindow: () => Promise<BrowserWindow>
|
||||
onClosed: (callback: () => void) => () => void
|
||||
}
|
||||
|
||||
export function setupOnboardingWindowManager(params: {
|
||||
@@ -29,6 +30,8 @@ export function setupOnboardingWindowManager(params: {
|
||||
i18n: I18n
|
||||
windowAuthManager: WindowAuthManager
|
||||
}): OnboardingWindowManager {
|
||||
const closeCallbacks = new Set<() => void>()
|
||||
|
||||
async function getOnboardingWindow(getWindow: () => Promise<BrowserWindow>) {
|
||||
const window = await getWindow()
|
||||
await toggleWindowShow(window)
|
||||
@@ -78,11 +81,26 @@ export function setupOnboardingWindowManager(params: {
|
||||
|
||||
await load(newWindow, withHashRoute(baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')), '/onboarding'))
|
||||
|
||||
newWindow.on('closed', () => {
|
||||
for (const cb of closeCallbacks) {
|
||||
try {
|
||||
cb()
|
||||
}
|
||||
catch { /* noop */ }
|
||||
}
|
||||
})
|
||||
|
||||
return newWindow
|
||||
})
|
||||
|
||||
return {
|
||||
getWindow: async () => reusableWindow.getWindow(),
|
||||
getAndToggleWindow: async () => await getOnboardingWindow(reusableWindow.getWindow),
|
||||
onClosed: (callback: () => void) => {
|
||||
closeCallbacks.add(callback)
|
||||
return () => {
|
||||
closeCallbacks.delete(callback)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,42 @@ export function widthFrom(bounds: Rectangle, sizeOptions: Size & { min?: Size, m
|
||||
return val
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a position for `target` adjacent to `anchor`, staying within `workArea`.
|
||||
*/
|
||||
export function computeAdjacentPosition(
|
||||
anchorBounds: Rectangle,
|
||||
targetSize: { width: number, height: number },
|
||||
workArea: Rectangle,
|
||||
offset = 16,
|
||||
): { x: number, y: number } {
|
||||
const centerY = anchorBounds.y + Math.floor((anchorBounds.height - targetSize.height) / 2)
|
||||
const clampY = (y: number) => Math.min(
|
||||
Math.max(y, workArea.y),
|
||||
workArea.y + workArea.height - targetSize.height,
|
||||
)
|
||||
|
||||
// Try right
|
||||
const rightX = anchorBounds.x + anchorBounds.width + offset
|
||||
if (rightX + targetSize.width <= workArea.x + workArea.width) {
|
||||
return { x: rightX, y: clampY(centerY) }
|
||||
}
|
||||
|
||||
// Try left
|
||||
const leftX = anchorBounds.x - targetSize.width - offset
|
||||
if (leftX >= workArea.x) {
|
||||
return { x: leftX, y: clampY(centerY) }
|
||||
}
|
||||
|
||||
// Fall back to below
|
||||
const belowY = anchorBounds.y + anchorBounds.height + offset
|
||||
const belowX = anchorBounds.x + Math.floor((anchorBounds.width - targetSize.width) / 2)
|
||||
return {
|
||||
x: Math.min(Math.max(belowX, workArea.x), workArea.x + workArea.width - targetSize.width),
|
||||
y: Math.min(belowY, workArea.y + workArea.height - targetSize.height),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate height based on options similar to how Web CSS does it.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user