Merge 6701f841e4 into 076cd214fe
This commit is contained in:
commit
dc580bcb29
|
|
@ -4,7 +4,7 @@ import {useI18n} from 'vue-i18n'
|
||||||
export function useCopyToClipboard() {
|
export function useCopyToClipboard() {
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
|
||||||
function fallbackCopyTextToClipboard(text: string) {
|
function fallbackCopyTextToClipboard(text: string): boolean {
|
||||||
const textArea = document.createElement('textarea')
|
const textArea = document.createElement('textarea')
|
||||||
textArea.value = text
|
textArea.value = text
|
||||||
|
|
||||||
|
|
@ -17,12 +17,13 @@ export function useCopyToClipboard() {
|
||||||
textArea.focus()
|
textArea.focus()
|
||||||
textArea.select()
|
textArea.select()
|
||||||
|
|
||||||
|
let success = false
|
||||||
try {
|
try {
|
||||||
// NOTE: the execCommand is deprecated but as of 2022_09
|
// NOTE: the execCommand is deprecated but as of 2022_09
|
||||||
// widely supported and works without https
|
// widely supported and works without https
|
||||||
const successful = document.execCommand('copy')
|
success = document.execCommand('copy')
|
||||||
if (!successful) {
|
if (!success) {
|
||||||
throw new Error()
|
error(t('misc.copyError'))
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -30,18 +31,20 @@ export function useCopyToClipboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.removeChild(textArea)
|
document.body.removeChild(textArea)
|
||||||
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
return async (text: string) => {
|
return async (text: string): Promise<boolean> => {
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
fallbackCopyTextToClipboard(text)
|
return fallbackCopyTextToClipboard(text)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
error(t('misc.copyError'))
|
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.",
|
"updateSuccess": "The task was saved successfully.",
|
||||||
"deleteSuccess": "The task has been deleted successfully.",
|
"deleteSuccess": "The task has been deleted successfully.",
|
||||||
"duplicateSuccess": "The task was duplicated successfully.",
|
"duplicateSuccess": "The task was duplicated successfully.",
|
||||||
|
"copyMarkdownSuccess": "The task was copied as Markdown.",
|
||||||
"noBucket": "No bucket",
|
"noBucket": "No bucket",
|
||||||
"bucketChangedSuccess": "The task bucket has been changed successfully.",
|
"bucketChangedSuccess": "The task bucket has been changed successfully.",
|
||||||
"belongsToProject": "This task belongs to project '{project}'",
|
"belongsToProject": "This task belongs to project '{project}'",
|
||||||
|
|
@ -1001,6 +1002,7 @@
|
||||||
"relatedTasks": "Add Relation",
|
"relatedTasks": "Add Relation",
|
||||||
"moveProject": "Move",
|
"moveProject": "Move",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
|
"copyMarkdown": "Copy as Markdown",
|
||||||
"color": "Set Color",
|
"color": "Set Color",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"favorite": "Add to Favorites",
|
"favorite": "Add to Favorites",
|
||||||
|
|
|
||||||
|
|
@ -543,6 +543,13 @@
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.duplicate') }}
|
{{ $t('task.detail.actions.duplicate') }}
|
||||||
</XButton>
|
</XButton>
|
||||||
|
<XButton
|
||||||
|
variant="secondary"
|
||||||
|
icon="paste"
|
||||||
|
@click="copyTaskAsMarkdown"
|
||||||
|
>
|
||||||
|
{{ $t('task.detail.actions.copyMarkdown') }}
|
||||||
|
</XButton>
|
||||||
|
|
||||||
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
|
<span class="action-heading">{{ $t('task.detail.dateAndTime') }}</span>
|
||||||
|
|
||||||
|
|
@ -707,6 +714,8 @@ import {useConfigStore} from '@/stores/config'
|
||||||
|
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
|
import {useTaskDetailShortcuts} from '@/composables/useTaskDetailShortcuts'
|
||||||
|
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
|
||||||
|
import {formatTaskAsMarkdown} from '@/helpers/taskMarkdown'
|
||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import type {Action as MessageAction} from '@/message'
|
import type {Action as MessageAction} from '@/message'
|
||||||
|
|
@ -723,6 +732,7 @@ defineEmits<{
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const copy = useCopyToClipboard()
|
||||||
|
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const taskStore = useTaskStore()
|
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) {
|
async function setPriority(priority: Priority) {
|
||||||
const newTask: ITask = {
|
const newTask: ITask = {
|
||||||
...task.value,
|
...task.value,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue