From a7a6a5368645a2e64e67b0d005c5dea9d5887a94 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 9 May 2026 15:22:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(voyage):=20add=20three=20annotation=20crea?= =?UTF-8?q?tion=20gestures=20+=20form=20modal=20=E2=80=94=20v4.2=20Step=20?= =?UTF-8?q?9=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three creation gestures + shared form modal for the v4.2 annotation playground (per critical decisions #2-#4 + research-06): Gesture 1 — text-anchored adder-popup: - mouseup-debounce 200ms (settles selection) - 300ms grace before hide (Hypothes.is friction-mitigation) - floating .voyage-adder-popup positioned at selection-bound corner - click -> opens form modal with derived heading-path + line + snippet Gesture 2 — paragraph-anchored hover-icon: - 24px pencil SVG injected per

/

  • after each render - opacity 0 default, opacity 1 on hover/focus-visible - click -> opens form modal with no snippet Gesture 3 — page-level note: - .voyage-page-note-btn injected in viewport - click -> opens form modal with target=page Form modal (shared): - role="dialog" aria-modal="true" + aria-labelledby - 400px right-anchored on backdrop (per critical decision #3) - 4 intent buttons (Fiks / Endre / Spørsmål / Block) as aria-pressed group - + + + + +
    @@ -319,6 +509,299 @@ playground first-run shows a complete round-trip-able artifact. mountRender(''); } + // ---- Step 9 — annotation creation gestures + modal ---------------- + // Anchor-ID generation: sequential ANN-NNNN per project+file. + // The "drafts" namespace under localStorage is per-project per-file. + var DRAFTS_KEY_SUFFIX = '.drafts'; + var ADDER_GRACE_MS = 300; // 300ms grace before popup hides on mouse-out + var ADDER_DEBOUNCE_MS = 200; // 200ms after mouseup to settle selection + var INTENT_DEFAULT = 'change'; + + function loadDrafts(key) { + try { + var raw = window.localStorage.getItem(key + DRAFTS_KEY_SUFFIX); + return raw ? JSON.parse(raw) : []; + } catch (e) { + return []; + } + } + function saveDrafts(key, list) { + try { + window.localStorage.setItem(key + DRAFTS_KEY_SUFFIX, JSON.stringify(list)); + } catch (e) { + /* ignore quota errors */ + } + } + function nextAnchorId(drafts) { + var max = 0; + for (var i = 0; i < drafts.length; i++) { + var m = String(drafts[i].id).match(/^ANN-(\d+)$/); + if (m) max = Math.max(max, parseInt(m[1], 10)); + } + var next = max + 1; + return 'ANN-' + String(next).padStart(4, '0'); + } + function deriveHeadingPath(node) { + // Walk previous siblings + ancestors to find the nearest heading. + var n = node; + while (n) { + while (n && !n.previousElementSibling && n.parentElement) n = n.parentElement; + if (!n) break; + n = n.previousElementSibling || (n.parentElement && n.parentElement.previousElementSibling); + if (n && /^H[1-6]$/.test(n.tagName)) { + return slugify(n.textContent || ''); + } + if (!n) break; + } + return 'page'; + } + function slugify(s) { + return String(s) + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .slice(0, 60) || 'page'; + } + function deriveLineNumber(node) { + // Approximate line from the running paragraph index — a real + // line-mapping would require pre-render line annotations. The export + // flow (Step 11) substitutes more accurate line numbers from the + // raw markdown source. + var viewport = $('voyage-viewport'); + if (!viewport) return null; + var ps = viewport.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, pre'); + for (var i = 0; i < ps.length; i++) { + if (ps[i] === node || ps[i].contains(node)) return i + 1; + } + return null; + } + + // ---- Modal control ------------------------------------------------- + var modalState = { + target: 'page', + line: null, + snippet: '', + intent: INTENT_DEFAULT, + currentStorageKey: '', + }; + + function openModal(opts) { + modalState.target = opts.target || 'page'; + modalState.line = opts.line || null; + modalState.snippet = opts.snippet || ''; + modalState.intent = INTENT_DEFAULT; + modalState.currentStorageKey = opts.storageKey || ''; + + var snippetEl = $('voyage-modal-snippet'); + if (snippetEl) { + if (modalState.snippet) { + snippetEl.textContent = modalState.snippet; + snippetEl.hidden = false; + } else { + snippetEl.hidden = true; + snippetEl.textContent = ''; + } + } + var info = $('voyage-modal-anchor-info'); + if (info) { + info.textContent = 'Forankret til: ' + modalState.target + + (modalState.line ? ' (linje ' + modalState.line + ')' : ''); + } + var ta = $('voyage-modal-comment'); + if (ta) ta.value = ''; + // Reset intent buttons + var btns = document.querySelectorAll('.voyage-modal__intent-btn'); + for (var i = 0; i < btns.length; i++) { + btns[i].setAttribute('aria-pressed', btns[i].getAttribute('data-intent') === INTENT_DEFAULT ? 'true' : 'false'); + } + var bd = $('voyage-modal-backdrop'); + if (bd) bd.hidden = false; + if (ta) ta.focus(); + } + + function closeModal() { + var bd = $('voyage-modal-backdrop'); + if (bd) bd.hidden = true; + } + + function saveModalAsAnnotation() { + var ta = $('voyage-modal-comment'); + if (!ta || !ta.value.trim()) return false; + var key = modalState.currentStorageKey; + if (!key) return false; + var drafts = loadDrafts(key); + var annot = { + id: nextAnchorId(drafts), + target_anchor: modalState.target, + line: modalState.line, + intent: modalState.intent, + snippet: modalState.snippet || '', + comment: ta.value.trim(), + created_at: new Date().toISOString(), + exported: false, + }; + drafts.push(annot); + saveDrafts(key, drafts); + announce('Annotation lagret: ' + annot.id); + return annot; + } + + // ---- Gesture 1 — text-anchored adder-popup (mouseup-debounce 200ms; 300ms grace) ---- + var adderPopup; + var adderTimer; + var adderGraceTimer; + + function showAdderPopup(rect, sel) { + if (!adderPopup) adderPopup = $('voyage-adder-popup'); + if (!adderPopup) return; + adderPopup.style.left = (rect.left + rect.width + 12) + 'px'; + adderPopup.style.top = (rect.top + rect.height + 4) + 'px'; + adderPopup._selection = sel; + adderPopup.hidden = false; + } + function hideAdderPopup() { + if (adderPopup) adderPopup.hidden = true; + } + + function onSelectionMaybeChanged() { + clearTimeout(adderTimer); + adderTimer = setTimeout(function () { + var sel = window.getSelection ? window.getSelection() : null; + if (!sel || sel.isCollapsed || !sel.rangeCount) { + // 300ms grace before hiding (per critical decision #2) + clearTimeout(adderGraceTimer); + adderGraceTimer = setTimeout(hideAdderPopup, ADDER_GRACE_MS); + return; + } + var range = sel.getRangeAt(0); + var viewport = $('voyage-viewport'); + if (!viewport || !viewport.contains(range.commonAncestorContainer)) { + hideAdderPopup(); + return; + } + var rect = range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + hideAdderPopup(); + return; + } + showAdderPopup(rect, sel.toString().slice(0, 80)); + }, ADDER_DEBOUNCE_MS); + } + + function onAdderClick() { + if (!adderPopup) return; + var snippet = adderPopup._selection || ''; + var sel = window.getSelection ? window.getSelection() : null; + var node = sel && sel.rangeCount ? sel.getRangeAt(0).startContainer : null; + var target = deriveHeadingPath(node && node.parentElement); + var line = deriveLineNumber(node && node.parentElement); + var ta = $('voyage-paste-input'); + var fm = quickParseFrontmatter(ta ? ta.value : ''); + var key = deriveStorageKey(fm); + hideAdderPopup(); + openModal({ target: target, line: line, snippet: snippet, storageKey: key }); + } + + // ---- Gesture 2 — paragraph-anchored pencil icon (hover-reveal) ----- + function injectPencilIcons() { + var viewport = $('voyage-viewport'); + if (!viewport) return; + var paras = viewport.querySelectorAll('p, li'); + for (var i = 0; i < paras.length; i++) { + var p = paras[i]; + if (p.querySelector('.voyage-pencil-btn')) continue; + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'voyage-pencil-btn'; + btn.setAttribute('aria-label', 'Annotér dette avsnittet'); + btn.innerHTML = + ''; + (function (el) { + btn.addEventListener('click', function () { + var ta = $('voyage-paste-input'); + var fm = quickParseFrontmatter(ta ? ta.value : ''); + var key = deriveStorageKey(fm); + var target = deriveHeadingPath(el); + var line = deriveLineNumber(el); + openModal({ target: target, line: line, snippet: '', storageKey: key }); + }); + })(p); + // Insert at start so it floats in left margin via CSS + p.style.position = 'relative'; + p.insertBefore(btn, p.firstChild); + } + } + + // ---- Gesture 3 — page-level note (button injected near viewport) --- + function ensurePageNoteButton() { + var btn = document.getElementById('voyage-page-note-btn'); + if (btn) return btn; + btn = document.createElement('button'); + btn.id = 'voyage-page-note-btn'; + btn.type = 'button'; + btn.className = 'voyage-page-note-btn'; + btn.textContent = '+ Legg til note (page-level)'; + btn.addEventListener('click', function () { + var ta = $('voyage-paste-input'); + var fm = quickParseFrontmatter(ta ? ta.value : ''); + var key = deriveStorageKey(fm); + openModal({ target: 'page', line: null, snippet: '', storageKey: key }); + }); + var layout = document.querySelector('.voyage-layout'); + if (layout) layout.appendChild(btn); + return btn; + } + + // Re-run gesture-2 + gesture-3 wiring after each render. + var originalMountRender = mountRender; + mountRender = function (text) { + originalMountRender(text); + injectPencilIcons(); + ensurePageNoteButton(); + }; + + // ---- Modal event wiring ------------------------------------------- + function wireModal() { + var bd = $('voyage-modal-backdrop'); + var form = $('voyage-modal'); + if (!bd || !form) return; + var cancelBtn = $('voyage-modal-cancel'); + if (cancelBtn) cancelBtn.addEventListener('click', closeModal); + + // Click on backdrop (outside modal) closes + bd.addEventListener('click', function (e) { + if (e.target === bd) closeModal(); + }); + + // ESC closes + document.addEventListener('keydown', function (e) { + if (!bd.hidden && e.key === 'Escape') { + closeModal(); + } + }); + + // Intent buttons (radiogroup-like via aria-pressed) + var intents = document.querySelectorAll('.voyage-modal__intent-btn'); + for (var i = 0; i < intents.length; i++) { + intents[i].addEventListener('click', function (e) { + for (var j = 0; j < intents.length; j++) { + intents[j].setAttribute('aria-pressed', intents[j] === e.currentTarget ? 'true' : 'false'); + } + modalState.intent = e.currentTarget.getAttribute('data-intent') || INTENT_DEFAULT; + }); + } + + // Save (form submit) + form.addEventListener('submit', function (e) { + e.preventDefault(); + var saved = saveModalAsAnnotation(); + if (saved) closeModal(); + }); + } + // Event wiring on DOMContentLoaded function init() { var renderBtn = $('voyage-render-btn'); @@ -336,6 +819,18 @@ playground first-run shows a complete round-trip-able artifact. if (clearBtn) { clearBtn.addEventListener('click', clearAll); } + + // Step 9 — gesture wiring + document.addEventListener('mouseup', onSelectionMaybeChanged); + document.addEventListener('selectionchange', onSelectionMaybeChanged); + var adder = $('voyage-adder-popup'); + if (adder) { + adder.addEventListener('click', onAdderClick); + adder.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdderClick(); } + }); + } + wireModal(); } if (document.readyState === 'loading') { diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs index 6a60baf..ee88670 100644 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs @@ -95,3 +95,20 @@ test('playground/lib/ contains vendored markdown-it + front-matter + highlight b assert.ok(existsSync(join(PLAYGROUND_LIB, f)), `playground/lib/${f} expected from vendor-playground-libs.mjs`); } }); + +// --- Step 9 — annotation creation gestures + form modal --------------- + +test('voyage-playground.html declares aria-modal="true" (Step 9 form modal A11Y)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /aria-modal="true"/, 'form modal must carry aria-modal="true"'); +}); + +test('voyage-playground.html declares ANN- anchor-ID prefix (Step 9 ID generation)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /ANN-/, 'sequential ANN-NNNN ID generation must appear in playground JS'); +}); + +test('voyage-playground.html declares 300ms grace constant (Step 9 adder-popup grace)', () => { + const text = readFileSync(HTML, 'utf-8'); + assert.match(text, /300\s*ms|GRACE_MS\s*=\s*300|ADDER_GRACE_MS/i, '300ms grace period for adder-popup must be present'); +});