feat: improve task note markdown editor
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 5m44s

This commit is contained in:
2026-09-11 17:37:53 +08:00
parent 9ec9d60633
commit 7caa78f318
5 changed files with 156 additions and 17 deletions
+45 -4
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
Settings, Trash2, X, Repeat2, RefreshCw,
} from 'lucide-vue-next'
import { buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { applyMarkdownFormat, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
@@ -92,6 +92,7 @@ const mobileDetail = ref(false)
const mobileMore = ref(false)
const moreSettingsOpen = ref(false)
const markdownPreview = ref(false)
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
const page = ref(1)
@@ -931,6 +932,28 @@ function selectTask(task: Task) {
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
void loadTaskRecurrence(task)
}
function formatTaskNote(format: MarkdownFormat) {
if (!selectedTask.value) return
const editor = taskNoteEditor.value
const start = editor?.selectionStart ?? selectedTask.value.description.length
const end = editor?.selectionEnd ?? start
const formatted = applyMarkdownFormat(selectedTask.value.description, start, end, format)
selectedTask.value.description = formatted.value
markdownPreview.value = false
void nextTick(() => {
const target = taskNoteEditor.value
if (!target) return
target.focus()
target.setSelectionRange(formatted.start, formatted.end)
})
}
function handleTaskNoteShortcut(event: KeyboardEvent) {
if (!(event.metaKey || event.ctrlKey)) return
const format = event.key.toLowerCase() === 'b' ? 'bold' : event.key.toLowerCase() === 'i' ? 'italic' : event.key.toLowerCase() === 'k' ? 'link' : null
if (!format) return
event.preventDefault()
formatTaskNote(format)
}
async function createFolder() {
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
try { folders.value.push(await api('/folders', { method: 'POST', body: JSON.stringify({ name }) })); toast('文件夹已创建') } catch (reason) { fail(reason) }
@@ -1444,7 +1467,25 @@ onUnmounted(() => {
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown"/></div>
<div class="field markdown">
<div class="field-label"><span>备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
<div v-if="!markdownPreview" class="markdown-editor-shell">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button>
<button type="button" aria-label="粗体" title="粗体" @click="formatTaskNote('bold')"><Bold/></button>
<button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button>
<button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button>
<button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button>
<button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button>
<button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button>
<button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button>
<button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button>
<button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
</div>
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" placeholder="写备注选中文字后可用上方工具栏添加格式" @keydown="handleTaskNoteShortcut"/>
</div>
<div v-else class="markdown-preview" :class="{'markdown-preview-empty':!selectedTask.description.trim()}" v-html="selectedTask.description.trim() ? renderMarkdown(selectedTask.description) : '<p>暂无备注,切回编辑开始书写。</p>'"/>
</div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
<label>优先级<select v-model.number="selectedTask.priority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>