feat: 支持指针模式复制粘贴、Markdown 链接渲染与多项优化

- 移除 Google Fonts 依赖,改用系统字体栈(优化国内访问)
- 指针模式下支持 Ctrl+C / Ctrl+V 复制粘贴选中便签与分组(自动重映射分组归属、偏移 24px 放置)
- 卡片渲染 Markdown 描述(marked + DOMPurify 消毒),新增「关联链接」按钮显示 link 字段
- 里程碑卡补渲染标签
- 忽略 *.tsbuildinfo 构建产物
This commit is contained in:
2026-08-05 21:45:09 +08:00
parent 81dd92cf22
commit 89b4c5f236
9 changed files with 244 additions and 6 deletions
+2
View File
@@ -2,6 +2,8 @@ node_modules/
dist/
docs/
*.tsbuildinfo
*.db
*.db-*
*.sqlite
-1
View File
@@ -4,7 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%234dabf7'/><text x='16' y='22' text-anchor='middle' font-size='18' font-weight='bold' fill='white'>T</text></svg>" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Todo Monitor</title>
</head>
<body>
+1 -1
View File
@@ -35,7 +35,7 @@
--text-canvas: #1e293b;
--text-canvas-secondary: #64748b;
--font-ui: 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-ui: -apple-system, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
--radius-sm: 6px;
@@ -22,6 +22,7 @@ const groupEditorVisible = shallowRef(false)
const activeTool = shallowRef<ToolMode>('pointer')
const selectedNoteIds = shallowRef(new Set<number>())
const selectedGroupIds = shallowRef(new Set<number>())
const clipboard = shallowRef<{ notes: Note[]; groups: Group[] } | null>(null)
let stage: Konva.Stage | null = null
let noteLayer: Konva.Layer | null = null
@@ -98,6 +99,19 @@ function setupPan() {
container.tabIndex = 0
container.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
const key = e.key.toLowerCase()
if (key === 'c') {
copySelection()
e.preventDefault()
return
}
if (key === 'v') {
pasteClipboard()
e.preventDefault()
return
}
}
if (e.code === 'Space' && !editorVisible.value) {
e.preventDefault()
isPanning = true
@@ -330,6 +344,61 @@ async function createNote(x: number, y: number) {
openEditor(note)
}
function copySelection() {
if (activeTool.value !== 'pointer') return
const notes = notesStore.notes
.filter((n) => selectedNoteIds.value.has(n.id))
.map((n) => ({ ...n }))
const groups = groupsStore.groups
.filter((g) => selectedGroupIds.value.has(g.id))
.map((g) => ({ ...g }))
if (notes.length === 0 && groups.length === 0) return
clipboard.value = { notes, groups }
}
async function pasteClipboard() {
const snapshot = clipboard.value
if (!snapshot || activeTool.value !== 'pointer') return
if (editorVisible.value || groupEditorVisible.value) return
const offset = 24
const groupIdMap = new Map<number, number>()
const newNoteIds: number[] = []
const newGroupIds: number[] = []
for (const g of snapshot.groups) {
const created = await groupsStore.createGroup({
name: g.name,
x: g.x + offset,
y: g.y + offset,
width: g.width,
height: g.height,
color: g.color,
priority: g.priority,
})
groupIdMap.set(g.id, created.id)
renderFrame(created)
newGroupIds.push(created.id)
}
for (const n of snapshot.notes) {
const newGroupId = n.group_id != null ? groupIdMap.get(n.group_id) ?? n.group_id : null
const created = await notesStore.createNote({
content: n.content,
x: n.x + offset,
y: n.y + offset,
width: n.width,
height: n.height,
color: n.color,
group_id: newGroupId,
})
renderNote(created)
newNoteIds.push(created.id)
}
clearSelection()
selectedNoteIds.value = new Set(newNoteIds)
selectedGroupIds.value = new Set(newGroupIds)
for (const id of newNoteIds) refreshNoteNode(id)
for (const id of newGroupIds) refreshFrameNode(id)
}
async function assignNotesToFrame(group: Group) {
for (const note of notesStore.notes) {
const cx = note.x + note.width / 2
@@ -3,6 +3,8 @@ import type { ParsedNote } from '@/types/card'
import { computed } from 'vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
import CardLink from '@/components/shared/CardLink.vue'
import { renderMarkdown } from '@/utils/md'
const props = defineProps<{
card: ParsedNote
@@ -46,7 +48,10 @@ const achievementColor = computed(() => {
/>
</div>
</div>
<p class="metric-desc" v-if="card.description">{{ card.description }}</p>
<p class="metric-desc" v-if="card.description" v-html="renderMarkdown(card.description)" />
<div class="metric-link" v-if="card.link">
<CardLink :url="card.link" />
</div>
</div>
</template>
@@ -133,4 +138,28 @@ const achievementColor = computed(() => {
margin-top: 12px;
text-align: center;
}
.metric-link {
display: flex;
justify-content: center;
margin-top: 10px;
}
.metric-desc :deep(a) {
color: var(--accent);
text-decoration: none;
word-break: break-all;
}
.metric-desc :deep(a:hover) {
text-decoration: underline;
}
.metric-desc :deep(strong) {
color: var(--text-primary);
}
.metric-desc :deep(p) {
margin: 4px 0;
}
</style>
@@ -4,6 +4,8 @@ import { computed } from 'vue'
import ProgressBar from '@/components/shared/ProgressBar.vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
import CardLink from '@/components/shared/CardLink.vue'
import { renderMarkdown } from '@/utils/md'
const props = defineProps<{
card: ParsedNote
@@ -54,7 +56,13 @@ const progress = computed(() => {
</div>
<p class="milestone-date">{{ card.deadline || '无截止日期' }}</p>
<ProgressBar v-if="progress !== null" :value="progress" :status="card.status || undefined" />
<p class="milestone-desc" v-if="card.description">{{ card.description }}</p>
<div class="milestone-tags" v-if="card.tags.length > 0">
<span class="tag" v-for="tag in card.tags.slice(0, 5)" :key="tag">{{ tag }}</span>
</div>
<p class="milestone-desc" v-if="card.description" v-html="renderMarkdown(card.description)" />
<div class="milestone-link" v-if="card.link">
<CardLink :url="card.link" />
</div>
</div>
</div>
</template>
@@ -104,9 +112,47 @@ const progress = computed(() => {
margin-bottom: 8px;
}
.milestone-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.milestone-tags .tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
font-weight: 500;
}
.milestone-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 8px;
}
.milestone-link {
margin-top: 8px;
}
.milestone-desc :deep(a) {
color: var(--accent);
text-decoration: none;
word-break: break-all;
}
.milestone-desc :deep(a:hover) {
text-decoration: underline;
}
.milestone-desc :deep(strong) {
color: var(--text-primary);
}
.milestone-desc :deep(p) {
margin: 4px 0;
}
</style>
+31 -2
View File
@@ -5,6 +5,8 @@ import { useNotesStore } from '@/stores/notes'
import ProgressBar from '@/components/shared/ProgressBar.vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
import CardLink from '@/components/shared/CardLink.vue'
import { renderMarkdown } from '@/utils/md'
const props = defineProps<{
card: ParsedNote
@@ -91,8 +93,10 @@ async function toggleSubTask(index: number) {
:status="card.status || undefined"
/>
<div class="card-description" v-if="card.description">
{{ card.description }}
<div class="card-description md-body" v-if="card.description" v-html="renderMarkdown(card.description)" />
<div class="card-link-row" v-if="card.link">
<CardLink :url="card.link" />
</div>
<ul class="subtask-list" v-if="card.subTasks.length > 0">
@@ -195,6 +199,31 @@ async function toggleSubTask(index: number) {
overflow: hidden;
}
.card-link-row {
display: flex;
align-items: center;
margin: 12px 0;
}
.md-body :deep(a) {
color: var(--accent);
text-decoration: none;
word-break: break-all;
}
.md-body :deep(a:hover) {
text-decoration: underline;
}
.md-body :deep(strong) {
color: var(--text-primary);
font-weight: 600;
}
.md-body :deep(p) {
margin: 4px 0;
}
.subtask-list {
list-style: none;
margin: 12px 0;
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import { normalizeUrl } from '@/utils/md'
const props = defineProps<{ url: string }>()
const href = computed(() => normalizeUrl(props.url))
</script>
<template>
<a
class="card-link"
:href="href"
target="_blank"
rel="noopener noreferrer"
:title="props.url"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
<span>关联链接</span>
</a>
</template>
<style scoped>
.card-link {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
border-radius: 14px;
text-decoration: none;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s;
}
.card-link:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'noopener noreferrer')
}
})
export function renderMarkdown(md: string): string {
return DOMPurify.sanitize(marked.parse(md, { breaks: true, async: false }) as string)
}
export function normalizeUrl(url: string): string {
return /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`
}