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 { 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>