Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- Relative `.md` links from a leaf page (e.g. `[sibling](./other.md)` in `docs/specs/notif.md`) now resolve to the sibling page (`/specs/other`) instead of a non-existent path nested under the current page (`/specs/notif/other`). Links now follow standard CommonMark semantics — resolved relative to the source file's directory — for both leaf pages and `index.md` directory pages. Links from README/`index.md` homepages (including the `docs/` source-dir prefix case) are unchanged.
- Opening an inline-comment deep link (`#comment-<id>`) no longer leaves the comment thread pinned in the wrong vertical position. The thread in the right-margin column (and the narrow-screen comment popover) could land hundreds of pixels above its highlighted passage and stay there when content above the passage reflowed *after* the thread was positioned — e.g. a web-font swap on first load, or a late-loading image or diagram. Threads now re-align whenever their highlighted passage moves, not only when the article is resized, so they track the highlight through any late layout shift. A normal click was never affected (it happens after the page has settled).
- Opening an inline-comment deep link (`#comment-<id>`) no longer leaves the comment thread pinned in the wrong vertical position. The thread in the right-margin column (and the narrow-screen comment popover) could land hundreds of pixels above its highlighted passage and stay there when content above the passage reflowed _after_ the thread was positioned — e.g. a web-font swap on first load, or a late-loading image or diagram. Threads now re-align whenever their highlighted passage moves, not only when the article is resized, so they track the highlight through any late layout shift. A normal click was never affected (it happens after the page has settled).
- The "Add comment" popover now appears when you select a line's first word by dragging right-to-left and release the mouse to the left of the article — and likewise for any text selection released outside the article body (past its right edge or below it). Previously the popover only showed when the mouse was released inside the article, so a right-to-left first-word selection silently produced nothing.

## [0.1.27] - 2026-06-22

Expand Down Expand Up @@ -53,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Breaking:** `rw serve` and the `rw comment` CLI now both key inline and page comments on the stable `(sectionRef, subpath)` pair instead of the page's URL path, so CLI-created and browser-created comments land on the same key. Relocating or remounting a whole section (its `sectionRef` unchanged) no longer orphans the comments on its pages; a section *rename* or moving a single page *within* a section still changes the key for the affected comments. The `--document` flag on `rw comment add` / `rw comment list` still accepts the URL path and resolves it to the composite key internally. It now also accepts the markdown source file path, with or without the docs-root prefix (e.g. `docs/guide.md` or `guide.md`), mapping it to the page's URL path the same way the server does — so a script or agent can pass the file it just edited directly. Comments created in 0.1.25 (keyed by the old path) are not migrated: they remain in the database but are no longer queried, so they effectively disappear from the UI and CLI — negligible impact given 0.1.25's age.
- **Breaking:** `rw serve` and the `rw comment` CLI now both key inline and page comments on the stable `(sectionRef, subpath)` pair instead of the page's URL path, so CLI-created and browser-created comments land on the same key. Relocating or remounting a whole section (its `sectionRef` unchanged) no longer orphans the comments on its pages; a section _rename_ or moving a single page _within_ a section still changes the key for the affected comments. The `--document` flag on `rw comment add` / `rw comment list` still accepts the URL path and resolves it to the composite key internally. It now also accepts the markdown source file path, with or without the docs-root prefix (e.g. `docs/guide.md` or `guide.md`), mapping it to the page's URL path the same way the server does — so a script or agent can pass the file it just edited directly. Comments created in 0.1.25 (keyed by the old path) are not migrated: they remain in the database but are no longer queried, so they effectively disappear from the UI and CLI — negligible impact given 0.1.25's age.

### Fixed

Expand Down Expand Up @@ -105,14 +106,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `rw serve` no longer fails to start when a project has a `README.md` but no `docs/` directory; the README is served as the homepage and live reload picks up a `docs/` directory created afterwards without a restart.
- Requesting a page that exists in the navigation tree but whose markdown source is missing from storage now returns `404 Not Found` instead of `500 Internal Server Error`.
- Documentation pages whose URL begins with `/api/` (e.g. `docs/api/usage.md`) no longer return 404 when opened directly or refreshed.
- Pages that reference other pages — via `[[wikilinks]]`, cross-section links, or C4 diagram entity includes — no longer keep showing stale content after the *referenced* page changes. The rendered-page cache key now incorporates a fingerprint of cross-page inputs.
- Pages that reference other pages — via `[[wikilinks]]`, cross-section links, or C4 diagram entity includes — no longer keep showing stale content after the _referenced_ page changes. The rendered-page cache key now incorporates a fingerprint of cross-page inputs.
- A transient panic inside `rw serve` (most realistically inside `storage.scan()` during a reload) no longer permanently bricks the server by poisoning the internal reload lock. Reads resume on the previous snapshot and the next reload trigger is honored; the storage layers and file-watcher debouncer got the same hardening.
- `rw serve` no longer keeps serving stale page content after a file change that lands while a previous reload's storage scan is still running — validity is now derived from a monotonic generation stamp, so a change can't be swallowed by an in-flight reload.
- New files created under `docs/` while `rw serve` is running now reliably appear in the navigation sidebar without a manual refresh (the live-reload Created handler no longer races its own invalidation).
- S3 (and other remote storage) outages no longer become "soft outages" where every read serializes on a mutex and re-calls the unreachable backend; a failed background reload keeps serving the stale snapshot and retries only on the next explicit signal.
- Heading anchor IDs are now guaranteed unique within a page (previously a slug colliding with another heading's auto-numbered suffix could emit duplicate `id`s, and a heading with no slug characters produced an empty `id=""`).
- An image inside a heading (e.g. `# ![](icon.png) Project Name`) now renders inside the `<h*>` element instead of escaping before it, fixing the document outline, TOC, and SEO for icon-led titles.
- Formatted image alt text (`![**Logo**](...)`, `` ![Press `Enter`](...) ``) no longer leaks empty inline tags next to the `<img>`, and inline code inside alt text now contributes to the rendered `alt` attribute instead of disappearing.
- Formatted image alt text (`![**Logo**](...)`, ``![Press `Enter`](...)``) no longer leaks empty inline tags next to the `<img>`, and inline code inside alt text now contributes to the rendered `alt` attribute instead of disappearing.
- Long URLs and other unbreakable tokens (UUIDs, hash digests, file paths) in tables, paragraphs, and list items now wrap instead of forcing horizontal scrolling on narrow viewports.
- Directives with non-ASCII characters in their attribute braces (e.g. `:foo[bar]{цвет}`, `{🎉}`) no longer panic the renderer; valid uses such as `{.заголовок}`, `{#заголовок}`, and `{цвет=зелёный}` were already safe and remain unchanged.
- Inline directive syntax (`:name[…]`) inside an inline code span, indented code block, or raw inline HTML is no longer expanded, so documentation can demonstrate `:status[…]` and other directives as code.
Expand Down
101 changes: 101 additions & 0 deletions packages/viewer/e2e/comment-selection-outside-article.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { test, expect, Page } from "@playwright/test";

// Wide viewport so the article has empty gutters on both sides.
test.use({ viewport: { width: 1400, height: 800 } });

/**
* Drag-select with the REAL mouse from (x1,y1) to (x2,y2). Unlike the synthetic
* `selectText` helper in comments.spec.ts (which dispatches a mouseup directly on
* <article>), this fires native events, so the mouseup lands wherever the pointer
* is released — including outside the article. That is the only way to reproduce
* the "release in the gutter" bug.
*/
async function dragSelect(page: Page, x1: number, y1: number, x2: number, y2: number) {
await page.mouse.move(x1, y1);
await page.mouse.down();
// Multiple steps emit intermediate mousemove events so the browser registers
// a drag-selection gesture rather than a single jump.
await page.mouse.move(x2, y2, { steps: 8 });
await page.mouse.up();
}

/** Viewport-coordinate rect of the first occurrence of `text` in the article. */
async function rectOf(page: Page, text: string) {
return page.evaluate((target) => {
const article = document.querySelector("article");
if (!article) throw new Error("no article");
const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const idx = node.data.indexOf(target);
if (idx === -1) continue;
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + target.length);
const r = range.getBoundingClientRect();
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
}
throw new Error(`"${target}" not found in article`);
}, text);
}

async function articleLeft(page: Page): Promise<number> {
return page.evaluate(() => {
const article = document.querySelector("article");
if (!article) throw new Error("no article");
return article.getBoundingClientRect().left;
});
}

/**
* Right-to-left drag-select of the homepage's first word, releasing the mouse
* just past the article's left edge (outside <article>) — the gutter-release
* gesture that reproduces the bug. Depends on the homepage fixture
* (e2e/fixtures/docs/index.md) starting its first paragraph with "Welcome".
*/
async function selectFirstWordReleasingInGutter(page: Page) {
const word = await rectOf(page, "Welcome");
const aLeft = await articleLeft(page);
// Guard the premise: at this viewport the article must have a left gutter, or
// the release point would land inside the article and the test would silently
// stop exercising the outside-release case.
expect(aLeft).toBeGreaterThan(20);
const y = (word.top + word.bottom) / 2;

// Start at the word's right edge and drag LEFT just past the article's edge
// into the empty gutter, releasing there (the mouseup target is outside
// <article>). The 10px offset keeps us in the empty padding, not adjacent
// sidebar text, so the selection stays within the article (the first word).
await dragSelect(page, word.right, y, aLeft - 10, y);
}

test.describe("Selection released outside the article", () => {
test("right-to-left drag of a line's first word still shows the Add comment popover", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("article").waitFor();

await selectFirstWordReleasingInGutter(page);

await expect(page.getByRole("button", { name: "Add comment" })).toBeVisible();
});

test("clicking the Add comment button after such a selection opens the draft", async ({
page,
}) => {
await page.goto("/");
await page.getByRole("article").waitFor();

await selectFirstWordReleasingInGutter(page);

const button = page.getByRole("button", { name: "Add comment" });
await expect(button).toBeVisible();

// Real click — exercises the button's mousedown (preventDefault keeps the
// selection alive) and click. The draft composer opens in the comments sidebar.
await button.click();
const sidebar = page.getByRole("complementary", { name: "Comments" });
await expect(sidebar.getByPlaceholder("Write a comment...")).toBeVisible();
});
});
42 changes: 29 additions & 13 deletions packages/viewer/src/components/PageContent.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,16 @@
);
});

// Text-selection state for the Add-comment popover. The hook owns the
// captured Range, the article-relative anchor point, and dismiss-on-collapse.
const selectionPopover = useSelectionPopover(() => articleRef ?? null, articleSize);
// Text-selection state for the Add-comment popover. The hook watches the
// document for selections itself — capturing on a document-level mouseup
// (gated on comments being enabled) and dismissing on collapse — so a
// selection released outside the article still opens the popover. It owns the
// captured Range and the article-relative anchor point.
const selectionPopover = useSelectionPopover(
() => articleRef ?? null,
articleSize,
() => comments.enabled,
);

// Drop any in-flight selection when the article content changes (live reload,
// navigation). The cached Range's start/end nodes get detached, so its rect
Expand Down Expand Up @@ -457,17 +464,16 @@
function handleMouseUp(event: MouseEvent) {
const selection = window.getSelection();

// If no text selected, check if click landed on a comment highlight
if (!selection || selection.isCollapsed) {
selectionPopover.clear();
// Non-collapsed selections are captured by the hook's document-level mouseup
// listener (which also catches releases outside the article), so this
// handler only deals with a collapsed click.
if (selection && !selection.isCollapsed) return;

// Toggle: click an inactive highlight to activate, click the active one to dismiss.
const hitId = findCommentAtPoint(event);
if (hitId) comments.activeId = hitId === comments.activeId ? null : hitId;
return;
}
selectionPopover.clear();

selectionPopover.capture(selection.getRangeAt(0));
// Toggle: click an inactive highlight to activate, click the active one to dismiss.
const hitId = findCommentAtPoint(event);
if (hitId) comments.activeId = hitId === comments.activeId ? null : hitId;
}

function handleMouseMove(event: MouseEvent) {
Expand Down Expand Up @@ -649,7 +655,17 @@
y={selectionPopover.pos.y - 8}
class="-translate-x-1/2 -translate-y-full"
>
<IconButton aria-label="Add comment" class="shadow-lg" onclick={handleAddComment}>
<!--
preventDefault on mousedown keeps the live text selection (and focus)
intact when the button is pressed, so handleAddComment still reads the
selection on click and the popover isn't torn down between down and up.
-->
<IconButton
aria-label="Add comment"
class="shadow-lg"
onmousedown={(e) => e.preventDefault()}
onclick={handleAddComment}
>
<svg
class="size-4"
fill="none"
Expand Down
Loading
Loading