SHA256
Дизайн Артёма
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user