<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { renderMarkdown } from '../taskui'
// Markdown-редактор с тулбаром (жирный/курсив/заголовок/списки/ссылка/код)
// и предпросмотром по переключателю. Ctrl+V (картинки) пробрасывается наверх.
// Текстовое поле — GnTextarea кита (его тёмные стили живут внутри .form-group).
const props = defineProps<{
modelValue: string
rows?: number
}>()
const emit = defineEmits<{
'update:modelValue': [string]
paste: [ClipboardEvent]
}>()
const { t } = useI18n()
const box = ref<HTMLDivElement | null>(null)
const preview = ref(false)
const html = computed(() => renderMarkdown(props.modelValue))
function taEl(): HTMLTextAreaElement | null {
return box.value?.querySelector('textarea') ?? null
}
// Обёртка выделения синтаксисом (**…**, `…`, […](…))
async function wrap(before: string, after: string, placeholder: string) {
const el = taEl()
if (!el) return
const s = el.selectionStart
const e = el.selectionEnd
const sel = props.modelValue.slice(s, e) || placeholder
emit(
'update:modelValue',
props.modelValue.slice(0, s) + before + sel + after + props.modelValue.slice(e),
)
await nextTick()
el.focus()
el.setSelectionRange(s + before.length, s + before.length + sel.length)
}
// Префикс для каждой строки выделения (заголовок, списки)
async function prefix(mark: string) {
const el = taEl()
if (!el) return
const s = el.selectionStart
const e = el.selectionEnd
const lineStart = props.modelValue.lastIndexOf('\n', s - 1) + 1
let lineEnd = props.modelValue.indexOf('\n', e)
if (lineEnd === -1) lineEnd = props.modelValue.length
const chunk = props.modelValue.slice(lineStart, lineEnd)
emit(
'update:modelValue',
props.modelValue.slice(0, lineStart) +
chunk
.split('\n')
.map((l) => mark + l)
.join('\n') +
props.modelValue.slice(lineEnd),
)
await nextTick()
el.focus()
el.setSelectionRange(s + mark.length, s + mark.length)
}
const tools = computed(() => [
{ icon: 'ph-text-bolder', label: t('md.bold'), run: () => wrap('**', '**', t('md.boldText')) },
{ icon: 'ph-text-italic', label: t('md.italic'), run: () => wrap('*', '*', t('md.italicText')) },
{ icon: 'ph-text-h-two', label: t('md.heading'), run: () => prefix('## ') },
{ icon: 'ph-list-bullets', label: t('md.list'), run: () => prefix('- ') },
{ icon: 'ph-list-checks', label: t('md.checklist'), run: () => prefix('- [ ] ') },
{
icon: 'ph-link',
label: t('md.link'),
run: () => wrap('[', '](https://)', t('md.linkText')),
},
{ icon: 'ph-code', label: t('md.code'), run: () => wrap('`', '`', '') },
])
</script>
<template>
<div ref="box" class="md-editor" @paste="emit('paste', $event)">
<div class="md-caption">{{ t('stack.form.description') }}</div>
<div class="md-toolbar">
<GnIconButton
v-for="tool in tools"
:key="tool.icon"
:icon="tool.icon"
:label="tool.label"
:title="tool.label"
size="sm"
@click="tool.run()"
/>
<GnIconButton
class="md-toggle"
:icon="preview ? 'ph-pencil-simple' : 'ph-eye'"
:label="preview ? t('common.edit') : t('common.preview')"
:title="preview ? t('common.edit') : t('common.preview')"
size="sm"
@click="preview = !preview"
/>
</div>
<GnTextarea
v-if="!preview"
class="md-textarea"
:model-value="modelValue"
:rows="rows ?? 6"
@update:model-value="emit('update:modelValue', $event)"
/>
<div v-else class="md-view md-preview" v-html="html" />
</div>
</template>
<style scoped>
.md-caption {
font-size: 0.85em;
color: var(--text-muted, #888);
margin-bottom: 0.3rem;
}
.md-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.4rem;
}
.md-toggle {
margin-left: auto;
}
.md-editor :deep(textarea.input) {
height: auto;
min-height: 9rem;
line-height: 1.5;
resize: vertical;
}
.md-preview {
min-height: 9rem;
}
</style>