diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b92c6ea..539301f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@
All notable code changes to **Markdown Viewer** are documented here.
Non-code commits (documentation, planning, README-only updates) are excluded.
+## Unreleased
+
+- **Comments & Suggestions UI:** Simplified the Review panel, composer, suggestion cards, and preview pins using the app's existing accent, button, border, hover, disabled, and theme tokens. The main toolbar Review button shows its open-item count inline beside the existing icon instead of using a separate count button. Review target buttons match the main toolbar controls, align in a straight column at the preview's right edge, and show a plus before feedback exists. Reviewed targets provide separate controls for reading saved feedback and adding another item, so opening the review count no longer opens the composer. Threads now record and display opened/closed date and time, and copied summaries include lifecycle dates plus open/resolved totals. Opening a new tab closes Review mode automatically. YAML frontmatter tables are reviewable, reviewed text has clear spacing from its side marker, and existing comments and suggestions can be edited in place. Saved feedback appears before the composer in one shared scroll area, keeping previous reviews visible in short panels while new and edited feedback scrolls into view. Refined the layout into a compact desktop side panel, tablet drawer, and touch-friendly mobile bottom sheet without introducing a separate color palette.
+
+---
+
## v3.9.1
- **Description:** Improved export reliability, file workflows, diagram insertion, shared-document safety, and browser compatibility.
diff --git a/README.md b/README.md
index 3b0fb9d3..dd5ec3c5 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 YAML frontmatter tables, headings, paragraphs, code blocks, and diagrams without changing the Markdown source. The responsive side panel uses the same controls and theme as the rest of the app.
+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 per-document review layer. Review pins attach comments or suggestions to rendered YAML frontmatter tables, headings, paragraphs, code blocks, and diagrams while the Markdown stays read-only. Empty targets show a plus. Reviewed targets show two separate controls: the review count opens saved feedback, and the adjacent plus opens the composer for another item. Threads show their opened and closed date/time, can be edited, resolved, reopened, deleted, or copied in a detailed Markdown summary. Opening a new tab closes Review mode automatically. The interface uses a desktop side panel, a tablet drawer, and a touch-friendly mobile bottom sheet. Review data stays with the local tab workspace, is excluded from Share Snapshot, and is relayed only while an existing Live Share room is active.
- **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.
diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html
index e4949d34..6cac0b7c 100644
--- a/desktop-app/resources/index.html
+++ b/desktop-app/resources/index.html
@@ -109,6 +109,10 @@
Markdown Viewer - Online
Live Share
+
+
+ 0 Review
+
@@ -236,6 +240,11 @@ Menu
+
+
-
-
-
Are you sure you want to delete all files?
+
+
+
+
This will remove all open files and review items. Unsaved changes cannot be recovered.
- Cancel
- Delete All
+ Cancel
+ Reset all
+
+
+
+
+
+
+
+
+
This comment or suggestion will be permanently deleted.
+
+ Cancel
+ Delete
@@ -538,6 +570,7 @@
Application shortcuts
Use the view buttons in the toolbar to switch between Editor, Split, and Preview modes.
Sync scrolling is available in Split view to keep the editor and preview aligned.
+ Review opens a read-only preview where you can comment on or suggest changes to headings, paragraphs, code blocks, and diagrams.
Import, Export, Copy, Share, and Theme toggle actions are always available in the header toolbar.
@@ -1069,7 +1102,75 @@
Open-source credits
-
+
+
+
+
diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js
index 72184ab8..2534ecd0 100644
--- a/desktop-app/resources/js/script.js
+++ b/desktop-app/resources/js/script.js
@@ -263,6 +263,18 @@ document.addEventListener("DOMContentLoaded", async function () {
const shareSnapshotViewOnlyTabIds = new Set();
const SHARE_SNAPSHOT_TAB_KIND = 'share-snapshot';
const APP_VERSION = '3.9.1';
+ const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, pre, .frontmatter-table, .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 activeReviewEditId = null;
+ let pendingReviewDelete = null;
+ let reviewPinsResizeObserver = null;
+ let reviewPinsLayoutFrame = null;
+ let reviewTargetSourceSnapshots = new WeakMap();
let activeModal = null;
let lastFocusedElement = null;
let isFindModalOpen = false;
@@ -292,6 +304,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 +331,34 @@ 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 reviewToolbarCount = document.getElementById('review-toolbar-count');
+ 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 reviewResolveAll = document.getElementById('review-resolve-all');
+ const reviewDeleteAll = document.getElementById('review-delete-all');
+ const reviewDeleteModal = document.getElementById('review-delete-modal');
+ const reviewDeleteTitle = document.getElementById('review-delete-title');
+ const reviewDeleteDescription = document.getElementById('review-delete-description');
+ const reviewDeleteClose = document.getElementById('review-delete-close');
+ const reviewDeleteCancel = document.getElementById('review-delete-cancel');
+ const reviewDeleteConfirm = document.getElementById('review-delete-confirm');
+ const reviewPanelBody = document.querySelector('.review-panel-body');
+ const reviewComposer = document.getElementById('review-composer');
+ const reviewComposerTitle = document.getElementById('review-composer-title');
+ 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 +436,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 +706,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);
@@ -2376,6 +2421,1287 @@ document.addEventListener("DOMContentLoaded", async function () {
let liveCollaborationModulesPromise = null;
const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit");
const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay");
+ const LIVE_REVIEW_EDIT_ORIGIN = Symbol("markdown-viewer-live-review-local-edit");
+ const LIVE_REVIEW_RELAY_ORIGIN = Symbol("markdown-viewer-live-review-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;
+ const resolved = thread.resolved === true;
+ const resolvedAt = resolved && Number.isFinite(Number(thread.resolvedAt))
+ ? Number(thread.resolvedAt)
+ : null;
+ 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: resolved,
+ resolvedAt: resolvedAt
+ };
+ }).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 cloneReviewThreadForLiveShare(thread) {
+ return {
+ id: thread.id,
+ anchor: Object.assign({}, thread.anchor),
+ kind: thread.kind,
+ body: thread.body,
+ createdAt: thread.createdAt,
+ updatedAt: thread.updatedAt,
+ resolved: thread.resolved === true,
+ resolvedAt: Number.isFinite(Number(thread.resolvedAt)) ? Number(thread.resolvedAt) : null
+ };
+ }
+
+ function liveReviewThreadsMatch(left, right) {
+ if (!left || !right) return false;
+ const leftAnchor = left.anchor || {};
+ const rightAnchor = right.anchor || {};
+ return left.id === right.id &&
+ left.kind === right.kind &&
+ left.body === right.body &&
+ left.createdAt === right.createdAt &&
+ left.updatedAt === right.updatedAt &&
+ left.resolved === right.resolved &&
+ left.resolvedAt === right.resolvedAt &&
+ leftAnchor.key === rightAnchor.key &&
+ leftAnchor.type === rightAnchor.type &&
+ leftAnchor.label === rightAnchor.label &&
+ leftAnchor.excerpt === rightAnchor.excerpt &&
+ leftAnchor.duplicateCount === rightAnchor.duplicateCount &&
+ leftAnchor.context === rightAnchor.context;
+ }
+
+ function replaceLiveReviewMapThreads(reviewDoc, reviewMap, rawThreads, origin) {
+ if (!reviewDoc || !reviewMap) return;
+ const threads = normalizeReviewThreads(rawThreads);
+ const nextIds = new Set(threads.map(function(thread) { return thread.id; }));
+ const staleIds = [];
+ reviewMap.forEach(function(_, id) {
+ if (!nextIds.has(id)) staleIds.push(id);
+ });
+
+ reviewDoc.transact(function() {
+ staleIds.forEach(function(id) { reviewMap.delete(id); });
+ threads.forEach(function(thread) {
+ const nextThread = cloneReviewThreadForLiveShare(thread);
+ if (!liveReviewThreadsMatch(reviewMap.get(thread.id), nextThread)) {
+ reviewMap.set(thread.id, nextThread);
+ }
+ });
+ }, origin);
+ }
+
+ function getLiveReviewMapThreads(reviewMap) {
+ const rawThreads = [];
+ if (!reviewMap) return rawThreads;
+ reviewMap.forEach(function(thread, id) {
+ if (!thread || typeof thread !== 'object') return;
+ rawThreads.push(Object.assign({}, thread, { id: String(id || thread.id || '') }));
+ });
+ return normalizeReviewThreads(rawThreads).sort(function(a, b) {
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
+ return a.id.localeCompare(b.id);
+ });
+ }
+
+ function syncLiveReviewThreadsFromTab() {
+ if (!liveCollaboration || !liveCollaboration.reviewDoc || !liveCollaboration.reviewMap) return;
+ const tab = tabs.find(function(item) { return item.id === liveCollaboration.tabId; });
+ if (!tab || tab.id !== activeTabId) return;
+ replaceLiveReviewMapThreads(
+ liveCollaboration.reviewDoc,
+ liveCollaboration.reviewMap,
+ tab.reviewThreads,
+ LIVE_REVIEW_EDIT_ORIGIN
+ );
+ }
+
+ function applyLiveReviewThreadsFromMap() {
+ if (!liveCollaboration || !liveCollaboration.reviewMap || !liveCollaboration.tabId) return;
+ const tab = tabs.find(function(item) { return item.id === liveCollaboration.tabId; });
+ if (!tab) return;
+
+ tab.reviewThreads = getLiveReviewMapThreads(liveCollaboration.reviewMap);
+ saveTabsToStorage(tabs);
+ if (tab.id === activeTabId) {
+ if (activeReviewEditId && !tab.reviewThreads.some(function(thread) { return thread.id === activeReviewEditId; })) {
+ closeReviewComposer();
+ }
+ decorateReviewTargets();
+ renderReviewPanel();
+ }
+ }
+
+ 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.matches('.frontmatter-table')) return 'table';
+ 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 === 'table') return 'YAML frontmatter';
+ 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;
+ }
+ const frontmatterTable = target.closest('.frontmatter-table');
+ if (frontmatterTable && frontmatterTable !== 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--table',
+ '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 previewRect = markdownPreview.getBoundingClientRect();
+ const pinRight = previewRect.right - 4;
+ reviewPinsLayer.querySelectorAll('.review-target-actions').forEach(function(actions) {
+ const anchor = {
+ key: actions.dataset.reviewAnchor,
+ duplicateCount: Number(actions.dataset.reviewDuplicateCount) || null,
+ context: actions.dataset.reviewContext || null
+ };
+ const target = findReviewTarget(anchor);
+ if (!target) {
+ actions.hidden = true;
+ return;
+ }
+ actions.hidden = false;
+ const targetRect = target.getBoundingClientRect();
+ const isDiagram = target.dataset.reviewType === 'diagram';
+ const top = isDiagram ? targetRect.bottom - 40 : targetRect.top + 4;
+ actions.style.top = (top - paneRect.top + previewPaneElement.scrollTop) + 'px';
+ actions.style.left = (pinRight - paneRect.left + previewPaneElement.scrollLeft) + 'px';
+ actions.style.transform = '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 actions = document.createElement('div');
+ actions.className = 'review-target-actions';
+ actions.dataset.reviewAnchor = anchorKey;
+ actions.dataset.reviewDuplicateCount = String(duplicateCount);
+ actions.dataset.reviewContext = context;
+ actions.dataset.html2canvasIgnore = 'true';
+ actions.setAttribute('role', 'group');
+ actions.setAttribute('aria-label', 'Review actions for ' + label);
+
+ function createTargetButton(command, iconClass, title, ariaLabel) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'tool-button review-target-button';
+ button.dataset.reviewCommand = command;
+ button.dataset.reviewAnchor = anchorKey;
+ button.dataset.reviewDuplicateCount = String(duplicateCount);
+ button.dataset.reviewContext = context;
+ button.dataset.html2canvasIgnore = 'true';
+ button.title = title;
+ button.setAttribute('aria-label', ariaLabel);
+ button.setAttribute('aria-controls', command === 'read' ? 'review-list' : 'review-composer');
+ const icon = document.createElement('i');
+ icon.className = iconClass;
+ icon.setAttribute('aria-hidden', 'true');
+ button.appendChild(icon);
+ return button;
+ }
+
+ if (targetThreads.length > 0) {
+ const reviewLabel = targetThreads.length + ' review item' + (targetThreads.length === 1 ? '' : 's');
+ const readButton = createTargetButton(
+ 'read',
+ 'bi bi-chat-square-text',
+ 'Read ' + reviewLabel,
+ 'Read ' + reviewLabel + ' for ' + label
+ );
+ readButton.classList.add('is-active');
+ readButton.dataset.reviewCount = String(targetThreads.length);
+ actions.appendChild(readButton);
+ }
+
+ actions.appendChild(createTargetButton(
+ 'add',
+ 'bi bi-plus-lg',
+ targetThreads.length > 0 ? 'Add another review item' : 'Add feedback',
+ 'Add feedback to ' + label
+ ));
+ if (reviewPinsLayer) reviewPinsLayer.appendChild(actions);
+ });
+
+ 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 updateReviewToggleActiveState(openCount) {
+ const count = typeof openCount === 'number'
+ ? openCount
+ : getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length;
+ if (reviewToggle) reviewToggle.classList.toggle('is-active', reviewModeActive || count > 0);
+ if (mobileReviewToggle) mobileReviewToggle.classList.toggle('is-active', reviewModeActive);
+ }
+
+ function updateReviewCountBadges() {
+ const openCount = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length;
+ const countLabel = openCount + ' open review item' + (openCount === 1 ? '' : 's');
+ if (reviewToolbarCount) {
+ reviewToolbarCount.textContent = String(openCount);
+ reviewToolbarCount.hidden = openCount === 0;
+ }
+ if (mobileReviewCountBadge) {
+ mobileReviewCountBadge.textContent = String(openCount);
+ mobileReviewCountBadge.hidden = openCount === 0;
+ mobileReviewCountBadge.title = countLabel;
+ }
+ if (reviewToggle) {
+ reviewToggle.setAttribute('aria-label', openCount > 0
+ ? 'Open comments and suggestions. ' + countLabel + '.'
+ : 'Open comments and suggestions');
+ reviewToggle.title = openCount > 0
+ ? 'Open comments and suggestions (' + countLabel + ')'
+ : 'Open comments and suggestions';
+ }
+ if (mobileReviewToggle) {
+ mobileReviewToggle.setAttribute('aria-label', openCount > 0
+ ? 'Open comments and suggestions. ' + openCount + ' open review item' + (openCount === 1 ? '' : 's') + '.'
+ : 'Open comments and suggestions');
+ }
+ updateReviewToggleActiveState(openCount);
+ }
+
+ 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' : '');
+ if (thread.id === activeReviewEditId) item.classList.add('is-review-thread-active');
+ 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-pencil-square' : '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 dates = document.createElement('div');
+ dates.className = 'review-thread-dates';
+ const openedTime = document.createElement('time');
+ openedTime.className = 'review-thread-time';
+ openedTime.dateTime = new Date(thread.createdAt).toISOString();
+ openedTime.textContent = 'Opened: ' + formatReviewTime(thread.createdAt);
+ dates.appendChild(openedTime);
+ if (thread.resolvedAt) {
+ const closedTime = document.createElement('time');
+ closedTime.className = 'review-thread-time';
+ closedTime.dateTime = new Date(thread.resolvedAt).toISOString();
+ closedTime.textContent = 'Closed: ' + formatReviewTime(thread.resolvedAt);
+ dates.appendChild(closedTime);
+ } else {
+ const closedTime = document.createElement('span');
+ closedTime.className = 'review-thread-time';
+ closedTime.textContent = thread.resolved ? 'Closed: Unavailable' : 'Closed: Not closed';
+ dates.appendChild(closedTime);
+ }
+ item.appendChild(dates);
+
+ const actions = document.createElement('div');
+ actions.className = 'review-thread-actions';
+ const editButton = document.createElement('button');
+ editButton.type = 'button';
+ editButton.className = 'review-thread-action';
+ editButton.dataset.reviewAction = 'edit';
+ editButton.dataset.reviewId = thread.id;
+ editButton.textContent = 'Edit';
+ editButton.setAttribute('aria-label', 'Edit ' + (thread.kind === 'suggestion' ? 'suggestion' : 'comment'));
+ actions.appendChild(editButton);
+ 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;
+ if (reviewResolveAll) reviewResolveAll.disabled = openCount === 0;
+ if (reviewDeleteAll) reviewDeleteAll.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 = 'Select a comment pin in the preview to add feedback.';
+ } else {
+ title.textContent = 'No open feedback';
+ copy.textContent = threads.length > 0
+ ? 'Everything has been resolved. Switch to Resolved or All to review earlier feedback.'
+ : 'Select a comment pin in the preview to add feedback.';
+ }
+ }
+ }
+
+ function setReviewFilter(nextFilter) {
+ reviewFilter = ['open', 'resolved', 'all'].includes(nextFilter) ? nextFilter : 'open';
+ document.querySelectorAll('[data-review-filter]').forEach(function(button) {
+ const active = button.dataset.reviewFilter === reviewFilter;
+ button.classList.toggle('is-active', active);
+ button.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ renderReviewPanel();
+ }
+
+ function getReviewThreadsForAnchor(anchor) {
+ if (!anchor || !anchor.key) return [];
+ return getActiveReviewThreads().filter(function(thread) {
+ if (!thread.anchor || thread.anchor.key !== anchor.key) return false;
+ if (anchor.duplicateCount && thread.anchor.duplicateCount && Number(thread.anchor.duplicateCount) !== Number(anchor.duplicateCount)) return false;
+ if (anchor.context && thread.anchor.context && thread.anchor.context !== anchor.context) return false;
+ return true;
+ });
+ }
+
+ function showReviewThreadsForAnchor(anchor) {
+ const threads = getReviewThreadsForAnchor(anchor).sort(function(a, b) {
+ return b.createdAt - a.createdAt;
+ });
+ if (threads.length === 0) return false;
+
+ closeReviewComposer();
+ setReviewFilter('all');
+
+ const matchingIds = new Set(threads.map(function(thread) { return thread.id; }));
+ const matchingItems = Array.from(reviewList.querySelectorAll('.review-thread')).filter(function(item) {
+ return matchingIds.has(item.dataset.reviewId);
+ });
+ matchingItems.forEach(function(item) {
+ item.classList.add('is-review-thread-active');
+ });
+ if (matchingItems[0]) {
+ matchingItems[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ const anchorButton = matchingItems[0].querySelector('.review-thread-anchor');
+ if (anchorButton) anchorButton.focus({ preventScroll: true });
+ }
+
+ clearTimeout(showReviewThreadsForAnchor._timeoutId);
+ showReviewThreadsForAnchor._timeoutId = setTimeout(function() {
+ matchingItems.forEach(function(item) {
+ item.classList.remove('is-review-thread-active');
+ });
+ }, 1800);
+ announceToScreenReader('Showing ' + threads.length + ' review item' + (threads.length === 1 ? '' : 's') + ' for this block.');
+ return true;
+ }
+
+ 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 (reviewComposerTitle) {
+ reviewComposerTitle.textContent = activeReviewEditId
+ ? (reviewComposerKind === 'suggestion' ? 'Edit suggestion' : 'Edit comment')
+ : (reviewComposerKind === 'suggestion' ? 'Add suggestion' : 'Add 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 = activeReviewEditId
+ ? 'Save changes'
+ : (reviewComposerKind === 'suggestion' ? 'Add suggestion' : 'Add comment');
+ }
+ }
+
+ function focusReviewPin(anchor) {
+ const target = findReviewTarget(anchor);
+ if (!target || !reviewPinsLayer) return false;
+ const matchingButtons = Array.from(reviewPinsLayer.querySelectorAll('.review-target-button')).filter(function(candidate) {
+ return candidate.dataset.reviewAnchor === target.dataset.reviewAnchor &&
+ candidate.dataset.reviewContext === target.dataset.reviewContext;
+ });
+ const button = matchingButtons.find(function(candidate) {
+ return candidate.dataset.reviewCommand === 'read';
+ }) || matchingButtons[0] || null;
+ if (!button) return false;
+ button.focus({ preventScroll: true });
+ return true;
+ }
+
+ function focusReviewComposerInput() {
+ if (!reviewFeedbackInput) return;
+ reviewFeedbackInput.focus();
+ requestAnimationFrame(function() {
+ if (reviewComposer && !reviewComposer.hidden && reviewPanelBody) {
+ reviewPanelBody.scrollTop = reviewPanelBody.scrollHeight;
+ }
+ });
+ }
+
+ function closeReviewComposer(options) {
+ options = options || {};
+ const returnAnchor = activeReviewAnchor;
+ const returnReviewId = activeReviewEditId;
+ if (activeReviewAnchor) {
+ const previousTarget = findReviewTarget(activeReviewAnchor);
+ if (previousTarget) previousTarget.classList.remove('is-review-target-active');
+ }
+ activeReviewAnchor = null;
+ activeReviewEditId = null;
+ if (reviewComposer) reviewComposer.hidden = true;
+ if (reviewFeedbackInput) reviewFeedbackInput.value = '';
+ if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT;
+ if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true;
+ if (reviewList) {
+ reviewList.querySelectorAll('.is-review-thread-active').forEach(function(item) {
+ item.classList.remove('is-review-thread-active');
+ });
+ }
+ if (options.restoreFocus && reviewModeActive) {
+ const editButton = returnReviewId && reviewList
+ ? Array.from(reviewList.querySelectorAll('[data-review-action="edit"]')).find(function(button) {
+ return button.dataset.reviewId === returnReviewId;
+ })
+ : null;
+ if (editButton) {
+ editButton.focus();
+ } else if (!focusReviewPin(returnAnchor) && reviewPanelClose) {
+ reviewPanelClose.focus();
+ }
+ }
+ }
+
+ function openReviewComposer(target, options) {
+ options = options || {};
+ if (!target) return;
+ const descriptor = getReviewAnchorDescriptor(target);
+ if (!descriptor) return;
+ closeReviewComposer();
+ activeReviewEditId = null;
+ 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 = '';
+ if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT;
+ if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true;
+ if (options.focusInput !== false) focusReviewComposerInput();
+ }
+ }
+
+ function openReviewEditor(thread) {
+ if (!thread || !reviewComposer || !reviewFeedbackInput) return;
+ closeReviewComposer();
+ activeReviewEditId = thread.id;
+ activeReviewAnchor = Object.assign({}, thread.anchor);
+ const target = findReviewTarget(thread.anchor);
+ if (target) target.classList.add('is-review-target-active');
+ if (reviewComposerAnchor) {
+ reviewComposerAnchor.textContent = thread.anchor.label + ': ' + thread.anchor.excerpt;
+ }
+ updateReviewComposerKind(thread.kind);
+ reviewComposer.hidden = false;
+ reviewFeedbackInput.value = thread.body;
+ if (reviewFeedbackCount) reviewFeedbackCount.textContent = reviewFeedbackInput.value.length + ' / ' + REVIEW_TEXT_LIMIT;
+ if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = false;
+ renderReviewPanel();
+ focusReviewComposerInput();
+ reviewFeedbackInput.setSelectionRange(reviewFeedbackInput.value.length, reviewFeedbackInput.value.length);
+ announceToScreenReader('Editing ' + (thread.kind === 'suggestion' ? 'suggestion.' : 'comment.'));
+ }
+
+ function persistReviewThreads(message) {
+ syncLiveReviewThreadsFromTab();
+ 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);
+ if (activeReviewEditId) {
+ const reviewId = activeReviewEditId;
+ const thread = getActiveReviewThreads().find(function(item) { return item.id === reviewId; });
+ if (!thread) {
+ closeReviewComposer();
+ announceToScreenReader('This review item is no longer available.');
+ return;
+ }
+ thread.kind = reviewComposerKind;
+ thread.body = body.slice(0, REVIEW_TEXT_LIMIT);
+ thread.updatedAt = Date.now();
+ closeReviewComposer();
+ persistReviewThreads('Review item updated.');
+ requestAnimationFrame(function() {
+ const editButton = reviewList
+ ? Array.from(reviewList.querySelectorAll('[data-review-action="edit"]')).find(function(button) {
+ return button.dataset.reviewId === reviewId;
+ })
+ : null;
+ if (editButton) editButton.focus();
+ });
+ return;
+ }
+ 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,
+ resolvedAt: null
+ });
+ 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 resolvedCount = threads.filter(function(thread) { return thread.resolved; }).length;
+ const lines = [
+ '# Review: ' + (tab && tab.title ? tab.title : 'Markdown document'),
+ '',
+ '- Generated: ' + formatReviewTime(Date.now()),
+ '- Total items: ' + threads.length,
+ '- Open items: ' + (threads.length - resolvedCount),
+ '- Resolved items: ' + resolvedCount,
+ ''
+ ];
+ threads.forEach(function(thread, index) {
+ const kind = thread.kind === 'suggestion' ? 'Suggestion' : 'Comment';
+ const closedAt = thread.resolved
+ ? (thread.resolvedAt ? formatReviewTime(thread.resolvedAt) : 'Unavailable')
+ : 'Not closed';
+ lines.push((index + 1) + '. **' + kind + '** - ' + thread.anchor.label);
+ lines.push(' - Status: ' + (thread.resolved ? 'Resolved' : 'Open'));
+ lines.push(' - Opened: ' + formatReviewTime(thread.createdAt));
+ lines.push(' - Closed: ' + closedAt);
+ 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 resolveAllReviewThreads() {
+ const openThreads = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; });
+ if (openThreads.length === 0) return;
+ const updatedAt = Date.now();
+ openThreads.forEach(function(thread) {
+ thread.resolved = true;
+ thread.updatedAt = updatedAt;
+ thread.resolvedAt = updatedAt;
+ });
+ persistReviewThreads(openThreads.length + ' review item' + (openThreads.length === 1 ? '' : 's') + ' resolved.');
+ requestAnimationFrame(function() {
+ if (reviewPanelClose) reviewPanelClose.focus();
+ });
+ }
+
+ function cancelReviewDeleteConfirmation() {
+ pendingReviewDelete = null;
+ if (reviewDeleteModal && reviewDeleteModal.getAttribute('aria-hidden') === 'false') {
+ closeAppModal(reviewDeleteModal);
+ }
+ }
+
+ function requestReviewDeleteConfirmation(reviewId) {
+ if (!reviewDeleteModal || !reviewDeleteCancel || !reviewDeleteConfirm) return;
+ const threads = getActiveReviewThreads();
+ const thread = reviewId
+ ? threads.find(function(item) { return item.id === reviewId; })
+ : null;
+ if ((reviewId && !thread) || (!reviewId && threads.length === 0)) return;
+
+ pendingReviewDelete = {
+ tabId: activeTabId,
+ reviewId: reviewId || null
+ };
+ if (reviewDeleteTitle) {
+ reviewDeleteTitle.textContent = reviewId ? 'Delete review item?' : 'Delete all review items?';
+ }
+ if (reviewDeleteDescription) {
+ reviewDeleteDescription.textContent = reviewId
+ ? 'This ' + (thread.kind === 'suggestion' ? 'suggestion' : 'comment') + ' will be permanently deleted. This cannot be undone.'
+ : 'All ' + threads.length + ' review item' + (threads.length === 1 ? '' : 's') + ' in this document will be permanently deleted. This cannot be undone.';
+ }
+ reviewDeleteConfirm.textContent = reviewId ? 'Delete item' : 'Delete all';
+ openAppModal(reviewDeleteModal, {
+ focusTarget: reviewDeleteCancel,
+ onClose: cancelReviewDeleteConfirmation
+ });
+ requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ if (reviewDeleteCancel) reviewDeleteCancel.focus({ preventScroll: true });
+ });
+ });
+ }
+
+ function confirmReviewDeletion() {
+ const pending = pendingReviewDelete;
+ pendingReviewDelete = null;
+ if (!pending || !reviewDeleteModal) return;
+
+ const tab = tabs.find(function(item) { return item.id === pending.tabId; });
+ if (!tab || tab.id !== activeTabId || !Array.isArray(tab.reviewThreads)) {
+ closeAppModal(reviewDeleteModal);
+ announceToScreenReader('Review deletion cancelled because the active document changed.');
+ return;
+ }
+
+ let deletedCount = 0;
+ if (pending.reviewId) {
+ const index = tab.reviewThreads.findIndex(function(thread) { return thread.id === pending.reviewId; });
+ if (index >= 0) {
+ if (activeReviewEditId === pending.reviewId) closeReviewComposer();
+ tab.reviewThreads.splice(index, 1);
+ deletedCount = 1;
+ }
+ } else {
+ deletedCount = tab.reviewThreads.length;
+ tab.reviewThreads.splice(0, tab.reviewThreads.length);
+ closeReviewComposer();
+ }
+
+ closeAppModal(reviewDeleteModal);
+ if (deletedCount > 0) {
+ persistReviewThreads(deletedCount === 1 ? 'Review item deleted.' : deletedCount + ' review items deleted.');
+ }
+ requestAnimationFrame(function() {
+ if (reviewPanelClose) reviewPanelClose.focus();
+ });
+ }
+
+ 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.setAttribute('aria-expanded', reviewModeActive ? 'true' : 'false');
+ button.setAttribute('aria-pressed', reviewModeActive ? 'true' : 'false');
+ });
+ updateReviewToggleActiveState();
+ updateReviewBackdrop();
+ updateLiveEditorAccess();
+ if (reviewModeActive) {
+ decorateReviewTargets();
+ renderReviewPanel();
+ if (options.focusPanel && reviewPanelClose) reviewPanelClose.focus();
+ } else {
+ cancelReviewDeleteConfirmation();
+ 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);
+ if (reviewResolveAll) reviewResolveAll.addEventListener('click', resolveAllReviewThreads);
+ if (reviewDeleteAll) {
+ reviewDeleteAll.addEventListener('click', function() {
+ requestReviewDeleteConfirmation(null);
+ });
+ }
+ if (reviewDeleteClose) reviewDeleteClose.addEventListener('click', cancelReviewDeleteConfirmation);
+ if (reviewDeleteCancel) reviewDeleteCancel.addEventListener('click', cancelReviewDeleteConfirmation);
+ if (reviewDeleteConfirm) reviewDeleteConfirm.addEventListener('click', confirmReviewDeletion);
+
+ 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() {
+ setReviewFilter(button.dataset.reviewFilter);
+ });
+ });
+
+ if (reviewPinsLayer) {
+ reviewPinsLayer.addEventListener('click', function(event) {
+ const button = event.target.closest('.review-target-button');
+ if (!button || !reviewModeActive) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const anchor = {
+ key: button.dataset.reviewAnchor,
+ duplicateCount: Number(button.dataset.reviewDuplicateCount) || null,
+ context: button.dataset.reviewContext || null
+ };
+ if (button.dataset.reviewCommand === 'read') {
+ showReviewThreadsForAnchor(anchor);
+ return;
+ }
+ const target = findReviewTarget(anchor);
+ openReviewComposer(target);
+ });
+ }
+
+ if (markdownPreview) {
+ markdownPreview.addEventListener('click', function(event) {
+ if (!reviewModeActive || event.target.closest('a, button, input, select, textarea, summary, [role="button"]')) return;
+ const target = event.target.closest('.review-target.has-review-thread');
+ if (!target || !markdownPreview.contains(target)) return;
+ const anchor = getReviewAnchorDescriptor(target);
+ if (!anchor) return;
+ event.preventDefault();
+ showReviewThreadsForAnchor(anchor);
+ });
+ }
+
+ 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 === 'edit') {
+ openReviewEditor(thread);
+ return;
+ }
+ if (action === 'toggle-resolved') {
+ thread.resolved = !thread.resolved;
+ const changedAt = Date.now();
+ thread.updatedAt = changedAt;
+ thread.resolvedAt = thread.resolved ? changedAt : null;
+ persistReviewThreads(thread.resolved ? 'Review item resolved.' : 'Review item reopened.');
+ return;
+ }
+ if (action === 'delete') {
+ requestReviewDeleteConfirmation(thread.id);
+ }
+ });
+ }
+
+ window.addEventListener('resize', updateReviewBackdrop);
+ document.addEventListener('keydown', function(event) {
+ if (event.defaultPrevented || 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;
@@ -2409,7 +3735,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 +3824,7 @@ document.addEventListener("DOMContentLoaded", async function () {
content: content,
scrollPos: 0,
viewMode: viewMode,
+ reviewThreads: [],
createdAt: Date.now()
};
}
@@ -2882,7 +4212,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,13 +4222,19 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function restoreViewMode(mode) {
+ if (reviewModeActive && activeTabId && !reviewPreviousViewModes.has(activeTabId)) {
+ reviewPreviousViewModes.set(activeTabId, mode || 'split');
+ }
currentViewMode = null;
setViewMode(mode || 'split');
}
function switchTab(tabId) {
if (tabId === activeTabId) return;
+ cancelReviewDeleteConfirmation();
saveCurrentTabState();
+ closeReviewComposer();
+ clearReviewDecorations();
// Clear typing timeout and reset tracking for the new tab
if (typingTimeout) {
@@ -2920,6 +4258,7 @@ document.addEventListener("DOMContentLoaded", async function () {
restoreViewMode(tab.viewMode);
refreshLiveEditorUi();
renderMarkdown();
+ renderReviewPanel();
requestAnimationFrame(function() {
markdownEditor.scrollTop = tab.scrollPos || 0;
updateLiveCursorPosition();
@@ -2934,6 +4273,7 @@ document.addEventListener("DOMContentLoaded", async function () {
alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.');
return;
}
+ if (reviewModeActive) setReviewMode(false);
if (!title) title = nextUntitledTitle();
const tab = createTab(content, title);
tabs.push(tab);
@@ -2942,6 +4282,9 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function closeTab(tabId) {
+ if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) {
+ cancelReviewDeleteConfirmation();
+ }
if (liveCollaboration && liveCollaboration.tabId === tabId) {
if (liveCollaboration.isHost) {
endLiveSessionForEveryone();
@@ -2953,6 +4296,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 +4333,8 @@ document.addEventListener("DOMContentLoaded", async function () {
}
saveTabsToStorage(tabs);
renderTabBar(tabs, activeTabId);
+ closeReviewComposer();
+ renderReviewPanel();
}
function deleteTab(tabId) {
@@ -3112,14 +4461,30 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function resetAllTabs() {
+ cancelReviewDeleteConfirmation();
const modal = document.getElementById('reset-confirm-modal');
const confirmBtn = document.getElementById('reset-modal-confirm');
const cancelBtn = document.getElementById('reset-modal-cancel');
- if (!modal) return;
+ const closeBtn = document.getElementById('reset-modal-close');
+ const description = document.getElementById('reset-modal-description');
+ if (!modal || !confirmBtn || !cancelBtn) return;
+
+ const fileCount = tabs.length;
+ const reviewCount = tabs.reduce(function(total, tab) {
+ return total + (Array.isArray(tab.reviewThreads) ? tab.reviewThreads.length : 0);
+ }, 0);
+ if (description) {
+ const fileSummary = fileCount + ' open file' + (fileCount === 1 ? '' : 's');
+ const reviewSummary = reviewCount + ' review item' + (reviewCount === 1 ? '' : 's');
+ const reviewDetails = reviewCount > 0 ? ' and ' + reviewSummary : '';
+ description.textContent = 'This will remove ' + fileSummary + reviewDetails + ' and end any active Live Share session. Unsaved changes cannot be recovered.';
+ }
function doReset() {
closeAppModal(modal);
cleanup();
+ closeReviewComposer();
+ clearReviewDecorations();
disconnectLiveCollaboration({ restoreOriginal: false, silent: true });
applyShareSnapshotAccessMode('edit');
resetShareSnapshotLink();
@@ -3136,6 +4501,8 @@ document.addEventListener("DOMContentLoaded", async function () {
refreshLiveEditorUi();
renderMarkdown();
renderTabBar(tabs, activeTabId);
+ closeReviewComposer();
+ renderReviewPanel();
}
function doCancel() {
@@ -3146,15 +4513,20 @@ document.addEventListener("DOMContentLoaded", async function () {
function cleanup() {
confirmBtn.removeEventListener('click', doReset);
cancelBtn.removeEventListener('click', doCancel);
+ if (closeBtn) closeBtn.removeEventListener('click', doCancel);
}
confirmBtn.addEventListener('click', doReset);
cancelBtn.addEventListener('click', doCancel);
+ if (closeBtn) closeBtn.addEventListener('click', doCancel);
openAppModal(modal, {
- focusTarget: confirmBtn,
+ focusTarget: cancelBtn,
onClose: doCancel
});
+ window.setTimeout(function() {
+ cancelBtn.focus();
+ }, 0);
}
function initTabs() {
@@ -3465,6 +4837,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 +6192,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 +6203,7 @@ ${selector} .arrowheadPath {
}
}
+ decorateReviewTargets();
updateDocumentStats();
updateFindHighlights();
cleanupImageObjectUrls();
@@ -5685,6 +7062,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 +11825,7 @@ ${selector} .arrowheadPath {
}
initTabs();
+ initReviewMode();
if (loadGlobalState().syncScrollingEnabled === false) toggleSyncScrolling();
updateMobileStats();
updateFindHighlights();
@@ -13338,7 +14720,7 @@ ${selector} .arrowheadPath {
}
function canMutateEditor() {
- return !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive();
+ return !reviewModeActive && !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive();
}
function isLiveMutatingAction(action) {
@@ -13377,16 +14759,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 +14783,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 +14874,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 +14890,7 @@ ${selector} .arrowheadPath {
button.setAttribute('aria-disabled', 'false');
}
});
- if (!viewOnly) {
+ if (!sourceReadOnly) {
updateUndoRedoButtons();
}
}
@@ -13625,6 +15008,20 @@ ${selector} .arrowheadPath {
}
}
+ function publishReviewState() {
+ if (!options.reviewDoc || (typeof options.canPublishReviewState === 'function' && !options.canPublishReviewState())) {
+ return;
+ }
+ try {
+ send({
+ type: 'review-sync-state',
+ update: encodeLiveBytes(options.Y.encodeStateAsUpdate(options.reviewDoc))
+ });
+ } catch (error) {
+ console.warn('Live Share review state publish failed:', error);
+ }
+ }
+
function publishPresence(cursor) {
send({
type: 'presence',
@@ -13644,6 +15041,7 @@ ${selector} .arrowheadPath {
if (!message || message.sender === options.participantId) return;
options.onMessage(message, {
publishCurrentState,
+ publishReviewState,
publishPresence
});
}
@@ -13658,6 +15056,18 @@ ${selector} .arrowheadPath {
};
options.ydoc.on('update', updateHandler);
+ const reviewUpdateHandler = function(update, origin) {
+ if (destroyed || origin === LIVE_REVIEW_RELAY_ORIGIN) return;
+ if (typeof options.canSendReviewUpdate === 'function' && !options.canSendReviewUpdate()) return;
+ send({
+ type: 'review-update',
+ update: encodeLiveBytes(update)
+ });
+ };
+ if (options.reviewDoc) {
+ options.reviewDoc.on('update', reviewUpdateHandler);
+ }
+
try {
socket = new WebSocket(socketUrl);
} catch (error) {
@@ -13674,8 +15084,10 @@ ${selector} .arrowheadPath {
participant: Object.assign({}, options.getParticipant(), { lastSeen: Date.now() })
});
send({ type: 'sync-request' });
+ send({ type: 'review-sync-request' });
flushPendingMessages();
setTimeout(publishCurrentState, 100);
+ setTimeout(publishReviewState, 125);
setTimeout(function() { publishPresence(options.getCursor()); }, 150);
});
@@ -13693,12 +15105,16 @@ ${selector} .arrowheadPath {
return {
send,
publishCurrentState,
+ publishReviewState,
publishPresence,
destroy() {
destroyed = true;
try {
options.ydoc.off('update', updateHandler);
} catch (_) {}
+ try {
+ if (options.reviewDoc) options.reviewDoc.off('update', reviewUpdateHandler);
+ } catch (_) {}
try {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
@@ -13967,6 +15383,7 @@ ${selector} .arrowheadPath {
applyLiveSessionTitle(liveCollaboration.roomTitle);
updateLiveAccessDisplay();
updateLiveEditorAccess();
+ applyLiveReviewThreadsFromMap();
return true;
}
@@ -14539,6 +15956,11 @@ ${selector} .arrowheadPath {
return;
}
+ if (message.type === 'review-sync-request') {
+ if (liveCollaboration.isHost) transport.publishReviewState();
+ return;
+ }
+
if ((message.type === 'y-update' || message.type === 'sync-state') && message.update) {
const hostParticipantId = liveCollaboration.sessionMap ? liveCollaboration.sessionMap.get('hostId') : null;
if (getCurrentLiveAccessMode() === LIVE_SHARE_ACCESS_VIEW && hostParticipantId && sender !== hostParticipantId) {
@@ -14553,6 +15975,19 @@ ${selector} .arrowheadPath {
return;
}
+ if ((message.type === 'review-update' || message.type === 'review-sync-state') && message.update) {
+ try {
+ liveCollaboration.Y.applyUpdate(
+ liveCollaboration.reviewDoc,
+ decodeLiveBytes(message.update),
+ LIVE_REVIEW_RELAY_ORIGIN
+ );
+ } catch (error) {
+ console.warn('Live Share review update failed:', error);
+ }
+ return;
+ }
+
if (message.type === 'hello' || message.type === 'presence') {
const participant = message.participant || {};
const name = participant.name || 'Guest';
@@ -14612,12 +16047,18 @@ ${selector} .arrowheadPath {
if (liveCollaboration.sessionMap && liveCollaboration.sessionObserver) {
liveCollaboration.sessionMap.unobserve(liveCollaboration.sessionObserver);
}
+ if (liveCollaboration.reviewMap && liveCollaboration.reviewObserver) {
+ liveCollaboration.reviewMap.unobserve(liveCollaboration.reviewObserver);
+ }
if (liveCollaboration.connection && typeof liveCollaboration.connection.destroy === 'function') {
liveCollaboration.connection.destroy();
}
if (liveCollaboration.ydoc && typeof liveCollaboration.ydoc.destroy === 'function') {
liveCollaboration.ydoc.destroy();
}
+ if (liveCollaboration.reviewDoc && typeof liveCollaboration.reviewDoc.destroy === 'function') {
+ liveCollaboration.reviewDoc.destroy();
+ }
} catch (error) {
console.warn('Failed to fully close live session:', error);
}
@@ -14687,7 +16128,17 @@ ${selector} .arrowheadPath {
const ydoc = new modules.Y.Doc();
const yText = ydoc.getText('markdown');
const sessionMap = ydoc.getMap('session');
+ const reviewDoc = new modules.Y.Doc();
+ const reviewMap = reviewDoc.getMap('reviewThreads');
const hostActiveTab = tabs.find(function(t) { return t.id === activeTabId; });
+ if (isHost && hostActiveTab) {
+ replaceLiveReviewMapThreads(
+ reviewDoc,
+ reviewMap,
+ hostActiveTab.reviewThreads,
+ LIVE_REVIEW_EDIT_ORIGIN
+ );
+ }
const requestedTitle = options.title || (hostActiveTab && hostActiveTab.title) || 'Live Share';
const hostTitle = getSafeLiveTitle(requestedTitle);
if (!isHost && options.initialUpdate) {
@@ -14772,6 +16223,8 @@ ${selector} .arrowheadPath {
Y: modules.Y,
yText,
sessionMap,
+ reviewDoc,
+ reviewMap,
tabId: liveTabId,
originalTabSnapshot,
returnTabId,
@@ -14789,6 +16242,7 @@ ${selector} .arrowheadPath {
lastMarkdown: shouldDeferParticipantTab ? '' : markdownEditor.value,
observer: null,
sessionObserver: null,
+ reviewObserver: null,
participantHeartbeatId: null,
connection: null,
participants,
@@ -14835,6 +16289,15 @@ ${selector} .arrowheadPath {
};
sessionMap.observe(liveCollaboration.sessionObserver);
+ liveCollaboration.reviewObserver = function(event, transaction) {
+ const origin = transaction
+ ? transaction.origin
+ : (event && event.transaction ? event.transaction.origin : null);
+ if (origin === LIVE_REVIEW_EDIT_ORIGIN) return;
+ applyLiveReviewThreadsFromMap();
+ };
+ reviewMap.observe(liveCollaboration.reviewObserver);
+
if (!isHost && yText.length > 0) {
const seededMarkdown = yText.toString();
applyLiveJoinSeedMarkdown(seededMarkdown);
@@ -14863,6 +16326,7 @@ ${selector} .arrowheadPath {
capability: liveCollaboration.socketCapability,
capabilities: liveCollaboration.isHost ? liveCollaboration.capabilities : null,
ydoc,
+ reviewDoc,
Y: modules.Y,
participantId: liveCollaboration.localParticipantId,
getParticipant: function() { return liveCollaboration.localParticipant; },
@@ -14882,6 +16346,19 @@ ${selector} .arrowheadPath {
(liveCollaboration.isHost || getCurrentLiveAccessMode() === LIVE_SHARE_ACCESS_EDIT)
);
},
+ canPublishReviewState: function() {
+ return Boolean(
+ liveCollaboration &&
+ liveCollaboration.reviewDoc === reviewDoc &&
+ liveCollaboration.isHost
+ );
+ },
+ canSendReviewUpdate: function() {
+ return Boolean(
+ liveCollaboration &&
+ liveCollaboration.reviewDoc === reviewDoc
+ );
+ },
onStatus: setLiveShareStatus,
onMessage: handleLiveRoomMessage
});
diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css
index 488e43c9..848a3169 100644
--- a/desktop-app/resources/styles.css
+++ b/desktop-app/resources/styles.css
@@ -5984,6 +5984,700 @@ html[data-theme="dark"] .mermaid svg {
isolation: isolate;
}
+/* ========================================
+ COMMENTS & SUGGESTIONS
+ ======================================== */
+.review-toolbar-count {
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.review-toolbar-count[hidden] {
+ display: none !important;
+}
+
+.mobile-review-count-badge {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ padding: 0;
+ border: 2px solid var(--header-bg);
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--bg-color);
+ background: var(--accent-color);
+ box-shadow: 0 0 0 1px var(--border-color);
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.mobile-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(360px, 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: 0 8px 32px rgba(0, 0, 0, 0.25);
+}
+
+.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: 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.review-panel-eyebrow {
+ margin: 0 0 3px;
+ color: var(--accent-color);
+ 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.is-danger:hover:not(:disabled) {
+ color: var(--color-danger-fg);
+ background: color-mix(in srgb, var(--color-danger-fg) 10%, transparent);
+ border-color: color-mix(in srgb, var(--color-danger-fg) 45%, 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-panel-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.review-panel-body {
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+}
+
+.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(--accent-color);
+ background: color-mix(in srgb, var(--accent-color) 8%, transparent);
+ box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12);
+}
+
+.review-composer {
+ flex: 0 0 auto;
+ margin: 14px 14px 4px;
+ padding: 14px;
+ background: var(--header-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.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-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(--accent-color);
+ outline: 2px solid color-mix(in srgb, var(--accent-color) 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: var(--accent-color);
+ background: var(--button-bg);
+ border: 1px solid var(--accent-color);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.76rem;
+ font-weight: 650;
+}
+
+.review-primary-btn:hover:not(:disabled) {
+ background: var(--button-hover);
+}
+
+.review-primary-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.review-list {
+ flex: 0 0 auto;
+ overflow: visible;
+ 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(--accent-color);
+ border-radius: 8px;
+}
+
+.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(--text-secondary);
+ background: var(--button-bg);
+ border: 1px solid var(--border-color);
+}
+
+.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(--accent-color);
+ 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-dates {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ margin-top: 9px;
+}
+
+.review-thread-time {
+ 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(--accent-color);
+ 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):not(.review-target--table) {
+ padding-inline-end: 44px;
+}
+
+#markdown-preview .review-target--table {
+ max-width: calc(100% - 44px);
+}
+
+#markdown-preview .review-target--diagram {
+ padding-block-end: 44px;
+}
+
+#markdown-preview .review-target.has-review-thread:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 76px;
+}
+
+#markdown-preview .review-target--table.has-review-thread {
+ max-width: calc(100% - 76px);
+}
+
+#markdown-preview .review-target.is-review-target-active {
+ outline: none;
+}
+
+#markdown-preview .review-target.has-review-thread,
+#markdown-preview .review-target.is-review-target-active {
+ box-shadow: inset 3px 0 0 var(--accent-color);
+}
+
+[dir="rtl"] #markdown-preview .review-target.has-review-thread,
+[dir="rtl"] #markdown-preview .review-target.is-review-target-active {
+ box-shadow: inset -3px 0 0 var(--accent-color);
+}
+
+#markdown-preview .review-target--heading.has-review-thread,
+#markdown-preview .review-target--heading.is-review-target-active,
+#markdown-preview .review-target--paragraph.has-review-thread,
+#markdown-preview .review-target--paragraph.is-review-target-active {
+ padding-inline-start: 10px;
+}
+
+#markdown-preview .review-target.has-review-thread {
+ cursor: pointer;
+}
+
+.review-pins-layer {
+ position: absolute;
+ z-index: 20;
+ inset: 0;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.review-pins-layer[hidden] {
+ display: none !important;
+}
+
+.review-target-actions {
+ position: absolute;
+ z-index: 8;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ pointer-events: auto;
+}
+
+.review-target-actions[hidden] {
+ display: none !important;
+}
+
+.review-target-button {
+ position: relative;
+ min-width: 28px;
+ height: 28px;
+ flex: 0 0 auto;
+ pointer-events: auto;
+}
+
+.review-target-button[data-review-count]::after {
+ content: attr(data-review-count);
+}
+
+.review-thread.is-review-thread-active {
+ border-color: var(--accent-color);
+}
+
+.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(--accent-color);
+ outline-offset: 2px;
+}
+
+@media (min-width: 1080px) {
+ .review-panel {
+ position: relative;
+ inset: auto;
+ width: 360px;
+ height: auto;
+ flex: 0 0 360px;
+ box-shadow: none;
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1079px) {
+ .review-panel {
+ width: min(360px, 100%);
+ }
+
+ .content-container.is-reviewing .preview-pane {
+ padding-inline-end: 380px;
+ }
+
+ .review-icon-btn,
+ .review-target-button {
+ min-width: 40px;
+ width: 40px;
+ height: 40px;
+ }
+
+ #markdown-preview .review-target.has-review-thread:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 96px;
+ }
+
+ #markdown-preview .review-target--table.has-review-thread {
+ max-width: calc(100% - 96px);
+ }
+
+ .review-filter-btn,
+ .review-kind-btn,
+ .review-primary-btn,
+ .review-thread-action {
+ min-height: 40px;
+ }
+}
+
+@media (max-width: 767px) {
+ .review-panel {
+ inset-block-start: auto;
+ inset-block-end: 0;
+ width: 100%;
+ height: min(68%, 620px);
+ 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(68vh + 16px);
+ }
+
+ .review-panel-header {
+ padding: 12px 14px;
+ }
+
+ .review-panel-toolbar {
+ flex-wrap: wrap;
+ gap: 8px;
+ padding-inline: 10px;
+ }
+
+ .review-panel-actions {
+ margin-inline-start: auto;
+ }
+
+ .review-panel-actions .review-icon-btn {
+ width: 44px;
+ height: 44px;
+ }
+
+ .review-filter-btn,
+ .review-kind-btn,
+ .review-primary-btn,
+ .review-thread-action {
+ min-height: 44px;
+ }
+
+ .review-composer {
+ margin: 10px 10px 4px;
+ padding: 12px;
+ }
+
+ .review-list {
+ padding: 8px 10px 16px;
+ }
+
+ .review-thread-action {
+ padding-inline: 10px;
+ }
+
+ .review-target-button {
+ min-width: 44px;
+ height: 44px;
+ }
+
+ #markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 56px;
+ }
+
+ #markdown-preview .review-target--table {
+ max-width: calc(100% - 56px);
+ }
+
+ #markdown-preview .review-target--diagram {
+ padding-block-end: 56px;
+ }
+
+ #markdown-preview .review-target.has-review-thread:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 104px;
+ }
+
+ #markdown-preview .review-target--table.has-review-thread {
+ max-width: calc(100% - 104px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .review-target-button {
+ transition: none;
+ }
+}
+
@@ -6011,10 +6705,32 @@ 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-actions,
+ .review-target-button {
display: none !important;
}
+ #markdown-preview .review-target,
+ #markdown-preview .review-target.has-review-thread,
+ #markdown-preview .review-target.is-review-target-active,
+ [dir="rtl"] #markdown-preview .review-target.has-review-thread {
+ outline: none !important;
+ box-shadow: none !important;
+ padding-inline-end: 0 !important;
+ padding-inline-start: 0 !important;
+ padding-block-end: 0 !important;
+ max-width: 100% !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/functions/api/share/[[id]].js b/functions/api/share/[[id]].js
index 2e801e4e..4cc25dcd 100644
--- a/functions/api/share/[[id]].js
+++ b/functions/api/share/[[id]].js
@@ -12,6 +12,11 @@ const ALLOWED_ORIGINS = new Set([
function isAllowedOrigin(origin) {
if (!origin) return true;
if (ALLOWED_ORIGINS.has(origin)) return true;
+
+ if (/^https:\/\/[a-z0-9-]+\.markdownviewer\.pages\.dev$/i.test(origin)) {
+ return true;
+ }
+
return /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(origin);
}
diff --git a/functions/live-room/[[room]].js b/functions/live-room/[[room]].js
index 2ced7d63..0c5adf7c 100644
--- a/functions/live-room/[[room]].js
+++ b/functions/live-room/[[room]].js
@@ -59,7 +59,10 @@ function isAllowedLiveOrigin(origin) {
if (origin === "https://markdownviewer.pages.dev" || origin === "null") {
return true;
}
- if (/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(origin)) {
+ if (/^https:\/\/[a-z0-9-]+\.markdownviewer\.pages\.dev$/i.test(origin || "")) {
+ return true;
+ }
+ if (/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(origin || "")) {
return true;
}
return false;
diff --git a/index.html b/index.html
index c8a6bd5b..a2a44362 100644
--- a/index.html
+++ b/index.html
@@ -202,6 +202,10 @@ Markdown Viewer - Online
Live Share
+
+
+ 0 Review
+
@@ -329,6 +333,11 @@ Menu
+
+
-
-
-
Are you sure you want to delete all files?
+
+
+
+
This will remove all open files and review items. Unsaved changes cannot be recovered.
- Cancel
- Delete All
+ Cancel
+ Reset all
+
+
+
+
+
+
+
+
+
This comment or suggestion will be permanently deleted.
+
+ Cancel
+ Delete
@@ -631,6 +663,7 @@
Application shortcuts
Use the view buttons in the toolbar to switch between Editor, Split, and Preview modes.
Sync scrolling is available in Split view to keep the editor and preview aligned.
+ Review opens a read-only preview where you can comment on or suggest changes to headings, paragraphs, code blocks, and diagrams.
Import, Export, Copy, Share, and Theme toggle actions are always available in the header toolbar.
@@ -1162,7 +1195,75 @@
Open-source credits
-
+
+
+
+
diff --git a/script.js b/script.js
index 72184ab8..2534ecd0 100644
--- a/script.js
+++ b/script.js
@@ -263,6 +263,18 @@ document.addEventListener("DOMContentLoaded", async function () {
const shareSnapshotViewOnlyTabIds = new Set();
const SHARE_SNAPSHOT_TAB_KIND = 'share-snapshot';
const APP_VERSION = '3.9.1';
+ const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, pre, .frontmatter-table, .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 activeReviewEditId = null;
+ let pendingReviewDelete = null;
+ let reviewPinsResizeObserver = null;
+ let reviewPinsLayoutFrame = null;
+ let reviewTargetSourceSnapshots = new WeakMap();
let activeModal = null;
let lastFocusedElement = null;
let isFindModalOpen = false;
@@ -292,6 +304,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 +331,34 @@ 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 reviewToolbarCount = document.getElementById('review-toolbar-count');
+ 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 reviewResolveAll = document.getElementById('review-resolve-all');
+ const reviewDeleteAll = document.getElementById('review-delete-all');
+ const reviewDeleteModal = document.getElementById('review-delete-modal');
+ const reviewDeleteTitle = document.getElementById('review-delete-title');
+ const reviewDeleteDescription = document.getElementById('review-delete-description');
+ const reviewDeleteClose = document.getElementById('review-delete-close');
+ const reviewDeleteCancel = document.getElementById('review-delete-cancel');
+ const reviewDeleteConfirm = document.getElementById('review-delete-confirm');
+ const reviewPanelBody = document.querySelector('.review-panel-body');
+ const reviewComposer = document.getElementById('review-composer');
+ const reviewComposerTitle = document.getElementById('review-composer-title');
+ 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 +436,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 +706,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);
@@ -2376,6 +2421,1287 @@ document.addEventListener("DOMContentLoaded", async function () {
let liveCollaborationModulesPromise = null;
const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit");
const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay");
+ const LIVE_REVIEW_EDIT_ORIGIN = Symbol("markdown-viewer-live-review-local-edit");
+ const LIVE_REVIEW_RELAY_ORIGIN = Symbol("markdown-viewer-live-review-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;
+ const resolved = thread.resolved === true;
+ const resolvedAt = resolved && Number.isFinite(Number(thread.resolvedAt))
+ ? Number(thread.resolvedAt)
+ : null;
+ 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: resolved,
+ resolvedAt: resolvedAt
+ };
+ }).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 cloneReviewThreadForLiveShare(thread) {
+ return {
+ id: thread.id,
+ anchor: Object.assign({}, thread.anchor),
+ kind: thread.kind,
+ body: thread.body,
+ createdAt: thread.createdAt,
+ updatedAt: thread.updatedAt,
+ resolved: thread.resolved === true,
+ resolvedAt: Number.isFinite(Number(thread.resolvedAt)) ? Number(thread.resolvedAt) : null
+ };
+ }
+
+ function liveReviewThreadsMatch(left, right) {
+ if (!left || !right) return false;
+ const leftAnchor = left.anchor || {};
+ const rightAnchor = right.anchor || {};
+ return left.id === right.id &&
+ left.kind === right.kind &&
+ left.body === right.body &&
+ left.createdAt === right.createdAt &&
+ left.updatedAt === right.updatedAt &&
+ left.resolved === right.resolved &&
+ left.resolvedAt === right.resolvedAt &&
+ leftAnchor.key === rightAnchor.key &&
+ leftAnchor.type === rightAnchor.type &&
+ leftAnchor.label === rightAnchor.label &&
+ leftAnchor.excerpt === rightAnchor.excerpt &&
+ leftAnchor.duplicateCount === rightAnchor.duplicateCount &&
+ leftAnchor.context === rightAnchor.context;
+ }
+
+ function replaceLiveReviewMapThreads(reviewDoc, reviewMap, rawThreads, origin) {
+ if (!reviewDoc || !reviewMap) return;
+ const threads = normalizeReviewThreads(rawThreads);
+ const nextIds = new Set(threads.map(function(thread) { return thread.id; }));
+ const staleIds = [];
+ reviewMap.forEach(function(_, id) {
+ if (!nextIds.has(id)) staleIds.push(id);
+ });
+
+ reviewDoc.transact(function() {
+ staleIds.forEach(function(id) { reviewMap.delete(id); });
+ threads.forEach(function(thread) {
+ const nextThread = cloneReviewThreadForLiveShare(thread);
+ if (!liveReviewThreadsMatch(reviewMap.get(thread.id), nextThread)) {
+ reviewMap.set(thread.id, nextThread);
+ }
+ });
+ }, origin);
+ }
+
+ function getLiveReviewMapThreads(reviewMap) {
+ const rawThreads = [];
+ if (!reviewMap) return rawThreads;
+ reviewMap.forEach(function(thread, id) {
+ if (!thread || typeof thread !== 'object') return;
+ rawThreads.push(Object.assign({}, thread, { id: String(id || thread.id || '') }));
+ });
+ return normalizeReviewThreads(rawThreads).sort(function(a, b) {
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
+ return a.id.localeCompare(b.id);
+ });
+ }
+
+ function syncLiveReviewThreadsFromTab() {
+ if (!liveCollaboration || !liveCollaboration.reviewDoc || !liveCollaboration.reviewMap) return;
+ const tab = tabs.find(function(item) { return item.id === liveCollaboration.tabId; });
+ if (!tab || tab.id !== activeTabId) return;
+ replaceLiveReviewMapThreads(
+ liveCollaboration.reviewDoc,
+ liveCollaboration.reviewMap,
+ tab.reviewThreads,
+ LIVE_REVIEW_EDIT_ORIGIN
+ );
+ }
+
+ function applyLiveReviewThreadsFromMap() {
+ if (!liveCollaboration || !liveCollaboration.reviewMap || !liveCollaboration.tabId) return;
+ const tab = tabs.find(function(item) { return item.id === liveCollaboration.tabId; });
+ if (!tab) return;
+
+ tab.reviewThreads = getLiveReviewMapThreads(liveCollaboration.reviewMap);
+ saveTabsToStorage(tabs);
+ if (tab.id === activeTabId) {
+ if (activeReviewEditId && !tab.reviewThreads.some(function(thread) { return thread.id === activeReviewEditId; })) {
+ closeReviewComposer();
+ }
+ decorateReviewTargets();
+ renderReviewPanel();
+ }
+ }
+
+ 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.matches('.frontmatter-table')) return 'table';
+ 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 === 'table') return 'YAML frontmatter';
+ 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;
+ }
+ const frontmatterTable = target.closest('.frontmatter-table');
+ if (frontmatterTable && frontmatterTable !== 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--table',
+ '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 previewRect = markdownPreview.getBoundingClientRect();
+ const pinRight = previewRect.right - 4;
+ reviewPinsLayer.querySelectorAll('.review-target-actions').forEach(function(actions) {
+ const anchor = {
+ key: actions.dataset.reviewAnchor,
+ duplicateCount: Number(actions.dataset.reviewDuplicateCount) || null,
+ context: actions.dataset.reviewContext || null
+ };
+ const target = findReviewTarget(anchor);
+ if (!target) {
+ actions.hidden = true;
+ return;
+ }
+ actions.hidden = false;
+ const targetRect = target.getBoundingClientRect();
+ const isDiagram = target.dataset.reviewType === 'diagram';
+ const top = isDiagram ? targetRect.bottom - 40 : targetRect.top + 4;
+ actions.style.top = (top - paneRect.top + previewPaneElement.scrollTop) + 'px';
+ actions.style.left = (pinRight - paneRect.left + previewPaneElement.scrollLeft) + 'px';
+ actions.style.transform = '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 actions = document.createElement('div');
+ actions.className = 'review-target-actions';
+ actions.dataset.reviewAnchor = anchorKey;
+ actions.dataset.reviewDuplicateCount = String(duplicateCount);
+ actions.dataset.reviewContext = context;
+ actions.dataset.html2canvasIgnore = 'true';
+ actions.setAttribute('role', 'group');
+ actions.setAttribute('aria-label', 'Review actions for ' + label);
+
+ function createTargetButton(command, iconClass, title, ariaLabel) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'tool-button review-target-button';
+ button.dataset.reviewCommand = command;
+ button.dataset.reviewAnchor = anchorKey;
+ button.dataset.reviewDuplicateCount = String(duplicateCount);
+ button.dataset.reviewContext = context;
+ button.dataset.html2canvasIgnore = 'true';
+ button.title = title;
+ button.setAttribute('aria-label', ariaLabel);
+ button.setAttribute('aria-controls', command === 'read' ? 'review-list' : 'review-composer');
+ const icon = document.createElement('i');
+ icon.className = iconClass;
+ icon.setAttribute('aria-hidden', 'true');
+ button.appendChild(icon);
+ return button;
+ }
+
+ if (targetThreads.length > 0) {
+ const reviewLabel = targetThreads.length + ' review item' + (targetThreads.length === 1 ? '' : 's');
+ const readButton = createTargetButton(
+ 'read',
+ 'bi bi-chat-square-text',
+ 'Read ' + reviewLabel,
+ 'Read ' + reviewLabel + ' for ' + label
+ );
+ readButton.classList.add('is-active');
+ readButton.dataset.reviewCount = String(targetThreads.length);
+ actions.appendChild(readButton);
+ }
+
+ actions.appendChild(createTargetButton(
+ 'add',
+ 'bi bi-plus-lg',
+ targetThreads.length > 0 ? 'Add another review item' : 'Add feedback',
+ 'Add feedback to ' + label
+ ));
+ if (reviewPinsLayer) reviewPinsLayer.appendChild(actions);
+ });
+
+ 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 updateReviewToggleActiveState(openCount) {
+ const count = typeof openCount === 'number'
+ ? openCount
+ : getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length;
+ if (reviewToggle) reviewToggle.classList.toggle('is-active', reviewModeActive || count > 0);
+ if (mobileReviewToggle) mobileReviewToggle.classList.toggle('is-active', reviewModeActive);
+ }
+
+ function updateReviewCountBadges() {
+ const openCount = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length;
+ const countLabel = openCount + ' open review item' + (openCount === 1 ? '' : 's');
+ if (reviewToolbarCount) {
+ reviewToolbarCount.textContent = String(openCount);
+ reviewToolbarCount.hidden = openCount === 0;
+ }
+ if (mobileReviewCountBadge) {
+ mobileReviewCountBadge.textContent = String(openCount);
+ mobileReviewCountBadge.hidden = openCount === 0;
+ mobileReviewCountBadge.title = countLabel;
+ }
+ if (reviewToggle) {
+ reviewToggle.setAttribute('aria-label', openCount > 0
+ ? 'Open comments and suggestions. ' + countLabel + '.'
+ : 'Open comments and suggestions');
+ reviewToggle.title = openCount > 0
+ ? 'Open comments and suggestions (' + countLabel + ')'
+ : 'Open comments and suggestions';
+ }
+ if (mobileReviewToggle) {
+ mobileReviewToggle.setAttribute('aria-label', openCount > 0
+ ? 'Open comments and suggestions. ' + openCount + ' open review item' + (openCount === 1 ? '' : 's') + '.'
+ : 'Open comments and suggestions');
+ }
+ updateReviewToggleActiveState(openCount);
+ }
+
+ 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' : '');
+ if (thread.id === activeReviewEditId) item.classList.add('is-review-thread-active');
+ 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-pencil-square' : '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 dates = document.createElement('div');
+ dates.className = 'review-thread-dates';
+ const openedTime = document.createElement('time');
+ openedTime.className = 'review-thread-time';
+ openedTime.dateTime = new Date(thread.createdAt).toISOString();
+ openedTime.textContent = 'Opened: ' + formatReviewTime(thread.createdAt);
+ dates.appendChild(openedTime);
+ if (thread.resolvedAt) {
+ const closedTime = document.createElement('time');
+ closedTime.className = 'review-thread-time';
+ closedTime.dateTime = new Date(thread.resolvedAt).toISOString();
+ closedTime.textContent = 'Closed: ' + formatReviewTime(thread.resolvedAt);
+ dates.appendChild(closedTime);
+ } else {
+ const closedTime = document.createElement('span');
+ closedTime.className = 'review-thread-time';
+ closedTime.textContent = thread.resolved ? 'Closed: Unavailable' : 'Closed: Not closed';
+ dates.appendChild(closedTime);
+ }
+ item.appendChild(dates);
+
+ const actions = document.createElement('div');
+ actions.className = 'review-thread-actions';
+ const editButton = document.createElement('button');
+ editButton.type = 'button';
+ editButton.className = 'review-thread-action';
+ editButton.dataset.reviewAction = 'edit';
+ editButton.dataset.reviewId = thread.id;
+ editButton.textContent = 'Edit';
+ editButton.setAttribute('aria-label', 'Edit ' + (thread.kind === 'suggestion' ? 'suggestion' : 'comment'));
+ actions.appendChild(editButton);
+ 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;
+ if (reviewResolveAll) reviewResolveAll.disabled = openCount === 0;
+ if (reviewDeleteAll) reviewDeleteAll.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 = 'Select a comment pin in the preview to add feedback.';
+ } else {
+ title.textContent = 'No open feedback';
+ copy.textContent = threads.length > 0
+ ? 'Everything has been resolved. Switch to Resolved or All to review earlier feedback.'
+ : 'Select a comment pin in the preview to add feedback.';
+ }
+ }
+ }
+
+ function setReviewFilter(nextFilter) {
+ reviewFilter = ['open', 'resolved', 'all'].includes(nextFilter) ? nextFilter : 'open';
+ document.querySelectorAll('[data-review-filter]').forEach(function(button) {
+ const active = button.dataset.reviewFilter === reviewFilter;
+ button.classList.toggle('is-active', active);
+ button.setAttribute('aria-pressed', active ? 'true' : 'false');
+ });
+ renderReviewPanel();
+ }
+
+ function getReviewThreadsForAnchor(anchor) {
+ if (!anchor || !anchor.key) return [];
+ return getActiveReviewThreads().filter(function(thread) {
+ if (!thread.anchor || thread.anchor.key !== anchor.key) return false;
+ if (anchor.duplicateCount && thread.anchor.duplicateCount && Number(thread.anchor.duplicateCount) !== Number(anchor.duplicateCount)) return false;
+ if (anchor.context && thread.anchor.context && thread.anchor.context !== anchor.context) return false;
+ return true;
+ });
+ }
+
+ function showReviewThreadsForAnchor(anchor) {
+ const threads = getReviewThreadsForAnchor(anchor).sort(function(a, b) {
+ return b.createdAt - a.createdAt;
+ });
+ if (threads.length === 0) return false;
+
+ closeReviewComposer();
+ setReviewFilter('all');
+
+ const matchingIds = new Set(threads.map(function(thread) { return thread.id; }));
+ const matchingItems = Array.from(reviewList.querySelectorAll('.review-thread')).filter(function(item) {
+ return matchingIds.has(item.dataset.reviewId);
+ });
+ matchingItems.forEach(function(item) {
+ item.classList.add('is-review-thread-active');
+ });
+ if (matchingItems[0]) {
+ matchingItems[0].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ const anchorButton = matchingItems[0].querySelector('.review-thread-anchor');
+ if (anchorButton) anchorButton.focus({ preventScroll: true });
+ }
+
+ clearTimeout(showReviewThreadsForAnchor._timeoutId);
+ showReviewThreadsForAnchor._timeoutId = setTimeout(function() {
+ matchingItems.forEach(function(item) {
+ item.classList.remove('is-review-thread-active');
+ });
+ }, 1800);
+ announceToScreenReader('Showing ' + threads.length + ' review item' + (threads.length === 1 ? '' : 's') + ' for this block.');
+ return true;
+ }
+
+ 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 (reviewComposerTitle) {
+ reviewComposerTitle.textContent = activeReviewEditId
+ ? (reviewComposerKind === 'suggestion' ? 'Edit suggestion' : 'Edit comment')
+ : (reviewComposerKind === 'suggestion' ? 'Add suggestion' : 'Add 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 = activeReviewEditId
+ ? 'Save changes'
+ : (reviewComposerKind === 'suggestion' ? 'Add suggestion' : 'Add comment');
+ }
+ }
+
+ function focusReviewPin(anchor) {
+ const target = findReviewTarget(anchor);
+ if (!target || !reviewPinsLayer) return false;
+ const matchingButtons = Array.from(reviewPinsLayer.querySelectorAll('.review-target-button')).filter(function(candidate) {
+ return candidate.dataset.reviewAnchor === target.dataset.reviewAnchor &&
+ candidate.dataset.reviewContext === target.dataset.reviewContext;
+ });
+ const button = matchingButtons.find(function(candidate) {
+ return candidate.dataset.reviewCommand === 'read';
+ }) || matchingButtons[0] || null;
+ if (!button) return false;
+ button.focus({ preventScroll: true });
+ return true;
+ }
+
+ function focusReviewComposerInput() {
+ if (!reviewFeedbackInput) return;
+ reviewFeedbackInput.focus();
+ requestAnimationFrame(function() {
+ if (reviewComposer && !reviewComposer.hidden && reviewPanelBody) {
+ reviewPanelBody.scrollTop = reviewPanelBody.scrollHeight;
+ }
+ });
+ }
+
+ function closeReviewComposer(options) {
+ options = options || {};
+ const returnAnchor = activeReviewAnchor;
+ const returnReviewId = activeReviewEditId;
+ if (activeReviewAnchor) {
+ const previousTarget = findReviewTarget(activeReviewAnchor);
+ if (previousTarget) previousTarget.classList.remove('is-review-target-active');
+ }
+ activeReviewAnchor = null;
+ activeReviewEditId = null;
+ if (reviewComposer) reviewComposer.hidden = true;
+ if (reviewFeedbackInput) reviewFeedbackInput.value = '';
+ if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT;
+ if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true;
+ if (reviewList) {
+ reviewList.querySelectorAll('.is-review-thread-active').forEach(function(item) {
+ item.classList.remove('is-review-thread-active');
+ });
+ }
+ if (options.restoreFocus && reviewModeActive) {
+ const editButton = returnReviewId && reviewList
+ ? Array.from(reviewList.querySelectorAll('[data-review-action="edit"]')).find(function(button) {
+ return button.dataset.reviewId === returnReviewId;
+ })
+ : null;
+ if (editButton) {
+ editButton.focus();
+ } else if (!focusReviewPin(returnAnchor) && reviewPanelClose) {
+ reviewPanelClose.focus();
+ }
+ }
+ }
+
+ function openReviewComposer(target, options) {
+ options = options || {};
+ if (!target) return;
+ const descriptor = getReviewAnchorDescriptor(target);
+ if (!descriptor) return;
+ closeReviewComposer();
+ activeReviewEditId = null;
+ 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 = '';
+ if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT;
+ if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true;
+ if (options.focusInput !== false) focusReviewComposerInput();
+ }
+ }
+
+ function openReviewEditor(thread) {
+ if (!thread || !reviewComposer || !reviewFeedbackInput) return;
+ closeReviewComposer();
+ activeReviewEditId = thread.id;
+ activeReviewAnchor = Object.assign({}, thread.anchor);
+ const target = findReviewTarget(thread.anchor);
+ if (target) target.classList.add('is-review-target-active');
+ if (reviewComposerAnchor) {
+ reviewComposerAnchor.textContent = thread.anchor.label + ': ' + thread.anchor.excerpt;
+ }
+ updateReviewComposerKind(thread.kind);
+ reviewComposer.hidden = false;
+ reviewFeedbackInput.value = thread.body;
+ if (reviewFeedbackCount) reviewFeedbackCount.textContent = reviewFeedbackInput.value.length + ' / ' + REVIEW_TEXT_LIMIT;
+ if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = false;
+ renderReviewPanel();
+ focusReviewComposerInput();
+ reviewFeedbackInput.setSelectionRange(reviewFeedbackInput.value.length, reviewFeedbackInput.value.length);
+ announceToScreenReader('Editing ' + (thread.kind === 'suggestion' ? 'suggestion.' : 'comment.'));
+ }
+
+ function persistReviewThreads(message) {
+ syncLiveReviewThreadsFromTab();
+ 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);
+ if (activeReviewEditId) {
+ const reviewId = activeReviewEditId;
+ const thread = getActiveReviewThreads().find(function(item) { return item.id === reviewId; });
+ if (!thread) {
+ closeReviewComposer();
+ announceToScreenReader('This review item is no longer available.');
+ return;
+ }
+ thread.kind = reviewComposerKind;
+ thread.body = body.slice(0, REVIEW_TEXT_LIMIT);
+ thread.updatedAt = Date.now();
+ closeReviewComposer();
+ persistReviewThreads('Review item updated.');
+ requestAnimationFrame(function() {
+ const editButton = reviewList
+ ? Array.from(reviewList.querySelectorAll('[data-review-action="edit"]')).find(function(button) {
+ return button.dataset.reviewId === reviewId;
+ })
+ : null;
+ if (editButton) editButton.focus();
+ });
+ return;
+ }
+ 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,
+ resolvedAt: null
+ });
+ 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 resolvedCount = threads.filter(function(thread) { return thread.resolved; }).length;
+ const lines = [
+ '# Review: ' + (tab && tab.title ? tab.title : 'Markdown document'),
+ '',
+ '- Generated: ' + formatReviewTime(Date.now()),
+ '- Total items: ' + threads.length,
+ '- Open items: ' + (threads.length - resolvedCount),
+ '- Resolved items: ' + resolvedCount,
+ ''
+ ];
+ threads.forEach(function(thread, index) {
+ const kind = thread.kind === 'suggestion' ? 'Suggestion' : 'Comment';
+ const closedAt = thread.resolved
+ ? (thread.resolvedAt ? formatReviewTime(thread.resolvedAt) : 'Unavailable')
+ : 'Not closed';
+ lines.push((index + 1) + '. **' + kind + '** - ' + thread.anchor.label);
+ lines.push(' - Status: ' + (thread.resolved ? 'Resolved' : 'Open'));
+ lines.push(' - Opened: ' + formatReviewTime(thread.createdAt));
+ lines.push(' - Closed: ' + closedAt);
+ 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 resolveAllReviewThreads() {
+ const openThreads = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; });
+ if (openThreads.length === 0) return;
+ const updatedAt = Date.now();
+ openThreads.forEach(function(thread) {
+ thread.resolved = true;
+ thread.updatedAt = updatedAt;
+ thread.resolvedAt = updatedAt;
+ });
+ persistReviewThreads(openThreads.length + ' review item' + (openThreads.length === 1 ? '' : 's') + ' resolved.');
+ requestAnimationFrame(function() {
+ if (reviewPanelClose) reviewPanelClose.focus();
+ });
+ }
+
+ function cancelReviewDeleteConfirmation() {
+ pendingReviewDelete = null;
+ if (reviewDeleteModal && reviewDeleteModal.getAttribute('aria-hidden') === 'false') {
+ closeAppModal(reviewDeleteModal);
+ }
+ }
+
+ function requestReviewDeleteConfirmation(reviewId) {
+ if (!reviewDeleteModal || !reviewDeleteCancel || !reviewDeleteConfirm) return;
+ const threads = getActiveReviewThreads();
+ const thread = reviewId
+ ? threads.find(function(item) { return item.id === reviewId; })
+ : null;
+ if ((reviewId && !thread) || (!reviewId && threads.length === 0)) return;
+
+ pendingReviewDelete = {
+ tabId: activeTabId,
+ reviewId: reviewId || null
+ };
+ if (reviewDeleteTitle) {
+ reviewDeleteTitle.textContent = reviewId ? 'Delete review item?' : 'Delete all review items?';
+ }
+ if (reviewDeleteDescription) {
+ reviewDeleteDescription.textContent = reviewId
+ ? 'This ' + (thread.kind === 'suggestion' ? 'suggestion' : 'comment') + ' will be permanently deleted. This cannot be undone.'
+ : 'All ' + threads.length + ' review item' + (threads.length === 1 ? '' : 's') + ' in this document will be permanently deleted. This cannot be undone.';
+ }
+ reviewDeleteConfirm.textContent = reviewId ? 'Delete item' : 'Delete all';
+ openAppModal(reviewDeleteModal, {
+ focusTarget: reviewDeleteCancel,
+ onClose: cancelReviewDeleteConfirmation
+ });
+ requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ if (reviewDeleteCancel) reviewDeleteCancel.focus({ preventScroll: true });
+ });
+ });
+ }
+
+ function confirmReviewDeletion() {
+ const pending = pendingReviewDelete;
+ pendingReviewDelete = null;
+ if (!pending || !reviewDeleteModal) return;
+
+ const tab = tabs.find(function(item) { return item.id === pending.tabId; });
+ if (!tab || tab.id !== activeTabId || !Array.isArray(tab.reviewThreads)) {
+ closeAppModal(reviewDeleteModal);
+ announceToScreenReader('Review deletion cancelled because the active document changed.');
+ return;
+ }
+
+ let deletedCount = 0;
+ if (pending.reviewId) {
+ const index = tab.reviewThreads.findIndex(function(thread) { return thread.id === pending.reviewId; });
+ if (index >= 0) {
+ if (activeReviewEditId === pending.reviewId) closeReviewComposer();
+ tab.reviewThreads.splice(index, 1);
+ deletedCount = 1;
+ }
+ } else {
+ deletedCount = tab.reviewThreads.length;
+ tab.reviewThreads.splice(0, tab.reviewThreads.length);
+ closeReviewComposer();
+ }
+
+ closeAppModal(reviewDeleteModal);
+ if (deletedCount > 0) {
+ persistReviewThreads(deletedCount === 1 ? 'Review item deleted.' : deletedCount + ' review items deleted.');
+ }
+ requestAnimationFrame(function() {
+ if (reviewPanelClose) reviewPanelClose.focus();
+ });
+ }
+
+ 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.setAttribute('aria-expanded', reviewModeActive ? 'true' : 'false');
+ button.setAttribute('aria-pressed', reviewModeActive ? 'true' : 'false');
+ });
+ updateReviewToggleActiveState();
+ updateReviewBackdrop();
+ updateLiveEditorAccess();
+ if (reviewModeActive) {
+ decorateReviewTargets();
+ renderReviewPanel();
+ if (options.focusPanel && reviewPanelClose) reviewPanelClose.focus();
+ } else {
+ cancelReviewDeleteConfirmation();
+ 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);
+ if (reviewResolveAll) reviewResolveAll.addEventListener('click', resolveAllReviewThreads);
+ if (reviewDeleteAll) {
+ reviewDeleteAll.addEventListener('click', function() {
+ requestReviewDeleteConfirmation(null);
+ });
+ }
+ if (reviewDeleteClose) reviewDeleteClose.addEventListener('click', cancelReviewDeleteConfirmation);
+ if (reviewDeleteCancel) reviewDeleteCancel.addEventListener('click', cancelReviewDeleteConfirmation);
+ if (reviewDeleteConfirm) reviewDeleteConfirm.addEventListener('click', confirmReviewDeletion);
+
+ 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() {
+ setReviewFilter(button.dataset.reviewFilter);
+ });
+ });
+
+ if (reviewPinsLayer) {
+ reviewPinsLayer.addEventListener('click', function(event) {
+ const button = event.target.closest('.review-target-button');
+ if (!button || !reviewModeActive) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const anchor = {
+ key: button.dataset.reviewAnchor,
+ duplicateCount: Number(button.dataset.reviewDuplicateCount) || null,
+ context: button.dataset.reviewContext || null
+ };
+ if (button.dataset.reviewCommand === 'read') {
+ showReviewThreadsForAnchor(anchor);
+ return;
+ }
+ const target = findReviewTarget(anchor);
+ openReviewComposer(target);
+ });
+ }
+
+ if (markdownPreview) {
+ markdownPreview.addEventListener('click', function(event) {
+ if (!reviewModeActive || event.target.closest('a, button, input, select, textarea, summary, [role="button"]')) return;
+ const target = event.target.closest('.review-target.has-review-thread');
+ if (!target || !markdownPreview.contains(target)) return;
+ const anchor = getReviewAnchorDescriptor(target);
+ if (!anchor) return;
+ event.preventDefault();
+ showReviewThreadsForAnchor(anchor);
+ });
+ }
+
+ 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 === 'edit') {
+ openReviewEditor(thread);
+ return;
+ }
+ if (action === 'toggle-resolved') {
+ thread.resolved = !thread.resolved;
+ const changedAt = Date.now();
+ thread.updatedAt = changedAt;
+ thread.resolvedAt = thread.resolved ? changedAt : null;
+ persistReviewThreads(thread.resolved ? 'Review item resolved.' : 'Review item reopened.');
+ return;
+ }
+ if (action === 'delete') {
+ requestReviewDeleteConfirmation(thread.id);
+ }
+ });
+ }
+
+ window.addEventListener('resize', updateReviewBackdrop);
+ document.addEventListener('keydown', function(event) {
+ if (event.defaultPrevented || 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;
@@ -2409,7 +3735,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 +3824,7 @@ document.addEventListener("DOMContentLoaded", async function () {
content: content,
scrollPos: 0,
viewMode: viewMode,
+ reviewThreads: [],
createdAt: Date.now()
};
}
@@ -2882,7 +4212,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,13 +4222,19 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function restoreViewMode(mode) {
+ if (reviewModeActive && activeTabId && !reviewPreviousViewModes.has(activeTabId)) {
+ reviewPreviousViewModes.set(activeTabId, mode || 'split');
+ }
currentViewMode = null;
setViewMode(mode || 'split');
}
function switchTab(tabId) {
if (tabId === activeTabId) return;
+ cancelReviewDeleteConfirmation();
saveCurrentTabState();
+ closeReviewComposer();
+ clearReviewDecorations();
// Clear typing timeout and reset tracking for the new tab
if (typingTimeout) {
@@ -2920,6 +4258,7 @@ document.addEventListener("DOMContentLoaded", async function () {
restoreViewMode(tab.viewMode);
refreshLiveEditorUi();
renderMarkdown();
+ renderReviewPanel();
requestAnimationFrame(function() {
markdownEditor.scrollTop = tab.scrollPos || 0;
updateLiveCursorPosition();
@@ -2934,6 +4273,7 @@ document.addEventListener("DOMContentLoaded", async function () {
alert('Maximum of 20 tabs reached. Please close an existing tab to open a new one.');
return;
}
+ if (reviewModeActive) setReviewMode(false);
if (!title) title = nextUntitledTitle();
const tab = createTab(content, title);
tabs.push(tab);
@@ -2942,6 +4282,9 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function closeTab(tabId) {
+ if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) {
+ cancelReviewDeleteConfirmation();
+ }
if (liveCollaboration && liveCollaboration.tabId === tabId) {
if (liveCollaboration.isHost) {
endLiveSessionForEveryone();
@@ -2953,6 +4296,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 +4333,8 @@ document.addEventListener("DOMContentLoaded", async function () {
}
saveTabsToStorage(tabs);
renderTabBar(tabs, activeTabId);
+ closeReviewComposer();
+ renderReviewPanel();
}
function deleteTab(tabId) {
@@ -3112,14 +4461,30 @@ document.addEventListener("DOMContentLoaded", async function () {
}
function resetAllTabs() {
+ cancelReviewDeleteConfirmation();
const modal = document.getElementById('reset-confirm-modal');
const confirmBtn = document.getElementById('reset-modal-confirm');
const cancelBtn = document.getElementById('reset-modal-cancel');
- if (!modal) return;
+ const closeBtn = document.getElementById('reset-modal-close');
+ const description = document.getElementById('reset-modal-description');
+ if (!modal || !confirmBtn || !cancelBtn) return;
+
+ const fileCount = tabs.length;
+ const reviewCount = tabs.reduce(function(total, tab) {
+ return total + (Array.isArray(tab.reviewThreads) ? tab.reviewThreads.length : 0);
+ }, 0);
+ if (description) {
+ const fileSummary = fileCount + ' open file' + (fileCount === 1 ? '' : 's');
+ const reviewSummary = reviewCount + ' review item' + (reviewCount === 1 ? '' : 's');
+ const reviewDetails = reviewCount > 0 ? ' and ' + reviewSummary : '';
+ description.textContent = 'This will remove ' + fileSummary + reviewDetails + ' and end any active Live Share session. Unsaved changes cannot be recovered.';
+ }
function doReset() {
closeAppModal(modal);
cleanup();
+ closeReviewComposer();
+ clearReviewDecorations();
disconnectLiveCollaboration({ restoreOriginal: false, silent: true });
applyShareSnapshotAccessMode('edit');
resetShareSnapshotLink();
@@ -3136,6 +4501,8 @@ document.addEventListener("DOMContentLoaded", async function () {
refreshLiveEditorUi();
renderMarkdown();
renderTabBar(tabs, activeTabId);
+ closeReviewComposer();
+ renderReviewPanel();
}
function doCancel() {
@@ -3146,15 +4513,20 @@ document.addEventListener("DOMContentLoaded", async function () {
function cleanup() {
confirmBtn.removeEventListener('click', doReset);
cancelBtn.removeEventListener('click', doCancel);
+ if (closeBtn) closeBtn.removeEventListener('click', doCancel);
}
confirmBtn.addEventListener('click', doReset);
cancelBtn.addEventListener('click', doCancel);
+ if (closeBtn) closeBtn.addEventListener('click', doCancel);
openAppModal(modal, {
- focusTarget: confirmBtn,
+ focusTarget: cancelBtn,
onClose: doCancel
});
+ window.setTimeout(function() {
+ cancelBtn.focus();
+ }, 0);
}
function initTabs() {
@@ -3465,6 +4837,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 +6192,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 +6203,7 @@ ${selector} .arrowheadPath {
}
}
+ decorateReviewTargets();
updateDocumentStats();
updateFindHighlights();
cleanupImageObjectUrls();
@@ -5685,6 +7062,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 +11825,7 @@ ${selector} .arrowheadPath {
}
initTabs();
+ initReviewMode();
if (loadGlobalState().syncScrollingEnabled === false) toggleSyncScrolling();
updateMobileStats();
updateFindHighlights();
@@ -13338,7 +14720,7 @@ ${selector} .arrowheadPath {
}
function canMutateEditor() {
- return !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive();
+ return !reviewModeActive && !isLiveViewOnlyParticipant() && !isShareSnapshotViewOnlyActive();
}
function isLiveMutatingAction(action) {
@@ -13377,16 +14759,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 +14783,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 +14874,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 +14890,7 @@ ${selector} .arrowheadPath {
button.setAttribute('aria-disabled', 'false');
}
});
- if (!viewOnly) {
+ if (!sourceReadOnly) {
updateUndoRedoButtons();
}
}
@@ -13625,6 +15008,20 @@ ${selector} .arrowheadPath {
}
}
+ function publishReviewState() {
+ if (!options.reviewDoc || (typeof options.canPublishReviewState === 'function' && !options.canPublishReviewState())) {
+ return;
+ }
+ try {
+ send({
+ type: 'review-sync-state',
+ update: encodeLiveBytes(options.Y.encodeStateAsUpdate(options.reviewDoc))
+ });
+ } catch (error) {
+ console.warn('Live Share review state publish failed:', error);
+ }
+ }
+
function publishPresence(cursor) {
send({
type: 'presence',
@@ -13644,6 +15041,7 @@ ${selector} .arrowheadPath {
if (!message || message.sender === options.participantId) return;
options.onMessage(message, {
publishCurrentState,
+ publishReviewState,
publishPresence
});
}
@@ -13658,6 +15056,18 @@ ${selector} .arrowheadPath {
};
options.ydoc.on('update', updateHandler);
+ const reviewUpdateHandler = function(update, origin) {
+ if (destroyed || origin === LIVE_REVIEW_RELAY_ORIGIN) return;
+ if (typeof options.canSendReviewUpdate === 'function' && !options.canSendReviewUpdate()) return;
+ send({
+ type: 'review-update',
+ update: encodeLiveBytes(update)
+ });
+ };
+ if (options.reviewDoc) {
+ options.reviewDoc.on('update', reviewUpdateHandler);
+ }
+
try {
socket = new WebSocket(socketUrl);
} catch (error) {
@@ -13674,8 +15084,10 @@ ${selector} .arrowheadPath {
participant: Object.assign({}, options.getParticipant(), { lastSeen: Date.now() })
});
send({ type: 'sync-request' });
+ send({ type: 'review-sync-request' });
flushPendingMessages();
setTimeout(publishCurrentState, 100);
+ setTimeout(publishReviewState, 125);
setTimeout(function() { publishPresence(options.getCursor()); }, 150);
});
@@ -13693,12 +15105,16 @@ ${selector} .arrowheadPath {
return {
send,
publishCurrentState,
+ publishReviewState,
publishPresence,
destroy() {
destroyed = true;
try {
options.ydoc.off('update', updateHandler);
} catch (_) {}
+ try {
+ if (options.reviewDoc) options.reviewDoc.off('update', reviewUpdateHandler);
+ } catch (_) {}
try {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
@@ -13967,6 +15383,7 @@ ${selector} .arrowheadPath {
applyLiveSessionTitle(liveCollaboration.roomTitle);
updateLiveAccessDisplay();
updateLiveEditorAccess();
+ applyLiveReviewThreadsFromMap();
return true;
}
@@ -14539,6 +15956,11 @@ ${selector} .arrowheadPath {
return;
}
+ if (message.type === 'review-sync-request') {
+ if (liveCollaboration.isHost) transport.publishReviewState();
+ return;
+ }
+
if ((message.type === 'y-update' || message.type === 'sync-state') && message.update) {
const hostParticipantId = liveCollaboration.sessionMap ? liveCollaboration.sessionMap.get('hostId') : null;
if (getCurrentLiveAccessMode() === LIVE_SHARE_ACCESS_VIEW && hostParticipantId && sender !== hostParticipantId) {
@@ -14553,6 +15975,19 @@ ${selector} .arrowheadPath {
return;
}
+ if ((message.type === 'review-update' || message.type === 'review-sync-state') && message.update) {
+ try {
+ liveCollaboration.Y.applyUpdate(
+ liveCollaboration.reviewDoc,
+ decodeLiveBytes(message.update),
+ LIVE_REVIEW_RELAY_ORIGIN
+ );
+ } catch (error) {
+ console.warn('Live Share review update failed:', error);
+ }
+ return;
+ }
+
if (message.type === 'hello' || message.type === 'presence') {
const participant = message.participant || {};
const name = participant.name || 'Guest';
@@ -14612,12 +16047,18 @@ ${selector} .arrowheadPath {
if (liveCollaboration.sessionMap && liveCollaboration.sessionObserver) {
liveCollaboration.sessionMap.unobserve(liveCollaboration.sessionObserver);
}
+ if (liveCollaboration.reviewMap && liveCollaboration.reviewObserver) {
+ liveCollaboration.reviewMap.unobserve(liveCollaboration.reviewObserver);
+ }
if (liveCollaboration.connection && typeof liveCollaboration.connection.destroy === 'function') {
liveCollaboration.connection.destroy();
}
if (liveCollaboration.ydoc && typeof liveCollaboration.ydoc.destroy === 'function') {
liveCollaboration.ydoc.destroy();
}
+ if (liveCollaboration.reviewDoc && typeof liveCollaboration.reviewDoc.destroy === 'function') {
+ liveCollaboration.reviewDoc.destroy();
+ }
} catch (error) {
console.warn('Failed to fully close live session:', error);
}
@@ -14687,7 +16128,17 @@ ${selector} .arrowheadPath {
const ydoc = new modules.Y.Doc();
const yText = ydoc.getText('markdown');
const sessionMap = ydoc.getMap('session');
+ const reviewDoc = new modules.Y.Doc();
+ const reviewMap = reviewDoc.getMap('reviewThreads');
const hostActiveTab = tabs.find(function(t) { return t.id === activeTabId; });
+ if (isHost && hostActiveTab) {
+ replaceLiveReviewMapThreads(
+ reviewDoc,
+ reviewMap,
+ hostActiveTab.reviewThreads,
+ LIVE_REVIEW_EDIT_ORIGIN
+ );
+ }
const requestedTitle = options.title || (hostActiveTab && hostActiveTab.title) || 'Live Share';
const hostTitle = getSafeLiveTitle(requestedTitle);
if (!isHost && options.initialUpdate) {
@@ -14772,6 +16223,8 @@ ${selector} .arrowheadPath {
Y: modules.Y,
yText,
sessionMap,
+ reviewDoc,
+ reviewMap,
tabId: liveTabId,
originalTabSnapshot,
returnTabId,
@@ -14789,6 +16242,7 @@ ${selector} .arrowheadPath {
lastMarkdown: shouldDeferParticipantTab ? '' : markdownEditor.value,
observer: null,
sessionObserver: null,
+ reviewObserver: null,
participantHeartbeatId: null,
connection: null,
participants,
@@ -14835,6 +16289,15 @@ ${selector} .arrowheadPath {
};
sessionMap.observe(liveCollaboration.sessionObserver);
+ liveCollaboration.reviewObserver = function(event, transaction) {
+ const origin = transaction
+ ? transaction.origin
+ : (event && event.transaction ? event.transaction.origin : null);
+ if (origin === LIVE_REVIEW_EDIT_ORIGIN) return;
+ applyLiveReviewThreadsFromMap();
+ };
+ reviewMap.observe(liveCollaboration.reviewObserver);
+
if (!isHost && yText.length > 0) {
const seededMarkdown = yText.toString();
applyLiveJoinSeedMarkdown(seededMarkdown);
@@ -14863,6 +16326,7 @@ ${selector} .arrowheadPath {
capability: liveCollaboration.socketCapability,
capabilities: liveCollaboration.isHost ? liveCollaboration.capabilities : null,
ydoc,
+ reviewDoc,
Y: modules.Y,
participantId: liveCollaboration.localParticipantId,
getParticipant: function() { return liveCollaboration.localParticipant; },
@@ -14882,6 +16346,19 @@ ${selector} .arrowheadPath {
(liveCollaboration.isHost || getCurrentLiveAccessMode() === LIVE_SHARE_ACCESS_EDIT)
);
},
+ canPublishReviewState: function() {
+ return Boolean(
+ liveCollaboration &&
+ liveCollaboration.reviewDoc === reviewDoc &&
+ liveCollaboration.isHost
+ );
+ },
+ canSendReviewUpdate: function() {
+ return Boolean(
+ liveCollaboration &&
+ liveCollaboration.reviewDoc === reviewDoc
+ );
+ },
onStatus: setLiveShareStatus,
onMessage: handleLiveRoomMessage
});
diff --git a/styles.css b/styles.css
index 488e43c9..848a3169 100644
--- a/styles.css
+++ b/styles.css
@@ -5984,6 +5984,700 @@ html[data-theme="dark"] .mermaid svg {
isolation: isolate;
}
+/* ========================================
+ COMMENTS & SUGGESTIONS
+ ======================================== */
+.review-toolbar-count {
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+
+.review-toolbar-count[hidden] {
+ display: none !important;
+}
+
+.mobile-review-count-badge {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ padding: 0;
+ border: 2px solid var(--header-bg);
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--bg-color);
+ background: var(--accent-color);
+ box-shadow: 0 0 0 1px var(--border-color);
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.mobile-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(360px, 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: 0 8px 32px rgba(0, 0, 0, 0.25);
+}
+
+.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: 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.review-panel-eyebrow {
+ margin: 0 0 3px;
+ color: var(--accent-color);
+ 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.is-danger:hover:not(:disabled) {
+ color: var(--color-danger-fg);
+ background: color-mix(in srgb, var(--color-danger-fg) 10%, transparent);
+ border-color: color-mix(in srgb, var(--color-danger-fg) 45%, 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-panel-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.review-panel-body {
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+}
+
+.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(--accent-color);
+ background: color-mix(in srgb, var(--accent-color) 8%, transparent);
+ box-shadow: 0 1px 3px rgba(31, 35, 40, 0.12);
+}
+
+.review-composer {
+ flex: 0 0 auto;
+ margin: 14px 14px 4px;
+ padding: 14px;
+ background: var(--header-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 8px;
+}
+
+.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-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(--accent-color);
+ outline: 2px solid color-mix(in srgb, var(--accent-color) 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: var(--accent-color);
+ background: var(--button-bg);
+ border: 1px solid var(--accent-color);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.76rem;
+ font-weight: 650;
+}
+
+.review-primary-btn:hover:not(:disabled) {
+ background: var(--button-hover);
+}
+
+.review-primary-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.review-list {
+ flex: 0 0 auto;
+ overflow: visible;
+ 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(--accent-color);
+ border-radius: 8px;
+}
+
+.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(--text-secondary);
+ background: var(--button-bg);
+ border: 1px solid var(--border-color);
+}
+
+.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(--accent-color);
+ 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-dates {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+ margin-top: 9px;
+}
+
+.review-thread-time {
+ 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(--accent-color);
+ 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):not(.review-target--table) {
+ padding-inline-end: 44px;
+}
+
+#markdown-preview .review-target--table {
+ max-width: calc(100% - 44px);
+}
+
+#markdown-preview .review-target--diagram {
+ padding-block-end: 44px;
+}
+
+#markdown-preview .review-target.has-review-thread:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 76px;
+}
+
+#markdown-preview .review-target--table.has-review-thread {
+ max-width: calc(100% - 76px);
+}
+
+#markdown-preview .review-target.is-review-target-active {
+ outline: none;
+}
+
+#markdown-preview .review-target.has-review-thread,
+#markdown-preview .review-target.is-review-target-active {
+ box-shadow: inset 3px 0 0 var(--accent-color);
+}
+
+[dir="rtl"] #markdown-preview .review-target.has-review-thread,
+[dir="rtl"] #markdown-preview .review-target.is-review-target-active {
+ box-shadow: inset -3px 0 0 var(--accent-color);
+}
+
+#markdown-preview .review-target--heading.has-review-thread,
+#markdown-preview .review-target--heading.is-review-target-active,
+#markdown-preview .review-target--paragraph.has-review-thread,
+#markdown-preview .review-target--paragraph.is-review-target-active {
+ padding-inline-start: 10px;
+}
+
+#markdown-preview .review-target.has-review-thread {
+ cursor: pointer;
+}
+
+.review-pins-layer {
+ position: absolute;
+ z-index: 20;
+ inset: 0;
+ overflow: visible;
+ pointer-events: none;
+}
+
+.review-pins-layer[hidden] {
+ display: none !important;
+}
+
+.review-target-actions {
+ position: absolute;
+ z-index: 8;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ pointer-events: auto;
+}
+
+.review-target-actions[hidden] {
+ display: none !important;
+}
+
+.review-target-button {
+ position: relative;
+ min-width: 28px;
+ height: 28px;
+ flex: 0 0 auto;
+ pointer-events: auto;
+}
+
+.review-target-button[data-review-count]::after {
+ content: attr(data-review-count);
+}
+
+.review-thread.is-review-thread-active {
+ border-color: var(--accent-color);
+}
+
+.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(--accent-color);
+ outline-offset: 2px;
+}
+
+@media (min-width: 1080px) {
+ .review-panel {
+ position: relative;
+ inset: auto;
+ width: 360px;
+ height: auto;
+ flex: 0 0 360px;
+ box-shadow: none;
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1079px) {
+ .review-panel {
+ width: min(360px, 100%);
+ }
+
+ .content-container.is-reviewing .preview-pane {
+ padding-inline-end: 380px;
+ }
+
+ .review-icon-btn,
+ .review-target-button {
+ min-width: 40px;
+ width: 40px;
+ height: 40px;
+ }
+
+ #markdown-preview .review-target.has-review-thread:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 96px;
+ }
+
+ #markdown-preview .review-target--table.has-review-thread {
+ max-width: calc(100% - 96px);
+ }
+
+ .review-filter-btn,
+ .review-kind-btn,
+ .review-primary-btn,
+ .review-thread-action {
+ min-height: 40px;
+ }
+}
+
+@media (max-width: 767px) {
+ .review-panel {
+ inset-block-start: auto;
+ inset-block-end: 0;
+ width: 100%;
+ height: min(68%, 620px);
+ 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(68vh + 16px);
+ }
+
+ .review-panel-header {
+ padding: 12px 14px;
+ }
+
+ .review-panel-toolbar {
+ flex-wrap: wrap;
+ gap: 8px;
+ padding-inline: 10px;
+ }
+
+ .review-panel-actions {
+ margin-inline-start: auto;
+ }
+
+ .review-panel-actions .review-icon-btn {
+ width: 44px;
+ height: 44px;
+ }
+
+ .review-filter-btn,
+ .review-kind-btn,
+ .review-primary-btn,
+ .review-thread-action {
+ min-height: 44px;
+ }
+
+ .review-composer {
+ margin: 10px 10px 4px;
+ padding: 12px;
+ }
+
+ .review-list {
+ padding: 8px 10px 16px;
+ }
+
+ .review-thread-action {
+ padding-inline: 10px;
+ }
+
+ .review-target-button {
+ min-width: 44px;
+ height: 44px;
+ }
+
+ #markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 56px;
+ }
+
+ #markdown-preview .review-target--table {
+ max-width: calc(100% - 56px);
+ }
+
+ #markdown-preview .review-target--diagram {
+ padding-block-end: 56px;
+ }
+
+ #markdown-preview .review-target.has-review-thread:not(.review-target--diagram):not(.review-target--table) {
+ padding-inline-end: 104px;
+ }
+
+ #markdown-preview .review-target--table.has-review-thread {
+ max-width: calc(100% - 104px);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .review-target-button {
+ transition: none;
+ }
+}
+
@@ -6011,10 +6705,32 @@ 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-actions,
+ .review-target-button {
display: none !important;
}
+ #markdown-preview .review-target,
+ #markdown-preview .review-target.has-review-thread,
+ #markdown-preview .review-target.is-review-target-active,
+ [dir="rtl"] #markdown-preview .review-target.has-review-thread {
+ outline: none !important;
+ box-shadow: none !important;
+ padding-inline-end: 0 !important;
+ padding-inline-start: 0 !important;
+ padding-block-end: 0 !important;
+ max-width: 100% !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/FAQ.md b/wiki/FAQ.md
index 5c4b09a8..17fc5160 100644
--- a/wiki/FAQ.md
+++ b/wiki/FAQ.md
@@ -50,7 +50,7 @@ Small snapshots keep compressed content inside the URL hash. Large snapshots use
### Is Live Share saved permanently?
-No. Live Share uses a temporary Cloudflare Durable Object room as a relay. It does not write the live document to KV or a database. While active, the room relays document updates, display names, presence, cursors, sync messages, leave messages, and session-end messages.
+No. Live Share uses a temporary Cloudflare Durable Object room as a relay. It does not write the live document to KV or a database. While active, the room relays document updates, review comments and suggestions, display names, presence, cursors, sync messages, leave messages, and session-end messages.
### Is Live Share end-to-end encrypted?
diff --git a/wiki/Features.md b/wiki/Features.md
index 5b445bb9..8509f8aa 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,36 @@ 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 YAML frontmatter tables, headings, paragraphs, fenced code blocks, and supported diagram containers, including Mermaid and the other diagram engines.
+- Empty targets show a plus that opens and focuses the composer. Targets with feedback show two controls: the review icon and count opens saved feedback, while the adjacent plus opens the composer for another item. Selecting reviewed content also opens its saved feedback without opening the composer.
+- A reviewer can add either a comment or a suggestion. Both are feedback records; suggestions do not apply source changes automatically.
+- Threads show their opened and closed date/time and can be edited in the existing composer, resolved, reopened, or deleted individually and filtered by status. The panel also provides a detailed Markdown summary with lifecycle dates and counts, Resolve all, and confirmation-protected Delete all actions.
+- Open-thread counts appear inline in the desktop Review button and in the mobile menu.
+- Review controls support keyboard focus, screen-reader labels, dark mode, RTL layout, a compact desktop side panel, a tablet drawer, and a touch-friendly mobile bottom sheet.
+- Opening a new document tab closes Review mode and restores the prior document view before switching tabs.
+- Comments and suggestions use the existing app accent, button surfaces, borders, hover states, disabled states, and Bootstrap Icons; the feature does not introduce a separate color palette.
+
+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 behavior:
+
+- Review threads are not encoded into Share Snapshot URLs or stored snapshot payloads.
+- Live Share synchronizes review creation, resolve/reopen state, and deletion through a separate Yjs document. View-only participants can send Review updates without permission to edit Markdown.
+- A participant's temporary live tab is removed on leave; synchronized feedback remains in the host's normal tab and in the active room state.
+- Use Copy review summary to transfer feedback outside the app or after a Live Share session.
+
## 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.
@@ -314,12 +344,12 @@ User flow:
Implementation:
-- The client uses Yjs for the shared document state.
+- The client uses separate Yjs documents for Markdown/session state and Review threads.
- Browser clients connect with WebSocket to `/live-room/?secret=`.
-- The Pages Function and Durable Object reject unsupported WebSocket `Origin` values. The production app, `null`, and localhost development origins are allowed.
+- The Pages Function and Durable Object reject unsupported WebSocket `Origin` values. The production app, HTTPS `*.markdownviewer.pages.dev` previews, `null`, and localhost development origins are allowed.
- The host connection establishes separate host, edit, and view capabilities. The Durable Object stores these capabilities and authenticates each joining role server-side.
- Cloudflare Pages routes the WebSocket to a Durable Object named `LIVE_ROOMS`.
-- The Durable Object relays only known message types and filters them by role: viewers cannot send updates or session-end messages, editors can send document sync messages, and only the host can send every supported type.
+- The Durable Object relays only known message types and filters them by role: viewers cannot send Markdown updates or session-end messages, but they can request and send Review updates; editors can send Markdown and Review updates; only the host can publish full Review state or send every supported type.
- The Durable Object does not write document state to KV or a database.
- Room identity is derived from room id plus secret.
@@ -478,6 +508,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 | Only during Live Share | Normal saved tabs plus temporary Live Share relay state | Excluded from document exports and Share Snapshot; synchronized between active Live Share participants. |
| 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/Live-Share-Cloudflare.md b/wiki/Live-Share-Cloudflare.md
index 113ed952..d27ce999 100644
--- a/wiki/Live-Share-Cloudflare.md
+++ b/wiki/Live-Share-Cloudflare.md
@@ -8,7 +8,7 @@ Live Share is the temporary Markdown collaboration feature in Markdown Viewer. I
2. The host enters or accepts a display name.
3. The host chooses Can edit or View only.
4. The app creates a random room id and a random room secret.
-5. The app creates a Yjs document for the active tab.
+5. The app creates separate Yjs documents for Markdown/session state and Review threads.
6. The host opens a WebSocket to `/live-room/?secret=`.
7. The host connection establishes separate host, edit, and view capabilities before the invite link is shown.
8. The invite link includes the selected role and capability for the recipient. Participants open it, join a temporary live tab, request the current Yjs state, and render participant avatars/cursors.
@@ -26,7 +26,7 @@ There are two checked-in Cloudflare entry points:
The Pages Function:
- Requires a WebSocket upgrade request.
-- Allows only the production app, `null`, and localhost/127.0.0.1 development `Origin` values.
+- Allows the production app, HTTPS `*.markdownviewer.pages.dev` preview deployments, `null`, and localhost/127.0.0.1 development `Origin` values.
- Requires a `LIVE_ROOMS` Durable Object binding.
- Rejects room names over 160 characters.
- Rejects secrets over 256 characters.
@@ -38,8 +38,8 @@ The Durable Object:
- Accepts the WebSocket pair.
- Authenticates the host capability when the room is first created and checks edit/view capabilities against the stored room credentials for later connections.
- Assigns a temporary socket participant id.
-- Relays only known message types: `hello`, `presence`, `sync-request`, `sync-state`, `y-update`, `leave`, and `session-end`.
-- Filters messages by role: viewers can send presence/sync requests but not document updates or session-end; editors can send document sync messages; only the host can send every supported message type.
+- Relays only known message types: `hello`, `presence`, `sync-request`, `sync-state`, `y-update`, `review-sync-request`, `review-sync-state`, `review-update`, `leave`, and `session-end`.
+- Filters messages by role: viewers can send presence, sync requests, and Review updates but not Markdown updates or session-end; editors can send Markdown and Review updates; only the host can publish full Review state and every supported message type.
- Adds `roomId` and `sentAt` to normalized messages.
- Broadcasts messages to other sockets in the same room.
- Broadcasts `leave` when a socket closes or errors.
diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md
index 1ab9a3ee..4e74e280 100644
--- a/wiki/Usage-Guide.md
+++ b/wiki/Usage-Guide.md
@@ -43,6 +43,26 @@ 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 plus pin on a rendered YAML frontmatter table, 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. After feedback is attached, use the review icon and count—or select the reviewed content—to read the saved items. Use the separate plus beside the count to open the composer and add another item to the same block.
+6. Select **Edit** to update an existing comment or suggestion, then select **Save changes**. Each thread shows when it was opened and, after resolution, when it was closed. Reopening clears the closing time until the item is resolved again.
+7. The panel toolbar can copy a detailed Markdown summary with generated, opened, and closed date/time plus open/resolved counts; resolve every open item; or delete all feedback after confirmation. Deleting an individual item also uses an in-app confirmation dialog.
+
+Opening a new tab closes Review mode automatically before switching to the new document.
+
+On desktop, Review opens as a compact side panel beside the preview. Tablet layouts use a side drawer, while phones use a taller bottom sheet with larger filter, composer, thread, and pin controls for touch. Light and dark themes use the same existing app colors, borders, hover states, and icons.
+
+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.
+
+Share Snapshot links transfer Markdown without review threads. Live Share rooms synchronize comments, suggestions, resolve/reopen state, and deletions through a separate Review channel. View-only participants can review without receiving Markdown edit permission. A participant's temporary live tab is removed when they leave, while synchronized feedback remains in the host's normal document tab. Private mode prevents review threads from being persisted with the local 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`.
@@ -125,7 +145,7 @@ Use Live Share when you want a temporary real-time room.
4. Start the session.
5. Copy the invite link after the room starts.
-Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not store the document in KV or a database. The invite URL contains a room id, room secret, access role/capability, and title, not the full document body. The server authenticates host, editable, and view-only capabilities and filters message types by role.
+Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not store the document in KV or a database. The invite URL contains a room id, room secret, access role/capability, and title, not the full document body. The server authenticates host, editable, and view-only capabilities and filters message types by role. Markdown and Review data use separate Yjs documents, so view-only participants can synchronize comments and suggestions without being allowed to edit Markdown.
Participants get a temporary live tab, presence avatars, and live cursor indicators. The host can end the session for everyone. Rooms are limited to 64 WebSocket participants and 1 MB live messages.
diff --git a/workers/live-room-worker.js b/workers/live-room-worker.js
index 66779b63..b69677a2 100644
--- a/workers/live-room-worker.js
+++ b/workers/live-room-worker.js
@@ -47,6 +47,9 @@ function isAllowedLiveOrigin(origin) {
if (origin === "https://markdownviewer.pages.dev" || origin === "null") {
return true;
}
+ if (/^https:\/\/[a-z0-9-]+\.markdownviewer\.pages\.dev$/i.test(origin || "")) {
+ return true;
+ }
if (/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(origin || "")) {
return true;
}
@@ -66,6 +69,8 @@ function canSendMessageType(role, type) {
"sync-request",
"sync-state",
"y-update",
+ "review-sync-request",
+ "review-update",
"leave"
]).has(type);
}
@@ -73,6 +78,8 @@ function canSendMessageType(role, type) {
"hello",
"presence",
"sync-request",
+ "review-sync-request",
+ "review-update",
"leave"
]).has(type);
}
@@ -91,6 +98,9 @@ function normalizeOutboundMessage(message, fallbackSender, roomId, role) {
"sync-request",
"sync-state",
"y-update",
+ "review-sync-request",
+ "review-sync-state",
+ "review-update",
"leave",
"session-end"
]);