diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index 109b040..8700a84 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -96,6 +96,149 @@ padding-left: var(--space-2, 0.5rem); margin-left: calc(var(--space-2, 0.5rem) * -1); } + + /* Step 9 — annotation creation gestures + form modal */ + + /* Gesture 1 — text-anchored adder-popup (mouseup-debounce 200ms; 300ms grace) */ + .voyage-adder-popup { + position: fixed; + z-index: 1000; + padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); + background: var(--color-bg, #fff); + border: 1px solid var(--color-border, #e3e6eb); + border-radius: var(--radius-md, 6px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + cursor: pointer; + font-size: 0.875rem; + transition: opacity 200ms ease; + } + .voyage-adder-popup[hidden] { display: none; } + + /* Gesture 2 — paragraph-anchored always-visible-icon (hover-reveal) */ + .voyage-viewport p { + position: relative; + } + .voyage-pencil-btn { + position: absolute; + left: -32px; + top: 0; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + opacity: 0; + cursor: pointer; + transition: opacity 150ms ease; + } + .voyage-viewport p:hover .voyage-pencil-btn, + .voyage-pencil-btn:focus-visible { + opacity: 1; + } + .voyage-pencil-btn svg { + width: 16px; + height: 16px; + fill: var(--color-text-muted, #5e6470); + } + + /* Gesture 3 — page-level note button (placeholder; sidebar is Step 10) */ + .voyage-page-note-btn { + padding: var(--space-2, 0.5rem) var(--space-4, 1rem); + border: 1px dashed var(--color-border, #e3e6eb); + background: var(--color-bg, #fff); + border-radius: var(--radius-sm, 4px); + cursor: pointer; + font: inherit; + } + + /* Form modal — role="dialog" aria-modal="true" */ + .voyage-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 1100; + display: flex; + align-items: center; + justify-content: flex-end; + } + .voyage-modal-backdrop[hidden] { display: none; } + .voyage-modal { + width: 400px; + max-width: 90vw; + max-height: 90vh; + margin: var(--space-6, 2rem); + background: var(--color-bg, #fff); + border-radius: var(--radius-md, 6px); + box-shadow: 0 8px 32px rgba(0,0,0,0.2); + display: flex; + flex-direction: column; + } + .voyage-modal__header { + padding: var(--space-4, 1rem); + border-bottom: 1px solid var(--color-border, #e3e6eb); + font-weight: 600; + } + .voyage-modal__body { + padding: var(--space-4, 1rem); + overflow: auto; + flex: 1; + } + .voyage-modal__snippet { + padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); + background: var(--color-bg-soft, #fafbfc); + border-left: 3px solid var(--color-accent, #4a86e8); + font-style: italic; + font-size: 0.875rem; + margin-bottom: var(--space-3, 0.75rem); + } + .voyage-modal__intents { + display: flex; + gap: var(--space-2, 0.5rem); + flex-wrap: wrap; + margin-bottom: var(--space-3, 0.75rem); + } + .voyage-modal__intent-btn { + padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); + border: 1px solid var(--color-border, #e3e6eb); + background: var(--color-bg, #fff); + border-radius: var(--radius-sm, 4px); + cursor: pointer; + font: inherit; + } + .voyage-modal__intent-btn[aria-pressed="true"] { + background: var(--color-accent, #4a86e8); + color: #fff; + border-color: var(--color-accent, #4a86e8); + } + .voyage-modal textarea { + width: 100%; + min-height: 6rem; + padding: var(--space-3, 0.75rem); + font: inherit; + border: 1px solid var(--color-border, #e3e6eb); + border-radius: var(--radius-sm, 4px); + resize: vertical; + } + .voyage-modal__footer { + display: flex; + gap: var(--space-2, 0.5rem); + padding: var(--space-4, 1rem); + border-top: 1px solid var(--color-border, #e3e6eb); + justify-content: flex-end; + } + .voyage-modal__footer button { + padding: var(--space-2, 0.5rem) var(--space-4, 1rem); + border: 1px solid var(--color-border, #e3e6eb); + background: var(--color-bg, #fff); + border-radius: var(--radius-sm, 4px); + cursor: pointer; + font: inherit; + } + .voyage-modal__footer button[type="submit"] { + background: var(--color-accent, #4a86e8); + color: #fff; + border-color: var(--color-accent, #4a86e8); + } @@ -144,6 +287,53 @@ + + + + + +
@@ -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'); +});