<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Attachment } from '../api'
import { api } from '../api'
import { renderMarkdown } from '../taskui'
import MdLightbox from './MdLightbox.vue'
// Markdown-редактор с тулбаром (жирный/курсив/заголовок/списки/ссылка/код)
// и предпросмотром по переключателю. Ctrl+V с картинками: при заданном
// uploadImages файлы загружаются колбэком, а markdown-ссылка вставляется
// в позицию курсора; миниатюры вставленного видны под полем. Текстовое
// поле — GnTextarea кита (его тёмные стили живут внутри .form-group).
const props = defineProps<{
modelValue: string
rows?: number
/** Подпись над полем (по умолчанию — «Описание») */
caption?: string
/** Загрузка картинок из буфера: файлы → сохранённые вложения (пустой массив — ошибка уже показана) */
uploadImages?: (files: File[]) => Promise<Attachment[]>
}>()
const emit = defineEmits<{
'update:modelValue': [string]
}>()
const { t } = useI18n()
// Подпись: не передана — стандартная; передана пустой строкой — скрыта (инлайн)
const captionText = computed(() =>
props.caption === undefined ? t('stack.form.description') : props.caption,
)
const box = ref<HTMLDivElement | null>(null)
const preview = ref(false)
const html = computed(() => renderMarkdown(props.modelValue))
// Вставка картинок: загрузка + markdown в позицию курсора + миниатюры под полем
const pasteBusy = ref(false)
const pasted = ref<{ url: string; name: string }[]>([])
const lightbox = ref<{ url: string; name: string } | null>(null)
function taEl(): HTMLTextAreaElement | null {
return box.value?.querySelector('textarea') ?? null
}
async function onPaste(event: ClipboardEvent) {
if (!props.uploadImages) return
const files = Array.from(event.clipboardData?.files ?? []).filter((f) =>
f.type.startsWith('image/'),
)
if (!files.length) return
event.preventDefault()
pasteBusy.value = true
try {
const saved = await props.uploadImages(files)
if (!saved.length) return
const insert = saved
.map((a) => `})`)
.join('\n')
// позиция курсора; в режиме превью textarea скрыта — вставляем в конец
const el = taEl()
const pos = el && !preview.value ? (el.selectionStart ?? props.modelValue.length) : props.modelValue.length
emit(
'update:modelValue',
props.modelValue.slice(0, pos) + insert + '\n' + props.modelValue.slice(pos),
)
pasted.value.push(...saved.map((a) => ({ url: api.attachmentUrl(a.id), name: a.original_name })))
await nextTick()
const caret = pos + insert.length + 1
el?.focus()
el?.setSelectionRange(caret, caret)
} finally {
pasteBusy.value = false
}
}
// Клик по картинке в предпросмотре → лайтбокс
function onPreviewClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
const img = target as HTMLImageElement
lightbox.value = { url: img.getAttribute('src') ?? '', name: img.alt }
}
}
// Обёртка выделения синтаксисом (**…**, `…`, […](…))
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="onPaste">
<!-- caption='' скрывает подпись (инлайн-правка описания на странице) -->
<div v-if="captionText" class="md-caption">{{ captionText }}</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"
/>
<span v-if="pasteBusy" class="md-uploading">{{ t('stack.uploading') }}</span>
</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" @click="onPreviewClick" />
<!-- миниатюры вставленных из буфера картинок — рядом с полем -->
<div v-if="pasted.length" class="md-pasted">
<button
v-for="(p, i) in pasted"
:key="`${p.url}-${i}`"
class="md-thumb"
type="button"
:title="p.name"
@click="lightbox = p"
>
<img :src="p.url" :alt="p.name" />
</button>
</div>
<MdLightbox
v-if="lightbox"
:src="lightbox.url"
:alt="lightbox.name"
@close="lightbox = null"
/>
</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-uploading {
font-size: 0.85em;
color: var(--text-muted, #888);
align-self: center;
}
.md-editor :deep(textarea.input) {
height: auto;
min-height: 9rem;
line-height: 1.5;
resize: vertical;
}
.md-preview {
min-height: 9rem;
}
/* миниатюры вставленных картинок */
.md-pasted {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
}
.md-thumb {
padding: 0;
border: 1px solid var(--border, #2a2e42);
background: none;
cursor: zoom-in;
line-height: 0;
}
.md-thumb img {
width: 72px;
height: 54px;
object-fit: cover;
display: block;
}
.md-thumb:hover {
border-color: var(--accent, #7aa2f7);
}
/* кликабельные картинки в предпросмотре */
.md-preview :deep(img) {
cursor: zoom-in;
}
</style>