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
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:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user