From 60287747b2d960af570cd6ab44c23203ca7e9c56 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 13 Jul 2026 01:05:22 +0530 Subject: [PATCH 01/18] feat(review): add comments and suggestion mode --- README.md | 12 +- desktop-app/resources/index.html | 72 ++- desktop-app/resources/js/script.js | 917 ++++++++++++++++++++++++++++- desktop-app/resources/styles.css | 614 ++++++++++++++++++- index.html | 72 ++- script.js | 917 ++++++++++++++++++++++++++++- styles.css | 614 ++++++++++++++++++- wiki/Features.md | 31 +- wiki/Usage-Guide.md | 14 + 9 files changed, 3228 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 3b0fb9d3..d6be1e4b 100644 --- a/README.md +++ b/README.md @@ -94,11 +94,12 @@ Markdown Viewer handles the usual Markdown basics, but its real value is helping - **STL 3D Model Renderer**: inspect 3D model previews alongside technical notes. - **ABC Music Player & Sheet Music Viewer**: render sheet music and play notation in the browser. -3. **Live Share Temporary Rooms**: collaborate in real time for quick editing sessions, reviews, or pair-writing, with server-checked host, editable, and view-only capabilities. -4. **Share Snapshot Links**: create view-only or editable point-in-time links when you need to send a document state quickly. Large stored snapshots expire after 90 days. -5. **LaTeX Math Notation**: render inline and display formulas with MathJax, useful for math-heavy notes, papers, and technical explanations. -6. **Markdown to PDF, HTML & PNG Export**: export Markdown, HTML, PNG, Browser Print / Save as PDF, or Legacy Raster PDF for documents that need sharing, printing, or archiving. -7. **Privacy and Security Controls**: use Private mode to prevent document persistence, clear saved local data, and rely on sanitized previews, hardened export HTML, and restricted desktop native APIs. +3. **Comments & Suggestions**: open a read-only Review workspace and attach feedback to rendered headings, paragraphs, code blocks, and diagrams without changing the Markdown source. Resolve threads or copy a review summary when the feedback is ready to share. +4. **Live Share Temporary Rooms**: collaborate in real time for quick editing sessions, reviews, or pair-writing, with server-checked host, editable, and view-only capabilities. +5. **Share Snapshot Links**: create view-only or editable point-in-time links when you need to send a document state quickly. Large stored snapshots expire after 90 days. +6. **LaTeX Math Notation**: render inline and display formulas with MathJax, useful for math-heavy notes, papers, and technical explanations. +7. **Markdown to PDF, HTML & PNG Export**: export Markdown, HTML, PNG, Browser Print / Save as PDF, or Legacy Raster PDF for documents that need sharing, printing, or archiving. +8. **Privacy and Security Controls**: use Private mode to prevent document persistence, clear saved local data, and rely on sanitized previews, hardened export HTML, and restricted desktop native APIs. For the full feature list, details, limitations, and privacy notes, see the [features reference](wiki/Features.md#product-summary). @@ -128,6 +129,7 @@ Markdown Viewer works well as a Markdown diagram editor for technical notes, doc ## Sharing, Collaboration, and Export +- **Comments & Suggestions** provides a local, per-document review layer. Review pins attach comments or suggestions to rendered headings, paragraphs, code blocks, and diagrams while the Markdown stays read-only. Threads can be resolved, reopened, deleted, or copied as a Markdown summary. Review data stays in the local tab workspace and is not automatically included in Share Snapshot or Live Share. - **Share Snapshot** creates quick links for point-in-time Markdown sharing. Small documents can stay in the URL hash; larger snapshots use temporary Cloudflare KV storage for up to 90 days when that backend is configured. Snapshot links are bearer links: anyone who has the link can open it.

Share Snapshot diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index e4949d34..7e4e6d8c 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -109,6 +109,11 @@

Markdown Viewer - Online Live Share + + - + + + + diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index e692ed31..d66c996c 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -263,6 +263,16 @@ document.addEventListener("DOMContentLoaded", async function () { const shareSnapshotViewOnlyTabIds = new Set(); const SHARE_SNAPSHOT_TAB_KIND = 'share-snapshot'; const APP_VERSION = '3.9.0'; + const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, pre, .diagram-viewer, .geojson-container, .topojson-container, .stl-container'; + const REVIEW_TEXT_LIMIT = 2000; + let reviewModeActive = false; + const reviewPreviousViewModes = new Map(); + let reviewFilter = 'open'; + let reviewComposerKind = 'comment'; + let activeReviewAnchor = null; + let reviewPinsResizeObserver = null; + let reviewPinsLayoutFrame = null; + let reviewTargetSourceSnapshots = new WeakMap(); let activeModal = null; let lastFocusedElement = null; let isFindModalOpen = false; @@ -292,6 +302,7 @@ document.addEventListener("DOMContentLoaded", async function () { const markdownEditor = document.getElementById("markdown-editor"); const markdownPreview = document.getElementById("markdown-preview"); + const reviewPinsLayer = document.getElementById('review-pins-layer'); const markdownFormatToolbar = document.getElementById("markdown-format-toolbar"); const themeToggle = document.getElementById("theme-toggle"); const directionToggle = document.getElementById("direction-toggle"); @@ -318,6 +329,24 @@ document.addEventListener("DOMContentLoaded", async function () { const readingTimeElement = document.getElementById("reading-time"); const wordCountElement = document.getElementById("word-count"); const charCountElement = document.getElementById("char-count"); + const reviewToggle = document.getElementById('review-toggle'); + const mobileReviewToggle = document.getElementById('mobile-review-toggle'); + const reviewCountBadge = document.getElementById('review-count-badge'); + const mobileReviewCountBadge = document.getElementById('mobile-review-count-badge'); + const reviewPanel = document.getElementById('review-panel'); + const reviewPanelBackdrop = document.getElementById('review-panel-backdrop'); + const reviewPanelClose = document.getElementById('review-panel-close'); + const reviewPanelSummary = document.getElementById('review-panel-summary'); + const reviewCopySummary = document.getElementById('review-copy-summary'); + const reviewComposer = document.getElementById('review-composer'); + const reviewComposerAnchor = document.getElementById('review-composer-anchor'); + const reviewComposerCancel = document.getElementById('review-composer-cancel'); + const reviewFeedbackLabel = document.getElementById('review-feedback-label'); + const reviewFeedbackInput = document.getElementById('review-feedback-input'); + const reviewFeedbackCount = document.getElementById('review-feedback-count'); + const reviewFeedbackSubmit = document.getElementById('review-feedback-submit'); + const reviewList = document.getElementById('review-list'); + const reviewEmptyState = document.getElementById('review-empty-state'); // View Mode Elements - Story 1.1 const contentContainer = document.querySelector(".content-container"); @@ -395,6 +424,9 @@ document.addEventListener("DOMContentLoaded", async function () { } function getEditorReadOnlyMessage() { + if (reviewModeActive) { + return 'Review mode keeps the Markdown source read only.'; + } if (isShareSnapshotViewOnlyActive()) { return 'This shared snapshot is view only.'; } @@ -662,6 +694,7 @@ document.addEventListener("DOMContentLoaded", async function () { function applyDirectionToContent(direction) { if (markdownEditor) markdownEditor.setAttribute("dir", direction); if (markdownPreview) markdownPreview.setAttribute("dir", direction); + scheduleReviewPinsLayout(); } applyDirectionToContent(initialDirection); updateDirectionToggleUI(initialDirection); @@ -2377,6 +2410,833 @@ document.addEventListener("DOMContentLoaded", async function () { const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit"); const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay"); + // ======================================== + // COMMENTS & SUGGESTIONS + // ======================================== + + function normalizeReviewThreads(rawThreads) { + if (!Array.isArray(rawThreads)) return []; + return rawThreads.slice(-500).map(function(thread) { + if (!thread || typeof thread !== 'object' || !thread.anchor || typeof thread.anchor.key !== 'string') { + return null; + } + const body = typeof thread.body === 'string' ? thread.body.trim().slice(0, REVIEW_TEXT_LIMIT) : ''; + if (!body) return null; + const createdAt = Number.isFinite(Number(thread.createdAt)) ? Number(thread.createdAt) : Date.now(); + const updatedAt = Number.isFinite(Number(thread.updatedAt)) ? Number(thread.updatedAt) : createdAt; + return { + id: typeof thread.id === 'string' && thread.id ? thread.id.slice(0, 120) : createReviewId(), + anchor: { + key: thread.anchor.key.slice(0, 240), + type: typeof thread.anchor.type === 'string' ? thread.anchor.type.slice(0, 40) : 'block', + label: typeof thread.anchor.label === 'string' ? thread.anchor.label.slice(0, 120) : 'Document block', + excerpt: typeof thread.anchor.excerpt === 'string' ? thread.anchor.excerpt.slice(0, 240) : '', + duplicateCount: Number.isFinite(Number(thread.anchor.duplicateCount)) + ? Math.max(1, Number(thread.anchor.duplicateCount)) + : null, + context: typeof thread.anchor.context === 'string' ? thread.anchor.context.slice(0, 80) : null + }, + kind: thread.kind === 'suggestion' ? 'suggestion' : 'comment', + body: body, + createdAt: createdAt, + updatedAt: updatedAt, + resolved: thread.resolved === true + }; + }).filter(Boolean); + } + + function createReviewId() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return 'review_' + window.crypto.randomUUID(); + } + return 'review_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); + } + + function getActiveReviewTab() { + return tabs.find(function(tab) { return tab.id === activeTabId; }) || null; + } + + function getActiveReviewThreads() { + const tab = getActiveReviewTab(); + if (!tab) return []; + if (!Array.isArray(tab.reviewThreads)) { + tab.reviewThreads = []; + } + return tab.reviewThreads; + } + + function decodeReviewSource(value) { + if (!value) return ''; + try { + return decodeURIComponent(value); + } catch (_) { + return String(value); + } + } + + function getReviewTargetType(target) { + if (!target) return 'block'; + if (target.matches('.diagram-viewer, .geojson-container, .topojson-container, .stl-container')) return 'diagram'; + if (target.tagName === 'PRE') return 'code'; + if (/^H[1-6]$/.test(target.tagName)) return 'heading'; + if (target.tagName === 'P') return 'paragraph'; + return 'block'; + } + + function normalizeReviewSource(source, preserveLines) { + const normalized = String(source || '').replace(/\r\n?/g, '\n'); + if (!preserveLines) { + return normalized.replace(/\s+/g, ' ').trim(); + } + return normalized.split('\n').map(function(line) { + return line.replace(/[ \t]+$/g, ''); + }).join('\n').trim(); + } + + function computeReviewTargetSource(target, type) { + if (!target) return ''; + if (type === 'diagram') { + const sourceNode = target.hasAttribute('data-original-code') + ? target + : target.querySelector('[data-original-code]'); + return normalizeReviewSource(sourceNode ? decodeReviewSource(sourceNode.getAttribute('data-original-code')) : '', true); + } + if (type === 'code') { + const code = target.querySelector('code'); + return normalizeReviewSource(code ? code.textContent : target.textContent, true); + } + + const clone = target.cloneNode(true); + clone.querySelectorAll('.review-target-button, .footnote-backref').forEach(function(node) { + node.remove(); + }); + clone.querySelectorAll('img').forEach(function(image) { + image.replaceWith(document.createTextNode(image.getAttribute('alt') || '')); + }); + return normalizeReviewSource(clone.textContent, false); + } + + function getReviewTargetSource(target, type) { + const snapshot = target ? reviewTargetSourceSnapshots.get(target) : null; + if (snapshot && snapshot.type === type) return snapshot.source; + return computeReviewTargetSource(target, type); + } + + function snapshotMathReviewTargetSources(mathTargets) { + if (!Array.isArray(mathTargets) || mathTargets.length === 0) return; + getReviewTargets().forEach(function(target) { + if (reviewTargetSourceSnapshots.has(target)) return; + const containsMathTarget = mathTargets.some(function(mathTarget) { + return mathTarget === target || mathTarget.contains(target) || target.contains(mathTarget); + }); + if (!containsMathTarget) return; + const type = getReviewTargetType(target); + reviewTargetSourceSnapshots.set(target, { + type: type, + source: computeReviewTargetSource(target, type) + }); + }); + } + + function invalidateReviewTargetSourceSnapshots(roots) { + (roots || []).forEach(function(root) { + const element = root && root.nodeType === Node.ELEMENT_NODE ? root : (root && root.parentElement); + if (!element) return; + const ancestorTarget = element.closest && element.closest(REVIEW_TARGET_SELECTOR); + if (ancestorTarget && markdownPreview.contains(ancestorTarget)) { + reviewTargetSourceSnapshots.delete(ancestorTarget); + } + if (element.matches && element.matches(REVIEW_TARGET_SELECTOR)) { + reviewTargetSourceSnapshots.delete(element); + } + element.querySelectorAll(REVIEW_TARGET_SELECTOR).forEach(function(target) { + reviewTargetSourceSnapshots.delete(target); + }); + }); + } + + function hashReviewSource(value) { + let hash = 2166136261; + const text = String(value || ''); + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); + } + + function getReviewTargetLabel(target, type) { + if (type === 'heading') return 'Heading ' + target.tagName; + if (type === 'paragraph') return 'Paragraph'; + if (type === 'code') { + const code = target.querySelector('code'); + const languageClass = code ? Array.from(code.classList).find(function(className) { + return className !== 'hljs' && className !== 'language-plaintext' && className !== 'plaintext'; + }) : ''; + const language = languageClass ? languageClass.replace(/^language-/, '') : ''; + return language ? language + ' code block' : 'Code block'; + } + if (type === 'diagram') { + let engine = target.getAttribute('data-diagram-engine'); + if (!engine && target.matches('.geojson-container')) engine = 'geojson'; + if (!engine && target.matches('.topojson-container')) engine = 'topojson'; + if (!engine && target.matches('.stl-container')) engine = 'stl'; + const specialLabels = { + geojson: 'GeoJSON map', + topojson: 'TopoJSON map', + stl: 'STL model' + }; + if (specialLabels[engine]) return specialLabels[engine]; + const engineLabel = engine ? getDiagramEngineLabel(engine) : 'Diagram'; + return engineLabel === 'Diagram' ? engineLabel : engineLabel + ' diagram'; + } + return 'Document block'; + } + + function getReviewAnchorDescriptor(target) { + if (!target || !target.dataset.reviewAnchor) return null; + const type = target.dataset.reviewType || getReviewTargetType(target); + const source = getReviewTargetSource(target, type); + const excerpt = source.length > 180 ? source.slice(0, 177) + '...' : source; + return { + key: target.dataset.reviewAnchor, + type: type, + label: target.dataset.reviewLabel || getReviewTargetLabel(target, type), + excerpt: excerpt || 'Empty rendered block', + duplicateCount: Math.max(1, Number(target.dataset.reviewDuplicateCount) || 1), + context: target.dataset.reviewContext || null + }; + } + + function getReviewTargets() { + if (!markdownPreview) return []; + return Array.from(markdownPreview.querySelectorAll(REVIEW_TARGET_SELECTOR)).filter(function(target) { + const diagramContainer = target.closest('.diagram-viewer, .geojson-container, .topojson-container, .stl-container'); + if (diagramContainer && diagramContainer !== target) { + return false; + } + return !target.closest('.review-target-button'); + }); + } + + function clearReviewDecorations() { + if (!markdownPreview) return; + if (reviewPinsLayer) reviewPinsLayer.textContent = ''; + if (reviewPinsResizeObserver) { + reviewPinsResizeObserver.disconnect(); + reviewPinsResizeObserver = null; + } + if (reviewPinsLayoutFrame) { + cancelAnimationFrame(reviewPinsLayoutFrame); + reviewPinsLayoutFrame = null; + } + markdownPreview.querySelectorAll('[data-review-anchor]').forEach(function(target) { + target.classList.remove( + 'review-target', + 'review-target--heading', + 'review-target--paragraph', + 'review-target--code', + 'review-target--diagram', + 'has-review-thread', + 'is-review-target-active' + ); + delete target.dataset.reviewAnchor; + delete target.dataset.reviewType; + delete target.dataset.reviewLabel; + delete target.dataset.reviewDuplicateCount; + delete target.dataset.reviewContext; + }); + } + + function positionReviewPins() { + reviewPinsLayoutFrame = null; + if (!reviewModeActive || !reviewPinsLayer || !previewPaneElement) return; + const paneRect = previewPaneElement.getBoundingClientRect(); + const isRtl = markdownPreview && markdownPreview.getAttribute('dir') === 'rtl'; + reviewPinsLayer.querySelectorAll('.review-target-button').forEach(function(button) { + const anchor = { + key: button.dataset.reviewAnchor, + duplicateCount: Number(button.dataset.reviewDuplicateCount) || null, + context: button.dataset.reviewContext || null + }; + const target = findReviewTarget(anchor); + if (!target) { + button.hidden = true; + return; + } + button.hidden = false; + const targetRect = target.getBoundingClientRect(); + const isDiagram = target.dataset.reviewType === 'diagram'; + const top = isDiagram ? targetRect.bottom - 40 : targetRect.top + 4; + const left = isRtl ? targetRect.left + 4 : targetRect.right - 4; + button.style.top = (top - paneRect.top + previewPaneElement.scrollTop) + 'px'; + button.style.left = (left - paneRect.left + previewPaneElement.scrollLeft) + 'px'; + button.style.transform = isRtl ? 'none' : 'translateX(-100%)'; + }); + } + + function scheduleReviewPinsLayout() { + if (!reviewModeActive || reviewPinsLayoutFrame) return; + reviewPinsLayoutFrame = requestAnimationFrame(positionReviewPins); + } + + function observeReviewPinLayout() { + if (typeof ResizeObserver === 'undefined') { + scheduleReviewPinsLayout(); + return; + } + reviewPinsResizeObserver = new ResizeObserver(scheduleReviewPinsLayout); + reviewPinsResizeObserver.observe(markdownPreview); + if (previewPaneElement) reviewPinsResizeObserver.observe(previewPaneElement); + scheduleReviewPinsLayout(); + } + + function decorateReviewTargets() { + clearReviewDecorations(); + if (!reviewModeActive || !markdownPreview || previewContainsSkeleton()) return; + + const threads = getActiveReviewThreads(); + const threadsByAnchor = new Map(); + threads.forEach(function(thread) { + const key = thread.anchor && thread.anchor.key; + if (!key) return; + if (!threadsByAnchor.has(key)) threadsByAnchor.set(key, []); + threadsByAnchor.get(key).push(thread); + }); + + const targetEntries = getReviewTargets().map(function(target) { + const type = getReviewTargetType(target); + const source = getReviewTargetSource(target, type); + const signature = type + '\u241f' + source; + return { target: target, type: type, source: source, signature: signature }; + }); + targetEntries.forEach(function(entry, index) { + const before = index > 0 ? targetEntries[index - 1].signature : 'review-start'; + const after = index < targetEntries.length - 1 ? targetEntries[index + 1].signature : 'review-end'; + entry.context = hashReviewSource(before) + ':' + hashReviewSource(after); + }); + const signatureCounts = new Map(); + targetEntries.forEach(function(entry) { + signatureCounts.set(entry.signature, (signatureCounts.get(entry.signature) || 0) + 1); + }); + + const occurrences = new Map(); + targetEntries.forEach(function(entry) { + const target = entry.target; + const type = entry.type; + const source = entry.source; + const signature = entry.signature; + const duplicateContext = entry.context; + const occurrence = occurrences.get(signature) || 0; + occurrences.set(signature, occurrence + 1); + const duplicateCount = signatureCounts.get(signature) || 1; + const context = duplicateCount > 1 ? duplicateContext : ''; + const anchorKey = type + ':' + hashReviewSource(signature) + ':' + occurrence; + const label = getReviewTargetLabel(target, type); + const targetThreads = (threadsByAnchor.get(anchorKey) || []).filter(function(thread) { + return (!thread.anchor.duplicateCount || thread.anchor.duplicateCount === duplicateCount) && + (!thread.anchor.context || thread.anchor.context === context); + }); + + target.dataset.reviewAnchor = anchorKey; + target.dataset.reviewType = type; + target.dataset.reviewLabel = label; + target.dataset.reviewDuplicateCount = String(duplicateCount); + target.dataset.reviewContext = context; + target.classList.add('review-target', 'review-target--' + type); + target.classList.toggle('has-review-thread', targetThreads.length > 0); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'review-target-button'; + button.dataset.reviewAnchor = anchorKey; + button.dataset.reviewDuplicateCount = String(duplicateCount); + button.dataset.reviewContext = context; + button.dataset.html2canvasIgnore = 'true'; + button.title = targetThreads.length > 0 ? 'Add feedback to this block' : 'Comment on this block'; + button.setAttribute('aria-label', targetThreads.length > 0 + ? 'Add feedback to ' + label + '. ' + targetThreads.length + ' review item' + (targetThreads.length === 1 ? '' : 's') + ' attached.' + : 'Add feedback to ' + label); + + const icon = document.createElement('i'); + icon.className = 'bi bi-chat-square-text'; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + if (targetThreads.length > 0) { + button.dataset.reviewCount = String(targetThreads.length); + } + if (reviewPinsLayer) reviewPinsLayer.appendChild(button); + }); + + observeReviewPinLayout(); + renderReviewPanel(); + } + + function findReviewTarget(anchor) { + const anchorKey = typeof anchor === 'string' ? anchor : (anchor && anchor.key); + if (!markdownPreview || !anchorKey) return null; + const target = Array.from(markdownPreview.querySelectorAll('[data-review-anchor]')).find(function(candidate) { + return candidate.dataset.reviewAnchor === anchorKey && !candidate.classList.contains('review-target-button'); + }) || null; + if (!target || !anchor || typeof anchor === 'string') return target; + if (anchor.duplicateCount && Number(target.dataset.reviewDuplicateCount) !== Number(anchor.duplicateCount)) return null; + if (anchor.context && target.dataset.reviewContext !== anchor.context) return null; + return target; + } + + function updateReviewCountBadges() { + const openCount = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length; + [reviewCountBadge, mobileReviewCountBadge].forEach(function(badge) { + if (!badge) return; + badge.textContent = String(openCount); + badge.hidden = openCount === 0; + badge.setAttribute('aria-label', openCount + ' open review item' + (openCount === 1 ? '' : 's')); + }); + } + + function formatReviewTime(timestamp) { + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(timestamp)); + } catch (_) { + return new Date(timestamp).toLocaleString(); + } + } + + function createReviewThreadElement(thread) { + const target = findReviewTarget(thread.anchor); + const item = document.createElement('article'); + item.className = 'review-thread' + (thread.resolved ? ' is-resolved' : '') + (!target ? ' is-orphaned' : ''); + item.dataset.reviewId = thread.id; + item.dataset.kind = thread.kind; + + const header = document.createElement('div'); + header.className = 'review-thread-header'; + const meta = document.createElement('div'); + meta.className = 'review-thread-meta'; + const kind = document.createElement('span'); + kind.className = 'review-kind-label'; + const kindIcon = document.createElement('i'); + kindIcon.className = thread.kind === 'suggestion' ? 'bi bi-lightbulb' : 'bi bi-chat-left-text'; + kindIcon.setAttribute('aria-hidden', 'true'); + kind.appendChild(kindIcon); + kind.appendChild(document.createTextNode(thread.kind === 'suggestion' ? 'Suggestion' : 'Comment')); + meta.appendChild(kind); + if (thread.resolved) { + const status = document.createElement('span'); + status.className = 'review-status-label'; + status.textContent = 'Resolved'; + meta.appendChild(status); + } + header.appendChild(meta); + item.appendChild(header); + + const anchor = document.createElement('button'); + anchor.type = 'button'; + anchor.className = 'review-thread-anchor'; + anchor.dataset.reviewAction = 'focus-anchor'; + anchor.dataset.reviewId = thread.id; + anchor.disabled = !target; + anchor.textContent = target + ? thread.anchor.label + ': ' + thread.anchor.excerpt + : 'Anchor no longer in preview - ' + thread.anchor.label + ': ' + thread.anchor.excerpt; + item.appendChild(anchor); + + const body = document.createElement('p'); + body.className = 'review-thread-body'; + body.textContent = thread.body; + item.appendChild(body); + + const time = document.createElement('time'); + time.className = 'review-thread-time'; + time.dateTime = new Date(thread.createdAt).toISOString(); + time.textContent = formatReviewTime(thread.createdAt); + item.appendChild(time); + + const actions = document.createElement('div'); + actions.className = 'review-thread-actions'; + const statusButton = document.createElement('button'); + statusButton.type = 'button'; + statusButton.className = 'review-thread-action'; + statusButton.dataset.reviewAction = 'toggle-resolved'; + statusButton.dataset.reviewId = thread.id; + statusButton.textContent = thread.resolved ? 'Reopen' : 'Resolve'; + actions.appendChild(statusButton); + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'review-thread-action is-danger'; + deleteButton.dataset.reviewAction = 'delete'; + deleteButton.dataset.reviewId = thread.id; + deleteButton.textContent = 'Delete'; + actions.appendChild(deleteButton); + item.appendChild(actions); + + return item; + } + + function renderReviewPanel() { + updateReviewCountBadges(); + if (!reviewPanel || !reviewList || !reviewEmptyState) return; + const threads = getActiveReviewThreads(); + const openCount = threads.filter(function(thread) { return !thread.resolved; }).length; + if (reviewPanelSummary) { + reviewPanelSummary.textContent = openCount === 0 + ? (threads.length === 0 ? 'No feedback yet' : 'All feedback resolved') + : openCount + ' open item' + (openCount === 1 ? '' : 's') + (threads.length === openCount ? '' : ' - ' + threads.length + ' total'); + } + if (reviewCopySummary) reviewCopySummary.disabled = threads.length === 0; + + const filteredThreads = threads.filter(function(thread) { + if (reviewFilter === 'resolved') return thread.resolved; + if (reviewFilter === 'all') return true; + return !thread.resolved; + }).sort(function(a, b) { return b.createdAt - a.createdAt; }); + + reviewList.textContent = ''; + filteredThreads.forEach(function(thread) { + reviewList.appendChild(createReviewThreadElement(thread)); + }); + reviewList.hidden = filteredThreads.length === 0; + reviewEmptyState.hidden = filteredThreads.length > 0; + if (filteredThreads.length === 0) { + const title = reviewEmptyState.querySelector('h3'); + const copy = reviewEmptyState.querySelector('p'); + if (reviewFilter === 'resolved') { + title.textContent = 'No resolved feedback'; + copy.textContent = 'Resolved comments and suggestions will appear here.'; + } else if (reviewFilter === 'all') { + title.textContent = 'No feedback yet'; + copy.textContent = 'Use the comment pins in the preview to leave feedback without changing the Markdown.'; + } else { + title.textContent = 'No open feedback'; + copy.textContent = threads.length > 0 + ? 'Everything has been resolved. Switch to Resolved or All to review earlier feedback.' + : 'Use the comment pins in the preview to leave feedback without changing the Markdown.'; + } + } + } + + function updateReviewComposerKind(kind) { + reviewComposerKind = kind === 'suggestion' ? 'suggestion' : 'comment'; + document.querySelectorAll('[data-review-kind]').forEach(function(button) { + const active = button.dataset.reviewKind === reviewComposerKind; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + if (reviewFeedbackLabel) { + reviewFeedbackLabel.textContent = reviewComposerKind === 'suggestion' ? 'Suggested change' : 'Comment'; + } + if (reviewFeedbackInput) { + reviewFeedbackInput.placeholder = reviewComposerKind === 'suggestion' + ? 'Describe the change you recommend without editing the source...' + : 'Leave a focused comment for the author...'; + } + if (reviewFeedbackSubmit) { + reviewFeedbackSubmit.textContent = reviewComposerKind === 'suggestion' ? 'Add suggestion' : 'Add comment'; + } + } + + function focusReviewPin(anchor) { + const target = findReviewTarget(anchor); + if (!target || !reviewPinsLayer) return false; + const button = Array.from(reviewPinsLayer.querySelectorAll('.review-target-button')).find(function(candidate) { + return candidate.dataset.reviewAnchor === target.dataset.reviewAnchor && + candidate.dataset.reviewContext === target.dataset.reviewContext; + }) || null; + if (!button) return false; + button.focus({ preventScroll: true }); + return true; + } + + function closeReviewComposer(options) { + options = options || {}; + const returnAnchor = activeReviewAnchor; + if (activeReviewAnchor) { + const previousTarget = findReviewTarget(activeReviewAnchor); + if (previousTarget) previousTarget.classList.remove('is-review-target-active'); + } + activeReviewAnchor = null; + if (reviewComposer) reviewComposer.hidden = true; + if (reviewFeedbackInput) reviewFeedbackInput.value = ''; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true; + if (options.restoreFocus && reviewModeActive && !focusReviewPin(returnAnchor) && reviewPanelClose) { + reviewPanelClose.focus(); + } + } + + function openReviewComposer(target) { + if (!target) return; + const descriptor = getReviewAnchorDescriptor(target); + if (!descriptor) return; + closeReviewComposer(); + activeReviewAnchor = descriptor; + target.classList.add('is-review-target-active'); + if (reviewComposerAnchor) { + reviewComposerAnchor.textContent = descriptor.label + ': ' + descriptor.excerpt; + } + updateReviewComposerKind(reviewComposerKind); + if (reviewComposer) reviewComposer.hidden = false; + if (reviewFeedbackInput) { + reviewFeedbackInput.value = ''; + reviewFeedbackInput.focus(); + } + } + + function persistReviewThreads(message) { + saveTabsToStorage(tabs); + decorateReviewTargets(); + renderReviewPanel(); + if (message) announceToScreenReader(message); + } + + function submitReviewFeedback() { + if (!activeReviewAnchor || !reviewFeedbackInput) return; + const body = reviewFeedbackInput.value.trim(); + if (!body) return; + const returnAnchor = Object.assign({}, activeReviewAnchor); + getActiveReviewThreads().push({ + id: createReviewId(), + anchor: Object.assign({}, activeReviewAnchor), + kind: reviewComposerKind, + body: body.slice(0, REVIEW_TEXT_LIMIT), + createdAt: Date.now(), + updatedAt: Date.now(), + resolved: false + }); + const label = reviewComposerKind === 'suggestion' ? 'Suggestion added.' : 'Comment added.'; + closeReviewComposer(); + persistReviewThreads(label); + focusReviewPin(returnAnchor); + } + + function focusReviewAnchor(thread) { + const target = thread && thread.anchor ? findReviewTarget(thread.anchor) : null; + if (!target) return; + markdownPreview.querySelectorAll('.is-review-target-active').forEach(function(node) { + node.classList.remove('is-review-target-active'); + }); + target.classList.add('is-review-target-active'); + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + focusReviewPin(thread.anchor); + clearTimeout(focusReviewAnchor._timeoutId); + focusReviewAnchor._timeoutId = setTimeout(function() { + if (!activeReviewAnchor || activeReviewAnchor.key !== thread.anchor.key) { + target.classList.remove('is-review-target-active'); + } + }, 1800); + } + + function buildReviewSummary() { + const tab = getActiveReviewTab(); + const threads = getActiveReviewThreads().slice().sort(function(a, b) { return a.createdAt - b.createdAt; }); + const lines = ['# Review: ' + (tab && tab.title ? tab.title : 'Markdown document'), '']; + threads.forEach(function(thread, index) { + const kind = thread.kind === 'suggestion' ? 'Suggestion' : 'Comment'; + lines.push((index + 1) + '. **' + kind + '** - ' + thread.anchor.label + (thread.resolved ? ' _(resolved)_' : '')); + if (thread.anchor.excerpt) lines.push(' > ' + thread.anchor.excerpt.replace(/\n/g, ' ')); + thread.body.split('\n').forEach(function(line) { + lines.push(' ' + line); + }); + lines.push(''); + }); + return lines.join('\n').trim(); + } + + async function copyReviewSummary() { + if (getActiveReviewThreads().length === 0) return; + try { + await copyTextToClipboard(buildReviewSummary()); + announceToScreenReader('Review summary copied.'); + if (reviewCopySummary) { + const icon = reviewCopySummary.querySelector('i'); + if (icon) icon.className = 'bi bi-check-lg'; + clearTimeout(copyReviewSummary._timeoutId); + copyReviewSummary._timeoutId = setTimeout(function() { + if (icon) icon.className = 'bi bi-clipboard'; + }, 1400); + } + } catch (error) { + console.error('Review summary copy failed:', error); + alert('Failed to copy review summary: ' + error.message); + } + } + + function updateReviewBackdrop() { + if (!reviewPanelBackdrop) return; + reviewPanelBackdrop.hidden = true; + } + + function setReviewMode(enabled, options) { + options = options || {}; + const nextState = Boolean(enabled); + const wasActive = reviewModeActive; + if (nextState && !wasActive) { + reviewPreviousViewModes.set(activeTabId, currentViewMode || 'split'); + } + reviewModeActive = nextState; + if (reviewModeActive && currentViewMode !== 'preview') { + setViewMode('preview'); + } + if (reviewPanel) reviewPanel.hidden = !reviewModeActive; + if (reviewPinsLayer) reviewPinsLayer.hidden = !reviewModeActive; + if (contentContainer) contentContainer.classList.toggle('is-reviewing', reviewModeActive); + [reviewToggle, mobileReviewToggle].forEach(function(button) { + if (!button) return; + button.classList.toggle('is-active', reviewModeActive); + button.setAttribute('aria-expanded', reviewModeActive ? 'true' : 'false'); + button.setAttribute('aria-pressed', reviewModeActive ? 'true' : 'false'); + }); + updateReviewBackdrop(); + updateLiveEditorAccess(); + if (reviewModeActive) { + decorateReviewTargets(); + renderReviewPanel(); + if (options.focusPanel && reviewPanelClose) reviewPanelClose.focus(); + } else { + closeReviewComposer(); + clearReviewDecorations(); + const restoreMode = reviewPreviousViewModes.get(activeTabId); + reviewPreviousViewModes.clear(); + if (restoreMode && restoreMode !== currentViewMode) { + setViewMode(restoreMode); + saveCurrentTabState(); + } + if (options.restoreFocus) { + const returnTarget = window.innerWidth < 768 ? mobileMenuToggle : reviewToggle; + if (returnTarget) returnTarget.focus(); + } + } + } + + function initReviewMode() { + tabs.forEach(function(tab) { + tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); + }); + updateReviewCountBadges(); + renderReviewPanel(); + + if (reviewToggle) { + reviewToggle.addEventListener('click', function() { + setReviewMode(!reviewModeActive, { focusPanel: !reviewModeActive }); + }); + } + if (mobileReviewToggle) { + mobileReviewToggle.addEventListener('click', function() { + closeMobileMenu(); + setReviewMode(!reviewModeActive, { focusPanel: !reviewModeActive }); + }); + } + if (reviewPanelClose) { + reviewPanelClose.addEventListener('click', function() { + setReviewMode(false, { restoreFocus: true }); + }); + } + if (reviewPanelBackdrop) { + reviewPanelBackdrop.addEventListener('click', function() { + setReviewMode(false, { restoreFocus: true }); + }); + } + if (reviewComposerCancel) { + reviewComposerCancel.addEventListener('click', function() { + closeReviewComposer({ restoreFocus: true }); + }); + } + if (reviewFeedbackInput) { + reviewFeedbackInput.addEventListener('input', function() { + const length = reviewFeedbackInput.value.length; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = length + ' / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = reviewFeedbackInput.value.trim().length === 0; + }); + reviewFeedbackInput.addEventListener('keydown', function(event) { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + submitReviewFeedback(); + } + }); + } + if (reviewFeedbackSubmit) reviewFeedbackSubmit.addEventListener('click', submitReviewFeedback); + if (reviewCopySummary) reviewCopySummary.addEventListener('click', copyReviewSummary); + + document.querySelectorAll('[data-review-kind]').forEach(function(button) { + button.addEventListener('click', function() { + updateReviewComposerKind(button.dataset.reviewKind); + if (reviewFeedbackInput) reviewFeedbackInput.focus(); + }); + }); + document.querySelectorAll('[data-review-filter]').forEach(function(button) { + button.addEventListener('click', function() { + reviewFilter = button.dataset.reviewFilter; + document.querySelectorAll('[data-review-filter]').forEach(function(filterButton) { + const active = filterButton === button; + filterButton.classList.toggle('is-active', active); + filterButton.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + renderReviewPanel(); + }); + }); + + if (reviewPinsLayer) { + reviewPinsLayer.addEventListener('click', function(event) { + const button = event.target.closest('.review-target-button'); + if (!button || !reviewModeActive) return; + event.preventDefault(); + event.stopPropagation(); + const target = findReviewTarget({ + key: button.dataset.reviewAnchor, + duplicateCount: Number(button.dataset.reviewDuplicateCount) || null, + context: button.dataset.reviewContext || null + }); + openReviewComposer(target); + }); + } + + if (previewPaneElement) { + previewPaneElement.addEventListener('scroll', scheduleReviewPinsLayout, { passive: true }); + } + + if (reviewList) { + reviewList.addEventListener('click', function(event) { + const actionButton = event.target.closest('[data-review-action]'); + if (!actionButton) return; + const thread = getActiveReviewThreads().find(function(item) { + return item.id === actionButton.dataset.reviewId; + }); + if (!thread) return; + const action = actionButton.dataset.reviewAction; + if (action === 'focus-anchor') { + focusReviewAnchor(thread); + return; + } + if (action === 'toggle-resolved') { + thread.resolved = !thread.resolved; + thread.updatedAt = Date.now(); + persistReviewThreads(thread.resolved ? 'Review item resolved.' : 'Review item reopened.'); + return; + } + if (action === 'delete' && confirm('Delete this review item?')) { + const index = getActiveReviewThreads().indexOf(thread); + if (index >= 0) getActiveReviewThreads().splice(index, 1); + persistReviewThreads('Review item deleted.'); + } + }); + } + + window.addEventListener('resize', updateReviewBackdrop); + document.addEventListener('keydown', function(event) { + if (event.key !== 'Escape' || !reviewModeActive || activeModal) return; + if (reviewComposer && !reviewComposer.hidden) { + event.preventDefault(); + closeReviewComposer({ restoreFocus: true }); + } else { + event.preventDefault(); + setReviewMode(false, { restoreFocus: true }); + } + }); + } + function refreshLiveEditorUi() { if (!liveShareUiReady) return; updateLiveAccessDisplay(); @@ -2409,7 +3269,10 @@ document.addEventListener("DOMContentLoaded", async function () { function loadTabsFromStorage() { try { - return stripTemporaryTabs(JSON.parse(localStorage.getItem(STORAGE_KEY)) || []); + return stripTemporaryTabs(JSON.parse(localStorage.getItem(STORAGE_KEY)) || []).map(function(tab) { + tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); + return tab; + }); } catch (e) { return []; } @@ -2495,6 +3358,7 @@ document.addEventListener("DOMContentLoaded", async function () { content: content, scrollPos: 0, viewMode: viewMode, + reviewThreads: [], createdAt: Date.now() }; } @@ -2882,7 +3746,9 @@ document.addEventListener("DOMContentLoaded", async function () { if (!tab) return; tab.content = markdownEditor.value; tab.scrollPos = markdownEditor.scrollTop; - tab.viewMode = currentViewMode || 'split'; + tab.viewMode = reviewModeActive && reviewPreviousViewModes.has(activeTabId) + ? reviewPreviousViewModes.get(activeTabId) + : (currentViewMode || 'split'); if (liveCollaboration && liveCollaboration.tabId === activeTabId) { return; } @@ -2890,6 +3756,9 @@ document.addEventListener("DOMContentLoaded", async function () { } function restoreViewMode(mode) { + if (reviewModeActive && activeTabId && !reviewPreviousViewModes.has(activeTabId)) { + reviewPreviousViewModes.set(activeTabId, mode || 'split'); + } currentViewMode = null; setViewMode(mode || 'split'); } @@ -2897,6 +3766,8 @@ document.addEventListener("DOMContentLoaded", async function () { function switchTab(tabId) { if (tabId === activeTabId) return; saveCurrentTabState(); + closeReviewComposer(); + clearReviewDecorations(); // Clear typing timeout and reset tracking for the new tab if (typingTimeout) { @@ -2920,6 +3791,7 @@ document.addEventListener("DOMContentLoaded", async function () { restoreViewMode(tab.viewMode); refreshLiveEditorUi(); renderMarkdown(); + renderReviewPanel(); requestAnimationFrame(function() { markdownEditor.scrollTop = tab.scrollPos || 0; updateLiveCursorPosition(); @@ -2953,6 +3825,10 @@ document.addEventListener("DOMContentLoaded", async function () { const idx = tabs.findIndex(function(t) { return t.id === tabId; }); if (idx === -1) return; + if (activeTabId === tabId) { + closeReviewComposer(); + clearReviewDecorations(); + } shareSnapshotViewOnlyTabIds.delete(tabId); // Clean up history of the closed tab @@ -2986,6 +3862,8 @@ document.addEventListener("DOMContentLoaded", async function () { } saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); + closeReviewComposer(); + renderReviewPanel(); } function deleteTab(tabId) { @@ -3120,6 +3998,8 @@ document.addEventListener("DOMContentLoaded", async function () { function doReset() { closeAppModal(modal); cleanup(); + closeReviewComposer(); + clearReviewDecorations(); disconnectLiveCollaboration({ restoreOriginal: false, silent: true }); applyShareSnapshotAccessMode('edit'); resetShareSnapshotLink(); @@ -3136,6 +4016,8 @@ document.addEventListener("DOMContentLoaded", async function () { refreshLiveEditorUi(); renderMarkdown(); renderTabBar(tabs, activeTabId); + closeReviewComposer(); + renderReviewPanel(); } function doCancel() { @@ -3465,6 +4347,9 @@ document.addEventListener("DOMContentLoaded", async function () { const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, { reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force, }); + invalidateReviewTargetSourceSnapshots( + patchResult && patchResult.fullReplace ? [markdownPreview] : ((patchResult && patchResult.updatedNodes) || []) + ); applyReferencePreviewLinks(markdownPreview, referenceData.definitions); enhanceGitHubAlerts(markdownPreview); @@ -4817,6 +5702,7 @@ ${selector} .arrowheadPath { const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '') || /```math\b/.test(rawVal || ''); if (hasMath) { const mathTargets = getMathJaxTypesetTargets(roots); + snapshotMathReviewTargetSources(mathTargets); if (mathTargets.length > 0) { ensureMathJaxReady().then(function() { if (context.renderId !== previewRenderGeneration) return; @@ -4827,6 +5713,7 @@ ${selector} .arrowheadPath { } } + decorateReviewTargets(); updateDocumentStats(); updateFindHighlights(); cleanupImageObjectUrls(); @@ -5685,6 +6572,10 @@ ${selector} .arrowheadPath { mode = 'preview'; announceToScreenReader(getEditorReadOnlyMessage()); } + if (reviewModeActive && mode !== 'preview') { + mode = 'preview'; + announceToScreenReader('Review mode uses the read-only preview.'); + } if (mode === currentViewMode) return; const previousMode = currentViewMode; @@ -10444,6 +11335,7 @@ ${selector} .arrowheadPath { } initTabs(); + initReviewMode(); if (loadGlobalState().syncScrollingEnabled === false) toggleSyncScrolling(); updateMobileStats(); updateFindHighlights(); @@ -13338,7 +14230,7 @@ ${selector} .arrowheadPath { } function canMutateEditor() { - return !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive(); + return !reviewModeActive && !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive(); } function isLiveMutatingAction(action) { @@ -13377,16 +14269,17 @@ ${selector} .arrowheadPath { const liveShareDocumentActive = isLiveShareDocumentActive(); const liveShareGuestDocumentActive = liveShareDocumentActive && !isLiveShareHostDocumentActive(); const viewOnly = isLiveViewOnlyParticipant() || snapshotViewOnly; + const sourceReadOnly = viewOnly || reviewModeActive; if (markdownEditor) { - markdownEditor.readOnly = viewOnly; - markdownEditor.setAttribute('aria-readonly', viewOnly ? 'true' : 'false'); - markdownEditor.classList.toggle('live-share-readonly-editor', viewOnly); - markdownEditor.title = viewOnly ? getEditorReadOnlyMessage() : ''; + markdownEditor.readOnly = sourceReadOnly; + markdownEditor.setAttribute('aria-readonly', sourceReadOnly ? 'true' : 'false'); + markdownEditor.classList.toggle('live-share-readonly-editor', sourceReadOnly); + markdownEditor.title = sourceReadOnly ? getEditorReadOnlyMessage() : ''; } viewModeButtons.forEach(function(button) { const mode = button.getAttribute('data-view-mode'); - const shouldDisable = snapshotViewOnly && mode !== 'preview'; + const shouldDisable = (snapshotViewOnly || reviewModeActive) && mode !== 'preview'; if (shouldDisable) { button.dataset.shareSnapshotDisabled = 'true'; button.disabled = true; @@ -13400,7 +14293,7 @@ ${selector} .arrowheadPath { mobileViewModeButtons.forEach(function(button) { const mode = button.getAttribute('data-mode'); - const shouldDisable = snapshotViewOnly && mode !== 'preview'; + const shouldDisable = (snapshotViewOnly || reviewModeActive) && mode !== 'preview'; if (shouldDisable) { button.dataset.shareSnapshotDisabled = 'true'; button.disabled = true; @@ -13491,10 +14384,10 @@ ${selector} .arrowheadPath { }); if (markdownFormatToolbar) { - markdownFormatToolbar.classList.toggle('is-live-view-only', viewOnly); + markdownFormatToolbar.classList.toggle('is-live-view-only', sourceReadOnly); markdownFormatToolbar.querySelectorAll('[data-md-action]').forEach(function(button) { const action = button.getAttribute('data-md-action'); - const shouldDisable = viewOnly && isLiveMutatingAction(action); + const shouldDisable = sourceReadOnly && isLiveMutatingAction(action); if (shouldDisable) { button.dataset.liveViewOnlyDisabled = 'true'; button.disabled = true; @@ -13507,7 +14400,7 @@ ${selector} .arrowheadPath { button.setAttribute('aria-disabled', 'false'); } }); - if (!viewOnly) { + if (!sourceReadOnly) { updateUndoRedoButtons(); } } diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 488e43c9..9846a973 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -23,6 +23,12 @@ /* Code block background for light mode */ --skeleton-bg: #e2e8f0; --skeleton-glow: rgba(255, 255, 255, 0.65); + --review-accent: #8250df; + --review-accent-strong: #6639ba; + --review-accent-soft: #f3effb; + --review-suggestion: #9a6700; + --review-suggestion-soft: #fff8c5; + --review-panel-shadow: -12px 0 32px rgba(31, 35, 40, 0.16); /* Find & Replace Panel custom properties (PERF-010 consolidated) */ --fr-bg: rgba(255, 255, 255, 0.95); @@ -63,6 +69,12 @@ /* Code block background for dark mode */ --skeleton-bg: #2d3139; --skeleton-glow: rgba(255, 255, 255, 0.08); + --review-accent: #a371f7; + --review-accent-strong: #bc8cff; + --review-accent-soft: rgba(163, 113, 247, 0.15); + --review-suggestion: #d29922; + --review-suggestion-soft: rgba(187, 128, 9, 0.16); + --review-panel-shadow: -12px 0 32px rgba(0, 0, 0, 0.45); /* Find & Replace Panel custom properties for dark mode */ --fr-bg: rgba(28, 33, 40, 0.98); @@ -5984,6 +5996,588 @@ html[data-theme="dark"] .mermaid svg { isolation: isolate; } +/* ======================================== + COMMENTS & SUGGESTIONS + ======================================== */ +.review-toggle { + position: relative; +} + +.review-toggle.is-active { + color: var(--review-accent-strong); + background: var(--review-accent-soft); + border-color: var(--review-accent); +} + +.review-count-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #ffffff; + background: var(--review-accent); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; +} + +.review-count-badge[hidden], +.review-panel[hidden], +.review-panel-backdrop[hidden], +.review-composer[hidden] { + display: none !important; +} + +#mobile-review-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.review-panel { + position: absolute; + z-index: 85; + inset-block: 0; + inset-inline-end: 0; + width: min(380px, calc(100% - 48px)); + display: flex; + flex-direction: column; + overflow: hidden; + color: var(--text-color); + background: var(--bg-color); + border-inline-start: 1px solid var(--border-color); + box-shadow: var(--review-panel-shadow); +} + +.review-panel-backdrop { + position: absolute; + z-index: 84; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgba(31, 35, 40, 0.38); + cursor: default; +} + +.review-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 18px 14px; + border-bottom: 1px solid var(--border-color); +} + +.review-panel-eyebrow { + margin: 0 0 3px; + color: var(--review-accent-strong); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.review-panel-header h2, +.review-composer-heading h3, +.review-empty-state h3 { + margin: 0; + color: var(--text-color); + font-weight: 650; +} + +.review-panel-header h2 { + font-size: 1rem; +} + +.review-panel-summary { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.review-icon-btn { + width: 32px; + height: 32px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} + +.review-icon-btn:hover:not(:disabled) { + color: var(--text-color); + background: var(--button-hover); + border-color: var(--border-color); +} + +.review-icon-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.review-panel-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); +} + +.review-filter-group, +.review-kind-group { + display: inline-flex; + align-items: center; + padding: 2px; + background: var(--button-bg); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.review-filter-btn, +.review-kind-btn { + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; + font-weight: 600; +} + +.review-filter-btn { + padding: 5px 10px; + font-size: 0.74rem; +} + +.review-filter-btn.is-active, +.review-kind-btn.is-active { + color: var(--review-accent-strong); + background: var(--bg-color); + box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12); +} + +.review-composer { + margin: 14px 14px 4px; + padding: 14px; + background: var(--review-accent-soft); + border: 1px solid color-mix(in srgb, var(--review-accent) 45%, var(--border-color)); + border-radius: 10px; +} + +.review-composer-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.review-composer-heading h3 { + font-size: 0.9rem; +} + +.review-anchor-preview { + display: -webkit-box; + margin: 10px 0 12px; + overflow: hidden; + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.review-kind-group { + width: 100%; + margin-bottom: 12px; +} + +.review-kind-btn { + flex: 1; + padding: 7px 8px; + font-size: 0.76rem; +} + +.review-kind-btn[data-review-kind="suggestion"].is-active { + color: var(--review-suggestion); + background: var(--review-suggestion-soft); +} + +.review-feedback-label { + display: block; + margin-bottom: 6px; + color: var(--text-color); + font-size: 0.76rem; + font-weight: 650; +} + +.review-feedback-input { + width: 100%; + min-height: 92px; + padding: 9px 10px; + resize: vertical; + color: var(--text-color); + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 7px; + font: inherit; + font-size: 0.82rem; + line-height: 1.45; +} + +.review-feedback-input:focus { + border-color: var(--review-accent); + outline: 2px solid color-mix(in srgb, var(--review-accent) 24%, transparent); + outline-offset: 0; +} + +.review-composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 9px; +} + +.review-feedback-count { + color: var(--text-secondary); + font-size: 0.7rem; +} + +.review-primary-btn { + min-height: 32px; + padding: 6px 12px; + color: #ffffff; + background: var(--review-accent); + border: 1px solid var(--review-accent-strong); + border-radius: 6px; + cursor: pointer; + font-size: 0.76rem; + font-weight: 650; +} + +.review-primary-btn:hover:not(:disabled) { + background: var(--review-accent-strong); +} + +.review-primary-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.review-list { + flex: 1; + overflow-y: auto; + padding: 10px 14px 18px; +} + +.review-thread { + margin-bottom: 10px; + padding: 12px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-inline-start: 3px solid var(--review-accent); + border-radius: 8px; +} + +.review-thread[data-kind="suggestion"] { + border-inline-start-color: var(--review-suggestion); +} + +.review-thread.is-resolved { + opacity: 0.72; +} + +.review-thread.is-orphaned { + border-style: dashed; +} + +.review-thread-header, +.review-thread-meta, +.review-thread-actions { + display: flex; + align-items: center; +} + +.review-thread-header { + justify-content: space-between; + gap: 8px; +} + +.review-thread-meta { + min-width: 0; + gap: 6px; +} + +.review-kind-label, +.review-status-label { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + font-size: 0.67rem; + font-weight: 700; + line-height: 1; +} + +.review-kind-label { + padding: 5px 7px; + color: var(--review-accent-strong); + background: var(--review-accent-soft); +} + +.review-thread[data-kind="suggestion"] .review-kind-label { + color: var(--review-suggestion); + background: var(--review-suggestion-soft); +} + +.review-status-label { + color: var(--text-secondary); +} + +.review-thread-anchor { + width: 100%; + margin: 9px 0 8px; + padding: 0; + overflow: hidden; + color: var(--text-secondary); + background: transparent; + border: 0; + cursor: pointer; + font-size: 0.72rem; + line-height: 1.35; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-thread-anchor:hover:not(:disabled) { + color: var(--review-accent-strong); + text-decoration: underline; +} + +.review-thread-anchor:disabled { + cursor: default; + white-space: normal; +} + +.review-thread-body { + margin: 0; + color: var(--text-color); + font-size: 0.82rem; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.review-thread-time { + display: block; + margin-top: 9px; + color: var(--text-secondary); + font-size: 0.67rem; +} + +.review-thread-actions { + justify-content: flex-end; + gap: 4px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.review-thread-action { + padding: 5px 7px; + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: 5px; + cursor: pointer; + font-size: 0.7rem; + font-weight: 600; +} + +.review-thread-action:hover { + color: var(--text-color); + background: var(--button-hover); +} + +.review-thread-action.is-danger:hover { + color: var(--color-danger-fg); +} + +.review-empty-state { + margin: auto; + padding: 30px 26px; + color: var(--text-secondary); + text-align: center; +} + +.review-empty-state i { + display: block; + margin-bottom: 10px; + color: var(--review-accent); + font-size: 1.7rem; +} + +.review-empty-state h3 { + font-size: 0.9rem; +} + +.review-empty-state p { + margin: 7px auto 0; + max-width: 270px; + font-size: 0.76rem; + line-height: 1.45; +} + +#markdown-preview .review-target { + border-radius: 5px; +} + +#markdown-preview .review-target:not(.review-target--diagram) { + padding-inline-end: 44px; +} + +#markdown-preview .review-target--diagram { + padding-block-end: 44px; +} + +#markdown-preview .review-target.is-review-target-active { + outline: 2px solid var(--review-accent); + outline-offset: 4px; +} + +#markdown-preview .review-target.has-review-thread { + box-shadow: inset 3px 0 0 var(--review-accent); +} + +[dir="rtl"] #markdown-preview .review-target.has-review-thread { + box-shadow: inset -3px 0 0 var(--review-accent); +} + +.review-pins-layer { + position: absolute; + z-index: 20; + inset: 0; + overflow: visible; + pointer-events: none; +} + +.review-pins-layer[hidden] { + display: none !important; +} + +.review-target-button { + position: absolute; + z-index: 8; + min-width: 30px; + height: 30px; + padding: 0 7px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + color: #ffffff; + background: var(--review-accent); + border: 1px solid var(--review-accent-strong); + border-radius: 999px; + box-shadow: 0 2px 8px rgba(31, 35, 40, 0.22); + cursor: pointer; + font-size: 0.68rem; + font-weight: 700; + opacity: 1; + pointer-events: auto; + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.review-target-button[data-review-count]::after { + content: attr(data-review-count); +} + +.review-target-button:hover { + background: var(--review-accent-strong); +} + +.review-filter-btn:focus-visible, +.review-kind-btn:focus-visible, +.review-icon-btn:focus-visible, +.review-primary-btn:focus-visible, +.review-thread-action:focus-visible, +.review-thread-anchor:focus-visible, +.review-target-button:focus-visible { + outline: 2px solid var(--review-accent); + outline-offset: 2px; +} + +@media (min-width: 1080px) { + .review-panel { + position: relative; + inset: auto; + width: 380px; + height: auto; + flex: 0 0 380px; + box-shadow: none; + } +} + +@media (min-width: 768px) and (max-width: 1079px) { + .review-panel { + width: min(390px, 100%); + } + + .content-container.is-reviewing .preview-pane { + padding-inline-end: 410px; + } +} + +@media (max-width: 767px) { + .review-panel { + inset-block-start: auto; + inset-block-end: 0; + width: 100%; + height: min(56%, 560px); + border-inline-start: 0; + border-top: 1px solid var(--border-color); + box-shadow: 0 -12px 32px rgba(31, 35, 40, 0.2); + } + + .content-container.is-reviewing .preview-pane { + padding-bottom: calc(56vh + 20px); + } + + .review-panel-header { + padding-top: 14px; + } + + .review-target-button { + min-width: 44px; + height: 44px; + } + + #markdown-preview .review-target:not(.review-target--diagram) { + padding-inline-end: 56px; + } + + #markdown-preview .review-target--diagram { + padding-block-end: 56px; + } +} + +@media (prefers-reduced-motion: reduce) { + .review-target-button { + transition: none; + } +} + @@ -6011,10 +6605,28 @@ html[data-theme="dark"] .mermaid svg { .plantuml-toolbar, .d2-toolbar, .graphviz-toolbar, - .stl-toolbar { + .stl-toolbar, + .review-panel, + .review-panel-backdrop, + .review-pins-layer, + .review-target-button { display: none !important; } + #markdown-preview .review-target, + #markdown-preview .review-target.has-review-thread, + [dir="rtl"] #markdown-preview .review-target.has-review-thread { + outline: none !important; + box-shadow: none !important; + padding-inline-end: 0 !important; + padding-block-end: 0 !important; + } + + .content-container.is-reviewing .preview-pane { + padding-inline-end: 20px !important; + padding-bottom: 20px !important; + } + .preview-pane { width: 100% !important; max-width: 100% !important; diff --git a/index.html b/index.html index c8a6bd5b..a062a3fc 100644 --- a/index.html +++ b/index.html @@ -202,6 +202,11 @@

Markdown Viewer - Online Live Share + + - + + + + diff --git a/script.js b/script.js index e692ed31..d66c996c 100644 --- a/script.js +++ b/script.js @@ -263,6 +263,16 @@ document.addEventListener("DOMContentLoaded", async function () { const shareSnapshotViewOnlyTabIds = new Set(); const SHARE_SNAPSHOT_TAB_KIND = 'share-snapshot'; const APP_VERSION = '3.9.0'; + const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, pre, .diagram-viewer, .geojson-container, .topojson-container, .stl-container'; + const REVIEW_TEXT_LIMIT = 2000; + let reviewModeActive = false; + const reviewPreviousViewModes = new Map(); + let reviewFilter = 'open'; + let reviewComposerKind = 'comment'; + let activeReviewAnchor = null; + let reviewPinsResizeObserver = null; + let reviewPinsLayoutFrame = null; + let reviewTargetSourceSnapshots = new WeakMap(); let activeModal = null; let lastFocusedElement = null; let isFindModalOpen = false; @@ -292,6 +302,7 @@ document.addEventListener("DOMContentLoaded", async function () { const markdownEditor = document.getElementById("markdown-editor"); const markdownPreview = document.getElementById("markdown-preview"); + const reviewPinsLayer = document.getElementById('review-pins-layer'); const markdownFormatToolbar = document.getElementById("markdown-format-toolbar"); const themeToggle = document.getElementById("theme-toggle"); const directionToggle = document.getElementById("direction-toggle"); @@ -318,6 +329,24 @@ document.addEventListener("DOMContentLoaded", async function () { const readingTimeElement = document.getElementById("reading-time"); const wordCountElement = document.getElementById("word-count"); const charCountElement = document.getElementById("char-count"); + const reviewToggle = document.getElementById('review-toggle'); + const mobileReviewToggle = document.getElementById('mobile-review-toggle'); + const reviewCountBadge = document.getElementById('review-count-badge'); + const mobileReviewCountBadge = document.getElementById('mobile-review-count-badge'); + const reviewPanel = document.getElementById('review-panel'); + const reviewPanelBackdrop = document.getElementById('review-panel-backdrop'); + const reviewPanelClose = document.getElementById('review-panel-close'); + const reviewPanelSummary = document.getElementById('review-panel-summary'); + const reviewCopySummary = document.getElementById('review-copy-summary'); + const reviewComposer = document.getElementById('review-composer'); + const reviewComposerAnchor = document.getElementById('review-composer-anchor'); + const reviewComposerCancel = document.getElementById('review-composer-cancel'); + const reviewFeedbackLabel = document.getElementById('review-feedback-label'); + const reviewFeedbackInput = document.getElementById('review-feedback-input'); + const reviewFeedbackCount = document.getElementById('review-feedback-count'); + const reviewFeedbackSubmit = document.getElementById('review-feedback-submit'); + const reviewList = document.getElementById('review-list'); + const reviewEmptyState = document.getElementById('review-empty-state'); // View Mode Elements - Story 1.1 const contentContainer = document.querySelector(".content-container"); @@ -395,6 +424,9 @@ document.addEventListener("DOMContentLoaded", async function () { } function getEditorReadOnlyMessage() { + if (reviewModeActive) { + return 'Review mode keeps the Markdown source read only.'; + } if (isShareSnapshotViewOnlyActive()) { return 'This shared snapshot is view only.'; } @@ -662,6 +694,7 @@ document.addEventListener("DOMContentLoaded", async function () { function applyDirectionToContent(direction) { if (markdownEditor) markdownEditor.setAttribute("dir", direction); if (markdownPreview) markdownPreview.setAttribute("dir", direction); + scheduleReviewPinsLayout(); } applyDirectionToContent(initialDirection); updateDirectionToggleUI(initialDirection); @@ -2377,6 +2410,833 @@ document.addEventListener("DOMContentLoaded", async function () { const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit"); const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay"); + // ======================================== + // COMMENTS & SUGGESTIONS + // ======================================== + + function normalizeReviewThreads(rawThreads) { + if (!Array.isArray(rawThreads)) return []; + return rawThreads.slice(-500).map(function(thread) { + if (!thread || typeof thread !== 'object' || !thread.anchor || typeof thread.anchor.key !== 'string') { + return null; + } + const body = typeof thread.body === 'string' ? thread.body.trim().slice(0, REVIEW_TEXT_LIMIT) : ''; + if (!body) return null; + const createdAt = Number.isFinite(Number(thread.createdAt)) ? Number(thread.createdAt) : Date.now(); + const updatedAt = Number.isFinite(Number(thread.updatedAt)) ? Number(thread.updatedAt) : createdAt; + return { + id: typeof thread.id === 'string' && thread.id ? thread.id.slice(0, 120) : createReviewId(), + anchor: { + key: thread.anchor.key.slice(0, 240), + type: typeof thread.anchor.type === 'string' ? thread.anchor.type.slice(0, 40) : 'block', + label: typeof thread.anchor.label === 'string' ? thread.anchor.label.slice(0, 120) : 'Document block', + excerpt: typeof thread.anchor.excerpt === 'string' ? thread.anchor.excerpt.slice(0, 240) : '', + duplicateCount: Number.isFinite(Number(thread.anchor.duplicateCount)) + ? Math.max(1, Number(thread.anchor.duplicateCount)) + : null, + context: typeof thread.anchor.context === 'string' ? thread.anchor.context.slice(0, 80) : null + }, + kind: thread.kind === 'suggestion' ? 'suggestion' : 'comment', + body: body, + createdAt: createdAt, + updatedAt: updatedAt, + resolved: thread.resolved === true + }; + }).filter(Boolean); + } + + function createReviewId() { + if (window.crypto && typeof window.crypto.randomUUID === 'function') { + return 'review_' + window.crypto.randomUUID(); + } + return 'review_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); + } + + function getActiveReviewTab() { + return tabs.find(function(tab) { return tab.id === activeTabId; }) || null; + } + + function getActiveReviewThreads() { + const tab = getActiveReviewTab(); + if (!tab) return []; + if (!Array.isArray(tab.reviewThreads)) { + tab.reviewThreads = []; + } + return tab.reviewThreads; + } + + function decodeReviewSource(value) { + if (!value) return ''; + try { + return decodeURIComponent(value); + } catch (_) { + return String(value); + } + } + + function getReviewTargetType(target) { + if (!target) return 'block'; + if (target.matches('.diagram-viewer, .geojson-container, .topojson-container, .stl-container')) return 'diagram'; + if (target.tagName === 'PRE') return 'code'; + if (/^H[1-6]$/.test(target.tagName)) return 'heading'; + if (target.tagName === 'P') return 'paragraph'; + return 'block'; + } + + function normalizeReviewSource(source, preserveLines) { + const normalized = String(source || '').replace(/\r\n?/g, '\n'); + if (!preserveLines) { + return normalized.replace(/\s+/g, ' ').trim(); + } + return normalized.split('\n').map(function(line) { + return line.replace(/[ \t]+$/g, ''); + }).join('\n').trim(); + } + + function computeReviewTargetSource(target, type) { + if (!target) return ''; + if (type === 'diagram') { + const sourceNode = target.hasAttribute('data-original-code') + ? target + : target.querySelector('[data-original-code]'); + return normalizeReviewSource(sourceNode ? decodeReviewSource(sourceNode.getAttribute('data-original-code')) : '', true); + } + if (type === 'code') { + const code = target.querySelector('code'); + return normalizeReviewSource(code ? code.textContent : target.textContent, true); + } + + const clone = target.cloneNode(true); + clone.querySelectorAll('.review-target-button, .footnote-backref').forEach(function(node) { + node.remove(); + }); + clone.querySelectorAll('img').forEach(function(image) { + image.replaceWith(document.createTextNode(image.getAttribute('alt') || '')); + }); + return normalizeReviewSource(clone.textContent, false); + } + + function getReviewTargetSource(target, type) { + const snapshot = target ? reviewTargetSourceSnapshots.get(target) : null; + if (snapshot && snapshot.type === type) return snapshot.source; + return computeReviewTargetSource(target, type); + } + + function snapshotMathReviewTargetSources(mathTargets) { + if (!Array.isArray(mathTargets) || mathTargets.length === 0) return; + getReviewTargets().forEach(function(target) { + if (reviewTargetSourceSnapshots.has(target)) return; + const containsMathTarget = mathTargets.some(function(mathTarget) { + return mathTarget === target || mathTarget.contains(target) || target.contains(mathTarget); + }); + if (!containsMathTarget) return; + const type = getReviewTargetType(target); + reviewTargetSourceSnapshots.set(target, { + type: type, + source: computeReviewTargetSource(target, type) + }); + }); + } + + function invalidateReviewTargetSourceSnapshots(roots) { + (roots || []).forEach(function(root) { + const element = root && root.nodeType === Node.ELEMENT_NODE ? root : (root && root.parentElement); + if (!element) return; + const ancestorTarget = element.closest && element.closest(REVIEW_TARGET_SELECTOR); + if (ancestorTarget && markdownPreview.contains(ancestorTarget)) { + reviewTargetSourceSnapshots.delete(ancestorTarget); + } + if (element.matches && element.matches(REVIEW_TARGET_SELECTOR)) { + reviewTargetSourceSnapshots.delete(element); + } + element.querySelectorAll(REVIEW_TARGET_SELECTOR).forEach(function(target) { + reviewTargetSourceSnapshots.delete(target); + }); + }); + } + + function hashReviewSource(value) { + let hash = 2166136261; + const text = String(value || ''); + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); + } + + function getReviewTargetLabel(target, type) { + if (type === 'heading') return 'Heading ' + target.tagName; + if (type === 'paragraph') return 'Paragraph'; + if (type === 'code') { + const code = target.querySelector('code'); + const languageClass = code ? Array.from(code.classList).find(function(className) { + return className !== 'hljs' && className !== 'language-plaintext' && className !== 'plaintext'; + }) : ''; + const language = languageClass ? languageClass.replace(/^language-/, '') : ''; + return language ? language + ' code block' : 'Code block'; + } + if (type === 'diagram') { + let engine = target.getAttribute('data-diagram-engine'); + if (!engine && target.matches('.geojson-container')) engine = 'geojson'; + if (!engine && target.matches('.topojson-container')) engine = 'topojson'; + if (!engine && target.matches('.stl-container')) engine = 'stl'; + const specialLabels = { + geojson: 'GeoJSON map', + topojson: 'TopoJSON map', + stl: 'STL model' + }; + if (specialLabels[engine]) return specialLabels[engine]; + const engineLabel = engine ? getDiagramEngineLabel(engine) : 'Diagram'; + return engineLabel === 'Diagram' ? engineLabel : engineLabel + ' diagram'; + } + return 'Document block'; + } + + function getReviewAnchorDescriptor(target) { + if (!target || !target.dataset.reviewAnchor) return null; + const type = target.dataset.reviewType || getReviewTargetType(target); + const source = getReviewTargetSource(target, type); + const excerpt = source.length > 180 ? source.slice(0, 177) + '...' : source; + return { + key: target.dataset.reviewAnchor, + type: type, + label: target.dataset.reviewLabel || getReviewTargetLabel(target, type), + excerpt: excerpt || 'Empty rendered block', + duplicateCount: Math.max(1, Number(target.dataset.reviewDuplicateCount) || 1), + context: target.dataset.reviewContext || null + }; + } + + function getReviewTargets() { + if (!markdownPreview) return []; + return Array.from(markdownPreview.querySelectorAll(REVIEW_TARGET_SELECTOR)).filter(function(target) { + const diagramContainer = target.closest('.diagram-viewer, .geojson-container, .topojson-container, .stl-container'); + if (diagramContainer && diagramContainer !== target) { + return false; + } + return !target.closest('.review-target-button'); + }); + } + + function clearReviewDecorations() { + if (!markdownPreview) return; + if (reviewPinsLayer) reviewPinsLayer.textContent = ''; + if (reviewPinsResizeObserver) { + reviewPinsResizeObserver.disconnect(); + reviewPinsResizeObserver = null; + } + if (reviewPinsLayoutFrame) { + cancelAnimationFrame(reviewPinsLayoutFrame); + reviewPinsLayoutFrame = null; + } + markdownPreview.querySelectorAll('[data-review-anchor]').forEach(function(target) { + target.classList.remove( + 'review-target', + 'review-target--heading', + 'review-target--paragraph', + 'review-target--code', + 'review-target--diagram', + 'has-review-thread', + 'is-review-target-active' + ); + delete target.dataset.reviewAnchor; + delete target.dataset.reviewType; + delete target.dataset.reviewLabel; + delete target.dataset.reviewDuplicateCount; + delete target.dataset.reviewContext; + }); + } + + function positionReviewPins() { + reviewPinsLayoutFrame = null; + if (!reviewModeActive || !reviewPinsLayer || !previewPaneElement) return; + const paneRect = previewPaneElement.getBoundingClientRect(); + const isRtl = markdownPreview && markdownPreview.getAttribute('dir') === 'rtl'; + reviewPinsLayer.querySelectorAll('.review-target-button').forEach(function(button) { + const anchor = { + key: button.dataset.reviewAnchor, + duplicateCount: Number(button.dataset.reviewDuplicateCount) || null, + context: button.dataset.reviewContext || null + }; + const target = findReviewTarget(anchor); + if (!target) { + button.hidden = true; + return; + } + button.hidden = false; + const targetRect = target.getBoundingClientRect(); + const isDiagram = target.dataset.reviewType === 'diagram'; + const top = isDiagram ? targetRect.bottom - 40 : targetRect.top + 4; + const left = isRtl ? targetRect.left + 4 : targetRect.right - 4; + button.style.top = (top - paneRect.top + previewPaneElement.scrollTop) + 'px'; + button.style.left = (left - paneRect.left + previewPaneElement.scrollLeft) + 'px'; + button.style.transform = isRtl ? 'none' : 'translateX(-100%)'; + }); + } + + function scheduleReviewPinsLayout() { + if (!reviewModeActive || reviewPinsLayoutFrame) return; + reviewPinsLayoutFrame = requestAnimationFrame(positionReviewPins); + } + + function observeReviewPinLayout() { + if (typeof ResizeObserver === 'undefined') { + scheduleReviewPinsLayout(); + return; + } + reviewPinsResizeObserver = new ResizeObserver(scheduleReviewPinsLayout); + reviewPinsResizeObserver.observe(markdownPreview); + if (previewPaneElement) reviewPinsResizeObserver.observe(previewPaneElement); + scheduleReviewPinsLayout(); + } + + function decorateReviewTargets() { + clearReviewDecorations(); + if (!reviewModeActive || !markdownPreview || previewContainsSkeleton()) return; + + const threads = getActiveReviewThreads(); + const threadsByAnchor = new Map(); + threads.forEach(function(thread) { + const key = thread.anchor && thread.anchor.key; + if (!key) return; + if (!threadsByAnchor.has(key)) threadsByAnchor.set(key, []); + threadsByAnchor.get(key).push(thread); + }); + + const targetEntries = getReviewTargets().map(function(target) { + const type = getReviewTargetType(target); + const source = getReviewTargetSource(target, type); + const signature = type + '\u241f' + source; + return { target: target, type: type, source: source, signature: signature }; + }); + targetEntries.forEach(function(entry, index) { + const before = index > 0 ? targetEntries[index - 1].signature : 'review-start'; + const after = index < targetEntries.length - 1 ? targetEntries[index + 1].signature : 'review-end'; + entry.context = hashReviewSource(before) + ':' + hashReviewSource(after); + }); + const signatureCounts = new Map(); + targetEntries.forEach(function(entry) { + signatureCounts.set(entry.signature, (signatureCounts.get(entry.signature) || 0) + 1); + }); + + const occurrences = new Map(); + targetEntries.forEach(function(entry) { + const target = entry.target; + const type = entry.type; + const source = entry.source; + const signature = entry.signature; + const duplicateContext = entry.context; + const occurrence = occurrences.get(signature) || 0; + occurrences.set(signature, occurrence + 1); + const duplicateCount = signatureCounts.get(signature) || 1; + const context = duplicateCount > 1 ? duplicateContext : ''; + const anchorKey = type + ':' + hashReviewSource(signature) + ':' + occurrence; + const label = getReviewTargetLabel(target, type); + const targetThreads = (threadsByAnchor.get(anchorKey) || []).filter(function(thread) { + return (!thread.anchor.duplicateCount || thread.anchor.duplicateCount === duplicateCount) && + (!thread.anchor.context || thread.anchor.context === context); + }); + + target.dataset.reviewAnchor = anchorKey; + target.dataset.reviewType = type; + target.dataset.reviewLabel = label; + target.dataset.reviewDuplicateCount = String(duplicateCount); + target.dataset.reviewContext = context; + target.classList.add('review-target', 'review-target--' + type); + target.classList.toggle('has-review-thread', targetThreads.length > 0); + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'review-target-button'; + button.dataset.reviewAnchor = anchorKey; + button.dataset.reviewDuplicateCount = String(duplicateCount); + button.dataset.reviewContext = context; + button.dataset.html2canvasIgnore = 'true'; + button.title = targetThreads.length > 0 ? 'Add feedback to this block' : 'Comment on this block'; + button.setAttribute('aria-label', targetThreads.length > 0 + ? 'Add feedback to ' + label + '. ' + targetThreads.length + ' review item' + (targetThreads.length === 1 ? '' : 's') + ' attached.' + : 'Add feedback to ' + label); + + const icon = document.createElement('i'); + icon.className = 'bi bi-chat-square-text'; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + if (targetThreads.length > 0) { + button.dataset.reviewCount = String(targetThreads.length); + } + if (reviewPinsLayer) reviewPinsLayer.appendChild(button); + }); + + observeReviewPinLayout(); + renderReviewPanel(); + } + + function findReviewTarget(anchor) { + const anchorKey = typeof anchor === 'string' ? anchor : (anchor && anchor.key); + if (!markdownPreview || !anchorKey) return null; + const target = Array.from(markdownPreview.querySelectorAll('[data-review-anchor]')).find(function(candidate) { + return candidate.dataset.reviewAnchor === anchorKey && !candidate.classList.contains('review-target-button'); + }) || null; + if (!target || !anchor || typeof anchor === 'string') return target; + if (anchor.duplicateCount && Number(target.dataset.reviewDuplicateCount) !== Number(anchor.duplicateCount)) return null; + if (anchor.context && target.dataset.reviewContext !== anchor.context) return null; + return target; + } + + function updateReviewCountBadges() { + const openCount = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length; + [reviewCountBadge, mobileReviewCountBadge].forEach(function(badge) { + if (!badge) return; + badge.textContent = String(openCount); + badge.hidden = openCount === 0; + badge.setAttribute('aria-label', openCount + ' open review item' + (openCount === 1 ? '' : 's')); + }); + } + + function formatReviewTime(timestamp) { + try { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' + }).format(new Date(timestamp)); + } catch (_) { + return new Date(timestamp).toLocaleString(); + } + } + + function createReviewThreadElement(thread) { + const target = findReviewTarget(thread.anchor); + const item = document.createElement('article'); + item.className = 'review-thread' + (thread.resolved ? ' is-resolved' : '') + (!target ? ' is-orphaned' : ''); + item.dataset.reviewId = thread.id; + item.dataset.kind = thread.kind; + + const header = document.createElement('div'); + header.className = 'review-thread-header'; + const meta = document.createElement('div'); + meta.className = 'review-thread-meta'; + const kind = document.createElement('span'); + kind.className = 'review-kind-label'; + const kindIcon = document.createElement('i'); + kindIcon.className = thread.kind === 'suggestion' ? 'bi bi-lightbulb' : 'bi bi-chat-left-text'; + kindIcon.setAttribute('aria-hidden', 'true'); + kind.appendChild(kindIcon); + kind.appendChild(document.createTextNode(thread.kind === 'suggestion' ? 'Suggestion' : 'Comment')); + meta.appendChild(kind); + if (thread.resolved) { + const status = document.createElement('span'); + status.className = 'review-status-label'; + status.textContent = 'Resolved'; + meta.appendChild(status); + } + header.appendChild(meta); + item.appendChild(header); + + const anchor = document.createElement('button'); + anchor.type = 'button'; + anchor.className = 'review-thread-anchor'; + anchor.dataset.reviewAction = 'focus-anchor'; + anchor.dataset.reviewId = thread.id; + anchor.disabled = !target; + anchor.textContent = target + ? thread.anchor.label + ': ' + thread.anchor.excerpt + : 'Anchor no longer in preview - ' + thread.anchor.label + ': ' + thread.anchor.excerpt; + item.appendChild(anchor); + + const body = document.createElement('p'); + body.className = 'review-thread-body'; + body.textContent = thread.body; + item.appendChild(body); + + const time = document.createElement('time'); + time.className = 'review-thread-time'; + time.dateTime = new Date(thread.createdAt).toISOString(); + time.textContent = formatReviewTime(thread.createdAt); + item.appendChild(time); + + const actions = document.createElement('div'); + actions.className = 'review-thread-actions'; + const statusButton = document.createElement('button'); + statusButton.type = 'button'; + statusButton.className = 'review-thread-action'; + statusButton.dataset.reviewAction = 'toggle-resolved'; + statusButton.dataset.reviewId = thread.id; + statusButton.textContent = thread.resolved ? 'Reopen' : 'Resolve'; + actions.appendChild(statusButton); + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'review-thread-action is-danger'; + deleteButton.dataset.reviewAction = 'delete'; + deleteButton.dataset.reviewId = thread.id; + deleteButton.textContent = 'Delete'; + actions.appendChild(deleteButton); + item.appendChild(actions); + + return item; + } + + function renderReviewPanel() { + updateReviewCountBadges(); + if (!reviewPanel || !reviewList || !reviewEmptyState) return; + const threads = getActiveReviewThreads(); + const openCount = threads.filter(function(thread) { return !thread.resolved; }).length; + if (reviewPanelSummary) { + reviewPanelSummary.textContent = openCount === 0 + ? (threads.length === 0 ? 'No feedback yet' : 'All feedback resolved') + : openCount + ' open item' + (openCount === 1 ? '' : 's') + (threads.length === openCount ? '' : ' - ' + threads.length + ' total'); + } + if (reviewCopySummary) reviewCopySummary.disabled = threads.length === 0; + + const filteredThreads = threads.filter(function(thread) { + if (reviewFilter === 'resolved') return thread.resolved; + if (reviewFilter === 'all') return true; + return !thread.resolved; + }).sort(function(a, b) { return b.createdAt - a.createdAt; }); + + reviewList.textContent = ''; + filteredThreads.forEach(function(thread) { + reviewList.appendChild(createReviewThreadElement(thread)); + }); + reviewList.hidden = filteredThreads.length === 0; + reviewEmptyState.hidden = filteredThreads.length > 0; + if (filteredThreads.length === 0) { + const title = reviewEmptyState.querySelector('h3'); + const copy = reviewEmptyState.querySelector('p'); + if (reviewFilter === 'resolved') { + title.textContent = 'No resolved feedback'; + copy.textContent = 'Resolved comments and suggestions will appear here.'; + } else if (reviewFilter === 'all') { + title.textContent = 'No feedback yet'; + copy.textContent = 'Use the comment pins in the preview to leave feedback without changing the Markdown.'; + } else { + title.textContent = 'No open feedback'; + copy.textContent = threads.length > 0 + ? 'Everything has been resolved. Switch to Resolved or All to review earlier feedback.' + : 'Use the comment pins in the preview to leave feedback without changing the Markdown.'; + } + } + } + + function updateReviewComposerKind(kind) { + reviewComposerKind = kind === 'suggestion' ? 'suggestion' : 'comment'; + document.querySelectorAll('[data-review-kind]').forEach(function(button) { + const active = button.dataset.reviewKind === reviewComposerKind; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + if (reviewFeedbackLabel) { + reviewFeedbackLabel.textContent = reviewComposerKind === 'suggestion' ? 'Suggested change' : 'Comment'; + } + if (reviewFeedbackInput) { + reviewFeedbackInput.placeholder = reviewComposerKind === 'suggestion' + ? 'Describe the change you recommend without editing the source...' + : 'Leave a focused comment for the author...'; + } + if (reviewFeedbackSubmit) { + reviewFeedbackSubmit.textContent = reviewComposerKind === 'suggestion' ? 'Add suggestion' : 'Add comment'; + } + } + + function focusReviewPin(anchor) { + const target = findReviewTarget(anchor); + if (!target || !reviewPinsLayer) return false; + const button = Array.from(reviewPinsLayer.querySelectorAll('.review-target-button')).find(function(candidate) { + return candidate.dataset.reviewAnchor === target.dataset.reviewAnchor && + candidate.dataset.reviewContext === target.dataset.reviewContext; + }) || null; + if (!button) return false; + button.focus({ preventScroll: true }); + return true; + } + + function closeReviewComposer(options) { + options = options || {}; + const returnAnchor = activeReviewAnchor; + if (activeReviewAnchor) { + const previousTarget = findReviewTarget(activeReviewAnchor); + if (previousTarget) previousTarget.classList.remove('is-review-target-active'); + } + activeReviewAnchor = null; + if (reviewComposer) reviewComposer.hidden = true; + if (reviewFeedbackInput) reviewFeedbackInput.value = ''; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true; + if (options.restoreFocus && reviewModeActive && !focusReviewPin(returnAnchor) && reviewPanelClose) { + reviewPanelClose.focus(); + } + } + + function openReviewComposer(target) { + if (!target) return; + const descriptor = getReviewAnchorDescriptor(target); + if (!descriptor) return; + closeReviewComposer(); + activeReviewAnchor = descriptor; + target.classList.add('is-review-target-active'); + if (reviewComposerAnchor) { + reviewComposerAnchor.textContent = descriptor.label + ': ' + descriptor.excerpt; + } + updateReviewComposerKind(reviewComposerKind); + if (reviewComposer) reviewComposer.hidden = false; + if (reviewFeedbackInput) { + reviewFeedbackInput.value = ''; + reviewFeedbackInput.focus(); + } + } + + function persistReviewThreads(message) { + saveTabsToStorage(tabs); + decorateReviewTargets(); + renderReviewPanel(); + if (message) announceToScreenReader(message); + } + + function submitReviewFeedback() { + if (!activeReviewAnchor || !reviewFeedbackInput) return; + const body = reviewFeedbackInput.value.trim(); + if (!body) return; + const returnAnchor = Object.assign({}, activeReviewAnchor); + getActiveReviewThreads().push({ + id: createReviewId(), + anchor: Object.assign({}, activeReviewAnchor), + kind: reviewComposerKind, + body: body.slice(0, REVIEW_TEXT_LIMIT), + createdAt: Date.now(), + updatedAt: Date.now(), + resolved: false + }); + const label = reviewComposerKind === 'suggestion' ? 'Suggestion added.' : 'Comment added.'; + closeReviewComposer(); + persistReviewThreads(label); + focusReviewPin(returnAnchor); + } + + function focusReviewAnchor(thread) { + const target = thread && thread.anchor ? findReviewTarget(thread.anchor) : null; + if (!target) return; + markdownPreview.querySelectorAll('.is-review-target-active').forEach(function(node) { + node.classList.remove('is-review-target-active'); + }); + target.classList.add('is-review-target-active'); + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + focusReviewPin(thread.anchor); + clearTimeout(focusReviewAnchor._timeoutId); + focusReviewAnchor._timeoutId = setTimeout(function() { + if (!activeReviewAnchor || activeReviewAnchor.key !== thread.anchor.key) { + target.classList.remove('is-review-target-active'); + } + }, 1800); + } + + function buildReviewSummary() { + const tab = getActiveReviewTab(); + const threads = getActiveReviewThreads().slice().sort(function(a, b) { return a.createdAt - b.createdAt; }); + const lines = ['# Review: ' + (tab && tab.title ? tab.title : 'Markdown document'), '']; + threads.forEach(function(thread, index) { + const kind = thread.kind === 'suggestion' ? 'Suggestion' : 'Comment'; + lines.push((index + 1) + '. **' + kind + '** - ' + thread.anchor.label + (thread.resolved ? ' _(resolved)_' : '')); + if (thread.anchor.excerpt) lines.push(' > ' + thread.anchor.excerpt.replace(/\n/g, ' ')); + thread.body.split('\n').forEach(function(line) { + lines.push(' ' + line); + }); + lines.push(''); + }); + return lines.join('\n').trim(); + } + + async function copyReviewSummary() { + if (getActiveReviewThreads().length === 0) return; + try { + await copyTextToClipboard(buildReviewSummary()); + announceToScreenReader('Review summary copied.'); + if (reviewCopySummary) { + const icon = reviewCopySummary.querySelector('i'); + if (icon) icon.className = 'bi bi-check-lg'; + clearTimeout(copyReviewSummary._timeoutId); + copyReviewSummary._timeoutId = setTimeout(function() { + if (icon) icon.className = 'bi bi-clipboard'; + }, 1400); + } + } catch (error) { + console.error('Review summary copy failed:', error); + alert('Failed to copy review summary: ' + error.message); + } + } + + function updateReviewBackdrop() { + if (!reviewPanelBackdrop) return; + reviewPanelBackdrop.hidden = true; + } + + function setReviewMode(enabled, options) { + options = options || {}; + const nextState = Boolean(enabled); + const wasActive = reviewModeActive; + if (nextState && !wasActive) { + reviewPreviousViewModes.set(activeTabId, currentViewMode || 'split'); + } + reviewModeActive = nextState; + if (reviewModeActive && currentViewMode !== 'preview') { + setViewMode('preview'); + } + if (reviewPanel) reviewPanel.hidden = !reviewModeActive; + if (reviewPinsLayer) reviewPinsLayer.hidden = !reviewModeActive; + if (contentContainer) contentContainer.classList.toggle('is-reviewing', reviewModeActive); + [reviewToggle, mobileReviewToggle].forEach(function(button) { + if (!button) return; + button.classList.toggle('is-active', reviewModeActive); + button.setAttribute('aria-expanded', reviewModeActive ? 'true' : 'false'); + button.setAttribute('aria-pressed', reviewModeActive ? 'true' : 'false'); + }); + updateReviewBackdrop(); + updateLiveEditorAccess(); + if (reviewModeActive) { + decorateReviewTargets(); + renderReviewPanel(); + if (options.focusPanel && reviewPanelClose) reviewPanelClose.focus(); + } else { + closeReviewComposer(); + clearReviewDecorations(); + const restoreMode = reviewPreviousViewModes.get(activeTabId); + reviewPreviousViewModes.clear(); + if (restoreMode && restoreMode !== currentViewMode) { + setViewMode(restoreMode); + saveCurrentTabState(); + } + if (options.restoreFocus) { + const returnTarget = window.innerWidth < 768 ? mobileMenuToggle : reviewToggle; + if (returnTarget) returnTarget.focus(); + } + } + } + + function initReviewMode() { + tabs.forEach(function(tab) { + tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); + }); + updateReviewCountBadges(); + renderReviewPanel(); + + if (reviewToggle) { + reviewToggle.addEventListener('click', function() { + setReviewMode(!reviewModeActive, { focusPanel: !reviewModeActive }); + }); + } + if (mobileReviewToggle) { + mobileReviewToggle.addEventListener('click', function() { + closeMobileMenu(); + setReviewMode(!reviewModeActive, { focusPanel: !reviewModeActive }); + }); + } + if (reviewPanelClose) { + reviewPanelClose.addEventListener('click', function() { + setReviewMode(false, { restoreFocus: true }); + }); + } + if (reviewPanelBackdrop) { + reviewPanelBackdrop.addEventListener('click', function() { + setReviewMode(false, { restoreFocus: true }); + }); + } + if (reviewComposerCancel) { + reviewComposerCancel.addEventListener('click', function() { + closeReviewComposer({ restoreFocus: true }); + }); + } + if (reviewFeedbackInput) { + reviewFeedbackInput.addEventListener('input', function() { + const length = reviewFeedbackInput.value.length; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = length + ' / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = reviewFeedbackInput.value.trim().length === 0; + }); + reviewFeedbackInput.addEventListener('keydown', function(event) { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + submitReviewFeedback(); + } + }); + } + if (reviewFeedbackSubmit) reviewFeedbackSubmit.addEventListener('click', submitReviewFeedback); + if (reviewCopySummary) reviewCopySummary.addEventListener('click', copyReviewSummary); + + document.querySelectorAll('[data-review-kind]').forEach(function(button) { + button.addEventListener('click', function() { + updateReviewComposerKind(button.dataset.reviewKind); + if (reviewFeedbackInput) reviewFeedbackInput.focus(); + }); + }); + document.querySelectorAll('[data-review-filter]').forEach(function(button) { + button.addEventListener('click', function() { + reviewFilter = button.dataset.reviewFilter; + document.querySelectorAll('[data-review-filter]').forEach(function(filterButton) { + const active = filterButton === button; + filterButton.classList.toggle('is-active', active); + filterButton.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + renderReviewPanel(); + }); + }); + + if (reviewPinsLayer) { + reviewPinsLayer.addEventListener('click', function(event) { + const button = event.target.closest('.review-target-button'); + if (!button || !reviewModeActive) return; + event.preventDefault(); + event.stopPropagation(); + const target = findReviewTarget({ + key: button.dataset.reviewAnchor, + duplicateCount: Number(button.dataset.reviewDuplicateCount) || null, + context: button.dataset.reviewContext || null + }); + openReviewComposer(target); + }); + } + + if (previewPaneElement) { + previewPaneElement.addEventListener('scroll', scheduleReviewPinsLayout, { passive: true }); + } + + if (reviewList) { + reviewList.addEventListener('click', function(event) { + const actionButton = event.target.closest('[data-review-action]'); + if (!actionButton) return; + const thread = getActiveReviewThreads().find(function(item) { + return item.id === actionButton.dataset.reviewId; + }); + if (!thread) return; + const action = actionButton.dataset.reviewAction; + if (action === 'focus-anchor') { + focusReviewAnchor(thread); + return; + } + if (action === 'toggle-resolved') { + thread.resolved = !thread.resolved; + thread.updatedAt = Date.now(); + persistReviewThreads(thread.resolved ? 'Review item resolved.' : 'Review item reopened.'); + return; + } + if (action === 'delete' && confirm('Delete this review item?')) { + const index = getActiveReviewThreads().indexOf(thread); + if (index >= 0) getActiveReviewThreads().splice(index, 1); + persistReviewThreads('Review item deleted.'); + } + }); + } + + window.addEventListener('resize', updateReviewBackdrop); + document.addEventListener('keydown', function(event) { + if (event.key !== 'Escape' || !reviewModeActive || activeModal) return; + if (reviewComposer && !reviewComposer.hidden) { + event.preventDefault(); + closeReviewComposer({ restoreFocus: true }); + } else { + event.preventDefault(); + setReviewMode(false, { restoreFocus: true }); + } + }); + } + function refreshLiveEditorUi() { if (!liveShareUiReady) return; updateLiveAccessDisplay(); @@ -2409,7 +3269,10 @@ document.addEventListener("DOMContentLoaded", async function () { function loadTabsFromStorage() { try { - return stripTemporaryTabs(JSON.parse(localStorage.getItem(STORAGE_KEY)) || []); + return stripTemporaryTabs(JSON.parse(localStorage.getItem(STORAGE_KEY)) || []).map(function(tab) { + tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); + return tab; + }); } catch (e) { return []; } @@ -2495,6 +3358,7 @@ document.addEventListener("DOMContentLoaded", async function () { content: content, scrollPos: 0, viewMode: viewMode, + reviewThreads: [], createdAt: Date.now() }; } @@ -2882,7 +3746,9 @@ document.addEventListener("DOMContentLoaded", async function () { if (!tab) return; tab.content = markdownEditor.value; tab.scrollPos = markdownEditor.scrollTop; - tab.viewMode = currentViewMode || 'split'; + tab.viewMode = reviewModeActive && reviewPreviousViewModes.has(activeTabId) + ? reviewPreviousViewModes.get(activeTabId) + : (currentViewMode || 'split'); if (liveCollaboration && liveCollaboration.tabId === activeTabId) { return; } @@ -2890,6 +3756,9 @@ document.addEventListener("DOMContentLoaded", async function () { } function restoreViewMode(mode) { + if (reviewModeActive && activeTabId && !reviewPreviousViewModes.has(activeTabId)) { + reviewPreviousViewModes.set(activeTabId, mode || 'split'); + } currentViewMode = null; setViewMode(mode || 'split'); } @@ -2897,6 +3766,8 @@ document.addEventListener("DOMContentLoaded", async function () { function switchTab(tabId) { if (tabId === activeTabId) return; saveCurrentTabState(); + closeReviewComposer(); + clearReviewDecorations(); // Clear typing timeout and reset tracking for the new tab if (typingTimeout) { @@ -2920,6 +3791,7 @@ document.addEventListener("DOMContentLoaded", async function () { restoreViewMode(tab.viewMode); refreshLiveEditorUi(); renderMarkdown(); + renderReviewPanel(); requestAnimationFrame(function() { markdownEditor.scrollTop = tab.scrollPos || 0; updateLiveCursorPosition(); @@ -2953,6 +3825,10 @@ document.addEventListener("DOMContentLoaded", async function () { const idx = tabs.findIndex(function(t) { return t.id === tabId; }); if (idx === -1) return; + if (activeTabId === tabId) { + closeReviewComposer(); + clearReviewDecorations(); + } shareSnapshotViewOnlyTabIds.delete(tabId); // Clean up history of the closed tab @@ -2986,6 +3862,8 @@ document.addEventListener("DOMContentLoaded", async function () { } saveTabsToStorage(tabs); renderTabBar(tabs, activeTabId); + closeReviewComposer(); + renderReviewPanel(); } function deleteTab(tabId) { @@ -3120,6 +3998,8 @@ document.addEventListener("DOMContentLoaded", async function () { function doReset() { closeAppModal(modal); cleanup(); + closeReviewComposer(); + clearReviewDecorations(); disconnectLiveCollaboration({ restoreOriginal: false, silent: true }); applyShareSnapshotAccessMode('edit'); resetShareSnapshotLink(); @@ -3136,6 +4016,8 @@ document.addEventListener("DOMContentLoaded", async function () { refreshLiveEditorUi(); renderMarkdown(); renderTabBar(tabs, activeTabId); + closeReviewComposer(); + renderReviewPanel(); } function doCancel() { @@ -3465,6 +4347,9 @@ document.addEventListener("DOMContentLoaded", async function () { const patchResult = patchPreviewDom(markdownPreview, sanitizedHtml, { reusePreviewBlocks: context.previewEngineMode === 'segmented' && !context.force, }); + invalidateReviewTargetSourceSnapshots( + patchResult && patchResult.fullReplace ? [markdownPreview] : ((patchResult && patchResult.updatedNodes) || []) + ); applyReferencePreviewLinks(markdownPreview, referenceData.definitions); enhanceGitHubAlerts(markdownPreview); @@ -4817,6 +5702,7 @@ ${selector} .arrowheadPath { const hasMath = /\$\$|\$[^$]|\\\(|\\\[/.test(rawVal || '') || /```math\b/.test(rawVal || ''); if (hasMath) { const mathTargets = getMathJaxTypesetTargets(roots); + snapshotMathReviewTargetSources(mathTargets); if (mathTargets.length > 0) { ensureMathJaxReady().then(function() { if (context.renderId !== previewRenderGeneration) return; @@ -4827,6 +5713,7 @@ ${selector} .arrowheadPath { } } + decorateReviewTargets(); updateDocumentStats(); updateFindHighlights(); cleanupImageObjectUrls(); @@ -5685,6 +6572,10 @@ ${selector} .arrowheadPath { mode = 'preview'; announceToScreenReader(getEditorReadOnlyMessage()); } + if (reviewModeActive && mode !== 'preview') { + mode = 'preview'; + announceToScreenReader('Review mode uses the read-only preview.'); + } if (mode === currentViewMode) return; const previousMode = currentViewMode; @@ -10444,6 +11335,7 @@ ${selector} .arrowheadPath { } initTabs(); + initReviewMode(); if (loadGlobalState().syncScrollingEnabled === false) toggleSyncScrolling(); updateMobileStats(); updateFindHighlights(); @@ -13338,7 +14230,7 @@ ${selector} .arrowheadPath { } function canMutateEditor() { - return !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive(); + return !reviewModeActive && !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive(); } function isLiveMutatingAction(action) { @@ -13377,16 +14269,17 @@ ${selector} .arrowheadPath { const liveShareDocumentActive = isLiveShareDocumentActive(); const liveShareGuestDocumentActive = liveShareDocumentActive && !isLiveShareHostDocumentActive(); const viewOnly = isLiveViewOnlyParticipant() || snapshotViewOnly; + const sourceReadOnly = viewOnly || reviewModeActive; if (markdownEditor) { - markdownEditor.readOnly = viewOnly; - markdownEditor.setAttribute('aria-readonly', viewOnly ? 'true' : 'false'); - markdownEditor.classList.toggle('live-share-readonly-editor', viewOnly); - markdownEditor.title = viewOnly ? getEditorReadOnlyMessage() : ''; + markdownEditor.readOnly = sourceReadOnly; + markdownEditor.setAttribute('aria-readonly', sourceReadOnly ? 'true' : 'false'); + markdownEditor.classList.toggle('live-share-readonly-editor', sourceReadOnly); + markdownEditor.title = sourceReadOnly ? getEditorReadOnlyMessage() : ''; } viewModeButtons.forEach(function(button) { const mode = button.getAttribute('data-view-mode'); - const shouldDisable = snapshotViewOnly && mode !== 'preview'; + const shouldDisable = (snapshotViewOnly || reviewModeActive) && mode !== 'preview'; if (shouldDisable) { button.dataset.shareSnapshotDisabled = 'true'; button.disabled = true; @@ -13400,7 +14293,7 @@ ${selector} .arrowheadPath { mobileViewModeButtons.forEach(function(button) { const mode = button.getAttribute('data-mode'); - const shouldDisable = snapshotViewOnly && mode !== 'preview'; + const shouldDisable = (snapshotViewOnly || reviewModeActive) && mode !== 'preview'; if (shouldDisable) { button.dataset.shareSnapshotDisabled = 'true'; button.disabled = true; @@ -13491,10 +14384,10 @@ ${selector} .arrowheadPath { }); if (markdownFormatToolbar) { - markdownFormatToolbar.classList.toggle('is-live-view-only', viewOnly); + markdownFormatToolbar.classList.toggle('is-live-view-only', sourceReadOnly); markdownFormatToolbar.querySelectorAll('[data-md-action]').forEach(function(button) { const action = button.getAttribute('data-md-action'); - const shouldDisable = viewOnly && isLiveMutatingAction(action); + const shouldDisable = sourceReadOnly && isLiveMutatingAction(action); if (shouldDisable) { button.dataset.liveViewOnlyDisabled = 'true'; button.disabled = true; @@ -13507,7 +14400,7 @@ ${selector} .arrowheadPath { button.setAttribute('aria-disabled', 'false'); } }); - if (!viewOnly) { + if (!sourceReadOnly) { updateUndoRedoButtons(); } } diff --git a/styles.css b/styles.css index 488e43c9..9846a973 100644 --- a/styles.css +++ b/styles.css @@ -23,6 +23,12 @@ /* Code block background for light mode */ --skeleton-bg: #e2e8f0; --skeleton-glow: rgba(255, 255, 255, 0.65); + --review-accent: #8250df; + --review-accent-strong: #6639ba; + --review-accent-soft: #f3effb; + --review-suggestion: #9a6700; + --review-suggestion-soft: #fff8c5; + --review-panel-shadow: -12px 0 32px rgba(31, 35, 40, 0.16); /* Find & Replace Panel custom properties (PERF-010 consolidated) */ --fr-bg: rgba(255, 255, 255, 0.95); @@ -63,6 +69,12 @@ /* Code block background for dark mode */ --skeleton-bg: #2d3139; --skeleton-glow: rgba(255, 255, 255, 0.08); + --review-accent: #a371f7; + --review-accent-strong: #bc8cff; + --review-accent-soft: rgba(163, 113, 247, 0.15); + --review-suggestion: #d29922; + --review-suggestion-soft: rgba(187, 128, 9, 0.16); + --review-panel-shadow: -12px 0 32px rgba(0, 0, 0, 0.45); /* Find & Replace Panel custom properties for dark mode */ --fr-bg: rgba(28, 33, 40, 0.98); @@ -5984,6 +5996,588 @@ html[data-theme="dark"] .mermaid svg { isolation: isolate; } +/* ======================================== + COMMENTS & SUGGESTIONS + ======================================== */ +.review-toggle { + position: relative; +} + +.review-toggle.is-active { + color: var(--review-accent-strong); + background: var(--review-accent-soft); + border-color: var(--review-accent); +} + +.review-count-badge { + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #ffffff; + background: var(--review-accent); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; +} + +.review-count-badge[hidden], +.review-panel[hidden], +.review-panel-backdrop[hidden], +.review-composer[hidden] { + display: none !important; +} + +#mobile-review-toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.review-panel { + position: absolute; + z-index: 85; + inset-block: 0; + inset-inline-end: 0; + width: min(380px, calc(100% - 48px)); + display: flex; + flex-direction: column; + overflow: hidden; + color: var(--text-color); + background: var(--bg-color); + border-inline-start: 1px solid var(--border-color); + box-shadow: var(--review-panel-shadow); +} + +.review-panel-backdrop { + position: absolute; + z-index: 84; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: rgba(31, 35, 40, 0.38); + cursor: default; +} + +.review-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 18px 14px; + border-bottom: 1px solid var(--border-color); +} + +.review-panel-eyebrow { + margin: 0 0 3px; + color: var(--review-accent-strong); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.review-panel-header h2, +.review-composer-heading h3, +.review-empty-state h3 { + margin: 0; + color: var(--text-color); + font-weight: 650; +} + +.review-panel-header h2 { + font-size: 1rem; +} + +.review-panel-summary { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 0.78rem; +} + +.review-icon-btn { + width: 32px; + height: 32px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} + +.review-icon-btn:hover:not(:disabled) { + color: var(--text-color); + background: var(--button-hover); + border-color: var(--border-color); +} + +.review-icon-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.review-panel-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); +} + +.review-filter-group, +.review-kind-group { + display: inline-flex; + align-items: center; + padding: 2px; + background: var(--button-bg); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.review-filter-btn, +.review-kind-btn { + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: 6px; + cursor: pointer; + font-weight: 600; +} + +.review-filter-btn { + padding: 5px 10px; + font-size: 0.74rem; +} + +.review-filter-btn.is-active, +.review-kind-btn.is-active { + color: var(--review-accent-strong); + background: var(--bg-color); + box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12); +} + +.review-composer { + margin: 14px 14px 4px; + padding: 14px; + background: var(--review-accent-soft); + border: 1px solid color-mix(in srgb, var(--review-accent) 45%, var(--border-color)); + border-radius: 10px; +} + +.review-composer-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.review-composer-heading h3 { + font-size: 0.9rem; +} + +.review-anchor-preview { + display: -webkit-box; + margin: 10px 0 12px; + overflow: hidden; + color: var(--text-secondary); + font-size: 0.78rem; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.review-kind-group { + width: 100%; + margin-bottom: 12px; +} + +.review-kind-btn { + flex: 1; + padding: 7px 8px; + font-size: 0.76rem; +} + +.review-kind-btn[data-review-kind="suggestion"].is-active { + color: var(--review-suggestion); + background: var(--review-suggestion-soft); +} + +.review-feedback-label { + display: block; + margin-bottom: 6px; + color: var(--text-color); + font-size: 0.76rem; + font-weight: 650; +} + +.review-feedback-input { + width: 100%; + min-height: 92px; + padding: 9px 10px; + resize: vertical; + color: var(--text-color); + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 7px; + font: inherit; + font-size: 0.82rem; + line-height: 1.45; +} + +.review-feedback-input:focus { + border-color: var(--review-accent); + outline: 2px solid color-mix(in srgb, var(--review-accent) 24%, transparent); + outline-offset: 0; +} + +.review-composer-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 9px; +} + +.review-feedback-count { + color: var(--text-secondary); + font-size: 0.7rem; +} + +.review-primary-btn { + min-height: 32px; + padding: 6px 12px; + color: #ffffff; + background: var(--review-accent); + border: 1px solid var(--review-accent-strong); + border-radius: 6px; + cursor: pointer; + font-size: 0.76rem; + font-weight: 650; +} + +.review-primary-btn:hover:not(:disabled) { + background: var(--review-accent-strong); +} + +.review-primary-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.review-list { + flex: 1; + overflow-y: auto; + padding: 10px 14px 18px; +} + +.review-thread { + margin-bottom: 10px; + padding: 12px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-inline-start: 3px solid var(--review-accent); + border-radius: 8px; +} + +.review-thread[data-kind="suggestion"] { + border-inline-start-color: var(--review-suggestion); +} + +.review-thread.is-resolved { + opacity: 0.72; +} + +.review-thread.is-orphaned { + border-style: dashed; +} + +.review-thread-header, +.review-thread-meta, +.review-thread-actions { + display: flex; + align-items: center; +} + +.review-thread-header { + justify-content: space-between; + gap: 8px; +} + +.review-thread-meta { + min-width: 0; + gap: 6px; +} + +.review-kind-label, +.review-status-label { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + font-size: 0.67rem; + font-weight: 700; + line-height: 1; +} + +.review-kind-label { + padding: 5px 7px; + color: var(--review-accent-strong); + background: var(--review-accent-soft); +} + +.review-thread[data-kind="suggestion"] .review-kind-label { + color: var(--review-suggestion); + background: var(--review-suggestion-soft); +} + +.review-status-label { + color: var(--text-secondary); +} + +.review-thread-anchor { + width: 100%; + margin: 9px 0 8px; + padding: 0; + overflow: hidden; + color: var(--text-secondary); + background: transparent; + border: 0; + cursor: pointer; + font-size: 0.72rem; + line-height: 1.35; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-thread-anchor:hover:not(:disabled) { + color: var(--review-accent-strong); + text-decoration: underline; +} + +.review-thread-anchor:disabled { + cursor: default; + white-space: normal; +} + +.review-thread-body { + margin: 0; + color: var(--text-color); + font-size: 0.82rem; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.review-thread-time { + display: block; + margin-top: 9px; + color: var(--text-secondary); + font-size: 0.67rem; +} + +.review-thread-actions { + justify-content: flex-end; + gap: 4px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.review-thread-action { + padding: 5px 7px; + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: 5px; + cursor: pointer; + font-size: 0.7rem; + font-weight: 600; +} + +.review-thread-action:hover { + color: var(--text-color); + background: var(--button-hover); +} + +.review-thread-action.is-danger:hover { + color: var(--color-danger-fg); +} + +.review-empty-state { + margin: auto; + padding: 30px 26px; + color: var(--text-secondary); + text-align: center; +} + +.review-empty-state i { + display: block; + margin-bottom: 10px; + color: var(--review-accent); + font-size: 1.7rem; +} + +.review-empty-state h3 { + font-size: 0.9rem; +} + +.review-empty-state p { + margin: 7px auto 0; + max-width: 270px; + font-size: 0.76rem; + line-height: 1.45; +} + +#markdown-preview .review-target { + border-radius: 5px; +} + +#markdown-preview .review-target:not(.review-target--diagram) { + padding-inline-end: 44px; +} + +#markdown-preview .review-target--diagram { + padding-block-end: 44px; +} + +#markdown-preview .review-target.is-review-target-active { + outline: 2px solid var(--review-accent); + outline-offset: 4px; +} + +#markdown-preview .review-target.has-review-thread { + box-shadow: inset 3px 0 0 var(--review-accent); +} + +[dir="rtl"] #markdown-preview .review-target.has-review-thread { + box-shadow: inset -3px 0 0 var(--review-accent); +} + +.review-pins-layer { + position: absolute; + z-index: 20; + inset: 0; + overflow: visible; + pointer-events: none; +} + +.review-pins-layer[hidden] { + display: none !important; +} + +.review-target-button { + position: absolute; + z-index: 8; + min-width: 30px; + height: 30px; + padding: 0 7px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + color: #ffffff; + background: var(--review-accent); + border: 1px solid var(--review-accent-strong); + border-radius: 999px; + box-shadow: 0 2px 8px rgba(31, 35, 40, 0.22); + cursor: pointer; + font-size: 0.68rem; + font-weight: 700; + opacity: 1; + pointer-events: auto; + transition: opacity 0.12s ease, transform 0.12s ease; +} + +.review-target-button[data-review-count]::after { + content: attr(data-review-count); +} + +.review-target-button:hover { + background: var(--review-accent-strong); +} + +.review-filter-btn:focus-visible, +.review-kind-btn:focus-visible, +.review-icon-btn:focus-visible, +.review-primary-btn:focus-visible, +.review-thread-action:focus-visible, +.review-thread-anchor:focus-visible, +.review-target-button:focus-visible { + outline: 2px solid var(--review-accent); + outline-offset: 2px; +} + +@media (min-width: 1080px) { + .review-panel { + position: relative; + inset: auto; + width: 380px; + height: auto; + flex: 0 0 380px; + box-shadow: none; + } +} + +@media (min-width: 768px) and (max-width: 1079px) { + .review-panel { + width: min(390px, 100%); + } + + .content-container.is-reviewing .preview-pane { + padding-inline-end: 410px; + } +} + +@media (max-width: 767px) { + .review-panel { + inset-block-start: auto; + inset-block-end: 0; + width: 100%; + height: min(56%, 560px); + border-inline-start: 0; + border-top: 1px solid var(--border-color); + box-shadow: 0 -12px 32px rgba(31, 35, 40, 0.2); + } + + .content-container.is-reviewing .preview-pane { + padding-bottom: calc(56vh + 20px); + } + + .review-panel-header { + padding-top: 14px; + } + + .review-target-button { + min-width: 44px; + height: 44px; + } + + #markdown-preview .review-target:not(.review-target--diagram) { + padding-inline-end: 56px; + } + + #markdown-preview .review-target--diagram { + padding-block-end: 56px; + } +} + +@media (prefers-reduced-motion: reduce) { + .review-target-button { + transition: none; + } +} + @@ -6011,10 +6605,28 @@ html[data-theme="dark"] .mermaid svg { .plantuml-toolbar, .d2-toolbar, .graphviz-toolbar, - .stl-toolbar { + .stl-toolbar, + .review-panel, + .review-panel-backdrop, + .review-pins-layer, + .review-target-button { display: none !important; } + #markdown-preview .review-target, + #markdown-preview .review-target.has-review-thread, + [dir="rtl"] #markdown-preview .review-target.has-review-thread { + outline: none !important; + box-shadow: none !important; + padding-inline-end: 0 !important; + padding-block-end: 0 !important; + } + + .content-container.is-reviewing .preview-pane { + padding-inline-end: 20px !important; + padding-bottom: 20px !important; + } + .preview-pane { width: 100% !important; max-width: 100% !important; diff --git a/wiki/Features.md b/wiki/Features.md index 5b445bb9..954e9d14 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -29,7 +29,7 @@ Users can work with multiple documents at once. - New tabs can be created from the tab bar, mobile menu, imports, shared snapshots, and Live Share joins. - Tabs can be renamed, duplicated, deleted, and reordered by drag and drop. - The app enforces a practical tab limit of 20 tabs. Shared snapshots refuse to open when this limit has been reached. -- Each normal tab stores a title, content, scroll position, view mode, and creation time. +- Each normal tab stores a title, content, scroll position, view mode, local review threads, and creation time. - The active tab id and untitled-document counter are stored separately. - Temporary Share Snapshot and Live Share tabs are deliberately excluded from persistent tab storage. - The Reset button clears the current saved workspace and returns the app to a clean starting state. @@ -38,7 +38,7 @@ Storage keys used by the current implementation include: | Key | What It Stores | | :--- | :--- | -| `markdownViewerTabs` | Normal saved document tabs. Temporary shared/live tabs are stripped before saving. | +| `markdownViewerTabs` | Normal saved document tabs, including local comments and suggestions. Temporary shared/live tabs are stripped before saving. | | `markdownViewerActiveTab` | The active tab id. | | `markdownViewerUntitledCounter` | Counter used for new Untitled tab names. | | `markdownViewerGlobalState` | Theme, direction, view preferences, scroll sync, and similar global UI state. | @@ -52,6 +52,32 @@ The About dialog includes two storage controls: - **Private mode** removes saved document/workspace state and prevents normal document-state keys from being written while it is enabled. The private-mode preference itself remains so the behavior survives a reload. - **Clear local data** removes saved document tabs, active-tab state, the untitled-tab counter, global workspace preferences, and their desktop storage mirrors. It does not revoke links that were already shared. +## Comments and Suggestion Mode + +Review mode provides a local feedback layer over the rendered document without inserting or changing Markdown. + +- The Review button opens a dedicated read-only preview workspace and restores the previous Editor, Split, or Preview layout when closed. +- Review pins are attached to rendered headings, paragraphs, fenced code blocks, and supported diagram containers, including Mermaid and the other diagram engines. +- A reviewer can add either a comment or a suggestion. Both are feedback records; suggestions do not apply source changes automatically. +- Threads can be resolved, reopened, deleted, filtered by status, and copied together as a Markdown review summary. +- Open-thread counts appear in the desktop header and mobile menu. +- Review controls support keyboard focus, screen-reader labels, dark mode, RTL layout, desktop side-by-side review, and a mobile partial-height panel. + +Anchoring and lifecycle: + +- Each thread stores a deterministic block signature, duplicate occurrence and count, neighboring block context for repeated blocks, block type, label, and source excerpt rather than relying on transient rendered element ids. +- The app reapplies review anchors after both main-thread and worker preview renders. +- If a reviewed block changes enough that its signature no longer matches, the thread stays visible as unanchored feedback instead of attaching to the wrong block. +- Review threads are stored per normal tab inside `markdownViewerTabs`. Duplicating a document starts the copy with no review threads. +- Private mode and Clear local data cover review threads because they use the same document storage as the Markdown tab. +- Review pins, outlines, the review panel, and thread content are excluded from Markdown, HTML, PDF, PNG, and browser-print output. + +Sharing limits: + +- Review threads are local-only and are not encoded into Share Snapshot URLs, stored snapshot payloads, or Live Share Yjs updates. +- Review feedback added to a temporary Share Snapshot or Live Share tab is kept only for that tab's current session. +- Use Copy review summary to transfer feedback outside the local workspace. + ## Editing and Formatting Tools The formatting toolbar inserts or transforms Markdown at the current selection. It provides WYSIWYG-style helpers for plain Markdown with live preview, not full in-place rich-text editing. @@ -478,6 +504,7 @@ Security limitations: | :--- | :--- | :--- | :--- | | Typing and local preview | No | Browser memory and saved tabs | Sanitized before preview insertion. | | Normal tab autosave | No | `localStorage` or desktop storage mirror | Cleared with site/app data or Reset. | +| Comments and suggestions | No | Inside normal saved tabs | Local-only review data; excluded from document exports and not synced by Share Snapshot or Live Share. | | Private mode | No | No document-state persistence | Clears existing document state when enabled and prevents normal document-state writes until disabled. | | Local file import | No | Current tab/workspace | Reads selected files only. | | Markdown/HTML/PDF/PNG export | No, except remote assets already referenced | User download location | Browser may request external images/fonts used by content. | diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index 1ab9a3ee..9838fbf9 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -43,6 +43,20 @@ The toolbar can: The editor also supports list continuation on Enter, two-space indent on Tab, outdent on Shift+Tab, and custom undo/redo. View-only shared tabs block editing actions and keep reading, find, help, and fullscreen available. +## Comments and Suggestions + +Use Review when you want to leave structured feedback without editing the Markdown source. + +1. Click **Review** in the desktop header or mobile menu. The app switches to a read-only preview workspace and remembers your previous view layout. +2. Select the comment pin on a rendered heading, paragraph, code block, or diagram. +3. Choose **Comment** for general feedback or **Suggestion** for a proposed change. +4. Enter the feedback and add it to the document review. +5. Use **Resolve** or **Reopen** to manage a thread. Use the clipboard button in the review panel to copy a Markdown summary of all feedback. + +Review threads are stored with the normal local document tab and survive reloads. They are not inserted into Markdown and are excluded from Markdown, HTML, PDF, PNG, and print output. If the reviewed source block changes, its thread remains in the panel as an unanchored item instead of moving to a different block. Duplicating a tab starts the copy without review threads. + +Review data is local-only. Share Snapshot links and Live Share rooms currently transfer Markdown, not review threads. Feedback created on a temporary shared or live tab lasts only for that tab's current session. Copy the review summary when you need to send feedback to an author. Private mode prevents review threads from being persisted along with the rest of the document workspace. + ## Find and Replace Open Find and Replace with the toolbar, `Ctrl+F`, or `Cmd+F`. Open replacement focus with `Ctrl+H` or `Cmd+H`. From 65a77c709e27af1ff067e42a887fe02d1fadba93 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 13 Jul 2026 01:22:38 +0530 Subject: [PATCH 02/18] style(review): align controls with app accent --- desktop-app/resources/index.html | 2 +- desktop-app/resources/styles.css | 66 ++++++++++++-------------------- index.html | 2 +- styles.css | 66 ++++++++++++-------------------- 4 files changed, 52 insertions(+), 84 deletions(-) diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 7e4e6d8c..9e2ad73d 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -110,7 +110,7 @@

Markdown Viewer - Online - diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 9846a973..6e5f8ae1 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -23,9 +23,6 @@ /* Code block background for light mode */ --skeleton-bg: #e2e8f0; --skeleton-glow: rgba(255, 255, 255, 0.65); - --review-accent: #8250df; - --review-accent-strong: #6639ba; - --review-accent-soft: #f3effb; --review-suggestion: #9a6700; --review-suggestion-soft: #fff8c5; --review-panel-shadow: -12px 0 32px rgba(31, 35, 40, 0.16); @@ -69,9 +66,6 @@ /* Code block background for dark mode */ --skeleton-bg: #2d3139; --skeleton-glow: rgba(255, 255, 255, 0.08); - --review-accent: #a371f7; - --review-accent-strong: #bc8cff; - --review-accent-soft: rgba(163, 113, 247, 0.15); --review-suggestion: #d29922; --review-suggestion-soft: rgba(187, 128, 9, 0.16); --review-panel-shadow: -12px 0 32px rgba(0, 0, 0, 0.45); @@ -5999,16 +5993,6 @@ html[data-theme="dark"] .mermaid svg { /* ======================================== COMMENTS & SUGGESTIONS ======================================== */ -.review-toggle { - position: relative; -} - -.review-toggle.is-active { - color: var(--review-accent-strong); - background: var(--review-accent-soft); - border-color: var(--review-accent); -} - .review-count-badge { min-width: 18px; height: 18px; @@ -6017,8 +6001,8 @@ html[data-theme="dark"] .mermaid svg { display: inline-flex; align-items: center; justify-content: center; - color: #ffffff; - background: var(--review-accent); + color: var(--bg-color); + background: var(--accent-color); font-size: 0.68rem; font-weight: 700; line-height: 1; @@ -6075,7 +6059,7 @@ html[data-theme="dark"] .mermaid svg { .review-panel-eyebrow { margin: 0 0 3px; - color: var(--review-accent-strong); + color: var(--accent-color); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; @@ -6161,7 +6145,7 @@ html[data-theme="dark"] .mermaid svg { .review-filter-btn.is-active, .review-kind-btn.is-active { - color: var(--review-accent-strong); + color: var(--accent-color); background: var(--bg-color); box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12); } @@ -6169,8 +6153,8 @@ html[data-theme="dark"] .mermaid svg { .review-composer { margin: 14px 14px 4px; padding: 14px; - background: var(--review-accent-soft); - border: 1px solid color-mix(in srgb, var(--review-accent) 45%, var(--border-color)); + background: color-mix(in srgb, var(--accent-color) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-color) 45%, var(--border-color)); border-radius: 10px; } @@ -6235,8 +6219,8 @@ html[data-theme="dark"] .mermaid svg { } .review-feedback-input:focus { - border-color: var(--review-accent); - outline: 2px solid color-mix(in srgb, var(--review-accent) 24%, transparent); + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 24%, transparent); outline-offset: 0; } @@ -6256,9 +6240,9 @@ html[data-theme="dark"] .mermaid svg { .review-primary-btn { min-height: 32px; padding: 6px 12px; - color: #ffffff; - background: var(--review-accent); - border: 1px solid var(--review-accent-strong); + color: var(--bg-color); + background: var(--accent-color); + border: 1px solid var(--accent-color); border-radius: 6px; cursor: pointer; font-size: 0.76rem; @@ -6266,7 +6250,7 @@ html[data-theme="dark"] .mermaid svg { } .review-primary-btn:hover:not(:disabled) { - background: var(--review-accent-strong); + background: color-mix(in srgb, var(--accent-color) 84%, #000000); } .review-primary-btn:disabled { @@ -6285,7 +6269,7 @@ html[data-theme="dark"] .mermaid svg { padding: 12px; background: var(--bg-color); border: 1px solid var(--border-color); - border-inline-start: 3px solid var(--review-accent); + border-inline-start: 3px solid var(--accent-color); border-radius: 8px; } @@ -6331,8 +6315,8 @@ html[data-theme="dark"] .mermaid svg { .review-kind-label { padding: 5px 7px; - color: var(--review-accent-strong); - background: var(--review-accent-soft); + color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 12%, transparent); } .review-thread[data-kind="suggestion"] .review-kind-label { @@ -6361,7 +6345,7 @@ html[data-theme="dark"] .mermaid svg { } .review-thread-anchor:hover:not(:disabled) { - color: var(--review-accent-strong); + color: var(--accent-color); text-decoration: underline; } @@ -6424,7 +6408,7 @@ html[data-theme="dark"] .mermaid svg { .review-empty-state i { display: block; margin-bottom: 10px; - color: var(--review-accent); + color: var(--accent-color); font-size: 1.7rem; } @@ -6452,16 +6436,16 @@ html[data-theme="dark"] .mermaid svg { } #markdown-preview .review-target.is-review-target-active { - outline: 2px solid var(--review-accent); + outline: 2px solid var(--accent-color); outline-offset: 4px; } #markdown-preview .review-target.has-review-thread { - box-shadow: inset 3px 0 0 var(--review-accent); + box-shadow: inset 3px 0 0 var(--accent-color); } [dir="rtl"] #markdown-preview .review-target.has-review-thread { - box-shadow: inset -3px 0 0 var(--review-accent); + box-shadow: inset -3px 0 0 var(--accent-color); } .review-pins-layer { @@ -6486,9 +6470,9 @@ html[data-theme="dark"] .mermaid svg { align-items: center; justify-content: center; gap: 4px; - color: #ffffff; - background: var(--review-accent); - border: 1px solid var(--review-accent-strong); + color: var(--bg-color); + background: var(--accent-color); + border: 1px solid var(--accent-color); border-radius: 999px; box-shadow: 0 2px 8px rgba(31, 35, 40, 0.22); cursor: pointer; @@ -6504,7 +6488,7 @@ html[data-theme="dark"] .mermaid svg { } .review-target-button:hover { - background: var(--review-accent-strong); + background: color-mix(in srgb, var(--accent-color) 84%, #000000); } .review-filter-btn:focus-visible, @@ -6514,7 +6498,7 @@ html[data-theme="dark"] .mermaid svg { .review-thread-action:focus-visible, .review-thread-anchor:focus-visible, .review-target-button:focus-visible { - outline: 2px solid var(--review-accent); + outline: 2px solid var(--accent-color); outline-offset: 2px; } diff --git a/index.html b/index.html index a062a3fc..0011c1b9 100644 --- a/index.html +++ b/index.html @@ -203,7 +203,7 @@

Markdown Viewer - Online - diff --git a/styles.css b/styles.css index 9846a973..6e5f8ae1 100644 --- a/styles.css +++ b/styles.css @@ -23,9 +23,6 @@ /* Code block background for light mode */ --skeleton-bg: #e2e8f0; --skeleton-glow: rgba(255, 255, 255, 0.65); - --review-accent: #8250df; - --review-accent-strong: #6639ba; - --review-accent-soft: #f3effb; --review-suggestion: #9a6700; --review-suggestion-soft: #fff8c5; --review-panel-shadow: -12px 0 32px rgba(31, 35, 40, 0.16); @@ -69,9 +66,6 @@ /* Code block background for dark mode */ --skeleton-bg: #2d3139; --skeleton-glow: rgba(255, 255, 255, 0.08); - --review-accent: #a371f7; - --review-accent-strong: #bc8cff; - --review-accent-soft: rgba(163, 113, 247, 0.15); --review-suggestion: #d29922; --review-suggestion-soft: rgba(187, 128, 9, 0.16); --review-panel-shadow: -12px 0 32px rgba(0, 0, 0, 0.45); @@ -5999,16 +5993,6 @@ html[data-theme="dark"] .mermaid svg { /* ======================================== COMMENTS & SUGGESTIONS ======================================== */ -.review-toggle { - position: relative; -} - -.review-toggle.is-active { - color: var(--review-accent-strong); - background: var(--review-accent-soft); - border-color: var(--review-accent); -} - .review-count-badge { min-width: 18px; height: 18px; @@ -6017,8 +6001,8 @@ html[data-theme="dark"] .mermaid svg { display: inline-flex; align-items: center; justify-content: center; - color: #ffffff; - background: var(--review-accent); + color: var(--bg-color); + background: var(--accent-color); font-size: 0.68rem; font-weight: 700; line-height: 1; @@ -6075,7 +6059,7 @@ html[data-theme="dark"] .mermaid svg { .review-panel-eyebrow { margin: 0 0 3px; - color: var(--review-accent-strong); + color: var(--accent-color); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; @@ -6161,7 +6145,7 @@ html[data-theme="dark"] .mermaid svg { .review-filter-btn.is-active, .review-kind-btn.is-active { - color: var(--review-accent-strong); + color: var(--accent-color); background: var(--bg-color); box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12); } @@ -6169,8 +6153,8 @@ html[data-theme="dark"] .mermaid svg { .review-composer { margin: 14px 14px 4px; padding: 14px; - background: var(--review-accent-soft); - border: 1px solid color-mix(in srgb, var(--review-accent) 45%, var(--border-color)); + background: color-mix(in srgb, var(--accent-color) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-color) 45%, var(--border-color)); border-radius: 10px; } @@ -6235,8 +6219,8 @@ html[data-theme="dark"] .mermaid svg { } .review-feedback-input:focus { - border-color: var(--review-accent); - outline: 2px solid color-mix(in srgb, var(--review-accent) 24%, transparent); + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 24%, transparent); outline-offset: 0; } @@ -6256,9 +6240,9 @@ html[data-theme="dark"] .mermaid svg { .review-primary-btn { min-height: 32px; padding: 6px 12px; - color: #ffffff; - background: var(--review-accent); - border: 1px solid var(--review-accent-strong); + color: var(--bg-color); + background: var(--accent-color); + border: 1px solid var(--accent-color); border-radius: 6px; cursor: pointer; font-size: 0.76rem; @@ -6266,7 +6250,7 @@ html[data-theme="dark"] .mermaid svg { } .review-primary-btn:hover:not(:disabled) { - background: var(--review-accent-strong); + background: color-mix(in srgb, var(--accent-color) 84%, #000000); } .review-primary-btn:disabled { @@ -6285,7 +6269,7 @@ html[data-theme="dark"] .mermaid svg { padding: 12px; background: var(--bg-color); border: 1px solid var(--border-color); - border-inline-start: 3px solid var(--review-accent); + border-inline-start: 3px solid var(--accent-color); border-radius: 8px; } @@ -6331,8 +6315,8 @@ html[data-theme="dark"] .mermaid svg { .review-kind-label { padding: 5px 7px; - color: var(--review-accent-strong); - background: var(--review-accent-soft); + color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 12%, transparent); } .review-thread[data-kind="suggestion"] .review-kind-label { @@ -6361,7 +6345,7 @@ html[data-theme="dark"] .mermaid svg { } .review-thread-anchor:hover:not(:disabled) { - color: var(--review-accent-strong); + color: var(--accent-color); text-decoration: underline; } @@ -6424,7 +6408,7 @@ html[data-theme="dark"] .mermaid svg { .review-empty-state i { display: block; margin-bottom: 10px; - color: var(--review-accent); + color: var(--accent-color); font-size: 1.7rem; } @@ -6452,16 +6436,16 @@ html[data-theme="dark"] .mermaid svg { } #markdown-preview .review-target.is-review-target-active { - outline: 2px solid var(--review-accent); + outline: 2px solid var(--accent-color); outline-offset: 4px; } #markdown-preview .review-target.has-review-thread { - box-shadow: inset 3px 0 0 var(--review-accent); + box-shadow: inset 3px 0 0 var(--accent-color); } [dir="rtl"] #markdown-preview .review-target.has-review-thread { - box-shadow: inset -3px 0 0 var(--review-accent); + box-shadow: inset -3px 0 0 var(--accent-color); } .review-pins-layer { @@ -6486,9 +6470,9 @@ html[data-theme="dark"] .mermaid svg { align-items: center; justify-content: center; gap: 4px; - color: #ffffff; - background: var(--review-accent); - border: 1px solid var(--review-accent-strong); + color: var(--bg-color); + background: var(--accent-color); + border: 1px solid var(--accent-color); border-radius: 999px; box-shadow: 0 2px 8px rgba(31, 35, 40, 0.22); cursor: pointer; @@ -6504,7 +6488,7 @@ html[data-theme="dark"] .mermaid svg { } .review-target-button:hover { - background: var(--review-accent-strong); + background: color-mix(in srgb, var(--accent-color) 84%, #000000); } .review-filter-btn:focus-visible, @@ -6514,7 +6498,7 @@ html[data-theme="dark"] .mermaid svg { .review-thread-action:focus-visible, .review-thread-anchor:focus-visible, .review-target-button:focus-visible { - outline: 2px solid var(--review-accent); + outline: 2px solid var(--accent-color); outline-offset: 2px; } From a36bd928150614b902f020305149cc7484b324af Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 13 Jul 2026 02:12:29 +0530 Subject: [PATCH 03/18] feat(review): improve review management controls --- desktop-app/resources/index.html | 35 ++++++- desktop-app/resources/js/script.js | 141 +++++++++++++++++++++++++++-- desktop-app/resources/styles.css | 44 ++++++++- index.html | 35 ++++++- script.js | 141 +++++++++++++++++++++++++++-- styles.css | 44 ++++++++- wiki/Features.md | 2 +- wiki/Usage-Guide.md | 2 +- 8 files changed, 410 insertions(+), 34 deletions(-) diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 9e2ad73d..06b03b17 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -112,8 +112,8 @@

Markdown Viewer - Online + + + +