MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback ( | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback (v5) | ||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
* | * | ||
* | * FIXES FROM v4 | ||
* | * ───────────── | ||
* • | * • BUG: "Cannot read properties of null (reading 'parentNode')" from | ||
* | * ToggleList.js crashing before wireEvents() completes. | ||
* instead of | * FIX: wrapSelection() now null-guards every DOM operation. Also | ||
* • | * wrapped surroundContents in a try/catch that falls back gracefully | ||
* to | * instead of letting the exception propagate. | ||
* | * | ||
* • | * • BUG: On mobile, _selRange is null when action buttons are tapped | ||
* • | * because selectionchange fires again (collapsing the selection) right | ||
* as the user lifts their finger to tap the action bar. | |||
* FIX: captureSelection() now saves _selText + a serialized anchor/ | |||
* focus pair at selectionchange time (600ms debounce). We store a | |||
* *snapshot* of the range so it survives the selection being cleared | |||
* by the tap on the action button. | |||
* | |||
* • BUG: Mobile bar appeared underneath browser's copy/paste menu. | |||
* FIX: Delay increased to 700ms and bar z-index lifted to 99999. | |||
* | |||
* • MISC: $mobileBar z-index fixed so it renders above gr-static-bar. | |||
*/ | */ | ||
| Line 43: | Line 53: | ||
// ── State ──────────────────────────────────────────────────────────── | // ── State ──────────────────────────────────────────────────────────── | ||
var _selRange = null; | var _selRange = null; // cloned Range — kept alive across taps | ||
var _selText = ''; | var _selText = ''; | ||
var _selRect = null; | var _selRect = null; | ||
| Line 51: | Line 61: | ||
var _selVersion = 0; | var _selVersion = 0; | ||
var _fabSelVer = -1; | var _fabSelVer = -1; | ||
var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | ||
// ── Helpers ────────────────────────────────────────────────────────── | // ── Helpers ────────────────────────────────────────────────────────── | ||
| Line 82: | Line 92: | ||
function buildDom() { | function buildDom() { | ||
$fab = $( [ | $fab = $( [ | ||
'<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">', | '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">', | ||
| Line 101: | Line 110: | ||
$('body').append($fab); | $('body').append($fab); | ||
$mobileBar = $( [ | $mobileBar = $( [ | ||
'<div id="gra-mobile-bar" role="toolbar" aria-label="Actions">', | '<div id="gra-mobile-bar" role="toolbar" aria-label="Actions">', | ||
| Line 128: | Line 134: | ||
$('body').append($mobileBar); | $('body').append($mobileBar); | ||
$fbComposer = $( [ | $fbComposer = $( [ | ||
'<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">', | '<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">', | ||
| Line 159: | Line 164: | ||
$('body').append($fbComposer); | $('body').append($fbComposer); | ||
$ntComposer = $( [ | $ntComposer = $( [ | ||
'<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">', | '<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">', | ||
| Line 175: | Line 179: | ||
$('body').append($ntComposer); | $('body').append($ntComposer); | ||
$bmComposer = $( [ | $bmComposer = $( [ | ||
'<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">', | '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">', | ||
| Line 191: | Line 194: | ||
$('body').append($bmComposer); | $('body').append($bmComposer); | ||
$panel = $( [ | $panel = $( [ | ||
'<div id="gra-panel" role="complementary" aria-label="Notes">', | '<div id="gra-panel" role="complementary" aria-label="Notes">', | ||
| Line 245: | Line 247: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// SELECTION | // SELECTION — snapshot stored at selectionchange time | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
/** | |||
* captureSelection() | |||
* Called from the debounced selectionchange handler (not from button tap). | |||
* Stores a cloned Range in _selRange so it survives the selection being | |||
* cleared when the user taps the action bar button. | |||
*/ | |||
function captureSelection() { | function captureSelection() { | ||
var sel = window.getSelection(); | var sel = window.getSelection(); | ||
| Line 256: | Line 264: | ||
var contentEl = document.querySelector(CONTENT_SEL); | var contentEl = document.querySelector(CONTENT_SEL); | ||
if (!contentEl) return false; | if (!contentEl) return false; | ||
var | var ancestor = range.commonAncestorContainer; | ||
if ( | if (ancestor.nodeType === 3) ancestor = ancestor.parentNode; | ||
if (!contentEl.contains( | if (!ancestor || !contentEl.contains(ancestor)) return false; | ||
_selText = text; | _selText = text; | ||
_selRect = range.getBoundingClientRect(); | _selRect = range.getBoundingClientRect(); | ||
/* Clone the range NOW while selection is live */ | |||
try { _selRange = range.cloneRange(); } | |||
catch(e){ _selRange = null; } | |||
return true; | |||
} | |||
/** | |||
* reCaptureFromDOM() | |||
* Fallback when _selRange is null at button-tap time (selection already | |||
* collapsed). Tries to find the text in the DOM using _selText. | |||
*/ | |||
function reCaptureFromDOM() { | |||
if (!_selText) return false; | |||
var contentEl = document.querySelector(CONTENT_SEL); | |||
if (!contentEl) return false; | |||
var found = findTextInContent(contentEl, _selText.slice(0,80).replace(/…$/,'').trim()); | |||
if (!found) return false; | |||
_selRange = found; | |||
return true; | return true; | ||
} | } | ||
function tryShowActions() { | function tryShowActions() { | ||
if ($fbComposer.hasClass('gra-composer-visible')) return; | if ($fbComposer && $fbComposer.hasClass('gra-composer-visible')) return; | ||
if ($ntComposer.hasClass('gra-composer-visible')) return; | if ($ntComposer && $ntComposer.hasClass('gra-composer-visible')) return; | ||
if ($bmComposer.hasClass('gra-composer-visible')) return; | if ($bmComposer && $bmComposer.hasClass('gra-composer-visible')) return; | ||
if (!captureSelection()) { | if (!captureSelection()) { | ||
hideActions(); | hideActions(); | ||
| Line 286: | Line 314: | ||
function showFab(rect) { | function showFab(rect) { | ||
if (_mobile) return; | if (_mobile) return; | ||
if (!rect) return; | if (!rect) return; | ||
var fabW = 46, fabH = 126; | var fabW = 46, fabH = 126; | ||
| Line 303: | Line 331: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function showMobileBar() { | function showMobileBar() { $mobileBar.addClass('gra-mobile-bar-visible'); } | ||
function hideMobileBar() { $mobileBar.removeClass('gra-mobile-bar-visible'); } | |||
} | function hideActions() { hideFab(); hideMobileBar(); } | ||
// ════════════════════════════════════════════════════════════════════ | |||
// WRAP SELECTION — null-safe version | |||
// ════════════════════════════════════════════════════════════════════ | |||
function | function wrapSelection(id, cssClass) { | ||
var range = _selRange; | |||
_selRange = null; | |||
if (!range) return null; | |||
/* Verify the range endpoints are still in the document */ | |||
try { | |||
if (!document.contains(range.startContainer) || | |||
!document.contains(range.endContainer)) { | |||
return null; | |||
} | |||
} catch(e) { return null; } | |||
function makeSpan() { | |||
var sp = document.createElement('span'); | |||
sp.className = cssClass; | |||
sp.setAttribute('data-gra-id', id); | |||
return; | return sp; | ||
} | } | ||
/* Try surroundContents first (fails if range crosses element boundaries) */ | |||
try { | |||
var span = makeSpan(); | |||
range.surroundContents(span); | |||
/* Verify the span was actually inserted */ | |||
if (span.parentNode) return span; | |||
} catch(e) { /* fall through */ } | |||
/* Fallback: extractContents + re-insert */ | |||
try { | try { | ||
var frag = range.extractContents(); | |||
var sp2 = makeSpan(); | |||
sp2.appendChild(frag); | |||
range.insertNode(sp2); | |||
/* null-guard: check parentNode before returning */ | |||
if (sp2 && sp2.parentNode) return sp2; | |||
} catch(e2) { /* give up */ } | |||
return null; | |||
} | } | ||
| Line 434: | Line 444: | ||
setTimeout(closeFeedbackComposer, 2500); | setTimeout(closeFeedbackComposer, 2500); | ||
} | } | ||
function showFeedbackError(msg) { | function showFeedbackError(msg) { | ||
$fbSubmit.prop('disabled', false).text('Send'); | $fbSubmit.prop('disabled', false).text('Send'); | ||
| Line 446: | Line 455: | ||
function openNoteComposer() { | function openNoteComposer() { | ||
hideActions(); | hideActions(); | ||
$ntComposer.css({ top: '', left: '', transform: '' }); | $ntComposer.css({ top: '', left: '', transform: '' }); | ||
$ntComposer.addClass('gra-composer-visible'); | $ntComposer.addClass('gra-composer-visible'); | ||
| Line 467: | Line 475: | ||
var ts = nowIso(); | var ts = nowIso(); | ||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | ||
/* _selRange may be null if selection was cleared — try re-capture */ | |||
if (!_selRange && _selText) reCaptureFromDOM(); | |||
var span = wrapSelection(id, 'gra-note-highlight'); | var span = wrapSelection(id, 'gra-note-highlight'); | ||
if (span) span.setAttribute('data-gra-quote', quote); | if (span) span.setAttribute('data-gra-quote', quote); | ||
| Line 490: | Line 500: | ||
function openBookmarkComposer() { | function openBookmarkComposer() { | ||
hideActions(); | hideActions(); | ||
$bmComposer.css({ top: '', left: '', transform: '' }); | $bmComposer.css({ top: '', left: '', transform: '' }); | ||
$bmComposer.addClass('gra-composer-visible'); | $bmComposer.addClass('gra-composer-visible'); | ||
| Line 508: | Line 517: | ||
var id = uid(); | var id = uid(); | ||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | ||
if (!_selRange && _selText) reCaptureFromDOM(); | |||
var span = wrapSelection(id, 'gra-bookmark-highlight'); | var span = wrapSelection(id, 'gra-bookmark-highlight'); | ||
if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); } | if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); } | ||
| Line 520: | Line 530: | ||
_bookmarks = _bookmarks.filter(function(b){ return b.id !== id; }); | _bookmarks = _bookmarks.filter(function(b){ return b.id !== id; }); | ||
var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight'); | var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight'); | ||
if (span) { | if (span && span.parentNode) { | ||
var p = span.parentNode; | var p = span.parentNode; | ||
while (span.firstChild) p.insertBefore(span.firstChild, span); | while (span.firstChild) p.insertBefore(span.firstChild, span); | ||
| Line 588: | Line 598: | ||
_notes = _notes.filter(function(n){ return n.id !== id; }); | _notes = _notes.filter(function(n){ return n.id !== id; }); | ||
var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight'); | var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight'); | ||
if (span) { | if (span && span.parentNode) { | ||
var p = span.parentNode; | var p = span.parentNode; | ||
while (span.firstChild) p.insertBefore(span.firstChild, span); | while (span.firstChild) p.insertBefore(span.firstChild, span); | ||
| Line 638: | Line 648: | ||
function wireEvents() { | function wireEvents() { | ||
/ | /* ── Desktop mouseup ── */ | ||
$(document).on('mouseup', function(e){ | $(document).on('mouseup', function(e){ | ||
if (e.button !== 0) return; | if (e.button !== 0) return; | ||
if (_mobile) return; | if (_mobile) return; | ||
setTimeout(tryShowActions, 20); | setTimeout(tryShowActions, 20); | ||
}); | }); | ||
/ | /* ── Mobile: selectionchange debounced 700ms ── | ||
* We snapshot _selRange inside captureSelection() at this point, | |||
* so it's safely stored before the user taps the action button | |||
* (which would clear window.getSelection()). */ | |||
var _selTimer = null; | var _selTimer = null; | ||
document.addEventListener('selectionchange', function() { | document.addEventListener('selectionchange', function() { | ||
| Line 657: | Line 668: | ||
if (_fabSelVer === v) return; | if (_fabSelVer === v) return; | ||
tryShowActions(); | tryShowActions(); | ||
}, | }, 700); | ||
}); | }); | ||
/ | /* ── Click outside → hide actions ── */ | ||
$(document).on('mousedown touchstart', function(e){ | $(document).on('mousedown touchstart', function(e){ | ||
var t = e.target; | var t = e.target; | ||
| Line 671: | Line 682: | ||
}); | }); | ||
/ | /* ── Desktop FAB ── */ | ||
$('#gra-fab-feedback').on('click', function(e){ | $('#gra-fab-feedback').on('click', function(e){ | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
if (!_selRange | if (!_selRange) captureSelection(); | ||
if (!_selRange) return; | |||
openFeedbackComposer(); | openFeedbackComposer(); | ||
}); | }); | ||
$('#gra-fab-note').on('click', function(e){ | $('#gra-fab-note').on('click', function(e){ | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
if (!_selRange | if (!_selRange) captureSelection(); | ||
if (!_selRange) return; | |||
openNoteComposer(); | openNoteComposer(); | ||
}); | }); | ||
$('#gra-fab-bookmark').on('click', function(e){ | $('#gra-fab-bookmark').on('click', function(e){ | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
if (!_selRange | if (!_selRange) captureSelection(); | ||
if (!_selRange) return; | |||
openBookmarkComposer(); | openBookmarkComposer(); | ||
}); | }); | ||
/ | /* ── Mobile bottom bar ── | ||
* _selRange was already stored when selectionchange fired 700ms | |||
* earlier. We use it directly — no need to call captureSelection() | |||
* again (selection is likely already collapsed from the tap). */ | |||
$('#gra-mob-feedback').on('click touchend', function(e){ | $('#gra-mob-feedback').on('click touchend', function(e){ | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
hideMobileBar(); | hideMobileBar(); | ||
if (!_selRange && ! | if (!_selRange && !reCaptureFromDOM()) return; | ||
openFeedbackComposer(); | openFeedbackComposer(); | ||
}); | }); | ||
| Line 698: | Line 715: | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
hideMobileBar(); | hideMobileBar(); | ||
if (!_selRange && ! | if (!_selRange && !reCaptureFromDOM()) return; | ||
openNoteComposer(); | openNoteComposer(); | ||
}); | }); | ||
| Line 704: | Line 721: | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
hideMobileBar(); | hideMobileBar(); | ||
if (!_selRange && ! | if (!_selRange && !reCaptureFromDOM()) return; | ||
openBookmarkComposer(); | openBookmarkComposer(); | ||
}); | }); | ||
| Line 710: | Line 727: | ||
e.preventDefault(); e.stopPropagation(); | e.preventDefault(); e.stopPropagation(); | ||
hideMobileBar(); | hideMobileBar(); | ||
_selRange = null; _selText = ''; _selRect = null; | |||
if (window.getSelection) window.getSelection().removeAllRanges(); | if (window.getSelection) window.getSelection().removeAllRanges(); | ||
}); | }); | ||
/ | /* ── Feedback composer ── */ | ||
$fbIssueType.on('change', function(){ | $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', | |||
$fbSubmit.on('click', submitFeedback); | $fbSubmit.on('click', submitFeedback); | ||
$fbText.on('keydown', function(e){ | $fbText.on('keydown', function(e){ if(e.key==='Escape') closeFeedbackComposer(); }); | ||
/ | /* ── Note composer ── */ | ||
$ntInput.on('input', function(){ | $ntInput.on('input', function(){ $ntSubmit.prop('disabled', !$(this).val().trim()); }); | ||
$('#gra-nt-cancel').on('click', closeNoteComposer); | $('#gra-nt-cancel').on('click', closeNoteComposer); | ||
$ntSubmit.on('click', submitNote); | $ntSubmit.on('click', submitNote); | ||
| Line 737: | Line 746: | ||
}); | }); | ||
/ | /* ── Bookmark composer ── */ | ||
$('#gra-bm-cancel').on('click', closeBookmarkComposer); | $('#gra-bm-cancel').on('click', closeBookmarkComposer); | ||
$bmSubmit.on('click', submitBookmark); | $bmSubmit.on('click', submitBookmark); | ||
| Line 745: | Line 754: | ||
}); | }); | ||
/ | /* ── 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 830: | Line 839: | ||
if (!needle) return; | if (!needle) return; | ||
var range = findTextInContent(document.querySelector(CONTENT_SEL), needle); | var range = findTextInContent(document.querySelector(CONTENT_SEL), needle); | ||
if (range) | if (!range) return; | ||
var sp = document.createElement('span'); | |||
sp.className = 'gra-note-highlight'; | |||
sp.setAttribute('data-gra-id', h.id); | |||
try { range.surroundContents(sp); } catch(e){ /* skip if crossing boundaries */ } | |||
}); | }); | ||
} | } | ||
| Line 846: | Line 854: | ||
if (!needle) return; | if (!needle) return; | ||
var found = findTextInContent(document.querySelector(CONTENT_SEL), needle); | var found = findTextInContent(document.querySelector(CONTENT_SEL), needle); | ||
if (found) | if (!found) return; | ||
var sp = document.createElement('span'); | |||
sp.className = 'gra-bookmark-highlight'; | |||
sp.setAttribute('data-gra-id', b.id); | |||
sp.setAttribute('data-gra-name', b.name); | |||
try { found.surroundContents(sp); } catch(e){} | |||
}); | }); | ||
} | } | ||
function findTextInContent(root, needle) { | function findTextInContent(root, needle) { | ||
if (!root) return null; | if (!root || !needle) return null; | ||
var text = root.textContent || ''; | var text = root.textContent || ''; | ||
var idx = text.indexOf(needle); | var idx = text.indexOf(needle); | ||
| Line 871: | Line 878: | ||
} | } | ||
if (!startNode || !endNode) return null; | if (!startNode || !endNode) return null; | ||
var r = document.createRange(); | try { | ||
var r = document.createRange(); | |||
r.setStart(startNode, startOffset); | |||
return | r.setEnd(endNode, endOffset); | ||
return r; | |||
} catch(e){ return null; } | |||
} | } | ||
| Line 882: | Line 891: | ||
$(function() { | $(function() { | ||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | _mobile = window.innerWidth < 768 || 'ontouchstart' in window; | ||
window.addEventListener('resize', function(){ | window.addEventListener('resize', function(){ | ||
| Line 892: | Line 900: | ||
loadNotes(); | loadNotes(); | ||
loadBookmarks(); | loadBookmarks(); | ||
restoreNoteHighlights(); | |||
/* Restore highlights in a setTimeout so they don't block page paint */ | |||
setTimeout(function(){ | |||
try { restoreNoteHighlights(); } catch(e){} | |||
try { restoreBookmarkHighlights(); } catch(e){} | |||
}, 500); | |||
}); | }); | ||
}() ); | }() ); | ||