diff --git a/dist/patchloop-widget.js b/dist/patchloop-widget.js index 2d95586..6d0ddfc 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,750 +441,881 @@ 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; -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; + 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); -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 -}; + if (maxBytes > 0 && bytes > maxBytes) { + return { + status: "omitted", + reason: "too-large", + kind: "viewport-svg", + mimeType: "image/svg+xml", + width, + height, + bytes, + maxBytes, + targetOverlay: overlay + }; + } -function init(options = {}) { - // From there is no body yet to mount into; retry once the DOM is ready. - if (!document.body) { - document.addEventListener("DOMContentLoaded", () => init(options), { once: true }); - return api; - } - // Re-init while comment mode is on must not leave stale mode state - // (crosshair cursor, active drag) behind the freshly rendered UI. - state.active = false; - document.documentElement.classList.remove("pl-feedback-active"); - state.drag = null; - state.pendingTarget = null; - state.editingId = null; - removeSelectionBox(); - state.options = { ...DEFAULTS, ...options }; - state.options.reviewer = initialReviewer(state.options); - // Resolved once here (option first, meta tags as fallback) so every payload - // built later carries the same provenance without re-reading the DOM. - state.options.sourceContext = resolveSourceContext(state.options.sourceContext, document); - injectStyles(); - renderShell(); - bindGlobalCapture(); - restorePersistedFeedback(); - applyCollapseState(); - renderFeedbackList(); - return api; + 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 destroy() { - document.documentElement.classList.remove("pl-feedback-active"); - document.querySelector("[data-patchloop-style]")?.remove(); - document.removeEventListener("mousedown", handleDocumentMouseDown, true); - document.removeEventListener("mousemove", handleDocumentMouseMove, true); - document.removeEventListener("mouseup", handleDocumentMouseUp, true); - document.removeEventListener("click", suppressDocumentClick, true); - window.removeEventListener("resize", handleWindowResize); - window.clearTimeout(state.resizeTimer); - state.approximateIds.clear(); - document.querySelector("[data-patchloop-root]")?.remove(); - document.querySelectorAll("[data-patchloop-pin]").forEach((node) => node.remove()); - document.querySelectorAll("[data-patchloop-area]").forEach((node) => node.remove()); - document.querySelectorAll(".pl-target-highlight").forEach((node) => node.classList.remove("pl-target-highlight")); - removeSelectionBox(); - state.active = false; - state.pendingTarget = null; - state.drag = null; - state.feedbackMarkers.clear(); - state.editingId = null; +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 initialReviewer(options) { - const configured = String(options.reviewer || "").trim(); - if (configured) return configured; - return loadStoredReviewer(options.reviewerStorageKey); +function visibleBackground(value) { + if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return ""; + return value; } -function renderShell() { - document.querySelector("[data-patchloop-root]")?.remove(); +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)}"` : ""; +} - const root = document.createElement("div"); - root.dataset.patchloopRoot = "true"; - root.className = `pl-root pl-${state.options.position}`; - root.innerHTML = ` -
-
- - PatchLoop - -
-
-

コメントモードを開始して、画面上の気になる場所をクリックしてください。

-
- - -
- ${renderDeliverySettings()} - -
-
- - - `; +// 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(""); +} - document.body.append(root); +// 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"); +} - root.querySelector("[data-pl-collapse]").addEventListener("click", toggleCollapse); - root.querySelector("[data-pl-mode]").addEventListener("click", toggleFeedbackMode); - root.querySelector("[data-pl-download-all]").addEventListener("click", downloadUnsentFeedback); - root.querySelector("[data-pl-clear]").addEventListener("click", clearPins); - root.querySelector("[data-pl-cancel]").addEventListener("click", cancelPendingComment); - root.querySelector("[data-pl-comment]").addEventListener("submit", submitComment); - root.querySelector("[data-pl-comment]").addEventListener("keydown", handleCommentKeydown); - root.querySelector("[data-pl-reviewer]").addEventListener("input", () => clearFormError(root.querySelector("[data-pl-comment]"))); - root.querySelector("[data-pl-list]").addEventListener("click", handleListClick); - root.querySelector("[data-pl-delivery-settings]")?.addEventListener("input", handleDeliverySettingsInput); - root.querySelector("[data-pl-delivery-settings]")?.addEventListener("change", handleDeliverySettingsInput); - syncDeliverySettingsVisibility(); +// 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 renderDeliverySettings() { - if (!state.options.showDeliverySettings) return ""; +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 bindGlobalCapture() { - document.removeEventListener("mousedown", handleDocumentMouseDown, true); - document.removeEventListener("mousemove", handleDocumentMouseMove, true); - document.removeEventListener("mouseup", handleDocumentMouseUp, true); - document.removeEventListener("click", suppressDocumentClick, true); - document.addEventListener("mousedown", handleDocumentMouseDown, true); - document.addEventListener("mousemove", handleDocumentMouseMove, true); - document.addEventListener("mouseup", handleDocumentMouseUp, true); - document.addEventListener("click", suppressDocumentClick, true); - window.removeEventListener("resize", handleWindowResize); - window.addEventListener("resize", handleWindowResize); +function byteLength(value) { + if (window.Blob) return new Blob([value]).size; + return base64Encode(value).length; } -function handleDocumentMouseDown(event) { - if (!state.active) return; - // Secondary/middle buttons keep their native behavior (context menu, - // autoscroll); capturing them would drop a pin under the context menu. - if (event.button !== 0) return; - if (event.target.closest("[data-patchloop-root]")) return; +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); +} - event.preventDefault(); - event.stopPropagation(); +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; - state.drag = { - startedAt: pointFromEvent(event), - latest: pointFromEvent(event), - target: event.target, - isDragging: false +// 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() }; - state.suppressNextClick = true; } -function handleDocumentMouseMove(event) { - if (!state.active || !state.drag) return; - // The mouseup can be missed entirely (button released outside the - // window); event.buttons reports what is actually held, so a move - // without the primary button cancels the drag instead of dragging - // a ghost selection box around. - if ((event.buttons & 1) === 0) { - removeSelectionBox(); - state.drag = null; - return; +// 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 ""; } - if (event.target.closest("[data-patchloop-root]")) 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; +} - event.preventDefault(); - event.stopPropagation(); +return { normalizePageUrl, samePersistedPage }; +})(); +// --- widget/src/persistence.js --- +const __pl_widget_src_persistence = (() => { +const { state } = __pl_widget_src_state; +const { samePersistedPage } = __pl_widget_src_url; - state.drag.latest = pointFromEvent(event); - const rect = rectFromPoints(state.drag.startedAt, state.drag.latest, viewportMetrics()); - state.drag.isDragging = rect.widthPx > 8 || rect.heightPx > 8; +const FEEDBACK_STORAGE_VERSION = 1; - if (state.drag.isDragging) { - renderSelectionBox(rect); +function loadStoredReviewer(storageKey) { + if (!storageKey) return ""; + try { + return String(window.localStorage.getItem(storageKey) || "").trim(); + } catch (_) { + return ""; } } -function handleDocumentMouseUp(event) { - if (!state.active || !state.drag) return; - if (event.target.closest("[data-patchloop-root]")) { - removeSelectionBox(); - state.drag = null; - 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. } +} - event.preventDefault(); - event.stopPropagation(); +function loadPersistedFeedback() { + if (!state.options.feedbackStorageKey) return []; - const start = state.drag.startedAt; - const end = pointFromEvent(event); - const rect = rectFromPoints(start, end, viewportMetrics()); - const target = document.elementFromPoint(start.clientX, start.clientY) || state.drag.target; + 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 []; + } +} - discardPendingMarker(); - // A new capture supersedes an interrupted edit; a stale editingId would - // route the submit into the edit branch and silently overwrite that item. - state.editingId = null; +function persistFeedbackList() { + if (!state.options.persistFeedback || !state.options.feedbackStorageKey) return; - let marker; - if (state.drag.isDragging) { - marker = addArea(rect); - const areaAnchor = buildAreaAnchor(target, rect); - state.pendingTarget = { - kind: "area", - ...pointFromClient(rect.leftPx, rect.topPx, viewportMetrics()), - area: { - x: round(rect.x), - y: round(rect.y), - width: round(rect.width), - height: round(rect.height), - clientX: Math.round(rect.leftPx), - clientY: Math.round(rect.topPx), - clientWidth: Math.round(rect.widthPx), - clientHeight: Math.round(rect.heightPx), - pageX: Math.round(rect.pageLeftPx), - pageY: Math.round(rect.pageTopPx), - documentX: round(rect.documentX), - documentY: round(rect.documentY), - documentWidth: round(rect.documentWidth), - documentHeight: round(rect.documentHeight) - }, - selector: selectorFor(target, document.body), - elementText: textFor(target), - anchor: areaAnchor.anchor, - anchorElement: areaAnchor.anchorElement, - markerNode: marker.node, - markerLabelNode: marker.label, - targetElement: target - }; - openCommentForm({ clientX: rect.rightPx, clientY: rect.bottomPx }); - } else { - const point = pointFromEvent(event); - marker = addPin(point); - const pointAnchor = buildPointAnchor(target, point); - state.pendingTarget = { - kind: "point", - ...point, - selector: selectorFor(target, document.body), - elementText: textFor(target), - anchor: pointAnchor.anchor, - anchorElement: pointAnchor.anchorElement, - markerNode: marker.node, - markerLabelNode: marker.label, - targetElement: target - }; - openCommentForm(point); + 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); + } } +} - highlightTarget(target); +function clearPersistedFeedback() { + if (!state.options.feedbackStorageKey) return; - removeSelectionBox(); - state.drag = null; + try { + window.localStorage.removeItem(state.options.feedbackStorageKey); + } catch (_) { + // Storage can be unavailable in privacy-restricted contexts. + } } -function suppressDocumentClick(event) { - if (!state.suppressNextClick) return; - state.suppressNextClick = false; - if (event.target.closest("[data-patchloop-root]")) return; - event.preventDefault(); - event.stopPropagation(); +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 toggleFeedbackMode() { - setFeedbackMode(!state.active); +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 setFeedbackMode(nextValue) { - state.active = nextValue; - document.documentElement.classList.toggle("pl-feedback-active", state.active); - const root = getRoot(); - const handleBtn = root.querySelector("[data-pl-collapse]"); - if (handleBtn) handleBtn.classList.toggle("pl-mode-on", state.active); - const modeBtn = root.querySelector("[data-pl-mode]"); - modeBtn.textContent = state.active ? "コメントモード終了" : "コメントモード開始"; - modeBtn.setAttribute("aria-pressed", String(state.active)); - root.querySelector("[data-pl-help]").textContent = state.active ? "点をクリック、または範囲をドラッグしてコメントできます。" : "コメントモードを開始して、画面上の気になる場所をクリックしてください。"; - if (!state.active) { - removeSelectionBox(); - state.drag = 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 openCommentForm(point, options = {}) { - const form = getRoot().querySelector("[data-pl-comment]"); - form.hidden = false; - clearFormError(form); - form.style.left = `${Math.max(8, Math.min(point.clientX + 14, window.innerWidth - 340))}px`; - form.style.top = `${Math.max(8, Math.min(point.clientY + 14, window.innerHeight - 250))}px`; - const commentEl = form.querySelector("[data-pl-comment-text]"); - const reviewerEl = form.querySelector("[data-pl-reviewer]"); - commentEl.value = options.comment != null ? options.comment : ""; - if (options.reviewer != null) { - reviewerEl.value = options.reviewer; +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 { 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 +// payload; the receiver still accepts that shape for files exported before +// the switch to batch download. +const EXPORT_VERSION = 2; + +function init(options = {}) { + // From there is no body yet to mount into; retry once the DOM is ready. + if (!document.body) { + document.addEventListener("DOMContentLoaded", () => init(options), { once: true }); + return api; } - commentEl.focus(); + // Re-init while comment mode is on must not leave stale mode state + // (crosshair cursor, active drag) behind the freshly rendered UI. + state.active = false; + document.documentElement.classList.remove("pl-feedback-active"); + state.drag = null; + state.pendingTarget = null; + state.editingId = null; + removeSelectionBox(); + state.options = { ...DEFAULTS, ...options }; + state.options.reviewer = initialReviewer(state.options); + // Resolved once here (option first, meta tags as fallback) so every payload + // built later carries the same provenance without re-reading the DOM. + state.options.sourceContext = resolveSourceContext(state.options.sourceContext, document); + injectStyles(); + renderShell(); + bindGlobalCapture(); + restorePersistedFeedback(); + applyCollapseState(); + renderFeedbackList(); + return api; } -function closeCommentForm() { - const form = getRoot().querySelector("[data-pl-comment]"); - clearFormError(form); - form.hidden = true; +function destroy() { + document.documentElement.classList.remove("pl-feedback-active"); + document.querySelector("[data-patchloop-style]")?.remove(); + document.removeEventListener("mousedown", handleDocumentMouseDown, true); + document.removeEventListener("mousemove", handleDocumentMouseMove, true); + document.removeEventListener("mouseup", handleDocumentMouseUp, true); + document.removeEventListener("click", suppressDocumentClick, true); + window.removeEventListener("resize", handleWindowResize); + window.clearTimeout(state.resizeTimer); + state.approximateIds.clear(); + document.querySelector("[data-patchloop-root]")?.remove(); + document.querySelectorAll("[data-patchloop-pin]").forEach((node) => node.remove()); + document.querySelectorAll("[data-patchloop-area]").forEach((node) => node.remove()); + document.querySelectorAll(".pl-target-highlight").forEach((node) => node.classList.remove("pl-target-highlight")); + removeSelectionBox(); + state.active = false; state.pendingTarget = null; + state.drag = null; + state.feedbackMarkers.clear(); + state.editingId = null; } -function handleCommentKeydown(event) { - if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return; - event.preventDefault(); - const form = event.currentTarget; - if (typeof form.requestSubmit === "function") { - form.requestSubmit(); - } else { - // Safari 15 has no requestSubmit; clicking the submit button keeps - // the submit event (and its validation) on the same path. - form.querySelector('button[type="submit"]')?.click(); - } +function initialReviewer(options) { + const configured = String(options.reviewer || "").trim(); + if (configured) return configured; + return loadStoredReviewer(options.reviewerStorageKey); } -function handleDeliverySettingsInput() { - const root = getRoot(); - if (!root) return; - const mode = root.querySelector("[data-pl-delivery-mode]")?.value; - const endpoint = root.querySelector("[data-pl-endpoint]")?.value; - const slackWebhookUrl = root.querySelector("[data-pl-slack-webhook]")?.value; +function renderShell() { + document.querySelector("[data-patchloop-root]")?.remove(); - if (mode) state.options.deliveryMode = mode; - if (endpoint != null) state.options.endpoint = endpoint.trim(); - if (slackWebhookUrl != null) state.options.slackWebhookUrl = slackWebhookUrl.trim(); + const root = document.createElement("div"); + root.dataset.patchloopRoot = "true"; + root.className = `pl-root pl-${state.options.position}`; + root.innerHTML = ` +
+
+ + PatchLoop + +
+
+

コメントモードを開始して、画面上の気になる場所をクリックしてください。

+
+ + +
+ ${renderDeliverySettings()} + +
+
+ + + `; + + document.body.append(root); + + root.querySelector("[data-pl-collapse]").addEventListener("click", toggleCollapse); + root.querySelector("[data-pl-mode]").addEventListener("click", toggleFeedbackMode); + root.querySelector("[data-pl-download-all]").addEventListener("click", downloadUnsentFeedback); + root.querySelector("[data-pl-clear]").addEventListener("click", clearPins); + root.querySelector("[data-pl-cancel]").addEventListener("click", cancelPendingComment); + root.querySelector("[data-pl-comment]").addEventListener("submit", submitComment); + root.querySelector("[data-pl-comment]").addEventListener("keydown", handleCommentKeydown); + root.querySelector("[data-pl-reviewer]").addEventListener("input", () => clearFormError(root.querySelector("[data-pl-comment]"))); + root.querySelector("[data-pl-list]").addEventListener("click", handleListClick); + root.querySelector("[data-pl-delivery-settings]")?.addEventListener("input", handleDeliverySettingsInput); + root.querySelector("[data-pl-delivery-settings]")?.addEventListener("change", handleDeliverySettingsInput); syncDeliverySettingsVisibility(); } -function syncDeliverySettingsVisibility() { - const root = getRoot(); - if (!root) return; - const mode = state.options.deliveryMode || "receiver"; - const endpointField = root.querySelector("[data-pl-endpoint-field]"); - const slackField = root.querySelector("[data-pl-slack-field]"); - if (endpointField) endpointField.hidden = mode !== "receiver"; - if (slackField) slackField.hidden = mode !== "slack-webhook"; - updateDownloadAllButton(); -} +function renderDeliverySettings() { + if (!state.options.showDeliverySettings) return ""; -function showFormError(form, message) { - const errorEl = form?.querySelector("[data-pl-form-error]"); - if (!errorEl) return; - errorEl.textContent = message; - errorEl.hidden = false; + return ` +
+ 送信設定 + + + +
+ `; } -function clearFormError(form) { - const errorEl = form?.querySelector("[data-pl-form-error]"); - if (!errorEl) return; - errorEl.textContent = ""; - errorEl.hidden = true; +function bindGlobalCapture() { + document.removeEventListener("mousedown", handleDocumentMouseDown, true); + document.removeEventListener("mousemove", handleDocumentMouseMove, true); + document.removeEventListener("mouseup", handleDocumentMouseUp, true); + document.removeEventListener("click", suppressDocumentClick, true); + document.addEventListener("mousedown", handleDocumentMouseDown, true); + document.addEventListener("mousemove", handleDocumentMouseMove, true); + document.addEventListener("mouseup", handleDocumentMouseUp, true); + document.addEventListener("click", suppressDocumentClick, true); + window.removeEventListener("resize", handleWindowResize); + window.addEventListener("resize", handleWindowResize); } -async function submitComment(event) { +function handleDocumentMouseDown(event) { + if (!state.active) return; + // Secondary/middle buttons keep their native behavior (context menu, + // autoscroll); capturing them would drop a pin under the context menu. + if (event.button !== 0) return; + if (event.target.closest("[data-patchloop-root]")) return; + event.preventDefault(); + event.stopPropagation(); - 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); + state.drag = { + startedAt: pointFromEvent(event), + latest: pointFromEvent(event), + target: event.target, + isDragging: false + }; + state.suppressNextClick = true; +} - 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(); +function handleDocumentMouseMove(event) { + if (!state.active || !state.drag) return; + // The mouseup can be missed entirely (button released outside the + // window); event.buttons reports what is actually held, so a move + // without the primary button cancels the drag instead of dragging + // a ghost selection box around. + if ((event.buttons & 1) === 0) { + removeSelectionBox(); + state.drag = null; return; } + if (event.target.closest("[data-patchloop-root]")) 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(); - } -} + event.preventDefault(); + event.stopPropagation(); -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() - }; + state.drag.latest = pointFromEvent(event); + const rect = rectFromPoints(state.drag.startedAt, state.drag.latest, viewportMetrics()); + state.drag.isDragging = rect.widthPx > 8 || rect.heightPx > 8; + + if (state.drag.isDragging) { + renderSelectionBox(rect); + } } -function captureScreenshot(target) { - if (!state.options.captureScreenshot) return null; +function handleDocumentMouseUp(event) { + if (!state.active || !state.drag) return; + if (event.target.closest("[data-patchloop-root]")) { + removeSelectionBox(); + state.drag = null; + return; + } - 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); + event.preventDefault(); + event.stopPropagation(); - if (maxBytes > 0 && bytes > maxBytes) { - return { - status: "omitted", - reason: "too-large", - kind: "viewport-svg", - mimeType: "image/svg+xml", - width, - height, - bytes, - maxBytes, - targetOverlay: overlay - }; - } + const start = state.drag.startedAt; + const end = pointFromEvent(event); + const rect = rectFromPoints(start, end, viewportMetrics()); + const target = document.elementFromPoint(start.clientX, start.clientY) || state.drag.target; - 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)}` + discardPendingMarker(); + // A new capture supersedes an interrupted edit; a stale editingId would + // route the submit into the edit branch and silently overwrite that item. + state.editingId = null; + + let marker; + if (state.drag.isDragging) { + marker = addArea(rect); + const areaAnchor = buildAreaAnchor(target, rect); + state.pendingTarget = { + kind: "area", + ...pointFromClient(rect.leftPx, rect.topPx, viewportMetrics()), + area: { + x: round(rect.x), + y: round(rect.y), + width: round(rect.width), + height: round(rect.height), + clientX: Math.round(rect.leftPx), + clientY: Math.round(rect.topPx), + clientWidth: Math.round(rect.widthPx), + clientHeight: Math.round(rect.heightPx), + pageX: Math.round(rect.pageLeftPx), + pageY: Math.round(rect.pageTopPx), + documentX: round(rect.documentX), + documentY: round(rect.documentY), + documentWidth: round(rect.documentWidth), + documentHeight: round(rect.documentHeight) + }, + selector: selectorFor(target, document.body), + elementText: textFor(target), + anchor: areaAnchor.anchor, + anchorElement: areaAnchor.anchorElement, + markerNode: marker.node, + markerLabelNode: marker.label, + targetElement: target }; - } catch (error) { - return { - status: "failed", - error: error.message + openCommentForm({ clientX: rect.rightPx, clientY: rect.bottomPx }); + } else { + const point = pointFromEvent(event); + marker = addPin(point); + const pointAnchor = buildPointAnchor(target, point); + state.pendingTarget = { + kind: "point", + ...point, + selector: selectorFor(target, document.body), + elementText: textFor(target), + anchor: pointAnchor.anchor, + anchorElement: pointAnchor.anchorElement, + markerNode: marker.node, + markerLabelNode: marker.label, + targetElement: target }; + openCommentForm(point); } -} - -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} -`; -} + highlightTarget(target); -function visibleBackground(value) { - if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") return ""; - return value; + removeSelectionBox(); + state.drag = null; } -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)}"` : ""; +function suppressDocumentClick(event) { + if (!state.suppressNextClick) return; + state.suppressNextClick = false; + if (event.target.closest("[data-patchloop-root]")) return; + event.preventDefault(); + event.stopPropagation(); } -// 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 toggleFeedbackMode() { + setFeedbackMode(!state.active); } -// 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 setFeedbackMode(nextValue) { + state.active = nextValue; + document.documentElement.classList.toggle("pl-feedback-active", state.active); + const root = getRoot(); + const handleBtn = root.querySelector("[data-pl-collapse]"); + if (handleBtn) handleBtn.classList.toggle("pl-mode-on", state.active); + const modeBtn = root.querySelector("[data-pl-mode]"); + modeBtn.textContent = state.active ? "コメントモード終了" : "コメントモード開始"; + modeBtn.setAttribute("aria-pressed", String(state.active)); + root.querySelector("[data-pl-help]").textContent = state.active ? "点をクリック、または範囲をドラッグしてコメントできます。" : "コメントモードを開始して、画面上の気になる場所をクリックしてください。"; + if (!state.active) { + removeSelectionBox(); + state.drag = null; + } } -// 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) - }; +function openCommentForm(point, options = {}) { + const form = getRoot().querySelector("[data-pl-comment]"); + form.hidden = false; + clearFormError(form); + form.style.left = `${Math.max(8, Math.min(point.clientX + 14, window.innerWidth - 340))}px`; + form.style.top = `${Math.max(8, Math.min(point.clientY + 14, window.innerHeight - 250))}px`; + const commentEl = form.querySelector("[data-pl-comment-text]"); + const reviewerEl = form.querySelector("[data-pl-reviewer]"); + commentEl.value = options.comment != null ? options.comment : ""; + if (options.reviewer != null) { + reviewerEl.value = options.reviewer; } + commentEl.focus(); +} - return { - kind: "point", - x: Math.round(target.pageX - window.scrollX), - y: Math.round(target.pageY - window.scrollY) - }; +function closeCommentForm() { + const form = getRoot().querySelector("[data-pl-comment]"); + clearFormError(form); + form.hidden = true; + state.pendingTarget = null; } -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 ` - - -!`; +function handleCommentKeydown(event) { + if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return; + event.preventDefault(); + const form = event.currentTarget; + if (typeof form.requestSubmit === "function") { + form.requestSubmit(); + } else { + // Safari 15 has no requestSubmit; clicking the submit button keeps + // the submit event (and its validation) on the same path. + form.querySelector('button[type="submit"]')?.click(); } +} - return ` - -`; +function handleDeliverySettingsInput() { + const root = getRoot(); + if (!root) return; + const mode = root.querySelector("[data-pl-delivery-mode]")?.value; + const endpoint = root.querySelector("[data-pl-endpoint]")?.value; + const slackWebhookUrl = root.querySelector("[data-pl-slack-webhook]")?.value; + + if (mode) state.options.deliveryMode = mode; + if (endpoint != null) state.options.endpoint = endpoint.trim(); + if (slackWebhookUrl != null) state.options.slackWebhookUrl = slackWebhookUrl.trim(); + syncDeliverySettingsVisibility(); } -function byteLength(value) { - if (window.Blob) return new Blob([value]).size; - return base64Encode(value).length; +function syncDeliverySettingsVisibility() { + const root = getRoot(); + if (!root) return; + const mode = state.options.deliveryMode || "receiver"; + const endpointField = root.querySelector("[data-pl-endpoint-field]"); + const slackField = root.querySelector("[data-pl-slack-field]"); + if (endpointField) endpointField.hidden = mode !== "receiver"; + if (slackField) slackField.hidden = mode !== "slack-webhook"; + updateDownloadAllButton(); } -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)); +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(); } - 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 @@ -1399,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, { @@ -2069,15 +2102,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..f8af3fd 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 { 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 // 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 @@ -967,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, { @@ -1637,15 +1235,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 +};