(function(F){ "use strict"; const P = F.page, D = F.dom; let idCounter = 0; const dummyPadding = D.div(); dummyPadding.style.height = '75em'; dummyPadding.refs = new Set(); F.dummyPadding = dummyPadding; class ForumPostEditor { #opt; #e; #att; #isWaiting = false; #tabs; #toDisable = []; #activeTab; #extraFields; #draft; constructor(opt){ opt = this.#opt = F.nu({ draftKey: undefined, hideStash: false }, opt); opt.isNewThread = !opt.inReplyTo && !opt.edit; if( opt.draftKey ){ this.#draft = F.nu(F.storage.getJSON(opt.draftKey, {})); } const e = this.#e = F.nu({ mimetype: F.nu(), button: F.nu() }); const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor'); D.clearElement(wrapper); if( !opt.inReplyTo ){ e.titleBar = D.addClass(D.div(),'titlebar'); e.title = D.attr( D.addClass(D.input('text'), 'title'), 'placeholder', 'Thread title (required)' ); e.title.setAttribute('maxlength', 125); e.titleBar.append( D.append(D.span(), "Title:"), e.title ); if( this.#draft ){ e.title.addEventListener('blur', ()=>{ this.#draft.title = e.title.value; this.#storeDraft(); }); e.title.value = this.#draft.title || opt.edit?.H || ''; }else if( opt.edit?.H ){ e.title.value = opt.edit.H; } wrapper.append(e.titleBar); } { e.mimetype.wrapper = D.addClass(D.div(), 'mimetype-wrapper'); const sel = e.mimetype.select = D.addClass(D.select(), 'mimetype-select'); sel.setAttribute('title', 'Markup format for this post.'); this.#toDisable.push(sel); let i = 0; D.option(sel, '', '- Markup format -').disabled = true; for(const [k,v] of Object.entries({ 'text/x-markdown': 'Markdown', 'text/x-fossil-wiki': 'Fossil Wiki', 'text/plain': 'Plain text' })) { D.option(sel, k, v); } sel.value = opt.mimetype || this.#draft?.mimetype || F.storage.get('forum-mimetype', sel.options[1].value); sel.addEventListener('change',ev=>{ if( this.#draft && this.#draft.mimetype!==ev.target.value ){ this.#draft.mimetype = ev.target.value; this.#storeDraft(); } F.storage.set('forum-mimetype', ev.target.value); }); e.mimetype.wrapper.append(sel); } e.buttons = D.addClass(D.div(), 'buttons'); { e.button.preview = D.attr( D.button("Preview", e=>this.#preview()), 'title', 'Preview your edits.' ); e.button.submit = D.attr( D.button("Post as "+F.user.name), 'title', 'Save any edits to the server. Not permitted until Preview has been used.' ); if( this.#draft && !opt.hideStash ){ e.button.stash = D.attr( D.button( "Stash", e=>this.close() ), 'title', "Close this editor and stash any edits locally." ); } if( opt.ondiscard instanceof Function ){ e.button.discard = D.attr( D.button('Discard'), 'title', 'Close the editor and discard all local edits.' ); } if( 1 ){ F.confirmer(e.button.submit, { confirmText: "Confirm submit...", onconfirm: ()=>this.#submit() }); if( e.button.discard ){ F.confirmer(e.button.discard, { confirmText: "Really discard?", onconfirm: ()=>this.discard() }); } }else{ e.button.submit.addEventListener('click', ()=>this.#submit()); if( e.button.discard ){ e.button.submit.addEventListener('click', ()=>this.discard()); } } e.button.submit.setAttribute('disabled', ''); wrapper.append(e.buttons); e.error = D.addClass(D.div(), 'error', 'hidden'); wrapper.append(e.error); e.error.addEventListener('dblclick',()=>this.reportError()); } const idPrefix = 'FormPostEditor'+(++idCounter); { e.tabs = D.attr( D.addClass(D.div(), 'tab-container'), 'id', idPrefix+'-tabs' ); this.#tabs = new F.TabManager(e.tabs); this.#tabs.addEventListener('before-switch-to', (ev)=>{ switch( (this.#activeTab = ev.detail) ){ case e.preview: this.#e.button.preview.click(); break; case e.help: if( e.help.$needsInit ){ delete e.help.$needsInit; this.#initHelpTab(); } break; case e.tabAttach: if( !this.#att ) this.#initAttacherTab(); break; } }); wrapper.append( e.tabs ); e.tabEdit = D.div(); e.tabEdit.classList.add('editor-wrapper'); e.editor = D.attr( D.addClass(D.textarea(), 'editor'), 'placeholder', 'Your message to other forum-goers...' ); if( opt.inReplyTo && !F.user.isIndividual ){ const w = D.addClass(D.div(), 'warning'); w.append( "You are not ", D.a(F.repoUrl( 'login' ), 'logged in'), ' so will be posting anonymously. ', "If you log in then return here, your draft edit will ", "still be here." ); e.tabEdit.append(w); } e.tabEdit.append(e.editor); e.tabEdit.dataset.tabLabel = (opt.edit || !opt.inReplyTo) ? 'Edit' : 'Reply'; this.#tabs.addTab( e.tabEdit ); this.#tabs.switchToTab( e.tabEdit ); if( this.#draft ){ this.editorContent = this.#draft.content || opt.edit?.W || ''; e.editor.addEventListener( 'blur', ()=>{ this.#draft.content = this.editorContent; this.#storeDraft(); } ); }else if( opt.edit?.W ){ this.editorContent = opt.artifact.W; } e.preview = D.addClass(D.div(), 'preview'); e.preview.dataset.tabLabel = 'Preview'; this.#toDisable.push(e.button.preview); this.#tabs.addTab( e.preview ); } if( F.user.enableDebug ){ e.debug = D.addClass(D.div(), 'debug'); e.debug.dataset.tabLabel = 'Debug'; e.debug.setAttribute('id', idPrefix+'-debug'); for(const [k,v] of Object.entries({ dryrun: 'Dry run', domod: 'Require moderation approval', fpsilent: 'Do not send notification emails' })){ const lbl = D.label(false, v); lbl.prepend(D.checkbox(k)); e.debug.append(lbl); } this.#tabs.addTab(e.debug); } e.buttons.append(e.mimetype.wrapper); if( opt.edit && !opt.inReplyTo && F.config.forumStatuses?.length>0 ){ const sel = e.status = D.select(); sel.setAttribute('title', 'The status tag value for this post.'); D.option(sel, "", "- Status -").disabled = true; for( const status of F.config.forumStatuses ){ D.option(sel, status.value, status.label); } e.buttons.append(sel); if( opt.status ){ sel.value = opt.status; }else if( this.#draft ){ if( this.#draft.status ){ sel.value = this.#draft.status; }else{ this.#draft.status = sel.value = F.config.forumStatuses[0].value; } sel.addEventListener('change',ev=>{ const v = sel.value; if( this.#draft.status !== v ){ this.#draft.status = v; this.#storeDraft(); } }); } } if( F.user.mayAttachForum ){ e.tabAttach = D.div(); e.tabAttach.setAttribute('id', idPrefix+'-attach'); e.tabAttach.dataset.tabLabel = 'Attachments'; this.#tabs.addTab(e.tabAttach); } e.buttons.append(e.button.preview, e.button.submit); if( e.button.stash ){ e.buttons.append(e.button.stash); this.#toDisable.push(e.button.stash); } if( e.button.discard ){ e.buttons.append(e.button.discard); this.#toDisable.push(e.button.discard); } e.help = D.attr(D.div(), 'id', idPrefix+'-help'); e.help.$needsInit = true; e.help.dataset.tabLabel = 'Help'; this.#tabs.addTab(e.help); if( opt.hiddenFields ){ this.addHiddenFields( opt.hiddenFields ); delete opt.hiddenFields; } { const eCb = D.checkbox(1); const eLbl = D.label(); const eHelp = D.append( D.span(), [ 'When checked, shift-enter will toggle between preview ', 'and edit modes, which is generally useful but some ', 'software keyboards misinteract with it. If the preview ', 'starts when tapping Enter, turn this setting off.' ].join('') ); eCb.checked = F.storage.getBool( 'edit-shift-enter-preview', true ); eCb.addEventListener('change', (ev)=>{ F.storage.set('edit-shift-enter-preview', eCb.checked); }); F.helpButtonlets.setup(eHelp); eLbl.append("Shift-enter toggles preview?", eCb, eHelp); e.tabEdit.append(eLbl); const isShiftEnter = (ev)=>eCb.checked && ev.shiftKey && 13===ev.keyCode; e.editor.addEventListener('keydown',(ev)=>{ if(!isShiftEnter(ev)) return; ev.preventDefault(); ev.stopPropagation(); e.editor.blur(); this.#tabs.switchToTab(e.preview); }, false); document.body.addEventListener('keydown',(ev)=>{ if(!isShiftEnter(ev)) return; if(this.#activeTab !== e.tabEdit){ ev.preventDefault(); ev.stopPropagation(); this.#tabs.switchToTab(e.tabEdit); e.editor.focus(); return false; } }, true); } if(0){ const elemsToToggle = document.body.querySelectorAll( ':scope > header, :scope > nav' ); e.button.toggleHeader = D.button('Toggle header', e=>{ for(const et of elemsToToggle){ et.classList.toggle('hidden'); } }); e.buttons.append(e.button.toggleHeader); } if(0){ const eLbl = D.label(false, "Posting as "+F.user.name) eLbl.classList.add('logged-in-as'); e.buttons.append(eLbl); } } close(){ const e = this.#e.widget; if( e?.parentNode ){ if( this.#opt.onclose instanceof Function ){ try{this.#opt.onclose();} catch(e){ console.error("ForumPostEditor.onclose() threw:",e); } } e.classList.add('animate-exit'); e.addEventListener('animationend', ()=>e.remove(), {once: true}); dummyPadding.refs.delete(this); if( 0===dummyPadding.refs.size ){ dummyPadding.remove(); } } } discard(){ if( this.#opt.ondiscard instanceof Function ){ try{this.#opt.ondiscard(this);} catch(e){ console.error("ForumPostEditor.ondiscard() threw:",e); } } this.#clearDraft(); this.close(); } get widget(){ if( !dummyPadding.parentElement ){ document.body.append(dummyPadding); } dummyPadding.refs.add(this); return this.#e.widget; } get editorContent(){ return this.#e.editor.value; } set editorContent(v){ this.#e.editor.value = v; } reportError(...msg){ const e = this.#e.error; D.clearElement(e); if( msg.length ){ console.error('ForumPostEditor:',...msg); e.classList.remove('hidden'); e.append( ...msg, D.br(), D.button("Clear", ()=>this.reportError()) ); }else{ e.classList.add('hidden'); } } addHiddenFields(list){ this.#extraFields ??= []; for( const f of list ){ if( !f ) continue; if( 'title'===f.name && this.#e.title ){ if( f.value && this.#opt.isNewThread && !this.#e.title.value ){ this.#e.title.value = f.value; } }else{ this.#extraFields.push(f); } } } get mimetype(){ return this.#e.mimetype.select.value; } get title(){ return this.#e.title?.value || this.#opt.edit?.H; } #initHelpTab(){ const eh = this.#e.help; const list = D.ul(); D.append( D.li(list), D.attr(D.a(F.repoUrl('md_rules'), 'Markdown markup rules'), 'target', '_new') ); D.append( D.li(list), D.attr(D.a(F.repoUrl('wiki_rules'), 'Fossil wiki markup rules'), 'target', '_new') ); D.append( D.li(list), "WARNING: draft edits are keyed on the ID of the message they ", "are editing or responding to. Attempting to edit or reply to ", "the same post from multiple tabs will cause the most-recently-edited ", "one to overwrite the draft slot for that post. In browsers which support ", "Web Locks, a second attempt to edit or reply to a post will be blocked ", "and an error will be shown explaining the problem." ); if( this.#e.status ){ D.append( D.li(list), "Tip: changing just the status in the editor will change only that, ", "not a whole new (but unedited) copy of the post." ); } eh.append(list); } #initAttacherTab(){ this.#att = new F.Attacher({ reverse: true }); if( this.#opt.edit ){ const eNote = D.append( D.div(), "Tip: attachments can be added to posts without editing them ", "by visiting ", D.attr( D.a(F.repoUrl('attachadd?target='+this.#opt.edit.uuid), '/attachadd'), 'target', '_new' ), ".", ); this.#e.tabAttach.append(eNote); } this.#e.tabAttach.append(this.#att.widget); } #newFormData(addThisContent){ const fd = new FormData; for(const f of this.#extraFields){ fd.append(f.name, f.value); } let v; if( this.#opt.inReplyTo ){ fd.append( 'firt', this.#opt.inReplyTo ); }else if( (v = (this.#e.title?.value?.trim?.() || this.#opt.edit?.H)) ){ fd.append('title', v); } fd.append('mimetype', this.mimetype); fd.append('content', addThisContent || this.editorContent.trim()); return fd; } async #fetchPreview(content){ const e = this.#e; const fd = new FormData; let ext; switch(this.mimetype){ case 'text/x-markdown': ext = 'md'; break; case 'text/x-fossil-wiki': ext = 'wiki'; break; default: ext = 'txt'; break; } fd.append('filename', 'x.'+ext); fd.append('content', this.editorContent.trim()); return window .fetch(F.repoUrl('ajax/preview-text'),{ method: 'POST', body: fd }) .then(r=>r.text()) .then(t=>{ if( /^\{.*}$/.test(t) ){ const o = JSON.parse(t); throw new Error(o.error); } return t; }); } #setPreviewContent(rawHtml){ const preview = this.#e.preview; const childs = [...preview.childNodes]; D.parseHtml(preview, rawHtml); D.remove(childs); if(F.pikchr && 'text/x-markdown'===this.mimetype){ F.pikchr.addSrcView( preview.querySelectorAll('svg.pikchr') ); } } async #preview(){ if( this.#isWaiting ) return; const e = this.#e; if( e.preview !== this.#activeTab ){ this.#tabs.switchToTab(e.preview); return; } const content = this.editorContent.trim(); if( !content ){ return; } if( 0 && !e.preview.firstElementChild ){ if( 0 ){ const c = window.getComputedStyle(e.editor); e.preview.style.height = c.height; }else{ e.preview.style.height = '20em'; } } this.#isWaiting = true; D.disable(this.#toDisable, e.button.submit); this.#fetchPreview(content) .then((c)=>{ this.#setPreviewContent(c); D.enable(e.button.submit); }) .catch(err=>{ e.preview.textContent = "Error fetching preview: "+err.message; console.error("Error fetching preview:",err); this.reportError(err.message); }) .finally(()=>{ this.#isWaiting = false; D.enable(this.#toDisable); }); } #validate(tgt){ if( this.#e.title ){ const v = this.#e.title.value.trim(); if( !v ){ this.reportError("A non-empty title is required."); return; } } return true; } #submit(){ if( this.#isWaiting ) return; if( !this.#validate() ) return; this.#isWaiting = true; const e = this.#e; D.disable(e.button.submit); const fd = this.#newFormData(); if( this.#e.status ){ const v = this.#e.status.value; if( this.#e.status.dataset.originalValue !== v ){ fd.append("status", v); } } if( e.debug ){ e.debug.querySelectorAll('input[type=checkbox]').forEach(cb=>{ if( cb.checked ){ fd.append(cb.value, 1); } }); } if( this.#att ){ this.#att.populateFormData(fd); } if( 0 ){ this.#isWaiting = false; return; } const resp = window.fetch(F.repoUrl('forumajax_save'), { method: 'POST', body: fd }).then(r=>r.json()) .then(j=>{ j = F.nu(j); console.debug("forum post editor response:",j); if( j.error ){ throw new Error(j.error); }else if( j.message ){ this.reportError(j.message); return; } if( 1 ){ this.#clearDraft(); if( this.#opt.onsubmit instanceof Function ){ try{this.#opt.onsubmit(this, j);} catch(e){ console.error("ForumPostEditor.onsubmit() threw: ", e); } } else if( 0 ){ if( this.#opt.edit.uuid === j.uuid && !j.statusModified && 0===j.attachedCount ){ this.reportError("No changes made."); }else{ window.location = F.repoUrl('forumpost/'+j.uuid); setTimeout(()=>this.close(), 500); } } }else{ this.reportError( "Saving worked but we're ignoring it and staying here." ); } }) .catch((e)=>this.reportError(e.message)) .finally(()=>this.#isWaiting = false); } #storeDraft(){ if( this.#draft ){ this.#draft.mtime = Date.now(); F.storage.setJSON(this.#opt.draftKey, this.#draft); } } #clearDraft(){ if( this.#draft ){ F.storage.remove(this.#opt.draftKey); this.#draft = F.nu(); } } static purgeOldDrafts(key, days=10){ const age = (3600 * 24 * days) * 1000; const now = Date.now(); const check = (k)=>{ const o = F.storage.getJSON(k); if( o && o.mtime && (!days || (o.mtime+age <= now)) ){ F.storage.remove(k); } }; if( key instanceof RegExp ){ for(const k of F.storage.keys(false).filter(v=>key.test(v))){ check(k); } }else{ check(key); } } async #fetchPost(){ } }; F.ForumPostEditor = ForumPostEditor; F.onPageLoad(function(){ const scrollbarIsVisible = (e)=>e.scrollHeight > e.clientHeight; const getWidgetHandler = function(widget, contentElem){ return function(ev){ if(ev) ev.preventDefault(); const wasExpanded = widget.classList.contains('expanded'); widget.classList.toggle('expanded'); contentElem.classList.toggle('expanded'); if(wasExpanded){ contentElem.classList.add('shrunken'); contentElem.parentElement.scrollIntoView({ behavior: 'smooth' }); }else{ contentElem.classList.remove('shrunken'); } return false; }; }; document.querySelectorAll( 'div.forumTime, div.forumEdit' ).forEach(function f(forumPostWrapper){ const content = forumPostWrapper.querySelector('div.forumPostBody'); if(!content || !scrollbarIsVisible(content)) return; const parent = content.parentElement, widget = D.addClass( D.div(), 'forum-post-collapser','bottom' ), rightTapZone = D.addClass( D.div(), 'forum-post-collapser','right' ); const refillTapZone = function f(){ if(!f.baseTapIndicatorHeight){ const h1 = document.querySelector('h1, h2'); f.baseTapIndicatorHeight = h1.getBoundingClientRect().height; } D.clearElement(rightTapZone); var rtzHeight = parseInt(window.getComputedStyle(rightTapZone).height); do { D.append(rightTapZone, D.span()); rtzHeight -= f.baseTapIndicatorHeight * 8; }while(rtzHeight>0); }; const handlerStep1 = getWidgetHandler(widget, content); const widgetEventHandler = ()=>{ handlerStep1(); refillTapZone(); }; content.classList.add('with-expander'); widget.addEventListener('click', widgetEventHandler, false); var i = 0; for( ; i < 3; ++i ) D.append(widget, D.span()); if(content.nextSibling){ forumPostWrapper.insertBefore(widget, content.nextSibling); }else{ forumPostWrapper.appendChild(widget); } content.appendChild(rightTapZone); rightTapZone.addEventListener('click', widgetEventHandler, false); refillTapZone(); }); if(F.pikchr){ F.pikchr.addSrcView(); } const eStatus = document.querySelector( 'form div.submenu select.submenuctrl[name="status"]' ); if( eStatus ){ const pForm = eStatus.parentElement?.parentElement; if( pForm ){ eStatus.addEventListener('change', ()=>{ pForm.querySelector('input[type="hidden"][name="x"]')?.remove?.(); }, true); } }else{ const formSubmitted = function(event){ const form = event.target; if( form.dataset.submitted ){ event.preventDefault(); return; } form.dataset.submitted = '1'; setTimeout(()=>{delete form.dataset.submitted}, 7000); return; }; const deactivateButtonInFlight = (elem, form)=>{ if( elem.disabled ) return; elem.disabled = true; setTimeout( ()=>elem.disabled = false, 10000 ); form.submit(); }; document.querySelectorAll("form").forEach(function(form){ form.addEventListener('submit', formSubmitted); form .querySelectorAll("input.action-close, input.action-reopen") .forEach(function(e){ e.classList.remove('hidden'); F.confirmer(e, { confirmText: (e.classList.contains('action-reopen') ? "Confirm re-open" : "Confirm close"), onconfirm: ()=>deactivateButtonInFlight(e, form) }); }); form .querySelectorAll("input.action-approve, input.action-reject") .forEach(function(e){ e.type = 'button'; const isApprove = e.classList.contains('action-approve'); F.confirmer(e, { confirmText: (isApprove ? "Confirm approval" : "Confirm rejection"), onconfirm: ()=>{ form.append( D.attr( D.input('hidden'), 'name', isApprove ? 'approve' : 'reject', 'value', 'ignored' ) ); deactivateButtonInFlight(e, form); } }); }); form .querySelectorAll("input[type='button'].action-status") .forEach(function(btn){ const sel = btn.previousElementSibling; const updateButton = ()=>{ if( sel.dataset.initialValue ){ if( sel.dataset.initialValue===sel.value ){ btn.setAttribute('disabled',''); }else{ btn.removeAttribute('disabled'); } }else{ if(sel.selectedIndex===0){ btn.setAttribute('disabled',''); }else{ btn.removeAttribute('disabled'); } } }; sel.addEventListener('change', updateButton, true); updateButton(); F.confirmer(btn, { confirmText: "Confirm status change", onconfirm: ()=>deactivateButtonInFlight(btn, form) }); }); }); } const initFPEWidget = (fpe, ePost)=>{ ePost.eUnhideThenWhenDone = [ ]; for( const ee of ePost.querySelectorAll( '.forumpost-single-controls, fieldset.forum-status-selection' ) ){ ee.hidden = true; ePost.eUnhideThenWhenDone.push(ee); } const w = fpe.widget; w.classList.add('animate-entrance'); ePost.append(w); requestAnimationFrame(() => { w.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }); }; const plugInEditor = (new URL(window.location).searchParams).get('nojs')===null; const eForumNew = ( plugInEditor && ( document.body.classList.contains('cpage-forumnew') || document.body.classList.contains('cpage-forume1') )) ? document.body.querySelector('#forumnew-placeholder') : null; if( plugInEditor && eForumNew ){ const fpe = new F.ForumPostEditor({ draftKey: 'draft-forumnew', hiddenFields: eForumNew.querySelectorAll('input[type=hidden]'), hideStash: true, ondiscard: ()=>{ window.location = F.repoUrl('forum'); }, onsubmit: (fpe, response)=>{ window.location = F.repoUrl('forumpost/'+response.uuid); } }); eForumNew.parentElement.insertBefore(fpe.widget, eForumNew); eForumNew.remove(); fossil.page.fpe = fpe; } else if( plugInEditor && (document.body.classList.contains('cpage-forumpost') || document.body.classList.contains('cpage-forumthread')) ){ const fetchPost = async (fpid)=>{ return window.fetch(F.repoUrl('ajax/artifact.json?uuid='+fpid)) .then(r=>r.json()) .then(j=>{ j = F.nu(j); if( j.error ) throw new Error(j.error); return j; }); }; const makeDraftKey = (prefix,uuid)=>{ return prefix+'-'+uuid.substr(0,12); }; const setupEditReplyElement = (ePost, eButton)=>{ ePost.dataset.originalMarginLeft = ePost.style.marginLeft; ePost.style.marginLeft = 'initial'; eButton.dataset.originalLabel = eButton.innerText; }; const restoreEditReplyElement = (ePost, eButton)=>{ if( ePost.dataset.originalMarginLeft ){ ePost.style.marginLeft = ePost.dataset.originalMarginLeft; delete ePost.dataset.originalMarginLeft; } if( eButton.dataset.originalLabel ){ eButton.innerText = eButton.dataset.originalLabel; delete eButton.dataset.originalLabel; } for(const ee of (ePost.eUnhideThenWhenDone || [])){ ee.removeAttribute('hidden'); } ePost.eUnhideThenWhenDone = undefined; }; const reportFPEError = (ePost,...msg)=>{ const e = D.addClass(D.p(), 'error'); e.append( ...msg, D.br(), D.button("Clear error", ()=>e.remove()) ); ePost.append(e); }; const replyClicked = async (form, ePost, eBtnReply)=>{ const fpid = ePost.dataset.fpid; const fEditHead = ePost.dataset.fedithead; const draftKey = makeDraftKey( 'draft-reply', fEditHead || fpid ); let releaseLock; if( window.navigator.locks ){ let forceReleaseLockOnClose = null; const handlePageHide = ()=>{ if( forceReleaseLockOnClose ){ forceReleaseLockOnClose(); } }; window.addEventListener('pagehide', handlePageHide); releaseLock = await new Promise((resolve)=>{ window.navigator.locks.request( 'fossil-'+draftKey, {ifAvailable: true}, async (lock)=>{ if( !lock ){ resolve(null); return; } let release; const lockReleased = new Promise(res=>release=res); forceReleaseLockOnClose = release; const wrappedRelease = ()=>{ window.removeEventListener('pagehide', handlePageHide); if( release ) release(); }; resolve(wrappedRelease); await lockReleased; }); }); if( !releaseLock ){ window.removeEventListener('pagehide', handlePageHide); reportFPEError( ePost, "This post is actively being replied to ", "in another tab. To avoid losing edits, ", "it cannot be opened here until the locking ", "tab is closed." ); return; } } setupEditReplyElement(ePost, eBtnReply); eBtnReply.innerText = "Replying..."; const ondone = (fpe, response)=>{ restoreEditReplyElement(ePost, eBtnReply); if( response ){ window.location = F.repoUrl('forumpost/'+response.uuid); setTimeout(()=>fpe.close(), 500); }else{ } }; const fpe = new F.ForumPostEditor(F.nu({ hiddenFields: form.querySelectorAll( 'input[type=hidden][name=csrf]' ), ondiscard: ()=>{}, onsubmit: ondone, onclose: ()=>{ if( releaseLock ){ releaseLock(); releaseLock = null; } ondone(); }, inReplyTo: fpid, draftKey })); initFPEWidget(fpe, ePost); }; const editClicked = async (form, ePost, eBtnEdit)=>{ const fpid = ePost.dataset.fpid; const firt = ePost.dataset.firt; const fEditHead = ePost.dataset.fedithead; const draftKey = makeDraftKey('draft-forumedit', fEditHead || fpid); let releaseLock; if( navigator.locks ){ releaseLock = await new Promise((resolve) => { navigator.locks.request( 'fossil-'+draftKey, {ifAvailable: true}, async (lock)=>{ if( !lock ){ resolve(null); return; } let release; const lockReleased = new Promise(res=>release=res); resolve(release); await lockReleased; }); }); if( !releaseLock ){ reportFPEError( ePost, "This post is actively being edited ", "in another tab. To avoid losing edits, ", "it cannot be opened here until the locking ", "tab is closed." ); return; } } setupEditReplyElement(ePost, eBtnEdit); eBtnEdit.innerText = "Editing..."; fetchPost(fpid) .then(artifact=>{ const ondone = (fpe, response)=>{ if( response ){ if( fpid === response.uuid && !response.statusModified && 0===response.attachedCount ){ fpe.reportError("No changes made."); }else{ restoreEditReplyElement(ePost, eBtnEdit); window.location = F.repoUrl('forumpost/'+response.uuid); setTimeout(()=>fpe.close(), 500); } }else{ restoreEditReplyElement(ePost, eBtnEdit); } }; const eStatusSelect = ePost.querySelector( ':scope > fieldset.forum-status-selection select[name=status]' ); const fpe = new F.ForumPostEditor(F.nu({ hiddenFields: form.querySelectorAll('input[type=hidden]'), ondiscard: ()=>{}, onsubmit: ondone, onclose: ()=>{ if(releaseLock){ releaseLock(); releaseLock = null; } ondone(); }, draftKey, edit: artifact, status: eStatusSelect?.value, inReplyTo: firt })); initFPEWidget(fpe, ePost); }) .catch(err=>{ if( releaseLock ){ releaseLock(); releaseLock = null; } restoreEditReplyElement(ePost, eBtnEdit); console.error("Error fetching post:", err); reportFPEError(ePost, "Error fetching post: ", err.message); }); }; document.body.querySelectorAll( '.forumpost-single-controls > form' ).forEach(form=>{ const eThePost = form.parentElement.parentElement; if( !eThePost?.dataset?.fpid ){ console.warn("Unexpected missing fpid", eThePost); return; } const checkButtonForDraft = (draftKeyPrefix, eBtn)=>{ const fpid = eThePost.dataset.fpid; const fEditHead = eThePost.dataset.fedithead; const draftKey = makeDraftKey(draftKeyPrefix, fEditHead || fpid); if( F.storage.contains(draftKey) ){ eBtn.classList.add('draft'); } F.storage.addEventListener('set', ({detail})=>{ if( draftKey === detail.key ){ eBtn.classList.add('draft'); } }); F.storage.addEventListener('remove', ({detail})=>{ if( draftKey === detail.key ){ eBtn.classList.remove('draft'); } }); }; const btnReply = form.querySelector('input[type=submit][name=reply]'); if( btnReply ){ const b = D.button("Reply", ()=>replyClicked(form, eThePost, b)); b.type = 'button'; checkButtonForDraft('draft-reply',b); btnReply.parentElement.insertBefore(b, btnReply); btnReply.remove(); } const btnEdit = form.querySelector('input[type=submit][name=edit]'); if( btnEdit ){ const b = D.button("Edit", ()=>editClicked(form, eThePost, b)); b.type = 'button'; checkButtonForDraft('draft-forumedit',b); btnEdit.parentElement.insertBefore(b, btnEdit); btnEdit.remove(); } }); } document.body.querySelectorAll('.remove-on-load').forEach(e=>e.remove()); document.body.querySelectorAll('.initially-hidden').forEach(e=>{ e.classList.remove('initially-hidden'); }); if( plugInEditor ){ document.body.querySelectorAll('.remove-if-replaced').forEach( e=>e.remove() ); const now = Date.now(); const lastPurge = +F.storage.get('forum-drafts-last-purge', 0); if( now - lastPurge > (24 * 60 * 60 * 1000) ){ F.storage.set('forum-drafts-last-purge', now); setTimeout(()=>{ F.ForumPostEditor.purgeOldDrafts( /^draft-(reply|forumedit)-.*/ ); }, 50); } } }); })(window.fossil);