133 lines
3.6 KiB
Vue
133 lines
3.6 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, ref } from 'vue'
|
|
import { renderMarkdown } from '@/lib/markdown'
|
|
import { MARKDOWN_ACTIONS, applyMarkdownAction, type MarkdownAction } from '@/lib/markdownEdit'
|
|
|
|
type Mode = 'write' | 'preview'
|
|
|
|
const props = defineProps<{
|
|
modelValue: string
|
|
disabled?: boolean
|
|
invalid?: boolean
|
|
required?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
|
|
|
const mode = ref<Mode>('write')
|
|
const root = ref<HTMLElement | null>(null)
|
|
|
|
const tabs = computed(() => [
|
|
{ label: t('Write'), value: 'write' },
|
|
{ label: t('Preview'), value: 'preview' },
|
|
])
|
|
const actions = computed(() => ({
|
|
bold: { label: t('Bold'), icon: 'i-lucide-bold' },
|
|
italic: { label: t('Italic'), icon: 'i-lucide-italic' },
|
|
heading: { label: t('Heading'), icon: 'i-lucide-heading' },
|
|
link: { label: t('Link'), icon: 'i-lucide-link' },
|
|
list: { label: t('List'), icon: 'i-lucide-list' },
|
|
}))
|
|
const rendered = computed(() => renderMarkdown(props.modelValue))
|
|
const empty = computed(() => props.modelValue.trim() === '')
|
|
|
|
function textarea() {
|
|
return root.value?.querySelector<HTMLTextAreaElement>('textarea') ?? null
|
|
}
|
|
|
|
async function run(action: MarkdownAction) {
|
|
const field = textarea()
|
|
if (!field) {
|
|
return
|
|
}
|
|
const edit = applyMarkdownAction(
|
|
action,
|
|
props.modelValue,
|
|
field.selectionStart,
|
|
field.selectionEnd,
|
|
)
|
|
emit('update:modelValue', edit.value)
|
|
await nextTick()
|
|
field.focus()
|
|
field.setSelectionRange(edit.start, edit.end)
|
|
}
|
|
|
|
async function onMode(value: string | number) {
|
|
mode.value = value === 'preview' ? 'preview' : 'write'
|
|
if (mode.value === 'write') {
|
|
await nextTick()
|
|
textarea()?.focus()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="root"
|
|
data-testid="markdown-field"
|
|
class="flex min-w-0 flex-col border bg-default"
|
|
:class="[
|
|
invalid ? 'border-error shadow-[inset_0_0_0_1px_var(--ui-error)]' : 'border-accented',
|
|
disabled ? 'bg-elevated' : '',
|
|
]"
|
|
>
|
|
<div class="flex items-center gap-0.5 overflow-hidden border-b border-default px-1.5">
|
|
<UTabs
|
|
:model-value="mode"
|
|
:items="tabs"
|
|
:content="false"
|
|
variant="link"
|
|
color="neutral"
|
|
:ui="{
|
|
root: 'mr-2 w-auto gap-0',
|
|
list: 'gap-3.5 px-1.5',
|
|
trigger: 'h-11 px-0 text-base md:h-8',
|
|
indicator: 'bg-primary',
|
|
}"
|
|
@update:model-value="onMode"
|
|
/>
|
|
<template v-if="mode === 'write'">
|
|
<UButton
|
|
v-for="action in MARKDOWN_ACTIONS"
|
|
:key="action"
|
|
type="button"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:icon="actions[action].icon"
|
|
:aria-label="actions[action].label"
|
|
:disabled="disabled"
|
|
class="size-10 justify-center text-toned md:size-7"
|
|
:ui="{ leadingIcon: 'size-4' }"
|
|
@click="run(action)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
<UTextarea
|
|
v-show="mode === 'write'"
|
|
:model-value="modelValue"
|
|
:placeholder="t('Write in Markdown')"
|
|
:disabled="disabled"
|
|
:aria-invalid="invalid ? 'true' : undefined"
|
|
:aria-required="required ? 'true' : undefined"
|
|
variant="none"
|
|
:rows="4"
|
|
autoresize
|
|
class="w-full"
|
|
:ui="{
|
|
base: 'min-h-30 px-2.5 py-2 font-mono text-lg/5.5 md:min-h-24 md:text-xs/4.75',
|
|
}"
|
|
@update:model-value="emit('update:modelValue', String($event))"
|
|
/>
|
|
<div
|
|
v-if="mode === 'preview'"
|
|
data-testid="markdown-preview"
|
|
class="flex min-h-30 flex-col gap-2 px-3 py-2.5 text-lg md:min-h-24 md:text-sm [&_a]:text-primary [&_a]:underline [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-[square] [&_ul]:ps-4.5"
|
|
>
|
|
<p v-if="empty" class="text-muted">{{ t('Nothing to preview yet.') }}</p>
|
|
<div v-else class="flex flex-col gap-2" v-html="rendered" />
|
|
</div>
|
|
</div>
|
|
</template>
|