Skip to content
Open
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
125 changes: 125 additions & 0 deletions COMMENTS-README.md
Original file line number Diff line number Diff line change
@@ -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
<!-- COMMENT: This is a comment -->
<!-- COMMENT [RESOLVED]: This one is already resolved -->
```

These are picked up from the rendered `<pre>` (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 `<code>` or `<a>`) 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 `<!-- COMMENT: ... -->` / `<!-- COMMENT [RESOLVED]: ... -->`
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 |
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
150 changes: 150 additions & 0 deletions background/comments.js
Original file line number Diff line number Diff line change
@@ -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 = '<!-- COMMENT' + status + ': ' + c.body.replace(/--/g, '—') + ' -->'
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)
}
}
3 changes: 3 additions & 0 deletions background/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) => (
Expand All @@ -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()
})()
18 changes: 10 additions & 8 deletions background/inject.js
Original file line number Diff line number Diff line change
@@ -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: [{
Expand All @@ -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: [
Expand All @@ -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
})
Expand Down
13 changes: 13 additions & 0 deletions background/storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ md.storage.defaults = (compilers) => {
mermaid: false,
syntax: true,
toc: false,
comments: true,
commentsWrite: false,
},
origins: {
'file://': {
Expand Down Expand Up @@ -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
}
}
Loading