@@ -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 } ;
}