import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js'; import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js'; import { state } from '../state.js'; import { toUserMessage } from '../services/ui-error-texts.js'; import { renderUserAvatar } from './avatar-image.js'; import { openAuthRequiredModal } from '../services/auth-required-modal.js'; const drafts = new Map(); function draftKey(key) { const login = String(state.session.login || 'guest').trim().toLowerCase(); return `${login}:${String(key || 'editor')}`; } function attachmentLabel(item) { const name = String(item?.name || 'Файл'); const size = Number(item?.size || 0); if (!size) return name; if (size < 1024) return `${name} · ${size} Б`; if (size < 1024 * 1024) return `${name} · ${Math.ceil(size / 1024)} КБ`; return `${name} · ${(size / 1024 / 1024).toFixed(1)} МБ`; } export function openChannelEditor({ id = 'channel-editor', title = 'Ответ', submitLabel = 'Ответить', placeholder = 'Напишите ответ', context = null, key = 'reply', extraControl = null, initialText = '', allowEmptyText = false, allowAttachments = true, rawText = false, onSubmit, isActive = () => true, } = {}) { if (!state.session.isAuthorized) { openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' }); return null; } const root = document.getElementById('modal-root'); if (!root || typeof onSubmit !== 'function') return null; if (root.querySelector('.channel-editor-overlay')) return null; const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const storageKey = draftKey(key); const login = state.session.login; const saved = drafts.get(storageKey) || { text: initialText, attachments: [] }; const attachments = Array.isArray(saved.attachments) ? [...saved.attachments] : []; const extraFields = extraControl ? [...extraControl.querySelectorAll('select,input')] : []; extraFields.forEach((field, index) => { if (saved.controls?.[index] !== undefined) field.value = saved.controls[index]; }); let inFlight = false; let composing = false; let closed = false; let picking = false; let completed = false; const attachmentController = new AbortController(); const overlay = document.createElement('div'); overlay.className = 'channel-editor-overlay'; overlay.id = id; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', `${id}-title`); const dialog = document.createElement('div'); dialog.className = 'channel-editor'; const header = document.createElement('header'); header.className = 'channel-editor__header'; const closeButton = document.createElement('button'); closeButton.type = 'button'; closeButton.className = 'icon-btn channel-editor__close'; closeButton.setAttribute('aria-label', 'Закрыть редактор'); closeButton.textContent = '×'; const heading = document.createElement('h2'); heading.id = `${id}-title`; heading.textContent = title; header.append(closeButton, heading, document.createElement('span')); const body = document.createElement('div'); body.className = 'channel-editor__body'; if (context && (context.author || context.text || context.attachmentLabel)) { const contextBox = document.createElement('div'); contextBox.className = 'channel-editor__context'; const contextTitle = document.createElement('strong'); contextTitle.textContent = context.author ? `В ответ ${context.author}` : 'Контекст сообщения'; const quote = document.createElement('p'); quote.textContent = String(context.text || context.attachmentLabel || 'Сообщение без текста'); const expand = document.createElement('button'); expand.type = 'button'; expand.className = 'text-btn channel-editor__context-toggle'; expand.textContent = 'Показать целиком'; expand.setAttribute('aria-expanded', 'false'); expand.addEventListener('click', () => { const expanded = contextBox.classList.toggle('is-expanded'); expand.textContent = expanded ? 'Свернуть' : 'Показать целиком'; expand.setAttribute('aria-expanded', String(expanded)); }); contextBox.append(contextTitle, quote, expand); body.append(contextBox); } const textarea = document.createElement('textarea'); textarea.className = 'channel-editor__input'; textarea.maxLength = 2000; textarea.placeholder = placeholder; textarea.setAttribute('aria-label', placeholder); textarea.value = String(saved.text || ''); const attachmentsBox = document.createElement('div'); attachmentsBox.className = 'channel-editor__attachments'; attachmentsBox.setAttribute('aria-live', 'polite'); const error = document.createElement('p'); error.className = 'channel-editor__error'; error.setAttribute('role', 'alert'); const clearButton = document.createElement('button'); clearButton.type = 'button'; clearButton.className = 'text-btn channel-editor__clear'; clearButton.textContent = 'Очистить черновик'; const author = document.createElement('div'); author.className = 'channel-editor__author'; author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' })); const authorName = document.createElement('strong'); authorName.textContent = login || 'Гость'; author.append(authorName); body.append(author); if (extraControl instanceof Node) body.append(extraControl); body.append(textarea, attachmentsBox, error, clearButton); const footer = document.createElement('footer'); footer.className = 'channel-editor__footer'; const attachButton = document.createElement('button'); attachButton.type = 'button'; attachButton.className = 'secondary-btn channel-editor__attach'; attachButton.textContent = 'Прикрепить'; attachButton.hidden = !allowAttachments; const counter = document.createElement('span'); counter.className = 'channel-editor__counter'; const submitButton = document.createElement('button'); submitButton.type = 'button'; submitButton.className = 'primary-btn channel-editor__submit'; submitButton.textContent = submitLabel; submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter'; footer.append(attachButton); footer.append(counter, submitButton); dialog.append(header, body, footer); overlay.append(dialog); root.replaceChildren(overlay); const saveDraft = () => { const value = { text: textarea.value, attachments: [...attachments], controls: extraFields.map((field) => field.value) }; if (completed) { drafts.delete(storageKey); return; } if (value.text || value.attachments.length) drafts.set(storageKey, value); else drafts.delete(storageKey); }; const sync = () => { textarea.style.height = 'auto'; textarea.style.height = `${Math.max(160, textarea.scrollHeight)}px`; const length = textarea.value.length; counter.textContent = `${length} / 2000`; counter.classList.toggle('is-near-limit', length >= 1800); submitButton.disabled = inFlight || picking || length > textarea.maxLength || (!allowEmptyText && !textarea.value.trim() && attachments.length === 0); dialog.setAttribute('aria-busy', String(inFlight || picking)); clearButton.hidden = !textarea.value && attachments.length === 0; attachmentsBox.replaceChildren(); attachments.forEach((item, index) => { const card = document.createElement('div'); card.className = 'channel-editor__attachment'; const label = document.createElement('span'); label.textContent = attachmentLabel(item); const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'icon-btn'; remove.setAttribute('aria-label', `Убрать вложение ${item?.name || ''}`); remove.textContent = '×'; remove.disabled = inFlight; remove.addEventListener('click', () => { attachments.splice(index, 1); saveDraft(); sync(); }); card.append(label, remove); attachmentsBox.append(card); }); }; const openedUrl = location.href; const historyId = `${Date.now()}:${Math.random()}`; history.pushState({ ...history.state, channelEditor: historyId }, '', openedUrl); const viewport = window.visualViewport; const updateViewport = () => { overlay.style.setProperty('--editor-height', `${viewport?.height || window.innerHeight}px`); overlay.style.setProperty('--editor-top', `${viewport?.offsetTop || 0}px`); }; const onBack = (event) => { if (closed) return; event.stopImmediatePropagation(); close({ fromHistory: true }); }; const observer = new MutationObserver(() => { if (!overlay.isConnected) close({ restoreFocus: false }); }); const close = ({ fromHistory = false, restoreFocus = true } = {}) => { if (closed) return; closed = true; saveDraft(); observer.disconnect(); attachmentController.abort(); document.removeEventListener('keydown', onDocumentKeydown); viewport?.removeEventListener('resize', updateViewport); viewport?.removeEventListener('scroll', updateViewport); window.removeEventListener('resize', updateViewport); window.removeEventListener('popstate', onBack, true); overlay.remove(); if (!fromHistory && location.href === openedUrl && history.state?.channelEditor === historyId) { window.addEventListener('popstate', (event) => { if (location.href === openedUrl) event.stopImmediatePropagation(); }, { capture: true, once: true }); history.back(); } if (restoreFocus && opener?.isConnected) opener.focus({ preventScroll: true }); }; overlay.cleanup = () => close({ restoreFocus: false }); const submit = async () => { if (inFlight || submitButton.disabled) return; inFlight = true; error.textContent = ''; textarea.disabled = true; attachButton.disabled = true; clearButton.disabled = true; if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = true; }); submitButton.textContent = 'Отправляем…'; sync(); try { await onSubmit({ text: rawText ? textarea.value.trim() : composeMessageWithAttachments(textarea.value.trim(), attachments), attachments: [...attachments], }); completed = true; attachments.forEach((item) => markArweaveAttachmentPlaced(login, item)); // Не удаляем новый черновик, открытый после закрытия отправляющего редактора. if (!closed || drafts.get(storageKey)?.text === textarea.value) drafts.delete(storageKey); if (!isActive() || closed) return; close(); } catch (submitError) { if (!isActive() || closed) return; inFlight = false; textarea.disabled = false; attachButton.disabled = false; clearButton.disabled = false; if (extraControl) extraControl.querySelectorAll('select,button,input').forEach((el) => { el.disabled = false; }); submitButton.textContent = submitLabel; error.textContent = toUserMessage(submitError, 'Не удалось отправить. Текст сохранён.'); saveDraft(); sync(); } }; const onDocumentKeydown = (event) => { if (!overlay.isConnected || document.querySelector('.ar-attachment-manager-root')) return; if (event.key === 'Escape') { event.preventDefault(); close(); return; } if (event.key === 'Enter' && (event.ctrlKey || event.metaKey) && !composing && !event.isComposing) { event.preventDefault(); void submit(); return; } if (event.key !== 'Tab') return; const focusable = [...overlay.querySelectorAll('button:not(:disabled), textarea:not(:disabled), select:not(:disabled)')].filter((el) => !el.hidden); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }; closeButton.addEventListener('click', () => close()); clearButton.addEventListener('click', () => { textarea.value = ''; attachments.splice(0); drafts.delete(storageKey); error.textContent = ''; sync(); textarea.focus(); }); textarea.addEventListener('input', () => { error.textContent = ''; saveDraft(); sync(); }); textarea.addEventListener('compositionstart', () => { composing = true; }); extraFields.forEach((field) => field.addEventListener('change', saveDraft)); textarea.addEventListener('compositionend', () => { composing = false; }); attachButton.addEventListener('click', async () => { if (picking || inFlight) return; if (attachments.length >= MAX_MESSAGE_ATTACHMENTS) { error.textContent = `Можно прикрепить максимум ${MAX_MESSAGE_ATTACHMENTS} файлов.`; return; } try { picking = true; dialog.inert = true; sync(); const item = await openArweaveAttachmentManager({ login, storagePwd: state.session.storagePwdInMemory, gateway: state.entrySettings.arweaveServer, selectedTxIds: attachments.map((attachment) => attachment.ar), signal: attachmentController.signal, }); if (!isActive() || !overlay.isConnected || !item) return; attachments.push(item); saveDraft(); sync(); } catch (attachError) { if (isActive() && overlay.isConnected) { error.textContent = toUserMessage(attachError, 'Не удалось добавить вложение.'); } } finally { picking = false; dialog.inert = false; if (!closed) { sync(); attachButton.focus(); } } }); submitButton.addEventListener('click', () => void submit()); document.addEventListener('keydown', onDocumentKeydown); window.addEventListener('popstate', onBack, true); viewport?.addEventListener('resize', updateViewport); viewport?.addEventListener('scroll', updateViewport); window.addEventListener('resize', updateViewport); observer.observe(root, { childList: true }); updateViewport(); sync(); requestAnimationFrame(() => { if (closed || !isActive()) return; const quote = overlay.querySelector('.channel-editor__context p'); const expand = overlay.querySelector('.channel-editor__context-toggle'); if (quote && expand && quote.clientHeight > 0) expand.hidden = quote.scrollHeight <= quote.clientHeight; sync(); textarea.focus(); textarea.setSelectionRange(textarea.value.length, textarea.value.length); }); return { close }; }