diff --git a/.scratch/browser-annotation-v2/PRD.md b/.scratch/browser-annotation-v2/PRD.md
index 9650018..0440907 100644
--- a/.scratch/browser-annotation-v2/PRD.md
+++ b/.scratch/browser-annotation-v2/PRD.md
@@ -92,6 +92,7 @@ Codex 默认是 Enter 直发、`Cmd+Enter` 攒(#22719 下大量反对);Pac
- 区域不「占有」锚点:点击锚点元素仍新建元素评论,多个区域可共享同一锚点。
- 拖动中 Esc / `pointercancel` / 松开在窗口外(`buttons === 0`)都取消框选,随后的 click 被吞;任一边小于 4px 视为误触。已保存区域用虚线轮廓,元素用实线。模式标签改为「Click or drag to annotate · Esc to exit」。
- 格式化:区域条目标题为 `#n [screenshot] area in \`selector\` (tag)`,**总是**输出 `- area: W×H at (x, y)`(截图有外扩边距、无框线,模型需要确切边界);composer chip 为 `#n Area · 摘要`。
+- (#455 review 修订)`area` 的偏移按锚点滚动内容计:保存时加上锚点 `scrollLeft`/`scrollTop`(锚点为文档滚动元素时除外,其 rect 已随页面滚动),否则锚点自身滚动内容时轮廓会漂。带截图的区域条目同时给出截图内坐标与视口坐标(`- area: W×H at (sx, sy) in its screenshot; (x, y) in the viewport`),截图按 `resolveCommentCropRect`(已移入 `packages/core`)的同样规则裁出。
## 切片
diff --git a/apps/desktop/electron/browser-annotation-overlay.test.ts b/apps/desktop/electron/browser-annotation-overlay.test.ts
index 0d91ac1..194e433 100644
--- a/apps/desktop/electron/browser-annotation-overlay.test.ts
+++ b/apps/desktop/electron/browser-annotation-overlay.test.ts
@@ -1624,4 +1624,106 @@ describe("annotation overlay — area selection", () => {
vi.useRealTimers();
}
});
+
+ it("stores the anchor's own scroll in the offset when the area is saved", async () => {
+ const h = harness();
+
+ document.body.innerHTML =
+ '
';
+ const box = place(document.getElementById("scrollbox")!, 40, 40, 200, 200);
+ const start = place(document.getElementById("start")!, 50, 50, 20, 10);
+ vi.spyOn(box, "scrollTop", "get").mockReturnValue(100);
+
+ h.overlay.setDesignMode(true);
+ drag(start, 60, 60, 160, 110);
+ typeDraft(h, "inside the scroller");
+ pressKey(editorInput(h), "Enter");
+ await nextFrames();
+
+ // The box does not move when its content scrolls, so the offset has to
+ // carry scrollTop to keep naming the same content.
+ expect(h.saved[0]!.annotation).toMatchObject({
+ selector: "#scrollbox",
+ rect: { x: 60, y: 60, width: 100, height: 50 },
+ area: { x: 20, y: 120, width: 100, height: 50 },
+ });
+ });
+
+ it("moves the outline with the content when the anchor scrolls its content", async () => {
+ const h = harness();
+
+ document.body.innerHTML = '';
+ const box = place(document.getElementById("scrollbox")!, 40, 40, 200, 200);
+ const scrollTop = vi.spyOn(box, "scrollTop", "get").mockReturnValue(100);
+
+ // area.y = 120 in content coords: 40 + 120 - 100 = 60 on screen.
+ h.overlay.syncAnnotations([
+ {
+ ...areaAnnotation("srv-a", 1, "#scrollbox"),
+ tag: "div",
+ rect: { x: 50, y: 60, width: 40, height: 20 },
+ area: { x: 10, y: 120, width: 40, height: 20 },
+ },
+ ]);
+
+ const outline = h.shadow().querySelector(
+ '[data-slot="annotation-outline"]',
+ )!;
+ expect(outline.style.top).toBe("60px");
+
+ // The anchor's rect is unchanged but its content moved: a mark pinned
+ // to the box alone would slide over different content.
+ scrollTop.mockReturnValue(130);
+ window.dispatchEvent(new Event("scroll"));
+ await nextFrames();
+
+ expect(outline.style.top).toBe("30px");
+ });
+
+ it("lets a badge open after a drag's trailing click landed on it", async () => {
+ const h = harness();
+ place(document.querySelector("main")!, 0, 0, 600, 400);
+ const button = place(document.getElementById("cta")!);
+
+ h.overlay.setDesignMode(true);
+ await annotate(h, button, "saved mark");
+ const badge = badges(h)[0]!;
+
+ // A drag released under the area threshold, with its trailing click
+ // landing on a saved badge: the badge swallows it — and the swallow
+ // clears the flag itself, so the next click is not eaten.
+ pointer(button, "pointerdown", 50, 50, { button: 0, buttons: 1 });
+ pointer(button, "pointermove", 200, 150, { buttons: 1 });
+ pointer(button, "pointerup", 200, 52, { button: 0, buttons: 0 });
+ badge.click();
+
+ expect(editorAbsent(h)).toBe(true);
+
+ badge.click();
+
+ expect(editorVisible(h)).toBe(true);
+ expect(editorInput(h).value).toBe("saved mark");
+ });
+
+ it("re-arms the click swallow on a pointerdown over overlay chrome", async () => {
+ const h = harness();
+ place(document.querySelector("main")!, 0, 0, 600, 400);
+ const button = place(document.getElementById("cta")!);
+
+ h.overlay.setDesignMode(true);
+ await annotate(h, button, "saved mark");
+ const badge = badges(h)[0]!;
+
+ // Same dead drag, but the next gesture starts on the badge itself: a
+ // pointerdown over overlay chrome must still reset the swallow — the
+ // page-target early return cannot be where it is re-armed.
+ pointer(button, "pointerdown", 50, 50, { button: 0, buttons: 1 });
+ pointer(button, "pointermove", 200, 150, { buttons: 1 });
+ pointer(button, "pointerup", 200, 52, { button: 0, buttons: 0 });
+ pointer(badge, "pointerdown", 0, 0, { button: 0, buttons: 1 });
+ badge.click();
+
+ expect(editorVisible(h)).toBe(true);
+ expect(editorInput(h).value).toBe("saved mark");
+ });
});
diff --git a/apps/desktop/electron/browser-annotation-overlay.ts b/apps/desktop/electron/browser-annotation-overlay.ts
index 1e81d57..9f00a7e 100644
--- a/apps/desktop/electron/browser-annotation-overlay.ts
+++ b/apps/desktop/electron/browser-annotation-overlay.ts
@@ -1,4 +1,5 @@
import {
+ anchorScroll,
describeAnnotatedArea,
describeAnnotatedElement,
maxCommentLength,
@@ -870,16 +871,19 @@ export function createAnnotationOverlay(options: {
* The mark's viewport rect: the element's own box, or for an area the box
* the saved offset lands at from wherever the anchor re-measures — what
* keeps a region pinned to its place inside a scrolled or reflowed anchor.
+ * Offsets are stored in the anchor's scrolled-content coordinates, so its
+ * own scroll comes back out here.
*/
function markedRect(
element: Element,
area: BrowserAnnotationElement["area"] | null,
): ViewportRect {
const anchor = element.getBoundingClientRect();
+ const scroll = area ? anchorScroll(element) : { x: 0, y: 0 };
return toViewportRect(
- anchor.left + (area?.x ?? 0),
- anchor.top + (area?.y ?? 0),
+ anchor.left + (area?.x ?? 0) - scroll.x,
+ anchor.top + (area?.y ?? 0) - scroll.y,
area?.width ?? anchor.width,
area?.height ?? anchor.height,
);
@@ -1111,8 +1115,10 @@ export function createAnnotationOverlay(options: {
styleBadge(badge);
badge.addEventListener("click", () => {
// A drag releasing over a badge fires this too — its click is
- // swallowed like the one that lands on page elements.
+ // swallowed like the one that lands on page elements. The flag is
+ // consumed, not held: it guards only this one trailing click.
if (suppressClick) {
+ suppressClick = false;
return;
}
const entry = entries.get(annotation.id);
@@ -1651,6 +1657,13 @@ export function createAnnotationOverlay(options: {
return;
}
+ // A new press re-arms the trailing-click swallow even when it lands on
+ // our own chrome — the early return below must not leave a stale flag
+ // eating the badge's next click.
+ if (event.type === "pointerdown") {
+ suppressClick = false;
+ }
+
const target = pageTarget(event);
// Our own badges and editor have to keep working, so their events are
@@ -1665,8 +1678,7 @@ export function createAnnotationOverlay(options: {
if (event.type === "pointerdown") {
// A press on a page target arms the drag: whether it stays a click is
- // the next pointermove's call. The trailing-click swallow re-arms too.
- suppressClick = false;
+ // the next pointermove's call.
const pointer = event as MouseEvent;
if (pointer.button === 0) {
dragStart = { x: pointer.clientX, y: pointer.clientY, target };
@@ -1730,10 +1742,13 @@ export function createAnnotationOverlay(options: {
const anchor = resolveAreaAnchor(start.target, rect);
const anchorRect = anchor.getBoundingClientRect();
+ // Scrolled-content coordinates, same rule as describeAnnotatedArea: the
+ // anchor's own scroll belongs to the offset, not the box position.
+ const scroll = anchorScroll(anchor);
requestEditor(anchor, undefined, {
- x: rect.left - anchorRect.left,
- y: rect.top - anchorRect.top,
+ x: rect.left - anchorRect.left + scroll.x,
+ y: rect.top - anchorRect.top + scroll.y,
width: rect.width,
height: rect.height,
});
diff --git a/apps/desktop/electron/browser-annotation.test.ts b/apps/desktop/electron/browser-annotation.test.ts
index ce4452d..49ed20b 100644
--- a/apps/desktop/electron/browser-annotation.test.ts
+++ b/apps/desktop/electron/browser-annotation.test.ts
@@ -220,6 +220,78 @@ describe("describeAnnotatedArea", () => {
// The anchor's text would describe the element, not the marked region.
expect(annotation).not.toHaveProperty("text");
});
+
+ it("counts the anchor's own scroll in the offset — the mark tracks content, not the box", () => {
+ mount(
+ '',
+ );
+ const anchor = document.getElementById("scrollbox")!;
+ vi.spyOn(anchor, "getBoundingClientRect").mockReturnValue({
+ x: 40,
+ y: 40,
+ left: 40,
+ top: 40,
+ width: 200,
+ height: 200,
+ right: 240,
+ bottom: 240,
+ toJSON: () => ({}),
+ } as DOMRect);
+ vi.spyOn(anchor, "scrollTop", "get").mockReturnValue(120);
+ vi.spyOn(anchor, "scrollLeft", "get").mockReturnValue(10);
+
+ // A scrolled anchor's border box does not move — without the scroll
+ // term the offset would point at different content after every scroll.
+ const annotation = describeAnnotatedArea(
+ anchor,
+ { left: 60, top: 90, width: 50, height: 30 },
+ 1,
+ "a1",
+ );
+
+ expect(annotation.area).toEqual({ x: 30, y: 170, width: 50, height: 30 });
+ });
+
+ it("skips the scroll term when the anchor is the document's scrolling element", () => {
+ // The scrolling element's rect already moves with the page scroll, so
+ // adding scrollTop would count it twice. jsdom leaves
+ // document.scrollingElement unimplemented — it is mocked directly.
+ const anchor = document.documentElement;
+ vi.spyOn(anchor, "getBoundingClientRect").mockReturnValue({
+ x: 0,
+ y: -100,
+ left: 0,
+ top: -100,
+ width: 800,
+ height: 800,
+ right: 800,
+ bottom: 700,
+ toJSON: () => ({}),
+ } as DOMRect);
+ vi.spyOn(anchor, "scrollTop", "get").mockReturnValue(100);
+ const own = Object.getOwnPropertyDescriptor(document, "scrollingElement");
+ Object.defineProperty(document, "scrollingElement", {
+ configurable: true,
+ value: anchor,
+ });
+
+ try {
+ const annotation = describeAnnotatedArea(
+ anchor,
+ { left: 10, top: 20, width: 50, height: 30 },
+ 1,
+ "a1",
+ );
+
+ expect(annotation.area).toEqual({ x: 10, y: 120, width: 50, height: 30 });
+ } finally {
+ if (own) {
+ Object.defineProperty(document, "scrollingElement", own);
+ } else {
+ Reflect.deleteProperty(document, "scrollingElement");
+ }
+ }
+ });
});
describe("acceptBrowserAnnotationMessage", () => {
diff --git a/apps/desktop/electron/browser-annotation.ts b/apps/desktop/electron/browser-annotation.ts
index 64c1ff7..3bb92a9 100644
--- a/apps/desktop/electron/browser-annotation.ts
+++ b/apps/desktop/electron/browser-annotation.ts
@@ -306,12 +306,25 @@ export function describeAnnotatedElement(
};
}
+/**
+ * How much of an anchor's scrolled content sits hidden above-left of its
+ * border box: its own scroll offsets — except for the document's scrolling
+ * element, whose box already rides the page scroll, so adding its scrollTop
+ * would count the same scroll twice.
+ */
+export function anchorScroll(anchor: Element) {
+ return anchor === anchor.ownerDocument.scrollingElement
+ ? { x: 0, y: 0 }
+ : { x: anchor.scrollLeft, y: anchor.scrollTop };
+}
+
/**
* An area comment: `selector`/`tag`/`source` describe the anchor — the
* smallest element fully containing the dragged rect — while `rect` is the
- * area's own viewport box and `area` its offset from the anchor's, so a
- * restore can re-derive the rect from wherever the anchor re-measures. No
- * `text`: the anchor's content would describe the element, not the region.
+ * area's own viewport box and `area` its offset from the anchor's border
+ * box in scrolled-content coordinates (`anchorScroll`, so the mark tracks
+ * the content, not the box). No `text`: the anchor's content would describe
+ * the element, not the region.
*/
export function describeAnnotatedArea(
anchor: Element,
@@ -320,6 +333,7 @@ export function describeAnnotatedArea(
id: string,
): BrowserAnnotationElement {
const anchorRect = anchor.getBoundingClientRect();
+ const scroll = anchorScroll(anchor);
const source = readSource(anchor);
return {
@@ -334,8 +348,8 @@ export function describeAnnotatedArea(
height: Math.round(areaRect.height),
},
area: {
- x: Math.round(areaRect.left - anchorRect.left),
- y: Math.round(areaRect.top - anchorRect.top),
+ x: Math.round(areaRect.left - anchorRect.left + scroll.x),
+ y: Math.round(areaRect.top - anchorRect.top + scroll.y),
width: Math.round(areaRect.width),
height: Math.round(areaRect.height),
},
diff --git a/apps/desktop/electron/browser-host.test.ts b/apps/desktop/electron/browser-host.test.ts
index e16a5eb..b4dc6e6 100644
--- a/apps/desktop/electron/browser-host.test.ts
+++ b/apps/desktop/electron/browser-host.test.ts
@@ -14,7 +14,6 @@ import {
maxSessionComments,
normalizeBrowserUrl,
resolveBrowserViewBounds,
- resolveCommentCropRect,
urlKey,
type BrowserHostView,
} from "./browser-host";
@@ -1258,40 +1257,4 @@ describe("urlKey", () => {
});
});
-describe("resolveCommentCropRect", () => {
- it("expands the element by 48px and grows to the minimum size", () => {
- expect(
- resolveCommentCropRect(
- { x: 400, y: 300, width: 40, height: 20 },
- { width: 1440, height: 900 },
- ),
- ).toEqual({ x: 260, y: 210, width: 320, height: 200 });
- });
-
- it("keeps a large element's expanded bounds", () => {
- expect(
- resolveCommentCropRect(
- { x: 400, y: 300, width: 500, height: 400 },
- { width: 1440, height: 900 },
- ),
- ).toEqual({ x: 352, y: 252, width: 596, height: 496 });
- });
- it("shifts an off-edge crop inside before shrinking it", () => {
- expect(
- resolveCommentCropRect(
- { x: 1380, y: 10, width: 40, height: 20 },
- { width: 1440, height: 900 },
- ),
- ).toEqual({ x: 1120, y: 0, width: 320, height: 200 });
- });
-
- it("clips a crop bigger than the viewport to it", () => {
- expect(
- resolveCommentCropRect(
- { x: 100, y: 100, width: 900, height: 800 },
- { width: 400, height: 300 },
- ),
- ).toEqual({ x: 0, y: 0, width: 400, height: 300 });
- });
-});
diff --git a/apps/desktop/electron/browser-host.ts b/apps/desktop/electron/browser-host.ts
index bb4eb8b..50945b3 100644
--- a/apps/desktop/electron/browser-host.ts
+++ b/apps/desktop/electron/browser-host.ts
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
+import { resolveCommentCropRect } from "@pace/core";
import type {
BrowserAnnotationElement,
BrowserAnnotationPalette,
@@ -192,52 +193,6 @@ export function urlKey(url: string) {
/** Comments a Session may keep at once — beyond it, saves are ignored. */
export const maxSessionComments = 200;
-/**
- * The crop saved with a comment: the element plus enough surroundings to
- * read, grown to a useful minimum, kept inside the viewport it was measured
- * in. Shifted before clipped — a crop near an edge keeps its full size if it
- * can be moved inside rather than shrunk.
- */
-export function resolveCommentCropRect(
- rect: { x: number; y: number; width: number; height: number },
- viewport: { width: number; height: number },
-): BrowserViewRect {
- const margin = 48;
- let x = rect.x - margin;
- let y = rect.y - margin;
- let width = rect.width + margin * 2;
- let height = rect.height + margin * 2;
-
- if (width < 320) {
- x -= (320 - width) / 2;
- width = 320;
- }
- if (height < 200) {
- y -= (200 - height) / 2;
- height = 200;
- }
-
- if (width <= viewport.width) {
- x = clamp(x, 0, viewport.width - width);
- } else {
- x = 0;
- width = viewport.width;
- }
- if (height <= viewport.height) {
- y = clamp(y, 0, viewport.height - height);
- } else {
- y = 0;
- height = viewport.height;
- }
-
- return {
- x: Math.round(x),
- y: Math.round(y),
- width: Math.round(width),
- height: Math.round(height),
- };
-}
-
export type BrowserHostSession = {
setPermissionRequestHandler(handler: (permission: string) => boolean): void;
setPermissionCheckHandler(handler: (permission: string) => boolean): void;
diff --git a/docs/adr/0029-embedded-browser-surface.md b/docs/adr/0029-embedded-browser-surface.md
index 8a18ede..f5fd10b 100644
--- a/docs/adr/0029-embedded-browser-surface.md
+++ b/docs/adr/0029-embedded-browser-surface.md
@@ -71,7 +71,7 @@ ADR-0013 把「内嵌浏览器 + DOM 标注」定为切换 Electron 外壳的承
- **评论仓归主进程、Session 级**:`browser-host.ts` 每 Session 持一个评论仓,仓内顺序即 `#n`(读时重算);每条 `BrowserComment` = 标注字段 + `tabId` / `url` / `title` / `viewport` / `stale` / `hasImage` / `createdAt`。上限 200 条(`maxSessionComments`),超出拒收并发 `comment-rejected`。新评论保存时 overlay 整层隐藏两帧再上报,主进程随即 `capturePage` 裁局部截图:标记矩形外扩 48 CSS px、最小 320×200、先平移再夹进视口;图片不进状态广播,按 id 由 `browser_comment_images` 取。关闭 tab 只移除页上标记,评论仍在仓里;关窗/退出即丢,不落盘。命令:`browser_list_comments`、`browser_settle_comments`(等在途截图落定再读,发送用它)、`browser_comment_images`、`browser_delete_comment`、`browser_consume_comments`;`browser_clear_annotations` 只清当前 tab 的评论。事件:`comments-changed`(带 revision,旧答复丢弃)、`comment-rejected`、`submit-requested`。composer 订阅读取,不持有第二份副本。
- **跨文档保留**:新文档 `ready` 与 `did-navigate-in-page`(SPA 路由切换)时,主进程下发 `sync-annotations`:本 tab 中 URL 键(origin + pathname + search,忽略 hash)相同的评论。overlay 用 selector 唯一解析元素,解析不到时用 MutationObserver 再等最多 5s,仍失败上报 `stale`;stale 评论仍可发送(文本与保存时的截图仍有效)。页上只显示当前 URL 的标记,其他页面的评论只在 composer 里。
- **覆盖层交互**:点击即打开已聚焦的草稿编辑器(textarea),评论必填,保存才成为标注;点已保存的元素或徽章进入编辑(带 Delete),不产生重复;一次只有一个编辑器,有未保存内容时点别处编辑器抖动而不切走。目标收敛:跳过行内装饰节点、SVG 内部与小于 8×8 的元素,优先最近的交互控件;`↑` / `↓` 沿祖先链手动微调。悬停标签 `tag.firstClass · W×H`(有 source 时加 `file:line`)。已保存标注画细轮廓 + 外侧右上角编号徽章,相邻徽章重叠时错开。模式可感知:视口内侧 2px 主题色描边 + 顶部居中标签「Click or drag to annotate · Esc to exit」。颜色取渲染层语义 token,随 `set-design-mode` / `set-annotation-palette` 下发,overlay 用 `CSS.supports` 逐个校验、不合法的回落内置默认。工具条按钮名 `Annotate`。
-- **区域框选**:标注模式下按住拖动超过 4px 进入框选,松开打开编辑器;任一边小于 4px 视为误触;Esc / pointercancel / 窗口外松开取消并吞掉随后的 click;不拦截滚动。`BrowserAnnotationElement.area`(相对锚点边框盒左上角的偏移,CSS px)存在即区域评论,`rect` 为保存时区域的视口矩形;锚点取按下点祖先链上第一个完整包含矩形且非 SVG 内部的元素,兜底 `body`;区域不带 `text`、不占有锚点(点锚点仍新建元素评论,多个区域可共享锚点);已保存区域画虚线轮廓。格式化无论有无截图都输出 `- area: W×H at (x, y)`。
+- **区域框选**:标注模式下按住拖动超过 4px 进入框选,松开打开编辑器;任一边小于 4px 视为误触;Esc / pointercancel / 窗口外松开取消并吞掉随后的 click;不拦截滚动。`BrowserAnnotationElement.area`(相对锚点边框盒左上角、按其滚动内容计的偏移,CSS px——保存时加上锚点 `scrollLeft`/`scrollTop`,锚点为文档滚动元素时除外)存在即区域评论,`rect` 为保存时区域的视口矩形;锚点取按下点祖先链上第一个完整包含矩形且非 SVG 内部的元素,兜底 `body`;区域不带 `text`、不占有锚点(点锚点仍新建元素评论,多个区域可共享锚点);已保存区域画虚线轮廓。格式化总输出 `- area:` 行:带截图时同时给出截图内坐标与视口坐标(`- area: W×H at (sx, sy) in its screenshot; (x, y) in the viewport`),无截图时只给视口坐标。
- **键位**:
| 位置 | 键 | 行为 |
diff --git a/packages/core/src/browser-annotation.test.ts b/packages/core/src/browser-annotation.test.ts
index 87e41bd..5588891 100644
--- a/packages/core/src/browser-annotation.test.ts
+++ b/packages/core/src/browser-annotation.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
formatBrowserComments,
+ resolveCommentCropRect,
type BrowserComment,
} from "./browser-annotation";
@@ -138,9 +139,10 @@ describe("formatBrowserComments", () => {
"#1 [screenshot] area in `#panel` (section)",
" move this down",
" - source: `src/hero.tsx:4`",
- // The crop has margins and no drawn box, so the bounds are always
- // printed — screenshot or not — and never as an element `rect:`.
- " - area: 60×30 at (36, 352)",
+ // The crop has margins and no drawn box, so the bounds are printed
+ // in the screenshot's own coordinates as well as the viewport's —
+ // the crop here was shifted by the left edge to (0, 267, 320, 200).
+ " - area: 60×30 at (36, 85) in its screenshot; (36, 352) in the viewport",
"",
"#2 area in `#panel` (section)",
" no shot",
@@ -149,6 +151,34 @@ describe("formatBrowserComments", () => {
);
});
+ it("subtracts a crop the viewport edge shifted, not the margin it asked for", () => {
+ expect(
+ formatBrowserComments([
+ comment({
+ index: 1,
+ hasImage: true,
+ viewport: { width: 400, height: 600, dpr: 1 },
+ area: { x: 4, y: 300, width: 40, height: 30 },
+ rect: { x: 10, y: 400, width: 40, height: 30 },
+ comment: "edge",
+ }),
+ ]),
+ ).toBe(
+ [
+ "Browser comments from the embedded preview. A comment marked [screenshot] has a cropped screenshot of its element attached; those screenshots are the last images in this message, in comment order.",
+ "",
+ "Page: Alpha — http://localhost:5173/a",
+ "Viewport: 400×600 @1x",
+ "",
+ "#1 [screenshot] area in `#cta` (button)",
+ " edge",
+ // The crop wanted (-130, 315) but the edge pushed it to (0, 315):
+ // in-screenshot is the rect minus what was actually captured.
+ " - area: 40×30 at (10, 85) in its screenshot; (10, 400) in the viewport",
+ ].join("\n"),
+ );
+ });
+
it("prints the page without a title and a comment without text", () => {
expect(
formatBrowserComments([
@@ -168,3 +198,41 @@ describe("formatBrowserComments", () => {
);
});
});
+
+describe("resolveCommentCropRect", () => {
+ it("expands the element by 48px and grows to the minimum size", () => {
+ expect(
+ resolveCommentCropRect(
+ { x: 400, y: 300, width: 40, height: 20 },
+ { width: 1440, height: 900 },
+ ),
+ ).toEqual({ x: 260, y: 210, width: 320, height: 200 });
+ });
+
+ it("keeps a large element's expanded bounds", () => {
+ expect(
+ resolveCommentCropRect(
+ { x: 400, y: 300, width: 500, height: 400 },
+ { width: 1440, height: 900 },
+ ),
+ ).toEqual({ x: 352, y: 252, width: 596, height: 496 });
+ });
+
+ it("shifts an off-edge crop inside before shrinking it", () => {
+ expect(
+ resolveCommentCropRect(
+ { x: 1380, y: 10, width: 40, height: 20 },
+ { width: 1440, height: 900 },
+ ),
+ ).toEqual({ x: 1120, y: 0, width: 320, height: 200 });
+ });
+
+ it("clips a crop bigger than the viewport to it", () => {
+ expect(
+ resolveCommentCropRect(
+ { x: 100, y: 100, width: 900, height: 800 },
+ { width: 400, height: 300 },
+ ),
+ ).toEqual({ x: 0, y: 0, width: 400, height: 300 });
+ });
+});
diff --git a/packages/core/src/browser-annotation.ts b/packages/core/src/browser-annotation.ts
index f5cab7c..2231913 100644
--- a/packages/core/src/browser-annotation.ts
+++ b/packages/core/src/browser-annotation.ts
@@ -38,9 +38,12 @@ export type BrowserAnnotationElement = {
rect: { x: number; y: number; width: number; height: number };
/**
* Present only for an area comment: the dragged rectangle relative to the
- * anchor element's border-box top-left, in CSS px. `selector`/`tag`/`source`
- * then describe the anchor (smallest element fully containing the area);
- * `rect` is the area's own viewport rect when saved.
+ * anchor element's border-box top-left in its scrolled content — i.e. plus
+ * the anchor's `scrollLeft`/`scrollTop` at save time, except when the
+ * anchor is the document's scrolling element (its rect already rides the
+ * page scroll). `selector`/`tag`/`source` then describe the anchor
+ * (smallest element fully containing the area); `rect` is the area's own
+ * viewport rect when saved.
*/
area?: { x: number; y: number; width: number; height: number };
source?: { file: string; line: number; column?: number };
@@ -73,6 +76,59 @@ export type BrowserComment = BrowserAnnotationElement & {
createdAt: string;
};
+function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), Math.max(min, max));
+}
+
+/**
+ * The crop saved with a comment: the element plus enough surroundings to
+ * read, grown to a useful minimum, kept inside the viewport it was measured
+ * in. Shifted before clipped — a crop near an edge keeps its full size if it
+ * can be moved inside rather than shrunk.
+ *
+ * Lives in core because both ends of the crop need it: main to take the shot,
+ * the formatter to name an area's bounds in the shot's own coordinates.
+ */
+export function resolveCommentCropRect(
+ rect: { x: number; y: number; width: number; height: number },
+ viewport: { width: number; height: number },
+): { x: number; y: number; width: number; height: number } {
+ const margin = 48;
+ let x = rect.x - margin;
+ let y = rect.y - margin;
+ let width = rect.width + margin * 2;
+ let height = rect.height + margin * 2;
+
+ if (width < 320) {
+ x -= (320 - width) / 2;
+ width = 320;
+ }
+ if (height < 200) {
+ y -= (200 - height) / 2;
+ height = 200;
+ }
+
+ if (width <= viewport.width) {
+ x = clamp(x, 0, viewport.width - width);
+ } else {
+ x = 0;
+ width = viewport.width;
+ }
+ if (height <= viewport.height) {
+ y = clamp(y, 0, viewport.height - height);
+ } else {
+ y = 0;
+ height = viewport.height;
+ }
+
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ width: Math.round(width),
+ height: Math.round(height),
+ };
+}
+
/**
* One line of it. Main folds the same fields on the way in, but the template's
* promise — one row per mark — is core's to keep for every caller of a public
@@ -158,8 +214,24 @@ export function formatBrowserComments(comments: readonly BrowserComment[]) {
}
// An area always prints its bounds: its crop has margins and no drawn
// box, so even a screenshot cannot say where exactly the region was.
+ // With a screenshot the bounds come twice — in the shot's own
+ // coordinates (1 image px is 1 CSS px after the CSS-width downsample,
+ // and the crop may have been shifted by a viewport edge) and in the
+ // viewport's.
if (comment.area) {
- lines.push(` - area: ${formatRect(comment.rect)}`);
+ if (comment.hasImage) {
+ const crop = resolveCommentCropRect(comment.rect, comment.viewport);
+ const inCrop = {
+ ...comment.rect,
+ x: comment.rect.x - crop.x,
+ y: comment.rect.y - crop.y,
+ };
+ lines.push(
+ ` - area: ${formatRect(inCrop)} in its screenshot; (${comment.rect.x}, ${comment.rect.y}) in the viewport`,
+ );
+ } else {
+ lines.push(` - area: ${formatRect(comment.rect)}`);
+ }
} else if (!comment.hasImage) {
lines.push(` - rect: ${formatRect(comment.rect)}`);
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 09baccd..506e63b 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -171,6 +171,7 @@ export {
export {
formatBrowserComments,
+ resolveCommentCropRect,
type BrowserAnnotationElement,
type BrowserAnnotationViewport,
type BrowserComment,