// attach.js is the attachment section's enhancements: the lightbox,
// the upload that goes straight to the bucket, and paste and drop.
//
// Without it every image is a plain link to its inline view, which
// opens the picture, and the form posts the file to quarry, which
// carries it to the bucket (RFC 0016 §Writing, the floor). With it the
// picture opens in place, and the file never touches the forge: the
// browser hashes it, asks begin for a presigned PUT, sends the bytes
// to the bucket with the two signed headers, and commits.
//
// Paste and drop send nothing at the time. A file pasted or dropped
// into a composer is previewed under the box and referenced in it;
// when the form is submitted the script uploads each pending file —
// begin with no target and the family, then the PUT — and submits the
// form with the tickets as hidden `attach` inputs, which the op that
// writes the object commits. A file pasted or dropped on a section
// lands in its file picker, and the Upload button is the one control
// that sends at once. Nothing here is load-bearing.
(function () {
'use strict';
var canHash = !!(window.crypto && crypto.subtle && window.FormData && window.URLSearchParams);
function hex(buf) {
return Array.prototype.map.call(new Uint8Array(buf), function (b) {
return ('0' + b.toString(16)).slice(-2);
}).join('');
}
// Replace the section with a fresh render of itself, so what the
// page shows after an upload is exactly what a reload would show.
function refresh(section) {
var repo = section.getAttribute('data-repo');
var q = new URLSearchParams({
target: section.getAttribute('data-target'),
family: section.getAttribute('data-family') || '',
compact: section.classList.contains('attach-compact') ? '1' : '0',
back: location.pathname + '#attachments'
});
return fetch('/r/' + repo + '/attach.frag?' + q.toString(), { credentials: 'same-origin' })
.then(function (res) { if (!res.ok) throw new Error('frag ' + res.status); return res.text(); })
.then(function (html) {
var tpl = document.createElement('template');
tpl.innerHTML = html.trim();
var next = tpl.content.firstElementChild;
if (!next) throw new Error('empty fragment');
section.replaceWith(next);
enhance(next);
})
.catch(function () { location.reload(); });
}
function post(url, body) {
return fetch(url, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams(body).toString()
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (j) {
if (!res.ok) throw new Error(j.error || ('refused (' + res.status + ')'));
return j;
});
});
}
// A clipboard image arrives as "image.png" from every browser, and a
// page of them would be twenty files with one name. Name it by the
// moment instead, in UTC so two people's screenshots sort together.
function pastedName(file) {
if (!/^image\.[a-z0-9]+$/i.test(file.name || '')) return file.name || 'pasted';
var d = new Date(), z = function (n) { return ('0' + n).slice(-2); };
return 'pasted-' + d.getUTCFullYear() + z(d.getUTCMonth() + 1) + z(d.getUTCDate()) +
'-' + z(d.getUTCHours()) + z(d.getUTCMinutes()) + z(d.getUTCSeconds()) +
(file.name.slice(file.name.lastIndexOf('.')) || '.png');
}
function sizeWords(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return Math.round(n / 1024) + ' KB';
return (n / 1048576).toFixed(1) + ' MB';
}
// Hash, begin and PUT: the road every upload takes. Resolves to the
// begin answer (ticket and all) once the bucket has the bytes. An
// empty target with a family defers the ticket for an object that
// does not exist yet.
function send(repo, target, family, file, name, bar) {
return file.arrayBuffer()
.then(function (buf) { return crypto.subtle.digest('SHA-256', buf); })
.then(function (digest) {
return post('/r/' + repo + '/attach/begin', {
target: target || '', family: family || '', name: name, size: String(file.size),
type: file.type || 'application/octet-stream', sha256: hex(digest)
});
})
.then(function (b) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(b.method || 'PUT', b.url);
// Content-Length is the browser's to send; the type is ours,
// and both are in the signature.
xhr.setRequestHeader('Content-Type', b.headers['Content-Type']);
xhr.upload.onprogress = function (ev) {
if (ev.lengthComputable && bar) bar.value = Math.round(100 * ev.loaded / ev.total);
};
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) resolve(b);
else reject(new Error('the bucket answered ' + xhr.status));
};
xhr.onerror = function () { reject(new Error('the upload to the bucket failed')); };
xhr.send(file);
});
});
}
// The progress bar and the error line of whatever holds the upload:
// the section's form, or the composer's control.
function gauge(el) {
var bar = el.querySelector('progress');
var err = el.querySelector('[data-hook=attach-error]');
return {
start: function () {
if (err) { err.hidden = true; err.textContent = ''; }
if (bar) { bar.hidden = false; bar.value = 0; bar.removeAttribute('value'); }
},
stop: function () { if (bar) bar.hidden = true; },
refuse: function (e) {
if (bar) bar.hidden = true;
if (err) { err.textContent = e.message || String(e); err.hidden = false; } else { alert(e.message || e); }
},
bar: bar
};
}
// Put the reference the markup resolves (internal/markup/attach.go)
// at the caret: an image inline, anything else as a link. The input
// event tells the composer's preview and the slash menu.
function insertRef(ta, name, isImage) {
if (!ta || typeof ta.setRangeText !== 'function') return;
var text = (isImage ? '!' : '') + '[' + name + '](attach:' + name + ')';
var at = ta.selectionStart;
if (at > 0 && ta.value[at - 1] !== '\n' && ta.value[at - 1] !== ' ') text = ' ' + text;
ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
// Run fn over items one after another, so the bar and the error line
// mean one file at a time. Resolves when the last is done or the
// first refuses.
function each(items, fn) {
return Array.prototype.reduce.call(items, function (p, it) {
return p.then(function () { return fn(it); });
}, Promise.resolve());
}
// The section's own form: one file through the bucket, then the
// section re-rendered.
function upload(form) {
var section = form.closest('.attach');
var repo = section.getAttribute('data-repo');
var target = section.getAttribute('data-target');
var input = form.querySelector('input[type=file]');
var file = input.files && input.files[0];
var go = form.querySelector('.attach-go');
if (!file) return Promise.resolve();
var g = gauge(form);
g.start();
go.disabled = true; input.disabled = true;
return send(repo, target, '', file, file.name, g.bar)
.then(function (b) { return post('/r/' + repo + '/attach/commit', { ticket: b.ticket }); })
.then(function () { g.stop(); form.reset(); return refresh(section); })
.catch(function (e) { g.refuse(e); go.disabled = false; input.disabled = false; });
}
// A file pasted or dropped on a section goes into its picker, where
// the Upload button sends it: the one path that uploads is still the
// one the person presses. A browser that cannot fill a picker keeps
// the picker as it was.
function pick(section, files) {
var input = section.querySelector('[data-hook=attach-form] input[type=file]');
if (!input || !files.length || !window.DataTransfer) return;
try {
var dt = new DataTransfer();
dt.items.add(files[0]);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
} catch (e) { /* the picker stays empty; the person chooses the file */ }
}
// Files pasted or dropped into a composer: previewed in the pending
// list, referenced in the box, and kept on the row for the submit.
// Nothing leaves the browser here.
function stage(ctl, files) {
var list = ctl.querySelector('[data-hook=attach-pending]');
var ta = composerBox(ctl);
Array.prototype.forEach.call(files, function (file) {
var name = pastedName(file);
var isImage = /^image\//.test(file.type);
var li = document.createElement('li');
li.setAttribute('data-name', name);
li.file = file;
if (isImage && window.URL && URL.createObjectURL) {
var img = document.createElement('img');
img.className = 'attach-thumb';
img.alt = '';
img.src = URL.createObjectURL(file);
li.appendChild(img);
}
var n = document.createElement('span');
n.className = 'attach-name'; n.textContent = name;
var sz = document.createElement('span');
sz.className = 'attach-size'; sz.textContent = sizeWords(file.size);
var b = document.createElement('button');
b.type = 'button'; b.textContent = 'Remove';
b.setAttribute('aria-label', 'take ' + name + ' out');
b.addEventListener('click', function () {
var img = li.querySelector('img');
if (img && URL.revokeObjectURL) URL.revokeObjectURL(img.src);
li.remove();
});
li.appendChild(n); li.appendChild(sz); li.appendChild(b);
list.appendChild(li);
insertRef(ta, name, isImage);
});
}
// The submit: every pending row without a ticket is uploaded in
// turn, and the form goes only when all are in the bucket. A refusal
// names the file on the error line and marks its row; the person
// takes it out or tries again, and rows already uploaded keep their
// ticket and are not sent twice.
function submitWithUploads(ctl, form, ev) {
if (form.getAttribute('data-attach-ready')) return;
var rows = Array.prototype.filter.call(ctl.querySelectorAll('[data-hook=attach-pending] > li'), function (li) {
return !li.getAttribute('data-ticket');
});
if (!rows.length) return;
ev.preventDefault();
var submitter = ev.submitter || null;
var repo = ctl.getAttribute('data-repo');
var family = ctl.getAttribute('data-family');
// An existing object (a ticket's comment box, the wiki editor) is
// named at begin; one not yet written is begun by family alone.
var target = ctl.getAttribute('data-target') || '';
var buttons = form.querySelectorAll('button[type=submit], input[type=submit]');
var g = gauge(ctl);
g.start();
buttons.forEach(function (b) { b.disabled = true; });
each(rows, function (li) {
li.removeAttribute('data-state');
return send(repo, target, family, li.file, li.getAttribute('data-name'), g.bar)
.then(function (b) {
li.setAttribute('data-ticket', b.ticket);
var input = document.createElement('input');
input.type = 'hidden'; input.name = 'attach'; input.value = b.ticket;
li.appendChild(input);
})
.catch(function (e) {
li.setAttribute('data-state', 'failed');
throw new Error(li.getAttribute('data-name') + ': ' + (e.message || e));
});
})
.then(function () {
g.stop();
form.setAttribute('data-attach-ready', '1');
if (typeof form.requestSubmit === 'function') { form.requestSubmit(submitter); return; }
// Without requestSubmit the pressed button's own name and value
// would be lost (a ticket's "Comment & close" is one), so they
// are carried as a hidden field.
if (submitter && submitter.name) {
var hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = submitter.name; hidden.value = submitter.value;
form.appendChild(hidden);
}
form.submit();
})
.catch(function (e) {
g.refuse(e);
buttons.forEach(function (b) { b.disabled = false; });
});
}
// The composer's textarea: the one in the same composer, else the
// first in the same form (the ticket form's plain Comment box).
function composerBox(ctl) {
var host = ctl.closest('.f-composer') || ctl.closest('form');
return host ? host.querySelector('textarea') : null;
}
// The element a composer's control frames while a file is over it.
function zoneOf(ctl) {
return ctl.closest('.f-composer') || ctl.closest('form') || ctl;
}
// Drop handling shared by sections and composers: the zone's
// data-drop is "over" while a file is above it and back to what the
// markup said ("ready", or nothing) after. dragleave fires for every
// child crossed, so it is reset only when the pointer has left the
// zone itself.
function droppable(zone, fn) {
if (!window.DataTransfer) return;
var idle = zone.getAttribute('data-drop');
var reset = function () {
if (idle) zone.setAttribute('data-drop', idle); else zone.removeAttribute('data-drop');
};
zone.addEventListener('dragover', function (ev) {
if (!ev.dataTransfer || Array.prototype.indexOf.call(ev.dataTransfer.types, 'Files') < 0) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = 'copy';
zone.setAttribute('data-drop', 'over');
});
zone.addEventListener('dragleave', function (ev) {
if (ev.relatedTarget && zone.contains(ev.relatedTarget)) return;
reset();
});
zone.addEventListener('drop', function (ev) {
reset();
if (!ev.dataTransfer || !ev.dataTransfer.files || !ev.dataTransfer.files.length) return;
ev.preventDefault();
fn(Array.prototype.slice.call(ev.dataTransfer.files));
});
}
// Wire one section: its form uploads through the bucket, a drop
// fills its picker, and its card buttons post without leaving the
// page. A destructive button asks first — the settings page's
// data-confirm idiom, with the browser's own dialog since these
// pages carry no other.
function enhance(section) {
var form = section.querySelector('[data-hook=attach-form]');
if (form && canHash) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
upload(form);
});
droppable(section, function (files) { pick(section, files); });
}
section.querySelectorAll('.attach-inline-form').forEach(function (f) {
f.addEventListener('submit', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var b = ev.submitter;
if (b && b.hasAttribute('data-confirm') && !window.confirm(b.getAttribute('data-confirm'))) return;
var body = {};
new FormData(f).forEach(function (v, k) { body[k] = v; });
post(f.getAttribute('action'), body)
.then(function () { return refresh(section); })
.catch(function (e) {
var err = section.querySelector('[data-hook=attach-error]');
if (err) { err.textContent = e.message; err.hidden = false; } else { alert(e.message); }
});
});
});
}
document.querySelectorAll('.attach[data-hook=attachments]').forEach(enhance);
// The composers' controls: drawn hidden, shown once the browser can
// do the work, a drop zone over the box they sit under, and the
// submit that uploads what was staged.
if (canHash) {
document.querySelectorAll('[data-hook=attach-composer]').forEach(function (ctl) {
var form = ctl.closest('form');
if (!form) return;
ctl.hidden = false;
var zone = zoneOf(ctl);
if (zone !== ctl) zone.setAttribute('data-drop', 'ready');
droppable(zone, function (files) { stage(ctl, files); });
form.addEventListener('submit', function (ev) { submitWithUploads(ctl, form, ev); });
});
}
// Which section a paste lands in when the page has several (a
// thread): the one around the focused element, else the first that
// offers an upload at all.
function sectionFor(active) {
var s = active && active.closest && active.closest('.attach[data-hook=attachments]');
if (s && s.querySelector('[data-hook=attach-form]')) return s;
var form = document.querySelector('.attach[data-hook=attachments] [data-hook=attach-form]');
return form ? form.closest('.attach') : null;
}
// A paste with files: staged in the composer the focus is in, else
// put in the picker of the section the rule above picks. Text
// pastes are the browser's.
if (canHash) {
document.addEventListener('paste', function (ev) {
var cd = ev.clipboardData;
if (!cd || !cd.files || !cd.files.length) return;
var files = Array.prototype.slice.call(cd.files);
var active = document.activeElement;
var host = active && active.closest && (active.closest('.f-composer') || active.closest('form'));
var ctl = host && host.querySelector('[data-hook=attach-composer]');
if (ctl) {
ev.preventDefault();
stage(ctl, files);
return;
}
var section = sectionFor(active);
if (!section) return;
ev.preventDefault();
pick(section, files);
});
}
// Every image on the page that an attachment route serves: the cards'
// previews and the inline images a body resolved from attach:. They
// are collected in document order, which is the order prev/next walk.
function shots() {
var out = [];
document.querySelectorAll('.attach-card[data-kind="image"]:not([data-held]) .attach-prev').forEach(function (a) {
var img = a.querySelector('img');
out.push({
src: a.getAttribute('href'),
name: a.getAttribute('data-attach-name') || (img && img.alt) || '',
sub: a.getAttribute('data-attach-sub') || '',
dl: a.getAttribute('data-attach-dl') || a.getAttribute('href'),
from: a
});
});
document.querySelectorAll('article img, .tkt-body img, .f-post-b img').forEach(function (img) {
var src = img.getAttribute('src') || '';
if (!/\/attachment\/[^/]+\/view$/.test(src)) return;
out.push({
src: src,
name: img.getAttribute('alt') || '',
sub: '',
dl: src.replace(/\/view$/, ''),
from: img
});
});
return out;
}
var all = shots();
if (!all.length) return;
var dlg = document.createElement('dialog');
dlg.className = 'attach-lightbox';
dlg.innerHTML =
'<figure><img alt=""></figure>' +
'<div class="attach-lb-bar">' +
'<button type="button" class="attach-lb-prev" aria-label="previous image">‹</button>' +
'<span class="attach-lb-meta"><span class="attach-lb-name"></span> <span class="attach-lb-sub"></span></span>' +
'<a class="attach-lb-dl" download>Download</a>' +
'<button type="button" class="attach-lb-next" aria-label="next image">›</button>' +
'<button type="button" class="attach-lb-close" aria-label="close">×</button>' +
'</div>';
document.body.appendChild(dlg);
var img = dlg.querySelector('img');
var nameEl = dlg.querySelector('.attach-lb-name');
var subEl = dlg.querySelector('.attach-lb-sub');
var dlEl = dlg.querySelector('.attach-lb-dl');
var at = 0;
var opener = null;
function show(i) {
if (i < 0) i = all.length - 1;
if (i >= all.length) i = 0;
at = i;
var s = all[at];
img.setAttribute('src', s.src);
img.setAttribute('alt', s.name);
nameEl.textContent = s.name;
subEl.textContent = s.sub;
dlEl.setAttribute('href', s.dl);
// One image is not a carousel; hide the arrows rather than offering
// two controls that do nothing.
var many = all.length > 1;
dlg.querySelector('.attach-lb-prev').hidden = !many;
dlg.querySelector('.attach-lb-next').hidden = !many;
}
function open(i, from) {
opener = from || null;
show(i);
if (typeof dlg.showModal === 'function') dlg.showModal();
else dlg.setAttribute('open', '');
dlg.querySelector('.attach-lb-close').focus();
}
// Focus goes back where it came from. A reader who opened the fourth
// card with the keyboard lands on the fourth card again, not at the
// top of the document.
dlg.addEventListener('close', function () {
if (opener && typeof opener.focus === 'function') opener.focus();
opener = null;
});
all.forEach(function (s, i) {
var target = s.from.tagName === 'IMG' && s.from.parentElement &&
s.from.parentElement.tagName === 'A' ? s.from.parentElement : s.from;
target.addEventListener('click', function (e) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
open(i, target);
});
});
dlg.querySelector('.attach-lb-prev').addEventListener('click', function () { show(at - 1); });
dlg.querySelector('.attach-lb-next').addEventListener('click', function () { show(at + 1); });
dlg.querySelector('.attach-lb-close').addEventListener('click', function () { dlg.close(); });
// The backdrop is the dialog itself: a click that lands on the
// element rather than on its contents is a click outside the picture.
dlg.addEventListener('click', function (e) { if (e.target === dlg) dlg.close(); });
dlg.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { e.preventDefault(); show(at - 1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); show(at + 1); }
});
})();