Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/buddy/src/shared/ui/markdown/DesktopMermaidBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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()
Expand All @@ -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"
/>
</template>
Expand Down
13 changes: 8 additions & 5 deletions apps/buddy/src/shared/ui/media/BuddyImagePreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,7 @@ const show = defineModel<boolean>('show', { default: false })

const { t } = useBuddyI18n(() => props.language)
const sourceList = computed(() => [...props.sources])
usePreviewWheelZoom()
const renderToolbar: NonNullable<ImageGroupProps['renderToolbar']> = ({ nodes }) => h(
'div',
{ class: 'buddy-image-preview-toolbar' },
Expand All @@ -25,23 +27,24 @@ const renderToolbar: NonNullable<ImageGroupProps['renderToolbar']> = ({ 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),
Expand Down
212 changes: 212 additions & 0 deletions apps/buddy/src/shared/ui/media/__tests__/usePreviewWheelZoom.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '<div><button class="mermaid-action-btn" data-preview-zoom="in"></button><button class="mermaid-action-btn" data-preview-zoom="out"></button></div><div class="content"><svg><path /></svg></div>'
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<HTMLImageElement>('.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<HTMLButtonElement>('[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: `<svg id="${id}" xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100"><rect width="100" height="50" /></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<HTMLButtonElement>('button[aria-label="Open"], button[aria-label="打开"]')
expect(open).not.toBeNull()
}, { timeout: 5_000 })
host.querySelector<HTMLButtonElement>('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)
})
})
63 changes: 63 additions & 0 deletions apps/buddy/src/shared/ui/media/usePreviewWheelZoom.ts
Original file line number Diff line number Diff line change
@@ -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<Element, WheelGesture>()

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<HTMLButtonElement>(`[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<HTMLButtonElement>(':scope > div:first-child > button.mermaid-action-btn')
return buttons[direction === 'in' ? 0 : 1]
}