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" variant="soft"
icon="i-lucide-notebook-pen" icon="i-lucide-notebook-pen"
size="sm" 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" class="hidden md:inline-flex"
@click="quickNoteModalOpen = true" @click="quickNoteModalOpen = true"
> >

20
app/components/QuickNoteEditor.vue

@ -11,6 +11,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [string] 'update:modelValue': [string]
'content-change': [string]
}>() }>()
const toast = useToast() const toast = useToast()
@ -34,7 +35,10 @@ const bridge = createPostBodyMarkdownEditorBridge({
} }
return new Vditor(element, buildQuickNoteEditorVditorOptions({ return new Vditor(element, buildQuickNoteEditorVditorOptions({
value, value,
onInput, onInput: (nextValue) => {
emit('content-change', nextValue)
onInput(nextValue)
},
onUploadError: () => { onUploadError: () => {
toast.add({ title: '图片上传失败', color: 'warning' }) toast.add({ title: '图片上传失败', color: 'warning' })
}, },
@ -47,12 +51,22 @@ function flushValue() {
if (typeof current !== 'string') { if (typeof current !== 'string') {
return return
} }
emit('content-change', current)
if (current === props.modelValue) { if (current === props.modelValue) {
return return
} }
emit('update:modelValue', current) emit('update:modelValue', current)
} }
function triggerImmediateSync() {
if (!import.meta.client) {
return
}
requestAnimationFrame(() => {
flushValue()
})
}
defineExpose<{ defineExpose<{
flushValue: () => void flushValue: () => void
}>({ }>({
@ -111,6 +125,10 @@ onBeforeUnmount(() => {
</div> </div>
<div <div
v-else v-else
@keyup.capture="triggerImmediateSync"
@compositionend.capture="triggerImmediateSync"
@paste.capture="triggerImmediateSync"
@cut.capture="triggerImmediateSync"
ref="mountEl" ref="mountEl"
class="h-full w-full min-w-0 rounded-lg overflow-hidden ring ring-default" 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 saving = ref(false)
const loadError = ref('') const loadError = ref('')
const fullScreen = ref(false) const fullScreen = ref(false)
const isMobileViewport = ref(false)
const lastSavedAt = ref<string | null>(null) const lastSavedAt = ref<string | null>(null)
const allowSilentClose = ref(false) const allowSilentClose = ref(false)
let removeRouteGuard: (() => void) | null = null 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 modalRect = reactive({ left: 0, top: 0, width: 0, height: 0 })
const dragging = ref(false) const dragging = ref(false)
@ -63,13 +65,21 @@ const draftContent = computed({
}) })
const isDirty = computed(() => modalState.value.isDirty) 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) const editorReloadToken = ref(0)
function syncDraftFromEditor() { function syncDraftFromEditor() {
quickNoteEditorRef.value?.flushValue() quickNoteEditorRef.value?.flushValue()
} }
function handleEditorContentChange(nextValue: string) {
if (nextValue === modalState.value.draftContent) {
return
}
modalState.value = updateDraftContent(modalState.value, nextValue)
}
function reloadEditor() { function reloadEditor() {
syncDraftFromEditor() syncDraftFromEditor()
editorReloadToken.value += 1 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(() => { const cardStyle = computed(() => {
if (fullScreen.value) { if (effectiveFullscreen.value) {
return {} return {}
} }
return { return {
@ -112,7 +102,7 @@ const cardStyle = computed(() => {
}) })
const cardClass = computed(() => { const cardClass = computed(() => {
if (fullScreen.value) { if (effectiveFullscreen.value) {
return 'h-full w-full rounded-none' return 'h-full w-full rounded-none'
} }
return 'absolute max-w-none rounded-xl' return 'absolute max-w-none rounded-xl'
@ -137,6 +127,13 @@ function updateModalSizeFromViewport() {
if (!import.meta.client) { if (!import.meta.client) {
return return
} }
if (isMobileViewport.value) {
modalRect.left = 0
modalRect.top = 0
modalRect.width = window.innerWidth
modalRect.height = window.innerHeight
return
}
const next = createDefaultModalRect({ const next = createDefaultModalRect({
viewportWidth: window.innerWidth, viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight, viewportHeight: window.innerHeight,
@ -162,7 +159,7 @@ function updateModalSizeFromViewport() {
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
if (resizing.value && !fullScreen.value) { if (resizing.value && !effectiveFullscreen.value) {
if (!import.meta.client) { if (!import.meta.client) {
return return
} }
@ -205,7 +202,7 @@ function handlePointerMove(event: PointerEvent) {
modalRect.height = clampedRect.height modalRect.height = clampedRect.height
return return
} }
if (!dragging.value || fullScreen.value) { if (!dragging.value || effectiveFullscreen.value) {
return return
} }
const clamped = clampModalRect({ const clamped = clampModalRect({
@ -224,7 +221,7 @@ function handlePointerMove(event: PointerEvent) {
} }
function startDragging(event: PointerEvent) { function startDragging(event: PointerEvent) {
if (fullScreen.value || event.button !== 0) { if (effectiveFullscreen.value || event.button !== 0) {
return return
} }
dragging.value = true dragging.value = true
@ -250,7 +247,7 @@ function stopResizing() {
} }
function startResizing(event: PointerEvent, direction: ResizeDirection) { 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 return
} }
resizing.value = true resizing.value = true
@ -266,6 +263,9 @@ function startResizing(event: PointerEvent, direction: ResizeDirection) {
} }
function toggleFullScreen() { function toggleFullScreen() {
if (isMobileViewport.value) {
return
}
fullScreen.value = !fullScreen.value fullScreen.value = !fullScreen.value
if (fullScreen.value) { if (fullScreen.value) {
stopDragging() stopDragging()
@ -365,13 +365,11 @@ watch(
} }
allowSilentClose.value = false allowSilentClose.value = false
if (!isOpen) { if (!isOpen) {
stopDraftSyncTimer()
fullScreen.value = false fullScreen.value = false
stopDragging() stopDragging()
stopResizing() stopResizing()
return return
} }
startDraftSyncTimer()
updateModalSizeFromViewport() updateModalSizeFromViewport()
void loadQuickNote() void loadQuickNote()
}, },
@ -381,6 +379,13 @@ onMounted(() => {
if (!import.meta.client) { if (!import.meta.client) {
return 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) => { removeRouteGuard = router.beforeEach((to) => {
syncDraftFromEditor() syncDraftFromEditor()
if (!open.value || !isDirty.value) { if (!open.value || !isDirty.value) {
@ -393,8 +398,12 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopDraftSyncTimer()
if (import.meta.client) { if (import.meta.client) {
if (viewportMql && onViewportChange) {
viewportMql.removeEventListener('change', onViewportChange)
}
onViewportChange = null
viewportMql = null
removeRouteGuard?.() removeRouteGuard?.()
removeRouteGuard = null removeRouteGuard = null
window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('beforeunload', handleBeforeUnload)
@ -426,7 +435,7 @@ onBeforeUnmount(() => {
<template #header> <template #header>
<div <div
class="flex items-center justify-between gap-3 select-none" class="flex items-center justify-between gap-3 select-none"
:class="fullScreen ? 'cursor-default' : 'cursor-move'" :class="effectiveFullscreen ? 'cursor-default' : 'cursor-move'"
@pointerdown="startDragging" @pointerdown="startDragging"
> >
<div class="min-w-0"> <div class="min-w-0">
@ -447,6 +456,7 @@ onBeforeUnmount(() => {
@click="reloadEditor" @click="reloadEditor"
/> />
<UButton <UButton
v-if="!isMobileViewport"
color="neutral" color="neutral"
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -490,6 +500,7 @@ onBeforeUnmount(() => {
ref="quickNoteEditorRef" ref="quickNoteEditorRef"
v-model="draftContent" v-model="draftContent"
:resize-signal="editorResizeSignal" :resize-signal="editorResizeSignal"
@content-change="handleEditorContentChange"
/> />
</div> </div>
</div> </div>
@ -522,7 +533,7 @@ onBeforeUnmount(() => {
</div> </div>
</template> </template>
</UCard> </UCard>
<template v-if="!fullScreen"> <template v-if="!effectiveFullscreen">
<button <button
type="button" type="button"
class="absolute left-0 top-0 z-10 h-full w-1 cursor-w-resize" 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.mode).toBe('ir')
expect(options.lang).toBe('zh_CN') expect(options.lang).toBe('zh_CN')
expect(options.cdn).toBe('/vditor') expect(options.cdn).toBe('/vditor')
expect(options.icon).toBe('material')
expect(options.preview).toEqual({ expect(options.preview).toEqual({
mode: 'editor', mode: 'editor',
actions: [], actions: [],

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

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

5
nuxt.config.ts

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

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.
Loading…
Cancel
Save