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 `
+`;
}
-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 `
-`;
-}
+ 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 `
-`;
-}
-
-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 `
+`;
+}
+
+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
+};