The operator: "fit and 1:1 modes as well as moving the forward and back arrows closer to the edge of the image ... mouse click and pan for 1:1 mode if it exceeds page width (defeat drag drop of image)". Ruled: "Fit may enlarge." - Fit: the picture's box is the stage's inner box, and object-fit: contain draws it whole at the largest size that fits, up or down, never cropped. It works with or without JS. 1:1 is natural pixels. - The Fit | 1:1 toggle shows for every picture; the per-picture hide is gone. It stays hidden without JS. - The mode persists as `stage-one` on <html>, set by the head script before the stage exists, so a 1:1 reel never paints a stage in Fit. Anything stored but "one" reads as Fit. Storage never raises. - The arrows sit wholly outside the DRAWN picture (near edge 8px), clamped 8px inside the stage. They sit over the picture only when it spans the stage, and never over the rail. They are re-placed on load, resize, mode switch and 1:1 scroll, and keep their CSS spot until the drawn box is known. - 1:1 drag-to-pan when the picture overflows either axis: the picture follows the pointer, a 4px threshold, pointer capture, grab/grabbing. The picture is draggable=false. The stage's reveal button moves out of the scrolled content to sit over the stage (a pan carried it off), so no control is a pan source. Contract docs/contracts/r2c_review_stage.contract.md (heid contract panel 4/4 folded; it changed the no-flash mechanism). Declared test changes: the Nyx stage-edge arrow test is replaced; the stage class and the toggle's `hidden` are updated. tests/mutations/r2c.toml 16/16. 803 passed.
336 lines
18 KiB
HTML
336 lines
18 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
|
||
{% block html_attrs %} data-booth="{{ name }}"{% endblock %}
|
||
{# THE REVIEW (R2 C6). One media item at full size — image, video or audio —
|
||
with the judgment on screen beside it, the whole set as a filmstrip below and
|
||
the tape above. Docs keep doc.html. Everything a mark can change is a
|
||
`data-region` the in-place script swaps (the rail, the filmstrip, the tape);
|
||
THE STAGE NEVER IS — swapping it would restart a playing track. #}
|
||
{% macro num(n) -%}#{{ "%0*d"|format(ord_width, n) }}{%- endmacro %}
|
||
{% block content %}
|
||
<div class="viewer review">
|
||
<div class="vbar">
|
||
<a class="vbtn vx" href="{{ back_url }}" title="back to the grid (Esc)">✕</a>
|
||
<span class="vname"><span class="ord">{{ num(ordinal) }}</span> {{ file }}</span>
|
||
<span class="vspacer"></span>
|
||
{% if kind == 'image' %}
|
||
{# A JS-only VIEWING convenience (INV-3): hidden until the script shows it,
|
||
and only ever rendered for a picture. With scripts off the image shows at
|
||
fit size and no judgment depends on this. #}
|
||
<span class="vtoggle" id="vtoggle" hidden>
|
||
<button type="button" class="vseg on" id="btn-fit" aria-pressed="true" title="the whole picture, as large as the stage allows">Fit</button><button type="button" class="vseg" id="btn-one" aria-pressed="false" title="natural pixels — drag to pan a large picture">1:1</button>
|
||
</span>
|
||
{% endif %}
|
||
{# r2b D2b + D2, in the top bar: outside every data-region, so no swap
|
||
replaces them. The fog form carries `back` and lands on this item. #}
|
||
<span class="region-wrap" data-region="blur-booth"><form class="blur-all{% if booth_blurred %} is-on{% endif %}" method="post" action="/b/{{ name_url }}/blurbooth">
|
||
<input type="hidden" name="on" value="{{ '0' if booth_blurred else '1' }}">
|
||
<input type="hidden" name="back" value="{{ file }}">
|
||
<button title="{{ 'un-blur the whole booth' if booth_blurred else 'blur every image and video in this booth — cosmetic only' }}">{{ '◉ booth blurred' if booth_blurred else '◌ blur booth' }}</button>
|
||
</form></span>
|
||
{% if film | selectattr('blurred') | list %}<button type="button" class="reveal-all-btn" data-reveal-all hidden title="blur is cosmetic — the files are still served"><span class="ra-label">👁 reveal all</span><span class="ra-note"> — blur is cosmetic</span></button>{% endif %}
|
||
<a class="vbtn" href="{{ file_url }}" download title="download {{ file }}">⬇</a>
|
||
</div>
|
||
|
||
{# THE TAPE (B's device): one segment per item in the review ring — seen,
|
||
flagged, current — so how far through the set you are is always in view. #}
|
||
<div class="tape" data-region="tape" aria-label="{{ seen_n }} of {{ ring_m }} seen">
|
||
<div class="tape-segs">
|
||
{% for x in film %}
|
||
<a class="tape-s{% if x.current %} is-current{% elif x.flagged %} is-flagged{% elif x.seen %} is-seen{% endif %}"
|
||
href="?f={{ x.url }}" title="{{ num(x.ordinal) }} {{ x.name }}"></a>
|
||
{% endfor %}
|
||
</div>
|
||
<span class="tape-count">{{ seen_n }} of {{ ring_m }} seen</span>
|
||
</div>
|
||
|
||
<div class="review-body">
|
||
{% if prev_url %}<a class="vnav vprev" href="?f={{ prev_url }}" title="previous (←)" aria-label="previous">‹</a>{% endif %}
|
||
<div class="vstage{% if kind == 'image' %} is-img{% endif %}{% if blurred %} is-blurred{% endif %}" id="vstage">
|
||
{% if kind == 'image' %}<img id="vimg" src="{{ file_url }}" alt="{{ file }}" draggable="false">
|
||
{% elif kind == 'video' %}<video id="vmedia" controls preload="metadata" src="{{ file_url }}"></video>
|
||
{% else %}<audio id="vmedia" controls preload="metadata" src="{{ file_url }}"></audio>
|
||
{% endif %}
|
||
</div>
|
||
{# The stage's reveal sits OVER the stage, outside its scrolled content
|
||
(r2c): in 1:1 a panned picture would otherwise carry it out of view, and
|
||
outside the stage it can never start a pan. #}
|
||
{% if blurred %}<button type="button" class="reveal" id="vreveal" aria-label="reveal {{ file }}">👁 reveal — blur is cosmetic</button>{% endif %}
|
||
{% if next_url %}<a class="vnav vnext" href="?f={{ next_url }}" title="next (→)" aria-label="next">›</a>{% endif %}
|
||
|
||
<aside class="vrail" id="rail" data-region="rail" aria-label="your judgment">
|
||
<div class="vr-sec">
|
||
<div class="vr-where"><span class="ord">{{ num(ordinal) }}</span> · {{ ring_k }} of {{ ring_m }}
|
||
{%- if group %} · {{ group.k }} of {{ group.n }} in {{ group.key }}{% endif %}</div>
|
||
{# THE ANNOTATION, at full size — the size where it is most readable. #}
|
||
{% if caption %}<div class="vcap">{{ caption }}</div>{% endif %}
|
||
</div>
|
||
|
||
<div class="vr-sec vr-judge">
|
||
<form class="vflag" method="post" action="/b/{{ name_url }}/flag" data-inplace>
|
||
<input type="hidden" name="target" value="{{ file }}">
|
||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||
<input type="hidden" name="back" value="view">
|
||
<input type="hidden" name="f" value="{{ file }}">
|
||
<button class="vbtn vflag-btn{% if flagged %} is-flagged{% endif %}" id="vflag-btn"
|
||
title="{{ 'un-flag this item' if flagged else 'flag this one' }} (F)"
|
||
>{{ '✔ flagged' if flagged else '○ flag' }} <kbd>F</kbd></button>
|
||
</form>
|
||
{% for m in marks if m.shape == 'note' %}
|
||
<div class="vnote"><pre>{{ m.text }}</pre>
|
||
<form method="post" action="/b/{{ name_url }}/unmark" data-inplace>
|
||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||
<input type="hidden" name="back" value="view">
|
||
<input type="hidden" name="f" value="{{ file }}">
|
||
<button class="mark-x" title="withdraw this note">×</button>
|
||
</form>
|
||
</div>
|
||
{% endfor %}
|
||
<form class="vaddnote" method="post" action="/b/{{ name_url }}/note" data-inplace>
|
||
<input type="hidden" name="target" value="{{ file }}">
|
||
<input type="hidden" name="back" value="view">
|
||
<input type="hidden" name="f" value="{{ file }}">
|
||
<textarea name="text" id="vnote-text" rows="2" placeholder="a note on this item (N)"></textarea>
|
||
<button type="submit">Add note</button>
|
||
</form>
|
||
</div>
|
||
|
||
{# A question ABOUT this item is answerable here. #}
|
||
{% if item_picks %}
|
||
<div class="vr-sec">
|
||
{% with marks=item_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if is_last %}
|
||
{# THE END OF THE SET — not a separate page: on the last item the rail
|
||
adds the summary and every question still open on the booth. #}
|
||
<div class="vr-sec vr-end">
|
||
<p class="vr-end-head">End of the set · {{ seen_n }} of {{ ring_m }} seen · {{ tray|length }} flagged</p>
|
||
{% if tray %}
|
||
<div class="tray">
|
||
{% for x in tray %}
|
||
<a class="tray-item{% if x.blurred %} is-blurred{% endif %}" href="?f={{ x.url }}" title="{{ x.name }}">
|
||
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="tray-kind">{{ x.kind }}</span>{% endif -%}
|
||
<span class="tray-ord">{{ num(x.ordinal) }}</span></a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% if other_picks %}
|
||
{% with marks=other_picks, picks_only=true, back_view=file, marks_page=false %}{% include "_marks.html" %}{% endwith %}
|
||
{% endif %}
|
||
</div>
|
||
{% elif other_picks %}
|
||
<div class="vr-sec vr-more">
|
||
<a href="/b/{{ name_url }}/">{{ other_picks|length }} more open question{{ '' if other_picks|length == 1 else 's' }} on this booth →</a>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="vr-keys"><kbd>←</kbd> <kbd>→</kbd> <kbd>Space</kbd> move · <kbd>F</kbd> flag · <kbd>N</kbd> note · <kbd>Esc</kbd> grid</div>
|
||
</aside>
|
||
</div>
|
||
|
||
{# THE FILMSTRIP: the review ring in set order, numbered like the tiles,
|
||
flagged frames underlined, the current one in the reticle. #}
|
||
<nav class="film" data-region="film" aria-label="the set">
|
||
{% for x in film %}
|
||
<a class="film-f{% if x.flagged %} is-flagged{% endif %}{% if x.current %} is-current{% endif %}{% if x.blurred %} is-blurred{% endif %}"
|
||
href="?f={{ x.url }}" title="{{ x.name }}"{% if x.current %} aria-current="true"{% endif %}>
|
||
{%- if x.kind == 'image' %}<img loading="lazy" decoding="async" src="{{ x.thumb or x.url }}" alt="">{% else %}<span class="film-kind">{{ '♪' if x.kind == 'audio' else '▶' }}</span>{% endif -%}
|
||
<span class="film-ord">{{ num(x.ordinal) }}</span></a>
|
||
{% endfor %}
|
||
</nav>
|
||
</div>
|
||
<style>
|
||
.vnav{position:absolute;top:50%;transform:translateY(-50%);z-index:4;display:flex;
|
||
align-items:center;justify-content:center;width:40px;height:56px;font-size:28px;
|
||
line-height:1;text-decoration:none;color:oklch(0.91 0.008 216);background:oklch(0.17 0.01 250 / .6);
|
||
border:1px solid rgb(255 255 255 / .12);border-radius:var(--radius-lg);margin:0 8px;user-select:none;
|
||
-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);
|
||
transition:background var(--dur-1) var(--ease-out),border-color var(--dur-1) var(--ease-out)}
|
||
.vnav:hover{background:oklch(0.21 0.01 248 / .92);border-color:rgb(255 255 255 / .3);text-decoration:none;
|
||
color:oklch(0.91 0.008 216)}
|
||
/* The next arrow clears the 360px verdict rail only while the rail sits
|
||
beside the stage. Scoped to the wide layout: stated bare, this rule came
|
||
later in the page than base.html's narrow override and silently won it,
|
||
parking the arrow 360px in from the edge of a phone. */
|
||
.vprev{left:0}.vnext{right:0}
|
||
@media (min-width:901px){.vnext{right:360px}}
|
||
.vcap{margin-top:10px;max-height:30vh;overflow-y:auto;font-size:var(--size-sm);line-height:var(--leading-body);
|
||
color:var(--text-body);white-space:pre-wrap}
|
||
@media print{.vcap{max-height:none;overflow:visible}.vnav{display:none}}
|
||
</style>
|
||
<script>
|
||
(function () {
|
||
var BACK = {{ back_url|tojson }};
|
||
var PREV = {{ (('?f=' ~ prev_url) if prev_url else '')|tojson }};
|
||
var NEXT = {{ (('?f=' ~ next_url) if next_url else '')|tojson }};
|
||
|
||
/* THE STAGE (r2c). The mode is ONE class on <html>, `stage-one` (absent =
|
||
Fit), set by the head script before the stage existed; this binds the
|
||
toggle, places the arrows at the drawn picture, and pans a 1:1 picture. */
|
||
var d = document.documentElement;
|
||
var stage = document.getElementById('vstage');
|
||
var img = document.getElementById('vimg');
|
||
var video = stage.querySelector('video');
|
||
var rbody = stage.parentElement; /* .review-body */
|
||
var prevA = rbody.querySelector('.vnav.vprev'), nextA = rbody.querySelector('.vnav.vnext');
|
||
|
||
/* The DRAWN picture's left and right edges, in viewport px, or null until
|
||
they are known (still loading, or failed): the arrows then keep their CSS
|
||
spot. Fit: the object-fit: contain box. 1:1: what shows in the stage. */
|
||
function drawn() {
|
||
var s = stage.getBoundingClientRect();
|
||
if (img) {
|
||
if (!img.naturalWidth) return null;
|
||
var b = img.getBoundingClientRect();
|
||
if (d.classList.contains('stage-one')) return {l: Math.max(b.left, s.left), r: Math.min(b.right, s.right)};
|
||
var k = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight), w = img.naturalWidth * k;
|
||
return {l: b.left + (b.width - w) / 2, r: b.left + (b.width + w) / 2};
|
||
}
|
||
if (video && video.videoWidth) {
|
||
var v = video.getBoundingClientRect();
|
||
return {l: v.left, r: v.right};
|
||
}
|
||
return null;
|
||
}
|
||
/* Each arrow wholly outside the drawn edge, its near edge 8px away, clamped
|
||
8px inside the stage — so over the picture only when the picture spans
|
||
the stage (operator: "unless the image spans the entire width"). */
|
||
function place() {
|
||
var p = drawn();
|
||
if (!p) return;
|
||
var s = stage.getBoundingClientRect(), o = rbody.getBoundingClientRect();
|
||
[[prevA, -1], [nextA, 1]].forEach(function (pair) {
|
||
var a = pair[0];
|
||
if (!a) return;
|
||
var w = a.offsetWidth, lo = s.left + 8, hi = s.right - 8 - w;
|
||
var x = pair[1] < 0 ? p.l - 8 - w : p.r + 8;
|
||
x = Math.max(lo, Math.min(hi, x));
|
||
a.classList.add('is-placed');
|
||
a.style.left = (x - o.left) + 'px';
|
||
a.style.top = (s.top - o.top + s.height / 2) + 'px';
|
||
});
|
||
}
|
||
function pannable() {
|
||
var can = !!img && d.classList.contains('stage-one') &&
|
||
(stage.scrollWidth > stage.clientWidth || stage.scrollHeight > stage.clientHeight);
|
||
stage.classList.toggle('can-pan', can);
|
||
return can;
|
||
}
|
||
function settle() { place(); pannable(); }
|
||
|
||
if (img) {
|
||
var toggle = document.getElementById('vtoggle');
|
||
var bFit = document.getElementById('btn-fit'), bOne = document.getElementById('btn-one');
|
||
var show = function () {
|
||
var one = d.classList.contains('stage-one');
|
||
bFit.classList.toggle('on', !one);
|
||
bOne.classList.toggle('on', one);
|
||
bFit.setAttribute('aria-pressed', one ? 'false' : 'true');
|
||
bOne.setAttribute('aria-pressed', one ? 'true' : 'false');
|
||
};
|
||
/* The click applies to the page first and is remembered second: storage
|
||
that throws costs the memory, never the click. */
|
||
var setMode = function (one) {
|
||
d.classList.toggle('stage-one', one);
|
||
try {
|
||
if (one) localStorage.setItem('booth.fit', 'one'); else localStorage.removeItem('booth.fit');
|
||
} catch (e) {}
|
||
show();
|
||
settle();
|
||
};
|
||
toggle.hidden = false;
|
||
show();
|
||
bFit.addEventListener('click', function () { setMode(false); });
|
||
bOne.addEventListener('click', function () { setMode(true); });
|
||
img.addEventListener('load', settle);
|
||
if (img.complete) settle();
|
||
|
||
/* DRAG TO PAN (r2c S4), on the stage only: the picture follows the
|
||
pointer, a press that moves under 4px is not a drag, and a press on a
|
||
control inside the stage (its reveal) keeps its click. The picture
|
||
cannot be dragged away. */
|
||
stage.addEventListener('dragstart', function (e) { e.preventDefault(); });
|
||
var drag = null;
|
||
stage.addEventListener('pointerdown', function (e) {
|
||
if (e.button !== 0 || !pannable()) return;
|
||
if (e.target.closest && e.target.closest('button, a, input, textarea, select, summary')) return;
|
||
drag = {x: e.clientX, y: e.clientY, l: stage.scrollLeft, t: stage.scrollTop, on: false, id: e.pointerId};
|
||
});
|
||
stage.addEventListener('pointermove', function (e) {
|
||
if (!drag || e.pointerId !== drag.id) return;
|
||
var dx = e.clientX - drag.x, dy = e.clientY - drag.y;
|
||
if (!drag.on) {
|
||
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
|
||
drag.on = true;
|
||
stage.classList.add('is-grabbing');
|
||
try { stage.setPointerCapture(drag.id); } catch (x) {}
|
||
}
|
||
stage.scrollLeft = drag.l - dx;
|
||
stage.scrollTop = drag.t - dy;
|
||
e.preventDefault();
|
||
});
|
||
var endDrag = function () {
|
||
if (drag && drag.on) stage.classList.remove('is-grabbing');
|
||
drag = null;
|
||
};
|
||
stage.addEventListener('pointerup', endDrag);
|
||
stage.addEventListener('pointercancel', endDrag);
|
||
}
|
||
if (video) video.addEventListener('loadedmetadata', settle);
|
||
if (window.ResizeObserver) new ResizeObserver(settle).observe(stage);
|
||
else window.addEventListener('resize', settle);
|
||
stage.addEventListener('scroll', place);
|
||
|
||
/* Keep the current frame in view on the filmstrip — on load, and after an
|
||
in-place save swaps the strip for a fresh one. Additive: without it the
|
||
strip is still a row of links. */
|
||
function centreFilm() {
|
||
var cur = document.querySelector('.film-f.is-current');
|
||
var film = document.querySelector('.film');
|
||
if (cur && film) film.scrollLeft = cur.offsetLeft - (film.clientWidth - cur.offsetWidth) / 2;
|
||
}
|
||
centreFilm();
|
||
document.addEventListener('booth:swapped', centreFilm);
|
||
|
||
/* Blur reveal on the stage — per-viewer, never persisted. Cosmetic, and
|
||
the button says so. */
|
||
var rv = document.getElementById('vreveal');
|
||
if (rv) rv.addEventListener('click', function () {
|
||
var on = document.getElementById('vstage').classList.toggle('revealed');
|
||
rv.textContent = on ? '🙈 hide' : '👁 reveal — blur is cosmetic';
|
||
});
|
||
|
||
/* EVERY key here, new and old, is ignored while focus is in something
|
||
editable: an arrow key in the note field is a caret move, and F typed
|
||
into a note is a letter, not a flag. */
|
||
function isEditable(el) {
|
||
return !!(el && (el.isContentEditable ||
|
||
/^(input|textarea|select)$/i.test(el.tagName || '')));
|
||
}
|
||
document.addEventListener('keydown', function (e) {
|
||
if (isEditable(e.target)) return;
|
||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||
/* Space moves only when the stage is not a player that wants it. */
|
||
var player = document.getElementById('vmedia');
|
||
if (e.key === 'Escape') window.location.href = BACK;
|
||
else if (e.key === 'ArrowLeft' && PREV) window.location.href = PREV;
|
||
else if (e.key === 'ArrowRight' && NEXT) window.location.href = NEXT;
|
||
/* ...and never from a focused control: Space is how a keyboard presses a
|
||
button or follows a link (r2b, heid bug-hunt — Reveal all and the fog
|
||
control could not be pressed). */
|
||
else if (e.key === ' ' && NEXT && e.target !== player && !(e.target.closest && e.target.closest('button, a, summary'))) { e.preventDefault(); window.location.href = e.shiftKey && PREV ? PREV : NEXT; }
|
||
else if (e.key === 'f' || e.key === 'F') {
|
||
var b = document.getElementById('vflag-btn'); /* re-read: the rail may have been swapped */
|
||
if (b) { e.preventDefault(); b.click(); }
|
||
}
|
||
else if (e.key === 'n' || e.key === 'N') {
|
||
var t = document.getElementById('vnote-text');
|
||
if (t) { e.preventDefault(); t.focus(); }
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|