feat: improve task note markdown editor
This commit is contained in:
+45
-4
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { applyMarkdownFormat, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -86,14 +86,25 @@ describe('task utilities', () => {
|
||||
expect(buildTaskRecurrencePayload('none', { afterCompletionDays: '1' })).toEqual({})
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
it('renders safe practical markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n> Note\n1. first\n- [x] done\n\n```js\nconst x = 1\n```\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
expect(html).toContain('<blockquote>Note</blockquote>')
|
||||
expect(html).toContain('<ol><li>first</li></ol>')
|
||||
expect(html).toContain('type="checkbox" disabled checked')
|
||||
expect(html).toContain('<pre><code class="language-js">const x = 1</code></pre>')
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('inserts markdown around selections and prefixes selected lines', () => {
|
||||
expect(applyMarkdownFormat('hello', 0, 5, 'bold')).toEqual({ value: '**hello**', start: 2, end: 7 })
|
||||
expect(applyMarkdownFormat('one\ntwo', 0, 7, 'bullet')).toEqual({ value: '- one\n- two', start: 2, end: 11 })
|
||||
expect(applyMarkdownFormat('', 0, 0, 'link')).toEqual({ value: '[链接文字](https://)', start: 1, end: 5 })
|
||||
expect(applyMarkdownFormat('work', 0, 4, 'task')).toEqual({ value: '- [ ] work', start: 6, end: 10 })
|
||||
})
|
||||
|
||||
it('filters titles, descriptions, and list names', () => {
|
||||
const searchable: SearchTask[] = [
|
||||
...tasks,
|
||||
|
||||
@@ -17,6 +17,38 @@ const escapeHtml = (value: string) =>
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
export type MarkdownFormat = 'heading' | 'bold' | 'italic' | 'bullet' | 'ordered' | 'task' | 'link' | 'code' | 'codeblock' | 'quote'
|
||||
|
||||
export function applyMarkdownFormat(value: string, start: number, end: number, format: MarkdownFormat) {
|
||||
const selected = value.slice(start, end)
|
||||
const replace = (replacement: string, selectionStart: number, selectionEnd: number) => ({
|
||||
value: value.slice(0, start) + replacement + value.slice(end),
|
||||
start: start + selectionStart,
|
||||
end: start + selectionEnd,
|
||||
})
|
||||
const wrap = (before: string, after = before, fallback = '文字') => {
|
||||
const content = selected || fallback
|
||||
return replace(`${before}${content}${after}`, before.length, before.length + content.length)
|
||||
}
|
||||
const prefixLines = (prefix: string, fallback = '列表项') => {
|
||||
const content = selected || fallback
|
||||
const replacement = content.split('\n').map((line) => `${prefix}${line}`).join('\n')
|
||||
return replace(replacement, prefix.length, replacement.length)
|
||||
}
|
||||
if (format === 'heading') return prefixLines('## ', '标题')
|
||||
if (format === 'bold') return wrap('**')
|
||||
if (format === 'italic') return wrap('*')
|
||||
if (format === 'bullet') return prefixLines('- ')
|
||||
if (format === 'ordered') return prefixLines('1. ')
|
||||
if (format === 'task') return prefixLines('- [ ] ')
|
||||
if (format === 'link') return selected
|
||||
? replace(`[${selected}](https://)`, 1, 1 + selected.length)
|
||||
: replace('[链接文字](https://)', 1, 5)
|
||||
if (format === 'code') return wrap('`', '`', '代码')
|
||||
if (format === 'codeblock') return wrap('```\n', '\n```', '代码')
|
||||
return prefixLines('> ', '引用')
|
||||
}
|
||||
|
||||
const renderInline = (value: string) => {
|
||||
let out = escapeHtml(value)
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
@@ -32,17 +64,45 @@ const renderInline = (value: string) => {
|
||||
export function renderMarkdown(markdown = '') {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
const html: string[] = []
|
||||
let inList = false
|
||||
let listType: 'ul' | 'ol' | null = null
|
||||
let inCode = false
|
||||
let codeLanguage = ''
|
||||
const codeLines: string[] = []
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>')
|
||||
inList = false
|
||||
if (listType) {
|
||||
html.push(`</${listType}>`)
|
||||
listType = null
|
||||
}
|
||||
}
|
||||
const openList = (type: 'ul' | 'ol') => {
|
||||
if (listType === type) return
|
||||
closeList()
|
||||
html.push(`<${type}>`)
|
||||
listType = type
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
const fence = line.match(/^```([\w-]*)\s*$/)
|
||||
if (fence) {
|
||||
closeList()
|
||||
if (inCode) {
|
||||
const languageClass = codeLanguage ? ` class="language-${escapeHtml(codeLanguage)}"` : ''
|
||||
html.push(`<pre><code${languageClass}>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
|
||||
codeLines.length = 0
|
||||
codeLanguage = ''
|
||||
inCode = false
|
||||
} else {
|
||||
inCode = true
|
||||
codeLanguage = fence[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (inCode) {
|
||||
codeLines.push(raw)
|
||||
continue
|
||||
}
|
||||
if (!line.trim()) {
|
||||
closeList()
|
||||
continue
|
||||
@@ -53,18 +113,29 @@ export function renderMarkdown(markdown = '') {
|
||||
} else if (line.startsWith('## ')) {
|
||||
closeList()
|
||||
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
|
||||
} else if (line.startsWith('> ')) {
|
||||
closeList()
|
||||
html.push(`<blockquote>${renderInline(line.slice(2))}</blockquote>`)
|
||||
} else if (/^\d+\. /.test(line)) {
|
||||
openList('ol')
|
||||
html.push(`<li>${renderInline(line.replace(/^\d+\. /, ''))}</li>`)
|
||||
} else if (/^[-*] \[[ xX]\] /.test(line)) {
|
||||
openList('ul')
|
||||
const checked = /^[-*] \[[xX]\] /.test(line)
|
||||
html.push(`<li class="task-list-item"><input type="checkbox" disabled${checked ? ' checked' : ''}>${renderInline(line.replace(/^[-*] \[[ xX]\] /, ''))}</li>`)
|
||||
} else if (/^[-*] /.test(line)) {
|
||||
if (!inList) {
|
||||
html.push('<ul>')
|
||||
inList = true
|
||||
}
|
||||
openList('ul')
|
||||
html.push(`<li>${renderInline(line.slice(2))}</li>`)
|
||||
} else if (/^(---|\*\*\*)$/.test(line.trim())) {
|
||||
closeList()
|
||||
html.push('<hr>')
|
||||
} else {
|
||||
closeList()
|
||||
html.push(`<p>${renderInline(line)}</p>`)
|
||||
}
|
||||
}
|
||||
closeList()
|
||||
if (inCode) html.push(`<pre><code>${escapeHtml(['```' + codeLanguage, ...codeLines].join('\n'))}</code></pre>`)
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
||||
.drag-handle{width:44px;min-width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;color:#b3a795;cursor:grab;touch-action:none;border-radius:8px}.drag-handle:active{cursor:grabbing}.drag-handle:disabled{opacity:.3;cursor:not-allowed}.drag-handle svg{width:17px;height:17px}.reordering{opacity:.78;z-index:4;box-shadow:0 12px 28px rgba(78,58,34,.18)!important;transition:none!important;pointer-events:none}.reorder-target{box-shadow:inset 0 2px 0 var(--accent)}
|
||||
.list-drag-handle{width:44px;min-width:44px;height:44px;display:grid;place-items:center;border:0;background:transparent;color:#ad9f8c;cursor:grab;touch-action:none;border-radius:8px}.list-drag-handle svg{width:15px;height:15px}.list-row{touch-action:pan-y}.list-row.list-dragging{position:relative;z-index:8;opacity:.8;box-shadow:0 10px 24px rgba(78,58,34,.2);transform:translateY(var(--list-drag-y));transition:none;pointer-events:none;background:#fffaf4}.folder-row.list-drop-target{background:var(--accent-soft);box-shadow:inset 3px 0 0 var(--accent)}.list-root-drop.list-drop-target{background:var(--accent-soft);color:#b7421e;border-radius:9px}.list-row.list-reorder-target{box-shadow:inset 0 2px 0 var(--accent)}.list-move-menu{display:grid;gap:2px;padding:0}.list-move-menu button{min-height:44px}.list-move-menu button:disabled,.sidebar-action-sheet .app-sheet__body button:disabled{opacity:.55;cursor:default}.sidebar-action-sheet{width:min(320px,calc(100vw - 24px));margin:12px;border:1px solid var(--border-cream);border-radius:18px;box-shadow:var(--highlight-inner),var(--shadow-raised);overflow:hidden}.sidebar-action-header.app-sheet__header,.sidebar-action-move-view.app-sheet__header{align-items:flex-start;min-height:64px;padding:13px 14px 12px}.sidebar-action-header>div,.sidebar-action-move-view>div{min-width:0;display:grid;gap:2px}.sidebar-action-header b,.sidebar-action-move-view b{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:15px}.sidebar-action-kind,.sidebar-action-group-title{font-size:11px;font-weight:750;letter-spacing:.04em;color:var(--muted)}.sidebar-action-body.app-sheet__body{gap:8px;padding:10px}.sidebar-action-group{display:grid;gap:2px;padding:6px;border:1px solid var(--border-cream);border-radius:14px;background:#fffaf4}.sidebar-action-group button,.sidebar-action-danger button,.list-move-menu button{width:100%;min-height:44px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:10px;border:0;border-radius:11px;background:transparent;padding:0 10px;text-align:left}.sidebar-action-group button:hover:not(:disabled),.list-move-menu button:hover:not(:disabled){background:#fff3e6}.sidebar-action-group button span,.sidebar-action-danger button span,.list-move-menu button span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-action-chevron{color:#b3a795}.sidebar-action-up{transform:rotate(180deg)}.sidebar-action-danger{border-top:1px solid #efd6cf;padding:9px 6px 6px;display:grid;gap:7px}.sidebar-action-danger>span{font-size:11px;line-height:1.4;color:#9a6a5f;padding:0 4px}.sidebar-action-danger button.danger{color:var(--danger);background:#fff6f2}.sidebar-action-move-back{min-height:44px;width:44px;border:0;border-radius:12px;background:transparent;color:#8a7e70;display:grid;place-items:center}.sidebar-action-move-back svg{transform:rotate(180deg)}.list-move-current{background:#fbe6dc!important;color:#7b351e;font-weight:750}
|
||||
.archived-lists{position:relative;margin-top:4px}.archived-lists-toggle{min-height:44px;width:100%;display:flex;align-items:center;gap:8px;border:0;border-radius:9px;background:transparent;padding:0 12px;color:#81786c;text-align:left;font-size:12px;font-weight:650}.archived-lists-toggle:hover:not(:disabled){background:rgba(255,255,255,.52)}.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.archived-lists-toggle:disabled{cursor:default;color:#aaa196}.archived-lists-toggle svg{width:15px;height:15px;transition:transform .16s ease}.archived-lists-toggle svg.expanded{transform:rotate(90deg)}.archived-list-items{display:grid;transition:opacity .16s ease}.archived-row{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;padding-left:35px;color:#746c61}.archived-row-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.archived-row-menu{display:grid;place-items:center}.archived-row-menu-trigger{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:#8e8477}.archived-row-menu-trigger svg{width:15px;height:15px}.archived-action-mask{display:contents}.archived-row-actions{position:fixed;z-index:70;width:164px;display:grid;gap:2px;padding:6px;background:#fffdf8;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.archived-row-actions button{min-height:44px;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;background:transparent;padding:8px 10px;text-align:left;font-size:12px}.archived-row-actions button:hover{background:#f8f1e8}.archived-row-actions svg{width:15px;height:15px}@media(max-width:930px){.archived-action-mask{display:block;position:fixed;z-index:60;inset:0;background:rgba(45,38,31,.3)}.archived-row-actions{position:fixed;z-index:61;left:0;right:0;top:auto;bottom:0;width:100%;padding:12px 16px calc(16px + env(safe-area-inset-bottom));border-radius:22px 22px 0 0}.archived-row-actions button{font-size:14px}}@media(prefers-reduced-motion:reduce){.archived-lists-toggle svg,.archived-list-items{transition:none}}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:12px}.detail-form>*{min-width:0}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}.task-detail-due-input{padding-inline:9px!important}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:12px}.detail-form>*{min-width:0}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}.task-detail-due-input{padding-inline:9px!important}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.hint{font-size:12px;color:#a49a8d}.markdown-mode-switch{display:flex;background:#eee7dc;padding:2px;border-radius:7px}.markdown-mode-switch button{min-height:30px;border:0;background:transparent;padding:4px 9px;border-radius:6px;font-size:11px}.markdown-mode-switch button.active{background:#fff;color:var(--accent);box-shadow:0 1px 3px rgba(78,58,34,.08)}.markdown-editor-shell{min-width:0;border:1px solid var(--line);border-radius:11px;background:#fff;overflow:hidden}.markdown-toolbar{display:flex;align-items:center;gap:2px;overflow-x:auto;padding:4px;background:#f7f0e5;border-bottom:1px solid var(--line);scrollbar-width:none}.markdown-toolbar::-webkit-scrollbar{display:none}.markdown-toolbar button{min-width:44px;min-height:44px;flex:0 0 44px;border:0;border-radius:8px;background:transparent;color:#70675c;display:grid;place-items:center;padding:0}.markdown-toolbar button:hover{background:#fff;color:var(--accent)}.markdown-toolbar button svg{width:16px;height:16px}.markdown-toolbar .markdown-codeblock{font:700 12px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown textarea{width:100%;min-height:180px;border:0;background:#fff;border-radius:0;padding:12px;resize:vertical;outline:none;font:13px/1.65 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown textarea:focus{box-shadow:inset 0 0 0 2px rgba(241,90,41,.15)}.markdown-preview{min-height:180px;padding:12px 14px;background:#fff;border:1px solid var(--line);border-radius:11px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview-empty{color:var(--muted)}.markdown-preview h1{margin:4px 0 12px;font-size:20px}.markdown-preview h2{margin:12px 0 8px;font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview ul,.markdown-preview ol{margin:8px 0;padding-left:22px}.markdown-preview blockquote{margin:10px 0;padding:7px 11px;border-left:3px solid #d7aa8f;background:#fff7ed;color:#665c51}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview pre{max-width:100%;overflow:auto;padding:11px;background:#332f2b;color:#f8f1e7;border-radius:9px}.markdown-preview pre code{padding:0;background:transparent;color:inherit}.markdown-preview a{color:var(--accent)}.markdown-preview hr{border:0;border-top:1px solid var(--line);margin:14px 0}.markdown-preview .task-list-item{list-style:none;margin-left:-20px;display:flex;align-items:flex-start;gap:7px}.markdown-preview .task-list-item input{margin-top:5px;accent-color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.field-hint{color:var(--muted);font-size:11px;font-weight:400}.after-completion-fields{display:grid;gap:5px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#fffaf4;color:#675f54;font-size:12px}.after-completion-fields>div{display:flex;align-items:center;gap:7px;min-height:44px}.after-completion-fields input{width:76px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:0 10px}.after-completion-fields small{color:var(--muted);line-height:1.5}.repeat-save{width:100%;min-height:44px}.repeat-custom-fields{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:11px;background:#fffaf4}.repeat-custom-fields>div,.repeat-custom-fields>label{display:flex;align-items:center;gap:8px;color:#675f54;font-size:12px}.repeat-custom-fields input[type="number"]{width:72px}.repeat-custom-fields input,.repeat-custom-fields select{min-height:40px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 9px}.weekday-picker{display:flex;gap:4px;flex-wrap:wrap}.weekday-picker label{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff}.weekday-picker input{position:absolute;opacity:0;pointer-events:none}.weekday-picker label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:220px minmax(400px,1fr) minmax(280px,30vw)}.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(400px,1fr) minmax(280px,30vw)}main{padding-inline:24px}}
|
||||
@media(max-width:930px){.shell{height:100dvh;display:block;overflow:auto}.shell.mobile-sidebar-open{overflow:hidden}.shell.mobile-sidebar-open main{overflow:hidden;touch-action:none}.sidebar{position:fixed;z-index:50;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%);overscroll-behavior:contain}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32);display:block}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar{grid-template-rows:auto auto;row-gap:0}.topbar h1{font-size:24px}.search-reveal{max-height:0;opacity:0;overflow:hidden;visibility:hidden;pointer-events:none;transform:translateY(-8px);transition:max-height .2s ease,opacity .16s ease,transform .2s ease}.search-reveal.mobile-search-open{max-height:52px;opacity:1;visibility:visible;pointer-events:auto;transform:none;overflow:visible}.search-reveal.mobile-search-pulling{max-height:var(--search-pull);opacity:var(--search-pull-opacity);visibility:visible;transform:translateY(calc(var(--search-pull) - 56px))}.search-pull-hint{display:block;height:16px;margin-top:-16px;text-align:center;color:var(--muted);font-size:11px;line-height:16px;opacity:0;pointer-events:none}.search-reveal.mobile-search-pulling .search-pull-hint{opacity:1}.completed-filter-pill{min-width:138px;height:44px}.completed-filter-pill__track{width:36px;height:22px}.completed-filter-pill__thumb{width:18px;height:18px}.today-board{gap:1px;margin:14px 0 4px;padding:5px}.today-track{min-height:58px;padding:8px 9px;gap:7px}.today-track-head{align-items:flex-start;flex-direction:column;gap:2px}.today-track-head strong{font-size:13px}.search{width:100%;margin:0;padding:8px}.search input{width:100%}.search kbd{display:none}.list-toolbar{height:auto;min-height:36px}.section-heading{margin:12px 0 8px}.empty{min-height:190px}.today-empty-panel{min-height:104px}.row-actions{position:static;opacity:1;pointer-events:auto}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row .list-row-main{padding-left:14px}.archived-row .list-row-main>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-height:44px;min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||
|
||||
@@ -273,7 +273,23 @@ describe('completion feedback motion', () => {
|
||||
expect(authBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(authBlock.indexOf('await loadRestoredView()'))
|
||||
})
|
||||
|
||||
it('relies on the task PATCH for atomic due and recurrence synchronization', () => {
|
||||
it('provides a practical task-note Markdown toolbar with editing and preview modes', () => {
|
||||
expect(app).toContain('ref="taskNoteEditor"')
|
||||
expect(app).toContain('class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式"')
|
||||
for (const label of ['标题', '粗体', '斜体', '无序列表', '有序列表', '待办', '链接', '行内代码', '代码块', '引用']) {
|
||||
expect(app).toContain(`aria-label="${label}"`)
|
||||
}
|
||||
expect(app).toContain('@click="formatTaskNote(')
|
||||
expect(app).toContain('@keydown="handleTaskNoteShortcut"')
|
||||
expect(app).toContain('class="markdown-mode-switch"')
|
||||
expect(app).toContain(':aria-pressed="!markdownPreview"')
|
||||
expect(app).toContain(':aria-pressed="markdownPreview"')
|
||||
expect(css).toContain('.markdown-toolbar{display:flex;')
|
||||
expect(css).toContain('overflow-x:auto')
|
||||
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
|
||||
})
|
||||
|
||||
it('keeps all task detail changes on the unified save button', () => {
|
||||
const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges('))
|
||||
expect(saveTaskBlock).not.toContain('/recurrences/')
|
||||
expect(saveTaskBlock).toContain('as Partial<Task>, false')
|
||||
|
||||
Reference in New Issue
Block a user