Last active
August 27, 2025 20:20
-
-
Save alexispurslane/8ad9b33d6f71f2c6522a3c18e7d33c87 to your computer and use it in GitHub Desktop.
Not mine, totally vibe coded, but a useful little totally local and self-contained thing you can embed into any HTML page to make it so that users can highlight and add comments and export/import them to share.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<style> | |
/* --- Commenting UI Styles --- */ | |
:root { | |
--comment-highlight-bg: rgba(51, 255, 153, 0.4); | |
--comment-ui-bg: #1a2a1a; | |
--comment-ui-text: #e8f8e8; | |
--comment-ui-accent: #33ff99; | |
--comment-ui-border: #6a826a; | |
} | |
.comment-highlight { | |
background-color: var(--comment-highlight-bg); | |
border-bottom: 2px dashed var(--comment-ui-accent); | |
cursor: pointer; | |
color: inherit; | |
} | |
/* --- Main Comment Creation Drawer --- */ | |
#comment-ui-container { | |
position: fixed; | |
bottom: 0; | |
left: 0; | |
width: 100%; | |
background-color: var(--comment-ui-bg); | |
color: var(--comment-ui-text); | |
box-shadow: 0 -2px 10px rgba(0,0,0,0.5); | |
z-index: 10000; | |
transform: translateY(100%); | |
transition: transform 0.3s ease-in-out; | |
padding: 1rem; | |
box-sizing: border-box; | |
display: flex; | |
flex-direction: column; | |
gap: 0.75rem; | |
border-top: 1px solid var(--comment-ui-border); | |
} | |
#comment-ui-container.visible { | |
transform: translateY(0); | |
} | |
#comment-ui-container button { | |
background-color: var(--comment-ui-accent); | |
color: var(--bg-color); | |
font-weight: bold; | |
border: none; | |
padding: 0.6rem 1rem; | |
border-radius: 5px; | |
cursor: pointer; | |
font-size: 0.9rem; | |
font-family: sans-serif; | |
text-align: center; | |
} | |
#comment-ui-container button.secondary { | |
background-color: var(--comment-ui-border); | |
color: var(--comment-ui-text); | |
font-weight: normal; | |
} | |
#comment-ui-container button:hover { opacity: 0.85; } | |
#comment-input-area { | |
width: 100%; | |
background-color: #0c140c; | |
color: var(--comment-ui-text); | |
border: 1px solid var(--comment-ui-border); | |
border-radius: 5px; | |
padding: 0.5rem; | |
font-size: 1rem; | |
font-family: sans-serif; | |
min-height: 80px; | |
box-sizing: border-box; | |
resize: vertical; | |
} | |
#comment-form { | |
display: none; | |
flex-direction: column; | |
gap: 0.75rem; | |
} | |
#comment-form .form-buttons { | |
display: flex; | |
gap: 0.5rem; | |
justify-content: flex-end; | |
} | |
/* --- Tooltip --- */ | |
#comment-tooltip { | |
position: absolute; | |
display: none; | |
background-color: var(--comment-ui-bg); | |
color: var(--comment-ui-text); | |
border: 1px solid var(--comment-ui-border); | |
border-radius: 5px; | |
padding: 0.8rem; | |
max-width: 300px; | |
font-size: 0.9rem; | |
z-index: 10001; | |
box-shadow: 0 4px 8px rgba(0,0,0,0.3); | |
overflow-wrap: break-word; | |
font-family: sans-serif; | |
line-height: 1.5; | |
} | |
.delete-comment-btn { | |
background: var(--comment-ui-border); | |
color: var(--comment-ui-text); | |
border: none; | |
padding: 4px 8px; | |
margin-top: 8px; | |
border-radius: 3px; | |
cursor: pointer; | |
display: block; | |
width: 100%; | |
font-size: 0.8rem; | |
} | |
.delete-comment-btn:hover { background: #c13434; } | |
/* --- Management Widget in Corner --- */ | |
#comment-widget-container { | |
position: fixed; | |
bottom: 15px; | |
right: 15px; | |
z-index: 9999; | |
display: flex; | |
flex-direction: column-reverse; | |
align-items: flex-end; | |
} | |
#comment-counter { | |
background-color: rgba(26, 42, 26, 0.8); | |
backdrop-filter: blur(5px); | |
color: var(--comment-ui-text); | |
padding: 5px 12px; | |
margin-top: 8px; | |
border-radius: 15px; | |
font-size: 0.85rem; | |
font-family: sans-serif; | |
display: none; | |
box-shadow: 0 1px 4px rgba(0,0,0,0.4); | |
border: 1px solid var(--comment-ui-border); | |
cursor: default; | |
transition: transform 0.2s ease-out; | |
} | |
#comment-widget-prompt { | |
background-color: rgba(26, 42, 26, 0.8); | |
backdrop-filter: blur(5px); | |
color: var(--comment-ui-text); | |
padding: 5px 10px; | |
margin-top: 8px; | |
border-radius: 15px; | |
font-size: 1rem; | |
display: none; /* Controlled by JS */ | |
box-shadow: 0 1px 4px rgba(0,0,0,0.4); | |
border: 1px solid var(--comment-ui-border); | |
cursor: default; | |
transition: transform 0.2s ease-out; | |
line-height: 1; | |
} | |
#comment-management-wrapper { | |
display: flex; | |
flex-direction: column; | |
align-items: flex-end; | |
gap: 8px; | |
opacity: 0; | |
visibility: hidden; | |
transform: translateY(10px); | |
transition: all 0.2s ease-out; | |
} | |
#comment-management-flyout { | |
display: flex; | |
gap: 0.5rem; | |
padding: 8px; | |
background-color: rgba(26, 42, 26, 0.9); | |
backdrop-filter: blur(5px); | |
border: 1px solid var(--comment-ui-border); | |
border-radius: 8px; | |
} | |
#comment-management-help-text { | |
color: var(--comment-ui-border); | |
font-size: 0.75rem; | |
font-family: sans-serif; | |
text-align: right; | |
padding: 0 4px; | |
max-width: 250px; | |
} | |
#comment-widget-container:hover #comment-management-wrapper { | |
opacity: 1; | |
visibility: visible; | |
transform: translateY(0); | |
} | |
#comment-widget-container:hover #comment-counter, | |
#comment-widget-container:hover #comment-widget-prompt { | |
transform: scale(1.05); | |
} | |
#comment-management-flyout button, | |
#comment-management-flyout .file-input-label { | |
background-color: var(--comment-ui-accent); | |
color: var(--bg-color); | |
font-weight: bold; | |
border: none; | |
padding: 0.5rem 0.8rem; | |
border-radius: 5px; | |
cursor: pointer; | |
font-size: 0.8rem; | |
font-family: sans-serif; | |
white-space: nowrap; | |
/* Ensure consistent size */ | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
#comment-management-flyout button.secondary { | |
background-color: var(--comment-ui-border); | |
color: var(--comment-ui-text); | |
font-weight: normal; | |
} | |
#comment-management-flyout button:hover, | |
#comment-management-flyout .file-input-label:hover { | |
opacity: 0.85; | |
} | |
#copy-link-btn { | |
background-color: #4A5D4A; /* A slightly different shade */ | |
} | |
#import-comments-input { display: none; } | |
/* --- Help Text and Keybinding Styles --- */ | |
.comment-help-text { | |
font-size: 0.8rem; | |
color: var(--comment-ui-border); | |
text-align: center; | |
width: 100%; | |
} | |
.comment-help-text kbd { | |
background-color: #0c140c; | |
border: 1px solid var(--comment-ui-border); | |
padding: 2px 6px; | |
border-radius: 4px; | |
font-family: monospace; | |
font-size: 0.75rem; | |
} | |
/* --- Comment Summary Section Styles --- */ | |
#comment-summary-section { | |
max-width: 60em; | |
margin: 5em auto; | |
padding: 2em; | |
border-top: 2px solid var(--comment-ui-border); | |
color: var(--comment-ui-text); | |
} | |
#comment-summary-section h2 { | |
font-family: var(--font-heading); | |
color: var(--comment-ui-accent); | |
text-transform: uppercase; | |
margin-top: 0; | |
border-bottom: none; | |
margin-bottom: 1em; | |
} | |
#comment-summary-list { | |
list-style: none; | |
padding: 0; | |
margin: 0; | |
} | |
#comment-summary-list li { | |
padding: 1.5em; | |
border-bottom: 1px solid var(--comment-ui-border); | |
cursor: pointer; | |
transition: background-color 0.2s ease; | |
position: relative; | |
} | |
#comment-summary-list li:hover { | |
background-color: var(--comment-ui-bg); | |
} | |
#comment-summary-list li blockquote { | |
margin: 0 0 0.5em 0; | |
padding-left: 1em; | |
border-left: 3px solid var(--comment-ui-accent); | |
color: var(--comment-ui-text); | |
font-style: italic; | |
opacity: 0.8; | |
} | |
#comment-summary-list li p { | |
margin: 0; | |
color: var(--comment-ui-text); | |
font-family: sans-serif; | |
} | |
.summary-delete-btn { | |
position: absolute; | |
top: 5px; | |
right: 5px; | |
font-size: 1.5rem; | |
color: var(--comment-ui-border); | |
transition: color 0.2s ease; | |
line-height: 1; | |
padding: 10px; | |
} | |
.summary-delete-btn:hover { | |
color: #c13434; | |
} | |
/* --- Mobile Responsiveness --- */ | |
@media (min-width: 600px) { | |
#comment-ui-container { | |
width: auto; | |
left: 50%; | |
transform: translateX(-50%) translateY(120%); | |
border-top-left-radius: 8px; | |
border-top-right-radius: 8px; | |
} | |
#comment-ui-container.visible { transform: translateX(-50%) translateY(0); } | |
} | |
</style> | |
<!-- The Tooltip and Counter now live outside the main UI container --> | |
<div id="comment-tooltip"></div> | |
<!-- New Widget Container --> | |
<div id="comment-widget-container"> | |
<div id="comment-counter"></div> | |
<div id="comment-management-wrapper"> | |
<div id="comment-management-flyout"> | |
<!-- NEW BUTTON HERE --> | |
<button id="copy-link-btn">Copy Link</button> | |
<label for="import-comments-input" class="file-input-label">Import</label> | |
<input type="file" id="import-comments-input" accept=".json"/> | |
<button id="export-comments-btn">Export</button> | |
<button id="clear-comments-btn" class="secondary">Clear All</button> | |
</div> | |
<small id="comment-management-help-text"> | |
Comments are stored locally in your browser. Use Export/Import to share. | |
</small> | |
</div> | |
</div> | |
<!-- Simplified Comment Creation Drawer --> | |
<div id="comment-ui-container"> | |
<button id="add-comment-btn" style="display: none;">Add Comment</button> | |
<div id="add-comment-help-text" class="comment-help-text" style="display: none;"> | |
Press <kbd>Shift</kbd> + <kbd>Enter</kbd> to add comment | |
</div> | |
<form id="comment-form"> | |
<textarea id="comment-input-area" placeholder="Type your comment here..."></textarea> | |
<div class="comment-help-text"> | |
<kbd>Shift</kbd> + <kbd>Enter</kbd> to save, <kbd>Esc</kbd> to cancel | |
</div> | |
<div class="form-buttons"> | |
<button type="button" id="cancel-comment-btn" class="secondary">Cancel</button> | |
<button type="submit" id="save-comment-btn">Save</button> | |
</div> | |
</form> | |
</div> | |
<section id="comment-summary-section" style="display: none;"> | |
<h2>Comment Summary</h2> | |
<ol id="comment-summary-list"></ol> | |
</section> | |
<script> | |
// A guard to prevent initializing the system more than once. | |
window.commentingSystemInitialized = false; | |
window.initializeCommentingSystem = function(rootElementId) { | |
if (window.commentingSystemInitialized) { | |
console.warn("Commenting system has already been initialized. Aborting."); | |
return; | |
} | |
// --- STATE MANAGEMENT --- | |
let comments = []; | |
let currentSelectionData = null; | |
// --- DOM ELEMENTS --- | |
const commentContainer = document.getElementById('comment-ui-container'); | |
const addCommentBtn = document.getElementById('add-comment-btn'); | |
const addCommentHelpText = document.getElementById('add-comment-help-text'); | |
const commentForm = document.getElementById('comment-form'); | |
const commentInput = document.getElementById('comment-input-area'); | |
const exportBtn = document.getElementById('export-comments-btn'); | |
const importInput = document.getElementById('import-comments-input'); | |
const clearBtn = document.getElementById('clear-comments-btn'); | |
const copyLinkBtn = document.getElementById('copy-link-btn'); | |
const tooltip = document.getElementById('comment-tooltip'); | |
const commentCounter = document.getElementById('comment-counter'); | |
const commentWidgetPrompt = document.getElementById('comment-widget-prompt'); | |
const commentSummarySection = document.getElementById('comment-summary-section'); | |
const commentSummaryList = document.getElementById('comment-summary-list'); | |
const contentRoot = document.getElementById(rootElementId) || document.body; | |
if (!contentRoot) { | |
console.error(`Root element with ID "${rootElementId}" not found. Commenting system cannot start.`); | |
return; | |
} | |
// --- URL Data Handling & Compression Helpers --- | |
const toUrlSafeBase64 = (buffer) => { | |
return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))) | |
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); | |
}; | |
const fromUrlSafeBase64 = (str) => { | |
str = str.replace(/-/g, '+').replace(/_/g, '/'); | |
while (str.length % 4) { str += '='; } | |
const binaryString = atob(str); | |
const bytes = new Uint8Array(binaryString.length); | |
for (let i = 0; i < binaryString.length; i++) { | |
bytes[i] = binaryString.charCodeAt(i); | |
} | |
return bytes.buffer; | |
}; | |
const compressData = async (data) => { | |
const stream = new Blob([JSON.stringify(data)], { type: 'application/json' }) | |
.stream().pipeThrough(new CompressionStream('gzip')); | |
return await new Response(stream).arrayBuffer(); | |
}; | |
const decompressData = async (compressedBuffer) => { | |
try { | |
const stream = new Blob([compressedBuffer], { type: 'application/gzip' }) | |
.stream().pipeThrough(new DecompressionStream('gzip')); | |
const decompressed = await new Response(stream).json(); | |
return decompressed; | |
} catch (e) { | |
console.error("Decompression failed:", e); | |
return null; | |
} | |
}; | |
// --- CORE FUNCTIONS --- | |
const getNodePath = (node) => { | |
const path = []; | |
while (node !== contentRoot && node) { | |
let sibling = node; let index = 0; | |
while ((sibling = sibling.previousSibling) != null) { index++; } | |
path.unshift(index); | |
node = node.parentNode; | |
} | |
return path; | |
}; | |
const getNodeFromPath = (path) => { | |
let node = contentRoot; | |
for (const index of path) { | |
if (node && node.childNodes[index]) { | |
node = node.childNodes[index]; | |
} else { return null; } | |
} | |
return node; | |
}; | |
const serializeRange = (range) => { | |
if (!range) return null; | |
return { | |
startPath: getNodePath(range.startContainer), | |
startOffset: range.startOffset, | |
endPath: getNodePath(range.endContainer), | |
endOffset: range.endOffset, | |
text: range.toString() | |
}; | |
}; | |
const deserializeRange = (savedRange) => { | |
const startContainer = getNodeFromPath(savedRange.startPath); | |
const endContainer = getNodeFromPath(savedRange.endPath); | |
if (startContainer && endContainer) { | |
try { | |
const range = document.createRange(); | |
const startOffset = Math.min(savedRange.startOffset, startContainer.textContent.length); | |
const endOffset = Math.min(savedRange.endOffset, endContainer.textContent.length); | |
range.setStart(startContainer, startOffset); | |
range.setEnd(endContainer, endOffset); | |
return range; | |
} catch (e) { console.error("Error setting range from path, falling back.", e, savedRange); } | |
} | |
console.warn("Path failed for highlight. Falling back to text search for:", savedRange.text); | |
const normalizedText = savedRange.text.replace(/\s+/g, ' ').trim(); | |
if (!normalizedText) return null; | |
const walker = document.createTreeWalker(contentRoot, NodeFilter.SHOW_TEXT); | |
let node; | |
while (node = walker.nextNode()) { | |
const normalizedNodeText = node.nodeValue.replace(/\s+/g, ' '); | |
const index = normalizedNodeText.indexOf(normalizedText); | |
if (index !== -1) { | |
const range = document.createRange(); | |
range.setStart(node, index); | |
range.setEnd(node, index + normalizedText.length); | |
return range; | |
} | |
} | |
console.error("Could not find text for highlight anywhere on the page:", savedRange); | |
return null; | |
}; | |
const renderCommentSummary = () => { | |
commentSummaryList.innerHTML = ''; | |
if (comments.length === 0) { | |
commentSummarySection.style.display = 'none'; | |
return; | |
} | |
commentSummarySection.style.display = 'block'; | |
const commentsWithElements = comments.map(comment => { | |
const el = document.querySelector(`.comment-highlight[data-comment-id="${comment.id}"]`); | |
return { comment, el }; | |
}).filter(item => item.el); | |
commentsWithElements.sort((a, b) => a.el.offsetTop - b.el.offsetTop); | |
commentsWithElements.forEach(({ comment }) => { | |
const li = document.createElement('li'); | |
li.dataset.commentId = comment.id; | |
const deleteBtn = document.createElement('span'); | |
deleteBtn.className = 'summary-delete-btn'; | |
deleteBtn.innerHTML = '×'; | |
deleteBtn.dataset.commentId = comment.id; | |
const blockquote = document.createElement('blockquote'); | |
const quoteText = comment.range.text.length > 100 ? comment.range.text.substring(0, 97) + '...' : comment.range.text; | |
blockquote.textContent = quoteText; | |
const p = document.createElement('p'); | |
p.textContent = comment.text; | |
li.appendChild(deleteBtn); | |
li.appendChild(blockquote); | |
li.appendChild(p); | |
commentSummaryList.appendChild(li); | |
}); | |
}; | |
const getLastHighlightScrollPercent = () => { | |
const highlights = Array.from(document.querySelectorAll('.comment-highlight')); | |
if (highlights.length === 0) return null; | |
const lastHighlight = highlights.reduce((last, current) => current.offsetTop > last.offsetTop ? current : last); | |
const docHeight = document.documentElement.scrollHeight; | |
const viewportHeight = window.innerHeight; | |
const scrollableHeight = docHeight - viewportHeight; | |
if (scrollableHeight <= 0) return 100; | |
const percentage = (lastHighlight.offsetTop / scrollableHeight) * 100; | |
return Math.min(100, Math.round(percentage)); | |
}; | |
const updateCounter = () => { | |
const count = comments.length; | |
if (count > 0) { | |
const percentage = getLastHighlightScrollPercent(); | |
let text = `${count} Comment${count > 1 ? 's' : ''}`; | |
if (percentage !== null) { | |
text += ` (${percentage}%)`; | |
} | |
commentCounter.textContent = text; | |
commentCounter.style.display = 'block'; | |
commentWidgetPrompt.style.display = 'none'; | |
} else { | |
commentCounter.style.display = 'none'; | |
commentWidgetPrompt.style.display = 'block'; | |
} | |
}; | |
const applyHighlight = (range, commentId) => { | |
if (!range || range.collapsed) return; | |
try { | |
const marker = document.createElement('mark'); | |
marker.className = 'comment-highlight'; | |
marker.dataset.commentId = commentId; | |
range.surroundContents(marker); | |
addHighlightEventListeners(marker); | |
} catch (e) { | |
console.warn("surroundContents failed, likely a multi-element selection. Using robust fallback.", e); | |
const walker = document.createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT); | |
const textNodes = []; | |
while(walker.nextNode()) { | |
if(range.intersectsNode(walker.currentNode)) { | |
textNodes.push(walker.currentNode); | |
} | |
} | |
for (const node of textNodes) { | |
const highlightRange = document.createRange(); | |
highlightRange.setStart(node, node === range.startContainer ? range.startOffset : 0); | |
highlightRange.setEnd(node, node === range.endContainer ? range.endOffset : node.textContent.length); | |
if (!highlightRange.collapsed) { | |
const marker = document.createElement('mark'); | |
marker.className = 'comment-highlight'; | |
marker.dataset.commentId = commentId; | |
highlightRange.surroundContents(marker); | |
addHighlightEventListeners(marker); | |
} | |
} | |
} | |
}; | |
const saveCommentsToLocal = () => { | |
try { localStorage.setItem('userComments', JSON.stringify(comments)); } | |
catch (e) { console.error("Could not save comments to localStorage.", e); } | |
}; | |
const applyAllHighlights = (commentsToApply) => { | |
const itemsWithRanges = commentsToApply.map(comment => ({ | |
comment, range: deserializeRange(comment.range) | |
})); | |
itemsWithRanges.sort((a, b) => { | |
if (!a.range || !b.range) return 0; | |
return b.range.compareBoundaryPoints(Range.START_TO_START, a.range) | |
}); | |
for (const item of itemsWithRanges) { | |
if (item.range) { | |
try { | |
applyHighlight(item.range, item.comment.id); | |
} catch (e) { | |
console.error("Failed to apply a highlight during import:", item.comment, e); | |
} | |
} | |
} | |
}; | |
const loadInitialComments = async () => { | |
const hash = window.location.hash.slice(1); | |
if (hash) { | |
console.log("Loading comments from URL..."); | |
const buffer = fromUrlSafeBase64(hash); | |
const data = await decompressData(buffer); | |
if (data && Array.isArray(data)) { | |
comments = data; | |
applyAllHighlights(comments); | |
saveCommentsToLocal(); | |
history.replaceState(null, '', window.location.pathname + window.location.search); | |
} | |
} else { | |
console.log("Loading comments from localStorage..."); | |
const savedCommentsJSON = localStorage.getItem('userComments'); | |
if (savedCommentsJSON) { | |
try { | |
const savedComments = JSON.parse(savedCommentsJSON); | |
if (Array.isArray(savedComments)) { | |
comments = savedComments; | |
applyAllHighlights(comments); | |
} | |
} catch (e) { | |
console.error("Could not parse comments from localStorage.", e); | |
localStorage.removeItem('userComments'); | |
} | |
} | |
} | |
updateCounter(); | |
renderCommentSummary(); | |
}; | |
const deleteComment = (commentId) => { | |
if (!confirm('Are you sure you want to delete this comment?')) return; | |
const commentIndex = comments.findIndex(c => c.id === commentId); | |
if (commentIndex > -1) { | |
comments.splice(commentIndex, 1); | |
saveCommentsToLocal(); | |
document.querySelectorAll(`.comment-highlight[data-comment-id="${commentId}"]`).forEach(removeHighlight); | |
updateCounter(); | |
hideTooltip(); | |
renderCommentSummary(); | |
} | |
}; | |
const showTooltip = (highlightEl) => { | |
const commentId = highlightEl.dataset.commentId; | |
const comment = comments.find(c => c.id === commentId); | |
if (!comment) return; | |
tooltip.innerHTML = ` | |
<span>${comment.text.replace(/</g, "<").replace(/>/g, ">")}</span> | |
<button class="delete-comment-btn" data-comment-id="${commentId}">Delete</button> | |
`; | |
tooltip.querySelector('.delete-comment-btn').addEventListener('click', (e) => { | |
e.stopPropagation(); | |
deleteComment(e.target.dataset.commentId); | |
}); | |
tooltip.style.display = 'block'; | |
tooltip.dataset.activeCommentId = commentId; | |
const rect = highlightEl.getBoundingClientRect(); | |
tooltip.style.left = `${rect.left + window.scrollX}px`; | |
tooltip.style.top = `${rect.bottom + window.scrollY + 5}px`; | |
const tooltipRect = tooltip.getBoundingClientRect(); | |
if (tooltipRect.right > window.innerWidth) { | |
tooltip.style.left = `${window.innerWidth - tooltipRect.width - 10 + window.scrollX}px`; | |
} | |
if (tooltipRect.top < 0) { | |
tooltip.style.top = `${rect.top + window.scrollY - tooltipRect.height - 5}px`; | |
} | |
}; | |
const hideTooltip = () => { | |
tooltip.style.display = 'none'; | |
tooltip.dataset.activeCommentId = ''; | |
}; | |
const addHighlightEventListeners = (marker) => { | |
marker.addEventListener('click', (e) => { | |
e.stopPropagation(); | |
if (tooltip.dataset.activeCommentId === marker.dataset.commentId) { | |
hideTooltip(); | |
} else { | |
showTooltip(marker); | |
} | |
}); | |
}; | |
const removeHighlight = (el) => { | |
const parent = el.parentNode; | |
while (el.firstChild) parent.insertBefore(el.firstChild, el); | |
parent.removeChild(el); | |
}; | |
const resetUI = () => { | |
commentContainer.classList.remove('visible'); | |
addCommentBtn.style.display = 'none'; | |
addCommentHelpText.style.display = 'none'; | |
commentForm.style.display = 'none'; | |
commentInput.value = ''; | |
currentSelectionData = null; | |
}; | |
const handleSelectionEnd = (e) => { | |
if (commentContainer.contains(e.target) || e.target.closest('.comment-highlight') || e.target.closest('#comment-widget-container')) return; | |
setTimeout(() => { | |
const selection = window.getSelection(); | |
if (selection && !selection.isCollapsed && selection.rangeCount > 0) { | |
if (contentRoot.contains(selection.anchorNode) && contentRoot.contains(selection.focusNode)) { | |
commentContainer.classList.add('visible'); | |
addCommentBtn.style.display = 'block'; | |
addCommentHelpText.style.display = 'block'; | |
commentForm.style.display = 'none'; | |
} | |
} else { | |
if (document.activeElement !== commentInput) resetUI(); | |
} | |
}, 10); | |
}; | |
contentRoot.addEventListener('mouseup', handleSelectionEnd); | |
contentRoot.addEventListener('touchend', handleSelectionEnd); | |
addCommentBtn.addEventListener('click', () => { | |
const selection = window.getSelection(); | |
if (selection && !selection.isCollapsed) { | |
currentSelectionData = serializeRange(selection.getRangeAt(0)); | |
addCommentBtn.style.display = 'none'; | |
addCommentHelpText.style.display = 'none'; | |
commentForm.style.display = 'flex'; | |
commentInput.focus(); | |
} else { resetUI(); } | |
}); | |
document.getElementById('cancel-comment-btn').addEventListener('click', () => { | |
resetUI(); | |
if (window.getSelection) window.getSelection().removeAllRanges(); | |
}); | |
commentForm.addEventListener('submit', (e) => { | |
e.preventDefault(); | |
const commentText = commentInput.value.trim(); | |
if (commentText && currentSelectionData) { | |
const rangeToHighlight = deserializeRange(currentSelectionData); | |
if (!rangeToHighlight) { resetUI(); return; } | |
const newComment = { | |
id: `comment-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, | |
text: commentText, | |
range: currentSelectionData | |
}; | |
comments.push(newComment); | |
saveCommentsToLocal(); | |
applyHighlight(rangeToHighlight, newComment.id); | |
resetUI(); | |
updateCounter(); | |
renderCommentSummary(); | |
if (window.getSelection) window.getSelection().removeAllRanges(); | |
} | |
}); | |
commentInput.addEventListener('focus', () => { document.body.style.overflow = 'hidden'; }); | |
commentInput.addEventListener('blur', () => { document.body.style.overflow = ''; }); | |
commentInput.addEventListener('keydown', (e) => { | |
if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); commentForm.requestSubmit(); } | |
if (e.key === 'Escape') { | |
e.preventDefault(); | |
resetUI(); | |
if (window.getSelection) window.getSelection().removeAllRanges(); | |
} | |
}); | |
exportBtn.addEventListener('click', () => { | |
if (comments.length === 0) { return; } | |
const dataStr = JSON.stringify(comments, null, 2); | |
const dataBlob = new Blob([dataStr], { type: 'application/json' }); | |
const url = URL.createObjectURL(dataBlob); | |
const a = document.createElement('a'); a.href = url; a.download = 'comments.json'; | |
document.body.appendChild(a); a.click(); document.body.removeChild(a); | |
URL.revokeObjectURL(url); | |
}); | |
importInput.addEventListener('change', (e) => { | |
const file = e.target.files[0]; | |
if (!file) return; | |
const reader = new FileReader(); | |
reader.onload = (event) => { | |
try { | |
const importedComments = JSON.parse(event.target.result); | |
if (Array.isArray(importedComments)) { | |
document.querySelectorAll('.comment-highlight').forEach(removeHighlight); | |
comments = importedComments; | |
applyAllHighlights(comments); | |
saveCommentsToLocal(); | |
updateCounter(); | |
renderCommentSummary(); | |
} | |
} catch (err) { | |
console.error(err); | |
alert('Error: Could not parse the JSON file.'); | |
} | |
}; | |
reader.readAsText(file); | |
e.target.value = ''; | |
}); | |
clearBtn.addEventListener('click', () => { | |
if (confirm('Are you sure you want to permanently delete all comments from this page?')) { | |
comments = []; | |
localStorage.removeItem('userComments'); | |
document.querySelectorAll('.comment-highlight').forEach(removeHighlight); | |
updateCounter(); | |
renderCommentSummary(); | |
} | |
}); | |
copyLinkBtn.addEventListener('click', async () => { | |
if (comments.length === 0) return; | |
const originalText = copyLinkBtn.textContent; | |
copyLinkBtn.textContent = 'Compressing...'; | |
copyLinkBtn.disabled = true; | |
try { | |
const compressed = await compressData(comments); | |
const base64 = toUrlSafeBase64(compressed); | |
const url = new URL(window.location); | |
url.hash = base64; | |
// NEW: URL Length Check | |
const URL_LENGTH_LIMIT = 2097152; | |
if (url.href.length > URL_LENGTH_LIMIT) { | |
alert("Warning: The generated link is very long and may not work in all browsers or applications.\n\nExporting a JSON file is recommended for many comments."); | |
} | |
await navigator.clipboard.writeText(url.href); | |
copyLinkBtn.textContent = 'Copied!'; | |
} catch (err) { | |
console.error("Failed to copy link:", err); | |
copyLinkBtn.textContent = 'Error!'; | |
} finally { | |
setTimeout(() => { | |
copyLinkBtn.textContent = originalText; | |
copyLinkBtn.disabled = false; | |
}, 2000); | |
} | |
}); | |
document.addEventListener('click', (e) => { | |
if (!e.target.closest('.comment-highlight') && !e.target.closest('#comment-tooltip')) { | |
hideTooltip(); | |
} | |
}); | |
document.addEventListener('keydown', (e) => { | |
if (e.key === 'Enter' && e.shiftKey && document.activeElement !== commentInput) { | |
if (addCommentBtn.style.display === 'block') { | |
e.preventDefault(); | |
addCommentBtn.click(); | |
} | |
} | |
}); | |
commentSummaryList.addEventListener('click', (e) => { | |
const deleteButton = e.target.closest('.summary-delete-btn'); | |
if (deleteButton) { | |
e.stopPropagation(); | |
deleteComment(deleteButton.dataset.commentId); | |
return; | |
} | |
const li = e.target.closest('li'); | |
if (li && li.dataset.commentId) { | |
const commentId = li.dataset.commentId; | |
const highlightElement = document.querySelector(`.comment-highlight[data-comment-id="${commentId}"]`); | |
if (highlightElement) { | |
highlightElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
setTimeout(() => { showTooltip(highlightElement); }, 300); | |
} | |
} | |
}); | |
loadInitialComments(); | |
window.commentingSystemInitialized = true; | |
console.log("Commenting system initialized successfully."); | |
}; | |
document.addEventListener('DOMContentLoaded', () => { | |
initializeCommentingSystem('content'); | |
}); | |
</script> | |
<!-- END: Text Highlighting and Commenting Feature --> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment