Browse Source

feat(quick-note): add resizable quick note modal with per-user persistence

Introduce a lightweight quick note flow with draggable/resizable modal editor, unsaved-change protection, and per-user backend storage APIs backed by a dedicated quick_notes schema.

Made-with: Cursor
main
npmrun 3 months ago
parent
commit
45f22de703
  1. 15
      app/components/AppShell.vue
  2. 142
      app/components/QuickNoteEditor.vue
  3. 578
      app/components/QuickNoteModal.vue
  4. 37
      app/components/quick-note-editor-vditor-config.ts
  5. 86
      app/components/quick-note-modal-layout.test.ts
  6. 113
      app/components/quick-note-modal-layout.ts
  7. 58
      app/components/quick-note-modal-state.test.ts
  8. 58
      app/components/quick-note-modal-state.ts
  9. 17
      packages/drizzle-pkg/database/sqlite/schema/content.ts
  10. 1
      packages/drizzle-pkg/lib/schema/content.ts
  11. 10
      packages/drizzle-pkg/migrations/0012_quick_notes.sql
  12. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  13. 13
      server/api/me/quick-note.get.ts
  14. 18
      server/api/me/quick-note.put.ts
  15. 172
      server/service/quick-note/index.test.ts
  16. 74
      server/service/quick-note/index.ts

15
app/components/AppShell.vue

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useAuthSession } from '../composables/useAuthSession'
import QuickNoteModal from './QuickNoteModal.vue'
withDefaults(
defineProps<{
@ -15,6 +16,7 @@ const { fetchData } = useClientApi()
const { allowRegister, siteName, showDiscoverInHeaderForGuest } = useGlobalConfig()
const logoutLoading = ref(false)
const quickNoteModalOpen = ref(false)
onMounted(() => {
ensureClientMeSynced().catch(() => {})
@ -185,6 +187,17 @@ async function logout() {
<template v-else-if="loggedIn && user">
<UButton
v-if="showQuickCreate"
color="neutral"
variant="soft"
icon="i-lucide-notebook-pen"
size="sm"
class="hidden md:inline-flex"
@click="quickNoteModalOpen = true"
>
速记
</UButton>
<UButton
v-if="showQuickCreate"
to="/me/posts/new"
color="primary"
variant="soft"
@ -267,6 +280,8 @@ async function logout() {
<slot />
</main>
<QuickNoteModal v-model:open="quickNoteModalOpen" />
<footer class="border-t border-default/80 bg-elevated/30">
<UContainer class="flex flex-col gap-2 py-6 text-sm text-muted sm:flex-row sm:items-center sm:justify-between">
<span>{{ siteName }} 个人资料文章时光机与 RSS</span>

142
app/components/QuickNoteEditor.vue

@ -0,0 +1,142 @@
<script setup lang="ts">
import 'vditor/dist/index.css'
import { createPostBodyMarkdownEditorBridge, type CreateVditorLikeOptions } from './post-body-markdown-editor-vditor-bridge'
import { initializePostBodyMarkdownEditorVditor } from './post-body-markdown-editor-vditor-init'
import { buildQuickNoteEditorVditorOptions } from './quick-note-editor-vditor-config'
const props = defineProps<{
modelValue: string
resizeSignal?: string
}>()
const emit = defineEmits<{
'update:modelValue': [string]
}>()
const toast = useToast()
const mountEl = ref<HTMLElement | null>(null)
const initErrorMessage = ref('')
const vditorCtor = shallowRef<null | (new (element: HTMLElement, options: Record<string, unknown>) => {
getValue: () => string
setValue: (value: string, render?: boolean) => void
destroy: () => void
})>(null)
let unmounted = false
const bridge = createPostBodyMarkdownEditorBridge({
getModelValue: () => props.modelValue,
emitUpdate: (value) => emit('update:modelValue', value),
createEditor: ({ element, value, onInput }: CreateVditorLikeOptions) => {
const Vditor = vditorCtor.value
if (!Vditor) {
throw new Error('Vditor constructor is not ready')
}
return new Vditor(element, buildQuickNoteEditorVditorOptions({
value,
onInput,
onUploadError: () => {
toast.add({ title: '图片上传失败', color: 'warning' })
},
}))
},
})
function flushValue() {
const current = bridge.getEditor()?.getValue()
if (typeof current !== 'string') {
return
}
if (current === props.modelValue) {
return
}
emit('update:modelValue', current)
}
defineExpose<{
flushValue: () => void
}>({
flushValue,
})
onMounted(async () => {
if (!import.meta.client) {
return
}
await initializePostBodyMarkdownEditorVditor({
importVditor: () => import('vditor'),
isUnmounted: () => unmounted,
onReady: (ctor) => {
vditorCtor.value = ctor
if (mountEl.value) {
bridge.mount(mountEl.value)
}
},
onError: (error) => {
initErrorMessage.value = '编辑器加载失败,请刷新重试'
toast.add({
title: initErrorMessage.value,
color: 'error',
})
console.error('Failed to initialize quick note editor', error)
},
})
})
watch(() => props.modelValue, () => {
bridge.syncFromProps()
})
watch(() => props.resizeSignal, async () => {
if (!import.meta.client) {
return
}
await nextTick()
const editor = bridge.getEditor() as { resize?: () => void } | null
editor?.resize?.()
})
onBeforeUnmount(() => {
unmounted = true
bridge.unmount()
})
</script>
<template>
<ClientOnly>
<div v-if="initErrorMessage" class="h-full w-full min-w-0 rounded-lg border border-error/50 bg-error/10 px-4 py-8 text-center">
<p class="text-sm text-error">
{{ initErrorMessage }}
</p>
</div>
<div
v-else
ref="mountEl"
class="h-full w-full min-w-0 rounded-lg overflow-hidden ring ring-default"
/>
<template #fallback>
<div class="text-muted text-sm py-10 text-center border border-default rounded-lg">
编辑器加载中
</div>
</template>
</ClientOnly>
</template>
<style scoped>
:deep(.vditor) {
height: 100% !important;
display: flex;
min-height: 0;
flex-direction: column;
}
:deep(.vditor-content) {
flex: 1;
min-height: 0;
}
:deep(.vditor-ir) {
height: 100% !important;
overflow: auto;
}
</style>

578
app/components/QuickNoteModal.vue

@ -0,0 +1,578 @@
<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 lastSavedAt = ref<string | null>(null)
const allowSilentClose = ref(false)
let removeRouteGuard: (() => void) | null = null
let syncDraftTimer: ReturnType<typeof setInterval> | 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 editorResizeSignal = computed(() => `${modalRect.width}x${modalRect.height}-${fullScreen.value ? 'full' : 'windowed'}`)
const editorReloadToken = ref(0)
function syncDraftFromEditor() {
quickNoteEditorRef.value?.flushValue()
}
function reloadEditor() {
syncDraftFromEditor()
editorReloadToken.value += 1
toast.add({
title: '编辑器已重新加载',
color: 'info',
})
}
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) {
return {}
}
return {
left: `${modalRect.left}px`,
top: `${modalRect.top}px`,
width: `${modalRect.width}px`,
height: `${modalRect.height}px`,
}
})
const cardClass = computed(() => {
if (fullScreen.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
}
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 && !fullScreen.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 || fullScreen.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 (fullScreen.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 (fullScreen.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() {
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) {
stopDraftSyncTimer()
fullScreen.value = false
stopDragging()
stopResizing()
return
}
startDraftSyncTimer()
updateModalSizeFromViewport()
void loadQuickNote()
},
)
onMounted(() => {
if (!import.meta.client) {
return
}
removeRouteGuard = router.beforeEach((to) => {
syncDraftFromEditor()
if (!open.value || !isDirty.value) {
return true
}
return confirmDiscard()
})
window.addEventListener('beforeunload', handleBeforeUnload)
window.addEventListener('resize', updateModalSizeFromViewport)
})
onBeforeUnmount(() => {
stopDraftSyncTimer()
if (import.meta.client) {
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="fullScreen ? '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
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"
/>
</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="!fullScreen">
<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>

37
app/components/quick-note-editor-vditor-config.ts

@ -0,0 +1,37 @@
import { buildPostBodyMarkdownEditorVditorOptions } from './post-body-markdown-editor-vditor-config'
interface BuildQuickNoteVditorOptionsInput {
value: string
onInput: (value: string) => void
onUploadError: () => void
}
const QUICK_NOTE_TOOLBAR: ReadonlyArray<string> = [
'bold',
'italic',
'headings',
'|',
'list',
'ordered-list',
'|',
'link',
'upload',
'code',
]
export function buildQuickNoteEditorVditorOptions(input: BuildQuickNoteVditorOptionsInput): Record<string, unknown> {
const baseOptions = buildPostBodyMarkdownEditorVditorOptions({
value: input.value,
isMobile: true,
onInput: input.onInput,
onUploadError: input.onUploadError,
})
return {
...baseOptions,
height: '100%',
toolbar: QUICK_NOTE_TOOLBAR,
}
}
export const quickNoteEditorToolbarPreset = QUICK_NOTE_TOOLBAR

86
app/components/quick-note-modal-layout.test.ts

@ -0,0 +1,86 @@
import { describe, expect, test } from 'bun:test'
import {
clampModalOffset,
clampModalRect,
clampModalSize,
createDefaultModalRect,
createDefaultModalSize,
} from './quick-note-modal-layout'
describe('quick-note modal layout helpers', () => {
test('createDefaultModalSize uses near-fullscreen viewport with margins', () => {
expect(createDefaultModalSize({
viewportWidth: 1200,
viewportHeight: 900,
margin: 24,
minWidth: 640,
minHeight: 420,
})).toEqual({
width: 1152,
height: 852,
})
})
test('createDefaultModalRect uses margin as origin', () => {
expect(createDefaultModalRect({
viewportWidth: 1200,
viewportHeight: 900,
margin: 24,
minWidth: 640,
minHeight: 420,
})).toEqual({
left: 24,
top: 24,
width: 1152,
height: 852,
})
})
test('clampModalSize enforces min and max bounds', () => {
expect(clampModalSize({
width: 400,
height: 2000,
minWidth: 640,
minHeight: 420,
maxWidth: 1152,
maxHeight: 852,
})).toEqual({
width: 640,
height: 852,
})
})
test('clampModalOffset keeps dragged modal inside viewport bounds', () => {
expect(clampModalOffset({
offsetX: 1000,
offsetY: -1000,
width: 800,
height: 500,
viewportWidth: 1200,
viewportHeight: 900,
margin: 24,
})).toEqual({
offsetX: 176,
offsetY: -176,
})
})
test('clampModalRect keeps resized modal inside viewport and min size', () => {
expect(clampModalRect({
left: -200,
top: 50,
width: 300,
height: 1200,
minWidth: 640,
minHeight: 420,
viewportWidth: 1200,
viewportHeight: 900,
margin: 24,
})).toEqual({
left: 24,
top: 24,
width: 640,
height: 852,
})
})
})

113
app/components/quick-note-modal-layout.ts

@ -0,0 +1,113 @@
export interface ModalSize {
width: number
height: number
}
export interface ModalOffset {
offsetX: number
offsetY: number
}
export interface ModalRect {
left: number
top: number
width: number
height: number
}
export interface CreateDefaultModalSizeInput {
viewportWidth: number
viewportHeight: number
margin: number
minWidth: number
minHeight: number
}
export interface CreateDefaultModalRectInput extends CreateDefaultModalSizeInput {}
export interface ClampModalSizeInput extends ModalSize {
minWidth: number
minHeight: number
maxWidth: number
maxHeight: number
}
export interface ClampModalOffsetInput extends ModalSize {
offsetX: number
offsetY: number
viewportWidth: number
viewportHeight: number
margin: number
}
export interface ClampModalRectInput extends ModalRect {
minWidth: number
minHeight: number
viewportWidth: number
viewportHeight: number
margin: number
}
export function clampModalSize(input: ClampModalSizeInput): ModalSize {
return {
width: Math.max(input.minWidth, Math.min(input.width, input.maxWidth)),
height: Math.max(input.minHeight, Math.min(input.height, input.maxHeight)),
}
}
export function createDefaultModalSize(input: CreateDefaultModalSizeInput): ModalSize {
const maxWidth = Math.max(input.minWidth, input.viewportWidth - input.margin * 2)
const maxHeight = Math.max(input.minHeight, input.viewportHeight - input.margin * 2)
return clampModalSize({
width: maxWidth,
height: maxHeight,
minWidth: input.minWidth,
minHeight: input.minHeight,
maxWidth,
maxHeight,
})
}
export function createDefaultModalRect(input: CreateDefaultModalRectInput): ModalRect {
const size = createDefaultModalSize(input)
return {
left: input.margin,
top: input.margin,
width: size.width,
height: size.height,
}
}
export function clampModalOffset(input: ClampModalOffsetInput): ModalOffset {
const centerX = (input.viewportWidth - input.width) / 2
const centerY = (input.viewportHeight - input.height) / 2
const minOffsetX = input.margin - centerX
const maxOffsetX = input.viewportWidth - input.margin - (centerX + input.width)
const minOffsetY = input.margin - centerY
const maxOffsetY = input.viewportHeight - input.margin - (centerY + input.height)
return {
offsetX: Math.max(minOffsetX, Math.min(input.offsetX, maxOffsetX)),
offsetY: Math.max(minOffsetY, Math.min(input.offsetY, maxOffsetY)),
}
}
export function clampModalRect(input: ClampModalRectInput): ModalRect {
const maxWidth = Math.max(input.minWidth, input.viewportWidth - input.margin * 2)
const maxHeight = Math.max(input.minHeight, input.viewportHeight - input.margin * 2)
const size = clampModalSize({
width: input.width,
height: input.height,
minWidth: input.minWidth,
minHeight: input.minHeight,
maxWidth,
maxHeight,
})
const maxLeft = input.viewportWidth - input.margin - size.width
const maxTop = input.viewportHeight - input.margin - size.height
return {
left: Math.max(input.margin, Math.min(input.left, maxLeft)),
top: Math.max(input.margin, Math.min(input.top, maxTop)),
width: size.width,
height: size.height,
}
}

58
app/components/quick-note-modal-state.test.ts

@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test'
import {
computeIsDirty,
createQuickNoteModalState,
markSaveFailed,
markSaveSucceeded,
updateDraftContent,
} from './quick-note-modal-state'
describe('quick-note modal state', () => {
test('draft differs from saved => dirty', () => {
expect(computeIsDirty({ savedContent: 'a', draftContent: 'b' })).toBe(true)
expect(computeIsDirty({ savedContent: 'a', draftContent: 'a' })).toBe(false)
})
test('saving success marks state clean', () => {
const initial = createQuickNoteModalState('hello')
const editing = updateDraftContent(initial, 'hello world')
expect(editing.isDirty).toBe(true)
const saved = markSaveSucceeded(editing)
expect(saved.savedContent).toBe('hello world')
expect(saved.draftContent).toBe('hello world')
expect(saved.isDirty).toBe(false)
expect(saved.lastSaveFailed).toBe(false)
})
test('saving failure keeps draft dirty', () => {
const initial = createQuickNoteModalState('hello')
const editing = updateDraftContent(initial, 'draft changed')
const failed = markSaveFailed(editing)
expect(failed.savedContent).toBe('hello')
expect(failed.draftContent).toBe('draft changed')
expect(failed.isDirty).toBe(true)
expect(failed.lastSaveFailed).toBe(true)
})
test('input after failed save resets lastSaveFailed', () => {
const initial = createQuickNoteModalState('hello')
const editing = updateDraftContent(initial, 'draft changed')
const failed = markSaveFailed(editing)
expect(failed.lastSaveFailed).toBe(true)
const editedAgain = updateDraftContent(failed, 'draft changed again')
expect(editedAgain.lastSaveFailed).toBe(false)
expect(editedAgain.isDirty).toBe(true)
})
test('save success after a failure clears lastSaveFailed', () => {
const initial = createQuickNoteModalState('hello')
const failed = markSaveFailed(updateDraftContent(initial, 'draft changed'))
const saved = markSaveSucceeded(failed)
expect(saved.lastSaveFailed).toBe(false)
expect(saved.isDirty).toBe(false)
})
})

58
app/components/quick-note-modal-state.ts

@ -0,0 +1,58 @@
export interface QuickNoteModalState {
savedContent: string
draftContent: string
isDirty: boolean
lastSaveFailed: boolean
}
export interface ComputeIsDirtyInput {
savedContent: string
draftContent: string
}
export function computeIsDirty(input: ComputeIsDirtyInput): boolean {
return input.savedContent !== input.draftContent
}
export function createQuickNoteModalState(savedContent: string): QuickNoteModalState {
return {
savedContent,
draftContent: savedContent,
isDirty: false,
lastSaveFailed: false,
}
}
export function updateDraftContent(state: QuickNoteModalState, draftContent: string): QuickNoteModalState {
return {
...state,
draftContent,
isDirty: computeIsDirty({
savedContent: state.savedContent,
draftContent,
}),
lastSaveFailed: false,
}
}
export function markSaveSucceeded(state: QuickNoteModalState): QuickNoteModalState {
const nextSavedContent = state.draftContent
return {
...state,
savedContent: nextSavedContent,
draftContent: nextSavedContent,
isDirty: false,
lastSaveFailed: false,
}
}
export function markSaveFailed(state: QuickNoteModalState): QuickNoteModalState {
return {
...state,
isDirty: computeIsDirty({
savedContent: state.savedContent,
draftContent: state.draftContent,
}),
lastSaveFailed: true,
}
}

17
packages/drizzle-pkg/database/sqlite/schema/content.ts

@ -129,6 +129,23 @@ export const timelineEvents = sqliteTable("timeline_events", {
.notNull(),
});
export const quickNotes = sqliteTable(
"quick_notes",
{
id: integer().primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
content: text().notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [uniqueIndex("quick_notes_user_id_unique").on(table.userId)],
);
export const postComments = sqliteTable(
"post_comments",
{

1
packages/drizzle-pkg/lib/schema/content.ts

@ -4,6 +4,7 @@ export {
postComments,
postTags,
posts,
quickNotes,
tags,
timelineEvents,
} from "../../database/sqlite/schema/content";

10
packages/drizzle-pkg/migrations/0012_quick_notes.sql

@ -0,0 +1,10 @@
CREATE TABLE `quick_notes` (
`id` integer PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`content` text NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `quick_notes_user_id_unique` ON `quick_notes` (`user_id`);

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -85,6 +85,13 @@
"when": 1777110000000,
"tag": "0011_post_tags",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1777120000000,
"tag": "0012_quick_notes",
"breakpoints": true
}
]
}

13
server/api/me/quick-note.get.ts

@ -0,0 +1,13 @@
import { getQuickNoteByUserId } from "#server/service/quick-note";
export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser();
const quickNote = await getQuickNoteByUserId(user.id);
return R.success({
quickNote: {
content: quickNote?.content ?? "",
updatedAt: quickNote?.updatedAt ?? null,
},
});
});

18
server/api/me/quick-note.put.ts

@ -0,0 +1,18 @@
import { upsertQuickNoteByUserId } from "#server/service/quick-note";
export default defineWrappedResponseHandler(async (event) => {
const user = await event.context.auth.requireUser();
const body = await readBody<{ content?: unknown }>(event);
if (typeof body?.content !== "string") {
throw createError({ statusCode: 400, statusMessage: "content 必须为字符串" });
}
const quickNote = await upsertQuickNoteByUserId(user.id, body.content);
return R.success({
quickNote: {
content: quickNote.content,
updatedAt: quickNote.updatedAt,
},
});
});

172
server/service/quick-note/index.test.ts

@ -0,0 +1,172 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { eq, sql } from "drizzle-orm";
process.env.DATABASE_URL ??= "file:./db.sqlite";
(globalThis as { createError?: (input: unknown) => unknown }).createError = (input: unknown) => {
if (input instanceof Error) {
return input;
}
const payload = (input ?? {}) as { statusMessage?: string };
const error = new Error(payload.statusMessage ?? "Error") as Error & Record<string, unknown>;
Object.assign(error, payload);
return error;
};
const { dbGlobal } = await import("drizzle-pkg/database/sqlite/db-bun");
mock.module("drizzle-pkg/lib/db", () => ({ dbGlobal }));
const { users } = await import("drizzle-pkg/lib/schema/auth");
const { quickNotes } = await import("drizzle-pkg/lib/schema/content");
const {
QUICK_NOTE_MAX_LENGTH,
getQuickNoteByUserId,
isQuickNoteUniqueViolation,
normalizeQuickNoteContent,
upsertQuickNoteByUserId,
validateQuickNoteContentLength,
} = await import("./index");
const TEST_USER = {
id: 920001,
username: "quick_note_u1",
password: "pw",
};
async function resetRows() {
await dbGlobal.delete(quickNotes).where(eq(quickNotes.userId, TEST_USER.id));
await dbGlobal.delete(users).where(eq(users.id, TEST_USER.id));
}
describe("quick-note service", () => {
beforeAll(async () => {
await dbGlobal.run(sql`
CREATE TABLE IF NOT EXISTS quick_notes (
id INTEGER PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL,
updated_at INTEGER DEFAULT (unixepoch() * 1000) NOT NULL
)
`);
await dbGlobal.run(sql`
CREATE UNIQUE INDEX IF NOT EXISTS quick_notes_user_id_unique ON quick_notes (user_id)
`);
await resetRows();
});
beforeEach(async () => {
await resetRows();
await dbGlobal.insert(users).values(TEST_USER);
});
test("exports max length constant", () => {
expect(QUICK_NOTE_MAX_LENGTH).toBe(200000);
});
test("normalizeQuickNoteContent converts CRLF to LF", () => {
expect(normalizeQuickNoteContent("a\r\nb\r\n")).toBe("a\nb\n");
});
test("validateQuickNoteContentLength throws 400 when too long", () => {
const tooLong = "a".repeat(QUICK_NOTE_MAX_LENGTH + 1);
expect(() => validateQuickNoteContentLength(tooLong)).toThrow("速记内容过长");
try {
validateQuickNoteContentLength(tooLong);
} catch (error) {
expect(error).toMatchObject({ statusCode: 400, statusMessage: "速记内容过长" });
}
});
test("validateQuickNoteContentLength allows content at max length", () => {
const maxLengthContent = "a".repeat(QUICK_NOTE_MAX_LENGTH);
expect(() => validateQuickNoteContentLength(maxLengthContent)).not.toThrow();
});
test("isQuickNoteUniqueViolation returns true for quick_notes user unique conflict", () => {
expect(
isQuickNoteUniqueViolation(new Error("UNIQUE constraint failed: quick_notes.user_id")),
).toBe(true);
});
test("isQuickNoteUniqueViolation returns false for non-quick-note conflict", () => {
expect(
isQuickNoteUniqueViolation(new Error("UNIQUE constraint failed: posts.user_id, posts.slug")),
).toBe(false);
expect(isQuickNoteUniqueViolation(new Error("random error"))).toBe(false);
});
test("getQuickNoteByUserId returns null when not found", async () => {
const row = await getQuickNoteByUserId(TEST_USER.id);
expect(row).toBeNull();
});
test("upsertQuickNoteByUserId inserts then updates and normalizes newlines", async () => {
const inserted = await upsertQuickNoteByUserId(TEST_USER.id, "line1\r\nline2");
expect(inserted.userId).toBe(TEST_USER.id);
expect(inserted.content).toBe("line1\nline2");
const updated = await upsertQuickNoteByUserId(TEST_USER.id, "next\r\nvalue");
expect(updated.id).toBe(inserted.id);
expect(updated.content).toBe("next\nvalue");
const fromDb = await getQuickNoteByUserId(TEST_USER.id);
expect(fromDb?.id).toBe(inserted.id);
expect(fromDb?.content).toBe("next\nvalue");
});
test("upsertQuickNoteByUserId supports empty string content", async () => {
const saved = await upsertQuickNoteByUserId(TEST_USER.id, "");
expect(saved.content).toBe("");
const fromDb = await getQuickNoteByUserId(TEST_USER.id);
expect(fromDb).not.toBeNull();
expect(fromDb?.content).toBe("");
});
test("upsertQuickNoteByUserId normalizes all CRLF to LF", async () => {
const raw = "\r\nline1\r\nline2\r\n";
const saved = await upsertQuickNoteByUserId(TEST_USER.id, raw);
expect(saved.content).toBe("\nline1\nline2\n");
const fromDb = await getQuickNoteByUserId(TEST_USER.id);
expect(fromDb?.content).toBe("\nline1\nline2\n");
});
test("upsertQuickNoteByUserId validates length after normalize", async () => {
const raw = "a\r\n".repeat(100000);
expect(raw.length).toBeGreaterThan(QUICK_NOTE_MAX_LENGTH);
expect(normalizeQuickNoteContent(raw).length).toBe(QUICK_NOTE_MAX_LENGTH);
const saved = await upsertQuickNoteByUserId(TEST_USER.id, raw);
expect(saved.content.length).toBe(QUICK_NOTE_MAX_LENGTH);
});
test("upsertQuickNoteByUserId falls back to update on unique conflict and returns row", async () => {
const originalInsert = dbGlobal.insert.bind(dbGlobal);
let injected = false;
(dbGlobal as { insert: typeof dbGlobal.insert }).insert = ((...args: Parameters<typeof dbGlobal.insert>) => {
const builder = originalInsert(...args) as { values: (payload: unknown) => Promise<unknown> } & Record<string, unknown>;
return {
...builder,
values: async (payload: unknown) => {
const result = await builder.values(payload);
if (!injected) {
injected = true;
throw new Error("UNIQUE constraint failed: quick_notes.user_id");
}
return result;
},
};
}) as typeof dbGlobal.insert;
try {
const saved = await upsertQuickNoteByUserId(TEST_USER.id, "fallback\r\nok");
expect(saved.userId).toBe(TEST_USER.id);
expect(saved.content).toBe("fallback\nok");
expect(injected).toBe(true);
} finally {
(dbGlobal as { insert: typeof dbGlobal.insert }).insert = originalInsert as typeof dbGlobal.insert;
}
});
});

74
server/service/quick-note/index.ts

@ -0,0 +1,74 @@
import { dbGlobal } from "drizzle-pkg/lib/db";
import { quickNotes } from "drizzle-pkg/lib/schema/content";
import { eq } from "drizzle-orm";
import { nextIntegerId } from "../../utils/sqlite-id";
export const QUICK_NOTE_MAX_LENGTH = 200000;
export function normalizeQuickNoteContent(content: string): string {
return content.replaceAll("\r\n", "\n");
}
export function validateQuickNoteContentLength(content: string) {
if (content.length > QUICK_NOTE_MAX_LENGTH) {
throw createError({ statusCode: 400, statusMessage: "速记内容过长" });
}
}
export function isQuickNoteUniqueViolation(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? "");
if (!message.includes("UNIQUE constraint failed")) {
return false;
}
return message.includes("quick_notes.user_id") || message.includes("quick_notes_user_id_unique");
}
export async function getQuickNoteByUserId(userId: number) {
const [row] = await dbGlobal
.select()
.from(quickNotes)
.where(eq(quickNotes.userId, userId))
.limit(1);
return row ?? null;
}
export async function upsertQuickNoteByUserId(userId: number, content: string) {
const normalizedContent = normalizeQuickNoteContent(content);
validateQuickNoteContentLength(normalizedContent);
const existing = await getQuickNoteByUserId(userId);
if (!existing) {
try {
const id = await nextIntegerId(quickNotes, quickNotes.id);
await dbGlobal.insert(quickNotes).values({
id,
userId,
content: normalizedContent,
});
} catch (error) {
// 处理并发下“先查后插”触发的唯一键冲突:回退为 update。
if (!isQuickNoteUniqueViolation(error)) {
throw error;
}
await dbGlobal
.update(quickNotes)
.set({ content: normalizedContent })
.where(eq(quickNotes.userId, userId));
}
const inserted = await getQuickNoteByUserId(userId);
if (!inserted) {
throw new Error(`速记写入失败:userId=${userId}`);
}
return inserted;
}
await dbGlobal
.update(quickNotes)
.set({ content: normalizedContent })
.where(eq(quickNotes.userId, userId));
const updated = await getQuickNoteByUserId(userId);
if (!updated) {
throw new Error(`速记更新失败:userId=${userId}`);
}
return updated;
}
Loading…
Cancel
Save