This commit is contained in:
2026-08-05 19:01:52 +08:00
parent cef594cc86
commit 6a1b49c385
25 changed files with 1354 additions and 109 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { CSSProperties, HTMLAttributes } from 'vue'
import {
DialogContent,
type DialogContentEmits,
@@ -8,23 +8,108 @@ import {
} from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const props = withDefaults(
defineProps<
DialogContentProps & {
class?: HTMLAttributes['class']
draggable?: boolean
}
>(),
{ draggable: true },
)
const emits = defineEmits<DialogContentEmits>()
const dragOffset = ref({ x: 0, y: 0 })
let activePointerId: number | undefined
let dragOrigin = { clientX: 0, clientY: 0, x: 0, y: 0 }
let dragBounds = { minX: 0, maxX: 0, minY: 0, maxY: 0 }
const delegatedProps = computed(() => {
const { class: _class, ...delegated } = props
const { class: _class, draggable: _draggable, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const dragStyle = computed<CSSProperties>(() => ({
'--ui-dialog-drag-x': `${dragOffset.value.x}px`,
'--ui-dialog-drag-y': `${dragOffset.value.y}px`,
}))
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function stopDragging() {
activePointerId = undefined
document.documentElement.classList.remove('is-dialog-dragging')
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}
function resetPosition() {
stopDragging()
dragOffset.value = { x: 0, y: 0 }
}
function handlePointerMove(event: PointerEvent) {
if (activePointerId !== event.pointerId) return
const deltaX = event.clientX - dragOrigin.clientX
const deltaY = event.clientY - dragOrigin.clientY
dragOffset.value = {
x: dragOrigin.x + clamp(deltaX, dragBounds.minX, dragBounds.maxX),
y: dragOrigin.y + clamp(deltaY, dragBounds.minY, dragBounds.maxY),
}
}
function handlePointerDown(event: PointerEvent) {
if (!props.draggable || event.button !== 0) return
const target = event.target
if (!(target instanceof Element)) return
const handle = target.closest<HTMLElement>('[data-ui-dialog-drag-handle]')
if (!handle || target.closest('button, a, input, textarea, select, [data-ui-dialog-no-drag]'))
return
const content = event.currentTarget
if (!(content instanceof HTMLElement)) return
const rect = content.getBoundingClientRect()
const viewportMargin = 8
activePointerId = event.pointerId
dragOrigin = {
clientX: event.clientX,
clientY: event.clientY,
x: dragOffset.value.x,
y: dragOffset.value.y,
}
dragBounds = {
minX: viewportMargin - rect.left,
maxX: window.innerWidth - viewportMargin - rect.right,
minY: viewportMargin - rect.top,
maxY: window.innerHeight - viewportMargin - rect.bottom,
}
document.documentElement.classList.add('is-dialog-dragging')
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
event.preventDefault()
}
onMounted(() => window.addEventListener('resize', resetPosition))
onBeforeUnmount(() => {
stopDragging()
window.removeEventListener('resize', resetPosition)
})
</script>
<template>
<DialogContent
v-bind="forwarded"
:style="dragStyle"
:data-draggable="draggable ? '' : undefined"
@pointerdown="handlePointerDown"
@open-auto-focus="resetPosition"
:class="
cn(
'ui-dialog-content fixed left-1/2 top-1/2 z-50 grid -translate-x-1/2 -translate-y-1/2 outline-none data-[state=closed]:animate-[dialog-out_120ms_ease-in] data-[state=open]:animate-[dialog-in_160ms_ease-out]',
'ui-dialog-content fixed left-1/2 top-1/2 z-50 grid outline-none data-[state=closed]:animate-[dialog-out_120ms_ease-in] data-[state=open]:animate-[dialog-in_160ms_ease-out]',
props.class,
)
"
@@ -36,6 +121,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<style scoped>
.ui-dialog-content {
width: min(var(--ui-dialog-viewport-inline-size), var(--ui-dialog-max-inline-size));
max-height: calc(100dvh - 16px);
overflow: hidden;
border: 1px solid var(--ui-dialog-border);
border-radius: 0;
@@ -44,5 +130,16 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
0 18px 38px var(--ui-dialog-shadow),
inset 0 0 0 1px var(--ui-dialog-inset);
color: var(--ui-surface-foreground);
transform: translate(
calc(-50% + var(--ui-dialog-drag-x, 0px)),
calc(-50% + var(--ui-dialog-drag-y, 0px))
);
will-change: transform;
}
:global(.is-dialog-dragging),
:global(.is-dialog-dragging *) {
cursor: grabbing !important;
user-select: none !important;
}
</style>
@@ -28,7 +28,11 @@ onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div class="ui-dialog-header" :class="{ 'is-focused': focused, 'is-unfocused': !focused }">
<div
class="ui-dialog-header"
:class="{ 'is-focused': focused, 'is-unfocused': !focused }"
data-ui-dialog-drag-handle
>
<img
v-if="focused && effectFrames.length"
class="ui-dialog-header__effect"
@@ -60,6 +64,9 @@ onBeforeUnmount(() => clearInterval(timer))
background: var(--ui-dialog-titlebar-background);
color: var(--ui-titlebar-foreground);
text-shadow: 0 1px 1px var(--ui-dialog-titlebar-text-shadow);
touch-action: none;
user-select: none;
cursor: grab;
}
.ui-dialog-header.is-focused {
@@ -87,6 +94,10 @@ onBeforeUnmount(() => clearInterval(timer))
position: relative;
z-index: 2;
grid-column: 2;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-align: center;
}
.ui-dialog-header__actions {
@@ -18,9 +18,15 @@ const delegatedProps = computed(() => {
<style scoped>
.ui-dialog-title {
display: block;
min-width: 0;
overflow: hidden;
color: var(--ui-titlebar-foreground);
font-size: var(--ui-control-font-size-default);
font-weight: 400;
line-height: var(--ui-control-line-height);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>