annotation system
This commit is contained in:
parent
1210314cc8
commit
b38e9359d5
5
spec.md
5
spec.md
|
|
@ -412,7 +412,7 @@ levineuwirth.org/
|
|||
- [x] Selection popup — `selection-popup.js` / `selection-popup.css`; context-aware toolbar appears 450 ms after text selection; see Implementation Notes
|
||||
- [x] Print stylesheet — `print.css` (media="print"); single-column, light colors, sidenotes as indented blocks, external URLs shown
|
||||
- [x] Current page (`/current.html`) — now-page; added to primary nav
|
||||
- [~] Annotations — `annotations.js` / `annotations.css`; localStorage infrastructure + highlight re-anchoring written; UI (button in selection popup) deferred
|
||||
- [x] Annotations — `annotations.js` / `annotations.css`; localStorage storage, text re-anchoring, highlight marks, tooltip with delete; color-picker UI in selection popup (four swatches + optional note field)
|
||||
|
||||
### Phase 4: Creative Content & Polish
|
||||
- [x] Image handling (lazy load, lightbox, figures, WebP `<picture>` wrapper for local raster images)
|
||||
|
|
@ -432,9 +432,10 @@ levineuwirth.org/
|
|||
- [ ] **Link archiving** — For all external links in `data/bibliography.bib` and in page bodies, check availability and save snapshots (Wayback Machine `save` API or local archivebox instance). Store archive URLs in `data/link-archive.json`; `Filters.Links` injects `data-archive-url` attributes; `popups.js` falls back to the archive if the live URL returns 404.
|
||||
- [ ] **Self-hosted git (Forgejo)** — Run Forgejo on the VPS. Mirror the build repo. Link from the colophon. Not essential; can remain on GitHub indefinitely.
|
||||
- [ ] **Reader mode** — Distraction-free reading overlay: hides nav, TOC, sidenotes; widens the body column to ~70ch; activated via a keyboard shortcut or settings panel toggle. Distinct from focus mode (which affects the nav) — reader mode affects the content layout.
|
||||
- [ ] **HTTP/3 + QUIC** — nginx 1.25+ supports HTTP/3 via `listen 443 quic reuseport` + `http3 on` + `Alt-Svc` header. Requires UDP 443 open in Hetzner's Cloud Firewall. Deferred: latency is currently geographic RTT, not server processing; gains would be modest for a static site from a single DC. CDN alternatives (Bunny.net, multi-region Hetzner with GeoDNS) would address the root cause but raise ethical or operational complexity concerns. Revisit if latency becomes a real user complaint. Server-side improvements (brotli pre-compression, `open_file_cache`) are a lower-cost step first.
|
||||
|
||||
### Phase 6: Deferred Features
|
||||
- [ ] **Annotation UI** — The `annotations.js` / `annotations.css` infrastructure exists (localStorage storage, re-anchoring on load, four highlight colors, hover tooltip). The selection popup "Annotate" button was removed pending a design decision on the color-picker and note-entry UX. Revisit: a popover with four color swatches and an optional text field, triggered from the selection popup.
|
||||
- [x] **Annotation UI** — `annotations.js` / `annotations.css`: localStorage storage, text-stream re-anchoring, four highlight colors (amber/sage/steel/rose), hover tooltip with delete. Selection popup "Annotate" button triggers a color-swatch + optional note picker; Enter or "Highlight" button commits; Escape cancels. Picker positioned above the selection, same inverted style as the tooltip. Settings panel includes a "Clear Annotations" button (with confirmation) that wipes all annotations site-wide via `Annotations.clearAll()`.
|
||||
- [~] **Visualization pipeline** — Implemented as a Pandoc IO filter (`Filters.Viz`), not a per-slug Hakyll rule. See Phase 4 entry and Implementation Notes. Infrastructure complete; production content pending.
|
||||
- [x] **Music catalog page** — `/music/` index listing all compositions grouped by instrumentation category (orchestral → chamber → solo → vocal → choral → electronic → other), with an optional Featured section. Auto-generated from composition frontmatter by `build/Catalog.hs`; renders HTML in Haskell (same pattern as backlinks). Category, year, duration, instrumentation, and ◼/♫ indicators for score/recording availability. `content/music/index.md` provides prose intro + abstract. Template: `templates/music-catalog.html`. CSS: `static/css/catalog.css`. Context: `musicCatalogCtx` (provides `catalog: true` flag, `featured-works`, `has-featured`, `catalog-by-category`).
|
||||
- [x] **Score reader swipe gestures** — `touchstart`/`touchend` listeners on `#score-reader-stage` with passive: true. Threshold: ≥ 50 px horizontal, < 30 px vertical drift. Left swipe → next page; right swipe → previous page.
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ mark.user-annotation:hover { filter: brightness(0.80); }
|
|||
[data-theme="dark"] mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); }
|
||||
[data-theme="dark"] mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); }
|
||||
|
||||
/* System dark mode fallback (for prefers-color-scheme without explicit data-theme) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--amber { background-color: rgba(245, 158, 11, 0.40); }
|
||||
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--sage { background-color: rgba(107, 158, 120, 0.42); }
|
||||
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--steel { background-color: rgba(112, 150, 184, 0.42); }
|
||||
:root:not([data-theme="light"]) mark.user-annotation.user-annotation--rose { background-color: rgba(200, 116, 116, 0.42); }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ANNOTATION TOOLTIP
|
||||
Appears on hover over a mark. Inverted colours like the
|
||||
|
|
@ -95,9 +103,113 @@ mark.user-annotation:hover { filter: brightness(0.80); }
|
|||
|
||||
.ann-tooltip-delete:hover { opacity: 1; }
|
||||
|
||||
/* ============================================================
|
||||
ANNOTATE PICKER
|
||||
Appears above the selection when "Annotate" is clicked.
|
||||
Inverted colors (dark bg, light text) — same as the tooltip.
|
||||
============================================================ */
|
||||
|
||||
.ann-picker {
|
||||
position: absolute;
|
||||
z-index: 760;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.28);
|
||||
padding: 0.55rem 0.6rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
width: 13rem;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.12s ease, visibility 0.12s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ann-picker.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ann-picker-swatches {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ann-picker-swatch {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
transition: transform 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
|
||||
.ann-picker-swatch--amber { background: rgba(245, 158, 11, 0.9); }
|
||||
.ann-picker-swatch--sage { background: rgba(107, 158, 120, 0.9); }
|
||||
.ann-picker-swatch--steel { background: rgba(112, 150, 184, 0.9); }
|
||||
.ann-picker-swatch--rose { background: rgba(200, 116, 116, 0.9); }
|
||||
|
||||
.ann-picker-swatch.is-selected,
|
||||
.ann-picker-swatch:focus-visible {
|
||||
border-color: var(--bg);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.ann-picker-note {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.28rem 0.4rem;
|
||||
margin-bottom: 0.45rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ann-picker-note::placeholder { opacity: 0.5; }
|
||||
|
||||
.ann-picker-note:focus {
|
||||
border-color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.ann-picker-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ann-picker-submit {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.22rem 0.55rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.ann-picker-submit:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
REDUCE MOTION
|
||||
============================================================ */
|
||||
|
||||
[data-reduce-motion] mark.user-annotation { transition: none; }
|
||||
[data-reduce-motion] .ann-tooltip { transition: none; }
|
||||
[data-reduce-motion] .ann-picker { transition: none; }
|
||||
|
|
|
|||
|
|
@ -206,6 +206,13 @@ nav.site-nav {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-btn--danger {
|
||||
color: rgba(180, 80, 80, 0.85);
|
||||
}
|
||||
.settings-btn--danger:hover {
|
||||
color: rgba(180, 80, 80, 1);
|
||||
}
|
||||
|
||||
.settings-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -215,31 +215,42 @@ abbr {
|
|||
sup { font-variant-position: super; line-height: 1; }
|
||||
sub { font-variant-position: sub; line-height: 1; }
|
||||
|
||||
/* Realistic Ink Highlighting */
|
||||
#markdownBody mark {
|
||||
/* Realistic Ink Highlighting — excludes user annotation marks */
|
||||
#markdownBody mark:not(.user-annotation) {
|
||||
background-color: transparent;
|
||||
background-image: linear-gradient(
|
||||
104deg,
|
||||
rgba(250, 235, 120, 0) 0%,
|
||||
rgba(250, 235, 120, 0.8) 2%,
|
||||
rgba(250, 220, 100, 0.9) 98%,
|
||||
104deg,
|
||||
rgba(250, 235, 120, 0) 0%,
|
||||
rgba(250, 235, 120, 0.8) 2%,
|
||||
rgba(250, 220, 100, 0.9) 98%,
|
||||
rgba(250, 220, 100, 0) 100%
|
||||
);
|
||||
background-size: 100% 0.7em;
|
||||
background-size: 100% 0.7em;
|
||||
background-position: 0 88%;
|
||||
background-repeat: no-repeat;
|
||||
padding: 0 0.2em;
|
||||
color: inherit;
|
||||
}
|
||||
[data-theme="dark"] #markdownBody mark {
|
||||
[data-theme="dark"] #markdownBody mark:not(.user-annotation) {
|
||||
background-image: linear-gradient(
|
||||
104deg,
|
||||
rgba(100, 130, 180, 0) 0%,
|
||||
rgba(100, 130, 180, 0.4) 2%,
|
||||
rgba(80, 110, 160, 0.5) 98%,
|
||||
104deg,
|
||||
rgba(100, 130, 180, 0) 0%,
|
||||
rgba(100, 130, 180, 0.4) 2%,
|
||||
rgba(80, 110, 160, 0.5) 98%,
|
||||
rgba(80, 110, 160, 0) 100%
|
||||
);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) #markdownBody mark:not(.user-annotation) {
|
||||
background-image: linear-gradient(
|
||||
104deg,
|
||||
rgba(100, 130, 180, 0) 0%,
|
||||
rgba(100, 130, 180, 0.4) 2%,
|
||||
rgba(80, 110, 160, 0.5) 98%,
|
||||
rgba(80, 110, 160, 0) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
|
|
|
|||
|
|
@ -234,6 +234,17 @@
|
|||
return ann;
|
||||
},
|
||||
remove: removeById,
|
||||
clearAll: function () {
|
||||
saveAll([]);
|
||||
document.querySelectorAll('mark.user-annotation').forEach(function (mark) {
|
||||
var parent = mark.parentNode;
|
||||
if (!parent) return;
|
||||
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
||||
parent.removeChild(mark);
|
||||
parent.normalize();
|
||||
});
|
||||
hideTooltip(true);
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,8 @@
|
|||
code (known lang) → Copy · [MDN / Hoogle / Docs…]
|
||||
code (unknown) → Copy
|
||||
math → Copy · nLab · OEIS · Wolfram
|
||||
prose (multi-word) → Annotate* · BibTeX · Copy · DuckDuckGo · Here · Wikipedia
|
||||
prose (one word) → Annotate* · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia
|
||||
|
||||
(* = placeholder, not yet wired)
|
||||
prose (multi-word) → Annotate · BibTeX · Copy · DuckDuckGo · Here · Wikipedia
|
||||
prose (one word) → Annotate · BibTeX · Copy · Define · DuckDuckGo · Here · Wikipedia
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -17,6 +15,8 @@
|
|||
|
||||
var popup = null;
|
||||
var showTimer = null;
|
||||
var picker = null;
|
||||
var pickerColor = 'amber';
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Documentation providers keyed by Prism language identifier.
|
||||
|
|
@ -58,7 +58,7 @@
|
|||
document.addEventListener('keyup', onKeyUp);
|
||||
document.addEventListener('mousedown', onMouseDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('scroll', hide, { passive: true });
|
||||
window.addEventListener('scroll', function () { hide(); hidePicker(); }, { passive: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
|
@ -80,11 +80,13 @@
|
|||
|
||||
function onMouseDown(e) {
|
||||
if (popup.contains(e.target)) return;
|
||||
if (picker && picker.classList.contains('is-visible') && picker.contains(e.target)) return;
|
||||
hide();
|
||||
hidePicker();
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
if (e.key === 'Escape') hide();
|
||||
if (e.key === 'Escape') { hide(); hidePicker(); }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
|
@ -149,7 +151,7 @@
|
|||
|
||||
position(rect);
|
||||
popup.style.visibility = '';
|
||||
bindActions(text);
|
||||
bindActions(text, rect);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
|
|
@ -211,8 +213,9 @@
|
|||
+ btn('wolfram', 'Wolfram');
|
||||
}
|
||||
|
||||
/* Prose — alphabetical: BibTeX · Copy · [Define] · DuckDuckGo · Here · Wikipedia */
|
||||
return btn('cite', 'BibTeX')
|
||||
/* Prose: Annotate · BibTeX · Copy · [Define] · DuckDuckGo · Here · Wikipedia */
|
||||
return btn('annotate', 'Annotate')
|
||||
+ btn('cite', 'BibTeX')
|
||||
+ btn('copy', 'Copy')
|
||||
+ (oneWord ? btn('define', 'Define') : '')
|
||||
+ btn('search', 'DuckDuckGo')
|
||||
|
|
@ -240,11 +243,11 @@
|
|||
Action bindings
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
function bindActions(text) {
|
||||
function bindActions(text, rect) {
|
||||
popup.querySelectorAll('[data-action]').forEach(function (el) {
|
||||
if (el.getAttribute('aria-disabled') === 'true') return;
|
||||
el.addEventListener('click', function () {
|
||||
dispatch(el.getAttribute('data-action'), text, el);
|
||||
dispatch(el.getAttribute('data-action'), text, el, rect);
|
||||
hide();
|
||||
});
|
||||
});
|
||||
|
|
@ -325,7 +328,7 @@
|
|||
Action dispatch
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
function dispatch(action, text, el) {
|
||||
function dispatch(action, text, el, rect) {
|
||||
var q = encodeURIComponent(text);
|
||||
if (action === 'search') {
|
||||
window.open('https://duckduckgo.com/?q=' + q, '_blank', 'noopener,noreferrer');
|
||||
|
|
@ -362,8 +365,97 @@
|
|||
} else if (action === 'here') {
|
||||
/* Site search via Pagefind — opens search page with query pre-filled. */
|
||||
window.open('/search.html?q=' + q, '_blank', 'noopener,noreferrer');
|
||||
|
||||
} else if (action === 'annotate') {
|
||||
showAnnotatePicker(text, rect);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Annotate picker — color swatches + optional note input
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
function showAnnotatePicker(text, selRect) {
|
||||
if (!picker) {
|
||||
picker = document.createElement('div');
|
||||
picker.className = 'ann-picker';
|
||||
picker.setAttribute('role', 'dialog');
|
||||
picker.setAttribute('aria-label', 'Annotate selection');
|
||||
document.body.appendChild(picker);
|
||||
}
|
||||
|
||||
pickerColor = 'amber';
|
||||
picker.innerHTML =
|
||||
'<div class="ann-picker-swatches">'
|
||||
+ swatchBtn('amber') + swatchBtn('sage') + swatchBtn('steel') + swatchBtn('rose')
|
||||
+ '</div>'
|
||||
+ '<input class="ann-picker-note" type="text" placeholder="Note (optional)" maxlength="200">'
|
||||
+ '<div class="ann-picker-actions">'
|
||||
+ '<button class="ann-picker-submit">Highlight</button>'
|
||||
+ '</div>';
|
||||
|
||||
picker.style.visibility = 'hidden';
|
||||
picker.classList.add('is-visible');
|
||||
positionPicker(selRect);
|
||||
picker.style.visibility = '';
|
||||
|
||||
var note = picker.querySelector('.ann-picker-note');
|
||||
var submitBtn = picker.querySelector('.ann-picker-submit');
|
||||
|
||||
picker.querySelector('.ann-picker-swatch[data-color="amber"]').classList.add('is-selected');
|
||||
|
||||
picker.querySelectorAll('.ann-picker-swatch').forEach(function (sw) {
|
||||
sw.addEventListener('click', function () {
|
||||
picker.querySelectorAll('.ann-picker-swatch').forEach(function (s) {
|
||||
s.classList.remove('is-selected');
|
||||
});
|
||||
sw.classList.add('is-selected');
|
||||
pickerColor = sw.getAttribute('data-color');
|
||||
});
|
||||
});
|
||||
|
||||
function commit() {
|
||||
if (window.Annotations) {
|
||||
window.Annotations.add(text, pickerColor, note.value.trim());
|
||||
}
|
||||
hidePicker();
|
||||
}
|
||||
|
||||
submitBtn.addEventListener('click', commit);
|
||||
note.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
||||
if (e.key === 'Escape') { hidePicker(); }
|
||||
});
|
||||
|
||||
setTimeout(function () { note.focus(); }, 0);
|
||||
}
|
||||
|
||||
function hidePicker() {
|
||||
if (picker) picker.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
function positionPicker(rect) {
|
||||
var pw = picker.offsetWidth;
|
||||
var ph = picker.offsetHeight;
|
||||
var GAP = 8;
|
||||
var sy = window.scrollY;
|
||||
var sx = window.scrollX;
|
||||
var vw = window.innerWidth;
|
||||
|
||||
var left = rect.left + sx + rect.width / 2 - pw / 2;
|
||||
left = Math.max(sx + GAP, Math.min(left, sx + vw - pw - GAP));
|
||||
|
||||
var top = rect.top + sy - ph - GAP;
|
||||
if (top < sy + GAP) top = rect.bottom + sy + GAP;
|
||||
|
||||
picker.style.left = left + 'px';
|
||||
picker.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function swatchBtn(color) {
|
||||
return '<button class="ann-picker-swatch ann-picker-swatch--' + color
|
||||
+ '" data-color="' + color + '" aria-label="' + color + '"></button>';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
}());
|
||||
|
|
|
|||
|
|
@ -88,7 +88,14 @@
|
|||
else if (action === 'text-larger') shiftSize(+1);
|
||||
else if (action === 'focus-mode') toggleDataAttr('focus-mode', 'data-focus-mode');
|
||||
else if (action === 'reduce-motion') toggleDataAttr('reduce-motion', 'data-reduce-motion');
|
||||
else if (action === 'print') { setOpen(false); window.print(); }
|
||||
else if (action === 'print') { setOpen(false); window.print(); }
|
||||
else if (action === 'clear-annotations') { clearAnnotations(); }
|
||||
}
|
||||
|
||||
function clearAnnotations() {
|
||||
if (!confirm('Remove all highlights and annotations across every page?')) return;
|
||||
if (window.Annotations) window.Annotations.clearAll();
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
/* Theme ----------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
</div>
|
||||
<div class="settings-section">
|
||||
<button class="settings-btn settings-btn--full" data-action="print">Print</button>
|
||||
<button class="settings-btn settings-btn--full settings-btn--danger" data-action="clear-annotations">Clear Annotations</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue