MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 109: | Line 109: | ||
' <div class="gra-composer-header">', | ' <div class="gra-composer-header">', | ||
' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | ' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | ||
' <strong> | ' <strong>Feedback</strong>', | ||
' <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>', | ' <button class="gra-btn-x" id="gra-fb-close" title="Close">✕</button>', | ||
' </div>', | ' </div>', | ||
Revision as of 09:23, 12 May 2026
/**
* gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback (v3)
* ══════════════════════════════════════════════════════════════════════
*
* CHANGES FROM v2
* ────────────────
* • "Comments" renamed to "Notes" throughout. Notes are user-local
* (localStorage only, not saved to wiki Talk pages).
* • Comment icon replaced with notes.svg (same path convention).
* • New "Feedback" button (flag icon) replaces the old comment FAB button.
* Shows a popup with:
* - Highlighted text (read-only)
* - Issue type: Wrong text / Reference issue / Spelling mistake / Other
* - Free-text area
* - Email field for user's address
* Submits via EmailJS / fetch to admin@anandamakaranda.com.
* NOT stored anywhere on the wiki. NOT shown in the panel.
* • Notes panel tab uses notes.svg icon.
* ══════════════════════════════════════════════════════════════════════
*/
/* global mw, $ */
( function () {
'use strict';
// ── Configuration ────────────────────────────────────────────────────
var ADMIN_EMAIL = 'admin@anandamakaranda.com';
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() : '?';
if ( window.mw ) {
var ns = mw.config.get( 'wgNamespaceNumber' );
if ( ns < 0 ) return;
}
// ── State ────────────────────────────────────────────────────────────
var _selRange = null;
var _selText = '';
var _selRect = null;
var _notes = []; // was _comments, now local only
var _bookmarks = [];
var _activeTab = 'notes';
var _selVersion = 0;
var _fabSelVer = -1;
// ── 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 window.innerWidth < 768 || 'ontouchstart' in window; }
// ── DOM references ───────────────────────────────────────────────────
var $fab, $panel, $backdrop;
var $ntComposer, $ntInput, $ntSubmit;
var $bmComposer, $bmInput, $bmSubmit;
var $fbComposer, $fbIssueType, $fbText, $fbEmail, $fbSubmit, $fbQuote;
var $tabNotes, $tabBookmarks;
var $paneNotes, $paneBookmarks;
// ════════════════════════════════════════════════════════════════════
// DOM BUILDER
// ════════════════════════════════════════════════════════════════════
function buildDom() {
// ── FAB strip — Feedback (flag) + Note (notes) + Bookmark ───────
$fab = $( [
'<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">',
// Feedback button — flag icon
' <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>',
// Note button — notes icon
' <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>',
// Bookmark 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 );
// ── Feedback composer (popup) ─────────────────────────────────────
$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">Wrong text</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" id="gra-nt-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 );
// Toggle button (bottom-right, persistent) — notes icon
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 );
} );
// Cache refs
$( '#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 tryShowFab() {
if ( $fbComposer.hasClass('gra-composer-visible') ) return;
if ( $ntComposer.hasClass('gra-composer-visible') ) return;
if ( $bmComposer.hasClass('gra-composer-visible') ) return;
if ( captureSelection() ) {
_fabSelVer = _selVersion;
showFab( _selRect );
} else {
hideFab();
}
}
// ════════════════════════════════════════════════════════════════════
// FAB POSITIONING
// ════════════════════════════════════════════════════════════════════
function showFab( rect ) {
if ( !rect ) return;
var fabW = 46, fabH = 126; // 3 buttons now
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'); }
// ════════════════════════════════════════════════════════════════════
// COMPOSER POSITIONING
// ════════════════════════════════════════════════════════════════════
function positionComposer( $el ) {
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 );
if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2;
$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 (sends email, not stored on wiki)
// ════════════════════════════════════════════════════════════════════
function openFeedbackComposer() {
hideFab();
// Show selected text in the popup
$fbQuote.text( _selText.slice(0, 200) + (_selText.length > 200 ? '…' : '') );
$fbIssueType.val('');
$fbText.val('');
$fbEmail.val('');
$fbSubmit.prop('disabled', true);
$( '#gra-fb-status' ).text('').removeClass('gra-fb-ok gra-fb-err');
positionComposer( $fbComposer );
$fbComposer.addClass('gra-composer-visible');
setTimeout( function() { $fbIssueType.focus(); }, isMobile() ? 300 : 0 );
}
function closeFeedbackComposer() {
$fbComposer.removeClass('gra-composer-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: 'Wrong text',
reference_issue: 'Reference issue',
spelling_mistake: 'Spelling mistake',
other: 'Other'
};
var body = [
'Page: ' + pageTitle.replace(/_/g,' '),
'URL: ' + window.location.href,
'Issue type: ' + ( issueLabels[issueType] || issueType ),
'Selected text: ' + quote,
'Details: ' + ( details || '(none)' ),
'User email: ' + ( email || '(not provided)' ),
'User: ' + ( currentUser || 'anonymous' ),
'Timestamp: ' + new Date().toISOString(),
].join('\n');
// ── Send via mailto as primary (works without backend) ────────
// Opens the user's mail client silently in a hidden iframe so it
// doesn't navigate away. Falls back gracefully if blocked.
var subject = encodeURIComponent(
'[Grantha Feedback] ' + ( issueLabels[issueType] || issueType )
+ ' — ' + pageTitle.replace(/_/g,' ').slice(0,40)
);
var mailBody = encodeURIComponent(body);
var mailtoUrl = 'mailto:' + ADMIN_EMAIL
+ '?subject=' + subject
+ '&body=' + mailBody;
// Try MW EmailUser API first (server-side, cleaner)
if ( window.mw && mw.config.get('wgUserName') ) {
var api = new mw.Api();
api.postWithEditToken({
action: 'emailuser',
target: 'Chandrashekars',
subject: '[Grantha Feedback] ' + ( issueLabels[issueType] || issueType )
+ ' — ' + pageTitle.replace(/_/g,' ').slice(0,40),
text: body,
ccme: 0,
}).then(function() {
showFeedbackSuccess();
}).catch(function() {
// Fall back to mailto
window.open( mailtoUrl, '_blank' );
showFeedbackSuccess();
});
} else {
// Anonymous: open mailto
window.open( mailtoUrl, '_blank' );
showFeedbackSuccess();
}
}
function showFeedbackSuccess() {
$fbSubmit.text('Send');
$( '#gra-fb-status' )
.text('✓ Feedback sent. Thank you!')
.addClass('gra-fb-ok');
setTimeout( closeFeedbackComposer, 2000 );
}
// ════════════════════════════════════════════════════════════════════
// NOTE FLOW (local only — localStorage, not wiki)
// ════════════════════════════════════════════════════════════════════
function openNoteComposer() {
hideFab();
positionComposer( $ntComposer );
$ntComposer.addClass('gra-composer-visible');
setTimeout( function() { $ntInput.focus(); }, isMobile() ? 300 : 0 );
}
function closeNoteComposer() {
$ntComposer.removeClass('gra-composer-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);
var entry = { id:id, ts:ts, quote:quote, text:text };
_notes.push( entry );
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() {
hideFab();
positionComposer( $bmComposer );
$bmComposer.addClass('gra-composer-visible');
setTimeout( function() { $bmInput.focus(); }, isMobile() ? 300 : 0 );
}
function closeBookmarkComposer() {
$bmComposer.removeClass('gra-composer-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 parent = span.parentNode;
while (span.firstChild) parent.insertBefore(span.firstChild, span);
parent.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 click ✎ 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 parent = span.parentNode;
while (span.firstChild) parent.insertBefore(span.firstChild, span);
parent.removeChild(span);
}
// Remove from highlight persistence
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 click 🔖 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 / keyup ──────────────────────────────────
$( document ).on('mouseup keyup', function(e){
if ( e.type === 'mouseup' && e.button !== 0 ) return;
setTimeout( tryShowFab, 20 );
});
// ── Mobile: selectionchange ───────────────────────────────────
var _selChangeTimer = null;
document.addEventListener('selectionchange', function() {
_selVersion++;
clearTimeout( _selChangeTimer );
var v = _selVersion;
_selChangeTimer = setTimeout(function(){
if ( v !== _selVersion ) return;
if ( _fabSelVer === v ) return;
tryShowFab();
}, 400);
});
// ── Mobile: touchend fallback ─────────────────────────────────
document.addEventListener('touchend', function(e){
if ( $fab[0] && $fab[0].contains(e.target) ) return;
if ( $fbComposer[0] && $fbComposer[0].contains(e.target) ) return;
if ( $ntComposer[0] && $ntComposer[0].contains(e.target) ) return;
if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return;
setTimeout( tryShowFab, 80 );
}, { passive: true });
// ── Click outside → hide FAB ──────────────────────────────────
$( document ).on('mousedown touchstart', function(e){
var t = e.target;
if ( $fab[0] && $fab[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;
hideFab();
});
// ── FAB buttons ───────────────────────────────────────────────
$( '#gra-fab-feedback' ).on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
if ( !_selRange && !captureSelection() ) return;
openFeedbackComposer();
});
$( '#gra-fab-note' ).on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
if ( !_selRange && !captureSelection() ) return;
openNoteComposer();
});
$( '#gra-fab-bookmark' ).on('click touchend', function(e){
e.preventDefault(); e.stopPropagation();
if ( !_selRange && !captureSelection() ) return;
openBookmarkComposer();
});
// ── Feedback composer ─────────────────────────────────────────
$fbIssueType.on('change', function(){
$fbSubmit.prop('disabled', !$( this ).val());
});
$( '#gra-fb-cancel, #gra-fb-close' ).on('click', function(){
closeFeedbackComposer(); hideFab();
});
$fbSubmit.on('click', submitFeedback);
$fbText.on('keydown', function(e){
if (e.key==='Escape') { closeFeedbackComposer(); hideFab(); }
});
// ── Note composer ─────────────────────────────────────────────
$ntInput.on('input', function(){
$ntSubmit.prop('disabled', !$( this ).val().trim());
});
$( '#gra-nt-cancel' ).on('click', function(){ closeNoteComposer(); hideFab(); });
$ntSubmit.on('click', submitNote);
$ntInput.on('keydown', function(e){
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') submitNote();
if (e.key==='Escape') { closeNoteComposer(); hideFab(); }
});
// ── Bookmark composer ─────────────────────────────────────────
$( '#gra-bm-cancel' ).on('click', function(){ closeBookmarkComposer(); hideFab(); });
$bmSubmit.on('click', submitBookmark);
$bmInput.on('keydown', function(e){
if (e.key==='Enter') submitBookmark();
if (e.key==='Escape') { closeBookmarkComposer(); hideFab(); }
});
// ── Panel ─────────────────────────────────────────────────────
$( '#gra-panel-close' ).on('click', closePanel);
$backdrop.on('click touchstart', closePanel);
$tabNotes.on('click', function(){ switchTab('notes'); });
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); });
// Click note card → scroll to highlight
$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); }
});
// Delete note
$paneNotes.on('click', '.gra-note-del', function(e){
e.stopPropagation();
var id = $( this ).attr('data-del-id');
if (id) deleteNote(id);
});
// Click bookmark card → scroll
$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); }
});
// Delete bookmark
$paneBookmarks.on('click', '.gra-bookmark-del', function(e){
e.stopPropagation();
var id = $( this ).attr('data-del-id');
if (id) deleteBookmark(id);
});
// Click note highlight in text → open panel
$( 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);
});
// Escape
$( document ).on('keydown', function(e){
if (e.key !== 'Escape') return;
if ($fbComposer.hasClass('gra-composer-visible')) { closeFeedbackComposer(); hideFab(); }
else if ($ntComposer.hasClass('gra-composer-visible')) { closeNoteComposer(); hideFab(); }
else if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); }
else closePanel();
});
}
// ════════════════════════════════════════════════════════════════════
// RESTORE HIGHLIGHTS on page reload
// ════════════════════════════════════════════════════════════════════
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 () {
buildDom();
wireEvents();
loadNotes();
loadBookmarks();
restoreNoteHighlights();
restoreBookmarkHighlights();
});
}() );