diff --git a/docs/TZ.md b/docs/TZ.md index f4019dc..5e1d447 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.52 | +| Версия ТЗ | 0.53 | | Дата | 2026-09-30 | | Статус | На обсуждении | @@ -88,6 +88,11 @@ - Описание задачи — **Markdown** (редактирование с форматированием). - Вложения — изображения, в том числе **вставка из буфера обмена** (Ctrl+V прямо в редактор). + Вставка работает и в форме создания (вложение привязывается к документу: при + создании задачи откладывается до первого сохранения — в поле сначала + blob-плейсхолдер с живым превью, после создания файлы грузятся и ссылки + меняются на настоящие); в заметке проекта при отсутствии документа он + материализуется текущей заметкой (0.52). - Отображение Markdown в описании (просмотр и редактирование). - **Чекбоксы списков задач** (`- [ ]` / `- [x]`) в отображаемом описании кликабельны: клик переключает пункт и сохраняет статус в markdown описания. diff --git a/frontend/src/components/MdEditor.vue b/frontend/src/components/MdEditor.vue index 2fa3291..73f371a 100644 --- a/frontend/src/components/MdEditor.vue +++ b/frontend/src/components/MdEditor.vue @@ -19,6 +19,9 @@ caption?: string /** Загрузка картинок из буфера: файлы → сохранённые вложения (пустой массив — ошибка уже показана) */ uploadImages?: (files: File[]) => Promise + /** URL картинки для вкладыша/миниатюры; дефолт — вложение по id. + Отложенные (несозданный документ) дают blob-плейсхолдер */ + imageHref?: (a: Attachment) => string }>() const emit = defineEmits<{ @@ -45,6 +48,10 @@ return box.value?.querySelector('textarea') ?? null } +function hrefFor(a: Attachment): string { + return props.imageHref ? props.imageHref(a) : api.attachmentUrl(a.id) +} + async function onPaste(event: ClipboardEvent) { if (!props.uploadImages) return const files = Array.from(event.clipboardData?.files ?? []).filter((f) => @@ -57,7 +64,7 @@ const saved = await props.uploadImages(files) if (!saved.length) return const insert = saved - .map((a) => `![${a.original_name}](${api.attachmentUrl(a.id)})`) + .map((a) => `![${a.original_name}](${hrefFor(a)})`) .join('\n') // позиция курсора; в режиме превью textarea скрыта — вставляем в конец const el = taEl() @@ -66,7 +73,7 @@ '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 }))) + pasted.value.push(...saved.map((a) => ({ url: hrefFor(a), name: a.original_name }))) await nextTick() const caret = pos + insert.length + 1 el?.focus() diff --git a/frontend/src/components/TaskForm.vue b/frontend/src/components/TaskForm.vue index 01ee0e6..937e940 100644 --- a/frontend/src/components/TaskForm.vue +++ b/frontend/src/components/TaskForm.vue @@ -93,6 +93,7 @@ function resetForNext() { form.value = { ...createDefaults(), project_id: form.value.project_id } selectedTags.value = [] + pendingImages.value = [] // после создания загрузились (или ушли с ошибкой — не копим мусор) void nextTick(() => formEl.value?.querySelector('input')?.focus()) } @@ -221,6 +222,25 @@ // остальное доуточняется PATCH'ем const created = await api.createTask(f.title, f.description, props.parentId) task = await api.updateTask(created.id, patch) + // Отложенные картинки: грузим к документу и заменяем blob-плейсхолдеры + const pending = [...pendingImages.value] + pendingImages.value = [] + if (pending.length && task.document_id) { + try { + const saved = await api.uploadAttachments( + task.document_id, + pending.map((p) => p.file), + ) + attachments.value.push(...saved) + let desc = f.description + saved.forEach((att, i) => { + desc = desc.split(pending[i]!.url).join(api.attachmentUrl(att.id)) + }) + if (desc !== f.description) await api.updateTask(task.id, { description: desc }) + } catch (e) { + toast.error({ title: t('common.error'), text: String(e) }) + } + } toast.success({ title: t('common.created'), text: t('task.createdRaw') }) // наградный тост после основного (провайдер кита держит один тост) notifyCreated() @@ -236,11 +256,37 @@ } } -// Вставка изображений из буфера — только для существующей задачи. -// MdEditor сам вставляет markdown-ссылку в позицию курсора и показывает -// миниатюру; здесь только загрузка на сервер. +// Вставка изображений из буфера. При правке — сразу к документу задачи. +// При создании документа ещё нет (создаётся лениво при сохранении описания): +// файлы держим локально, MdEditor вставляет markdown с blob-плейсхолдером, +// после создания на сабмите грузим их к документу и заменяем ссылки. +// imageHref отдаёт blob-URL заглушки, чтобы превью показывало картинку. +function imageHref(a: Attachment): string { + const blob = (a as { url?: string }).url + return blob ?? api.attachmentUrl(a.id) +} + +const pendingImages = ref<{ file: File; url: string }[]>([]) + async function uploadImages(files: File[]): Promise { - if (!props.task) return [] + if (!props.task) { + return files.map((file) => { + const url = URL.createObjectURL(file) + pendingImages.value.push({ file, url }) + // id неважен (imageHref берёт blob-URL) — заглушка под тип Attachment + return { + id: 0, + document_id: 0, + original_name: file.name, + mime: file.type, + size: file.size, + created_at: '', + url, + } as unknown as Attachment + }) + } + // MdEditor сам вставляет markdown-ссылку в позицию курсора и показывает + // миниатюру; здесь только загрузка на сервер. uploading.value = true try { const saved = await api.uploadAttachments(props.task.document_id!, files) @@ -284,7 +330,7 @@ ]" /> - + { - const project0 = project.value - if (!project0?.document_id) return [] try { - return await api.uploadAttachments(project0.document_id, files) + if (project.value && project.value.document_id === null) { + project.value = await api.updateProject(project.value.id, { note: project.value.note }) + } + const docId = project.value?.document_id + if (!docId) return [] + return await api.uploadAttachments(docId, files) } catch (e) { toast.error({ title: t('common.error'), text: String(e) }) return []