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] +}