From 888f978e4aaa1c08c5237c3edc5ea0e1f32207f5 Mon Sep 17 00:00:00 2001
From: probe
Date: Wed, 21 Oct 2026 12:28:08 +0800
Subject: [PATCH] =?UTF-8?q?feat(buddy):=20=E5=9B=BE=E7=89=87=E5=92=8C?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=9B=BE=E9=A2=84=E8=A7=88=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E6=BB=9A=E8=BD=AE=E7=BC=A9=E6=94=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
图片预览和流程图放大面板只能点工具栏的放大、缩小按钮缩放,滚动滚轮没有反应。
新增 usePreviewWheelZoom:在预览浮层内滚动滚轮时触发对应的缩放按钮,多个预览共用一个监听,最后一个卸载时释放。
横向滚动、按住 Shift 和增量为 0 的滚动不处理;触控板的小增量会累积,并限制连续触发的频率。
流程图内联视图仍关闭自带的滚轮缩放,避免干扰页面滚动。
Co-Authored-By: Claude Sonnet 5.5
---
.../ui/markdown/DesktopMermaidBlock.vue | 3 +
.../src/shared/ui/media/BuddyImagePreview.vue | 13 +-
.../__tests__/usePreviewWheelZoom.spec.ts | 212 ++++++++++++++++++
.../shared/ui/media/usePreviewWheelZoom.ts | 63 ++++++
4 files changed, 286 insertions(+), 5 deletions(-)
create mode 100644 apps/buddy/src/shared/ui/media/__tests__/usePreviewWheelZoom.spec.ts
create mode 100644 apps/buddy/src/shared/ui/media/usePreviewWheelZoom.ts
diff --git a/apps/buddy/src/shared/ui/markdown/DesktopMermaidBlock.vue b/apps/buddy/src/shared/ui/markdown/DesktopMermaidBlock.vue
index 7815992ca..60b1274e4 100644
--- a/apps/buddy/src/shared/ui/markdown/DesktopMermaidBlock.vue
+++ b/apps/buddy/src/shared/ui/markdown/DesktopMermaidBlock.vue
@@ -5,6 +5,7 @@ import { useMessage } from 'naive-ui'
import { computed } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'
import { useDesktopUi } from '@/shared/ui/desktopUiContext'
+import { usePreviewWheelZoom } from '@/shared/ui/media/usePreviewWheelZoom'
import { desktopMermaidTheme } from '@/theme/mermaidTheme'
defineOptions({ inheritAttrs: false })
@@ -20,6 +21,7 @@ const { language } = useDesktopUi()
const { t } = useBuddyI18n(language)
const message = useMessage()
const themeKey = computed(() => JSON.stringify(desktopMermaidTheme.value))
+usePreviewWheelZoom()
async function copy(event: MermaidBlockEvent) {
event.preventDefault()
@@ -46,6 +48,7 @@ async function copy(event: MermaidBlockEvent) {
:show-collapse-button="true"
:is-strict="true"
:enable-mermaid-interactions="false"
+ :enable-wheel-zoom="false"
@copy="copy"
/>
diff --git a/apps/buddy/src/shared/ui/media/BuddyImagePreview.vue b/apps/buddy/src/shared/ui/media/BuddyImagePreview.vue
index 2d6649477..e7634db20 100644
--- a/apps/buddy/src/shared/ui/media/BuddyImagePreview.vue
+++ b/apps/buddy/src/shared/ui/media/BuddyImagePreview.vue
@@ -5,6 +5,7 @@ import type { BuddyLocale } from '@/i18n/buddyI18n'
import { NImageGroup } from 'naive-ui'
import { computed, h } from 'vue'
import { useBuddyI18n } from '@/i18n/buddyI18n'
+import { usePreviewWheelZoom } from './usePreviewWheelZoom'
const props = defineProps<{
language: BuddyLocale
@@ -15,6 +16,7 @@ const show = defineModel('show', { default: false })
const { t } = useBuddyI18n(() => props.language)
const sourceList = computed(() => [...props.sources])
+usePreviewWheelZoom()
const renderToolbar: NonNullable = ({ nodes }) => h(
'div',
{ class: 'buddy-image-preview-toolbar' },
@@ -25,23 +27,24 @@ const renderToolbar: NonNullable = ({ nodes })
]),
h('span', { 'aria-hidden': 'true', 'class': 'buddy-image-preview-toolbar__divider' }),
h('div', { class: 'buddy-image-preview-toolbar__group' }, [
- toolbarButton(nodes.zoomOut, t('desktop.imagePreview.zoomOut')),
- toolbarButton(nodes.zoomIn, t('desktop.imagePreview.zoomIn')),
+ toolbarButton(nodes.zoomOut, t('desktop.imagePreview.zoomOut'), 'out'),
+ toolbarButton(nodes.zoomIn, t('desktop.imagePreview.zoomIn'), 'in'),
]),
h('span', { 'aria-hidden': 'true', 'class': 'buddy-image-preview-toolbar__divider' }),
h('div', { class: 'buddy-image-preview-toolbar__group' }, [
toolbarButton(nodes.download, t('desktop.imagePreview.download')),
- toolbarButton(nodes.close, t('desktop.imagePreview.close'), true),
+ toolbarButton(nodes.close, t('desktop.imagePreview.close'), 'close'),
]),
],
)
-function toolbarButton(node: VNode, label: string, close = false): VNode {
+function toolbarButton(node: VNode, label: string, action?: 'in' | 'out' | 'close'): VNode {
return h('button', {
'aria-label': label,
+ 'data-preview-zoom': action === 'close' ? undefined : action,
'class': [
'buddy-image-preview-toolbar__button',
- { 'is-close': close },
+ { 'is-close': action === 'close' },
],
'type': 'button',
'onClick': (event: MouseEvent) => invokeToolbarNode(node, event),
diff --git a/apps/buddy/src/shared/ui/media/__tests__/usePreviewWheelZoom.spec.ts b/apps/buddy/src/shared/ui/media/__tests__/usePreviewWheelZoom.spec.ts
new file mode 100644
index 000000000..578a77b6b
--- /dev/null
+++ b/apps/buddy/src/shared/ui/media/__tests__/usePreviewWheelZoom.spec.ts
@@ -0,0 +1,212 @@
+// @vitest-environment jsdom
+import type { MermaidBlockNodeProps } from 'markstream-vue'
+import { MermaidBlockNode, setMermaidLoader } from 'markstream-vue'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createApp, effectScope, h, nextTick } from 'vue'
+import BuddyImagePreview from '../BuddyImagePreview.vue'
+import { usePreviewWheelZoom } from '../usePreviewWheelZoom'
+
+const cleanups: Array<() => void> = []
+
+afterEach(() => {
+ for (const cleanup of cleanups.splice(0).reverse())
+ cleanup()
+ document.body.replaceChildren()
+ document.body.style.overflow = ''
+})
+
+async function subscribe() {
+ const scope = effectScope()
+ scope.run(() => usePreviewWheelZoom())
+ cleanups.push(() => scope.stop())
+ await nextTick()
+ return scope
+}
+
+function fixture(className = 'n-image-preview-container') {
+ const overlay = document.createElement('div')
+ overlay.className = className
+ overlay.innerHTML = ''
+ document.body.append(overlay)
+ const buttons = overlay.querySelectorAll('button')
+ const zoomIn = vi.fn()
+ const zoomOut = vi.fn()
+ buttons[0]!.addEventListener('click', zoomIn)
+ buttons[1]!.addEventListener('click', zoomOut)
+ return { overlay, content: overlay.querySelector('path')!, zoomIn, zoomOut }
+}
+
+function wheel(target: Element, deltaY: number, options: WheelEventInit = {}) {
+ const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, ...options })
+ target.dispatchEvent(event)
+ return event
+}
+
+describe('preview wheel zoom', () => {
+ it.each(['n-image-preview-container', 'mermaid-modal-panel'])('zooms only the active %s and blocks background scrolling', async (className) => {
+ vi.useFakeTimers()
+ await subscribe()
+ const active = fixture(className)
+ const other = fixture(className)
+ const bubble = vi.fn()
+ active.overlay.addEventListener('wheel', bubble)
+
+ expect(wheel(active.content, -100).defaultPrevented).toBe(true)
+ await nextTick()
+ expect(active.zoomIn).toHaveBeenCalledTimes(1)
+ expect(other.zoomIn).not.toHaveBeenCalled()
+ expect(bubble).not.toHaveBeenCalled()
+
+ // Changing direction starts a new gesture, still respecting the rate limit.
+ vi.advanceTimersByTime(80)
+ wheel(active.content, 100)
+ expect(active.zoomOut).toHaveBeenCalledTimes(1)
+ })
+
+ it('leaves inline diagrams, ordinary content and unrelated image groups alone', async () => {
+ await subscribe()
+ for (const className of ['mermaid-preview-area', 'buddy-chat-markdown', 'n-image-preview-container']) {
+ const { content, overlay, zoomIn } = fixture(className)
+ if (className === 'n-image-preview-container')
+ overlay.querySelectorAll('[data-preview-zoom]').forEach(button => button.removeAttribute('data-preview-zoom'))
+ expect(wheel(content, -100).defaultPrevented).toBe(false)
+ expect(wheel(content, -100, { ctrlKey: true }).defaultPrevented).toBe(false)
+ expect(zoomIn).not.toHaveBeenCalled()
+ }
+ })
+
+ it('ignores horizontal, shifted and zero-delta scrolling', async () => {
+ await subscribe()
+ const { content, zoomIn } = fixture()
+ expect(wheel(content, 0).defaultPrevented).toBe(false)
+ expect(wheel(content, -10, { deltaX: 100 }).defaultPrevented).toBe(false)
+ expect(wheel(content, -100, { shiftKey: true }).defaultPrevented).toBe(false)
+ expect(zoomIn).not.toHaveBeenCalled()
+ })
+
+ it.each([{ deltaY: -3, deltaMode: 1 }, { deltaY: -1, deltaMode: 2 }])('normalizes deltaMode $deltaMode', async ({ deltaY, deltaMode }) => {
+ await subscribe()
+ const { content, zoomIn } = fixture()
+ wheel(content, deltaY, { deltaMode })
+ expect(zoomIn).toHaveBeenCalledTimes(1)
+ })
+
+ it('accumulates trackpad deltas, throttles bursts and discards stale/reversed motion', async () => {
+ vi.useFakeTimers()
+ await subscribe()
+ const { content, zoomIn, zoomOut } = fixture()
+ for (let i = 0; i < 3; i++)
+ wheel(content, -10)
+ expect(zoomIn).not.toHaveBeenCalled()
+ wheel(content, -10)
+ expect(zoomIn).toHaveBeenCalledTimes(1)
+ for (let i = 0; i < 10; i++)
+ wheel(content, -100)
+ expect(zoomIn).toHaveBeenCalledTimes(1)
+
+ vi.advanceTimersByTime(80)
+ wheel(content, 10)
+ expect(zoomOut).not.toHaveBeenCalled()
+ wheel(content, 30)
+ expect(zoomOut).toHaveBeenCalledTimes(1)
+
+ vi.advanceTimersByTime(201)
+ wheel(content, 30)
+ expect(zoomOut).toHaveBeenCalledTimes(1)
+ })
+
+ it('shares a single listener and releases it when the last consumer unmounts', async () => {
+ const first = await subscribe()
+ const second = await subscribe()
+ const { content, zoomIn, zoomOut } = fixture()
+ wheel(content, -100)
+ expect(zoomIn).toHaveBeenCalledTimes(1)
+ first.stop()
+
+ const other = fixture()
+ wheel(other.content, 100)
+ expect(other.zoomOut).toHaveBeenCalledTimes(1)
+ second.stop()
+ expect(wheel(content, 100).defaultPrevented).toBe(false)
+ expect(zoomOut).not.toHaveBeenCalled()
+
+ await subscribe()
+ const reopened = fixture()
+ wheel(reopened.content, -100)
+ expect(reopened.zoomIn).toHaveBeenCalledTimes(1)
+ })
+
+ it('uses the real image preview toolbar and existing scale state', async () => {
+ const host = document.createElement('div')
+ document.body.append(host)
+ const app = createApp({
+ render: () => h(BuddyImagePreview, { language: 'zh-CN', sources: ['test.png'], show: true }),
+ })
+ app.mount(host)
+ cleanups.push(() => app.unmount())
+ await nextTick()
+
+ const image = document.querySelector('.n-image-preview-container .n-image-preview')!
+ expect(image).not.toBeNull()
+ wheel(image, -100)
+ await nextTick()
+ expect(image.style.transform).toContain('scale(1.5)')
+ document.querySelector('[data-preview-zoom="out"]')!.click()
+ expect(image.style.transform).toContain('scale(1)')
+ })
+
+ it('uses the real Mermaid modal toolbar without enabling inline wheel zoom', async () => {
+ vi.stubGlobal('ResizeObserver', class {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ })
+ setMermaidLoader(async () => ({
+ initialize() {},
+ async parse() { return true },
+ async render(id: string) {
+ return { svg: `` }
+ },
+ }))
+ const host = document.createElement('div')
+ document.body.append(host)
+ const app = createApp({
+ setup() {
+ usePreviewWheelZoom()
+ const node = { type: 'code_block', language: 'mermaid', code: 'graph TD; A-->B', raw: '' } as MermaidBlockNodeProps['node']
+ return () => h(MermaidBlockNode, {
+ node,
+ loading: false,
+ enableWheelZoom: false,
+ renderDebounceMs: 0,
+ contentStableDelayMs: 0,
+ })
+ },
+ })
+ app.mount(host)
+ cleanups.push(() => app.unmount())
+
+ await vi.waitFor(() => {
+ const open = host.querySelector('button[aria-label="Open"], button[aria-label="打开"]')
+ expect(open).not.toBeNull()
+ }, { timeout: 5_000 })
+ host.querySelector('button[aria-label="Open"], button[aria-label="打开"]')!.click()
+ await vi.waitFor(() => expect(document.querySelector('.mermaid-modal-panel [data-mermaid-wrapper]')).not.toBeNull())
+ const panel = document.querySelector('.mermaid-modal-panel')!
+ const content = panel.querySelector('[data-mermaid-wrapper]')!
+ const scale = panel.querySelectorAll('button')[2]!
+ expect(wheel(content, -100).defaultPrevented).toBe(true)
+ await nextTick()
+ expect(scale.textContent).toContain('110%')
+ panel.querySelectorAll('button')[1]!.click()
+ await nextTick()
+ expect(scale.textContent).toContain('100%')
+
+ const inline = host.querySelector('.mermaid-preview-area')!
+ expect(wheel(inline, -100).defaultPrevented).toBe(false)
+ expect(wheel(inline, -100, { ctrlKey: true }).defaultPrevented).toBe(false)
+ panel.querySelectorAll('button')[3]!.click()
+ await nextTick()
+ expect(wheel(inline, -100).defaultPrevented).toBe(false)
+ })
+})
diff --git a/apps/buddy/src/shared/ui/media/usePreviewWheelZoom.ts b/apps/buddy/src/shared/ui/media/usePreviewWheelZoom.ts
new file mode 100644
index 000000000..c9aab34d5
--- /dev/null
+++ b/apps/buddy/src/shared/ui/media/usePreviewWheelZoom.ts
@@ -0,0 +1,63 @@
+import { createSharedComposable, useEventListener } from '@vueuse/core'
+
+interface WheelGesture {
+ delta: number
+ lastEvent: number
+ lastZoom: number
+}
+
+// Both libraries teleport their previews outside the component's DOM tree.
+// Share one listener across all mounted previews, and release it with the last scope.
+export const usePreviewWheelZoom = createSharedComposable(() => {
+ const gestures = new WeakMap()
+
+ useEventListener('wheel', (event: WheelEvent) => {
+ if (!(event.target instanceof Element) || !Number.isFinite(event.deltaY)
+ || event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY) || event.shiftKey) {
+ return
+ }
+
+ const overlay = event.target.closest('.n-image-preview-container, .mermaid-modal-panel')
+ if (!overlay)
+ return
+
+ const direction = event.deltaY < 0 ? 'in' : 'out'
+ const button = findZoomButton(overlay, direction)
+ if (!button)
+ return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const now = Date.now()
+ let gesture = gestures.get(overlay)
+ if (!gesture) {
+ gesture = { delta: 0, lastEvent: now, lastZoom: Number.NEGATIVE_INFINITY }
+ gestures.set(overlay, gesture)
+ }
+ if (now - gesture.lastEvent > 200 || Math.sign(gesture.delta) !== Math.sign(event.deltaY))
+ gesture.delta = 0
+
+ // Normalize mouse-wheel lines/pages and accumulate small trackpad movements.
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? overlay.clientHeight || window.innerHeight : 1
+ gesture.delta += event.deltaY * unit
+ gesture.lastEvent = now
+ if (Math.abs(gesture.delta) < 40 || now - gesture.lastZoom < 80)
+ return
+
+ gesture.delta = 0
+ gesture.lastZoom = now
+ button.click()
+ }, { capture: true, passive: false })
+})
+
+function findZoomButton(overlay: Element, direction: 'in' | 'out'): HTMLButtonElement | undefined {
+ if (overlay.matches('.n-image-preview-container'))
+ return overlay.querySelector(`[data-preview-zoom="${direction}"]`) ?? undefined
+
+ // markstream-vue 2.0.19's modal toolbar has zoom in, zoom out, reset, close
+ // in that order, without action attributes. Keep this adapter covered by a
+ // real-component test when upgrading the dependency; never match translated text.
+ const buttons = overlay.querySelectorAll(':scope > div:first-child > button.mermaid-action-btn')
+ return buttons[direction === 'in' ? 0 : 1]
+}