fix(ui): standardize scrollable areas (#2399)
This commit is contained in:
@@ -30,7 +30,7 @@ async function extractColorsFromModel() {
|
||||
<div flex class="relative h-full flex-col-reverse md:flex-row">
|
||||
<ModelSettings
|
||||
ref="modelSettingsRef"
|
||||
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
|
||||
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh relative"
|
||||
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
|
||||
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
|
||||
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { artistryTestComfyUIConnection, isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { useArtistryStore } from '@proj-airi/stage-ui/stores/modules/artistry'
|
||||
import { Button, FieldInput, GhostButton } from '@proj-airi/ui'
|
||||
import { Button, FieldInput, GhostButton, ScrollableArea } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -493,34 +493,36 @@ function copyToClipboard(text: string) {
|
||||
{{ t('settings.pages.providers.provider.comfyui.settings.upload.select_fields') }}
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 flex flex-col gap-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="node in parsedWorkflow.nodes"
|
||||
:key="node.id"
|
||||
class="border border-neutral-200 rounded-lg p-3 dark:border-neutral-700"
|
||||
>
|
||||
<div class="mb-1 text-sm text-neutral-700 font-medium dark:text-neutral-300">
|
||||
{{ node.title }}
|
||||
<span class="ml-1 text-xs text-neutral-400">({{ node.type }})</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pl-3">
|
||||
<label
|
||||
v-for="(val, field) in node.inputs"
|
||||
:key="String(field)"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-indigo-500"
|
||||
:checked="isFieldSelected(node.title, String(field))"
|
||||
@change="toggleField(node.title, String(field))"
|
||||
<ScrollableArea :class="['max-h-80']">
|
||||
<div :class="['flex flex-col gap-2']">
|
||||
<div
|
||||
v-for="node in parsedWorkflow.nodes"
|
||||
:key="node.id"
|
||||
class="border border-neutral-200 rounded-lg p-3 dark:border-neutral-700"
|
||||
>
|
||||
<div class="mb-1 text-sm text-neutral-700 font-medium dark:text-neutral-300">
|
||||
{{ node.title }}
|
||||
<span class="ml-1 text-xs text-neutral-400">({{ node.type }})</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pl-3">
|
||||
<label
|
||||
v-for="(val, field) in node.inputs"
|
||||
:key="String(field)"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span class="text-neutral-600 font-mono dark:text-neutral-400">{{ field }}</span>
|
||||
<span class="truncate text-neutral-400 dark:text-neutral-500">= {{ formatValue(val) }}</span>
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-indigo-500"
|
||||
:checked="isFieldSelected(node.title, String(field))"
|
||||
@change="toggleField(node.title, String(field))"
|
||||
>
|
||||
<span class="text-neutral-600 font-mono dark:text-neutral-400">{{ field }}</span>
|
||||
<span class="truncate text-neutral-400 dark:text-neutral-500">= {{ formatValue(val) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between">
|
||||
<span class="text-xs text-neutral-400">{{ t('settings.pages.providers.provider.comfyui.settings.upload.fields_exposed', { count: totalExposed }) }}</span>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { ScrollableArea } from '@proj-airi/ui'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
bg="neutral-100 dark:neutral-800"
|
||||
overflow-y-scroll
|
||||
<ScrollableArea
|
||||
:class="[
|
||||
'bg-neutral-100 dark:bg-neutral-800',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ScrollableArea } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useJournalPreviewStore } from '../../../stores/journal-preview'
|
||||
@@ -49,12 +50,16 @@ const { closePreview, downloadImage } = store
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-if="previewModal.type === 'text'" class="max-h-[60vh] overflow-y-auto px-4 py-3">
|
||||
<ScrollableArea
|
||||
v-if="previewModal.type === 'text'"
|
||||
:class="['max-h-[60vh]']"
|
||||
:viewport-class="['px-4 py-3']"
|
||||
>
|
||||
<MarkdownRenderer
|
||||
:content="previewModal.content"
|
||||
class="max-w-none prose prose-sm dark:prose-invert"
|
||||
/>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
<div v-else class="flex items-center justify-center p-2">
|
||||
<img :src="previewModal.content" class="max-h-[60vh] w-auto rounded-lg object-contain">
|
||||
</div>
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import { defineComponent, shallowRef } from 'vue'
|
||||
|
||||
import ChatHistoryScrollContainer from './chat-history-scroll-container.vue'
|
||||
|
||||
describe('chat history scroll container', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Reka mounts the desktop scrollbar only while the viewport scrolls, but it
|
||||
// appears and disappears without the overlay fade used by the reference UI.
|
||||
// We add the animation only to desktop chat so other scroll areas keep their
|
||||
// existing presentation.
|
||||
// Reference: https://kingsora.github.io/OverlayScrollbars/example/vue/
|
||||
it('shows the desktop scrollbar only while the message viewport scrolls', async () => {
|
||||
const container = shallowRef<InstanceType<typeof ChatHistoryScrollContainer>>()
|
||||
const TestHost = defineComponent({
|
||||
components: { ChatHistoryScrollContainer },
|
||||
setup: () => ({ container }),
|
||||
template: `
|
||||
<ChatHistoryScrollContainer
|
||||
ref="container"
|
||||
variant="desktop"
|
||||
style="height: 120px; width: 240px"
|
||||
>
|
||||
<div style="height: 240px">Long desktop history</div>
|
||||
</ChatHistoryScrollContainer>
|
||||
`,
|
||||
})
|
||||
|
||||
const screen = await render(TestHost)
|
||||
const viewport = screen.container.querySelector<HTMLElement>('.chat-history-list')
|
||||
|
||||
expect(viewport?.matches('[data-reka-scroll-area-viewport]')).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
|
||||
|
||||
if (!viewport)
|
||||
throw new Error('Expected a desktop chat history viewport.')
|
||||
|
||||
viewport.scrollTop = 40
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).not.toBeNull()
|
||||
})
|
||||
|
||||
const visibleScrollbar = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
|
||||
if (!visibleScrollbar)
|
||||
throw new Error('Expected the desktop chat scrollbar while scrolling.')
|
||||
|
||||
expect(visibleScrollbar.dataset.state).toBe('visible')
|
||||
expect(getComputedStyle(visibleScrollbar).animationName).toContain('chat-history-scrollbar-fade-in')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const hidingScrollbar = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
|
||||
expect(hidingScrollbar?.dataset.state).toBe('hidden')
|
||||
expect(getComputedStyle(hidingScrollbar!).animationName).toContain('chat-history-scrollbar-fade-out')
|
||||
}, { interval: 20, timeout: 1200 })
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
|
||||
}, { timeout: 1200 })
|
||||
|
||||
expect(container.value?.viewport).toBe(viewport)
|
||||
expect(screen.container.querySelectorAll('.chat-history-list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('exposes a native overflow viewport for mobile chat', async () => {
|
||||
const container = shallowRef<InstanceType<typeof ChatHistoryScrollContainer>>()
|
||||
const TestHost = defineComponent({
|
||||
components: { ChatHistoryScrollContainer },
|
||||
setup: () => ({ container }),
|
||||
template: `
|
||||
<ChatHistoryScrollContainer
|
||||
ref="container"
|
||||
variant="mobile"
|
||||
style="height: 120px; width: 240px"
|
||||
>
|
||||
<div style="height: 240px">Long mobile history</div>
|
||||
</ChatHistoryScrollContainer>
|
||||
`,
|
||||
})
|
||||
|
||||
const screen = await render(TestHost)
|
||||
const viewport = screen.container.querySelector<HTMLElement>('.chat-history-list')
|
||||
|
||||
expect(viewport).not.toBeNull()
|
||||
if (!viewport)
|
||||
throw new Error('Expected a mobile chat history viewport.')
|
||||
|
||||
expect(viewport.matches('[data-reka-scroll-area-viewport]')).toBe(false)
|
||||
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
|
||||
expect(getComputedStyle(viewport).overflowY).toBe('auto')
|
||||
expect(container.value?.viewport).toBe(viewport)
|
||||
})
|
||||
})
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { ScrollableArea } from '@proj-airi/ui'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'desktop' | 'mobile'
|
||||
}>()
|
||||
|
||||
const desktopAreaRef = useTemplateRef<InstanceType<typeof ScrollableArea>>('desktop-area')
|
||||
const mobileViewportRef = useTemplateRef<HTMLElement>('mobile-viewport')
|
||||
const viewport = computed<HTMLElement | null>(() => props.variant === 'desktop'
|
||||
? desktopAreaRef.value?.viewport ?? null
|
||||
: mobileViewportRef.value)
|
||||
|
||||
defineExpose({
|
||||
viewport,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollableArea
|
||||
v-if="props.variant === 'desktop'"
|
||||
ref="desktop-area"
|
||||
v-bind="$attrs"
|
||||
type="scroll"
|
||||
:viewport-class="[
|
||||
'chat-history-list',
|
||||
]"
|
||||
:class="[
|
||||
'chat-history-scroll-area h-full w-full',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</ScrollableArea>
|
||||
|
||||
<div
|
||||
v-else
|
||||
ref="mobile-viewport"
|
||||
v-bind="$attrs"
|
||||
:class="[
|
||||
'chat-history-list chat-history-list--mobile',
|
||||
'relative h-full w-full overflow-y-auto rounded-xl',
|
||||
'<sm:px-2 <sm:py-2',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-history-scroll-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-history-scroll-area :deep(.chat-history-list) {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical) {
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical[data-state='visible']) {
|
||||
animation: chat-history-scrollbar-fade-in 150ms ease-out both;
|
||||
}
|
||||
|
||||
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical[data-state='hidden']) {
|
||||
animation: chat-history-scrollbar-fade-out 180ms ease-in both;
|
||||
}
|
||||
|
||||
@keyframes chat-history-scrollbar-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chat-history-scrollbar-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-history-scroll-area :deep(.scrollable-area-scrollbar--vertical) {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-history-list--mobile {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.chat-history-list--mobile :deep(.chat-message-item-container) {
|
||||
--chat-top-fade-transparent-stop: -1px;
|
||||
--chat-top-fade-opaque-stop: 0px;
|
||||
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent var(--chat-top-fade-transparent-stop),
|
||||
black var(--chat-top-fade-opaque-stop)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent var(--chat-top-fade-transparent-stop),
|
||||
black var(--chat-top-fade-opaque-stop)
|
||||
);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,81 @@ function createEnglishI18n() {
|
||||
}
|
||||
|
||||
describe('chat history', () => {
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The desktop chat needs the styled Reka viewport, but forcing its track to
|
||||
// stay mounted leaves an inert scrollbar visible when short content cannot scroll.
|
||||
// Reka's automatic visibility must own the track without changing the viewport.
|
||||
it('uses a Reka viewport without showing a scrollbar for a short desktop chat', async () => {
|
||||
const screen = await render(ChatHistory, {
|
||||
props: {
|
||||
messages: [{ id: 'user-1', role: 'user', content: 'Hello' }],
|
||||
style: 'height: 240px; width: 320px; overflow-y: auto;',
|
||||
},
|
||||
global: {
|
||||
plugins: [createEnglishI18n()],
|
||||
},
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('.chat-history-list')).not.toBeNull()
|
||||
})
|
||||
|
||||
const history = screen.container.querySelector<HTMLElement>('.chat-history-list')
|
||||
expect(history).not.toBeNull()
|
||||
if (!history)
|
||||
throw new Error('Expected a chat history viewport.')
|
||||
|
||||
const track = screen.container.querySelector<HTMLElement>('.scrollable-area-scrollbar--vertical')
|
||||
expect(history.matches('[data-reka-scroll-area-viewport]')).toBe(true)
|
||||
expect(track === null || track.dataset.state === 'hidden').toBe(true)
|
||||
})
|
||||
|
||||
it('virtualizes long desktop history inside one Reka viewport', async () => {
|
||||
const messages: ChatHistoryItem[] = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `desktop-user-${index}`,
|
||||
role: 'user',
|
||||
content: `Desktop message ${index} `.repeat(index % 6 + 1),
|
||||
createdAt: index,
|
||||
}))
|
||||
|
||||
const screen = await render(ChatHistory, {
|
||||
props: {
|
||||
messages,
|
||||
style: 'height: 240px; width: 320px;',
|
||||
},
|
||||
global: {
|
||||
plugins: [createEnglishI18n()],
|
||||
},
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const renderedMessages = screen.container.querySelectorAll('.chat-message-item')
|
||||
expect(renderedMessages.length).toBeGreaterThan(0)
|
||||
expect(renderedMessages.length).toBeLessThan(messages.length)
|
||||
})
|
||||
|
||||
const history = screen.container.querySelector<HTMLElement>('.chat-history-list')
|
||||
expect(history).not.toBeNull()
|
||||
if (!history)
|
||||
throw new Error('Expected a desktop chat history viewport.')
|
||||
|
||||
expect(history.matches('[data-reka-scroll-area-viewport]')).toBe(true)
|
||||
expect(screen.container.querySelectorAll('.chat-history-list')).toHaveLength(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).not.toBeNull()
|
||||
expect(history.scrollHeight).toBeGreaterThan(history.clientHeight)
|
||||
expect(screen.container.textContent).toContain('Desktop message 99')
|
||||
})
|
||||
|
||||
history.scrollTop = 0
|
||||
history.dispatchEvent(new Event('scroll'))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.container.textContent).toContain('Desktop message 0')
|
||||
})
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// Virtua keeps its internal content root at least as tall as the viewport, but
|
||||
@@ -112,6 +187,14 @@ describe('chat history', () => {
|
||||
if (!history)
|
||||
throw new Error('Expected a chat history viewport.')
|
||||
|
||||
expect(history.matches('[data-reka-scroll-area-viewport]')).toBe(false)
|
||||
expect(screen.container.querySelectorAll('.chat-history-list')).toHaveLength(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(history.scrollHeight).toBeGreaterThan(history.clientHeight)
|
||||
})
|
||||
expect(getComputedStyle(history).overflowY).toBe('auto')
|
||||
expect(screen.container.querySelector('.scrollable-area-scrollbar--vertical')).toBeNull()
|
||||
|
||||
history.scrollTop = 0
|
||||
history.dispatchEvent(new Event('scroll'))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ChatAssistantItem from './assistant-item.vue'
|
||||
import ChatHistoryScrollContainer from './chat-history-scroll-container.vue'
|
||||
import ChatErrorItem from './error-item.vue'
|
||||
import ChatHistoryMessageFrame from './history-message-frame.vue'
|
||||
import ChatUserItem from './user-item.vue'
|
||||
@@ -18,6 +19,10 @@ import { useChatHistoryTopFade } from '../composables/use-chat-history-top-fade'
|
||||
import { useVirtualizerBottomAlignment, useVirtualizerScroll } from '../composables/use-virtualizer-scroll'
|
||||
import { getChatHistoryItemKey } from '../utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
messages: ChatHistoryItem[]
|
||||
streamingMessage?: StreamingAssistantMessage
|
||||
@@ -44,7 +49,8 @@ const emit = defineEmits<{
|
||||
/** Keeps about two mobile viewports ready so fast flicks do not expose an unmounted gap. */
|
||||
const CHAT_HISTORY_OVERSCAN = 600
|
||||
|
||||
const chatHistoryRef = useTemplateRef<HTMLDivElement>('chatHistory')
|
||||
const scrollContainerRef = useTemplateRef<InstanceType<typeof ChatHistoryScrollContainer>>('scroll-container')
|
||||
const chatHistoryRef = computed<HTMLElement | null>(() => scrollContainerRef.value?.viewport ?? null)
|
||||
const virtualizerRef = useTemplateRef<VirtualizerHandle>('virtualizer')
|
||||
const { scrollToIndex } = useVirtualizerScroll(virtualizerRef)
|
||||
|
||||
@@ -134,20 +140,17 @@ function emitToolCallRerun(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="chatHistory"
|
||||
:class="[
|
||||
'chat-history-list',
|
||||
'relative h-full w-full overflow-y-auto rounded-xl',
|
||||
'<sm:px-2 <sm:py-2',
|
||||
variant === 'mobile' ? 'chat-history-list--mobile' : '',
|
||||
]"
|
||||
<ChatHistoryScrollContainer
|
||||
ref="scroll-container"
|
||||
v-bind="$attrs"
|
||||
:variant="variant"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualizer"
|
||||
:data="renderMessages"
|
||||
:buffer-size="CHAT_HISTORY_OVERSCAN"
|
||||
:item-props="itemProps"
|
||||
:scroll-ref="chatHistoryRef ?? undefined"
|
||||
>
|
||||
<template #default="{ item: message, index }">
|
||||
<ChatHistoryMessageFrame
|
||||
@@ -192,25 +195,5 @@ function emitToolCallRerun(
|
||||
</ChatHistoryMessageFrame>
|
||||
</template>
|
||||
</Virtualizer>
|
||||
</div>
|
||||
</ChatHistoryScrollContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-history-list--mobile :deep(.chat-message-item-container) {
|
||||
--chat-top-fade-transparent-stop: -1px;
|
||||
--chat-top-fade-opaque-stop: 0px;
|
||||
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent var(--chat-top-fade-transparent-stop),
|
||||
black var(--chat-top-fade-opaque-stop)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent var(--chat-top-fade-transparent-stop),
|
||||
black var(--chat-top-fade-opaque-stop)
|
||||
);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { ScrollableArea } from '@proj-airi/ui'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import { defineComponent, ref, shallowRef } from 'vue'
|
||||
|
||||
describe('shared scrollable area', () => {
|
||||
it('exposes the Reka viewport and renders the requested scrollbar', async () => {
|
||||
const area = shallowRef<InstanceType<typeof ScrollableArea>>()
|
||||
const TestHost = defineComponent({
|
||||
components: { ScrollableArea },
|
||||
setup: () => ({ area }),
|
||||
template: `
|
||||
<ScrollableArea ref="area" type="always" style="height: 120px">
|
||||
<div style="height: 240px">Scrollable content</div>
|
||||
</ScrollableArea>
|
||||
`,
|
||||
})
|
||||
|
||||
const screen = await render(TestHost)
|
||||
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
|
||||
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(viewport?.clientHeight).toBe(120)
|
||||
expect(screen.container.querySelector('[data-orientation="vertical"]')).not.toBeNull()
|
||||
expect(area.value?.viewport).toBe(viewport)
|
||||
})
|
||||
|
||||
it('renders both scrollbar orientations around the same viewport', async () => {
|
||||
const TestHost = defineComponent({
|
||||
components: { ScrollableArea },
|
||||
template: `
|
||||
<ScrollableArea orientation="both" type="always" style="height: 120px; width: 120px">
|
||||
<div style="height: 240px; width: 240px">Two-axis content</div>
|
||||
</ScrollableArea>
|
||||
`,
|
||||
})
|
||||
|
||||
const screen = await render(TestHost)
|
||||
const viewport = screen.container.querySelector('[data-reka-scroll-area-viewport]')
|
||||
const verticalTrack = screen.container.querySelector<HTMLElement>('[data-orientation="vertical"]')
|
||||
const horizontalTrack = screen.container.querySelector<HTMLElement>('[data-orientation="horizontal"]')
|
||||
const verticalThumb = verticalTrack?.firstElementChild as HTMLElement | null
|
||||
const horizontalThumb = horizontalTrack?.firstElementChild as HTMLElement | null
|
||||
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(verticalThumb?.getBoundingClientRect().width).toBeGreaterThan(0)
|
||||
expect(verticalThumb?.getBoundingClientRect().height).toBeGreaterThan(0)
|
||||
expect(horizontalThumb?.getBoundingClientRect().width).toBeGreaterThan(0)
|
||||
expect(horizontalThumb?.getBoundingClientRect().height).toBeGreaterThan(0)
|
||||
expect(viewport?.textContent).toContain('Two-axis content')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
// Reka UI 2.10.3 clears both enabled-axis flags when either scrollbar unmounts.
|
||||
// Switching away from `both` therefore leaves the surviving axis disabled.
|
||||
// We restore the requested flags after Reka finishes the scrollbar DOM update.
|
||||
// Report: https://github.com/moeru-ai/airi/pull/2399#discussion_r3886316598
|
||||
it('keeps the remaining axis scrollable when orientation changes', async () => {
|
||||
const orientation = ref<'vertical' | 'horizontal' | 'both'>('both')
|
||||
const TestHost = defineComponent({
|
||||
components: { ScrollableArea },
|
||||
setup: () => ({ orientation }),
|
||||
template: `
|
||||
<ScrollableArea :orientation="orientation" type="always" style="height: 120px; width: 120px">
|
||||
<div style="height: 240px; width: 240px">Two-axis content</div>
|
||||
</ScrollableArea>
|
||||
`,
|
||||
})
|
||||
|
||||
const screen = await render(TestHost)
|
||||
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
|
||||
|
||||
orientation.value = 'vertical'
|
||||
await expect.poll(() => viewport?.style.overflowY).toBe('scroll')
|
||||
expect(viewport?.style.overflowX).toBe('hidden')
|
||||
expect(screen.container.querySelector('[data-orientation="vertical"]')).not.toBeNull()
|
||||
expect(screen.container.querySelector('[data-orientation="horizontal"]')).toBeNull()
|
||||
viewport!.scrollTop = 40
|
||||
expect(viewport?.scrollTop).toBe(40)
|
||||
|
||||
orientation.value = 'horizontal'
|
||||
await expect.poll(() => viewport?.style.overflowX).toBe('scroll')
|
||||
expect(viewport?.style.overflowY).toBe('hidden')
|
||||
expect(screen.container.querySelector('[data-orientation="vertical"]')).toBeNull()
|
||||
expect(screen.container.querySelector('[data-orientation="horizontal"]')).not.toBeNull()
|
||||
viewport!.scrollLeft = 40
|
||||
expect(viewport?.scrollLeft).toBe(40)
|
||||
})
|
||||
|
||||
it('keeps max-height panels scrollable without a fixed height', async () => {
|
||||
const TestHost = defineComponent({
|
||||
components: { ScrollableArea },
|
||||
template: `
|
||||
<ScrollableArea type="always" style="max-height: 120px">
|
||||
<div style="height: 240px">Tall preview</div>
|
||||
</ScrollableArea>
|
||||
`,
|
||||
})
|
||||
|
||||
const screen = await render(TestHost)
|
||||
const viewport = screen.container.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
|
||||
|
||||
expect(viewport?.clientHeight).toBe(120)
|
||||
expect(viewport?.scrollHeight).toBe(240)
|
||||
})
|
||||
})
|
||||
+31
-5
@@ -39,7 +39,10 @@ function sessionMeta(sessionId: string, updatedAt: number): ChatSessionMeta {
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
function createHarness(rows = [
|
||||
{ meta: sessionMeta('session-one', 2), preview: 'First chat', isActive: true, updatedAtLabel: 'now' },
|
||||
{ meta: sessionMeta('session-two', 1), preview: 'Second chat', isActive: false, updatedAtLabel: 'yesterday' },
|
||||
]) {
|
||||
return defineComponent({
|
||||
name: 'SessionsDialogHarness',
|
||||
components: { SessionsDialog },
|
||||
@@ -52,10 +55,7 @@ function createHarness() {
|
||||
created,
|
||||
deleted,
|
||||
selected,
|
||||
rows: [
|
||||
{ meta: sessionMeta('session-one', 2), preview: 'First chat', isActive: true, updatedAtLabel: 'now' },
|
||||
{ meta: sessionMeta('session-two', 1), preview: 'Second chat', isActive: false, updatedAtLabel: 'yesterday' },
|
||||
],
|
||||
rows,
|
||||
}
|
||||
},
|
||||
template: `
|
||||
@@ -77,6 +77,30 @@ function createHarness() {
|
||||
}
|
||||
|
||||
describe('sessions dialog actions', () => {
|
||||
it('constrains long mobile session lists to a scrollable viewport', async () => {
|
||||
const rows = Array.from({ length: 30 }, (_, index) => ({
|
||||
meta: sessionMeta(`session-${index}`, 30 - index),
|
||||
preview: `Chat ${index}`,
|
||||
isActive: index === 0,
|
||||
updatedAtLabel: 'now',
|
||||
}))
|
||||
|
||||
await render(createHarness(rows), {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
})
|
||||
|
||||
const viewport = document.querySelector<HTMLElement>('[data-reka-scroll-area-viewport]')
|
||||
|
||||
expect(viewport).not.toBeNull()
|
||||
await expect.poll(() => viewport?.clientHeight ?? 0).toBeLessThan(viewport?.scrollHeight ?? 0)
|
||||
await expect.poll(() => document.querySelector('.scrollable-area-scrollbar--vertical')).not.toBeNull()
|
||||
|
||||
viewport!.scrollTop = 120
|
||||
expect(viewport?.scrollTop).toBe(120)
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/issues/2085
|
||||
it('keeps add, switch, and delete actions independent for Issue #2085', async () => {
|
||||
// ROOT CAUSE:
|
||||
@@ -91,6 +115,8 @@ describe('sessions dialog actions', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(document.querySelector('[data-reka-scroll-area-viewport]')).not.toBeNull()
|
||||
|
||||
await screen.getByRole('button', { name: 'New chat' }).click()
|
||||
await expect.element(screen.getByLabelText('created-session-count')).toHaveTextContent('1')
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatSessionMeta } from '../../../../types/chat-session'
|
||||
|
||||
import { ScrollableArea } from '@proj-airi/ui'
|
||||
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -75,7 +76,15 @@ const { t } = useI18n()
|
||||
{{ t('stage.chat.sessions.new') }}
|
||||
</button>
|
||||
</div>
|
||||
<div :class="['flex-1 overflow-y-auto px-2 pb-4']">
|
||||
<ScrollableArea
|
||||
:class="['min-h-0 flex-1']"
|
||||
:style="{
|
||||
maxHeight: isDesktop
|
||||
? 'calc(80dvh - 4rem)'
|
||||
: `calc(85dvh - ${mobilePaddingBottom} - 5rem)`,
|
||||
}"
|
||||
:viewport-class="['px-2 pb-4']"
|
||||
>
|
||||
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
|
||||
{{ t('stage.chat.sessions.empty') }}
|
||||
</div>
|
||||
@@ -124,7 +133,7 @@ const { t } = useI18n()
|
||||
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
interface ChatHistoryScrollOptions<TMessage> {
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>
|
||||
container: Readonly<Ref<HTMLElement | null>>
|
||||
messages: Readonly<Ref<TMessage[]>>
|
||||
getKey: (message: TMessage, index: number) => string | number
|
||||
scrollToIndex: (index: number, align: 'start' | 'end') => void
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ interface VirtualScrollRequest {
|
||||
}
|
||||
|
||||
interface VirtualizerBottomAlignmentOptions {
|
||||
container: Readonly<ShallowRef<HTMLElement | null>>
|
||||
container: Readonly<Ref<HTMLElement | null>>
|
||||
itemCount: Readonly<Ref<number>>
|
||||
virtualizer: Readonly<ShallowRef<VirtualizerHandle | null>>
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ onMounted(() => screenSafeArea.update())
|
||||
<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-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
|
||||
<DialogContent class="fixed left-1/2 top-1/2 z-9999 h-[min(100dvh,48rem)] max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Onboarding</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
+88
-86
@@ -3,7 +3,7 @@ import type { ProviderMetadata } from '../../../../libs/providers/metadata'
|
||||
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Button, Callout, FieldCheckbox, FieldInput } from '@proj-airi/ui'
|
||||
import { Button, Callout, FieldCheckbox, FieldInput, ScrollableArea } from '@proj-airi/ui'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -230,97 +230,99 @@ initializeForm()
|
||||
</h2>
|
||||
<div h-5 w-5 />
|
||||
</div>
|
||||
<div v-if="props.selectedProvider" flex-1 overflow-y-auto space-y-4>
|
||||
<Callout :label="t('settings.dialogs.onboarding.credentialsSafeLabel')" theme="violet">
|
||||
<div>
|
||||
<ScrollableArea v-if="props.selectedProvider" :class="['min-h-0 flex-1']">
|
||||
<div :class="['space-y-4']">
|
||||
<Callout :label="t('settings.dialogs.onboarding.credentialsSafeLabel')" theme="violet">
|
||||
<div>
|
||||
{{ t('settings.dialogs.onboarding.credentialsSafeLocal') }}
|
||||
</div>
|
||||
<div>
|
||||
<i18n-t keypath="settings.dialogs.onboarding.credentialsSafeOpenSource" tag="span">
|
||||
<template #github>
|
||||
<span inline-flex translate-y-1 items-center gap-1>
|
||||
<span i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<div>
|
||||
{{ t('settings.dialogs.onboarding.credentialsSafeLocal') }}
|
||||
</div>
|
||||
<div>
|
||||
<i18n-t keypath="settings.dialogs.onboarding.credentialsSafeOpenSource" tag="span">
|
||||
<template #github>
|
||||
<span inline-flex translate-y-1 items-center gap-1>
|
||||
<span i-simple-icons:github inline-block /><a decoration-underline decoration-dashed href="https://github.com/moeru-ai/airi" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</div>
|
||||
</Callout>
|
||||
<div class="space-y-4">
|
||||
<!-- Custom onboarding fields (provider-specific, e.g. Amazon Bedrock SigV4) -->
|
||||
<template v-if="hasOnboardingFields">
|
||||
<FieldInput
|
||||
v-for="field in props.selectedProvider.onboardingFields"
|
||||
:key="field.key"
|
||||
v-model="customFieldValues[field.key]"
|
||||
:type="field.type"
|
||||
:label="field.label"
|
||||
:description="field.description"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Standard fields for other providers -->
|
||||
<template v-else>
|
||||
<!-- API Key Input -->
|
||||
<div v-if="needsApiKey">
|
||||
<FieldInput
|
||||
v-model="apiKey"
|
||||
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
|
||||
type="password"
|
||||
label="API Key"
|
||||
description="Enter your API key for the selected provider."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Base URL Input -->
|
||||
<div v-if="needsBaseUrl">
|
||||
<FieldInput
|
||||
v-model="baseUrl"
|
||||
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
|
||||
type="text"
|
||||
label="Base URL"
|
||||
description="Enter the base URL for the provider's API."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Account ID for Cloudflare -->
|
||||
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
|
||||
<ProviderAccountIdInput v-model="accountId" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Callout>
|
||||
<div class="space-y-4">
|
||||
<!-- Custom onboarding fields (provider-specific, e.g. Amazon Bedrock SigV4) -->
|
||||
<template v-if="hasOnboardingFields">
|
||||
<FieldInput
|
||||
v-for="field in props.selectedProvider.onboardingFields"
|
||||
:key="field.key"
|
||||
v-model="customFieldValues[field.key]"
|
||||
:type="field.type"
|
||||
:label="field.label"
|
||||
:description="field.description"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Standard fields for other providers -->
|
||||
<template v-else>
|
||||
<!-- API Key Input -->
|
||||
<div v-if="needsApiKey">
|
||||
<FieldInput
|
||||
v-model="apiKey"
|
||||
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
|
||||
type="password"
|
||||
label="API Key"
|
||||
description="Enter your API key for the selected provider."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<!-- Chat Ping Check Option -->
|
||||
<FieldCheckbox
|
||||
v-if="showChatCheckOption"
|
||||
v-model="enableChatCheck"
|
||||
:label="t('settings.dialogs.onboarding.enableChatCheck')"
|
||||
placement="left"
|
||||
/>
|
||||
|
||||
<!-- Base URL Input -->
|
||||
<div v-if="needsBaseUrl">
|
||||
<FieldInput
|
||||
v-model="baseUrl"
|
||||
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
|
||||
type="text"
|
||||
label="Base URL"
|
||||
description="Enter the base URL for the provider's API."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Account ID for Cloudflare -->
|
||||
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
|
||||
<ProviderAccountIdInput v-model="accountId" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- Validation Status -->
|
||||
<Alert v-if="validation === 'failed'" type="error">
|
||||
<template #title>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
|
||||
@click="handleContinueAnyway"
|
||||
>
|
||||
{{ t('settings.pages.providers.common.continueAnyway') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="validationError" #content>
|
||||
<pre class="whitespace-pre-wrap break-all">{{ String(validationError) }}</pre>
|
||||
</template>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
<!-- Chat Ping Check Option -->
|
||||
<FieldCheckbox
|
||||
v-if="showChatCheckOption"
|
||||
v-model="enableChatCheck"
|
||||
:label="t('settings.dialogs.onboarding.enableChatCheck')"
|
||||
placement="left"
|
||||
/>
|
||||
|
||||
<!-- Validation Status -->
|
||||
<Alert v-if="validation === 'failed'" type="error">
|
||||
<template #title>
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
|
||||
@click="handleContinueAnyway"
|
||||
>
|
||||
{{ t('settings.pages.providers.common.continueAnyway') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="validationError" #content>
|
||||
<pre class="whitespace-pre-wrap break-all">{{ String(validationError) }}</pre>
|
||||
</template>
|
||||
</Alert>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<Button
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
import type { ProviderMetadata } from '../../../../libs/providers/metadata'
|
||||
import type { OnboardingStepNextHandler, OnboardingStepPrevHandler } from './types'
|
||||
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { Button, ScrollableArea } from '@proj-airi/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -40,7 +40,7 @@ const selectedProviderIdModel = computed({
|
||||
</h2>
|
||||
<div class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<ScrollableArea :class="['min-h-0 flex-1']">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<RadioCardDetail
|
||||
v-for="provider in props.popularProviders"
|
||||
@@ -54,7 +54,7 @@ const selectedProviderIdModel = computed({
|
||||
@click="props.onSelectProvider(provider)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
<Button
|
||||
class="flex-shrink-0"
|
||||
:label="t('settings.dialogs.onboarding.next')"
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
import type { DisplayModel } from '../../../../stores/display-models'
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { Button, Callout } from '@proj-airi/ui'
|
||||
import { Button, Callout, ScrollableArea } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -67,85 +67,86 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
<ScrollableArea
|
||||
:class="[
|
||||
'flex flex-col gap-2',
|
||||
'z-10 overflow-y-scroll p-2',
|
||||
'z-10',
|
||||
settingsClass,
|
||||
]"
|
||||
>
|
||||
<Callout :label="t('settings.model-select.panel-callout.support-status-header')">
|
||||
<i18n-t keypath="settings.model-select.panel-callout.support-status" tag="p">
|
||||
<template #select-button>
|
||||
<strong>{{ t('settings.model-select.select-model.button') }}</strong>
|
||||
</template>
|
||||
<template #zip>
|
||||
<code>.zip</code>
|
||||
</template>
|
||||
<template #vrm>
|
||||
<code>.vrm</code>
|
||||
</template>
|
||||
<template #mmd>
|
||||
<code>.pmx</code>/<code>.pmd</code>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p>
|
||||
{{ t('settings.model-select.panel-callout.model-type-example') }}
|
||||
</p>
|
||||
<p v-if="effectiveRenderer === 'tachie'">
|
||||
{{ t('settings.tachie.archive-description') }}
|
||||
</p>
|
||||
</Callout>
|
||||
<div :class="['flex flex-wrap items-center gap-2']">
|
||||
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
|
||||
<Button>
|
||||
{{ t('settings.model-select.select-model.button') }}
|
||||
</Button>
|
||||
</ModelSelectorDialog>
|
||||
<slot name="actions" />
|
||||
<div :class="['flex flex-col gap-2 p-2']">
|
||||
<Callout :label="t('settings.model-select.panel-callout.support-status-header')">
|
||||
<i18n-t keypath="settings.model-select.panel-callout.support-status" tag="p">
|
||||
<template #select-button>
|
||||
<strong>{{ t('settings.model-select.select-model.button') }}</strong>
|
||||
</template>
|
||||
<template #zip>
|
||||
<code>.zip</code>
|
||||
</template>
|
||||
<template #vrm>
|
||||
<code>.vrm</code>
|
||||
</template>
|
||||
<template #mmd>
|
||||
<code>.pmx</code>/<code>.pmd</code>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p>
|
||||
{{ t('settings.model-select.panel-callout.model-type-example') }}
|
||||
</p>
|
||||
<p v-if="effectiveRenderer === 'tachie'">
|
||||
{{ t('settings.tachie.archive-description') }}
|
||||
</p>
|
||||
</Callout>
|
||||
<div :class="['flex flex-wrap items-center gap-2']">
|
||||
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="stageModelSelectedDisplayModel" @pick="handleModelPick">
|
||||
<Button>
|
||||
{{ t('settings.model-select.select-model.button') }}
|
||||
</Button>
|
||||
</ModelSelectorDialog>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<Live2D
|
||||
v-if="effectiveRenderer === 'live2d'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<VRM
|
||||
v-if="effectiveRenderer === 'vrm'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Spine
|
||||
v-if="effectiveRenderer === 'spine'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="$emit('extractColorsFromModel')"
|
||||
/>
|
||||
<MMD
|
||||
v-if="effectiveRenderer === 'mmd'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Tachie
|
||||
v-if="effectiveRenderer === 'tachie'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Godot
|
||||
v-if="effectiveRenderer === 'godot'"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
:view-snapshot="godotViewSnapshot"
|
||||
:view-error="godotViewError"
|
||||
:view-controls-locked="godotViewControlsLocked"
|
||||
@patch-view-state="emit('patchGodotViewState', $event)"
|
||||
/>
|
||||
</div>
|
||||
<Live2D
|
||||
v-if="effectiveRenderer === 'live2d'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<VRM
|
||||
v-if="effectiveRenderer === 'vrm'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Spine
|
||||
v-if="effectiveRenderer === 'spine'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="$emit('extractColorsFromModel')"
|
||||
/>
|
||||
<MMD
|
||||
v-if="effectiveRenderer === 'mmd'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Tachie
|
||||
v-if="effectiveRenderer === 'tachie'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Godot
|
||||
v-if="effectiveRenderer === 'godot'"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
:view-snapshot="godotViewSnapshot"
|
||||
:view-error="godotViewError"
|
||||
:view-controls-locked="godotViewControlsLocked"
|
||||
@patch-view-state="emit('patchGodotViewState', $event)"
|
||||
/>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Collapsible } from './collapsible.vue'
|
||||
export { default as Screen } from './screen.vue'
|
||||
export { default as ScrollableArea } from './scrollable-area.vue'
|
||||
export { default as Skeleton } from './skeleton.vue'
|
||||
export { default as Truncatable } from './truncatable.vue'
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import type { ScrollAreaRootProps } from 'reka-ui'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import { injectScrollAreaRootContext, ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||
import { computed, defineComponent, nextTick, useTemplateRef, watch } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<ScrollableAreaProps>(), {
|
||||
contentAsChild: false,
|
||||
orientation: 'vertical',
|
||||
type: 'auto',
|
||||
viewportClass: undefined,
|
||||
})
|
||||
|
||||
type ScrollableAreaOrientation = 'vertical' | 'horizontal' | 'both'
|
||||
|
||||
// NOTICE:
|
||||
// Reka UI 2.10.3 clears both axis flags when either scrollbar unmounts.
|
||||
// Restore the requested flags after the scrollbar DOM update.
|
||||
// Source: https://github.com/moeru-ai/airi/pull/2399#discussion_r3886316598
|
||||
// Remove this controller when Reka only clears the axis owned by its scrollbar.
|
||||
const ScrollAreaAxisController = defineComponent({
|
||||
props: {
|
||||
orientation: {
|
||||
type: String as PropType<ScrollableAreaOrientation>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(controllerProps) {
|
||||
const rootContext = injectScrollAreaRootContext()
|
||||
|
||||
watch(
|
||||
() => controllerProps.orientation,
|
||||
async (orientation) => {
|
||||
await nextTick()
|
||||
rootContext.onScrollbarXEnabledChange(orientation === 'horizontal' || orientation === 'both')
|
||||
rootContext.onScrollbarYEnabledChange(orientation === 'vertical' || orientation === 'both')
|
||||
},
|
||||
{ flush: 'post', immediate: true },
|
||||
)
|
||||
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
|
||||
interface ScrollableAreaProps {
|
||||
contentAsChild?: boolean
|
||||
orientation?: ScrollableAreaOrientation
|
||||
type?: ScrollAreaRootProps['type']
|
||||
viewportClass?: string | string[]
|
||||
}
|
||||
|
||||
const rootRef = useTemplateRef<{ viewport: HTMLElement | undefined }>('root')
|
||||
const viewport = computed(() => rootRef.value?.viewport)
|
||||
|
||||
defineExpose({
|
||||
viewport,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScrollAreaRoot
|
||||
ref="root"
|
||||
v-bind="$attrs"
|
||||
:type="props.type"
|
||||
:class="[
|
||||
'relative min-h-0 min-w-0 overflow-hidden',
|
||||
]"
|
||||
>
|
||||
<ScrollAreaAxisController :orientation="props.orientation" />
|
||||
|
||||
<ScrollAreaViewport
|
||||
:as-child="props.contentAsChild"
|
||||
:style="{
|
||||
height: '100%',
|
||||
maxHeight: 'inherit',
|
||||
maxWidth: 'inherit',
|
||||
width: '100%',
|
||||
}"
|
||||
:class="[
|
||||
'h-full w-full rounded-[inherit]',
|
||||
props.viewportClass,
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</ScrollAreaViewport>
|
||||
|
||||
<ScrollAreaScrollbar
|
||||
v-if="props.orientation === 'vertical' || props.orientation === 'both'"
|
||||
orientation="vertical"
|
||||
:style="{
|
||||
display: 'flex',
|
||||
width: '0.625rem',
|
||||
}"
|
||||
:class="[
|
||||
'scrollable-area-scrollbar scrollable-area-scrollbar--vertical',
|
||||
'z-10 touch-none select-none p-0.5',
|
||||
'transition-colors duration-150',
|
||||
]"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
:style="{
|
||||
width: '100%',
|
||||
}"
|
||||
:class="[
|
||||
'scrollable-area-thumb--vertical',
|
||||
'relative rounded-full',
|
||||
'bg-neutral-400/55 hover:bg-neutral-500/70',
|
||||
'dark:bg-neutral-600/65 dark:hover:bg-neutral-500/80',
|
||||
]"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
|
||||
<ScrollAreaScrollbar
|
||||
v-if="props.orientation === 'horizontal' || props.orientation === 'both'"
|
||||
orientation="horizontal"
|
||||
:style="{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '0.625rem',
|
||||
}"
|
||||
:class="[
|
||||
'scrollable-area-scrollbar scrollable-area-scrollbar--horizontal',
|
||||
'z-10 touch-none select-none p-0.5',
|
||||
'transition-colors duration-150',
|
||||
]"
|
||||
>
|
||||
<ScrollAreaThumb
|
||||
:style="{
|
||||
height: '100%',
|
||||
}"
|
||||
:class="[
|
||||
'scrollable-area-thumb--horizontal',
|
||||
'relative rounded-full',
|
||||
'bg-neutral-400/55 hover:bg-neutral-500/70',
|
||||
'dark:bg-neutral-600/65 dark:hover:bg-neutral-500/80',
|
||||
]"
|
||||
/>
|
||||
</ScrollAreaScrollbar>
|
||||
|
||||
<ScrollAreaCorner v-if="props.orientation === 'both'" />
|
||||
</ScrollAreaRoot>
|
||||
</template>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { errorCauseFrom, errorMessageFrom, errorNameFrom, errorStackFrom } from '@moeru/std'
|
||||
import { ScrollAreaCorner, ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||
import { computed, shallowRef } from 'vue'
|
||||
|
||||
import ScrollableArea from '../layouts/scrollable-area.vue'
|
||||
import IconButton from './icon-button.vue'
|
||||
|
||||
type HeightPreset = 'sm' | 'md' | 'lg' | 'xl' | 'auto'
|
||||
@@ -178,32 +178,26 @@ async function copyContent() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollAreaRoot
|
||||
type="auto"
|
||||
<ScrollableArea
|
||||
:class="[
|
||||
'relative w-full overflow-hidden rounded-xl',
|
||||
...heightPresetClasses[heightPreset],
|
||||
]"
|
||||
:viewport-class="['h-full w-full']"
|
||||
>
|
||||
<ScrollAreaViewport :class="['h-full w-full']">
|
||||
<div :class="['flex flex-col gap-2 p-3 text-xs']">
|
||||
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
|
||||
{{ resolvedErrorName || 'Error' }}
|
||||
<span v-if="resolvedMessage">
|
||||
: {{ resolvedMessage }}
|
||||
</span>
|
||||
</div>
|
||||
<pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
|
||||
<pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
|
||||
<div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
|
||||
No error details available.
|
||||
</div>
|
||||
<div :class="['flex flex-col gap-2 p-3 text-xs']">
|
||||
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
|
||||
{{ resolvedErrorName || 'Error' }}
|
||||
<span v-if="resolvedMessage">
|
||||
: {{ resolvedMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="vertical" :class="['w-2 p-0.5']">
|
||||
<ScrollAreaThumb :class="['rounded-full bg-neutral-300/80 dark:bg-neutral-700/80']" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
<pre v-if="resolvedStack" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']"> {{ resolvedStack }}</pre>
|
||||
<pre v-if="resolvedCause" :class="['whitespace-pre-wrap break-words text-neutral-700 leading-relaxed dark:text-neutral-200']">{{ `Cause:\n${resolvedCause}` }}</pre>
|
||||
<div v-if="!panelContent" :class="['text-neutral-600 dark:text-neutral-300']">
|
||||
No error details available.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollableArea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user