From 98e196710df1dd0dfdfd9c62709a290fa6b29744 Mon Sep 17 00:00:00 2001 From: kosako Date: Sat, 11 Jul 2026 18:19:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?widget:=20state=20=E3=81=A8=E7=B4=94?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=82=AF=E3=83=A9=E3=82=B9=E3=82=BF=E3=82=92?= =?UTF-8?q?=204=20=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=81=AB?= =?UTF-8?q?=E5=88=86=E5=89=B2=E3=81=99=E3=82=8B=20(#109=20R-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit widget/src/index.js (1729 行) から挙動を変えずにコードを一字一句そのまま 移動して 4 モジュールを新設する (verbatim move): - state.js: DEFAULTS と state (オブジェクト参照共有。state への再代入は 無く全参照がプロパティ読み書きのため挙動不変) - screenshot.js: captureScreenshot / buildScreenshotSvg / screenshotOverlayFor / collectReadableStyles / visibleBackground / snapshotClassAttr / serializeAsXhtml / renderScreenshotOverlay / byteLength / base64Encode - persistence.js: FEEDBACK_STORAGE_VERSION / loadStoredReviewer / saveReviewer / loadPersistedFeedback / persistFeedbackList / clearPersistedFeedback / feedbackStorageEnvelope / serializeFeedbackForStorage / normalizePersistedFeedback / isMatchingFeedbackEnvelope - payload.js: PAYLOAD_SCHEMA_VERSION / buildPayload / generateFeedbackId (呼び出し元が buildPayload のみであることを grep で確認) restorePersistedFeedback は index.js 残留関数 (removeCommittedMarkers / restoreFeedbackMarkers) を呼ぶ orchestrator のため index.js に残す。 Slack 直送系 (postSlackWebhook ほか) は #97 で削除予定のため移動しない。 safeFilePart は R-1 スライスと競合させないため触らない。 新モジュールは全て「葉」(index.js への逆依存なし) で、dist の差分は モジュール境界コメント・IIFE ラッパ・並び順・import 行のみ。全 1912 行の 関数本体テキストがバイト一致することを機械照合で確認済み。 node 環境で動く純関数に単体テストを追加: - test/widget-persistence.test.js: serialize/normalize の round-trip と envelope 一致判定 - test/widget-screenshot.test.js: byteLength / base64Encode Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRdDjwxbAAaTMwwnzipkau --- dist/patchloop-widget.js | 1127 ++++++++++++++++--------------- test/widget-persistence.test.js | 81 +++ test/widget-screenshot.test.js | 37 + widget/src/index.js | 414 +----------- widget/src/payload.js | 60 ++ widget/src/persistence.js | 106 +++ widget/src/screenshot.js | 210 ++++++ widget/src/state.js | 39 ++ 8 files changed, 1117 insertions(+), 957 deletions(-) create mode 100644 test/widget-persistence.test.js create mode 100644 test/widget-screenshot.test.js create mode 100644 widget/src/payload.js create mode 100644 widget/src/persistence.js create mode 100644 widget/src/screenshot.js create mode 100644 widget/src/state.js diff --git a/dist/patchloop-widget.js b/dist/patchloop-widget.js index 2d95586..2d457b8 100644 --- a/dist/patchloop-widget.js +++ b/dist/patchloop-widget.js @@ -217,6 +217,92 @@ function textFor(element) { return { cssEscape, selectorFor, textFor }; })(); +// --- widget/src/source-context.js --- +const __pl_widget_src_source_context = (() => { +// Resolves the payload's sourceContext (#96): which repo/branch/commit the +// reviewed page was built from, so a coding agent can map feedback selectors +// back to source. The init option is the source of truth — the embedding side +// injects real values at build/deploy time. Meta tags are the fallback for +// hosts that can only stamp static HTML. The receiver's config is deliberately +// not a source: one receiver serves payloads from many previews, so a +// per-process value cannot be correct across projects. +const SOURCE_CONTEXT_FIELDS = [ + { key: "repo", metaName: "patchloop:repo" }, + { key: "branch", metaName: "patchloop:branch" }, + { key: "commit", metaName: "patchloop:commit" }, + { key: "root", metaName: "patchloop:root" }, + { key: "buildUrl", metaName: "patchloop:build-url" }, + { key: "previewUrl", metaName: "patchloop:preview-url" } +]; + +function resolveSourceContext(configured, doc) { + const options = configured && typeof configured === "object" ? configured : {}; + const context = {}; + for (const { key, metaName } of SOURCE_CONTEXT_FIELDS) { + const value = cleanValue(options[key]) || metaContent(doc, metaName); + if (value) context[key] = value; + } + return Object.keys(context).length > 0 ? context : null; +} + +function metaContent(doc, metaName) { + const node = doc.querySelector(`meta[name="${metaName}"]`); + return node ? cleanValue(node.content) : ""; +} + +// Only trimmed non-empty strings count; anything else (numbers, objects, a +// blank template placeholder like "" left unfilled) is treated as absent so +// the payload never carries junk values into stored records or issues. +function cleanValue(value) { + return typeof value === "string" ? value.trim() : ""; +} + +return { resolveSourceContext }; +})(); +// --- widget/src/state.js --- +const __pl_widget_src_state = (() => { +const DEFAULTS = { + projectId: "local-demo", + demoId: "plain-html", + endpoint: "", + // Public per-project key sent with receiver posts (#44). It ships in the + // page, so it identifies the project and blocks indiscriminate spam rather + // than acting as a secret. Empty = receiver runs with open ingest. + ingestKey: "", + // Git provenance of the page under review (#96): { repo, branch, commit, + // root, buildUrl, previewUrl }, all optional strings. The embedding side + // injects real values at build/deploy time; tags + // fill any missing field. + sourceContext: null, + deliveryMode: "receiver", + slackWebhookUrl: "", + showDeliverySettings: false, + reviewer: "", + reviewerStorageKey: "patchloop:reviewer", + persistFeedback: true, + feedbackStorageKey: "patchloop:feedback", + position: "bottom-right", + captureScreenshot: true, + screenshotMaxBytes: 1_200_000, + onSubmit: null +}; + +const state = { + options: { ...DEFAULTS }, + active: false, + pendingTarget: null, + drag: null, + suppressNextClick: false, + feedback: [], + feedbackMarkers: new Map(), + approximateIds: new Set(), + resizeTimer: null, + editingId: null, + collapsed: true +}; + +return { DEFAULTS, state }; +})(); // --- widget/src/snapshot-css.js --- const __pl_widget_src_snapshot_css = (() => { // CSS processing for the SVG snapshot. Media conditions are injected as a @@ -287,81 +373,6 @@ function flattenRulesForSnapshot(rules, mediaMatches) { return { freezeViewportUnits, flattenRulesForSnapshot }; })(); -// --- widget/src/url.js --- -const __pl_widget_src_url = (() => { -// Page-identity helpers for persisted feedback. -// -// Saved feedback is keyed by the page it was left on. Using the full -// `location.href` (which includes the query string and hash) is too strict: -// after anchor navigation (`#section`), tracking params (`?utm=...`), or a -// normalizing redirect, the URL no longer matches byte-for-byte and the saved -// feedback is silently dropped — and then overwritten. The envelope is already -// scoped by projectId/demoId, so comparing only origin + pathname is enough. - -function normalizePageUrl(url, base) { - try { - const parsed = new URL(url, base); - return `${parsed.origin}${parsed.pathname}`; - } catch (_) { - return ""; - } -} - -function samePersistedPage(storedUrl, currentUrl) { - if (typeof storedUrl !== "string" || storedUrl === "") return false; - // The stored pageUrl is always a full href (the save side writes - // window.location.href). Parse both WITHOUT a base so a missing, empty, - // relative, or otherwise malformed stored value cannot be resolved against - // the current page and falsely match it. - const stored = normalizePageUrl(storedUrl, undefined); - const current = normalizePageUrl(currentUrl, undefined); - return stored !== "" && stored === current; -} - -return { normalizePageUrl, samePersistedPage }; -})(); -// --- widget/src/source-context.js --- -const __pl_widget_src_source_context = (() => { -// Resolves the payload's sourceContext (#96): which repo/branch/commit the -// reviewed page was built from, so a coding agent can map feedback selectors -// back to source. The init option is the source of truth — the embedding side -// injects real values at build/deploy time. Meta tags are the fallback for -// hosts that can only stamp static HTML. The receiver's config is deliberately -// not a source: one receiver serves payloads from many previews, so a -// per-process value cannot be correct across projects. -const SOURCE_CONTEXT_FIELDS = [ - { key: "repo", metaName: "patchloop:repo" }, - { key: "branch", metaName: "patchloop:branch" }, - { key: "commit", metaName: "patchloop:commit" }, - { key: "root", metaName: "patchloop:root" }, - { key: "buildUrl", metaName: "patchloop:build-url" }, - { key: "previewUrl", metaName: "patchloop:preview-url" } -]; - -function resolveSourceContext(configured, doc) { - const options = configured && typeof configured === "object" ? configured : {}; - const context = {}; - for (const { key, metaName } of SOURCE_CONTEXT_FIELDS) { - const value = cleanValue(options[key]) || metaContent(doc, metaName); - if (value) context[key] = value; - } - return Object.keys(context).length > 0 ? context : null; -} - -function metaContent(doc, metaName) { - const node = doc.querySelector(`meta[name="${metaName}"]`); - return node ? cleanValue(node.content) : ""; -} - -// Only trimmed non-empty strings count; anything else (numbers, objects, a -// blank template placeholder like "" left unfilled) is treated as absent so -// the payload never carries junk values into stored records or issues. -function cleanValue(value) { - return typeof value === "string" ? value.trim() : ""; -} - -return { resolveSourceContext }; -})(); // --- shared/format.js --- const __pl_shared_format = (() => { // Formatting helpers shared by the widget (bundled into dist) and the @@ -430,65 +441,444 @@ function formatTarget(target) { return { safeFilePart, truncateText, present, escapeHtml, escapeXml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget }; })(); -const { pointFromClient, rectFromPoints, rectContainsArea, pointFromStoredTarget, rectFromStoredArea, round, numberOrNull } = __pl_widget_src_geometry; -const { pointAnchorOffsets, areaAnchorOffsets, roundedAnchor, geometryFromAnchor, viewportDiffersFromCreation } = __pl_widget_src_anchoring; -const { selectorFor, textFor } = __pl_widget_src_selector; +// --- widget/src/screenshot.js --- +const __pl_widget_src_screenshot = (() => { +const { state } = __pl_widget_src_state; const { freezeViewportUnits, flattenRulesForSnapshot } = __pl_widget_src_snapshot_css; -const { samePersistedPage } = __pl_widget_src_url; -const { resolveSourceContext } = __pl_widget_src_source_context; -const { truncateText, present, escapeHtml, escapeXml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = __pl_shared_format; +const { escapeHtml, escapeXml } = __pl_shared_format; -const DEFAULTS = { - projectId: "local-demo", - demoId: "plain-html", - endpoint: "", - // Public per-project key sent with receiver posts (#44). It ships in the - // page, so it identifies the project and blocks indiscriminate spam rather - // than acting as a secret. Empty = receiver runs with open ingest. - ingestKey: "", - // Git provenance of the page under review (#96): { repo, branch, commit, - // root, buildUrl, previewUrl }, all optional strings. The embedding side - // injects real values at build/deploy time; tags - // fill any missing field. - sourceContext: null, - deliveryMode: "receiver", - slackWebhookUrl: "", - showDeliverySettings: false, - reviewer: "", - reviewerStorageKey: "patchloop:reviewer", - persistFeedback: true, - feedbackStorageKey: "patchloop:feedback", - position: "bottom-right", - captureScreenshot: true, - screenshotMaxBytes: 1_200_000, - onSubmit: null -}; +function captureScreenshot(target) { + if (!state.options.captureScreenshot) return null; + + try { + const width = Math.max(document.documentElement.clientWidth, window.innerWidth, 1); + const height = Math.max(document.documentElement.clientHeight, window.innerHeight, 1); + const documentWidth = Math.max(document.documentElement.scrollWidth, width); + const documentHeight = Math.max(document.documentElement.scrollHeight, height); + const overlay = screenshotOverlayFor(target); + const svg = buildScreenshotSvg({ + width, + height, + documentWidth, + documentHeight, + scrollX: window.scrollX, + scrollY: window.scrollY, + overlay + }); + const bytes = byteLength(svg); + const maxBytes = Number(state.options.screenshotMaxBytes || 0); + + if (maxBytes > 0 && bytes > maxBytes) { + return { + status: "omitted", + reason: "too-large", + kind: "viewport-svg", + mimeType: "image/svg+xml", + width, + height, + bytes, + maxBytes, + targetOverlay: overlay + }; + } + + return { + status: "captured", + kind: "viewport-svg", + mimeType: "image/svg+xml", + width, + height, + scrollX: Math.round(window.scrollX), + scrollY: Math.round(window.scrollY), + devicePixelRatio: window.devicePixelRatio || 1, + bytes, + targetOverlay: overlay, + dataUrl: `data:image/svg+xml;base64,${base64Encode(svg)}` + }; + } catch (error) { + return { + status: "failed", + error: error.message + }; + } +} + +function buildScreenshotSvg({ width, height, documentWidth, documentHeight, scrollX, scrollY, overlay }) { + const bodyClone = document.body.cloneNode(true); + bodyClone.querySelectorAll("[data-patchloop-root], [data-patchloop-pin], [data-patchloop-area], [data-patchloop-selection], script").forEach((node) => node.remove()); + bodyClone.querySelectorAll(".pl-target-highlight").forEach((node) => node.classList.remove("pl-target-highlight")); + + const bodyStyle = window.getComputedStyle(document.body); + // A transparent body paints the html (or default white) background; the + // snapshot must do the same instead of losing the page background. + const background = visibleBackground(bodyStyle.backgroundColor) + || visibleBackground(window.getComputedStyle(document.documentElement).backgroundColor) + || "#ffffff"; + const color = bodyStyle.color || "#14211d"; + const font = bodyStyle.font || bodyStyle.fontFamily || "system-ui, sans-serif"; + const htmlClassAttr = snapshotClassAttr(document.documentElement); + const bodyClassAttr = snapshotClassAttr(document.body); + // The tag is regenerated, so its inline style must be carried + // over; the snapshot's own layout overrides come after and win. + const bodyInlineStyle = String(document.body.getAttribute("style") || "").trim(); + const bodyStylePrefix = bodyInlineStyle ? bodyInlineStyle.replace(/;?$/, ";") : ""; + const styles = `${freezeViewportUnits(collectReadableStyles(), width, height)}\n* { box-sizing: border-box; }\n`; + const overlayMarkup = renderScreenshotOverlay(overlay); + const bodyMarkup = serializeAsXhtml(bodyClone); + + return ` + + + + + + + + + ${bodyMarkup} + + + + +${overlayMarkup} +`; +} + +function visibleBackground(value) { + if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return ""; + return value; +} + +function snapshotClassAttr(element) { + // The widget's own mode class (crosshair cursor) is capture-state, not + // page state, and must not leak into the snapshot. + const value = String(element.getAttribute("class") || "") + .split(/\s+/) + .filter((token) => token && token !== "pl-feedback-active") + .join(" "); + return value ? ` class="${escapeHtml(value)}"` : ""; +} + +// The SVG is parsed as XML, so the clone must be serialized as XHTML: +// innerHTML emits HTML syntax (unclosed void elements like
, named +// entities like  ) that breaks XML parsing and renders the whole +// snapshot as a broken image. XMLSerializer self-closes void elements and +// emits characters instead of HTML-only entities. +function serializeAsXhtml(root) { + const serializer = new XMLSerializer(); + return Array.from(root.childNodes) + .map((node) => { + try { + return serializer.serializeToString(node); + } catch (_) { + return ""; + } + }) + .join(""); +} + +// Media queries inside the snapshot re-evaluate against the SVG's rendered +// size (e.g. a scaled-down inbox preview), reflowing the clone away from the +// captured layout while overlay coordinates stay fixed. Resolve media +// conditions at capture time instead: inline the rules that match the +// current viewport and drop the rest, so the snapshot keeps the captured +// layout at any display size. +function collectReadableStyles() { + const chunks = []; + const mediaMatches = (mediaText) => window.matchMedia(mediaText).matches; + const sheets = [...Array.from(document.styleSheets), ...Array.from(document.adoptedStyleSheets || [])]; + sheets.forEach((sheet) => { + try { + if (sheet.ownerNode?.dataset?.patchloopStyle) return; + if (sheet.disabled) return; + if (sheet.media && sheet.media.mediaText && !mediaMatches(sheet.media.mediaText)) return; + const flattened = flattenRulesForSnapshot(sheet.cssRules, mediaMatches); + if (flattened) chunks.push(flattened); + } catch (_) { + // Cross-origin stylesheets cannot be read. The snapshot still includes DOM and overlay context. + } + }); + return chunks.join("\n"); +} + +// Overlay coordinates must be viewport-relative at capture time, so derive +// them from the page-pixel position (kept fresh by re-anchoring) and the +// current scroll instead of the click-time client coordinates. +function screenshotOverlayFor(target) { + if (target.kind === "area" && target.area) { + return { + kind: "area", + x: Math.round(target.area.pageX - window.scrollX), + y: Math.round(target.area.pageY - window.scrollY), + width: Math.round(target.area.clientWidth), + height: Math.round(target.area.clientHeight) + }; + } + + return { + kind: "point", + x: Math.round(target.pageX - window.scrollX), + y: Math.round(target.pageY - window.scrollY) + }; +} + +function renderScreenshotOverlay(overlay) { + if (!overlay) return ""; + if (overlay.kind === "area") { + const x = Math.max(0, overlay.x); + const y = Math.max(0, overlay.y); + const width = Math.max(1, overlay.width); + const height = Math.max(1, overlay.height); + return ` + + +!`; + } + + return ` + +`; +} + +function byteLength(value) { + if (window.Blob) return new Blob([value]).size; + return base64Encode(value).length; +} + +function base64Encode(value) { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (let i = 0; i < bytes.length; i += 8192) { + binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + } + return btoa(binary); +} + +return { captureScreenshot, byteLength, base64Encode }; +})(); +// --- widget/src/payload.js --- +const __pl_widget_src_payload = (() => { +const { round } = __pl_widget_src_geometry; +const { roundedAnchor } = __pl_widget_src_anchoring; +const { state } = __pl_widget_src_state; +const { captureScreenshot } = __pl_widget_src_screenshot; + +// Version of the feedback payload schema itself (distinct from the storage +// envelope and export bundle versions). Bump when the payload shape changes +// so the receiver can branch on it as the schema grows for team use. +// v2 adds the optional sourceContext block (#96). +const PAYLOAD_SCHEMA_VERSION = 2; + +function buildPayload(comment, reviewer, target) { + return { + schemaVersion: PAYLOAD_SCHEMA_VERSION, + id: generateFeedbackId(), + projectId: state.options.projectId, + demoId: state.options.demoId, + comment, + reviewer, + page: { + url: window.location.href, + title: document.title + }, + sourceContext: state.options.sourceContext, + target: { + kind: target.kind || "point", + x: round(target.x), + y: round(target.y), + clientX: Math.round(target.clientX), + clientY: Math.round(target.clientY), + pageX: Math.round(target.pageX), + pageY: Math.round(target.pageY), + documentX: round(target.documentX), + documentY: round(target.documentY), + area: target.area || null, + selector: target.selector, + text: target.elementText, + anchor: roundedAnchor(target.anchor) + }, + environment: { + viewport: { + width: window.innerWidth, + height: window.innerHeight + }, + browser: navigator.userAgent, + language: navigator.language + }, + screenshot: captureScreenshot(target), + createdAt: new Date().toISOString() + }; +} + +// Date.now() alone collides across tabs/reviewers within the same +// millisecond, which also collides marker Map keys and orphans nodes. +function generateFeedbackId() { + const random = globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" + ? globalThis.crypto.randomUUID().slice(0, 8) + : Math.random().toString(36).slice(2, 10); + return `pl_${Date.now()}_${random}`; +} + +return { buildPayload }; +})(); +// --- widget/src/url.js --- +const __pl_widget_src_url = (() => { +// Page-identity helpers for persisted feedback. +// +// Saved feedback is keyed by the page it was left on. Using the full +// `location.href` (which includes the query string and hash) is too strict: +// after anchor navigation (`#section`), tracking params (`?utm=...`), or a +// normalizing redirect, the URL no longer matches byte-for-byte and the saved +// feedback is silently dropped — and then overwritten. The envelope is already +// scoped by projectId/demoId, so comparing only origin + pathname is enough. + +function normalizePageUrl(url, base) { + try { + const parsed = new URL(url, base); + return `${parsed.origin}${parsed.pathname}`; + } catch (_) { + return ""; + } +} + +function samePersistedPage(storedUrl, currentUrl) { + if (typeof storedUrl !== "string" || storedUrl === "") return false; + // The stored pageUrl is always a full href (the save side writes + // window.location.href). Parse both WITHOUT a base so a missing, empty, + // relative, or otherwise malformed stored value cannot be resolved against + // the current page and falsely match it. + const stored = normalizePageUrl(storedUrl, undefined); + const current = normalizePageUrl(currentUrl, undefined); + return stored !== "" && stored === current; +} + +return { normalizePageUrl, samePersistedPage }; +})(); +// --- widget/src/persistence.js --- +const __pl_widget_src_persistence = (() => { +const { state } = __pl_widget_src_state; +const { samePersistedPage } = __pl_widget_src_url; + +const FEEDBACK_STORAGE_VERSION = 1; + +function loadStoredReviewer(storageKey) { + if (!storageKey) return ""; + try { + return String(window.localStorage.getItem(storageKey) || "").trim(); + } catch (_) { + return ""; + } +} + +function saveReviewer(reviewer) { + state.options.reviewer = reviewer; + if (!state.options.reviewerStorageKey) return; + try { + window.localStorage.setItem(state.options.reviewerStorageKey, reviewer); + } catch (_) { + // Storage can be unavailable in privacy-restricted contexts. + } +} + +function loadPersistedFeedback() { + if (!state.options.feedbackStorageKey) return []; + + try { + const raw = window.localStorage.getItem(state.options.feedbackStorageKey); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!isMatchingFeedbackEnvelope(parsed)) return []; + return Array.isArray(parsed.feedback) + ? parsed.feedback.map(normalizePersistedFeedback).filter(Boolean) + : []; + } catch (error) { + console.warn("[PatchLoop] persisted feedback ignored", error); + return []; + } +} + +function persistFeedbackList() { + if (!state.options.persistFeedback || !state.options.feedbackStorageKey) return; + + const envelope = feedbackStorageEnvelope(state.feedback); + try { + window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(envelope)); + } catch (error) { + const compactEnvelope = feedbackStorageEnvelope(state.feedback, { omitScreenshotDataUrl: true }); + try { + window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(compactEnvelope)); + console.warn("[PatchLoop] persisted feedback without screenshot dataUrl", error); + } catch (retryError) { + console.warn("[PatchLoop] unable to persist feedback", retryError); + } + } +} + +function clearPersistedFeedback() { + if (!state.options.feedbackStorageKey) return; + + try { + window.localStorage.removeItem(state.options.feedbackStorageKey); + } catch (_) { + // Storage can be unavailable in privacy-restricted contexts. + } +} + +function feedbackStorageEnvelope(feedback, options = {}) { + return { + version: FEEDBACK_STORAGE_VERSION, + projectId: state.options.projectId, + demoId: state.options.demoId, + pageUrl: window.location.href, + savedAt: new Date().toISOString(), + feedback: feedback.map((item) => serializeFeedbackForStorage(item, options)).filter(Boolean) + }; +} + +function serializeFeedbackForStorage(item, options = {}) { + try { + const copy = JSON.parse(JSON.stringify(item)); + if (options.omitScreenshotDataUrl && copy.screenshot) { + delete copy.screenshot.dataUrl; + copy.screenshot.persistedWithoutDataUrl = true; + } + return copy; + } catch (_) { + return null; + } +} + +function normalizePersistedFeedback(item) { + if (!item || typeof item !== "object") return null; + if (!item.id || !item.target || typeof item.target !== "object") return null; + return item; +} + +function isMatchingFeedbackEnvelope(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + if (value.version !== FEEDBACK_STORAGE_VERSION) return false; + if (value.projectId !== state.options.projectId) return false; + if (value.demoId !== state.options.demoId) return false; + if (!samePersistedPage(value.pageUrl, window.location.href)) return false; + return Array.isArray(value.feedback); +} + +return { FEEDBACK_STORAGE_VERSION, loadStoredReviewer, saveReviewer, loadPersistedFeedback, persistFeedbackList, clearPersistedFeedback, serializeFeedbackForStorage, normalizePersistedFeedback, isMatchingFeedbackEnvelope }; +})(); +const { pointFromClient, rectFromPoints, rectContainsArea, pointFromStoredTarget, rectFromStoredArea, round, numberOrNull } = __pl_widget_src_geometry; +const { pointAnchorOffsets, areaAnchorOffsets, geometryFromAnchor, viewportDiffersFromCreation } = __pl_widget_src_anchoring; +const { selectorFor, textFor } = __pl_widget_src_selector; +const { resolveSourceContext } = __pl_widget_src_source_context; +const { DEFAULTS, state } = __pl_widget_src_state; +const { buildPayload } = __pl_widget_src_payload; +const { loadStoredReviewer, saveReviewer, persistFeedbackList, loadPersistedFeedback, clearPersistedFeedback } = __pl_widget_src_persistence; +const { truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = __pl_shared_format; const EXPORT_KIND = "patchloop-feedback-bundle"; // v2 carries an array of feedback (batch export). v1 wrapped a single // payload; the receiver still accepts that shape for files exported before // the switch to batch download. const EXPORT_VERSION = 2; -const FEEDBACK_STORAGE_VERSION = 1; -// Version of the feedback payload schema itself (distinct from the storage -// envelope and export bundle versions). Bump when the payload shape changes -// so the receiver can branch on it as the schema grows for team use. -// v2 adds the optional sourceContext block (#96). -const PAYLOAD_SCHEMA_VERSION = 2; - -const state = { - options: { ...DEFAULTS }, - active: false, - pendingTarget: null, - drag: null, - suppressNextClick: false, - feedback: [], - feedbackMarkers: new Map(), - approximateIds: new Set(), - resizeTimer: null, - editingId: null, - collapsed: true -}; function init(options = {}) { // From there is no body yet to mount into; retry once the DOM is ready. @@ -850,330 +1240,82 @@ function syncDeliverySettingsVisibility() { const slackField = root.querySelector("[data-pl-slack-field]"); if (endpointField) endpointField.hidden = mode !== "receiver"; if (slackField) slackField.hidden = mode !== "slack-webhook"; - updateDownloadAllButton(); -} - -function showFormError(form, message) { - const errorEl = form?.querySelector("[data-pl-form-error]"); - if (!errorEl) return; - errorEl.textContent = message; - errorEl.hidden = false; -} - -function clearFormError(form) { - const errorEl = form?.querySelector("[data-pl-form-error]"); - if (!errorEl) return; - errorEl.textContent = ""; - errorEl.hidden = true; -} - -async function submitComment(event) { - event.preventDefault(); - - const root = getRoot(); - const form = root.querySelector("[data-pl-comment]"); - const comment = root.querySelector("[data-pl-comment-text]").value.trim(); - const reviewer = root.querySelector("[data-pl-reviewer]").value.trim(); - if (!comment) return; - if (!reviewer) { - showFormError(form, "投稿者名を入力してください。"); - root.querySelector("[data-pl-reviewer]").focus(); - return; - } - clearFormError(form); - - if (state.editingId) { - const target = state.feedback.find((item) => item.id === state.editingId); - if (target) { - const changed = target.comment !== comment || target.reviewer !== reviewer; - target.comment = comment; - target.reviewer = reviewer; - // An edited comment that was already exported must re-enter the unsent - // batch, otherwise the correction never reaches the receiver. - if (changed && target.exported) { - delete target.exported; - delete target.exportedAt; - delete target.exportedFileName; - } - saveReviewer(reviewer); - persistFeedbackList(); - renderFeedbackList(); - } - state.editingId = null; - closeCommentForm(); - return; - } - - if (!state.pendingTarget) return; - - saveReviewer(reviewer); - const payload = buildPayload(comment, reviewer, state.pendingTarget); - state.feedback.unshift(payload); - finalizePendingMarker(payload.id); - persistFeedbackList(); - renderFeedbackList(); - expandPanel(); - closeCommentForm(); - - document.dispatchEvent(new CustomEvent("patchloop:feedback", { detail: payload })); - - if (typeof state.options.onSubmit === "function") { - state.options.onSubmit(payload); - } - - if (shouldDeliverFeedback()) { - await deliverFeedback(payload); - persistFeedbackList(); - renderFeedbackList(); - } -} - -function buildPayload(comment, reviewer, target) { - return { - schemaVersion: PAYLOAD_SCHEMA_VERSION, - id: generateFeedbackId(), - projectId: state.options.projectId, - demoId: state.options.demoId, - comment, - reviewer, - page: { - url: window.location.href, - title: document.title - }, - sourceContext: state.options.sourceContext, - target: { - kind: target.kind || "point", - x: round(target.x), - y: round(target.y), - clientX: Math.round(target.clientX), - clientY: Math.round(target.clientY), - pageX: Math.round(target.pageX), - pageY: Math.round(target.pageY), - documentX: round(target.documentX), - documentY: round(target.documentY), - area: target.area || null, - selector: target.selector, - text: target.elementText, - anchor: roundedAnchor(target.anchor) - }, - environment: { - viewport: { - width: window.innerWidth, - height: window.innerHeight - }, - browser: navigator.userAgent, - language: navigator.language - }, - screenshot: captureScreenshot(target), - createdAt: new Date().toISOString() - }; -} - -function captureScreenshot(target) { - if (!state.options.captureScreenshot) return null; - - try { - const width = Math.max(document.documentElement.clientWidth, window.innerWidth, 1); - const height = Math.max(document.documentElement.clientHeight, window.innerHeight, 1); - const documentWidth = Math.max(document.documentElement.scrollWidth, width); - const documentHeight = Math.max(document.documentElement.scrollHeight, height); - const overlay = screenshotOverlayFor(target); - const svg = buildScreenshotSvg({ - width, - height, - documentWidth, - documentHeight, - scrollX: window.scrollX, - scrollY: window.scrollY, - overlay - }); - const bytes = byteLength(svg); - const maxBytes = Number(state.options.screenshotMaxBytes || 0); - - if (maxBytes > 0 && bytes > maxBytes) { - return { - status: "omitted", - reason: "too-large", - kind: "viewport-svg", - mimeType: "image/svg+xml", - width, - height, - bytes, - maxBytes, - targetOverlay: overlay - }; - } - - return { - status: "captured", - kind: "viewport-svg", - mimeType: "image/svg+xml", - width, - height, - scrollX: Math.round(window.scrollX), - scrollY: Math.round(window.scrollY), - devicePixelRatio: window.devicePixelRatio || 1, - bytes, - targetOverlay: overlay, - dataUrl: `data:image/svg+xml;base64,${base64Encode(svg)}` - }; - } catch (error) { - return { - status: "failed", - error: error.message - }; - } -} - -function buildScreenshotSvg({ width, height, documentWidth, documentHeight, scrollX, scrollY, overlay }) { - const bodyClone = document.body.cloneNode(true); - bodyClone.querySelectorAll("[data-patchloop-root], [data-patchloop-pin], [data-patchloop-area], [data-patchloop-selection], script").forEach((node) => node.remove()); - bodyClone.querySelectorAll(".pl-target-highlight").forEach((node) => node.classList.remove("pl-target-highlight")); - - const bodyStyle = window.getComputedStyle(document.body); - // A transparent body paints the html (or default white) background; the - // snapshot must do the same instead of losing the page background. - const background = visibleBackground(bodyStyle.backgroundColor) - || visibleBackground(window.getComputedStyle(document.documentElement).backgroundColor) - || "#ffffff"; - const color = bodyStyle.color || "#14211d"; - const font = bodyStyle.font || bodyStyle.fontFamily || "system-ui, sans-serif"; - const htmlClassAttr = snapshotClassAttr(document.documentElement); - const bodyClassAttr = snapshotClassAttr(document.body); - // The tag is regenerated, so its inline style must be carried - // over; the snapshot's own layout overrides come after and win. - const bodyInlineStyle = String(document.body.getAttribute("style") || "").trim(); - const bodyStylePrefix = bodyInlineStyle ? bodyInlineStyle.replace(/;?$/, ";") : ""; - const styles = `${freezeViewportUnits(collectReadableStyles(), width, height)}\n* { box-sizing: border-box; }\n`; - const overlayMarkup = renderScreenshotOverlay(overlay); - const bodyMarkup = serializeAsXhtml(bodyClone); - - return ` - - - - - - - - - ${bodyMarkup} - - - - -${overlayMarkup} -`; -} - -function visibleBackground(value) { - if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return ""; - return value; -} - -function snapshotClassAttr(element) { - // The widget's own mode class (crosshair cursor) is capture-state, not - // page state, and must not leak into the snapshot. - const value = String(element.getAttribute("class") || "") - .split(/\s+/) - .filter((token) => token && token !== "pl-feedback-active") - .join(" "); - return value ? ` class="${escapeHtml(value)}"` : ""; + updateDownloadAllButton(); } -// The SVG is parsed as XML, so the clone must be serialized as XHTML: -// innerHTML emits HTML syntax (unclosed void elements like
, named -// entities like  ) that breaks XML parsing and renders the whole -// snapshot as a broken image. XMLSerializer self-closes void elements and -// emits characters instead of HTML-only entities. -function serializeAsXhtml(root) { - const serializer = new XMLSerializer(); - return Array.from(root.childNodes) - .map((node) => { - try { - return serializer.serializeToString(node); - } catch (_) { - return ""; - } - }) - .join(""); +function showFormError(form, message) { + const errorEl = form?.querySelector("[data-pl-form-error]"); + if (!errorEl) return; + errorEl.textContent = message; + errorEl.hidden = false; } -// Media queries inside the snapshot re-evaluate against the SVG's rendered -// size (e.g. a scaled-down inbox preview), reflowing the clone away from the -// captured layout while overlay coordinates stay fixed. Resolve media -// conditions at capture time instead: inline the rules that match the -// current viewport and drop the rest, so the snapshot keeps the captured -// layout at any display size. -function collectReadableStyles() { - const chunks = []; - const mediaMatches = (mediaText) => window.matchMedia(mediaText).matches; - const sheets = [...Array.from(document.styleSheets), ...Array.from(document.adoptedStyleSheets || [])]; - sheets.forEach((sheet) => { - try { - if (sheet.ownerNode?.dataset?.patchloopStyle) return; - if (sheet.disabled) return; - if (sheet.media && sheet.media.mediaText && !mediaMatches(sheet.media.mediaText)) return; - const flattened = flattenRulesForSnapshot(sheet.cssRules, mediaMatches); - if (flattened) chunks.push(flattened); - } catch (_) { - // Cross-origin stylesheets cannot be read. The snapshot still includes DOM and overlay context. - } - }); - return chunks.join("\n"); +function clearFormError(form) { + const errorEl = form?.querySelector("[data-pl-form-error]"); + if (!errorEl) return; + errorEl.textContent = ""; + errorEl.hidden = true; } -// Overlay coordinates must be viewport-relative at capture time, so derive -// them from the page-pixel position (kept fresh by re-anchoring) and the -// current scroll instead of the click-time client coordinates. -function screenshotOverlayFor(target) { - if (target.kind === "area" && target.area) { - return { - kind: "area", - x: Math.round(target.area.pageX - window.scrollX), - y: Math.round(target.area.pageY - window.scrollY), - width: Math.round(target.area.clientWidth), - height: Math.round(target.area.clientHeight) - }; - } +async function submitComment(event) { + event.preventDefault(); - return { - kind: "point", - x: Math.round(target.pageX - window.scrollX), - y: Math.round(target.pageY - window.scrollY) - }; -} + const root = getRoot(); + const form = root.querySelector("[data-pl-comment]"); + const comment = root.querySelector("[data-pl-comment-text]").value.trim(); + const reviewer = root.querySelector("[data-pl-reviewer]").value.trim(); + if (!comment) return; + if (!reviewer) { + showFormError(form, "投稿者名を入力してください。"); + root.querySelector("[data-pl-reviewer]").focus(); + return; + } + clearFormError(form); -function renderScreenshotOverlay(overlay) { - if (!overlay) return ""; - if (overlay.kind === "area") { - const x = Math.max(0, overlay.x); - const y = Math.max(0, overlay.y); - const width = Math.max(1, overlay.width); - const height = Math.max(1, overlay.height); - return ` - - -!`; + if (state.editingId) { + const target = state.feedback.find((item) => item.id === state.editingId); + if (target) { + const changed = target.comment !== comment || target.reviewer !== reviewer; + target.comment = comment; + target.reviewer = reviewer; + // An edited comment that was already exported must re-enter the unsent + // batch, otherwise the correction never reaches the receiver. + if (changed && target.exported) { + delete target.exported; + delete target.exportedAt; + delete target.exportedFileName; + } + saveReviewer(reviewer); + persistFeedbackList(); + renderFeedbackList(); + } + state.editingId = null; + closeCommentForm(); + return; } - return ` - -`; -} + if (!state.pendingTarget) return; -function byteLength(value) { - if (window.Blob) return new Blob([value]).size; - return base64Encode(value).length; -} + saveReviewer(reviewer); + const payload = buildPayload(comment, reviewer, state.pendingTarget); + state.feedback.unshift(payload); + finalizePendingMarker(payload.id); + persistFeedbackList(); + renderFeedbackList(); + expandPanel(); + closeCommentForm(); -function base64Encode(value) { - const bytes = new TextEncoder().encode(value); - let binary = ""; - for (let i = 0; i < bytes.length; i += 8192) { - binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + document.dispatchEvent(new CustomEvent("patchloop:feedback", { detail: payload })); + + if (typeof state.options.onSubmit === "function") { + state.options.onSubmit(payload); + } + + if (shouldDeliverFeedback()) { + await deliverFeedback(payload); + persistFeedbackList(); + renderFeedbackList(); } - return btoa(binary); } async function postFeedback(payload) { @@ -1194,25 +1336,6 @@ async function postFeedback(payload) { console.info("[PatchLoop] delivery", payload.id, payload.delivery); } -function loadStoredReviewer(storageKey) { - if (!storageKey) return ""; - try { - return String(window.localStorage.getItem(storageKey) || "").trim(); - } catch (_) { - return ""; - } -} - -function saveReviewer(reviewer) { - state.options.reviewer = reviewer; - if (!state.options.reviewerStorageKey) return; - try { - window.localStorage.setItem(state.options.reviewerStorageKey, reviewer); - } catch (_) { - // Storage can be unavailable in privacy-restricted contexts. - } -} - function restorePersistedFeedback() { removeCommittedMarkers(); state.feedbackMarkers.clear(); @@ -1228,89 +1351,6 @@ function restorePersistedFeedback() { persistFeedbackList(); } -function loadPersistedFeedback() { - if (!state.options.feedbackStorageKey) return []; - - try { - const raw = window.localStorage.getItem(state.options.feedbackStorageKey); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!isMatchingFeedbackEnvelope(parsed)) return []; - return Array.isArray(parsed.feedback) - ? parsed.feedback.map(normalizePersistedFeedback).filter(Boolean) - : []; - } catch (error) { - console.warn("[PatchLoop] persisted feedback ignored", error); - return []; - } -} - -function persistFeedbackList() { - if (!state.options.persistFeedback || !state.options.feedbackStorageKey) return; - - const envelope = feedbackStorageEnvelope(state.feedback); - try { - window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(envelope)); - } catch (error) { - const compactEnvelope = feedbackStorageEnvelope(state.feedback, { omitScreenshotDataUrl: true }); - try { - window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(compactEnvelope)); - console.warn("[PatchLoop] persisted feedback without screenshot dataUrl", error); - } catch (retryError) { - console.warn("[PatchLoop] unable to persist feedback", retryError); - } - } -} - -function clearPersistedFeedback() { - if (!state.options.feedbackStorageKey) return; - - try { - window.localStorage.removeItem(state.options.feedbackStorageKey); - } catch (_) { - // Storage can be unavailable in privacy-restricted contexts. - } -} - -function feedbackStorageEnvelope(feedback, options = {}) { - return { - version: FEEDBACK_STORAGE_VERSION, - projectId: state.options.projectId, - demoId: state.options.demoId, - pageUrl: window.location.href, - savedAt: new Date().toISOString(), - feedback: feedback.map((item) => serializeFeedbackForStorage(item, options)).filter(Boolean) - }; -} - -function serializeFeedbackForStorage(item, options = {}) { - try { - const copy = JSON.parse(JSON.stringify(item)); - if (options.omitScreenshotDataUrl && copy.screenshot) { - delete copy.screenshot.dataUrl; - copy.screenshot.persistedWithoutDataUrl = true; - } - return copy; - } catch (_) { - return null; - } -} - -function normalizePersistedFeedback(item) { - if (!item || typeof item !== "object") return null; - if (!item.id || !item.target || typeof item.target !== "object") return null; - return item; -} - -function isMatchingFeedbackEnvelope(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - if (value.version !== FEEDBACK_STORAGE_VERSION) return false; - if (value.projectId !== state.options.projectId) return false; - if (value.demoId !== state.options.demoId) return false; - if (!samePersistedPage(value.pageUrl, window.location.href)) return false; - return Array.isArray(value.feedback); -} - function shouldDeliverFeedback() { if (state.options.deliveryMode === "none") return false; // Download mode no longer ships per comment; the reviewer exports the @@ -2069,15 +2109,6 @@ function addArea(rect) { return { node: area, label }; } -// Date.now() alone collides across tabs/reviewers within the same -// millisecond, which also collides marker Map keys and orphans nodes. -function generateFeedbackId() { - const random = globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" - ? globalThis.crypto.randomUUID().slice(0, 8) - : Math.random().toString(36).slice(2, 10); - return `pl_${Date.now()}_${random}`; -} - function getRoot() { return document.querySelector("[data-patchloop-root]"); } diff --git a/test/widget-persistence.test.js b/test/widget-persistence.test.js new file mode 100644 index 0000000..719222e --- /dev/null +++ b/test/widget-persistence.test.js @@ -0,0 +1,81 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +// isMatchingFeedbackEnvelope compares the stored pageUrl against +// window.location.href at call time; node has no window, so provide the one +// global the pure checks read (node --test isolates globals per test file). +globalThis.window = { location: { href: "https://demo.example/app?utm=x#top" } }; + +const { state } = require("../widget/src/state.js"); +const { FEEDBACK_STORAGE_VERSION, serializeFeedbackForStorage, normalizePersistedFeedback, isMatchingFeedbackEnvelope } = require("../widget/src/persistence.js"); + +state.options.projectId = "proj-a"; +state.options.demoId = "demo-1"; + +function envelope(overrides = {}) { + return { + version: FEEDBACK_STORAGE_VERSION, + projectId: "proj-a", + demoId: "demo-1", + pageUrl: "https://demo.example/app", + feedback: [], + ...overrides + }; +} + +test("serializeFeedbackForStorage deep-copies the item and round-trips through normalize", () => { + const item = { + id: "pl_1", + target: { kind: "point", x: 12.3, selector: "main > p" }, + screenshot: { status: "captured", dataUrl: "data:image/svg+xml;base64,abc" } + }; + const copy = serializeFeedbackForStorage(item); + assert.notEqual(copy, item); + assert.deepEqual(copy, item); + assert.equal(normalizePersistedFeedback(copy), copy); +}); + +test("serializeFeedbackForStorage omits the screenshot dataUrl on request without touching the item", () => { + const item = { id: "pl_2", target: {}, screenshot: { status: "captured", dataUrl: "data:image/svg+xml;base64,abc" } }; + const copy = serializeFeedbackForStorage(item, { omitScreenshotDataUrl: true }); + assert.equal(copy.screenshot.dataUrl, undefined); + assert.equal(copy.screenshot.persistedWithoutDataUrl, true); + assert.equal(item.screenshot.dataUrl, "data:image/svg+xml;base64,abc"); +}); + +test("serializeFeedbackForStorage returns null for unserializable items", () => { + const item = { id: "pl_3", target: {} }; + item.self = item; + assert.equal(serializeFeedbackForStorage(item), null); +}); + +test("normalizePersistedFeedback keeps items with an id and an object target", () => { + const item = { id: "pl_4", target: { kind: "area" } }; + assert.equal(normalizePersistedFeedback(item), item); +}); + +test("normalizePersistedFeedback rejects malformed entries", () => { + assert.equal(normalizePersistedFeedback(null), null); + assert.equal(normalizePersistedFeedback("text"), null); + assert.equal(normalizePersistedFeedback({ target: {} }), null); + assert.equal(normalizePersistedFeedback({ id: "x" }), null); + assert.equal(normalizePersistedFeedback({ id: "x", target: "main > p" }), null); +}); + +test("isMatchingFeedbackEnvelope matches the current version/project/demo/page", () => { + assert.equal(isMatchingFeedbackEnvelope(envelope()), true); + // Query string and hash differences on the same page still match. + assert.equal(isMatchingFeedbackEnvelope(envelope({ pageUrl: "https://demo.example/app?other=1#sec" })), true); +}); + +test("isMatchingFeedbackEnvelope rejects mismatched envelopes", () => { + assert.equal(isMatchingFeedbackEnvelope(null), false); + assert.equal(isMatchingFeedbackEnvelope([]), false); + assert.equal(isMatchingFeedbackEnvelope(envelope({ version: FEEDBACK_STORAGE_VERSION + 1 })), false); + assert.equal(isMatchingFeedbackEnvelope(envelope({ projectId: "someone-else" })), false); + assert.equal(isMatchingFeedbackEnvelope(envelope({ demoId: "other-demo" })), false); + assert.equal(isMatchingFeedbackEnvelope(envelope({ pageUrl: "https://demo.example/other" })), false); + assert.equal(isMatchingFeedbackEnvelope(envelope({ feedback: {} })), false); +}); diff --git a/test/widget-screenshot.test.js b/test/widget-screenshot.test.js new file mode 100644 index 0000000..5909930 --- /dev/null +++ b/test/widget-screenshot.test.js @@ -0,0 +1,37 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +// byteLength probes window.Blob before constructing a Blob; node has a global +// Blob but no window, so expose it (node --test isolates globals per file). +globalThis.window = { Blob: globalThis.Blob }; + +const { byteLength, base64Encode } = require("../widget/src/screenshot.js"); + +test("base64Encode matches Buffer's base64 for ascii and multi-byte text", () => { + for (const value of ["", "hello", "日本語のテキスト✓", '&']) { + assert.equal(base64Encode(value), Buffer.from(value, "utf8").toString("base64")); + } +}); + +test("base64Encode handles inputs larger than its 8192-byte chunks", () => { + const value = "svg-content-".repeat(3000); + assert.equal(base64Encode(value), Buffer.from(value, "utf8").toString("base64")); +}); + +test("byteLength counts utf-8 bytes via Blob", () => { + assert.equal(byteLength(""), 0); + assert.equal(byteLength("hello"), 5); + assert.equal(byteLength("日本語"), Buffer.byteLength("日本語", "utf8")); +}); + +test("byteLength falls back to the base64 length without Blob", () => { + const original = globalThis.window.Blob; + globalThis.window.Blob = undefined; + try { + assert.equal(byteLength("日本語"), base64Encode("日本語").length); + } finally { + globalThis.window.Blob = original; + } +}); diff --git a/widget/src/index.js b/widget/src/index.js index b084900..edc1724 100644 --- a/widget/src/index.js +++ b/widget/src/index.js @@ -1,62 +1,17 @@ import { pointFromClient, rectFromPoints, rectContainsArea, pointFromStoredTarget, rectFromStoredArea, round, numberOrNull } from "./geometry.js"; -import { pointAnchorOffsets, areaAnchorOffsets, roundedAnchor, geometryFromAnchor, viewportDiffersFromCreation } from "./anchoring.js"; +import { pointAnchorOffsets, areaAnchorOffsets, geometryFromAnchor, viewportDiffersFromCreation } from "./anchoring.js"; import { selectorFor, textFor } from "./selector.js"; -import { freezeViewportUnits, flattenRulesForSnapshot } from "./snapshot-css.js"; -import { samePersistedPage } from "./url.js"; import { resolveSourceContext } from "./source-context.js"; -import { truncateText, present, escapeHtml, escapeXml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } from "../../shared/format.js"; - -const DEFAULTS = { - projectId: "local-demo", - demoId: "plain-html", - endpoint: "", - // Public per-project key sent with receiver posts (#44). It ships in the - // page, so it identifies the project and blocks indiscriminate spam rather - // than acting as a secret. Empty = receiver runs with open ingest. - ingestKey: "", - // Git provenance of the page under review (#96): { repo, branch, commit, - // root, buildUrl, previewUrl }, all optional strings. The embedding side - // injects real values at build/deploy time; tags - // fill any missing field. - sourceContext: null, - deliveryMode: "receiver", - slackWebhookUrl: "", - showDeliverySettings: false, - reviewer: "", - reviewerStorageKey: "patchloop:reviewer", - persistFeedback: true, - feedbackStorageKey: "patchloop:feedback", - position: "bottom-right", - captureScreenshot: true, - screenshotMaxBytes: 1_200_000, - onSubmit: null -}; +import { DEFAULTS, state } from "./state.js"; +import { buildPayload } from "./payload.js"; +import { loadStoredReviewer, saveReviewer, persistFeedbackList, loadPersistedFeedback, clearPersistedFeedback } from "./persistence.js"; +import { truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } from "../../shared/format.js"; const EXPORT_KIND = "patchloop-feedback-bundle"; // v2 carries an array of feedback (batch export). v1 wrapped a single // payload; the receiver still accepts that shape for files exported before // the switch to batch download. const EXPORT_VERSION = 2; -const FEEDBACK_STORAGE_VERSION = 1; -// Version of the feedback payload schema itself (distinct from the storage -// envelope and export bundle versions). Bump when the payload shape changes -// so the receiver can branch on it as the schema grows for team use. -// v2 adds the optional sourceContext block (#96). -const PAYLOAD_SCHEMA_VERSION = 2; - -const state = { - options: { ...DEFAULTS }, - active: false, - pendingTarget: null, - drag: null, - suppressNextClick: false, - feedback: [], - feedbackMarkers: new Map(), - approximateIds: new Set(), - resizeTimer: null, - editingId: null, - collapsed: true -}; function init(options = {}) { // From there is no body yet to mount into; retry once the DOM is ready. @@ -496,254 +451,6 @@ async function submitComment(event) { } } -function buildPayload(comment, reviewer, target) { - return { - schemaVersion: PAYLOAD_SCHEMA_VERSION, - id: generateFeedbackId(), - projectId: state.options.projectId, - demoId: state.options.demoId, - comment, - reviewer, - page: { - url: window.location.href, - title: document.title - }, - sourceContext: state.options.sourceContext, - target: { - kind: target.kind || "point", - x: round(target.x), - y: round(target.y), - clientX: Math.round(target.clientX), - clientY: Math.round(target.clientY), - pageX: Math.round(target.pageX), - pageY: Math.round(target.pageY), - documentX: round(target.documentX), - documentY: round(target.documentY), - area: target.area || null, - selector: target.selector, - text: target.elementText, - anchor: roundedAnchor(target.anchor) - }, - environment: { - viewport: { - width: window.innerWidth, - height: window.innerHeight - }, - browser: navigator.userAgent, - language: navigator.language - }, - screenshot: captureScreenshot(target), - createdAt: new Date().toISOString() - }; -} - -function captureScreenshot(target) { - if (!state.options.captureScreenshot) return null; - - try { - const width = Math.max(document.documentElement.clientWidth, window.innerWidth, 1); - const height = Math.max(document.documentElement.clientHeight, window.innerHeight, 1); - const documentWidth = Math.max(document.documentElement.scrollWidth, width); - const documentHeight = Math.max(document.documentElement.scrollHeight, height); - const overlay = screenshotOverlayFor(target); - const svg = buildScreenshotSvg({ - width, - height, - documentWidth, - documentHeight, - scrollX: window.scrollX, - scrollY: window.scrollY, - overlay - }); - const bytes = byteLength(svg); - const maxBytes = Number(state.options.screenshotMaxBytes || 0); - - if (maxBytes > 0 && bytes > maxBytes) { - return { - status: "omitted", - reason: "too-large", - kind: "viewport-svg", - mimeType: "image/svg+xml", - width, - height, - bytes, - maxBytes, - targetOverlay: overlay - }; - } - - return { - status: "captured", - kind: "viewport-svg", - mimeType: "image/svg+xml", - width, - height, - scrollX: Math.round(window.scrollX), - scrollY: Math.round(window.scrollY), - devicePixelRatio: window.devicePixelRatio || 1, - bytes, - targetOverlay: overlay, - dataUrl: `data:image/svg+xml;base64,${base64Encode(svg)}` - }; - } catch (error) { - return { - status: "failed", - error: error.message - }; - } -} - -function buildScreenshotSvg({ width, height, documentWidth, documentHeight, scrollX, scrollY, overlay }) { - const bodyClone = document.body.cloneNode(true); - bodyClone.querySelectorAll("[data-patchloop-root], [data-patchloop-pin], [data-patchloop-area], [data-patchloop-selection], script").forEach((node) => node.remove()); - bodyClone.querySelectorAll(".pl-target-highlight").forEach((node) => node.classList.remove("pl-target-highlight")); - - const bodyStyle = window.getComputedStyle(document.body); - // A transparent body paints the html (or default white) background; the - // snapshot must do the same instead of losing the page background. - const background = visibleBackground(bodyStyle.backgroundColor) - || visibleBackground(window.getComputedStyle(document.documentElement).backgroundColor) - || "#ffffff"; - const color = bodyStyle.color || "#14211d"; - const font = bodyStyle.font || bodyStyle.fontFamily || "system-ui, sans-serif"; - const htmlClassAttr = snapshotClassAttr(document.documentElement); - const bodyClassAttr = snapshotClassAttr(document.body); - // The tag is regenerated, so its inline style must be carried - // over; the snapshot's own layout overrides come after and win. - const bodyInlineStyle = String(document.body.getAttribute("style") || "").trim(); - const bodyStylePrefix = bodyInlineStyle ? bodyInlineStyle.replace(/;?$/, ";") : ""; - const styles = `${freezeViewportUnits(collectReadableStyles(), width, height)}\n* { box-sizing: border-box; }\n`; - const overlayMarkup = renderScreenshotOverlay(overlay); - const bodyMarkup = serializeAsXhtml(bodyClone); - - return ` - - - - - - - - - ${bodyMarkup} - - - - -${overlayMarkup} -`; -} - -function visibleBackground(value) { - if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return ""; - return value; -} - -function snapshotClassAttr(element) { - // The widget's own mode class (crosshair cursor) is capture-state, not - // page state, and must not leak into the snapshot. - const value = String(element.getAttribute("class") || "") - .split(/\s+/) - .filter((token) => token && token !== "pl-feedback-active") - .join(" "); - return value ? ` class="${escapeHtml(value)}"` : ""; -} - -// The SVG is parsed as XML, so the clone must be serialized as XHTML: -// innerHTML emits HTML syntax (unclosed void elements like
, named -// entities like  ) that breaks XML parsing and renders the whole -// snapshot as a broken image. XMLSerializer self-closes void elements and -// emits characters instead of HTML-only entities. -function serializeAsXhtml(root) { - const serializer = new XMLSerializer(); - return Array.from(root.childNodes) - .map((node) => { - try { - return serializer.serializeToString(node); - } catch (_) { - return ""; - } - }) - .join(""); -} - -// Media queries inside the snapshot re-evaluate against the SVG's rendered -// size (e.g. a scaled-down inbox preview), reflowing the clone away from the -// captured layout while overlay coordinates stay fixed. Resolve media -// conditions at capture time instead: inline the rules that match the -// current viewport and drop the rest, so the snapshot keeps the captured -// layout at any display size. -function collectReadableStyles() { - const chunks = []; - const mediaMatches = (mediaText) => window.matchMedia(mediaText).matches; - const sheets = [...Array.from(document.styleSheets), ...Array.from(document.adoptedStyleSheets || [])]; - sheets.forEach((sheet) => { - try { - if (sheet.ownerNode?.dataset?.patchloopStyle) return; - if (sheet.disabled) return; - if (sheet.media && sheet.media.mediaText && !mediaMatches(sheet.media.mediaText)) return; - const flattened = flattenRulesForSnapshot(sheet.cssRules, mediaMatches); - if (flattened) chunks.push(flattened); - } catch (_) { - // Cross-origin stylesheets cannot be read. The snapshot still includes DOM and overlay context. - } - }); - return chunks.join("\n"); -} - -// Overlay coordinates must be viewport-relative at capture time, so derive -// them from the page-pixel position (kept fresh by re-anchoring) and the -// current scroll instead of the click-time client coordinates. -function screenshotOverlayFor(target) { - if (target.kind === "area" && target.area) { - return { - kind: "area", - x: Math.round(target.area.pageX - window.scrollX), - y: Math.round(target.area.pageY - window.scrollY), - width: Math.round(target.area.clientWidth), - height: Math.round(target.area.clientHeight) - }; - } - - return { - kind: "point", - x: Math.round(target.pageX - window.scrollX), - y: Math.round(target.pageY - window.scrollY) - }; -} - -function renderScreenshotOverlay(overlay) { - if (!overlay) return ""; - if (overlay.kind === "area") { - const x = Math.max(0, overlay.x); - const y = Math.max(0, overlay.y); - const width = Math.max(1, overlay.width); - const height = Math.max(1, overlay.height); - return ` - - -!`; - } - - return ` - -`; -} - -function byteLength(value) { - if (window.Blob) return new Blob([value]).size; - return base64Encode(value).length; -} - -function base64Encode(value) { - const bytes = new TextEncoder().encode(value); - let binary = ""; - for (let i = 0; i < bytes.length; i += 8192) { - binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); - } - return btoa(binary); -} - async function postFeedback(payload) { try { const headers = { "Content-Type": "application/json" }; @@ -762,25 +469,6 @@ async function postFeedback(payload) { console.info("[PatchLoop] delivery", payload.id, payload.delivery); } -function loadStoredReviewer(storageKey) { - if (!storageKey) return ""; - try { - return String(window.localStorage.getItem(storageKey) || "").trim(); - } catch (_) { - return ""; - } -} - -function saveReviewer(reviewer) { - state.options.reviewer = reviewer; - if (!state.options.reviewerStorageKey) return; - try { - window.localStorage.setItem(state.options.reviewerStorageKey, reviewer); - } catch (_) { - // Storage can be unavailable in privacy-restricted contexts. - } -} - function restorePersistedFeedback() { removeCommittedMarkers(); state.feedbackMarkers.clear(); @@ -796,89 +484,6 @@ function restorePersistedFeedback() { persistFeedbackList(); } -function loadPersistedFeedback() { - if (!state.options.feedbackStorageKey) return []; - - try { - const raw = window.localStorage.getItem(state.options.feedbackStorageKey); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!isMatchingFeedbackEnvelope(parsed)) return []; - return Array.isArray(parsed.feedback) - ? parsed.feedback.map(normalizePersistedFeedback).filter(Boolean) - : []; - } catch (error) { - console.warn("[PatchLoop] persisted feedback ignored", error); - return []; - } -} - -function persistFeedbackList() { - if (!state.options.persistFeedback || !state.options.feedbackStorageKey) return; - - const envelope = feedbackStorageEnvelope(state.feedback); - try { - window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(envelope)); - } catch (error) { - const compactEnvelope = feedbackStorageEnvelope(state.feedback, { omitScreenshotDataUrl: true }); - try { - window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(compactEnvelope)); - console.warn("[PatchLoop] persisted feedback without screenshot dataUrl", error); - } catch (retryError) { - console.warn("[PatchLoop] unable to persist feedback", retryError); - } - } -} - -function clearPersistedFeedback() { - if (!state.options.feedbackStorageKey) return; - - try { - window.localStorage.removeItem(state.options.feedbackStorageKey); - } catch (_) { - // Storage can be unavailable in privacy-restricted contexts. - } -} - -function feedbackStorageEnvelope(feedback, options = {}) { - return { - version: FEEDBACK_STORAGE_VERSION, - projectId: state.options.projectId, - demoId: state.options.demoId, - pageUrl: window.location.href, - savedAt: new Date().toISOString(), - feedback: feedback.map((item) => serializeFeedbackForStorage(item, options)).filter(Boolean) - }; -} - -function serializeFeedbackForStorage(item, options = {}) { - try { - const copy = JSON.parse(JSON.stringify(item)); - if (options.omitScreenshotDataUrl && copy.screenshot) { - delete copy.screenshot.dataUrl; - copy.screenshot.persistedWithoutDataUrl = true; - } - return copy; - } catch (_) { - return null; - } -} - -function normalizePersistedFeedback(item) { - if (!item || typeof item !== "object") return null; - if (!item.id || !item.target || typeof item.target !== "object") return null; - return item; -} - -function isMatchingFeedbackEnvelope(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - if (value.version !== FEEDBACK_STORAGE_VERSION) return false; - if (value.projectId !== state.options.projectId) return false; - if (value.demoId !== state.options.demoId) return false; - if (!samePersistedPage(value.pageUrl, window.location.href)) return false; - return Array.isArray(value.feedback); -} - function shouldDeliverFeedback() { if (state.options.deliveryMode === "none") return false; // Download mode no longer ships per comment; the reviewer exports the @@ -1637,15 +1242,6 @@ function addArea(rect) { return { node: area, label }; } -// Date.now() alone collides across tabs/reviewers within the same -// millisecond, which also collides marker Map keys and orphans nodes. -function generateFeedbackId() { - const random = globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" - ? globalThis.crypto.randomUUID().slice(0, 8) - : Math.random().toString(36).slice(2, 10); - return `pl_${Date.now()}_${random}`; -} - function getRoot() { return document.querySelector("[data-patchloop-root]"); } diff --git a/widget/src/payload.js b/widget/src/payload.js new file mode 100644 index 0000000..388848a --- /dev/null +++ b/widget/src/payload.js @@ -0,0 +1,60 @@ +import { round } from "./geometry.js"; +import { roundedAnchor } from "./anchoring.js"; +import { state } from "./state.js"; +import { captureScreenshot } from "./screenshot.js"; + +// Version of the feedback payload schema itself (distinct from the storage +// envelope and export bundle versions). Bump when the payload shape changes +// so the receiver can branch on it as the schema grows for team use. +// v2 adds the optional sourceContext block (#96). +const PAYLOAD_SCHEMA_VERSION = 2; + +export function buildPayload(comment, reviewer, target) { + return { + schemaVersion: PAYLOAD_SCHEMA_VERSION, + id: generateFeedbackId(), + projectId: state.options.projectId, + demoId: state.options.demoId, + comment, + reviewer, + page: { + url: window.location.href, + title: document.title + }, + sourceContext: state.options.sourceContext, + target: { + kind: target.kind || "point", + x: round(target.x), + y: round(target.y), + clientX: Math.round(target.clientX), + clientY: Math.round(target.clientY), + pageX: Math.round(target.pageX), + pageY: Math.round(target.pageY), + documentX: round(target.documentX), + documentY: round(target.documentY), + area: target.area || null, + selector: target.selector, + text: target.elementText, + anchor: roundedAnchor(target.anchor) + }, + environment: { + viewport: { + width: window.innerWidth, + height: window.innerHeight + }, + browser: navigator.userAgent, + language: navigator.language + }, + screenshot: captureScreenshot(target), + createdAt: new Date().toISOString() + }; +} + +// Date.now() alone collides across tabs/reviewers within the same +// millisecond, which also collides marker Map keys and orphans nodes. +function generateFeedbackId() { + const random = globalThis.crypto && typeof globalThis.crypto.randomUUID === "function" + ? globalThis.crypto.randomUUID().slice(0, 8) + : Math.random().toString(36).slice(2, 10); + return `pl_${Date.now()}_${random}`; +} diff --git a/widget/src/persistence.js b/widget/src/persistence.js new file mode 100644 index 0000000..5f8bac4 --- /dev/null +++ b/widget/src/persistence.js @@ -0,0 +1,106 @@ +import { state } from "./state.js"; +import { samePersistedPage } from "./url.js"; + +export const FEEDBACK_STORAGE_VERSION = 1; + +export function loadStoredReviewer(storageKey) { + if (!storageKey) return ""; + try { + return String(window.localStorage.getItem(storageKey) || "").trim(); + } catch (_) { + return ""; + } +} + +export function saveReviewer(reviewer) { + state.options.reviewer = reviewer; + if (!state.options.reviewerStorageKey) return; + try { + window.localStorage.setItem(state.options.reviewerStorageKey, reviewer); + } catch (_) { + // Storage can be unavailable in privacy-restricted contexts. + } +} + +export function loadPersistedFeedback() { + if (!state.options.feedbackStorageKey) return []; + + try { + const raw = window.localStorage.getItem(state.options.feedbackStorageKey); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!isMatchingFeedbackEnvelope(parsed)) return []; + return Array.isArray(parsed.feedback) + ? parsed.feedback.map(normalizePersistedFeedback).filter(Boolean) + : []; + } catch (error) { + console.warn("[PatchLoop] persisted feedback ignored", error); + return []; + } +} + +export function persistFeedbackList() { + if (!state.options.persistFeedback || !state.options.feedbackStorageKey) return; + + const envelope = feedbackStorageEnvelope(state.feedback); + try { + window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(envelope)); + } catch (error) { + const compactEnvelope = feedbackStorageEnvelope(state.feedback, { omitScreenshotDataUrl: true }); + try { + window.localStorage.setItem(state.options.feedbackStorageKey, JSON.stringify(compactEnvelope)); + console.warn("[PatchLoop] persisted feedback without screenshot dataUrl", error); + } catch (retryError) { + console.warn("[PatchLoop] unable to persist feedback", retryError); + } + } +} + +export function clearPersistedFeedback() { + if (!state.options.feedbackStorageKey) return; + + try { + window.localStorage.removeItem(state.options.feedbackStorageKey); + } catch (_) { + // Storage can be unavailable in privacy-restricted contexts. + } +} + +function feedbackStorageEnvelope(feedback, options = {}) { + return { + version: FEEDBACK_STORAGE_VERSION, + projectId: state.options.projectId, + demoId: state.options.demoId, + pageUrl: window.location.href, + savedAt: new Date().toISOString(), + feedback: feedback.map((item) => serializeFeedbackForStorage(item, options)).filter(Boolean) + }; +} + +export function serializeFeedbackForStorage(item, options = {}) { + try { + const copy = JSON.parse(JSON.stringify(item)); + if (options.omitScreenshotDataUrl && copy.screenshot) { + delete copy.screenshot.dataUrl; + copy.screenshot.persistedWithoutDataUrl = true; + } + return copy; + } catch (_) { + return null; + } +} + +export function normalizePersistedFeedback(item) { + if (!item || typeof item !== "object") return null; + if (!item.id || !item.target || typeof item.target !== "object") return null; + return item; +} + +export function isMatchingFeedbackEnvelope(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + if (value.version !== FEEDBACK_STORAGE_VERSION) return false; + if (value.projectId !== state.options.projectId) return false; + if (value.demoId !== state.options.demoId) return false; + if (!samePersistedPage(value.pageUrl, window.location.href)) return false; + return Array.isArray(value.feedback); +} diff --git a/widget/src/screenshot.js b/widget/src/screenshot.js new file mode 100644 index 0000000..de8ad32 --- /dev/null +++ b/widget/src/screenshot.js @@ -0,0 +1,210 @@ +import { state } from "./state.js"; +import { freezeViewportUnits, flattenRulesForSnapshot } from "./snapshot-css.js"; +import { escapeHtml, escapeXml } from "../../shared/format.js"; + +export function captureScreenshot(target) { + if (!state.options.captureScreenshot) return null; + + try { + const width = Math.max(document.documentElement.clientWidth, window.innerWidth, 1); + const height = Math.max(document.documentElement.clientHeight, window.innerHeight, 1); + const documentWidth = Math.max(document.documentElement.scrollWidth, width); + const documentHeight = Math.max(document.documentElement.scrollHeight, height); + const overlay = screenshotOverlayFor(target); + const svg = buildScreenshotSvg({ + width, + height, + documentWidth, + documentHeight, + scrollX: window.scrollX, + scrollY: window.scrollY, + overlay + }); + const bytes = byteLength(svg); + const maxBytes = Number(state.options.screenshotMaxBytes || 0); + + if (maxBytes > 0 && bytes > maxBytes) { + return { + status: "omitted", + reason: "too-large", + kind: "viewport-svg", + mimeType: "image/svg+xml", + width, + height, + bytes, + maxBytes, + targetOverlay: overlay + }; + } + + return { + status: "captured", + kind: "viewport-svg", + mimeType: "image/svg+xml", + width, + height, + scrollX: Math.round(window.scrollX), + scrollY: Math.round(window.scrollY), + devicePixelRatio: window.devicePixelRatio || 1, + bytes, + targetOverlay: overlay, + dataUrl: `data:image/svg+xml;base64,${base64Encode(svg)}` + }; + } catch (error) { + return { + status: "failed", + error: error.message + }; + } +} + +function buildScreenshotSvg({ width, height, documentWidth, documentHeight, scrollX, scrollY, overlay }) { + const bodyClone = document.body.cloneNode(true); + bodyClone.querySelectorAll("[data-patchloop-root], [data-patchloop-pin], [data-patchloop-area], [data-patchloop-selection], script").forEach((node) => node.remove()); + bodyClone.querySelectorAll(".pl-target-highlight").forEach((node) => node.classList.remove("pl-target-highlight")); + + const bodyStyle = window.getComputedStyle(document.body); + // A transparent body paints the html (or default white) background; the + // snapshot must do the same instead of losing the page background. + const background = visibleBackground(bodyStyle.backgroundColor) + || visibleBackground(window.getComputedStyle(document.documentElement).backgroundColor) + || "#ffffff"; + const color = bodyStyle.color || "#14211d"; + const font = bodyStyle.font || bodyStyle.fontFamily || "system-ui, sans-serif"; + const htmlClassAttr = snapshotClassAttr(document.documentElement); + const bodyClassAttr = snapshotClassAttr(document.body); + // The tag is regenerated, so its inline style must be carried + // over; the snapshot's own layout overrides come after and win. + const bodyInlineStyle = String(document.body.getAttribute("style") || "").trim(); + const bodyStylePrefix = bodyInlineStyle ? bodyInlineStyle.replace(/;?$/, ";") : ""; + const styles = `${freezeViewportUnits(collectReadableStyles(), width, height)}\n* { box-sizing: border-box; }\n`; + const overlayMarkup = renderScreenshotOverlay(overlay); + const bodyMarkup = serializeAsXhtml(bodyClone); + + return ` + + + + + + + + + ${bodyMarkup} + + + + +${overlayMarkup} +`; +} + +function visibleBackground(value) { + if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return ""; + return value; +} + +function snapshotClassAttr(element) { + // The widget's own mode class (crosshair cursor) is capture-state, not + // page state, and must not leak into the snapshot. + const value = String(element.getAttribute("class") || "") + .split(/\s+/) + .filter((token) => token && token !== "pl-feedback-active") + .join(" "); + return value ? ` class="${escapeHtml(value)}"` : ""; +} + +// The SVG is parsed as XML, so the clone must be serialized as XHTML: +// innerHTML emits HTML syntax (unclosed void elements like
, named +// entities like  ) that breaks XML parsing and renders the whole +// snapshot as a broken image. XMLSerializer self-closes void elements and +// emits characters instead of HTML-only entities. +function serializeAsXhtml(root) { + const serializer = new XMLSerializer(); + return Array.from(root.childNodes) + .map((node) => { + try { + return serializer.serializeToString(node); + } catch (_) { + return ""; + } + }) + .join(""); +} + +// Media queries inside the snapshot re-evaluate against the SVG's rendered +// size (e.g. a scaled-down inbox preview), reflowing the clone away from the +// captured layout while overlay coordinates stay fixed. Resolve media +// conditions at capture time instead: inline the rules that match the +// current viewport and drop the rest, so the snapshot keeps the captured +// layout at any display size. +function collectReadableStyles() { + const chunks = []; + const mediaMatches = (mediaText) => window.matchMedia(mediaText).matches; + const sheets = [...Array.from(document.styleSheets), ...Array.from(document.adoptedStyleSheets || [])]; + sheets.forEach((sheet) => { + try { + if (sheet.ownerNode?.dataset?.patchloopStyle) return; + if (sheet.disabled) return; + if (sheet.media && sheet.media.mediaText && !mediaMatches(sheet.media.mediaText)) return; + const flattened = flattenRulesForSnapshot(sheet.cssRules, mediaMatches); + if (flattened) chunks.push(flattened); + } catch (_) { + // Cross-origin stylesheets cannot be read. The snapshot still includes DOM and overlay context. + } + }); + return chunks.join("\n"); +} + +// Overlay coordinates must be viewport-relative at capture time, so derive +// them from the page-pixel position (kept fresh by re-anchoring) and the +// current scroll instead of the click-time client coordinates. +function screenshotOverlayFor(target) { + if (target.kind === "area" && target.area) { + return { + kind: "area", + x: Math.round(target.area.pageX - window.scrollX), + y: Math.round(target.area.pageY - window.scrollY), + width: Math.round(target.area.clientWidth), + height: Math.round(target.area.clientHeight) + }; + } + + return { + kind: "point", + x: Math.round(target.pageX - window.scrollX), + y: Math.round(target.pageY - window.scrollY) + }; +} + +function renderScreenshotOverlay(overlay) { + if (!overlay) return ""; + if (overlay.kind === "area") { + const x = Math.max(0, overlay.x); + const y = Math.max(0, overlay.y); + const width = Math.max(1, overlay.width); + const height = Math.max(1, overlay.height); + return ` + + +!`; + } + + return ` + +`; +} + +export function byteLength(value) { + if (window.Blob) return new Blob([value]).size; + return base64Encode(value).length; +} + +export function base64Encode(value) { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (let i = 0; i < bytes.length; i += 8192) { + binary += String.fromCharCode(...bytes.subarray(i, i + 8192)); + } + return btoa(binary); +} diff --git a/widget/src/state.js b/widget/src/state.js new file mode 100644 index 0000000..f797ee8 --- /dev/null +++ b/widget/src/state.js @@ -0,0 +1,39 @@ +export const DEFAULTS = { + projectId: "local-demo", + demoId: "plain-html", + endpoint: "", + // Public per-project key sent with receiver posts (#44). It ships in the + // page, so it identifies the project and blocks indiscriminate spam rather + // than acting as a secret. Empty = receiver runs with open ingest. + ingestKey: "", + // Git provenance of the page under review (#96): { repo, branch, commit, + // root, buildUrl, previewUrl }, all optional strings. The embedding side + // injects real values at build/deploy time; tags + // fill any missing field. + sourceContext: null, + deliveryMode: "receiver", + slackWebhookUrl: "", + showDeliverySettings: false, + reviewer: "", + reviewerStorageKey: "patchloop:reviewer", + persistFeedback: true, + feedbackStorageKey: "patchloop:feedback", + position: "bottom-right", + captureScreenshot: true, + screenshotMaxBytes: 1_200_000, + onSubmit: null +}; + +export const state = { + options: { ...DEFAULTS }, + active: false, + pendingTarget: null, + drag: null, + suppressNextClick: false, + feedback: [], + feedbackMarkers: new Map(), + approximateIds: new Set(), + resizeTimer: null, + editingId: null, + collapsed: true +}; From 242f0411a1aea08086dbb27a9bc78d131287a04d Mon Sep 17 00:00:00 2001 From: kosako Date: Sat, 11 Jul 2026 18:27:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?widget:=20safeFilePart=20=E3=82=92=20shared?= =?UTF-8?q?/format.js=20=E5=8F=82=E7=85=A7=E3=81=AB=E5=88=87=E3=82=8A?= =?UTF-8?q?=E6=9B=BF=E3=81=88=E3=82=8B=20(#109=20R-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-1 で shared へ移設済みの safeFilePart を widget 側でも import に切替え、 バイト同一の重複定義(監査 L-31)を解消する。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LRdDjwxbAAaTMwwnzipkau --- dist/patchloop-widget.js | 9 +-------- widget/src/index.js | 9 +-------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/dist/patchloop-widget.js b/dist/patchloop-widget.js index 2d457b8..6d0ddfc 100644 --- a/dist/patchloop-widget.js +++ b/dist/patchloop-widget.js @@ -872,7 +872,7 @@ const { resolveSourceContext } = __pl_widget_src_source_context; const { DEFAULTS, state } = __pl_widget_src_state; const { buildPayload } = __pl_widget_src_payload; const { loadStoredReviewer, saveReviewer, persistFeedbackList, loadPersistedFeedback, clearPersistedFeedback } = __pl_widget_src_persistence; -const { truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = __pl_shared_format; +const { safeFilePart, truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } = __pl_shared_format; const EXPORT_KIND = "patchloop-feedback-bundle"; // v2 carries an array of feedback (batch export). v1 wrapped a single @@ -1439,13 +1439,6 @@ function updateDownloadAllButton() { button.textContent = unsent > 0 ? `未送信をまとめてDL(${unsent})` : "未送信はありません"; } -function safeFilePart(value) { - return String(value || "feedback") - .replace(/[^a-zA-Z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80) || "feedback"; -} - async function postSlackWebhook(payload) { try { await fetch(state.options.slackWebhookUrl, { diff --git a/widget/src/index.js b/widget/src/index.js index edc1724..f8af3fd 100644 --- a/widget/src/index.js +++ b/widget/src/index.js @@ -5,7 +5,7 @@ import { resolveSourceContext } from "./source-context.js"; import { DEFAULTS, state } from "./state.js"; import { buildPayload } from "./payload.js"; import { loadStoredReviewer, saveReviewer, persistFeedbackList, loadPersistedFeedback, clearPersistedFeedback } from "./persistence.js"; -import { truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } from "../../shared/format.js"; +import { safeFilePart, truncateText, present, escapeHtml, slackEscape, formatSlackCode, formatSlackLink, formatViewport, formatTarget } from "../../shared/format.js"; const EXPORT_KIND = "patchloop-feedback-bundle"; // v2 carries an array of feedback (batch export). v1 wrapped a single @@ -572,13 +572,6 @@ function updateDownloadAllButton() { button.textContent = unsent > 0 ? `未送信をまとめてDL(${unsent})` : "未送信はありません"; } -function safeFilePart(value) { - return String(value || "feedback") - .replace(/[^a-zA-Z0-9_-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 80) || "feedback"; -} - async function postSlackWebhook(payload) { try { await fetch(state.options.slackWebhookUrl, {