diff --git a/.gitignore b/.gitignore index e91a3ce..a811ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ package-lock.json /*.zip /manifest.json /background/index-compilers.js + +# Local-only planning docs, not part of the shipped feature +/DESIGN-comments.md +/ROADMAP-comments.md diff --git a/COMMENTS-README.md b/COMMENTS-README.md new file mode 100644 index 0000000..9e9b528 --- /dev/null +++ b/COMMENTS-README.md @@ -0,0 +1,125 @@ +# Comments + +Attach comments to any selected text in a rendered markdown document. Comments +are anchored to the selected passage and highlighted inline; a sidebar panel +lists them all, filterable and searchable. + +## Enabling + +Two independent settings under Content Options: + +| Setting | Default | Effect | +| :- | :-: | :- | +| **Show comments** | `true` | Renders highlights on anchored text and lets you open the sidebar to browse existing comments. Read-only — no way to create, edit, or delete anything. | +| **Create comments** | `false` | Adds everything needed to author comments: the selection tooltip, `Cmd/Ctrl+Shift+K`, the right-click "Add Comment" context menu item, and every mutating sidebar action (reply, edit, resolve, delete, import, Resolve All, Delete All). Has no visible effect if *Show comments* is off. | + +New installs default to view-only (`Show comments` on, `Create comments` +off). Users upgrading from a version where comments were already fully +enabled keep write access automatically — the migration sets `Create +comments` to `true` for anyone who already had the old single `comments` +setting on, so existing workflows aren't broken by the split. + +## Using it + +With **Create comments** enabled: + +- Select text in the rendered document → a tooltip appears near the + selection → click it, or press `Cmd/Ctrl+Shift+K`, to open the comment + input. +- Right-click a selection for an "Add Comment" context menu item. +- `Cmd/Ctrl+Enter` saves, `Escape` cancels, while writing a comment, reply, + or edit. +- Each comment can be given an optional **tag** (`note`, `question`, + `suggestion`, `issue`, `outdated`, `action-needed`) and an optional + **severity** (`low`, `medium`, `high`, `critical`), rendered as pills in + the sidebar. +- Comments can be **resolved** (strikethrough, dimmed) and reopened + individually, or in bulk via **Resolve All** / **Delete All** in the + sidebar header — both prompt for confirmation before acting. +- **Reply** to a comment to start a thread; replies show author and + timestamp, indented under the parent. +- **Suggestion mode**: propose replacement text for the selected passage; + the sidebar renders it as a strikethrough/insert diff. + +With just **Show comments** enabled (or as a read-only viewer of someone +else's comments): + +- Highlighted passages are visible and clickable. +- `Ctrl+]` / `Ctrl+[` jump to the next/previous comment in document order, + scrolling the page and flashing the corresponding sidebar entry. +- Clicking outside the sidebar closes it. +- Comments can still be exported (JSON or Markdown) — export is + non-destructive and available in both modes. + +## Storage & persistence + +Comments are stored in `chrome.storage.local`, keyed by page URL — not in a +sidecar file on disk and not via native messaging. There is no additional +permission required beyond what the extension already has for local file +access. + +On load, comments come from two sources that get merged: + +1. **Stored comments** — whatever was previously saved for this URL. +2. **Inline comments** — parsed from HTML comment markers embedded directly + in the markdown source: + + ```html + + + ``` + + These are picked up from the rendered `
` (raw view) or, on `file://`
+ pages where that's unavailable, re-fetched from the source file. Inline
+ comments let you seed or share comments by committing them directly into
+ the markdown file — see [Export as Markdown](#export) below.
+
+Stored comments take priority on merge; inline-only comments (not already in
+storage) are added, deduplicated by ID and by anchor+body.
+
+## Anchoring
+
+Each comment anchors to a passage using the selected text plus a small
+amount of surrounding context (up to ~100 characters before, ~50 after, cut
+at paragraph boundaries) and the nearest preceding heading, so that minor
+edits elsewhere in the document don't break the anchor. If the anchor text
+can no longer be found in the current rendering, the comment still appears
+in the sidebar, flagged as an orphan.
+
+Anchors that span more than one inline element (for example, a selection
+that starts in plain text and continues into a `` or ``) are
+highlighted by wrapping each affected text node individually, rather than
+requiring the whole selection to sit inside one DOM node.
+
+Highlights are automatically reapplied if the underlying rendered content
+is replaced — for example after autoreload picks up a file change, or after
+switching themes or toggling the raw view.
+
+## Import / Export
+
+- **Export as JSON** — the full comment set for the current page, suitable
+ for backup or hand-off to another viewer/session.
+- **Export as Markdown** — writes the current comments back into the
+ document as `` / ``
+ markers near their anchored text, producing a `.commented.md` file. This
+ is the round-trip mechanism for sharing comments as part of the document
+ itself (e.g. committing them to a git repo) rather than as separate
+ storage.
+- **Import** (write mode only) — load a previously exported JSON file back
+ into the current page's comment set.
+
+All comment data — whether loaded from storage, parsed from inline markers,
+or imported from JSON — is validated against an allowlist before being
+trusted: tags and severities must match a fixed known set, IDs must match
+`[\w-]+`, and text fields are length-capped. Nothing from an untrusted
+source is rendered as raw HTML.
+
+## Keyboard reference
+
+| Shortcut | Context | Action |
+| :- | :- | :- |
+| `Cmd/Ctrl+Shift+K` | Text selected, write mode | Open comment input for the selection |
+| `Cmd/Ctrl+Enter` | Comment/reply/edit box focused | Save |
+| `Escape` | Comment/reply/edit box focused | Cancel |
+| `Ctrl+]` | Anywhere | Jump to next comment |
+| `Ctrl+[` | Anywhere | Jump to previous comment |
diff --git a/README.md b/README.md
index 9d0be28..5981427 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,8 @@ Full **CommonMark** support including **GFM** tables and strikethrough **+**
| Option | Default | Description
| :- | :-: | :-
| **autoreload** | `false` | Auto reload on file change
+| **comments** | **`true`** | Show comments (highlights + sidebar). See [Comments](COMMENTS-README.md)
+| **commentsWrite** | `false` | Allow adding/editing comments. See [Comments](COMMENTS-README.md)
| **emoji** | `false` | Convert emoji `:shortnames:` into EmojiOne images
| **mathjax** | `false` | Render MathJax formulas
| **mermaid** | `false` | Render Mermaid diagrams
@@ -126,6 +128,15 @@ When enabled the extension will make a GET request every second to markdown file
- `file:///` URLs
- any host that resolves to localhost IPv4 `127.0.0.1` or IPv6 `::1`
+## Comments
+
+Select any text in a rendered markdown document to attach a comment to it.
+Comments persist locally per page, can be tagged/prioritized/replied to/
+resolved, and can be exported as JSON or written back into the markdown
+source as HTML comment markers. See [COMMENTS-README.md](COMMENTS-README.md)
+for the full write-up, including the read/write settings split, keyboard
+shortcuts, and data format.
+
## Emoji
Convert emoji :shortnames: into EmojiOne images:
diff --git a/background/comments.js b/background/comments.js
new file mode 100644
index 0000000..2248ce1
--- /dev/null
+++ b/background/comments.js
@@ -0,0 +1,150 @@
+md.comments = ({storage: {state}}) => {
+
+ // Register context menu
+ chrome.contextMenus.create({
+ id: 'markdown-viewer-add-comment',
+ title: 'Add Comment',
+ contexts: ['selection'],
+ documentUrlPatterns: ['file:///*']
+ })
+
+ // Handle context menu click
+ chrome.contextMenus.onClicked.addListener((info, tab) => {
+ if (info.menuItemId === 'markdown-viewer-add-comment') {
+ chrome.tabs.sendMessage(tab.id, {
+ message: 'comments.add-from-menu',
+ selectionText: info.selectionText
+ })
+ }
+ })
+
+ // Handle comment storage messages
+ return (req, sender, sendResponse) => {
+ if (req.message === 'comments.load') {
+ var key = 'comments:' + req.url
+ chrome.storage.local.get(key, (res) => {
+ sendResponse({comments: res[key] || []})
+ })
+ return true
+ }
+
+ else if (req.message === 'comments.save') {
+ var key = 'comments:' + req.url
+ chrome.storage.local.set({[key]: req.comments}, () => {
+ sendResponse({ok: true})
+ })
+ return true
+ }
+
+ else if (req.message === 'comments.export') {
+ var key = 'comments:' + req.url
+ chrome.storage.local.get(key, (res) => {
+ var comments = res[key] || []
+ var filename = req.filename || 'comments.json'
+
+ var exportData = {
+ version: 1,
+ source: req.url,
+ exportedAt: new Date().toISOString(),
+ comments: comments
+ }
+
+ // Create a data URL and trigger download
+ var json = JSON.stringify(exportData, null, 2)
+ var blob = new Blob([json], {type: 'application/json'})
+ var reader = new FileReader()
+ reader.onload = () => {
+ chrome.downloads.download({
+ url: reader.result,
+ filename: filename,
+ saveAs: true
+ }, () => {
+ sendResponse({ok: true})
+ })
+ }
+ reader.readAsDataURL(blob)
+ })
+ return true
+ }
+
+ else if (req.message === 'comments.export-md') {
+ // Fetch the raw markdown source, inject inline HTML comments, download as .md
+ fetch(req.url)
+ .then((res) => res.text())
+ .then((markdown) => {
+ var annotated = injectInlineComments(markdown, req.comments)
+ var blob = new Blob([annotated], {type: 'text/markdown'})
+ var reader = new FileReader()
+ reader.onload = () => {
+ chrome.downloads.download({
+ url: reader.result,
+ filename: req.filename,
+ saveAs: true
+ }, () => {
+ sendResponse({ok: true})
+ })
+ }
+ reader.readAsDataURL(blob)
+ })
+ .catch((err) => {
+ sendResponse({ok: false, error: err.message})
+ })
+ return true
+ }
+
+ else if (req.message === 'comments.badge') {
+ var text = req.count > 0 ? String(req.count) : ''
+ chrome.action.setBadgeText({text: text, tabId: sender.tab.id})
+ chrome.action.setBadgeBackgroundColor({color: '#2563eb', tabId: sender.tab.id})
+ return false
+ }
+
+ else if (req.message === 'comments.clear') {
+ var key = 'comments:' + req.url
+ chrome.storage.local.remove(key, () => {
+ sendResponse({ok: true})
+ })
+ return true
+ }
+ }
+
+ function injectInlineComments (markdown, comments) {
+ // Sort comments by position in the source (later first so insertions don't shift offsets)
+ var sorted = comments
+ .filter((c) => c.anchor && c.anchor.text)
+ .map((c) => {
+ var idx = findAnchorInSource(markdown, c.anchor)
+ return {comment: c, idx: idx}
+ })
+ .filter((item) => item.idx >= 0)
+ .sort((a, b) => b.idx - a.idx)
+
+ var result = markdown
+ sorted.forEach((item) => {
+ var c = item.comment
+ var anchorEnd = item.idx + c.anchor.text.length
+ var status = c.resolved ? ' [RESOLVED]' : ''
+ var commentTag = ''
+ result = result.substring(0, anchorEnd) + commentTag + result.substring(anchorEnd)
+ })
+
+ return result
+ }
+
+ function findAnchorInSource (markdown, anchor) {
+ // Try exact text match with prefix context
+ if (anchor.prefix) {
+ var withPrefix = anchor.prefix + anchor.text
+ var idx = markdown.indexOf(withPrefix)
+ if (idx >= 0) return idx + anchor.prefix.length
+ }
+ // Try exact text match with suffix context
+ if (anchor.suffix) {
+ var withSuffix = anchor.text + anchor.suffix
+ var idx = markdown.indexOf(withSuffix)
+ if (idx >= 0) return idx
+ }
+ // Fallback: plain text match
+ return markdown.indexOf(anchor.text)
+ }
+}
diff --git a/background/index.js b/background/index.js
index 54a1e7b..06f8dc7 100644
--- a/background/index.js
+++ b/background/index.js
@@ -14,6 +14,7 @@ importScripts('/background/messages.js')
importScripts('/background/mathjax.js')
importScripts('/background/xhr.js')
importScripts('/background/icon.js')
+importScripts('/background/comments.js')
;(() => {
var storage = md.storage(md)
@@ -23,6 +24,7 @@ importScripts('/background/icon.js')
var mathjax = md.mathjax()
var xhr = md.xhr()
var icon = md.icon({storage})
+ var comments = md.comments({storage})
var compilers = Object.keys(md.compilers)
.reduce((all, compiler) => (
@@ -34,6 +36,7 @@ importScripts('/background/icon.js')
chrome.tabs.onUpdated.addListener(detect.tab)
chrome.runtime.onMessage.addListener(messages)
+ chrome.runtime.onMessage.addListener(comments)
icon()
})()
diff --git a/background/inject.js b/background/inject.js
index da617b3..b8da56d 100644
--- a/background/inject.js
+++ b/background/inject.js
@@ -1,6 +1,15 @@
md.inject = ({storage: {state}}) => (id) => {
+ chrome.scripting.insertCSS({
+ target: {tabId: id},
+ files: [
+ '/content/index.css',
+ '/content/themes.css',
+ (state.content.comments || state.content.commentsWrite) && '/content/comments.css',
+ ].filter(Boolean)
+ })
+
chrome.scripting.executeScript({
target: {tabId: id},
args: [{
@@ -19,14 +28,6 @@ md.inject = ({storage: {state}}) => (id) => {
injectImmediately: true
})
- chrome.scripting.insertCSS({
- target: {tabId: id},
- files: [
- '/content/index.css',
- '/content/themes.css',
- ]
- })
-
chrome.scripting.executeScript({
target: {tabId: id},
files: [
@@ -38,6 +39,7 @@ md.inject = ({storage: {state}}) => (id) => {
'/content/index.js',
'/content/scroll.js',
state.content.autoreload && '/content/autoreload.js',
+ (state.content.comments || state.content.commentsWrite) && '/content/comments.js',
].filter(Boolean).flat(),
injectImmediately: true
})
diff --git a/background/storage.js b/background/storage.js
index 8f5e403..e2a07f6 100644
--- a/background/storage.js
+++ b/background/storage.js
@@ -53,6 +53,8 @@ md.storage.defaults = (compilers) => {
mermaid: false,
syntax: true,
toc: false,
+ comments: true,
+ commentsWrite: false,
},
origins: {
'file://': {
@@ -193,4 +195,15 @@ md.storage.migrations = (state) => {
color: 'auto'
}
}
+ // v5.3 -> v5.3.1
+ if (state.content.comments === undefined) {
+ state.content.comments = true
+ }
+ // v5.3.1 -> v5.3.2
+ if (state.content.commentsWrite === undefined) {
+ // Existing users who already had comments enabled keep write access
+ // (they may already be actively creating comments). New installs
+ // default to read-only per the updated default above.
+ state.content.commentsWrite = state.content.comments === true
+ }
}
diff --git a/content/comments.css b/content/comments.css
new file mode 100644
index 0000000..924f0ba
--- /dev/null
+++ b/content/comments.css
@@ -0,0 +1,456 @@
+/* ─── Tooltip ─────────────────────────────────────────────── */
+
+#_comments-tooltip {
+ position: absolute;
+ z-index: 10003;
+ background: var(--comments-btn-bg, #2563eb);
+ color: #fff;
+ border-radius: 4px;
+ padding: 4px 10px;
+ font-size: 12px;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+ user-select: none;
+ white-space: nowrap;
+}
+#_comments-tooltip:hover { opacity: 0.9; }
+
+/* ─── Toggle Button ───────────────────────────────────────── */
+
+#_comments-toggle {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 10000;
+ background: var(--comments-btn-bg, #2563eb);
+ color: var(--comments-btn-fg, #fff);
+ border: none;
+ border-radius: 24px;
+ padding: 8px 14px;
+ font-size: 14px;
+ cursor: pointer;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+ transition: transform 0.15s, box-shadow 0.15s;
+}
+#_comments-toggle:hover {
+ transform: scale(1.05);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+
+/* ─── Highlights ──────────────────────────────────────────── */
+
+mark._comment-highlight {
+ background: var(--comments-highlight-bg, rgba(255, 220, 0, 0.25));
+ border-bottom: 2px solid var(--comments-highlight-border, rgba(200, 160, 0, 0.5));
+ cursor: pointer;
+ border-radius: 2px;
+ transition: background 0.2s;
+}
+mark._comment-highlight:hover { background: var(--comments-highlight-hover, rgba(255, 220, 0, 0.45)); }
+mark._comment-highlight._severity-critical { border-bottom-color: #dc2626; background: rgba(220, 38, 38, 0.1); }
+mark._comment-highlight._severity-high { border-bottom-color: #ea580c; background: rgba(234, 88, 12, 0.1); }
+mark._comment-highlight._severity-medium { border-bottom-color: #ca8a04; }
+mark._comment-highlight._flash { animation: _comment-flash 1s ease; }
+
+@keyframes _comment-flash {
+ 0%, 100% { background: var(--comments-highlight-bg, rgba(255, 220, 0, 0.25)); }
+ 50% { background: rgba(255, 180, 0, 0.5); }
+}
+
+/* ─── Sidebar ─────────────────────────────────────────────── */
+
+#_comments-sidebar {
+ position: fixed;
+ top: 0;
+ right: -360px;
+ width: 340px;
+ height: 100vh;
+ z-index: 10001;
+ background: var(--comments-sidebar-bg, #fff);
+ border-left: 1px solid var(--comments-sidebar-border, #e0e0e0);
+ box-shadow: -2px 0 12px rgba(0,0,0,0.1);
+ display: flex;
+ flex-direction: column;
+ transition: right 0.25s ease;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ font-size: 13px;
+ color: var(--comments-sidebar-fg, #333);
+}
+#_comments-sidebar._visible { right: 0; }
+
+._comments-sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--comments-sidebar-border, #e0e0e0);
+ flex-shrink: 0;
+ gap: 4px;
+}
+._comments-title { font-weight: 600; font-size: 13px; white-space: nowrap; }
+._comments-badge {
+ display: inline-block;
+ background: var(--comments-badge-bg, #2563eb);
+ color: #fff;
+ border-radius: 10px;
+ padding: 1px 7px;
+ font-size: 11px;
+ margin-left: 6px;
+}
+._comments-sidebar-actions { display: flex; gap: 2px; flex-wrap: nowrap; }
+._comments-sidebar-actions button {
+ background: none;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 4px;
+ cursor: pointer;
+ padding: 3px 5px;
+ font-size: 10px;
+ color: var(--comments-sidebar-fg, #555);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+._comments-sidebar-actions button:hover { background: var(--comments-item-hover, #f5f5f5); }
+
+/* ─── Filters ─────────────────────────────────────────────── */
+
+._comments-sidebar-filters {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--comments-sidebar-border, #e0e0e0);
+ flex-shrink: 0;
+}
+._comments-search {
+ width: 100%;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 4px;
+ padding: 5px 8px;
+ font-size: 12px;
+ background: var(--comments-input-bg, #fff);
+ color: var(--comments-sidebar-fg, #333);
+ box-sizing: border-box;
+ margin-bottom: 6px;
+}
+._comments-search:focus { outline: none; border-color: var(--comments-btn-bg, #2563eb); }
+._comments-filter-row { display: flex; gap: 4px; }
+._comments-filter-row select {
+ flex: 1;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 3px;
+ padding: 3px 4px;
+ font-size: 11px;
+ background: var(--comments-input-bg, #fff);
+ color: var(--comments-sidebar-fg, #333);
+}
+
+._comments-sidebar-body { flex: 1; overflow-y: auto; padding: 8px; }
+
+/* ─── Comment Items ───────────────────────────────────────── */
+
+._comments-item {
+ padding: 10px 12px;
+ border: 1px solid var(--comments-item-border, #eee);
+ border-radius: 6px;
+ margin-bottom: 8px;
+ transition: background 0.15s;
+ cursor: pointer;
+}
+._comments-item:hover { background: var(--comments-item-hover, #f9f9f9); }
+._comments-item._resolved { opacity: 0.5; }
+._comments-item._orphan { border-left: 3px solid #f59e0b; }
+._comments-item._orphan ._comments-item-anchor { color: var(--comments-meta-fg, #999); }
+._comments-item._flash { animation: _comment-item-flash 1s ease; }
+@keyframes _comment-item-flash {
+ 0%, 100% { background: transparent; }
+ 50% { background: var(--comments-highlight-bg, rgba(255, 220, 0, 0.25)); }
+}
+
+/* Pills */
+._comments-item-pills { margin-bottom: 4px; display: flex; gap: 4px; flex-wrap: wrap; }
+._pill {
+ display: inline-block;
+ font-size: 10px;
+ font-weight: 600;
+ padding: 1px 6px;
+ border-radius: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+._pill-tag { background: #e0e7ff; color: #3730a3; }
+._pill-note { background: #e0e7ff; color: #3730a3; }
+._pill-question { background: #dbeafe; color: #1d4ed8; }
+._pill-suggestion { background: #d1fae5; color: #065f46; }
+._pill-issue { background: #fee2e2; color: #991b1b; }
+._pill-outdated { background: #fef3c7; color: #92400e; }
+._pill-action-needed { background: #fce7f3; color: #9d174d; }
+._pill-orphan { background: #fef3c7; color: #92400e; }
+._pill-severity { font-weight: 700; }
+._pill-critical { background: #dc2626; color: #fff; }
+._pill-high { background: #ea580c; color: #fff; }
+._pill-medium { background: #ca8a04; color: #fff; }
+._pill-low { background: #6b7280; color: #fff; }
+
+._comments-item-anchor {
+ font-size: 12px;
+ color: var(--comments-anchor-fg, #666);
+ font-style: italic;
+ margin-bottom: 6px;
+ cursor: pointer;
+ padding: 4px 6px;
+ background: var(--comments-anchor-bg, #f7f7f7);
+ border-radius: 3px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+._comments-item-anchor:hover { background: var(--comments-anchor-hover, #eef); }
+
+._comments-item-hint {
+ font-size: 11px;
+ color: var(--comments-meta-fg, #999);
+ margin-bottom: 4px;
+ font-style: italic;
+}
+
+._comments-item-body {
+ white-space: pre-wrap;
+ line-height: 1.4;
+ margin-bottom: 6px;
+}
+._comments-item-body a { color: var(--comments-btn-bg, #2563eb); text-decoration: underline; }
+
+/* Suggestion diff */
+._comments-item-suggestion {
+ font-size: 12px;
+ padding: 6px 8px;
+ background: var(--comments-anchor-bg, #f7f7f7);
+ border-radius: 4px;
+ margin-bottom: 6px;
+ line-height: 1.4;
+}
+._suggestion-label { font-weight: 600; color: var(--comments-anchor-fg, #666); }
+._comments-item-suggestion del { color: #dc2626; text-decoration: line-through; }
+._comments-item-suggestion ins { color: #16a34a; text-decoration: none; font-weight: 500; }
+
+/* Replies */
+._comments-replies {
+ border-left: 2px solid var(--comments-sidebar-border, #e0e0e0);
+ margin: 6px 0 6px 8px;
+ padding-left: 10px;
+}
+._comments-reply {
+ font-size: 12px;
+ margin-bottom: 4px;
+ line-height: 1.4;
+}
+._comments-reply-date { color: var(--comments-meta-fg, #999); font-size: 10px; }
+._comments-reply-input { margin-top: 8px; }
+._comments-reply-textarea {
+ width: 100%;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 4px;
+ padding: 6px;
+ font-family: inherit;
+ font-size: 12px;
+ resize: vertical;
+ min-height: 40px;
+ background: var(--comments-input-bg, #fff);
+ color: var(--comments-sidebar-fg, #333);
+ box-sizing: border-box;
+}
+._comments-reply-textarea:focus { outline: none; border-color: var(--comments-btn-bg, #2563eb); }
+
+._comments-item-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 11px;
+ color: var(--comments-meta-fg, #999);
+}
+._comments-item-actions button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ font-size: 12px;
+ padding: 2px 5px;
+ border-radius: 3px;
+ color: var(--comments-meta-fg, #888);
+}
+._comments-item-actions button:hover { background: var(--comments-item-hover, #eee); color: var(--comments-sidebar-fg, #333); }
+
+._comments-empty {
+ text-align: center;
+ color: var(--comments-meta-fg, #999);
+ padding: 40px 20px;
+ line-height: 1.6;
+}
+._comments-empty kbd {
+ background: var(--comments-anchor-bg, #f0f0f0);
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 3px;
+ padding: 1px 5px;
+ font-family: inherit;
+ font-size: 12px;
+}
+
+/* ─── Edit textarea ───────────────────────────────────────── */
+
+._comments-edit-textarea {
+ width: 100%;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 4px;
+ padding: 6px;
+ font-family: inherit;
+ font-size: 13px;
+ resize: vertical;
+ min-height: 50px;
+ background: var(--comments-input-bg, #fff);
+ color: var(--comments-sidebar-fg, #333);
+ box-sizing: border-box;
+}
+._comments-edit-textarea:focus { outline: none; border-color: var(--comments-btn-bg, #2563eb); }
+._comments-edit-actions { display: flex; gap: 6px; margin-top: 6px; }
+._comments-edit-actions button {
+ font-size: 11px;
+ padding: 3px 10px;
+ border-radius: 3px;
+ cursor: pointer;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ background: none;
+ color: var(--comments-sidebar-fg, #555);
+}
+._comments-edit-actions ._comments-edit-save,
+._comments-edit-actions ._comments-reply-save {
+ background: var(--comments-btn-bg, #2563eb);
+ color: #fff;
+ border: none;
+}
+
+/* ─── Comment Input ───────────────────────────────────────── */
+
+#_comments-input {
+ position: absolute;
+ right: 20px;
+ z-index: 10002;
+ width: 340px;
+ background: var(--comments-sidebar-bg, #fff);
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 8px;
+ box-shadow: 0 4px 16px rgba(0,0,0,0.15);
+ padding: 12px;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ font-size: 13px;
+ color: var(--comments-sidebar-fg, #333);
+}
+._comments-input-header {
+ font-size: 12px;
+ color: var(--comments-meta-fg, #777);
+ margin-bottom: 8px;
+ line-height: 1.4;
+}
+._comments-input-header em { color: var(--comments-anchor-fg, #555); }
+._comments-input-textarea,
+._comments-input-suggestion {
+ width: 100%;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 4px;
+ padding: 8px;
+ font-family: inherit;
+ font-size: 13px;
+ resize: vertical;
+ min-height: 50px;
+ background: var(--comments-input-bg, #fff);
+ color: var(--comments-sidebar-fg, #333);
+ box-sizing: border-box;
+ margin-bottom: 6px;
+}
+._comments-input-textarea:focus,
+._comments-input-suggestion:focus {
+ outline: none;
+ border-color: var(--comments-btn-bg, #2563eb);
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+._comments-input-options {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-bottom: 6px;
+ flex-wrap: wrap;
+}
+._comments-input-options select {
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 3px;
+ padding: 3px 6px;
+ font-size: 11px;
+ background: var(--comments-input-bg, #fff);
+ color: var(--comments-sidebar-fg, #333);
+}
+._comments-input-suggest-label {
+ font-size: 11px;
+ color: var(--comments-meta-fg, #777);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+}
+._comments-input-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+}
+._comments-btn-save {
+ background: var(--comments-btn-bg, #2563eb);
+ color: #fff;
+ border: none;
+ border-radius: 4px;
+ padding: 6px 14px;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+}
+._comments-btn-save:hover { opacity: 0.9; }
+._comments-btn-cancel {
+ background: none;
+ border: 1px solid var(--comments-sidebar-border, #ddd);
+ border-radius: 4px;
+ padding: 6px 14px;
+ cursor: pointer;
+ font-size: 12px;
+ color: var(--comments-sidebar-fg, #555);
+}
+._comments-btn-cancel:hover { background: var(--comments-item-hover, #f5f5f5); }
+
+/* ─── Dark Mode ───────────────────────────────────────────── */
+
+body._color-dark {
+ --comments-btn-bg: #3b82f6;
+ --comments-btn-fg: #fff;
+ --comments-highlight-bg: rgba(200, 160, 0, 0.2);
+ --comments-highlight-border: rgba(200, 160, 0, 0.4);
+ --comments-highlight-hover: rgba(200, 160, 0, 0.35);
+ --comments-sidebar-bg: #1e1e1e;
+ --comments-sidebar-border: #333;
+ --comments-sidebar-fg: #e0e0e0;
+ --comments-item-border: #333;
+ --comments-item-hover: #2a2a2a;
+ --comments-anchor-bg: #2a2a2a;
+ --comments-anchor-fg: #aaa;
+ --comments-anchor-hover: #333;
+ --comments-meta-fg: #777;
+ --comments-badge-bg: #3b82f6;
+ --comments-input-bg: #252525;
+}
+
+body._color-dark ._pill-tag,
+body._color-dark ._pill-note { background: #312e81; color: #c7d2fe; }
+body._color-dark ._pill-question { background: #1e3a5f; color: #93c5fd; }
+body._color-dark ._pill-suggestion { background: #064e3b; color: #6ee7b7; }
+body._color-dark ._pill-issue { background: #7f1d1d; color: #fca5a5; }
+body._color-dark ._pill-outdated { background: #78350f; color: #fde68a; }
+body._color-dark ._pill-action-needed { background: #831843; color: #f9a8d4; }
+
+/* ─── Responsive ──────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+ #_comments-sidebar { width: 100%; right: -100%; }
+ #_comments-input { width: calc(100% - 40px); right: 20px; left: 20px; }
+}
diff --git a/content/comments.js b/content/comments.js
new file mode 100644
index 0000000..e936a2e
--- /dev/null
+++ b/content/comments.js
@@ -0,0 +1,1125 @@
+;(() => {
+ // ─── STATE ────────────────────────────────────────────────────
+
+ var comments = []
+ var pendingSelection = null
+ var sidebarVisible = false
+ var closeSidebarOnClick = true
+ var pageUrl = location.href
+ var authorName = ''
+ var filters = {status: 'all', tag: 'all', severity: 'all'}
+ var searchQuery = ''
+ var editingId = null
+ var replyingId = null
+ var showingInput = false
+ var showingTooltip = null // {top, left} or null
+ // `args` is set globally by background/inject.js before this script runs.
+ // Fall back to write-enabled if unavailable (e.g. loaded standalone/tests).
+ var writeEnabled = (typeof args !== 'undefined' && args.content)
+ ? !!args.content.commentsWrite
+ : true
+
+ var TAGS = ['note', 'question', 'suggestion', 'issue', 'outdated', 'action-needed']
+ var SEVERITIES = ['low', 'medium', 'high', 'critical']
+
+ // ─── SANITIZATION HELPERS ───────────────────────────────────────
+ // Only allow known-good values through to CSS class names / attributes.
+ // Anything imported from JSON or parsed from inline comments passes
+ // through these before being trusted anywhere.
+
+ function sanitizeTag (tag) {
+ return TAGS.indexOf(tag) !== -1 ? tag : null
+ }
+
+ function sanitizeSeverity (severity) {
+ return SEVERITIES.indexOf(severity) !== -1 ? severity : null
+ }
+
+ function sanitizeId (id, prefix) {
+ return (typeof id === 'string' && /^[\w-]+$/.test(id))
+ ? id
+ : (prefix || 'id_') + Date.now() + '_' + Math.random().toString(36).substring(2, 8)
+ }
+
+ function sanitizeComment (c) {
+ if (!c || typeof c !== 'object') return null
+ return {
+ id: sanitizeId(c.id, 'imp_'),
+ anchor: {
+ text: typeof c.anchor?.text === 'string' ? c.anchor.text.substring(0, 200) : '',
+ prefix: typeof c.anchor?.prefix === 'string' ? c.anchor.prefix.substring(0, 30) : '',
+ suffix: typeof c.anchor?.suffix === 'string' ? c.anchor.suffix.substring(0, 30) : '',
+ heading: typeof c.anchor?.heading === 'string' ? c.anchor.heading.substring(0, 200) : ''
+ },
+ body: typeof c.body === 'string' ? c.body : '',
+ author: typeof c.author === 'string' ? c.author.substring(0, 100) : null,
+ tag: sanitizeTag(c.tag),
+ severity: sanitizeSeverity(c.severity),
+ suggestion: typeof c.suggestion === 'string' ? c.suggestion : null,
+ replies: Array.isArray(c.replies) ? c.replies.map(sanitizeReply).filter(Boolean) : [],
+ createdAt: typeof c.createdAt === 'string' ? c.createdAt : new Date().toISOString(),
+ updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : null,
+ resolved: !!c.resolved,
+ _fromInline: !!c._fromInline
+ }
+ }
+
+ function sanitizeReply (r) {
+ if (!r || typeof r !== 'object') return null
+ return {
+ id: sanitizeId(r.id, 'r_'),
+ author: typeof r.author === 'string' ? r.author.substring(0, 100) : null,
+ body: typeof r.body === 'string' ? r.body : '',
+ createdAt: typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString()
+ }
+ }
+
+ // ─── INITIALIZATION ───────────────────────────────────────────
+
+ function init () {
+ loadAuthor()
+ loadComments()
+ if (writeEnabled) {
+ setupKeyboardShortcut()
+ setupSelectionListener()
+ setupMessageListener()
+ } else {
+ setupNavigationShortcut()
+ setupCloseOnClickListener()
+ }
+ mountUi()
+ createToggleButton()
+ watchContentReplacement()
+ }
+
+ // ─── STORAGE ──────────────────────────────────────────────────
+
+ function loadAuthor () {
+ chrome.storage.sync.get(['commentsAuthor', 'commentsCloseOnClick'], (res) => {
+ authorName = res.commentsAuthor || ''
+ closeSidebarOnClick = res.commentsCloseOnClick !== false // default true
+ })
+ }
+
+ function loadComments () {
+ chrome.runtime.sendMessage({
+ message: 'comments.load',
+ url: pageUrl
+ }, (res) => {
+ // Distinguish "background responded with zero comments" from
+ // "background didn't respond" (e.g. MV3 service worker was asleep
+ // and sendMessage resolved with undefined, or chrome.runtime.lastError
+ // was set). Treating the latter as "stored = []" would cause the
+ // merge below to silently overwrite real stored comments.
+ if (chrome.runtime.lastError || !res) {
+ console.error('[comments] failed to load stored comments, skipping merge/save:', chrome.runtime.lastError)
+ comments = parseInlineComments()
+ renderHighlights()
+ redraw()
+ updateBadge()
+ return
+ }
+
+ var stored = Array.isArray(res.comments) ? res.comments.map(sanitizeComment).filter(Boolean) : []
+ var inline = parseInlineComments()
+ comments = mergeComments(stored, inline)
+ if (comments.length > stored.length) {
+ // Persist merged result so inline comments are editable
+ saveComments()
+ }
+ renderHighlights()
+ redraw()
+ updateBadge()
+ })
+ }
+
+ function parseInlineComments () {
+ // The original may have been replaced by Mithril's mount.
+ // Try to read it first; if empty, fetch the raw file.
+ var pre = document.querySelector('pre')
+ var source = pre ? (pre.textContent || pre.innerText || '') : ''
+
+ if (source && source.includes('/g
+ var match
+
+ while ((match = regex.exec(source)) !== null) {
+ var resolved = !!match[1]
+ var body = match[2].replace(/—/g, '--')
+
+ var commentStart = match.index
+ var commentEnd = commentStart + match[0].length
+
+ // Grab text before the comment on the same line
+ var beforeChunk = source.substring(Math.max(0, commentStart - 100), commentStart)
+ var lastNewline = beforeChunk.lastIndexOf('\n')
+ var lineBeforeComment = lastNewline >= 0 ? beforeChunk.substring(lastNewline + 1) : beforeChunk
+
+ // Grab text after the comment (up to 50 chars, stop at newline)
+ var afterChunk = source.substring(commentEnd, commentEnd + 50)
+ var firstNewline = afterChunk.indexOf('\n')
+ var lineAfterComment = firstNewline >= 0 ? afterChunk.substring(0, firstNewline) : afterChunk
+
+ // Use text before comment as anchor (strip markdown syntax)
+ var anchorText = lineBeforeComment.replace(/[#*_`>\[\]|]/g, '').trim()
+ if (anchorText.length < 3) {
+ anchorText = lineAfterComment.replace(/[#*_`>\[\]|]/g, '').trim()
+ }
+ if (anchorText.length < 3) continue
+
+ // Prefix for disambiguation
+ var prefixStart = Math.max(0, commentStart - 130)
+ var prefixChunk = source.substring(prefixStart, Math.max(0, commentStart - 100))
+ var prefix = prefixChunk.replace(/[#*_`>\[\]|]/g, '').trim()
+
+ // Find nearest heading above
+ var heading = ''
+ var beforeAll = source.substring(0, commentStart)
+ var headingMatches = beforeAll.match(/^#{1,6}\s+.+$/gm)
+ if (headingMatches) heading = headingMatches[headingMatches.length - 1].replace(/^#+\s*/, '')
+
+ results.push(sanitizeComment({
+ id: 'inline_' + hashCode(anchorText + body),
+ anchor: {
+ text: anchorText.substring(0, 200),
+ prefix: prefix.substring(0, 30),
+ suffix: lineAfterComment.replace(/[#*_`>\[\]|]/g, '').trim().substring(0, 30),
+ heading: heading
+ },
+ body: body.trim(),
+ author: null,
+ tag: null,
+ severity: null,
+ suggestion: null,
+ replies: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: null,
+ resolved: resolved,
+ _fromInline: true
+ }))
+ }
+ return results
+ }
+
+ function mergeComments (stored, inline) {
+ if (!inline.length) return stored
+ if (!stored.length) return inline
+
+ // Merge: stored comments take priority (user may have edited them)
+ var storedIds = new Set(stored.map((c) => c.id))
+ var newInline = inline.filter((c) => !storedIds.has(c.id))
+
+ // Also check by anchor text to avoid duplicating if ID format differs
+ var storedAnchors = new Set(stored.map((c) => c.anchor.text + '|||' + c.body))
+ var deduped = newInline.filter((c) => !storedAnchors.has(c.anchor.text + '|||' + c.body))
+
+ return stored.concat(deduped)
+ }
+
+ function hashCode (str) {
+ var hash = 0
+ for (var i = 0; i < str.length; i++) {
+ var ch = str.charCodeAt(i)
+ hash = ((hash << 5) - hash) + ch
+ hash |= 0
+ }
+ return Math.abs(hash).toString(36)
+ }
+
+ function saveComments () {
+ // Strip internal edit/reply scratch fields (_draftBody/_draftReply)
+ // before persisting — they're only used for the controlled-textarea
+ // pattern in renderEditBox/renderReplyBox and should never reach
+ // storage, e.g. if a user abandons an edit without pressing Escape.
+ var toPersist = comments.map((c) => {
+ var clean = Object.assign({}, c)
+ delete clean._draftBody
+ delete clean._draftReply
+ return clean
+ })
+ chrome.runtime.sendMessage({
+ message: 'comments.save',
+ url: pageUrl,
+ comments: toPersist
+ }, () => {
+ if (chrome.runtime.lastError) {
+ console.error('[comments] failed to save comments:', chrome.runtime.lastError)
+ }
+ })
+ updateBadge()
+ }
+
+ // ─── SELECTION HANDLING ───────────────────────────────────────
+
+ function setupSelectionListener () {
+ document.addEventListener('mouseup', (e) => {
+ if (e.target.closest('#_comments-sidebar') || e.target.closest('#_comments-input') || e.target.closest('#_comments-tooltip')) {
+ return
+ }
+ showingTooltip = null
+ var sel = window.getSelection()
+ if (sel && sel.toString().trim().length > 0) {
+ pendingSelection = captureSelection(sel)
+ showingTooltip = {top: e.pageY - 40, left: e.pageX}
+ redraw()
+ } else {
+ redraw()
+ }
+ })
+ setupCloseOnClickListener()
+ }
+
+ function setupCloseOnClickListener () {
+ document.addEventListener('mousedown', (e) => {
+ if (!e.target.closest('#_comments-tooltip')) {
+ if (showingTooltip) { showingTooltip = null; redraw() }
+ }
+ // Close sidebar on click in document (not on our UI elements)
+ if (sidebarVisible && closeSidebarOnClick
+ && !e.target.closest('#_comments-sidebar')
+ && !e.target.closest('#_comments-toggle')
+ && !e.target.closest('#_comments-input')
+ && !e.target.closest('#_comments-tooltip')
+ && !e.target.closest('mark._comment-highlight')) {
+ hideSidebar()
+ }
+ })
+ }
+
+ function setupKeyboardShortcut () {
+ document.addEventListener('keydown', (e) => {
+ // Cmd+Shift+K — add comment (write mode only; init() only calls this fn when writeEnabled)
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'K') {
+ e.preventDefault()
+ e.stopPropagation()
+ var sel = window.getSelection()
+ if (sel && sel.toString().trim().length > 0) {
+ pendingSelection = captureSelection(sel)
+ showCommentInput()
+ }
+ }
+ })
+ setupNavigationShortcut()
+ }
+
+ function setupNavigationShortcut () {
+ document.addEventListener('keydown', (e) => {
+ // Ctrl+] — next comment
+ if (e.ctrlKey && e.key === ']') {
+ e.preventDefault()
+ navigateComment(1)
+ }
+ // Ctrl+[ — previous comment
+ if (e.ctrlKey && e.key === '[') {
+ e.preventDefault()
+ navigateComment(-1)
+ }
+ })
+ }
+
+ function setupMessageListener () {
+ chrome.runtime.onMessage.addListener((req) => {
+ if (req.message === 'comments.add-from-menu') {
+ if (pendingSelection) showCommentInput()
+ }
+ })
+ }
+
+ function captureSelection (sel) {
+ var range = sel.getRangeAt(0)
+ var text = sel.toString().trim()
+ var container = range.commonAncestorContainer
+ var fullText = (container.textContent || '')
+ var startOffset = fullText.indexOf(text)
+ var prefix = fullText.substring(Math.max(0, startOffset - 30), startOffset)
+ var suffix = fullText.substring(startOffset + text.length, startOffset + text.length + 30)
+
+ var heading = ''
+ var node = range.startContainer
+ while (node && node !== document.body) {
+ if (node.previousElementSibling) {
+ var prev = node.previousElementSibling
+ if (/^H[1-6]$/.test(prev.tagName)) {
+ heading = prev.textContent
+ break
+ }
+ }
+ node = node.parentElement
+ }
+
+ var rect = range.getBoundingClientRect()
+ return {
+ text: text.substring(0, 200),
+ prefix: prefix,
+ suffix: suffix,
+ heading: heading,
+ rect: {top: rect.top + window.scrollY, left: rect.left}
+ }
+ }
+
+ // ─── HIGHLIGHTS ───────────────────────────────────────────────
+
+ var anchoredIds = new Set()
+ var applyingHighlights = false
+
+ function renderHighlights () {
+ applyingHighlights = true
+ document.querySelectorAll('mark._comment-highlight').forEach((el) => el.replaceWith(...el.childNodes))
+ anchoredIds.clear()
+ comments.forEach((comment) => {
+ if (comment.resolved) return
+ if (highlightText(comment)) {
+ anchoredIds.add(comment.id)
+ }
+ })
+ redraw() // orphan/anchored state in the sidebar may have changed
+ // Let the DOM settle before re-arming mutation detection, so our
+ // own insertions/removals above don't re-trigger the observer.
+ requestAnimationFrame(() => { applyingHighlights = false })
+ }
+
+ // content/index.js's own Mithril app rebuilds #_html/#_markdown from
+ // scratch on autoreload, theme switch, and raw-view toggle (m.trust()
+ // replaces the whole subtree), destroying our elements each
+ // time with no callback into this script. Watch for that and
+ // re-apply highlights rather than leaving them silently gone.
+ var highlightObserver = null
+
+ function watchContentReplacement () {
+ var content = document.getElementById('_html') || document.getElementById('_markdown')
+ if (!content || !content.parentElement) return
+
+ var debounceTimer = null
+ highlightObserver = new MutationObserver(() => {
+ if (applyingHighlights) return // ignore mutations caused by our own renderHighlights()
+ // A single Mithril re-render can fire multiple mutation records;
+ // coalesce into one renderHighlights() call.
+ clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(renderHighlights, 50)
+ })
+ // Observe the parent, since #_html/#_markdown itself may be replaced
+ // wholesale (not just mutated) on theme/raw toggles.
+ highlightObserver.observe(content.parentElement, {childList: true, subtree: true})
+ }
+
+ function highlightText (comment) {
+ var content = document.getElementById('_html') || document.getElementById('_markdown')
+ if (!content) return false
+
+ var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, null)
+ var searchText = comment.anchor.text
+ var node
+
+ while ((node = walker.nextNode())) {
+ var idx = node.textContent.indexOf(searchText)
+ if (idx === -1) continue
+
+ var parentText = node.parentElement.textContent || ''
+ if (comment.anchor.prefix && !parentText.includes(comment.anchor.prefix + searchText)) {
+ if (comment.anchor.suffix && !parentText.includes(searchText + comment.anchor.suffix)) {
+ continue
+ }
+ }
+
+ if (highlightWithinSingleNode(node, idx, searchText, comment)) return true
+ // surroundContents() threw for this single-node match (shouldn't
+ // normally happen for a genuinely single-node range, but fall
+ // through to the cross-node path defensively rather than give up).
+ break
+ }
+
+ // No single text node contains the full anchor text — this is the
+ // common case for anchors that span an inline element boundary
+ // (bold/code/link). Search across node boundaries instead.
+ return highlightAcrossNodes(content, searchText, comment)
+ }
+
+ function highlightWithinSingleNode (node, idx, searchText, comment) {
+ var range = document.createRange()
+ range.setStart(node, idx)
+ range.setEnd(node, Math.min(idx + searchText.length, node.textContent.length))
+
+ var mark = document.createElement('mark')
+ mark.className = '_comment-highlight'
+ if (comment.severity) mark.classList.add('_severity-' + comment.severity)
+ mark.dataset.commentId = comment.id
+ mark.addEventListener('click', () => {
+ showSidebar()
+ scrollSidebarTo(comment.id)
+ })
+
+ try {
+ range.surroundContents(mark)
+ return true
+ } catch (e) {
+ // Range spans multiple elements (e.g. crosses a //
+ // boundary) — surroundContents() cannot wrap a partial multi-element
+ // range. Caller falls back to highlightAcrossNodes().
+ return false
+ }
+ }
+
+ // Fallback for anchors that span more than one text node/element.
+ // Finds all text nodes under `root` that together contain `searchText`
+ // (allowing it to cross element boundaries) and wraps each node's
+ // matching segment individually, rather than requiring a single
+ // contiguous Range.surroundContents() call.
+ function highlightAcrossNodes (root, searchText, comment) {
+ var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
+ var textNodes = []
+ var node
+ while ((node = walker.nextNode())) textNodes.push(node)
+
+ var combined = textNodes.map((n) => n.textContent).join('')
+ var start = combined.indexOf(searchText)
+ if (start === -1) return false
+ var end = start + searchText.length
+
+ var wrapped = []
+ var pos = 0
+ for (var i = 0; i < textNodes.length; i++) {
+ var n = textNodes[i]
+ var len = n.textContent.length
+ var nodeStart = pos
+ var nodeEnd = pos + len
+ pos += len
+
+ var overlapStart = Math.max(start, nodeStart)
+ var overlapEnd = Math.min(end, nodeEnd)
+ if (overlapStart >= overlapEnd) continue // no overlap with this node
+
+ var localStart = overlapStart - nodeStart
+ var localEnd = overlapEnd - nodeStart
+
+ try {
+ var range = document.createRange()
+ range.setStart(n, localStart)
+ range.setEnd(n, localEnd)
+ var mark = document.createElement('mark')
+ mark.className = '_comment-highlight'
+ if (comment.severity) mark.classList.add('_severity-' + comment.severity)
+ mark.dataset.commentId = comment.id
+ mark.addEventListener('click', () => {
+ showSidebar()
+ scrollSidebarTo(comment.id)
+ })
+ range.surroundContents(mark)
+ wrapped.push(mark)
+ } catch (e) {
+ // A single text-node segment should never throw (it's always a
+ // simple, single-node range), but guard anyway rather than
+ // leaving a partially-wrapped anchor.
+ return wrapped.length > 0
+ }
+ }
+ return wrapped.length > 0
+ }
+
+ // ─── KEYBOARD NAVIGATION ──────────────────────────────────────
+
+ var navIndex = -1
+
+ function navigateComment (direction) {
+ var open = comments.filter((c) => !c.resolved)
+ if (!open.length) return
+ navIndex = (navIndex + direction + open.length) % open.length
+ var c = open[navIndex]
+ scrollToHighlight(c.id)
+ showSidebar()
+ scrollSidebarTo(c.id)
+ }
+
+ // ─── MITHRIL UI ───────────────────────────────────────────────
+ // Everything that renders comment content (body, tag, severity, id,
+ // author, replies, anchor text) goes through Mithril's `m()`, which
+ // treats all non-`m.trust()` children as text nodes and escapes them
+ // by construction. There is no innerHTML template-string surface here
+ // for a stray unescaped field to slip through.
+
+ var uiRoot = null
+
+ function mountUi () {
+ // content/index.js does `m.mount($('body'), {...})`, which means
+ // Mithril owns and fully diffs body's children on every redraw
+ // (theme switch, autoreload, raw toggle, etc). Any DOM node we append
+ // as a child of that isn't part of that app's own vnode tree
+ // gets silently removed on its next redraw. Mount as a sibling of
+ // instead (a child of ) so it's outside that app's
+ // managed subtree entirely.
+ uiRoot = document.createElement('div')
+ uiRoot.id = '_comments-ui-root'
+ document.documentElement.appendChild(uiRoot)
+ m.mount(uiRoot, {view: renderUi})
+ }
+
+ function redraw () {
+ if (uiRoot) m.redraw()
+ }
+
+ function renderUi () {
+ return [
+ renderTooltip(),
+ showingInput && renderCommentInput(),
+ renderSidebarPanel()
+ ]
+ }
+
+ function renderTooltip () {
+ if (!showingTooltip) return null
+ return m('div#_comments-tooltip', {
+ style: {top: showingTooltip.top + 'px', left: showingTooltip.left + 'px'},
+ onclick: () => {
+ showingTooltip = null
+ showCommentInput()
+ }
+ }, '💬 Comment')
+ }
+
+ function showCommentInput () {
+ showingTooltip = null
+ showingInput = true
+ inputDraft = {body: '', tag: '', severity: '', suggestOn: false, suggestion: ''}
+ redraw()
+ }
+
+ function removeCommentInput () {
+ showingInput = false
+ pendingSelection = null
+ redraw()
+ }
+
+ var inputDraft = {body: '', tag: '', severity: '', suggestOn: false, suggestion: ''}
+
+ function renderCommentInput () {
+ if (!pendingSelection) return null
+ var top = pendingSelection.rect.top + 30
+ var label = pendingSelection.text.substring(0, 50) + (pendingSelection.text.length > 50 ? '…' : '')
+
+ return m('div#_comments-input', {style: {top: top + 'px'}},
+ m('div._comments-input-header',
+ m('span', 'Comment on: ', m('em', '"' + label + '"'))
+ ),
+ m('textarea._comments-input-textarea', {
+ rows: 3,
+ placeholder: 'Type your comment...',
+ value: inputDraft.body,
+ oncreate: (vnode) => vnode.dom.focus(),
+ oninput: (e) => { inputDraft.body = e.target.value },
+ onkeydown: (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveNewComment() }
+ if (e.key === 'Escape') removeCommentInput()
+ }
+ }),
+ m('div._comments-input-options',
+ m('select._comments-input-tag', {
+ title: 'Tag (optional)',
+ value: inputDraft.tag,
+ onchange: (e) => { inputDraft.tag = e.target.value }
+ },
+ m('option', {value: ''}, '— tag —'),
+ TAGS.map((t) => m('option', {value: t}, t))
+ ),
+ m('select._comments-input-severity', {
+ title: 'Severity (optional)',
+ value: inputDraft.severity,
+ onchange: (e) => { inputDraft.severity = e.target.value }
+ },
+ m('option', {value: ''}, '— severity —'),
+ SEVERITIES.map((s) => m('option', {value: s}, s))
+ ),
+ m('label._comments-input-suggest-label',
+ m('input[type=checkbox]._comments-input-suggest-toggle', {
+ checked: inputDraft.suggestOn,
+ onchange: (e) => { inputDraft.suggestOn = e.target.checked }
+ }),
+ ' Suggest replacement'
+ )
+ ),
+ inputDraft.suggestOn && m('textarea._comments-input-suggestion', {
+ rows: 2,
+ placeholder: 'Suggested replacement text...',
+ value: inputDraft.suggestion,
+ oncreate: (vnode) => vnode.dom.focus(),
+ oninput: (e) => { inputDraft.suggestion = e.target.value },
+ onkeydown: (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveNewComment() }
+ if (e.key === 'Escape') removeCommentInput()
+ }
+ }),
+ m('div._comments-input-actions',
+ m('button._comments-btn-save', {onclick: saveNewComment}, 'Save (⌘↵)'),
+ m('button._comments-btn-cancel', {onclick: removeCommentInput}, 'Cancel')
+ )
+ )
+ }
+
+ function saveNewComment () {
+ var body = inputDraft.body.trim()
+ if (!body || !pendingSelection) return
+
+ var comment = {
+ id: 'c_' + Date.now() + '_' + Math.random().toString(36).substring(2, 6),
+ anchor: {
+ text: pendingSelection.text,
+ prefix: pendingSelection.prefix,
+ suffix: pendingSelection.suffix,
+ heading: pendingSelection.heading
+ },
+ body: body,
+ author: authorName || null,
+ tag: sanitizeTag(inputDraft.tag) || null,
+ severity: sanitizeSeverity(inputDraft.severity) || null,
+ suggestion: inputDraft.suggestOn ? (inputDraft.suggestion.trim() || null) : null,
+ replies: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: null,
+ resolved: false
+ }
+
+ comments.push(comment)
+ saveComments()
+ removeCommentInput()
+ renderHighlights()
+ redraw()
+ window.getSelection().removeAllRanges()
+ }
+
+ // ─── SIDEBAR ──────────────────────────────────────────────────
+
+ function toggleSidebar () { sidebarVisible ? hideSidebar() : showSidebar() }
+ function showSidebar () { sidebarVisible = true; redraw() }
+ function hideSidebar () { sidebarVisible = false; redraw() }
+
+ function createToggleButton () {
+ var btn = document.createElement('button')
+ btn.id = '_comments-toggle'
+ btn.title = 'Toggle Comments Panel (Ctrl+] / Ctrl+[ to navigate)'
+ btn.textContent = '💬'
+ btn.addEventListener('click', toggleSidebar)
+ // Same reasoning as mountUi(): append outside since the main
+ // app's m.mount($('body'), ...) would otherwise strip this on redraw.
+ document.documentElement.appendChild(btn)
+ }
+
+ function getFilteredComments () {
+ return comments.filter((c) => {
+ if (filters.status === 'open' && c.resolved) return false
+ if (filters.status === 'resolved' && !c.resolved) return false
+ if (filters.tag !== 'all' && c.tag !== filters.tag) return false
+ if (filters.severity !== 'all' && c.severity !== filters.severity) return false
+ if (searchQuery) {
+ var hay = [c.body, c.anchor.text, c.tag || '', c.author || '', c.suggestion || ''].join(' ').toLowerCase()
+ if (!hay.includes(searchQuery)) return false
+ }
+ return true
+ })
+ }
+
+ function renderSidebarPanel () {
+ return m('div#_comments-sidebar', {class: sidebarVisible ? '_visible' : ''},
+ renderSidebarHeader(),
+ renderSidebarFilters(),
+ m('div._comments-sidebar-body', renderSidebarBody())
+ )
+ }
+
+ function renderSidebarHeader () {
+ var openCount = comments.filter((c) => !c.resolved).length
+ return m('div._comments-sidebar-header',
+ m('span._comments-title', 'Comments ',
+ m('span._comments-badge', {style: {display: openCount > 0 ? 'inline' : 'none'}}, String(openCount))
+ ),
+ m('div._comments-sidebar-actions',
+ writeEnabled && m('button._comments-btn-import', {title: 'Import comments from a JSON file', onclick: importComments}, '⬆'),
+ writeEnabled && m('button._comments-btn-resolve-all', {title: 'Resolve all comments', onclick: resolveAll}, '✓ All'),
+ writeEnabled && m('button._comments-btn-delete-all', {title: 'Delete all comments', onclick: deleteAll}, '🗑 All'),
+ m('button._comments-btn-export-json', {title: 'Download comments as structured JSON data file', onclick: exportCommentsJson}, '⬇ JSON'),
+ m('button._comments-btn-export-md', {title: 'Download markdown file with comments embedded as HTML comments', onclick: exportCommentsMd}, '⬇ MD'),
+ m('button._comments-btn-close', {title: 'Close comments panel', onclick: hideSidebar}, '✕')
+ )
+ )
+ }
+
+ function renderSidebarFilters () {
+ return m('div._comments-sidebar-filters',
+ m('input._comments-search[type=text]', {
+ placeholder: 'Search comments...',
+ title: 'Search comment text, tags, authors',
+ value: searchQuery,
+ oninput: (e) => { searchQuery = e.target.value.toLowerCase(); redraw() }
+ }),
+ m('div._comments-filter-row',
+ m('select._comments-filter-status', {
+ title: 'Filter by status',
+ value: filters.status,
+ onchange: (e) => { filters.status = e.target.value; redraw() }
+ },
+ m('option', {value: 'all'}, 'All'),
+ m('option', {value: 'open'}, 'Open'),
+ m('option', {value: 'resolved'}, 'Resolved')
+ ),
+ m('select._comments-filter-tag', {
+ title: 'Filter by tag',
+ value: filters.tag,
+ onchange: (e) => { filters.tag = e.target.value; redraw() }
+ },
+ m('option', {value: 'all'}, 'All tags'),
+ TAGS.map((t) => m('option', {value: t}, t))
+ ),
+ m('select._comments-filter-severity', {
+ title: 'Filter by severity',
+ value: filters.severity,
+ onchange: (e) => { filters.severity = e.target.value; redraw() }
+ },
+ m('option', {value: 'all'}, 'All severity'),
+ SEVERITIES.map((s) => m('option', {value: s}, s))
+ )
+ )
+ )
+ }
+
+ function renderSidebarBody () {
+ if (comments.length === 0) {
+ return m('div._comments-empty',
+ 'No comments yet.', m('br'),
+ 'Select text and click the 💬 tooltip,', m('br'),
+ 'or press ', m('kbd', '⌘⇧K'), ' to add a comment.'
+ )
+ }
+
+ var filtered = getFilteredComments()
+ if (filtered.length === 0) {
+ return m('div._comments-empty', 'No comments match current filters.')
+ }
+
+ return filtered.map(renderCommentItem)
+ }
+
+ function renderCommentItem (c) {
+ var isOrphan = !c.resolved && !anchoredIds.has(c.id)
+ var isEditing = editingId === c.id
+ var isReplying = replyingId === c.id
+
+ return m('div._comments-item', {
+ key: c.id,
+ class: [c.resolved && '_resolved', isOrphan && '_orphan'].filter(Boolean).join(' '),
+ 'data-id': c.id,
+ onclick: () => scrollToHighlight(c.id)
+ },
+ m('div._comments-item-pills',
+ isOrphan && m('span._pill._pill-orphan', {title: 'Anchor text not found in document'}, '⚠️ unanchored'),
+ c.tag && m('span._pill._pill-tag', {class: '_pill-' + c.tag}, c.tag),
+ c.severity && m('span._pill._pill-severity', {class: '_pill-' + c.severity}, c.severity)
+ ),
+ m('div._comments-item-anchor', {title: c.anchor.text},
+ '"' + c.anchor.text.substring(0, 60) + (c.anchor.text.length > 60 ? '…' : '') + '"'
+ ),
+ isOrphan && c.anchor.heading && m('div._comments-item-hint', 'Was near: ' + c.anchor.heading),
+ isEditing ? renderEditBox(c) : m('div._comments-item-body', renderLinkified(c.body)),
+ c.suggestion && m('div._comments-item-suggestion',
+ m('span._suggestion-label', 'Suggestion:'), ' ',
+ m('del', c.anchor.text.substring(0, 80)), ' → ', m('ins', c.suggestion.substring(0, 80))
+ ),
+ c.replies && c.replies.length > 0 && m('div._comments-replies',
+ c.replies.map((r) => m('div._comments-reply', {key: r.id},
+ m('strong', (r.author || 'Anonymous') + ':'), ' ',
+ renderLinkified(r.body), ' ',
+ m('span._comments-reply-date', formatDate(r.createdAt))
+ ))
+ ),
+ isReplying && renderReplyBox(c),
+ m('div._comments-item-meta',
+ m('span._comments-item-date', (c.author ? c.author + ' · ' : '') + formatDate(c.createdAt)),
+ writeEnabled && !isEditing && m('span._comments-item-actions',
+ m('button._comments-btn-reply', {title: 'Reply', onclick: (e) => { e.stopPropagation(); replyingId = replyingId === c.id ? null : c.id; redraw() }}, '↩'),
+ m('button._comments-btn-edit', {title: 'Edit', onclick: (e) => { e.stopPropagation(); editingId = c.id; redraw() }}, '✎'),
+ c.resolved
+ ? m('button._comments-btn-unresolve', {title: 'Reopen', onclick: (e) => { e.stopPropagation(); unresolveComment(c.id) }}, '↺')
+ : m('button._comments-btn-resolve', {title: 'Resolve', onclick: (e) => { e.stopPropagation(); resolveComment(c.id) }}, '✓'),
+ m('button._comments-btn-delete', {title: 'Delete', onclick: (e) => { e.stopPropagation(); deleteComment(c.id) }}, '✕')
+ )
+ )
+ )
+ }
+
+ // Renders text with bare https?:// URLs turned into clickable links.
+ // Operates on plain (unescaped) text and returns an array of Mithril
+ // vnodes/strings — escaping is handled by Mithril itself since none of
+ // the pieces are passed through m.trust().
+ function renderLinkified (text) {
+ var parts = String(text || '').split(/(https?:\/\/[^\s<]+)/g)
+ return parts.map((part, i) =>
+ /^https?:\/\//.test(part)
+ ? m('a', {key: i, href: encodeURI(part), target: '_blank', rel: 'noopener'}, part)
+ : part
+ )
+ }
+
+ function renderEditBox (c) {
+ if (c._draftBody === undefined) c._draftBody = c.body
+ return m('div._comments-item-body',
+ m('textarea._comments-edit-textarea', {
+ value: c._draftBody,
+ oncreate: (vnode) => {
+ vnode.dom.focus()
+ vnode.dom.setSelectionRange(vnode.dom.value.length, vnode.dom.value.length)
+ },
+ oninput: (e) => { c._draftBody = e.target.value },
+ onkeydown: (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); commitEdit(c) }
+ if (e.key === 'Escape') { delete c._draftBody; editingId = null; redraw() }
+ }
+ }),
+ m('div._comments-edit-actions',
+ m('button._comments-edit-save', {onclick: () => commitEdit(c)}, 'Save'),
+ m('button._comments-edit-cancel', {onclick: () => { delete c._draftBody; editingId = null; redraw() }}, 'Cancel')
+ )
+ )
+ }
+
+ function commitEdit (c) {
+ var v = (c._draftBody !== undefined ? c._draftBody : c.body).trim()
+ if (v) { c.body = v; c.updatedAt = new Date().toISOString(); saveComments() }
+ delete c._draftBody
+ editingId = null
+ redraw()
+ }
+
+ function renderReplyBox (c) {
+ if (c._draftReply === undefined) c._draftReply = ''
+ return m('div._comments-reply-input',
+ m('textarea._comments-reply-textarea', {
+ rows: 2,
+ placeholder: 'Reply...',
+ value: c._draftReply,
+ oncreate: (vnode) => vnode.dom.focus(),
+ oninput: (e) => { c._draftReply = e.target.value },
+ onkeydown: (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); commitReply(c) }
+ if (e.key === 'Escape') { delete c._draftReply; replyingId = null; redraw() }
+ }
+ }),
+ m('div._comments-edit-actions',
+ m('button._comments-reply-save', {onclick: () => commitReply(c)}, 'Reply'),
+ m('button._comments-reply-cancel', {onclick: () => { delete c._draftReply; replyingId = null; redraw() }}, 'Cancel')
+ )
+ )
+ }
+
+ function commitReply (c) {
+ var v = (c._draftReply || '').trim()
+ if (v) {
+ if (!c.replies) c.replies = []
+ c.replies.push({
+ id: 'r_' + Date.now() + '_' + Math.random().toString(36).substring(2, 6),
+ author: authorName || null,
+ body: v,
+ createdAt: new Date().toISOString()
+ })
+ saveComments()
+ }
+ delete c._draftReply
+ replyingId = null
+ redraw()
+ }
+
+ function scrollSidebarTo (commentId) {
+ // Wait a tick for Mithril to render the sidebar (e.g. after showSidebar())
+ requestAnimationFrame(() => {
+ var item = document.querySelector('._comments-item[data-id="' + commentId + '"]')
+ if (item) {
+ item.scrollIntoView({behavior: 'smooth', block: 'center'})
+ item.classList.add('_flash')
+ setTimeout(() => item.classList.remove('_flash'), 1000)
+ }
+ })
+ }
+
+ function scrollToHighlight (commentId) {
+ var mark = document.querySelector('mark[data-comment-id="' + commentId + '"]')
+ if (mark) {
+ // Force scroll by computing absolute position and scrolling directly
+ var rect = mark.getBoundingClientRect()
+ var scrollTop = window.pageYOffset || document.documentElement.scrollTop
+ var targetY = rect.top + scrollTop - (window.innerHeight / 2)
+ window.scrollTo({top: targetY, behavior: 'smooth'})
+ mark.classList.remove('_flash')
+ void mark.offsetWidth // force reflow to restart animation
+ mark.classList.add('_flash')
+ setTimeout(() => mark.classList.remove('_flash'), 1000)
+ }
+ }
+
+ // ─── COMMENT OPERATIONS ───────────────────────────────────────
+
+ function resolveComment (id) {
+ var c = comments.find((c) => c.id === id)
+ if (c) { c.resolved = true; saveComments(); renderHighlights(); redraw() }
+ }
+
+ function unresolveComment (id) {
+ var c = comments.find((c) => c.id === id)
+ if (c) { c.resolved = false; saveComments(); renderHighlights(); redraw() }
+ }
+
+ function deleteComment (id) {
+ comments = comments.filter((c) => c.id !== id)
+ saveComments(); renderHighlights(); redraw()
+ }
+
+ function resolveAll () {
+ var openCount = comments.filter((c) => !c.resolved).length
+ if (!openCount) return
+ if (!confirm('Resolve all ' + openCount + ' open comment(s)?')) return
+ comments.forEach((c) => { c.resolved = true })
+ saveComments(); renderHighlights(); redraw()
+ }
+
+ function deleteAll () {
+ if (!comments.length) return
+ if (!confirm('Delete all ' + comments.length + ' comment(s)?')) return
+ comments = []
+ saveComments(); renderHighlights(); redraw()
+ }
+
+ // ─── IMPORT / EXPORT ──────────────────────────────────────────
+
+ function importComments () {
+ var input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.json'
+ input.addEventListener('change', (e) => {
+ var file = e.target.files[0]
+ if (!file) return
+ var reader = new FileReader()
+ reader.onload = () => {
+ try {
+ var data = JSON.parse(reader.result)
+ var imported = data.comments || data
+ if (!Array.isArray(imported)) { alert('Invalid comments file'); return }
+
+ // All imported records pass through sanitizeComment() — untrusted
+ // JSON can otherwise carry crafted id/tag/severity/author values.
+ var sanitized = imported.map(sanitizeComment).filter(Boolean)
+
+ var mode = comments.length > 0
+ ? confirm('Merge with existing comments?\n\nOK = Merge\nCancel = Replace all')
+ : true
+
+ if (mode) {
+ // Merge — skip duplicates by ID
+ var existingIds = new Set(comments.map((c) => c.id))
+ var newOnes = sanitized.filter((c) => !existingIds.has(c.id))
+ comments = comments.concat(newOnes)
+ } else {
+ comments = sanitized
+ }
+
+ saveComments()
+ renderHighlights()
+ redraw()
+ } catch (err) {
+ alert('Failed to parse file: ' + err.message)
+ }
+ }
+ reader.readAsText(file)
+ })
+ input.click()
+ }
+
+ function exportCommentsJson () {
+ var filename = safeExportFilename('.comments.json')
+ chrome.runtime.sendMessage({
+ message: 'comments.export',
+ url: pageUrl,
+ filename: filename
+ })
+ }
+
+ function exportCommentsMd () {
+ var filename = safeExportFilename('.commented.md', true)
+ chrome.runtime.sendMessage({
+ message: 'comments.export-md',
+ url: pageUrl,
+ filename: filename,
+ comments: comments
+ })
+ }
+
+ function safeExportFilename (suffix, stripMd) {
+ var base = 'document'
+ try {
+ var pathParts = new URL(pageUrl).pathname.split('/')
+ base = pathParts[pathParts.length - 1] || 'document'
+ } catch (e) {
+ // Malformed/non-standard pageUrl (e.g. about:blank) — fall back
+ // to a generic filename rather than throwing and breaking export.
+ }
+ if (stripMd) base = base.replace(/\.md$/i, '')
+ return base + suffix
+ }
+
+ // ─── BADGE ────────────────────────────────────────────────────
+
+ function updateBadge () {
+ var count = comments.filter((c) => !c.resolved).length
+ var toggle = document.getElementById('_comments-toggle')
+ if (toggle) {
+ toggle.textContent = count > 0 ? `💬 ${count}` : '💬'
+ }
+ // Extension icon badge
+ chrome.runtime.sendMessage({message: 'comments.badge', count: count})
+ }
+
+ function formatDate (iso) {
+ if (!iso) return ''
+ var d = new Date(iso)
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
+ }
+
+ // ─── BOOT ─────────────────────────────────────────────────────
+
+ var bootAttempts = 0
+ var bootInterval = setInterval(() => {
+ if (document.getElementById('_html') || document.getElementById('_markdown')) {
+ clearInterval(bootInterval)
+ init()
+ return
+ }
+ if (++bootAttempts > 50) { // ~5s cap
+ clearInterval(bootInterval)
+ console.warn('[comments] gave up waiting for #_html/#_markdown to render')
+ }
+ }, 100)
+})()
diff --git a/manifest.chrome.json b/manifest.chrome.json
index 7f52256..8e9f147 100644
--- a/manifest.chrome.json
+++ b/manifest.chrome.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name" : "Markdown Viewer",
- "version" : "5.3",
+ "version" : "5.4",
"description" : "Dark Mode • Themes • Autoreload • Mermaid Diagrams • MathJax • ToC • Syntax Highlighting",
"homepage_url": "https://chromewebstore.google.com/detail/markdown-viewer/ckkdlimhmcjmikdlpkmbgfkaikojcbjk",
@@ -52,7 +52,9 @@
"permissions": [
"storage",
- "scripting"
+ "scripting",
+ "contextMenus",
+ "downloads"
],
"host_permissions": [
diff --git a/popup/index.js b/popup/index.js
index fe01bae..48ae176 100644
--- a/popup/index.js
+++ b/popup/index.js
@@ -65,11 +65,26 @@ var Popup = () => {
mathjax: 'Render MathJax formulas',
mermaid: 'Mermaid diagrams',
syntax: 'Syntax highlighting for fenced code blocks',
+ comments: 'View comments: highlights + sidebar (read-only)',
+ commentsWrite: 'Create comments: ⌘⇧K + selection tooltip (adds on top of View comments)',
+ }
+ },
+ // Optional display-name overrides for content switches whose raw
+ // storage key alone isn't self-explanatory in the UI (e.g. two
+ // keys that only differ by a suffix). Falls back to the raw key
+ // for every other setting via labelFor() below.
+ labels: {
+ content: {
+ comments: 'Show comments',
+ commentsWrite: 'Create comments',
}
},
settings: {}
}
+ var labelFor = (tab, key) =>
+ (state.labels[tab] && state.labels[tab][key]) || key
+
var events = {
tab: (e) => {
state.tab = e.target.hash.replace('#tab-', '')
@@ -290,7 +305,7 @@ var Popup = () => {
onchange: events.content
}),
m('.mdc-switch__background', m('.mdc-switch__knob')),
- m('span.mdc-switch-label', key)
+ m('span.mdc-switch-label', labelFor('content', key))
))
)
)
@@ -402,7 +417,7 @@ var Popup = () => {
onchange: events.content
}),
m('.mdc-switch__background', m('.mdc-switch__knob')),
- m('span.mdc-switch-label', key)
+ m('span.mdc-switch-label', labelFor('content', key))
))
)
),