feat(stage-ui,stage-web): custom chat background (#825)
This commit is contained in:
+8
-1
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
+65
@@ -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>
|
||||
+339
@@ -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')
|
||||
|
||||
Generated
+3
-16
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user