Refactor extension handlers and improve mobile UX

- Split extensionMessageHandler into separate handler files
  - Created handlers directory with individual files for database, filesystem, http, permissions, context, and storage
  - Reduced main handler file from 602 to 342 lines
  - Improved code organization and maintainability

- Add viewport utilities for safe area handling
  - New viewport.ts utility with helpers for fullscreen dimensions
  - Proper safe area inset calculations for mobile devices
  - Fixed window positioning on small screens to start at 0,0

- Create UiDrawer wrapper component
  - Automatically applies safe area insets
  - Uses TypeScript DrawerProps interface for code completion
  - Replaced all UDrawer instances with UiDrawer

- Improve window management
  - Windows on small screens now use full viewport with safe areas
  - Fixed maximize functionality to respect safe areas
  - Consolidated safe area logic in reusable utilities
This commit is contained in:
2025-11-08 00:14:53 +01:00
parent 2b739b9e79
commit 43ba246174
20 changed files with 2261 additions and 1725 deletions

View File

@ -23,9 +23,9 @@
"@nuxt/icon": "2.0.0",
"@nuxt/ui": "4.1.0",
"@nuxtjs/i18n": "10.0.6",
"@pinia/nuxt": "^0.11.2",
"@supabase/supabase-js": "^2.79.0",
"@tailwindcss/vite": "^4.1.16",
"@pinia/nuxt": "^0.11.3",
"@supabase/supabase-js": "^2.80.0",
"@tailwindcss/vite": "^4.1.17",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "^2.4.4",
@ -38,32 +38,32 @@
"@vueuse/gesture": "^2.0.0",
"@vueuse/nuxt": "^13.9.0",
"drizzle-orm": "^0.44.7",
"eslint": "^9.38.0",
"eslint": "^9.39.1",
"nuxt-zod-i18n": "^1.12.1",
"swiper": "^12.0.3",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"tailwindcss": "^4.1.17",
"vue": "^3.5.24",
"vue-router": "^4.6.3",
"zod": "^3.25.76"
},
"devDependencies": {
"@iconify-json/hugeicons": "^1.2.17",
"@iconify-json/lucide": "^1.2.71",
"@iconify/json": "^2.2.401",
"@iconify/tailwind4": "^1.0.6",
"@iconify-json/lucide": "^1.2.72",
"@iconify/json": "^2.2.404",
"@iconify/tailwind4": "^1.1.0",
"@libsql/client": "^0.15.15",
"@tauri-apps/cli": "^2.9.1",
"@types/node": "^24.9.1",
"@tauri-apps/cli": "^2.9.3",
"@types/node": "^24.10.0",
"@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "^3.5.22",
"drizzle-kit": "^0.31.5",
"globals": "^16.4.0",
"nuxt": "^4.2.0",
"@vue/compiler-sfc": "^3.5.24",
"drizzle-kit": "^0.31.6",
"globals": "^16.5.0",
"nuxt": "^4.2.1",
"prettier": "3.6.2",
"tsx": "^4.20.6",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.1.3",
"vite": "^7.2.2",
"vue-tsc": "3.0.6"
},
"prettier": {

3222
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
<template>
<UDrawer
<UiDrawer
v-model:open="open"
direction="right"
:title="t('launcher.title')"
@ -7,9 +7,6 @@
:overlay="false"
:modal="false"
:handle-only="true"
:ui="{
content: 'w-dvw max-w-md sm:max-w-fit',
}"
>
<UButton
icon="material-symbols:apps"
@ -66,7 +63,7 @@
</div>
</div>
</template>
</UDrawer>
</UiDrawer>
<!-- Uninstall Confirmation Dialog -->
<UiDialogConfirm

View File

@ -83,6 +83,7 @@
</template>
<script setup lang="ts">
import { getAvailableContentHeight } from '~/utils/viewport'
const props = defineProps<{
id: string
title: string
@ -329,31 +330,11 @@ const handleMaximize = () => {
const bounds = getViewportBounds()
if (bounds && bounds.width > 0 && bounds.height > 0) {
// Get safe-area-insets from CSS variables for debug
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Desktop container uses 'absolute inset-0' which stretches over full viewport
// bounds.height = full viewport height (includes header area + safe-areas)
// We need to calculate available space properly
// Get header height from UI store (measured reactively in layout)
const uiStore = useUiStore()
const headerHeight = uiStore.headerHeight
x.value = 0
y.value = 0 // Start below header and status bar
y.value = 0
width.value = bounds.width
// Height: viewport - header - both safe-areas
height.value = bounds.height - headerHeight - safeAreaTop - safeAreaBottom
// Use helper function to calculate correct height with safe areas
height.value = getAvailableContentHeight()
isMaximized.value = true
}
}

View File

@ -1,5 +1,5 @@
<template>
<UDrawer
<UiDrawer
v-model:open="localShowWindowOverview"
direction="bottom"
:title="t('modal.title')"
@ -70,7 +70,7 @@
</div>
</div>
</template>
</UDrawer>
</UiDrawer>
</template>
<script setup lang="ts">

View File

@ -1,5 +1,5 @@
<template>
<UDrawer
<UiDrawer
v-model:open="isOverviewMode"
direction="left"
:overlay="false"
@ -8,7 +8,7 @@
description="Workspaces"
>
<template #content>
<div class="py-8 pl-8 pr-4 h-full overflow-y-auto">
<div class="pl-8 pr-4 h-full overflow-y-auto">
<!-- Workspace Cards -->
<div class="flex flex-col gap-3">
<HaexWorkspaceCard
@ -29,7 +29,7 @@
/>
</div>
</template>
</UDrawer>
</UiDrawer>
</template>
<script setup lang="ts">

View File

@ -0,0 +1,30 @@
<template>
<UDrawer
v-bind="$attrs"
:ui="{
content:
'pb-[env(safe-area-inset-bottom)] pt-[env(safe-area-inset-top)] w-dvw max-w-md sm:max-w-fit',
...(ui || {}),
}"
>
<template
v-for="(_, name) in $slots"
#[name]="slotData"
>
<slot
:name="name"
v-bind="slotData"
/>
</template>
</UDrawer>
</template>
<script setup lang="ts">
import type { DrawerProps } from '@nuxt/ui'
/**
* Wrapper around UDrawer that automatically applies safe area insets for mobile devices.
* Passes through all props and slots to UDrawer.
*/
defineProps</* @vue-ignore */ DrawerProps>()
</script>

View File

@ -83,8 +83,6 @@ const filteredSlots = computed(() => {
Object.entries(useSlots()).filter(([name]) => name !== 'trailing'),
)
})
const { isSmallScreen } = storeToRefs(useUiStore())
</script>
<i18n lang="yaml">

View File

@ -1,38 +1,29 @@
// composables/extensionMessageHandler.ts
import { invoke } from '@tauri-apps/api/core'
import type { IHaexHubExtension } from '~/types/haexhub'
import {
EXTENSION_PROTOCOL_NAME,
EXTENSION_PROTOCOL_PREFIX,
} from '~/config/constants'
import type { Platform } from '@tauri-apps/plugin-os'
interface ExtensionRequest {
id: string
method: string
params: Record<string, unknown>
timestamp: number
}
import {
handleDatabaseMethodAsync,
handleFilesystemMethodAsync,
handleHttpMethodAsync,
handlePermissionsMethodAsync,
handleContextMethodAsync,
handleStorageMethodAsync,
setContextGetters,
type ExtensionRequest,
type ExtensionInstance,
} from './handlers'
// Globaler Handler - nur einmal registriert
let globalHandlerRegistered = false
interface ExtensionInstance {
extension: IHaexHubExtension
windowId: string
}
const iframeRegistry = new Map<HTMLIFrameElement, ExtensionInstance>()
// Map event.source (WindowProxy) to extension instance for sandbox-compatible matching
const sourceRegistry = new Map<Window, ExtensionInstance>()
// Reverse map: window ID to Window for broadcasting (supports multiple windows per extension)
const windowIdToWindowMap = new Map<string, Window>()
// Store context values that need to be accessed outside setup
let contextGetters: {
getTheme: () => string
getLocale: () => string
getPlatform: () => Platform | undefined
} | null = null
const registerGlobalMessageHandler = () => {
if (globalHandlerRegistered) return
@ -227,13 +218,11 @@ export const useExtensionMessageHandler = (
const { locale } = useI18n()
const { platform } = useDeviceStore()
// Store getters for use outside setup context
if (!contextGetters) {
contextGetters = {
getTheme: () => currentTheme.value?.value || 'system',
getLocale: () => locale.value,
getPlatform: () => platform,
}
}
setContextGetters({
getTheme: () => currentTheme.value?.value || 'system',
getLocale: () => locale.value,
getPlatform: () => platform,
})
// Registriere globalen Handler beim ersten Aufruf
registerGlobalMessageHandler()
@ -275,12 +264,7 @@ export const registerExtensionIFrame = (
// Stelle sicher, dass der globale Handler registriert ist
registerGlobalMessageHandler()
// Warnung wenn Context Getters nicht initialisiert wurden
if (!contextGetters) {
console.warn(
'Context getters not initialized. Make sure useExtensionMessageHandler was called in setup context first.',
)
}
// Note: Context getters should be initialized via useExtensionMessageHandler first
iframeRegistry.set(iframe, { extension, windowId })
}
@ -338,221 +322,21 @@ export const broadcastContextToAllExtensions = (context: {
timestamp: Date.now(),
}
console.log('[ExtensionHandler] Broadcasting context to all extensions:', context)
console.log(
'[ExtensionHandler] Broadcasting context to all extensions:',
context,
)
// Send to all registered extension windows
for (const [_, instance] of iframeRegistry.entries()) {
const win = windowIdToWindowMap.get(instance.windowId)
if (win) {
console.log('[ExtensionHandler] Sending context to:', instance.extension.name, instance.windowId)
console.log(
'[ExtensionHandler] Sending context to:',
instance.extension.name,
instance.windowId,
)
win.postMessage(message, '*')
}
}
}
// ==========================================
// Database Methods
// ==========================================
async function handleDatabaseMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension, // Direkter Typ
) {
const params = request.params as {
query?: string
params?: unknown[]
}
switch (request.method) {
case 'haextension.db.query': {
try {
const rows = await invoke<unknown[]>('extension_sql_select', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 0,
lastInsertId: undefined,
}
} catch (error: any) {
// If error is about non-SELECT statements (INSERT/UPDATE/DELETE with RETURNING),
// automatically retry with execute
if (error?.message?.includes('Only SELECT statements are allowed')) {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: rows.length,
lastInsertId: undefined,
}
}
throw error
}
}
case 'haextension.db.execute': {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 1,
lastInsertId: undefined,
}
}
case 'haextension.db.transaction': {
const statements =
(request.params as { statements?: string[] }).statements || []
for (const stmt of statements) {
await invoke('extension_sql_execute', {
sql: stmt,
params: [],
publicKey: extension.publicKey,
name: extension.name,
})
}
return { success: true }
}
default:
throw new Error(`Unknown database method: ${request.method}`)
}
}
// ==========================================
// Filesystem Methods (TODO)
// ==========================================
async function handleFilesystemMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!request || !extension) return
// TODO: Implementiere Filesystem Commands im Backend
throw new Error('Filesystem methods not yet implemented')
}
// ==========================================
// HTTP Methods (TODO)
// ==========================================
async function handleHttpMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere HTTP Commands im Backend
throw new Error('HTTP methods not yet implemented')
}
// ==========================================
// Permission Methods (TODO)
// ==========================================
async function handlePermissionsMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere Permission Request UI
throw new Error('Permission methods not yet implemented')
}
// ==========================================
// Context Methods
// ==========================================
async function handleContextMethodAsync(request: ExtensionRequest) {
switch (request.method) {
case 'haextension.context.get':
if (!contextGetters) {
throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
)
}
return {
theme: contextGetters.getTheme(),
locale: contextGetters.getLocale(),
platform: contextGetters.getPlatform(),
}
default:
throw new Error(`Unknown context method: ${request.method}`)
}
}
// ==========================================
// Storage Methods
// ==========================================
async function handleStorageMethodAsync(
request: ExtensionRequest,
instance: ExtensionInstance,
) {
// Storage is now per-window, not per-extension
const storageKey = `ext_${instance.extension.id}_${instance.windowId}_`
console.log(
`[HaexHub Storage] ${request.method} for window ${instance.windowId}`,
)
switch (request.method) {
case 'haextension.storage.getItem': {
const key = request.params.key as string
return localStorage.getItem(storageKey + key)
}
case 'haextension.storage.setItem': {
const key = request.params.key as string
const value = request.params.value as string
localStorage.setItem(storageKey + key, value)
return null
}
case 'haextension.storage.removeItem': {
const key = request.params.key as string
localStorage.removeItem(storageKey + key)
return null
}
case 'haextension.storage.clear': {
// Remove only instance-specific keys
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(storageKey),
)
keys.forEach((k) => localStorage.removeItem(k))
return null
}
case 'haextension.storage.keys': {
// Return only instance-specific keys (without prefix)
const keys = Object.keys(localStorage)
.filter((k) => k.startsWith(storageKey))
.map((k) => k.substring(storageKey.length))
return keys
}
default:
throw new Error(`Unknown storage method: ${request.method}`)
}
}

View File

@ -0,0 +1,36 @@
import type { Platform } from '@tauri-apps/plugin-os'
import type { ExtensionRequest } from './types'
// Context getters are set from the main handler during initialization
let contextGetters: {
getTheme: () => string
getLocale: () => string
getPlatform: () => Platform | undefined
} | null = null
export function setContextGetters(getters: {
getTheme: () => string
getLocale: () => string
getPlatform: () => Platform | undefined
}) {
contextGetters = getters
}
export async function handleContextMethodAsync(request: ExtensionRequest) {
switch (request.method) {
case 'haextension.context.get':
if (!contextGetters) {
throw new Error(
'Context not initialized. Make sure useExtensionMessageHandler is called in a component.',
)
}
return {
theme: contextGetters.getTheme(),
locale: contextGetters.getLocale(),
platform: contextGetters.getPlatform(),
}
default:
throw new Error(`Unknown context method: ${request.method}`)
}
}

View File

@ -0,0 +1,84 @@
import { invoke } from '@tauri-apps/api/core'
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleDatabaseMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
const params = request.params as {
query?: string
params?: unknown[]
}
switch (request.method) {
case 'haextension.db.query': {
try {
const rows = await invoke<unknown[]>('extension_sql_select', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 0,
lastInsertId: undefined,
}
} catch (error) {
// If error is about non-SELECT statements (INSERT/UPDATE/DELETE with RETURNING),
// automatically retry with execute
if (error?.message?.includes('Only SELECT statements are allowed')) {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: rows.length,
lastInsertId: undefined,
}
}
throw error
}
}
case 'haextension.db.execute': {
const rows = await invoke<unknown[]>('extension_sql_execute', {
sql: params.query || '',
params: params.params || [],
publicKey: extension.publicKey,
name: extension.name,
})
return {
rows,
rowsAffected: 1,
lastInsertId: undefined,
}
}
case 'haextension.db.transaction': {
const statements =
(request.params as { statements?: string[] }).statements || []
for (const stmt of statements) {
await invoke('extension_sql_execute', {
sql: stmt,
params: [],
publicKey: extension.publicKey,
name: extension.name,
})
}
return { success: true }
}
default:
throw new Error(`Unknown database method: ${request.method}`)
}
}

View File

@ -0,0 +1,48 @@
import { save } from '@tauri-apps/plugin-dialog'
import { writeFile } from '@tauri-apps/plugin-fs'
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleFilesystemMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!request || !extension) return
switch (request.method) {
case 'haextension.fs.saveFile': {
const params = request.params as {
data: number[]
defaultPath?: string
title?: string
filters?: Array<{ name: string; extensions: string[] }>
}
// Convert number array back to Uint8Array
const data = new Uint8Array(params.data)
// Open save dialog
const filePath = await save({
defaultPath: params.defaultPath,
title: params.title || 'Save File',
filters: params.filters,
})
// User cancelled
if (!filePath) {
return null
}
// Write file
await writeFile(filePath, data)
return {
path: filePath,
success: true,
}
}
default:
throw new Error(`Unknown filesystem method: ${request.method}`)
}
}

View File

@ -0,0 +1,14 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handleHttpMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere HTTP Commands im Backend
throw new Error('HTTP methods not yet implemented')
}

View File

@ -0,0 +1,10 @@
// Export all handler functions
export { handleDatabaseMethodAsync } from './database'
export { handleFilesystemMethodAsync } from './filesystem'
export { handleHttpMethodAsync } from './http'
export { handlePermissionsMethodAsync } from './permissions'
export { handleContextMethodAsync, setContextGetters } from './context'
export { handleStorageMethodAsync } from './storage'
// Export shared types
export type { ExtensionRequest, ExtensionInstance } from './types'

View File

@ -0,0 +1,14 @@
import type { IHaexHubExtension } from '~/types/haexhub'
import type { ExtensionRequest } from './types'
export async function handlePermissionsMethodAsync(
request: ExtensionRequest,
extension: IHaexHubExtension,
) {
if (!extension || !request) {
throw new Error('Extension not found')
}
// TODO: Implementiere Permission Request UI
throw new Error('Permission methods not yet implemented')
}

View File

@ -0,0 +1,52 @@
import type { ExtensionRequest, ExtensionInstance } from './types'
export async function handleStorageMethodAsync(
request: ExtensionRequest,
instance: ExtensionInstance,
) {
// Storage is now per-window, not per-extension
const storageKey = `ext_${instance.extension.id}_${instance.windowId}_`
console.log(
`[HaexHub Storage] ${request.method} for window ${instance.windowId}`,
)
switch (request.method) {
case 'haextension.storage.getItem': {
const key = request.params.key as string
return localStorage.getItem(storageKey + key)
}
case 'haextension.storage.setItem': {
const key = request.params.key as string
const value = request.params.value as string
localStorage.setItem(storageKey + key, value)
return null
}
case 'haextension.storage.removeItem': {
const key = request.params.key as string
localStorage.removeItem(storageKey + key)
return null
}
case 'haextension.storage.clear': {
// Remove only instance-specific keys
const keys = Object.keys(localStorage).filter((k) =>
k.startsWith(storageKey),
)
keys.forEach((k) => localStorage.removeItem(k))
return null
}
case 'haextension.storage.keys': {
// Return only instance-specific keys (without prefix)
const keys = Object.keys(localStorage)
.filter((k) => k.startsWith(storageKey))
.map((k) => k.substring(storageKey.length))
return keys
}
default:
throw new Error(`Unknown storage method: ${request.method}`)
}
}

View File

@ -0,0 +1,14 @@
// Shared types for extension message handlers
import type { IHaexHubExtension } from '~/types/haexhub'
export interface ExtensionRequest {
id: string
method: string
params: Record<string, unknown>
timestamp: number
}
export interface ExtensionInstance {
extension: IHaexHubExtension
windowId: string
}

View File

@ -1,4 +1,5 @@
import { defineAsyncComponent, type Component } from 'vue'
import { getFullscreenDimensions } from '~/utils/viewport'
export interface IWindow {
id: string
@ -191,22 +192,42 @@ export const useWindowManagerStore = defineStore('windowManager', () => {
const viewportHeight = window.innerHeight - 60
console.log('viewportHeight', window.innerHeight, viewportHeight)
const windowHeight = Math.min(height, viewportHeight)
// Adjust width proportionally if needed (optional)
const aspectRatio = width / height
const windowWidth = Math.min(
width,
viewportWidth,
windowHeight * aspectRatio,
)
// Check if we're on a small screen
const { isSmallScreen } = useUiStore()
// Calculate centered position with cascading offset (only count windows in current workspace)
const offset = currentWorkspaceWindows.value.length * 30
const centerX = Math.max(0, (viewportWidth - windowWidth) / 1 / 3)
const centerY = Math.max(0, (viewportHeight - windowHeight) / 1 / 3)
const x = Math.min(centerX + offset, viewportWidth - windowWidth)
const y = Math.min(centerY + offset, viewportHeight - windowHeight)
let windowWidth: number
let windowHeight: number
let x: number
let y: number
if (isSmallScreen) {
// On small screens, make window fullscreen starting at 0,0
// Use helper function to calculate correct dimensions with safe areas
const fullscreen = getFullscreenDimensions()
x = fullscreen.x
y = fullscreen.y
windowWidth = fullscreen.width
windowHeight = fullscreen.height
} else {
// On larger screens, use normal sizing and positioning
windowHeight = Math.min(height, viewportHeight)
// Adjust width proportionally if needed (optional)
const aspectRatio = width / height
windowWidth = Math.min(
width,
viewportWidth,
windowHeight * aspectRatio,
)
// Calculate centered position with cascading offset (only count windows in current workspace)
const offset = currentWorkspaceWindows.value.length * 30
const centerX = Math.max(0, (viewportWidth - windowWidth) / 1 / 3)
const centerY = Math.max(0, (viewportHeight - windowHeight) / 1 / 3)
x = Math.min(centerX + offset, viewportWidth - windowWidth)
y = Math.min(centerY + offset, viewportHeight - windowHeight)
}
const newWindow: IWindow = {
id: windowId,

67
src/utils/viewport.ts Normal file
View File

@ -0,0 +1,67 @@
// Viewport and safe area utilities
export interface ViewportDimensions {
width: number
height: number
safeAreaTop: number
safeAreaBottom: number
headerHeight: number
}
/**
* Get viewport dimensions with safe areas and header height
*/
export function getViewportDimensions(): ViewportDimensions {
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight - 60 // Subtract tab bar height
// Get safe-area-insets from CSS variables
const safeAreaTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-top',
) || '0',
)
const safeAreaBottom = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
'--safe-area-inset-bottom',
) || '0',
)
// Get header height from UI store
const { headerHeight } = useUiStore()
return {
width: viewportWidth,
height: viewportHeight,
safeAreaTop,
safeAreaBottom,
headerHeight,
}
}
/**
* Calculate available content height (viewport height minus safe areas)
* Note: viewport height already excludes header, so we only subtract safe areas
*/
export function getAvailableContentHeight(): number {
const dimensions = getViewportDimensions()
return (
dimensions.height -
dimensions.safeAreaTop -
dimensions.safeAreaBottom
)
}
/**
* Calculate fullscreen window dimensions (for small screens)
*/
export function getFullscreenDimensions() {
const dimensions = getViewportDimensions()
return {
x: 0,
y: 0,
width: dimensions.width,
height: getAvailableContentHeight(),
}
}