Newer
Older
gnexus-tasks / frontend / src / components / MdEditor.vue
<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) => `![${a.original_name}](${api.attachmentUrl(a.id)})`)
      .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>