Jelajahi Sumber

feat: 支持媒体全览滑动与预览关闭

samien 2 bulan lalu
induk
melakukan
6a20a46fe9

+ 13 - 5
src/client/components/library/PreviewInspector.vue

@@ -4,8 +4,13 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import MarkdownContent from '@/components/messages/MarkdownContent.vue'
 import MarkdownContent from '@/components/messages/MarkdownContent.vue'
 import type { UiLibraryItem } from './types'
 import type { UiLibraryItem } from './types'
 
 
-const props = defineProps<{ item: UiLibraryItem }>()
-const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
+const props = withDefaults(defineProps<{
+  item: UiLibraryItem
+  canPrevious?: boolean
+  canNext?: boolean
+  showClose?: boolean
+}>(), { canPrevious: false, canNext: false, showClose: false })
+const emit = defineEmits<{ close: []; previous: []; next: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
 
 
 const canInline = computed(() => !!props.item.previewUrl)
 const canInline = computed(() => !!props.item.previewUrl)
 const isTextual = computed(() => ['text', 'code'].includes(props.item.kind))
 const isTextual = computed(() => ['text', 'code'].includes(props.item.kind))
@@ -89,12 +94,14 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
 <template>
 <template>
   <div class="preview-inspector">
   <div class="preview-inspector">
     <header>
     <header>
-      <button class="icon-button preview-back" type="button" aria-label="关闭预览" @click="emit('close')"><AppIcon name="chevron-left" /></button>
       <strong :title="item.name">{{ item.title || item.name }}</strong>
       <strong :title="item.name">{{ item.title || item.name }}</strong>
       <div class="preview-header-actions">
       <div class="preview-header-actions">
+        <button v-if="canPrevious" class="icon-button" type="button" aria-label="上一张媒体" title="上一张媒体" @click="emit('previous')"><AppIcon name="chevron-left" :size="16" /></button>
+        <button v-if="canNext" class="icon-button" type="button" aria-label="下一张媒体" title="下一张媒体" @click="emit('next')"><AppIcon class="preview-next-icon" name="chevron-left" :size="16" /></button>
         <button v-if="item.sourceSessionId" class="icon-button" type="button" aria-label="跳转到来源消息" title="跳转到来源消息" @click="emit('source', item)"><AppIcon name="external" :size="15" /></button>
         <button v-if="item.sourceSessionId" class="icon-button" type="button" aria-label="跳转到来源消息" title="跳转到来源消息" @click="emit('source', item)"><AppIcon name="external" :size="15" /></button>
         <a v-if="item.downloadUrl || item.previewUrl" class="icon-button" :href="item.downloadUrl || item.previewUrl" download aria-label="下载" title="下载"><AppIcon name="download" :size="16" /></a>
         <a v-if="item.downloadUrl || item.previewUrl" class="icon-button" :href="item.downloadUrl || item.previewUrl" download aria-label="下载" title="下载"><AppIcon name="download" :size="16" /></a>
         <button class="icon-button" type="button" aria-label="加入输入框" title="加入输入框" @click="emit('addToComposer', item)"><AppIcon name="plus" :size="17" /></button>
         <button class="icon-button" type="button" aria-label="加入输入框" title="加入输入框" @click="emit('addToComposer', item)"><AppIcon name="plus" :size="17" /></button>
+        <button v-if="showClose" class="icon-button preview-close" type="button" aria-label="关闭预览" title="关闭预览" @click="emit('close')"><AppIcon name="close" :size="17" /></button>
       </div>
       </div>
     </header>
     </header>
 
 
@@ -116,11 +123,12 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
 <style scoped>
 <style scoped>
 .preview-inspector { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
 .preview-inspector { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
 header { display: flex; min-height: 46px; align-items: center; gap: 8px; padding: 5px 10px; border-bottom: 1px solid var(--line); } .preview-back { display: none; } header > strong { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; font-weight: 630; text-overflow: ellipsis; white-space: nowrap; }.preview-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 2px; }.preview-header-actions .icon-button { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; text-decoration: none; }.preview-header-actions .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
 header { display: flex; min-height: 46px; align-items: center; gap: 8px; padding: 5px 10px; border-bottom: 1px solid var(--line); } .preview-back { display: none; } header > strong { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; font-weight: 630; text-overflow: ellipsis; white-space: nowrap; }.preview-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 2px; }.preview-header-actions .icon-button { display: grid; width: 30px; height: 30px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; text-decoration: none; }.preview-header-actions .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
-.preview-stage { display: grid; min-height: 0; flex: 1 1 auto; place-items: center; overflow: hidden; background: var(--surface-soft); }.preview-stage img, .preview-stage video { display: block; width: 100%; height: auto; max-height: 100%; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: 100%; border: 0; }.preview-text { width: 100%; height: 100%; box-sizing: border-box; padding: 16px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
+.preview-next-icon { transform: rotate(180deg); }
+.preview-stage { display: grid; min-height: 0; flex: 1 1 auto; place-items: center; overflow: hidden; background: var(--surface-soft); touch-action: pan-y; }.preview-stage img, .preview-stage video { display: block; width: 100%; height: 100%; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: 100%; border: 0; }.preview-text { width: 100%; height: 100%; box-sizing: border-box; padding: 16px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
 .preview-office { width: 100%; height: 100%; overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { padding: 18px 0; background: #e6e6e4; }.preview-office :deep(.docx) { margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }
 .preview-office { width: 100%; height: 100%; overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { padding: 18px 0; background: #e6e6e4; }.preview-office :deep(.docx) { margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }
 .preview-pdf { display: flex; width: 100%; height: 100%; box-sizing: border-box; align-items: center; flex-direction: column; gap: 12px; padding: 12px; overflow: auto; background: #dadad7; }.preview-pdf :deep(.pdf-page) { flex: 0 0 auto; overflow: hidden; background: #fff; box-shadow: 0 3px 14px rgba(0,0,0,.15); }.preview-pdf :deep(canvas) { display: block; }
 .preview-pdf { display: flex; width: 100%; height: 100%; box-sizing: border-box; align-items: center; flex-direction: column; gap: 12px; padding: 12px; overflow: auto; background: #dadad7; }.preview-pdf :deep(.pdf-page) { flex: 0 0 auto; overflow: hidden; background: #fff; box-shadow: 0 3px 14px rgba(0,0,0,.15); }.preview-pdf :deep(canvas) { display: block; }
 .preview-sheet { width: 100%; height: 100%; overflow: auto; background: var(--surface-raised); }.preview-sheet table { min-width: 100%; border-collapse: collapse; font-size: 9px; }.preview-sheet td { min-width: 72px; max-width: 240px; padding: 6px 8px; overflow: hidden; border: 1px solid var(--line); color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }.preview-sheet tr:first-child td { position: sticky; top: 0; background: var(--surface-soft); color: var(--text-primary); font-weight: 600; }
 .preview-sheet { width: 100%; height: 100%; overflow: auto; background: var(--surface-raised); }.preview-sheet table { min-width: 100%; border-collapse: collapse; font-size: 9px; }.preview-sheet td { min-width: 72px; max-width: 240px; padding: 6px 8px; overflow: hidden; border: 1px solid var(--line); color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }.preview-sheet tr:first-child td { position: sticky; top: 0; background: var(--surface-soft); color: var(--text-primary); font-weight: 600; }
 .preview-link { display: flex; width: calc(100% - 34px); flex-direction: column; align-items: center; color: var(--text-secondary); text-decoration: none; text-align: center; }.preview-link > span, .preview-unavailable > span { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); }.preview-link strong { max-width: 100%; overflow: hidden; color: var(--text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.preview-link small { margin-top: 5px; color: var(--text-muted); font-size: 9px; }
 .preview-link { display: flex; width: calc(100% - 34px); flex-direction: column; align-items: center; color: var(--text-secondary); text-decoration: none; text-align: center; }.preview-link > span, .preview-unavailable > span { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); }.preview-link strong { max-width: 100%; overflow: hidden; color: var(--text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.preview-link small { margin-top: 5px; color: var(--text-muted); font-size: 9px; }
 .preview-unavailable { display: flex; padding: 30px; flex-direction: column; align-items: center; color: var(--text-secondary); text-align: center; }.preview-unavailable strong { font-size: 11px; }.preview-unavailable p { margin: 5px 0 0; color: var(--text-muted); font-size: 9px; }
 .preview-unavailable { display: flex; padding: 30px; flex-direction: column; align-items: center; color: var(--text-secondary); text-align: center; }.preview-unavailable strong { font-size: 11px; }.preview-unavailable p { margin: 5px 0 0; color: var(--text-muted); font-size: 9px; }
-@media (max-width: 900px) { .preview-back { display: grid; } header { padding-right: 12px; } }
+@media (max-width: 900px) { header { padding-right: 12px; } }
 </style>
 </style>

+ 43 - 4
src/client/components/library/PreviewModal.vue

@@ -1,17 +1,57 @@
 <script setup lang="ts">
 <script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import PreviewInspector from './PreviewInspector.vue'
 import PreviewInspector from './PreviewInspector.vue'
 import type { UiLibraryItem } from './types'
 import type { UiLibraryItem } from './types'
 
 
-defineProps<{ item: UiLibraryItem }>()
+const props = withDefaults(defineProps<{ item: UiLibraryItem; items?: UiLibraryItem[] }>(), { items: () => [] })
 const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
 const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
+
+const mediaItems = computed(() => {
+  const candidates = props.items.filter(item => item.kind === 'image' || item.kind === 'video')
+  return candidates.some(item => item.id === props.item.id) ? candidates : [props.item]
+})
+const activeIndex = ref(0)
+const activeItem = computed(() => mediaItems.value[activeIndex.value] ?? props.item)
+const canPrevious = computed(() => activeIndex.value > 0)
+const canNext = computed(() => activeIndex.value < mediaItems.value.length - 1)
+let swipeStart: number | undefined
+
+function resetActiveItem() {
+  const index = mediaItems.value.findIndex(item => item.id === props.item.id)
+  activeIndex.value = index >= 0 ? index : 0
+}
+function previous() { if (canPrevious.value) activeIndex.value -= 1 }
+function next() { if (canNext.value) activeIndex.value += 1 }
+function startSwipe(event: PointerEvent) {
+  if (event.button !== 0 && event.pointerType === 'mouse') return
+  if ((event.target as HTMLElement).closest('button, a, audio')) return
+  swipeStart = event.clientX
+}
+function finishSwipe(event: PointerEvent) {
+  if (swipeStart === undefined) return
+  const delta = event.clientX - swipeStart
+  swipeStart = undefined
+  if (Math.abs(delta) < 48) return
+  if (delta < 0) next()
+  else previous()
+}
+function onKeydown(event: KeyboardEvent) {
+  if (event.key !== 'Escape') return
+  event.preventDefault()
+  emit('close')
+}
+
+watch([() => props.item.id, mediaItems], resetActiveItem, { immediate: true })
+onMounted(() => window.addEventListener('keydown', onKeydown))
+onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
 </script>
 </script>
 
 
 <template>
 <template>
   <Teleport to="body">
   <Teleport to="body">
     <Transition name="preview-modal">
     <Transition name="preview-modal">
       <div class="preview-modal-layer" @mousedown.self="emit('close')">
       <div class="preview-modal-layer" @mousedown.self="emit('close')">
-        <section class="preview-modal" role="dialog" aria-modal="true" :aria-label="`预览 ${item.title || item.name}`">
-          <PreviewInspector :item="item" @close="emit('close')" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
+        <section class="preview-modal" role="dialog" aria-modal="true" :aria-label="`预览 ${activeItem.title || activeItem.name}`" @pointerdown="startSwipe" @pointerup="finishSwipe" @pointercancel="swipeStart = undefined">
+          <PreviewInspector :item="activeItem" :can-previous="canPrevious" :can-next="canNext" show-close @close="emit('close')" @previous="previous" @next="next" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
         </section>
         </section>
       </div>
       </div>
     </Transition>
     </Transition>
@@ -22,7 +62,6 @@ const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; sour
 .preview-modal-layer { position: fixed; z-index: 90; inset: 0; display: grid; place-items: center; padding: 28px; background: var(--scrim); backdrop-filter: blur(4px); }
 .preview-modal-layer { position: fixed; z-index: 90; inset: 0; display: grid; place-items: center; padding: 28px; background: var(--scrim); backdrop-filter: blur(4px); }
 .preview-modal { display: flex; width: min(960px, calc(100vw - 56px)); height: min(820px, calc(100vh - 56px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 70px rgba(0,0,0,.28); }
 .preview-modal { display: flex; width: min(960px, calc(100vw - 56px)); height: min(820px, calc(100vh - 56px)); overflow: hidden; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 70px rgba(0,0,0,.28); }
 .preview-modal :deep(.preview-inspector) { width: 100%; }
 .preview-modal :deep(.preview-inspector) { width: 100%; }
-.preview-modal :deep(.preview-back) { display: grid; }
 .preview-modal :deep(.preview-stage) { min-height: min(58vh, 560px); }
 .preview-modal :deep(.preview-stage) { min-height: min(58vh, 560px); }
 .preview-modal-enter-active, .preview-modal-leave-active { transition: opacity 160ms ease; }
 .preview-modal-enter-active, .preview-modal-leave-active { transition: opacity 160ms ease; }
 .preview-modal-enter-active .preview-modal, .preview-modal-leave-active .preview-modal { transition: transform 180ms var(--ease-out), opacity 150ms ease; }
 .preview-modal-enter-active .preview-modal, .preview-modal-leave-active .preview-modal { transition: transform 180ms var(--ease-out), opacity 150ms ease; }

+ 1 - 1
src/client/views/FilesView.vue

@@ -105,7 +105,7 @@ watch(() => auth.activeProfile?.name, profile => {
       />
       />
     </section>
     </section>
   </WorkspaceView>
   </WorkspaceView>
-  <PreviewModal v-if="selected" :item="selected" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
+  <PreviewModal v-if="selected" :item="selected" :items="items" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>

+ 4 - 2
tests/e2e/workspace.spec.ts

@@ -35,9 +35,10 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   const previewDialog = page.getByRole('dialog', { name: /预览 demo-report.pdf/ })
   const previewDialog = page.getByRole('dialog', { name: /预览 demo-report.pdf/ })
   await expect(previewDialog.getByRole('link', { name: '下载' })).toBeVisible()
   await expect(previewDialog.getByRole('link', { name: '下载' })).toBeVisible()
   await expect(previewDialog.getByRole('button', { name: '加入输入框' })).toBeVisible()
   await expect(previewDialog.getByRole('button', { name: '加入输入框' })).toBeVisible()
+  await expect(previewDialog.locator('.preview-close')).toBeVisible()
   const modalHeight = await page.locator('.preview-modal').evaluate(element => element.getBoundingClientRect().height)
   const modalHeight = await page.locator('.preview-modal').evaluate(element => element.getBoundingClientRect().height)
   expect(modalHeight).toBeGreaterThan(600)
   expect(modalHeight).toBeGreaterThan(600)
-  await page.getByRole('button', { name: '关闭预览' }).click()
+  await previewDialog.locator('.preview-close').click()
 
 
   await page.goto('/artifacts')
   await page.goto('/artifacts')
   await expect(page).toHaveURL(/\/chat$/)
   await expect(page).toHaveURL(/\/chat$/)
@@ -78,7 +79,8 @@ test('renders historical assistant MEDIA as Markdown in chat and group chat', as
   await expect(image).toBeVisible()
   await expect(image).toBeVisible()
   await image.click()
   await image.click()
   await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeVisible()
   await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeVisible()
-  await page.getByRole('button', { name: '关闭预览' }).click()
+  await page.keyboard.press('Escape')
+  await expect(page.getByRole('dialog', { name: /预览 AppIcon-1024.png/ })).toBeHidden()
 
 
   await page.getByRole('button', { name: '群聊' }).click()
   await page.getByRole('button', { name: '群聊' }).click()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()
   await expect(page.locator('.markdown img[alt="AppIcon-1024.png"]')).toBeVisible()