feat(frontend): allow copying task details as Markdown

Add a button in the task detail actions panel that copies the task
as Markdown to the clipboard. The output format is:

  # Task title

  Description as markdown (links, bold, lists preserved)

Uses prosemirror-markdown (already bundled via @tiptap/pm) to convert
the HTML description to proper Markdown. No new dependencies added.
This commit is contained in:
KodjoSuprem 2026-05-25 01:08:41 +02:00
parent 56b82b23d8
commit 8efceada1f
5 changed files with 221 additions and 7 deletions

View File

@ -4,7 +4,7 @@ import {useI18n} from 'vue-i18n'
export function useCopyToClipboard() {
const {t} = useI18n({useScope: 'global'})
function fallbackCopyTextToClipboard(text: string) {
function fallbackCopyTextToClipboard(text: string): boolean {
const textArea = document.createElement('textarea')
textArea.value = text
@ -17,12 +17,13 @@ export function useCopyToClipboard() {
textArea.focus()
textArea.select()
let success = false
try {
// NOTE: the execCommand is deprecated but as of 2022_09
// widely supported and works without https
const successful = document.execCommand('copy')
if (!successful) {
throw new Error()
success = document.execCommand('copy')
if (!success) {
error(t('misc.copyError'))
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
@ -30,18 +31,20 @@ export function useCopyToClipboard() {
}
document.body.removeChild(textArea)
return success
}
return async (text: string) => {
return async (text: string): Promise<boolean> => {
if (!navigator.clipboard) {
fallbackCopyTextToClipboard(text)
return
return fallbackCopyTextToClipboard(text)
}
try {
await navigator.clipboard.writeText(text)
return true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch(e) {
error(t('misc.copyError'))
return false
}
}
}

View File

@ -0,0 +1,100 @@
import {describe, expect, it} from 'vitest'
import {formatTaskAsMarkdown} from './taskMarkdown'
describe('formatTaskAsMarkdown', () => {
it('formats a task with title and description', () => {
const markdown = formatTaskAsMarkdown({
title: 'Write release notes',
description: '<p>Summarize the changes for v1.0</p>',
})
expect(markdown).toBe('# Write release notes\n\nSummarize the changes for v1.0')
})
it('formats a task with title only when description is empty', () => {
const markdown = formatTaskAsMarkdown({
title: 'Write release notes',
description: '',
})
expect(markdown).toBe('# Write release notes')
})
it('treats TipTap empty content as no description', () => {
const markdown = formatTaskAsMarkdown({
title: 'Write release notes',
description: '<p></p>',
})
expect(markdown).toBe('# Write release notes')
})
it('converts links to markdown links', () => {
const markdown = formatTaskAsMarkdown({
title: 'Check docs',
description: '<p>See <a href="https://vikunja.io/docs">the documentation</a> for details.</p>',
})
expect(markdown).toBe('# Check docs\n\nSee [the documentation](https://vikunja.io/docs) for details.')
})
it('converts bold and italic to markdown', () => {
const markdown = formatTaskAsMarkdown({
title: 'Formatting test',
description: '<p>This is <strong>important</strong> and <em>urgent</em></p>',
})
expect(markdown).toBe('# Formatting test\n\nThis is **important** and *urgent*')
})
it('converts bullet lists to markdown', () => {
const markdown = formatTaskAsMarkdown({
title: 'Shopping list',
description: '<ul><li><p>Milk</p></li><li><p>Eggs</p></li><li><p>Bread</p></li></ul>',
})
expect(markdown).toContain('* Milk')
expect(markdown).toContain('* Eggs')
expect(markdown).toContain('* Bread')
})
it('converts task lists with checked/unchecked state', () => {
const markdown = formatTaskAsMarkdown({
title: 'Checklist',
description: '<ul data-type="taskList"><li data-type="taskItem" data-checked="true"><p>Done item</p></li><li data-type="taskItem" data-checked="false"><p>Todo item</p></li></ul>',
})
expect(markdown).toContain('[x] Done item')
expect(markdown).toContain('[ ] Todo item')
})
it('converts tables to pipe-separated rows', () => {
const markdown = formatTaskAsMarkdown({
title: 'Table test',
description: '<table><tr><th>Name</th><th>Status</th></tr><tr><td>Task A</td><td>Done</td></tr><tr><td>Task B</td><td>Pending</td></tr></table>',
})
expect(markdown).toContain('Name | Status')
expect(markdown).toContain('Task A | Done')
expect(markdown).toContain('Task B | Pending')
})
it('preserves line breaks across paragraphs', () => {
const markdown = formatTaskAsMarkdown({
title: 'Multi-paragraph',
description: '<p>First paragraph</p><p>Second paragraph</p>',
})
expect(markdown).toBe('# Multi-paragraph\n\nFirst paragraph\n\nSecond paragraph')
})
it('trims whitespace from the title', () => {
const markdown = formatTaskAsMarkdown({
title: ' Spaces around ',
description: '',
})
expect(markdown).toBe('# Spaces around')
})
})

View File

@ -0,0 +1,92 @@
import type {ITask} from '@/modelTypes/ITask'
import {DOMParser as ProseMirrorDOMParser} from '@tiptap/pm/model'
import {defaultMarkdownSerializer, MarkdownSerializer, schema} from '@tiptap/pm/markdown'
type MarkdownTask = Pick<ITask, 'title' | 'description'>
const pmDOMParser = ProseMirrorDOMParser.fromSchema(schema)
// Non-strict mode: unknown nodes render their text content instead of throwing
const markdownSerializer = new MarkdownSerializer(
defaultMarkdownSerializer.nodes,
defaultMarkdownSerializer.marks,
{strict: false},
)
// Placeholders for task checkboxes — replaced after serialization
// because the serializer would escape the brackets
const UNCHECKED_MARKER = '\u{FFFC}\u{2610}'
const CHECKED_MARKER = '\u{FFFC}\u{2611}'
/**
* Pre-processes TipTap HTML so the default ProseMirror schema can parse it.
*/
function preprocessHTML(html: string): string {
const doc = new DOMParser().parseFromString(html, 'text/html')
for (const item of doc.querySelectorAll('li[data-type="taskItem"]')) {
const checked = item.getAttribute('data-checked') === 'true'
const prefix = checked ? CHECKED_MARKER : UNCHECKED_MARKER
const firstBlock = item.querySelector('p, div')
if (firstBlock) {
firstBlock.insertBefore(doc.createTextNode(prefix), firstBlock.firstChild)
} else {
item.insertBefore(doc.createTextNode(prefix), item.firstChild)
}
item.removeAttribute('data-type')
item.removeAttribute('data-checked')
}
for (const list of doc.querySelectorAll('ul[data-type="taskList"]')) {
list.removeAttribute('data-type')
}
for (const table of doc.querySelectorAll('table')) {
const rows: string[] = []
for (const tr of table.querySelectorAll('tr')) {
const cells: string[] = []
for (const cell of tr.querySelectorAll('td, th')) {
cells.push((cell.textContent || '').trim())
}
rows.push(cells.join(' | '))
}
const replacement = doc.createElement('div')
for (const row of rows) {
const p = doc.createElement('p')
p.textContent = row
replacement.appendChild(p)
}
table.replaceWith(replacement)
}
return doc.body.innerHTML
}
function htmlToMarkdown(html: string): string {
if (!html || html === '<p></p>') {
return ''
}
const preprocessed = preprocessHTML(html)
const dom = new DOMParser().parseFromString(preprocessed, 'text/html')
const doc = pmDOMParser.parse(dom.body)
return markdownSerializer.serialize(doc)
.replace(new RegExp(UNCHECKED_MARKER, 'g'), '[ ] ')
.replace(new RegExp(CHECKED_MARKER, 'g'), '[x] ')
.trim()
}
/**
* Format a task as Markdown (title as heading + description).
*/
export function formatTaskAsMarkdown(task: MarkdownTask): string {
const parts: string[] = [`# ${task.title.trim()}`]
const description = htmlToMarkdown(task.description)
if (description) {
parts.push('', description)
}
return parts.join('\n')
}

View File

@ -965,6 +965,7 @@
"updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.",
"duplicateSuccess": "The task was duplicated successfully.",
"copyMarkdownSuccess": "The task was copied as Markdown.",
"noBucket": "No bucket",
"bucketChangedSuccess": "The task bucket has been changed successfully.",
"belongsToProject": "This task belongs to project '{project}'",
@ -996,6 +997,7 @@
"relatedTasks": "Add Relation",
"moveProject": "Move",
"duplicate": "Duplicate",
"copyMarkdown": "Copy as Markdown",
"color": "Set Color",
"delete": "Delete",
"favorite": "Add to Favorites",

View File

@ -534,6 +534,13 @@
>
{{ $t('task.detail.actions.duplicate') }}
</XButton>
<XButton
variant="secondary"
icon="paste"
@click="copyTaskAsMarkdown"
>
{{ $t('task.detail.actions.copyMarkdown') }}
</XButton>
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
@ -685,6 +692,8 @@ import {useBaseStore} from '@/stores/base'
import {useTitle} from '@/composables/useTitle'
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {formatTaskAsMarkdown} from '@/helpers/taskMarkdown'
import {success} from '@/message'
import type {Action as MessageAction} from '@/message'
@ -701,6 +710,7 @@ defineEmits<{
const router = useRouter()
const route = useRoute()
const {t} = useI18n({useScope: 'global'})
const copy = useCopyToClipboard()
const projectStore = useProjectStore()
const taskStore = useTaskStore()
@ -1146,6 +1156,13 @@ async function duplicateCurrentTask() {
}
}
async function copyTaskAsMarkdown() {
const copied = await copy(formatTaskAsMarkdown(task.value))
if (copied) {
success({message: t('task.detail.copyMarkdownSuccess')})
}
}
async function setPriority(priority: Priority) {
const newTask: ITask = {
...task.value,