Merge pull request #6706 from ouchihao/codex/issue-3679-draggable-custom-page-button

feat(custom-page): make the open-link button draggable
This commit is contained in:
Wesley Liddick
2026-09-08 20:47:42 +08:00
committed by GitHub
2 changed files with 230 additions and 2 deletions
+69 -2
View File
@@ -94,12 +94,21 @@
</div>
<!-- Iframe embed mode -->
<div v-else class="custom-embed-shell">
<div v-else ref="embedShell" class="custom-embed-shell">
<a
ref="openButton"
:href="embeddedUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary btn-sm custom-open-fab"
:style="openButtonPosition ? { left: `${openButtonPosition.x}px`, top: `${openButtonPosition.y}px`, right: 'auto' } : undefined"
@pointerdown="startButtonDrag"
@pointermove="moveButtonDrag"
@pointerup="endButtonDrag"
@pointercancel="endButtonDrag"
@lostpointercapture="endButtonDrag"
@dragstart.prevent
@click="handleOpenButtonClick"
>
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
{{ t('customPage.openInNewTab') }}
@@ -118,6 +127,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useResizeObserver } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
@@ -150,6 +160,63 @@ const tocVisible = ref(typeof window !== 'undefined' ? window.innerWidth > 768 :
const activeHeadingId = ref('')
let themeObserver: MutationObserver | null = null
const embedShell = ref<HTMLElement | null>(null)
const openButton = ref<HTMLAnchorElement | null>(null)
const openButtonPosition = ref<{ x: number; y: number } | null>(null)
let buttonDrag: { pointerId: number; startX: number; startY: number; x: number; y: number } | null = null
let suppressButtonClick = false
function setOpenButtonPosition(x: number, y: number) {
const shell = embedShell.value
const button = openButton.value
if (!shell || !button) return
openButtonPosition.value = {
x: Math.max(0, Math.min(x, shell.clientWidth - button.offsetWidth)),
y: Math.max(0, Math.min(y, shell.clientHeight - button.offsetHeight)),
}
}
function startButtonDrag(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary || !openButton.value) return
const button = openButton.value
suppressButtonClick = false
buttonDrag = {
pointerId: event.pointerId, startX: event.clientX, startY: event.clientY,
x: button.offsetLeft, y: button.offsetTop,
}
// Capture keeps receiving moves when the pointer crosses the embedded iframe.
button.setPointerCapture(event.pointerId)
}
function moveButtonDrag(event: PointerEvent) {
if (!buttonDrag || event.pointerId !== buttonDrag.pointerId) return
const dx = event.clientX - buttonDrag.startX
const dy = event.clientY - buttonDrag.startY
if (!suppressButtonClick && Math.hypot(dx, dy) < 4) return
suppressButtonClick = true
setOpenButtonPosition(buttonDrag.x + dx, buttonDrag.y + dy)
event.preventDefault()
}
function endButtonDrag(event: PointerEvent) {
if (!buttonDrag || event.pointerId !== buttonDrag.pointerId) return
buttonDrag = null
if (openButton.value?.hasPointerCapture(event.pointerId)) {
openButton.value.releasePointerCapture(event.pointerId)
}
}
function handleOpenButtonClick(event: MouseEvent) {
if (suppressButtonClick && event.detail !== 0) event.preventDefault()
suppressButtonClick = false
}
useResizeObserver([embedShell, openButton], () => {
if (openButtonPosition.value) {
setOpenButtonPosition(openButtonPosition.value.x, openButtonPosition.value.y)
}
})
const menuItemId = computed(() => route.params.id as string)
const menuItem = computed(() => {
@@ -445,7 +512,7 @@ onUnmounted(() => {
}
.custom-open-fab {
@apply absolute right-3 top-3 z-10;
@apply absolute right-3 top-3 z-10 w-max max-w-full touch-none select-none transition-colors;
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:supports-[backdrop-filter]:bg-dark-800/80;
}
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import CustomPageView from '../CustomPageView.vue'
const { appStore } = vi.hoisted(() => ({
appStore: {
publicSettingsLoaded: true,
cachedPublicSettings: { custom_menu_items: [{ id: 'docs', url: 'https://example.com/docs' }] },
},
}))
vi.mock('@/components/layout/AppLayout.vue', () => ({ default: { template: '<div><slot /></div>' } }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { id: 'docs' } }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key, locale: { value: 'en' } }) }))
vi.mock('@/stores', () => ({ useAppStore: () => appStore }))
vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isAdmin: false, user: { id: 7 }, token: 'test-token' }) }))
vi.mock('@/stores/adminSettings', () => ({ useAdminSettingsStore: () => ({ customMenuItems: [] }) }))
vi.mock('@/api/client', () => ({ buildApiUrl: (path: string) => `/api/v1${path}` }))
let notifyResize: () => void
const wrappers: ReturnType<typeof mount>[] = []
function mountPage() {
const wrapper = mount(CustomPageView, {
global: { stubs: { AppLayout: { template: '<div><slot /></div>' }, Icon: true } },
})
wrappers.push(wrapper)
return wrapper
}
function mountEmbed() {
const wrapper = mountPage()
const shell = wrapper.get('.custom-embed-shell').element
const button = wrapper.get<HTMLAnchorElement>('.custom-open-fab').element
const size = { width: 800, height: 600 }
let capturedPointer: number | null = null
Object.defineProperties(shell, {
clientWidth: { get: () => size.width }, clientHeight: { get: () => size.height },
})
Object.defineProperties(button, {
offsetWidth: { value: 100 }, offsetHeight: { value: 32 },
offsetLeft: { get: () => Number.parseFloat(button.style.left || '688') },
offsetTop: { get: () => Number.parseFloat(button.style.top || '12') },
setPointerCapture: { value: vi.fn((id: number) => { capturedPointer = id }) },
hasPointerCapture: { value: (id: number) => capturedPointer === id },
releasePointerCapture: { value: vi.fn(() => { capturedPointer = null }) },
})
return { wrapper, button, size }
}
async function pointer(button: HTMLElement, type: string, x: number, y: number, extra = {}) {
const event = new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true, ...extra })
Object.defineProperties(event, {
pointerId: { value: 1 }, isPrimary: { value: true },
})
button.dispatchEvent(event)
await nextTick()
}
function click(button: HTMLElement, detail = 1) {
const event = new MouseEvent('click', { bubbles: true, cancelable: true, detail })
button.dispatchEvent(event)
return event
}
describe('custom page open button', () => {
beforeEach(() => {
appStore.cachedPublicSettings.custom_menu_items = [{ id: 'docs', url: 'https://example.com/docs' }]
vi.stubGlobal('ResizeObserver', class {
constructor(callback: () => void) { notifyResize = callback }
observe() {}
disconnect() {}
})
})
afterEach(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
vi.unstubAllGlobals()
})
it('preserves the embedded URL, secure link attributes, and normal clicks with small pointer movements', async () => {
const { wrapper, button } = mountEmbed()
expect(button.href).toBe(wrapper.get('iframe').attributes('src'))
expect(button.href).toContain('user_id=7')
expect(button.href).toContain('token=test-token')
expect(button.target).toBe('_blank')
expect(button.rel).toBe('noopener noreferrer')
await pointer(button, 'pointerdown', 700, 24)
await pointer(button, 'pointermove', 702, 25)
await pointer(button, 'pointerup', 702, 25)
expect(button.style.left).toBe('')
expect(click(button).defaultPrevented).toBe(false)
expect(click(button, 0).defaultPrevented).toBe(false)
})
it('captures the pointer across iframe content and suppresses only the click following a drag', async () => {
const { button } = mountEmbed()
await pointer(button, 'pointerdown', 700, 24)
expect(button.setPointerCapture).toHaveBeenCalledWith(1)
await pointer(button, 'pointermove', 200, 124)
expect(button.style.left).toBe('188px')
expect(button.style.top).toBe('112px')
await pointer(button, 'pointerup', 200, 124)
expect(button.releasePointerCapture).toHaveBeenCalledWith(1)
expect(click(button).defaultPrevented).toBe(true)
expect(click(button).defaultPrevented).toBe(false)
await pointer(button, 'pointerdown', 200, 124)
await pointer(button, 'pointermove', 220, 124)
await pointer(button, 'pointerup', 220, 124)
expect(click(button, 0).defaultPrevented).toBe(false)
})
it('keeps the button inside each boundary and reachable when the container shrinks', async () => {
const { button, size } = mountEmbed()
await pointer(button, 'pointerdown', 700, 24)
await pointer(button, 'pointermove', -1000, -1000)
expect([button.style.left, button.style.top]).toEqual(['0px', '0px'])
await pointer(button, 'pointermove', 2000, 2000)
expect([button.style.left, button.style.top]).toEqual(['700px', '568px'])
await pointer(button, 'pointerup', 2000, 2000)
size.width = 300
size.height = 200
notifyResize()
await nextTick()
expect([button.style.left, button.style.top]).toEqual(['200px', '168px'])
})
it('stops moving on cancellation or lost pointer capture and permits the next normal click', async () => {
const { button } = mountEmbed()
for (const endEvent of ['pointercancel', 'lostpointercapture']) {
await pointer(button, 'pointerdown', 700, 24)
await pointer(button, 'pointermove', 500, 124)
await pointer(button, endEvent, 500, 124)
const position = button.style.cssText
await pointer(button, 'pointermove', 400, 224)
expect(button.style.cssText).toBe(position)
await pointer(button, 'pointerdown', 500, 124)
await pointer(button, 'pointerup', 500, 124)
expect(click(button).defaultPrevented).toBe(false)
}
})
it('leaves secondary mouse button gestures alone', async () => {
const { button } = mountEmbed()
await pointer(button, 'pointerdown', 700, 24, { button: 2 })
await pointer(button, 'pointermove', 500, 124)
expect(button.setPointerCapture).not.toHaveBeenCalled()
expect(button.style.left).toBe('')
})
it('keeps Markdown pages separate from the embedded-page controls', async () => {
appStore.cachedPublicSettings.custom_menu_items = [{ id: 'docs', url: 'md:guide' }]
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, text: async () => '# Guide' }))
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('.custom-open-fab').exists()).toBe(false)
expect(wrapper.find('iframe').exists()).toBe(false)
expect(wrapper.get('.markdown-page-content h1').text()).toBe('Guide')
})
})