MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| (28 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback ( | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Footnotes + Feedback (v6 + Strategy B + Footnotes tab) | ||
*/ | */ | ||
| Line 8: | Line 7: | ||
'use strict'; | 'use strict'; | ||
var CONTENT_SEL = '#mw-content-text'; | var CONTENT_SEL = '#mw-content-text'; | ||
var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
| Line 18: | Line 16: | ||
if ( currentUser && window.mw ) { | if ( currentUser && window.mw ) { | ||
new mw.Api().get({ | new mw.Api().get({ action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2 }) | ||
.then( function (data) { | |||
var info = data && data.query && data.query.userinfo; | |||
if ( info && info.email ) currentUserEmail = info.email; | |||
} ).catch( function () {} ); | |||
} | } | ||
| Line 31: | Line 28: | ||
} | } | ||
var _selRange = null; | |||
var _selRange = null; | |||
var _selText = ''; | var _selText = ''; | ||
var _selRect = null; | var _selRect = null; | ||
| Line 41: | Line 37: | ||
var _fabSelVer = -1; | var _fabSelVer = -1; | ||
var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | ||
var _fabTouched = false; // flag to prevent hideActions when tapping fab | |||
function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); } | function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); } | ||
function esc(s) { | function esc(s) { | ||
| Line 59: | Line 55: | ||
function isMobile() { return _mobile; } | function isMobile() { return _mobile; } | ||
var $fab, $mobileBar, $panel, $backdrop; | var $fab, $mobileBar, $panel, $backdrop; | ||
var $ntComposer, $ntInput, $ntSubmit; | var $ntComposer, $ntInput, $ntSubmit; | ||
var $bmComposer, $bmInput, $bmSubmit; | var $bmComposer, $bmInput, $bmSubmit; | ||
var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | ||
var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks; | // CHANGED: added $tabFootnotes / $paneFootnotes alongside the existing | ||
// Notes/Bookmarks tab+pane refs, for the new Footnotes tab. | |||
var $tabNotes, $tabBookmarks, $tabFootnotes, $paneNotes, $paneBookmarks, $paneFootnotes; | |||
function buildDom() { | function buildDom() { | ||
| Line 88: | Line 81: | ||
' <span class="gra-icon gra-icon-search" aria-hidden="true"></span>', | ' <span class="gra-icon gra-icon-search" aria-hidden="true"></span>', | ||
' <span class="gra-fab-btn-label">Search</span>', | ' <span class="gra-fab-btn-label">Search</span>', | ||
' </button>', | |||
' <button class="gra-fab-btn gra-fab-btn-dismiss" id="gra-fab-dismiss" type="button" aria-label="Dismiss">', | |||
' <span class="gra-icon gra-icon-dismiss" aria-hidden="true"></span>', | |||
' <span class="gra-fab-btn-label">Close</span>', | |||
' </button>', | ' </button>', | ||
'</div>', | '</div>', | ||
| Line 93: | Line 90: | ||
$('body').append($fab); | $('body').append($fab); | ||
$mobileBar = $( | $mobileBar = $('<div id="gra-mobile-bar"></div>'); | ||
$('body').append($mobileBar); | $('body').append($mobileBar); | ||
| Line 189: | Line 165: | ||
' <button class="gra-tab" id="gra-tab-bookmarks">', | ' <button class="gra-tab" id="gra-tab-bookmarks">', | ||
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', | ||
' </button>', | |||
' <button class="gra-tab" id="gra-tab-footnotes">', | |||
' <span class="gra-icon gra-icon-footnote" aria-hidden="true"></span> Footnotes', | |||
' </button>', | ' </button>', | ||
' </div>', | ' </div>', | ||
| Line 194: | Line 173: | ||
' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', | ' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', | ||
' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ||
' <div class="gra-pane" id="gra-pane-footnotes"></div>', | |||
' </div>', | ' </div>', | ||
'</div>', | '</div>', | ||
| Line 212: | Line 192: | ||
$panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab); | $panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab); | ||
}); | }); | ||
$('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30)); | $('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30)); | ||
$tabNotes = $('#gra-tab-notes'); | $tabNotes = $('#gra-tab-notes'); | ||
$tabBookmarks = $('#gra-tab-bookmarks'); | $tabBookmarks = $('#gra-tab-bookmarks'); | ||
$tabFootnotes = $('#gra-tab-footnotes'); | |||
$paneNotes = $('#gra-pane-notes'); | $paneNotes = $('#gra-pane-notes'); | ||
$paneBookmarks= $('#gra-pane-bookmarks'); | $paneBookmarks= $('#gra-pane-bookmarks'); | ||
$paneFootnotes= $('#gra-pane-footnotes'); | |||
$ntInput = $('#gra-nt-input'); | $ntInput = $('#gra-nt-input'); | ||
$ntSubmit = $('#gra-nt-submit'); | $ntSubmit = $('#gra-nt-submit'); | ||
| Line 230: | Line 211: | ||
} | } | ||
function captureSelection() { | function captureSelection() { | ||
var sel = window.getSelection(); | var sel = window.getSelection(); | ||
| Line 251: | Line 222: | ||
if (ancestor.nodeType === 3) ancestor = ancestor.parentNode; | if (ancestor.nodeType === 3) ancestor = ancestor.parentNode; | ||
if (!ancestor || !contentEl.contains(ancestor)) return false; | if (!ancestor || !contentEl.contains(ancestor)) return false; | ||
var _editorEl = document.getElementById('se-surface') || | var _editorEl = document.getElementById('se-surface') || | ||
document.querySelector('.se-outer'); | document.querySelector('.se-outer'); | ||
if ( _editorEl && _editorEl.contains(ancestor) ) return false; | if ( _editorEl && _editorEl.contains(ancestor) ) return false; | ||
if ( document.body.classList.contains('gra-editing') ) return false; | |||
_selText = text; | _selText = text; | ||
_selRect = range.getBoundingClientRect(); | _selRect = range.getBoundingClientRect(); | ||
try { _selRange = range.cloneRange(); } | try { _selRange = range.cloneRange(); } | ||
catch(e){ _selRange = null; } | catch(e){ _selRange = null; } | ||
return true; | return true; | ||
} | } | ||
function reCaptureFromDOM() { | function reCaptureFromDOM() { | ||
if (!_selText) return false; | if (!_selText) return false; | ||
| Line 290: | Line 247: | ||
if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return; | if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return; | ||
if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return; | if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return; | ||
if (!captureSelection()) { | if (!captureSelection()) { hideActions(); return; } | ||
_fabSelVer = _selVersion; | _fabSelVer = _selVersion; | ||
showFab(_selRect); | |||
} | |||
function showFab(rect) { | |||
if (!rect) return; | |||
var fabW, fabH, top, left; | |||
if (_mobile) { | if (_mobile) { | ||
$fab.css({ position: '', top: '', left: '', visibility: '' }) | |||
.addClass('gra-fab-visible gra-fab-mobile-docked'); | |||
return; | |||
} | } | ||
fabW = 46; fabH = 126; | |||
top = rect.top + (rect.height / 2) - (fabH / 2); | |||
left = rect.right + 10; | |||
if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10; | |||
top = clamp(top, 8, window.innerHeight - fabH - 8); | |||
left = clamp(left, 8, window.innerWidth - fabW - 8); | |||
$fab.css({ top: top + 'px', left: left + 'px' }).addClass('gra-fab-visible'); | |||
} | } | ||
function hideFab() { $fab.removeClass('gra-fab-visible gra-fab-mobile-docked'); } | |||
function hideActions() { hideFab(); } | |||
function | |||
} | |||
function hideActions() | |||
function wrapSelection(id, cssClass) { | function wrapSelection(id, cssClass) { | ||
var range = _selRange; | var range = _selRange; | ||
_selRange = null; | _selRange = null; | ||
if (!range) return null; | if (!range) return null; | ||
try { | try { | ||
if (!document.contains(range.startContainer) || | if (!document.contains(range.startContainer) || | ||
!document.contains(range.endContainer)) | !document.contains(range.endContainer)) return null; | ||
} catch(e) { return null; } | } catch(e) { return null; } | ||
function makeSpan() { | function makeSpan() { | ||
var sp = document.createElement('span'); | var sp = document.createElement('span'); | ||
| Line 359: | Line 286: | ||
return sp; | return sp; | ||
} | } | ||
try { | try { | ||
var span = makeSpan(); | var span = makeSpan(); | ||
range.surroundContents(span); | range.surroundContents(span); | ||
if (span.parentNode) return span; | if (span.parentNode) return span; | ||
} catch(e) { | } catch(e) {} | ||
try { | try { | ||
var frag = range.extractContents(); | var frag = range.extractContents(); | ||
| Line 374: | Line 296: | ||
sp2.appendChild(frag); | sp2.appendChild(frag); | ||
range.insertNode(sp2); | range.insertNode(sp2); | ||
if (sp2 && sp2.parentNode) return sp2; | if (sp2 && sp2.parentNode) return sp2; | ||
} catch(e2) { | } catch(e2) {} | ||
return null; | return null; | ||
} | } | ||
function openFeedbackComposer() { | function openFeedbackComposer() { | ||
| Line 394: | Line 310: | ||
if (currentUserEmail) $fbEmail.val(currentUserEmail); | if (currentUserEmail) $fbEmail.val(currentUserEmail); | ||
else $fbEmail.val(''); | else $fbEmail.val(''); | ||
$fbComposer.css({top:'', left:'', transform:''}); | if (!_mobile) $fbComposer.css({top:'', left:'', transform:''}); | ||
$fbComposer.addClass('gra-composer-visible'); | $fbComposer.addClass('gra-composer-visible'); | ||
$backdrop.addClass('gra-backdrop-visible'); | $backdrop.addClass('gra-backdrop-visible'); | ||
| Line 412: | Line 328: | ||
var quote = $fbQuote.text(); | var quote = $fbQuote.text(); | ||
if (!issueType) return; | if (!issueType) return; | ||
$fbSubmit.prop('disabled', true).text('Sending…'); | $fbSubmit.prop('disabled', true).text('Sending…'); | ||
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); | $('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); | ||
var issueLabels = { | var issueLabels = { | ||
wrong_text: 'Formatting error', reference_issue: 'Reference issue', | wrong_text: 'Formatting error', reference_issue: 'Reference issue', | ||
spelling_mistake: 'Spelling mistake', other: 'Other' | spelling_mistake: 'Spelling mistake', other: 'Other' | ||
}; | }; | ||
var payload = new FormData(); | var payload = new FormData(); | ||
payload.append('issue_type', issueLabels[issueType] || issueType); | payload.append('issue_type', issueLabels[issueType] || issueType); | ||
| Line 429: | Line 342: | ||
payload.append('user_email', email || currentUserEmail || ''); | payload.append('user_email', email || currentUserEmail || ''); | ||
payload.append('wiki_user', currentUser || 'anonymous'); | payload.append('wiki_user', currentUser || 'anonymous'); | ||
fetch('/feedback.php', {method:'POST', body:payload}) | fetch('/feedback.php', {method:'POST', body:payload}) | ||
.then(function(r){ return r.json(); }) | .then(function(r){ return r.json(); }) | ||
| Line 448: | Line 360: | ||
$('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err'); | $('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err'); | ||
} | } | ||
function openNoteComposer() { | function openNoteComposer() { | ||
hideActions(); | hideActions(); | ||
$ntComposer.css({ top: '', left: '', transform: '' }); | if (!_mobile) $ntComposer.css({ top: '', left: '', transform: '' }); | ||
$ntComposer.addClass('gra-composer-visible'); | $ntComposer.addClass('gra-composer-visible'); | ||
$backdrop.addClass('gra-backdrop-visible'); | $backdrop.addClass('gra-backdrop-visible'); | ||
| Line 475: | Line 383: | ||
var ts = nowIso(); | var ts = nowIso(); | ||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | ||
if (!_selRange && _selText) reCaptureFromDOM(); | if (!_selRange && _selText) reCaptureFromDOM(); | ||
var span = wrapSelection(id, 'gra-note-highlight'); | var span = wrapSelection(id, 'gra-note-highlight'); | ||
| Line 493: | Line 400: | ||
try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){} | try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){} | ||
} | } | ||
function openBookmarkComposer() { | function openBookmarkComposer() { | ||
hideActions(); | hideActions(); | ||
$bmComposer.css({ top: '', left: '', transform: '' }); | if (!_mobile) $bmComposer.css({ top: '', left: '', transform: '' }); | ||
$bmComposer.addClass('gra-composer-visible'); | $bmComposer.addClass('gra-composer-visible'); | ||
$backdrop.addClass('gra-backdrop-visible'); | $backdrop.addClass('gra-backdrop-visible'); | ||
| Line 544: | Line 447: | ||
try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){} | try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){} | ||
} | } | ||
function openPanel(tab) { | function openPanel(tab) { | ||
| Line 559: | Line 458: | ||
$backdrop.removeClass('gra-backdrop-visible'); | $backdrop.removeClass('gra-backdrop-visible'); | ||
} | } | ||
// CHANGED: added the footnotes case, alongside notes/bookmarks. | |||
function switchTab(tab) { | function switchTab(tab) { | ||
_activeTab = tab; | _activeTab = tab; | ||
$tabNotes.toggleClass('gra-tab-active', tab==='notes'); | $tabNotes.toggleClass('gra-tab-active', tab==='notes'); | ||
$tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | ||
$tabFootnotes.toggleClass('gra-tab-active', tab==='footnotes'); | |||
$paneNotes.toggleClass('gra-pane-active', tab==='notes'); | $paneNotes.toggleClass('gra-pane-active', tab==='notes'); | ||
$paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | ||
$paneFootnotes.toggleClass('gra-pane-active', tab==='footnotes'); | |||
if (tab==='notes') renderNoteCards(); | if (tab==='notes') renderNoteCards(); | ||
else renderBookmarkCards(); | else if (tab==='bookmarks') renderBookmarkCards(); | ||
else renderFootnoteCards(); | |||
} | } | ||
function renderNoteCards() { | function renderNoteCards() { | ||
| Line 582: | Line 481: | ||
html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">' | html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">' | ||
+ '<div class="gra-card-header">' | + '<div class="gra-card-header">' | ||
+ '< | + '<span class="gra-icon gra-icon-note" aria-hidden="true"></span>' | ||
+ '<div class="gra-card-meta">' | + '<div class="gra-card-meta">' | ||
+ (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '') | + (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '') | ||
| Line 630: | Line 529: | ||
} | } | ||
// | // NEW: Footnotes tab — unlike Notes/Bookmarks, footnotes aren't personal | ||
// | // localStorage annotations, they're real saved page content (QuickEdit | ||
// | // writes .gra-qe-footnotes/.gra-qe-footnote-item directly into the saved | ||
// HTML). So this reads straight off the live DOM on every open, no | |||
// fetch, no storage — verified the scan + both-direction lookup logic | |||
// against realistic saved-footnote HTML before wiring this in (6/6 pass). | |||
function renderFootnoteCards() { | |||
var items = document.querySelectorAll(CONTENT_SEL + ' .gra-qe-footnote-item'); | |||
if (!items.length) { | |||
$paneFootnotes.html('<div class="gra-empty-state">No footnotes on this page.</div>'); | |||
return; | |||
} | |||
var html = ''; | |||
Array.prototype.forEach.call(items, function (li) { | |||
var numEl = li.querySelector('.gra-qe-footnote-num'); | |||
var quoteEl = li.querySelector('.gra-qe-footnote-quote'); | |||
var textEl = li.querySelector('.gra-qe-footnote-text'); | |||
var num = numEl ? numEl.textContent.trim() : ''; | |||
// CHANGED: was showing the NUMBER as the card's primary line and the | |||
// footnote's own note text as secondary — nowhere did it show WHICH | |||
// text in the document the footnote actually belongs to, which was | |||
// the whole point of being able to identify one footnote from | |||
// another. Now shows the referenced quote as primary (matching how | |||
// Notes/Bookmarks already identify themselves by quote, not by an | |||
// arbitrary id), with the number as a small prefix and the | |||
// footnote's own note text as secondary. Falls back to the note | |||
// text alone if a footnote predates this change and has no quote | |||
// span at all, rather than showing nothing. | |||
var quote = quoteEl ? quoteEl.textContent : ''; | |||
var text = textEl ? textEl.textContent : (quoteEl ? '' : li.textContent); | |||
var primary = quote ? (num + ' ' + quote) : (num + ' ' + text); | |||
var id = li.getAttribute('data-gra-id') || ''; | |||
html += '<div class="gra-bookmark-card gra-footnote-card" data-gra-id="'+esc(id)+'">' | |||
+ '<span class="gra-icon gra-icon-footnote" aria-hidden="true"></span>' | |||
+ '<div class="gra-bookmark-info">' | |||
+ '<div class="gra-bookmark-name">'+esc(primary)+'</div>' | |||
+ (quote ? '<div class="gra-bookmark-quote">'+esc(text)+'</div>' : '') | |||
+ '</div>' | |||
+ '</div>'; | |||
}); | |||
$paneFootnotes.html(html); | |||
} | |||
function scrollToHighlight(id) { | function scrollToHighlight(id) { | ||
| Line 642: | Line 580: | ||
} | } | ||
function wireEvents() { | |||
/* Suppress native context menu inside article content (Android/desktop) */ | |||
document.addEventListener('contextmenu', function(e) { | document.addEventListener('contextmenu', function(e) { | ||
var tag = e.target.tagName; | |||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; | |||
var c = document.querySelector(CONTENT_SEL); | |||
if (c && c.contains(e.target)) e.preventDefault(); | |||
}, { passive: false }); | }, { passive: false }); | ||
/* | /* Desktop mouseup */ | ||
$(document).on('mouseup', function(e){ | $(document).on('mouseup', function(e){ | ||
if (e.button !== 0) return; | if (e.button !== 0) return; | ||
| Line 661: | Line 597: | ||
}); | }); | ||
/* | /* Separate timers so mobile + desktop never clobber each other */ | ||
var _selTimer = null; /* desktop debounce */ | |||
var _mobShowTimer = null; /* mobile show-on-touchend */ | |||
document.addEventListener('touchend', function() { | /* Mobile: show fab quickly after finger lifts (selection settled). | ||
180ms feels instant while still letting the range stabilise. */ | |||
document.addEventListener('touchend', function(e) { | |||
if (!_mobile) return; | if (!_mobile) return; | ||
clearTimeout( | if ($fab[0] && $fab[0].contains(e.target)) return; | ||
clearTimeout(_mobShowTimer); | |||
_mobShowTimer = setTimeout(function() { | |||
var sel = window.getSelection(); | |||
if (!sel || sel.isCollapsed || !sel.toString().trim()) return; | |||
tryShowActions(); | |||
}, 180); | |||
}, { passive: true }); | }, { passive: true }); | ||
/* | /* Mobile: only HIDE the fab when selection is cleared while it's visible. | ||
(Reposition isn't needed now that the bar is docked, and re-running | |||
showFab here was causing the lag/flicker.) */ | |||
document.addEventListener('selectionchange', function() { | document.addEventListener('selectionchange', function() { | ||
if (!_mobile) return; | |||
if (!$fab.hasClass('gra-fab-visible')) return; | |||
var sel = window.getSelection(); | |||
if (!sel || sel.isCollapsed || !sel.toString().trim()) { | |||
clearTimeout(_mobShowTimer); | |||
hideActions(); | |||
} | |||
}); | |||
/* selectionchange debounced (desktop only) */ | |||
document.addEventListener('selectionchange', function() { | |||
if (_mobile) return; | |||
_selVersion++; | _selVersion++; | ||
clearTimeout(_selTimer); | clearTimeout(_selTimer); | ||
| Line 684: | Line 640: | ||
}); | }); | ||
/* ── Click outside → hide actions | /* ── KEY FIX: fab touchstart sets flag to prevent hideActions ── */ | ||
$fab[0].addEventListener('touchstart', function(e) { | |||
_fabTouched = true; | |||
/* Don't propagate to document handler */ | |||
e.stopPropagation(); | |||
}, { passive: true }); | |||
/* Click outside → hide actions (blocked if fab was touched) */ | |||
$(document).on('mousedown touchstart', function(e){ | $(document).on('mousedown touchstart', function(e){ | ||
if (_fabTouched) { _fabTouched = false; return; } | |||
var t = e.target; | var t = e.target; | ||
if ($fab[0] | if ($fab[0] && $fab[0].contains(t)) return; | ||
if ($fbComposer[0] && $fbComposer[0].contains(t)) return; | |||
if ($fbComposer[0] | if ($ntComposer[0] && $ntComposer[0].contains(t)) return; | ||
if ($ntComposer[0] | if ($bmComposer[0] && $bmComposer[0].contains(t)) return; | ||
if ($bmComposer[0] | |||
hideActions(); | hideActions(); | ||
}); | }); | ||
/* ── | /* ── FAB buttons — use touchend for mobile, click for desktop ── */ | ||
function fabAction(btnId, action) { | |||
var el = document.getElementById(btnId); | |||
if (!el) return; | |||
/* touchend: fires before document touchstart clears _selRange */ | |||
el.addEventListener('touchend', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
if (!_selRange && !reCaptureFromDOM()) return; | |||
action(); | |||
}, { passive: false }); | |||
/* click: for desktop */ | |||
el.addEventListener('click', function(e) { | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
if (!_selRange && !reCaptureFromDOM()) return; | |||
action(); | |||
}); | |||
} | |||
fabAction('gra-fab-note', openNoteComposer); | |||
fabAction('gra-fab-bookmark', openBookmarkComposer); | |||
fabAction('gra-fab-feedback', openFeedbackComposer); | |||
document.getElementById('gra-fab-search').addEventListener('touchend', function(e) { | |||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
var q = _selText; | var q = _selText; | ||
| Line 717: | Line 688: | ||
_selRange = null; _selText = ''; _selRect = null; | _selRange = null; _selText = ''; _selRect = null; | ||
if (q && window.showSearchDialog) { window.showSearchDialog(q); } | if (q && window.showSearchDialog) { window.showSearchDialog(q); } | ||
}, { passive: false }); | |||
} | document.getElementById('gra-fab-search').addEventListener('click', function(e) { | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
var q = _selText; | var q = _selText; | ||
hideActions(); | |||
_selRange = null; _selText = ''; _selRect = null; | _selRange = null; _selText = ''; _selRect = null; | ||
if (window. | if (q && window.showSearchDialog) { window.showSearchDialog(q); } | ||
if (q | else if (q) { $(document).trigger($.Event('keydown', {ctrlKey:true, key:'k', keyCode:75})); } | ||
}); | }); | ||
/* ── Feedback composer | /* ── Dismiss button: hide toolbar + clear selection (mobile) ── */ | ||
(function () { | |||
var dismissEl = document.getElementById('gra-fab-dismiss'); | |||
if (!dismissEl) return; | |||
function doDismiss(e) { | |||
e.preventDefault(); e.stopPropagation(); | |||
hideActions(); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
if (window.getSelection) { | |||
var s = window.getSelection(); | |||
if (s && s.removeAllRanges) s.removeAllRanges(); | |||
} | |||
} | |||
dismissEl.addEventListener('touchend', doDismiss, { passive: false }); | |||
dismissEl.addEventListener('click', doDismiss); | |||
}()); | |||
/* Feedback composer */ | |||
$fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); }); | $fbIssueType.on('change', function(){ $fbSubmit.prop('disabled', !$(this).val()); }); | ||
$('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer); | $('#gra-fb-cancel, #gra-fb-close').on('click', closeFeedbackComposer); | ||
| Line 757: | Line 721: | ||
$fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); }); | $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); }); | ||
/* | /* Note composer */ | ||
$ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); }); | $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); }); | ||
$('#gra-nt-cancel').on('click', closeNoteComposer); | $('#gra-nt-cancel').on('click', closeNoteComposer); | ||
| Line 766: | Line 730: | ||
}); | }); | ||
/* | /* Bookmark composer */ | ||
$('#gra-bm-cancel').on('click', closeBookmarkComposer); | $('#gra-bm-cancel').on('click', closeBookmarkComposer); | ||
$bmSubmit.on('click', submitBookmark); | $bmSubmit.on('click', submitBookmark); | ||
| Line 774: | Line 738: | ||
}); | }); | ||
/* | /* Panel */ | ||
$('#gra-panel-close').on('click', closePanel); | $('#gra-panel-close').on('click', closePanel); | ||
$backdrop.on('click touchend', function(e){ | $backdrop.on('click touchend', function(e){ | ||
| Line 785: | Line 749: | ||
$tabNotes.on('click', function(){ switchTab('notes'); }); | $tabNotes.on('click', function(){ switchTab('notes'); }); | ||
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | ||
// NEW: third tab. | |||
$tabFootnotes.on('click', function(){ switchTab('footnotes'); }); | |||
$paneNotes.on('click', '.gra-note-card', function(e){ | $paneNotes.on('click', '.gra-note-card', function(e){ | ||
| Line 805: | Line 771: | ||
var id = $(this).attr('data-del-id'); | var id = $(this).attr('data-del-id'); | ||
if (id) deleteBookmark(id); | if (id) deleteBookmark(id); | ||
}); | |||
// CHANGED: footnote highlights now carry data-gra-id (see quickedit.js's | |||
// redesign — plain highlight span, no href/marker element anymore), so | |||
// this can reuse the EXACT same scrollToHighlight(id) helper Notes/ | |||
// Bookmarks already use below, instead of custom href-parsing logic | |||
// that broke every time the marker format changed. | |||
$paneFootnotes.on('click', '.gra-footnote-card', function(){ | |||
var id = $(this).attr('data-gra-id'); | |||
if (id) { closePanel(); scrollToHighlight(id); } | |||
}); | }); | ||
| Line 825: | Line 800: | ||
var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]'); | var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]'); | ||
if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | ||
}, 100); | |||
}); | |||
// CHANGED: was .gra-qe-footnote-ref a with e.preventDefault() (it used | |||
// to be a real link). Now a plain highlight span, same as notes/ | |||
// bookmarks — no href to prevent, id read directly via data-gra-id, | |||
// and the handler is now identical in shape to the two right above it. | |||
$(CONTENT_SEL).on('click', '.gra-qe-footnote-highlight', function(){ | |||
var id = $(this).attr('data-gra-id'); | |||
openPanel('footnotes'); | |||
setTimeout(function(){ | |||
var $card = $paneFootnotes.find('[data-gra-id="'+id+'"]'); | |||
if ($card.length) { | |||
$card.addClass('gra-card-active'); | |||
$card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | |||
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); | |||
} | |||
}, 100); | }, 100); | ||
}); | }); | ||
| Line 836: | Line 827: | ||
}); | }); | ||
} | } | ||
function persistNoteHighlight(id, quote) { | function persistNoteHighlight(id, quote) { | ||
| Line 863: | Line 850: | ||
sp.className = 'gra-note-highlight'; | sp.className = 'gra-note-highlight'; | ||
sp.setAttribute('data-gra-id', h.id); | sp.setAttribute('data-gra-id', h.id); | ||
try { range.surroundContents(sp); } catch(e){ | try { range.surroundContents(sp); } catch(e){} | ||
}); | }); | ||
} | } | ||
| Line 905: | Line 892: | ||
} catch(e){ return null; } | } catch(e){ return null; } | ||
} | } | ||
$(function() { | $(function() { | ||
| Line 915: | Line 898: | ||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | ||
}); | }); | ||
buildDom(); | buildDom(); | ||
wireEvents(); | wireEvents(); | ||
loadNotes(); | loadNotes(); | ||
loadBookmarks(); | loadBookmarks(); | ||
setTimeout(function(){ | setTimeout(function(){ | ||
try { restoreNoteHighlights(); } catch(e){} | try { restoreNoteHighlights(); } catch(e){} | ||