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
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>
|
|
|