feat(stage-ui,stage-web): custom chat background (#825)

This commit is contained in:
Rin
2025-12-24 12:38:09 +08:00
committed by GitHub
parent 2e4c5a7719
commit 74136b9ccf
20 changed files with 1100 additions and 85 deletions
+8 -1
View File
@@ -1,5 +1,12 @@
[workspace]
members = [ "crates/tauri-plugin-ipc-audio-transcription-ort", "crates/tauri-plugin-ipc-audio-vad-ort", "crates/tauri-plugin-mcp", "crates/tauri-plugin-rdev", "crates/tauri-plugin-window-pass-through-on-hover", "crates/tauri-plugin-window-router-link" ]
members = [
"crates/tauri-plugin-ipc-audio-transcription-ort",
"crates/tauri-plugin-ipc-audio-vad-ort",
"crates/tauri-plugin-mcp",
"crates/tauri-plugin-rdev",
"crates/tauri-plugin-window-pass-through-on-hover",
"crates/tauri-plugin-window-router-link"
]
resolver = "2"
[workspace.package]
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { BackgroundPickerDialog } from '@proj-airi/stage-ui/components'
import { storeToRefs } from 'pinia'
import { useBackgroundStore } from '../../stores/background'
const show = defineModel<boolean>({ default: false })
const backgroundStore = useBackgroundStore()
const { options, selectedOption } = storeToRefs(backgroundStore)
</script>
<template>
<BackgroundPickerDialog
v-model="show"
:selected="selectedOption"
:options="options"
@apply="backgroundStore.applyPickerSelection"
@remove="option => backgroundStore.removeOption(option.id)"
/>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import AnimatedWave from '../Widgets/AnimatedWave.vue'
import Cross from './Cross.vue'
</script>
<template>
<Cross>
<AnimatedWave
fill-color="linear-gradient(120deg, hsl(var(--chromatic-hue) 72% 75%), hsl(var(--chromatic-hue) 70% 62%))"
class="h-full w-full"
>
<div class="relative h-full w-full from-black/10 via-black/0 to-black/0 bg-gradient-to-b" />
</AnimatedWave>
</Cross>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { BackgroundItem } from '../../stores/background'
import ThemeOverlay from '@proj-airi/stage-ui/components/ThemeOverlay.vue'
import { useTheme } from '@proj-airi/ui'
import { computed, ref } from 'vue'
import AnimatedWave from '../Widgets/AnimatedWave.vue'
import Cross from './Cross.vue'
import { BackgroundKind } from '../../stores/background'
defineProps<{
background: BackgroundItem
topColor?: string
}>()
const { isDark: dark } = useTheme()
const containerRef = ref<HTMLElement | null>(null)
const waveFillColor = computed(() => {
const hue = 'var(--chromatic-hue)'
return dark.value
? `hsl(${hue} 60% 32%)`
: `hsl(${hue} 75% 78%)`
})
defineExpose({
surfaceEl: containerRef,
})
</script>
<template>
<div ref="containerRef" class="customized-background relative min-h-100dvh w-full overflow-hidden">
<!-- Background layers -->
<div
class="absolute inset-0 z-0 transition-all duration-300"
:class="[(background.blur && background.kind !== BackgroundKind.Wave) ? 'blur-md scale-110' : '']"
>
<template v-if="background.kind === BackgroundKind.Wave">
<Cross class="h-full w-full">
<AnimatedWave
class="h-full w-full"
:fill-color="waveFillColor"
/>
</Cross>
</template>
<template v-else-if="background.kind === BackgroundKind.Image">
<img
:src="background.src"
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
>
</template>
<template v-else>
<div class="h-full w-full bg-neutral-950" />
</template>
</div>
<!-- Overlay (not for wave) -->
<ThemeOverlay v-if="background.kind !== BackgroundKind.Wave" :color="topColor" />
<!-- Content layer (kept mounted during background switches) -->
<div class="relative z-10 h-full w-full">
<slot />
</div>
</div>
</template>
<style scoped>
</style>
@@ -1,11 +1,15 @@
<script setup lang="ts">
import { useTheme } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import LogoDark from '../../assets/logo-dark.svg'
import Logo from '../../assets/logo.svg'
import { BackgroundKind, useBackgroundStore } from '../../stores/background'
const { isDark: dark } = useTheme()
const { selectedOption } = storeToRefs(useBackgroundStore())
</script>
<template>
@@ -13,11 +17,13 @@ const { isDark: dark } = useTheme()
to="/" flex="~" items-center
gap-2 px-2 text-nowrap text-2xl outline-none
>
<template v-if="dark">
<img :src="LogoDark" h-8 w-8 class="theme-colored">
</template>
<template v-else>
<img :src="Logo" h-8 w-8 class="theme-colored">
<template v-if="selectedOption?.kind === BackgroundKind.Wave">
<template v-if="dark">
<img :src="LogoDark" h-8 w-8 class="theme-colored">
</template>
<template v-else>
<img :src="Logo" h-8 w-8 class="theme-colored">
</template>
</template>
</RouterLink>
</template>
@@ -15,6 +15,7 @@ import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import AppBackgroundPickerDialog from '../Backgrounds/AppBackgroundPickerDialog.vue'
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
import ActionAbout from './InteractiveArea/Actions/About.vue'
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
@@ -29,6 +30,7 @@ const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInpu
const messageInput = ref('')
const isComposing = ref(false)
const backgroundDialogOpen = ref(false)
const screenSafeArea = useScreenSafeArea()
const providersStore = useProvidersStore()
@@ -130,6 +132,7 @@ onMounted(() => {
<template>
<div fixed bottom-0 w-full flex flex-col>
<AppBackgroundPickerDialog v-model="backgroundDialogOpen" />
<KeepAlive>
<Transition name="fade">
<ChatHistory
@@ -180,6 +183,9 @@ onMounted(() => {
<div v-else i-solar:sun-2-outline size-5 text="neutral-500 dark:neutral-400" />
</Transition>
</button>
<button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Background" @click="backgroundDialogOpen = true">
<div i-solar:gallery-wide-bold-duotone size-5 text="neutral-500 dark:neutral-400" />
</button>
<!-- <button border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" w-fit flex items-center self-end justify-center rounded-xl p-2 backdrop-blur-md title="Language">
<div i-solar:earth-outline size-5 text="neutral-500 dark:neutral-400" />
</button> -->
@@ -1,12 +1,18 @@
<script setup lang="ts">
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useTheme } from '@proj-airi/ui'
import { ref } from 'vue'
import AppBackgroundPickerDialog from '../Backgrounds/AppBackgroundPickerDialog.vue'
const { cleanupMessages } = useChatStore()
const { isDark, toggleDark } = useTheme()
const backgroundDialogOpen = ref(false)
</script>
<template>
<AppBackgroundPickerDialog v-model="backgroundDialogOpen" />
<div absolute bottom--8 right-0 flex gap-2>
<button
class="max-h-[10lh] min-h-[1lh]"
@@ -33,5 +39,16 @@ const { isDark, toggleDark } = useTheme()
<div v-else i-solar:sun-2-bold />
</Transition>
</button>
<button
class="max-h-[10lh] min-h-[1lh]"
bg="neutral-100 dark:neutral-800"
text="lg neutral-500 dark:neutral-400"
flex items-center justify-center rounded-md p-2 outline-none
transition-colors transition-transform active:scale-95
title="Background"
@click="backgroundDialogOpen = true"
>
<div i-solar:gallery-wide-bold-duotone />
</button>
</div>
</template>
@@ -1,7 +1,18 @@
import type { Ref } from 'vue'
import type CustomizedBackground from '../components/Backgrounds/CustomizedBackground.vue'
import type { BackgroundItem } from '../stores/background'
import Color from 'colorjs.io'
import { withRetry } from '@moeru/std'
import { colorFromElement, patchThemeSamplingHtml2CanvasClone } from '@proj-airi/stage-ui/libs'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { useTheme } from '@proj-airi/ui'
import { useDocumentVisibility, useIntervalFn } from '@vueuse/core'
import { nextTick, watch } from 'vue'
import { BackgroundKind } from '../stores/background'
export function themeColorFromPropertyOf(colorFromClass: string, property: string): () => Promise<string> {
return async () => {
@@ -44,3 +55,140 @@ export function useThemeColor(colorFrom: () => string | Promise<string>) {
updateThemeColor,
}
}
export function useBackgroundThemeColor({
backgroundSurface,
selectedOption,
sampledColor,
}: {
backgroundSurface: Ref<InstanceType<typeof CustomizedBackground> | undefined | null>
selectedOption: Ref<BackgroundItem | undefined>
sampledColor: Ref<string>
}) {
const { themeColorsHue, themeColorsHueDynamic } = useSettings()
let samplingToken = 0
const { isDark } = useTheme()
function getWaveThemeColor() {
// We read directly from computed style to catch the animation value
return isDark.value ? `hsl(${themeColorsHue} 60% 32%)` : `hsl(${themeColorsHue} 75% 78%)`
}
const { updateThemeColor } = useThemeColor(() => {
if (selectedOption.value?.kind === BackgroundKind.Wave) {
return getWaveThemeColor()
}
return sampledColor.value
})
// Keep theme-color reasonably fresh for animated wave backgrounds without doing per-frame work.
const { pause, resume } = useIntervalFn(() => {
if (useDocumentVisibility().value !== 'visible')
return
if (selectedOption.value?.kind === BackgroundKind.Wave && themeColorsHueDynamic)
void updateThemeColor()
}, 250, { immediate: false })
watch([() => selectedOption.value?.kind, () => themeColorsHueDynamic], ([kind, dynamic]) => {
if (kind === BackgroundKind.Wave && dynamic) {
void updateThemeColor()
resume()
}
else {
pause()
}
}, { immediate: true })
async function waitForBackgroundReady() {
await nextTick()
const image = backgroundSurface.value?.surfaceEl?.querySelector('img')
if (image && !image.complete) {
await new Promise<void>((resolve, reject) => {
image.addEventListener('load', () => resolve(), { once: true })
image.addEventListener('error', () => reject(new Error('Background image failed to load')), { once: true })
})
}
}
// Exposed for optional manual triggers; also used within syncBackgroundTheme.
async function sampleBackgroundColor() {
const token = ++samplingToken
const optionId = selectedOption.value?.id
if (selectedOption.value?.kind === BackgroundKind.Wave) {
await updateThemeColor()
return
}
const el = backgroundSurface.value?.surfaceEl
if (!el)
return
await waitForBackgroundReady()
const result = await colorFromElement(el, {
mode: 'html2canvas',
html2canvas: {
region: {
x: 0,
y: 0,
width: el.offsetWidth,
height: Math.min(140, el.offsetHeight),
},
sampleHeight: 20,
sampleStride: 10,
scale: 0.5,
backgroundColor: null,
allowTaint: true,
useCORS: true,
onclone: patchThemeSamplingHtml2CanvasClone,
},
})
const color = result.html2canvas?.average
if (token !== samplingToken)
return
if (optionId && selectedOption.value?.id !== optionId)
return
if (color) {
sampledColor.value = color
}
}
async function syncBackgroundTheme() {
if (selectedOption.value?.kind === BackgroundKind.Wave) {
await updateThemeColor()
}
else if (sampledColor.value) {
await updateThemeColor()
}
else {
await sampleBackgroundColor()
}
}
watch([selectedOption], () => {
syncBackgroundTheme()
}, { immediate: true })
watch(sampledColor, () => {
syncBackgroundTheme()
})
watch(() => backgroundSurface.value?.surfaceEl, (el) => {
if (el)
syncBackgroundTheme()
})
watch(isDark, () => {
syncBackgroundTheme()
})
return {
sampledColor,
sampleBackgroundColor,
syncBackgroundTheme,
}
}
@@ -32,9 +32,6 @@ const mode = ref<'vibrant' | 'html2canvas'>('vibrant')
const imageRef = useTemplateRef<HTMLDivElement>('imageRef')
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvas')
// Theme color integration
const { updateThemeColor } = useThemeColor(() => dominantColor.value)
// Computed gradient for top bar blending
const topBar = computed(() => {
if (mode.value === 'vibrant') {
@@ -47,6 +44,9 @@ const topBar = computed(() => {
return ''
})
// Theme color integration
const { updateThemeColor } = useThemeColor(() => topBar.value)
async function refreshColors() {
if (!imageRef.value || images.value.length === 0) {
return
@@ -132,8 +132,6 @@ onUnmounted(() => {
</div>
</div>
<div class="transparent-gradient-overlay absolute inset-0 h-[calc((1lh+1rem+1rem)*2)] w-full" :style="{ background: topBar }" />
<img
ref="imageRef"
:src="images[0]"
@@ -220,21 +218,4 @@ onUnmounted(() => {
</template>
<style scoped>
/*
DO NOT ATTEMPT TO USE backdrop-filter TOGETHER WITH mask-image.
html - Why doesn't blur backdrop-filter work together with mask-image? - Stack Overflow
https://stackoverflow.com/questions/72780266/why-doesnt-blur-backdrop-filter-work-together-with-mask-image
*/
.transparent-gradient-overlay {
--gradient: linear-gradient(to top, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 50%);
-webkit-mask-image: var(--gradient);
mask-image: var(--gradient);
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: bottom;
mask-position: bottom;
}
</style>
+39 -40
View File
@@ -12,21 +12,19 @@ import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consci
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useTheme } from '@proj-airi/ui'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import Cross from '../components/Backgrounds/Cross.vue'
import CustomizedBackground from '../components/Backgrounds/CustomizedBackground.vue'
import Header from '../components/Layouts/Header.vue'
import InteractiveArea from '../components/Layouts/InteractiveArea.vue'
import MobileHeader from '../components/Layouts/MobileHeader.vue'
import MobileInteractiveArea from '../components/Layouts/MobileInteractiveArea.vue'
import AnimatedWave from '../components/Widgets/AnimatedWave.vue'
import { themeColorFromPropertyOf, useThemeColor } from '../composables/theme-color'
import { useBackgroundThemeColor } from '../composables/theme-color'
import { useBackgroundStore } from '../stores/background'
const { isDark: dark } = useTheme()
const paused = ref(false)
function handleSettingsOpen(open: boolean) {
@@ -38,9 +36,12 @@ const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
const { updateThemeColor } = useThemeColor(themeColorFromPropertyOf('.widgets.top-widgets .colored-area', 'background-color'))
watch(dark, () => updateThemeColor(), { immediate: true })
onMounted(() => updateThemeColor())
const backgroundStore = useBackgroundStore()
const { selectedOption, sampledColor } = storeToRefs(backgroundStore)
const backgroundSurface = useTemplateRef<InstanceType<typeof CustomizedBackground>>('backgroundSurface')
const { syncBackgroundTheme } = useBackgroundThemeColor({ backgroundSurface, selectedOption, sampledColor })
onMounted(() => syncBackgroundTheme())
// Audio + transcription pipeline (mirrors stage-tamagotchi)
const settingsAudioDeviceStore = useSettingsAudioDevice()
@@ -129,38 +130,36 @@ watch([stream, () => vadLoaded.value], async ([s, loaded]) => {
</script>
<template>
<Cross>
<AnimatedWave
class="widgets top-widgets"
:fill-color="dark
? 'oklch(35% calc(var(--chromatic-chroma) * 0.6) var(--chromatic-hue))'
: 'color-mix(in srgb, oklch(95% calc(var(--chromatic-chroma-50) * 0.5) var(--chromatic-hue)) 80%, oklch(100% 0 360))'"
>
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
<WidgetStage
flex-1 min-w="1/2"
:paused="paused"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
<CustomizedBackground
ref="backgroundSurface"
class="widgets top-widgets"
:background="selectedOption"
:top-color="sampledColor"
>
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="hidden md:flex" />
<MobileHeader class="flex md:hidden" />
</div>
</AnimatedWave>
</Cross>
<!-- page -->
<div relative flex="~ 1 row gap-y-0 gap-x-2 <md:col">
<WidgetStage
flex-1 min-w="1/2"
:paused="paused"
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
<MobileInteractiveArea v-if="isMobile" @settings-open="handleSettingsOpen" />
</div>
</div>
</CustomizedBackground>
</template>
<route lang="yaml">
+279
View File
@@ -0,0 +1,279 @@
import type { BackgroundOption } from '@proj-airi/stage-ui/components'
import type { Ref, ShallowRef } from 'vue'
import localforage from 'localforage'
import { useLocalStorage, useObjectUrl } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, markRaw, onScopeDispose, ref, shallowRef } from 'vue'
import ChromaticWavePreview from '../components/Backgrounds/ChromaticWavePreview.vue'
export enum BackgroundKind {
Wave = 'wave',
Image = 'image',
}
export interface BackgroundItem extends BackgroundOption {
kind: BackgroundKind
importedAt?: number
}
type PersistedBackgroundItem = Omit<BackgroundItem, 'file'> & {
file?: Blob
}
export const useBackgroundStore = defineStore('background', () => {
// TODO: STORAGE_PREFIX used with multiple less maintainable `localforage` and `key.startsWith(...)` call that creates complexity.
const STORAGE_PREFIX = 'background-'
const presets: BackgroundItem[] = [
{
id: 'colorful-wave',
label: 'Colorful Wave',
description: 'Animated wave on cross grid',
kind: BackgroundKind.Wave,
component: markRaw(ChromaticWavePreview),
},
]
const options = ref<BackgroundItem[]>([...presets])
const loading = ref(false)
const selectedId = useLocalStorage<string>('settings/theme/background/selected-id', options.value[0]?.id)
const sampledColor = useLocalStorage<string>('settings/theme/background/sampled-color', '')
const selectedOption = computed(() => options.value.find(option => option.id === selectedId.value) ?? options.value[0])
const blobRefs = new Map<string, ShallowRef<Blob | undefined>>()
const urlRefs = new Map<string, Readonly<Ref<string | undefined>>>()
function ensureObjectUrl(id: string, blob: Blob) {
let blobRef = blobRefs.get(id)
let urlRef = urlRefs.get(id)
if (!blobRef || !urlRef) {
blobRef = shallowRef<Blob | undefined>(blob)
blobRefs.set(id, blobRef)
urlRef = useObjectUrl(blobRef)
urlRefs.set(id, urlRef)
}
if (blobRef.value !== blob)
blobRef.value = blob
return urlRef!.value!
}
onScopeDispose(() => {
blobRefs.clear()
urlRefs.clear()
})
async function migrateDataUrlToBlob(key: string, val: PersistedBackgroundItem, dataUrl: string) {
try {
const blob = await (await fetch(dataUrl)).blob()
const objectUrl = ensureObjectUrl(key, blob)
const existing = options.value.find(o => o.id === key)
if (existing) {
existing.src = objectUrl
existing.file = undefined
}
const payload: PersistedBackgroundItem = {
...val,
src: undefined,
file: blob,
}
await localforage.setItem<PersistedBackgroundItem>(key, payload)
}
catch (error) {
console.error('Failed to migrate background data URL to Blob', error)
}
}
function setSelection(option: BackgroundItem, color?: string) {
selectedId.value = option.id
if (color)
sampledColor.value = color
}
async function applyPickerSelection(payload: { option: BackgroundOption, color?: string }) {
const kind = payload.option.kind === BackgroundKind.Wave
? BackgroundKind.Wave
: payload.option.kind === BackgroundKind.Image
? BackgroundKind.Image
: BackgroundKind.Image
const selection: BackgroundItem = {
...payload.option,
kind,
}
const saved = await addOption(selection)
setSelection(saved, payload.color)
return saved
}
async function loadFromIndexedDb() {
if (loading.value)
return
loading.value = true
const stored: BackgroundItem[] = []
try {
await localforage.iterate<PersistedBackgroundItem, void>((val, key) => {
if (!key.startsWith(STORAGE_PREFIX))
return
const storedBlob = val.file instanceof Blob ? val.file : undefined
const storedSrc = typeof val.src === 'string' && val.src.length > 0 ? val.src : undefined
if (storedBlob) {
const objectUrl = ensureObjectUrl(key, storedBlob)
stored.push({
...val,
id: key,
kind: BackgroundKind.Image,
src: objectUrl,
file: undefined,
component: undefined,
removable: true,
})
return
}
if (storedSrc) {
stored.push({
...val,
id: key,
kind: BackgroundKind.Image,
src: storedSrc,
file: undefined,
component: undefined,
removable: true,
})
if (storedSrc.startsWith('data:')) {
setTimeout(() => {
void migrateDataUrlToBlob(key, val, storedSrc)
}, 0)
}
}
})
}
catch (error) {
console.error('Failed to load backgrounds from IndexedDB', error)
}
options.value = [...presets, ...stored].sort((a, b) => (b.importedAt ?? 0) - (a.importedAt ?? 0))
loading.value = false
}
void loadFromIndexedDb()
async function addOption(option: BackgroundItem): Promise<BackgroundItem> {
const normalizedId = option.file ? (option.id.startsWith(STORAGE_PREFIX) ? option.id : `${STORAGE_PREFIX}${option.id}`) : option.id
const hasUploadedFile = option.file instanceof Blob
const storedBlob = hasUploadedFile ? option.file : undefined
const src = storedBlob
? ensureObjectUrl(normalizedId, storedBlob)
: option.src
const normalizedOption: BackgroundItem = {
...option,
id: normalizedId,
kind: option.kind ?? BackgroundKind.Image,
component: option.component ? markRaw(option.component) : option.component,
src,
importedAt: option.importedAt ?? Date.now(),
blur: option.blur,
file: undefined,
removable: true,
}
const existing = options.value.find(o => o.id === normalizedId)
if (existing) {
Object.assign(existing, normalizedOption)
}
else {
options.value.push(normalizedOption)
}
selectedId.value = normalizedId
if (hasUploadedFile && storedBlob) {
const payload: PersistedBackgroundItem = {
...normalizedOption,
// ensure we store under prefix for consistency
id: normalizedId.startsWith(STORAGE_PREFIX) ? normalizedId : `${STORAGE_PREFIX}${normalizedId}`,
src: undefined,
file: storedBlob,
removable: true,
}
try {
await localforage.setItem<PersistedBackgroundItem>(payload.id, payload)
}
catch (error) {
console.error('Failed to persist background', error)
}
}
return normalizedOption
}
async function removeOption(optionId: string) {
const optionIndex = options.value.findIndex(o => o.id === optionId)
if (optionIndex === -1)
return
const option = options.value[optionIndex]
// Remove from localforage
try {
if (option.id.startsWith(STORAGE_PREFIX)) {
await localforage.removeItem(option.id)
}
}
catch (error) {
console.error('Failed to remove background from storage', error)
}
const blobRef = blobRefs.get(optionId)
if (blobRef)
blobRef.value = undefined
blobRefs.delete(optionId)
urlRefs.delete(optionId)
// Remove from list
options.value.splice(optionIndex, 1)
// If selected, fallback to first available option
if (selectedId.value === optionId) {
const fallback = options.value[0]
if (fallback) {
selectedId.value = fallback.id
}
}
}
function setSampledColor(color?: string) {
if (color)
sampledColor.value = color
}
return {
options,
selectedId,
selectedOption,
sampledColor,
loading,
loadFromIndexedDb,
addOption,
removeOption,
setSelection,
applyPickerSelection,
setSampledColor,
}
})
@@ -0,0 +1,14 @@
<script setup lang="ts">
const props = defineProps<{
color?: string
}>()
</script>
<template>
<div
class="theme-overlay pointer-events-none absolute inset-x-0 top-0 z-1 h-24 lg:h-32"
:style="{
background: props.color ? `linear-gradient(to bottom, ${props.color} 0%, transparent 100%)` : undefined,
}"
/>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BackgroundOption } from './types'
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, VisuallyHidden } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } from 'vaul-vue'
import { onMounted } from 'vue'
import BackgroundPicker from './background-picker.vue'
const props = defineProps<{
options: BackgroundOption[]
}>()
const emit = defineEmits<{
(e: 'apply', payload: { option: BackgroundOption, color?: string }): void
(e: 'remove', option: BackgroundOption): void
}>()
const showDialog = defineModel({ type: Boolean, default: false, required: false })
const selected = defineModel<BackgroundOption | undefined>('selected', { default: undefined })
const isDesktop = useMediaQuery('(min-width: 768px)')
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
</script>
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-[9999] max-h-[85vh] max-w-5xl w-[92dvw] flex flex-col transform overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<VisuallyHidden>
<DialogTitle>Background Picker</DialogTitle>
</VisuallyHidden>
<BackgroundPicker
v-model="selected"
:options="props.options"
allow-upload
class="min-h-0 flex-1"
@apply="payload => { emit('apply', payload); showDialog = false }"
@import="payload => emit('apply', payload)"
@remove="option => emit('remove', option)"
/>
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
<DrawerPortal>
<DrawerOverlay class="fixed inset-0" />
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[85%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md dark:bg-neutral-900/95" :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }">
<DrawerHandle />
<BackgroundPicker
v-model="selected"
:options="props.options"
allow-upload
class="min-h-0 flex-1"
@apply="payload => { emit('apply', payload); showDialog = false }"
@import="payload => emit('apply', payload)"
@remove="option => emit('remove', option)"
/>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
@@ -0,0 +1,339 @@
<script setup lang="ts">
import type { Ref, ShallowRef } from 'vue'
import type { BackgroundOption } from './types'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { BasicInputFile } from '@proj-airi/ui'
import { useObjectUrl } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { computed, nextTick, onScopeDispose, ref, shallowRef, watch } from 'vue'
import ThemeOverlay from '../../../ThemeOverlay.vue'
import { colorFromElement, patchThemeSamplingHtml2CanvasClone } from '../../../../libs'
const props = withDefaults(defineProps<{
options: BackgroundOption[]
allowUpload?: boolean
idPrefix?: string
}>(), {
allowUpload: false,
idPrefix: 'background-',
})
const emit = defineEmits<{
(e: 'apply', payload: { option: BackgroundOption, color?: string }): void
(e: 'import', payload: { option: BackgroundOption, color?: string }): void
(e: 'change', payload: { option: BackgroundOption | undefined }): void
(e: 'remove', option: BackgroundOption): void
}>()
const { themeColorsHue } = useSettings()
const modelValue = defineModel<BackgroundOption | undefined>({ default: undefined })
const previewRef = ref<HTMLElement | null>(null)
const uploadingFiles = ref<File[]>([])
const customOptions = ref<BackgroundOption[]>([])
const blobRefs = new Map<string, ShallowRef<Blob | undefined>>()
const urlRefs = new Map<string, Readonly<Ref<string | undefined>>>()
const selectedId = ref<string | undefined>(modelValue.value?.id)
const busy = ref(false)
const mergedOptions = computed(() => {
const propIds = new Set(props.options.map(o => o.id))
return [...props.options, ...customOptions.value.filter(o => !propIds.has(o.id))]
})
const selectedOption = computed(() => mergedOptions.value.find(option => option.id === selectedId.value))
const enableBlur = ref(modelValue.value?.blur ?? false)
const previewColor = ref<string | undefined>(undefined)
watch(() => modelValue.value?.id, (id) => {
if (id === undefined)
return
enableBlur.value = modelValue.value?.blur ?? false
})
function ensureObjectUrl(id: string, file: File) {
let blobRef = blobRefs.get(id)
let urlRef = urlRefs.get(id)
if (!blobRef || !urlRef) {
blobRef = shallowRef<Blob | undefined>(file)
blobRefs.set(id, blobRef)
urlRef = useObjectUrl(blobRef)
urlRefs.set(id, urlRef)
}
if (blobRef.value !== file)
blobRef.value = file
return urlRef!.value!
}
async function waitForPreviewReady() {
await nextTick()
const image = previewRef.value?.querySelector('img')
if (image && !image.complete) {
await Promise.race([
new Promise<void>((resolve, reject) => {
image.addEventListener('load', () => resolve(), { once: true })
image.addEventListener('error', () => reject(new Error('Preview image failed to load')), { once: true })
}),
new Promise<void>(resolve => setTimeout(resolve, 3000)), // 3s timeout safety
])
}
}
onScopeDispose(() => {
blobRefs.clear()
urlRefs.clear()
})
watch(modelValue, (value) => {
selectedId.value = value?.id
})
let previewSamplingToken = 0
watch(selectedOption, async (option) => {
const token = ++previewSamplingToken
previewColor.value = undefined
emit('change', { option })
if (option?.kind === 'wave') {
previewColor.value = themeColorsHue.toString()
}
else if (option) {
await waitForPreviewReady()
const result = await colorFromElement(previewRef.value!, {
mode: 'html2canvas',
html2canvas: {
region: {
x: 0,
y: 0,
width: previewRef.value!.offsetWidth,
height: Math.min(120, previewRef.value!.offsetHeight),
},
scale: 0.2, // Use a small scale for faster preview sampling
backgroundColor: null,
allowTaint: true,
useCORS: true,
onclone: patchThemeSamplingHtml2CanvasClone,
},
})
if (token === previewSamplingToken)
previewColor.value = result.html2canvas?.average
}
else {
previewColor.value = undefined
}
})
function getPreviewSrc(option?: BackgroundOption) {
if (!option)
return ''
if (option.file) {
return ensureObjectUrl(option.id, option.file)
}
return option.src ?? ''
}
async function handleFilesChange(files: File[]) {
for (const file of files) {
const option: BackgroundOption = {
id: `${props.idPrefix}custom-${nanoid(6)}`,
label: file.name || 'Custom Background',
file,
kind: 'image',
}
customOptions.value.push(option)
selectedId.value = option.id
// Auto-persist: wait for preview to be ready and trigger apply logic
await nextTick()
await applySelection(true)
}
}
watch(uploadingFiles, (files) => {
handleFilesChange(files ?? [])
})
async function applySelection(isImport = false) {
if (!selectedOption.value)
return
// If we are already sampling (from the watcher), wait for it or use the current previewColor
// For auto-import, we might want to wait a bit to get a real color, or just use what we have.
busy.value = true
try {
if (selectedOption.value.kind === 'wave') {
const color = themeColorsHue.toString()
const payload = { option: { ...selectedOption.value, blur: enableBlur.value }, color }
if (isImport)
(emit as any)('import', payload)
else
(emit as any)('apply', payload)
return
}
// For standard images, we use the color already being sampled by the watcher.
// If it's not ready yet, we wait a bit for it.
if (!previewColor.value) {
await waitForPreviewReady()
// Give it a tiny bit more time for the watcher's sampling to finish
if (!previewColor.value)
await new Promise(resolve => setTimeout(resolve, 300))
}
const payload = { option: { ...selectedOption.value, blur: enableBlur.value }, color: previewColor.value }
if (isImport)
(emit as any)('import', payload)
else
(emit as any)('apply', payload)
}
catch (error) {
console.error('Background application failed:', error)
}
finally {
busy.value = false
}
}
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="flex-1 overflow-x-hidden overflow-y-auto overscroll-contain p-1 scrollbar-none">
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<button
v-for="option in mergedOptions"
:key="option.id"
type="button"
class="background-option group relative border-2 rounded-xl bg-neutral-100/80 p-2 text-left transition-colors dark:bg-neutral-900/80"
:class="[option.id === selectedId ? 'selected border-primary-500/80 shadow-primary-500/10 shadow-lg' : 'border-neutral-200 dark:border-neutral-800']"
@click="selectedId = option.id"
>
<div class="aspect-video w-full overflow-hidden border border-neutral-200 rounded-lg bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-800/70">
<component
:is="option.component"
v-if="option.component"
class="h-full w-full"
/>
<img
v-else-if="getPreviewSrc(option)"
:src="getPreviewSrc(option)"
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
>
<div v-else class="h-full w-full flex items-center justify-center text-sm text-neutral-500 dark:text-neutral-400">
No preview
</div>
</div>
<div class="mt-2 flex flex-col gap-1">
<span class="text-base text-neutral-800 font-medium dark:text-neutral-100">{{ option.label }}</span>
<span v-if="option.description" class="text-xs text-neutral-500 dark:text-neutral-400">
{{ option.description }}
</span>
</div>
<div
v-if="option.removable"
class="trash-button absolute right-2 top-2 z-10 flex cursor-pointer items-center justify-center rounded-full bg-neutral-200/50 p-1 text-neutral-600 backdrop-blur-md transition-opacity dark:bg-neutral-800/50"
:class="[option.id === selectedId ? 'opacity-100' : 'opacity-0']"
title="Remove background"
@click.stop="emit('remove', option)"
>
<div class="i-solar:trash-bin-trash-bold h-4 w-4" />
</div>
</button>
</div>
<div v-if="allowUpload" class="flex flex-wrap gap-2">
<BasicInputFile v-model="uploadingFiles" class="cursor-pointer">
<div class="upload-button flex items-center gap-2 border border-neutral-300 rounded-lg border-dashed px-3 py-2 text-sm text-neutral-600 transition-colors dark:border-neutral-700 dark:text-neutral-300">
<div i-solar:add-square-linear />
<span>Add custom background</span>
</div>
</BasicInputFile>
</div>
<div class="border border-neutral-200 rounded-xl bg-neutral-50 p-3 dark:border-neutral-800 dark:bg-neutral-900/70">
<p class="mb-2 text-sm text-neutral-600 dark:text-neutral-300">
Preview
</p>
<label
v-if="selectedOption?.kind !== 'wave'"
class="flex items-center gap-2 pb-2 text-sm text-neutral-700 dark:text-neutral-200"
>
<input v-model="enableBlur" type="checkbox" class="accent-primary-500">
<span>Blur</span>
</label>
<div
ref="previewRef"
class="relative h-48 overflow-hidden border border-neutral-200 rounded-xl bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800"
>
<div
class="h-full w-full transition-all duration-300"
:class="[(enableBlur && selectedOption?.kind !== 'wave') ? 'blur-md scale-110' : '']"
>
<component
:is="selectedOption?.component"
v-if="selectedOption?.component"
class="h-full w-full"
/>
<img
v-else-if="getPreviewSrc(selectedOption)"
:src="getPreviewSrc(selectedOption)"
class="h-full w-full object-cover"
>
<div v-else class="h-full w-full flex items-center justify-center text-neutral-500 dark:text-neutral-400">
Select a background
</div>
</div>
<ThemeOverlay v-if="(selectedOption as any)?.kind !== 'wave'" :color="previewColor" />
</div>
</div>
</div>
</div>
<div class="flex justify-end bg-inherit pt-4">
<button
class="apply-button rounded-lg bg-primary-500 px-4 py-2 text-sm text-white font-medium shadow transition-transform disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!selectedOption || busy"
@click="() => applySelection()"
>
{{ busy ? 'Sampling...' : 'Use this background' }}
</button>
</div>
</div>
</template>
<style scoped>
@media (hover: hover) {
.background-option:hover:not(.selected) {
--at-apply: border-primary-400/80;
}
.background-option:hover .trash-button {
--at-apply: opacity-100;
}
.trash-button:hover {
--at-apply: bg-red-500 text-white;
}
.upload-button:hover {
--at-apply: border-primary-400 text-primary-500 dark:border-primary-400 dark:text-primary-400;
}
.apply-button:hover:not(:disabled) {
--at-apply: -translate-y-0.5;
}
}
</style>
@@ -0,0 +1,3 @@
export { default as BackgroundPickerDialog } from './background-picker-dialog.vue'
export { default as BackgroundPicker } from './background-picker.vue'
export * from './types'
@@ -0,0 +1,31 @@
import type { Component } from 'vue'
export interface BackgroundOption {
id: string
label: string
description?: string
/**
* Optional kind discriminator forwarded to the consumer.
*/
kind?: string
/**
* File for custom uploads; used to derive object URLs and for persistence.
*/
file?: File
/**
* Optional image source used in preview and selection.
*/
src?: string
/**
* Apply blur on render.
*/
blur?: boolean
/**
* Optional component renderer when the background is procedural/pattern-based.
*/
component?: Component
/**
* Whether the background can be removed.
*/
removable?: boolean
}
@@ -1,4 +1,5 @@
export * from './about'
export { default as AboutDialogWithContent } from './about.vue'
export * from './audio-input'
export * from './background-picker'
export * from './onboarding'
@@ -394,7 +394,10 @@ function updateDropShadowFilter() {
return
}
const color = getComputedStyle(dropShadowColorComputer.value!).backgroundColor
if (!dropShadowColorComputer.value)
return
const color = getComputedStyle(dropShadowColorComputer.value).backgroundColor
dropShadowFilter.value.color = Number(formatHex(color)!.replace('#', '0x'))
model.value.filters = [dropShadowFilter.value]
}
@@ -7,6 +7,24 @@ import { Vibrant } from 'node-vibrant/browser'
export type ColorFromElementMode = 'vibrant' | 'html2canvas' | 'both'
export function patchThemeSamplingHtml2CanvasClone(doc: Document) {
if (!('document' in globalThis) || globalThis.document == null)
return
if (!('getComputedStyle' in globalThis))
return
doc.querySelectorAll('.theme-overlay').forEach((overlay) => {
(overlay as HTMLElement).style.display = 'none'
})
doc.querySelectorAll('.colored-area').forEach((wave) => {
const waveEl = wave as HTMLElement
const isDark = document.documentElement.classList.contains('dark')
const hue = getComputedStyle(document.documentElement).getPropertyValue('--chromatic-hue') || '200'
waveEl.style.background = isDark ? `hsl(${hue} 60% 32%)` : `hsl(${hue} 75% 78%)`
})
}
export interface ColorFromElementOptions {
/**
* Which extraction pipeline to run. Use `'both'` to mirror the devtools view.
@@ -55,6 +73,7 @@ export interface ColorFromElementOptions {
useCORS?: boolean
backgroundColor?: string | null
logging?: boolean
onclone?: (doc: Document) => void
}
}
@@ -145,6 +164,7 @@ async function extractWithHtml2Canvas(element: HTMLElement, options: ColorFromEl
height: captureHeight,
x: region.x,
y: region.y,
onclone: options?.onclone,
})
const ctx = canvas.getContext('2d')
+3 -16
View File
@@ -1959,7 +1959,7 @@ importers:
version: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-vue-devtools:
specifier: ^8.0.5
version: 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
version: 8.0.5(@nuxt/kit@4.0.3(magicast@0.5.1))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
@@ -1993,7 +1993,7 @@ importers:
version: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-vue-devtools:
specifier: ^8.0.5
version: 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
version: 8.0.5(@nuxt/kit@4.0.3(magicast@0.5.1))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
@@ -33069,6 +33069,7 @@ snapshots:
'@nuxt/kit': 3.20.2(magicast@0.3.5)
transitivePeerDependencies:
- supports-color
optional: true
vite-plugin-inspect@11.3.3(@nuxt/kit@3.20.2(magicast@0.3.5))(vite@6.4.1(@types/node@24.10.4)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
@@ -33147,20 +33148,6 @@ snapshots:
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.25(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(@nuxt/kit@3.20.2(magicast@0.3.5))(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(rolldown-vite@7.2.11(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@babel/core': 7.28.5