Merge 6701f841e4 into 076cd214fe
This commit is contained in:
commit
dc580bcb29
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -969,6 +969,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}'",
|
||||
|
|
@ -1001,6 +1002,7 @@
|
|||
"relatedTasks": "Add Relation",
|
||||
"moveProject": "Move",
|
||||
"duplicate": "Duplicate",
|
||||
"copyMarkdown": "Copy as Markdown",
|
||||
"color": "Set Color",
|
||||
"delete": "Delete",
|
||||
"favorite": "Add to Favorites",
|
||||
|
|
|
|||
|
|
@ -543,6 +543,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>
|
||||
|
||||
|
|
@ -707,6 +714,8 @@ import {useConfigStore} from '@/stores/config'
|
|||
|
||||
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'
|
||||
|
|
@ -723,6 +732,7 @@ defineEmits<{
|
|||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const copy = useCopyToClipboard()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
|
@ -1178,6 +1188,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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue