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
387 lines
12 KiB
Vue
387 lines
12 KiB
Vue
<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>
|