SHA256
364 lines
15 KiB
JavaScript
364 lines
15 KiB
JavaScript
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 };
|
||
}
|