"use strict"; (function(namespace){ "use strict"; const F = namespace, D = F.dom; let idCounter = 0; class Attacher { #opt; #rows = []; #e = Object.create(null); #events = new EventTarget(); constructor(opt){ this.#opt = opt = F.nu({ addButtonLabel: false, startWith: 0, limit: 0, dryRun: undefined, description: true, reverse: false }, opt); this.#e.body = D.addClass(D.div(), 'Attacher'); if( opt.reverse ) this.#e.body.classList.add('reverse'); const eBtnAdd = this.#e.btnAdd = D.addClass( D.button(this.#opt.addButtonLabel || 'Add attachment', ()=>this.#addRow()), 'attach-add-button' ); eBtnAdd.type = 'button'; opt.ownsAddButton = true; this.#e.err = D.addClass(D.div(), 'error', 'hidden'); this.#e.body.append(this.#e.err); this.#e.err.addEventListener('dblclick',()=>this.reportError()); const eControls = this.#e.controls = D.addClass(D.div(), 'attach-controls'); eControls.append(eBtnAdd); if( opt.container ){ opt.container.appendChild(this.#e.body); } this.#e.body.appendChild(eControls); if( opt.listener ){ const doCb = (eventType, key)=>{ const f = (opt.listener instanceof Function) ? opt.listener : (opt.listener[key] || opt.listener.all); if( f instanceof Function ){ this.addEventListener(eventType, f); } }; doCb('entry-added', 'add'); doCb('entry-removed', 'remove'); doCb('entry-populated', 'populate'); } if( opt.dryRun ){ const eLbl = D.label(false, "Dry-run?"); const eCb = D.checkbox(true); eLbl.append(eCb); eControls.append(eLbl); eCb.checked = opt.dryRun = true; eCb.addEventListener('change',()=>opt.dryRun=eCb.checked); } if( Array.isArray(opt.controls) ){ eControls.append(...opt.controls); } if( opt.startWith > 0 ){ for(let i = 0; i < opt.startWith; ++i ){ this.#addRow(); } }else{ this.#updateControls(); } } get widget(){ return this.#e.body; } addEventListener(...args){ return this.#events.addEventListener(...args); } removeEventListener(...args){ return this.#events.removeEventListener(...args); } get isPopulated(){ for(let r of this.#rows){ if( r.file ) return true; } return false; } get isDryRun(){ return !!this.#opt.dryRun; } get controlsElement(){ return this.#e.controls; } reportError(...msg){ const e = this.#e.err; D.clearElement(e); if( msg.length ){ e.classList.remove('hidden'); e.append(...msg); }else{ e.classList.add('hidden'); } } #removeRow(rowObj){ const er = rowObj.e.row; if( er.parentNode ){ this.#rows = this.#rows.filter(v=>v!==rowObj); this.#updateControls(); er.classList.add('animate-exit'); er.addEventListener('animationend', ()=>er.remove(), {once: true}); this.#events.dispatchEvent( new CustomEvent('entry-removed',{ detail: F.nu({ type: 'entry-removed', row: rowObj, attacher: this }) }) ); } } clear(){ for(const r of [...this.#rows]){ this.#removeRow(r); } this.reportError(); } #updateControls(){ const b = this.#e.btnAdd; if( this.#opt.limit>0 && this.#rows.length >= this.#opt.limit ){ b.classList.add('hidden'); D.disable(b); }else{ b.classList.remove('hidden'); D.enable(b); if( this.#opt.ownsAddButton ){ this.#e.body.append(this.#e.controls); } } } takeAddButton(){ if( this.#opt.ownsAddButton ){ this.#opt.ownsAddButton; } return this.#e.btnAdd; } #rowError(rowObj,...msg){ let e = rowObj.e.err; if( e ){ D.clearElement(e); }else{ if( !msg.length ) return; e = rowObj.e.err = D.addClass(D.span(), 'error'); rowObj.e.info.append(e); } if( msg.length ){ e.append(...msg); e.classList.remove('hidden'); }else{ e.classList.add('hidden'); } } #addRow(){ const id = ++idCounter; const rowObj = F.nu({ id, file: null, mimeType: '' }); const eRow = D.addClass(D.div(), 'attach-row'); const eDropzone = D.addClass(D.div(), 'attach-dropzone'); const eFile = D.addClass( D.input('file'), 'attach-file-input', 'hidden' ); const eInfo = D.addClass(D.span(), 'attach-row-info'); const eFilename = D.append( D.addClass(D.span(), 'attach-filename'), "Select/drop file or click the outer border and tap your "+ "platform's conventional keyboard shortcut." ); const eSize = D.addClass(D.span(), 'attach-size'); eInfo.append(eFilename, eSize); const eDesc = this.#opt.description ? D.addClass( D.attr(D.textarea(), 'placeholder', 'Optional description...'), 'attach-desc' ) : undefined; const eRemove = D.addClass( D.button('X', (ev)=>{ ev.stopPropagation(); this.#removeRow(rowObj); }), 'attach-row-remove' ); eRemove.setAttribute('title', 'Remove this attachment.'); eRemove.type = 'button'; D.append(eDropzone, eInfo, eFile, eRemove); eDropzone.addEventListener('click', ()=>eFile.click()); eFile.addEventListener('change', (ev)=>{ if( ev.target.files.length ){ this.#injestBlob(rowObj, ev.target.files[0]); } }); eDropzone.addEventListener('dragover', (ev)=>{ ev.preventDefault(); eDropzone.classList.add('dragover'); }); eDropzone.addEventListener('dragleave', (ev)=>{ eDropzone.classList.remove('dragover'); }); const handleDrop = (ev, theRealRowObj)=>{ ev.preventDefault(); eDropzone.classList.remove('dragover'); if( ev.dataTransfer.files.length ){ const r = theRealRowObj || rowObj; this.#injestBlob(r, ev.dataTransfer.files[0]); } }; eDropzone.addEventListener('drop', handleDrop); const pasteImage = (event, item)=>{ if( item.type.indexOf('image') === 0 ) { event.preventDefault(); const blob = item.getAsFile(); if( blob.name === 'image.png' ){ rowObj.overrideName = `pasted-image-${Date.now()}.png`; } this.#injestBlob(rowObj, blob); return true; } return false; }; const pasteThing = (event, thing)=>{ if( pasteImage(event, thing) ) return true; if( 'file' === thing.kind ){ event.preventDefault(); const blob = thing.getAsFile(); if( blob ){ this.#injestBlob(rowObj, blob); return true; } } return false; }; eDesc?.addEventListener?.('paste', (e) => { e.stopPropagation(); const items = (e.clipboardData || e.originalEvent.clipboardData)?.items; if( !items ) return; for( let i = 0; i < items.length; ++i ){ const item = items[i]; if( pasteThing(e, item) ){ break; } } }); eRow.addEventListener('paste', (e) => { const items = (e.clipboardData || e.originalEvent.clipboardData)?.items; if( !items ) return; for( let i = 0; i < items.length; ++i ){ const item = items[i]; if( item.type === 'text/plain' ){ e.preventDefault(); item.getAsString((text) => { rowObj.overrideName = `pasted-text-${Date.now()}.txt`; const blob = new File([text], rowObj.overrideName, {type: 'text/plain'}); this.#injestBlob(rowObj, blob); }); break; }else if( pasteThing(e, item) ){ break; } } }); eRow.append(eDropzone); if( eDesc ) eRow.append(eDesc); rowObj.e = F.nu({ dropzone: eDropzone, info: eInfo, filename: eFilename, size: eSize, desc: eDesc, row: eRow, remove: eRemove }); this.#e.body.append(eRow); eRow.classList.add('animate-entrance'); requestAnimationFrame(() => { eRow.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); }); this.#rows.push( rowObj ); this.#updateControls(); this.#events.dispatchEvent( new CustomEvent('entry-added',{ detail: F.nu({ type: 'entry-added', row: rowObj, attacher: this }) }) ); if( 0 ){ D.attr(eRow, 'tabindex', '-1'); eRow.focus(); } } #rowMatchingName(name){ for(let r of this.#rows){ if( r.file?.name===name ) return r; } } #injestBlob(rowObj, file){ if( !file ) return; const old = this.#rowMatchingName(file.name); if( rowObj.overrideName ){ if( rowObj.overrideName !== file.name ){ file = new File([file], rowObj.overrideName, {type: file.type}); } rowObj.overrideName = undefined; } if( old && rowObj !== old ){ this.#rowError(old); this.#removeRow(rowObj); rowObj = old; } let szLbl; if( file.size < 500000 ){ szLbl = file.size + ' bytes'; }else if( file.size < 1000000 ){ szLbl = (file.size / 1024).toFixed(2)+' KB'; }else{ szLbl = (file.size / (1024 * 1024)).toFixed(2)+' MB'; } this.#rowError(rowObj); rowObj.file = file; rowObj.mimeType = file.type || 'application/octet-stream'; D.clearElement(rowObj.e.filename).append(file.name || 'Pasted Content'); D.clearElement(rowObj.e.size).append(szLbl, ' ', rowObj.mimeType || ''); rowObj.e.dropzone.classList.add('populated'); if( rowObj.e.desc ){ rowObj.e.desc.classList.remove('hidden'); } if( rowObj.e.thumbnail ){ rowObj.e.thumbnail.remove(); rowObj.e.thumbnail = undefined; } if( file.type?.startsWith?.('image/') || file.type==='BITMAP' ){ const img = rowObj.e.thumbnail = D.img(); rowObj.e.dropzone.insertBefore(img, rowObj.e.remove); img.classList.add('thumbnail'); const reader = new FileReader(); reader.onload = (e)=>img.setAttribute('src', e.target.result); reader.readAsDataURL(file); } if( F.config.attachmentSizeLimit>0 && file.size>F.config.attachmentSizeLimit ){ const eLink = D.a(F.repoUrl('help/attachment-size-limit'),'limit'); eLink.addEventListener('click', ev=>ev.stopPropagation()); this.#rowError(rowObj, "Too large: ", eLink, " is ",F.config.attachmentSizeLimit," bytes"); rowObj.ok = false; }else if( !file.size ){ this.#rowError(rowObj, "Cannot attach zero-byte files."); rowObj.ok = false; }else{ rowObj.ok = true; } this.#events.dispatchEvent( new CustomEvent('entry-populated',{ detail: F.nu({ type: 'entry-populated', row: rowObj, attacher: this }) }) ); } collectState(){ const rv = []; for(let r of this.#rows){ if( !r.e.dropzone?.classList?.contains?.('populated') ){ continue; } rv.push(F.nu({ name: r.name || r.file.name, content: r.file, description: r.e.desc?.value ?? '', mimeType: r.mimeType })); } return rv; } populateFormData(fd, namePrefix='file'){ const st = this.collectState(); let i = 0; for( ; i < st.length; ++i){ const s = st[i]; const suffix = i+1; fd.append(`${namePrefix}${suffix}`, s.content, s.name); const d = s.description?.trim?.(); if( d ){ fd.append(`${namePrefix}${suffix}_desc`, d); } } return i; } }; F.Attacher = Attacher; F.onPageLoad(function(){ const eAttachWrapper = document.querySelector('#attachadd-form-wrapper'); if( eAttachWrapper ){ document.body.querySelectorAll('#attachadd-legacy-form').forEach(e=>e.remove()); eAttachWrapper.classList.remove('hidden'); const urlArgs = new URLSearchParams(window.location.search); let zTarget = urlArgs.get('target'); let zTo = urlArgs.get('to') || urlArgs.get('from'); const eBtnSubmit = D.button("Submit"); eBtnSubmit.type = 'button'; const updateBtnSubmit = (attacher)=>{ if( attacher.isPopulated ){ eBtnSubmit.removeAttribute('disabled'); }else{ eBtnSubmit.setAttribute('disabled', ''); } }; const cbAttacherChange = (ev)=>{ const a = ev.detail.attacher; updateBtnSubmit(a); }; const att = new Attacher({ container: eAttachWrapper, startWith: 1, listener: cbAttacherChange, controls: [eBtnSubmit], description: true }); eBtnSubmit.addEventListener('click', async (ev)=>{ att.reportError(); const li = att.collectState(); if( !li.length ) return; if( eBtnSubmit.dataset.submitted ) return; eBtnSubmit.dataset.submitted = 1; D.disable(eBtnSubmit); const fd = new FormData(); att.populateFormData(fd); for( const eIn of eAttachWrapper.querySelectorAll( 'input[type="hidden"]' ) ){ if( eIn.name==='target' ){ zTarget = eIn.value; }else if( eIn.name==='to' || (eIn.name==='from' && !zTo) ){ zTo = eIn.value; } fd.append(eIn.name, eIn.value) } if( att.isDryRun ){ fd.append('dryrun', '1'); } let err; const resp = await window.fetch(F.repoUrl('attachadd_ajax_post'), { method: 'POST', body: fd }).catch((e)=>{ err = e; }); D.enable(eBtnSubmit); delete eBtnSubmit.dataset.submitted; const jr = err ? undefined : await resp.json().catch(()=>{}); if( err || jr?.error || !resp.ok ){ const msg = err ? err.message : (jr?.error || resp.statusText); att.reportError("Attaching failed: ", msg); }else{ att.clear(); let to = zTo || jr?.redirect; if( to ){ if( '/'!==to[0] ){ to = F.repoUrl(to); } window.location = to; }else if( zTarget ){ window.location = '?target='+zTarget+'&'+Date.now(); } } }); updateBtnSubmit(att); F.page.attacher = att; } }); })(window.fossil);