feat: 支持指针模式复制粘贴、Markdown 链接渲染与多项优化
- 移除 Google Fonts 依赖,改用系统字体栈(优化国内访问) - 指针模式下支持 Ctrl+C / Ctrl+V 复制粘贴选中便签与分组(自动重映射分组归属、偏移 24px 放置) - 卡片渲染 Markdown 描述(marked + DOMPurify 消毒),新增「关联链接」按钮显示 link 字段 - 里程碑卡补渲染标签 - 忽略 *.tsbuildinfo 构建产物
This commit is contained in:
@@ -2,6 +2,8 @@ node_modules/
|
||||
dist/
|
||||
docs/
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user