Files
apparel-designer/src/components/canvas/DebugOverlay.jsx
2026-05-23 03:28:58 -05:00

997 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import Konva from 'konva';
/**
* Position / transform debug overlay.
*
* What this is
* ────────────
* A developer-only HUD that reports the selected element's live
* coordinate state in a fixed bottom-left panel. Built originally
* for diagnosing a rotation-pivot bug where bbox-clamping in
* `constrainTransform` operated on a different coord system than
* the wrapper bounds it was comparing against (stage vs design),
* producing per-frame drift on rotation gestures near canvas edges.
* The bug is fixed; the overlay is kept as a permanent diagnostic
* surface since rotation/clamp/offset interactions are the most
* likely re-failure area as the editor evolves and a future
* regression will be much faster to diagnose if the readouts are
* already wired up.
*
* What it shows
* ─────────────
* Four sections, all updated on requestAnimationFrame while mounted:
*
* • element (state) — React state for the selected element. The
* numbers fed BACK into the next render. If state and node
* disagree about position/rotation, the next paint will jump.
*
* • node (live konva) — the live Konva node's attrs. node.x/y is
* the pivot position in layer-local coords (with always-center
* offset, this equals element.x + W/2, element.y + H/2 by
* construction). Offset should be glued to (W/2, H/2).
*
* • derived — the comparison section. `visual TL (konva)` is the
* ground truth: where Konva's own getClientRect says the visible
* top-left lands. `simple formula` is what unflipReportedXY
* computes when committing position changes. `rot-aware` is
* what the formula would be if you naively rotated the offset
* vector before subtracting (kept around as a foil to remind
* you the simple formula is the round-trip-correct one, even
* though it doesn't match visual TL in any obvious way).
*
* • last gesture — summary of the most recent drag/transform. Most
* useful field is `bound calls (N clamped)`: if N > 0 during a
* gesture that should have been free, the wrapper clamp is
* interfering and the user will see drift.
*
* Activation
* ──────────
* Two ways to turn it on, neither requiring a code edit:
*
* • URL: append `?debug=1` to the editor URL.
* • LocalStorage: `localStorage.setItem('paw_debug_overlay', '1')`,
* then reload.
*
* The URL path also persists to localStorage so subsequent reloads
* keep it on without re-appending the param. The on-panel ×
* button clears both and reloads.
*
* The entire overlay component is gated behind a `DEBUG_OVERLAY_ENABLED`
* constant in App.jsx (read once at module load). When the flag is
* off, the conditional render short-circuits and this file is dead
* code from the user's perspective; the bundler will not tree-shake
* the import itself, but the component never mounts and the only
* runtime cost is the import being parsed.
*
* UX
* ───
* Fixed bottom-left, semi-transparent dark panel with mono-font
* readouts. A small "×" closes it for the rest of the session
* (clears the URL param via history.replaceState and the
* localStorage key, then reloads). A "copy" button next to the
* close emits a markdown-formatted snapshot to the clipboard for
* paste-into-chat diagnostics. The panel doesn't intercept canvas
* pointer events outside its own box, so it never gets in the way
* of an interaction.
*
* Performance
* ──────────
* Polls the Konva node every animation frame while mounted. That's
* fine for a developer overlay — adds one rAF per frame on top of
* Konva's own rendering, well below the budget — but it's the reason
* the component is opt-in. We deliberately don't subscribe to Konva
* events for the rAF poll, because we want the readouts to update
* during continuous transforms, when no React state changes are
* firing. (Gesture event subscriptions DO exist, separately, to
* capture transform start/move/end snapshots for the "last gesture"
* summary.)
*/
export function DebugOverlay({ element, stageContainerRef }) {
// Live readouts. Updated on each rAF tick while mounted; null when
// there's no element selected or no node found.
const [readout, setReadout] = useState(null);
const rafRef = useRef(null);
// Gesture-tracking state. Captured by listeners on the Konva node's
// transformstart / transform / transformend events. Refs (not
// state) because we want the per-frame `transform` handler to be
// allocation-light — only the React state we render from updates
// when there's meaningful new info.
//
// Shape:
// gestureRef.current = {
// startedAt: Date.now() at transformstart
// startState: { element snapshot, node snapshot, visual TL }
// firstFrame: same shape, captured on FIRST transform event
// (i.e. the very first frame after start)
// lastFrame: same shape, captured on EVERY transform event
// endState: captured on transformend
// boundRejects: number of times constrainTransform returned
// oldBox during this gesture (i.e. clamped)
// boundCalls: total number of constrainTransform calls
// }
//
// The overlay displays the latest completed gesture's delta plus a
// running count of bound-rejects on the active gesture. That's
// enough to distinguish "first-frame jump" from "clamp-induced
// jump" from "transform-end round-trip jump".
const gestureRef = useRef(null);
const [gestureSummary, setGestureSummary] = useState(null);
// Hook the active Konva node's transform events. Re-runs whenever
// the selected element changes (so we re-attach to the new node).
// We use a separate effect from the rAF poll so the event
// subscription doesn't churn when the rAF restarts.
useEffect(() => {
if (!element) return undefined;
const stageContainer = stageContainerRef?.current;
if (!stageContainer) return undefined;
// Resolve the node. If it isn't mounted yet (e.g. a freshly-added
// element that hasn't hit Konva's render pass), retry on the next
// frame. The retry loop is bounded by the effect's cleanup.
let cancelled = false;
let retryRaf = null;
let attachedNode = null;
const snapshot = (node) => {
// Best-effort snapshot of everything we care about. Returns null
// if the node has vanished (e.g. element deleted mid-gesture);
// callers tolerate null.
if (!node) return null;
const layer = node.getLayer?.();
let clientRect = null;
try {
clientRect = layer
? node.getClientRect({ relativeTo: layer })
: node.getClientRect();
} catch { /* ignore */ }
return {
t: Date.now(),
elementX: element.x ?? 0,
elementY: element.y ?? 0,
elementRotation: element.rotation ?? 0,
nodeX: node.x(),
nodeY: node.y(),
nodeRotation: node.rotation(),
offsetX: node.offsetX(),
offsetY: node.offsetY(),
visualX: clientRect?.x,
visualY: clientRect?.y,
visualW: clientRect?.width,
visualH: clientRect?.height,
};
};
const onStart = () => {
gestureRef.current = {
startedAt: Date.now(),
startState: snapshot(attachedNode),
firstFrame: null,
lastFrame: null,
endState: null,
boundRejects: 0,
boundCalls: 0,
};
};
const onTransform = () => {
const g = gestureRef.current;
if (!g) return;
const snap = snapshot(attachedNode);
if (!g.firstFrame) g.firstFrame = snap;
g.lastFrame = snap;
};
const onEnd = () => {
const g = gestureRef.current;
if (!g) return;
g.endState = snapshot(attachedNode);
// Materialize a frozen summary for the overlay to render. The
// ref-tracked gestureRef gets reset on the next gesture's start.
setGestureSummary({
durationMs: g.endState ? g.endState.t - g.startedAt : null,
boundCalls: g.boundCalls,
boundRejects: g.boundRejects,
startState: g.startState,
firstFrame: g.firstFrame,
endState: g.endState,
});
};
const tryAttach = () => {
if (cancelled) return;
const stage = (() => {
if (Konva && Array.isArray(Konva.stages)) {
for (const s of Konva.stages) {
try { if (s.container() === stageContainer) return s; } catch { /* ignore */ }
}
}
return null;
})();
const node = stage?.findOne('.' + element.id);
if (!node) {
retryRaf = requestAnimationFrame(tryAttach);
return;
}
attachedNode = node;
// Namespace events with .pawdbg so we can off() them cleanly
// without disturbing anything else Konva or the app has
// attached.
node.on('transformstart.pawdbg', onStart);
node.on('transform.pawdbg', onTransform);
node.on('transformend.pawdbg', onEnd);
// Drag events too — the same "jump" symptom could appear on
// drag if the bound function clamps an early frame.
node.on('dragstart.pawdbg', onStart);
node.on('dragmove.pawdbg', onTransform);
node.on('dragend.pawdbg', onEnd);
};
tryAttach();
return () => {
cancelled = true;
if (retryRaf != null) cancelAnimationFrame(retryRaf);
if (attachedNode) {
try {
attachedNode.off('transformstart.pawdbg');
attachedNode.off('transform.pawdbg');
attachedNode.off('transformend.pawdbg');
attachedNode.off('dragstart.pawdbg');
attachedNode.off('dragmove.pawdbg');
attachedNode.off('dragend.pawdbg');
} catch { /* ignore */ }
}
};
}, [element, stageContainerRef]);
// Bound-function instrumentation. We expose a global hook the
// production canvasDragBound / constrainTransform can call into IF
// the debug overlay has set window.__pawDebugBound. DesignCanvas
// checks the global before each bound-call increment, so the
// instrumentation is a no-op when the overlay isn't mounted.
//
// The hook surface is intentionally minimal: a single boolean
// `rejected` argument indicating whether constrainTransform
// modified or reverted the user-proposed box. Per-call payloads
// were tried during the original diagnostic session but added
// allocation pressure to the bound hot path with little additional
// diagnostic value over the count-of-clamps. If a future
// investigation needs more detail it can be re-added — the
// existing hook is a stable insertion point.
//
// Why a global rather than React context: the bound functions live
// inside DesignCanvas's useCallback with an empty dep array (Konva
// binds them by identity at mount time, and changing identity would
// force a re-attach). Reading the latest hook ref through a global
// avoids both the re-bind cost and the gymnastics of passing a ref
// through props.
useEffect(() => {
if (typeof window === 'undefined') return undefined;
window.__pawDebugBound = {
noteBoundCall: (rejected) => {
const g = gestureRef.current;
if (!g) return;
g.boundCalls += 1;
if (rejected) g.boundRejects += 1;
},
};
return () => {
delete window.__pawDebugBound;
};
}, []);
useEffect(() => {
let cancelled = false;
// Resolve the Konva stage owning our container. Same pattern used
// by TextEditAffordance and the crop handler in App.jsx.
const resolveStage = () => {
const container = stageContainerRef?.current;
if (!container) return null;
if (Konva && Array.isArray(Konva.stages)) {
for (const s of Konva.stages) {
try { if (s.container() === container) return s; } catch { /* ignore */ }
}
}
const canvas = container.querySelector?.('canvas');
return canvas?._konvaNode?.getStage?.() ?? null;
};
const tick = () => {
if (cancelled) return;
if (!element) {
setReadout(null);
rafRef.current = requestAnimationFrame(tick);
return;
}
const stage = resolveStage();
const node = stage?.findOne('.' + element.id);
if (!node) {
// No node — but we still have an element. Element data alone
// is useful; mark the node-derived fields as missing.
setReadout({
elementId: element.id,
element: pickElementFields(element),
node: null,
derived: null,
});
rafRef.current = requestAnimationFrame(tick);
return;
}
// Node-reported values. All of these come straight from Konva's
// attrs — node.x() / .y() / .rotation() / .offsetX() / .offsetY()
// / .scaleX() / .scaleY() / .width() / .height() are accessors.
const nodeX = node.x();
const nodeY = node.y();
const nodeW = node.width?.() ?? 0;
const nodeH = node.height?.() ?? 0;
const rotDeg = node.rotation();
const offsetX = node.offsetX();
const offsetY = node.offsetY();
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Stage-coord visual AABB. Definitive "where is this element on
// screen" — Konva computes this from the full transform chain.
//
// IMPORTANT: relativeTo here is the NODE'S OWN LAYER, not the
// stage. The layer is offset by LAYER_OFFSET_X/Y in stage coords
// (set in DesignCanvas via `<Layer x={LAYER_OFFSET_X} ... />`),
// while node.x() reports layer-LOCAL coords. Asking for the
// client rect relative to the stage would add the layer offset
// back in and we'd be comparing two different coord systems.
// relativeTo the layer keeps everything in the same
// (~design-coord) space as node.x/y, snap bbox attrs, and
// element.x/y.
let clientRect = null;
try {
const layer = node.getLayer?.();
clientRect = layer
? node.getClientRect({ relativeTo: layer })
: node.getClientRect();
} catch { /* ignore */ }
// Derived: what the CURRENT unflipReportedXY would write back
// to element.x/y if a drag/transform ended right now. The
// production code path is unflipReportedXY(node, width, height,
// flipX, flipY); since the helper now ignores flip flags, this
// simplifies to (nodeX - W/2, nodeY - H/2) using whichever width
// the onTransformEnd handler reads (the freshly-computed
// newWidth, not node.width()).
//
// For diagnostic purposes we report the SIMPLE formula's output
// using element.width as the size source (matches the rotation
// case which doesn't change W/H, just the rotation angle).
const simpleReportedX = nodeX - (element.width ?? nodeW) / 2;
const simpleReportedY = nodeY - (element.height ?? nodeH) / 2;
// CORRECT formula: rotate the offset vector by the node's
// rotation before subtracting. For a node with offsetX/Y at
// (W/2, H/2) and rotation R, the visual top-left in stage
// coords is:
// visualX = nodeX - (W/2)·cos(R) + (H/2)·sin(R)
// visualY = nodeY - (W/2)·sin(R) - (H/2)·cos(R)
// Equivalent to: rotate the local offset point (W/2, H/2)
// by R, then subtract from nodeX/Y to get where (0,0) in
// local space lands. That (0,0) is the visual top-left of
// an axis-aligned bbox spanning (0,0) → (W,H) in local space.
//
// The clientRect.x/y values above are what Konva itself says
// the visual top-left is, so if our formula is right these
// two should match to within float noise. Comparing them is
// the single most useful check for "is the rotation-aware
// formula correct?"
const rad = (rotDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const W = element.width ?? nodeW;
const H = element.height ?? nodeH;
const correctReportedX = nodeX - (W / 2) * cos + (H / 2) * sin;
const correctReportedY = nodeY - (W / 2) * sin - (H / 2) * cos;
// Round-trip predictions: if we write {simpleReportedX} into
// element.x, next render will draw the node at renderX =
// element.x + W/2 = nodeX. So the node's position is
// preserved — but its bbox is NOT, because the rotation pivot
// moved between the old and new state. The visible top-left
// jumps by (correctReportedX - simpleReportedX, ...) on the
// next paint. That delta is what the user sees as "jumping."
const simpleDeltaX = simpleReportedX - correctReportedX;
const simpleDeltaY = simpleReportedY - correctReportedY;
const predictedJumpPx = Math.hypot(simpleDeltaX, simpleDeltaY);
setReadout({
elementId: element.id,
element: pickElementFields(element),
node: {
x: nodeX,
y: nodeY,
width: nodeW,
height: nodeH,
rotation: rotDeg,
offsetX,
offsetY,
scaleX,
scaleY,
},
derived: {
clientRect,
simpleReportedX,
simpleReportedY,
correctReportedX,
correctReportedY,
simpleDeltaX,
simpleDeltaY,
predictedJumpPx,
},
});
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelled = true;
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [element, stageContainerRef]);
const handleDismiss = () => {
try {
localStorage.removeItem('paw_debug_overlay');
} catch { /* ignore */ }
try {
const url = new URL(window.location.href);
url.searchParams.delete('debug');
window.history.replaceState({}, '', url.toString());
} catch { /* ignore */ }
// Force a reload so the App-level gate re-evaluates and unmounts
// this component cleanly. Simpler than threading a callback up.
window.location.reload();
};
// "Just copied" flash state for the copy button. Flips true on a
// successful clipboard write, back to false ~1.2s later via a
// timeout. The button reads its label from this so the user sees
// immediate feedback that the click landed — clipboard writes are
// invisible otherwise. Stored as state (not a ref) so the label
// re-renders.
const [justCopied, setJustCopied] = useState(false);
const copyTimeoutRef = useRef(null);
useEffect(() => () => {
// Cleanup on unmount: the timeout would harmlessly fire after
// unmount and setState into a stale tree if we didn't clear it.
if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
}, []);
const handleCopy = async () => {
// Build the markdown payload from the current readout +
// gestureSummary. Both can be null (e.g. nothing selected, or no
// gesture has happened yet); the formatter tolerates that and
// omits sections rather than emitting empty tables.
const text = formatReadoutForClipboard(readout, gestureSummary);
if (!text) return;
// Two-tier write: modern Clipboard API first, falling back to the
// legacy execCommand path if the modern one is unavailable (older
// browsers, insecure contexts, some embedded webviews). The
// fallback uses a hidden textarea + selectAll + execCommand which
// is the documented compat technique. Either way we flip the
// "just copied" flash on success.
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch { /* fall through to fallback */ }
if (!ok) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
ok = document.execCommand('copy');
document.body.removeChild(ta);
} catch { /* ignore */ }
}
if (ok) {
setJustCopied(true);
if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(() => setJustCopied(false), 1200);
}
};
// Panel styles inline. The overlay is a developer tool; not worth
// adding a CSS file we'd want to delete when the bug is fixed.
const panelStyle = {
position: 'fixed',
left: '0.5rem',
bottom: '0.5rem',
zIndex: 9999,
background: 'rgba(15, 23, 42, 0.92)',
color: '#e2e8f0',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '11px',
lineHeight: 1.45,
padding: '0.6rem 0.75rem',
borderRadius: '8px',
maxWidth: '320px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.35)',
pointerEvents: 'auto',
userSelect: 'text',
};
const headerStyle = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '0.4rem',
fontWeight: 600,
color: '#f59e0b',
fontSize: '12px',
};
const dismissBtnStyle = {
background: 'transparent',
border: 'none',
color: '#94a3b8',
fontSize: '14px',
cursor: 'pointer',
padding: 0,
lineHeight: 1,
};
// Copy button. Slightly larger hit target than the dismiss button
// because it's the action the user reaches for most often during
// a debug session, and uses a subtle pink tint when not flashing
// so it reads as the primary affordance on this panel. When
// `justCopied` is true, the button briefly swaps its label to
// "copied!" with a green tint as confirmation.
const copyBtnStyle = {
background: 'transparent',
border: '1px solid rgba(148, 163, 184, 0.35)',
color: justCopied ? '#86efac' : '#cbd5e1',
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
cursor: 'pointer',
padding: '0.15rem 0.4rem',
borderRadius: '4px',
lineHeight: 1.3,
transition: 'color 0.15s ease, border-color 0.15s ease',
borderColor: justCopied ? 'rgba(134, 239, 172, 0.55)' : 'rgba(148, 163, 184, 0.35)',
};
const headerActionsStyle = {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
};
const sectionStyle = {
borderTop: '1px solid rgba(148, 163, 184, 0.18)',
paddingTop: '0.35rem',
marginTop: '0.35rem',
};
const sectionTitle = {
color: '#94a3b8',
fontSize: '10px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginBottom: '0.15rem',
};
const highlightDelta = (val) => {
// Color-code the delta so a glance tells you whether it's a real
// discrepancy or float noise. Threshold at 0.1px — anything below
// that is rounding, anything above is the bug.
const abs = Math.abs(val);
if (abs < 0.1) return '#94a3b8';
if (abs < 2) return '#fbbf24';
return '#f87171';
};
return (
<div style={panelStyle} role="region" aria-label="Debug overlay">
<div style={headerStyle}>
<span>🐛 element debug</span>
<div style={headerActionsStyle}>
<button
type="button"
onClick={handleCopy}
style={copyBtnStyle}
aria-label="Copy debug data to clipboard"
title="Copy as markdown"
>
{justCopied ? 'copied!' : 'copy'}
</button>
<button
type="button"
onClick={handleDismiss}
style={dismissBtnStyle}
aria-label="Close debug overlay"
title="Close (clears flag + reloads)"
>
×
</button>
</div>
</div>
{!readout && (
<div style={{ color: '#94a3b8' }}>No element selected.</div>
)}
{readout && (
<>
<div>
<span style={{ color: '#94a3b8' }}>id: </span>
<span>{readout.elementId.slice(0, 12)}</span>
</div>
<div style={sectionStyle}>
<div style={sectionTitle}>element (state)</div>
<Row label="x" value={readout.element.x} />
<Row label="y" value={readout.element.y} />
<Row label="w" value={readout.element.width} />
<Row label="h" value={readout.element.height} />
<Row label="rot°" value={readout.element.rotation} />
<Row label="flip" value={`${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'}`} mono />
</div>
{readout.node && (
<div style={sectionStyle}>
<div style={sectionTitle}>node (live konva)</div>
<Row label="x" value={readout.node.x} />
<Row label="y" value={readout.node.y} />
<Row label="off" value={`${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)}`} mono />
<Row label="scale" value={`${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)}`} mono />
<Row label="rot°" value={readout.node.rotation} />
</div>
)}
{readout.derived && (
<div style={sectionStyle}>
<div style={sectionTitle}>derived</div>
{readout.derived.clientRect && (
<Row
label="visual TL (konva)"
value={`${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)}`}
mono
/>
)}
<Row
label="simple formula"
value={`${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)}`}
mono
hint="node.x - W/2 / node.y - H/2"
/>
<Row
label="rot-aware"
value={`${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)}`}
mono
hint="subtracts rotated offset vector"
/>
<Row
label="Δ x,y"
value={`${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)}`}
mono
color={highlightDelta(readout.derived.predictedJumpPx)}
/>
<Row
label="predicted jump"
value={`${fmt(readout.derived.predictedJumpPx)}px`}
mono
color={highlightDelta(readout.derived.predictedJumpPx)}
hint="how far the bbox will visibly jump on transform-end"
/>
</div>
)}
{gestureSummary && (
<div style={sectionStyle}>
<div style={sectionTitle}>last gesture</div>
<Row
label="duration"
value={gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'}
mono
/>
<Row
label="bound calls"
value={`${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped)`}
mono
color={gestureSummary.boundRejects > 0 ? '#fbbf24' : undefined}
hint="how many times canvasDragBound/constrainTransform fired; clamped = returned oldBox or a slid box"
/>
{gestureSummary.startState && gestureSummary.firstFrame && (
<>
<Row
label="first-frame Δ visual"
value={fmtDeltaPair(
gestureSummary.firstFrame.visualX,
gestureSummary.startState.visualX,
gestureSummary.firstFrame.visualY,
gestureSummary.startState.visualY,
)}
mono
color={highlightDelta(
hypotPair(
gestureSummary.firstFrame.visualX,
gestureSummary.startState.visualX,
gestureSummary.firstFrame.visualY,
gestureSummary.startState.visualY,
),
)}
hint="how far visual TL moved between gesture-start and FIRST frame; big = jump on grab"
/>
<Row
label="first-frame Δ rot"
value={fmtDelta(
gestureSummary.firstFrame.nodeRotation,
gestureSummary.startState.nodeRotation,
)}
mono
hint="how much rotation changed between gesture-start and FIRST frame"
/>
</>
)}
{gestureSummary.startState && gestureSummary.endState && (
<>
<Row
label="total Δ visual"
value={fmtDeltaPair(
gestureSummary.endState.visualX,
gestureSummary.startState.visualX,
gestureSummary.endState.visualY,
gestureSummary.startState.visualY,
)}
mono
hint="visual TL drift from start to end of gesture"
/>
<Row
label="total Δ rot"
value={fmtDelta(
gestureSummary.endState.nodeRotation,
gestureSummary.startState.nodeRotation,
)}
mono
/>
</>
)}
</div>
)}
</>
)}
</div>
);
}
/* Helpers ───────────────────────────────────────────────────────────── */
function pickElementFields(el) {
return {
x: el.x ?? 0,
y: el.y ?? 0,
width: el.width ?? el.fontSize ?? 0,
height: el.height ?? el.fontSize ?? 0,
rotation: el.rotation ?? 0,
flipX: !!el.flipX,
flipY: !!el.flipY,
};
}
function fmt(n) {
if (n == null || Number.isNaN(n)) return '—';
// Two decimal places — enough resolution to spot sub-pixel drift
// without making numbers visually noisy. Negative zero collapses
// to plain "0.00".
const v = Math.round(n * 100) / 100;
return Object.is(v, -0) ? '0.00' : v.toFixed(2);
}
function fmtDelta(after, before) {
if (after == null || before == null) return '—';
const d = after - before;
const sign = d > 0 ? '+' : '';
return `${sign}${fmt(d)}`;
}
function fmtDeltaPair(ax, bx, ay, by) {
return `${fmtDelta(ax, bx)}, ${fmtDelta(ay, by)}`;
}
function hypotPair(ax, bx, ay, by) {
if (ax == null || bx == null || ay == null || by == null) return 0;
return Math.hypot(ax - bx, ay - by);
}
/**
* Serialize the current readout + gesture summary into a markdown
* payload suitable for pasting into chat (issue tracker, doc, etc.).
*
* Format choices
* ──────────────
* One markdown table per logical section, with a small header
* preamble carrying timestamp + element id. Tables render cleanly
* in most chat clients (this one included) and remain readable as
* plain text if the renderer doesn't pick them up.
*
* Numbers go through the same `fmt` helper used by the on-screen
* rows so the clipboard contents match what the user sees in the
* panel character-for-character (two decimals, signed deltas, em-
* dash for missing values). No surprise reformat between visual
* and pasted data.
*
* Each section is independently optional — if there's no node, the
* "node (live konva)" table is omitted entirely rather than rendered
* with em-dashes everywhere. If no gesture has happened yet, the
* "last gesture" section is omitted. The header line is always
* present so a paste with just the element-state table is still
* self-describing.
*
* Returns the empty string when there's nothing meaningful to copy
* (no readout at all); the caller bails before writing to the
* clipboard in that case so we don't replace whatever the user had
* there before with empty text.
*/
function formatReadoutForClipboard(readout, gestureSummary) {
if (!readout) return '';
const lines = [];
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
lines.push(`### element debug — ${ts}`);
lines.push(`**id**: \`${readout.elementId}\``);
lines.push('');
// element (state)
lines.push('**element (state)**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
lines.push(`| x | ${fmt(readout.element.x)} |`);
lines.push(`| y | ${fmt(readout.element.y)} |`);
lines.push(`| w | ${fmt(readout.element.width)} |`);
lines.push(`| h | ${fmt(readout.element.height)} |`);
lines.push(`| rot° | ${fmt(readout.element.rotation)} |`);
lines.push(`| flip | ${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'} |`);
lines.push('');
// node (live konva)
if (readout.node) {
lines.push('**node (live konva)**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
lines.push(`| x | ${fmt(readout.node.x)} |`);
lines.push(`| y | ${fmt(readout.node.y)} |`);
lines.push(`| offset | ${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)} |`);
lines.push(`| scale | ${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)} |`);
lines.push(`| rot° | ${fmt(readout.node.rotation)} |`);
lines.push('');
}
// derived
if (readout.derived) {
lines.push('**derived**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
if (readout.derived.clientRect) {
lines.push(`| visual TL (konva) | ${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)} |`);
}
lines.push(`| simple formula | ${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)} |`);
lines.push(`| rot-aware | ${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)} |`);
lines.push(`| Δ x,y | ${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)} |`);
lines.push(`| predicted jump | ${fmt(readout.derived.predictedJumpPx)}px |`);
lines.push('');
}
// last gesture
if (gestureSummary) {
lines.push('**last gesture**');
lines.push('');
lines.push('| field | value |');
lines.push('|---|---|');
lines.push(`| duration | ${gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'} |`);
lines.push(`| bound calls | ${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped) |`);
if (gestureSummary.startState && gestureSummary.firstFrame) {
lines.push(`| first-frame Δ visual | ${fmtDeltaPair(
gestureSummary.firstFrame.visualX,
gestureSummary.startState.visualX,
gestureSummary.firstFrame.visualY,
gestureSummary.startState.visualY,
)} |`);
lines.push(`| first-frame Δ rot | ${fmtDelta(
gestureSummary.firstFrame.nodeRotation,
gestureSummary.startState.nodeRotation,
)} |`);
}
if (gestureSummary.startState && gestureSummary.endState) {
lines.push(`| total Δ visual | ${fmtDeltaPair(
gestureSummary.endState.visualX,
gestureSummary.startState.visualX,
gestureSummary.endState.visualY,
gestureSummary.startState.visualY,
)} |`);
lines.push(`| total Δ rot | ${fmtDelta(
gestureSummary.endState.nodeRotation,
gestureSummary.startState.nodeRotation,
)} |`);
}
lines.push('');
}
// Trim the trailing blank line so the payload ends without an
// awkward extra newline when pasted into a tight text field.
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines.join('\n');
}
function Row({ label, value, mono, hint, color }) {
const rowStyle = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: '0.5rem',
};
const labelStyle = {
color: '#94a3b8',
fontSize: '10.5px',
};
const valueStyle = {
fontFamily: mono ? 'inherit' : undefined,
color: color ?? '#e2e8f0',
fontVariantNumeric: 'tabular-nums',
};
return (
<div style={rowStyle} title={hint || undefined}>
<span style={labelStyle}>{label}</span>
<span style={valueStyle}>{typeof value === 'number' ? fmt(value) : value}</span>
</div>
);
}
/**
* Read the debug-flag state from URL and localStorage. Either source
* is enough to enable. Called once at App mount; the result drives
* conditional rendering of <DebugOverlay /> for the rest of the session
* (a reload re-reads, so toggles take effect on next page load).
*
* Defensive try/catches because SSR and storage-disabled environments
* (private windows in some browsers) throw on access.
*/
export function readDebugFlag() {
try {
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
const param = url.searchParams.get('debug');
if (param === '1' || param === 'true') {
// Persist so subsequent reloads keep it on without the URL.
try { localStorage.setItem('paw_debug_overlay', '1'); } catch { /* ignore */ }
return true;
}
}
} catch { /* ignore */ }
try {
if (typeof localStorage !== 'undefined') {
return localStorage.getItem('paw_debug_overlay') === '1';
}
} catch { /* ignore */ }
return false;
}