997 lines
37 KiB
JavaScript
997 lines
37 KiB
JavaScript
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;
|
||
}
|