You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

589 lines
17 KiB

<script setup lang="ts">
import QuickNoteEditor from './QuickNoteEditor.vue'
import {
createQuickNoteModalState,
markSaveFailed,
markSaveSucceeded,
updateDraftContent,
} from './quick-note-modal-state'
import {
clampModalRect,
createDefaultModalRect,
} from './quick-note-modal-layout'
interface QuickNotePayload {
quickNote: {
content: string
updatedAt: string | null
}
}
const open = defineModel<boolean>('open', { default: false })
const router = useRouter()
const { fetchData, getApiErrorMessage } = useClientApi()
const toast = useToast()
const modalState = ref(createQuickNoteModalState(''))
const quickNoteEditorRef = ref<null | { flushValue: () => void }>(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
const fullScreen = ref(false)
const isMobileViewport = ref(false)
const lastSavedAt = ref<string | null>(null)
const allowSilentClose = ref(false)
let removeRouteGuard: (() => void) | null = null
let viewportMql: MediaQueryList | null = null
let onViewportChange: ((event: MediaQueryListEvent) => void) | null = null
const modalRect = reactive({ left: 0, top: 0, width: 0, height: 0 })
const dragging = ref(false)
const resizing = ref(false)
type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
let resizeDirection: ResizeDirection = 'se'
let dragStartPointerX = 0
let dragStartPointerY = 0
let dragStartLeft = 0
let dragStartTop = 0
let resizeStartPointerX = 0
let resizeStartPointerY = 0
let resizeStartWidth = 0
let resizeStartHeight = 0
let resizeStartLeft = 0
let resizeStartTop = 0
const MODAL_MARGIN_PX = 24
const MIN_MODAL_WIDTH_PX = 640
const MIN_MODAL_HEIGHT_PX = 420
const draftContent = computed({
get: () => modalState.value.draftContent,
set: (value: string) => {
modalState.value = updateDraftContent(modalState.value, value)
},
})
const isDirty = computed(() => modalState.value.isDirty)
const effectiveFullscreen = computed(() => fullScreen.value || isMobileViewport.value)
const editorResizeSignal = computed(() => `${modalRect.width}x${modalRect.height}-${effectiveFullscreen.value ? 'full' : 'windowed'}`)
const editorReloadToken = ref(0)
function syncDraftFromEditor() {
quickNoteEditorRef.value?.flushValue()
}
function handleEditorContentChange(nextValue: string) {
if (nextValue === modalState.value.draftContent) {
return
}
modalState.value = updateDraftContent(modalState.value, nextValue)
}
function reloadEditor() {
syncDraftFromEditor()
editorReloadToken.value += 1
toast.add({
title: '编辑器已重新加载',
color: 'info',
})
}
const cardStyle = computed(() => {
if (effectiveFullscreen.value) {
return {}
}
return {
left: `${modalRect.left}px`,
top: `${modalRect.top}px`,
width: `${modalRect.width}px`,
height: `${modalRect.height}px`,
}
})
const cardClass = computed(() => {
if (effectiveFullscreen.value) {
return 'h-full w-full rounded-none'
}
return 'absolute max-w-none rounded-xl'
})
const overlayClass = computed(() => {
return 'fixed inset-0 z-[60] bg-black/35 backdrop-blur-[1px] pointer-events-none'
})
function stopDragging() {
if (!dragging.value) {
return
}
dragging.value = false
if (import.meta.client) {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopDragging)
}
}
function updateModalSizeFromViewport() {
if (!import.meta.client) {
return
}
if (isMobileViewport.value) {
modalRect.left = 0
modalRect.top = 0
modalRect.width = window.innerWidth
modalRect.height = window.innerHeight
return
}
const next = createDefaultModalRect({
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
margin: MODAL_MARGIN_PX,
minWidth: MIN_MODAL_WIDTH_PX,
minHeight: MIN_MODAL_HEIGHT_PX,
})
const clamped = clampModalRect({
left: modalRect.left || next.left,
top: modalRect.top || next.top,
width: modalRect.width || next.width,
height: modalRect.height || next.height,
minWidth: MIN_MODAL_WIDTH_PX,
minHeight: MIN_MODAL_HEIGHT_PX,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
margin: MODAL_MARGIN_PX,
})
modalRect.left = clamped.left
modalRect.top = clamped.top
modalRect.width = clamped.width
modalRect.height = clamped.height
}
function handlePointerMove(event: PointerEvent) {
if (resizing.value && !effectiveFullscreen.value) {
if (!import.meta.client) {
return
}
const deltaX = event.clientX - resizeStartPointerX
const deltaY = event.clientY - resizeStartPointerY
let nextLeft = resizeStartLeft
let nextTop = resizeStartTop
let nextWidth = resizeStartWidth
let nextHeight = resizeStartHeight
if (resizeDirection.includes('e')) {
nextWidth = resizeStartWidth + deltaX
}
if (resizeDirection.includes('s')) {
nextHeight = resizeStartHeight + deltaY
}
if (resizeDirection.includes('w')) {
nextLeft = resizeStartLeft + deltaX
nextWidth = resizeStartWidth - deltaX
}
if (resizeDirection.includes('n')) {
nextTop = resizeStartTop + deltaY
nextHeight = resizeStartHeight - deltaY
}
const clampedRect = clampModalRect({
left: nextLeft,
top: nextTop,
width: nextWidth,
height: nextHeight,
minWidth: MIN_MODAL_WIDTH_PX,
minHeight: MIN_MODAL_HEIGHT_PX,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
margin: MODAL_MARGIN_PX,
})
modalRect.left = clampedRect.left
modalRect.top = clampedRect.top
modalRect.width = clampedRect.width
modalRect.height = clampedRect.height
return
}
if (!dragging.value || effectiveFullscreen.value) {
return
}
const clamped = clampModalRect({
left: dragStartLeft + (event.clientX - dragStartPointerX),
top: dragStartTop + (event.clientY - dragStartPointerY),
width: modalRect.width,
height: modalRect.height,
minWidth: MIN_MODAL_WIDTH_PX,
minHeight: MIN_MODAL_HEIGHT_PX,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
margin: MODAL_MARGIN_PX,
})
modalRect.left = clamped.left
modalRect.top = clamped.top
}
function startDragging(event: PointerEvent) {
if (effectiveFullscreen.value || event.button !== 0) {
return
}
dragging.value = true
dragStartPointerX = event.clientX
dragStartPointerY = event.clientY
dragStartLeft = modalRect.left
dragStartTop = modalRect.top
if (import.meta.client) {
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', stopDragging)
}
}
function stopResizing() {
if (!resizing.value) {
return
}
resizing.value = false
if (import.meta.client) {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', stopResizing)
}
}
function startResizing(event: PointerEvent, direction: ResizeDirection) {
if (effectiveFullscreen.value || event.button !== 0 || !import.meta.client) {
return
}
resizing.value = true
resizeDirection = direction
resizeStartPointerX = event.clientX
resizeStartPointerY = event.clientY
resizeStartLeft = modalRect.left
resizeStartTop = modalRect.top
resizeStartWidth = modalRect.width
resizeStartHeight = modalRect.height
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', stopResizing)
}
function toggleFullScreen() {
if (isMobileViewport.value) {
return
}
fullScreen.value = !fullScreen.value
if (fullScreen.value) {
stopDragging()
stopResizing()
} else if (modalRect.width === 0 || modalRect.height === 0) {
updateModalSizeFromViewport()
}
}
async function loadQuickNote() {
loading.value = true
loadError.value = ''
try {
const data = await fetchData<QuickNotePayload>('/api/me/quick-note')
modalState.value = createQuickNoteModalState(data.quickNote.content ?? '')
lastSavedAt.value = data.quickNote.updatedAt
} catch (error) {
loadError.value = getApiErrorMessage(error)
modalState.value = createQuickNoteModalState('')
toast.add({
title: `速记加载失败:${loadError.value}`,
color: 'error',
})
} finally {
loading.value = false
}
}
async function saveQuickNote() {
if (saving.value) {
return
}
saving.value = true
try {
const data = await fetchData<QuickNotePayload>('/api/me/quick-note', {
method: 'PUT',
body: {
content: modalState.value.draftContent,
},
})
modalState.value = markSaveSucceeded(modalState.value)
lastSavedAt.value = data.quickNote.updatedAt
toast.add({
title: '速记已保存',
color: 'success',
})
} catch (error) {
modalState.value = markSaveFailed(modalState.value)
toast.add({
title: `保存失败:${getApiErrorMessage(error)}`,
color: 'error',
})
} finally {
saving.value = false
}
}
function requestClose() {
syncDraftFromEditor()
if (isDirty.value && !confirmDiscard()) {
return
}
closeModal()
}
function confirmDiscard(): boolean {
if (!import.meta.client) {
return false
}
return window.confirm('当前速记有未保存内容,确认后将丢失本次修改。')
}
function closeModal() {
allowSilentClose.value = true
open.value = false
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
syncDraftFromEditor()
if (!open.value || !isDirty.value) {
return
}
event.preventDefault()
event.returnValue = ''
}
watch(
() => open.value,
(isOpen, wasOpen) => {
if (wasOpen && !isOpen && !allowSilentClose.value && isDirty.value) {
if (confirmDiscard()) {
allowSilentClose.value = true
return
}
open.value = true
return
}
allowSilentClose.value = false
if (!isOpen) {
fullScreen.value = false
stopDragging()
stopResizing()
return
}
updateModalSizeFromViewport()
void loadQuickNote()
},
)
onMounted(() => {
if (!import.meta.client) {
return
}
viewportMql = window.matchMedia('(max-width: 767px)')
isMobileViewport.value = viewportMql.matches
onViewportChange = (event: MediaQueryListEvent) => {
isMobileViewport.value = event.matches
updateModalSizeFromViewport()
}
viewportMql.addEventListener('change', onViewportChange)
removeRouteGuard = router.beforeEach((to) => {
syncDraftFromEditor()
if (!open.value || !isDirty.value) {
return true
}
return confirmDiscard()
})
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('resize', updateModalSizeFromViewport)
})
onBeforeUnmount(() => {
if (import.meta.client) {
if (viewportMql && onViewportChange) {
viewportMql.removeEventListener('change', onViewportChange)
}
onViewportChange = null
viewportMql = null
removeRouteGuard?.()
removeRouteGuard = null
window.removeEventListener('beforeunload', handleBeforeUnload)
window.removeEventListener('resize', updateModalSizeFromViewport)
}
stopDragging()
stopResizing()
})
</script>
<template>
<Teleport to="body">
<div
v-if="open"
:class="overlayClass"
>
<div
:class="cardClass"
:style="cardStyle"
class="relative pointer-events-auto"
>
<UCard
class="h-full w-full"
:ui="{
body: 'h-[calc(100%-7rem)] p-3 md:p-4',
footer: 'border-t border-default/80',
}"
>
<template #header>
<div
class="flex items-center justify-between gap-3 select-none"
:class="effectiveFullscreen ? 'cursor-default' : 'cursor-move'"
@pointerdown="startDragging"
>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-highlighted">
速记
</p>
<p class="truncate text-xs text-muted">
{{ lastSavedAt ? `上次保存:${new Date(lastSavedAt).toLocaleString()}` : '尚未保存过内容' }}
</p>
</div>
<div class="flex items-center gap-2" @pointerdown.stop>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-refresh-cw"
aria-label="重新加载编辑器"
@click="reloadEditor"
/>
<UButton
v-if="!isMobileViewport"
color="neutral"
variant="ghost"
size="sm"
:icon="fullScreen ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
:aria-label="fullScreen ? '退出全屏' : '全屏'"
@click="toggleFullScreen"
/>
<UButton
color="neutral"
variant="ghost"
size="sm"
icon="i-lucide-x"
aria-label="关闭速记"
@click="requestClose"
/>
</div>
</div>
</template>
<div class="flex h-full min-h-0 flex-col gap-3">
<UAlert
v-if="loadError"
color="error"
variant="subtle"
title="速记内容加载失败"
:description="loadError"
/>
<UAlert
v-if="modalState.lastSaveFailed"
color="warning"
variant="subtle"
title="最近一次保存失败"
description="请检查网络后重试保存。"
/>
<div v-if="loading" class="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-default bg-elevated/40">
<UIcon name="i-lucide-loader-2" class="size-5 animate-spin text-primary" />
</div>
<div v-else class="min-h-0 flex-1">
<QuickNoteEditor
:key="editorReloadToken"
ref="quickNoteEditorRef"
v-model="draftContent"
:resize-signal="editorResizeSignal"
@content-change="handleEditorContentChange"
/>
</div>
</div>
<template #footer>
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-muted">
{{ isDirty ? '有未保存修改' : '已保存' }}
</span>
<div class="flex items-center gap-2">
<UButton
color="neutral"
variant="outline"
size="sm"
:disabled="loading || saving"
@click="requestClose"
>
关闭
</UButton>
<UButton
color="primary"
size="sm"
:loading="saving"
:disabled="loading"
@click="saveQuickNote"
>
保存
</UButton>
</div>
</div>
</template>
</UCard>
<template v-if="!effectiveFullscreen">
<button
type="button"
class="absolute left-0 top-0 z-10 h-full w-1 cursor-w-resize"
aria-label="从左侧调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'w')"
/>
<button
type="button"
class="absolute right-0 top-0 z-10 h-full w-1 cursor-e-resize"
aria-label="从右侧调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'e')"
/>
<button
type="button"
class="absolute left-0 top-0 z-10 h-1 w-full cursor-n-resize"
aria-label="从上侧调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'n')"
/>
<button
type="button"
class="absolute bottom-0 left-0 z-10 h-1 w-full cursor-s-resize"
aria-label="从下侧调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 's')"
/>
<button
type="button"
class="absolute left-0 top-0 z-20 h-3 w-3 cursor-nw-resize"
aria-label="从左上角调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'nw')"
/>
<button
type="button"
class="absolute right-0 top-0 z-20 h-3 w-3 cursor-ne-resize"
aria-label="从右上角调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'ne')"
/>
<button
type="button"
class="absolute bottom-0 left-0 z-20 h-3 w-3 cursor-sw-resize"
aria-label="从左下角调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'sw')"
/>
<button
type="button"
class="absolute bottom-0 right-0 z-20 h-3 w-3 cursor-se-resize"
aria-label="从右下角调整速记弹框尺寸"
@pointerdown.prevent.stop="startResizing($event, 'se')"
/>
</template>
</div>
</div>
</Teleport>
</template>