feat(KAN-68): add visual Markdown ticket description editor
CI/CD / preconditions (pull_request) Successful in 1s
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m11s
CI/CD / test_and_check_app (pull_request) Successful in 5m58s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-10-01 19:21:22 +00:00
parent 704fe5d241
commit d8e1659f4b
14 changed files with 1619 additions and 12 deletions
+1
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import './theme.css';
@import './ticket-description.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
+71
View File
@@ -0,0 +1,71 @@
.ticket-description {
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: var(--text-sm);
line-height: 1.75;
}
.ticket-description > * + * {
margin-top: 0.75rem;
}
.ticket-description h1 {
font-size: 1.5rem;
font-weight: 700;
}
.ticket-description h2 {
font-size: 1.25rem;
font-weight: 700;
}
.ticket-description h3,
.ticket-description h4,
.ticket-description h5,
.ticket-description h6 {
font-size: 1.125rem;
font-weight: 600;
}
.ticket-description ul {
list-style: disc;
padding-left: 1.5rem;
}
.ticket-description ol {
list-style: decimal;
padding-left: 1.5rem;
}
.ticket-description li > ul,
.ticket-description li > ol {
margin-top: 0.25rem;
}
.ticket-description blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.ticket-description a {
color: var(--color-primary);
text-decoration: underline;
text-underline-offset: 3px;
}
.ticket-description code {
border-radius: 0.25rem;
background: var(--color-muted);
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
.ticket-description pre {
max-width: 100%;
overflow-x: auto;
border-radius: 0.375rem;
background: var(--color-muted);
padding: 0.75rem;
white-space: pre;
}
.ticket-description pre code {
padding: 0;
background: none;
}
.ticket-description hr {
border-color: var(--color-border);
margin-block: 1rem;
}
.ticket-description:focus-visible {
outline: none;
}
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed, defineComponent, h } from 'vue';
import {
parseDescription,
renderDescriptionNode,
supportsVisualDescription,
} from '@/lib/ticket-description';
const props = defineProps<{ description: string }>();
const document = computed(() =>
supportsVisualDescription(props.description)
? parseDescription(props.description)
: null,
);
const Content = defineComponent(
() => () =>
document.value
? h('div', { class: 'ticket-description' }, [
renderDescriptionNode(document.value),
])
: h(
'p',
{
class: 'wrap-anywhere whitespace-pre-wrap text-sm leading-7',
},
props.description,
),
);
</script>
<template>
<Content />
</template>
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { EditorContent, useEditor } from '@tiptap/vue-3';
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
SquareCode,
Link,
Unlink,
Undo2,
Redo2,
} from 'lucide-vue-next';
import { computed, nextTick, ref, watch } from 'vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useLocalization } from '@/composables/useLocalization';
import {
descriptionExtensions,
parseDescription,
safeDescriptionLink,
supportsVisualDescription,
} from '@/lib/ticket-description';
import type {
DescriptionToolbarAction,
TicketDescriptionEditorProps,
} from '@/types/ticket-description';
const props = defineProps<TicketDescriptionEditorProps>();
const model = defineModel<string>({ required: true });
const { t } = useLocalization();
const sourceMode = ref(!supportsVisualDescription(model.value));
const supported = computed(() => supportsVisualDescription(model.value));
const visual = computed(() => !sourceMode.value && supported.value);
const linkOpen = ref(false);
const linkUrl = ref('');
const linkError = ref('');
const linkInput = ref<InstanceType<typeof Input> | null>(null);
let currentMarkdown = model.value;
const editor = useEditor({
extensions: descriptionExtensions(),
content: supported.value ? parseDescription(model.value) : '',
editable: !props.disabled,
editorProps: {
attributes: {
id: props.id,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': `${props.id}-label`,
'aria-describedby': `${props.id}-hint ${props.id}-error`,
'aria-invalid': String(Boolean(props.error)),
'aria-disabled': String(Boolean(props.disabled)),
class: 'ticket-description min-h-48 px-3 py-3 outline-none',
},
},
onUpdate: ({ editor: instance }) => {
currentMarkdown = instance.isEmpty ? '' : instance.getMarkdown();
model.value = currentMarkdown;
},
});
watch(model, (value) => {
if (value === currentMarkdown) {
return;
}
currentMarkdown = value;
if (supportsVisualDescription(value)) {
editor.value?.commands.setContent(parseDescription(value), {
emitUpdate: false,
});
}
});
watch(
() => props.disabled,
(disabled) => {
editor.value?.setEditable(!disabled, false);
if (disabled) {
linkOpen.value = false;
}
},
);
watch([() => props.error, () => props.disabled], () => {
editor.value?.setOptions({
editorProps: {
attributes: {
...editor.value.options.editorProps.attributes,
'aria-invalid': String(Boolean(props.error)),
'aria-disabled': String(Boolean(props.disabled)),
},
},
});
});
const actions: DescriptionToolbarAction[] = [
{
label: 'descriptionEditor.bold',
icon: Bold,
active: 'bold',
run: (c) => c.toggleBold(),
},
{
label: 'descriptionEditor.italic',
icon: Italic,
active: 'italic',
run: (c) => c.toggleItalic(),
},
{
label: 'descriptionEditor.strike',
icon: Strikethrough,
active: 'strike',
run: (c) => c.toggleStrike(),
},
{
label: 'descriptionEditor.heading1',
icon: Heading1,
active: 'heading',
attributes: { level: 1 },
run: (c) => c.toggleHeading({ level: 1 }),
},
{
label: 'descriptionEditor.heading2',
icon: Heading2,
active: 'heading',
attributes: { level: 2 },
run: (c) => c.toggleHeading({ level: 2 }),
},
{
label: 'descriptionEditor.heading3',
icon: Heading3,
active: 'heading',
attributes: { level: 3 },
run: (c) => c.toggleHeading({ level: 3 }),
},
{
label: 'descriptionEditor.bulletList',
icon: List,
active: 'bulletList',
run: (c) => c.toggleBulletList(),
},
{
label: 'descriptionEditor.orderedList',
icon: ListOrdered,
active: 'orderedList',
run: (c) => c.toggleOrderedList(),
},
{
label: 'descriptionEditor.quote',
icon: Quote,
active: 'blockquote',
run: (c) => c.toggleBlockquote(),
},
{
label: 'descriptionEditor.code',
icon: Code,
active: 'code',
run: (c) => c.toggleCode(),
},
{
label: 'descriptionEditor.codeBlock',
icon: SquareCode,
active: 'codeBlock',
run: (c) => c.toggleCodeBlock(),
},
{ label: 'descriptionEditor.undo', icon: Undo2, run: (c) => c.undo() },
{ label: 'descriptionEditor.redo', icon: Redo2, run: (c) => c.redo() },
];
function runAction(action: DescriptionToolbarAction): void {
if (editor.value && !props.disabled) {
action.run(editor.value.chain().focus()).run();
}
}
function switchMode(): void {
linkOpen.value = false;
sourceMode.value = !sourceMode.value;
}
async function editLink(): Promise<void> {
linkUrl.value = editor.value?.getAttributes('link').href ?? '';
linkError.value = '';
linkOpen.value = true;
await nextTick();
linkInput.value?.$el?.focus();
}
function saveLink(): void {
if (!editor.value || props.disabled) {
return;
}
const href = safeDescriptionLink(linkUrl.value.trim());
if (!href) {
linkError.value = t('descriptionEditor.invalidLink');
return;
}
const chain = editor.value.chain().focus().extendMarkRange('link');
if (editor.value.state.selection.empty && !editor.value.isActive('link')) {
chain
.insertContent({
type: 'text',
text: href,
marks: [{ type: 'link', attrs: { href } }],
})
.run();
} else {
chain.setLink({ href }).run();
}
linkOpen.value = false;
}
function closeLink(): void {
linkOpen.value = false;
editor.value?.commands.focus();
}
</script>
<template>
<div class="grid min-w-0 gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<Label
:id="`${id}-label`"
:for="id"
@click="visual && editor?.commands.focus()"
>{{ label }}</Label
>
<Button
type="button"
variant="ghost"
size="sm"
:disabled="disabled || (!supported && !visual)"
:aria-pressed="sourceMode"
@click="switchMode"
>
{{
visual
? t('descriptionEditor.source')
: t('descriptionEditor.visual')
}}
</Button>
</div>
<div
class="border-input bg-card focus-within:border-ring focus-within:ring-ring/35 shadow-xs min-w-0 overflow-hidden rounded-md border focus-within:ring-2"
:class="{ 'border-destructive': error, 'opacity-60': disabled }"
>
<div
v-if="visual"
role="group"
:aria-label="t('descriptionEditor.formatting')"
class="bg-muted/30 flex flex-wrap gap-1 border-b p-2"
>
<Button
v-for="action in actions"
:key="action.label"
type="button"
size="icon-sm"
:variant="
action.active &&
editor?.isActive(action.active, action.attributes)
? 'secondary'
: 'ghost'
"
:title="t(action.label)"
:aria-label="t(action.label)"
:aria-pressed="
action.active
? Boolean(
editor?.isActive(
action.active,
action.attributes,
),
)
: undefined
"
:disabled="
disabled ||
!editor ||
!action.run(editor.can().chain()).run()
"
@mousedown.prevent
@click="runAction(action)"
>
<component
:is="action.icon"
class="size-4"
aria-hidden="true"
/>
</Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:title="t('descriptionEditor.link')"
:aria-label="t('descriptionEditor.link')"
:aria-pressed="Boolean(editor?.isActive('link'))"
:disabled="
disabled ||
!editor ||
!editor.can().setLink({ href: 'https://example.com' })
"
@mousedown.prevent
@click="editLink"
><Link class="size-4" aria-hidden="true"
/></Button>
<Button
type="button"
size="icon-sm"
variant="ghost"
:title="t('descriptionEditor.unlink')"
:aria-label="t('descriptionEditor.unlink')"
:disabled="disabled || !editor?.isActive('link')"
@mousedown.prevent
@click="
editor
?.chain()
.focus()
.extendMarkRange('link')
.unsetLink()
.run()
"
><Unlink class="size-4" aria-hidden="true"
/></Button>
</div>
<div
v-if="linkOpen && visual"
class="grid gap-2 border-b p-3"
@keydown.esc.prevent.stop="closeLink"
>
<Label :for="`${id}-link`">{{
t('descriptionEditor.linkUrl')
}}</Label>
<Input
:id="`${id}-link`"
ref="linkInput"
v-model="linkUrl"
type="text"
:disabled="disabled"
:invalid="Boolean(linkError)"
:aria-describedby="`${id}-link-error`"
placeholder="https://"
@keydown.enter.prevent.stop="saveLink"
/>
<InputError :id="`${id}-link-error`" :message="linkError" />
<div class="flex gap-2">
<Button
type="button"
size="sm"
:disabled="disabled"
@click="saveLink"
>{{ t('descriptionEditor.applyLink') }}</Button
>
<Button
type="button"
size="sm"
variant="ghost"
@click="closeLink"
>{{ t('common.cancel') }}</Button
>
</div>
</div>
<EditorContent v-if="visual && editor" :editor="editor" />
<textarea
v-else
:id="id"
v-model="model"
:name="id"
:disabled="disabled"
:placeholder="placeholder"
:aria-labelledby="`${id}-label`"
:aria-describedby="`${id}-hint ${id}-error`"
:aria-invalid="Boolean(error)"
class="min-h-48 w-full resize-y bg-transparent px-3 py-3 font-mono text-sm outline-none"
/>
</div>
<p :id="`${id}-hint`" class="text-muted-foreground text-xs">
{{
supported
? t('descriptionEditor.hint')
: t('descriptionEditor.unsupported')
}}
</p>
<InputError :id="`${id}-error`" :message="error" />
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import TicketDescriptionEditor from '@/components/tickets/TicketDescriptionEditor.vue';
import TicketFormField from '@/components/tickets/TicketFormField.vue';
import { useLocalization } from '@/composables/useLocalization';
import { ticketStatusLabel } from '@/lib/tickets';
@@ -72,10 +73,9 @@ const statusOptions = computed(
:placeholder="t('ticketForm.titlePlaceholder')"
required
/>
<TicketFormField
<TicketDescriptionEditor
id="description"
v-model="description"
kind="textarea"
:label="t('ticketDetail.description')"
:error="errors.description"
:disabled="disabled"
@@ -13,6 +13,7 @@ import {
watch,
} from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import TicketDescription from '@/components/tickets/TicketDescription.vue';
import InputError from '@/components/InputError.vue';
import AcceptanceCriteriaChecklist from '@/components/tickets/AcceptanceCriteriaChecklist.vue';
import FormFeedback from '@/components/FormFeedback.vue';
@@ -272,16 +273,12 @@ const submit = (): void => {
</h2></CardHeader
>
<CardContent>
<p
class="wrap-anywhere whitespace-pre-wrap text-sm leading-7"
:class="{
'text-muted-foreground': !ticket.description,
}"
>
{{
ticket.description ||
t('ticketDetail.noDescription')
}}
<TicketDescription
v-if="ticket.description"
:description="ticket.description"
/>
<p v-else class="text-muted-foreground text-sm leading-7">
{{ t('ticketDetail.noDescription') }}
</p>
</CardContent>
</Card>
+129
View File
@@ -0,0 +1,129 @@
import type { JSONContent } from '@tiptap/core';
import { Markdown, MarkdownManager } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import { h } from 'vue';
import type { VNodeChild } from 'vue';
export function safeDescriptionLink(value: unknown): string | null {
if (
typeof value !== 'string' ||
/[\s\\]/u.test(value) ||
[...value].some(
(character) =>
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
)
) {
return null;
}
if (/^(https?:\/\/|mailto:)/i.test(value)) {
try {
const url = new URL(value);
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
? value
: null;
} catch {
return null;
}
}
return /^(\/(?!\/)|#)/.test(value) ? value : null;
}
export function descriptionExtensions() {
return [
StarterKit.configure({
underline: false,
trailingNode: false,
link: {
openOnClick: false,
autolink: false,
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
},
}),
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
];
}
const markdown = new MarkdownManager({
extensions: descriptionExtensions(),
markedOptions: { gfm: true, breaks: true },
});
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
export function supportsVisualDescription(value: string): boolean {
let supported = true;
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
if (
['html', 'table', 'image'].includes(token.type) ||
(token.type === 'list_item' && token.task) ||
(token.type === 'link' && !safeDescriptionLink(token.href))
) {
supported = false;
}
});
return supported;
}
export function parseDescription(value: string): JSONContent {
return markdown.parse(value);
}
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
export function renderDescriptionNode(node: JSONContent): VNodeChild {
if (node.type === 'text') {
let content: VNodeChild = node.text ?? '';
for (const mark of node.marks ?? []) {
const tag = {
bold: 'strong',
italic: 'em',
strike: 's',
code: 'code',
}[mark.type];
if (tag) {
content = h(tag, {}, [content]);
} else if (mark.type === 'link') {
const href = safeDescriptionLink(mark.attrs?.href);
if (href) {
content = h(
'a',
{ href, target: '_blank', rel: 'noopener noreferrer' },
[content],
);
}
}
}
return content;
}
const children = (node.content ?? []).map(renderDescriptionNode);
switch (node.type) {
case 'doc':
return children;
case 'paragraph':
return h('p', {}, children);
case 'heading':
return h(
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
{},
children,
);
case 'bulletList':
return h('ul', {}, children);
case 'orderedList':
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
case 'listItem':
return h('li', {}, children);
case 'blockquote':
return h('blockquote', {}, children);
case 'codeBlock':
return h('pre', {}, [h('code', {}, children)]);
case 'hardBreak':
return h('br');
case 'horizontalRule':
return h('hr');
default:
return children;
}
}
+27
View File
@@ -1,4 +1,31 @@
export const en = {
'descriptionEditor.bold': 'Bold',
'descriptionEditor.italic': 'Italic',
'descriptionEditor.strike': 'Strikethrough',
'descriptionEditor.heading1': 'Heading 1',
'descriptionEditor.heading2': 'Heading 2',
'descriptionEditor.heading3': 'Heading 3',
'descriptionEditor.bulletList': 'Bullet list',
'descriptionEditor.orderedList': 'Numbered list',
'descriptionEditor.quote': 'Quote',
'descriptionEditor.code': 'Inline code',
'descriptionEditor.codeBlock': 'Code block',
'descriptionEditor.undo': 'Undo',
'descriptionEditor.redo': 'Redo',
'descriptionEditor.link': 'Add or edit link',
'descriptionEditor.unlink': 'Remove link',
'descriptionEditor.linkUrl': 'Link address',
'descriptionEditor.applyLink': 'Apply link',
'descriptionEditor.invalidLink':
'Enter a valid https://, http://, mailto:, /path or #anchor address without spaces.',
'descriptionEditor.formatting': 'Description formatting',
'descriptionEditor.source': 'Markdown',
'descriptionEditor.visual': 'Visual editor',
'descriptionEditor.hint':
'Format your description using the toolbar or edit its Markdown source.',
'descriptionEditor.unsupported':
'This description contains formatting that requires Markdown editing. Its original content is preserved.',
'nativeApp.title': 'App',
'nativeApp.description': 'Manage the KanbanGit app on this device.',
'nativeApp.workspace': 'Workspace',
+19
View File
@@ -0,0 +1,19 @@
import type { ChainedCommands } from '@tiptap/core';
import type { Component } from 'vue';
import type { TranslationKey } from '@/types/localization';
export type TicketDescriptionEditorProps = {
id: string;
label: string;
error?: string;
disabled?: boolean;
placeholder?: string;
};
export type DescriptionToolbarAction = {
label: TranslationKey;
icon: Component;
active?: string;
attributes?: Record<string, unknown>;
run: (chain: ChainedCommands) => ChainedCommands;
};