diff --git a/README.md b/README.md index a1df2c49..32ef3085 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ## What Markdown Viewer Does -Markdown Viewer is an open source, local-first workspace for developers, writers, students, researchers, and anyone working with `.md` or `.markdown` files. Go beyond plain text. Organize multiple documents, review with comments and suggestions without changing the Markdown, and render diagrams, maps, charts, math, 3D models, and music—all in one focused workspace. +Markdown Viewer is an open source, local-first workspace for developers, writers, students, researchers, and anyone working with `.md` or `.markdown` files. Go beyond plain text. Organize multiple documents, review with anchored comments without changing the Markdown, and render diagrams, maps, charts, math, 3D models, and music—all in one focused workspace. Turn a document into a Share Snapshot link for quick handoffs, or start access-controlled Live Share for real-time co-editing, live cursors, comments, and suggestions. Everyday editing stays on your device. No account is required, and the application includes no ads, analytics, telemetry, or subscriptions. diff --git a/assets/lucide-icons.css b/assets/lucide-icons.css index dda69892..b65da455 100644 --- a/assets/lucide-icons.css +++ b/assets/lucide-icons.css @@ -21,6 +21,8 @@ pointer-events: none; } +.lucide-clipboard { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+PHN2ZyBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1jbGlwYm9hcmQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiID48cmVjdCB3aWR0aD0iOCIgaGVpZ2h0PSI0IiB4PSI4IiB5PSIyIiByeD0iMSIgcnk9IjEiIC8+PHBhdGggZD0iTTE2IDRoMmEyIDIgMCAwIDEgMiAydjE0YTIgMiAwIDAgMS0yIDJINmEyIDIgMCAwIDEtMi0yVjZhMiAyIDAgMCAxIDItMmgyIiAvPjwvc3ZnPg=="); } + @media (forced-colors: active) { .lucide { background-color: ButtonText; } } @@ -95,6 +97,7 @@ .lucide-map-pin { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWFwLXBpbiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPiA8cGF0aCBkPSJNMjAgMTBjMCA0Ljk5My01LjUzOSAxMC4xOTMtNy4zOTkgMTEuNzk5YTEgMSAwIDAgMS0xLjIwMiAwQzkuNTM5IDIwLjE5MyA0IDE0Ljk5MyA0IDEwYTggOCAwIDAgMSAxNiAwIiAvPiA8Y2lyY2xlIGN4PSIxMiIgY3k9IjEwIiByPSIzIiAvPiA8L3N2Zz4="); } .lucide-maximize { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWF4aW1pemUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiID4gPHBhdGggZD0iTTggM0g1YTIgMiAwIDAgMC0yIDJ2MyIgLz4gPHBhdGggZD0iTTIxIDhWNWEyIDIgMCAwIDAtMi0yaC0zIiAvPiA8cGF0aCBkPSJNMyAxNnYzYTIgMiAwIDAgMCAyIDJoMyIgLz4gPHBhdGggZD0iTTE2IDIxaDNhMiAyIDAgMCAwIDItMnYtMyIgLz4gPC9zdmc+"); } .lucide-menu { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWVudSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPiA8cGF0aCBkPSJNNCA1aDE2IiAvPiA8cGF0aCBkPSJNNCAxMmgxNiIgLz4gPHBhdGggZD0iTTQgMTloMTYiIC8+IDwvc3ZnPg=="); } +.lucide-message-square { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWVzc2FnZS1zcXVhcmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiID4gPHBhdGggZD0iTTIyIDE3YTIgMiAwIDAgMS0yIDJINi44MjhhMiAyIDAgMCAwLTEuNDE0LjU4NmwtMi4yMDIgMi4yMDJBLjcxLjcxIDAgMCAxIDIgMjEuMjg2VjVhMiAyIDAgMCAxIDItMmgxNmEyIDIgMCAwIDEgMiAyeiIgLz4gPC9zdmc+"); } .lucide-message-square-more { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWVzc2FnZS1zcXVhcmUtbW9yZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPiA8cGF0aCBkPSJNMjIgMTdhMiAyIDAgMCAxLTIgMkg2LjgyOGEyIDIgMCAwIDAtMS40MTQuNTg2bC0yLjIwMiAyLjIwMkEuNzEuNzEgMCAwIDEgMiAyMS4yODZWNWEyIDIgMCAwIDEgMi0yaDE2YTIgMiAwIDAgMSAyIDJ6IiAvPiA8cGF0aCBkPSJNMTIgMTFoLjAxIiAvPiA8cGF0aCBkPSJNMTYgMTFoLjAxIiAvPiA8cGF0aCBkPSJNOCAxMWguMDEiIC8+IDwvc3ZnPg=="); } .lucide-message-square-text { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWVzc2FnZS1zcXVhcmUtdGV4dCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgPiA8cGF0aCBkPSJNMjIgMTdhMiAyIDAgMCAxLTIgMkg2LjgyOGEyIDIgMCAwIDAtMS40MTQuNTg2bC0yLjIwMiAyLjIwMkEuNzEuNzEgMCAwIDEgMiAyMS4yODZWNWEyIDIgMCAwIDEgMi0yaDE2YTIgMiAwIDAgMSAyIDJ6IiAvPiA8cGF0aCBkPSJNNyAxMWgxMCIgLz4gPHBhdGggZD0iTTcgMTVoNiIgLz4gPHBhdGggZD0iTTcgN2g4IiAvPiA8L3N2Zz4="); } .lucide-minimize { --lucide-icon: url("data:image/svg+xml;base64,PCEtLSBAbGljZW5zZSBsdWNpZGUtc3RhdGljIHYxLjI1LjAgLSBJU0MgLS0+IDxzdmcgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWluaW1pemUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiID4gPHBhdGggZD0iTTggM3YzYTIgMiAwIDAgMS0yIDJIMyIgLz4gPHBhdGggZD0iTTIxIDhoLTNhMiAyIDAgMCAxLTItMlYzIiAvPiA8cGF0aCBkPSJNMyAxNmgzYTIgMiAwIDAgMSAyIDJ2MyIgLz4gPHBhdGggZD0iTTE2IDIxdi0zYTIgMiAwIDAgMSAyLTJoMyIgLz4gPC9zdmc+"); } diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index ef73df36..d62cdaf5 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -53,11 +53,22 @@

Markdown Viewer - Online Markd - - -
+
@@ -259,7 +281,7 @@

Actions

- @@ -370,7 +392,7 @@

Explorer

-
+
@@ -382,7 +404,7 @@

Explorer

-
+
-
-
+
+
+
+ + +
-
- - - - - +
+ +
-
+
+ + -
- - -
+
+
+ + + + +
-
- - - - -
- - - -
-
@@ -589,7 +590,7 @@

Explorer

@@ -790,9 +791,9 @@
@@ -1485,78 +1486,59 @@

Quick start

- - + diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 49db4386..2603217e 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -298,18 +298,22 @@ document.addEventListener("DOMContentLoaded", async function () { let currentViewMode = 'split'; // 'editor', 'split', or 'preview' const shareSnapshotViewOnlyTabIds = new Set(); const SHARE_SNAPSHOT_TAB_KIND = 'share-snapshot'; - const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, pre, .frontmatter-table, .diagram-viewer, .geojson-container, .topojson-container, .stl-container'; + const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, li, blockquote, img, video, audio, mjx-container, .frontmatter-table, .diagram-viewer, .geojson-container, .topojson-container, .stl-container'; + const REVIEW_ELEMENT_TYPES = new Set(['image', 'media', 'diagram', 'math', 'table']); + const REVIEW_REPLY_COLLAPSE_THRESHOLD = 4; 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 activeReviewReplyEdit = null; + let activeReviewId = null; + let hoveredReviewId = null; + let pendingReviewSelection = null; + const expandedReviewThreadIds = new Set(); let pendingReviewDelete = null; let pendingWorkspaceBackupInput = null; - let reviewPinsResizeObserver = null; - let reviewPinsLayoutFrame = null; let reviewTargetSourceSnapshots = new WeakMap(); let activeModal = null; let lastFocusedElement = null; @@ -340,9 +344,10 @@ 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 themeToggleIcon = document.getElementById("theme-toggle-icon"); + const themeSwitchIcon = document.getElementById("theme-switch-icon"); const directionToggle = document.getElementById("direction-toggle"); const importFromFileButton = document.getElementById("import-from-file"); const importFromGithubButton = document.getElementById("import-from-github"); @@ -374,6 +379,7 @@ document.addEventListener("DOMContentLoaded", async function () { const reviewPanelBackdrop = document.getElementById('review-panel-backdrop'); const reviewPanelClose = document.getElementById('review-panel-close'); const reviewPanelSummary = document.getElementById('review-panel-summary'); + const reviewNewComment = document.getElementById('review-new-comment'); const reviewCopySummary = document.getElementById('review-copy-summary'); const reviewResolveAll = document.getElementById('review-resolve-all'); const reviewDeleteAll = document.getElementById('review-delete-all'); @@ -387,6 +393,8 @@ document.addEventListener("DOMContentLoaded", async function () { const reviewComposer = document.getElementById('review-composer'); const reviewComposerTitle = document.getElementById('review-composer-title'); const reviewComposerAnchor = document.getElementById('review-composer-anchor'); + const reviewComposerAuthor = document.getElementById('review-composer-author'); + const reviewComposerAvatar = document.getElementById('review-composer-avatar'); const reviewComposerCancel = document.getElementById('review-composer-cancel'); const reviewFeedbackLabel = document.getElementById('review-feedback-label'); const reviewFeedbackInput = document.getElementById('review-feedback-input'); @@ -757,7 +765,7 @@ document.addEventListener("DOMContentLoaded", async function () { return 'Open or create a document to use editing tools.'; } if (reviewModeActive) { - return 'Review mode keeps the Markdown source read only.'; + return 'Comments keeps the Markdown source read only.'; } if (isShareSnapshotViewOnlyActive()) { return 'This shared snapshot is view only.'; @@ -801,6 +809,7 @@ document.addEventListener("DOMContentLoaded", async function () { const mobileExportToggle = document.querySelector('[aria-controls="mobile-menu-export-panel"]'); const mobileThemeToggle = document.getElementById("mobile-theme-toggle"); const mobileThemeIcon = document.getElementById("mobile-theme-icon"); + const mobileThemeSwitchIcon = document.getElementById("mobile-theme-switch-icon"); const mobileThemeStatus = document.getElementById("mobile-theme-status"); const mobilePrivateModeToggle = document.getElementById("mobile-private-mode-toggle"); const mobileStorageSettingsButton = document.getElementById("mobile-storage-settings-button"); @@ -1579,22 +1588,25 @@ document.addEventListener("DOMContentLoaded", async function () { document.documentElement.setAttribute("data-theme", initialTheme); function updateThemeToggleUI(theme) { - const useLightAppearance = theme === "dark"; + const darkModeEnabled = theme === "dark"; + const themeIconName = darkModeEnabled ? 'moon' : 'sun'; if (themeToggle) { - const icon = themeToggle.querySelector('i'); const description = document.getElementById('theme-toggle-description'); - if (icon) icon.className = 'lucide lucide-' + (useLightAppearance ? 'sun' : 'moon'); - if (description) description.textContent = useLightAppearance ? 'Switch to light appearance' : 'Switch to dark appearance'; - themeToggle.setAttribute('aria-label', useLightAppearance ? 'Use light appearance' : 'Use dark appearance'); - themeToggle.setAttribute('title', useLightAppearance ? 'Use light appearance' : 'Use dark appearance'); + themeToggle.classList.toggle('is-active', darkModeEnabled); + themeToggle.setAttribute('aria-pressed', String(darkModeEnabled)); + if (themeToggleIcon) themeToggleIcon.className = 'lucide lucide-' + themeIconName; + if (themeSwitchIcon) themeSwitchIcon.className = 'lucide lucide-' + themeIconName + ' settings-switch-icon'; + if (description) description.textContent = darkModeEnabled ? 'Switch to light appearance' : 'Switch to dark appearance'; + themeToggle.setAttribute('aria-label', darkModeEnabled ? 'Use light appearance' : 'Use dark appearance'); + themeToggle.setAttribute('title', darkModeEnabled ? 'Use light appearance' : 'Use dark appearance'); } if (mobileThemeToggle) { - const darkModeEnabled = theme === 'dark'; mobileThemeToggle.classList.toggle('is-active', darkModeEnabled); mobileThemeToggle.setAttribute('aria-pressed', String(darkModeEnabled)); mobileThemeToggle.setAttribute('aria-label', darkModeEnabled ? 'Use light mode' : 'Use dark mode'); mobileThemeToggle.setAttribute('title', darkModeEnabled ? 'Use light mode' : 'Use dark mode'); - if (mobileThemeIcon) mobileThemeIcon.className = 'lucide lucide-' + (darkModeEnabled ? 'moon' : 'sun'); + if (mobileThemeIcon) mobileThemeIcon.className = 'lucide lucide-' + themeIconName; + if (mobileThemeSwitchIcon) mobileThemeSwitchIcon.className = 'lucide lucide-' + themeIconName + ' settings-switch-icon'; if (mobileThemeStatus) mobileThemeStatus.textContent = darkModeEnabled ? 'Dark mode' : 'Light mode'; } } @@ -3263,6 +3275,7 @@ document.addEventListener("DOMContentLoaded", async function () { queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { mjx.removeAttribute('tabindex'); }); + if (reviewModeActive) decorateReviewTargets(); }).catch(function(err) { console.warn('MathJax typesetting failed:', err); }); @@ -6829,12 +6842,20 @@ document.addEventListener("DOMContentLoaded", async function () { if (thread.resolved) { const status = document.createElement('span'); status.className = 'review-status-label'; - status.textContent = 'Resolved'; + const statusIcon = document.createElement('i'); + statusIcon.className = 'lucide lucide-check'; + statusIcon.setAttribute('aria-hidden', 'true'); + status.appendChild(statusIcon); + status.appendChild(document.createTextNode('Resolved')); meta.appendChild(status); } header.appendChild(meta); item.appendChild(header); + const content = document.createElement('div'); + content.className = 'review-thread-content'; + item.appendChild(content); + const anchor = document.createElement('button'); anchor.type = 'button'; anchor.className = 'review-thread-anchor'; @@ -6844,59 +6865,79 @@ document.addEventListener("DOMContentLoaded", async function () { anchor.textContent = target ? thread.anchor.label + ': ' + thread.anchor.excerpt : 'Anchor no longer in preview - ' + thread.anchor.label + ': ' + thread.anchor.excerpt; - item.appendChild(anchor); + content.appendChild(anchor); const body = document.createElement('p'); body.className = 'review-thread-body'; body.textContent = thread.body; - item.appendChild(body); + content.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); + const openedIcon = document.createElement('i'); + openedIcon.className = 'lucide lucide-clock-3'; + openedIcon.setAttribute('aria-hidden', 'true'); + openedTime.appendChild(openedIcon); + openedTime.appendChild(document.createTextNode('Opened: ' + formatReviewTime(thread.createdAt))); dates.appendChild(openedTime); if (thread.resolvedAt) { const closedTime = document.createElement('time'); - closedTime.className = 'review-thread-time'; + closedTime.className = 'review-thread-time is-closed'; closedTime.dateTime = new Date(thread.resolvedAt).toISOString(); - closedTime.textContent = 'Closed: ' + formatReviewTime(thread.resolvedAt); + const closedIcon = document.createElement('i'); + closedIcon.className = 'lucide lucide-check'; + closedIcon.setAttribute('aria-hidden', 'true'); + closedTime.appendChild(closedIcon); + closedTime.appendChild(document.createTextNode('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'; + closedTime.className = 'review-thread-time ' + (thread.resolved ? 'is-closed' : 'is-open'); + const closedIcon = document.createElement('i'); + closedIcon.className = thread.resolved ? 'lucide lucide-check' : 'lucide lucide-circle'; + closedIcon.setAttribute('aria-hidden', 'true'); + closedTime.appendChild(closedIcon); + closedTime.appendChild(document.createTextNode(thread.resolved ? 'Closed: Unavailable' : 'Closed: Not closed')); dates.appendChild(closedTime); } - item.appendChild(dates); + content.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.setAttribute('role', 'group'); + actions.setAttribute('aria-label', 'Review item actions'); + + function createThreadAction(action, iconClass, label, danger) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'review-thread-action' + (danger ? ' is-danger' : ''); + button.dataset.reviewAction = action; + button.dataset.reviewId = thread.id; + button.title = label; + button.setAttribute('aria-label', label); + const icon = document.createElement('i'); + icon.className = iconClass; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + return button; + } + + const itemLabel = thread.kind === 'suggestion' ? 'suggestion' : 'comment'; + const editButton = createThreadAction('edit', 'lucide lucide-square-pen', 'Edit ' + itemLabel, false); 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'; + const statusButton = createThreadAction( + 'toggle-resolved', + thread.resolved ? 'lucide lucide-refresh-cw' : 'lucide lucide-check', + (thread.resolved ? 'Reopen ' : 'Resolve ') + itemLabel, + false + ); 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'; + const deleteButton = createThreadAction('delete', 'lucide lucide-trash-2', 'Delete ' + itemLabel, true); actions.appendChild(deleteButton); - item.appendChild(actions); + header.appendChild(actions); return item; } @@ -6935,12 +6976,12 @@ document.addEventListener("DOMContentLoaded", async function () { 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.'; + copy.textContent = 'Choose the comment button beside any block to start a review.'; } 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.'; + : 'Choose the comment button beside any block to start a review.'; } } } @@ -7257,100 +7298,1803 @@ document.addEventListener("DOMContentLoaded", async function () { requestAnimationFrame(function() { if (reviewPanelClose) reviewPanelClose.focus(); }); - } - - function cancelReviewDeleteConfirmation() { - pendingReviewDelete = null; - if (reviewDeleteModal && reviewDeleteModal.getAttribute('aria-hidden') === 'false') { - closeAppModal(reviewDeleteModal); - } + } + + 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); + if (nextState && isReleaseNotesActive()) { + announceToScreenReader('Release notes do not support comments or suggestions.'); + return; + } + 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(false); + 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 }); + } + }); + } + + // Comments v2 keeps the persisted reviewThreads collection compatible while + // replacing block-side actions with a selection-first, Word-style workflow. + function normalizeReviewSelection(rawSelection) { + if (!rawSelection || typeof rawSelection !== 'object') return null; + const kind = ['text', 'element', 'block'].includes(rawSelection.kind) + ? rawSelection.kind + : null; + if (!kind) return null; + if (kind === 'text') { + const start = Math.max(0, Number(rawSelection.start) || 0); + const end = Math.max(start, Number(rawSelection.end) || start); + const quote = typeof rawSelection.quote === 'string' + ? rawSelection.quote.slice(0, 1000) + : ''; + if (!quote || end <= start) return null; + return { + kind: 'text', + start: start, + end: end, + quote: quote, + prefix: typeof rawSelection.prefix === 'string' ? rawSelection.prefix.slice(-64) : '', + suffix: typeof rawSelection.suffix === 'string' ? rawSelection.suffix.slice(0, 64) : '' + }; + } + return { + kind: kind, + elementType: typeof rawSelection.elementType === 'string' + ? rawSelection.elementType.slice(0, 40) + : null + }; + } + + 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 authorId = typeof thread.authorId === 'string' && thread.authorId + ? thread.authorId.slice(0, 160) + : null; + const replies = Array.isArray(thread.replies) + ? thread.replies.slice(-200).map(function(reply) { + if (!reply || typeof reply !== 'object') return null; + const replyBody = typeof reply.body === 'string' + ? reply.body.trim().slice(0, REVIEW_TEXT_LIMIT) + : ''; + if (!replyBody) return null; + const replyCreatedAt = Number.isFinite(Number(reply.createdAt)) + ? Number(reply.createdAt) + : createdAt; + const replyAuthorId = typeof reply.authorId === 'string' && reply.authorId + ? reply.authorId.slice(0, 160) + : null; + const isOwnerReply = !replyAuthorId || replyAuthorId === 'document-owner'; + return { + id: typeof reply.id === 'string' && reply.id + ? reply.id.slice(0, 120) + : createReviewId(), + author: !isOwnerReply && typeof reply.author === 'string' && reply.author.trim() + ? reply.author.trim().slice(0, 120) + : 'Author', + authorId: replyAuthorId, + body: replyBody, + createdAt: replyCreatedAt, + updatedAt: Number.isFinite(Number(reply.updatedAt)) + ? Number(reply.updatedAt) + : replyCreatedAt + }; + }).filter(Boolean) + : []; + const isOwnerThread = !authorId || authorId === 'document-owner'; + 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 content', + 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, + selection: normalizeReviewSelection(thread.anchor.selection) + }, + // Keep legacy suggestion data intact. New comments are always stored as comments. + kind: thread.kind === 'suggestion' ? 'suggestion' : 'comment', + author: !isOwnerThread && typeof thread.author === 'string' && thread.author.trim() + ? thread.author.trim().slice(0, 120) + : 'Author', + authorId: authorId, + body: body, + createdAt: createdAt, + updatedAt: updatedAt, + resolved: resolved, + resolvedAt: resolved && Number.isFinite(Number(thread.resolvedAt)) + ? Number(thread.resolvedAt) + : null, + replies: replies + }; + }).filter(Boolean); + } + + function cloneReviewThreadForLiveShare(thread) { + return { + id: thread.id, + anchor: Object.assign({}, thread.anchor, { + selection: thread.anchor && thread.anchor.selection + ? Object.assign({}, thread.anchor.selection) + : null + }), + kind: thread.kind === 'suggestion' ? 'suggestion' : 'comment', + author: thread.author || 'Author', + authorId: thread.authorId || null, + body: thread.body, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + resolved: thread.resolved === true, + resolvedAt: Number.isFinite(Number(thread.resolvedAt)) ? Number(thread.resolvedAt) : null, + replies: (thread.replies || []).map(function(reply) { + return { + id: reply.id, + author: reply.author || 'Author', + authorId: reply.authorId || null, + body: reply.body, + createdAt: reply.createdAt, + updatedAt: reply.updatedAt + }; + }) + }; + } + + function liveReviewThreadsMatch(left, right) { + if (!left || !right) return false; + return left.id === right.id && + left.kind === right.kind && + (left.author || '') === (right.author || '') && + (left.authorId || '') === (right.authorId || '') && + left.body === right.body && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.resolved === right.resolved && + left.resolvedAt === right.resolvedAt && + JSON.stringify(left.anchor || {}) === JSON.stringify(right.anchor || {}) && + JSON.stringify(left.replies || []) === JSON.stringify(right.replies || []); + } + + function getCurrentReviewAuthorIdentity() { + if (liveCollaboration && liveCollaboration.localParticipantId && liveCollaboration.localParticipant) { + const liveName = String(liveCollaboration.localParticipant.name || '').trim(); + return { + id: String(liveCollaboration.localParticipantId).slice(0, 160), + name: (liveName || 'Live Share participant').slice(0, 120) + }; + } + return { id: 'document-owner', name: 'Author' }; + } + + function getCurrentReviewAuthor() { + return getCurrentReviewAuthorIdentity().name; + } + + function updateReviewAuthorIdentity(authorId, authorName) { + if (!authorId || !authorName) return false; + const tab = getActiveReviewTab(); + if (!tab || !Array.isArray(tab.reviewThreads)) return false; + let changed = false; + tab.reviewThreads.forEach(function(thread) { + if (thread.authorId === authorId && thread.author !== authorName) { + thread.author = authorName; + changed = true; + } + (thread.replies || []).forEach(function(reply) { + if (reply.authorId === authorId && reply.author !== authorName) { + reply.author = authorName; + changed = true; + } + }); + }); + if (!changed) return false; + syncLiveReviewThreadsFromTab(); + if (activeTabId) saveTabsToStorage(tabs, [activeTabId]); + renderReviewPanel(); + return true; + } + + function getReviewAvatarLabel(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + if (parts.length > 1) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return (parts[0] || 'A').slice(0, 1).toUpperCase(); + } + + function getReviewTargetType(target) { + if (!target) return 'block'; + if (target.matches('img')) return 'image'; + if (target.matches('video, audio')) return 'media'; + if (target.matches('mjx-container')) return 'math'; + 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 (target.tagName === 'LI') return 'list-item'; + if (target.tagName === 'BLOCKQUOTE') return 'quote'; + if (/^H[1-6]$/.test(target.tagName)) return 'heading'; + if (target.tagName === 'P') return 'paragraph'; + return 'block'; + } + + function computeReviewTargetSource(target, type) { + if (!target) return ''; + if (type === 'image') { + return normalizeReviewSource([ + target.getAttribute('alt') || '', + target.getAttribute('src') || '', + target.getAttribute('title') || '' + ].join('\n'), true); + } + if (type === 'media') { + const sources = Array.from(target.querySelectorAll('source')).map(function(source) { + return source.getAttribute('src') || ''; + }); + return normalizeReviewSource([ + target.getAttribute('aria-label') || '', + target.getAttribute('title') || '', + target.getAttribute('src') || '', + sources.join('\n') + ].join('\n'), true); + } + if (type === 'math') { + return normalizeReviewSource( + target.getAttribute('aria-label') || target.getAttribute('data-latex') || target.textContent, + false + ); + } + 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-comment-highlight, .review-pending-highlight').forEach(function(mark) { + mark.replaceWith(document.createTextNode(mark.textContent || '')); + }); + 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 getReviewTargetLabel(target, type) { + if (type === 'heading') return 'Heading ' + target.tagName; + if (type === 'paragraph') return 'Paragraph'; + if (type === 'list-item') return 'List item'; + if (type === 'quote') return 'Quote'; + if (type === 'image') return target.getAttribute('alt') ? 'Image: ' + target.getAttribute('alt') : 'Image'; + if (type === 'media') return target.tagName === 'AUDIO' ? 'Audio' : 'Video'; + if (type === 'math') return 'Math expression'; + 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 labels = { geojson: 'GeoJSON map', topojson: 'TopoJSON map', stl: 'STL model' }; + if (labels[engine]) return labels[engine]; + const engineLabel = engine ? getDiagramEngineLabel(engine) : 'Diagram'; + return engineLabel === 'Diagram' ? engineLabel : engineLabel + ' diagram'; + } + return 'Document content'; + } + + function getReviewAnchorDescriptor(target, selection) { + if (!target || !target.dataset.reviewAnchor) return null; + const type = target.dataset.reviewType || getReviewTargetType(target); + const source = getReviewTargetSource(target, type); + const normalizedSelection = normalizeReviewSelection(selection); + const selectedExcerpt = normalizedSelection && normalizedSelection.quote + ? normalizeReviewSource(normalizedSelection.quote, false) + : ''; + const excerptSource = selectedExcerpt || source; + return { + key: target.dataset.reviewAnchor, + type: type, + label: target.dataset.reviewLabel || getReviewTargetLabel(target, type), + excerpt: excerptSource.length > 180 ? excerptSource.slice(0, 177) + '...' : (excerptSource || 'Selected content'), + duplicateCount: Math.max(1, Number(target.dataset.reviewDuplicateCount) || 1), + context: target.dataset.reviewContext || null, + selection: normalizedSelection + }; + } + + function getReviewTargets() { + if (!markdownPreview) return []; + return Array.from(markdownPreview.querySelectorAll(REVIEW_TARGET_SELECTOR)).filter(function(target) { + const diagram = target.closest('.diagram-viewer, .geojson-container, .topojson-container, .stl-container'); + if (diagram && diagram !== target) return false; + const table = target.closest('.frontmatter-table'); + if (table && table !== target) return false; + return !target.closest('button, .review-composer, .review-target-button'); + }); + } + + function unwrapReviewMarks() { + if (!markdownPreview) return; + const parents = new Set(); + markdownPreview.querySelectorAll('.review-comment-highlight, .review-pending-highlight').forEach(function(mark) { + const parent = mark.parentNode; + if (parent) parents.add(parent); + mark.replaceWith(document.createTextNode(mark.textContent || '')); + }); + parents.forEach(function(parent) { parent.normalize(); }); + } + + function clearReviewDecorations() { + if (!markdownPreview) return; + unwrapReviewMarks(); + markdownPreview.querySelectorAll('.review-comment-element, .review-pending-element').forEach(function(element) { + element.classList.remove( + 'review-comment-element', + 'review-pending-element', + 'is-review-highlight-active', + 'is-review-highlight-hover', + 'is-review-highlight-emphasized' + ); + delete element.dataset.reviewIds; + }); + markdownPreview.querySelectorAll('[data-review-anchor]').forEach(function(target) { + target.classList.remove( + 'review-target', + 'review-target--heading', + 'review-target--paragraph', + 'review-target--list-item', + 'review-target--quote', + 'review-target--code', + 'review-target--table', + 'review-target--diagram', + 'review-target--image', + 'review-target--media', + 'review-target--math', + 'review-element-selectable', + 'is-review-element-selected', + 'has-review-native-text-selection', + '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; + delete target.dataset.reviewIds; + }); + } + + function findReviewTarget(anchor) { + const anchorKey = typeof anchor === 'string' ? anchor : (anchor && anchor.key); + if (!markdownPreview || !anchorKey) return null; + let target = Array.from(markdownPreview.querySelectorAll('[data-review-anchor]')).find(function(candidate) { + if (candidate.dataset.reviewAnchor !== anchorKey) return false; + if (!anchor || typeof anchor === 'string') return true; + if (anchor.duplicateCount && Number(candidate.dataset.reviewDuplicateCount) !== Number(anchor.duplicateCount)) return false; + if (anchor.context && candidate.dataset.reviewContext !== anchor.context) return false; + return true; + }) || null; + if (target || !anchor || typeof anchor === 'string') return target; + + const selection = normalizeReviewSelection(anchor.selection); + const quote = selection && selection.quote; + if (quote) { + const matches = getReviewTargets().filter(function(candidate) { + if (anchor.type && candidate.dataset.reviewType !== anchor.type) return false; + return (candidate.textContent || '').includes(quote); + }); + if (matches.length === 1) return matches[0]; + target = matches.find(function(candidate) { + const text = candidate.textContent || ''; + const index = text.indexOf(quote); + const prefix = text.slice(Math.max(0, index - (selection.prefix || '').length), index); + const suffix = text.slice(index + quote.length, index + quote.length + (selection.suffix || '').length); + return (!selection.prefix || prefix.endsWith(selection.prefix)) && + (!selection.suffix || suffix.startsWith(selection.suffix)); + }) || null; + if (target) return target; + } + + const excerpt = normalizeReviewSource(anchor.excerpt || '', false); + if (!excerpt) return null; + const sourceMatches = getReviewTargets().filter(function(candidate) { + if (anchor.type && candidate.dataset.reviewType !== anchor.type) return false; + const source = getReviewTargetSource(candidate, candidate.dataset.reviewType); + return source === excerpt || source.startsWith(excerpt.replace(/\.\.\.$/, '')); + }); + return sourceMatches.length === 1 ? sourceMatches[0] : null; + } + + function addReviewId(element, reviewId) { + if (!element || !reviewId) return; + const ids = new Set(String(element.dataset.reviewIds || '').split(/\s+/).filter(Boolean)); + ids.add(reviewId); + element.dataset.reviewIds = Array.from(ids).join(' '); + } + + function resolveTextSelectionOffsets(target, selection) { + if (!target || !selection || selection.kind !== 'text') return null; + const text = target.textContent || ''; + let start = Math.max(0, Math.min(text.length, Number(selection.start) || 0)); + let end = Math.max(start, Math.min(text.length, Number(selection.end) || start)); + if (text.slice(start, end) === selection.quote) return { start: start, end: end }; + const matches = []; + let cursor = 0; + while (selection.quote && cursor <= text.length) { + const index = text.indexOf(selection.quote, cursor); + if (index < 0) break; + matches.push(index); + cursor = index + Math.max(1, selection.quote.length); + } + if (matches.length === 0) return null; + let best = matches[0]; + let bestScore = -1; + matches.forEach(function(index) { + let score = 0; + const prefix = text.slice(Math.max(0, index - (selection.prefix || '').length), index); + const suffix = text.slice(index + selection.quote.length, index + selection.quote.length + (selection.suffix || '').length); + if (selection.prefix && prefix.endsWith(selection.prefix)) score += 2; + if (selection.suffix && suffix.startsWith(selection.suffix)) score += 2; + score -= Math.abs(index - start) / Math.max(1, text.length); + if (score > bestScore) { + bestScore = score; + best = index; + } + }); + return { start: best, end: best + selection.quote.length }; + } + + function applyTextReviewHighlights(target, entries) { + if (!target || !entries || entries.length === 0) return; + const textNodes = []; + const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + const parent = node.parentElement; + if (!node.nodeValue || !parent || parent.closest('script, style, button, textarea')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + let node; + let offset = 0; + while ((node = walker.nextNode())) { + textNodes.push({ node: node, start: offset, end: offset + node.nodeValue.length }); + offset += node.nodeValue.length; + } + + textNodes.forEach(function(entry) { + const boundaries = new Set([0, entry.node.nodeValue.length]); + entries.forEach(function(highlight) { + const start = Math.max(entry.start, highlight.start); + const end = Math.min(entry.end, highlight.end); + if (end > start) { + boundaries.add(start - entry.start); + boundaries.add(end - entry.start); + } + }); + const cuts = Array.from(boundaries).sort(function(a, b) { return a - b; }); + if (cuts.length <= 2) return; + const fragment = document.createDocumentFragment(); + for (let index = 0; index < cuts.length - 1; index += 1) { + const localStart = cuts[index]; + const localEnd = cuts[index + 1]; + const globalStart = entry.start + localStart; + const globalEnd = entry.start + localEnd; + const covering = entries.filter(function(highlight) { + return highlight.start < globalEnd && highlight.end > globalStart; + }); + const text = entry.node.nodeValue.slice(localStart, localEnd); + if (covering.length === 0) { + fragment.appendChild(document.createTextNode(text)); + continue; + } + const mark = document.createElement('mark'); + mark.className = covering.some(function(item) { return item.pending; }) + ? 'review-pending-highlight' + : 'review-comment-highlight'; + if (covering.some(function(item) { return item.pending && item.start === globalStart; })) { + mark.classList.add('review-selection-start'); + } + if (covering.some(function(item) { return item.pending && item.end === globalEnd; })) { + mark.classList.add('review-selection-end'); + } + const startIds = covering.filter(function(item) { + return !item.pending && item.start === globalStart; + }).map(function(item) { return item.id; }); + const endIds = covering.filter(function(item) { + return !item.pending && item.end === globalEnd; + }).map(function(item) { return item.id; }); + if (startIds.length) mark.dataset.reviewStartIds = startIds.join(' '); + if (endIds.length) mark.dataset.reviewEndIds = endIds.join(' '); + covering.filter(function(item) { return !item.pending; }).forEach(function(item) { + addReviewId(mark, item.id); + }); + mark.textContent = text; + fragment.appendChild(mark); + } + entry.node.replaceWith(fragment); + }); + } + + function decorateReviewTargets() { + clearReviewDecorations(); + if (!reviewModeActive || !markdownPreview || previewContainsSkeleton()) { + updateReviewNewButton(); + return; + } + + 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 occurrence = occurrences.get(entry.signature) || 0; + occurrences.set(entry.signature, occurrence + 1); + const duplicateCount = signatureCounts.get(entry.signature) || 1; + const context = duplicateCount > 1 ? entry.context : ''; + entry.target.dataset.reviewAnchor = entry.type + ':' + hashReviewSource(entry.signature) + ':' + occurrence; + entry.target.dataset.reviewType = entry.type; + entry.target.dataset.reviewLabel = getReviewTargetLabel(entry.target, entry.type); + entry.target.dataset.reviewDuplicateCount = String(duplicateCount); + entry.target.dataset.reviewContext = context; + entry.target.classList.add('review-target', 'review-target--' + entry.type); + if (REVIEW_ELEMENT_TYPES.has(entry.type)) entry.target.classList.add('review-element-selectable'); + }); + + const textHighlights = new Map(); + getActiveReviewThreads().forEach(function(thread) { + const target = findReviewTarget(thread.anchor); + if (!target) return; + const selection = normalizeReviewSelection(thread.anchor.selection); + target.classList.add('has-review-thread'); + if (selection && selection.kind === 'text') { + const offsets = resolveTextSelectionOffsets(target, selection); + if (!offsets) return; + if (!textHighlights.has(target)) textHighlights.set(target, []); + textHighlights.get(target).push({ id: thread.id, start: offsets.start, end: offsets.end, pending: false }); + return; + } + target.classList.add('review-comment-element'); + addReviewId(target, thread.id); + }); + + const pendingAnchor = reviewComposer && !reviewComposer.hidden && !activeReviewEditId + ? activeReviewAnchor + : pendingReviewSelection; + if (pendingAnchor) { + const pendingTarget = findReviewTarget(pendingAnchor); + const pendingSelection = normalizeReviewSelection(pendingAnchor.selection); + if (pendingTarget && (!pendingSelection || pendingSelection.kind !== 'text')) { + pendingTarget.classList.add('review-pending-element'); + } + } + + textHighlights.forEach(function(entries, target) { + applyTextReviewHighlights(target, entries); + }); + syncReviewInteractionState(); + updateReviewNewButton(); + } + + function elementHasReviewId(element, reviewId) { + return Boolean(element && reviewId && String(element.dataset.reviewIds || '').split(/\s+/).includes(reviewId)); + } + + function getReviewDocumentNodes(reviewId) { + if (!markdownPreview || !reviewId) return []; + return Array.from(markdownPreview.querySelectorAll('[data-review-ids]')).filter(function(element) { + return elementHasReviewId(element, reviewId); + }); + } + + function syncReviewInteractionState() { + const emphasizedReviewId = hoveredReviewId || activeReviewId; + if (markdownPreview) { + markdownPreview.querySelectorAll('[data-review-ids]').forEach(function(element) { + const isActive = elementHasReviewId(element, activeReviewId); + const isHovered = elementHasReviewId(element, hoveredReviewId); + const isEmphasized = elementHasReviewId(element, emphasizedReviewId); + element.classList.toggle('is-review-highlight-active', isActive); + element.classList.toggle('is-review-highlight-hover', !isActive && isHovered); + element.classList.toggle('is-review-highlight-emphasized', isEmphasized); + element.classList.toggle( + 'is-review-selection-start', + isActive && String(element.dataset.reviewStartIds || '').split(/\s+/).includes(activeReviewId) + ); + element.classList.toggle( + 'is-review-selection-end', + isActive && String(element.dataset.reviewEndIds || '').split(/\s+/).includes(activeReviewId) + ); + }); + const pendingElement = pendingReviewSelection && + pendingReviewSelection.selection && + pendingReviewSelection.selection.kind !== 'text' + ? findReviewTarget(pendingReviewSelection) + : null; + const pendingTextTarget = pendingReviewSelection && + pendingReviewSelection.selection && + pendingReviewSelection.selection.kind === 'text' + ? findReviewTarget(pendingReviewSelection) + : null; + markdownPreview.querySelectorAll('.review-element-selectable').forEach(function(element) { + element.classList.toggle('is-review-element-selected', element === pendingElement); + element.classList.toggle('has-review-native-text-selection', element === pendingTextTarget); + }); + } + if (reviewList) { + reviewList.querySelectorAll('.review-thread').forEach(function(item) { + const isActive = item.dataset.reviewId === activeReviewId; + const isHovered = item.dataset.reviewId === hoveredReviewId; + const isEmphasized = item.dataset.reviewId === emphasizedReviewId; + item.classList.toggle('is-review-thread-active', isActive); + item.classList.toggle('is-review-thread-hover', !isActive && isHovered); + item.classList.toggle('is-review-thread-emphasized', isEmphasized); + item.setAttribute('aria-expanded', isActive ? 'true' : 'false'); + }); + } + } + + function setReviewHover(reviewId) { + hoveredReviewId = reviewId || null; + syncReviewInteractionState(); + } + + function activateReviewThread(thread, options) { + if (!thread) return; + options = options || {}; + activeReviewId = thread.id; + pendingReviewSelection = null; + if (!activeReviewEditId) closeReviewComposer({ keepSelection: false }); + if (reviewFilter === 'resolved' && !thread.resolved) setReviewFilter('all'); + if (reviewFilter === 'open' && thread.resolved) setReviewFilter('all'); + syncReviewInteractionState(); + + const card = reviewList && reviewList.querySelector('.review-thread[data-review-id="' + thread.id + '"]'); + if (options.scrollPanel !== false && card) { + card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (options.focusCard) card.focus({ preventScroll: true }); + } + const nodes = getReviewDocumentNodes(thread.id); + const documentNode = nodes.find(function(node) { + return node.classList.contains('review-comment-highlight') || node.classList.contains('review-comment-element'); + }) || nodes[0] || findReviewTarget(thread.anchor); + if (options.scrollDocument && documentNode) { + documentNode.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + } + announceToScreenReader('Comment by ' + (thread.author || getCurrentReviewAuthor()) + ' selected.'); + } + + 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 || count > 0); + if (mobileReviewStatus) mobileReviewStatus.textContent = reviewModeActive ? 'Open' : 'Off'; + } + + function updateReviewCountBadges() { + const openCount = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length; + const countLabel = openCount + ' open comment' + (openCount === 1 ? '' : 's'); + if (reviewToolbarCount) { + reviewToolbarCount.textContent = String(openCount); + reviewToolbarCount.hidden = openCount === 0; + } + if (reviewToggle) { + reviewToggle.setAttribute('aria-label', openCount > 0 ? 'Open comments. ' + countLabel + '.' : 'Open comments'); + reviewToggle.title = openCount > 0 ? 'Open comments (' + countLabel + ')' : 'Open comments'; + } + updateReviewToggleActiveState(openCount); + } + + function createReviewThreadAction(action, iconClass, label, danger, reviewId) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'review-thread-action' + (danger ? ' is-danger' : ''); + button.dataset.reviewAction = action; + button.dataset.reviewId = reviewId; + button.title = label; + button.setAttribute('aria-label', label); + const icon = document.createElement('i'); + icon.className = iconClass; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + return button; + } + + function createReviewReplyAction(action, iconClass, label, danger, reviewId, replyId) { + const button = createReviewThreadAction(action, iconClass, label, danger, reviewId); + button.classList.add('review-reply-action'); + button.dataset.reviewReplyId = replyId; + return button; + } + + function createReviewReplyElement(reply, thread) { + const author = reply.author || 'Author'; + const item = document.createElement('div'); + item.className = 'review-reply'; + item.dataset.reviewReplyId = reply.id; + + const header = document.createElement('div'); + header.className = 'review-reply-header'; + const avatar = document.createElement('span'); + avatar.className = 'review-author-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = getReviewAvatarLabel(author); + const name = document.createElement('strong'); + name.className = 'review-author-name'; + name.textContent = author; + const timestamp = document.createElement('time'); + timestamp.className = 'review-reply-time'; + timestamp.dateTime = new Date(reply.createdAt).toISOString(); + timestamp.textContent = formatReviewTime(reply.createdAt); + const authorMeta = document.createElement('span'); + authorMeta.className = 'review-author-meta'; + authorMeta.appendChild(name); + authorMeta.appendChild(timestamp); + header.appendChild(avatar); + header.appendChild(authorMeta); + + const actions = document.createElement('div'); + actions.className = 'review-reply-actions'; + actions.setAttribute('role', 'group'); + actions.setAttribute('aria-label', 'Reply actions'); + actions.appendChild(createReviewReplyAction( + 'edit-reply', + 'lucide lucide-square-pen', + 'Edit reply', + false, + thread.id, + reply.id + )); + actions.appendChild(createReviewReplyAction( + 'delete-reply', + 'lucide lucide-trash-2', + 'Delete reply', + true, + thread.id, + reply.id + )); + header.appendChild(actions); + item.appendChild(header); + + const isEditing = activeReviewReplyEdit && + activeReviewReplyEdit.threadId === thread.id && + activeReviewReplyEdit.replyId === reply.id; + if (isEditing) { + item.classList.add('is-editing'); + const form = document.createElement('form'); + form.className = 'review-reply-edit-form'; + form.dataset.reviewId = thread.id; + form.dataset.reviewReplyId = reply.id; + const input = document.createElement('textarea'); + input.className = 'review-reply-edit-input'; + input.maxLength = REVIEW_TEXT_LIMIT; + input.rows = 2; + input.value = reply.body; + input.setAttribute('aria-label', 'Edit reply'); + const controls = document.createElement('div'); + controls.className = 'review-reply-edit-actions'; + controls.appendChild(createReviewReplyAction( + 'save-reply', + 'lucide lucide-check', + 'Save reply', + false, + thread.id, + reply.id + )); + controls.appendChild(createReviewReplyAction( + 'cancel-reply-edit', + 'lucide lucide-x', + 'Cancel reply edit', + false, + thread.id, + reply.id + )); + form.appendChild(input); + form.appendChild(controls); + item.appendChild(form); + } else { + const body = document.createElement('p'); + body.className = 'review-reply-body'; + body.textContent = reply.body; + item.appendChild(body); + } + return item; + } + + function createReviewConversation(thread) { + const conversation = document.createElement('section'); + conversation.className = 'review-thread-conversation'; + conversation.setAttribute('aria-label', 'Replies'); + + const replies = document.createElement('div'); + replies.className = 'review-replies'; + const threadReplies = thread.replies || []; + const isCollapsible = threadReplies.length > REVIEW_REPLY_COLLAPSE_THRESHOLD; + const isExpanded = isCollapsible && expandedReviewThreadIds.has(thread.id); + threadReplies.forEach(function(reply, index) { + const isMiddleReply = isCollapsible && index > 0 && index < threadReplies.length - 1; + if (!isExpanded && isMiddleReply) { + if (index !== 1) return; + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'review-replies-more'; + more.dataset.reviewAction = 'toggle-replies'; + more.dataset.reviewId = thread.id; + more.setAttribute('aria-expanded', 'false'); + more.textContent = 'Show ' + (threadReplies.length - 2) + ' more replies'; + replies.appendChild(more); + return; + } + replies.appendChild(createReviewReplyElement(reply, thread)); + }); + conversation.appendChild(replies); + + if (!thread.resolved) { + const form = document.createElement('form'); + form.className = 'review-reply-composer'; + form.dataset.reviewId = thread.id; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'review-reply-input'; + input.maxLength = REVIEW_TEXT_LIMIT; + input.placeholder = 'Reply'; + input.setAttribute('aria-label', 'Reply to comment'); + const submit = document.createElement('button'); + submit.type = 'submit'; + submit.className = 'review-reply-submit'; + submit.dataset.reviewAction = 'submit-reply'; + submit.dataset.reviewId = thread.id; + submit.title = 'Post reply'; + submit.setAttribute('aria-label', 'Post reply'); + const icon = document.createElement('i'); + icon.className = 'lucide lucide-check'; + icon.setAttribute('aria-hidden', 'true'); + submit.appendChild(icon); + form.appendChild(input); + form.appendChild(submit); + conversation.appendChild(form); + } + return conversation; + } + + function createReviewThreadElement(thread) { + const target = findReviewTarget(thread.anchor); + const author = thread.author || 'Author'; + const isRepliesCollapsible = (thread.replies || []).length > REVIEW_REPLY_COLLAPSE_THRESHOLD; + const isRepliesExpanded = isRepliesCollapsible && expandedReviewThreadIds.has(thread.id); + const item = document.createElement('article'); + item.className = 'review-thread' + + (thread.resolved ? ' is-resolved' : '') + + (!target ? ' is-orphaned' : '') + + (isRepliesCollapsible ? ' has-collapsible-replies' : '') + + (isRepliesExpanded ? ' has-expanded-replies' : ''); + item.dataset.reviewId = thread.id; + item.dataset.kind = thread.kind; + item.tabIndex = 0; + item.setAttribute('role', 'group'); + item.setAttribute('aria-expanded', thread.id === activeReviewId ? 'true' : 'false'); + item.setAttribute('aria-label', 'Comment by ' + author + ': ' + thread.body); + + const header = document.createElement('div'); + header.className = 'review-thread-header'; + const authorRow = document.createElement('div'); + authorRow.className = 'review-thread-author'; + const avatar = document.createElement('span'); + avatar.className = 'review-author-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = getReviewAvatarLabel(author); + const name = document.createElement('strong'); + name.className = 'review-author-name'; + name.textContent = author; + const dates = document.createElement('span'); + dates.className = 'review-thread-dates'; + const opened = document.createElement('time'); + opened.className = 'review-thread-time'; + opened.dateTime = new Date(thread.createdAt).toISOString(); + opened.textContent = formatReviewTime(thread.createdAt); + opened.title = 'Opened ' + formatReviewTime(thread.createdAt); + opened.setAttribute('aria-label', opened.title); + dates.appendChild(opened); + if (thread.resolvedAt) { + const closed = document.createElement('time'); + closed.className = 'review-thread-time is-closed'; + closed.dateTime = new Date(thread.resolvedAt).toISOString(); + closed.textContent = 'Closed: ' + formatReviewTime(thread.resolvedAt); + dates.appendChild(closed); + } + const authorMeta = document.createElement('span'); + authorMeta.className = 'review-author-meta'; + authorMeta.appendChild(name); + authorMeta.appendChild(dates); + authorRow.appendChild(avatar); + authorRow.appendChild(authorMeta); + if (thread.resolved) { + const resolved = document.createElement('span'); + resolved.className = 'review-status-label'; + resolved.title = 'Resolved'; + resolved.setAttribute('aria-label', 'Resolved'); + const icon = document.createElement('i'); + icon.className = 'lucide lucide-check'; + icon.setAttribute('aria-hidden', 'true'); + resolved.appendChild(icon); + authorRow.appendChild(resolved); + } + header.appendChild(authorRow); + + const actions = document.createElement('div'); + actions.className = 'review-thread-actions'; + actions.setAttribute('role', 'group'); + actions.setAttribute('aria-label', 'Comment actions'); + actions.appendChild(createReviewThreadAction('edit', 'lucide lucide-square-pen', 'Edit comment', false, thread.id)); + if (isRepliesCollapsible) { + const toggleReplies = createReviewThreadAction( + 'toggle-replies', + isRepliesExpanded ? 'lucide lucide-fold-vertical' : 'lucide lucide-unfold-vertical', + isRepliesExpanded ? 'Collapse replies' : 'Expand replies', + false, + thread.id + ); + toggleReplies.setAttribute('aria-expanded', isRepliesExpanded ? 'true' : 'false'); + actions.appendChild(toggleReplies); + } + actions.appendChild(createReviewThreadAction( + 'toggle-resolved', + thread.resolved ? 'lucide lucide-refresh-cw' : 'lucide lucide-check', + thread.resolved ? 'Reopen comment' : 'Resolve comment', + false, + thread.id + )); + actions.appendChild(createReviewThreadAction('delete', 'lucide lucide-trash-2', 'Delete comment', true, thread.id)); + header.appendChild(actions); + item.appendChild(header); + + const body = document.createElement('p'); + body.className = 'review-thread-body'; + body.textContent = thread.body; + item.appendChild(body); + item.appendChild(createReviewConversation(thread)); + return item; + } + + function renderReviewPanel() { + updateReviewCountBadges(); + if (!reviewPanel || !reviewList || !reviewEmptyState) return; + const threads = getActiveReviewThreads(); + if (reviewPanelSummary) { + reviewPanelSummary.textContent = threads.length + ' comment' + (threads.length === 1 ? '' : 's'); + } + 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 || (reviewComposer && !reviewComposer.hidden); + if (filteredThreads.length === 0) { + const title = reviewEmptyState.querySelector('h3'); + const copy = reviewEmptyState.querySelector('p'); + if (reviewFilter === 'resolved') { + title.textContent = 'No resolved comments'; + copy.textContent = 'Resolved comments will appear here.'; + } else if (reviewFilter === 'all') { + title.textContent = 'No comments yet'; + copy.textContent = 'Select content in the document, then choose New.'; + } else { + title.textContent = threads.length ? 'No open comments' : 'No comments yet'; + copy.textContent = threads.length + ? 'All comments are resolved. Use All to review them.' + : 'Select content in the document, then choose New.'; + } + } + syncReviewInteractionState(); + updateReviewNewButton(); + } + + 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) { + const target = findReviewTarget(anchor); + if (!target) return []; + return getActiveReviewThreads().filter(function(thread) { + return findReviewTarget(thread.anchor) === target; + }); + } + + function showReviewThreadsForAnchor(anchor) { + const threads = getReviewThreadsForAnchor(anchor).sort(function(a, b) { return b.createdAt - a.createdAt; }); + if (!threads.length) return false; + activateReviewThread(threads[0], { scrollPanel: true, focusCard: true }); + return true; + } + + function updateReviewNewButton() { + if (!reviewNewComment) return; + const hasSelection = Boolean(pendingReviewSelection && findReviewTarget(pendingReviewSelection)); + const composerOpen = Boolean(reviewComposer && !reviewComposer.hidden); + reviewNewComment.disabled = !reviewModeActive || !hasSelection || composerOpen; + reviewNewComment.title = hasSelection + ? 'Create a comment for the selected content' + : 'Select document content first'; + reviewNewComment.setAttribute('aria-label', hasSelection + ? 'New comment for selected content' + : 'New comment. Select document content first.'); + } + + function buildTextReviewSelection(target, range) { + if (!target || !range || range.collapsed) return null; + const before = document.createRange(); + before.selectNodeContents(target); + before.setEnd(range.startContainer, range.startOffset); + const through = document.createRange(); + through.selectNodeContents(target); + through.setEnd(range.endContainer, range.endOffset); + let start = before.toString().length; + let end = through.toString().length; + const rawQuote = range.toString(); + const leading = (rawQuote.match(/^\s*/) || [''])[0].length; + const trailing = (rawQuote.match(/\s*$/) || [''])[0].length; + start += leading; + end -= trailing; + if (end <= start) return null; + const text = target.textContent || ''; + const quote = text.slice(start, end) || rawQuote.trim(); + if (!quote) return null; + return { + kind: 'text', + start: start, + end: end, + quote: quote.slice(0, 1000), + prefix: text.slice(Math.max(0, start - 48), start), + suffix: text.slice(end, end + 48) + }; + } + + function getReviewNativeTextRange(anchor) { + const selection = anchor && normalizeReviewSelection(anchor.selection); + const target = selection && selection.kind === 'text' ? findReviewTarget(anchor) : null; + const offsets = target && resolveTextSelectionOffsets(target, selection); + if (!target || !offsets) return null; + const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + const parent = node.parentElement; + if (!node.nodeValue || !parent || parent.closest('script, style, button, textarea')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + let node; + let cursor = 0; + let startBoundary = null; + let endBoundary = null; + while ((node = walker.nextNode())) { + const nextCursor = cursor + node.nodeValue.length; + if (!startBoundary && offsets.start >= cursor && offsets.start <= nextCursor) { + startBoundary = { node: node, offset: offsets.start - cursor }; + } + if (!endBoundary && offsets.end >= cursor && offsets.end <= nextCursor) { + endBoundary = { node: node, offset: offsets.end - cursor }; + } + cursor = nextCursor; + if (startBoundary && endBoundary) break; + } + if (!startBoundary || !endBoundary) return null; + const range = document.createRange(); + range.setStart(startBoundary.node, startBoundary.offset); + range.setEnd(endBoundary.node, endBoundary.offset); + return range; + } + + function clearReviewComposerSelection() { + if (window.CSS && CSS.highlights) CSS.highlights.delete('review-pending-native-selection'); + } + + function showReviewComposerSelection(anchor) { + clearReviewComposerSelection(); + const range = getReviewNativeTextRange(anchor); + if (!range) return false; + if (window.CSS && CSS.highlights && typeof window.Highlight === 'function') { + CSS.highlights.set('review-pending-native-selection', new Highlight(range)); + return true; + } + if (!window.getSelection) return false; + const nativeSelection = window.getSelection(); + nativeSelection.removeAllRanges(); + nativeSelection.addRange(range); + return true; + } + + function setPendingReviewSelection(descriptor, options) { + if (!descriptor) return; + options = options || {}; + pendingReviewSelection = descriptor; + activeReviewId = null; + hoveredReviewId = null; + const selection = normalizeReviewSelection(descriptor.selection); + if (!selection || selection.kind !== 'text') decorateReviewTargets(); + renderReviewPanel(); + if (options.announce !== false) { + announceToScreenReader('Content selected. Choose New to add a comment.'); + } + } + + function captureReviewTextSelection() { + if (!reviewModeActive || (reviewComposer && !reviewComposer.hidden)) return false; + const selection = window.getSelection && window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false; + const range = selection.getRangeAt(0); + const startElement = range.startContainer.nodeType === Node.ELEMENT_NODE + ? range.startContainer + : range.startContainer.parentElement; + const endElement = range.endContainer.nodeType === Node.ELEMENT_NODE + ? range.endContainer + : range.endContainer.parentElement; + if (!startElement || !endElement || !markdownPreview.contains(startElement) || !markdownPreview.contains(endElement)) return false; + const startTarget = startElement.closest('[data-review-anchor]'); + const endTarget = endElement.closest('[data-review-anchor]'); + if (!startTarget || startTarget !== endTarget) { + announceToScreenReader('Select content within one paragraph, heading, list item, or content block.'); + return false; + } + const textSelection = buildTextReviewSelection(startTarget, range); + if (!textSelection) return false; + const descriptor = getReviewAnchorDescriptor(startTarget, textSelection); + if (!descriptor) return false; + setPendingReviewSelection(descriptor); + return true; + } + + function selectReviewElement(target) { + if (!target || !target.dataset.reviewAnchor) return; + const type = target.dataset.reviewType || getReviewTargetType(target); + if (!REVIEW_ELEMENT_TYPES.has(type)) return; + const descriptor = getReviewAnchorDescriptor(target, { kind: 'element', elementType: type }); + if (descriptor) setPendingReviewSelection(descriptor); + } + + function reviewClickIsInsideNativeSelection(event, pendingSelection) { + const selection = window.getSelection && window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false; + if (selection.toString().trim() !== String(pendingSelection.quote || '').trim()) return false; + const pointX = Number(event.clientX); + const pointY = Number(event.clientY); + return Array.from(selection.getRangeAt(0).getClientRects()).some(function(rect) { + return pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom; + }); + } + + function focusReviewComposerInput() { + if (!reviewFeedbackInput) return; + reviewFeedbackInput.focus({ preventScroll: true }); + const cursorPosition = reviewFeedbackInput.value.length; + reviewFeedbackInput.setSelectionRange(cursorPosition, cursorPosition); + requestAnimationFrame(function() { + if (reviewComposer && !reviewComposer.hidden && reviewPanelBody) reviewPanelBody.scrollTop = 0; + }); + } + + function clearReviewNativeSelection() { + const selection = window.getSelection && window.getSelection(); + if (selection && selection.rangeCount > 0) selection.removeAllRanges(); + } + + function dismissEmptyNewReviewComposer() { + if (!reviewComposer || reviewComposer.hidden || activeReviewEditId) return false; + if (reviewFeedbackInput && reviewFeedbackInput.value.trim()) return false; + pendingReviewSelection = null; + clearReviewNativeSelection(); + closeReviewComposer({ keepSelection: false }); + announceToScreenReader('Empty comment cancelled and content selection cleared.'); + return true; + } + + function closeReviewComposer(options) { + options = options || {}; + const wasEditing = Boolean(activeReviewEditId); + clearReviewComposerSelection(); + 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 (!options.keepSelection && wasEditing) pendingReviewSelection = null; + decorateReviewTargets(); + renderReviewPanel(); + if (options.restoreFocus && reviewModeActive) { + if (reviewNewComment && !reviewNewComment.disabled) reviewNewComment.focus(); + else if (reviewPanelClose) reviewPanelClose.focus(); + } + } + + function populateReviewComposer(author, anchor, isEditing) { + if (reviewComposer) reviewComposer.setAttribute('aria-label', isEditing ? 'Edit comment' : 'New comment'); + if (reviewComposerTitle) reviewComposerTitle.textContent = isEditing ? 'Edit comment' : 'New comment'; + if (reviewComposerAuthor) reviewComposerAuthor.textContent = author; + if (reviewComposerAvatar) reviewComposerAvatar.textContent = getReviewAvatarLabel(author); + if (reviewComposerAnchor) reviewComposerAnchor.textContent = anchor.excerpt || anchor.label; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.textContent = isEditing ? 'Save' : 'Submit'; + } + + function openNewReviewComposer() { + if (!pendingReviewSelection) { + announceToScreenReader('Select document content before creating a comment.'); + return; + } + const target = findReviewTarget(pendingReviewSelection); + if (!target) { + pendingReviewSelection = null; + updateReviewNewButton(); + announceToScreenReader('The selected content is no longer available. Select it again.'); + return; + } + activeReviewEditId = null; + activeReviewAnchor = Object.assign({}, pendingReviewSelection, { + selection: pendingReviewSelection.selection ? Object.assign({}, pendingReviewSelection.selection) : null + }); + activeReviewId = null; + populateReviewComposer(getCurrentReviewAuthor(), activeReviewAnchor, false); + if (reviewComposer) reviewComposer.hidden = false; + if (reviewFeedbackInput) reviewFeedbackInput.value = ''; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true; + const selection = normalizeReviewSelection(activeReviewAnchor.selection); + if (!selection || selection.kind !== 'text') decorateReviewTargets(); + renderReviewPanel(); + focusReviewComposerInput(); + if (selection && selection.kind === 'text') { + showReviewComposerSelection(activeReviewAnchor); + clearReviewNativeSelection(); + } + } + + function openReviewComposer() { + openNewReviewComposer(); + } + + function openReviewEditor(thread) { + if (!thread || !reviewComposer || !reviewFeedbackInput) return; + clearReviewComposerSelection(); + pendingReviewSelection = null; + activeReviewEditId = thread.id; + activeReviewId = thread.id; + activeReviewAnchor = Object.assign({}, thread.anchor, { + selection: thread.anchor.selection ? Object.assign({}, thread.anchor.selection) : null + }); + populateReviewComposer(thread.author || getCurrentReviewAuthor(), activeReviewAnchor, true); + reviewComposer.hidden = false; + reviewFeedbackInput.value = thread.body; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = reviewFeedbackInput.value.length + ' / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = false; + decorateReviewTargets(); + renderReviewPanel(); + focusReviewComposerInput(); + reviewFeedbackInput.setSelectionRange(reviewFeedbackInput.value.length, reviewFeedbackInput.value.length); + announceToScreenReader('Editing comment.'); + } + + function updateReviewComposerKind() { + // Retained as a compatibility no-op for legacy callers. The v2 UI only creates comments. + } + + function focusReviewPin() { + if (reviewNewComment && !reviewNewComment.disabled) { + reviewNewComment.focus({ preventScroll: true }); + return true; + } + return false; + } + + function submitReviewFeedback() { + if (!activeReviewAnchor || !reviewFeedbackInput) return; + const body = reviewFeedbackInput.value.trim(); + if (!body) return; + if (activeReviewEditId) { + const reviewId = activeReviewEditId; + const thread = getActiveReviewThreads().find(function(item) { return item.id === reviewId; }); + if (!thread) { + closeReviewComposer(); + announceToScreenReader('This comment is no longer available.'); + return; + } + thread.body = body.slice(0, REVIEW_TEXT_LIMIT); + thread.updatedAt = Date.now(); + clearReviewComposerSelection(); + activeReviewEditId = null; + activeReviewAnchor = null; + if (reviewComposer) reviewComposer.hidden = true; + persistReviewThreads('Comment updated.'); + activateReviewThread(thread, { scrollPanel: true, focusCard: true }); + return; + } + + const now = Date.now(); + const authorIdentity = getCurrentReviewAuthorIdentity(); + const thread = { + id: createReviewId(), + anchor: Object.assign({}, activeReviewAnchor, { + selection: activeReviewAnchor.selection ? Object.assign({}, activeReviewAnchor.selection) : null + }), + kind: 'comment', + author: authorIdentity.name, + authorId: authorIdentity.id, + body: body.slice(0, REVIEW_TEXT_LIMIT), + createdAt: now, + updatedAt: now, + resolved: false, + resolvedAt: null, + replies: [] + }; + getActiveReviewThreads().push(thread); + clearReviewComposerSelection(); + pendingReviewSelection = null; + activeReviewAnchor = null; + activeReviewId = thread.id; + if (reviewComposer) reviewComposer.hidden = true; + if (reviewFeedbackInput) reviewFeedbackInput.value = ''; + persistReviewThreads('Comment added.'); + activateReviewThread(thread, { scrollPanel: true, focusCard: true }); + } + + function submitReviewReply(thread, form) { + if (!thread || thread.resolved || !form) return; + const input = form.querySelector('.review-reply-input'); + const body = input ? input.value.trim() : ''; + if (!body) { + if (input) input.focus(); + return; + } + const now = Date.now(); + const authorIdentity = getCurrentReviewAuthorIdentity(); + if (!Array.isArray(thread.replies)) thread.replies = []; + thread.replies.push({ + id: createReviewId(), + author: authorIdentity.name, + authorId: authorIdentity.id, + body: body.slice(0, REVIEW_TEXT_LIMIT), + createdAt: now, + updatedAt: now + }); + thread.updatedAt = now; + activeReviewId = thread.id; + persistReviewThreads('Reply added.'); + requestAnimationFrame(function() { + const card = reviewList && reviewList.querySelector('.review-thread[data-review-id="' + thread.id + '"]'); + const nextInput = card && card.querySelector('.review-reply-input'); + if (nextInput) nextInput.focus(); + }); + } + + function editReviewReply(thread, replyId) { + const reply = thread && Array.isArray(thread.replies) + ? thread.replies.find(function(item) { return item.id === replyId; }) + : null; + if (!reply) return; + activeReviewReplyEdit = { threadId: thread.id, replyId: reply.id }; + activeReviewId = thread.id; + renderReviewPanel(); + requestAnimationFrame(function() { + const input = reviewList && reviewList.querySelector( + '.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input' + ); + if (input) { + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + } + }); + } + + function saveReviewReply(thread, replyId, form) { + const reply = thread && Array.isArray(thread.replies) + ? thread.replies.find(function(item) { return item.id === replyId; }) + : null; + const input = form && form.querySelector('.review-reply-edit-input'); + const body = input ? input.value.trim() : ''; + if (!reply || !body) { + if (input) input.focus(); + return; + } + const now = Date.now(); + reply.body = body.slice(0, REVIEW_TEXT_LIMIT); + reply.updatedAt = now; + thread.updatedAt = now; + activeReviewReplyEdit = null; + activeReviewId = thread.id; + persistReviewThreads('Reply updated.'); + } + + function deleteReviewReply(thread, replyId) { + if (!thread || !Array.isArray(thread.replies)) return; + const replyIndex = thread.replies.findIndex(function(item) { return item.id === replyId; }); + if (replyIndex < 0) return; + thread.replies.splice(replyIndex, 1); + thread.updatedAt = Date.now(); + if (activeReviewReplyEdit && activeReviewReplyEdit.threadId === thread.id && activeReviewReplyEdit.replyId === replyId) { + activeReviewReplyEdit = null; + } + if (thread.replies.length <= REVIEW_REPLY_COLLAPSE_THRESHOLD) expandedReviewThreadIds.delete(thread.id); + activeReviewId = thread.id; + persistReviewThreads('Reply deleted.'); + } + + function focusReviewAnchor(thread) { + activateReviewThread(thread, { scrollPanel: false, scrollDocument: true }); + } + + 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 = [ + '# Comments: ' + (tab && tab.title ? tab.title : 'Markdown document'), + '', + '- Generated: ' + formatReviewTime(Date.now()), + '- Total comments: ' + threads.length, + '- Open comments: ' + (threads.length - resolvedCount), + '- Resolved comments: ' + resolvedCount, + '' + ]; + threads.forEach(function(thread, index) { + lines.push((index + 1) + '. **' + (thread.author || getCurrentReviewAuthor()) + '**'); + lines.push(' - Status: ' + (thread.resolved ? 'Resolved' : 'Open')); + lines.push(' - Opened: ' + formatReviewTime(thread.createdAt)); + if (thread.resolvedAt) lines.push(' - Closed: ' + formatReviewTime(thread.resolvedAt)); + if (thread.anchor.excerpt) lines.push(' > ' + thread.anchor.excerpt.replace(/\n/g, ' ')); + thread.body.split('\n').forEach(function(line) { lines.push(' ' + line); }); + (thread.replies || []).forEach(function(reply) { + lines.push(' - Reply by **' + (reply.author || 'Author') + '** (' + formatReviewTime(reply.createdAt) + ')'); + reply.body.split('\n').forEach(function(line) { lines.push(' ' + line); }); + }); + lines.push(''); + }); + return lines.join('\n').trim(); } function requestReviewDeleteConfirmation(reviewId) { if (!reviewDeleteModal || !reviewDeleteCancel || !reviewDeleteConfirm) return; const threads = getActiveReviewThreads(); - const thread = reviewId - ? threads.find(function(item) { return item.id === reviewId; }) - : null; + 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?'; - } + pendingReviewDelete = { tabId: activeTabId, reviewId: reviewId || null }; + if (reviewDeleteTitle) reviewDeleteTitle.textContent = reviewId ? 'Delete comment?' : 'Delete all comments?'; 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.'; + ? 'This comment will be permanently deleted. This cannot be undone.' + : 'All ' + threads.length + ' comments in this document will be permanently deleted. This cannot be undone.'; } - reviewDeleteConfirm.textContent = reviewId ? 'Delete item' : 'Delete all'; + reviewDeleteConfirm.textContent = reviewId ? 'Delete comment' : '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; + if (reviewPanelBackdrop) reviewPanelBackdrop.hidden = true; } function setReviewMode(enabled, options) { options = options || {}; const nextState = Boolean(enabled); - const wasActive = reviewModeActive; - if (nextState && !wasActive) { - reviewPreviousViewModes.set(activeTabId, currentViewMode || 'split'); + if (nextState && isReleaseNotesActive()) { + announceToScreenReader('Release notes do not support comments.'); + return; } + const wasActive = reviewModeActive; + if (nextState && !wasActive) reviewPreviousViewModes.set(activeTabId, currentViewMode || 'split'); reviewModeActive = nextState; - if (reviewModeActive && currentViewMode !== 'preview') { - setViewMode('preview'); - } + 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; @@ -7366,7 +9110,11 @@ document.addEventListener("DOMContentLoaded", async function () { if (options.focusPanel && reviewPanelClose) reviewPanelClose.focus(); } else { cancelReviewDeleteConfirmation(); - closeReviewComposer(); + pendingReviewSelection = null; + activeReviewReplyEdit = null; + activeReviewId = null; + hoveredReviewId = null; + closeReviewComposer({ keepSelection: false }); clearReviewDecorations(); const restoreMode = reviewPreviousViewModes.get(activeTabId); reviewPreviousViewModes.clear(); @@ -7382,38 +9130,27 @@ document.addEventListener("DOMContentLoaded", async function () { } function initReviewMode() { - tabs.forEach(function(tab) { - tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); - }); + 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(false); - 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 (reviewToggle) reviewToggle.addEventListener('click', function() { + setReviewMode(!reviewModeActive, { focusPanel: !reviewModeActive }); + }); + if (mobileReviewToggle) mobileReviewToggle.addEventListener('click', function() { + closeMobileMenu(false); + 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 (reviewNewComment) reviewNewComment.addEventListener('click', openNewReviewComposer); + if (reviewComposerCancel) reviewComposerCancel.addEventListener('click', function() { + closeReviewComposer({ restoreFocus: true, keepSelection: true }); + }); if (reviewFeedbackInput) { reviewFeedbackInput.addEventListener('input', function() { const length = reviewFeedbackInput.value.length; @@ -7428,94 +9165,181 @@ document.addEventListener("DOMContentLoaded", async function () { }); } 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); - }); - } + document.querySelectorAll('[data-review-filter]').forEach(function(button) { + button.addEventListener('click', function() { setReviewFilter(button.dataset.reviewFilter); }); + }); 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(); + if (markdownPreview) { + markdownPreview.addEventListener('mouseup', function() { + requestAnimationFrame(captureReviewTextSelection); }); - }); - document.querySelectorAll('[data-review-filter]').forEach(function(button) { - button.addEventListener('click', function() { - setReviewFilter(button.dataset.reviewFilter); + markdownPreview.addEventListener('keyup', function() { + requestAnimationFrame(captureReviewTextSelection); }); - }); - - 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; + markdownPreview.addEventListener('click', function(event) { + if (!reviewModeActive || (reviewComposer && !reviewComposer.hidden)) return; + const nativeSelection = window.getSelection && window.getSelection(); + if (nativeSelection && !nativeSelection.isCollapsed && nativeSelection.rangeCount > 0) { + const range = nativeSelection.getRangeAt(0); + const startElement = range.startContainer.nodeType === Node.ELEMENT_NODE + ? range.startContainer + : range.startContainer.parentElement; + const endElement = range.endContainer.nodeType === Node.ELEMENT_NODE + ? range.endContainer + : range.endContainer.parentElement; + if (startElement && endElement && markdownPreview.contains(startElement) && markdownPreview.contains(endElement)) { + return; + } + } + const highlighted = event.target.closest('[data-review-ids]'); + if (highlighted && markdownPreview.contains(highlighted)) { + const reviewId = String(highlighted.dataset.reviewIds || '').split(/\s+/).filter(Boolean)[0]; + const thread = getActiveReviewThreads().find(function(item) { return item.id === reviewId; }); + if (thread) { + event.preventDefault(); + activateReviewThread(thread, { scrollPanel: true, focusCard: true }); + return; + } + } + const selectable = event.target.closest('[data-review-anchor]'); + const selectableType = selectable && selectable.dataset.reviewType; + if (selectable && markdownPreview.contains(selectable) && REVIEW_ELEMENT_TYPES.has(selectableType)) { + event.preventDefault(); + selectReviewElement(selectable); } - 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); + markdownPreview.addEventListener('pointerover', function(event) { + const highlighted = event.target.closest('[data-review-ids]'); + if (!highlighted || !markdownPreview.contains(highlighted)) return; + const reviewId = String(highlighted.dataset.reviewIds || '').split(/\s+/).filter(Boolean)[0]; + setReviewHover(reviewId); + }); + markdownPreview.addEventListener('pointerout', function(event) { + const from = event.target.closest('[data-review-ids]'); + const to = event.relatedTarget && event.relatedTarget.closest + ? event.relatedTarget.closest('[data-review-ids]') + : null; + if (from && from !== to) setReviewHover(null); }); } - if (previewPaneElement) { - previewPaneElement.addEventListener('scroll', scheduleReviewPinsLayout, { passive: true }); - } + document.addEventListener('click', function(event) { + if (!reviewModeActive) return; + if (reviewComposer && !reviewComposer.hidden) { + const clickedComposer = reviewComposer.contains(event.target); + const clickedNewButton = reviewNewComment && reviewNewComment.contains(event.target); + if (!clickedComposer && !clickedNewButton) dismissEmptyNewReviewComposer(); + return; + } + const selection = pendingReviewSelection && normalizeReviewSelection(pendingReviewSelection.selection); + if (!selection) return; + if (selection.kind === 'text' && reviewClickIsInsideNativeSelection(event, selection)) return; + if (selection.kind === 'element') { + const selectedTarget = findReviewTarget(pendingReviewSelection); + if (selectedTarget && selectedTarget.contains(event.target)) return; + } + pendingReviewSelection = null; + decorateReviewTargets(); + renderReviewPanel(); + announceToScreenReader('Content selection cleared.'); + }); 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; - }); + const card = event.target.closest('.review-thread'); + if (!card) return; + const thread = getActiveReviewThreads().find(function(item) { return item.id === card.dataset.reviewId; }); if (!thread) return; - const action = actionButton.dataset.reviewAction; - if (action === 'focus-anchor') { - focusReviewAnchor(thread); + if (event.target.closest('.review-reply-input, .review-reply-edit-input')) { + activeReviewId = thread.id; + syncReviewInteractionState(); return; } - if (action === 'edit') { - openReviewEditor(thread); + const actionButton = event.target.closest('[data-review-action]'); + if (!actionButton) { + activateReviewThread(thread, { scrollPanel: false, scrollDocument: true }); return; } - if (action === 'toggle-resolved') { + const action = actionButton.dataset.reviewAction; + if (action === 'focus-anchor') { + focusReviewAnchor(thread); + } else if (action === 'edit') { + openReviewEditor(thread); + } else 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') { + persistReviewThreads(thread.resolved ? 'Comment resolved.' : 'Comment reopened.'); + if ((reviewFilter === 'open' && thread.resolved) || (reviewFilter === 'resolved' && !thread.resolved)) { + activeReviewId = null; + renderReviewPanel(); + } else { + activateReviewThread(thread, { scrollPanel: true }); + } + } else if (action === 'delete') { requestReviewDeleteConfirmation(thread.id); + } else if (action === 'edit-reply') { + editReviewReply(thread, actionButton.dataset.reviewReplyId); + } else if (action === 'save-reply') { + saveReviewReply( + thread, + actionButton.dataset.reviewReplyId, + actionButton.closest('.review-reply-edit-form') + ); + } else if (action === 'cancel-reply-edit') { + activeReviewReplyEdit = null; + activeReviewId = thread.id; + renderReviewPanel(); + } else if (action === 'delete-reply') { + deleteReviewReply(thread, actionButton.dataset.reviewReplyId); + } else if (action === 'toggle-replies') { + event.preventDefault(); + if (expandedReviewThreadIds.has(thread.id)) expandedReviewThreadIds.delete(thread.id); + else expandedReviewThreadIds.add(thread.id); + activeReviewId = thread.id; + renderReviewPanel(); + requestAnimationFrame(function() { + const toggle = reviewList && reviewList.querySelector( + '.review-thread[data-review-id="' + thread.id + '"] .review-thread-actions [data-review-action="toggle-replies"]' + ); + if (toggle) toggle.focus({ preventScroll: true }); + }); + announceToScreenReader(expandedReviewThreadIds.has(thread.id) ? 'All replies shown.' : 'Middle replies hidden.'); + } else if (action === 'submit-reply') { + event.preventDefault(); + submitReviewReply(thread, actionButton.closest('.review-reply-composer')); } }); + reviewList.addEventListener('submit', function(event) { + const form = event.target.closest('.review-reply-composer'); + if (!form) return; + event.preventDefault(); + const thread = getActiveReviewThreads().find(function(item) { return item.id === form.dataset.reviewId; }); + if (thread) submitReviewReply(thread, form); + }); + reviewList.addEventListener('keydown', function(event) { + const card = event.target.closest('.review-thread'); + if (!card || event.target.closest('input, textarea, button, [data-review-action]')) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + const thread = getActiveReviewThreads().find(function(item) { return item.id === card.dataset.reviewId; }); + if (thread) activateReviewThread(thread, { scrollPanel: false, scrollDocument: true }); + }); + reviewList.addEventListener('pointerover', function(event) { + const card = event.target.closest('.review-thread'); + if (card) setReviewHover(card.dataset.reviewId); + }); + reviewList.addEventListener('pointerout', function(event) { + const from = event.target.closest('.review-thread'); + const to = event.relatedTarget && event.relatedTarget.closest + ? event.relatedTarget.closest('.review-thread') + : null; + if (from && from !== to) setReviewHover(null); + }); } window.addEventListener('resize', updateReviewBackdrop); @@ -7523,7 +9347,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (event.defaultPrevented || event.key !== 'Escape' || !reviewModeActive || activeModal) return; if (reviewComposer && !reviewComposer.hidden) { event.preventDefault(); - closeReviewComposer({ restoreFocus: true }); + closeReviewComposer({ restoreFocus: true, keepSelection: true }); } else { event.preventDefault(); setReviewMode(false, { restoreFocus: true }); @@ -7879,6 +9703,8 @@ document.addEventListener("DOMContentLoaded", async function () { } updateMissingDocumentControlState([ + copyMarkdownButton, + reviewToggle, exportDropdown, shareButton, liveShareButton, @@ -7922,6 +9748,8 @@ document.addEventListener("DOMContentLoaded", async function () { function clearActiveDocument() { activeTabId = null; selectedDocumentId = null; + document.body.classList.remove('release-notes-active'); + updateReleaseNotesActionAvailability(false); removeStorageItem(ACTIVE_TAB_KEY); closeReviewComposer(); clearReviewDecorations(); @@ -8014,33 +9842,29 @@ document.addEventListener("DOMContentLoaded", async function () { dropdown.className = 'tab-menu-dropdown'; dropdown.setAttribute('data-tab-menu-dropdown', 'true'); dropdown.setAttribute('role', 'menu'); - if (isReleaseNotesTab(tab)) { - dropdown.innerHTML = ''; - } else { - const duplicateAction = isShareSnapshotTab(tab) - ? '' - : ''; - const downloadAction = isShareSnapshotTab(tab) - ? '' - : ''; - const favoriteAction = isShareSnapshotTab(tab) - ? '' - : ''; - const isCombinedSplitTab = tab.id === activeTabId && Boolean(secondarySplitTabId); - const splitAction = isCombinedSplitTab - ? '' - : tabs.length > 1 - ? '' - : ''; - dropdown.innerHTML = - '' + - duplicateAction + - favoriteAction + - splitAction + - downloadAction + - '' + - ''; - } + const duplicateAction = isShareSnapshotTab(tab) + ? '' + : ''; + const downloadAction = isShareSnapshotTab(tab) + ? '' + : ''; + const favoriteAction = isShareSnapshotTab(tab) + ? '' + : ''; + const isCombinedSplitTab = tab.id === activeTabId && Boolean(secondarySplitTabId); + const splitAction = isCombinedSplitTab + ? '' + : tabs.length > 1 + ? '' + : ''; + dropdown.innerHTML = + '' + + duplicateAction + + favoriteAction + + splitAction + + downloadAction + + '' + + ''; menuBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -8196,7 +10020,7 @@ document.addEventListener("DOMContentLoaded", async function () { }, { id: 'all', icon: 'lucide-square-x', label: 'Close all' }]; - const actions = isReleaseNotesTab(tab) ? closeActions : [{ + const actions = isReleaseNotesTab(tab) ? closeActions.slice(0, 1) : [{ id: isCombinedSplitTab ? 'split-close' : 'split', icon: isCombinedSplitTab ? 'lucide-panel-right-close' : 'lucide-columns-2', label: isCombinedSplitTab ? 'Exit split view' : 'Open in split view', @@ -8333,7 +10157,9 @@ document.addEventListener("DOMContentLoaded", async function () { titleSpan.title = getDocumentTabHoverTitle(tab); } - const tabMenu = createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' }); + const tabMenu = isReleaseNotesTab(tab) + ? null + : createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' }); const closeButton = document.createElement('button'); closeButton.type = 'button'; @@ -8351,7 +10177,7 @@ document.addEventListener("DOMContentLoaded", async function () { item.appendChild(fileIcon); item.appendChild(titleSpan); - item.appendChild(tabMenu.button); + if (tabMenu) item.appendChild(tabMenu.button); item.appendChild(closeButton); item.addEventListener('contextmenu', function(event) { @@ -8913,6 +10739,7 @@ document.addEventListener("DOMContentLoaded", async function () { saveSecondarySplitState(); closeReviewComposer(); clearReviewDecorations(); + if (isReleaseNotesTab(tab) && reviewModeActive) setReviewMode(false); // Clear typing timeout and reset tracking for the new tab if (typingTimeout) { @@ -8923,11 +10750,14 @@ document.addEventListener("DOMContentLoaded", async function () { pendingState = null; activeTabId = tabId; + const releaseNotesActive = isReleaseNotesTab(tab); + document.body.classList.toggle('release-notes-active', releaseNotesActive); if (swapSplitPanes) secondarySplitTabId = previousActiveTabId; saveActiveTabId(activeTabId); updateNoOpenDocumentState(); - selectedDocumentId = tabId; - setSingleDocumentTreeSelection('document', tabId); + selectedDocumentId = releaseNotesActive ? null : tabId; + if (releaseNotesActive) clearDocumentTreeSelection({ announce: false }); + else setSingleDocumentTreeSelection('document', tabId); tab.lastOpenedAt = Date.now(); if (!isTemporaryDocument(tab)) saveTabsToStorage(tabs, [tab.id]); markdownEditor.value = tab.content; @@ -13399,7 +15229,7 @@ ${selector} .arrowheadPath { } if (reviewModeActive && mode !== 'preview') { mode = 'preview'; - announceToScreenReader('Review mode uses the read-only preview.'); + announceToScreenReader('Comments uses the read-only preview.'); } if (mode === currentViewMode) return; @@ -18764,6 +20594,55 @@ ${selector} .arrowheadPath { }); } + function initDropdownMenuMotion() { + if (!window.bootstrap || !window.bootstrap.Dropdown) return; + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const closeDurationMs = 120; + + document.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(function(toggle) { + if (toggle.dataset.dropdownMotionReady === 'true') return; + const dropdown = toggle.closest('.dropdown'); + const menu = dropdown && Array.from(dropdown.children).find(function(child) { + return child.classList && child.classList.contains('dropdown-menu'); + }); + if (!dropdown || !menu) return; + + toggle.dataset.dropdownMotionReady = 'true'; + const instance = window.bootstrap.Dropdown.getOrCreateInstance(toggle); + let closeTimer = null; + let allowImmediateHide = false; + + toggle.addEventListener('show.bs.dropdown', function() { + if (closeTimer !== null) window.clearTimeout(closeTimer); + closeTimer = null; + allowImmediateHide = false; + menu.classList.remove('is-closing'); + }); + + toggle.addEventListener('hide.bs.dropdown', function(event) { + if (allowImmediateHide || reducedMotion.matches) return; + event.preventDefault(); + if (closeTimer !== null) return; + + menu.classList.add('is-closing'); + closeTimer = window.setTimeout(function() { + closeTimer = null; + allowImmediateHide = true; + instance.hide(); + allowImmediateHide = false; + }, closeDurationMs); + }); + + toggle.addEventListener('hidden.bs.dropdown', function() { + if (closeTimer !== null) window.clearTimeout(closeTimer); + closeTimer = null; + allowImmediateHide = false; + menu.classList.remove('is-closing'); + }); + }); + } + // Story 1.3: Resize Divider Functions function initResizer() { if (!resizeDivider) return; @@ -19387,6 +21266,7 @@ ${selector} .arrowheadPath { initMarkdownFormatToolbar(); initToolbarDropdownPortals(); + initDropdownMenuMotion(); initFindReplaceModal(); initAppModals(); document.addEventListener('fullscreenchange', function() { @@ -21679,6 +23559,7 @@ ${selector} .arrowheadPath { }); copyMarkdownButton.addEventListener("click", async function () { + if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; try { await copyTextToClipboard(markdownEditor.value); @@ -22335,6 +24216,9 @@ ${selector} .arrowheadPath { function updateReleaseNotesActionAvailability(releaseNotesActive) { const actions = [ + toggleSyncButton, + copyMarkdownButton, + reviewToggle, exportDropdown, exportMd, exportHtml, @@ -22347,10 +24231,21 @@ ${selector} .arrowheadPath { mobileExportHtml, mobileExportPdf, mobileExportPng, + mobileToggleSyncButton, + mobileCopyMarkdownButton, + mobileReviewToggle, mobileShareButton, mobileLiveShareButton ]; + viewModeButtons.forEach(function(action) { actions.push(action); }); + mobileViewModeButtons.forEach(function(action) { actions.push(action); }); + if (markdownFormatToolbar) { + markdownFormatToolbar.querySelectorAll('button').forEach(function(action) { actions.push(action); }); + markdownFormatToolbar.classList.toggle('is-release-notes-disabled', releaseNotesActive); + markdownFormatToolbar.setAttribute('aria-disabled', releaseNotesActive ? 'true' : 'false'); + } + actions.forEach(function(action) { if (!action) return; if (releaseNotesActive) { @@ -22361,11 +24256,15 @@ ${selector} .arrowheadPath { ? action.getAttribute('tabindex') : '__missing__'; } + action.dataset.releaseNotesOriginalTitle = action.hasAttribute('title') + ? action.getAttribute('title') + : '__missing__'; } action.dataset.releaseNotesDisabled = 'true'; if ('disabled' in action) action.disabled = true; action.classList.add('disabled'); action.setAttribute('aria-disabled', 'true'); + action.setAttribute('title', 'Unavailable in Release Notes'); if (action.tagName === 'A') action.setAttribute('tabindex', '-1'); return; } @@ -22381,9 +24280,16 @@ ${selector} .arrowheadPath { const remainsDisabled = wasDisabled || hasAnotherDisabledState; delete action.dataset.releaseNotesDisabled; delete action.dataset.releaseNotesWasDisabled; + const originalTitle = action.dataset.releaseNotesOriginalTitle; + delete action.dataset.releaseNotesOriginalTitle; if ('disabled' in action) action.disabled = remainsDisabled; action.classList.toggle('disabled', remainsDisabled); action.setAttribute('aria-disabled', remainsDisabled ? 'true' : 'false'); + if (originalTitle && originalTitle !== '__missing__') { + action.setAttribute('title', originalTitle); + } else { + action.removeAttribute('title'); + } if (action.tagName === 'A') { const originalTabindex = action.dataset.releaseNotesOriginalTabindex; delete action.dataset.releaseNotesOriginalTabindex; @@ -22413,6 +24319,8 @@ ${selector} .arrowheadPath { const liveShareDocumentActive = isLiveShareDocumentActive(); const liveShareGuestDocumentActive = liveShareDocumentActive && !isLiveShareHostDocumentActive(); const viewOnly = isLiveViewOnlyParticipant() || snapshotViewOnly || releaseNotesActive; + document.body.classList.toggle('release-notes-active', releaseNotesActive); + if (!releaseNotesActive) updateReleaseNotesActionAvailability(false); const sourceReadOnly = viewOnly || reviewModeActive; if (markdownEditor) { markdownEditor.readOnly = sourceReadOnly; @@ -22531,9 +24439,10 @@ ${selector} .arrowheadPath { if (markdownFormatToolbar) { markdownFormatToolbar.classList.toggle('is-live-view-only', sourceReadOnly); - markdownFormatToolbar.querySelectorAll('[data-md-action]').forEach(function(button) { + markdownFormatToolbar.querySelectorAll('[data-md-action], [data-toolbar-menu-toggle]').forEach(function(button) { const action = button.getAttribute('data-md-action'); - const shouldDisable = sourceReadOnly && isLiveMutatingAction(action); + const isMenuToggle = button.hasAttribute('data-toolbar-menu-toggle'); + const shouldDisable = sourceReadOnly && (isMenuToggle || isLiveMutatingAction(action)); if (shouldDisable) { button.dataset.liveViewOnlyDisabled = 'true'; button.disabled = true; @@ -22550,8 +24459,9 @@ ${selector} .arrowheadPath { updateUndoRedoButtons(); } } + updateSyncToggleVisibility(currentViewMode); updateDocumentToolbarAvailability(hasActiveOpenDocument()); - updateReleaseNotesActionAvailability(releaseNotesActive); + if (releaseNotesActive) updateReleaseNotesActionAvailability(true); } function getLiveRoomSocketUrl(roomId, secret, auth) { @@ -22917,6 +24827,7 @@ ${selector} .arrowheadPath { }; liveCollaboration.localParticipant = updatedParticipant; liveCollaboration.participants.set(liveCollaboration.localParticipantId, updatedParticipant); + updateReviewAuthorIdentity(liveCollaboration.localParticipantId, safeName); if (liveCollaboration.connection) { liveCollaboration.connection.publishPresence(liveCollaboration.localCursor); } @@ -24464,6 +26375,10 @@ ${selector} .arrowheadPath { if (isCmdOrCtrl && e.shiftKey && !e.altKey && key === 'c') { e.preventDefault(); + if (isReleaseNotesActive()) { + announceToScreenReader('Release notes do not expose Markdown source.'); + return; + } const focusedEditor = document.activeElement === documentSplitEditor ? documentSplitEditor : markdownEditor; const selected = focusedEditor.value.slice(focusedEditor.selectionStart, focusedEditor.selectionEnd); copyTextToClipboard(selected || focusedEditor.value) diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index fd95dc38..a32ab5fe 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -43,6 +43,7 @@ --scrollbar-thumb: #c1c1c1; --scrollbar-track: #f1f1f1; --accent-color: #0366d6; + --review-accent: #7c3aed; --success-color: #116329; --warning-note-bg: #fff8c5; --warning-note-border: #d4a72c; @@ -104,6 +105,7 @@ --scrollbar-thumb: #484f58; --scrollbar-track: #21262d; --accent-color: #58a6ff; + --review-accent: #a78bfa; --success-color: #3fb950; --warning-note-bg: #2d220c; --warning-note-border: #9e6a03; @@ -360,11 +362,10 @@ body { cursor: pointer; } -.document-sidebar-filters { +.segmented-control { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; - margin: 0 8px 7px; padding: 3px; border: 1px solid var(--border-color); border-radius: 6px; @@ -372,6 +373,10 @@ body { box-shadow: inset 0 1px 1px rgba(31, 35, 40, 0.03); } +.document-sidebar-filters { + margin: 0 8px 7px; +} + /* Bootstrap Icons 1.11.3 GitHub mark: the only branded, non-Lucide UI icon. */ .bi { display: inline-block; @@ -394,7 +399,7 @@ body { mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktZ2l0aHViIiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGQ9Ik04IDBDMy41OCAwIDAgMy41OCAwIDhjMCAzLjU0IDIuMjkgNi41MyA1LjQ3IDcuNTkuNC4wNy41NS0uMTcuNTUtLjM4IDAtLjE5LS4wMS0uODItLjAxLTEuNDktMi4wMS4zNy0yLjUzLS40OS0yLjY5LS45NC0uMDktLjIzLS40OC0uOTQtLjgyLTEuMTMtLjI4LS4xNS0uNjgtLjUyLS4wMS0uNTMuNjMtLjAxIDEuMDguNTggMS4yMy44Mi43MiAxLjIxIDEuODcuODcgMi4zMy42Ni4wNy0uNTIuMjgtLjg3LjUxLTEuMDctMS43OC0uMi0zLjY0LS44OS0zLjY0LTMuOTUgMC0uODcuMzEtMS41OS44Mi0yLjE1LS4wOC0uMi0uMzYtMS4wMi4wOC0yLjEyIDAgMCAuNjctLjIxIDIuMi44Mi42NC0uMTggMS4zMi0uMjcgMi0uMjdzMS4zNi4wOSAyIC4yN2MxLjUzLTEuMDQgMi4yLS44MiAyLjItLjgyLjQ0IDEuMS4xNiAxLjkyLjA4IDIuMTIuNTEuNTYuODIgMS4yNy44MiAyLjE1IDAgMy4wNy0xLjg3IDMuNzUtMy42NSAzLjk1LjI5LjI1LjU0LjczLjU0IDEuNDggMCAxLjA3LS4wMSAxLjkzLS4wMSAyLjIgMCAuMjEuMTUuNDYuNTUuMzhBOC4wMSA4LjAxIDAgMCAwIDE2IDhjMC00LjQyLTMuNTgtOC04LTgiLz4KPC9zdmc+"); } -.document-filter-btn { +.segmented-control-btn { display: flex; align-items: center; justify-content: center; @@ -403,12 +408,22 @@ body { min-height: 24px; gap: 5px; padding: 3px 6px; + border: 1px solid transparent; border-radius: 4px; + background: transparent; color: var(--text-secondary); + cursor: pointer; + font: inherit; font-size: 11px; line-height: 1.2; text-align: center; white-space: nowrap; + transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.segmented-control-btn:hover:not(.is-active) { + background: var(--button-hover); + color: var(--text-color); } .document-filter-btn i { @@ -418,7 +433,7 @@ body { text-align: center; } -.document-filter-btn.is-active { +.segmented-control-btn.is-active { background: var(--bg-color); color: var(--text-color); font-weight: 500; @@ -1930,16 +1945,737 @@ body.document-sidebar-collapsed .document-sidebar { border-radius: 0; } -.markdown-body .code-preview-block > pre > code { - padding: 0; - border-radius: 0; -} +.markdown-body .code-preview-block > pre > code { + padding: 0; + border-radius: 0; +} + +@media print { + .code-preview-toolbar { + display: none; + } +} + +@keyframes review-selection-caret-blink { + 0%, 48% { + border-inline-start-color: var(--review-accent); + border-inline-end-color: var(--review-accent); + } + 49%, 100% { + border-inline-start-color: transparent; + border-inline-end-color: transparent; + } +} + +::highlight(review-pending-native-selection) { + color: HighlightText; + background-color: Highlight; +} + +/* ======================================== + COMMENTS — SELECTION-FIRST EXPERIENCE + ======================================== */ +body { +.review-panel { + width: min(344px, calc(100% - 40px)); + font-size: var(--ui-font-md); +} + +.review-panel-header { + min-height: 46px; + padding: 7px 8px 7px 12px; +} + +.review-panel-header h2 { + font-size: var(--ui-font-lg); +} + +.review-panel-toolbar { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 7px 8px; + background: var(--menu-surface); +} + +.review-new-btn, +.review-secondary-btn { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 9px; + color: var(--text-color); + background: var(--button-bg); + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); + cursor: pointer; + font: inherit; + font-size: var(--ui-font-md); + font-weight: 600; + line-height: 1; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.review-new-btn i { + font-size: var(--ui-icon-size-md); +} + +.review-new-btn:hover:not(:disabled), +.review-secondary-btn:hover:not(:disabled) { + background: var(--button-hover); + border-color: color-mix(in srgb, var(--text-secondary) 45%, var(--menu-border)); +} + +.review-new-btn:disabled { + color: var(--text-secondary); + opacity: 0.55; + cursor: not-allowed; +} + +.review-panel-summary { + margin: 0; + white-space: nowrap; +} + +.review-filter-group { + width: auto; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + justify-self: end; +} + +.review-filter-btn { + min-height: 24px; + font-size: var(--ui-font-xs); +} + +.review-composer { + order: -1; + margin: 10px 10px 4px; + border-color: var(--accent-color); + border-radius: var(--menu-item-radius); + box-shadow: 0 2px 8px color-mix(in srgb, var(--text-color) 9%, transparent); +} + +.review-composer-body { + padding: 10px 10px 8px; +} + +.review-composer-author, +.review-thread-author { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.review-thread-author { + align-items: flex-start; +} + +.review-author-meta { + min-width: 0; + display: grid; + flex: 1; + gap: 1px; +} + +.review-composer-author { + margin-bottom: 8px; +} + +.review-author-avatar { + width: 24px; + height: 24px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 11%, var(--menu-surface)); + border: 1px solid color-mix(in srgb, var(--accent-color) 24%, var(--border-color)); + border-radius: 50%; + font-size: var(--ui-font-xs); + font-weight: 700; + line-height: 1; +} + +.review-composer-author strong, +.review-author-name { + min-width: 0; + overflow: hidden; + color: var(--text-color); + font-size: var(--ui-font-md); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-anchor-preview { + margin-bottom: 8px; + padding: 5px 7px; + border-inline-start-color: color-mix(in srgb, var(--accent-color) 55%, var(--border-color)); + font-size: var(--ui-font-sm); + -webkit-line-clamp: 1; +} + +.review-feedback-input { + min-height: 76px; + padding: 8px 9px; + resize: vertical; +} + +.review-composer-footer { + padding: 7px 10px 8px; + background: var(--menu-surface); +} + +.review-composer-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.review-primary-btn, +.review-secondary-btn { + min-height: 30px; + padding: 5px 10px; +} + +.review-list { + gap: 7px; + padding: 8px 10px 14px; +} + +.review-thread { + --review-thread-surface: var(--menu-surface); + padding: 9px 10px 10px; + overflow: visible; + background: var(--review-thread-surface); + border-color: var(--border-color); + border-radius: var(--menu-item-radius); + box-shadow: none; + cursor: pointer; + transition: border-color 160ms ease; +} + +.review-thread.is-resolved { + --review-thread-surface: color-mix(in srgb, var(--button-bg) 52%, var(--menu-surface)); +} + +.review-thread-header { + min-height: 24px; + padding: 0; + padding-inline-end: 88px; + background: transparent; + border: 0; +} + +.review-thread.has-collapsible-replies .review-thread-header { + padding-inline-end: 116px; +} + +.review-thread .review-author-avatar { + width: 22px; + height: 22px; + flex-basis: 22px; +} + +.review-thread-body { + margin: 7px 0 0; + margin-inline-start: 29px; + font-size: var(--ui-font-md); + line-height: 1.45; +} + +.review-thread-actions { + position: absolute; + z-index: 1; + inset-block-start: 5px; + inset-inline-end: 5px; + width: auto; + min-width: 0; + height: 30px; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + gap: 2px; + padding: 2px; + opacity: 0; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); + transition: opacity 140ms ease; +} + +.review-thread-action { + width: 24px; + min-width: 24px; + height: 24px; + min-height: 24px; + flex: 0 0 24px; + padding: 0; +} + +.review-thread:hover .review-thread-actions, +.review-thread:focus-within .review-thread-actions, +.review-thread.is-review-thread-active .review-thread-actions { + opacity: 1; +} + +.review-thread-dates { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + border: 0; +} + +.review-thread-time { + font-size: var(--ui-font-xs); + line-height: 1.25; +} + +.review-thread.is-review-thread-emphasized { + border-color: var(--accent-color); + background: var(--review-thread-surface); + box-shadow: none; +} + +.review-thread:hover, +.review-thread.is-review-thread-active { + background: var(--review-thread-surface); + box-shadow: none; +} + +.review-thread-conversation { + display: none; + margin: 10px 0 0; + padding-top: 9px; + border-top: 1px solid var(--border-color); +} + +.review-thread.is-review-thread-active .review-thread-conversation { + display: block; +} + +.review-replies { + display: grid; + gap: 11px; + margin-inline-start: 18px; +} + +.review-reply { + position: relative; + min-width: 0; +} + +.review-reply-header { + position: relative; + display: flex; + align-items: flex-start; + gap: 7px; + min-height: 26px; + padding-inline-end: 58px; +} + +.review-reply-actions { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px; + opacity: 0; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); + transition: opacity 140ms ease; +} + +.review-reply:hover .review-reply-actions, +.review-reply:focus-within .review-reply-actions { + opacity: 1; +} + +.review-reply-action { + width: 22px; + min-width: 22px; + height: 22px; + min-height: 22px; + flex-basis: 22px; +} + +.review-reply .review-author-avatar { + width: 22px; + height: 22px; + flex-basis: 22px; +} + +.review-reply-body { + margin: 5px 0 0; + margin-inline-start: 29px; + color: var(--text-color); + font-size: var(--ui-font-md); + line-height: 1.45; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.review-reply-time { + display: block; + margin: 0; + color: var(--text-secondary); + font-size: var(--ui-font-xs); + line-height: 1.25; +} + +.review-reply-edit-form { + display: flex; + align-items: flex-end; + gap: 5px; + margin: 5px 0 0; + margin-inline-start: 29px; +} + +.review-reply-edit-input { + min-width: 0; + min-height: 54px; + flex: 1; + padding: 5px 7px; + resize: vertical; + color: var(--text-color); + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); + font: inherit; + font-size: var(--ui-font-md); + line-height: 1.4; +} + +.review-reply-edit-input:focus { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 22%, transparent); + outline-offset: 0; +} + +.review-reply-edit-actions { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); +} + +.review-replies-more { + justify-self: start; + margin-inline-start: 29px; + padding: 2px 0; + color: var(--review-accent); + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + font-size: var(--ui-font-sm); + font-weight: 650; +} + +.review-replies-more:hover, +.review-replies-more:focus-visible { + color: color-mix(in srgb, var(--review-accent) 82%, var(--text-color)); + text-decoration: underline; + outline: none; +} + +.review-reply-composer { + display: flex; + align-items: center; + gap: 6px; + margin: 11px 0 0; + margin-inline-start: 18px; +} + +.review-reply-input { + min-width: 0; + min-height: 30px; + flex: 1; + padding: 5px 8px; + color: var(--text-color); + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); + font: inherit; + font-size: var(--ui-font-md); +} + +.review-reply-input:focus { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 22%, transparent); + outline-offset: 0; +} + +.review-reply-submit { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #ffffff; + background: var(--accent-color); + border: 1px solid var(--accent-color); + border-radius: var(--menu-item-radius); + cursor: pointer; + font-size: var(--ui-icon-size-md); +} + +.review-reply-submit:hover { + filter: brightness(0.94); +} + +.review-thread.is-resolved { + opacity: 0.78; +} + +.review-status-label { + width: 18px; + height: 18px; + justify-content: center; + padding: 0; + border: 0; + border-radius: 50%; +} + +.review-empty-state { + padding: 34px 24px; +} + +.review-empty-state i { + width: 36px; + height: 36px; + margin-bottom: 10px; + font-size: var(--ui-icon-size-lg); +} + +#markdown-preview .review-target, +#markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table), +#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: 0; + padding-inline-end: 0; + border-radius: 4px; +} + +#markdown-preview .review-target--table, +#markdown-preview .review-target--table.has-review-thread { + max-width: 100%; +} + +#markdown-preview .review-target--diagram { + padding-block-end: 0; +} + +#markdown-preview .review-target.has-review-thread, +#markdown-preview .review-target.is-review-target-active, +[dir="rtl"] #markdown-preview .review-target.has-review-thread, +[dir="rtl"] #markdown-preview .review-target.is-review-target-active { + box-shadow: none; +} + +#markdown-preview .review-comment-highlight, +#markdown-preview .review-pending-highlight { + padding: 0; + color: inherit; + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +#markdown-preview .review-comment-highlight { + background: color-mix(in srgb, var(--accent-color) 22%, transparent); + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent-color) 60%, transparent); + cursor: pointer; + transition: background-color 140ms ease, box-shadow 140ms ease; +} + +#markdown-preview .review-pending-highlight { + position: relative; + background: color-mix(in srgb, var(--review-accent) 18%, transparent); + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--review-accent) 64%, transparent); +} + +#markdown-preview .review-comment-element, +#markdown-preview .review-pending-element { + position: relative; + border-radius: 5px; + transition: background-color 140ms ease, box-shadow 140ms ease, outline-color 140ms ease; +} + +#markdown-preview .review-comment-element { + outline: 1px solid color-mix(in srgb, var(--accent-color) 40%, transparent); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 7%, transparent); + cursor: pointer; +} + +#markdown-preview .review-pending-element { + outline: 2px dashed color-mix(in srgb, var(--review-accent) 68%, transparent); + outline-offset: 3px; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--review-accent) 8%, transparent); +} + +#markdown-preview .is-review-highlight-emphasized { + background-color: color-mix(in srgb, var(--review-accent) 20%, transparent); + outline-color: color-mix(in srgb, var(--review-accent) 70%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-accent) 12%, transparent); +} + +#markdown-preview .review-comment-highlight.is-review-highlight-emphasized { + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--review-accent) 70%, transparent); +} + +#markdown-preview .review-element-selectable { + cursor: pointer; +} + +#markdown-preview .review-element-selectable:hover:not(.review-comment-element):not(.review-pending-element):not(.is-review-element-selected):not(.has-review-native-text-selection) { + outline: 2px dotted var(--review-accent); + outline-offset: 3px; +} + +#markdown-preview .review-element-selectable.is-review-element-selected { + outline: 2px solid var(--review-accent); + outline-offset: 3px; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--review-accent) 10%, transparent); +} + +#markdown-preview .review-pending-highlight.review-selection-start, +#markdown-preview .review-comment-highlight.is-review-selection-start, +#markdown-preview .review-pending-highlight.review-selection-end, +#markdown-preview .review-comment-highlight.is-review-selection-end { + box-decoration-break: slice; + -webkit-box-decoration-break: slice; + animation: review-selection-caret-blink 900ms steps(1, end) infinite; +} + +#markdown-preview .review-pending-highlight.review-selection-start, +#markdown-preview .review-comment-highlight.is-review-selection-start { + border-inline-start: 2px solid var(--review-accent); +} + +#markdown-preview .review-pending-highlight.review-selection-end, +#markdown-preview .review-comment-highlight.is-review-selection-end { + border-inline-end: 2px solid var(--review-accent); +} + +@media (prefers-reduced-motion: reduce) { + #markdown-preview .review-pending-highlight.review-selection-start, + #markdown-preview .review-comment-highlight.is-review-selection-start, + #markdown-preview .review-pending-highlight.review-selection-end, + #markdown-preview .review-comment-highlight.is-review-selection-end { + animation: none; + } +} + +.review-new-btn:focus-visible, +.review-secondary-btn:focus-visible, +.review-thread:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +@media (min-width: 1080px) { + .review-panel { + width: 344px; + flex-basis: 344px; + } +} + +@media (min-width: 768px) and (max-width: 1079px) { + .review-panel { + width: min(344px, 100%); + } + + .content-container.is-reviewing .preview-pane { + padding-inline-end: 364px; + } + + #markdown-preview .review-target, + #markdown-preview .review-target.has-review-thread, + #markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table) { + padding-inline-end: 0; + } + + #markdown-preview .review-target--table, + #markdown-preview .review-target--table.has-review-thread { + max-width: 100%; + } +} + +@media (max-width: 767px) { + .review-panel { + width: 100%; + height: min(68%, 620px); + } + + .review-panel-header { + min-height: 48px; + padding: 6px 8px 6px 12px; + } + + .review-panel-toolbar { + grid-template-columns: auto auto minmax(150px, 1fr); + flex-wrap: nowrap; + gap: 6px; + padding: 7px 8px; + overflow-x: auto; + } + + .review-new-btn, + .review-secondary-btn, + .review-primary-btn, + .review-filter-btn { + min-height: 40px; + } + + .review-thread-action { + min-width: 24px; + width: 24px; + height: 24px; + min-height: 24px; + flex-basis: 24px; + } + + .review-thread-actions { + position: absolute; + gap: 2px; + opacity: 0; + } + + #markdown-preview .review-target, + #markdown-preview .review-target.has-review-thread, + #markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table) { + padding-inline-end: 0; + } -@media print { - .code-preview-toolbar { - display: none; + #markdown-preview .review-target--table, + #markdown-preview .review-target--table.has-review-thread { + max-width: 100%; + } + + #markdown-preview .review-target--diagram { + padding-block-end: 0; } } +} .markdown-body img { background-color: transparent; @@ -2218,10 +2954,6 @@ body.document-sidebar-collapsed .document-sidebar { gap: 5px; } -.header-right .header-command-btn .btn-text { - display: inline; -} - .header-command-btn { width: auto; padding-inline: 10px; @@ -2235,6 +2967,25 @@ body.document-sidebar-collapsed .document-sidebar { display: inline-flex; } +.header-icon-dropdown .dropdown-toggle::after { + display: none; +} + +.header-icon-dropdown > .header-icon-command { + width: 38px; + min-width: 38px; + gap: 3px; +} + +.header-toolbar-divider { + width: 1px; + height: 20px; + flex: 0 0 1px; + margin-inline: 2px; + background: var(--border-color); + opacity: 0.72; +} + .app-command-menu.header-new-menu, .app-command-menu.header-export-menu { width: max-content; @@ -2255,7 +3006,7 @@ body.document-sidebar-collapsed .document-sidebar { min-width: 30px; height: 30px; padding: 0; - border: 1px solid transparent; + border: 1px solid var(--border-color); border-radius: 8px; background: transparent; color: var(--text-color); @@ -2283,6 +3034,76 @@ body.document-sidebar-collapsed .document-sidebar { line-height: 1; } +.header-icon-command .header-dropdown-chevron { + font-size: var(--ui-icon-size-xs); + opacity: 0.72; +} + +.header-icon-command.is-active, +.header-icon-command.is-active:hover, +.header-icon-command.is-live-active, +.header-icon-command.is-live-active:hover, +.header-icon-command.sync-active, +.header-icon-command.sync-active:hover, +.header-icon-dropdown.show > .header-icon-command:not(:disabled):not([aria-disabled="true"]), +.header-icon-dropdown > .header-icon-command.show:not(:disabled):not([aria-disabled="true"]), +.settings-dropdown.show > .header-icon-command:not(:disabled):not([aria-disabled="true"]), +.settings-dropdown > .header-icon-command.show:not(:disabled):not([aria-disabled="true"]) { + border-color: color-mix(in srgb, var(--accent-color) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent-color) 10%, transparent); + color: var(--accent-color); +} + +.header-icon-command.sync-active:disabled, +.header-icon-command.sync-active[aria-disabled="true"] { + border-color: transparent; + background: transparent; + color: var(--text-color); + box-shadow: none; +} + +.header-icon-command#review-toggle:has(.review-toolbar-count:not([hidden])) { + width: auto; + padding-inline: 6px; +} + +.header-view-toolbar { + flex: 0 0 auto; +} + +.header-view-toolbar .view-toggle-btn { + width: 27px; + min-width: 27px; + height: 26px; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + box-shadow: none; +} + +.header-view-toolbar .view-toggle-btn:hover, +.header-view-toolbar .view-toggle-btn:focus-visible { + border: 0; + background: var(--toolbar-control-hover); + color: var(--text-color); +} + +.header-view-toolbar .view-toggle-btn.is-active, +.header-view-toolbar .view-toggle-btn.is-active:hover { + border: 0; + background: var(--segmented-surface); + color: var(--accent-color); + box-shadow: var(--segmented-shadow); +} + +.header-view-toolbar .view-toggle-btn:disabled, +.header-view-toolbar .view-toggle-btn[aria-disabled="true"] { + background: transparent; + box-shadow: none; +} + .settings-dropdown { display: inline-flex; } @@ -2433,8 +3254,8 @@ body.document-sidebar-collapsed .document-sidebar { .settings-switch { position: relative; - width: 32px; - height: 18px; + width: 40px; + height: 22px; border-radius: 999px; background: var(--button-active); transition: background-color 0.16s ease; @@ -2444,20 +3265,46 @@ body.document-sidebar-collapsed .document-sidebar { position: absolute; top: 3px; left: 3px; - width: 12px; - height: 12px; + width: 16px; + height: 16px; border-radius: 50%; background: var(--text-secondary); transition: transform 0.16s ease, background-color 0.16s ease; } +.themed-settings-switch > span { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-color); + background: var(--bg-color); + transition: color 0.16s ease, transform 0.16s ease; +} + +.settings-switch-icon { + width: 12px; + height: 12px; + color: currentColor; + font-size: 12px; + filter: + drop-shadow(0.35px 0 0 currentColor) + drop-shadow(-0.35px 0 0 currentColor) + drop-shadow(0 0.35px 0 currentColor) + drop-shadow(0 -0.35px 0 currentColor); +} + .settings-menu-item.is-active .settings-switch { background: var(--accent-color); } .settings-menu-item.is-active .settings-switch > span { background: #fff; - transform: translateX(14px); + transform: translateX(18px); +} + +.settings-menu-item.is-active .themed-settings-switch > span { + color: var(--text-color); + background: var(--bg-color); } .settings-language-dropdown { @@ -2800,46 +3647,10 @@ body.document-sidebar-collapsed .document-sidebar { flex-shrink: 0; } -.markdown-toolbar-group--utilities { - position: sticky; - z-index: 6; - inset-inline-end: 0; - margin-inline-start: auto; - border-right: 0; - background: var(--toolbar-surface); -} - -.markdown-toolbar-group--document { - gap: 2px; - padding-inline-start: 6px; -} - .workspace-format-actions { padding-inline: 5px; } -.markdown-toolbar-group--document .tool-button { - flex: 0 0 auto; - width: 30px; - height: 30px; - min-width: 30px; - padding: 0; - border-color: transparent; - border-radius: 7px; - background: transparent; -} - -.markdown-toolbar-group--document .tool-button:hover, -.markdown-toolbar-group--document .tool-button:focus-visible { - border-color: transparent; - background: var(--toolbar-control-hover); -} - -.markdown-toolbar-group--document #review-toggle:has(.review-toolbar-count:not([hidden])) { - width: auto; - padding-inline: 5px; -} - .document-actions-menu { width: 280px; min-width: 280px; @@ -2860,14 +3671,6 @@ body.document-sidebar-collapsed .document-sidebar { z-index: var(--layer-menu); } -.document-command-divider { - width: 1px; - height: 18px; - margin-inline: 4px; - background: var(--border-color); - opacity: 0.75; -} - .markdown-view-toolbar { display: inline-flex; align-items: center; @@ -2879,44 +3682,6 @@ body.document-sidebar-collapsed .document-sidebar { background: var(--segmented-bg); } -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn { - width: 27px; - height: 26px; - min-width: 27px; - border: 0; - border-radius: 7px; - background: transparent; - color: var(--text-secondary); - box-shadow: none; -} - -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn:hover, -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn:focus-visible { - border: 0; - background: var(--toolbar-control-hover); - color: var(--text-color); -} - -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn.is-active, -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn.is-active:hover { - border: 0; - background: var(--segmented-surface); - color: var(--accent-color); - box-shadow: var(--segmented-shadow); -} - -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn:disabled, -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn[aria-disabled="true"] { - background: transparent; - box-shadow: none; -} - -.markdown-toolbar-group--utilities .tool-button { - flex: 0 0 auto; - height: 30px; - min-width: 30px; -} - .markdown-toolbar-group:first-child { padding-left: 0; } @@ -3017,17 +3782,6 @@ body.document-sidebar-collapsed .document-sidebar { gap: 5px; } -.markdown-tool-select--insert { - width: 30px; - min-width: 30px; - gap: 0; - padding: 0; -} - -.markdown-tool-select--insert .lucide-ellipsis { - font-size: var(--ui-icon-size-lg); -} - .markdown-tool-select-label { overflow: hidden; max-width: 48px; @@ -3680,12 +4434,6 @@ a:focus { min-width: 44px; } - .markdown-tool-select--insert { - width: 30px !important; - min-width: 30px; - padding-inline: 0 !important; - } - .markdown-format-toolbar { height: 40px !important; } @@ -3699,23 +4447,6 @@ a:focus { height: 40px !important; } - .markdown-toolbar-group--document .tool-button { - width: 30px; - height: 30px; - min-width: 30px; - padding: 0; - } - - .markdown-view-toolbar { - height: 32px; - } - - .markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn { - width: 27px; - height: 26px; - min-width: 27px; - } - .document-action-item { min-height: 44px; } @@ -4061,15 +4792,6 @@ a:focus { scroll-padding-inline: 6px; } - .markdown-toolbar-group--utilities { - position: static; - margin-inline-start: 0; - } - - .markdown-tool-select--insert { - min-width: 30px; - } - .markdown-tool-menu-item, .tab-menu-item, .header-command-menu .app-menu-item { @@ -7794,26 +8516,72 @@ html[lang="ko"] .markdown-body h3 { margin-bottom: 0.6em; } -/* Consistent, position-safe menu motion. Popper owns menu transforms. */ +/* Consistent, position-safe menu motion. Popper owns transform; the individual + translate/scale properties compose with it without disturbing placement. */ @keyframes app-menu-fade { from { opacity: 0; } to { opacity: 1; } } .dropdown-menu { + --app-menu-shift-x: 0px; + --app-menu-shift-y: -4px; opacity: 0; display: block; visibility: hidden; - transition: opacity 0.15s ease-out, visibility 0.15s ease-out; + pointer-events: none; + translate: var(--app-menu-shift-x) var(--app-menu-shift-y); + scale: 0.985; + transform-origin: top center; + transition: + opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1), + translate 0.18s cubic-bezier(0.16, 1, 0.3, 1), + scale 0.18s cubic-bezier(0.16, 1, 0.3, 1), + visibility 0s linear 0.18s; +} + +.dropdown-menu[data-popper-placement^="top"] { + --app-menu-shift-y: 4px; + transform-origin: bottom center; +} + +.dropdown-menu[data-popper-placement^="left"] { + --app-menu-shift-x: 4px; + --app-menu-shift-y: 0px; + transform-origin: center right; +} + +.dropdown-menu[data-popper-placement^="right"] { + --app-menu-shift-x: -4px; + --app-menu-shift-y: 0px; + transform-origin: center left; } .dropdown-menu.show { opacity: 1; visibility: visible; + pointer-events: auto; + translate: 0 0; + scale: 1; + transition-delay: 0s; +} + +.dropdown-menu.show.is-closing { + opacity: 0; + visibility: visible; + pointer-events: none; + translate: var(--app-menu-shift-x) var(--app-menu-shift-y); + scale: 0.985; + transition-duration: 0.12s; + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); } @media (prefers-reduced-motion: reduce) { - .dropdown-menu { + .dropdown-menu, + .dropdown-menu.show, + .dropdown-menu.show.is-closing { + translate: 0 0; + scale: 1; transition: none; } @@ -9808,9 +10576,11 @@ html[data-theme="dark"] .mermaid svg { 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); + background: var(--menu-surface); + border-inline-start: 1px solid var(--menu-border); + box-shadow: var(--menu-shadow); + font-size: var(--ui-font-md); + animation: review-panel-enter 180ms ease-out; } .review-panel-backdrop { @@ -9820,28 +10590,21 @@ html[data-theme="dark"] .mermaid svg { width: 100%; height: 100%; border: 0; - background: rgba(31, 35, 40, 0.38); + background: color-mix(in srgb, var(--text-color) 36%, transparent); cursor: default; + animation: review-backdrop-enter 140ms ease-out; } .review-panel-header { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 16px; - padding: 16px; + gap: 12px; + min-height: 50px; + padding: 7px 9px 7px 12px; 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 { @@ -9851,18 +10614,20 @@ html[data-theme="dark"] .mermaid svg { } .review-panel-header h2 { - font-size: 1rem; + font-size: var(--ui-font-lg); + line-height: 1.25; } .review-panel-summary { - margin: 4px 0 0; + margin: 2px 0 0; color: var(--text-secondary); - font-size: 0.78rem; + font-size: var(--ui-font-sm); + line-height: 1.35; } .review-icon-btn { - width: 32px; - height: 32px; + width: 30px; + height: 30px; flex: 0 0 auto; display: inline-flex; align-items: center; @@ -9870,13 +10635,18 @@ html[data-theme="dark"] .mermaid svg { color: var(--text-secondary); background: transparent; border: 1px solid transparent; - border-radius: 6px; + border-radius: var(--menu-item-radius); cursor: pointer; + transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; +} + +.review-icon-btn i { + font-size: var(--ui-icon-size-md); } .review-icon-btn:hover:not(:disabled) { color: var(--text-color); - background: var(--button-hover); + background: var(--toolbar-control-hover); border-color: var(--border-color); } @@ -9887,7 +10657,8 @@ html[data-theme="dark"] .mermaid svg { } .review-icon-btn:disabled { - opacity: 0.45; + color: color-mix(in srgb, var(--text-secondary) 50%, transparent); + opacity: 0.55; cursor: not-allowed; } @@ -9895,8 +10666,9 @@ html[data-theme="dark"] .mermaid svg { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 10px 14px; + gap: 8px; + padding: 7px 8px; + background: var(--header-bg); border-bottom: 1px solid var(--border-color); } @@ -9912,87 +10684,93 @@ html[data-theme="dark"] .mermaid svg { display: flex; flex-direction: column; overflow-y: auto; + background: var(--menu-surface); } - -.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-panel-body:has(.review-composer:not([hidden])) .review-empty-state { + display: none; } -.review-filter-btn, -.review-kind-btn { - color: var(--text-secondary); - background: transparent; - border: 0; - border-radius: 6px; - cursor: pointer; - font-weight: 600; +.review-filter-group { + width: 180px; + flex: 0 0 auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3px; + margin: 0; + padding: 3px; } .review-filter-btn { - padding: 5px 10px; - font-size: 0.74rem; + width: 100%; + min-width: 0; + padding-inline: 0; } -.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-kind-group { + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + margin: 0 0 11px; + padding: 3px; +} + +.review-kind-btn { + width: 100%; + min-width: 0; + padding-inline: 0; } .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; + margin: 12px 12px 2px; + padding: 0; + overflow: hidden; + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-radius); + box-shadow: 0 3px 12px color-mix(in srgb, var(--text-color) 11%, transparent); } .review-composer-heading { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 10px; + min-height: 40px; + padding: 6px 7px 6px 11px; + background: color-mix(in srgb, var(--header-bg) 68%, var(--menu-surface)); + border-bottom: 1px solid var(--border-color); } .review-composer-heading h3 { - font-size: 0.9rem; + font-size: var(--ui-font-lg); + line-height: 1.35; +} + +.review-composer-body { + padding: 10px 12px; } .review-anchor-preview { display: -webkit-box; - margin: 10px 0 12px; + margin: 0 0 10px; + padding: 7px 9px; overflow: hidden; color: var(--text-secondary); - font-size: 0.78rem; + background: var(--button-bg); + border-inline-start: 2px solid var(--border-color); + border-radius: 0 var(--menu-item-radius) var(--menu-item-radius) 0; + font-size: var(--ui-font-md); 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-size: var(--ui-font-md); font-weight: 650; } @@ -10003,11 +10781,12 @@ html[data-theme="dark"] .mermaid svg { resize: vertical; color: var(--text-color); background: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 7px; + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); font: inherit; - font-size: 0.82rem; + font-size: var(--ui-font-md); line-height: 1.45; + transition: border-color 160ms ease, box-shadow 160ms ease; } .review-feedback-input:focus { @@ -10021,52 +10800,77 @@ html[data-theme="dark"] .mermaid svg { align-items: center; justify-content: space-between; gap: 12px; - margin-top: 9px; + margin: 0; + padding: 9px 12px 10px; + background: color-mix(in srgb, var(--header-bg) 52%, var(--menu-surface)); + border-top: 1px solid var(--border-color); } .review-feedback-count { color: var(--text-secondary); - font-size: 0.7rem; + font-size: var(--ui-font-sm); + font-variant-numeric: tabular-nums; } .review-primary-btn { - min-height: 32px; - padding: 6px 12px; - color: var(--accent-color); - background: var(--button-bg); + min-height: 34px; + padding: 7px 13px; + color: var(--bg-color); + background: var(--accent-color); border: 1px solid var(--accent-color); - border-radius: 6px; + border-radius: var(--menu-item-radius); cursor: pointer; - font-size: 0.76rem; + font-size: var(--ui-font-md); font-weight: 650; + transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; } .review-primary-btn:hover:not(:disabled) { - background: var(--button-hover); + background: color-mix(in srgb, var(--accent-color) 88%, var(--text-color)); + border-color: color-mix(in srgb, var(--accent-color) 88%, var(--text-color)); } .review-primary-btn:disabled { - opacity: 0.5; + color: var(--text-secondary); + background: var(--button-bg); + border-color: var(--border-color); + opacity: 0.62; cursor: not-allowed; } .review-list { flex: 0 0 auto; + display: grid; + gap: 10px; overflow: visible; - padding: 10px 14px 18px; + padding: 10px 12px 16px; } .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; + position: relative; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-radius); + box-shadow: 0 1px 2px color-mix(in srgb, var(--text-color) 8%, transparent); + transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +@media (hover: hover) { + .review-thread:hover { + border-color: color-mix(in srgb, var(--text-secondary) 42%, var(--menu-border)); + box-shadow: 0 3px 10px color-mix(in srgb, var(--text-color) 9%, transparent); + } } .review-thread.is-resolved { - opacity: 0.72; + background: color-mix(in srgb, var(--button-bg) 52%, var(--menu-surface)); +} + +.review-thread.is-resolved .review-thread-body { + color: var(--text-secondary); } .review-thread.is-orphaned { @@ -10081,8 +10885,12 @@ html[data-theme="dark"] .mermaid svg { } .review-thread-header { + min-height: 40px; justify-content: space-between; gap: 8px; + padding: 6px 7px 6px 11px; + background: color-mix(in srgb, var(--header-bg) 68%, var(--menu-surface)); + border-bottom: 1px solid var(--border-color); } .review-thread-meta { @@ -10090,47 +10898,62 @@ html[data-theme="dark"] .mermaid svg { gap: 6px; } +.review-thread-content { + padding: 10px 12px 11px; +} + .review-kind-label, .review-status-label { display: inline-flex; align-items: center; gap: 4px; - border-radius: 999px; - font-size: 0.67rem; - font-weight: 700; + font-size: var(--ui-font-sm); + font-weight: 650; line-height: 1; } .review-kind-label { - padding: 5px 7px; - color: var(--text-secondary); - background: var(--button-bg); - border: 1px solid var(--border-color); + color: var(--text-color); +} + +.review-kind-label i { + font-size: var(--ui-icon-size-sm); } .review-status-label { + padding-inline-start: 7px; color: var(--text-secondary); + border-inline-start: 1px solid var(--border-color); +} + +.review-status-label i { + font-size: var(--ui-icon-size-sm); } .review-thread-anchor { width: 100%; - margin: 9px 0 8px; - padding: 0; + margin: 0 0 10px; + padding: 7px 9px; overflow: hidden; color: var(--text-secondary); - background: transparent; - border: 0; + background: var(--button-bg); + border: 1px solid transparent; + border-inline-start-color: var(--border-color); + border-inline-start-width: 2px; + border-radius: 0 var(--menu-item-radius) var(--menu-item-radius) 0; cursor: pointer; - font-size: 0.72rem; - line-height: 1.35; + font-size: var(--ui-font-md); + line-height: 1.4; text-align: start; text-overflow: ellipsis; white-space: nowrap; + transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; } .review-thread-anchor:hover:not(:disabled) { - color: var(--accent-color); - text-decoration: underline; + color: var(--text-color); + background: var(--button-hover); + border-color: var(--border-color); } .review-thread-anchor:disabled { @@ -10141,7 +10964,7 @@ html[data-theme="dark"] .mermaid svg { .review-thread-body { margin: 0; color: var(--text-color); - font-size: 0.82rem; + font-size: var(--ui-font-md); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; @@ -10150,37 +10973,64 @@ html[data-theme="dark"] .mermaid svg { .review-thread-dates { display: flex; flex-wrap: wrap; - gap: 4px 12px; - margin-top: 9px; + gap: 6px 12px; + margin-top: 11px; + padding-top: 9px; + border-top: 1px solid var(--border-color); } .review-thread-time { + display: inline-flex; + align-items: center; + gap: 4px; color: var(--text-secondary); - font-size: 0.67rem; + font-size: var(--ui-font-sm); + line-height: 1.3; +} + +.review-thread-time i { + flex: 0 0 auto; + font-size: var(--ui-icon-size-xs); } .review-thread-actions { + flex: 0 0 auto; justify-content: flex-end; - gap: 4px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); + gap: 2px; + margin: 0; + padding: 2px; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); } .review-thread-action { - padding: 5px 7px; + width: 24px; + height: 24px; + min-height: 24px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; color: var(--text-secondary); background: transparent; - border: 0; - border-radius: 5px; + border: 1px solid transparent; + border-radius: calc(var(--menu-item-radius) - 3px); cursor: pointer; - font-size: 0.7rem; - font-weight: 600; + font-size: var(--ui-icon-size-sm); + line-height: 1; + transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; +} + +.review-thread-action i { + font-size: var(--ui-icon-size-md); } .review-thread-action:hover { color: var(--text-color); - background: var(--button-hover); + background: var(--toolbar-control-hover); + border-color: transparent; } .review-thread-action.is-danger:hover { @@ -10189,27 +11039,34 @@ html[data-theme="dark"] .mermaid svg { .review-empty-state { margin: auto; - padding: 30px 26px; + padding: 36px 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; + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; + color: var(--text-secondary); + background: var(--button-bg); + border: 1px solid var(--border-color); + border-radius: 50%; + font-size: var(--ui-icon-size-xl); } .review-empty-state h3 { - font-size: 0.9rem; + font-size: var(--ui-font-md); } .review-empty-state p { margin: 7px auto 0; max-width: 270px; - font-size: 0.76rem; - line-height: 1.45; + font-size: var(--ui-font-md); + line-height: 1.5; } #markdown-preview .review-target { @@ -10300,6 +11157,24 @@ html[data-theme="dark"] .mermaid svg { .review-thread.is-review-thread-active { border-color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 7%, var(--menu-surface)); + box-shadow: inset 3px 0 0 var(--accent-color); +} + +@keyframes review-panel-enter { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes review-backdrop-enter { + from { opacity: 0; } + to { opacity: 1; } } .review-filter-btn:focus-visible, @@ -10354,6 +11229,17 @@ html[data-theme="dark"] .mermaid svg { .review-thread-action { min-height: 40px; } + + .review-thread-action { + min-width: 40px; + width: 40px; + height: 40px; + flex-basis: 40px; + } + + .review-thread-actions { + gap: 6px; + } } @media (max-width: 767px) { @@ -10364,7 +11250,7 @@ html[data-theme="dark"] .mermaid svg { 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); + box-shadow: var(--menu-shadow); } .content-container.is-reviewing .preview-pane { @@ -10390,6 +11276,12 @@ html[data-theme="dark"] .mermaid svg { height: 44px; } + .review-panel-header .review-icon-btn, + .review-composer-heading .review-icon-btn { + width: 44px; + height: 44px; + } + .review-filter-btn, .review-kind-btn, .review-primary-btn, @@ -10399,7 +11291,7 @@ html[data-theme="dark"] .mermaid svg { .review-composer { margin: 10px 10px 4px; - padding: 12px; + padding: 0; } .review-list { @@ -10407,7 +11299,14 @@ html[data-theme="dark"] .mermaid svg { } .review-thread-action { - padding-inline: 10px; + min-width: 44px; + width: 44px; + height: 44px; + flex-basis: 44px; + } + + .review-thread-actions { + gap: 8px; } .review-target-button { @@ -10437,7 +11336,10 @@ html[data-theme="dark"] .mermaid svg { } @media (prefers-reduced-motion: reduce) { + .review-panel, + .review-panel-backdrop, .review-target-button { + animation: none; transition: none; } } @@ -11177,7 +12079,22 @@ html[data-theme="dark"] .mermaid svg { .mobile-settings-toggle.is-active .settings-switch > span { background: #fff; - transform: translateX(14px); + transform: translateX(18px); +} + +.mobile-settings-toggle.is-active .themed-settings-switch > span { + color: var(--text-color); + background: var(--bg-color); +} + +html[data-theme="dark"] .settings-menu-item.is-active .settings-switch, +html[data-theme="dark"] .mobile-settings-toggle.is-active .settings-switch { + background: var(--button-active); +} + +#private-mode-toggle.is-active .settings-switch, +#mobile-private-mode-toggle.is-active .settings-switch { + background: var(--accent-color); } .mobile-menu-command.is-active .mobile-menu-item-label > i, @@ -11749,6 +12666,18 @@ html[data-theme="dark"] .mermaid svg { /* ======================================== VERSIONED RELEASE NOTES ======================================== */ +body.release-notes-active #review-panel, +body.release-notes-active #review-panel-backdrop, +body.release-notes-active #review-composer, +body.release-notes-active #review-pins-layer { + display: none !important; +} + +body.release-notes-active .app-status-bar, +body.release-notes-active .mobile-stats-container { + opacity: 0.5; +} + .tab-item.release-notes-tab { width: max-content; min-width: max-content; diff --git a/index.html b/index.html index b57ca427..3a5abaf5 100644 --- a/index.html +++ b/index.html @@ -146,11 +146,22 @@

Markdown Viewer - Online Markd - -

-
+
@@ -352,7 +374,7 @@

Actions

- @@ -463,7 +485,7 @@

Explorer

-
+
@@ -475,7 +497,7 @@

Explorer

-
+
-
-
+
+
+
+ + +
-
- - - - - +
+ +
-
+
+ + -
- - -
+
+
+ + + + +
-
- - - - -
- - - -
-
@@ -682,7 +683,7 @@

Explorer

@@ -883,9 +884,9 @@
@@ -1578,78 +1579,59 @@

Quick start

- - + diff --git a/script.js b/script.js index 49db4386..2603217e 100644 --- a/script.js +++ b/script.js @@ -298,18 +298,22 @@ document.addEventListener("DOMContentLoaded", async function () { let currentViewMode = 'split'; // 'editor', 'split', or 'preview' const shareSnapshotViewOnlyTabIds = new Set(); const SHARE_SNAPSHOT_TAB_KIND = 'share-snapshot'; - const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, pre, .frontmatter-table, .diagram-viewer, .geojson-container, .topojson-container, .stl-container'; + const REVIEW_TARGET_SELECTOR = 'h1, h2, h3, h4, h5, h6, p, li, blockquote, img, video, audio, mjx-container, .frontmatter-table, .diagram-viewer, .geojson-container, .topojson-container, .stl-container'; + const REVIEW_ELEMENT_TYPES = new Set(['image', 'media', 'diagram', 'math', 'table']); + const REVIEW_REPLY_COLLAPSE_THRESHOLD = 4; 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 activeReviewReplyEdit = null; + let activeReviewId = null; + let hoveredReviewId = null; + let pendingReviewSelection = null; + const expandedReviewThreadIds = new Set(); let pendingReviewDelete = null; let pendingWorkspaceBackupInput = null; - let reviewPinsResizeObserver = null; - let reviewPinsLayoutFrame = null; let reviewTargetSourceSnapshots = new WeakMap(); let activeModal = null; let lastFocusedElement = null; @@ -340,9 +344,10 @@ 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 themeToggleIcon = document.getElementById("theme-toggle-icon"); + const themeSwitchIcon = document.getElementById("theme-switch-icon"); const directionToggle = document.getElementById("direction-toggle"); const importFromFileButton = document.getElementById("import-from-file"); const importFromGithubButton = document.getElementById("import-from-github"); @@ -374,6 +379,7 @@ document.addEventListener("DOMContentLoaded", async function () { const reviewPanelBackdrop = document.getElementById('review-panel-backdrop'); const reviewPanelClose = document.getElementById('review-panel-close'); const reviewPanelSummary = document.getElementById('review-panel-summary'); + const reviewNewComment = document.getElementById('review-new-comment'); const reviewCopySummary = document.getElementById('review-copy-summary'); const reviewResolveAll = document.getElementById('review-resolve-all'); const reviewDeleteAll = document.getElementById('review-delete-all'); @@ -387,6 +393,8 @@ document.addEventListener("DOMContentLoaded", async function () { const reviewComposer = document.getElementById('review-composer'); const reviewComposerTitle = document.getElementById('review-composer-title'); const reviewComposerAnchor = document.getElementById('review-composer-anchor'); + const reviewComposerAuthor = document.getElementById('review-composer-author'); + const reviewComposerAvatar = document.getElementById('review-composer-avatar'); const reviewComposerCancel = document.getElementById('review-composer-cancel'); const reviewFeedbackLabel = document.getElementById('review-feedback-label'); const reviewFeedbackInput = document.getElementById('review-feedback-input'); @@ -757,7 +765,7 @@ document.addEventListener("DOMContentLoaded", async function () { return 'Open or create a document to use editing tools.'; } if (reviewModeActive) { - return 'Review mode keeps the Markdown source read only.'; + return 'Comments keeps the Markdown source read only.'; } if (isShareSnapshotViewOnlyActive()) { return 'This shared snapshot is view only.'; @@ -801,6 +809,7 @@ document.addEventListener("DOMContentLoaded", async function () { const mobileExportToggle = document.querySelector('[aria-controls="mobile-menu-export-panel"]'); const mobileThemeToggle = document.getElementById("mobile-theme-toggle"); const mobileThemeIcon = document.getElementById("mobile-theme-icon"); + const mobileThemeSwitchIcon = document.getElementById("mobile-theme-switch-icon"); const mobileThemeStatus = document.getElementById("mobile-theme-status"); const mobilePrivateModeToggle = document.getElementById("mobile-private-mode-toggle"); const mobileStorageSettingsButton = document.getElementById("mobile-storage-settings-button"); @@ -1579,22 +1588,25 @@ document.addEventListener("DOMContentLoaded", async function () { document.documentElement.setAttribute("data-theme", initialTheme); function updateThemeToggleUI(theme) { - const useLightAppearance = theme === "dark"; + const darkModeEnabled = theme === "dark"; + const themeIconName = darkModeEnabled ? 'moon' : 'sun'; if (themeToggle) { - const icon = themeToggle.querySelector('i'); const description = document.getElementById('theme-toggle-description'); - if (icon) icon.className = 'lucide lucide-' + (useLightAppearance ? 'sun' : 'moon'); - if (description) description.textContent = useLightAppearance ? 'Switch to light appearance' : 'Switch to dark appearance'; - themeToggle.setAttribute('aria-label', useLightAppearance ? 'Use light appearance' : 'Use dark appearance'); - themeToggle.setAttribute('title', useLightAppearance ? 'Use light appearance' : 'Use dark appearance'); + themeToggle.classList.toggle('is-active', darkModeEnabled); + themeToggle.setAttribute('aria-pressed', String(darkModeEnabled)); + if (themeToggleIcon) themeToggleIcon.className = 'lucide lucide-' + themeIconName; + if (themeSwitchIcon) themeSwitchIcon.className = 'lucide lucide-' + themeIconName + ' settings-switch-icon'; + if (description) description.textContent = darkModeEnabled ? 'Switch to light appearance' : 'Switch to dark appearance'; + themeToggle.setAttribute('aria-label', darkModeEnabled ? 'Use light appearance' : 'Use dark appearance'); + themeToggle.setAttribute('title', darkModeEnabled ? 'Use light appearance' : 'Use dark appearance'); } if (mobileThemeToggle) { - const darkModeEnabled = theme === 'dark'; mobileThemeToggle.classList.toggle('is-active', darkModeEnabled); mobileThemeToggle.setAttribute('aria-pressed', String(darkModeEnabled)); mobileThemeToggle.setAttribute('aria-label', darkModeEnabled ? 'Use light mode' : 'Use dark mode'); mobileThemeToggle.setAttribute('title', darkModeEnabled ? 'Use light mode' : 'Use dark mode'); - if (mobileThemeIcon) mobileThemeIcon.className = 'lucide lucide-' + (darkModeEnabled ? 'moon' : 'sun'); + if (mobileThemeIcon) mobileThemeIcon.className = 'lucide lucide-' + themeIconName; + if (mobileThemeSwitchIcon) mobileThemeSwitchIcon.className = 'lucide lucide-' + themeIconName + ' settings-switch-icon'; if (mobileThemeStatus) mobileThemeStatus.textContent = darkModeEnabled ? 'Dark mode' : 'Light mode'; } } @@ -3263,6 +3275,7 @@ document.addEventListener("DOMContentLoaded", async function () { queryPreviewRoots(mathTargets, 'mjx-container[tabindex="0"]').forEach(function(mjx) { mjx.removeAttribute('tabindex'); }); + if (reviewModeActive) decorateReviewTargets(); }).catch(function(err) { console.warn('MathJax typesetting failed:', err); }); @@ -6829,12 +6842,20 @@ document.addEventListener("DOMContentLoaded", async function () { if (thread.resolved) { const status = document.createElement('span'); status.className = 'review-status-label'; - status.textContent = 'Resolved'; + const statusIcon = document.createElement('i'); + statusIcon.className = 'lucide lucide-check'; + statusIcon.setAttribute('aria-hidden', 'true'); + status.appendChild(statusIcon); + status.appendChild(document.createTextNode('Resolved')); meta.appendChild(status); } header.appendChild(meta); item.appendChild(header); + const content = document.createElement('div'); + content.className = 'review-thread-content'; + item.appendChild(content); + const anchor = document.createElement('button'); anchor.type = 'button'; anchor.className = 'review-thread-anchor'; @@ -6844,59 +6865,79 @@ document.addEventListener("DOMContentLoaded", async function () { anchor.textContent = target ? thread.anchor.label + ': ' + thread.anchor.excerpt : 'Anchor no longer in preview - ' + thread.anchor.label + ': ' + thread.anchor.excerpt; - item.appendChild(anchor); + content.appendChild(anchor); const body = document.createElement('p'); body.className = 'review-thread-body'; body.textContent = thread.body; - item.appendChild(body); + content.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); + const openedIcon = document.createElement('i'); + openedIcon.className = 'lucide lucide-clock-3'; + openedIcon.setAttribute('aria-hidden', 'true'); + openedTime.appendChild(openedIcon); + openedTime.appendChild(document.createTextNode('Opened: ' + formatReviewTime(thread.createdAt))); dates.appendChild(openedTime); if (thread.resolvedAt) { const closedTime = document.createElement('time'); - closedTime.className = 'review-thread-time'; + closedTime.className = 'review-thread-time is-closed'; closedTime.dateTime = new Date(thread.resolvedAt).toISOString(); - closedTime.textContent = 'Closed: ' + formatReviewTime(thread.resolvedAt); + const closedIcon = document.createElement('i'); + closedIcon.className = 'lucide lucide-check'; + closedIcon.setAttribute('aria-hidden', 'true'); + closedTime.appendChild(closedIcon); + closedTime.appendChild(document.createTextNode('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'; + closedTime.className = 'review-thread-time ' + (thread.resolved ? 'is-closed' : 'is-open'); + const closedIcon = document.createElement('i'); + closedIcon.className = thread.resolved ? 'lucide lucide-check' : 'lucide lucide-circle'; + closedIcon.setAttribute('aria-hidden', 'true'); + closedTime.appendChild(closedIcon); + closedTime.appendChild(document.createTextNode(thread.resolved ? 'Closed: Unavailable' : 'Closed: Not closed')); dates.appendChild(closedTime); } - item.appendChild(dates); + content.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.setAttribute('role', 'group'); + actions.setAttribute('aria-label', 'Review item actions'); + + function createThreadAction(action, iconClass, label, danger) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'review-thread-action' + (danger ? ' is-danger' : ''); + button.dataset.reviewAction = action; + button.dataset.reviewId = thread.id; + button.title = label; + button.setAttribute('aria-label', label); + const icon = document.createElement('i'); + icon.className = iconClass; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + return button; + } + + const itemLabel = thread.kind === 'suggestion' ? 'suggestion' : 'comment'; + const editButton = createThreadAction('edit', 'lucide lucide-square-pen', 'Edit ' + itemLabel, false); 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'; + const statusButton = createThreadAction( + 'toggle-resolved', + thread.resolved ? 'lucide lucide-refresh-cw' : 'lucide lucide-check', + (thread.resolved ? 'Reopen ' : 'Resolve ') + itemLabel, + false + ); 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'; + const deleteButton = createThreadAction('delete', 'lucide lucide-trash-2', 'Delete ' + itemLabel, true); actions.appendChild(deleteButton); - item.appendChild(actions); + header.appendChild(actions); return item; } @@ -6935,12 +6976,12 @@ document.addEventListener("DOMContentLoaded", async function () { 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.'; + copy.textContent = 'Choose the comment button beside any block to start a review.'; } 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.'; + : 'Choose the comment button beside any block to start a review.'; } } } @@ -7257,100 +7298,1803 @@ document.addEventListener("DOMContentLoaded", async function () { requestAnimationFrame(function() { if (reviewPanelClose) reviewPanelClose.focus(); }); - } - - function cancelReviewDeleteConfirmation() { - pendingReviewDelete = null; - if (reviewDeleteModal && reviewDeleteModal.getAttribute('aria-hidden') === 'false') { - closeAppModal(reviewDeleteModal); - } + } + + 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); + if (nextState && isReleaseNotesActive()) { + announceToScreenReader('Release notes do not support comments or suggestions.'); + return; + } + 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(false); + 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 }); + } + }); + } + + // Comments v2 keeps the persisted reviewThreads collection compatible while + // replacing block-side actions with a selection-first, Word-style workflow. + function normalizeReviewSelection(rawSelection) { + if (!rawSelection || typeof rawSelection !== 'object') return null; + const kind = ['text', 'element', 'block'].includes(rawSelection.kind) + ? rawSelection.kind + : null; + if (!kind) return null; + if (kind === 'text') { + const start = Math.max(0, Number(rawSelection.start) || 0); + const end = Math.max(start, Number(rawSelection.end) || start); + const quote = typeof rawSelection.quote === 'string' + ? rawSelection.quote.slice(0, 1000) + : ''; + if (!quote || end <= start) return null; + return { + kind: 'text', + start: start, + end: end, + quote: quote, + prefix: typeof rawSelection.prefix === 'string' ? rawSelection.prefix.slice(-64) : '', + suffix: typeof rawSelection.suffix === 'string' ? rawSelection.suffix.slice(0, 64) : '' + }; + } + return { + kind: kind, + elementType: typeof rawSelection.elementType === 'string' + ? rawSelection.elementType.slice(0, 40) + : null + }; + } + + 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 authorId = typeof thread.authorId === 'string' && thread.authorId + ? thread.authorId.slice(0, 160) + : null; + const replies = Array.isArray(thread.replies) + ? thread.replies.slice(-200).map(function(reply) { + if (!reply || typeof reply !== 'object') return null; + const replyBody = typeof reply.body === 'string' + ? reply.body.trim().slice(0, REVIEW_TEXT_LIMIT) + : ''; + if (!replyBody) return null; + const replyCreatedAt = Number.isFinite(Number(reply.createdAt)) + ? Number(reply.createdAt) + : createdAt; + const replyAuthorId = typeof reply.authorId === 'string' && reply.authorId + ? reply.authorId.slice(0, 160) + : null; + const isOwnerReply = !replyAuthorId || replyAuthorId === 'document-owner'; + return { + id: typeof reply.id === 'string' && reply.id + ? reply.id.slice(0, 120) + : createReviewId(), + author: !isOwnerReply && typeof reply.author === 'string' && reply.author.trim() + ? reply.author.trim().slice(0, 120) + : 'Author', + authorId: replyAuthorId, + body: replyBody, + createdAt: replyCreatedAt, + updatedAt: Number.isFinite(Number(reply.updatedAt)) + ? Number(reply.updatedAt) + : replyCreatedAt + }; + }).filter(Boolean) + : []; + const isOwnerThread = !authorId || authorId === 'document-owner'; + 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 content', + 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, + selection: normalizeReviewSelection(thread.anchor.selection) + }, + // Keep legacy suggestion data intact. New comments are always stored as comments. + kind: thread.kind === 'suggestion' ? 'suggestion' : 'comment', + author: !isOwnerThread && typeof thread.author === 'string' && thread.author.trim() + ? thread.author.trim().slice(0, 120) + : 'Author', + authorId: authorId, + body: body, + createdAt: createdAt, + updatedAt: updatedAt, + resolved: resolved, + resolvedAt: resolved && Number.isFinite(Number(thread.resolvedAt)) + ? Number(thread.resolvedAt) + : null, + replies: replies + }; + }).filter(Boolean); + } + + function cloneReviewThreadForLiveShare(thread) { + return { + id: thread.id, + anchor: Object.assign({}, thread.anchor, { + selection: thread.anchor && thread.anchor.selection + ? Object.assign({}, thread.anchor.selection) + : null + }), + kind: thread.kind === 'suggestion' ? 'suggestion' : 'comment', + author: thread.author || 'Author', + authorId: thread.authorId || null, + body: thread.body, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + resolved: thread.resolved === true, + resolvedAt: Number.isFinite(Number(thread.resolvedAt)) ? Number(thread.resolvedAt) : null, + replies: (thread.replies || []).map(function(reply) { + return { + id: reply.id, + author: reply.author || 'Author', + authorId: reply.authorId || null, + body: reply.body, + createdAt: reply.createdAt, + updatedAt: reply.updatedAt + }; + }) + }; + } + + function liveReviewThreadsMatch(left, right) { + if (!left || !right) return false; + return left.id === right.id && + left.kind === right.kind && + (left.author || '') === (right.author || '') && + (left.authorId || '') === (right.authorId || '') && + left.body === right.body && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt && + left.resolved === right.resolved && + left.resolvedAt === right.resolvedAt && + JSON.stringify(left.anchor || {}) === JSON.stringify(right.anchor || {}) && + JSON.stringify(left.replies || []) === JSON.stringify(right.replies || []); + } + + function getCurrentReviewAuthorIdentity() { + if (liveCollaboration && liveCollaboration.localParticipantId && liveCollaboration.localParticipant) { + const liveName = String(liveCollaboration.localParticipant.name || '').trim(); + return { + id: String(liveCollaboration.localParticipantId).slice(0, 160), + name: (liveName || 'Live Share participant').slice(0, 120) + }; + } + return { id: 'document-owner', name: 'Author' }; + } + + function getCurrentReviewAuthor() { + return getCurrentReviewAuthorIdentity().name; + } + + function updateReviewAuthorIdentity(authorId, authorName) { + if (!authorId || !authorName) return false; + const tab = getActiveReviewTab(); + if (!tab || !Array.isArray(tab.reviewThreads)) return false; + let changed = false; + tab.reviewThreads.forEach(function(thread) { + if (thread.authorId === authorId && thread.author !== authorName) { + thread.author = authorName; + changed = true; + } + (thread.replies || []).forEach(function(reply) { + if (reply.authorId === authorId && reply.author !== authorName) { + reply.author = authorName; + changed = true; + } + }); + }); + if (!changed) return false; + syncLiveReviewThreadsFromTab(); + if (activeTabId) saveTabsToStorage(tabs, [activeTabId]); + renderReviewPanel(); + return true; + } + + function getReviewAvatarLabel(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + if (parts.length > 1) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return (parts[0] || 'A').slice(0, 1).toUpperCase(); + } + + function getReviewTargetType(target) { + if (!target) return 'block'; + if (target.matches('img')) return 'image'; + if (target.matches('video, audio')) return 'media'; + if (target.matches('mjx-container')) return 'math'; + 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 (target.tagName === 'LI') return 'list-item'; + if (target.tagName === 'BLOCKQUOTE') return 'quote'; + if (/^H[1-6]$/.test(target.tagName)) return 'heading'; + if (target.tagName === 'P') return 'paragraph'; + return 'block'; + } + + function computeReviewTargetSource(target, type) { + if (!target) return ''; + if (type === 'image') { + return normalizeReviewSource([ + target.getAttribute('alt') || '', + target.getAttribute('src') || '', + target.getAttribute('title') || '' + ].join('\n'), true); + } + if (type === 'media') { + const sources = Array.from(target.querySelectorAll('source')).map(function(source) { + return source.getAttribute('src') || ''; + }); + return normalizeReviewSource([ + target.getAttribute('aria-label') || '', + target.getAttribute('title') || '', + target.getAttribute('src') || '', + sources.join('\n') + ].join('\n'), true); + } + if (type === 'math') { + return normalizeReviewSource( + target.getAttribute('aria-label') || target.getAttribute('data-latex') || target.textContent, + false + ); + } + 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-comment-highlight, .review-pending-highlight').forEach(function(mark) { + mark.replaceWith(document.createTextNode(mark.textContent || '')); + }); + 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 getReviewTargetLabel(target, type) { + if (type === 'heading') return 'Heading ' + target.tagName; + if (type === 'paragraph') return 'Paragraph'; + if (type === 'list-item') return 'List item'; + if (type === 'quote') return 'Quote'; + if (type === 'image') return target.getAttribute('alt') ? 'Image: ' + target.getAttribute('alt') : 'Image'; + if (type === 'media') return target.tagName === 'AUDIO' ? 'Audio' : 'Video'; + if (type === 'math') return 'Math expression'; + 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 labels = { geojson: 'GeoJSON map', topojson: 'TopoJSON map', stl: 'STL model' }; + if (labels[engine]) return labels[engine]; + const engineLabel = engine ? getDiagramEngineLabel(engine) : 'Diagram'; + return engineLabel === 'Diagram' ? engineLabel : engineLabel + ' diagram'; + } + return 'Document content'; + } + + function getReviewAnchorDescriptor(target, selection) { + if (!target || !target.dataset.reviewAnchor) return null; + const type = target.dataset.reviewType || getReviewTargetType(target); + const source = getReviewTargetSource(target, type); + const normalizedSelection = normalizeReviewSelection(selection); + const selectedExcerpt = normalizedSelection && normalizedSelection.quote + ? normalizeReviewSource(normalizedSelection.quote, false) + : ''; + const excerptSource = selectedExcerpt || source; + return { + key: target.dataset.reviewAnchor, + type: type, + label: target.dataset.reviewLabel || getReviewTargetLabel(target, type), + excerpt: excerptSource.length > 180 ? excerptSource.slice(0, 177) + '...' : (excerptSource || 'Selected content'), + duplicateCount: Math.max(1, Number(target.dataset.reviewDuplicateCount) || 1), + context: target.dataset.reviewContext || null, + selection: normalizedSelection + }; + } + + function getReviewTargets() { + if (!markdownPreview) return []; + return Array.from(markdownPreview.querySelectorAll(REVIEW_TARGET_SELECTOR)).filter(function(target) { + const diagram = target.closest('.diagram-viewer, .geojson-container, .topojson-container, .stl-container'); + if (diagram && diagram !== target) return false; + const table = target.closest('.frontmatter-table'); + if (table && table !== target) return false; + return !target.closest('button, .review-composer, .review-target-button'); + }); + } + + function unwrapReviewMarks() { + if (!markdownPreview) return; + const parents = new Set(); + markdownPreview.querySelectorAll('.review-comment-highlight, .review-pending-highlight').forEach(function(mark) { + const parent = mark.parentNode; + if (parent) parents.add(parent); + mark.replaceWith(document.createTextNode(mark.textContent || '')); + }); + parents.forEach(function(parent) { parent.normalize(); }); + } + + function clearReviewDecorations() { + if (!markdownPreview) return; + unwrapReviewMarks(); + markdownPreview.querySelectorAll('.review-comment-element, .review-pending-element').forEach(function(element) { + element.classList.remove( + 'review-comment-element', + 'review-pending-element', + 'is-review-highlight-active', + 'is-review-highlight-hover', + 'is-review-highlight-emphasized' + ); + delete element.dataset.reviewIds; + }); + markdownPreview.querySelectorAll('[data-review-anchor]').forEach(function(target) { + target.classList.remove( + 'review-target', + 'review-target--heading', + 'review-target--paragraph', + 'review-target--list-item', + 'review-target--quote', + 'review-target--code', + 'review-target--table', + 'review-target--diagram', + 'review-target--image', + 'review-target--media', + 'review-target--math', + 'review-element-selectable', + 'is-review-element-selected', + 'has-review-native-text-selection', + '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; + delete target.dataset.reviewIds; + }); + } + + function findReviewTarget(anchor) { + const anchorKey = typeof anchor === 'string' ? anchor : (anchor && anchor.key); + if (!markdownPreview || !anchorKey) return null; + let target = Array.from(markdownPreview.querySelectorAll('[data-review-anchor]')).find(function(candidate) { + if (candidate.dataset.reviewAnchor !== anchorKey) return false; + if (!anchor || typeof anchor === 'string') return true; + if (anchor.duplicateCount && Number(candidate.dataset.reviewDuplicateCount) !== Number(anchor.duplicateCount)) return false; + if (anchor.context && candidate.dataset.reviewContext !== anchor.context) return false; + return true; + }) || null; + if (target || !anchor || typeof anchor === 'string') return target; + + const selection = normalizeReviewSelection(anchor.selection); + const quote = selection && selection.quote; + if (quote) { + const matches = getReviewTargets().filter(function(candidate) { + if (anchor.type && candidate.dataset.reviewType !== anchor.type) return false; + return (candidate.textContent || '').includes(quote); + }); + if (matches.length === 1) return matches[0]; + target = matches.find(function(candidate) { + const text = candidate.textContent || ''; + const index = text.indexOf(quote); + const prefix = text.slice(Math.max(0, index - (selection.prefix || '').length), index); + const suffix = text.slice(index + quote.length, index + quote.length + (selection.suffix || '').length); + return (!selection.prefix || prefix.endsWith(selection.prefix)) && + (!selection.suffix || suffix.startsWith(selection.suffix)); + }) || null; + if (target) return target; + } + + const excerpt = normalizeReviewSource(anchor.excerpt || '', false); + if (!excerpt) return null; + const sourceMatches = getReviewTargets().filter(function(candidate) { + if (anchor.type && candidate.dataset.reviewType !== anchor.type) return false; + const source = getReviewTargetSource(candidate, candidate.dataset.reviewType); + return source === excerpt || source.startsWith(excerpt.replace(/\.\.\.$/, '')); + }); + return sourceMatches.length === 1 ? sourceMatches[0] : null; + } + + function addReviewId(element, reviewId) { + if (!element || !reviewId) return; + const ids = new Set(String(element.dataset.reviewIds || '').split(/\s+/).filter(Boolean)); + ids.add(reviewId); + element.dataset.reviewIds = Array.from(ids).join(' '); + } + + function resolveTextSelectionOffsets(target, selection) { + if (!target || !selection || selection.kind !== 'text') return null; + const text = target.textContent || ''; + let start = Math.max(0, Math.min(text.length, Number(selection.start) || 0)); + let end = Math.max(start, Math.min(text.length, Number(selection.end) || start)); + if (text.slice(start, end) === selection.quote) return { start: start, end: end }; + const matches = []; + let cursor = 0; + while (selection.quote && cursor <= text.length) { + const index = text.indexOf(selection.quote, cursor); + if (index < 0) break; + matches.push(index); + cursor = index + Math.max(1, selection.quote.length); + } + if (matches.length === 0) return null; + let best = matches[0]; + let bestScore = -1; + matches.forEach(function(index) { + let score = 0; + const prefix = text.slice(Math.max(0, index - (selection.prefix || '').length), index); + const suffix = text.slice(index + selection.quote.length, index + selection.quote.length + (selection.suffix || '').length); + if (selection.prefix && prefix.endsWith(selection.prefix)) score += 2; + if (selection.suffix && suffix.startsWith(selection.suffix)) score += 2; + score -= Math.abs(index - start) / Math.max(1, text.length); + if (score > bestScore) { + bestScore = score; + best = index; + } + }); + return { start: best, end: best + selection.quote.length }; + } + + function applyTextReviewHighlights(target, entries) { + if (!target || !entries || entries.length === 0) return; + const textNodes = []; + const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + const parent = node.parentElement; + if (!node.nodeValue || !parent || parent.closest('script, style, button, textarea')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + let node; + let offset = 0; + while ((node = walker.nextNode())) { + textNodes.push({ node: node, start: offset, end: offset + node.nodeValue.length }); + offset += node.nodeValue.length; + } + + textNodes.forEach(function(entry) { + const boundaries = new Set([0, entry.node.nodeValue.length]); + entries.forEach(function(highlight) { + const start = Math.max(entry.start, highlight.start); + const end = Math.min(entry.end, highlight.end); + if (end > start) { + boundaries.add(start - entry.start); + boundaries.add(end - entry.start); + } + }); + const cuts = Array.from(boundaries).sort(function(a, b) { return a - b; }); + if (cuts.length <= 2) return; + const fragment = document.createDocumentFragment(); + for (let index = 0; index < cuts.length - 1; index += 1) { + const localStart = cuts[index]; + const localEnd = cuts[index + 1]; + const globalStart = entry.start + localStart; + const globalEnd = entry.start + localEnd; + const covering = entries.filter(function(highlight) { + return highlight.start < globalEnd && highlight.end > globalStart; + }); + const text = entry.node.nodeValue.slice(localStart, localEnd); + if (covering.length === 0) { + fragment.appendChild(document.createTextNode(text)); + continue; + } + const mark = document.createElement('mark'); + mark.className = covering.some(function(item) { return item.pending; }) + ? 'review-pending-highlight' + : 'review-comment-highlight'; + if (covering.some(function(item) { return item.pending && item.start === globalStart; })) { + mark.classList.add('review-selection-start'); + } + if (covering.some(function(item) { return item.pending && item.end === globalEnd; })) { + mark.classList.add('review-selection-end'); + } + const startIds = covering.filter(function(item) { + return !item.pending && item.start === globalStart; + }).map(function(item) { return item.id; }); + const endIds = covering.filter(function(item) { + return !item.pending && item.end === globalEnd; + }).map(function(item) { return item.id; }); + if (startIds.length) mark.dataset.reviewStartIds = startIds.join(' '); + if (endIds.length) mark.dataset.reviewEndIds = endIds.join(' '); + covering.filter(function(item) { return !item.pending; }).forEach(function(item) { + addReviewId(mark, item.id); + }); + mark.textContent = text; + fragment.appendChild(mark); + } + entry.node.replaceWith(fragment); + }); + } + + function decorateReviewTargets() { + clearReviewDecorations(); + if (!reviewModeActive || !markdownPreview || previewContainsSkeleton()) { + updateReviewNewButton(); + return; + } + + 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 occurrence = occurrences.get(entry.signature) || 0; + occurrences.set(entry.signature, occurrence + 1); + const duplicateCount = signatureCounts.get(entry.signature) || 1; + const context = duplicateCount > 1 ? entry.context : ''; + entry.target.dataset.reviewAnchor = entry.type + ':' + hashReviewSource(entry.signature) + ':' + occurrence; + entry.target.dataset.reviewType = entry.type; + entry.target.dataset.reviewLabel = getReviewTargetLabel(entry.target, entry.type); + entry.target.dataset.reviewDuplicateCount = String(duplicateCount); + entry.target.dataset.reviewContext = context; + entry.target.classList.add('review-target', 'review-target--' + entry.type); + if (REVIEW_ELEMENT_TYPES.has(entry.type)) entry.target.classList.add('review-element-selectable'); + }); + + const textHighlights = new Map(); + getActiveReviewThreads().forEach(function(thread) { + const target = findReviewTarget(thread.anchor); + if (!target) return; + const selection = normalizeReviewSelection(thread.anchor.selection); + target.classList.add('has-review-thread'); + if (selection && selection.kind === 'text') { + const offsets = resolveTextSelectionOffsets(target, selection); + if (!offsets) return; + if (!textHighlights.has(target)) textHighlights.set(target, []); + textHighlights.get(target).push({ id: thread.id, start: offsets.start, end: offsets.end, pending: false }); + return; + } + target.classList.add('review-comment-element'); + addReviewId(target, thread.id); + }); + + const pendingAnchor = reviewComposer && !reviewComposer.hidden && !activeReviewEditId + ? activeReviewAnchor + : pendingReviewSelection; + if (pendingAnchor) { + const pendingTarget = findReviewTarget(pendingAnchor); + const pendingSelection = normalizeReviewSelection(pendingAnchor.selection); + if (pendingTarget && (!pendingSelection || pendingSelection.kind !== 'text')) { + pendingTarget.classList.add('review-pending-element'); + } + } + + textHighlights.forEach(function(entries, target) { + applyTextReviewHighlights(target, entries); + }); + syncReviewInteractionState(); + updateReviewNewButton(); + } + + function elementHasReviewId(element, reviewId) { + return Boolean(element && reviewId && String(element.dataset.reviewIds || '').split(/\s+/).includes(reviewId)); + } + + function getReviewDocumentNodes(reviewId) { + if (!markdownPreview || !reviewId) return []; + return Array.from(markdownPreview.querySelectorAll('[data-review-ids]')).filter(function(element) { + return elementHasReviewId(element, reviewId); + }); + } + + function syncReviewInteractionState() { + const emphasizedReviewId = hoveredReviewId || activeReviewId; + if (markdownPreview) { + markdownPreview.querySelectorAll('[data-review-ids]').forEach(function(element) { + const isActive = elementHasReviewId(element, activeReviewId); + const isHovered = elementHasReviewId(element, hoveredReviewId); + const isEmphasized = elementHasReviewId(element, emphasizedReviewId); + element.classList.toggle('is-review-highlight-active', isActive); + element.classList.toggle('is-review-highlight-hover', !isActive && isHovered); + element.classList.toggle('is-review-highlight-emphasized', isEmphasized); + element.classList.toggle( + 'is-review-selection-start', + isActive && String(element.dataset.reviewStartIds || '').split(/\s+/).includes(activeReviewId) + ); + element.classList.toggle( + 'is-review-selection-end', + isActive && String(element.dataset.reviewEndIds || '').split(/\s+/).includes(activeReviewId) + ); + }); + const pendingElement = pendingReviewSelection && + pendingReviewSelection.selection && + pendingReviewSelection.selection.kind !== 'text' + ? findReviewTarget(pendingReviewSelection) + : null; + const pendingTextTarget = pendingReviewSelection && + pendingReviewSelection.selection && + pendingReviewSelection.selection.kind === 'text' + ? findReviewTarget(pendingReviewSelection) + : null; + markdownPreview.querySelectorAll('.review-element-selectable').forEach(function(element) { + element.classList.toggle('is-review-element-selected', element === pendingElement); + element.classList.toggle('has-review-native-text-selection', element === pendingTextTarget); + }); + } + if (reviewList) { + reviewList.querySelectorAll('.review-thread').forEach(function(item) { + const isActive = item.dataset.reviewId === activeReviewId; + const isHovered = item.dataset.reviewId === hoveredReviewId; + const isEmphasized = item.dataset.reviewId === emphasizedReviewId; + item.classList.toggle('is-review-thread-active', isActive); + item.classList.toggle('is-review-thread-hover', !isActive && isHovered); + item.classList.toggle('is-review-thread-emphasized', isEmphasized); + item.setAttribute('aria-expanded', isActive ? 'true' : 'false'); + }); + } + } + + function setReviewHover(reviewId) { + hoveredReviewId = reviewId || null; + syncReviewInteractionState(); + } + + function activateReviewThread(thread, options) { + if (!thread) return; + options = options || {}; + activeReviewId = thread.id; + pendingReviewSelection = null; + if (!activeReviewEditId) closeReviewComposer({ keepSelection: false }); + if (reviewFilter === 'resolved' && !thread.resolved) setReviewFilter('all'); + if (reviewFilter === 'open' && thread.resolved) setReviewFilter('all'); + syncReviewInteractionState(); + + const card = reviewList && reviewList.querySelector('.review-thread[data-review-id="' + thread.id + '"]'); + if (options.scrollPanel !== false && card) { + card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (options.focusCard) card.focus({ preventScroll: true }); + } + const nodes = getReviewDocumentNodes(thread.id); + const documentNode = nodes.find(function(node) { + return node.classList.contains('review-comment-highlight') || node.classList.contains('review-comment-element'); + }) || nodes[0] || findReviewTarget(thread.anchor); + if (options.scrollDocument && documentNode) { + documentNode.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); + } + announceToScreenReader('Comment by ' + (thread.author || getCurrentReviewAuthor()) + ' selected.'); + } + + 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 || count > 0); + if (mobileReviewStatus) mobileReviewStatus.textContent = reviewModeActive ? 'Open' : 'Off'; + } + + function updateReviewCountBadges() { + const openCount = getActiveReviewThreads().filter(function(thread) { return !thread.resolved; }).length; + const countLabel = openCount + ' open comment' + (openCount === 1 ? '' : 's'); + if (reviewToolbarCount) { + reviewToolbarCount.textContent = String(openCount); + reviewToolbarCount.hidden = openCount === 0; + } + if (reviewToggle) { + reviewToggle.setAttribute('aria-label', openCount > 0 ? 'Open comments. ' + countLabel + '.' : 'Open comments'); + reviewToggle.title = openCount > 0 ? 'Open comments (' + countLabel + ')' : 'Open comments'; + } + updateReviewToggleActiveState(openCount); + } + + function createReviewThreadAction(action, iconClass, label, danger, reviewId) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'review-thread-action' + (danger ? ' is-danger' : ''); + button.dataset.reviewAction = action; + button.dataset.reviewId = reviewId; + button.title = label; + button.setAttribute('aria-label', label); + const icon = document.createElement('i'); + icon.className = iconClass; + icon.setAttribute('aria-hidden', 'true'); + button.appendChild(icon); + return button; + } + + function createReviewReplyAction(action, iconClass, label, danger, reviewId, replyId) { + const button = createReviewThreadAction(action, iconClass, label, danger, reviewId); + button.classList.add('review-reply-action'); + button.dataset.reviewReplyId = replyId; + return button; + } + + function createReviewReplyElement(reply, thread) { + const author = reply.author || 'Author'; + const item = document.createElement('div'); + item.className = 'review-reply'; + item.dataset.reviewReplyId = reply.id; + + const header = document.createElement('div'); + header.className = 'review-reply-header'; + const avatar = document.createElement('span'); + avatar.className = 'review-author-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = getReviewAvatarLabel(author); + const name = document.createElement('strong'); + name.className = 'review-author-name'; + name.textContent = author; + const timestamp = document.createElement('time'); + timestamp.className = 'review-reply-time'; + timestamp.dateTime = new Date(reply.createdAt).toISOString(); + timestamp.textContent = formatReviewTime(reply.createdAt); + const authorMeta = document.createElement('span'); + authorMeta.className = 'review-author-meta'; + authorMeta.appendChild(name); + authorMeta.appendChild(timestamp); + header.appendChild(avatar); + header.appendChild(authorMeta); + + const actions = document.createElement('div'); + actions.className = 'review-reply-actions'; + actions.setAttribute('role', 'group'); + actions.setAttribute('aria-label', 'Reply actions'); + actions.appendChild(createReviewReplyAction( + 'edit-reply', + 'lucide lucide-square-pen', + 'Edit reply', + false, + thread.id, + reply.id + )); + actions.appendChild(createReviewReplyAction( + 'delete-reply', + 'lucide lucide-trash-2', + 'Delete reply', + true, + thread.id, + reply.id + )); + header.appendChild(actions); + item.appendChild(header); + + const isEditing = activeReviewReplyEdit && + activeReviewReplyEdit.threadId === thread.id && + activeReviewReplyEdit.replyId === reply.id; + if (isEditing) { + item.classList.add('is-editing'); + const form = document.createElement('form'); + form.className = 'review-reply-edit-form'; + form.dataset.reviewId = thread.id; + form.dataset.reviewReplyId = reply.id; + const input = document.createElement('textarea'); + input.className = 'review-reply-edit-input'; + input.maxLength = REVIEW_TEXT_LIMIT; + input.rows = 2; + input.value = reply.body; + input.setAttribute('aria-label', 'Edit reply'); + const controls = document.createElement('div'); + controls.className = 'review-reply-edit-actions'; + controls.appendChild(createReviewReplyAction( + 'save-reply', + 'lucide lucide-check', + 'Save reply', + false, + thread.id, + reply.id + )); + controls.appendChild(createReviewReplyAction( + 'cancel-reply-edit', + 'lucide lucide-x', + 'Cancel reply edit', + false, + thread.id, + reply.id + )); + form.appendChild(input); + form.appendChild(controls); + item.appendChild(form); + } else { + const body = document.createElement('p'); + body.className = 'review-reply-body'; + body.textContent = reply.body; + item.appendChild(body); + } + return item; + } + + function createReviewConversation(thread) { + const conversation = document.createElement('section'); + conversation.className = 'review-thread-conversation'; + conversation.setAttribute('aria-label', 'Replies'); + + const replies = document.createElement('div'); + replies.className = 'review-replies'; + const threadReplies = thread.replies || []; + const isCollapsible = threadReplies.length > REVIEW_REPLY_COLLAPSE_THRESHOLD; + const isExpanded = isCollapsible && expandedReviewThreadIds.has(thread.id); + threadReplies.forEach(function(reply, index) { + const isMiddleReply = isCollapsible && index > 0 && index < threadReplies.length - 1; + if (!isExpanded && isMiddleReply) { + if (index !== 1) return; + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'review-replies-more'; + more.dataset.reviewAction = 'toggle-replies'; + more.dataset.reviewId = thread.id; + more.setAttribute('aria-expanded', 'false'); + more.textContent = 'Show ' + (threadReplies.length - 2) + ' more replies'; + replies.appendChild(more); + return; + } + replies.appendChild(createReviewReplyElement(reply, thread)); + }); + conversation.appendChild(replies); + + if (!thread.resolved) { + const form = document.createElement('form'); + form.className = 'review-reply-composer'; + form.dataset.reviewId = thread.id; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'review-reply-input'; + input.maxLength = REVIEW_TEXT_LIMIT; + input.placeholder = 'Reply'; + input.setAttribute('aria-label', 'Reply to comment'); + const submit = document.createElement('button'); + submit.type = 'submit'; + submit.className = 'review-reply-submit'; + submit.dataset.reviewAction = 'submit-reply'; + submit.dataset.reviewId = thread.id; + submit.title = 'Post reply'; + submit.setAttribute('aria-label', 'Post reply'); + const icon = document.createElement('i'); + icon.className = 'lucide lucide-check'; + icon.setAttribute('aria-hidden', 'true'); + submit.appendChild(icon); + form.appendChild(input); + form.appendChild(submit); + conversation.appendChild(form); + } + return conversation; + } + + function createReviewThreadElement(thread) { + const target = findReviewTarget(thread.anchor); + const author = thread.author || 'Author'; + const isRepliesCollapsible = (thread.replies || []).length > REVIEW_REPLY_COLLAPSE_THRESHOLD; + const isRepliesExpanded = isRepliesCollapsible && expandedReviewThreadIds.has(thread.id); + const item = document.createElement('article'); + item.className = 'review-thread' + + (thread.resolved ? ' is-resolved' : '') + + (!target ? ' is-orphaned' : '') + + (isRepliesCollapsible ? ' has-collapsible-replies' : '') + + (isRepliesExpanded ? ' has-expanded-replies' : ''); + item.dataset.reviewId = thread.id; + item.dataset.kind = thread.kind; + item.tabIndex = 0; + item.setAttribute('role', 'group'); + item.setAttribute('aria-expanded', thread.id === activeReviewId ? 'true' : 'false'); + item.setAttribute('aria-label', 'Comment by ' + author + ': ' + thread.body); + + const header = document.createElement('div'); + header.className = 'review-thread-header'; + const authorRow = document.createElement('div'); + authorRow.className = 'review-thread-author'; + const avatar = document.createElement('span'); + avatar.className = 'review-author-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = getReviewAvatarLabel(author); + const name = document.createElement('strong'); + name.className = 'review-author-name'; + name.textContent = author; + const dates = document.createElement('span'); + dates.className = 'review-thread-dates'; + const opened = document.createElement('time'); + opened.className = 'review-thread-time'; + opened.dateTime = new Date(thread.createdAt).toISOString(); + opened.textContent = formatReviewTime(thread.createdAt); + opened.title = 'Opened ' + formatReviewTime(thread.createdAt); + opened.setAttribute('aria-label', opened.title); + dates.appendChild(opened); + if (thread.resolvedAt) { + const closed = document.createElement('time'); + closed.className = 'review-thread-time is-closed'; + closed.dateTime = new Date(thread.resolvedAt).toISOString(); + closed.textContent = 'Closed: ' + formatReviewTime(thread.resolvedAt); + dates.appendChild(closed); + } + const authorMeta = document.createElement('span'); + authorMeta.className = 'review-author-meta'; + authorMeta.appendChild(name); + authorMeta.appendChild(dates); + authorRow.appendChild(avatar); + authorRow.appendChild(authorMeta); + if (thread.resolved) { + const resolved = document.createElement('span'); + resolved.className = 'review-status-label'; + resolved.title = 'Resolved'; + resolved.setAttribute('aria-label', 'Resolved'); + const icon = document.createElement('i'); + icon.className = 'lucide lucide-check'; + icon.setAttribute('aria-hidden', 'true'); + resolved.appendChild(icon); + authorRow.appendChild(resolved); + } + header.appendChild(authorRow); + + const actions = document.createElement('div'); + actions.className = 'review-thread-actions'; + actions.setAttribute('role', 'group'); + actions.setAttribute('aria-label', 'Comment actions'); + actions.appendChild(createReviewThreadAction('edit', 'lucide lucide-square-pen', 'Edit comment', false, thread.id)); + if (isRepliesCollapsible) { + const toggleReplies = createReviewThreadAction( + 'toggle-replies', + isRepliesExpanded ? 'lucide lucide-fold-vertical' : 'lucide lucide-unfold-vertical', + isRepliesExpanded ? 'Collapse replies' : 'Expand replies', + false, + thread.id + ); + toggleReplies.setAttribute('aria-expanded', isRepliesExpanded ? 'true' : 'false'); + actions.appendChild(toggleReplies); + } + actions.appendChild(createReviewThreadAction( + 'toggle-resolved', + thread.resolved ? 'lucide lucide-refresh-cw' : 'lucide lucide-check', + thread.resolved ? 'Reopen comment' : 'Resolve comment', + false, + thread.id + )); + actions.appendChild(createReviewThreadAction('delete', 'lucide lucide-trash-2', 'Delete comment', true, thread.id)); + header.appendChild(actions); + item.appendChild(header); + + const body = document.createElement('p'); + body.className = 'review-thread-body'; + body.textContent = thread.body; + item.appendChild(body); + item.appendChild(createReviewConversation(thread)); + return item; + } + + function renderReviewPanel() { + updateReviewCountBadges(); + if (!reviewPanel || !reviewList || !reviewEmptyState) return; + const threads = getActiveReviewThreads(); + if (reviewPanelSummary) { + reviewPanelSummary.textContent = threads.length + ' comment' + (threads.length === 1 ? '' : 's'); + } + 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 || (reviewComposer && !reviewComposer.hidden); + if (filteredThreads.length === 0) { + const title = reviewEmptyState.querySelector('h3'); + const copy = reviewEmptyState.querySelector('p'); + if (reviewFilter === 'resolved') { + title.textContent = 'No resolved comments'; + copy.textContent = 'Resolved comments will appear here.'; + } else if (reviewFilter === 'all') { + title.textContent = 'No comments yet'; + copy.textContent = 'Select content in the document, then choose New.'; + } else { + title.textContent = threads.length ? 'No open comments' : 'No comments yet'; + copy.textContent = threads.length + ? 'All comments are resolved. Use All to review them.' + : 'Select content in the document, then choose New.'; + } + } + syncReviewInteractionState(); + updateReviewNewButton(); + } + + 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) { + const target = findReviewTarget(anchor); + if (!target) return []; + return getActiveReviewThreads().filter(function(thread) { + return findReviewTarget(thread.anchor) === target; + }); + } + + function showReviewThreadsForAnchor(anchor) { + const threads = getReviewThreadsForAnchor(anchor).sort(function(a, b) { return b.createdAt - a.createdAt; }); + if (!threads.length) return false; + activateReviewThread(threads[0], { scrollPanel: true, focusCard: true }); + return true; + } + + function updateReviewNewButton() { + if (!reviewNewComment) return; + const hasSelection = Boolean(pendingReviewSelection && findReviewTarget(pendingReviewSelection)); + const composerOpen = Boolean(reviewComposer && !reviewComposer.hidden); + reviewNewComment.disabled = !reviewModeActive || !hasSelection || composerOpen; + reviewNewComment.title = hasSelection + ? 'Create a comment for the selected content' + : 'Select document content first'; + reviewNewComment.setAttribute('aria-label', hasSelection + ? 'New comment for selected content' + : 'New comment. Select document content first.'); + } + + function buildTextReviewSelection(target, range) { + if (!target || !range || range.collapsed) return null; + const before = document.createRange(); + before.selectNodeContents(target); + before.setEnd(range.startContainer, range.startOffset); + const through = document.createRange(); + through.selectNodeContents(target); + through.setEnd(range.endContainer, range.endOffset); + let start = before.toString().length; + let end = through.toString().length; + const rawQuote = range.toString(); + const leading = (rawQuote.match(/^\s*/) || [''])[0].length; + const trailing = (rawQuote.match(/\s*$/) || [''])[0].length; + start += leading; + end -= trailing; + if (end <= start) return null; + const text = target.textContent || ''; + const quote = text.slice(start, end) || rawQuote.trim(); + if (!quote) return null; + return { + kind: 'text', + start: start, + end: end, + quote: quote.slice(0, 1000), + prefix: text.slice(Math.max(0, start - 48), start), + suffix: text.slice(end, end + 48) + }; + } + + function getReviewNativeTextRange(anchor) { + const selection = anchor && normalizeReviewSelection(anchor.selection); + const target = selection && selection.kind === 'text' ? findReviewTarget(anchor) : null; + const offsets = target && resolveTextSelectionOffsets(target, selection); + if (!target || !offsets) return null; + const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, { + acceptNode: function(node) { + const parent = node.parentElement; + if (!node.nodeValue || !parent || parent.closest('script, style, button, textarea')) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + let node; + let cursor = 0; + let startBoundary = null; + let endBoundary = null; + while ((node = walker.nextNode())) { + const nextCursor = cursor + node.nodeValue.length; + if (!startBoundary && offsets.start >= cursor && offsets.start <= nextCursor) { + startBoundary = { node: node, offset: offsets.start - cursor }; + } + if (!endBoundary && offsets.end >= cursor && offsets.end <= nextCursor) { + endBoundary = { node: node, offset: offsets.end - cursor }; + } + cursor = nextCursor; + if (startBoundary && endBoundary) break; + } + if (!startBoundary || !endBoundary) return null; + const range = document.createRange(); + range.setStart(startBoundary.node, startBoundary.offset); + range.setEnd(endBoundary.node, endBoundary.offset); + return range; + } + + function clearReviewComposerSelection() { + if (window.CSS && CSS.highlights) CSS.highlights.delete('review-pending-native-selection'); + } + + function showReviewComposerSelection(anchor) { + clearReviewComposerSelection(); + const range = getReviewNativeTextRange(anchor); + if (!range) return false; + if (window.CSS && CSS.highlights && typeof window.Highlight === 'function') { + CSS.highlights.set('review-pending-native-selection', new Highlight(range)); + return true; + } + if (!window.getSelection) return false; + const nativeSelection = window.getSelection(); + nativeSelection.removeAllRanges(); + nativeSelection.addRange(range); + return true; + } + + function setPendingReviewSelection(descriptor, options) { + if (!descriptor) return; + options = options || {}; + pendingReviewSelection = descriptor; + activeReviewId = null; + hoveredReviewId = null; + const selection = normalizeReviewSelection(descriptor.selection); + if (!selection || selection.kind !== 'text') decorateReviewTargets(); + renderReviewPanel(); + if (options.announce !== false) { + announceToScreenReader('Content selected. Choose New to add a comment.'); + } + } + + function captureReviewTextSelection() { + if (!reviewModeActive || (reviewComposer && !reviewComposer.hidden)) return false; + const selection = window.getSelection && window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false; + const range = selection.getRangeAt(0); + const startElement = range.startContainer.nodeType === Node.ELEMENT_NODE + ? range.startContainer + : range.startContainer.parentElement; + const endElement = range.endContainer.nodeType === Node.ELEMENT_NODE + ? range.endContainer + : range.endContainer.parentElement; + if (!startElement || !endElement || !markdownPreview.contains(startElement) || !markdownPreview.contains(endElement)) return false; + const startTarget = startElement.closest('[data-review-anchor]'); + const endTarget = endElement.closest('[data-review-anchor]'); + if (!startTarget || startTarget !== endTarget) { + announceToScreenReader('Select content within one paragraph, heading, list item, or content block.'); + return false; + } + const textSelection = buildTextReviewSelection(startTarget, range); + if (!textSelection) return false; + const descriptor = getReviewAnchorDescriptor(startTarget, textSelection); + if (!descriptor) return false; + setPendingReviewSelection(descriptor); + return true; + } + + function selectReviewElement(target) { + if (!target || !target.dataset.reviewAnchor) return; + const type = target.dataset.reviewType || getReviewTargetType(target); + if (!REVIEW_ELEMENT_TYPES.has(type)) return; + const descriptor = getReviewAnchorDescriptor(target, { kind: 'element', elementType: type }); + if (descriptor) setPendingReviewSelection(descriptor); + } + + function reviewClickIsInsideNativeSelection(event, pendingSelection) { + const selection = window.getSelection && window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return false; + if (selection.toString().trim() !== String(pendingSelection.quote || '').trim()) return false; + const pointX = Number(event.clientX); + const pointY = Number(event.clientY); + return Array.from(selection.getRangeAt(0).getClientRects()).some(function(rect) { + return pointX >= rect.left && pointX <= rect.right && pointY >= rect.top && pointY <= rect.bottom; + }); + } + + function focusReviewComposerInput() { + if (!reviewFeedbackInput) return; + reviewFeedbackInput.focus({ preventScroll: true }); + const cursorPosition = reviewFeedbackInput.value.length; + reviewFeedbackInput.setSelectionRange(cursorPosition, cursorPosition); + requestAnimationFrame(function() { + if (reviewComposer && !reviewComposer.hidden && reviewPanelBody) reviewPanelBody.scrollTop = 0; + }); + } + + function clearReviewNativeSelection() { + const selection = window.getSelection && window.getSelection(); + if (selection && selection.rangeCount > 0) selection.removeAllRanges(); + } + + function dismissEmptyNewReviewComposer() { + if (!reviewComposer || reviewComposer.hidden || activeReviewEditId) return false; + if (reviewFeedbackInput && reviewFeedbackInput.value.trim()) return false; + pendingReviewSelection = null; + clearReviewNativeSelection(); + closeReviewComposer({ keepSelection: false }); + announceToScreenReader('Empty comment cancelled and content selection cleared.'); + return true; + } + + function closeReviewComposer(options) { + options = options || {}; + const wasEditing = Boolean(activeReviewEditId); + clearReviewComposerSelection(); + 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 (!options.keepSelection && wasEditing) pendingReviewSelection = null; + decorateReviewTargets(); + renderReviewPanel(); + if (options.restoreFocus && reviewModeActive) { + if (reviewNewComment && !reviewNewComment.disabled) reviewNewComment.focus(); + else if (reviewPanelClose) reviewPanelClose.focus(); + } + } + + function populateReviewComposer(author, anchor, isEditing) { + if (reviewComposer) reviewComposer.setAttribute('aria-label', isEditing ? 'Edit comment' : 'New comment'); + if (reviewComposerTitle) reviewComposerTitle.textContent = isEditing ? 'Edit comment' : 'New comment'; + if (reviewComposerAuthor) reviewComposerAuthor.textContent = author; + if (reviewComposerAvatar) reviewComposerAvatar.textContent = getReviewAvatarLabel(author); + if (reviewComposerAnchor) reviewComposerAnchor.textContent = anchor.excerpt || anchor.label; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.textContent = isEditing ? 'Save' : 'Submit'; + } + + function openNewReviewComposer() { + if (!pendingReviewSelection) { + announceToScreenReader('Select document content before creating a comment.'); + return; + } + const target = findReviewTarget(pendingReviewSelection); + if (!target) { + pendingReviewSelection = null; + updateReviewNewButton(); + announceToScreenReader('The selected content is no longer available. Select it again.'); + return; + } + activeReviewEditId = null; + activeReviewAnchor = Object.assign({}, pendingReviewSelection, { + selection: pendingReviewSelection.selection ? Object.assign({}, pendingReviewSelection.selection) : null + }); + activeReviewId = null; + populateReviewComposer(getCurrentReviewAuthor(), activeReviewAnchor, false); + if (reviewComposer) reviewComposer.hidden = false; + if (reviewFeedbackInput) reviewFeedbackInput.value = ''; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = '0 / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = true; + const selection = normalizeReviewSelection(activeReviewAnchor.selection); + if (!selection || selection.kind !== 'text') decorateReviewTargets(); + renderReviewPanel(); + focusReviewComposerInput(); + if (selection && selection.kind === 'text') { + showReviewComposerSelection(activeReviewAnchor); + clearReviewNativeSelection(); + } + } + + function openReviewComposer() { + openNewReviewComposer(); + } + + function openReviewEditor(thread) { + if (!thread || !reviewComposer || !reviewFeedbackInput) return; + clearReviewComposerSelection(); + pendingReviewSelection = null; + activeReviewEditId = thread.id; + activeReviewId = thread.id; + activeReviewAnchor = Object.assign({}, thread.anchor, { + selection: thread.anchor.selection ? Object.assign({}, thread.anchor.selection) : null + }); + populateReviewComposer(thread.author || getCurrentReviewAuthor(), activeReviewAnchor, true); + reviewComposer.hidden = false; + reviewFeedbackInput.value = thread.body; + if (reviewFeedbackCount) reviewFeedbackCount.textContent = reviewFeedbackInput.value.length + ' / ' + REVIEW_TEXT_LIMIT; + if (reviewFeedbackSubmit) reviewFeedbackSubmit.disabled = false; + decorateReviewTargets(); + renderReviewPanel(); + focusReviewComposerInput(); + reviewFeedbackInput.setSelectionRange(reviewFeedbackInput.value.length, reviewFeedbackInput.value.length); + announceToScreenReader('Editing comment.'); + } + + function updateReviewComposerKind() { + // Retained as a compatibility no-op for legacy callers. The v2 UI only creates comments. + } + + function focusReviewPin() { + if (reviewNewComment && !reviewNewComment.disabled) { + reviewNewComment.focus({ preventScroll: true }); + return true; + } + return false; + } + + function submitReviewFeedback() { + if (!activeReviewAnchor || !reviewFeedbackInput) return; + const body = reviewFeedbackInput.value.trim(); + if (!body) return; + if (activeReviewEditId) { + const reviewId = activeReviewEditId; + const thread = getActiveReviewThreads().find(function(item) { return item.id === reviewId; }); + if (!thread) { + closeReviewComposer(); + announceToScreenReader('This comment is no longer available.'); + return; + } + thread.body = body.slice(0, REVIEW_TEXT_LIMIT); + thread.updatedAt = Date.now(); + clearReviewComposerSelection(); + activeReviewEditId = null; + activeReviewAnchor = null; + if (reviewComposer) reviewComposer.hidden = true; + persistReviewThreads('Comment updated.'); + activateReviewThread(thread, { scrollPanel: true, focusCard: true }); + return; + } + + const now = Date.now(); + const authorIdentity = getCurrentReviewAuthorIdentity(); + const thread = { + id: createReviewId(), + anchor: Object.assign({}, activeReviewAnchor, { + selection: activeReviewAnchor.selection ? Object.assign({}, activeReviewAnchor.selection) : null + }), + kind: 'comment', + author: authorIdentity.name, + authorId: authorIdentity.id, + body: body.slice(0, REVIEW_TEXT_LIMIT), + createdAt: now, + updatedAt: now, + resolved: false, + resolvedAt: null, + replies: [] + }; + getActiveReviewThreads().push(thread); + clearReviewComposerSelection(); + pendingReviewSelection = null; + activeReviewAnchor = null; + activeReviewId = thread.id; + if (reviewComposer) reviewComposer.hidden = true; + if (reviewFeedbackInput) reviewFeedbackInput.value = ''; + persistReviewThreads('Comment added.'); + activateReviewThread(thread, { scrollPanel: true, focusCard: true }); + } + + function submitReviewReply(thread, form) { + if (!thread || thread.resolved || !form) return; + const input = form.querySelector('.review-reply-input'); + const body = input ? input.value.trim() : ''; + if (!body) { + if (input) input.focus(); + return; + } + const now = Date.now(); + const authorIdentity = getCurrentReviewAuthorIdentity(); + if (!Array.isArray(thread.replies)) thread.replies = []; + thread.replies.push({ + id: createReviewId(), + author: authorIdentity.name, + authorId: authorIdentity.id, + body: body.slice(0, REVIEW_TEXT_LIMIT), + createdAt: now, + updatedAt: now + }); + thread.updatedAt = now; + activeReviewId = thread.id; + persistReviewThreads('Reply added.'); + requestAnimationFrame(function() { + const card = reviewList && reviewList.querySelector('.review-thread[data-review-id="' + thread.id + '"]'); + const nextInput = card && card.querySelector('.review-reply-input'); + if (nextInput) nextInput.focus(); + }); + } + + function editReviewReply(thread, replyId) { + const reply = thread && Array.isArray(thread.replies) + ? thread.replies.find(function(item) { return item.id === replyId; }) + : null; + if (!reply) return; + activeReviewReplyEdit = { threadId: thread.id, replyId: reply.id }; + activeReviewId = thread.id; + renderReviewPanel(); + requestAnimationFrame(function() { + const input = reviewList && reviewList.querySelector( + '.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input' + ); + if (input) { + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + } + }); + } + + function saveReviewReply(thread, replyId, form) { + const reply = thread && Array.isArray(thread.replies) + ? thread.replies.find(function(item) { return item.id === replyId; }) + : null; + const input = form && form.querySelector('.review-reply-edit-input'); + const body = input ? input.value.trim() : ''; + if (!reply || !body) { + if (input) input.focus(); + return; + } + const now = Date.now(); + reply.body = body.slice(0, REVIEW_TEXT_LIMIT); + reply.updatedAt = now; + thread.updatedAt = now; + activeReviewReplyEdit = null; + activeReviewId = thread.id; + persistReviewThreads('Reply updated.'); + } + + function deleteReviewReply(thread, replyId) { + if (!thread || !Array.isArray(thread.replies)) return; + const replyIndex = thread.replies.findIndex(function(item) { return item.id === replyId; }); + if (replyIndex < 0) return; + thread.replies.splice(replyIndex, 1); + thread.updatedAt = Date.now(); + if (activeReviewReplyEdit && activeReviewReplyEdit.threadId === thread.id && activeReviewReplyEdit.replyId === replyId) { + activeReviewReplyEdit = null; + } + if (thread.replies.length <= REVIEW_REPLY_COLLAPSE_THRESHOLD) expandedReviewThreadIds.delete(thread.id); + activeReviewId = thread.id; + persistReviewThreads('Reply deleted.'); + } + + function focusReviewAnchor(thread) { + activateReviewThread(thread, { scrollPanel: false, scrollDocument: true }); + } + + 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 = [ + '# Comments: ' + (tab && tab.title ? tab.title : 'Markdown document'), + '', + '- Generated: ' + formatReviewTime(Date.now()), + '- Total comments: ' + threads.length, + '- Open comments: ' + (threads.length - resolvedCount), + '- Resolved comments: ' + resolvedCount, + '' + ]; + threads.forEach(function(thread, index) { + lines.push((index + 1) + '. **' + (thread.author || getCurrentReviewAuthor()) + '**'); + lines.push(' - Status: ' + (thread.resolved ? 'Resolved' : 'Open')); + lines.push(' - Opened: ' + formatReviewTime(thread.createdAt)); + if (thread.resolvedAt) lines.push(' - Closed: ' + formatReviewTime(thread.resolvedAt)); + if (thread.anchor.excerpt) lines.push(' > ' + thread.anchor.excerpt.replace(/\n/g, ' ')); + thread.body.split('\n').forEach(function(line) { lines.push(' ' + line); }); + (thread.replies || []).forEach(function(reply) { + lines.push(' - Reply by **' + (reply.author || 'Author') + '** (' + formatReviewTime(reply.createdAt) + ')'); + reply.body.split('\n').forEach(function(line) { lines.push(' ' + line); }); + }); + lines.push(''); + }); + return lines.join('\n').trim(); } function requestReviewDeleteConfirmation(reviewId) { if (!reviewDeleteModal || !reviewDeleteCancel || !reviewDeleteConfirm) return; const threads = getActiveReviewThreads(); - const thread = reviewId - ? threads.find(function(item) { return item.id === reviewId; }) - : null; + 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?'; - } + pendingReviewDelete = { tabId: activeTabId, reviewId: reviewId || null }; + if (reviewDeleteTitle) reviewDeleteTitle.textContent = reviewId ? 'Delete comment?' : 'Delete all comments?'; 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.'; + ? 'This comment will be permanently deleted. This cannot be undone.' + : 'All ' + threads.length + ' comments in this document will be permanently deleted. This cannot be undone.'; } - reviewDeleteConfirm.textContent = reviewId ? 'Delete item' : 'Delete all'; + reviewDeleteConfirm.textContent = reviewId ? 'Delete comment' : '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; + if (reviewPanelBackdrop) reviewPanelBackdrop.hidden = true; } function setReviewMode(enabled, options) { options = options || {}; const nextState = Boolean(enabled); - const wasActive = reviewModeActive; - if (nextState && !wasActive) { - reviewPreviousViewModes.set(activeTabId, currentViewMode || 'split'); + if (nextState && isReleaseNotesActive()) { + announceToScreenReader('Release notes do not support comments.'); + return; } + const wasActive = reviewModeActive; + if (nextState && !wasActive) reviewPreviousViewModes.set(activeTabId, currentViewMode || 'split'); reviewModeActive = nextState; - if (reviewModeActive && currentViewMode !== 'preview') { - setViewMode('preview'); - } + 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; @@ -7366,7 +9110,11 @@ document.addEventListener("DOMContentLoaded", async function () { if (options.focusPanel && reviewPanelClose) reviewPanelClose.focus(); } else { cancelReviewDeleteConfirmation(); - closeReviewComposer(); + pendingReviewSelection = null; + activeReviewReplyEdit = null; + activeReviewId = null; + hoveredReviewId = null; + closeReviewComposer({ keepSelection: false }); clearReviewDecorations(); const restoreMode = reviewPreviousViewModes.get(activeTabId); reviewPreviousViewModes.clear(); @@ -7382,38 +9130,27 @@ document.addEventListener("DOMContentLoaded", async function () { } function initReviewMode() { - tabs.forEach(function(tab) { - tab.reviewThreads = normalizeReviewThreads(tab.reviewThreads); - }); + 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(false); - 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 (reviewToggle) reviewToggle.addEventListener('click', function() { + setReviewMode(!reviewModeActive, { focusPanel: !reviewModeActive }); + }); + if (mobileReviewToggle) mobileReviewToggle.addEventListener('click', function() { + closeMobileMenu(false); + 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 (reviewNewComment) reviewNewComment.addEventListener('click', openNewReviewComposer); + if (reviewComposerCancel) reviewComposerCancel.addEventListener('click', function() { + closeReviewComposer({ restoreFocus: true, keepSelection: true }); + }); if (reviewFeedbackInput) { reviewFeedbackInput.addEventListener('input', function() { const length = reviewFeedbackInput.value.length; @@ -7428,94 +9165,181 @@ document.addEventListener("DOMContentLoaded", async function () { }); } 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); - }); - } + document.querySelectorAll('[data-review-filter]').forEach(function(button) { + button.addEventListener('click', function() { setReviewFilter(button.dataset.reviewFilter); }); + }); 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(); + if (markdownPreview) { + markdownPreview.addEventListener('mouseup', function() { + requestAnimationFrame(captureReviewTextSelection); }); - }); - document.querySelectorAll('[data-review-filter]').forEach(function(button) { - button.addEventListener('click', function() { - setReviewFilter(button.dataset.reviewFilter); + markdownPreview.addEventListener('keyup', function() { + requestAnimationFrame(captureReviewTextSelection); }); - }); - - 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; + markdownPreview.addEventListener('click', function(event) { + if (!reviewModeActive || (reviewComposer && !reviewComposer.hidden)) return; + const nativeSelection = window.getSelection && window.getSelection(); + if (nativeSelection && !nativeSelection.isCollapsed && nativeSelection.rangeCount > 0) { + const range = nativeSelection.getRangeAt(0); + const startElement = range.startContainer.nodeType === Node.ELEMENT_NODE + ? range.startContainer + : range.startContainer.parentElement; + const endElement = range.endContainer.nodeType === Node.ELEMENT_NODE + ? range.endContainer + : range.endContainer.parentElement; + if (startElement && endElement && markdownPreview.contains(startElement) && markdownPreview.contains(endElement)) { + return; + } + } + const highlighted = event.target.closest('[data-review-ids]'); + if (highlighted && markdownPreview.contains(highlighted)) { + const reviewId = String(highlighted.dataset.reviewIds || '').split(/\s+/).filter(Boolean)[0]; + const thread = getActiveReviewThreads().find(function(item) { return item.id === reviewId; }); + if (thread) { + event.preventDefault(); + activateReviewThread(thread, { scrollPanel: true, focusCard: true }); + return; + } + } + const selectable = event.target.closest('[data-review-anchor]'); + const selectableType = selectable && selectable.dataset.reviewType; + if (selectable && markdownPreview.contains(selectable) && REVIEW_ELEMENT_TYPES.has(selectableType)) { + event.preventDefault(); + selectReviewElement(selectable); } - 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); + markdownPreview.addEventListener('pointerover', function(event) { + const highlighted = event.target.closest('[data-review-ids]'); + if (!highlighted || !markdownPreview.contains(highlighted)) return; + const reviewId = String(highlighted.dataset.reviewIds || '').split(/\s+/).filter(Boolean)[0]; + setReviewHover(reviewId); + }); + markdownPreview.addEventListener('pointerout', function(event) { + const from = event.target.closest('[data-review-ids]'); + const to = event.relatedTarget && event.relatedTarget.closest + ? event.relatedTarget.closest('[data-review-ids]') + : null; + if (from && from !== to) setReviewHover(null); }); } - if (previewPaneElement) { - previewPaneElement.addEventListener('scroll', scheduleReviewPinsLayout, { passive: true }); - } + document.addEventListener('click', function(event) { + if (!reviewModeActive) return; + if (reviewComposer && !reviewComposer.hidden) { + const clickedComposer = reviewComposer.contains(event.target); + const clickedNewButton = reviewNewComment && reviewNewComment.contains(event.target); + if (!clickedComposer && !clickedNewButton) dismissEmptyNewReviewComposer(); + return; + } + const selection = pendingReviewSelection && normalizeReviewSelection(pendingReviewSelection.selection); + if (!selection) return; + if (selection.kind === 'text' && reviewClickIsInsideNativeSelection(event, selection)) return; + if (selection.kind === 'element') { + const selectedTarget = findReviewTarget(pendingReviewSelection); + if (selectedTarget && selectedTarget.contains(event.target)) return; + } + pendingReviewSelection = null; + decorateReviewTargets(); + renderReviewPanel(); + announceToScreenReader('Content selection cleared.'); + }); 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; - }); + const card = event.target.closest('.review-thread'); + if (!card) return; + const thread = getActiveReviewThreads().find(function(item) { return item.id === card.dataset.reviewId; }); if (!thread) return; - const action = actionButton.dataset.reviewAction; - if (action === 'focus-anchor') { - focusReviewAnchor(thread); + if (event.target.closest('.review-reply-input, .review-reply-edit-input')) { + activeReviewId = thread.id; + syncReviewInteractionState(); return; } - if (action === 'edit') { - openReviewEditor(thread); + const actionButton = event.target.closest('[data-review-action]'); + if (!actionButton) { + activateReviewThread(thread, { scrollPanel: false, scrollDocument: true }); return; } - if (action === 'toggle-resolved') { + const action = actionButton.dataset.reviewAction; + if (action === 'focus-anchor') { + focusReviewAnchor(thread); + } else if (action === 'edit') { + openReviewEditor(thread); + } else 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') { + persistReviewThreads(thread.resolved ? 'Comment resolved.' : 'Comment reopened.'); + if ((reviewFilter === 'open' && thread.resolved) || (reviewFilter === 'resolved' && !thread.resolved)) { + activeReviewId = null; + renderReviewPanel(); + } else { + activateReviewThread(thread, { scrollPanel: true }); + } + } else if (action === 'delete') { requestReviewDeleteConfirmation(thread.id); + } else if (action === 'edit-reply') { + editReviewReply(thread, actionButton.dataset.reviewReplyId); + } else if (action === 'save-reply') { + saveReviewReply( + thread, + actionButton.dataset.reviewReplyId, + actionButton.closest('.review-reply-edit-form') + ); + } else if (action === 'cancel-reply-edit') { + activeReviewReplyEdit = null; + activeReviewId = thread.id; + renderReviewPanel(); + } else if (action === 'delete-reply') { + deleteReviewReply(thread, actionButton.dataset.reviewReplyId); + } else if (action === 'toggle-replies') { + event.preventDefault(); + if (expandedReviewThreadIds.has(thread.id)) expandedReviewThreadIds.delete(thread.id); + else expandedReviewThreadIds.add(thread.id); + activeReviewId = thread.id; + renderReviewPanel(); + requestAnimationFrame(function() { + const toggle = reviewList && reviewList.querySelector( + '.review-thread[data-review-id="' + thread.id + '"] .review-thread-actions [data-review-action="toggle-replies"]' + ); + if (toggle) toggle.focus({ preventScroll: true }); + }); + announceToScreenReader(expandedReviewThreadIds.has(thread.id) ? 'All replies shown.' : 'Middle replies hidden.'); + } else if (action === 'submit-reply') { + event.preventDefault(); + submitReviewReply(thread, actionButton.closest('.review-reply-composer')); } }); + reviewList.addEventListener('submit', function(event) { + const form = event.target.closest('.review-reply-composer'); + if (!form) return; + event.preventDefault(); + const thread = getActiveReviewThreads().find(function(item) { return item.id === form.dataset.reviewId; }); + if (thread) submitReviewReply(thread, form); + }); + reviewList.addEventListener('keydown', function(event) { + const card = event.target.closest('.review-thread'); + if (!card || event.target.closest('input, textarea, button, [data-review-action]')) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + const thread = getActiveReviewThreads().find(function(item) { return item.id === card.dataset.reviewId; }); + if (thread) activateReviewThread(thread, { scrollPanel: false, scrollDocument: true }); + }); + reviewList.addEventListener('pointerover', function(event) { + const card = event.target.closest('.review-thread'); + if (card) setReviewHover(card.dataset.reviewId); + }); + reviewList.addEventListener('pointerout', function(event) { + const from = event.target.closest('.review-thread'); + const to = event.relatedTarget && event.relatedTarget.closest + ? event.relatedTarget.closest('.review-thread') + : null; + if (from && from !== to) setReviewHover(null); + }); } window.addEventListener('resize', updateReviewBackdrop); @@ -7523,7 +9347,7 @@ document.addEventListener("DOMContentLoaded", async function () { if (event.defaultPrevented || event.key !== 'Escape' || !reviewModeActive || activeModal) return; if (reviewComposer && !reviewComposer.hidden) { event.preventDefault(); - closeReviewComposer({ restoreFocus: true }); + closeReviewComposer({ restoreFocus: true, keepSelection: true }); } else { event.preventDefault(); setReviewMode(false, { restoreFocus: true }); @@ -7879,6 +9703,8 @@ document.addEventListener("DOMContentLoaded", async function () { } updateMissingDocumentControlState([ + copyMarkdownButton, + reviewToggle, exportDropdown, shareButton, liveShareButton, @@ -7922,6 +9748,8 @@ document.addEventListener("DOMContentLoaded", async function () { function clearActiveDocument() { activeTabId = null; selectedDocumentId = null; + document.body.classList.remove('release-notes-active'); + updateReleaseNotesActionAvailability(false); removeStorageItem(ACTIVE_TAB_KEY); closeReviewComposer(); clearReviewDecorations(); @@ -8014,33 +9842,29 @@ document.addEventListener("DOMContentLoaded", async function () { dropdown.className = 'tab-menu-dropdown'; dropdown.setAttribute('data-tab-menu-dropdown', 'true'); dropdown.setAttribute('role', 'menu'); - if (isReleaseNotesTab(tab)) { - dropdown.innerHTML = ''; - } else { - const duplicateAction = isShareSnapshotTab(tab) - ? '' - : ''; - const downloadAction = isShareSnapshotTab(tab) - ? '' - : ''; - const favoriteAction = isShareSnapshotTab(tab) - ? '' - : ''; - const isCombinedSplitTab = tab.id === activeTabId && Boolean(secondarySplitTabId); - const splitAction = isCombinedSplitTab - ? '' - : tabs.length > 1 - ? '' - : ''; - dropdown.innerHTML = - '' + - duplicateAction + - favoriteAction + - splitAction + - downloadAction + - '' + - ''; - } + const duplicateAction = isShareSnapshotTab(tab) + ? '' + : ''; + const downloadAction = isShareSnapshotTab(tab) + ? '' + : ''; + const favoriteAction = isShareSnapshotTab(tab) + ? '' + : ''; + const isCombinedSplitTab = tab.id === activeTabId && Boolean(secondarySplitTabId); + const splitAction = isCombinedSplitTab + ? '' + : tabs.length > 1 + ? '' + : ''; + dropdown.innerHTML = + '' + + duplicateAction + + favoriteAction + + splitAction + + downloadAction + + '' + + ''; menuBtn.addEventListener('click', function(e) { e.preventDefault(); @@ -8196,7 +10020,7 @@ document.addEventListener("DOMContentLoaded", async function () { }, { id: 'all', icon: 'lucide-square-x', label: 'Close all' }]; - const actions = isReleaseNotesTab(tab) ? closeActions : [{ + const actions = isReleaseNotesTab(tab) ? closeActions.slice(0, 1) : [{ id: isCombinedSplitTab ? 'split-close' : 'split', icon: isCombinedSplitTab ? 'lucide-panel-right-close' : 'lucide-columns-2', label: isCombinedSplitTab ? 'Exit split view' : 'Open in split view', @@ -8333,7 +10157,9 @@ document.addEventListener("DOMContentLoaded", async function () { titleSpan.title = getDocumentTabHoverTitle(tab); } - const tabMenu = createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' }); + const tabMenu = isReleaseNotesTab(tab) + ? null + : createTabActionMenu(tab, { menuIdPrefix: 'desktop-tab-menu' }); const closeButton = document.createElement('button'); closeButton.type = 'button'; @@ -8351,7 +10177,7 @@ document.addEventListener("DOMContentLoaded", async function () { item.appendChild(fileIcon); item.appendChild(titleSpan); - item.appendChild(tabMenu.button); + if (tabMenu) item.appendChild(tabMenu.button); item.appendChild(closeButton); item.addEventListener('contextmenu', function(event) { @@ -8913,6 +10739,7 @@ document.addEventListener("DOMContentLoaded", async function () { saveSecondarySplitState(); closeReviewComposer(); clearReviewDecorations(); + if (isReleaseNotesTab(tab) && reviewModeActive) setReviewMode(false); // Clear typing timeout and reset tracking for the new tab if (typingTimeout) { @@ -8923,11 +10750,14 @@ document.addEventListener("DOMContentLoaded", async function () { pendingState = null; activeTabId = tabId; + const releaseNotesActive = isReleaseNotesTab(tab); + document.body.classList.toggle('release-notes-active', releaseNotesActive); if (swapSplitPanes) secondarySplitTabId = previousActiveTabId; saveActiveTabId(activeTabId); updateNoOpenDocumentState(); - selectedDocumentId = tabId; - setSingleDocumentTreeSelection('document', tabId); + selectedDocumentId = releaseNotesActive ? null : tabId; + if (releaseNotesActive) clearDocumentTreeSelection({ announce: false }); + else setSingleDocumentTreeSelection('document', tabId); tab.lastOpenedAt = Date.now(); if (!isTemporaryDocument(tab)) saveTabsToStorage(tabs, [tab.id]); markdownEditor.value = tab.content; @@ -13399,7 +15229,7 @@ ${selector} .arrowheadPath { } if (reviewModeActive && mode !== 'preview') { mode = 'preview'; - announceToScreenReader('Review mode uses the read-only preview.'); + announceToScreenReader('Comments uses the read-only preview.'); } if (mode === currentViewMode) return; @@ -18764,6 +20594,55 @@ ${selector} .arrowheadPath { }); } + function initDropdownMenuMotion() { + if (!window.bootstrap || !window.bootstrap.Dropdown) return; + + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + const closeDurationMs = 120; + + document.querySelectorAll('[data-bs-toggle="dropdown"]').forEach(function(toggle) { + if (toggle.dataset.dropdownMotionReady === 'true') return; + const dropdown = toggle.closest('.dropdown'); + const menu = dropdown && Array.from(dropdown.children).find(function(child) { + return child.classList && child.classList.contains('dropdown-menu'); + }); + if (!dropdown || !menu) return; + + toggle.dataset.dropdownMotionReady = 'true'; + const instance = window.bootstrap.Dropdown.getOrCreateInstance(toggle); + let closeTimer = null; + let allowImmediateHide = false; + + toggle.addEventListener('show.bs.dropdown', function() { + if (closeTimer !== null) window.clearTimeout(closeTimer); + closeTimer = null; + allowImmediateHide = false; + menu.classList.remove('is-closing'); + }); + + toggle.addEventListener('hide.bs.dropdown', function(event) { + if (allowImmediateHide || reducedMotion.matches) return; + event.preventDefault(); + if (closeTimer !== null) return; + + menu.classList.add('is-closing'); + closeTimer = window.setTimeout(function() { + closeTimer = null; + allowImmediateHide = true; + instance.hide(); + allowImmediateHide = false; + }, closeDurationMs); + }); + + toggle.addEventListener('hidden.bs.dropdown', function() { + if (closeTimer !== null) window.clearTimeout(closeTimer); + closeTimer = null; + allowImmediateHide = false; + menu.classList.remove('is-closing'); + }); + }); + } + // Story 1.3: Resize Divider Functions function initResizer() { if (!resizeDivider) return; @@ -19387,6 +21266,7 @@ ${selector} .arrowheadPath { initMarkdownFormatToolbar(); initToolbarDropdownPortals(); + initDropdownMenuMotion(); initFindReplaceModal(); initAppModals(); document.addEventListener('fullscreenchange', function() { @@ -21679,6 +23559,7 @@ ${selector} .arrowheadPath { }); copyMarkdownButton.addEventListener("click", async function () { + if (isReleaseNotesActive()) return; if (blockShareSnapshotSourceAccess()) return; try { await copyTextToClipboard(markdownEditor.value); @@ -22335,6 +24216,9 @@ ${selector} .arrowheadPath { function updateReleaseNotesActionAvailability(releaseNotesActive) { const actions = [ + toggleSyncButton, + copyMarkdownButton, + reviewToggle, exportDropdown, exportMd, exportHtml, @@ -22347,10 +24231,21 @@ ${selector} .arrowheadPath { mobileExportHtml, mobileExportPdf, mobileExportPng, + mobileToggleSyncButton, + mobileCopyMarkdownButton, + mobileReviewToggle, mobileShareButton, mobileLiveShareButton ]; + viewModeButtons.forEach(function(action) { actions.push(action); }); + mobileViewModeButtons.forEach(function(action) { actions.push(action); }); + if (markdownFormatToolbar) { + markdownFormatToolbar.querySelectorAll('button').forEach(function(action) { actions.push(action); }); + markdownFormatToolbar.classList.toggle('is-release-notes-disabled', releaseNotesActive); + markdownFormatToolbar.setAttribute('aria-disabled', releaseNotesActive ? 'true' : 'false'); + } + actions.forEach(function(action) { if (!action) return; if (releaseNotesActive) { @@ -22361,11 +24256,15 @@ ${selector} .arrowheadPath { ? action.getAttribute('tabindex') : '__missing__'; } + action.dataset.releaseNotesOriginalTitle = action.hasAttribute('title') + ? action.getAttribute('title') + : '__missing__'; } action.dataset.releaseNotesDisabled = 'true'; if ('disabled' in action) action.disabled = true; action.classList.add('disabled'); action.setAttribute('aria-disabled', 'true'); + action.setAttribute('title', 'Unavailable in Release Notes'); if (action.tagName === 'A') action.setAttribute('tabindex', '-1'); return; } @@ -22381,9 +24280,16 @@ ${selector} .arrowheadPath { const remainsDisabled = wasDisabled || hasAnotherDisabledState; delete action.dataset.releaseNotesDisabled; delete action.dataset.releaseNotesWasDisabled; + const originalTitle = action.dataset.releaseNotesOriginalTitle; + delete action.dataset.releaseNotesOriginalTitle; if ('disabled' in action) action.disabled = remainsDisabled; action.classList.toggle('disabled', remainsDisabled); action.setAttribute('aria-disabled', remainsDisabled ? 'true' : 'false'); + if (originalTitle && originalTitle !== '__missing__') { + action.setAttribute('title', originalTitle); + } else { + action.removeAttribute('title'); + } if (action.tagName === 'A') { const originalTabindex = action.dataset.releaseNotesOriginalTabindex; delete action.dataset.releaseNotesOriginalTabindex; @@ -22413,6 +24319,8 @@ ${selector} .arrowheadPath { const liveShareDocumentActive = isLiveShareDocumentActive(); const liveShareGuestDocumentActive = liveShareDocumentActive && !isLiveShareHostDocumentActive(); const viewOnly = isLiveViewOnlyParticipant() || snapshotViewOnly || releaseNotesActive; + document.body.classList.toggle('release-notes-active', releaseNotesActive); + if (!releaseNotesActive) updateReleaseNotesActionAvailability(false); const sourceReadOnly = viewOnly || reviewModeActive; if (markdownEditor) { markdownEditor.readOnly = sourceReadOnly; @@ -22531,9 +24439,10 @@ ${selector} .arrowheadPath { if (markdownFormatToolbar) { markdownFormatToolbar.classList.toggle('is-live-view-only', sourceReadOnly); - markdownFormatToolbar.querySelectorAll('[data-md-action]').forEach(function(button) { + markdownFormatToolbar.querySelectorAll('[data-md-action], [data-toolbar-menu-toggle]').forEach(function(button) { const action = button.getAttribute('data-md-action'); - const shouldDisable = sourceReadOnly && isLiveMutatingAction(action); + const isMenuToggle = button.hasAttribute('data-toolbar-menu-toggle'); + const shouldDisable = sourceReadOnly && (isMenuToggle || isLiveMutatingAction(action)); if (shouldDisable) { button.dataset.liveViewOnlyDisabled = 'true'; button.disabled = true; @@ -22550,8 +24459,9 @@ ${selector} .arrowheadPath { updateUndoRedoButtons(); } } + updateSyncToggleVisibility(currentViewMode); updateDocumentToolbarAvailability(hasActiveOpenDocument()); - updateReleaseNotesActionAvailability(releaseNotesActive); + if (releaseNotesActive) updateReleaseNotesActionAvailability(true); } function getLiveRoomSocketUrl(roomId, secret, auth) { @@ -22917,6 +24827,7 @@ ${selector} .arrowheadPath { }; liveCollaboration.localParticipant = updatedParticipant; liveCollaboration.participants.set(liveCollaboration.localParticipantId, updatedParticipant); + updateReviewAuthorIdentity(liveCollaboration.localParticipantId, safeName); if (liveCollaboration.connection) { liveCollaboration.connection.publishPresence(liveCollaboration.localCursor); } @@ -24464,6 +26375,10 @@ ${selector} .arrowheadPath { if (isCmdOrCtrl && e.shiftKey && !e.altKey && key === 'c') { e.preventDefault(); + if (isReleaseNotesActive()) { + announceToScreenReader('Release notes do not expose Markdown source.'); + return; + } const focusedEditor = document.activeElement === documentSplitEditor ? documentSplitEditor : markdownEditor; const selected = focusedEditor.value.slice(focusedEditor.selectionStart, focusedEditor.selectionEnd); copyTextToClipboard(selected || focusedEditor.value) diff --git a/styles.css b/styles.css index fd95dc38..a32ab5fe 100644 --- a/styles.css +++ b/styles.css @@ -43,6 +43,7 @@ --scrollbar-thumb: #c1c1c1; --scrollbar-track: #f1f1f1; --accent-color: #0366d6; + --review-accent: #7c3aed; --success-color: #116329; --warning-note-bg: #fff8c5; --warning-note-border: #d4a72c; @@ -104,6 +105,7 @@ --scrollbar-thumb: #484f58; --scrollbar-track: #21262d; --accent-color: #58a6ff; + --review-accent: #a78bfa; --success-color: #3fb950; --warning-note-bg: #2d220c; --warning-note-border: #9e6a03; @@ -360,11 +362,10 @@ body { cursor: pointer; } -.document-sidebar-filters { +.segmented-control { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 2px; - margin: 0 8px 7px; padding: 3px; border: 1px solid var(--border-color); border-radius: 6px; @@ -372,6 +373,10 @@ body { box-shadow: inset 0 1px 1px rgba(31, 35, 40, 0.03); } +.document-sidebar-filters { + margin: 0 8px 7px; +} + /* Bootstrap Icons 1.11.3 GitHub mark: the only branded, non-Lucide UI icon. */ .bi { display: inline-block; @@ -394,7 +399,7 @@ body { mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgZmlsbD0iY3VycmVudENvbG9yIiBjbGFzcz0iYmkgYmktZ2l0aHViIiB2aWV3Qm94PSIwIDAgMTYgMTYiPgogIDxwYXRoIGQ9Ik04IDBDMy41OCAwIDAgMy41OCAwIDhjMCAzLjU0IDIuMjkgNi41MyA1LjQ3IDcuNTkuNC4wNy41NS0uMTcuNTUtLjM4IDAtLjE5LS4wMS0uODItLjAxLTEuNDktMi4wMS4zNy0yLjUzLS40OS0yLjY5LS45NC0uMDktLjIzLS40OC0uOTQtLjgyLTEuMTMtLjI4LS4xNS0uNjgtLjUyLS4wMS0uNTMuNjMtLjAxIDEuMDguNTggMS4yMy44Mi43MiAxLjIxIDEuODcuODcgMi4zMy42Ni4wNy0uNTIuMjgtLjg3LjUxLTEuMDctMS43OC0uMi0zLjY0LS44OS0zLjY0LTMuOTUgMC0uODcuMzEtMS41OS44Mi0yLjE1LS4wOC0uMi0uMzYtMS4wMi4wOC0yLjEyIDAgMCAuNjctLjIxIDIuMi44Mi42NC0uMTggMS4zMi0uMjcgMi0uMjdzMS4zNi4wOSAyIC4yN2MxLjUzLTEuMDQgMi4yLS44MiAyLjItLjgyLjQ0IDEuMS4xNiAxLjkyLjA4IDIuMTIuNTEuNTYuODIgMS4yNy44MiAyLjE1IDAgMy4wNy0xLjg3IDMuNzUtMy42NSAzLjk1LjI5LjI1LjU0LjczLjU0IDEuNDggMCAxLjA3LS4wMSAxLjkzLS4wMSAyLjIgMCAuMjEuMTUuNDYuNTUuMzhBOC4wMSA4LjAxIDAgMCAwIDE2IDhjMC00LjQyLTMuNTgtOC04LTgiLz4KPC9zdmc+"); } -.document-filter-btn { +.segmented-control-btn { display: flex; align-items: center; justify-content: center; @@ -403,12 +408,22 @@ body { min-height: 24px; gap: 5px; padding: 3px 6px; + border: 1px solid transparent; border-radius: 4px; + background: transparent; color: var(--text-secondary); + cursor: pointer; + font: inherit; font-size: 11px; line-height: 1.2; text-align: center; white-space: nowrap; + transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.segmented-control-btn:hover:not(.is-active) { + background: var(--button-hover); + color: var(--text-color); } .document-filter-btn i { @@ -418,7 +433,7 @@ body { text-align: center; } -.document-filter-btn.is-active { +.segmented-control-btn.is-active { background: var(--bg-color); color: var(--text-color); font-weight: 500; @@ -1930,16 +1945,737 @@ body.document-sidebar-collapsed .document-sidebar { border-radius: 0; } -.markdown-body .code-preview-block > pre > code { - padding: 0; - border-radius: 0; -} +.markdown-body .code-preview-block > pre > code { + padding: 0; + border-radius: 0; +} + +@media print { + .code-preview-toolbar { + display: none; + } +} + +@keyframes review-selection-caret-blink { + 0%, 48% { + border-inline-start-color: var(--review-accent); + border-inline-end-color: var(--review-accent); + } + 49%, 100% { + border-inline-start-color: transparent; + border-inline-end-color: transparent; + } +} + +::highlight(review-pending-native-selection) { + color: HighlightText; + background-color: Highlight; +} + +/* ======================================== + COMMENTS — SELECTION-FIRST EXPERIENCE + ======================================== */ +body { +.review-panel { + width: min(344px, calc(100% - 40px)); + font-size: var(--ui-font-md); +} + +.review-panel-header { + min-height: 46px; + padding: 7px 8px 7px 12px; +} + +.review-panel-header h2 { + font-size: var(--ui-font-lg); +} + +.review-panel-toolbar { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 7px 8px; + background: var(--menu-surface); +} + +.review-new-btn, +.review-secondary-btn { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 9px; + color: var(--text-color); + background: var(--button-bg); + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); + cursor: pointer; + font: inherit; + font-size: var(--ui-font-md); + font-weight: 600; + line-height: 1; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.review-new-btn i { + font-size: var(--ui-icon-size-md); +} + +.review-new-btn:hover:not(:disabled), +.review-secondary-btn:hover:not(:disabled) { + background: var(--button-hover); + border-color: color-mix(in srgb, var(--text-secondary) 45%, var(--menu-border)); +} + +.review-new-btn:disabled { + color: var(--text-secondary); + opacity: 0.55; + cursor: not-allowed; +} + +.review-panel-summary { + margin: 0; + white-space: nowrap; +} + +.review-filter-group { + width: auto; + min-width: 0; + grid-template-columns: repeat(3, minmax(0, 1fr)); + justify-self: end; +} + +.review-filter-btn { + min-height: 24px; + font-size: var(--ui-font-xs); +} + +.review-composer { + order: -1; + margin: 10px 10px 4px; + border-color: var(--accent-color); + border-radius: var(--menu-item-radius); + box-shadow: 0 2px 8px color-mix(in srgb, var(--text-color) 9%, transparent); +} + +.review-composer-body { + padding: 10px 10px 8px; +} + +.review-composer-author, +.review-thread-author { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.review-thread-author { + align-items: flex-start; +} + +.review-author-meta { + min-width: 0; + display: grid; + flex: 1; + gap: 1px; +} + +.review-composer-author { + margin-bottom: 8px; +} + +.review-author-avatar { + width: 24px; + height: 24px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 11%, var(--menu-surface)); + border: 1px solid color-mix(in srgb, var(--accent-color) 24%, var(--border-color)); + border-radius: 50%; + font-size: var(--ui-font-xs); + font-weight: 700; + line-height: 1; +} + +.review-composer-author strong, +.review-author-name { + min-width: 0; + overflow: hidden; + color: var(--text-color); + font-size: var(--ui-font-md); + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-anchor-preview { + margin-bottom: 8px; + padding: 5px 7px; + border-inline-start-color: color-mix(in srgb, var(--accent-color) 55%, var(--border-color)); + font-size: var(--ui-font-sm); + -webkit-line-clamp: 1; +} + +.review-feedback-input { + min-height: 76px; + padding: 8px 9px; + resize: vertical; +} + +.review-composer-footer { + padding: 7px 10px 8px; + background: var(--menu-surface); +} + +.review-composer-actions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.review-primary-btn, +.review-secondary-btn { + min-height: 30px; + padding: 5px 10px; +} + +.review-list { + gap: 7px; + padding: 8px 10px 14px; +} + +.review-thread { + --review-thread-surface: var(--menu-surface); + padding: 9px 10px 10px; + overflow: visible; + background: var(--review-thread-surface); + border-color: var(--border-color); + border-radius: var(--menu-item-radius); + box-shadow: none; + cursor: pointer; + transition: border-color 160ms ease; +} + +.review-thread.is-resolved { + --review-thread-surface: color-mix(in srgb, var(--button-bg) 52%, var(--menu-surface)); +} + +.review-thread-header { + min-height: 24px; + padding: 0; + padding-inline-end: 88px; + background: transparent; + border: 0; +} + +.review-thread.has-collapsible-replies .review-thread-header { + padding-inline-end: 116px; +} + +.review-thread .review-author-avatar { + width: 22px; + height: 22px; + flex-basis: 22px; +} + +.review-thread-body { + margin: 7px 0 0; + margin-inline-start: 29px; + font-size: var(--ui-font-md); + line-height: 1.45; +} + +.review-thread-actions { + position: absolute; + z-index: 1; + inset-block-start: 5px; + inset-inline-end: 5px; + width: auto; + min-width: 0; + height: 30px; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + gap: 2px; + padding: 2px; + opacity: 0; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); + transition: opacity 140ms ease; +} + +.review-thread-action { + width: 24px; + min-width: 24px; + height: 24px; + min-height: 24px; + flex: 0 0 24px; + padding: 0; +} + +.review-thread:hover .review-thread-actions, +.review-thread:focus-within .review-thread-actions, +.review-thread.is-review-thread-active .review-thread-actions { + opacity: 1; +} + +.review-thread-dates { + display: grid; + gap: 1px; + margin: 0; + padding: 0; + border: 0; +} + +.review-thread-time { + font-size: var(--ui-font-xs); + line-height: 1.25; +} + +.review-thread.is-review-thread-emphasized { + border-color: var(--accent-color); + background: var(--review-thread-surface); + box-shadow: none; +} + +.review-thread:hover, +.review-thread.is-review-thread-active { + background: var(--review-thread-surface); + box-shadow: none; +} + +.review-thread-conversation { + display: none; + margin: 10px 0 0; + padding-top: 9px; + border-top: 1px solid var(--border-color); +} + +.review-thread.is-review-thread-active .review-thread-conversation { + display: block; +} + +.review-replies { + display: grid; + gap: 11px; + margin-inline-start: 18px; +} + +.review-reply { + position: relative; + min-width: 0; +} + +.review-reply-header { + position: relative; + display: flex; + align-items: flex-start; + gap: 7px; + min-height: 26px; + padding-inline-end: 58px; +} + +.review-reply-actions { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px; + opacity: 0; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); + transition: opacity 140ms ease; +} + +.review-reply:hover .review-reply-actions, +.review-reply:focus-within .review-reply-actions { + opacity: 1; +} + +.review-reply-action { + width: 22px; + min-width: 22px; + height: 22px; + min-height: 22px; + flex-basis: 22px; +} + +.review-reply .review-author-avatar { + width: 22px; + height: 22px; + flex-basis: 22px; +} + +.review-reply-body { + margin: 5px 0 0; + margin-inline-start: 29px; + color: var(--text-color); + font-size: var(--ui-font-md); + line-height: 1.45; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.review-reply-time { + display: block; + margin: 0; + color: var(--text-secondary); + font-size: var(--ui-font-xs); + line-height: 1.25; +} + +.review-reply-edit-form { + display: flex; + align-items: flex-end; + gap: 5px; + margin: 5px 0 0; + margin-inline-start: 29px; +} + +.review-reply-edit-input { + min-width: 0; + min-height: 54px; + flex: 1; + padding: 5px 7px; + resize: vertical; + color: var(--text-color); + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); + font: inherit; + font-size: var(--ui-font-md); + line-height: 1.4; +} + +.review-reply-edit-input:focus { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 22%, transparent); + outline-offset: 0; +} + +.review-reply-edit-actions { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 1px; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); +} + +.review-replies-more { + justify-self: start; + margin-inline-start: 29px; + padding: 2px 0; + color: var(--review-accent); + background: transparent; + border: 0; + cursor: pointer; + font: inherit; + font-size: var(--ui-font-sm); + font-weight: 650; +} + +.review-replies-more:hover, +.review-replies-more:focus-visible { + color: color-mix(in srgb, var(--review-accent) 82%, var(--text-color)); + text-decoration: underline; + outline: none; +} + +.review-reply-composer { + display: flex; + align-items: center; + gap: 6px; + margin: 11px 0 0; + margin-inline-start: 18px; +} + +.review-reply-input { + min-width: 0; + min-height: 30px; + flex: 1; + padding: 5px 8px; + color: var(--text-color); + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); + font: inherit; + font-size: var(--ui-font-md); +} + +.review-reply-input:focus { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 22%, transparent); + outline-offset: 0; +} + +.review-reply-submit { + width: 30px; + height: 30px; + flex: 0 0 30px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #ffffff; + background: var(--accent-color); + border: 1px solid var(--accent-color); + border-radius: var(--menu-item-radius); + cursor: pointer; + font-size: var(--ui-icon-size-md); +} + +.review-reply-submit:hover { + filter: brightness(0.94); +} + +.review-thread.is-resolved { + opacity: 0.78; +} + +.review-status-label { + width: 18px; + height: 18px; + justify-content: center; + padding: 0; + border: 0; + border-radius: 50%; +} + +.review-empty-state { + padding: 34px 24px; +} + +.review-empty-state i { + width: 36px; + height: 36px; + margin-bottom: 10px; + font-size: var(--ui-icon-size-lg); +} + +#markdown-preview .review-target, +#markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table), +#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: 0; + padding-inline-end: 0; + border-radius: 4px; +} + +#markdown-preview .review-target--table, +#markdown-preview .review-target--table.has-review-thread { + max-width: 100%; +} + +#markdown-preview .review-target--diagram { + padding-block-end: 0; +} + +#markdown-preview .review-target.has-review-thread, +#markdown-preview .review-target.is-review-target-active, +[dir="rtl"] #markdown-preview .review-target.has-review-thread, +[dir="rtl"] #markdown-preview .review-target.is-review-target-active { + box-shadow: none; +} + +#markdown-preview .review-comment-highlight, +#markdown-preview .review-pending-highlight { + padding: 0; + color: inherit; + border-radius: 2px; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +#markdown-preview .review-comment-highlight { + background: color-mix(in srgb, var(--accent-color) 22%, transparent); + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--accent-color) 60%, transparent); + cursor: pointer; + transition: background-color 140ms ease, box-shadow 140ms ease; +} + +#markdown-preview .review-pending-highlight { + position: relative; + background: color-mix(in srgb, var(--review-accent) 18%, transparent); + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--review-accent) 64%, transparent); +} + +#markdown-preview .review-comment-element, +#markdown-preview .review-pending-element { + position: relative; + border-radius: 5px; + transition: background-color 140ms ease, box-shadow 140ms ease, outline-color 140ms ease; +} + +#markdown-preview .review-comment-element { + outline: 1px solid color-mix(in srgb, var(--accent-color) 40%, transparent); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 7%, transparent); + cursor: pointer; +} + +#markdown-preview .review-pending-element { + outline: 2px dashed color-mix(in srgb, var(--review-accent) 68%, transparent); + outline-offset: 3px; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--review-accent) 8%, transparent); +} + +#markdown-preview .is-review-highlight-emphasized { + background-color: color-mix(in srgb, var(--review-accent) 20%, transparent); + outline-color: color-mix(in srgb, var(--review-accent) 70%, transparent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-accent) 12%, transparent); +} + +#markdown-preview .review-comment-highlight.is-review-highlight-emphasized { + box-shadow: inset 0 -2px 0 color-mix(in srgb, var(--review-accent) 70%, transparent); +} + +#markdown-preview .review-element-selectable { + cursor: pointer; +} + +#markdown-preview .review-element-selectable:hover:not(.review-comment-element):not(.review-pending-element):not(.is-review-element-selected):not(.has-review-native-text-selection) { + outline: 2px dotted var(--review-accent); + outline-offset: 3px; +} + +#markdown-preview .review-element-selectable.is-review-element-selected { + outline: 2px solid var(--review-accent); + outline-offset: 3px; + box-shadow: 0 0 0 4px color-mix(in srgb, var(--review-accent) 10%, transparent); +} + +#markdown-preview .review-pending-highlight.review-selection-start, +#markdown-preview .review-comment-highlight.is-review-selection-start, +#markdown-preview .review-pending-highlight.review-selection-end, +#markdown-preview .review-comment-highlight.is-review-selection-end { + box-decoration-break: slice; + -webkit-box-decoration-break: slice; + animation: review-selection-caret-blink 900ms steps(1, end) infinite; +} + +#markdown-preview .review-pending-highlight.review-selection-start, +#markdown-preview .review-comment-highlight.is-review-selection-start { + border-inline-start: 2px solid var(--review-accent); +} + +#markdown-preview .review-pending-highlight.review-selection-end, +#markdown-preview .review-comment-highlight.is-review-selection-end { + border-inline-end: 2px solid var(--review-accent); +} + +@media (prefers-reduced-motion: reduce) { + #markdown-preview .review-pending-highlight.review-selection-start, + #markdown-preview .review-comment-highlight.is-review-selection-start, + #markdown-preview .review-pending-highlight.review-selection-end, + #markdown-preview .review-comment-highlight.is-review-selection-end { + animation: none; + } +} + +.review-new-btn:focus-visible, +.review-secondary-btn:focus-visible, +.review-thread:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +@media (min-width: 1080px) { + .review-panel { + width: 344px; + flex-basis: 344px; + } +} + +@media (min-width: 768px) and (max-width: 1079px) { + .review-panel { + width: min(344px, 100%); + } + + .content-container.is-reviewing .preview-pane { + padding-inline-end: 364px; + } + + #markdown-preview .review-target, + #markdown-preview .review-target.has-review-thread, + #markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table) { + padding-inline-end: 0; + } + + #markdown-preview .review-target--table, + #markdown-preview .review-target--table.has-review-thread { + max-width: 100%; + } +} + +@media (max-width: 767px) { + .review-panel { + width: 100%; + height: min(68%, 620px); + } + + .review-panel-header { + min-height: 48px; + padding: 6px 8px 6px 12px; + } + + .review-panel-toolbar { + grid-template-columns: auto auto minmax(150px, 1fr); + flex-wrap: nowrap; + gap: 6px; + padding: 7px 8px; + overflow-x: auto; + } + + .review-new-btn, + .review-secondary-btn, + .review-primary-btn, + .review-filter-btn { + min-height: 40px; + } + + .review-thread-action { + min-width: 24px; + width: 24px; + height: 24px; + min-height: 24px; + flex-basis: 24px; + } + + .review-thread-actions { + position: absolute; + gap: 2px; + opacity: 0; + } + + #markdown-preview .review-target, + #markdown-preview .review-target.has-review-thread, + #markdown-preview .review-target:not(.review-target--diagram):not(.review-target--table) { + padding-inline-end: 0; + } -@media print { - .code-preview-toolbar { - display: none; + #markdown-preview .review-target--table, + #markdown-preview .review-target--table.has-review-thread { + max-width: 100%; + } + + #markdown-preview .review-target--diagram { + padding-block-end: 0; } } +} .markdown-body img { background-color: transparent; @@ -2218,10 +2954,6 @@ body.document-sidebar-collapsed .document-sidebar { gap: 5px; } -.header-right .header-command-btn .btn-text { - display: inline; -} - .header-command-btn { width: auto; padding-inline: 10px; @@ -2235,6 +2967,25 @@ body.document-sidebar-collapsed .document-sidebar { display: inline-flex; } +.header-icon-dropdown .dropdown-toggle::after { + display: none; +} + +.header-icon-dropdown > .header-icon-command { + width: 38px; + min-width: 38px; + gap: 3px; +} + +.header-toolbar-divider { + width: 1px; + height: 20px; + flex: 0 0 1px; + margin-inline: 2px; + background: var(--border-color); + opacity: 0.72; +} + .app-command-menu.header-new-menu, .app-command-menu.header-export-menu { width: max-content; @@ -2255,7 +3006,7 @@ body.document-sidebar-collapsed .document-sidebar { min-width: 30px; height: 30px; padding: 0; - border: 1px solid transparent; + border: 1px solid var(--border-color); border-radius: 8px; background: transparent; color: var(--text-color); @@ -2283,6 +3034,76 @@ body.document-sidebar-collapsed .document-sidebar { line-height: 1; } +.header-icon-command .header-dropdown-chevron { + font-size: var(--ui-icon-size-xs); + opacity: 0.72; +} + +.header-icon-command.is-active, +.header-icon-command.is-active:hover, +.header-icon-command.is-live-active, +.header-icon-command.is-live-active:hover, +.header-icon-command.sync-active, +.header-icon-command.sync-active:hover, +.header-icon-dropdown.show > .header-icon-command:not(:disabled):not([aria-disabled="true"]), +.header-icon-dropdown > .header-icon-command.show:not(:disabled):not([aria-disabled="true"]), +.settings-dropdown.show > .header-icon-command:not(:disabled):not([aria-disabled="true"]), +.settings-dropdown > .header-icon-command.show:not(:disabled):not([aria-disabled="true"]) { + border-color: color-mix(in srgb, var(--accent-color) 28%, var(--border-color)); + background: color-mix(in srgb, var(--accent-color) 10%, transparent); + color: var(--accent-color); +} + +.header-icon-command.sync-active:disabled, +.header-icon-command.sync-active[aria-disabled="true"] { + border-color: transparent; + background: transparent; + color: var(--text-color); + box-shadow: none; +} + +.header-icon-command#review-toggle:has(.review-toolbar-count:not([hidden])) { + width: auto; + padding-inline: 6px; +} + +.header-view-toolbar { + flex: 0 0 auto; +} + +.header-view-toolbar .view-toggle-btn { + width: 27px; + min-width: 27px; + height: 26px; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + box-shadow: none; +} + +.header-view-toolbar .view-toggle-btn:hover, +.header-view-toolbar .view-toggle-btn:focus-visible { + border: 0; + background: var(--toolbar-control-hover); + color: var(--text-color); +} + +.header-view-toolbar .view-toggle-btn.is-active, +.header-view-toolbar .view-toggle-btn.is-active:hover { + border: 0; + background: var(--segmented-surface); + color: var(--accent-color); + box-shadow: var(--segmented-shadow); +} + +.header-view-toolbar .view-toggle-btn:disabled, +.header-view-toolbar .view-toggle-btn[aria-disabled="true"] { + background: transparent; + box-shadow: none; +} + .settings-dropdown { display: inline-flex; } @@ -2433,8 +3254,8 @@ body.document-sidebar-collapsed .document-sidebar { .settings-switch { position: relative; - width: 32px; - height: 18px; + width: 40px; + height: 22px; border-radius: 999px; background: var(--button-active); transition: background-color 0.16s ease; @@ -2444,20 +3265,46 @@ body.document-sidebar-collapsed .document-sidebar { position: absolute; top: 3px; left: 3px; - width: 12px; - height: 12px; + width: 16px; + height: 16px; border-radius: 50%; background: var(--text-secondary); transition: transform 0.16s ease, background-color 0.16s ease; } +.themed-settings-switch > span { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-color); + background: var(--bg-color); + transition: color 0.16s ease, transform 0.16s ease; +} + +.settings-switch-icon { + width: 12px; + height: 12px; + color: currentColor; + font-size: 12px; + filter: + drop-shadow(0.35px 0 0 currentColor) + drop-shadow(-0.35px 0 0 currentColor) + drop-shadow(0 0.35px 0 currentColor) + drop-shadow(0 -0.35px 0 currentColor); +} + .settings-menu-item.is-active .settings-switch { background: var(--accent-color); } .settings-menu-item.is-active .settings-switch > span { background: #fff; - transform: translateX(14px); + transform: translateX(18px); +} + +.settings-menu-item.is-active .themed-settings-switch > span { + color: var(--text-color); + background: var(--bg-color); } .settings-language-dropdown { @@ -2800,46 +3647,10 @@ body.document-sidebar-collapsed .document-sidebar { flex-shrink: 0; } -.markdown-toolbar-group--utilities { - position: sticky; - z-index: 6; - inset-inline-end: 0; - margin-inline-start: auto; - border-right: 0; - background: var(--toolbar-surface); -} - -.markdown-toolbar-group--document { - gap: 2px; - padding-inline-start: 6px; -} - .workspace-format-actions { padding-inline: 5px; } -.markdown-toolbar-group--document .tool-button { - flex: 0 0 auto; - width: 30px; - height: 30px; - min-width: 30px; - padding: 0; - border-color: transparent; - border-radius: 7px; - background: transparent; -} - -.markdown-toolbar-group--document .tool-button:hover, -.markdown-toolbar-group--document .tool-button:focus-visible { - border-color: transparent; - background: var(--toolbar-control-hover); -} - -.markdown-toolbar-group--document #review-toggle:has(.review-toolbar-count:not([hidden])) { - width: auto; - padding-inline: 5px; -} - .document-actions-menu { width: 280px; min-width: 280px; @@ -2860,14 +3671,6 @@ body.document-sidebar-collapsed .document-sidebar { z-index: var(--layer-menu); } -.document-command-divider { - width: 1px; - height: 18px; - margin-inline: 4px; - background: var(--border-color); - opacity: 0.75; -} - .markdown-view-toolbar { display: inline-flex; align-items: center; @@ -2879,44 +3682,6 @@ body.document-sidebar-collapsed .document-sidebar { background: var(--segmented-bg); } -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn { - width: 27px; - height: 26px; - min-width: 27px; - border: 0; - border-radius: 7px; - background: transparent; - color: var(--text-secondary); - box-shadow: none; -} - -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn:hover, -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn:focus-visible { - border: 0; - background: var(--toolbar-control-hover); - color: var(--text-color); -} - -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn.is-active, -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn.is-active:hover { - border: 0; - background: var(--segmented-surface); - color: var(--accent-color); - box-shadow: var(--segmented-shadow); -} - -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn:disabled, -.markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn[aria-disabled="true"] { - background: transparent; - box-shadow: none; -} - -.markdown-toolbar-group--utilities .tool-button { - flex: 0 0 auto; - height: 30px; - min-width: 30px; -} - .markdown-toolbar-group:first-child { padding-left: 0; } @@ -3017,17 +3782,6 @@ body.document-sidebar-collapsed .document-sidebar { gap: 5px; } -.markdown-tool-select--insert { - width: 30px; - min-width: 30px; - gap: 0; - padding: 0; -} - -.markdown-tool-select--insert .lucide-ellipsis { - font-size: var(--ui-icon-size-lg); -} - .markdown-tool-select-label { overflow: hidden; max-width: 48px; @@ -3680,12 +4434,6 @@ a:focus { min-width: 44px; } - .markdown-tool-select--insert { - width: 30px !important; - min-width: 30px; - padding-inline: 0 !important; - } - .markdown-format-toolbar { height: 40px !important; } @@ -3699,23 +4447,6 @@ a:focus { height: 40px !important; } - .markdown-toolbar-group--document .tool-button { - width: 30px; - height: 30px; - min-width: 30px; - padding: 0; - } - - .markdown-view-toolbar { - height: 32px; - } - - .markdown-toolbar-group--document .markdown-view-toolbar .view-toggle-btn { - width: 27px; - height: 26px; - min-width: 27px; - } - .document-action-item { min-height: 44px; } @@ -4061,15 +4792,6 @@ a:focus { scroll-padding-inline: 6px; } - .markdown-toolbar-group--utilities { - position: static; - margin-inline-start: 0; - } - - .markdown-tool-select--insert { - min-width: 30px; - } - .markdown-tool-menu-item, .tab-menu-item, .header-command-menu .app-menu-item { @@ -7794,26 +8516,72 @@ html[lang="ko"] .markdown-body h3 { margin-bottom: 0.6em; } -/* Consistent, position-safe menu motion. Popper owns menu transforms. */ +/* Consistent, position-safe menu motion. Popper owns transform; the individual + translate/scale properties compose with it without disturbing placement. */ @keyframes app-menu-fade { from { opacity: 0; } to { opacity: 1; } } .dropdown-menu { + --app-menu-shift-x: 0px; + --app-menu-shift-y: -4px; opacity: 0; display: block; visibility: hidden; - transition: opacity 0.15s ease-out, visibility 0.15s ease-out; + pointer-events: none; + translate: var(--app-menu-shift-x) var(--app-menu-shift-y); + scale: 0.985; + transform-origin: top center; + transition: + opacity 0.18s cubic-bezier(0.16, 1, 0.3, 1), + translate 0.18s cubic-bezier(0.16, 1, 0.3, 1), + scale 0.18s cubic-bezier(0.16, 1, 0.3, 1), + visibility 0s linear 0.18s; +} + +.dropdown-menu[data-popper-placement^="top"] { + --app-menu-shift-y: 4px; + transform-origin: bottom center; +} + +.dropdown-menu[data-popper-placement^="left"] { + --app-menu-shift-x: 4px; + --app-menu-shift-y: 0px; + transform-origin: center right; +} + +.dropdown-menu[data-popper-placement^="right"] { + --app-menu-shift-x: -4px; + --app-menu-shift-y: 0px; + transform-origin: center left; } .dropdown-menu.show { opacity: 1; visibility: visible; + pointer-events: auto; + translate: 0 0; + scale: 1; + transition-delay: 0s; +} + +.dropdown-menu.show.is-closing { + opacity: 0; + visibility: visible; + pointer-events: none; + translate: var(--app-menu-shift-x) var(--app-menu-shift-y); + scale: 0.985; + transition-duration: 0.12s; + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); } @media (prefers-reduced-motion: reduce) { - .dropdown-menu { + .dropdown-menu, + .dropdown-menu.show, + .dropdown-menu.show.is-closing { + translate: 0 0; + scale: 1; transition: none; } @@ -9808,9 +10576,11 @@ html[data-theme="dark"] .mermaid svg { 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); + background: var(--menu-surface); + border-inline-start: 1px solid var(--menu-border); + box-shadow: var(--menu-shadow); + font-size: var(--ui-font-md); + animation: review-panel-enter 180ms ease-out; } .review-panel-backdrop { @@ -9820,28 +10590,21 @@ html[data-theme="dark"] .mermaid svg { width: 100%; height: 100%; border: 0; - background: rgba(31, 35, 40, 0.38); + background: color-mix(in srgb, var(--text-color) 36%, transparent); cursor: default; + animation: review-backdrop-enter 140ms ease-out; } .review-panel-header { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 16px; - padding: 16px; + gap: 12px; + min-height: 50px; + padding: 7px 9px 7px 12px; 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 { @@ -9851,18 +10614,20 @@ html[data-theme="dark"] .mermaid svg { } .review-panel-header h2 { - font-size: 1rem; + font-size: var(--ui-font-lg); + line-height: 1.25; } .review-panel-summary { - margin: 4px 0 0; + margin: 2px 0 0; color: var(--text-secondary); - font-size: 0.78rem; + font-size: var(--ui-font-sm); + line-height: 1.35; } .review-icon-btn { - width: 32px; - height: 32px; + width: 30px; + height: 30px; flex: 0 0 auto; display: inline-flex; align-items: center; @@ -9870,13 +10635,18 @@ html[data-theme="dark"] .mermaid svg { color: var(--text-secondary); background: transparent; border: 1px solid transparent; - border-radius: 6px; + border-radius: var(--menu-item-radius); cursor: pointer; + transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; +} + +.review-icon-btn i { + font-size: var(--ui-icon-size-md); } .review-icon-btn:hover:not(:disabled) { color: var(--text-color); - background: var(--button-hover); + background: var(--toolbar-control-hover); border-color: var(--border-color); } @@ -9887,7 +10657,8 @@ html[data-theme="dark"] .mermaid svg { } .review-icon-btn:disabled { - opacity: 0.45; + color: color-mix(in srgb, var(--text-secondary) 50%, transparent); + opacity: 0.55; cursor: not-allowed; } @@ -9895,8 +10666,9 @@ html[data-theme="dark"] .mermaid svg { display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 10px 14px; + gap: 8px; + padding: 7px 8px; + background: var(--header-bg); border-bottom: 1px solid var(--border-color); } @@ -9912,87 +10684,93 @@ html[data-theme="dark"] .mermaid svg { display: flex; flex-direction: column; overflow-y: auto; + background: var(--menu-surface); } - -.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-panel-body:has(.review-composer:not([hidden])) .review-empty-state { + display: none; } -.review-filter-btn, -.review-kind-btn { - color: var(--text-secondary); - background: transparent; - border: 0; - border-radius: 6px; - cursor: pointer; - font-weight: 600; +.review-filter-group { + width: 180px; + flex: 0 0 auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 3px; + margin: 0; + padding: 3px; } .review-filter-btn { - padding: 5px 10px; - font-size: 0.74rem; + width: 100%; + min-width: 0; + padding-inline: 0; } -.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-kind-group { + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + margin: 0 0 11px; + padding: 3px; +} + +.review-kind-btn { + width: 100%; + min-width: 0; + padding-inline: 0; } .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; + margin: 12px 12px 2px; + padding: 0; + overflow: hidden; + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-radius); + box-shadow: 0 3px 12px color-mix(in srgb, var(--text-color) 11%, transparent); } .review-composer-heading { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 10px; + min-height: 40px; + padding: 6px 7px 6px 11px; + background: color-mix(in srgb, var(--header-bg) 68%, var(--menu-surface)); + border-bottom: 1px solid var(--border-color); } .review-composer-heading h3 { - font-size: 0.9rem; + font-size: var(--ui-font-lg); + line-height: 1.35; +} + +.review-composer-body { + padding: 10px 12px; } .review-anchor-preview { display: -webkit-box; - margin: 10px 0 12px; + margin: 0 0 10px; + padding: 7px 9px; overflow: hidden; color: var(--text-secondary); - font-size: 0.78rem; + background: var(--button-bg); + border-inline-start: 2px solid var(--border-color); + border-radius: 0 var(--menu-item-radius) var(--menu-item-radius) 0; + font-size: var(--ui-font-md); 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-size: var(--ui-font-md); font-weight: 650; } @@ -10003,11 +10781,12 @@ html[data-theme="dark"] .mermaid svg { resize: vertical; color: var(--text-color); background: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: 7px; + border: 1px solid var(--menu-border); + border-radius: var(--menu-item-radius); font: inherit; - font-size: 0.82rem; + font-size: var(--ui-font-md); line-height: 1.45; + transition: border-color 160ms ease, box-shadow 160ms ease; } .review-feedback-input:focus { @@ -10021,52 +10800,77 @@ html[data-theme="dark"] .mermaid svg { align-items: center; justify-content: space-between; gap: 12px; - margin-top: 9px; + margin: 0; + padding: 9px 12px 10px; + background: color-mix(in srgb, var(--header-bg) 52%, var(--menu-surface)); + border-top: 1px solid var(--border-color); } .review-feedback-count { color: var(--text-secondary); - font-size: 0.7rem; + font-size: var(--ui-font-sm); + font-variant-numeric: tabular-nums; } .review-primary-btn { - min-height: 32px; - padding: 6px 12px; - color: var(--accent-color); - background: var(--button-bg); + min-height: 34px; + padding: 7px 13px; + color: var(--bg-color); + background: var(--accent-color); border: 1px solid var(--accent-color); - border-radius: 6px; + border-radius: var(--menu-item-radius); cursor: pointer; - font-size: 0.76rem; + font-size: var(--ui-font-md); font-weight: 650; + transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; } .review-primary-btn:hover:not(:disabled) { - background: var(--button-hover); + background: color-mix(in srgb, var(--accent-color) 88%, var(--text-color)); + border-color: color-mix(in srgb, var(--accent-color) 88%, var(--text-color)); } .review-primary-btn:disabled { - opacity: 0.5; + color: var(--text-secondary); + background: var(--button-bg); + border-color: var(--border-color); + opacity: 0.62; cursor: not-allowed; } .review-list { flex: 0 0 auto; + display: grid; + gap: 10px; overflow: visible; - padding: 10px 14px 18px; + padding: 10px 12px 16px; } .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; + position: relative; + margin: 0; + padding: 0; + overflow: hidden; + background: var(--menu-surface); + border: 1px solid var(--menu-border); + border-radius: var(--menu-radius); + box-shadow: 0 1px 2px color-mix(in srgb, var(--text-color) 8%, transparent); + transition: background-color 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +@media (hover: hover) { + .review-thread:hover { + border-color: color-mix(in srgb, var(--text-secondary) 42%, var(--menu-border)); + box-shadow: 0 3px 10px color-mix(in srgb, var(--text-color) 9%, transparent); + } } .review-thread.is-resolved { - opacity: 0.72; + background: color-mix(in srgb, var(--button-bg) 52%, var(--menu-surface)); +} + +.review-thread.is-resolved .review-thread-body { + color: var(--text-secondary); } .review-thread.is-orphaned { @@ -10081,8 +10885,12 @@ html[data-theme="dark"] .mermaid svg { } .review-thread-header { + min-height: 40px; justify-content: space-between; gap: 8px; + padding: 6px 7px 6px 11px; + background: color-mix(in srgb, var(--header-bg) 68%, var(--menu-surface)); + border-bottom: 1px solid var(--border-color); } .review-thread-meta { @@ -10090,47 +10898,62 @@ html[data-theme="dark"] .mermaid svg { gap: 6px; } +.review-thread-content { + padding: 10px 12px 11px; +} + .review-kind-label, .review-status-label { display: inline-flex; align-items: center; gap: 4px; - border-radius: 999px; - font-size: 0.67rem; - font-weight: 700; + font-size: var(--ui-font-sm); + font-weight: 650; line-height: 1; } .review-kind-label { - padding: 5px 7px; - color: var(--text-secondary); - background: var(--button-bg); - border: 1px solid var(--border-color); + color: var(--text-color); +} + +.review-kind-label i { + font-size: var(--ui-icon-size-sm); } .review-status-label { + padding-inline-start: 7px; color: var(--text-secondary); + border-inline-start: 1px solid var(--border-color); +} + +.review-status-label i { + font-size: var(--ui-icon-size-sm); } .review-thread-anchor { width: 100%; - margin: 9px 0 8px; - padding: 0; + margin: 0 0 10px; + padding: 7px 9px; overflow: hidden; color: var(--text-secondary); - background: transparent; - border: 0; + background: var(--button-bg); + border: 1px solid transparent; + border-inline-start-color: var(--border-color); + border-inline-start-width: 2px; + border-radius: 0 var(--menu-item-radius) var(--menu-item-radius) 0; cursor: pointer; - font-size: 0.72rem; - line-height: 1.35; + font-size: var(--ui-font-md); + line-height: 1.4; text-align: start; text-overflow: ellipsis; white-space: nowrap; + transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; } .review-thread-anchor:hover:not(:disabled) { - color: var(--accent-color); - text-decoration: underline; + color: var(--text-color); + background: var(--button-hover); + border-color: var(--border-color); } .review-thread-anchor:disabled { @@ -10141,7 +10964,7 @@ html[data-theme="dark"] .mermaid svg { .review-thread-body { margin: 0; color: var(--text-color); - font-size: 0.82rem; + font-size: var(--ui-font-md); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; @@ -10150,37 +10973,64 @@ html[data-theme="dark"] .mermaid svg { .review-thread-dates { display: flex; flex-wrap: wrap; - gap: 4px 12px; - margin-top: 9px; + gap: 6px 12px; + margin-top: 11px; + padding-top: 9px; + border-top: 1px solid var(--border-color); } .review-thread-time { + display: inline-flex; + align-items: center; + gap: 4px; color: var(--text-secondary); - font-size: 0.67rem; + font-size: var(--ui-font-sm); + line-height: 1.3; +} + +.review-thread-time i { + flex: 0 0 auto; + font-size: var(--ui-icon-size-xs); } .review-thread-actions { + flex: 0 0 auto; justify-content: flex-end; - gap: 4px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); + gap: 2px; + margin: 0; + padding: 2px; + background: var(--menu-surface); + border: 1px solid var(--border-color); + border-radius: var(--menu-item-radius); } .review-thread-action { - padding: 5px 7px; + width: 24px; + height: 24px; + min-height: 24px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; color: var(--text-secondary); background: transparent; - border: 0; - border-radius: 5px; + border: 1px solid transparent; + border-radius: calc(var(--menu-item-radius) - 3px); cursor: pointer; - font-size: 0.7rem; - font-weight: 600; + font-size: var(--ui-icon-size-sm); + line-height: 1; + transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease; +} + +.review-thread-action i { + font-size: var(--ui-icon-size-md); } .review-thread-action:hover { color: var(--text-color); - background: var(--button-hover); + background: var(--toolbar-control-hover); + border-color: transparent; } .review-thread-action.is-danger:hover { @@ -10189,27 +11039,34 @@ html[data-theme="dark"] .mermaid svg { .review-empty-state { margin: auto; - padding: 30px 26px; + padding: 36px 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; + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; + color: var(--text-secondary); + background: var(--button-bg); + border: 1px solid var(--border-color); + border-radius: 50%; + font-size: var(--ui-icon-size-xl); } .review-empty-state h3 { - font-size: 0.9rem; + font-size: var(--ui-font-md); } .review-empty-state p { margin: 7px auto 0; max-width: 270px; - font-size: 0.76rem; - line-height: 1.45; + font-size: var(--ui-font-md); + line-height: 1.5; } #markdown-preview .review-target { @@ -10300,6 +11157,24 @@ html[data-theme="dark"] .mermaid svg { .review-thread.is-review-thread-active { border-color: var(--accent-color); + background: color-mix(in srgb, var(--accent-color) 7%, var(--menu-surface)); + box-shadow: inset 3px 0 0 var(--accent-color); +} + +@keyframes review-panel-enter { + from { + opacity: 0; + transform: translateX(12px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes review-backdrop-enter { + from { opacity: 0; } + to { opacity: 1; } } .review-filter-btn:focus-visible, @@ -10354,6 +11229,17 @@ html[data-theme="dark"] .mermaid svg { .review-thread-action { min-height: 40px; } + + .review-thread-action { + min-width: 40px; + width: 40px; + height: 40px; + flex-basis: 40px; + } + + .review-thread-actions { + gap: 6px; + } } @media (max-width: 767px) { @@ -10364,7 +11250,7 @@ html[data-theme="dark"] .mermaid svg { 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); + box-shadow: var(--menu-shadow); } .content-container.is-reviewing .preview-pane { @@ -10390,6 +11276,12 @@ html[data-theme="dark"] .mermaid svg { height: 44px; } + .review-panel-header .review-icon-btn, + .review-composer-heading .review-icon-btn { + width: 44px; + height: 44px; + } + .review-filter-btn, .review-kind-btn, .review-primary-btn, @@ -10399,7 +11291,7 @@ html[data-theme="dark"] .mermaid svg { .review-composer { margin: 10px 10px 4px; - padding: 12px; + padding: 0; } .review-list { @@ -10407,7 +11299,14 @@ html[data-theme="dark"] .mermaid svg { } .review-thread-action { - padding-inline: 10px; + min-width: 44px; + width: 44px; + height: 44px; + flex-basis: 44px; + } + + .review-thread-actions { + gap: 8px; } .review-target-button { @@ -10437,7 +11336,10 @@ html[data-theme="dark"] .mermaid svg { } @media (prefers-reduced-motion: reduce) { + .review-panel, + .review-panel-backdrop, .review-target-button { + animation: none; transition: none; } } @@ -11177,7 +12079,22 @@ html[data-theme="dark"] .mermaid svg { .mobile-settings-toggle.is-active .settings-switch > span { background: #fff; - transform: translateX(14px); + transform: translateX(18px); +} + +.mobile-settings-toggle.is-active .themed-settings-switch > span { + color: var(--text-color); + background: var(--bg-color); +} + +html[data-theme="dark"] .settings-menu-item.is-active .settings-switch, +html[data-theme="dark"] .mobile-settings-toggle.is-active .settings-switch { + background: var(--button-active); +} + +#private-mode-toggle.is-active .settings-switch, +#mobile-private-mode-toggle.is-active .settings-switch { + background: var(--accent-color); } .mobile-menu-command.is-active .mobile-menu-item-label > i, @@ -11749,6 +12666,18 @@ html[data-theme="dark"] .mermaid svg { /* ======================================== VERSIONED RELEASE NOTES ======================================== */ +body.release-notes-active #review-panel, +body.release-notes-active #review-panel-backdrop, +body.release-notes-active #review-composer, +body.release-notes-active #review-pins-layer { + display: none !important; +} + +body.release-notes-active .app-status-bar, +body.release-notes-active .mobile-stats-container { + opacity: 0.5; +} + .tab-item.release-notes-tab { width: max-content; min-width: max-content; diff --git a/tests/e2e/comments-redesign.spec.js b/tests/e2e/comments-redesign.spec.js new file mode 100644 index 00000000..5ff0d31a --- /dev/null +++ b/tests/e2e/comments-redesign.spec.js @@ -0,0 +1,513 @@ +const { test, expect } = require('@playwright/test'); +const { + openApp, + setEditorContent, + stubLazyRendererLibraries, + storedDocuments +} = require('../helpers/app'); + +async function selectPreviewText(page, selector, selectedText, clickAfterMouseup = false) { + await page.locator(selector).filter({ hasText: selectedText }).first().evaluate((element, options) => { + const { text, clickAfterMouseup } = options; + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let node; + let offset = 0; + let startNode = null; + let startOffset = 0; + let endNode = null; + let endOffset = 0; + const fullText = element.textContent || ''; + const start = fullText.indexOf(text); + const end = start + text.length; + while ((node = walker.nextNode())) { + const nextOffset = offset + node.nodeValue.length; + if (!startNode && start >= offset && start <= nextOffset) { + startNode = node; + startOffset = start - offset; + } + if (!endNode && end >= offset && end <= nextOffset) { + endNode = node; + endOffset = end - offset; + break; + } + offset = nextOffset; + } + if (!startNode || !endNode) throw new Error('Unable to build selection'); + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + if (clickAfterMouseup) { + const rect = range.getClientRects()[0]; + element.dispatchEvent(new MouseEvent('click', { + bubbles: true, + clientX: rect.left + (rect.width / 2), + clientY: rect.top + (rect.height / 2) + })); + } + }, { text: selectedText, clickAfterMouseup }); +} + +async function addTextComment(page, selector, selectedText, body) { + await selectPreviewText(page, selector, selectedText); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill(body); + await page.locator('#review-feedback-submit').click(); +} + +test.beforeEach(async ({ page }) => { + await stubLazyRendererLibraries(page); + await openApp(page); + await setEditorContent(page, `--- +author: Baivab Sarkar +--- + +# Comments redesign + +Select this precise phrase for a focused comment. + +- A list item can be commented on too. + +![Reference image](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=) + +Equation $x^2$ remains reviewable. + +\`\`\`js +console.log('reviewable code'); +\`\`\` + +\`\`\`mermaid +graph LR + A --> B +\`\`\``); + await expect(page.locator('#markdown-preview h1')).toHaveText('Comments redesign'); +}); + +test('uses selection → New → Submit and keeps comment metadata compact', async ({ page }) => { + await page.locator('#review-toggle').click(); + + await expect(page.locator('#review-panel-title')).toHaveText('Comments'); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + await expect(page.locator('.review-target-button')).toHaveCount(0); + + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await expect(page.locator('#review-new-comment')).toBeEnabled(); + await expect(page.locator('.review-pending-highlight')).toHaveCount(0); + await expect(page.locator('.review-comment-highlight, .review-comment-element')).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.getSelection().toString())).toBe('precise phrase'); + + await page.locator('#markdown-preview h1').click(); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + await expect.poll(() => page.evaluate(() => window.getSelection().isCollapsed)).toBe(true); + + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await expect(page.locator('#review-new-comment')).toBeEnabled(); + + await page.locator('#review-new-comment').click(); + await expect(page.locator('#review-composer')).toBeVisible(); + await expect(page.locator('#review-composer-author')).toHaveText('Author'); + await expect(page.locator('#review-composer-avatar')).toHaveText('A'); + await expect(page.locator('.review-composer-heading, #review-composer-anchor')).toHaveCount(0); + await expect(page.locator('.review-pending-highlight')).toHaveCount(0); + await expect(page.locator('#review-feedback-input')).toBeFocused(); + await expect.poll(() => page.evaluate(() => { + const highlight = CSS.highlights.get('review-pending-native-selection'); + const range = highlight ? Array.from(highlight)[0] : null; + return { + nativeSelection: window.getSelection().toString(), + selectedText: range ? range.toString() : '', + inputFocused: document.activeElement === document.querySelector('#review-feedback-input') + }; + })).toEqual({ nativeSelection: '', selectedText: 'precise phrase', inputFocused: true }); + await expect.poll(() => page.locator('#review-feedback-input').evaluate(input => ({ + start: input.selectionStart, + end: input.selectionEnd + }))).toEqual({ start: 0, end: 0 }); + + await page.locator('.review-panel-header').click(); + await expect(page.locator('#review-composer')).toBeHidden(); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + await expect.poll(() => page.evaluate(() => window.getSelection().isCollapsed)).toBe(true); + await expect.poll(() => page.evaluate(() => CSS.highlights.has('review-pending-native-selection'))).toBe(false); + + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await page.locator('#review-new-comment').click(); + await expect(page.locator('#review-composer')).toBeVisible(); + await page.keyboard.type('Unsaved draft'); + await page.locator('.review-panel-header').click(); + await expect(page.locator('#review-composer')).toBeVisible(); + await expect(page.locator('#review-feedback-input')).toHaveValue('Unsaved draft'); + await page.locator('#review-composer-cancel').click(); + await expect(page.locator('#review-composer')).toBeHidden(); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await page.locator('#review-new-comment').click(); + await expect(page.locator('#review-composer')).toBeVisible(); + await expect(page.locator('.review-pending-highlight')).toHaveCount(0); + await page.locator('#review-feedback-input').fill('This wording is clear.'); + await expect(page.locator('#review-feedback-submit')).toBeEnabled(); + await page.locator('#review-feedback-submit').click(); + await expect(page.locator('#review-composer')).toBeHidden(); + expect(await page.evaluate(() => ({ + inputValue: document.querySelector('#review-feedback-input').value, + summary: document.querySelector('#review-panel-summary').textContent, + selection: window.getSelection().toString() + }))).toEqual({ inputValue: '', summary: '1 comment', selection: '' }); + + const card = page.locator('.review-thread'); + await expect(card).toHaveCount(1); + await expect(card.locator('.review-author-name').first()).toHaveText('Author'); + await expect(card.locator('.review-author-avatar').first()).toHaveText('A'); + await expect(card.locator('.review-thread-body')).toHaveText('This wording is clear.'); + const submittedHighlight = page.locator('.review-comment-highlight'); + await expect(submittedHighlight).toHaveText('precise phrase'); + await expect(submittedHighlight).toHaveClass(/is-review-selection-start/); + await expect(submittedHighlight).toHaveClass(/is-review-selection-end/); + const markerTypography = await submittedHighlight.evaluate(element => ({ + highlightHeight: element.getBoundingClientRect().height, + startBorderWidth: parseFloat(getComputedStyle(element).borderInlineStartWidth), + endBorderWidth: parseFloat(getComputedStyle(element).borderInlineEndWidth), + startBorderStyle: getComputedStyle(element).borderInlineStartStyle, + endBorderStyle: getComputedStyle(element).borderInlineEndStyle + })); + expect(markerTypography.highlightHeight).toBeGreaterThan(0); + expect(markerTypography.startBorderWidth).toBe(2); + expect(markerTypography.endBorderWidth).toBe(2); + expect(markerTypography.startBorderStyle).toBe('solid'); + expect(markerTypography.endBorderStyle).toBe('solid'); + await expect(card.locator('.review-thread-details, .review-thread-anchor')).toHaveCount(0); + await expect(card.locator('.review-thread-dates')).toBeVisible(); + await expect(card.locator('.review-thread-time').first()).not.toContainText('Opened:'); + await expect(card).not.toContainText('Closed: Not closed'); + const metadataOrder = await card.evaluate(element => { + const name = element.querySelector(':scope > .review-thread-header .review-author-name'); + const time = element.querySelector(':scope > .review-thread-header .review-thread-time'); + const body = element.querySelector(':scope > .review-thread-body'); + return { + nameBeforeTime: Boolean(name.compareDocumentPosition(time) & Node.DOCUMENT_POSITION_FOLLOWING), + timeBeforeBody: Boolean(time.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING), + nameFontSize: parseFloat(getComputedStyle(name).fontSize), + timeFontSize: parseFloat(getComputedStyle(time).fontSize) + }; + }); + expect(metadataOrder.nameBeforeTime).toBe(true); + expect(metadataOrder.timeBeforeBody).toBe(true); + expect(metadataOrder.timeFontSize).toBeLessThan(metadataOrder.nameFontSize); + + await page.locator('#review-panel-close').click(); + await page.reload(); + await page.locator('#review-toggle').click(); + await expect(page.locator('.review-thread')).toContainText('This wording is clear.'); + await expect(page.locator('.review-thread-dates')).toBeVisible(); + const persistedHighlight = page.locator('.review-comment-highlight'); + await expect(persistedHighlight).toHaveText('precise phrase'); + const persistedHighlightStyle = await persistedHighlight.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, + underline: getComputedStyle(element).boxShadow, + visibleRects: element.getClientRects().length + })); + expect(persistedHighlightStyle.background).not.toBe('rgba(0, 0, 0, 0)'); + expect(persistedHighlightStyle.background).not.toBe('transparent'); + expect(persistedHighlightStyle.underline).not.toBe('none'); + expect(persistedHighlightStyle.visibleRects).toBeGreaterThan(0); + expect(JSON.stringify(await storedDocuments(page))).toContain('This wording is clear.'); +}); + +test('synchronizes hover and click states in both directions', async ({ page }) => { + await page.locator('#review-toggle').click(); + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Synchronized comment.'); + await page.locator('#review-feedback-submit').click(); + + const card = page.locator('.review-thread'); + const highlight = page.locator('.review-comment-highlight'); + await page.locator('.review-panel-header').hover(); + await card.hover(); + await expect(highlight).toHaveClass(/is-review-highlight-hover|is-review-highlight-active/); + + await page.locator('.review-panel-header').hover(); + await highlight.hover(); + await expect(card).toHaveClass(/is-review-thread-active|is-review-thread-hover/); + + await highlight.click(); + await expect(card).toHaveClass(/is-review-thread-active/); + await expect(card.locator('.review-thread-details, .review-thread-anchor')).toHaveCount(0); + await expect(card).toHaveCSS('border-color', 'rgb(3, 102, 214)'); + await expect(page.locator('#markdown-preview p').filter({ hasText: 'precise phrase' })).not.toHaveClass(/is-review-highlight-active/); + await expect(highlight).toHaveClass(/is-review-highlight-active/); + + await page.locator('.review-panel-header').hover(); + await card.click(); + await expect(highlight).toHaveClass(/is-review-highlight-active/); +}); + +test('keeps toolbar borders at rest and changes only the pointed or open border to blue', async ({ page }) => { + await page.locator('#review-toggle').click(); + await addTextComment(page, '#markdown-preview p', 'precise phrase', 'Comment one.'); + await addTextComment(page, '#markdown-preview p', 'focused comment', 'Comment two.'); + await addTextComment(page, '#markdown-preview li', 'list item', 'Comment three.'); + + const first = page.locator('.review-thread').filter({ hasText: 'Comment one.' }); + const second = page.locator('.review-thread').filter({ hasText: 'Comment two.' }); + const third = page.locator('.review-thread').filter({ hasText: 'Comment three.' }); + const backgroundsBeforeHover = await Promise.all([first, second, third].map(card => + card.evaluate(element => getComputedStyle(element).backgroundColor) + )); + await second.hover(); + await expect(second).toHaveCSS('border-color', 'rgb(3, 102, 214)'); + await expect(first).toHaveCSS('border-color', 'rgb(225, 228, 232)'); + await expect(third).toHaveCSS('border-color', 'rgb(3, 102, 214)'); + expect(await Promise.all([first, second, third].map(card => + card.evaluate(element => getComputedStyle(element).backgroundColor) + ))).toEqual(backgroundsBeforeHover); + + await page.locator('.review-panel-header').hover(); + await expect(third).toHaveCSS('border-color', 'rgb(3, 102, 214)'); + await expect(first).toHaveCSS('border-color', 'rgb(225, 228, 232)'); + await expect(second).toHaveCSS('border-color', 'rgb(225, 228, 232)'); + expect(await Promise.all([first, second, third].map(card => + card.evaluate(element => getComputedStyle(element).backgroundColor) + ))).toEqual(backgroundsBeforeHover); +}); + +test('adds nested replies and persists their author and timestamp', async ({ page }) => { + await page.locator('#review-toggle').click(); + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Top-level comment.'); + await page.locator('#review-feedback-submit').click(); + + const card = page.locator('.review-thread'); + await expect(card.locator('.review-reply-input')).toBeVisible(); + await card.locator('.review-reply-input').fill('First nested reply.'); + await card.locator('.review-reply-submit').click(); + await expect(card.locator('.review-reply')).toHaveCount(1); + await expect(card.locator('.review-reply .review-author-name')).toHaveText('Author'); + await expect(card.locator('.review-reply-body')).toHaveText('First nested reply.'); + await expect(card.locator('.review-reply-time')).not.toBeEmpty(); + const replyOrder = await card.locator('.review-reply').evaluate(element => { + const name = element.querySelector('.review-author-name'); + const time = element.querySelector('.review-reply-time'); + const body = element.querySelector('.review-reply-body'); + return { + nameBeforeTime: Boolean(name.compareDocumentPosition(time) & Node.DOCUMENT_POSITION_FOLLOWING), + timeBeforeBody: Boolean(time.compareDocumentPosition(body) & Node.DOCUMENT_POSITION_FOLLOWING), + nameFontSize: parseFloat(getComputedStyle(name).fontSize), + timeFontSize: parseFloat(getComputedStyle(time).fontSize) + }; + }); + expect(replyOrder.nameBeforeTime).toBe(true); + expect(replyOrder.timeBeforeBody).toBe(true); + expect(replyOrder.timeFontSize).toBeLessThan(replyOrder.nameFontSize); + + const firstReply = card.locator('.review-reply').first(); + await firstReply.hover(); + const replyActions = firstReply.locator('.review-reply-actions'); + await expect(replyActions.locator('[data-review-action="edit-reply"]')).toBeVisible(); + await expect(replyActions.locator('[data-review-action="delete-reply"]')).toBeVisible(); + const actionPosition = await firstReply.evaluate(element => { + const header = element.querySelector('.review-reply-header').getBoundingClientRect(); + const actions = element.querySelector('.review-reply-actions').getBoundingClientRect(); + return { + alignedAtTop: Math.abs(actions.top - header.top) <= 1, + alignedAtEnd: Math.abs(actions.right - header.right) <= 1 + }; + }); + expect(actionPosition).toEqual({ alignedAtTop: true, alignedAtEnd: true }); + + await replyActions.locator('[data-review-action="edit-reply"]').click(); + const editInput = card.locator('.review-reply-edit-input'); + await expect(editInput).toBeVisible(); + await expect(editInput).toHaveValue('First nested reply.'); + await editInput.fill('Edited nested reply.'); + await card.locator('[data-review-action="save-reply"]').click(); + await expect(card.locator('.review-reply-body')).toHaveText('Edited nested reply.'); + + await card.locator('.review-reply-input').fill('Second nested reply.'); + await card.locator('.review-reply-input').press('Enter'); + await expect(card.locator('.review-reply')).toHaveCount(2); + + const secondReply = card.locator('.review-reply').last(); + await secondReply.hover(); + await secondReply.locator('[data-review-action="delete-reply"]').click(); + await expect(card.locator('.review-reply')).toHaveCount(1); + + await card.locator('.review-reply-input').fill('Persisted nested reply.'); + await card.locator('.review-reply-input').press('Enter'); + await expect(card.locator('.review-reply')).toHaveCount(2); + + await page.reload(); + await expect(page.locator('#markdown-preview h1')).toHaveText('Comments redesign'); + await page.locator('#review-toggle').click(); + await expect(page.locator('#review-panel')).toBeVisible(); + await page.locator('.review-thread').click(); + await expect(page.locator('.review-reply')).toHaveCount(2); + await expect(page.locator('.review-reply').first()).toContainText('Edited nested reply.'); + expect(JSON.stringify(await storedDocuments(page))).toContain('Persisted nested reply.'); + expect(JSON.stringify(await storedDocuments(page))).not.toContain('Second nested reply.'); +}); + +test('keeps comment actions and nested indentation consistent across screen sizes', async ({ page }) => { + await page.locator('#review-toggle').click(); + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Responsive comment.'); + await page.locator('#review-feedback-submit').click(); + + const card = page.locator('.review-thread'); + await card.locator('.review-reply-input').fill('Indented reply.'); + await card.locator('.review-reply-submit').click(); + + const measurements = []; + for (const viewport of [ + { width: 1280, height: 800 }, + { width: 900, height: 720 }, + { width: 375, height: 667 } + ]) { + await page.setViewportSize(viewport); + await card.scrollIntoViewIfNeeded(); + measurements.push(await card.evaluate(element => { + const actions = element.querySelector('.review-thread-actions'); + const buttons = Array.from(element.querySelectorAll(':scope > .review-thread-header > .review-thread-actions > .review-thread-action')); + const icons = Array.from(element.querySelectorAll(':scope > .review-thread-header > .review-thread-actions > .review-thread-action i')); + const topAvatar = element.querySelector(':scope > .review-thread-header .review-author-avatar'); + const replyAvatar = element.querySelector('.review-reply .review-author-avatar'); + const panel = element.closest('#review-panel'); + return { + actionWidth: Math.round(actions.getBoundingClientRect().width), + actionHeight: Math.round(actions.getBoundingClientRect().height), + buttonWidths: buttons.map(button => Math.round(button.getBoundingClientRect().width)), + buttonHeights: buttons.map(button => Math.round(button.getBoundingClientRect().height)), + buttonRows: new Set(buttons.map(button => Math.round(button.getBoundingClientRect().top))).size, + iconSizes: icons.map(icon => Math.round(icon.getBoundingClientRect().width)), + replyIndent: Math.round(replyAvatar.getBoundingClientRect().left - topAvatar.getBoundingClientRect().left), + cardOverflow: element.scrollWidth - element.clientWidth, + panelOverflow: panel.scrollWidth - panel.clientWidth, + nameFont: getComputedStyle(element.querySelector('.review-author-name')).fontFamily, + bodyFont: getComputedStyle(document.body).fontFamily, + activeBackground: getComputedStyle(element).backgroundColor, + activeBorder: getComputedStyle(element).borderColor, + replyButtonBackground: getComputedStyle(element.querySelector('.review-reply-submit')).backgroundColor + }; + })); + } + + measurements.forEach(metrics => { + expect(metrics.actionWidth).toBeLessThanOrEqual(82); + expect(metrics.actionHeight).toBe(30); + expect(metrics.buttonWidths).toEqual([24, 24, 24]); + expect(metrics.buttonHeights).toEqual([24, 24, 24]); + expect(metrics.buttonRows).toBe(1); + expect(metrics.iconSizes).toEqual([14, 14, 14]); + expect(metrics.replyIndent).toBeGreaterThanOrEqual(18); + expect(metrics.cardOverflow).toBeLessThanOrEqual(0); + expect(metrics.panelOverflow).toBeLessThanOrEqual(0); + expect(metrics.nameFont).toBe(metrics.bodyFont); + expect(metrics.activeBackground).toBe('rgb(255, 255, 255)'); + expect(metrics.activeBorder).toBe('rgb(3, 102, 214)'); + expect(metrics.replyButtonBackground).toBe('rgb(3, 102, 214)'); + }); +}); + +test('comments on YAML, images, rendered math, diagrams, and resolves without empty closed metadata', async ({ page }) => { + await page.locator('#review-toggle').click(); + + const codeBlock = page.locator('#markdown-preview pre').filter({ hasText: 'reviewable code' }); + await expect(codeBlock).not.toHaveAttribute('data-review-anchor', /.+/); + await codeBlock.click(); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + + const yamlTable = page.locator('#markdown-preview .frontmatter-table'); + await expect(yamlTable).toHaveAttribute('data-review-anchor', /.+/); + await selectPreviewText(page, '#markdown-preview .frontmatter-table', 'Baivab Sarkar', true); + await expect(page.locator('#review-new-comment')).toBeEnabled(); + await expect(yamlTable).toHaveClass(/has-review-native-text-selection/); + await expect(yamlTable).not.toHaveClass(/is-review-element-selected|review-pending-element/); + await expect(yamlTable).toHaveCSS('outline-style', 'none'); + await page.locator('#markdown-preview h1').click(); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + + await yamlTable.click(); + await expect(yamlTable).toHaveClass(/is-review-element-selected/); + await expect(page.locator('#review-new-comment')).toBeEnabled(); + await page.locator('#markdown-preview h1').click(); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + + const image = page.locator('#markdown-preview img[alt="Reference image"]'); + await image.hover(); + await expect(image).toHaveCSS('outline-style', 'dotted'); + await expect(image).toHaveCSS('outline-color', 'rgb(124, 58, 237)'); + await image.click(); + await expect(image).toHaveClass(/is-review-element-selected/); + await expect(page.locator('#review-new-comment')).toBeEnabled(); + await page.locator('#markdown-preview h1').click(); + await expect(image).not.toHaveClass(/is-review-element-selected|review-pending-element/); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + + for (const [selector, clickSelector, text] of [ + ['#markdown-preview .frontmatter-table', '#markdown-preview .frontmatter-table', 'YAML comment.'], + ['#markdown-preview img[alt="Reference image"]', '#markdown-preview img[alt="Reference image"]', 'Image comment.'], + ['#markdown-preview mjx-container', '#markdown-preview mjx-container', 'Math comment.'], + ['#markdown-preview .diagram-viewer', '#markdown-preview .diagram-viewer svg', 'Diagram comment.'] + ]) { + await expect(page.locator(selector).first()).toBeVisible(); + await expect(page.locator(clickSelector).first()).toBeVisible(); + await page.locator(clickSelector).first().click(); + await expect(page.locator('#review-new-comment')).toBeEnabled(); + await page.locator('#review-new-comment').click(); + await expect(page.locator(selector).first()).toHaveClass(/review-pending-element/); + await page.locator('#review-feedback-input').fill(text); + await page.locator('#review-feedback-submit').click(); + } + + await expect(page.locator('.review-thread')).toHaveCount(4); + await expect(page.locator('.review-comment-element')).toHaveCount(4); + const activeCard = page.locator('.review-thread').filter({ hasText: 'Math comment.' }); + await activeCard.click(); + await activeCard.locator('[data-review-action="toggle-resolved"]').click(); + await page.locator('[data-review-filter="resolved"]').click(); + const resolvedCard = page.locator('.review-thread').filter({ hasText: 'Math comment.' }); + await expect(resolvedCard).toBeVisible(); + await resolvedCard.click(); + await expect(resolvedCard.locator('.review-thread-time.is-closed')).toContainText('Closed:'); + await expect(page.locator('.review-status-label')).toHaveAttribute('aria-label', 'Resolved'); +}); + +test('collapses long nested conversations to first and last replies and expands on demand', async ({ page }) => { + await page.locator('#review-toggle').click(); + await selectPreviewText(page, '#markdown-preview p', 'precise phrase'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Long conversation root.'); + await page.locator('#review-feedback-submit').click(); + + const card = page.locator('.review-thread'); + for (let index = 1; index <= 5; index += 1) { + await card.locator('.review-reply-input').fill(`Nested reply ${index}.`); + await card.locator('.review-reply-submit').click(); + } + + await expect(card).toHaveClass(/has-collapsible-replies/); + await expect(card.locator('.review-reply')).toHaveCount(2); + await expect(card.locator('.review-reply').first()).toContainText('Nested reply 1.'); + await expect(card.locator('.review-reply').last()).toContainText('Nested reply 5.'); + await expect(card.locator('.review-replies-more')).toHaveText('Show 3 more replies'); + const headerToggle = card.locator('.review-thread-actions [data-review-action="toggle-replies"]'); + await expect(headerToggle).toHaveAttribute('aria-label', 'Expand replies'); + await expect(headerToggle.locator('i')).toHaveClass(/lucide-unfold-vertical/); + + await card.locator('.review-replies-more').click(); + await expect(card.locator('.review-reply')).toHaveCount(5); + await expect(headerToggle).toHaveAttribute('aria-label', 'Collapse replies'); + await expect(headerToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(headerToggle.locator('i')).toHaveClass(/lucide-fold-vertical/); + + await headerToggle.click(); + await expect(card.locator('.review-reply')).toHaveCount(2); + await expect(headerToggle).toHaveAttribute('aria-label', 'Expand replies'); + await expect(headerToggle.locator('i')).toHaveClass(/lucide-unfold-vertical/); +}); diff --git a/tests/e2e/responsive-storage.spec.js b/tests/e2e/responsive-storage.spec.js index c5638b2a..15c4341f 100644 --- a/tests/e2e/responsive-storage.spec.js +++ b/tests/e2e/responsive-storage.spec.js @@ -8,15 +8,49 @@ const { waitForAppReady } = require('../helpers/app'); +async function resolveCssColor(page, variableName) { + return page.evaluate(name => { + const probe = document.createElement('span'); + probe.style.color = `var(${name})`; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, variableName); +} + test('theme switching stores and restores the selected theme', async ({ page }) => { await openApp(page); const initialTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); + const initialThemeIcon = initialTheme === 'dark' ? 'lucide-moon' : 'lucide-sun'; + const toggledThemeIcon = initialTheme === 'dark' ? 'lucide-sun' : 'lucide-moon'; await page.getByRole('button', { name: 'Open workspace settings' }).click(); - await page.locator('#theme-toggle').click(); + const themeToggle = page.locator('#theme-toggle'); + const themeSwitch = themeToggle.locator('.settings-switch'); + const privateModeSwitch = page.locator('#private-mode-toggle .settings-switch'); + + await expect(themeToggle).toHaveClass(/settings-menu-item--toggle/); + await expect(themeToggle).toHaveAttribute('aria-pressed', String(initialTheme === 'dark')); + await expect(themeSwitch).toBeVisible(); + await expect(themeSwitch).toHaveCSS('width', '40px'); + await expect(themeSwitch).toHaveCSS('height', '22px'); + await expect(page.locator('#theme-switch-icon')).toHaveClass(new RegExp(`\\b${initialThemeIcon}\\b`)); + await expect(page.locator('#theme-switch-icon')).not.toHaveCSS('mask-image', 'none'); + await expect(themeSwitch).toHaveCSS('width', await privateModeSwitch.evaluate(element => getComputedStyle(element).width)); + await expect(themeSwitch).toHaveCSS('height', await privateModeSwitch.evaluate(element => getComputedStyle(element).height)); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('border-radius', '50%'); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor)); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color)); + + await themeToggle.click(); const toggledTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); expect(toggledTheme).not.toBe(initialTheme); + await expect(themeToggle).toHaveAttribute('aria-pressed', String(toggledTheme === 'dark')); + await expect(page.locator('#theme-switch-icon')).toHaveClass(new RegExp(`\\b${toggledThemeIcon}\\b`)); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor)); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color)); await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('markdownViewerGlobalState') || '{}').theme)).toBe(toggledTheme); await page.reload(); @@ -24,6 +58,44 @@ test('theme switching stores and restores the selected theme', async ({ page }) await expect.poll(() => page.evaluate(() => document.documentElement.getAttribute('data-theme'))).toBe(toggledTheme); }); +test('dark mode keeps the active theme switch dark and the private mode switch blue', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'dark' }); + await openApp(page); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.getByRole('button', { name: 'Open workspace settings' }).click(); + const themeSwitch = page.locator('#theme-toggle .settings-switch'); + const privateModeToggle = page.locator('#private-mode-toggle'); + const privateModeSwitch = privateModeToggle.locator('.settings-switch'); + const darkTrackColor = await privateModeSwitch.evaluate(element => getComputedStyle(element).backgroundColor); + const accentTrackColor = await resolveCssColor(page, '--accent-color'); + + await expect(themeSwitch).toHaveCSS('background-color', darkTrackColor); + await privateModeToggle.click(); + await expect(privateModeToggle).toHaveAttribute('aria-pressed', 'true'); + await expect(privateModeSwitch).toHaveCSS('background-color', accentTrackColor); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('width', '12px'); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('height', '12px'); + await expect(page.locator('#theme-switch-icon')).not.toHaveCSS('filter', 'none'); + await expect(privateModeSwitch.locator('.lucide')).toHaveCount(0); +}); + +test('light mode uses the accent blue for active private mode without an inner icon', async ({ page }) => { + await page.emulateMedia({ colorScheme: 'light' }); + await openApp(page); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light'); + + await page.getByRole('button', { name: 'Open workspace settings' }).click(); + const privateModeToggle = page.locator('#private-mode-toggle'); + const privateModeSwitch = privateModeToggle.locator('.settings-switch'); + const accentTrackColor = await resolveCssColor(page, '--accent-color'); + + await privateModeToggle.click(); + await expect(privateModeToggle).toHaveAttribute('aria-pressed', 'true'); + await expect(privateModeSwitch).toHaveCSS('background-color', accentTrackColor); + await expect(privateModeSwitch.locator('.lucide')).toHaveCount(0); +}); + test('document tabs persist across reload in normal mode', async ({ page }) => { await openApp(page); await setEditorContent(page, '# Persistence Check\n\nSaved locally.'); @@ -364,7 +436,10 @@ test('mobile layout exposes menu controls at 375px width', async ({ page }) => { await expect(page.locator('#mobile-review-toggle')).toBeVisible(); await expect(page.locator('#mobile-private-mode-toggle')).toBeVisible(); await expect(page.locator('#mobile-theme-toggle .settings-switch')).toBeVisible(); + await expect(page.locator('#mobile-theme-switch-icon')).toHaveClass(/lucide-(sun|moon)/); + await expect(page.locator('#mobile-theme-switch-icon')).not.toHaveCSS('mask-image', 'none'); await expect(page.locator('#mobile-private-mode-toggle .settings-switch')).toBeVisible(); + await expect(page.locator('#mobile-private-mode-toggle .settings-switch .lucide')).toHaveCount(0); const settingsOrder = await page.locator('#mobile-menu-settings-panel > *').evaluateAll(elements => elements.filter(element => element.matches('button, .mobile-menu-language')).map(element => element.id || element.className) ); diff --git a/tests/e2e/review-design.spec.js b/tests/e2e/review-design.spec.js new file mode 100644 index 00000000..a53074d3 --- /dev/null +++ b/tests/e2e/review-design.spec.js @@ -0,0 +1,62 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +test.beforeEach(async ({ page }) => { + await openApp(page); + await setEditorContent(page, '# Compact comments\n\nSelect this content to review.'); + await expect(page.locator('#markdown-preview h1')).toHaveText('Compact comments'); +}); + +test('uses a compact selection-first panel with application tokens', async ({ page }) => { + await page.locator('#review-toggle').click(); + await expect(page.locator('.review-target-button')).toHaveCount(0); + await expect(page.locator('#review-new-comment')).toBeDisabled(); + await expect(page.locator('#review-panel-title')).toHaveText('Comments'); + + const metrics = await page.locator('#review-panel').evaluate(panel => { + const header = panel.querySelector('.review-panel-header'); + const toolbar = panel.querySelector('.review-panel-toolbar'); + const filters = Array.from(panel.querySelectorAll('.review-filter-btn')); + return { + width: panel.getBoundingClientRect().width, + headerHeight: header.getBoundingClientRect().height, + toolbarOverflow: toolbar.scrollWidth - toolbar.clientWidth, + filterHeights: filters.map(button => button.getBoundingClientRect().height), + fontFamily: getComputedStyle(panel).fontFamily, + bodyFontFamily: getComputedStyle(document.body).fontFamily + }; + }); + expect(metrics.width).toBeLessThanOrEqual(345); + expect(metrics.headerHeight).toBeLessThanOrEqual(48); + expect(metrics.toolbarOverflow).toBeLessThanOrEqual(0); + expect(metrics.fontFamily).toBe(metrics.bodyFontFamily); + metrics.filterHeights.forEach(height => expect(height).toBeGreaterThanOrEqual(24)); +}); + +test('mobile Comments sheet remains usable without horizontal overflow', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.setViewportSize({ width: 375, height: 667 }); + await page.locator('#mobile-menu-toggle').click(); + await page.locator('#mobile-review-toggle').click(); + await expect(page.locator('#review-panel')).toBeVisible(); + + const metrics = await page.locator('#review-panel').evaluate(panel => { + const rect = panel.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + viewportWidth: window.innerWidth, + scrollWidth: panel.scrollWidth, + clientWidth: panel.clientWidth, + animationName: getComputedStyle(panel).animationName, + closeHeight: panel.querySelector('#review-panel-close').getBoundingClientRect().height, + newHeight: panel.querySelector('#review-new-comment').getBoundingClientRect().height + }; + }); + expect(metrics.left).toBeGreaterThanOrEqual(0); + expect(metrics.right).toBeLessThanOrEqual(metrics.viewportWidth); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth); + expect(metrics.animationName).toBe('none'); + expect(metrics.closeHeight).toBeGreaterThanOrEqual(40); + expect(metrics.newHeight).toBeGreaterThanOrEqual(40); +}); diff --git a/tests/e2e/review-mode.spec.js b/tests/e2e/review-mode.spec.js index 4d61f102..4350251c 100644 --- a/tests/e2e/review-mode.spec.js +++ b/tests/e2e/review-mode.spec.js @@ -1,283 +1,82 @@ const { test, expect } = require('@playwright/test'); -const { - openApp, - setEditorContent, - editorValue, - storedDocuments, - stubLazyRendererLibraries -} = require('../helpers/app'); - -const reviewMarkdown = `# Review heading - -Paragraph for focused feedback. - -\`\`\`javascript -console.log('review'); -\`\`\` - -\`\`\`mermaid -flowchart LR - A --> B -\`\`\``; +const { openApp, setEditorContent, storedDocuments } = require('../helpers/app'); + +async function selectText(page, selector, text) { + await page.locator(selector).evaluate((element, selectedText) => { + const node = Array.from(element.childNodes).find(child => ( + child.nodeType === Node.TEXT_NODE && child.nodeValue.includes(selectedText) + )); + if (!node) throw new Error('Selection text not found'); + const start = node.nodeValue.indexOf(selectedText); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + selectedText.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }, text); +} test.beforeEach(async ({ page }) => { - await stubLazyRendererLibraries(page); await openApp(page); - await setEditorContent(page, reviewMarkdown); - await expect(page.locator('#markdown-preview h1')).toHaveText('Review heading'); + await setEditorContent(page, '# Stable heading\n\nA stable paragraph for comment lifecycle testing.'); + await expect(page.locator('#markdown-preview h1')).toHaveText('Stable heading'); }); -test('adds persistent comments and suggestions without changing Markdown', async ({ page }) => { - const original = await editorValue(page); +test('comments persist without changing Markdown and retain selection anchors', async ({ page }) => { + const markdown = await page.locator('#markdown-editor').inputValue(); await page.locator('#review-toggle').click(); - - await expect(page.locator('#review-panel')).toBeVisible(); - await expect(page.locator('#markdown-editor')).toHaveAttribute('readonly', ''); - await expect(page.locator('#review-pins-layer .review-target-button')).toHaveCount(4); - const pinWidths = await page.locator('#review-pins-layer .review-target-button').evaluateAll((pins) => ( - pins.map((pin) => pin.getBoundingClientRect().width) - )); - expect(Math.max(...pinWidths)).toBeLessThanOrEqual(44); - await expect(page.locator('#markdown-preview > h1.review-target')).toHaveText('Review heading'); - - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="heading:"]').click(); - await page.locator('#review-feedback-input').fill('Clarify the audience for this section.'); - await page.locator('#review-feedback-submit').click(); - await expect(page.locator('.review-thread')).toContainText('Clarify the audience'); - - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').click(); - await page.locator('[data-review-kind="suggestion"]').click(); - await page.locator('#review-feedback-input').fill('Use a shorter opening sentence.'); + await selectText(page, '#markdown-preview p', 'stable paragraph'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Persistent anchored comment.'); await page.locator('#review-feedback-submit').click(); - await expect(page.locator('.review-thread[data-kind="suggestion"]')).toContainText('Use a shorter opening'); - await expect(page.locator('.review-thread-dates')).toHaveCount(2); - await expect(page.locator('.review-thread-dates').first()).toContainText('Opened:'); - await expect(page.locator('.review-thread-dates').first()).toContainText('Closed: Not closed'); - await expect(page.locator('#review-toolbar-count')).toHaveText('2'); - const reviewButtonMetrics = await page.locator('#review-toggle').evaluate((button) => { - const rect = button.getBoundingClientRect(); - const count = button.querySelector('#review-toolbar-count'); - return { - height: rect.height, - width: rect.width, - countVisible: count && getComputedStyle(count).display !== 'none', - countText: count && count.textContent, - active: button.classList.contains('is-active') - }; - }); - expect(reviewButtonMetrics.height).toBe(30); - expect(reviewButtonMetrics.width).toBeGreaterThan(30); - expect(reviewButtonMetrics).toMatchObject({ countVisible: true, countText: '2', active: true }); - await page.evaluate(() => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: async (text) => { window.__copiedReviewSummary = text; } } - }); - }); - await page.locator('#review-copy-summary').click(); - const copiedSummary = await page.evaluate(() => window.__copiedReviewSummary); - expect(copiedSummary).toContain('Generated:'); - expect(copiedSummary).toContain('Total items: 2'); - expect(copiedSummary).toContain('Open items: 2'); - expect(copiedSummary).toContain('Resolved items: 0'); - expect(copiedSummary).toContain('Opened:'); - expect(copiedSummary).toContain('Closed: Not closed'); - await expect.poll(() => editorValue(page)).toBe(original); - await expect.poll(async () => JSON.stringify(await storedDocuments(page))).toContain('Clarify the audience'); + expect(await page.locator('#markdown-editor').inputValue()).toBe(markdown); + await expect.poll(async () => JSON.stringify(await storedDocuments(page))).toContain('Persistent anchored comment.'); + const stored = JSON.stringify(await storedDocuments(page)); + expect(stored).toContain('Persistent anchored comment.'); + expect(stored).toContain('stable paragraph'); + expect(stored).toContain('"kind":"text"'); await page.reload(); - await expect(page.locator('#markdown-editor')).toBeVisible(); await page.locator('#review-toggle').click(); - await expect(page.locator('.review-thread')).toHaveCount(2); - await expect(page.locator('#review-toolbar-count')).toHaveText('2'); + await expect(page.locator('.review-thread')).toContainText('Persistent anchored comment.'); + await expect(page.locator('.review-comment-highlight')).toHaveText('stable paragraph'); }); -test('uses an in-app confirmation modal when deleting a review item', async ({ page }) => { - await page.evaluate(() => { - window.confirm = () => { throw new Error('Native confirm should not be used for review deletion.'); }; - }); +test('comments can be edited, resolved, reopened, and deleted', async ({ page }) => { await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="heading:"]').click(); - await page.locator('#review-feedback-input').fill('Delete this through the app modal.'); + await selectText(page, '#markdown-preview h1', 'Stable heading'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Original comment.'); await page.locator('#review-feedback-submit').click(); - await page.locator('[data-review-action="delete"]').click(); - await expect(page.locator('#review-delete-modal')).toBeVisible(); - await expect(page.locator('#review-delete-modal')).toHaveAttribute('aria-hidden', 'false'); - await expect(page.locator('#review-delete-title')).toHaveText('Delete review item?'); - await expect.poll(() => page.evaluate(() => document.activeElement && document.activeElement.id)).toBe('review-delete-cancel'); - - await page.locator('#review-delete-modal').press('Escape'); - await expect(page.locator('#review-delete-modal')).toBeHidden(); - await expect(page.locator('#review-panel')).toBeVisible(); - await expect(page.locator('.review-thread')).toHaveCount(1); - - await page.locator('[data-review-action="delete"]').click(); - await page.locator('#review-delete-confirm').click(); - await expect(page.locator('.review-thread')).toHaveCount(0); - await expect(page.locator('#review-toolbar-count')).toBeHidden(); - await expect(page.locator('#review-delete-modal')).toBeHidden(); -}); - -test('resolves and deletes all review items from the panel toolbar', async ({ page }) => { - await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="heading:"]').click(); - await page.locator('#review-feedback-input').fill('First bulk review item.'); - await page.locator('#review-feedback-submit').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').click(); - await page.locator('#review-feedback-input').fill('Second bulk review item.'); + const card = page.locator('.review-thread'); + await card.locator('[data-review-action="edit"]').click(); + await page.locator('#review-feedback-input').fill('Edited comment.'); await page.locator('#review-feedback-submit').click(); + await expect(card).toContainText('Edited comment.'); - await expect(page.locator('#review-resolve-all')).toBeEnabled(); - await expect(page.locator('#review-delete-all')).toBeEnabled(); - await page.locator('#review-resolve-all').click(); - await expect(page.locator('#review-toolbar-count')).toBeHidden(); - await expect(page.locator('#review-panel-summary')).toHaveText('All feedback resolved'); + await card.locator('[data-review-action="toggle-resolved"]').click(); await page.locator('[data-review-filter="resolved"]').click(); - await expect(page.locator('.review-thread')).toHaveCount(2); - await expect(page.locator('.review-thread-dates').first()).toContainText('Closed:'); - await expect(page.locator('.review-thread-dates').first()).not.toContainText('Closed: Not closed'); - const readResolvedAtValues = async () => { - const documents = await storedDocuments(page); - return documents.flatMap((tab) => (tab.reviewThreads || []).map((thread) => thread.resolvedAt)); - }; - await expect.poll(async () => (await readResolvedAtValues()).length).toBe(2); - const resolvedAtValues = await readResolvedAtValues(); - expect(resolvedAtValues).toHaveLength(2); - expect(resolvedAtValues.every(Number.isFinite)).toBe(true); - - await page.locator('#review-delete-all').click(); - await expect(page.locator('#review-delete-title')).toHaveText('Delete all review items?'); - await expect(page.locator('#review-delete-description')).toContainText('All 2 review items in this document'); - await page.locator('#review-delete-cancel').click(); - await expect(page.locator('.review-thread')).toHaveCount(2); + await page.locator('.review-thread').click(); + await expect(page.locator('.review-thread-time.is-closed')).toContainText('Closed:'); + await page.locator('[data-review-action="toggle-resolved"]').click(); + await page.locator('[data-review-filter="open"]').click(); + await expect(page.locator('.review-thread')).toContainText('Edited comment.'); - await page.locator('#review-delete-all').click(); + await page.locator('[data-review-action="delete"]').click(); await page.locator('#review-delete-confirm').click(); await expect(page.locator('.review-thread')).toHaveCount(0); - await expect(page.locator('#review-copy-summary')).toBeDisabled(); - await expect(page.locator('#review-resolve-all')).toBeDisabled(); - await expect(page.locator('#review-delete-all')).toBeDisabled(); -}); - -test('resolves feedback and retains an orphaned thread when its block changes', async ({ page }) => { - await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="heading:"]').click(); - await page.locator('#review-feedback-input').fill('Rework this heading.'); - await page.locator('#review-feedback-submit').click(); - - await page.locator('.review-thread-action', { hasText: 'Resolve' }).click(); - await expect(page.locator('#review-toolbar-count')).toBeHidden(); - await page.locator('[data-review-filter="resolved"]').click(); - await expect(page.locator('.review-thread')).toContainText('Resolved'); - - await page.locator('#review-panel-close').click(); - await setEditorContent(page, reviewMarkdown.replace('# Review heading', '# Replacement heading')); - await expect(page.locator('#markdown-preview h1')).toHaveText('Replacement heading'); - await page.locator('#review-toggle').click(); - await page.locator('[data-review-filter="all"]').click(); - await expect(page.locator('.review-thread')).toHaveClass(/is-orphaned/); - await expect(page.locator('.review-thread-anchor')).toContainText('Anchor no longer in preview'); -}); - -test('does not duplicate review mode inside mobile settings', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 812 }); - await page.locator('#mobile-menu-toggle').click(); - await page.locator('[data-mobile-menu-section-toggle]', { hasText: 'Settings' }).click(); - - await expect(page.locator('#mobile-review-toggle')).toHaveCount(1); - await expect(page.locator('#review-toggle')).toBeAttached(); -}); - -test('keeps feedback orphaned when the number of identical blocks changes', async ({ page }) => { - await setEditorContent(page, '# Duplicates\n\nRepeated paragraph.\n\nRepeated paragraph.'); - await expect(page.locator('#markdown-preview p')).toHaveCount(2); - await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').nth(1).click(); - await page.locator('#review-feedback-input').fill('Feedback for the second copy.'); - await page.locator('#review-feedback-submit').click(); - await page.locator('#review-panel-close').click(); - - await setEditorContent(page, '# Duplicates\n\nRepeated paragraph.\n\nRepeated paragraph.\n\nRepeated paragraph.'); - await expect(page.locator('#markdown-preview p')).toHaveCount(3); - await page.locator('#review-toggle').click(); - await expect(page.locator('.review-thread')).toHaveClass(/is-orphaned/); -}); - -test('keeps feedback orphaned when identical blocks are reordered', async ({ page }) => { - const firstOrder = '# First\n\nRepeated paragraph.\n\n# Second\n\nRepeated paragraph.'; - const secondOrder = '# Second\n\nRepeated paragraph.\n\n# First\n\nRepeated paragraph.'; - await setEditorContent(page, firstOrder); - await expect(page.locator('#markdown-preview p')).toHaveCount(2); - await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').nth(1).click(); - await page.locator('#review-feedback-input').fill('Feedback for the paragraph under Second.'); - await page.locator('#review-feedback-submit').click(); - await page.locator('#review-panel-close').click(); - - await setEditorContent(page, secondOrder); - await expect(page.locator('#markdown-preview h1').first()).toHaveText('Second'); - await page.locator('#review-toggle').click(); - await expect(page.locator('.review-thread')).toHaveClass(/is-orphaned/); -}); - -test('keeps feedback attached when content near a unique block changes', async ({ page }) => { - await setEditorContent(page, '# Stable\n\nUnique reviewed paragraph.\n\nOriginal neighbor.'); - await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').first().click(); - await page.locator('#review-feedback-input').fill('Keep this attached to the unique paragraph.'); - await page.locator('#review-feedback-submit').click(); - await page.locator('#review-panel-close').click(); - - await setEditorContent(page, '# Stable\n\nUnique reviewed paragraph.\n\nInserted neighbor.\n\nOriginal neighbor.'); - await page.locator('#review-toggle').click(); - await expect(page.locator('.review-thread')).not.toHaveClass(/is-orphaned/); - await expect(page.locator('.review-thread-anchor')).not.toContainText('Anchor no longer in preview'); }); -test('keeps math feedback attached after asynchronous typesetting and reload', async ({ page }) => { - const installMathJaxStub = () => { - window.MathJax = { - startup: { promise: Promise.resolve() }, - typesetClear() {}, - async typesetPromise(targets) { - await new Promise((resolve) => setTimeout(resolve, 30)); - targets.forEach((target) => { - target.innerHTML = 'Typeset equation output'; - }); - } - }; - }; - await page.addInitScript(installMathJaxStub); - await page.evaluate(installMathJaxStub); - await setEditorContent(page, '# Math review\n\nEquation $x^2$ stays anchored.'); - await expect(page.locator('#markdown-preview p')).toContainText('Typeset equation output'); - - await page.locator('#review-toggle').click(); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').click(); - await page.locator('#review-feedback-input').fill('Check the exponent notation.'); - await page.locator('#review-feedback-submit').click(); - await expect(page.locator('.review-thread')).not.toHaveClass(/is-orphaned/); - - await page.reload(); - await expect(page.locator('#markdown-preview p')).toContainText('Typeset equation output'); - await page.locator('#review-toggle').click(); - await expect(page.locator('.review-thread')).not.toHaveClass(/is-orphaned/); - await expect(page.locator('.review-thread-anchor')).not.toContainText('Anchor no longer in preview'); -}); - -test('closes Review mode for a new tab and restores the previous tab view', async ({ page }) => { +test('opening a new tab closes Comments and restores the previous view', async ({ page }) => { await page.getByRole('button', { name: 'Edit Markdown' }).click(); - await expect(page.locator('.content-container')).toHaveClass(/view-editor-only/); await page.locator('#review-toggle').click(); await expect(page.locator('.content-container')).toHaveClass(/view-preview-only/); - await page.locator('#tab-new-btn').click(); await expect(page.locator('#review-panel')).toBeHidden(); - await expect(page.locator('#review-toggle')).toHaveAttribute('aria-pressed', 'false'); - await expect(page.locator('.content-container')).toHaveClass(/view-split/); - await page.locator('#tab-list [role="tab"]').filter({ hasText: 'Welcome to Markdown' }).click(); await expect(page.locator('.content-container')).toHaveClass(/view-editor-only/); }); diff --git a/tests/e2e/share-live.spec.js b/tests/e2e/share-live.spec.js index 67ed6956..8c58601d 100644 --- a/tests/e2e/share-live.spec.js +++ b/tests/e2e/share-live.spec.js @@ -12,6 +12,23 @@ function localizeAppLink(url, baseURL) { return `${baseURL}/${parsed.hash}`; } +async function selectLiveReviewText(page, selector, text) { + await page.locator(selector).evaluate((element, selectedText) => { + const node = Array.from(element.childNodes).find(child => ( + child.nodeType === Node.TEXT_NODE && child.nodeValue.includes(selectedText) + )); + if (!node) throw new Error('Review selection text not found'); + const start = node.nodeValue.indexOf(selectedText); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + selectedText.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }, text); +} + test('Share Snapshot creates a view-only hash link that opens without permanent tab persistence', async ({ browser, page, baseURL }) => { await openApp(page); await setEditorContent(page, await fixture('share.md')); @@ -29,6 +46,14 @@ test('Share Snapshot creates a view-only hash link that opens without permanent await expect(sharedPage.locator('.content-container')).toHaveClass(/view-preview-only/); await expect(sharedPage.locator('#markdown-editor')).toHaveJSProperty('readOnly', true); await expect(sharedPage.locator('#markdown-preview')).toContainText('This text should survive a snapshot round trip.'); + await expect(sharedPage.locator('#toggle-sync')).toBeDisabled(); + await expect(sharedPage.locator('#toggle-sync')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + + const formatMenuToggles = sharedPage.locator('#markdown-format-toolbar [data-toolbar-menu-toggle]'); + await expect(formatMenuToggles).toHaveCount(3); + await expect(sharedPage.locator('#markdown-format-toolbar [data-toolbar-menu-toggle]:not(:disabled)')).toHaveCount(0); + await expect(sharedPage.locator('[data-toolbar-menu-toggle="heading"]')).toHaveCSS('opacity', '0.4'); + await expect(sharedPage.locator('[data-toolbar-menu-toggle="case"]')).toHaveCSS('opacity', '0.4'); const storedTabs = JSON.stringify(await storedDocuments(sharedPage)); expect(storedTabs).not.toContain('This text should survive a snapshot round trip.'); @@ -107,34 +132,47 @@ test('Live Share synchronizes Review feedback with view-only participants', asyn await expect(guestPage.locator('#markdown-preview h1')).toHaveText('Live review sync'); await expect(guestPage.locator('#markdown-editor')).toHaveJSProperty('readOnly', true); + await guestPage.locator('#live-share-button').click(); + await guestPage.locator('#live-share-display-name').fill('Guest reviewer'); + await guestPage.locator('#live-share-modal-close-icon').click(); await guestPage.locator('#review-toggle').click(); - await guestPage.locator('#review-pins-layer .review-target-button[data-review-anchor^="heading:"]').click(); + await selectLiveReviewText(guestPage, '#markdown-preview h1', 'Live review sync'); + await guestPage.locator('#review-new-comment').click(); await guestPage.locator('#review-feedback-input').fill('Participant comment for the host.'); await guestPage.locator('#review-feedback-submit').click(); await expect(page.locator('#review-toolbar-count')).toHaveText('1'); await page.locator('#review-toggle').click(); await expect(page.locator('.review-thread')).toContainText('Participant comment for the host.'); - await page.locator('#review-pins-layer .review-target-button[data-review-anchor^="paragraph:"]').click(); - await page.locator('[data-review-kind="suggestion"]').click(); - await page.locator('#review-feedback-input').fill('Host suggestion for the participant.'); + await selectLiveReviewText(page, '#markdown-preview p', 'shared feedback'); + await page.locator('#review-new-comment').click(); + await page.locator('#review-feedback-input').fill('Host comment for the participant.'); await page.locator('#review-feedback-submit').click(); await expect(guestPage.locator('#review-toolbar-count')).toHaveText('2'); await expect(page.locator('.review-thread')).toHaveCount(2); await expect(page.locator('.review-thread').filter({ hasText: 'Participant comment for the host.' })).toBeVisible(); - await expect(guestPage.locator('.review-thread').filter({ hasText: 'Host suggestion for the participant.' })).toBeVisible(); + await expect(guestPage.locator('.review-thread').filter({ hasText: 'Host comment for the participant.' })).toBeVisible(); const participantComment = page.locator('.review-thread').filter({ hasText: 'Participant comment for the host.' }); + await expect(participantComment.locator('.review-author-name').first()).toHaveText('Guest reviewer'); + await expect(guestPage.locator('.review-thread').filter({ hasText: 'Host comment for the participant.' }).locator('.review-author-name').first()).toHaveText('Host'); + + await guestPage.locator('#live-share-button').click(); + await guestPage.locator('#live-share-display-name').fill('Guest reviewer renamed'); + await guestPage.locator('#live-share-modal-close-icon').click(); + await expect(participantComment.locator('.review-author-name').first()).toHaveText('Guest reviewer renamed'); + await participantComment.locator('[data-review-action="toggle-resolved"]').click(); await expect(guestPage.locator('#review-toolbar-count')).toHaveText('1'); await guestPage.locator('[data-review-filter="resolved"]').click(); await expect(guestPage.locator('.review-thread')).toContainText('Participant comment for the host.'); + await guestPage.locator('.review-thread').click(); await expect(guestPage.locator('.review-thread-dates')).toContainText('Closed:'); await expect(guestPage.locator('.review-thread-dates')).not.toContainText('Closed: Not closed'); await guestPage.locator('[data-review-filter="open"]').click(); - await guestPage.locator('.review-thread').filter({ hasText: 'Host suggestion for the participant.' }).locator('[data-review-action="delete"]').click(); + await guestPage.locator('.review-thread').filter({ hasText: 'Host comment for the participant.' }).locator('[data-review-action="delete"]').click(); await guestPage.locator('#review-delete-confirm').click(); await page.locator('[data-review-filter="all"]').click(); await expect(page.locator('.review-thread')).toHaveCount(1); diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js index aff00d74..64fe25bf 100644 --- a/tests/e2e/smoke.spec.js +++ b/tests/e2e/smoke.spec.js @@ -1,6 +1,6 @@ const { test, expect } = require('@playwright/test'); const { execFileSync } = require('node:child_process'); -const { openApp } = require('../helpers/app'); +const { openApp, stubClipboard } = require('../helpers/app'); test('static build smoke check passes', () => { const output = execFileSync('node', ['tests/helpers/static-build-check.mjs'], { @@ -30,3 +30,87 @@ test('first run opens release notes in the background without stealing editor fo await expect(page.locator('#markdown-editor')).toBeEditable(); expect(criticalErrors).toEqual([]); }); + +test('release notes behave as an application surface instead of a document', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openApp(page); + await stubClipboard(page); + + await page.locator('#review-toggle').click(); + await expect(page.locator('#review-panel')).toBeVisible(); + await page.locator('#header-about-button').click(); + await page.getByRole('button', { name: 'Show Release Notes' }).click(); + + const releaseTab = page.locator('#tab-list .release-notes-tab'); + await expect(releaseTab).toHaveClass(/active/); + await expect(page.locator('body')).toHaveClass(/release-notes-active/); + await expect(page.locator('#document-tree [aria-current="page"]')).toHaveCount(0); + await expect(page.locator('#review-panel')).toBeHidden(); + await expect(page.locator('.review-target-actions')).toHaveCount(0); + + await expect(page.locator('.header-view-toolbar')).toBeVisible(); + await expect(page.locator('.header-view-toolbar button')).toHaveCount(3); + await expect(page.locator('.header-view-toolbar button:not(:disabled)')).toHaveCount(0); + await expect(page.locator('#toggle-sync')).toBeVisible(); + await expect(page.locator('#toggle-sync')).toBeDisabled(); + await expect(page.locator('#toggle-sync')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)'); + await expect(page.locator('#copy-markdown-button')).toBeVisible(); + await expect(page.locator('#copy-markdown-button')).toBeDisabled(); + await expect(page.locator('#exportDropdown')).toBeVisible(); + await expect(page.locator('#exportDropdown')).toBeDisabled(); + await expect(page.locator('#share-button')).toBeVisible(); + await expect(page.locator('#share-button')).toBeDisabled(); + await expect(page.locator('#live-share-button')).toBeVisible(); + await expect(page.locator('#live-share-button')).toBeDisabled(); + await expect(page.locator('#review-toggle')).toBeVisible(); + await expect(page.locator('#review-toggle')).toBeDisabled(); + await expect(page.locator('#markdown-format-toolbar')).toBeVisible(); + await expect(page.locator('#markdown-format-toolbar button:not(:disabled)')).toHaveCount(0); + await expect(page.locator('.app-status-bar')).toBeVisible(); + + await expect(page.locator('#importDropdown')).toBeVisible(); + await expect(page.locator('[aria-label="Report an issue"]')).toBeVisible(); + await expect(page.locator('#header-about-button')).toBeVisible(); + await expect(page.locator('#workspaceSettingsDropdown')).toBeVisible(); + + await page.keyboard.press('Control+Shift+C'); + await expect.poll(() => page.evaluate(() => window.__copiedText)).toBe(''); + await page.evaluate(() => document.getElementById('review-toggle').click()); + await expect(page.locator('#review-panel')).toBeHidden(); + + await expect(releaseTab.locator('.tab-menu-btn')).toHaveCount(0); + await expect(releaseTab.locator('.tab-close-btn')).toBeVisible(); + + await page.setViewportSize({ width: 375, height: 812 }); + await page.locator('#mobile-menu-toggle').click(); + await expect(page.locator('.mobile-menu-overview')).toBeVisible(); + await expect(page.locator('.mobile-view-mode-btn:not(:disabled)')).toHaveCount(0); + await expect(page.locator('#mobile-document-tools')).toBeVisible(); + await expect(page.locator('#mobile-document-tools button:not(:disabled)')).toHaveCount(0); + await expect(page.locator('#mobile-export-section')).toBeVisible(); + await expect(page.locator('[aria-controls="mobile-menu-export-panel"]')).toBeDisabled(); + await expect(page.locator('#mobile-share-button')).toBeVisible(); + await expect(page.locator('#mobile-share-button')).toBeDisabled(); + await expect(page.locator('#mobile-live-share-button')).toBeVisible(); + await expect(page.locator('#mobile-live-share-button')).toBeDisabled(); + await expect(page.locator('[aria-controls="mobile-menu-new-panel"]')).toBeVisible(); + await page.locator('#close-mobile-menu').click(); + + await page.locator('#tab-list .tab-item:not(.release-notes-tab)').first().click(); + await page.setViewportSize({ width: 1440, height: 900 }); + await expect(page.locator('body')).not.toHaveClass(/release-notes-active/); + await expect(page.locator('#tab-list .tab-item:not(.release-notes-tab)').first().locator('.tab-menu-btn')).toBeVisible(); + await expect(page.locator('.header-view-toolbar button[data-view-mode="split"]')).toBeEnabled(); + await expect(page.locator('#toggle-sync')).toBeEnabled(); + await expect(page.locator('#copy-markdown-button')).toBeVisible(); + await expect(page.locator('#copy-markdown-button')).toBeEnabled(); + await expect(page.locator('#exportDropdown')).toBeEnabled(); + await expect(page.locator('#share-button')).toBeEnabled(); + await expect(page.locator('#live-share-button')).toBeEnabled(); + await expect(page.locator('#review-toggle')).toBeVisible(); + await expect(page.locator('#review-toggle')).toBeEnabled(); + await expect(page.locator('#markdown-format-toolbar')).toBeVisible(); + await expect(page.locator('#markdown-format-toolbar [data-md-action="bold"]')).toBeEnabled(); + await expect(page.locator('.app-status-bar')).toBeVisible(); + await expect(page.locator('#document-tree [aria-current="page"]')).toHaveCount(1); +}); diff --git a/tests/e2e/status-toolbar-followup.spec.js b/tests/e2e/status-toolbar-followup.spec.js index d8c97873..247052c5 100644 --- a/tests/e2e/status-toolbar-followup.spec.js +++ b/tests/e2e/status-toolbar-followup.spec.js @@ -5,27 +5,58 @@ test.beforeEach(async ({ page }) => { await openApp(page); }); -test('header and formatting toolbar expose the requested actions in order', async ({ page }) => { +test('header consolidates icon document actions in the requested order', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); const header = page.locator('.header-right'); await expect(page.locator('.app-header h1')).toBeVisible(); expect(await page.locator('.app-header h1').evaluate(title => title.getBoundingClientRect().width)).toBeGreaterThan(115); expect(await page.locator('#document-sidebar').evaluate(sidebar => sidebar.getBoundingClientRect().width)).toBeGreaterThanOrEqual(220); - for (const selector of ['#importDropdown', '#share-button', '#live-share-button', '#header-about-button', '#workspaceSettingsDropdown']) { + for (const selector of ['#importDropdown', '#copy-markdown-button', '#toggle-sync', '#review-toggle', '#share-button', '#live-share-button', '#exportDropdown', '#header-about-button', '#workspaceSettingsDropdown']) { await expect(header.locator(selector)).toBeVisible(); } await expect(header.locator('[aria-label="Report an issue"]')).toBeVisible(); - await expect(header.locator('#exportDropdown')).toBeVisible(); - await expect(header.locator('#toggle-sync, #copy-markdown-button')).toHaveCount(0); + await expect(header.locator('.header-view-toolbar .view-toggle-btn')).toHaveCount(3); + await expect(header.locator('#importDropdown .btn-text, #share-button .btn-text, #live-share-button .btn-text, #exportDropdown .btn-text')).toHaveCount(0); + await expect(header.locator('#importDropdown i').first()).toHaveClass('lucide lucide-plus'); + await expect(header.locator('#copy-markdown-button i')).toHaveClass('lucide lucide-clipboard'); + expect(await header.locator('#copy-markdown-button i').evaluate(icon => getComputedStyle(icon).maskImage)).not.toBe('none'); + expect(await header.locator('#copy-markdown-button').evaluate(button => getComputedStyle(button).borderTopColor)).not.toBe('rgba(0, 0, 0, 0)'); + for (const selector of ['#importDropdown', '#exportDropdown']) { + const trigger = header.locator(selector); + await expect(trigger.locator('.header-dropdown-chevron')).toHaveClass(/lucide-chevron-down/); + const iconSizes = await trigger.locator('i').evaluateAll(icons => icons.map(icon => Number.parseFloat(getComputedStyle(icon).fontSize))); + expect(iconSizes[1]).toBeLessThan(iconSizes[0]); + expect(await trigger.evaluate(button => button.getBoundingClientRect().width)).toBeGreaterThanOrEqual(38); + } + await expect(header.locator('#workspaceSettingsDropdown .header-dropdown-chevron')).toHaveCount(0); + + const headerOrder = await header.locator(':scope > button, :scope > a, :scope > .dropdown > button').evaluateAll(elements => + elements.map(element => element.id || element.getAttribute('aria-label')) + ); + expect(headerOrder).toEqual([ + 'toggle-sync', + 'importDropdown', + 'copy-markdown-button', + 'exportDropdown', + 'share-button', + 'live-share-button', + 'review-toggle', + 'Report an issue', + 'header-about-button', + 'workspaceSettingsDropdown' + ]); const toolbar = page.locator('#markdown-format-toolbar'); await expect(toolbar.locator('.workspace-format-actions [data-md-action="find"]')).toBeVisible(); await expect(toolbar.locator('.workspace-format-actions [data-md-action="fullscreen"]')).toBeVisible(); await expect(toolbar.locator('.workspace-format-actions [data-md-action="find"] i')).toHaveClass(/lucide-search/); await expect(toolbar.locator('.workspace-format-actions [data-md-action="fullscreen"] i')).toHaveClass(/lucide-maximize/); - await expect(toolbar.locator('#review-toggle')).toBeVisible(); - await expect(toolbar.locator('#toggle-sync, #copy-markdown-button')).toHaveCount(2); + await expect(toolbar.locator('#review-toggle, #toggle-sync, #copy-markdown-button')).toHaveCount(0); await expect(toolbar.locator('#exportDropdown')).toHaveCount(0); + await expect(toolbar.locator('.markdown-tool-select--insert, [data-toolbar-menu="insert"]')).toHaveCount(0); + await expect(toolbar.locator('.markdown-toolbar-group--content > .markdown-tool-btn')).toHaveCount(4); + await expect(toolbar.locator('.markdown-toolbar-group--technical > .markdown-tool-btn')).toHaveCount(3); + await expect(toolbar.locator('.markdown-toolbar-group--advanced > .markdown-tool-btn')).toHaveCount(5); await toolbar.locator('.workspace-format-actions [data-md-action="find"]').click(); await expect(page.locator('#find-replace-modal')).toBeVisible(); @@ -39,13 +70,13 @@ test('header and formatting toolbar expose the requested actions in order', asyn await fullscreen.click(); await expect.poll(() => page.evaluate(() => Boolean(document.fullscreenElement))).toBe(false); - await toolbar.locator('#toggle-sync').click(); - await expect(toolbar.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'false'); - await toolbar.locator('#toggle-sync').click(); - await expect(toolbar.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'true'); + await header.locator('#toggle-sync').click(); + await expect(header.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'false'); + await header.locator('#toggle-sync').click(); + await expect(header.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'true'); await stubClipboard(page); - await toolbar.locator('#copy-markdown-button').click(); - await expect(toolbar.locator('#copy-markdown-button')).toHaveAttribute('aria-label', 'Copied'); + await header.locator('#copy-markdown-button').click(); + await expect(header.locator('#copy-markdown-button')).toHaveAttribute('aria-label', 'Copied'); await expect.poll(() => page.evaluate(() => window.__copiedText)).not.toBe(''); await header.locator('#importDropdown').click(); diff --git a/tests/e2e/tab-split-sidebar-update.spec.js b/tests/e2e/tab-split-sidebar-update.spec.js index 75fbe452..8abaacba 100644 --- a/tests/e2e/tab-split-sidebar-update.spec.js +++ b/tests/e2e/tab-split-sidebar-update.spec.js @@ -119,7 +119,7 @@ test('split view uses one combined tab and offers only edit or preview modes', a await expect(page.locator('.view-toolbar [data-view-mode="split"]')).toBeEnabled(); }); -test('header keeps global actions concise and settings groups workspace preferences', async ({ page }) => { +test('header groups document actions before application preferences', async ({ page }) => { const header = page.locator('.header-right'); await expect(header.locator('#importDropdown')).toBeVisible(); await expect(header.locator('#share-button')).toBeVisible(); @@ -128,41 +128,39 @@ test('header keeps global actions concise and settings groups workspace preferen await expect(header.locator('#header-about-button')).toBeVisible(); await expect(header.locator('#workspaceSettingsDropdown')).toBeVisible(); await expect(header.locator('#exportDropdown')).toBeVisible(); - await expect(header.locator('#toggle-sync, #review-toggle, #copy-markdown-button')).toHaveCount(0); + await expect(header.locator('#toggle-sync, #review-toggle, #copy-markdown-button')).toHaveCount(3); + await expect(header.locator('.header-view-toolbar .view-toggle-btn')).toHaveCount(3); const headerActions = await header.locator(':scope > button, :scope > a, :scope > .dropdown > button').evaluateAll(elements => elements.map(element => element.id || element.getAttribute('aria-label')) ); expect(headerActions).toEqual([ + 'toggle-sync', 'importDropdown', + 'copy-markdown-button', 'exportDropdown', 'share-button', 'live-share-button', + 'review-toggle', 'Report an issue', 'header-about-button', 'workspaceSettingsDropdown' ]); const formatToolbar = page.locator('#markdown-format-toolbar'); - await expect(formatToolbar.locator('#toggle-sync')).toBeVisible(); - await expect(formatToolbar.locator('#copy-markdown-button')).toBeVisible(); - await expect(formatToolbar.locator('#review-toggle')).toBeVisible(); + await expect(formatToolbar.locator('#toggle-sync, #copy-markdown-button, #review-toggle')).toHaveCount(0); await expect(formatToolbar.locator('#exportDropdown')).toHaveCount(0); await expect(formatToolbar.locator('#documentActionsDropdown')).toHaveCount(0); await expect(page.locator('.app-brand-logo')).toHaveCount(0); - const utilityOrder = await formatToolbar.locator('.markdown-toolbar-group--utilities').evaluate(group => - Array.from(group.children).map(element => element.id || element.className) - ); - expect(utilityOrder.slice(0, 4)).toEqual(['toggle-sync', 'copy-markdown-button', 'review-toggle', 'document-command-divider']); - await formatToolbar.locator('#toggle-sync').click(); - await expect(formatToolbar.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'false'); - await expect(formatToolbar.locator('#toggle-sync')).toHaveAttribute('aria-label', 'Enable synchronized scrolling'); - await formatToolbar.locator('#toggle-sync').click(); - await expect(formatToolbar.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'true'); + await header.locator('#toggle-sync').click(); + await expect(header.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'false'); + await expect(header.locator('#toggle-sync')).toHaveAttribute('aria-label', 'Enable synchronized scrolling'); + await header.locator('#toggle-sync').click(); + await expect(header.locator('#toggle-sync')).toHaveAttribute('aria-pressed', 'true'); await stubClipboard(page); - await formatToolbar.locator('#copy-markdown-button').click(); - await expect(formatToolbar.locator('#copy-markdown-button')).toHaveAttribute('aria-label', 'Copied'); - await expect(formatToolbar.locator('#copy-markdown-button i')).toHaveClass(/lucide-check/); + await header.locator('#copy-markdown-button').click(); + await expect(header.locator('#copy-markdown-button')).toHaveAttribute('aria-label', 'Copied'); + await expect(header.locator('#copy-markdown-button i')).toHaveClass(/lucide-check/); await expect.poll(() => page.evaluate(() => window.__copiedText)).not.toBe(''); const initialTabCount = await page.locator('#tab-list .tab-item').count(); @@ -188,14 +186,10 @@ test('header keeps global actions concise and settings groups workspace preferen await expect(settings.locator('#theme-toggle')).toContainText('Appearance'); await page.locator('#workspaceSettingsDropdown').click(); - const insertToggle = formatToolbar.locator('.markdown-tool-select--insert'); - await expect(insertToggle).toHaveAttribute('aria-label', 'More tools'); - await expect(insertToggle).not.toContainText('Insert'); - await expect(formatToolbar.locator('[data-md-action="diagram"]')).toHaveCount(1); - await expect(formatToolbar.locator('[data-toolbar-menu="insert"] [data-md-action="diagram"]')).toHaveCount(0); - await insertToggle.press('Enter'); - await expect(formatToolbar.locator('[data-toolbar-menu="insert"]')).toHaveClass(/open/); - await page.keyboard.press('Escape'); + await expect(formatToolbar.locator('.markdown-tool-select--insert, [data-toolbar-menu="insert"]')).toHaveCount(0); + await expect(formatToolbar.locator('.markdown-toolbar-group--content > .markdown-tool-btn')).toHaveCount(4); + await expect(formatToolbar.locator('.markdown-toolbar-group--technical > .markdown-tool-btn')).toHaveCount(3); + await expect(formatToolbar.locator('.markdown-toolbar-group--advanced > .markdown-tool-btn')).toHaveCount(5); await header.locator('#exportDropdown').click(); const exportMenu = page.locator('[aria-labelledby="exportDropdown"]'); @@ -203,8 +197,8 @@ test('header keeps global actions concise and settings groups workspace preferen await expect(exportMenu).not.toHaveClass(/toolbar-portal-menu/); await expect(exportMenu).toBeInViewport(); - await expect(formatToolbar.locator('.markdown-view-toolbar .view-toggle-btn')).toHaveCount(3); - expect(await formatToolbar.locator('.markdown-view-toolbar .view-toggle-btn').evaluateAll(buttons => + await expect(header.locator('.header-view-toolbar .view-toggle-btn')).toHaveCount(3); + expect(await header.locator('.header-view-toolbar .view-toggle-btn').evaluateAll(buttons => buttons.map(button => button.getAttribute('data-view-mode')) )).toEqual(['editor', 'split', 'preview']); const activeStyle = await page.locator('.view-toggle-btn.is-active').evaluate(button => ({ @@ -282,7 +276,10 @@ test('format toolbar consolidates heading, case, alignment, and insert actions', await expect(page.locator('[data-toolbar-menu-toggle="heading"]')).toBeVisible(); await expect(page.locator('[data-toolbar-menu-toggle="case"]')).toBeVisible(); await expect(page.locator('[data-toolbar-menu-toggle="alignment"]')).toBeVisible(); - await expect(page.locator('[data-toolbar-menu-toggle="insert"]')).toBeVisible(); + await expect(page.locator('[data-toolbar-menu-toggle="insert"], [data-toolbar-menu="insert"]')).toHaveCount(0); + await expect(page.locator('.markdown-toolbar-group--content > .markdown-tool-btn')).toHaveCount(4); + await expect(page.locator('.markdown-toolbar-group--technical > .markdown-tool-btn')).toHaveCount(3); + await expect(page.locator('.markdown-toolbar-group--advanced > .markdown-tool-btn')).toHaveCount(5); await setEditorContent(page, 'toolbar heading'); await page.locator('[data-toolbar-menu-toggle="heading"]').click(); @@ -468,19 +465,9 @@ test('toolbar stays usable on phone and landscape widths', async ({ page }) => { await page.locator('[data-toolbar-menu-toggle="heading"]').click(); await expect(page.locator('[data-toolbar-menu="heading"]')).toBeVisible(); await page.keyboard.press('Escape'); - await page.locator('#markdown-format-toolbar').evaluate(toolbar => { toolbar.scrollLeft = toolbar.scrollWidth; }); - const utilityPlacement = await page.evaluate(() => { - const toolbar = document.querySelector('#markdown-format-toolbar').getBoundingClientRect(); - const utilities = document.querySelector('.markdown-toolbar-group--utilities').getBoundingClientRect(); - return { - toolbarLeft: Math.round(toolbar.left), - toolbarRight: Math.round(toolbar.right), - utilitiesRight: Math.round(utilities.right), - utilitiesLeft: Math.round(utilities.left) - }; - }); - expect(utilityPlacement.utilitiesRight).toBeLessThanOrEqual(utilityPlacement.toolbarRight); - expect(utilityPlacement.utilitiesLeft).toBeGreaterThanOrEqual(utilityPlacement.toolbarLeft); + const toolbar = page.locator('#markdown-format-toolbar'); + await toolbar.evaluate(element => { element.scrollLeft = element.scrollWidth; }); + await expect(toolbar.locator('.markdown-toolbar-group--advanced [data-md-action="alert"]')).toBeInViewport(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.setViewportSize({ width: 844, height: 390 }); diff --git a/tests/e2e/ui-toolbar-redesign.spec.js b/tests/e2e/ui-toolbar-redesign.spec.js index eb8f19c3..0425199f 100644 --- a/tests/e2e/ui-toolbar-redesign.spec.js +++ b/tests/e2e/ui-toolbar-redesign.spec.js @@ -30,18 +30,15 @@ test('Explorer labels and sidebar toggle describe the next action', async ({ pag test('application header keeps its height while showing the compact product identity', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); - await expect(page.locator('.app-brand-icon')).toHaveAttribute('src', 'assets/icon.jpg'); + await expect(page.locator('.app-brand-icon')).toHaveCount(0); await expect(page.locator('.app-brand-title-row h1')).toContainText('Markdown Viewer'); await expect(page.locator('.app-brand-subtitle')).toHaveText('Write. Preview. Share.'); const geometry = await page.evaluate(() => ({ - headerHeight: document.querySelector('.app-header').getBoundingClientRect().height, - iconWidth: document.querySelector('.app-brand-icon').getBoundingClientRect().width, - iconHeight: document.querySelector('.app-brand-icon').getBoundingClientRect().height + headerHeight: document.querySelector('.app-header').getBoundingClientRect().height })); expect(geometry.headerHeight).toBeGreaterThanOrEqual(40); expect(geometry.headerHeight).toBeLessThanOrEqual(45); - expect([geometry.iconWidth, geometry.iconHeight]).toEqual([32, 32]); await expect(page.locator('.app-brand .github-link i')).toHaveCSS('font-size', '20px'); await expect(page.locator('#tab-new-btn')).toHaveCSS('width', '25px'); await expect(page.locator('#tab-new-btn')).toHaveCSS('height', '25px'); @@ -71,8 +68,7 @@ test('shared application overlays use compact type, spacing, and surface styling await expect(page.locator('.about-support')).toContainText('Developed and maintained by ThisIs-Developer.'); await page.locator('#about-modal-close').click(); - await page.locator('.markdown-tool-select--insert').click(); - await page.locator('[data-toolbar-menu="insert"] [data-md-action="alert"]').click(); + await page.locator('.markdown-toolbar-group--advanced [data-md-action="alert"]').click(); await expect(page.locator('#alert-modal')).toHaveClass(/is-visible/); await expect(page.locator('#alert-modal .alert-option').first()).toBeFocused(); await page.keyboard.press('Escape'); @@ -110,36 +106,44 @@ test('every application dialog uses the shared alert modal shell', async ({ page expect(dialogShells.filter(dialog => !dialog.sharedShell || !dialog.header || !dialog.close || !dialog.footer || dialog.padding !== '0px')).toEqual([]); }); -test('toolbar groups preserve actions while simplifying Insert and promoting diagrams', async ({ page }) => { +test('header orders document actions and formatting toolbar follows the recommended grouping', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); + const header = page.locator('.header-right'); const toolbar = page.locator('#markdown-format-toolbar'); - const utilityOrder = await toolbar.locator('.markdown-toolbar-group--utilities').evaluate(group => - Array.from(group.children).map(element => element.id || element.className) + const documentActionOrder = await header.locator(':scope > button, :scope > .dropdown > button').evaluateAll(elements => + elements.map(element => element.id) ); - expect(utilityOrder.slice(0, 4)).toEqual([ + expect(documentActionOrder.slice(0, 7)).toEqual([ 'toggle-sync', + 'importDropdown', 'copy-markdown-button', - 'review-toggle', - 'document-command-divider' + 'exportDropdown', + 'share-button', + 'live-share-button', + 'review-toggle' ]); - const insertToggle = toolbar.locator('.markdown-tool-select--insert'); - await expect(insertToggle).toHaveText(''); - await expect(insertToggle).toHaveAttribute('title', 'More tools'); - await expect(insertToggle.locator('.lucide-ellipsis')).toHaveCount(1); - await expect(toolbar.locator('[data-md-action="diagram"]')).toHaveCount(1); - await expect(toolbar.locator('[data-toolbar-menu="insert"] [data-md-action="diagram"]')).toHaveCount(0); - - await insertToggle.press('Enter'); - const insertMenu = toolbar.locator('[data-toolbar-menu="insert"]'); - await expect(insertMenu).toHaveClass(/open/); - await expect(insertMenu.locator('.markdown-tool-menu-item').first()).toBeFocused(); - await page.keyboard.press('Escape'); - await expect(insertMenu).not.toHaveClass(/open/); - await expect(insertToggle).toBeFocused(); + const toolbarGroups = await toolbar.locator(':scope > .markdown-toolbar-group').evaluateAll(groups => + groups.map(group => ({ + label: group.getAttribute('aria-label'), + actions: Array.from(group.children).map(child => { + const control = child.matches('button') ? child : child.querySelector(':scope > button'); + return control?.getAttribute('data-md-action') || control?.getAttribute('data-toolbar-menu-toggle'); + }).filter(Boolean) + })) + ); + expect(toolbarGroups).toEqual([ + { label: 'History', actions: ['undo', 'redo'] }, + { label: 'Text formatting', actions: ['heading', 'bold', 'italic', 'strike', 'inline-code', 'case'] }, + { label: 'Paragraph formatting', actions: ['quote', 'unordered-list', 'ordered-list', 'alignment'] }, + { label: 'Content insertion', actions: ['link', 'image', 'table', 'reference'] }, + { label: 'Technical content', actions: ['code-block', 'terminal-block', 'diagram'] }, + { label: 'Additional insertion', actions: ['horizontal-rule', 'alert', 'date-time', 'symbols', 'emoji'] }, + { label: 'Workspace actions', actions: ['find', 'fullscreen'] } + ]); + await expect(toolbar.locator('.markdown-tool-select--insert, [data-toolbar-menu="insert"]')).toHaveCount(0); - await insertToggle.click(); - await insertMenu.locator('[data-md-action="emoji"]').click(); + await toolbar.locator('[data-md-action="emoji"]').click(); await expect(page.locator('#emoji-modal')).toBeVisible(); await page.locator('#emoji-modal-search').press('Escape'); await expect(page.locator('#emoji-modal')).toBeHidden(); @@ -227,22 +231,39 @@ test('shared interface roles use the application type and icon scale', async ({ expect(sizing.mobileIcons).toEqual(['14px']); }); -test('New, Export, and formatting menus share one visual system and keyboard dismissal', async ({ page }) => { +test('New and Export menus share one visual system and keyboard dismissal', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); + const activeSyncStyle = await page.locator('#toggle-sync').evaluate(button => { + const style = getComputedStyle(button); + return [style.backgroundColor, style.borderColor, style.color]; + }); + const expectActiveDropdownStyle = async locator => { + await expect.poll(() => locator.evaluate(button => { + const style = getComputedStyle(button); + return [style.backgroundColor, style.borderColor, style.color]; + })).toEqual(activeSyncStyle); + }; + await page.locator('#importDropdown').click(); const newMenu = page.locator('[aria-labelledby="importDropdown"]'); await expect(newMenu).toBeVisible(); + await expect(page.locator('#importDropdown')).toHaveAttribute('aria-expanded', 'true'); + await expectActiveDropdownStyle(page.locator('#importDropdown')); const newSurface = await newMenu.evaluate(menu => { const style = getComputedStyle(menu); return [style.backgroundColor, style.borderColor, style.borderRadius, style.boxShadow]; }); await page.keyboard.press('Escape'); + await expect(newMenu).toHaveClass(/is-closing/); + await expect(newMenu).toHaveCSS('pointer-events', 'none'); await expect(newMenu).toBeHidden(); await page.locator('#exportDropdown').click(); const exportMenu = page.locator('[aria-labelledby="exportDropdown"]'); await expect(exportMenu).toBeVisible(); + await expect(page.locator('#exportDropdown')).toHaveAttribute('aria-expanded', 'true'); + await expectActiveDropdownStyle(page.locator('#exportDropdown')); const exportSurface = await exportMenu.evaluate(menu => { const style = getComputedStyle(menu); return [style.backgroundColor, style.borderColor, style.borderRadius, style.boxShadow]; @@ -250,16 +271,26 @@ test('New, Export, and formatting menus share one visual system and keyboard dis expect(exportSurface).toEqual(newSurface); await page.keyboard.press('Escape'); - const insertToggle = page.locator('.markdown-tool-select--insert'); - await insertToggle.click(); - const insertMenu = page.locator('[data-toolbar-menu="insert"]'); - const insertSurface = await insertMenu.evaluate(menu => { - const style = getComputedStyle(menu); - return [style.backgroundColor, style.borderColor, style.borderRadius, style.boxShadow]; - }); - expect(insertSurface).toEqual(newSurface); - await page.locator('#markdown-editor').click(); - await expect(insertMenu).not.toHaveClass(/open/); + await page.locator('#workspaceSettingsDropdown').click(); + await expect(page.locator('#workspaceSettingsDropdown')).toHaveAttribute('aria-expanded', 'true'); + await expectActiveDropdownStyle(page.locator('#workspaceSettingsDropdown')); + await page.keyboard.press('Escape'); + + await expect(page.locator('.markdown-tool-select--insert, [data-toolbar-menu="insert"]')).toHaveCount(0); +}); + +test('dropdown motion respects reduced-motion preferences', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + const toggle = page.locator('#importDropdown'); + const menu = page.locator('[aria-labelledby="importDropdown"]'); + + await toggle.click(); + await expect(menu).toBeVisible(); + await expect(menu).toHaveCSS('transition-duration', '0s'); + + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + await expect(menu).not.toHaveClass(/is-closing/); }); test('toolbar uses theme surfaces and remains usable at desktop and phone widths', async ({ page }) => { @@ -269,8 +300,8 @@ test('toolbar uses theme surfaces and remains usable at desktop and phone widths const desktopSizing = await page.evaluate(() => { const toolbar = document.querySelector('#markdown-format-toolbar'); const formatButton = toolbar.querySelector('.markdown-tool-btn'); - const utilityButton = toolbar.querySelector('.markdown-toolbar-group--document .tool-button'); - const viewButton = toolbar.querySelector('.markdown-view-toolbar .view-toggle-btn'); + const utilityButton = document.querySelector('.header-right #copy-markdown-button'); + const viewButton = document.querySelector('.header-view-toolbar .view-toggle-btn'); return { toolbarHeight: toolbar.getBoundingClientRect().height, formatButton: [formatButton.getBoundingClientRect().width, formatButton.getBoundingClientRect().height], @@ -294,14 +325,6 @@ test('toolbar uses theme surfaces and remains usable at desktop and phone widths const button = toolbar.querySelector('.markdown-tool-btn'); return [button.getBoundingClientRect().width, button.getBoundingClientRect().height]; })(), - utilityButton: (() => { - const button = toolbar.querySelector('.markdown-toolbar-group--document .tool-button'); - return [button.getBoundingClientRect().width, button.getBoundingClientRect().height]; - })(), - viewButton: (() => { - const button = toolbar.querySelector('.markdown-view-toolbar .view-toggle-btn'); - return [button.getBoundingClientRect().width, button.getBoundingClientRect().height]; - })(), minimumTarget: Math.min(...buttons.map(button => button.getBoundingClientRect().height)) }; }); @@ -309,8 +332,8 @@ test('toolbar uses theme surfaces and remains usable at desktop and phone widths expect(mobileSizing.toolbarScrollable).toBe(true); expect(mobileSizing.toolbarHeight).toBe(desktopSizing.toolbarHeight); expect(mobileSizing.formatButton).toEqual(desktopSizing.formatButton); - expect(mobileSizing.utilityButton).toEqual(desktopSizing.utilityButton); - expect(mobileSizing.viewButton).toEqual(desktopSizing.viewButton); + expect(desktopSizing.utilityButton).toEqual([30, 30]); + expect(desktopSizing.viewButton).toEqual([27, 26]); expect(mobileSizing.minimumTarget).toBeGreaterThanOrEqual(26); await page.evaluate(() => { document.documentElement.style.fontSize = '20px'; }); diff --git a/wiki/Features.md b/wiki/Features.md index 0c0216ea..feb1f5ee 100644 --- a/wiki/Features.md +++ b/wiki/Features.md @@ -67,24 +67,27 @@ On the web, document data lives in IndexedDB while small preferences remain in ` Workspace settings includes **Private mode**, which pauses document-state writes for the current private session without clearing existing documents or Secret Workspace. The private-mode preference remains so the behavior survives a reload. **Storage and Backup** reports usage, separate normal and secret document counts, a logical browser storage location or fixed desktop vault path, exports/imports ZIP backups, and can include unchanged encrypted Secret Workspace records. Import permanently replaces the current workspace after confirmation. Backups exclude trash, desktop history, and recovery journals. Browser storage is best-effort by default and is shown as read-only status. **Reset workspace** permanently clears all local workspace data after confirmation; **Reset Secret Workspace** remains available for deleting only the encrypted area. -## Comments and Suggestion Mode +## Comments -Review mode adds structured feedback to the rendered document without inserting or changing Markdown. +Comments add anchored feedback to the rendered document without inserting or changing Markdown. User flow: -- Open **Review** from the desktop document toolbar or mobile menu. -- Select the plus beside a rendered YAML table, heading, paragraph, code block, or diagram. -- Add a comment or suggestion. Reviewed blocks show one control for reading existing feedback and a separate plus for adding another item. -- Edit, resolve, reopen, or delete individual threads. The panel can also copy a Markdown summary, resolve all open items, or delete all feedback after confirmation. -- Close Review to restore the previous Editor, Split, or Preview layout. Opening a new tab closes Review automatically. +- Open **Comments** from the desktop document toolbar or mobile menu. +- Select text in a heading, paragraph, list, quote, or code block, or click an image, diagram, map, model, or rendered math expression. +- Choose **New** to preview the selected anchor with the app's blue review highlight, then write and submit the comment. Cancel removes the temporary highlight. +- Hover or click between document highlights and compact comment cards to synchronize both sides. +- Activate a comment to read its conversation or add nested replies from the compact **Reply** field. +- Edit, resolve, reopen, or delete individual comments. Open, Resolved, and All filters keep completed feedback accessible. +- Close Comments to restore the previous Editor, Split, or Preview layout. Opening a new tab closes Comments automatically. Storage and sharing: -- Review threads stay with normal local tabs and survive reloads. Private mode pauses new persistence; workspace backups retain review data, while Reset workspace deletes it. +- Comments stay with normal local tabs and survive reloads. Private mode pauses new persistence; workspace backups retain comment data, while Reset workspace deletes it. - Feedback is excluded from Markdown, HTML, PDF, PNG, print, duplicated tabs, and Share Snapshot links. - If the related source block changes, the thread remains visible as unanchored feedback instead of moving to the wrong block. -- Live Share synchronizes Review threads through a separate Yjs document. View-only participants can review without receiving Markdown edit permission. +- Local owner comments use **Author**. Live Share comments and replies use each participant's current display name and update when that participant renames themself. +- Live Share synchronizes comments and replies through a separate Yjs document. View-only participants can comment without receiving Markdown edit permission. - The panel is a side panel on desktop, a drawer on tablet, and a touch-friendly bottom sheet on mobile, using the app's existing colors, controls, themes, and accessibility patterns. ## Editing and Formatting Tools @@ -552,7 +555,7 @@ Security limitations: | :--- | :--- | :--- | :--- | | Typing and local preview | No | Browser memory and per-document storage | Sanitized before preview insertion. | | Normal tab autosave | No | Per-document IndexedDB records or desktop vault `.md` files | Content is written independently rather than serializing the whole Workspace. | -| Comments and suggestions | Only during Live Share | Normal document metadata plus temporary Live Share relay state | Excluded from document exports and Share Snapshot; synchronized between active Live Share participants. | +| Comments | Only during Live Share | Normal document metadata plus temporary Live Share relay state | Excluded from document exports and Share Snapshot; synchronized between active Live Share participants. | | Private mode | No | No new document-state persistence during the session | Existing saved documents and Secret Workspace remain intact. | | Local file import | No | Current tab/workspace | Reads selected files only. | | Managed media upload | Yes, after first-use consent | Cloudflare KV, content-addressed, 90-day TTL | Publicly retrievable by its unguessable HTTPS URL until expiry; still images 300 KiB optimized, GIF 5 MiB, video 10 MiB. | diff --git a/wiki/Share-Snapshot.md b/wiki/Share-Snapshot.md index f63dc7de..fbfa88a0 100644 --- a/wiki/Share-Snapshot.md +++ b/wiki/Share-Snapshot.md @@ -86,7 +86,7 @@ Share Snapshot copies Markdown, not every referenced resource. - Managed media remains available through its public-by-link URL until that media record expires. - External images, media, fonts, and links are requested from their original hosts. - Legacy inline data images increase the snapshot size and can force stored-snapshot mode. -- Review comments and suggestions are not included. +- Comments are not included. - A remote diagram fence is re-rendered by the recipient's browser and can send its source to a renderer. ## Security Boundaries diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index 7e7ad27d..dc312aec 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -60,19 +60,19 @@ 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 +## Comments -Use Review when you want to leave structured feedback without editing the Markdown source. +Use Comments when you want to leave anchored feedback without editing the Markdown source. -1. Click **Review** in the desktop document toolbar or mobile menu. -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, then select **Add comment** or **Add suggestion**. -5. Use the review count to read saved feedback and the separate plus to add another item to the same block. Threads can be edited, resolved, reopened, or deleted. +1. Open **Comments** from the desktop document toolbar or mobile menu. +2. Select text in a heading, paragraph, list, quote, or code block, or click an image, rendered diagram, map, model, or math expression. +3. Choose **New** in the Comments panel. A temporary blue highlight previews the selected anchor; choosing **Cancel** removes it. +4. Enter the comment, then choose **Submit** or press `Ctrl+Enter`/`Cmd+Enter`. +5. Click a comment card or its highlighted document content to activate and synchronize both sides. Hovering either side temporarily highlights the other. -The panel toolbar can copy a Markdown summary, resolve all open items, or delete all feedback after confirmation. Review uses a side panel on desktop, a drawer on tablet, and a bottom sheet on mobile. Opening a new tab closes Review automatically. +Open, Resolved, and All filters keep completed feedback available without letting it dominate the panel. Comment cards show the author, comment, and date without repeating the selected document text. Active cards reveal nested replies and a compact Reply field; resolved cards also show their closed time. Comments can be edited, resolved, reopened, or deleted. The interface uses a side panel on desktop, a drawer on tablet, and a bottom sheet on mobile. Opening a new tab closes Comments automatically. -Review threads stay with normal local tabs and are excluded from document exports, duplicated tabs, and Share Snapshot links. Live Share synchronizes them while the room is active, including for view-only participants; Private mode pauses new local persistence, workspace backups retain review data, and Reset workspace deletes it. +Comments stay with normal local tabs and are excluded from document exports, duplicated tabs, and Share Snapshot links. Owner comments use **Author**. Live Share comments and replies use the participant display name and follow later name changes while the room is active, including for view-only participants. Private mode pauses new local persistence, workspace backups retain comment data, and Reset workspace deletes it. Legacy stored comments and suggestions remain readable. ## Find and Replace @@ -168,7 +168,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 persist Markdown or Review Document content server-side. The Durable Object does persist the host, edit, and view capability values plus their creation time. The invite URL contains a room id, room secret, access role/capability, and title, not the full Document body. The server authenticates host, Can edit, 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. +Live Share sends real-time Yjs updates through a Cloudflare Durable Object. It does not persist Markdown or comment content server-side. The Durable Object does persist the host, edit, and view capability values plus their creation time. The invite URL contains a room id, room secret, access role/capability, and title, not the full Document body. The server authenticates host, Can edit, and View only capabilities and filters message types by role. Markdown and comment data use separate Yjs documents, so View only participants can synchronize comments 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 8 MB live messages. Managed images, GIFs, and videos travel as short HTTPS links rather than binary Live Share messages and stop rendering when their 90-day storage TTL expires.