diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index 7e22f8c..ef75cbd 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -104,8 +104,12 @@ await page.mouse.up(); // the composer autofocuses its title input; drive it with real keys since - // Playwright locators can't pierce the closed shadow root - await page.waitForTimeout(1500); // picker click → composer mount + focus + // Playwright locators can't pierce the closed shadow root. The region + // selector sits between the pick and the composer — Enter accepts the + // whole-page screenshot. + await page.waitForTimeout(1500); // picker click → region selector mount + capture + await page.keyboard.press("Enter"); // whole-page screenshot option + await page.waitForTimeout(1500); // confirm → composer mount + focus await page.keyboard.type("Button looks broken after hover"); await page.keyboard.press("Tab"); await page.keyboard.type("Repro: hover, then click."); diff --git a/packages/extension/src/content/overlay/App.vue b/packages/extension/src/content/overlay/App.vue index defa534..5332acc 100644 --- a/packages/extension/src/content/overlay/App.vue +++ b/packages/extension/src/content/overlay/App.vue @@ -4,21 +4,35 @@ import type { ElementContext } from "@ltt/shared"; import type { OverlayState } from "./mount"; import PickerLayer from "./PickerLayer.vue"; +import RegionSelector from "./RegionSelector.vue"; import NoteComposer from "./NoteComposer.vue"; import RecorderBar from "./RecorderBar.vue"; const props = defineProps<{ state: OverlayState }>(); const pickedElement = ref(null); +/** screenshot chosen in the region selector, passed to the composer */ +const screenshotDataUrl = ref(null); const composerOpen = ref(false); function onPick(element: ElementContext) { pickedElement.value = element; +} + +function onRegionConfirmed(dataUrl: string) { + screenshotDataUrl.value = dataUrl; composerOpen.value = true; } +function onRegionCancelled() { + pickedElement.value = null; + // keep the picker alive while recording so another element can be picked + props.state.mode = props.state.recording ? "picker" : "idle"; +} + function closeComposer() { composerOpen.value = false; + screenshotDataUrl.value = null; pickedElement.value = null; // keep the picker alive while recording so another element can be picked props.state.mode = props.state.recording ? "picker" : "idle"; @@ -47,10 +61,17 @@ @pick="onPick" @cancel="cancelPicker" /> + diff --git a/packages/extension/src/content/overlay/NoteComposer.vue b/packages/extension/src/content/overlay/NoteComposer.vue index f2cf10a..90f5460 100644 --- a/packages/extension/src/content/overlay/NoteComposer.vue +++ b/packages/extension/src/content/overlay/NoteComposer.vue @@ -1,5 +1,5 @@ + + + + \ No newline at end of file diff --git a/packages/extension/verify-region.mjs b/packages/extension/verify-region.mjs new file mode 100644 index 0000000..a7fa602 --- /dev/null +++ b/packages/extension/verify-region.mjs @@ -0,0 +1,139 @@ +/** Verify region select + full-page stitch in the extension overlay. */ +import { chromium } from "playwright-core"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const root = dirname(fileURLToPath(import.meta.url)); +const SERVER = "http://localhost:8001"; +const PAGE_PORT = 8898; +const stamp = Date.now(); +const EMAIL = `region-e2e-${stamp}@example.com`; +const PASSWORD = "region-pass-1"; + +// tall test page (2500px) with two colored blocks +const pageHtml = `Region target + +
Top block
+
Middle block
+
Bottom block
+`; + +const httpServer = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(pageHtml); +}); +await new Promise((r) => httpServer.listen(PAGE_PORT, r)); + +async function api(path, options = {}) { + const r = await fetch(SERVER + path, options); + if (!r.ok) throw new Error(`API ${path} -> ${r.status}: ${await r.text()}`); + return r.json(); +} + +await api("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname: `region-e2e-${stamp}`, email: EMAIL, password: PASSWORD }), +}); +const login = await api("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Client": "extension" }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), +}); +const auth = { Authorization: `Bearer ${login.token}` }; +const project = await api("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ name: "Region E2E" }), +}); + +const executablePath = join(homedir(), ".cache/ms-playwright/chromium-1234/chrome-linux64/chrome"); +const context = await chromium.launchPersistentContext("", { + executablePath, + headless: true, + args: [ + `--disable-extensions-except=${join(root, "dist/chrome")}`, + `--load-extension=${join(root, "dist/chrome")}`, + ], +}); +let [worker] = context.serviceWorkers(); +if (!worker) worker = await context.waitForEvent("serviceworker", { timeout: 10000 }); + +// sign in through options +const options = await context.newPage(); +await options.goto(`chrome-extension://${new URL(worker.url()).host}/src/options/options.html`); +await options.fill('input[type="email"]', EMAIL); +await options.fill('input[type="password"]', PASSWORD); +await options.click("text=Sign in"); +await options.waitForSelector("text=Default project", { timeout: 10000 }); +await options.selectOption("select", project.id); +await options.close(); + +const page = await context.newPage({ viewport: { width: 1000, height: 700 } }); +await page.goto(`http://localhost:${PAGE_PORT}/`); +await worker.evaluate(() => self.__lttTriggerAction()); +await page.waitForTimeout(1000); +await page.locator("#target").scrollIntoViewIfNeeded(); +const box = await page.locator("#target").boundingBox(); +await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + +// --- case 1: drag a region over the middle block --- +await page.waitForTimeout(1200); // capture lands, selector visible +await page.screenshot({ path: "/tmp/ltt-shots/18-region-selector.png" }); +const mid = await page.locator("#mid").boundingBox(); +await page.mouse.move(mid.x + 30, mid.y + 30); +await page.mouse.down(); +await page.mouse.move(mid.x + 500, mid.y + 400, { steps: 6 }); +await page.mouse.up(); +await page.waitForTimeout(1500); // composer mount +await page.keyboard.type("Region shot"); +await page.keyboard.press("Tab"); +await page.keyboard.type("cropped"); +await page.keyboard.press("Control+Enter"); +await page.waitForTimeout(4000); + +const reports1 = await api(`/api/projects/${project.id}/reports`, { headers: auth }); +const items1 = reports1.items ?? reports1; +if (!items1.length) throw new Error("no report after region submit"); +const report1 = await api(`/api/reports/${items1[0].share_token}`); +const shot1 = report1.attachments[0]; +console.log("region shot:", JSON.stringify({ filename: shot1.filename, size: shot1.size })); +const dim1 = await pngSize(SERVER, items1[0].share_token, shot1.file_id); +console.log("region dims:", JSON.stringify(dim1), "(expect ~470x400 @dpr1)"); + +// --- case 2: whole page (Enter) → stitched 1000x2500 --- +const page2 = await context.newPage({ viewport: { width: 1000, height: 700 } }); +await page2.goto(`http://localhost:${PAGE_PORT}/`); +await worker.evaluate(() => self.__lttTriggerAction()); +await page2.waitForTimeout(1000); +await page2.locator("#target").scrollIntoViewIfNeeded(); +const box2 = await page2.locator("#target").boundingBox(); +await page2.mouse.click(box2.x + box2.width / 2, box2.y + box2.height / 2); +await page2.waitForTimeout(1200); +await page2.keyboard.press("Enter"); // whole page +await page2.waitForTimeout(2500); // stitching scrolls the page +await page2.screenshot({ path: "/tmp/ltt-shots/19-fullpage-stitching.png" }); +await page2.waitForTimeout(5000); // composer mounts after stitching +await page2.keyboard.type("Full page shot"); +await page2.keyboard.press("Tab"); +await page2.keyboard.type("stitched"); +await page2.keyboard.press("Control+Enter"); +await page2.waitForTimeout(6000); + +const items2 = (await api(`/api/projects/${project.id}/reports`, { headers: auth })).items; +const report2 = await api(`/api/reports/${items2[0].share_token}`); +const dim2 = await pngSize(SERVER, items2[0].share_token, report2.attachments[0].file_id); +console.log("fullpage dims:", JSON.stringify(dim2), "(expect 1000x2500 @dpr1)"); + +await context.close(); +httpServer.close(); + +async function pngSize(server, reportToken, fileId) { + const r = await fetch(`${server}/api/reports/by-token/${reportToken}/files/${fileId}`); + if (!r.ok) throw new Error(`file fetch -> ${r.status}`); + const buf = Buffer.from(await r.arrayBuffer()); + return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) }; +} \ No newline at end of file diff --git a/packages/ui/src/AnnotationEditor.vue b/packages/ui/src/AnnotationEditor.vue index 2a2fe1b..84edea2 100644 --- a/packages/ui/src/AnnotationEditor.vue +++ b/packages/ui/src/AnnotationEditor.vue @@ -2,6 +2,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import type { AnnotationShape } from "@ltt/shared"; import { ANNOTATION_COLORS } from "@ltt/shared"; +import { drawShape } from "./shapeDraw"; const props = withDefaults( defineProps<{ @@ -64,64 +65,6 @@ } } -function drawShape(c: CanvasRenderingContext2D, shape: AnnotationShape, w: number, h: number) { - c.strokeStyle = shape.color; - c.fillStyle = shape.color; - c.lineWidth = shape.strokeWidth; - c.lineCap = "round"; - c.lineJoin = "round"; - const px = (x: number) => x * w; - const py = (y: number) => y * h; - switch (shape.tool) { - case "pen": { - const pts = shape.points ?? []; - if (pts.length < 2) break; - c.beginPath(); - c.moveTo(px(pts[0][0]), py(pts[0][1])); - for (const [x, y] of pts.slice(1)) c.lineTo(px(x), py(y)); - c.stroke(); - break; - } - case "arrow": { - const pts = shape.points ?? []; - if (pts.length < 2) break; - const [x1, y1] = pts[0]; - const [x2, y2] = pts[pts.length - 1]; - const ax = px(x1), ay = py(y1), bx = px(x2), by = py(y2); - c.beginPath(); - c.moveTo(ax, ay); - c.lineTo(bx, by); - c.stroke(); - const angle = Math.atan2(by - ay, bx - ax); - const head = Math.max(shape.strokeWidth * 4, 10); - c.beginPath(); - c.moveTo(bx, by); - c.lineTo(bx - head * Math.cos(angle - Math.PI / 7), by - head * Math.sin(angle - Math.PI / 7)); - c.lineTo(bx - head * Math.cos(angle + Math.PI / 7), by - head * Math.sin(angle + Math.PI / 7)); - c.closePath(); - c.fill(); - break; - } - case "rect": { - const r = shape.rect; - if (!r) break; - c.strokeRect(px(r[0]), py(r[1]), px(r[2]), py(r[3])); - break; - } - case "text": { - if (!shape.text || !shape.pos) break; - const fontSize = Math.max(shape.strokeWidth * 5, 14); - c.font = `bold ${fontSize}px "IBM Plex Mono", monospace`; - c.save(); - c.shadowColor = "rgba(0,0,0,0.8)"; - c.shadowBlur = 3; - c.fillText(shape.text, px(shape.pos[0]), py(shape.pos[1])); - c.restore(); - break; - } - } -} - function toFraction(event: PointerEvent): [number, number] { const canvas = canvasRef.value!; const rect = canvas.getBoundingClientRect(); diff --git a/packages/ui/src/ScreenshotViewer.vue b/packages/ui/src/ScreenshotViewer.vue index b248c60..8d04d2e 100644 --- a/packages/ui/src/ScreenshotViewer.vue +++ b/packages/ui/src/ScreenshotViewer.vue @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, ref, watch } from "vue"; import type { AnnotationShape } from "@ltt/shared"; import AnnotationEditor from "./AnnotationEditor.vue"; +import { drawShapes } from "./shapeDraw"; const props = defineProps<{ /** Image URL to display. */ @@ -49,6 +50,9 @@ const lightboxOpen = ref(false); /** natural pixel size of the image */ const natural = ref({ w: 0, h: 0 }); +/** decoded image element reused for the lightbox canvas */ +let imageEl: HTMLImageElement | null = null; +const lightboxCanvas = ref(); /** current zoom factor; capped at 1 — beyond natural size there is no detail to reveal */ const zoom = ref(1); /** smallest useful zoom (fit-to-screen at open time) */ @@ -66,19 +70,43 @@ () => props.src, () => { const image = new Image(); + image.crossOrigin = "anonymous"; image.onload = () => { natural.value = { w: image.naturalWidth, h: image.naturalHeight }; + imageEl = image; + redrawLightbox(); }; image.src = props.src; }, { immediate: true } ); +watch( + () => props.shapes, + () => redrawLightbox(), + { deep: true } +); + +/** the lightbox canvas renders the flattened annotated screenshot */ +function redrawLightbox() { + const canvas = lightboxCanvas.value; + if (!canvas || !imageEl) return; + canvas.width = imageEl.naturalWidth; + canvas.height = imageEl.naturalHeight; + const c = canvas.getContext("2d"); + if (!c) return; + c.drawImage(imageEl, 0, 0); + drawShapes(c, props.shapes ?? [], canvas.width, canvas.height); +} + function openLightbox() { lightboxOpen.value = true; resetView(); document.addEventListener("keydown", onLightboxKeydown); - requestAnimationFrame(() => lightboxEl.value?.focus()); + requestAnimationFrame(() => { + redrawLightbox(); + lightboxEl.value?.focus(); + }); } function closeLightbox() { @@ -208,11 +236,10 @@ @pointerdown="startPan" @click="onBackdropClick" > - + x * w; + const py = (y: number) => y * h; + switch (shape.tool) { + case "pen": { + const pts = shape.points ?? []; + if (pts.length < 2) break; + c.beginPath(); + c.moveTo(px(pts[0][0]), py(pts[0][1])); + for (const [x, y] of pts.slice(1)) c.lineTo(px(x), py(y)); + c.stroke(); + break; + } + case "arrow": { + const pts = shape.points ?? []; + if (pts.length < 2) break; + const [x1, y1] = pts[0]; + const [x2, y2] = pts[pts.length - 1]; + const ax = px(x1), ay = py(y1), bx = px(x2), by = py(y2); + c.beginPath(); + c.moveTo(ax, ay); + c.lineTo(bx, by); + c.stroke(); + const angle = Math.atan2(by - ay, bx - ax); + const head = Math.max(shape.strokeWidth * 4, 10); + c.beginPath(); + c.moveTo(bx, by); + c.lineTo(bx - head * Math.cos(angle - Math.PI / 7), by - head * Math.sin(angle - Math.PI / 7)); + c.lineTo(bx - head * Math.cos(angle + Math.PI / 7), by - head * Math.sin(angle + Math.PI / 7)); + c.closePath(); + c.fill(); + break; + } + case "rect": { + const r = shape.rect; + if (!r) break; + c.strokeRect(px(r[0]), py(r[1]), px(r[2]), py(r[3])); + break; + } + case "text": { + if (!shape.text || !shape.pos) break; + const fontSize = Math.max(shape.strokeWidth * 5, 14); + c.font = `bold ${fontSize}px "IBM Plex Mono", monospace`; + c.save(); + c.shadowColor = "rgba(0,0,0,0.8)"; + c.shadowBlur = 3; + c.fillText(shape.text, px(shape.pos[0]), py(shape.pos[1])); + c.restore(); + break; + } + } +} + +export function drawShapes(c: CanvasRenderingContext2D, shapes: AnnotationShape[], w: number, h: number) { + for (const shape of shapes) drawShape(c, shape, w, h); +} \ No newline at end of file diff --git a/packages/web/verify-annot.mjs b/packages/web/verify-annot.mjs new file mode 100644 index 0000000..37b1891 --- /dev/null +++ b/packages/web/verify-annot.mjs @@ -0,0 +1,81 @@ +/** Verify the lightbox renders the ANNOTATED screenshot. */ +import { chromium } from "playwright-core"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; + +const root = dirname(fileURLToPath(import.meta.url)); +const SERVER = "http://localhost:8001"; +const WEB = "http://localhost:5173"; +const stamp = Date.now(); +const EMAIL = `lb-annot-${stamp}@example.com`; +const PASSWORD = "lb-annot-pass"; + +const executablePath = join(homedir(), ".cache/ms-playwright/chromium-1234/chrome-linux64/chrome"); +const browser = await chromium.launch({ executablePath, headless: true }); + +async function api(path, options = {}) { + const r = await fetch(SERVER + path, options); + if (!r.ok) throw new Error(`API ${path} -> ${r.status}`); + return r.json(); +} + +await api("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname: `lb-annot-${stamp}`, email: EMAIL, password: PASSWORD }), +}); +const login = await api("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Client": "extension" }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }), +}); +const auth = { Authorization: `Bearer ${login.token}` }; +const project = await api("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ name: "Lightbox Annot" }), +}); + +const maker = await browser.newPage({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: 2 }); +await maker.setContent(` +

Annotated lightbox test

`); +const png = await maker.screenshot({ path: "/tmp/ltt-shots/annot-src.png" }); +await maker.close(); + +const form = new FormData(); +form.append("file", new Blob([png], { type: "image/png" }), "annot.png"); +const up = await api("/api/uploads", { method: "POST", headers: auth, body: form }); +const note = await api("/api/reports", { + method: "POST", + headers: { "Content-Type": "application/json", ...auth }, + body: JSON.stringify({ + project_id: project.id, + type: "element_note", + title: "Annotated lightbox", + environment: { user_agent: "verify", browser: "Chromium", os: "Linux", viewport: { w: 1200, h: 800 }, dpr: 2, language: "en" }, + attachment_ids: [up.file_id], + annotation_shapes: { + [up.file_id]: [ + { tool: "rect", color: "#F7768E", strokeWidth: 6, rect: [0.1, 0.1, 0.5, 0.3] }, + { tool: "text", color: "#F7768E", strokeWidth: 4, pos: [0.12, 0.5], text: "BUG HERE" }, + ], + }, + }), +}); + +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +await page.goto(`${WEB}/r/${note.share_token}`); +await page.waitForSelector(".screenshot-expand", { timeout: 15000 }); +await page.waitForTimeout(1200); +await page.click(".screenshot-expand"); +await page.waitForSelector(".lightbox canvas", { timeout: 5000 }); +await page.waitForTimeout(1000); +const canvasInfo = await page.evaluate(() => { + const canvas = document.querySelector(".lightbox canvas"); + return canvas ? { w: canvas.width, h: canvas.height } : null; +}); +console.log("lightbox canvas:", JSON.stringify(canvasInfo)); +await page.screenshot({ path: "/tmp/ltt-shots/17-lightbox-annotated.png" }); +await browser.close(); +console.log("VERIFY OK"); \ No newline at end of file