MediaWiki:Gadget-GrAnnotations.js: Difference between revisions
No edit summary |
No edit summary |
||
| Line 1: | Line 1: | ||
/** | /** | ||
* gr_annotations.js — grantha.io inline | * gr_annotations.js — grantha.io inline Notes + Bookmarks + Feedback (v3) | ||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
* | * | ||
* CHANGES FROM | * 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. | ||
* | |||
* | |||
* | |||
* | |||
* | |||
* | |||
* | |||
* ══════════════════════════════════════════════════════════════════════ | * ══════════════════════════════════════════════════════════════════════ | ||
*/ | */ | ||
| Line 52: | Line 25: | ||
// ── Configuration ──────────────────────────────────────────────────── | // ── Configuration ──────────────────────────────────────────────────── | ||
var | var ADMIN_EMAIL = 'admin@anandamakaranda.com'; | ||
var CONTENT_SEL = '#mw-content-text'; | var CONTENT_SEL = '#mw-content-text'; | ||
var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | var BM_LS_KEY = 'grantha_bm_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
var | var NT_LS_KEY = 'grantha_nt_' + ( ( window.mw && mw.config.get( 'wgPageName' ) ) || '' ); | ||
var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | var pageTitle = ( window.mw && mw.config.get( 'wgPageName' ) ) || ''; | ||
var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; | var currentUser = ( window.mw && mw.config.get( 'wgUserName' ) ) || ''; | ||
var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?'; | var userInitial = currentUser ? currentUser.charAt( 0 ).toUpperCase() : '?'; | ||
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; | ||
} | } | ||
| Line 72: | Line 42: | ||
var _selText = ''; | var _selText = ''; | ||
var _selRect = null; | var _selRect = null; | ||
var | var _notes = []; // was _comments, now local only | ||
var _bookmarks = []; | var _bookmarks = []; | ||
var _activeTab = 'notes'; | |||
var _activeTab = ' | var _selVersion = 0; | ||
var _fabSelVer = -1; | |||
var _selVersion = 0; | |||
var _fabSelVer = -1; | |||
// ── Helpers ────────────────────────────────────────────────────────── | // ── Helpers ────────────────────────────────────────────────────────── | ||
| Line 90: | Line 57: | ||
.replace(/>/g,'>').replace(/"/g,'"'); | .replace(/>/g,'>').replace(/"/g,'"'); | ||
} | } | ||
function nowIso() { | function nowIso() { return new Date().toISOString().replace(/\.\d{3}Z$/,'Z'); } | ||
function fmtTs( ts ) { | function fmtTs( ts ) { | ||
try { | try { | ||
| Line 105: | Line 70: | ||
// ── DOM references ─────────────────────────────────────────────────── | // ── DOM references ─────────────────────────────────────────────────── | ||
var $fab, $panel, $backdrop; | var $fab, $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; | ||
var $paneNotes, $paneBookmarks; | |||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 116: | Line 82: | ||
function buildDom() { | function buildDom() { | ||
// ── FAB strip | // ── FAB strip — Feedback (flag) + Note (notes) + Bookmark ─────── | ||
$fab = $( [ | $fab = $( [ | ||
'<div id="gra-fab" role="toolbar" aria-label=" | '<div id="gra-fab" role="toolbar" aria-label="Feedback / Note / Bookmark">', | ||
' <button class="gra-fab-btn" id="gra-fab- | // Feedback button — flag icon | ||
' <span class="gra-icon gra-icon- | ' <button class="gra-fab-btn" id="gra-fab-feedback" type="button" aria-label="Send feedback">', | ||
' <span class="gra-fab-tooltip"> | ' <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>', | ' </button>', | ||
// Bookmark button | |||
' <button class="gra-fab-btn" id="gra-fab-bookmark" type="button" aria-label="Bookmark">', | ' <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-icon gra-icon-bookmark" aria-hidden="true"></span>', | ||
| Line 131: | Line 104: | ||
$( 'body' ).append( $fab ); | $( 'body' ).append( $fab ); | ||
// ── | // ── Feedback composer (popup) ───────────────────────────────────── | ||
// | $fbComposer = $( [ | ||
'<div class="gra-composer" id="gra-fb-composer" role="dialog" aria-label="Send feedback">', | |||
' <div class="gra- | ' <div class="gra-composer-header">', | ||
' | ' <span class="gra-icon gra-icon-feedback" aria-hidden="true"></span>', | ||
' | ' <strong>Report an Issue</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>', | ||
].join(''); | ' <div class="gra-fb-status" id="gra-fb-status"></div>', | ||
'</div>', | |||
].join('') ); | |||
$( 'body' ).append( $fbComposer ); | |||
$ | // ── Note composer ───────────────────────────────────────────────── | ||
'<div class="gra-composer" id="gra- | $ntComposer = $( [ | ||
'<div class="gra-composer" id="gra-nt-composer" role="dialog" aria-label="Add note">', | |||
' <div class="gra-composer-user">', | ' <div class="gra-composer-user">', | ||
' <div class="gra-avatar" id="gra- | ' <div class="gra-avatar" id="gra-nt-avatar">' + esc(currentUser ? userInitial : '✎') + '</div>', | ||
' <div class="gra-composer-uname">' + esc(currentUser || ' | ' <div class="gra-composer-uname">' + esc(currentUser || 'Note') + '</div>', | ||
' </div>', | ' </div>', | ||
' <textarea class="gra-composer-input" id="gra-nt-input"', | |||
' <textarea class="gra-composer-input" id="gra- | ' placeholder="Write a note…" rows="3"></textarea>', | ||
' placeholder="Write a | |||
' <div class="gra-composer-actions">', | ' <div class="gra-composer-actions">', | ||
' <button class="gra-btn-cancel" id="gra- | ' <button class="gra-btn-cancel" id="gra-nt-cancel">Cancel</button>', | ||
' <button class="gra-btn-submit" id="gra- | ' <button class="gra-btn-submit" id="gra-nt-submit" disabled>Save Note</button>', | ||
' </div>', | ' </div>', | ||
'</div>', | '</div>', | ||
].join('') ); | ].join('') ); | ||
$( 'body' ).append( $ | $( 'body' ).append( $ntComposer ); | ||
// ── Bookmark composer ───────────────────────────────────────────── | // ── Bookmark composer ───────────────────────────────────────────── | ||
| Line 176: | Line 173: | ||
// ── Right panel ─────────────────────────────────────────────────── | // ── Right panel ─────────────────────────────────────────────────── | ||
$panel = $( [ | $panel = $( [ | ||
'<div id="gra-panel" role="complementary" aria-label=" | '<div id="gra-panel" role="complementary" aria-label="Notes">', | ||
' <div id="gra-panel-head">', | ' <div id="gra-panel-head">', | ||
' <div id="gra-panel-title"></div>', | ' <div id="gra-panel-title"></div>', | ||
| Line 182: | Line 179: | ||
' </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">', | ||
| Line 190: | Line 187: | ||
' </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" id="gra-pane-bookmarks"></div>', | ' <div class="gra-pane" id="gra-pane-bookmarks"></div>', | ||
' </div>', | ' </div>', | ||
| Line 200: | Line 197: | ||
$( 'body' ).append( $backdrop ); | $( 'body' ).append( $backdrop ); | ||
// Toggle | // Toggle button (bottom-right, persistent) — notes icon | ||
var $toggle = $( [ | var $toggle = $( [ | ||
'<button id="gra-toggle" aria-label=" | '<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" aria-live="polite"></span>', | ' <span id="gra-toggle-badge" aria-live="polite"></span>', | ||
'</button>', | '</button>', | ||
| Line 212: | Line 209: | ||
} ); | } ); | ||
// Cache | // Cache refs | ||
$( '#gra-panel-title' ) | $( '#gra-panel-title' ) | ||
.text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) ); | .text( pageTitle.replace(/_/g,' ').split('/')[0].slice(0,30) ); | ||
$ | $tabNotes = $( '#gra-tab-notes' ); | ||
$tabBookmarks = $( '#gra-tab-bookmarks' ); | $tabBookmarks = $( '#gra-tab-bookmarks' ); | ||
$ | $paneNotes = $( '#gra-pane-notes' ); | ||
$paneBookmarks= $( '#gra-pane-bookmarks' ); | $paneBookmarks= $( '#gra-pane-bookmarks' ); | ||
$ | $ntInput = $( '#gra-nt-input' ); | ||
$ | $ntSubmit = $( '#gra-nt-submit' ); | ||
$bmInput = $( '#gra-bm-input' ); | $bmInput = $( '#gra-bm-input' ); | ||
$bmSubmit = $( '#gra-bm-submit' ); | $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 | // SELECTION | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 236: | Line 237: | ||
var text = sel.toString().trim(); | var text = sel.toString().trim(); | ||
if ( !text || text.length < 2 ) return false; | if ( !text || text.length < 2 ) return false; | ||
var contentEl = document.querySelector( CONTENT_SEL ); | var contentEl = document.querySelector( CONTENT_SEL ); | ||
if ( !contentEl ) return false; | if ( !contentEl ) return false; | ||
| Line 242: | Line 242: | ||
if ( start.nodeType === 3 ) start = start.parentNode; | if ( start.nodeType === 3 ) start = start.parentNode; | ||
if ( !contentEl.contains( start ) ) return false; | if ( !contentEl.contains( start ) ) return false; | ||
_selText = text; | _selText = text; | ||
_selRange = range.cloneRange(); | _selRange = range.cloneRange(); | ||
| Line 250: | Line 249: | ||
function tryShowFab() { | function tryShowFab() { | ||
if ( $ | if ( $fbComposer.hasClass('gra-composer-visible') ) return; | ||
if ( $bmComposer.hasClass('gra-composer-visible') | if ( $ntComposer.hasClass('gra-composer-visible') ) return; | ||
if ( $bmComposer.hasClass('gra-composer-visible') ) return; | |||
if ( captureSelection() ) { | if ( captureSelection() ) { | ||
_fabSelVer = _selVersion; | _fabSelVer = _selVersion; | ||
| Line 266: | Line 266: | ||
function showFab( rect ) { | function showFab( rect ) { | ||
if ( !rect ) return; | if ( !rect ) return; | ||
var fabW = 46, fabH = | var fabW = 46, fabH = 126; // 3 buttons now | ||
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; | ||
| Line 283: | Line 283: | ||
function positionComposer( $el ) { | function positionComposer( $el ) { | ||
if ( !_selRect ) return; | if ( !_selRect ) return; | ||
var W | 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; | if ( left + W > window.innerWidth - 8 ) left = window.innerWidth - W - 8; | ||
left = Math.max( left, 8 ); | left = Math.max( left, 8 ); | ||
if ( top + | if ( top + 280 > window.innerHeight ) top = _selRect.top - 290; | ||
top = Math.max( top, 8 ); | top = Math.max( top, 8 ); | ||
if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2; | if ( window.innerWidth < 400 ) left = ( window.innerWidth - W ) / 2; | ||
$el.css({ top: top + 'px', left: left + 'px' }); | $el.css({ top: top + 'px', left: left + 'px' }); | ||
| Line 323: | Line 322: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// | // FEEDBACK FLOW (sends email, not stored on wiki) | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function | function openFeedbackComposer() { | ||
hideFab(); | hideFab(); | ||
positionComposer( $ | // Show selected text in the popup | ||
$ | $fbQuote.text( _selText.slice(0, 200) + (_selText.length > 200 ? '…' : '') ); | ||
$fbIssueType.val(''); | |||
setTimeout( function() { $ | $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 | function closeFeedbackComposer() { | ||
$ | $fbComposer.removeClass('gra-composer-visible'); | ||
_selRange = null; _selText = ''; _selRect = null; | _selRange = null; _selText = ''; _selRect = null; | ||
} | } | ||
function | function submitFeedback() { | ||
var | var issueType = $fbIssueType.val(); | ||
if ( !text ) | 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 | 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'); | |||
var | // ── 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; | |||
var | // 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 | function closeNoteComposer() { | ||
$ntComposer.removeClass('gra-composer-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); | |||
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 | function loadNotes() { | ||
try { | |||
var r = localStorage.getItem( NT_LS_KEY ); | |||
var | if (r) _notes = JSON.parse(r) || []; | ||
} catch(e){} | |||
} | } | ||
| Line 520: | Line 527: | ||
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(); | else renderBookmarkCards(); | ||
} | } | ||
| Line 532: | Line 539: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function | function renderNoteCards() { | ||
if (! | if (!_notes.length) { | ||
$ | $paneNotes.html('<div class="gra-empty-state">No notes yet.<br>Select text and click ✎ 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">' | ||
+ '<div class="gra-card- | + (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>' | ||
+ '<div class="gra-card-text">' + esc( | + (n.quote ? '<div class="gra-card-quote">' + esc(n.quote) + '</div>' : '') | ||
+ '<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 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(); | |||
} | } | ||
| Line 585: | Line 611: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
// EVENT WIRING | // EVENT WIRING | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
| Line 592: | Line 618: | ||
// ── Desktop: mouseup / keyup ────────────────────────────────── | // ── Desktop: mouseup / keyup ────────────────────────────────── | ||
$( document ).on('mouseup keyup', function(e){ | $( document ).on('mouseup keyup', function(e){ | ||
if ( e.type === 'mouseup' && e.button !== 0 ) return; | if ( e.type === 'mouseup' && e.button !== 0 ) return; | ||
setTimeout( tryShowFab, 20 ); | setTimeout( tryShowFab, 20 ); | ||
}); | }); | ||
// ── Mobile: selectionchange | // ── Mobile: selectionchange ─────────────────────────────────── | ||
var _selChangeTimer = null; | var _selChangeTimer = null; | ||
document.addEventListener('selectionchange', function() { | document.addEventListener('selectionchange', function() { | ||
| Line 605: | Line 629: | ||
var v = _selVersion; | var v = _selVersion; | ||
_selChangeTimer = setTimeout(function(){ | _selChangeTimer = setTimeout(function(){ | ||
if ( v !== _selVersion ) return; | if ( v !== _selVersion ) return; | ||
if ( _fabSelVer === v ) return; | if ( _fabSelVer === v ) return; | ||
tryShowFab(); | tryShowFab(); | ||
}, 400); | }, 400); | ||
}); | }); | ||
// ── Mobile: touchend fallback | // ── Mobile: touchend fallback ───────────────────────────────── | ||
document.addEventListener('touchend', function(e){ | document.addEventListener('touchend', function(e){ | ||
if ( $fab[0] && $fab[0].contains(e.target) ) return; | if ( $fab[0] && $fab[0].contains(e.target) ) return; | ||
if ( $ | if ( $fbComposer[0] && $fbComposer[0].contains(e.target) ) return; | ||
if ( $bmComposer[0] | if ( $ntComposer[0] && $ntComposer[0].contains(e.target) ) return; | ||
if ( $bmComposer[0] && $bmComposer[0].contains(e.target) ) return; | |||
setTimeout( tryShowFab, 80 ); | setTimeout( tryShowFab, 80 ); | ||
}, { passive: true }); | }, { passive: true }); | ||
| Line 626: | Line 648: | ||
var t = e.target; | var t = e.target; | ||
if ( $fab[0] && $fab[0].contains(t) ) return; | if ( $fab[0] && $fab[0].contains(t) ) return; | ||
if ( $ | if ( $fbComposer[0] && $fbComposer[0].contains(t) ) return; | ||
if ( $bmComposer[0] | if ( $ntComposer[0] && $ntComposer[0].contains(t) ) return; | ||
if ( $bmComposer[0] && $bmComposer[0].contains(t) ) return; | |||
hideFab(); | hideFab(); | ||
}); | }); | ||
// ── FAB buttons ─────────────────────────────────────────────── | // ── FAB buttons ─────────────────────────────────────────────── | ||
$( '#gra-fab- | $( '#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(); | e.preventDefault(); e.stopPropagation(); | ||
if ( !_selRange && !captureSelection() ) return; | if ( !_selRange && !captureSelection() ) return; | ||
openNoteComposer(); | |||
}); | }); | ||
$( '#gra-fab-bookmark' ).on('click touchend', function(e){ | $( '#gra-fab-bookmark' ).on('click touchend', function(e){ | ||
| Line 644: | Line 671: | ||
}); | }); | ||
// ── | // ── Feedback composer ───────────────────────────────────────── | ||
$ | $fbIssueType.on('change', function(){ | ||
$ | $fbSubmit.prop('disabled', !$( this ).val()); | ||
}); | }); | ||
$( '#gra- | $( '#gra-fb-cancel, #gra-fb-close' ).on('click', function(){ | ||
$ | closeFeedbackComposer(); hideFab(); | ||
$ | }); | ||
if ((e.ctrlKey||e.metaKey) && e.key==='Enter') | $fbSubmit.on('click', submitFeedback); | ||
if (e.key==='Escape') { | $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(); } | |||
}); | }); | ||
| Line 666: | Line 705: | ||
$( '#gra-panel-close' ).on('click', closePanel); | $( '#gra-panel-close' ).on('click', closePanel); | ||
$backdrop.on('click touchstart', closePanel); | $backdrop.on('click touchstart', closePanel); | ||
$ | $tabNotes.on('click', function(){ switchTab('notes'); }); | ||
$tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | $tabBookmarks.on('click', function(){ switchTab('bookmarks'); }); | ||
// Click | // 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'); | var id = $( this ).attr('data-gra-id'); | ||
if (id) { closePanel(); scrollToHighlight(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 | // Click bookmark card → scroll | ||
| Line 687: | Line 733: | ||
}); | }); | ||
// | // Click note highlight in text → open panel | ||
$( CONTENT_SEL ).on('click', '.gra- | $( CONTENT_SEL ).on('click', '.gra-note-highlight', function(){ | ||
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'); | ||
| Line 712: | Line 758: | ||
$( document ).on('keydown', function(e){ | $( document ).on('keydown', function(e){ | ||
if (e.key !== 'Escape') return; | if (e.key !== 'Escape') return; | ||
if ($ | 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 if ($bmComposer.hasClass('gra-composer-visible')) { closeBookmarkComposer(); hideFab(); } | ||
else closePanel(); | else closePanel(); | ||
| Line 722: | Line 769: | ||
// ════════════════════════════════════════════════════════════════════ | // ════════════════════════════════════════════════════════════════════ | ||
function | function persistNoteHighlight(id, quote) { | ||
try { | try { | ||
var s = JSON.parse(localStorage.getItem( | var s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); | ||
s = s.filter(function(h){ return h.id !== id; }); | s = s.filter(function(h){ return h.id !== id; }); | ||
s.push({id:id, quote:quote}); | s.push({id:id, quote:quote}); | ||
localStorage.setItem( | localStorage.setItem(NT_LS_KEY+'_hl', JSON.stringify(s)); | ||
} catch(e){} | } catch(e){} | ||
} | } | ||
function | function restoreNoteHighlights() { | ||
var s = []; | var s = []; | ||
try { s = JSON.parse(localStorage.getItem( | try { s = JSON.parse(localStorage.getItem(NT_LS_KEY+'_hl')||'[]'); } catch(e){} | ||
s.forEach(function(h){ | 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; | ||
| Line 742: | Line 789: | ||
if (range) { | if (range) { | ||
var sp = document.createElement('span'); | var sp = document.createElement('span'); | ||
sp.className = 'gra- | sp.className = 'gra-note-highlight'; | ||
sp.setAttribute('data-gra-id', h.id); | sp.setAttribute('data-gra-id', h.id); | ||
try { range.surroundContents(sp); } catch(e){} | try { range.surroundContents(sp); } catch(e){} | ||
| Line 794: | Line 841: | ||
buildDom(); | buildDom(); | ||
wireEvents(); | wireEvents(); | ||
loadNotes(); | |||
loadBookmarks(); | loadBookmarks(); | ||
restoreNoteHighlights(); | |||
restoreBookmarkHighlights(); | restoreBookmarkHighlights(); | ||
}); | }); | ||
}() ); | }() ); | ||