MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback (v4) | ||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
* | * | ||
* | * CHANGES FROM v3 | ||
* | * ──────────────── | ||
* | * • Mobile: FAB no longer fights browser's native copy/paste menu. | ||
* On mobile, a bottom-sheet action bar slides up after selection | |||
* instead of a tiny floating strip next to the text. | |||
* | * • Mobile: Long-press detection improved — waits for selectionchange | ||
* to settle before showing the action bar. | |||
* • Mobile: Action bar buttons are large (48px tap targets) with labels. | |||
* • Desktop: FAB strip unchanged — appears beside selection. | |||
* • Feedback composer: centered modal on all screen sizes. | |||
* | |||
* | |||
* | |||
* | |||
* • | |||
* • | |||
*/ | */ | ||
| Line 64: | Line 20: | ||
// ── Configuration ──────────────────────────────────────────────────── | // ── Configuration ──────────────────────────────────────────────────── | ||
var | var CONTENT_SEL = '#mw-content-text'; | ||
var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | |||
var NT_LS_KEY = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | |||
var BM_LS_KEY | var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | ||
var | var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; | ||
var pageTitle | var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?'; | ||
var currentUserEmail = ''; | |||
if ( currentUser && window.mw ) { | |||
var | new mw.Api().get({ | ||
action: 'query', meta: 'userinfo', uiprop: 'email', formatversion: 2, | |||
var userInitial | }).then( function (data) { | ||
var info = data && data.query && data.query.userinfo; | |||
if ( info && info.email ) currentUserEmail = info.email; | |||
} ).catch( function () {} ); | |||
} | |||
if ( window.mw ) { | if ( window.mw ) { | ||
var ns = mw.config.get( 'wgNamespaceNumber' ); | var ns = mw.config.get( 'wgNamespaceNumber' ); | ||
if ( ns < 0 ) return; | if ( ns < 0 ) return; | ||
} | } | ||
// ── State ──────────────────────────────────────────────────────────── | // ── State ──────────────────────────────────────────────────────────── | ||
var _selRange = null; | |||
var _selRange = null; | var _selText = ''; | ||
var _selText = ''; | var _selRect = null; | ||
var _selRect = null; | var _notes = []; | ||
var | var _bookmarks = []; | ||
var _bookmarks = []; | var _activeTab = 'notes'; | ||
var | var _selVersion = 0; | ||
var | var _fabSelVer = -1; | ||
var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; // set immediately | |||
// ── Helpers ────────────────────────────────────────────────────────── | // ── Helpers ────────────────────────────────────────────────────────── | ||
function uid() { | function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); } | ||
function esc(s) { | |||
return String(s||'').replace(/&/g,'&').replace(/</g,'<') | |||
.replace(/>/g,'>').replace(/"/g,'"'); | |||
} | } | ||
function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); } | |||
function fmtTs(ts) { | |||
function nowIso() { | |||
function fmtTs( ts ) { | |||
try { | try { | ||
var d = new Date( ts ); | var d = new Date(ts); | ||
return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'}) | return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'}) | ||
+ ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false}); | + ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false}); | ||
} catch(e){ return ts; } | } catch(e){ return ts; } | ||
} | } | ||
function clamp( | function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); } | ||
function isMobile() { return _mobile; } | |||
// ── DOM references | // ── DOM references ─────────────────────────────────────────────────── | ||
var $fab, $panel, $backdrop | var $fab, $mobileBar, $panel, $backdrop; | ||
var $ | var $ntComposer, $ntInput, $ntSubmit; | ||
var $bmComposer, $bmInput, $bmSubmit; | var $bmComposer, $bmInput, $bmSubmit; | ||
var $ | var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote; | ||
var $ | var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks; | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 127: | Line 82: | ||
function buildDom() { | function buildDom() { | ||
// ── Desktop FAB strip ──────────────────────────────────────────── | |||
// ── | |||
$fab = $( [ | $fab = $( [ | ||
'<div id="gra-fab">', | '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">', | ||
' <button class="gra-fab-btn" id="gra-fab- | ' <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | ||
' <span class="gra-fab-tooltip"> | ' <span class="gra-fab-tooltip">Feedback</span>', | ||
' </button>', | ' </button>', | ||
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button">', | ' <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Add note">', | ||
' <span class="gra-icon gra-icon-bookmark"></span>', | ' <span class="gra-icon gra-icon-note" aria-hidden="true"></span>', | ||
' <span class="gra-fab-tooltip">Note</span>', | |||
' </button>', | |||
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">', | |||
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', | |||
' <span class="gra-fab-tooltip">Bookmark</span>', | ' <span class="gra-fab-tooltip">Bookmark</span>', | ||
' </button>', | ' </button>', | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $fab ); | $('body').append($fab); | ||
// ── | // ── Mobile bottom action bar (shown instead of FAB on mobile) ──── | ||
$ | // Slides up from the bottom — well above the browser's copy/paste menu | ||
'<div class="gra- | // Large tap targets (48px+) with text labels | ||
' | $mobileBar = $( [ | ||
' < | '<div id="gra-mobile-bar" role="toolbar" aria-label="Actions">', | ||
' < | ' <div id="gra-mobile-bar-inner">', | ||
' <button class="gra-mob-btn" id="gra-mob-feedback" type="button">', | |||
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | |||
' <span class="gra-mob-label">Feedback</span>', | |||
' </button>', | |||
' <button class="gra-mob-btn" id="gra-mob-note" type="button">', | |||
' <span class="gra-icon gra-icon-note" aria-hidden="true"></span>', | |||
' <span class="gra-mob-label">Note</span>', | |||
' </button>', | |||
' <button class="gra-mob-btn" id="gra-mob-bookmark" type="button">', | |||
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', | |||
' <span class="gra-mob-label">Bookmark</span>', | |||
' </button>', | |||
' <button class="gra-mob-btn gra-mob-dismiss" id="gra-mob-dismiss" type="button">', | |||
' <span style="font-size:20px;line-height:1">✕</span>', | |||
' <span class="gra-mob-label">Dismiss</span>', | |||
' </button>', | |||
' </div>', | ' </div>', | ||
' | '</div>', | ||
' | ].join('') ); | ||
' < | $('body').append($mobileBar); | ||
' <button class="gra- | |||
// ── Feedback composer (centered modal) ─────────────────────────── | |||
$fbComposer = $( [ | |||
'<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">', | |||
' <div class="gra-composer-header">', | |||
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | |||
' <strong>Feedback</strong>', | |||
' <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>', | |||
' </div>', | ' </div>', | ||
' < | ' <div class="gra-fb-quote-label">Selected text:</div>', | ||
' placeholder="Describe the issue…" rows="3"', | ' <div class="gra-fb-quote" id="gra-fb-quote"></div>', | ||
' | ' <div class="gra-fb-field-label">Issue type</div>', | ||
' <select class="gra-fb-select" id="gra-fb-issue">', | |||
' <option value="">— Choose —</option>', | |||
' <option value="wrong_text">Formatting error</option>', | |||
' <option value="reference_issue">Reference issue</option>', | |||
' <option value="spelling_mistake">Spelling mistake</option>', | |||
' <option value="other">Other</option>', | |||
' </select>', | |||
' <div class="gra-fb-field-label">Details (optional)</div>', | |||
' <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>', | |||
' <div class="gra-fb-field-label">Your email (optional)</div>', | |||
' <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">', | |||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra- | ' <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>', | ||
' <button class="gra-btn-submit" id="gra- | ' <button class="gra-btn-submit" id="gra-fb-submit" disabled>Send</button>', | ||
' </div>', | ' </div>', | ||
' <div class="gra-fb-status" id="gra-fb-status"></div>', | |||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$('body').append($fbComposer); | |||
// ── Note composer ──────────────────────────────────────────────── | |||
$ntComposer = $( [ | |||
'<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">', | |||
' <div class="gra-composer-user">', | |||
' <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>', | |||
' <div class="gra-composer-uname">' + esc(currentUser || 'Note') + '</div>', | |||
' </div>', | |||
' <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>', | |||
' <div class="gra-composer-actions">', | |||
' <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>', | |||
' <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>', | |||
' </div>', | |||
'</div>', | |||
].join('') ); | |||
$('body').append($ntComposer); | |||
// ── Bookmark composer | // ── Bookmark composer ───────────────────────────────────────────── | ||
$bmComposer = $( [ | $bmComposer = $( [ | ||
'<div class="gra-bm-composer" id="gra-bm-composer">', | '<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">', | ||
' <div class="gra-bm-composer-label">', | ' <div class="gra-bm-composer-label">', | ||
' <span class="gra-icon gra-icon-bookmark"></span>', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>', | ||
' Save bookmark', | ' Save bookmark', | ||
' </div>', | ' </div>', | ||
' <input class="gra-composer-input" id="gra-bm-input" | ' <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">', | ||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>', | ' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>', | ||
| Line 181: | Line 189: | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $bmComposer ); | $('body').append($bmComposer); | ||
// ── Right panel ─────────────────────────────────────────────────── | // ── Right panel ─────────────────────────────────────────────────── | ||
$panel = $( [ | $panel = $( [ | ||
'<div id="gra-panel">', | '<div id="gra-panel" role="complementary" aria-label="Notes">', | ||
' <div id="gra-panel-head">', | ' <div id="gra-panel-head">', | ||
' <div></div>', | ' <div id="gra-panel-title"></div>', | ||
' <button id="gra-panel-close" title="Close | ' <button id="gra-panel-close" title="Close">✕</button>', | ||
' </div>', | ' </div>', | ||
' <div id="gra-tabs">', | ' <div id="gra-tabs">', | ||
' <button class="gra-tab gra-tab-active" id="gra-tab- | ' <button class="gra-tab gra-tab-active" id="gra-tab-notes">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes', | ||
' </button>', | ' </button>', | ||
' <button class="gra-tab" id="gra-tab-bookmarks">', | ' <button class="gra-tab" id="gra-tab-bookmarks">', | ||
' <span class="gra-icon gra-icon-bookmark"></span> Bookmarks', | ' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks', | ||
' </button>', | ' </button>', | ||
' </div>', | ' </div>', | ||
' <div id="gra-panel-body">', | ' <div id="gra-panel-body">', | ||
' <div class="gra-pane gra-pane-active" id="gra-pane- | ' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>', | ||
' <div class="gra-pane" | ' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ||
' </div>', | ' </div>', | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $panel ); | $('body').append($panel); | ||
$backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>'); | |||
$backdrop = $('<div id="gra-backdrop"></div>'); | $('body').append($backdrop); | ||
$( 'body' ).append( $backdrop ); | |||
var $toggle = $( [ | var $toggle = $( [ | ||
'<button id="gra-toggle" | '<button id="gra-toggle" aria-label="Notes">', | ||
' <span class="gra-icon gra-icon- | ' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>', | ||
' <span id="gra-toggle-badge"></span>', | ' <span id="gra-toggle-badge" aria-live="polite"></span>', | ||
'</button>', | '</button>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $toggle ); | $('body').append($toggle); | ||
$toggle.on( 'click', function() { | $toggle.on('click', function() { | ||
$panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab); | |||
}); | |||
$('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30)); | |||
$tabNotes = $('#gra-tab-notes'); | |||
$tabBookmarks = $('#gra-tab-bookmarks'); | |||
$paneNotes = $('#gra-pane-notes'); | |||
$paneBookmarks= $('#gra-pane-bookmarks'); | |||
$ntInput = $('#gra-nt-input'); | |||
$ntSubmit = $('#gra-nt-submit'); | |||
$bmInput = $('#gra-bm-input'); | |||
$bmSubmit = $('#gra-bm-submit'); | |||
$fbIssueType = $('#gra-fb-issue'); | |||
$fbText = $('#gra-fb-text'); | |||
$fbEmail = $('#gra-fb-email'); | |||
$fbSubmit = $('#gra-fb-submit'); | |||
$fbQuote = $('#gra-fb-quote'); | |||
} | |||
// ════════════════════════════════════════════════════════════════════ | |||
// SELECTION | |||
// ════════════════════════════════════════════════════════════════════ | |||
function captureSelection() { | |||
if ( ! | var sel = window.getSelection(); | ||
if (!sel || sel.isCollapsed || !sel.rangeCount) return false; | |||
var range = sel.getRangeAt(0); | |||
var text = sel.toString().trim(); | |||
if (!text || text.length < 2) return false; | |||
var contentEl = document.querySelector(CONTENT_SEL); | |||
if (!contentEl) return false; | |||
var start = range.commonAncestorContainer; | |||
if (start.nodeType === 3) start = start.parentNode; | |||
if (!contentEl.contains(start)) return false; | |||
_selText = text; | |||
_selRange = range.cloneRange(); | |||
_selRect = range.getBoundingClientRect(); | |||
return true; | |||
} | |||
function tryShowActions() { | |||
if ($fbComposer.hasClass('gra-composer-visible')) return; | |||
if ($ntComposer.hasClass('gra-composer-visible')) return; | |||
if ($bmComposer.hasClass('gra-composer-visible')) return; | |||
if (!captureSelection()) { | |||
hideActions(); | |||
return; | |||
} | |||
_fabSelVer = _selVersion; | |||
if (_mobile) { | |||
showMobileBar(); | |||
} else { | |||
showFab(_selRect); | |||
} | } | ||
} | } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// | // DESKTOP FAB | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function showFab( rect ) { | function showFab(rect) { | ||
if ( | if (_mobile) return; // mobile uses bottom bar | ||
if (!rect) return; | |||
var fabW = 46, fabH = 126; | |||
var fabW = 46 | var top = rect.top + (rect.height/2) - (fabH/2); | ||
var top = rect.top + ( rect.height / 2 ) - ( fabH / 2 ); | |||
var left = rect.right + 10; | var left = rect.right + 10; | ||
if (left + fabW > window.innerWidth - 8) left = rect.left - fabW - 10; | |||
top = clamp(top, 8, window.innerHeight - fabH - 8); | |||
if ( left + fabW > window.innerWidth - 8 ) | left = clamp(left, 8, window.innerWidth - fabW - 8); | ||
$fab.css({top: top+'px', left: left+'px'}).addClass('gra-fab-visible'); | |||
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() { | function hideFab() { $fab.removeClass('gra-fab-visible'); } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// | // MOBILE BOTTOM BAR | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function | function showMobileBar() { | ||
$mobileBar.addClass('gra-mobile-bar-visible'); | |||
} | |||
function hideMobileBar() { | |||
$mobileBar.removeClass('gra-mobile-bar-visible'); | |||
} | |||
function hideActions() { | |||
hideFab(); | |||
hideMobileBar(); | |||
} | |||
// Debug helper — remove after testing | |||
window._graDebug = function() { | |||
console.log('mobile:', _mobile, 'selText:', _selText, 'selRange:', _selRange); | |||
console.log('bar visible:', document.getElementById('gra-mobile-bar') && | |||
} | document.getElementById('gra-mobile-bar').classList.contains('gra-mobile-bar-visible')); | ||
}; | |||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// COMPOSER POSITIONING | // COMPOSER POSITIONING (desktop note/bookmark only) | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function positionComposer( $el ) { | function positionComposer($el) { | ||
if ( !_selRect ) return; | if (isMobile()) { | ||
// On mobile always center — don't anchor to selection | |||
$el.css({top: '', left: '', transform: ''}); | |||
return; | |||
} | |||
if (!_selRect) return; | |||
var W = 340; | |||
var top = _selRect.bottom + 8; | var top = _selRect.bottom + 8; | ||
var left = _selRect.left; | var left = _selRect.left; | ||
if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8; | |||
left = Math.max(left, 8); | |||
if ( left + | if (top + 280 > window.innerHeight) top = _selRect.top - 290; | ||
top = Math.max(top, 8); | |||
$el.css({top: top+'px', left: left+'px'}); | |||
left = Math.max( left, 8 ); | |||
if ( top + | |||
top = Math.max( top, 8 ); | |||
$el.css({ top: top + 'px', left: left + 'px' }); | |||
} | } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// WRAP SELECTION | // WRAP SELECTION | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function wrapSelection( id, cssClass ) { | function wrapSelection(id, cssClass) { | ||
if ( !_selRange ) return null; | if (!_selRange) return null; | ||
var range = _selRange; | |||
_selRange = null; | |||
try { | try { | ||
var span = document.createElement('span'); | var span = document.createElement('span'); | ||
span.className = cssClass; | span.className = cssClass; | ||
span.setAttribute('data-gra-id', id); | span.setAttribute('data-gra-id', id); | ||
range.surroundContents(span); | |||
return span; | return span; | ||
} catch ( e ) { | } catch(e) { | ||
try { | try { | ||
var frag = | var frag = range.extractContents(); | ||
var | var sp2 = document.createElement('span'); | ||
sp2.className = cssClass; | |||
sp2.setAttribute('data-gra-id', id); | |||
sp2.appendChild(frag); | |||
range.insertNode(sp2); | |||
return | return sp2; | ||
} catch(e2) { return null; } | } catch(e2) { return null; } | ||
} | } | ||
| Line 354: | Line 372: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// | // FEEDBACK FLOW | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function | function openFeedbackComposer() { | ||
hideActions(); | |||
$fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : '')); | |||
$fbIssueType.val(''); | |||
$ | $fbText.val(''); | ||
$ | $fbSubmit.prop('disabled', true); | ||
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); | |||
if (currentUserEmail) $fbEmail.val(currentUserEmail); | |||
else $fbEmail.val(''); | |||
$fbComposer.css({top:'', left:'', transform:''}); | |||
$fbComposer.addClass('gra-composer-visible'); | |||
$backdrop.addClass('gra-backdrop-visible'); | |||
setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0); | |||
} | } | ||
function | function closeFeedbackComposer() { | ||
$ | $fbComposer.removeClass('gra-composer-visible'); | ||
$ | $backdrop.removeClass('gra-backdrop-visible'); | ||
_selRange = null; _selText = ''; _selRect = null; | |||
_selRange = null; | |||
} | } | ||
function | function submitFeedback() { | ||
var | var issueType = $fbIssueType.val(); | ||
var details = $fbText.val().trim(); | |||
var | var email = $fbEmail.val().trim(); | ||
var quote = $fbQuote.text(); | |||
var quote = | if (!issueType) return; | ||
$fbSubmit.prop('disabled', true).text('Sending…'); | |||
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err'); | |||
var issueLabels = { | |||
var | wrong_text: 'Formatting error', reference_issue: 'Reference issue', | ||
spelling_mistake: 'Spelling mistake', other: 'Other' | |||
}; | |||
// | var payload = new FormData(); | ||
payload.append('issue_type', issueLabels[issueType] || issueType); | |||
payload.append('page', pageTitle.replace(/_/g,' ')); | |||
payload.append('url', window.location.href); | |||
payload.append('selected_text', quote); | |||
payload.append('details', details || ''); | |||
payload.append('user_email', email || currentUserEmail || ''); | |||
payload.append('wiki_user', currentUser || 'anonymous'); | |||
/ | fetch('/feedback.php', {method:'POST', body:payload}) | ||
.then(function(r){ return r.json(); }) | |||
.then(function(data){ | |||
if (data && data.ok) showFeedbackSuccess(); | |||
else showFeedbackError(data && data.error ? data.error : 'Could not send.'); | |||
}) | |||
.catch(function(){ showFeedbackError('Network error. Please try again.'); }); | |||
} | } | ||
function | function showFeedbackSuccess() { | ||
$fbSubmit.prop('disabled', false).text('Send'); | |||
$('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok'); | |||
setTimeout(closeFeedbackComposer, 2500); | |||
} | } | ||
function | function showFeedbackError(msg) { | ||
$fbSubmit.prop('disabled', false).text('Send'); | |||
$('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err'); | |||
} | |||
// ════════════════════════════════════════════════════════════════════ | |||
// NOTE FLOW | |||
// ════════════════════════════════════════════════════════════════════ | |||
/ | function openNoteComposer() { | ||
hideActions(); | |||
// Centered modal on all devices | |||
$ntComposer.css({ top: '', left: '', transform: '' }); | |||
$ntComposer.addClass('gra-composer-visible'); | |||
$backdrop.addClass('gra-backdrop-visible'); | |||
setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0); | |||
} | |||
function closeNoteComposer() { | |||
$ntComposer.removeClass('gra-composer-visible'); | |||
$backdrop.removeClass('gra-backdrop-visible'); | |||
$ntInput.val(''); | |||
$ntSubmit.prop('disabled', true); | |||
_selRange = null; _selText = ''; _selRect = null; | |||
} | } | ||
function submitNote() { | |||
function | var text = $ntInput.val().trim(); | ||
if (!text) return; | |||
if ( ! | var id = uid(); | ||
var ts = nowIso(); | |||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | |||
var span = wrapSelection(id, 'gra-note-highlight'); | |||
if (span) span.setAttribute('data-gra-quote', quote); | |||
_notes.push({id:id, ts:ts, quote:quote, text:text}); | |||
persistNotes(); | |||
persistNoteHighlight(id, quote); | |||
renderNoteCards(); | |||
closeNoteComposer(); | |||
openPanel('notes'); | |||
} | } | ||
function | function persistNotes() { | ||
try { localStorage.setItem(NT_LS_KEY, JSON.stringify(_notes)); } catch(e){} | |||
} | |||
function loadNotes() { | |||
try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){} | |||
} | } | ||
| Line 524: | Line 489: | ||
function openBookmarkComposer() { | function openBookmarkComposer() { | ||
hideActions(); | |||
// Centered modal — same as feedback, no position anchoring | |||
$bmComposer.css({ top: '', left: '', transform: '' }); | |||
$bmComposer.addClass('gra-composer-visible'); | $bmComposer.addClass('gra-composer-visible'); | ||
$ | $backdrop.addClass('gra-backdrop-visible'); | ||
setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0); | |||
} | } | ||
function closeBookmarkComposer() { | function closeBookmarkComposer() { | ||
$bmComposer.removeClass('gra-composer-visible'); | $bmComposer.removeClass('gra-composer-visible'); | ||
$backdrop.removeClass('gra-backdrop-visible'); | |||
$bmInput.val(''); | $bmInput.val(''); | ||
_selRange = null; | _selRange = null; _selText = ''; _selRect = null; | ||
} | } | ||
function submitBookmark() { | function submitBookmark() { | ||
var name = $bmInput.val().trim() || ( 'Bookmark ' + (_bookmarks.length+1) ); | var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1)); | ||
var id = uid(); | var id = uid(); | ||
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : ''); | |||
var quote = _selText.slice(0,120) + (_selText.length>120?'…':''); | var span = wrapSelection(id, 'gra-bookmark-highlight'); | ||
if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); } | |||
_bookmarks.push({id:id, name:name, quote:quote, ts:nowIso()}); | |||
var span = wrapSelection( id, 'gra-bookmark-highlight' ); | |||
if ( span ) { | |||
persistBookmarks(); | persistBookmarks(); | ||
renderBookmarkCards(); | renderBookmarkCards(); | ||
| Line 559: | Line 517: | ||
} | } | ||
function deleteBookmark( id ) { | function deleteBookmark(id) { | ||
_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) { | ||
var | var p = span.parentNode; | ||
while (span.firstChild) | while (span.firstChild) p.insertBefore(span.firstChild, span); | ||
p.removeChild(span); | |||
} | } | ||
persistBookmarks(); | persistBookmarks(); renderBookmarkCards(); | ||
} | } | ||
| Line 575: | Line 531: | ||
try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){} | try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){} | ||
} | } | ||
function loadBookmarks() { | function loadBookmarks() { | ||
try { | try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){} | ||
} | } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// PANEL | // PANEL | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function openPanel( tab ) { | function openPanel(tab) { | ||
_activeTab = tab || _activeTab; | _activeTab = tab || _activeTab; | ||
switchTab( _activeTab ); | switchTab(_activeTab); | ||
$panel.addClass('gra-panel-open'); | $panel.addClass('gra-panel-open'); | ||
$backdrop.addClass('gra-backdrop-visible'); | $backdrop.addClass('gra-backdrop-visible'); | ||
} | } | ||
function closePanel() { | function closePanel() { | ||
$panel.removeClass('gra-panel-open'); | $panel.removeClass('gra-panel-open'); | ||
$backdrop.removeClass('gra-backdrop-visible'); | $backdrop.removeClass('gra-backdrop-visible'); | ||
} | } | ||
function switchTab(tab) { | |||
function switchTab( tab ) { | |||
_activeTab = tab; | _activeTab = tab; | ||
$ | $tabNotes.toggleClass('gra-tab-active', tab==='notes'); | ||
$tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | $tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks'); | ||
$ | $paneNotes.toggleClass('gra-pane-active', tab==='notes'); | ||
$paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | $paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks'); | ||
if (tab===' | if (tab==='notes') renderNoteCards(); | ||
else renderBookmarkCards(); | |||
} | } | ||
| Line 616: | Line 563: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function | function renderNoteCards() { | ||
if ( | if (!_notes.length) { | ||
$ | $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>'); | ||
return; | return; | ||
} | } | ||
var html = ''; | var html = ''; | ||
_notes.slice().reverse().forEach(function(n){ | |||
html += '<div class="gra- | html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">' | ||
+ '<div class="gra-card-header">' | + '<div class="gra-card-header">' | ||
+ '<div class="gra-avatar"> | + '<div class="gra-avatar">✎</div>' | ||
+ '<div class="gra-card-meta">' | + '<div class="gra-card-meta">' | ||
+ (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '') | |||
+ ( | |||
+ '</div>' | + '</div>' | ||
+ '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>' | |||
+ '</div>' | + '</div>' | ||
+ ( | + (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '') | ||
+ '<div class="gra-card-text">' + esc( | + '<div class="gra-card-text">'+esc(n.text)+'</div>' | ||
+ '</div>'; | + '</div>'; | ||
}); | }); | ||
$ | $paneNotes.html(html); | ||
} | |||
function deleteNote(id) { | |||
_notes = _notes.filter(function(n){ return n.id !== id; }); | |||
var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight'); | |||
if (span) { | |||
var p = span.parentNode; | |||
while (span.firstChild) p.insertBefore(span.firstChild, span); | |||
p.removeChild(span); | |||
} | |||
try { | |||
var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); | |||
s = s.filter(function(h){ return h.id !== id; }); | |||
localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s)); | |||
} catch(e){} | |||
persistNotes(); renderNoteCards(); | |||
} | } | ||
function renderBookmarkCards() { | function renderBookmarkCards() { | ||
if ( _bookmarks.length | if (!_bookmarks.length) { | ||
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and | $paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>'); | ||
return; | return; | ||
} | } | ||
var html = ''; | var html = ''; | ||
_bookmarks.slice().reverse().forEach(function(b){ | _bookmarks.slice().reverse().forEach(function(b){ | ||
html += '<div class="gra-bookmark-card" data-gra-id="' + esc(b.id) + '">' | html += '<div class="gra-bookmark-card" data-gra-id="'+esc(b.id)+'">' | ||
+ '<span class="gra-icon gra-icon-bookmark"></span>' | + '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>' | ||
+ '<div class="gra-bookmark-info">' | + '<div class="gra-bookmark-info">' | ||
+ '<div class="gra-bookmark-name">' + esc(b.name) + '</div>' | + '<div class="gra-bookmark-name">'+esc(b.name)+'</div>' | ||
+ (b.quote ? '<div class="gra-bookmark-quote">' + esc(b.quote) + '</div>' : '') | + (b.quote ? '<div class="gra-bookmark-quote">'+esc(b.quote)+'</div>' : '') | ||
+ '</div>' | + '</div>' | ||
+ '<button class="gra-bookmark-del" data-del-id="' + esc(b.id) + '" title="Remove | + '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>' | ||
+ '</div>'; | + '</div>'; | ||
}); | }); | ||
| Line 661: | Line 624: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function scrollToHighlight( id ) { | function scrollToHighlight(id) { | ||
var el = document.querySelector('[data-gra-id="' + id + '"]'); | var el = document.querySelector('[data-gra-id="'+id+'"]'); | ||
if (!el) return; | if (!el) return; | ||
el.scrollIntoView({ behavior:'smooth', block:'center' }); | el.scrollIntoView({behavior:'smooth', block:'center'}); | ||
el.classList.add('gra-hl-active'); | el.classList.add('gra-hl-active'); | ||
setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000); | setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000); | ||
| Line 675: | Line 638: | ||
function wireEvents() { | function wireEvents() { | ||
// ── | // ── Desktop mouseup ─────────────────────────────────────────── | ||
$( document ).on('mouseup | $(document).on('mouseup', function(e){ | ||
if (e.button !== 0) return; | |||
if ( | if (_mobile) return; // mobile uses selectionchange | ||
if ( | setTimeout(tryShowActions, 20); | ||
setTimeout( | |||
}); | }); | ||
// ── | // ── Mobile: selectionchange (debounced 600ms) ───────────────── | ||
// | // We wait longer than v3 so the browser's own copy menu has time | ||
// | // to appear first — user can still copy, THEN our bar slides up. | ||
var _selTimer = null; | |||
document.addEventListener('selectionchange', function() { | |||
_selVersion++; | |||
clearTimeout(_selTimer); | |||
var v = _selVersion; | |||
_selTimer = setTimeout(function(){ | |||
if (v !== _selVersion) return; | |||
if (_fabSelVer === v) return; | |||
tryShowActions(); | |||
}, 600); // 600ms — after browser copy menu appears | |||
}); | }); | ||
// ── Click outside → hide | // ── Click outside → hide actions ────────────────────────────── | ||
$( document ).on('mousedown', function(e){ | $(document).on('mousedown touchstart', function(e){ | ||
var | var t = e.target; | ||
if ( | if ($fab[0] && $fab[0].contains(t)) return; | ||
if ($mobileBar[0] && $mobileBar[0].contains(t)) return; | |||
if ($fbComposer[0] && $fbComposer[0].contains(t)) return; | |||
if ($ntComposer[0] && $ntComposer[0].contains(t)) return; | |||
if ($bmComposer[0] && $bmComposer[0].contains(t)) return; | |||
hideActions(); | |||
}); | }); | ||
// ── FAB | // ── Desktop FAB buttons ─────────────────────────────────────── | ||
$('#gra-fab-feedback').on('click', function(e){ | |||
e.preventDefault(); e.stopPropagation(); | |||
if (!_selRange && !captureSelection()) return; | |||
$( '#gra-fab- | openFeedbackComposer(); | ||
e.preventDefault(); | }); | ||
$('#gra-fab-note').on('click', function(e){ | |||
if ( !_selRange ) return; | e.preventDefault(); e.stopPropagation(); | ||
if (!_selRange && !captureSelection()) return; | |||
openNoteComposer(); | |||
}); | |||
$('#gra-fab-bookmark').on('click', function(e){ | |||
e.preventDefault(); e.stopPropagation(); | |||
if (!_selRange && !captureSelection()) return; | |||
openBookmarkComposer(); | |||
}); | }); | ||
// ── | // ── Mobile bottom bar buttons ───────────────────────────────── | ||
$( '#gra- | $('#gra-mob-feedback').on('click touchend', function(e){ | ||
e.preventDefault(); | e.preventDefault(); e.stopPropagation(); | ||
e.stopPropagation(); | hideMobileBar(); | ||
if ( !_selRange ) return; | if (!_selRange && !captureSelection()) return; | ||
openFeedbackComposer(); | |||
}); | |||
$('#gra-mob-note').on('click touchend', function(e){ | |||
e.preventDefault(); e.stopPropagation(); | |||
hideMobileBar(); | |||
if (!_selRange && !captureSelection()) return; | |||
openNoteComposer(); | |||
}); | |||
$('#gra-mob-bookmark').on('click touchend', function(e){ | |||
e.preventDefault(); e.stopPropagation(); | |||
hideMobileBar(); | |||
if (!_selRange && !captureSelection()) return; | |||
openBookmarkComposer(); | openBookmarkComposer(); | ||
}); | |||
$('#gra-mob-dismiss').on('click touchend', function(e){ | |||
e.preventDefault(); e.stopPropagation(); | |||
hideMobileBar(); | |||
// Clear selection visually | |||
if (window.getSelection) window.getSelection().removeAllRanges(); | |||
}); | }); | ||
// ── | // ── Feedback composer ───────────────────────────────────────── | ||
$fbIssueType.on('change', function(){ | |||
$fbSubmit.prop('disabled', !$(this).val()); | |||
}); | |||
$ | $('#gra-fb-cancel, #gra-fb-close').on('click', function(){ | ||
closeFeedbackComposer(); | |||
}); | |||
$fbSubmit.on('click', submitFeedback); | |||
$fbText.on('keydown', function(e){ | |||
if (e.key==='Escape') closeFeedbackComposer(); | |||
$ | |||
}); | }); | ||
$ | |||
// ── Note composer ───────────────────────────────────────────── | |||
$ntInput.on('input', function(){ | |||
$ntSubmit.prop('disabled', !$(this).val().trim()); | |||
}); | }); | ||
$ | $('#gra-nt-cancel').on('click', closeNoteComposer); | ||
$ | $ntSubmit.on('click', submitNote); | ||
$ntInput.on('keydown', function(e){ | |||
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') | if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote(); | ||
if (e.key==='Escape') | if (e.key==='Escape') closeNoteComposer(); | ||
}); | }); | ||
// ── Bookmark composer ───────────────────────────────────────── | // ── Bookmark composer ───────────────────────────────────────── | ||
$( '#gra-bm-cancel' ).on('click', | $('#gra-bm-cancel').on('click', closeBookmarkComposer); | ||
$bmSubmit.on('click', submitBookmark); | $bmSubmit.on('click', submitBookmark); | ||
$bmInput.on('keydown', function(e){ | $bmInput.on('keydown', function(e){ | ||
if (e.key==='Enter') submitBookmark(); | if (e.key==='Enter') submitBookmark(); | ||
if (e.key==='Escape') | if (e.key==='Escape') closeBookmarkComposer(); | ||
}); | }); | ||
// ── Panel | // ── Panel ───────────────────────────────────────────────────── | ||
$( '#gra-panel-close' ).on('click', closePanel); | $('#gra-panel-close').on('click', closePanel); | ||
$backdrop.on('click', closePanel); | $backdrop.on('click touchend', function(e){ | ||
e.preventDefault(); | |||
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer(); | |||
$ | else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer(); | ||
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer(); | |||
else closePanel(); | |||
}); | |||
$tabNotes.on('click', function(){ switchTab('notes'); }); | |||
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | ||
$paneNotes.on('click', '.gra-note-card', function(e){ | |||
$ | if ($(e.target).hasClass('gra-note-del')) return; | ||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
if (id) scrollToHighlight(id); | if (id) { closePanel(); scrollToHighlight(id); } | ||
}); | |||
$paneNotes.on('click', '.gra-note-del', function(e){ | |||
e.stopPropagation(); | |||
var id = $(this).attr('data-del-id'); | |||
if (id) deleteNote(id); | |||
}); | }); | ||
$paneBookmarks.on('click', '.gra-bookmark-card', function(e){ | $paneBookmarks.on('click', '.gra-bookmark-card', function(e){ | ||
if ($(e.target).hasClass('gra-bookmark-del')) return; | if ($(e.target).hasClass('gra-bookmark-del')) return; | ||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
if (id) scrollToHighlight(id); | if (id) { closePanel(); scrollToHighlight(id); } | ||
}); | }); | ||
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){ | $paneBookmarks.on('click', '.gra-bookmark-del', function(e){ | ||
e.stopPropagation(); | e.stopPropagation(); | ||
| Line 811: | Line 778: | ||
}); | }); | ||
$(CONTENT_SEL).on('click', '.gra-note-highlight', function(){ | |||
$( CONTENT_SEL ).on('click', '.gra- | |||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
openPanel(' | openPanel('notes'); | ||
setTimeout(function(){ | setTimeout(function(){ | ||
var $card = $ | var $card = $paneNotes.find('[data-gra-id="'+id+'"]'); | ||
if ($card.length) { | if ($card.length) { | ||
$card.addClass('gra-card-active'); | $card.addClass('gra-card-active'); | ||
$card[0].scrollIntoView({behavior:'smooth',block:'nearest'}); | $card[0].scrollIntoView({behavior:'smooth', block:'nearest'}); | ||
setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); | setTimeout(function(){ $card.removeClass('gra-card-active'); }, 2000); | ||
} | } | ||
}, 100); | }, 100); | ||
}); | }); | ||
$(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){ | |||
$( CONTENT_SEL ).on('click', '.gra-bookmark-highlight', function(){ | |||
var id = $(this).attr('data-gra-id'); | var id = $(this).attr('data-gra-id'); | ||
openPanel('bookmarks'); | openPanel('bookmarks'); | ||
setTimeout(function(){ | setTimeout(function(){ | ||
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); | }, 100); | ||
}); | }); | ||
$(document).on('keydown', function(e){ | |||
$( document ).on('keydown', function(e){ | if (e.key !== 'Escape') return; | ||
if (e.key | if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer(); | ||
else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer(); | |||
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer(); | |||
else closePanel(); | |||
}); | }); | ||
} | } | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// RESTORE | // RESTORE HIGHLIGHTS | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function persistNoteHighlight(id, quote) { | |||
function | |||
try { | try { | ||
var | var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); | ||
s = s.filter(function(h){ return h.id !== id; }); | |||
s.push({id:id, quote:quote}); | |||
localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s)); | |||
localStorage.setItem( | |||
} catch(e){} | } catch(e){} | ||
} | } | ||
function | function restoreNoteHighlights() { | ||
var | var s = []; | ||
try { | try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){} | ||
s.forEach(function(h){ | |||
if ( !h.quote || !h.id ) return; | if (!h.quote || !h.id) return; | ||
if ( document.querySelector( '[data-gra-id="' + h.id + '"].gra- | if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return; | ||
var needle = h.quote.replace(/…$/, '').trim().slice(0, 80); | var needle = h.quote.replace(/…$/,'').trim().slice(0,80); | ||
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) { | ||
var | var sp = document.createElement('span'); | ||
sp.className = 'gra-note-highlight'; | |||
sp.setAttribute('data-gra-id', h.id); | |||
try { range.surroundContents( | try { range.surroundContents(sp); } catch(e){} | ||
} | } | ||
}); | }); | ||
| Line 887: | Line 841: | ||
function restoreBookmarkHighlights() { | function restoreBookmarkHighlights() { | ||
_bookmarks.forEach(function(b){ | _bookmarks.forEach(function(b){ | ||
if ( !b.quote ) return; | if (!b.quote) return; | ||
if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return; | |||
if ( document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight') | |||
var needle = b.quote.replace(/…$/,'').trim().slice(0,60); | var needle = b.quote.replace(/…$/,'').trim().slice(0,60); | ||
if (!needle) return; | if (!needle) return; | ||
var found = findTextInContent(document.querySelector(CONTENT_SEL), needle); | |||
var found = findTextInContent( | |||
if (found) { | if (found) { | ||
var | 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 { | try { found.surroundContents(sp); } catch(e){} | ||
} | } | ||
}); | }); | ||
} | } | ||
function findTextInContent( root, needle ) { | function findTextInContent(root, needle) { | ||
if (!root) return null; | |||
var text = root.textContent || ''; | var text = root.textContent || ''; | ||
var idx = text.indexOf(needle); | var idx = text.indexOf(needle); | ||
if (idx < 0) return null; | if (idx < 0) return null; | ||
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); | var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false); | ||
var pos | var pos = 0, node, startNode, startOffset, endNode, endOffset; | ||
while ((node = iter.nextNode())) { | while ((node = iter.nextNode())) { | ||
var len = node.nodeValue.length; | var len = node.nodeValue.length; | ||
if (!startNode && pos+len > idx) { | if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; } | ||
var endIdx = idx + needle.length; | var endIdx = idx + needle.length; | ||
if (startNode && pos+len >= endIdx) { | if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; } | ||
pos += len; | pos += len; | ||
} | } | ||
if (!startNode || !endNode) return null; | if (!startNode || !endNode) return null; | ||
var | var r = document.createRange(); | ||
r.setStart(startNode, startOffset); | |||
r.setEnd(endNode, endOffset); | |||
return | return r; | ||
} | } | ||
| Line 943: | Line 881: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
$( function () { | $(function() { | ||
// Detect mobile once on load | |||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | |||
window.addEventListener('resize', function(){ | |||
_mobile = window.innerWidth < 768 || 'ontouchstart' in window; | |||
}); | |||
buildDom(); | buildDom(); | ||
wireEvents(); | wireEvents(); | ||
loadNotes(); | |||
loadBookmarks(); | loadBookmarks(); | ||
restoreNoteHighlights(); | |||
restoreBookmarkHighlights(); | restoreBookmarkHighlights(); | ||
}); | |||
} ); | |||
}() ); | }() ); | ||
Latest revision as of 18:51, 14 May 2026
/**
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback (v4)
* ══════════════════════════════════════════════════════════════════════
*
* CHANGES FROM v3
* ────────────────
* • Mobile: FAB no longer fights browser's native copy/paste menu.
* On mobile, a bottom-sheet action bar slides up after selection
* instead of a tiny floating strip next to the text.
* • Mobile: Long-press detection improved — waits for selectionchange
* to settle before showing the action bar.
* • Mobile: Action bar buttons are large (48px tap targets) with labels.
* • Desktop: FAB strip unchanged — appears beside selection.
* • Feedback composer: centered modal on all screen sizes.
*/
/* global mw, $ */
( function () {
'use strict';
// ── Configuration ────────────────────────────────────────────────────
var CONTENT_SEL = '#mw-content-text';
var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
var NT_LS_KEY = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' );
var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || '';
var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || '';
var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?';
var currentUserEmail = '';
if ( currentUser && window.mw ) {
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 () {} );
}
if ( window.mw ) {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns < 0 ) return;
}
// ── State ────────────────────────────────────────────────────────────
var _selRange = null;
var _selText = '';
var _selRect = null;
var _notes = [];
var _bookmarks = [];
var _activeTab = 'notes';
var _selVersion = 0;
var _fabSelVer = -1;
var _mobile = window.innerWidth < 768 || 'ontouchstart' in window; // set immediately
// ── Helpers ──────────────────────────────────────────────────────────
function uid() { return 'gra_' + Date.now() + '_' + Math.random().toString(36).slice(2,7); }
function esc(s) {
return String(s||'').replace(/&/g,'&').replace(/</g,'<')
.replace(/>/g,'>').replace(/"/g,'"');
}
function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); }
function fmtTs(ts) {
try {
var d = new Date(ts);
return d.toLocaleDateString('en-IN',{day:'numeric',month:'short',year:'numeric'})
+ ' ' + d.toLocaleTimeString('en-IN',{hour:'2-digit',minute:'2-digit',hour12:false});
} catch(e){ return ts; }
}
function clamp(v,lo,hi){ return Math.max(lo,Math.min(hi,v)); }
function isMobile() { return _mobile; }
// ── DOM references ───────────────────────────────────────────────────
var $fab, $mobileBar, $panel, $backdrop;
var $ntComposer, $ntInput, $ntSubmit;
var $bmComposer, $bmInput, $bmSubmit;
var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
var $tabNotes, $tabBookmarks, $paneNotes, $paneBookmarks;
// ════════════════════════════════════════════════════════════════════
// DOM BUILDER
// ════════════════════════════════════════════════════════════════════
function buildDom() {
// ── Desktop FAB strip ────────────────────────────────────────────
$fab = $( [
'<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
' <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">',
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">Feedback</span>',
' </button>',
' <button class="gra-fab-btn" id="gra-fab-note" type="button" aria-label="Add note">',
' <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">Note</span>',
' </button>',
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' <span class="gra-fab-tooltip">Bookmark</span>',
' </button>',
'</div>',
].join('') );
$('body').append($fab);
// ── Mobile bottom action bar (shown instead of FAB on mobile) ────
// Slides up from the bottom — well above the browser's copy/paste menu
// Large tap targets (48px+) with text labels
$mobileBar = $( [
'<div id="gra-mobile-bar" role="toolbar" aria-label="Actions">',
' <div id="gra-mobile-bar-inner">',
' <button class="gra-mob-btn" id="gra-mob-feedback" type="button">',
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
' <span class="gra-mob-label">Feedback</span>',
' </button>',
' <button class="gra-mob-btn" id="gra-mob-note" type="button">',
' <span class="gra-icon gra-icon-note" aria-hidden="true"></span>',
' <span class="gra-mob-label">Note</span>',
' </button>',
' <button class="gra-mob-btn" id="gra-mob-bookmark" type="button">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' <span class="gra-mob-label">Bookmark</span>',
' </button>',
' <button class="gra-mob-btn gra-mob-dismiss" id="gra-mob-dismiss" type="button">',
' <span style="font-size:20px;line-height:1">✕</span>',
' <span class="gra-mob-label">Dismiss</span>',
' </button>',
' </div>',
'</div>',
].join('') );
$('body').append($mobileBar);
// ── Feedback composer (centered modal) ───────────────────────────
$fbComposer = $( [
'<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">',
' <div class="gra-composer-header">',
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>',
' <strong>Feedback</strong>',
' <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>',
' </div>',
' <div class="gra-fb-quote-label">Selected text:</div>',
' <div class="gra-fb-quote" id="gra-fb-quote"></div>',
' <div class="gra-fb-field-label">Issue type</div>',
' <select class="gra-fb-select" id="gra-fb-issue">',
' <option value="">— Choose —</option>',
' <option value="wrong_text">Formatting error</option>',
' <option value="reference_issue">Reference issue</option>',
' <option value="spelling_mistake">Spelling mistake</option>',
' <option value="other">Other</option>',
' </select>',
' <div class="gra-fb-field-label">Details (optional)</div>',
' <textarea class="gra-composer-input" id="gra-fb-text" placeholder="Describe the issue…" rows="3"></textarea>',
' <div class="gra-fb-field-label">Your email (optional)</div>',
' <input class="gra-composer-input gra-fb-email-input" id="gra-fb-email" type="email" placeholder="you@example.com" autocomplete="email">',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-fb-cancel">Cancel</button>',
' <button class="gra-btn-submit" id="gra-fb-submit" disabled>Send</button>',
' </div>',
' <div class="gra-fb-status" id="gra-fb-status"></div>',
'</div>',
].join('') );
$('body').append($fbComposer);
// ── Note composer ────────────────────────────────────────────────
$ntComposer = $( [
'<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">',
' <div class="gra-composer-user">',
' <div class="gra-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>',
' <div class="gra-composer-uname">' + esc(currentUser || 'Note') + '</div>',
' </div>',
' <textarea class="gra-composer-input" id="gra-nt-input" placeholder="Write a note…" rows="3"></textarea>',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>',
' <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>',
' </div>',
'</div>',
].join('') );
$('body').append($ntComposer);
// ── Bookmark composer ─────────────────────────────────────────────
$bmComposer = $( [
'<div class="gra-bm-composer" id="gra-bm-composer" role="dialog" aria-label="Bookmark">',
' <div class="gra-bm-composer-label">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>',
' Save bookmark',
' </div>',
' <input class="gra-composer-input" id="gra-bm-input" type="text" placeholder="Name this bookmark…" autocomplete="off">',
' <div class="gra-composer-actions">',
' <button class="gra-btn-cancel" id="gra-bm-cancel">Cancel</button>',
' <button class="gra-btn-submit" id="gra-bm-submit">Save</button>',
' </div>',
'</div>',
].join('') );
$('body').append($bmComposer);
// ── Right panel ───────────────────────────────────────────────────
$panel = $( [
'<div id="gra-panel" role="complementary" aria-label="Notes">',
' <div id="gra-panel-head">',
' <div id="gra-panel-title"></div>',
' <button id="gra-panel-close" title="Close">✕</button>',
' </div>',
' <div id="gra-tabs">',
' <button class="gra-tab gra-tab-active" id="gra-tab-notes">',
' <span class="gra-icon gra-icon-note" aria-hidden="true"></span> Notes',
' </button>',
' <button class="gra-tab" id="gra-tab-bookmarks">',
' <span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span> Bookmarks',
' </button>',
' </div>',
' <div id="gra-panel-body">',
' <div class="gra-pane gra-pane-active" id="gra-pane-notes"></div>',
' <div class="gra-pane" id="gra-pane-bookmarks"></div>',
' </div>',
'</div>',
].join('') );
$('body').append($panel);
$backdrop = $('<div id="gra-backdrop" aria-hidden="true"></div>');
$('body').append($backdrop);
var $toggle = $( [
'<button id="gra-toggle" aria-label="Notes">',
' <span class="gra-icon gra-icon-note" id="gra-toggle-icon" aria-hidden="true"></span>',
' <span id="gra-toggle-badge" aria-live="polite"></span>',
'</button>',
].join('') );
$('body').append($toggle);
$toggle.on('click', function() {
$panel.hasClass('gra-panel-open') ? closePanel() : openPanel(_activeTab);
});
$('#gra-panel-title').text(pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30));
$tabNotes = $('#gra-tab-notes');
$tabBookmarks = $('#gra-tab-bookmarks');
$paneNotes = $('#gra-pane-notes');
$paneBookmarks= $('#gra-pane-bookmarks');
$ntInput = $('#gra-nt-input');
$ntSubmit = $('#gra-nt-submit');
$bmInput = $('#gra-bm-input');
$bmSubmit = $('#gra-bm-submit');
$fbIssueType = $('#gra-fb-issue');
$fbText = $('#gra-fb-text');
$fbEmail = $('#gra-fb-email');
$fbSubmit = $('#gra-fb-submit');
$fbQuote = $('#gra-fb-quote');
}
// ════════════════════════════════════════════════════════════════════
// SELECTION
// ════════════════════════════════════════════════════════════════════
function captureSelection() {
var sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return false;
var range = sel.getRangeAt(0);
var text = sel.toString().trim();
if (!text || text.length < 2) return false;
var contentEl = document.querySelector(CONTENT_SEL);
if (!contentEl) return false;
var start = range.commonAncestorContainer;
if (start.nodeType === 3) start = start.parentNode;
if (!contentEl.contains(start)) return false;
_selText = text;
_selRange = range.cloneRange();
_selRect = range.getBoundingClientRect();
return true;
}
function tryShowActions() {
if ($fbComposer.hasClass('gra-composer-visible')) return;
if ($ntComposer.hasClass('gra-composer-visible')) return;
if ($bmComposer.hasClass('gra-composer-visible')) return;
if (!captureSelection()) {
hideActions();
return;
}
_fabSelVer = _selVersion;
if (_mobile) {
showMobileBar();
} else {
showFab(_selRect);
}
}
// ════════════════════════════════════════════════════════════════════
// DESKTOP FAB
// ════════════════════════════════════════════════════════════════════
function showFab(rect) {
if (_mobile) return; // mobile uses bottom bar
if (!rect) return;
var fabW = 46, fabH = 126;
var top = rect.top + (rect.height/2) - (fabH/2);
var 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'); }
// ════════════════════════════════════════════════════════════════════
// MOBILE BOTTOM BAR
// ════════════════════════════════════════════════════════════════════
function showMobileBar() {
$mobileBar.addClass('gra-mobile-bar-visible');
}
function hideMobileBar() {
$mobileBar.removeClass('gra-mobile-bar-visible');
}
function hideActions() {
hideFab();
hideMobileBar();
}
// Debug helper — remove after testing
window._graDebug = function() {
console.log('mobile:', _mobile, 'selText:', _selText, 'selRange:', _selRange);
console.log('bar visible:', document.getElementById('gra-mobile-bar') &&
document.getElementById('gra-mobile-bar').classList.contains('gra-mobile-bar-visible'));
};
// ════════════════════════════════════════════════════════════════════
// COMPOSER POSITIONING (desktop note/bookmark only)
// ════════════════════════════════════════════════════════════════════
function positionComposer($el) {
if (isMobile()) {
// On mobile always center — don't anchor to selection
$el.css({top: '', left: '', transform: ''});
return;
}
if (!_selRect) return;
var W = 340;
var top = _selRect.bottom + 8;
var left = _selRect.left;
if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
left = Math.max(left, 8);
if (top + 280 > window.innerHeight) top = _selRect.top - 290;
top = Math.max(top, 8);
$el.css({top: top+'px', left: left+'px'});
}
// ════════════════════════════════════════════════════════════════════
// WRAP SELECTION
// ════════════════════════════════════════════════════════════════════
function wrapSelection(id, cssClass) {
if (!_selRange) return null;
var range = _selRange;
_selRange = null;
try {
var span = document.createElement('span');
span.className = cssClass;
span.setAttribute('data-gra-id', id);
range.surroundContents(span);
return span;
} catch(e) {
try {
var frag = range.extractContents();
var sp2 = document.createElement('span');
sp2.className = cssClass;
sp2.setAttribute('data-gra-id', id);
sp2.appendChild(frag);
range.insertNode(sp2);
return sp2;
} catch(e2) { return null; }
}
}
// ════════════════════════════════════════════════════════════════════
// FEEDBACK FLOW
// ════════════════════════════════════════════════════════════════════
function openFeedbackComposer() {
hideActions();
$fbQuote.text(_selText.slice(0,200) + (_selText.length > 200 ? '…' : ''));
$fbIssueType.val('');
$fbText.val('');
$fbSubmit.prop('disabled', true);
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
if (currentUserEmail) $fbEmail.val(currentUserEmail);
else $fbEmail.val('');
$fbComposer.css({top:'', left:'', transform:''});
$fbComposer.addClass('gra-composer-visible');
$backdrop.addClass('gra-backdrop-visible');
setTimeout(function(){ $fbIssueType.focus(); }, isMobile() ? 300 : 0);
}
function closeFeedbackComposer() {
$fbComposer.removeClass('gra-composer-visible');
$backdrop.removeClass('gra-backdrop-visible');
_selRange = null; _selText = ''; _selRect = null;
}
function submitFeedback() {
var issueType = $fbIssueType.val();
var details = $fbText.val().trim();
var email = $fbEmail.val().trim();
var quote = $fbQuote.text();
if (!issueType) return;
$fbSubmit.prop('disabled', true).text('Sending…');
$('#gra-fb-status').text('').removeClass('gra-fb-ok gra-fb-err');
var issueLabels = {
wrong_text: 'Formatting error', reference_issue: 'Reference issue',
spelling_mistake: 'Spelling mistake', other: 'Other'
};
var payload = new FormData();
payload.append('issue_type', issueLabels[issueType] || issueType);
payload.append('page', pageTitle.replace(/_/g,' '));
payload.append('url', window.location.href);
payload.append('selected_text', quote);
payload.append('details', details || '');
payload.append('user_email', email || currentUserEmail || '');
payload.append('wiki_user', currentUser || 'anonymous');
fetch('/feedback.php', {method:'POST', body:payload})
.then(function(r){ return r.json(); })
.then(function(data){
if (data && data.ok) showFeedbackSuccess();
else showFeedbackError(data && data.error ? data.error : 'Could not send.');
})
.catch(function(){ showFeedbackError('Network error. Please try again.'); });
}
function showFeedbackSuccess() {
$fbSubmit.prop('disabled', false).text('Send');
$('#gra-fb-status').text('✓ Feedback sent. Thank you!').addClass('gra-fb-ok');
setTimeout(closeFeedbackComposer, 2500);
}
function showFeedbackError(msg) {
$fbSubmit.prop('disabled', false).text('Send');
$('#gra-fb-status').text('✗ ' + msg).addClass('gra-fb-err');
}
// ════════════════════════════════════════════════════════════════════
// NOTE FLOW
// ════════════════════════════════════════════════════════════════════
function openNoteComposer() {
hideActions();
// Centered modal on all devices
$ntComposer.css({ top: '', left: '', transform: '' });
$ntComposer.addClass('gra-composer-visible');
$backdrop.addClass('gra-backdrop-visible');
setTimeout(function(){ $ntInput.focus(); }, isMobile() ? 300 : 0);
}
function closeNoteComposer() {
$ntComposer.removeClass('gra-composer-visible');
$backdrop.removeClass('gra-backdrop-visible');
$ntInput.val('');
$ntSubmit.prop('disabled', true);
_selRange = null; _selText = ''; _selRect = null;
}
function submitNote() {
var text = $ntInput.val().trim();
if (!text) return;
var id = uid();
var ts = nowIso();
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
var span = wrapSelection(id, 'gra-note-highlight');
if (span) span.setAttribute('data-gra-quote', quote);
_notes.push({id:id, ts:ts, quote:quote, text:text});
persistNotes();
persistNoteHighlight(id, quote);
renderNoteCards();
closeNoteComposer();
openPanel('notes');
}
function persistNotes() {
try { localStorage.setItem(NT_LS_KEY, JSON.stringify(_notes)); } catch(e){}
}
function loadNotes() {
try { var r = localStorage.getItem(NT_LS_KEY); if (r) _notes = JSON.parse(r)||[]; } catch(e){}
}
// ════════════════════════════════════════════════════════════════════
// BOOKMARK FLOW
// ════════════════════════════════════════════════════════════════════
function openBookmarkComposer() {
hideActions();
// Centered modal — same as feedback, no position anchoring
$bmComposer.css({ top: '', left: '', transform: '' });
$bmComposer.addClass('gra-composer-visible');
$backdrop.addClass('gra-backdrop-visible');
setTimeout(function(){ $bmInput.focus(); }, isMobile() ? 300 : 0);
}
function closeBookmarkComposer() {
$bmComposer.removeClass('gra-composer-visible');
$backdrop.removeClass('gra-backdrop-visible');
$bmInput.val('');
_selRange = null; _selText = ''; _selRect = null;
}
function submitBookmark() {
var name = $bmInput.val().trim() || ('Bookmark ' + (_bookmarks.length+1));
var id = uid();
var quote = _selText.slice(0,120) + (_selText.length > 120 ? '…' : '');
var span = wrapSelection(id, 'gra-bookmark-highlight');
if (span) { span.setAttribute('data-gra-id', id); span.setAttribute('data-gra-name', name); }
_bookmarks.push({id:id, name:name, quote:quote, ts:nowIso()});
persistBookmarks();
renderBookmarkCards();
closeBookmarkComposer();
openPanel('bookmarks');
}
function deleteBookmark(id) {
_bookmarks = _bookmarks.filter(function(b){ return b.id !== id; });
var span = document.querySelector('[data-gra-id="'+id+'"].gra-bookmark-highlight');
if (span) {
var p = span.parentNode;
while (span.firstChild) p.insertBefore(span.firstChild, span);
p.removeChild(span);
}
persistBookmarks(); renderBookmarkCards();
}
function persistBookmarks() {
try { localStorage.setItem(BM_LS_KEY, JSON.stringify(_bookmarks)); } catch(e){}
}
function loadBookmarks() {
try { var r = localStorage.getItem(BM_LS_KEY); if (r) _bookmarks = JSON.parse(r)||[]; } catch(e){}
}
// ════════════════════════════════════════════════════════════════════
// PANEL
// ════════════════════════════════════════════════════════════════════
function openPanel(tab) {
_activeTab = tab || _activeTab;
switchTab(_activeTab);
$panel.addClass('gra-panel-open');
$backdrop.addClass('gra-backdrop-visible');
}
function closePanel() {
$panel.removeClass('gra-panel-open');
$backdrop.removeClass('gra-backdrop-visible');
}
function switchTab(tab) {
_activeTab = tab;
$tabNotes.toggleClass('gra-tab-active', tab==='notes');
$tabBookmarks.toggleClass('gra-tab-active', tab==='bookmarks');
$paneNotes.toggleClass('gra-pane-active', tab==='notes');
$paneBookmarks.toggleClass('gra-pane-active', tab==='bookmarks');
if (tab==='notes') renderNoteCards();
else renderBookmarkCards();
}
// ════════════════════════════════════════════════════════════════════
// RENDER CARDS
// ════════════════════════════════════════════════════════════════════
function renderNoteCards() {
if (!_notes.length) {
$paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and tap ✎ to add one.</div>');
return;
}
var html = '';
_notes.slice().reverse().forEach(function(n){
html += '<div class="gra-note-card" data-gra-id="'+esc(n.id)+'">'
+ '<div class="gra-card-header">'
+ '<div class="gra-avatar">✎</div>'
+ '<div class="gra-card-meta">'
+ (n.ts ? '<div class="gra-card-ts">'+esc(fmtTs(n.ts))+'</div>' : '')
+ '</div>'
+ '<button class="gra-note-del" data-del-id="'+esc(n.id)+'" title="Delete">×</button>'
+ '</div>'
+ (n.quote ? '<div class="gra-card-quote">'+esc(n.quote)+'</div>' : '')
+ '<div class="gra-card-text">'+esc(n.text)+'</div>'
+ '</div>';
});
$paneNotes.html(html);
}
function deleteNote(id) {
_notes = _notes.filter(function(n){ return n.id !== id; });
var span = document.querySelector('[data-gra-id="'+id+'"].gra-note-highlight');
if (span) {
var p = span.parentNode;
while (span.firstChild) p.insertBefore(span.firstChild, span);
p.removeChild(span);
}
try {
var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
s = s.filter(function(h){ return h.id !== id; });
localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
} catch(e){}
persistNotes(); renderNoteCards();
}
function renderBookmarkCards() {
if (!_bookmarks.length) {
$paneBookmarks.html('<div class="gra-empty-state">No bookmarks yet.<br>Select text and tap 🔖 to save one.</div>');
return;
}
var html = '';
_bookmarks.slice().reverse().forEach(function(b){
html += '<div class="gra-bookmark-card" data-gra-id="'+esc(b.id)+'">'
+ '<span class="gra-icon gra-icon-bookmark" aria-hidden="true"></span>'
+ '<div class="gra-bookmark-info">'
+ '<div class="gra-bookmark-name">'+esc(b.name)+'</div>'
+ (b.quote ? '<div class="gra-bookmark-quote">'+esc(b.quote)+'</div>' : '')
+ '</div>'
+ '<button class="gra-bookmark-del" data-del-id="'+esc(b.id)+'" title="Remove">×</button>'
+ '</div>';
});
$paneBookmarks.html(html);
}
// ════════════════════════════════════════════════════════════════════
// SCROLL TO HIGHLIGHT
// ════════════════════════════════════════════════════════════════════
function scrollToHighlight(id) {
var el = document.querySelector('[data-gra-id="'+id+'"]');
if (!el) return;
el.scrollIntoView({behavior:'smooth', block:'center'});
el.classList.add('gra-hl-active');
setTimeout(function(){ el.classList.remove('gra-hl-active'); }, 2000);
}
// ════════════════════════════════════════════════════════════════════
// EVENT WIRING
// ════════════════════════════════════════════════════════════════════
function wireEvents() {
// ── Desktop mouseup ───────────────────────────────────────────
$(document).on('mouseup', function(e){
if (e.button !== 0) return;
if (_mobile) return; // mobile uses selectionchange
setTimeout(tryShowActions, 20);
});
// ── Mobile: selectionchange (debounced 600ms) ─────────────────
// We wait longer than v3 so the browser's own copy menu has time
// to appear first — user can still copy, THEN our bar slides up.
var _selTimer = null;
document.addEventListener('selectionchange', function() {
_selVersion++;
clearTimeout(_selTimer);
var v = _selVersion;
_selTimer = setTimeout(function(){
if (v !== _selVersion) return;
if (_fabSelVer === v) return;
tryShowActions();
}, 600); // 600ms — after browser copy menu appears
});
// ── Click outside → hide actions ──────────────────────────────
$(document).on('mousedown touchstart', function(e){
var t = e.target;
if ($fab[0] && $fab[0].contains(t)) return;
if ($mobileBar[0] && $mobileBar[0].contains(t)) return;
if ($fbComposer[0] && $fbComposer[0].contains(t)) return;
if ($ntComposer[0] && $ntComposer[0].contains(t)) return;
if ($bmComposer[0] && $bmComposer[0].contains(t)) return;
hideActions();
});
// ── Desktop FAB buttons ───────────────────────────────────────
$('#gra-fab-feedback').on('click', function(e){
e.preventDefault(); e.stopPropagation();
if (!_selRange && !captureSelection()) return;
openFeedbackComposer();
});
$('#gra-fab-note').on('click', function(e){
e.preventDefault(); e.stopPropagation();
if (!_selRange && !captureSelection()) return;
openNoteComposer();
});
$('#gra-fab-bookmark').on('click', function(e){
e.preventDefault(); e.stopPropagation();
if (!_selRange && !captureSelection()) return;
openBookmarkComposer();
});
// ── Mobile bottom bar buttons ─────────────────────────────────
$('#gra-mob-feedback').on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
hideMobileBar();
if (!_selRange && !captureSelection()) return;
openFeedbackComposer();
});
$('#gra-mob-note').on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
hideMobileBar();
if (!_selRange && !captureSelection()) return;
openNoteComposer();
});
$('#gra-mob-bookmark').on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
hideMobileBar();
if (!_selRange && !captureSelection()) return;
openBookmarkComposer();
});
$('#gra-mob-dismiss').on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
hideMobileBar();
// Clear selection visually
if (window.getSelection) window.getSelection().removeAllRanges();
});
// ── Feedback composer ─────────────────────────────────────────
$fbIssueType.on('change', function(){
$fbSubmit.prop('disabled', !$(this).val());
});
$('#gra-fb-cancel, #gra-fb-close').on('click', function(){
closeFeedbackComposer();
});
$fbSubmit.on('click', submitFeedback);
$fbText.on('keydown', function(e){
if (e.key==='Escape') closeFeedbackComposer();
});
// ── Note composer ─────────────────────────────────────────────
$ntInput.on('input', function(){
$ntSubmit.prop('disabled', !$(this).val().trim());
});
$('#gra-nt-cancel').on('click', closeNoteComposer);
$ntSubmit.on('click', submitNote);
$ntInput.on('keydown', function(e){
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote();
if (e.key==='Escape') closeNoteComposer();
});
// ── Bookmark composer ─────────────────────────────────────────
$('#gra-bm-cancel').on('click', closeBookmarkComposer);
$bmSubmit.on('click', submitBookmark);
$bmInput.on('keydown', function(e){
if (e.key==='Enter') submitBookmark();
if (e.key==='Escape') closeBookmarkComposer();
});
// ── Panel ─────────────────────────────────────────────────────
$('#gra-panel-close').on('click', closePanel);
$backdrop.on('click touchend', function(e){
e.preventDefault();
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
else closePanel();
});
$tabNotes.on('click', function(){ switchTab('notes'); });
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
$paneNotes.on('click', '.gra-note-card', function(e){
if ($(e.target).hasClass('gra-note-del')) return;
var id = $(this).attr('data-gra-id');
if (id) { closePanel(); scrollToHighlight(id); }
});
$paneNotes.on('click', '.gra-note-del', function(e){
e.stopPropagation();
var id = $(this).attr('data-del-id');
if (id) deleteNote(id);
});
$paneBookmarks.on('click', '.gra-bookmark-card', function(e){
if ($(e.target).hasClass('gra-bookmark-del')) return;
var id = $(this).attr('data-gra-id');
if (id) { closePanel(); scrollToHighlight(id); }
});
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){
e.stopPropagation();
var id = $(this).attr('data-del-id');
if (id) deleteBookmark(id);
});
$(CONTENT_SEL).on('click', '.gra-note-highlight', function(){
var id = $(this).attr('data-gra-id');
openPanel('notes');
setTimeout(function(){
var $card = $paneNotes.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);
});
$(CONTENT_SEL).on('click', '.gra-bookmark-highlight', function(){
var id = $(this).attr('data-gra-id');
openPanel('bookmarks');
setTimeout(function(){
var $card = $paneBookmarks.find('[data-gra-id="'+id+'"]');
if ($card.length) $card[0].scrollIntoView({behavior:'smooth', block:'nearest'});
}, 100);
});
$(document).on('keydown', function(e){
if (e.key !== 'Escape') return;
if ($fbComposer.hasClass('gra-composer-visible')) closeFeedbackComposer();
else if ($ntComposer.hasClass('gra-composer-visible')) closeNoteComposer();
else if ($bmComposer.hasClass('gra-composer-visible')) closeBookmarkComposer();
else closePanel();
});
}
// ════════════════════════════════════════════════════════════════════
// RESTORE HIGHLIGHTS
// ════════════════════════════════════════════════════════════════════
function persistNoteHighlight(id, quote) {
try {
var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]');
s = s.filter(function(h){ return h.id !== id; });
s.push({id:id, quote:quote});
localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s));
} catch(e){}
}
function restoreNoteHighlights() {
var s = [];
try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){}
s.forEach(function(h){
if (!h.quote || !h.id) return;
if (document.querySelector('[data-gra-id="'+h.id+'"].gra-note-highlight')) return;
var needle = h.quote.replace(/…$/,'').trim().slice(0,80);
if (!needle) return;
var range = findTextInContent(document.querySelector(CONTENT_SEL), needle);
if (range) {
var sp = document.createElement('span');
sp.className = 'gra-note-highlight';
sp.setAttribute('data-gra-id', h.id);
try { range.surroundContents(sp); } catch(e){}
}
});
}
function restoreBookmarkHighlights() {
_bookmarks.forEach(function(b){
if (!b.quote) return;
if (document.querySelector('[data-gra-id="'+b.id+'"].gra-bookmark-highlight')) return;
var needle = b.quote.replace(/…$/,'').trim().slice(0,60);
if (!needle) return;
var found = findTextInContent(document.querySelector(CONTENT_SEL), needle);
if (found) {
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) {
if (!root) return null;
var text = root.textContent || '';
var idx = text.indexOf(needle);
if (idx < 0) return null;
var iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
var pos = 0, node, startNode, startOffset, endNode, endOffset;
while ((node = iter.nextNode())) {
var len = node.nodeValue.length;
if (!startNode && pos + len > idx) { startNode = node; startOffset = idx - pos; }
var endIdx = idx + needle.length;
if (startNode && pos + len >= endIdx) { endNode = node; endOffset = endIdx - pos; break; }
pos += len;
}
if (!startNode || !endNode) return null;
var r = document.createRange();
r.setStart(startNode, startOffset);
r.setEnd(endNode, endOffset);
return r;
}
// ════════════════════════════════════════════════════════════════════
// BOOT
// ════════════════════════════════════════════════════════════════════
$(function() {
// Detect mobile once on load
_mobile = window.innerWidth < 768 || 'ontouchstart' in window;
window.addEventListener('resize', function(){
_mobile = window.innerWidth < 768 || 'ontouchstart' in window;
});
buildDom();
wireEvents();
loadNotes();
loadBookmarks();
restoreNoteHighlights();
restoreBookmarkHighlights();
});
}() );