Browse Source

feat(quick-note): enhance quick note editor with content change events and mobile responsiveness

- Added a new event for content changes in the Quick Note Editor to improve synchronization with the modal state.
- Implemented mobile viewport detection to adjust the editor's behavior and layout accordingly.
- Updated the Quick Note Modal to handle editor content changes and improve resizing logic based on viewport size.
- Introduced cache control headers for the Vditor route to optimize performance.

These changes enhance the user experience by ensuring better content management and responsiveness in the Quick Note feature.
main
npmrun 2 weeks ago
parent
commit
2382dad188
  1. 10
      app/components/AppShell.vue
  2. 20
      app/components/QuickNoteEditor.vue
  3. 77
      app/components/QuickNoteModal.vue
  4. 1
      app/components/post-body-markdown-editor-vditor-config.test.ts
  5. 1
      app/components/post-body-markdown-editor-vditor-config.ts
  6. 5
      nuxt.config.ts
  7. BIN
      packages/drizzle-pkg/db.sqlite

10
app/components/AppShell.vue

@ -191,6 +191,16 @@ async function logout() {
variant="soft"
icon="i-lucide-notebook-pen"
size="sm"
class="md:hidden"
aria-label="打开速记"
@click="quickNoteModalOpen = true"
/>
<UButton
v-if="showQuickCreate"
color="neutral"
variant="soft"
icon="i-lucide-notebook-pen"
size="sm"
class="hidden md:inline-flex"
@click="quickNoteModalOpen = true"
>

20
app/components/QuickNoteEditor.vue

@ -11,6 +11,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [string]
'content-change': [string]
}>()
const toast = useToast()
@ -34,7 +35,10 @@ const bridge = createPostBodyMarkdownEditorBridge({
}
return new Vditor(element, buildQuickNoteEditorVditorOptions({
value,
onInput,
onInput: (nextValue) => {
emit('content-change', nextValue)
onInput(nextValue)
},
onUploadError: () => {
toast.add({ title: '图片上传失败', color: 'warning' })
},
@ -47,12 +51,22 @@ function flushValue() {
if (typeof current !== 'string') {
return
}
emit('content-change', current)
if (current === props.modelValue) {
return
}
emit('update:modelValue', current)
}
function triggerImmediateSync() {
if (!import.meta.client) {
return
}
requestAnimationFrame(() => {
flushValue()
})
}
defineExpose<{
flushValue: () => void
}>({
@ -111,6 +125,10 @@ onBeforeUnmount(() => {
</div>
<div
v-else
@keyup.capture="triggerImmediateSync"
@compositionend.capture="triggerImmediateSync"
@paste.capture="triggerImmediateSync"
@cut.capture="triggerImmediateSync"
ref="mountEl"
class="h-full w-full min-w-0 rounded-lg overflow-hidden ring ring-default"
/>

77
app/components/QuickNoteModal.vue

@ -30,10 +30,12 @@ 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 syncDraftTimer: ReturnType<typeof setInterval> | 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)
@ -63,13 +65,21 @@ const draftContent = computed({
})
const isDirty = computed(() => modalState.value.isDirty)
const editorResizeSignal = computed(() => `${modalRect.width}x${modalRect.height}-${fullScreen.value ? 'full' : 'windowed'}`)
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
@ -79,28 +89,8 @@ function reloadEditor() {
})
}
function startDraftSyncTimer() {
if (!import.meta.client || syncDraftTimer) {
return
}
syncDraftTimer = setInterval(() => {
if (!open.value || loading.value || saving.value) {
return
}
syncDraftFromEditor()
}, 120)
}
function stopDraftSyncTimer() {
if (!syncDraftTimer) {
return
}
clearInterval(syncDraftTimer)
syncDraftTimer = null
}
const cardStyle = computed(() => {
if (fullScreen.value) {
if (effectiveFullscreen.value) {
return {}
}
return {
@ -112,7 +102,7 @@ const cardStyle = computed(() => {
})
const cardClass = computed(() => {
if (fullScreen.value) {
if (effectiveFullscreen.value) {
return 'h-full w-full rounded-none'
}
return 'absolute max-w-none rounded-xl'
@ -137,6 +127,13 @@ 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,
@ -162,7 +159,7 @@ function updateModalSizeFromViewport() {
}
function handlePointerMove(event: PointerEvent) {
if (resizing.value && !fullScreen.value) {
if (resizing.value && !effectiveFullscreen.value) {
if (!import.meta.client) {
return
}
@ -205,7 +202,7 @@ function handlePointerMove(event: PointerEvent) {
modalRect.height = clampedRect.height
return
}
if (!dragging.value || fullScreen.value) {
if (!dragging.value || effectiveFullscreen.value) {
return
}
const clamped = clampModalRect({
@ -224,7 +221,7 @@ function handlePointerMove(event: PointerEvent) {
}
function startDragging(event: PointerEvent) {
if (fullScreen.value || event.button !== 0) {
if (effectiveFullscreen.value || event.button !== 0) {
return
}
dragging.value = true
@ -250,7 +247,7 @@ function stopResizing() {
}
function startResizing(event: PointerEvent, direction: ResizeDirection) {
if (fullScreen.value || event.button !== 0 || !import.meta.client) {
if (effectiveFullscreen.value || event.button !== 0 || !import.meta.client) {
return
}
resizing.value = true
@ -266,6 +263,9 @@ function startResizing(event: PointerEvent, direction: ResizeDirection) {
}
function toggleFullScreen() {
if (isMobileViewport.value) {
return
}
fullScreen.value = !fullScreen.value
if (fullScreen.value) {
stopDragging()
@ -365,13 +365,11 @@ watch(
}
allowSilentClose.value = false
if (!isOpen) {
stopDraftSyncTimer()
fullScreen.value = false
stopDragging()
stopResizing()
return
}
startDraftSyncTimer()
updateModalSizeFromViewport()
void loadQuickNote()
},
@ -381,6 +379,13 @@ 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) {
@ -393,8 +398,12 @@ onMounted(() => {
})
onBeforeUnmount(() => {
stopDraftSyncTimer()
if (import.meta.client) {
if (viewportMql && onViewportChange) {
viewportMql.removeEventListener('change', onViewportChange)
}
onViewportChange = null
viewportMql = null
removeRouteGuard?.()
removeRouteGuard = null
window.removeEventListener('beforeunload', handleBeforeUnload)
@ -426,7 +435,7 @@ onBeforeUnmount(() => {
<template #header>
<div
class="flex items-center justify-between gap-3 select-none"
:class="fullScreen ? 'cursor-default' : 'cursor-move'"
:class="effectiveFullscreen ? 'cursor-default' : 'cursor-move'"
@pointerdown="startDragging"
>
<div class="min-w-0">
@ -447,6 +456,7 @@ onBeforeUnmount(() => {
@click="reloadEditor"
/>
<UButton
v-if="!isMobileViewport"
color="neutral"
variant="ghost"
size="sm"
@ -490,6 +500,7 @@ onBeforeUnmount(() => {
ref="quickNoteEditorRef"
v-model="draftContent"
:resize-signal="editorResizeSignal"
@content-change="handleEditorContentChange"
/>
</div>
</div>
@ -522,7 +533,7 @@ onBeforeUnmount(() => {
</div>
</template>
</UCard>
<template v-if="!fullScreen">
<template v-if="!effectiveFullscreen">
<button
type="button"
class="absolute left-0 top-0 z-10 h-full w-1 cursor-w-resize"

1
app/components/post-body-markdown-editor-vditor-config.test.ts

@ -16,6 +16,7 @@ describe('PostBodyMarkdownEditor Vditor config', () => {
expect(options.mode).toBe('ir')
expect(options.lang).toBe('zh_CN')
expect(options.cdn).toBe('/vditor')
expect(options.icon).toBe('material')
expect(options.preview).toEqual({
mode: 'editor',
actions: [],

1
app/components/post-body-markdown-editor-vditor-config.ts

@ -55,6 +55,7 @@ export function buildPostBodyMarkdownEditorVditorOptions(input: BuildVditorOptio
value: input.value,
lang: 'zh_CN',
cdn: '/vditor',
icon: 'material',
cache: { enable: false },
mode: 'ir',
preview: {

5
nuxt.config.ts

@ -48,6 +48,11 @@ export default defineNuxtConfig({
devtools: { enabled: true },
/** 探针路径与 `server/middleware/00.cloud-probe.ts` 一致:禁止 CDN/LB 长期缓存探测响应 */
routeRules: {
'/vditor/**': {
headers: {
'cache-control': 'public, max-age=31536000, immutable',
},
},
...Object.fromEntries(
CLOUD_PROBE_PATHS.map((path) => [
path,

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save