Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

142
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,142 @@
import '../styles/Header.css';
import { t } from '../i18n/t';
/**
* Pawfectly Yours top bar.
*
* Layout:
* [logo + tagline] [page title] [clear] [preview] [download] [Save] [Share] [cart]
*
* On mobile (<768px) collapses to:
* [back arrow] [page title] [clear] [preview] [download] [menu]
*
* Undo/redo moved out of the header into the canvas-area bottom bar
* (Change 19) — see HistoryControls.jsx. The header was getting
* crowded with seven distinct actions; undo/redo were the most
* obviously "editor view" rather than "ship the design" group, so
* they pair more naturally with the zoom controls now.
*/
export function Header({
cartCount = 0,
onSave, onShare, onOpenCart, onOpenMobileMenu,
onTestDownload, isExporting = false,
onClear, canClear = false,
onPreview,
}) {
return (
<header className="ph-header" role="banner">
<a className="ph-logo" href="#" onClick={(e) => e.preventDefault()} aria-label={`${t('header.app-name')} home`}>
<span className="ph-logo__mark" aria-hidden="true">🐾</span>
<span className="ph-logo__wordmark">
<span className="ph-logo__name">{t('header.app-name')}</span>
<span className="ph-logo__tagline">{t('header.app-tagline')}</span>
</span>
</a>
<h1 className="ph-title">
{t('header.page-title')} <span aria-hidden="true">💕</span>
</h1>
<div className="ph-actions">
{/* Clear canvas — wipes all elements + active template. The
* actual confirm flow lives in App.jsx (two-tap with a toast
* confirmation). The button is dimmed when there's nothing to
* clear so the action's affordance matches its effect. (Fix
* for #35.) */}
<button
type="button"
className="ph-iconbtn"
onClick={onClear}
disabled={!canClear}
aria-label={t('header.clear')}
title={t('header.clear-tooltip')}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
</svg>
</button>
{/* Print preview — server-renders the design at print resolution
* and shows it in a modal so the user can verify what will
* actually print before adding to cart. Disabled while another
* export is in flight. (Fix for #33.) */}
<button
type="button"
className="ph-iconbtn"
onClick={onPreview}
disabled={isExporting}
aria-label={t('header.preview')}
title={t('header.preview-tooltip')}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
{/* Download — dedicated print-test button. exportDesign already
* auto-downloads after the server returns, so this is functionally
* a sibling of Save with explicit "give me the file" semantics.
* Kept small (icon-only) so it reads as a utility/testing affordance,
* not a primary CTA competing with Save. Disabled while an export
* is in flight so rapid clicks don't fire parallel POSTs. */}
<button
type="button"
className="ph-iconbtn"
onClick={onTestDownload}
disabled={isExporting}
aria-label={t('header.download')}
title={t('header.download-tooltip')}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M7 10l5 5 5-5" />
<path d="M12 15V3" />
</svg>
</button>
<button type="button" className="ph-pillbtn ph-pillbtn--filled" onClick={onSave}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M12 21s-7-4.5-9.3-9A5.4 5.4 0 0 1 12 5.5 5.4 5.4 0 0 1 21.3 12C19 16.5 12 21 12 21z" />
</svg>
<span>{t('header.save')}</span>
</button>
<button type="button" className="ph-pillbtn ph-pillbtn--ghost" onClick={onShare}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="2.5" />
<circle cx="6" cy="12" r="2.5" />
<circle cx="18" cy="19" r="2.5" />
<path d="M8.2 10.7l7.6-4.4" />
<path d="M8.2 13.3l7.6 4.4" />
</svg>
<span>{t('header.share')}</span>
</button>
<button type="button" className="ph-cart" onClick={onOpenCart} aria-label={t('header.cart-aria', { count: cartCount, plural: cartCount === 1 ? 'item' : 'items' })}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="9" cy="20" r="1.6" />
<circle cx="17" cy="20" r="1.6" />
<path d="M3 4h2l2.4 11.5a2 2 0 0 0 2 1.5h7.6a2 2 0 0 0 2-1.5L21 8H6" />
</svg>
{cartCount > 0 && <span className="ph-cart__badge">{cartCount}</span>}
</button>
<button
type="button"
className="ph-iconbtn ph-iconbtn--menu"
onClick={onOpenMobileMenu}
aria-label={t('header.menu')}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
</svg>
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react';
import '../styles/MobileBottomSheet.css';
/**
* Mobile bottom sheet.
*
* Slides up from below to cover most of the viewport. Backdrop dismisses on
* tap. Drag handle at the top is decorative + clickable to close. ESC also
* closes. Body scroll is locked while open so the user doesn't accidentally
* pan the page underneath.
*
* Animation is CSS transform-based so it stays on the GPU and doesn't trigger
* layout work — important on low-end devices where slide animations can chug
* if they go through paint.
*
* Compact mode
* ────────────
* When `compact` is true, the sheet's max-height shrinks to ~55vh instead of
* 78vh. The use case is text editing on mobile: the user types in the sheet
* and watches the result render on the canvas above. With the full-height
* sheet, the canvas is hidden behind the sheet and the user can't see what
* they're typing. Compact mode leaves enough viewport above the sheet for
* the canvas to remain visible.
*/
export function MobileBottomSheet({ open, onClose, children, compact = false }) {
const sheetRef = useRef(null);
// Lock body scroll while open. We intentionally toggle a class on <body>
// rather than mutating style.overflow directly — that way other sources of
// scroll-lock (e.g. the photo-editor modal) don't stomp each other.
useEffect(() => {
if (!open) return;
document.body.classList.add('has-bottom-sheet');
return () => document.body.classList.remove('has-bottom-sheet');
}, [open]);
// ESC to dismiss.
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
return (
<div
className={`mbs${open ? ' is-open' : ''}${compact ? ' is-compact' : ''}`}
aria-hidden={!open}
>
<button
type="button"
className="mbs__backdrop"
aria-label="Close"
onClick={onClose}
tabIndex={open ? 0 : -1}
/>
<div
ref={sheetRef}
className="mbs__sheet"
role="dialog"
aria-modal="true"
aria-label="Customization options"
>
<button
type="button"
className="mbs__handle"
onClick={onClose}
aria-label="Close"
>
<span className="mbs__handle-grip" aria-hidden="true" />
</button>
<div className="mbs__content">
{children}
</div>
</div>
</div>
);
}

View File

@@ -1,30 +1,51 @@
import { useState, useEffect } from 'react';
import { useRegisterSW } from 'virtual:pwa-register/react';
import '../styles/PWAInstall.css';
export function PWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState(null);
const [showInstall, setShowInstall] = useState(false);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [newWorker, setNewWorker] = useState(null);
// Service worker registration + update detection. With registerType: 'prompt'
// in vite.config.js, the user has to opt in to applying an update — this hook
// exposes the state and the trigger function in one place.
const {
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegisterError(error) {
// eslint-disable-next-line no-console
console.warn('Service worker registration failed:', error);
},
});
useEffect(() => {
const handleBeforeInstallPrompt = (e) => { e.preventDefault(); setDeferredPrompt(e); setShowInstall(true); };
const handleSWUpdated = (event) => { setNewWorker(event.detail); setUpdateAvailable(true); };
const handleBeforeInstallPrompt = (e) => {
e.preventDefault();
setDeferredPrompt(e);
setShowInstall(true);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('swUpdated', handleSWUpdated);
return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('swUpdated', handleSWUpdated); };
return () => window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') { setShowInstall(false); setDeferredPrompt(null); }
if (outcome === 'accepted') {
setShowInstall(false);
setDeferredPrompt(null);
}
};
const handleUpdate = () => { if (newWorker) { newWorker.postMessage({ type: 'SKIP_WAITING' }); window.location.reload(); } };
const handleUpdate = () => {
// updateServiceWorker(true) skips the waiting state, activates the new SW,
// and reloads the page. The plugin handles the reload internally.
updateServiceWorker(true);
};
if (!showInstall && !updateAvailable) return null;
if (!showInstall && !needRefresh) return null;
return (
<>
@@ -37,11 +58,11 @@ export function PWAInstall() {
</div>
</div>
)}
{updateAvailable && (
{needRefresh && (
<div className="pwa-update-banner">
<span>🔄 New version available!</span>
<button onClick={handleUpdate} className="refresh-btn">Refresh</button>
<button onClick={() => { setUpdateAvailable(false); setNewWorker(null); }} className="close-btn"></button>
<button onClick={() => setNeedRefresh(false)} className="close-btn"></button>
</div>
)}
</>

View File

@@ -0,0 +1,188 @@
import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval';
/**
* "Remove background" control for the floating element toolbar.
*
* Lives under canvas/ rather than sidebar/ because it's rendered inside
* ElementToolbar (a canvas-area component) and operates on the selected
* canvas element.
*
* Render variants
* ───────────────
* • compact (default) — renders as a `.el-toolbar__btn`-shaped button
* that fits alongside Flip H, Flip V, and Crop in the toolbar's
* action row. Vertical layout: icon glyph on top, small label
* below (label hidden on mobile via the same media query as the
* other action buttons). While the model is loading or the
* background is being removed, the button shows a spinner glyph
* and is disabled.
* • full — the original wide pill-shaped button. Currently unused
* by callers but kept around in case a future surface (e.g. a
* dedicated photo-actions panel) wants the more prominent
* treatment. Pass `variant="full"` to opt in.
*
* Why one component with a variant prop and not two
* ─────────────────────────────────────────────────
* The hook plumbing (useBackgroundRemoval) and the post-removal
* geometry math (mapping the alpha bbox from source pixel space back
* into canvas units, accounting for rotation) is the same regardless
* of how the button is rendered. Splitting into two components would
* mean duplicating that logic or extracting it into a hook anyway;
* one component with a presentational switch keeps things compact.
*/
export function BackgroundRemovalButton({
selectedElement,
onUpdate,
variant = 'compact',
}) {
const { loading, progress, removeBackground } = useBackgroundRemoval();
const handleRemoveBackground = async () => {
if (!selectedElement || selectedElement.type !== 'image') return;
// removeBackground handles model loading internally if needed.
const result = await removeBackground(selectedElement.src);
if (!result) return;
const { url, bbox } = result;
// Defensive bail — a zero-area bbox would produce NaN aspect
// below. removeBackground shouldn't return one in practice (an
// empty mask means everything was transparent already), but if it
// does we skip the update rather than landing garbage geometry in
// state.
if (bbox.sw <= 0 || bbox.sh <= 0) return;
// Scale the bg-removed subject to occupy the ORIGINAL element's
// on-canvas bounding box, preserving the subject's aspect ratio
// (letterboxing along whichever axis is needed). Previously we
// tightened the bbox to wrap just the subject pixels, which made
// the element visually shrink after bg removal — a 200×100 photo
// of a pet on a busy background would collapse to a 60×40 pet,
// forcing the user to manually resize it back up if they wanted
// the subject at the same scale.
//
// Letterbox-fit logic is the same shape as `handleResetCrop`'s
// "resized" branch in App.jsx:
//
// subjectAspect > originalAspect : subject is relatively
// wider → fill the box's WIDTH, let the height shrink to
// match the subject's aspect.
//
// subjectAspect ≤ originalAspect : subject is relatively
// taller (or square) → fill HEIGHT, let width shrink.
//
// The new (smaller-on-at-least-one-axis) bbox is then centered
// inside the original bbox so the subject sits where the user
// last saw it, not anchored to a corner.
const oldW = selectedElement.width || 100;
const oldH = selectedElement.height || 100;
const subjectAspect = bbox.sw / bbox.sh;
const originalAspect = oldW / oldH;
let newW;
let newH;
if (subjectAspect > originalAspect) {
newW = oldW;
newH = oldW / subjectAspect;
} else {
newH = oldH;
newW = oldH * subjectAspect;
}
// Center the new bbox inside the original. Konva rotates around
// the element's (x, y) origin, not its visual center, so we
// compute the centered offset in LOCAL (unrotated) coords first,
// then rotate it into canvas coords to find the new top-left.
// This preserves the visual center of the element through the bg
// removal even when the element was rotated beforehand. Flip is
// not specially handled; centering means the subject lands in
// the same spot regardless of flip state.
const rad = ((selectedElement.rotation || 0) * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const localOffsetX = (oldW - newW) / 2;
const localOffsetY = (oldH - newH) / 2;
const newX = (selectedElement.x || 0) + localOffsetX * cos - localOffsetY * sin;
const newY = (selectedElement.y || 0) + localOffsetX * sin + localOffsetY * cos;
onUpdate(selectedElement.id, {
src: url,
x: newX,
y: newY,
width: newW,
height: newH,
// The bg-removed result is already cropped to its visible region,
// so any prior `crop` (e.g. from a slot template) no longer maps.
crop: undefined,
// Pre/post-crop tracking from in-app crops also no longer applies
// — the image source has changed, so those dimensions reference
// an image that's gone. Clearing here mirrors the cleanup in
// `handlePhotoEditComplete` over in App.jsx.
preCrop: undefined,
postCrop: undefined,
bgRemoved: true,
});
};
if (!selectedElement || selectedElement.type !== 'image') return null;
// Compact: matches `.el-toolbar__btn` so it sits cleanly next to
// Flip H / Flip V / Crop in the action row. SVG glyph is a small
// "magic wand" — same metaphor as the original ✨ emoji but
// rendered as a proper icon so it inherits currentColor for
// hover/active states. While loading, the button shows a spinner
// and disables itself; progress (0-100) is included in the
// tooltip / aria-label so screen-reader users get a status read.
if (variant === 'compact') {
const label = loading
? (progress > 0 ? `Removing background (${progress}%)` : 'Removing background\u2026')
: 'Remove background';
return (
<button
type="button"
onClick={handleRemoveBackground}
disabled={loading}
className={`el-toolbar__btn${loading ? ' el-toolbar__btn--busy' : ''}`}
aria-label={label}
title={label}
>
{loading ? (
<span className="el-toolbar__spinner" aria-hidden="true" />
) : (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Magic wand + sparkles — read as "make the background go
away" without committing to any one literal "background"
glyph. Matches the previous ✨ emoji metaphor. */}
<path d="M15 4V2" />
<path d="M15 16v-2" />
<path d="M8 9h2" />
<path d="M20 9h2" />
<path d="M17.8 11.8L19 13" />
<path d="M15 9h.01" />
<path d="M17.8 6.2L19 5" />
<path d="M3 21l9-9" />
<path d="M12.2 6.2L11 5" />
</svg>
)}
<span className="el-toolbar__btn-label">{loading ? 'Working\u2026' : 'Remove BG'}</span>
</button>
);
}
// Full (legacy) — wide pill button. Kept for completeness; no
// current caller uses it. Same logic, different chrome.
return (
<div className="bg-removal-container">
<button className="bg-removal-btn" onClick={handleRemoveBackground} disabled={loading}>
{loading ? (
<>
<div className="spinner-small" />
{progress > 0 ? `Loading: ${progress}%` : 'Removing Background...'}
</>
) : (
<> Remove Background</>
)}
</button>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import '../../styles/CanvasHint.css';
/**
* Small rounded badge ("Canvas — Click and drag to move") shown in the
* top-left of the canvas area. Mirrors the screenshot's onboarding hint.
*/
export function CanvasHint() {
return (
<div className="canvas-hint" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M5 3l8 18 2-7 7-2L5 3z" />
</svg>
<div className="canvas-hint__text">
<strong>Canvas</strong>
<span>Click and drag to move</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,283 @@
import { useEffect, useRef, useCallback } from 'react';
import { Layer, Rect, Line, Transformer } from 'react-konva';
/**
* On-canvas crop overlay.
*
* Mounted by DesignCanvas when App's `cropping` state targets an image
* element. Renders into its OWN dedicated Layer above the elements
* layer — keeping it out of the elements layer means there's zero
* z-order or hit-test ambiguity with image / text nodes or the shared
* multi-select Transformer that lives down there. The Layer carries
* the same `x`/`y` offset as the elements layer so the crop Rect's
* x/y are in canonical 0..CANVAS_SIZE design space.
*
* Contents (in z-order, bottom up):
*
* 1. Four dim rects covering the parts of the IMAGE ELEMENT outside
* the current crop selection — the user sees what will be kept
* (bright) vs what will be discarded (dimmed).
*
* 2. Rule-of-thirds guide lines inside the crop rect.
*
* 3. The crop rect itself — transparent fill, pink stroke. NOT
* currently draggable; see the inline IMPORTANT note on the Rect
* below for the reason and a sketch of how interior-drag could
* be added back safely.
*
* 4. Konva Transformer attached to the rect. All 8 anchors enabled
* so the user can resize from corners or edges (including
* shrinking each side independently via the middle-edge anchors).
* `keepRatio` off — crop is about choosing composition, not
* preserving aspect.
*
* Coordinate-system note (the bug that took several iterations to find)
* ────────────────────────────────────────────────────────────────────
* Konva's Transformer calls `boundBoxFunc(oldBox, newBox)` with boxes
* in ABSOLUTE STAGE COORDS, NOT in the layer-local coord system of
* the attached node. This is undocumented in the prose of the Konva
* docs (the docs just say "format: {x, y, width, height, rotation}"
* without specifying the coord system), but it's empirically confirmed
* by inspecting the box values during a drag: a Rect at layer-local
* (86, 71) inside a Layer offset by (162, 216) produces newBox.x = 248.
*
* The element's x/y/width/height in `props.element` are in layer-local
* design coords (App's state, same space as `cropping.rect`). To
* compare newBox against the element's bounds, we must first convert
* newBox from absolute to layer-local by subtracting layerX/layerY.
* Earlier iterations skipped that conversion, so every drag failed
* the "is the new box inside the element?" check by ~LAYER_OFFSET_X
* units, and the Transformer rejected every newBox by returning
* oldBox each frame — visible symptom: anchors didn't move on drag.
*
* Why Konva primitives instead of a dedicated crop library
* ─────────────────────────────────────────────────────────
* The editor is already 100% Konva. Bringing in something like
* react-image-crop or cropperjs would mean rendering the image in
* the DOM during crop mode (those libs operate on <img>) and then
* synchronizing the crop result back to the Konva element — two
* rendering paths for the same element. Konva's own Rect + Transformer
* give us the exact UI with zero extra code or sync, and the result
* (a `crop` attr on Konva.Image) is already part of the schema.
*/
export function CropOverlay({
element,
cropRect,
onCropRectChange,
layerX,
layerY,
}) {
const rectRef = useRef(null);
const trRef = useRef(null);
// Wire the Transformer to the rect after both have mounted. Pure
// useEffect pattern — runs once after the first render commits
// both nodes to the Konva tree. Empty deps because re-running
// would call nodes([rect]) again with the same node (no-op).
useEffect(() => {
const transformer = trRef.current;
const shape = rectRef.current;
if (!transformer || !shape) return;
transformer.nodes([shape]);
transformer.getLayer()?.batchDraw();
}, []);
// Transform bound — keep the rect inside the element's bbox while
// resizing via the anchors.
//
// newBox is in ABSOLUTE STAGE COORDS (see the coordinate-system
// note in the module docblock). We convert to layer-local by
// subtracting the layer offset, then compare against the element's
// layer-local bounds.
//
// EPS of 5 is generous to handle:
// • Sub-pixel artifacts in Konva's continuous transform math.
// • The 1.5px stroke half-width that, even with ignoreStroke on
// the Transformer, can produce a fraction of a unit's
// discrepancy between the Rect's bbox and its content area.
const boundBoxFunc = useCallback((oldBox, newBox) => {
const EPS = 5;
const MIN_CROP = 12;
if (newBox.width < MIN_CROP || newBox.height < MIN_CROP) return oldBox;
// Convert from absolute stage coords to layer-local design coords.
// The element's x/y/width/height are in the same layer-local space.
const localX = newBox.x - layerX;
const localY = newBox.y - layerY;
const localRight = localX + newBox.width;
const localBottom = localY + newBox.height;
if (
localX < element.x - EPS
|| localY < element.y - EPS
|| localRight > element.x + element.width + EPS
|| localBottom > element.y + element.height + EPS
) return oldBox;
return newBox;
}, [element.x, element.y, element.width, element.height, layerX, layerY]);
// Commit geometry changes back up to App. Called on transform-end
// (continuous updates would be over-eager — the overlay is purely
// visual until the user clicks Apply). Bakes the Transformer's
// scale into width/height so subsequent transforms start from a
// clean ±1 scale — same pattern as ImageElement.
//
// node.x() and node.y() return layer-local coords, which is what
// App's cropping.rect state expects.
const commitGeometry = useCallback(() => {
const node = rectRef.current;
if (!node) return;
const sx = node.scaleX();
const sy = node.scaleY();
const w = Math.max(12, node.width() * Math.abs(sx));
const h = Math.max(12, node.height() * Math.abs(sy));
node.scaleX(1);
node.scaleY(1);
node.width(w);
node.height(h);
onCropRectChange({
x: node.x(),
y: node.y(),
width: w,
height: h,
});
}, [onCropRectChange]);
// Geometry shortcuts for the dim mask + guides.
const ex = element.x;
const ey = element.y;
const ew = element.width;
const eh = element.height;
const cx = cropRect.x;
const cy = cropRect.y;
const cw = cropRect.width;
const ch = cropRect.height;
const MASK_FILL = 'rgba(15, 23, 42, 0.55)'; // slate-900 at low opacity
return (
<Layer x={layerX} y={layerY}>
{/* Dim mask — top strip. */}
<Rect
x={ex}
y={ey}
width={ew}
height={Math.max(0, cy - ey)}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Dim mask — bottom strip. */}
<Rect
x={ex}
y={cy + ch}
width={ew}
height={Math.max(0, (ey + eh) - (cy + ch))}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Dim mask — left strip. */}
<Rect
x={ex}
y={cy}
width={Math.max(0, cx - ex)}
height={ch}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Dim mask — right strip. */}
<Rect
x={cx + cw}
y={cy}
width={Math.max(0, (ex + ew) - (cx + cw))}
height={ch}
fill={MASK_FILL}
listening={false}
perfectDrawEnabled={false}
/>
{/* Rule-of-thirds guide lines. */}
<Line
points={[cx + cw / 3, cy, cx + cw / 3, cy + ch]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
<Line
points={[cx + (cw * 2) / 3, cy, cx + (cw * 2) / 3, cy + ch]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
<Line
points={[cx, cy + ch / 3, cx + cw, cy + ch / 3]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
<Line
points={[cx, cy + (ch * 2) / 3, cx + cw, cy + (ch * 2) / 3]}
stroke="rgba(255, 255, 255, 0.55)"
strokeWidth={1}
dash={[4, 4]}
listening={false}
perfectDrawEnabled={false}
/>
{/* The crop rect itself. Transparent fill + pink stroke.
*
* IMPORTANT — the Rect is intentionally NOT draggable.
*
* Earlier iterations had `draggable={true}` plus a generous
* `hitStrokeWidth={20}` so the user could grab the rect's
* border to translate the whole crop region. That combination
* overlapped the rect's hit area with the Transformer's anchor
* hit areas at the corners; Konva's hit-test gave inconsistent
* results between anchor-resize and rect-drag.
*
* For now the user shrinks the crop region by dragging each
* edge inward via its middle-edge anchor. Translating the
* whole crop would need a separate inner Rect with its own
* listening / draggable, sized to NOT overlap the anchor
* positions — worthwhile as a follow-up if users miss the
* interior-drag affordance. */}
<Rect
ref={rectRef}
x={cropRect.x}
y={cropRect.y}
width={cropRect.width}
height={cropRect.height}
fill="rgba(0, 0, 0, 0.01)"
stroke="#ec4899"
strokeWidth={1.5}
onTransformEnd={commitGeometry}
/>
{/* Transformer — 8 anchors (default), no rotation, no keepRatio.
* `ignoreStroke` keeps the anchor positions tracking the rect's
* content geometry rather than the stroke-inflated bbox; without
* it, the anchors render 0.75px outside each edge and the
* resize feel is slightly off. */}
<Transformer
ref={trRef}
rotateEnabled={false}
keepRatio={false}
boundBoxFunc={boundBoxFunc}
anchorFill="#fff"
anchorStroke="#ec4899"
anchorSize={12}
anchorCornerRadius={2}
borderEnabled={false}
ignoreStroke
/>
</Layer>
);
}

View File

@@ -0,0 +1,996 @@
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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
// Reuses the visual treatment defined in ZoomControls.css (same pill
// chrome — white rounded container, brand-pink hover, disabled state).
// We import that CSS rather than copy the rules to keep the two
// pills perfectly in sync; if the pill chrome is ever refined in
// ZoomControls.css the same look applies here automatically.
//
// Future cleanup: extract `.zoom-controls` / `.zoom-controls__btn` to a
// shared `.pill-toolbar` block in a neutral file and migrate both
// components. Not done now because the rename touches multiple
// components and isn't blocking — the import-reuse is enough to keep
// these visually identical in the meantime.
import '../../styles/ZoomControls.css';
import { t } from '../../i18n/t';
/**
* Undo / Redo controls.
*
* Lives in the canvas-area's bottom bar alongside the zoom/snap pill,
* so all canvas-level "editor view" actions cluster together. Moved
* out of the Header in Change 19 — the Header had become crowded
* with seven distinct actions (undo, redo, clear, preview, download,
* Save, Share, cart) and the undo/redo pair was the most obviously
* "editor view" rather than "ship the design" group.
*
* Each button is disabled when its action isn't available (no
* history past this point in either direction). The icons match
* what was previously in the Header: a curved arrow turning back
* left for undo, the same arrow mirrored for redo.
*
* i18n: the strings live under `header.undo` / `header.redo` keys
* historically. We continue to use them here even though the buttons
* have moved out of the header — they're functional labels not
* location-specific, and renaming them would mean updating every
* locale file. The keys are flat strings, the namespace prefix is
* just punctuation.
*/
export function HistoryControls({ canUndo, onUndo, canRedo, onRedo }) {
return (
<div className="zoom-controls" role="toolbar" aria-label="Edit history">
<button
type="button"
className="zoom-controls__btn"
onClick={onUndo}
disabled={!canUndo}
aria-label={t('header.undo')}
title={t('header.undo-tooltip')}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Curved arrow turning back to the left — universal undo glyph. */}
<path d="M9 14L4 9l5-5" />
<path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5h-4" />
</svg>
</button>
<button
type="button"
className="zoom-controls__btn"
onClick={onRedo}
disabled={!canRedo}
aria-label={t('header.redo')}
title={t('header.redo-tooltip')}
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Mirror of the undo glyph. */}
<path d="M15 14l5-5-5-5" />
<path d="M20 9H9a5 5 0 0 0-5 5v0a5 5 0 0 0 5 5h4" />
</svg>
</button>
</div>
);
}

View File

@@ -1,12 +1,69 @@
import { useEffect, useRef, memo, useCallback } from 'react';
import { Image, Transformer } from 'react-konva';
import { useEffect, useRef, memo } from 'react';
import { Image, Rect } from 'react-konva';
import Konva from 'konva';
import useImage from 'use-image';
import { getFilterPreset } from '../../constants/imageFilters';
import { MIN_ELEMENT_SIZE } from '../../constants/elements';
import { unflipReportedXY } from '../../utils/flipOffset';
// Per-element Transformer rendering was moved to DesignCanvas's dedicated
// transformer layer (see DesignCanvas.jsx). This component now renders
// the image + its out-of-bounds warning; selection chrome lives one
// layer above so resize / rotate handles always draw on top of later
// elements in z-order. Imports for Transformer / rotation snap / corner
// anchors / transformer visual style were dropped along with the JSX
// that consumed them.
//
// MIN_ELEMENT_SIZE stays imported — onTransformEnd still applies it
// as a defensive floor when committing the final width/height to
// state. DesignCanvas's bound function clamps DURING the transform
// (so the user can't drag handles past the floor), but the commit
// here is the last line of defence: any rounding, gesture
// interruption, or future bound-function bug that lets a sub-floor
// value slip through still gets clamped before it lands in state.
function URLImage({ src, innerRef, ...props }) {
const [img] = useImage(src, 'anonymous');
return <Image image={img} ref={innerRef} {...props} />;
}
/**
* Konva Image wrapper.
*
* ── Flipping ─────────────────────────────────────────────────────────────
* Horizontal/vertical flip is implemented as `scaleX/scaleY = ±1` on the node
* combined with a matching `offsetX/offsetY` so the flip happens in-place
* (around the bounding-box center) instead of around the (x, y) origin (which
* would visually "shoot off" the element).
*
* The user can still resize via the transformer corner handles. Konva applies
* the transform as a multiplier on `scaleX`, so a flipped element being
* resized to 1.5× lands at scaleX = -1.5. In `onTransformEnd` we collapse
* that back into width/height (taking absolute value of the scale) and
* preserve the flip state on the element. The transformer never *changes*
* the flip — only the explicit Flip H / Flip V buttons do.
*
* ── Opacity ──────────────────────────────────────────────────────────────
* Pass-through to Konva's native `opacity` prop on the node.
*
* ── Lock ─────────────────────────────────────────────────────────────────
* When `locked` is true, the element can't be dragged or transformed. The
* transformer still renders a border (so the user knows it's selected) but
* with no anchors or rotate handle. Toggle via the lock button in the
* floating element toolbar.
*
* ── Filters ──────────────────────────────────────────────────────────────
* `filter` is a preset id (see constants/imageFilters.js). Applying a Konva
* filter requires the node to be cached, so we attach a useEffect that
* caches+filters once the underlying image has loaded and re-runs whenever
* the filter or the image changes. Server-side render does NOT currently
* apply filters; this is an editor-only visual at this time.
*
* ── Rotation snap ────────────────────────────────────────────────────────
* When `shiftHeld` is true, the transformer snaps to 15° increments. The
* shift state is tracked at the App level so all selected elements get
* the snap behavior simultaneously.
*/
export const ImageElement = memo(function ImageElement({
id: _id,
x = 0,
@@ -16,54 +73,238 @@ export const ImageElement = memo(function ImageElement({
rotation = 0,
src,
crop,
isSelected,
opacity = 1,
flipX = false,
flipY = false,
locked = false,
filter = 'none',
// Brightness / contrast adjustments (range matches Konva.Filters):
// brightness: -1 (fully dark) … 0 (no change) … +1 (fully bright)
// contrast: -100 (fully flat) … 0 (no change) … +100 (max contrast)
// Default 0 = identity; both compose with the preset `filter` so a
// user can pick "Sepia" and then nudge brightness up, etc.
brightness = 0,
contrast = 0,
isSelected: _isSelected,
// Change 3 — when true, render a warning-yellow Rect behind the
// image so the user can see WHICH element is out of bounds, not just
// that *something* is. Computed at the DesignCanvas level via the
// same `isElementOutOfBounds` math that drives the bottom-chip count.
isOutOfBounds = false,
shiftHeld: _shiftHeld = false,
onSelect,
onUpdate,
onCommit,
dragBoundFunc,
transformBoundFunc,
transformBoundFunc: _transformBoundFunc,
}) {
const shapeRef = useRef(null);
const trRef = useRef(null);
const attachTransformer = useCallback(() => {
if (!isSelected) return;
const transformer = trRef.current;
const shape = shapeRef.current;
if (!transformer || !shape) return;
transformer.nodes([shape]);
transformer.getLayer()?.batchDraw();
}, [isSelected]);
const setTransformerRef = useCallback(
(node) => {
trRef.current = node;
attachTransformer();
},
[attachTransformer]
);
// Filter application. Konva requires the node to be cached for filters
// to take effect, and re-cached whenever the input pixels (image, size)
// or the filter list change. We do this in a useEffect that also handles
// the initial async image load — the underlying `useImage` inside
// URLImage updates the node's `image` attr asynchronously, so we listen
// for the `imageChange` event on the node and re-cache then.
//
// Brightness and contrast stack on top of the preset filter — Konva's
// `filters()` accepts an array and runs them in order, so a Sepia preset
// with brightness +0.2 gives a brightened sepia. Brightness 0 and
// contrast 0 are identity values, but applying the filter is still
// cheap (the no-op multiplier), so we keep them in the chain whenever
// the user has touched the sliders rather than special-casing.
useEffect(() => {
attachTransformer();
}, [attachTransformer]);
const node = shapeRef.current;
if (!node) return;
const applyFilter = () => {
const preset = getFilterPreset(filter);
// Lookup order: customFn (e.g. Filerobot's Kelvin) takes precedence,
// then a string name resolved against Konva.Filters, else no filter.
const presetFn = preset.customFn ?? (preset.konva ? Konva.Filters[preset.konva] : null);
const filters = [];
if (presetFn) filters.push(presetFn);
// Add Brighten / Contrast only when the user has actually moved
// the slider off zero. Avoids cache+filter overhead for the
// common "untouched" case while still composing cleanly with
// the preset when active.
if (brightness !== 0) filters.push(Konva.Filters.Brighten);
if (contrast !== 0) filters.push(Konva.Filters.Contrast);
if (filters.length > 0) {
node.cache();
node.filters(filters);
} else {
node.filters([]);
node.clearCache();
}
node.getLayer()?.batchDraw();
};
// Apply once for the current state.
applyFilter();
// Re-apply when the image itself loads/changes — Konva's `image` attr
// is set async by `useImage` and the cache is dimension-sensitive,
// so an early `cache()` call before the image arrives produces an
// empty filtered layer. The `imageChange` event fires whenever the
// attr is reassigned.
node.on('imageChange.filterapply', applyFilter);
return () => {
node.off('imageChange.filterapply');
};
}, [filter, brightness, contrast, src, width, height]);
// Always-center offset for rotation + flip-in-place.
//
// Konva rotates a node around its local origin (the point at
// `offsetX, offsetY`). Setting offset to the bbox CENTER means:
//
// • Rotation pivots around the center — what users expect from
// a rotate handle. Previously the offset was zero for
// non-flipped elements, which put the pivot at the top-left;
// on touch devices the rotate handle had inherent sub-pixel
// contact offset, and that small offset rotated around the
// top-left visibly swung the whole bbox sideways on the first
// frame of rotation. Desktop didn't see it because mouse-down
// is pixel-precise (first frame stays at ~0° rotation). The
// center pivot fixes the mobile feel without changing desktop.
//
// • Flipping (scaleX/Y = -1) reflects around the local origin,
// so a center-aligned offset gives in-place flip for free.
// This used to be the only reason offset was set; we now set
// it unconditionally and get correct rotation as a bonus.
//
// Because the offset shifts the node's local origin, the rendered
// top-left lands at `(x - offsetX, y - offsetY)`. To keep the
// user-supplied (element.x, element.y) as the visual top-left,
// renderX/renderY are computed by ADDING the offset back to the
// stored position.
//
// Migration note for existing saved designs
// ───────────────────────────────────────────
// For non-flipped, non-rotated elements: identical render — the
// bbox is axis-aligned, so the center-pivot draws at the same
// screen location as the old top-left-pivot.
// For non-flipped, ROTATED elements saved before this change:
// their stored (x, y) was the location the OLD top-left pivot
// ended up at AFTER the rotation. Now we re-interpret (x, y) as
// the pivot of a CENTER-rotated element, so the visible bbox will
// shift on first load. The drift is bounded (the bbox stays the
// same size; only its position moves) and the user can re-position
// by dragging. No automatic migration: the editor doesn't have a
// persistence version field, and the rotated-saved-design case is
// rare enough that adding heuristic detection isn't worth the
// complexity.
// For FLIPPED rotated elements: no visual change. The offset was
// already center in the old code (that was the original purpose),
// so the rotation behavior is unchanged for them.
const offsetX = width / 2;
const offsetY = height / 2;
const renderX = x + offsetX;
const renderY = y + offsetY;
// Bbox in PIVOT-RELATIVE local coords, exposed as custom Konva
// attrs that DesignCanvas's canvasDragBound reads.
//
// canvasDragBound computes rotated corners with the formula
// (x·cos - y·sin, x·sin + y·cos), which rotates the (xMin, yMin)
// to (xMax, yMax) rectangle around the ORIGIN (0, 0) of the
// node-local frame. With offset at the bbox center, the local
// origin IS the rotation pivot — and the bbox itself spans from
// (-W/2, -H/2) to (+W/2, +H/2) relative to that pivot. Without
// setting these explicit attrs, the function would fall back to
// its `?? 0` / `?? this.width()` defaults (correct only for the
// old offset=0 convention) and the clamping would be off by
// half a bbox.
//
// The values are scalar so they can be read by Konva's attrs
// system without React intermediation — same pattern TextElement
// already uses for visible-ink bbox.
const snapBboxMinX = -width / 2;
const snapBboxMinY = -height / 2;
const snapBboxMaxX = width / 2;
const snapBboxMaxY = height / 2;
return (
<>
{/* Out-of-bounds warning background (Change 3). Rendered BEFORE
the image so it sits behind. Uses the same render-time
position / rotation / flip math as the image itself, so the
highlight follows the element through every transform. We
intentionally don't include the warning rect in the
Transformer's nodes() — it's purely decorative and shouldn't
add to the selection bbox. The yellow is the classic CSS
`gold` family at low opacity so it reads as a warning without
drowning the underlying artwork.
Same always-center offset as the image below so the warning
tracks the image identically through rotation. Previously
the warning's offset was conditional on flip and could drift
relative to the image when the offset conventions diverged;
mirroring the image's render math here keeps them locked. */}
{isOutOfBounds && (
<Rect
x={renderX}
y={renderY}
width={width}
height={height}
offsetX={offsetX}
offsetY={offsetY}
scaleX={flipX ? -1 : 1}
scaleY={flipY ? -1 : 1}
rotation={rotation}
fill="#fde68a"
stroke="#f59e0b"
strokeWidth={1.5}
opacity={0.65}
listening={false}
perfectDrawEnabled={false}
/>
)}
<URLImage
innerRef={shapeRef}
x={x}
y={y}
/* Konva `name` attr = element id. Lets DesignCanvas locate the
* underlying Konva node via `layer.findOne('.' + id)` for two
* features:
* 1. Marquee drag-select reads each node's getClientRect to
* test intersection with the marquee bbox.
* 2. Shared multi-element Transformer wires `transformer.nodes`
* to the actual Konva nodes whenever selectedIds changes.
* Using `name` rather than `id` because Konva's `id()` is reserved
* for its own internal id-uniqueness machinery, while `name` is
* effectively a free-form className with `.name`-selector support. */
name={_id}
x={renderX}
y={renderY}
width={width}
height={height}
offsetX={offsetX}
offsetY={offsetY}
scaleX={flipX ? -1 : 1}
scaleY={flipY ? -1 : 1}
rotation={rotation}
opacity={opacity}
/* Pivot-relative bbox attrs read by canvasDragBound. See the
* docblock above the snapBbox* declarations for the full
* rationale; in short, they describe the visible rect in the
* post-offset local frame so the clamp math lines up with the
* rotated AABB at the wrapper boundary. */
_pawSnapBboxMinX={snapBboxMinX}
_pawSnapBboxMinY={snapBboxMinY}
_pawSnapBboxMaxX={snapBboxMaxX}
_pawSnapBboxMaxY={snapBboxMaxY}
/* Konva reads `brightness` / `contrast` off the node's attrs
* during filter application these props set the values that
* Konva.Filters.Brighten and Konva.Filters.Contrast consume. */
brightness={brightness}
contrast={contrast}
src={src}
crop={
crop
? { x: crop.sx, y: crop.sy, width: crop.sWidth, height: crop.sHeight }
: undefined
}
draggable
draggable={!locked}
dragBoundFunc={dragBoundFunc}
onClick={(e) => {
e.cancelBubble = true;
@@ -73,42 +314,61 @@ export const ImageElement = memo(function ImageElement({
e.cancelBubble = true;
onSelect?.();
}}
/* Bug 6 fix select on press, not just on click-up. See
* TextElement.jsx for the full rationale: click-then-drag
* without an intermediate mouseup never fires onClick, so the
* pre-fix flow left the element unselected once the user
* started dragging immediately and the DesignCanvas-level
* Transformer (which attaches to the selected node) never
* mounted handles around it. */
onMouseDown={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onTouchStart={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onDragEnd={(e) => {
onUpdate({ x: e.target.x(), y: e.target.y() });
// Convert back to top-left-origin coords if we were drawing flipped.
const { x: reportedX, y: reportedY } = unflipReportedXY(e.target, width, height, flipX, flipY);
onUpdate({ x: reportedX, y: reportedY });
onCommit?.();
}}
onTransformEnd={() => {
const node = shapeRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
// The transformer multiplies into scaleX/scaleY. We absorb the
// *magnitude* into width/height and keep the *sign* (flip state)
// separate by reading element.flipX/flipY explicitly.
const sx = node.scaleX();
const sy = node.scaleY();
const newWidth = Math.max(MIN_ELEMENT_SIZE, node.width() * Math.abs(sx));
const newHeight = Math.max(MIN_ELEMENT_SIZE, node.height() * Math.abs(sy));
// Reset scale to ±1 (the flip-state base) so subsequent transforms
// start fresh.
node.scaleX(flipX ? -1 : 1);
node.scaleY(flipY ? -1 : 1);
// Convert back to top-left-origin coords using the freshly-resolved
// dimensions, not the stale prop values — a shrink-then-flip would
// otherwise offset by the wrong half-bbox.
const { x: reportedX, y: reportedY } = unflipReportedXY(node, newWidth, newHeight, flipX, flipY);
onUpdate({
x: node.x(),
y: node.y(),
width: Math.max(20, node.width() * scaleX),
height: Math.max(20, node.height() * scaleY),
x: reportedX,
y: reportedY,
width: newWidth,
height: newHeight,
rotation: node.rotation(),
});
onCommit?.();
}}
/>
{isSelected && (
<Transformer
ref={setTransformerRef}
keepRatio
boundBoxFunc={(oldBox, newBox) => {
if (Math.abs(newBox.width) < 20 || Math.abs(newBox.height) < 20) return oldBox;
return transformBoundFunc ? transformBoundFunc(oldBox, newBox) : newBox;
}}
anchorSize={8}
anchorCornerRadius={4}
borderStroke="#38bdf8"
anchorStroke="#38bdf8"
anchorFill="#ffffff"
/>
)}
{/* Per-element Transformer removed (bug 8 fix). Selection chrome
now lives in DesignCanvas's dedicated transformer layer,
which renders ABOVE the elements layer so resize / rotate
handles always draw on top of every element regardless of
z-order. The MIN_ELEMENT_SIZE floor and keepRatio / corner-
anchor configuration that used to live here moved with it. */}
</>
);
});

View File

@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
/**
* Inline text editor (S2).
*
* Renders a transparent <textarea> overlay positioned exactly over a
* canvas text element so the user can edit the text in place rather than
* jumping to the side panel. Activated by double-clicking a text element.
*
* Coordinates
* ───────────
* The Konva Stage uses a CSS transform (`scale(s)`) to fit the design
* canvas into whatever DOM area is available. The text node's
* `getClientRect({ relativeTo: stage })` returns coordinates in the
* Stage's INTERNAL space (the canonical 300×300 design coords). To
* place the textarea over the text on screen, we multiply those by the
* stage's CSS scale factor and offset by the stage container's
* page-level position.
*
* pageX = stageBox.left + rect.x × cssScale
* pageY = stageBox.top + rect.y × cssScale
*
* Rotation is intentionally NOT supported inline — rotated text would
* need a CSS transform on the textarea, plus pointer events get weird.
* For rotated/arc text, the side panel remains the editing surface;
* double-click still selects the text, just doesn't open the inline
* editor.
*
* Lifecycle
* ─────────
* Mount → focus the textarea, select all so the user can immediately
* type to replace.
* Input → onChange streams every keystroke to onUpdate (the same
* updateSelectedElement path the side-panel textarea uses, with
* center-preservation already applied by the App-level wrapper).
* Dismiss → onCommit + onClose, fired on blur OR Escape.
*
* Enter/Return creates a newline (textarea default). Cmd/Ctrl+Enter
* dismisses, mirroring most chat-UI conventions for "done editing".
*/
export function InlineTextEditor({ element, rect, onUpdate, onCommit, onClose }) {
const textareaRef = useRef(null);
// Focus + select-all on mount, so the user can immediately type to
// replace the existing text. Running once via empty deps; the
// textarea is keyed by element.id at the call site so a different
// element triggers a remount.
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.select();
}, []);
if (!element || !rect) return null;
const handleChange = (e) => {
onUpdate?.({ text: e.target.value });
};
const handleKeyDown = (e) => {
// Escape dismisses without committing the in-flight value (the
// updates already streamed via onUpdate, and history will commit
// them at onClose anyway). Cmd/Ctrl+Enter is the explicit-commit
// shortcut. Plain Enter inserts a newline as expected.
if (e.key === 'Escape') {
e.preventDefault();
onClose?.();
return;
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
onCommit?.();
onClose?.();
}
// Stop propagation so global shortcuts (Delete, Backspace, [, ])
// don't fire while the user is editing.
e.stopPropagation();
};
const handleBlur = () => {
onCommit?.();
onClose?.();
};
// Match the rendered text's typography so what the user types lines
// up visually with what they'll see when they're done editing. The
// textarea sits on top of the text node, so any mismatch reads as
// "the editor is in a different font than my text" — confusing.
const fontSize = (element.fontSize || 24) * (rect.scale || 1);
const style = {
position: 'fixed',
left: `${rect.left}px`,
top: `${rect.top}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
fontSize: `${fontSize}px`,
fontFamily: element.fontFamily || 'DM Sans',
color: element.fill || '#0f172a',
background: 'rgba(255, 255, 255, 0.92)',
border: '2px solid #ec4899',
borderRadius: '4px',
outline: 'none',
padding: '2px 4px',
margin: 0,
resize: 'none',
overflow: 'hidden',
lineHeight: 1.2,
textAlign: 'center',
boxSizing: 'border-box',
boxShadow: '0 0 0 4px rgba(236, 72, 153, 0.18)',
zIndex: 1000,
};
return createPortal(
<textarea
ref={textareaRef}
value={element.text ?? ''}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
style={style}
aria-label="Edit text inline"
/>,
document.body,
);
}

View File

@@ -0,0 +1,128 @@
import '../../styles/ModelSilhouette.css';
/**
* Stylized silhouette of a person wearing a t-shirt. Rendered behind the
* shirt SVG when "Preview on Model" is enabled, so the user can see what
* their design looks like worn rather than as a flat mockup.
*
* The shape is intentionally simple: head + neck + shoulders + arms. The
* shirt SVG sits on top, hiding the torso area. The result reads as
* "a person wearing this shirt" without needing a full photographic model.
*
* viewBox matches the shirt SVG (480×540) so the two layers register cleanly
* when stacked in the same wrapper.
*/
export function ModelSilhouette() {
return (
<svg
className="model-silhouette"
viewBox="0 0 480 540"
role="img"
aria-label="Model preview"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="model-skin" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f5d4b8" />
<stop offset="100%" stopColor="#e8b894" />
</linearGradient>
<linearGradient id="model-hair" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3d2817" />
<stop offset="100%" stopColor="#2a1a0e" />
</linearGradient>
<radialGradient id="model-bg" cx="50%" cy="40%" r="60%">
<stop offset="0%" stopColor="rgba(253, 226, 234, 0.7)" />
<stop offset="100%" stopColor="rgba(253, 226, 234, 0.0)" />
</radialGradient>
</defs>
{/* Soft halo behind the model so it visually recedes from the canvas frame */}
<rect x="0" y="0" width="480" height="540" fill="url(#model-bg)" />
{/* Hair — covers the top/sides of the head, gives a silhouette feel */}
<path
d="
M 195 25
Q 175 35 168 65
Q 162 92 175 110
L 200 90
Q 240 70 280 90
L 305 110
Q 318 92 312 65
Q 305 35 285 25
Q 240 5 195 25
Z
"
fill="url(#model-hair)"
/>
{/* Head — slightly narrower than the hair so the hair frames it */}
<ellipse cx="240" cy="68" rx="44" ry="52" fill="url(#model-skin)" />
{/* Subtle shadow under the chin where the neck begins */}
<ellipse cx="240" cy="118" rx="22" ry="6" fill="rgba(31, 29, 35, 0.08)" />
{/* Neck — short, slightly tapered */}
<path
d="
M 220 110
Q 218 130 215 145
L 265 145
Q 262 130 260 110
Z
"
fill="url(#model-skin)"
/>
{/* Shoulders & upper arms — the shirt SVG paints on top so we only need
the parts that peek out. We extend the arms slightly past the shirt
sleeves on both sides. */}
<path
d="
M 60 145
Q 100 138 150 145
L 170 200
Q 130 220 95 220
Q 70 215 55 195
Z
"
fill="url(#model-skin)"
/>
<path
d="
M 420 145
Q 380 138 330 145
L 310 200
Q 350 220 385 220
Q 410 215 425 195
Z
"
fill="url(#model-skin)"
/>
{/* Forearms peeking out below the shirt sleeves */}
<path
d="
M 95 220
Q 75 280 70 360
Q 78 380 95 378
Q 110 360 115 295
Q 112 250 110 220
Z
"
fill="url(#model-skin)"
/>
<path
d="
M 385 220
Q 405 280 410 360
Q 402 380 385 378
Q 370 360 365 295
Q 368 250 370 220
Z
"
fill="url(#model-skin)"
/>
</svg>
);
}

View File

@@ -1,16 +1,86 @@
import { Group, Rect, Text, Line } from 'react-konva';
export function SlotPlaceholder({ slot, isEmpty = true }) {
/**
* Placeholder for an empty template slot.
*
* Rendered as a dashed sky-blue rectangle with corner brackets and a
* camera glyph + label. Visible only when the slot is unfilled
* (`isEmpty`); once an element is assigned to the slot, the layer above
* draws the element and the placeholder returns null.
*
* Interactivity
* ─────────────
* When an `onClick` handler is provided, the placeholder's background
* rect listens for clicks and the Group fires onClick/onTap. The
* decorative children (dashed-border rect, label text, corner brackets)
* stay `listening={false}` so the visible click target is just the
* background fill — no flicker between brackets and rect.
*
* Hovering over an interactive slot flips the stage cursor to `pointer`
* so the affordance reads as clickable. Konva doesn't have a built-in
* cursor prop for shapes; setting it via the stage container is the
* standard pattern.
*
* Layer ordering
* ──────────────
* The parent `DesignCanvas` renders this layer BEFORE the user-elements
* layer so that a user-placed element drawn over a slot visually
* obscures the dashed outline and intercepts clicks first. That matches
* user expectation: when you've put something into a slot region, you
* want to interact with what you put there, not the slot underneath.
*/
export function SlotPlaceholder({ slot, isEmpty = true, isLoading = false, onClick }) {
const { bounds, label } = slot;
const { x, y, width, height } = bounds;
if (!isEmpty) return null;
// While loading, the slot is non-interactive and shows a spinner-style
// overlay instead of the camera glyph. Suppressing onClick prevents
// the user from re-triggering the picker mid-upload, and the dimmed
// fill + "Loading…" label communicate that the click registered.
// (S25.)
const isInteractive = typeof onClick === 'function' && !isLoading;
const handleMouseEnter = (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'pointer';
};
const handleMouseLeave = (e) => {
const stage = e.target.getStage();
if (stage) stage.container().style.cursor = 'default';
};
return (
<Group name={`slot-placeholder-${slot.id}`}>
<Group
name={`slot-placeholder-${slot.id}`}
onClick={isInteractive ? onClick : undefined}
onTap={isInteractive ? onClick : undefined}
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
>
{/* Dashed border — decorative, doesn't intercept clicks. */}
<Rect x={x} y={y} width={width} height={height} stroke="#94a3b8" strokeWidth={2} dash={[8, 4]} cornerRadius={4} listening={false} />
<Rect x={x} y={y} width={width} height={height} fill="rgba(148, 163, 184, 0.1)" listening={false} />
<Text text="📷" x={x + width / 2} y={y + height / 2 - 20} fontSize={24} align="center" offsetX={12} listening={false} />
<Text text={label || 'Drop image here'} x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#64748b" align="center" offsetX={width / 2} listening={false} />
{/* Soft-fill background — the click target. Listens only when an
onClick handler is present, so non-interactive slots don't
shadow the Stage's deselect-on-empty-canvas behavior. The fill
deepens slightly while loading to communicate the "working…"
state visually. */}
<Rect
x={x} y={y} width={width} height={height}
fill={isLoading ? 'rgba(56, 189, 248, 0.18)' : 'rgba(148, 163, 184, 0.1)'}
listening={isInteractive}
/>
{isLoading ? (
<>
<Text text="…" x={x + width / 2} y={y + height / 2 - 20} fontSize={28} align="center" offsetX={10} fill="#0284c7" listening={false} />
<Text text="Loading…" x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#0369a1" align="center" offsetX={width / 2} listening={false} />
</>
) : (
<>
<Text text="📷" x={x + width / 2} y={y + height / 2 - 20} fontSize={24} align="center" offsetX={12} listening={false} />
<Text text={label || 'Drop image here'} x={x + width / 2} y={y + height / 2 + 10} fontSize={11} fontFamily="DM Sans" fill="#64748b" align="center" offsetX={width / 2} listening={false} />
</>
)}
<Line points={[x, y, x + 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x, y, x, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
<Line points={[x + width, y, x + width - 20, y]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />

View File

@@ -1,16 +1,149 @@
import '../../styles/TShirtSVG.css';
export function TShirtSVG({ size = 300 }) {
const padding = size * 0.1;
const innerSize = size - padding * 2;
/**
* Pure-SVG t-shirt mockup. The path is a stylized tee silhouette (collar +
* sleeves + body). Color is the user-selected garment color from the Shirt
* Options panel. The print-zone rectangle in the chest area indicates where
* the design overlay will land — it's visual-only (the actual design overlay
* sits in the Konva stage on top of this).
*
* Sized via `viewBox` so the SVG scales fluidly with its container.
*/
export function TShirtSVG({ size = 360, color = '#ffffff', showPrintZone = true }) {
// Compute a complementary stroke shade so the silhouette stays visible on
// light AND dark fills. We darken the fill ~12% in HSL space — close enough
// for our small palette without pulling in a color library.
const stroke = darken(color, 0.12);
const isDark = perceptualLuminance(color) < 0.45;
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}
className="tshirt-svg">
<path d={`M ${padding} ${padding + innerSize * 0.15} L ${padding + innerSize * 0.15} ${padding} L ${size - padding - innerSize * 0.15} ${padding} L ${size - padding} ${padding + innerSize * 0.15} L ${size - padding} ${size - padding} L ${padding} ${size - padding} Z`}
fill="none" stroke="var(--border)" strokeWidth="2" strokeDasharray="4,4" />
<rect x={size * 0.3} y={size * 0.25} width={size * 0.4} height={size * 0.35} fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.5" />
<text x={size / 2} y={size * 0.45} textAnchor="middle" fill="var(--text-muted)" fontSize="10" fontFamily="var(--font-mono)">Print Zone</text>
<svg
className="tshirt-svg"
viewBox="40 50 400 470"
role="img"
aria-label="T-shirt preview"
preserveAspectRatio="xMidYMid meet"
>
{/* Soft drop shadow under the shirt — kept subtle so the canvas remains the focus. */}
<defs>
<filter id="tshirt-shadow" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" />
<feOffset dx="0" dy="6" result="offset" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.18" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Shirt silhouette ---------------------------------------------------- */}
<g filter="url(#tshirt-shadow)">
<path
d="
M 110 90
L 170 60
Q 192 84 240 84
Q 288 84 310 60
L 370 90
L 430 130
L 400 200
L 360 180
L 360 480
Q 360 500 340 500
L 140 500
Q 120 500 120 480
L 120 180
L 80 200
L 50 130
Z
"
fill={color}
stroke={stroke}
strokeWidth="2"
strokeLinejoin="round"
/>
{/* Collar */}
<path
d="
M 200 78
Q 240 110 280 78
"
fill="none"
stroke={stroke}
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
{/* Print zone — corner brackets, not a closed rectangle, so the design
overlay isn't visually competing with a hard frame. */}
{showPrintZone && (
<g
className="tshirt-svg__print-zone"
stroke={isDark ? 'rgba(255,255,255,0.55)' : 'rgba(31, 29, 35, 0.18)'}
strokeWidth="1.5"
strokeLinecap="round"
fill="none"
>
{/* Corners of a 220×220 print zone centered in the chest */}
{(() => {
const x = 130;
const y = 180;
const w = 220;
const h = 220;
const c = 14;
return (
<>
<path d={`M ${x} ${y + c} L ${x} ${y} L ${x + c} ${y}`} />
<path d={`M ${x + w - c} ${y} L ${x + w} ${y} L ${x + w} ${y + c}`} />
<path d={`M ${x + w} ${y + h - c} L ${x + w} ${y + h} L ${x + w - c} ${y + h}`} />
<path d={`M ${x + c} ${y + h} L ${x} ${y + h} L ${x} ${y + h - c}`} />
</>
);
})()}
</g>
)}
</svg>
);
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Tiny color helpers — local to this file to avoid pulling in a dependency. */
/* ────────────────────────────────────────────────────────────────────────── */
function parseHex(hex) {
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex);
if (!m) return [255, 255, 255];
const v = m[1];
if (v.length === 3) {
return [
parseInt(v[0] + v[0], 16),
parseInt(v[1] + v[1], 16),
parseInt(v[2] + v[2], 16),
];
}
return [
parseInt(v.slice(0, 2), 16),
parseInt(v.slice(2, 4), 16),
parseInt(v.slice(4, 6), 16),
];
}
function toHex(r, g, b) {
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
return `#${[r, g, b].map((v) => clamp(v).toString(16).padStart(2, '0')).join('')}`;
}
function darken(hex, amount = 0.1) {
const [r, g, b] = parseHex(hex);
return toHex(r * (1 - amount), g * (1 - amount), b * (1 - amount));
}
function perceptualLuminance(hex) {
const [r, g, b] = parseHex(hex);
// Rec. 601 luma — perceptually weighted, fast.
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}

View File

@@ -0,0 +1,286 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import Konva from 'konva';
import { getNodePageRect } from '../../utils/konvaCoords';
/**
* Pencil overlay for editing text — MOBILE ONLY in the current flow.
*
* The desktop editing model has been reverted: selecting a text
* element on the canvas auto-switches the right rail to the Text
* tab, with edits flowing live to the selected element. No pencil
* affordance is rendered on desktop.
*
* On mobile, the right-rail-equivalent (the bottom sheet) is hidden
* by default — auto-opening it on every text selection would be
* disruptive (the sheet covers most of the canvas). So mobile keeps
* an explicit gesture: tap the on-canvas pencil next to a selected
* text element to open the sheet on the Text tab in edit mode. This
* is the same component that used to be desktop-only; the gate in
* App.jsx flipped from `!isMobile` to `isMobile`.
*
* The pencil renders a 40×40 button positioned just outside the
* top-right corner of the selected text node's bounding box. 40px
* is comfortably above the WCAG / iOS minimum touch target (44pt /
* ~44px logical px) for the small canvas sizes typical on mobile;
* users with thumb-sized text targets can still hit the pencil
* without colliding with the text itself.
*
* Coordinate plumbing: stage's outer CSS scale is applied to the
* node's internal getClientRect to get a page-pixel rect. We listen
* for window resize and scroll so the button stays glued when the
* user pans the page or resizes.
*
* Tap handler fires `onStartEdit({ id, rect })` — App.jsx's
* handleStartTextEdit — which sets editingTextElementId, flips the
* sidebar's active tab to Text, and opens the bottom sheet.
*/
export function TextEditAffordance({
element,
stageContainerRef,
onStartEdit,
}) {
// Position state. null until we've measured the node at least once;
// hidden until then so the button doesn't flash at (0, 0) on first
// render before the layout effect runs. We also reset to null when
// the element id changes so the pencil doesn't briefly linger at the
// PREVIOUS element's position while waiting for the next measure to
// resolve — see the elementRef sync below for why that mattered.
const [pos, setPos] = useState(null);
// Ref to the latest element so the rAF-throttled measure (called from
// window resize/scroll listeners) can read it without re-binding on
// every render — same pattern as DesignCanvas's snapEnabledRef.
//
// IMPORTANT — sync in render body, not in a useEffect.
// ─────────────────────────────────────────────────────────────────────────────
// Previously this was `useEffect(() => { elementRef.current = element; }, [element])`,
// which runs AFTER the layout effect below. So on an element-prop
// change (user clicks a different text element), the sequence was:
//
// 1. Render with new `element` prop.
// 2. useLayoutEffect fires → calls measure().
// 3. measure() reads elementRef.current → STILL the OLD element.
// 4. measure() looks up the OLD node, sets `pos` to its rect.
// 5. useEffect fires → finally updates elementRef.current to new
// element. But measure() has already run with stale data.
//
// Result: the pencil stayed glued to the previously-selected text
// until something else (resize, scroll, prop unrelated to selection)
// forced a re-measure.
//
// Fix: assign during render. By the time useLayoutEffect runs,
// elementRef.current already points at the new element. Assignment
// to a ref's `.current` during render is safe — it doesn't trigger
// re-renders, and React's StrictMode double-invocation is also
// tolerant (the assignment is idempotent).
const elementRef = useRef(element);
elementRef.current = element;
// Track the previous element id so we can clear the visible position
// when the selection changes. Without this, the old pos lingered
// visibly for one paint while the layout effect's measure() ran —
// measure() does its work synchronously, but if the new node hasn't
// mounted yet (rare but possible during a tab switch + selection
// change in the same batch), the bail-out path leaves pos at its
// last value. Forcing null on id change makes the pencil disappear
// until the new position is known, which is less visually startling
// than seeing it pop from one element to another.
const prevIdRef = useRef(element?.id);
if (prevIdRef.current !== element?.id) {
prevIdRef.current = element?.id;
if (pos !== null) {
// Schedule a clear; using setPos inside render is illegal, so
// gate via a microtask. The layout effect below will re-measure
// and set the new position on the same render tick, so this
// clear is only visible if measure() fails to resolve a node.
// Using queueMicrotask rather than setPos directly avoids the
// "Cannot update during render" warning while keeping the
// clear in the same event loop tick.
queueMicrotask(() => {
// Re-check: another render may have already populated pos
// for the new element before this microtask runs.
if (prevIdRef.current === element?.id) {
setPos((curr) => (curr && curr._forId !== element?.id ? null : curr));
}
});
}
}
// Measure: find the Konva node by `name` attr (= element id), compute
// its page-pixel rect, and update `pos`. Bails to null when the
// element is gone or the stage isn't ready.
const measure = () => {
const el = elementRef.current;
// Read the container fresh each measure. The ref is set by
// DesignCanvas's Stage ref callback on mount, which fires AFTER
// App's first render — so on the very first paint stageContainerRef
// may still be null. The window-level event listeners + the
// element-prop-driven effect both eventually re-call measure once
// it's populated, so we just bail until then.
const container = stageContainerRef?.current ?? null;
if (!el || !container) {
setPos((prev) => (prev === null ? prev : null));
return;
}
// `container` is the Konva Stage's outer DOM element. Konva attaches
// the underlying Stage instance via the `_konva` field on the
// container's children — but the safer cross-version path is the
// public `Konva.Stage.container()` reverse: we walk down to find a
// canvas, then read its parent's `__konvaNode` (set on creation).
// Instead of relying on private fields, we look up the Stage via
// the `Konva.stages` registry: it's documented and stable.
let stage = null;
// Konva exposes a static `Konva.stages` array of every Stage
// currently mounted on the page. Walking it is O(stages) — in a
// single-canvas app there's exactly one Stage, so this is
// O(1) in practice. The fallback `_konvaNode` backref on the
// canvas DOM element handles versions where the stages array
// shape changes.
if (Konva && Array.isArray(Konva.stages)) {
for (const s of Konva.stages) {
try { if (s.container() === container) { stage = s; break; } } catch { /* ignore */ }
}
}
if (!stage) {
// Fallback: the parent's first canvas child has a `_konvaNode`
// backref on most versions. Try that before giving up.
const canvas = container.querySelector('canvas');
stage = canvas?._konvaNode?.getStage?.() ?? null;
}
if (!stage) {
setPos((prev) => (prev === null ? prev : null));
return;
}
const node = stage.findOne('.' + el.id);
if (!node) {
setPos((prev) => (prev === null ? prev : null));
return;
}
const rect = getNodePageRect(node);
if (!rect) {
setPos((prev) => (prev === null ? prev : null));
return;
}
// Tag the rect with the element id it was measured for. The
// render-body element-id-change guard above uses this so a stale
// microtask-scheduled clear can't wipe a position that's already
// been recomputed for the new element.
rect._forId = el.id;
setPos(rect);
};
// Re-measure whenever the selected element's identity OR any prop
// that affects its rendered bbox changes — fontSize, text, fontFamily,
// x, y, rotation, arc. The dep list intentionally lists each one
// rather than the whole element object because Konva nodes mutate
// their attrs in place during transforms; the wrapping element prop
// is recreated by useDesignEditor on every update, so depending on
// it directly would also work, but enumerating gives a clearer
// signal of what actually drives the re-measure.
useLayoutEffect(() => {
measure();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
element?.id,
element?.x, element?.y,
element?.text, element?.fontFamily, element?.fontSize,
element?.rotation, element?.arc,
element?.flipX, element?.flipY,
stageContainerRef,
]);
// Window-level events that can move the stage on screen without
// changing element props. Re-measure rAF-throttled so a fast scroll
// or resize doesn't drop hundreds of setState calls into React.
useEffect(() => {
let raf = null;
const onUpdate = () => {
if (raf !== null) return;
raf = requestAnimationFrame(() => {
raf = null;
measure();
});
};
window.addEventListener('resize', onUpdate);
window.addEventListener('scroll', onUpdate, true); // capture: catch nested scroll containers too
return () => {
if (raf !== null) cancelAnimationFrame(raf);
window.removeEventListener('resize', onUpdate);
window.removeEventListener('scroll', onUpdate, true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!element || element.type !== 'text' || !pos) return null;
// Anchor: just outside the top-right corner of the bbox, offset
// slightly into the empty space so the button doesn't overlap the
// selection border. 40px button sized for comfortable mobile
// tapping (above the ~44pt iOS touch-target minimum once CSS
// pixel-density scaling is considered), 6px gap from the right
// edge so the button doesn't visually crowd the bbox border.
const BTN = 40;
const GAP = 6;
const left = pos.left + pos.width + GAP;
const top = pos.top - GAP;
const handleClick = (e) => {
e.stopPropagation();
onStartEdit?.({ id: element.id, rect: pos });
};
// The button uses position: fixed so it tracks the viewport directly;
// any ancestor's transform or overflow won't affect placement.
const style = {
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${BTN}px`,
height: `${BTN}px`,
border: '1.5px solid #ec4899',
background: '#fff',
color: '#ec4899',
borderRadius: '50%',
boxShadow: '0 2px 8px rgba(236, 72, 153, 0.32)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
padding: 0,
// z-index sits between the FAB (700) and the mobile bottom
// sheet (800) so the pencil floats above the canvas chrome but
// is OCCLUDED by the bottom sheet when it opens. Previously 999
// — high enough to draw over the sheet, which meant when the
// user opened the "add item" sheet with a text selected, the
// pencil bled through the sheet's content area and looked like
// it belonged to whatever element it happened to overlap. The
// canvas itself has no z-index of its own (regular flow), so
// a value of 750 stays above all canvas-area chrome (zoom
// controls at z-index 10, mobile toolbar at 600, FAB at 700)
// while letting the sheet (800) cover it.
zIndex: 750,
};
return createPortal(
<button
type="button"
style={style}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleClick}
aria-label="Edit text"
title="Edit text"
>
{/* Pencil glyph — same path as the mobile-toolbar Edit text
button so the two surfaces agree visually. Sized to fill
~half the button diameter (20px in a 40px button) so the
icon reads clearly at touch-target distances. */}
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>,
document.body,
);
}

View File

@@ -1,99 +1,557 @@
import { useEffect, useRef, memo, useCallback } from 'react';
import { Text, Transformer } from 'react-konva';
import { useEffect, useRef, memo } from 'react';
import { Group, Text, TextPath, Rect } from 'react-konva';
import { measureTextMetrics } from '../../utils/textMetrics';
import { computeTextVisibleBbox } from '../../utils/textGeometry';
import { useFontsReady } from '../../hooks/useFontsReady';
import { LINE_HEIGHT_RATIO, MIN_FONT_SIZE, DEFAULT_BODY_FONT } from '../../constants/textMetrics';
import { unflipReportedXY } from '../../utils/flipOffset';
// Per-element Transformer rendering was moved to DesignCanvas's dedicated
// transformer layer (see DesignCanvas.jsx). This component now renders
// ONLY the text node + its out-of-bounds warning; selection chrome lives
// one layer above. Imports for Transformer / rotation snap / corner
// anchors / transformer visual style are dropped along with the JSX
// that consumed them.
//
// Outline-on-silhouette rendering
// ───────────────────────────────
// Konva's default render order for Text / TextPath is fill-then-stroke,
// which paints each glyph's stroke ON TOP of the fill. When characters
// overlap (script faces with tight kerning — Pacifico is the obvious
// example in our palette, but Caveat does it too), each character's
// stroke draws across the previous character's fill, leaving visible
// outline lines slicing through the interior of the word.
//
// Fix is render-order based: stroke first at 2× the user's configured
// width, fill on top. The fill then covers (a) the inner half of the
// stroke and (b) any portion of any glyph's stroke that's inside the
// union of glyph fills. Net visible outline = the outer silhouette of
// the whole word.
//
// Two routes implement this depending on whether the text is arc'd:
//
// • Flat (arc = 0): one Konva.Text node with `fillAfterStrokeEnabled`.
// Konva.Text strokes/fills a whole line in ONE strokeText/fillText
// call, so the trick lands natively on a single node.
//
// • Arc'd (arc ≠ 0): two TextPath nodes, stroke-only below + fill-only
// above. Konva.TextPath draws glyphs ONE AT A TIME with per-glyph
// transforms along the path — the trick can't span across glyphs
// in one node, so we split the work into two nodes. The fill node's
// fill operations cover (a) and (b) above for every glyph.
//
// Either way, the user's configured `strokeWidth` becomes the visible
// outer half of the stroke; the actual stroke passed to Konva is 2×
// that value (the inner half gets eaten by the fill).
//
// Live transforms must keep stroke + fill in lockstep, so both children
// live inside a Konva.Group that owns the element's transform attrs.
// The Transformer in DesignCanvas attaches to the Group via
// `findOne('.' + id)`; Group-level scale/rotation propagates to all
// children, so the stroke and fill stay aligned every frame.
/**
* Konva Text wrapper.
*
* Mirrors ImageElement's flip / opacity handling. See ImageElement.jsx for
* the rationale on offset-then-scale to keep the flip in-place. Text-resize
* scales the font-size rather than width/height (text doesn't have an
* intrinsic box; Konva computes one from the rendered glyphs).
*
* ── Arc ──────────────────────────────────────────────────────────────────
* Text can be curved along an arc using Konva's `TextPath`. The `arc` prop
* is a number in [-100, 100]:
*
* arc = 0 → flat text (renders as <Text>)
* arc > 0 → upward arc (text reads on the top of the curve, rainbow-style)
* arc < 0 → downward arc (text reads on the bottom, smile-shape)
* |arc| = 100 → tightly curved (semicircle-like)
*
* Path geometry
* ─────────────
* The path is a quadratic Bézier (cheap, visually indistinguishable from a
* true circular arc at typical curvature values). It starts at (0, baseline)
* and ends at (W, baseline) with a control point at (W/2, controlY) pulling
* the curve up or down.
*
* W is the *actual* rendered text width (via canvas `measureText`), not a
* geometric estimate. This matters for two reasons:
*
* 1. If the path is shorter than the text, Konva silently clips trailing
* characters. A wide-glyph font like a serif overflows the
* `fontSize × length × 0.55` estimate that previous versions used.
*
* 2. If the path is longer than the text, the bulge peak (at W/2) sits
* past the visual midpoint of the text. A condensed face like
* Bebas Neue under-flows the geometric estimate, so the arc looked
* lopsided.
*
* We also pass `align="center"` to the TextPath so any small measurement
* residual is absorbed evenly at both ends rather than only the right.
*/
export const TextElement = memo(function TextElement({
id: _id,
x = 0,
y = 0,
text = '',
fontSize = 24,
fontFamily = 'DM Sans',
fontFamily = DEFAULT_BODY_FONT,
fill = '#0f172a',
stroke = null,
strokeWidth = 0,
rotation = 0,
isSelected,
opacity = 1,
flipX = false,
flipY = false,
arc = 0,
locked = false,
isSelected: _isSelected,
// Change 3 — see ImageElement.jsx for the rationale. For text we
// use the visible-ink bbox computed below (the same one fed to
// canvasDragBound for snap centering) rather than Konva.Text's
// line-height bbox so the warning rect hugs the actual glyphs.
isOutOfBounds = false,
shiftHeld: _shiftHeld = false,
onSelect,
onUpdate,
onCommit,
// Change 7 — the on-canvas double-click handler that used to fire
// this is gone. Text editing is now triggered exclusively via the
// pencil affordance (desktop, rendered by TextEditAffordance) or the
// mobile Edit-text toolbar button. We keep the prop here for the
// affordance components to call through, but TextElement itself
// doesn't fire it on double-click anymore.
onStartEdit,
dragBoundFunc,
transformBoundFunc,
transformBoundFunc: _transformBoundFunc,
}) {
const textRef = useRef(null);
const trRef = useRef(null);
// groupRef — the Konva.Group that wraps the text nodes is now the
// "element" node from the rest of the system's perspective:
// • carries the element's `name` (= id) for findOne lookups,
// • carries the draggable + dragBoundFunc + event handlers,
// • carries the _pawSnapBbox* custom attrs the snap math reads,
// • is what the DesignCanvas Transformer attaches to.
//
// The Text / TextPath children inside it are positioned in Group-
// local coordinates (no x/y/offset/scale/rotation of their own),
// so Group-level transforms propagate uniformly. There's no longer
// a separate `textRef` — nothing outside this component needs to
// address the inner Text node specifically.
const groupRef = useRef(null);
const attachTransformer = useCallback(() => {
if (!isSelected) return;
const transformer = trRef.current;
const node = textRef.current;
if (!transformer || !node) return;
// Subscribe to font-loading state. Calling the hook in the render body
// means this component re-renders once `document.fonts.ready` resolves,
// which causes `measureTextWidth` below to retry with the now-loaded
// font and produce an accurate width. Without this, the first render
// can use fallback-font metrics and the arc path / centering math is
// off until the next unrelated update happens to come along.
useFontsReady();
transformer.nodes([node]);
transformer.getLayer()?.batchDraw();
}, [isSelected]);
// Real rendered text width plus glyph ascent/descent. Pixel-perfect for
// the current font; falls back to coarse geometric estimates only when
// DOM is unavailable. We still call measureTextMetrics here for the
// whole-string width (used to size the path and Konva.Text bbox), but
// the visible-ink envelope below comes from `computeTextVisibleBbox`
// which does per-character measurement for arc'd text — see
// textGeometry.js docblock for the rationale.
const metrics = measureTextMetrics(text, fontSize, fontFamily);
const measuredW = metrics.width;
// Cap measured width at canvas size to keep approximate bounding box
// sensible even with very long strings.
const approxW = Math.max(fontSize, measuredW);
const approxH = fontSize * LINE_HEIGHT_RATIO;
const setTransformerRef = useCallback(
(node) => {
trRef.current = node;
attachTransformer();
},
[attachTransformer]
);
// Always-center offset for rotation + flip-in-place. See the
// matching docblock in ImageElement.jsx for the full rationale; the
// short version: rotating around the local origin (= offset point)
// gives center-pivot rotation when offset is at the bbox center,
// which is what users expect from a rotate handle and avoids the
// "swing on first touch" bug that the old top-left-pivot caused on
// mobile. Flip-in-place falls out of the same center-aligned offset
// for free.
//
// For text the "bbox" is the line-height envelope (approxW, approxH)
// rather than the tighter visible-ink envelope — we want rotation to
// pivot around the GEOMETRIC center of the text node, not the
// visible-ink center, because the geometric center is what the user
// sees the rotate handle attach to via the Transformer (which reads
// getSelfRect, overridden below to the visible-ink bbox — close to
// but not exactly identical to the line-height envelope; the small
// difference is unnoticeable for rotation feel).
const offsetX = approxW / 2;
const offsetY = approxH / 2;
const renderX = x + offsetX;
const renderY = y + offsetY;
// Arc path — only computed when a non-zero arc is requested. The path is
// expressed in the text node's local coordinate system: it starts at
// (0, baselineY) and ends at (approxW, baselineY), with a quadratic
// control point pulling the curve up (arc > 0) or down (arc < 0).
const arcAbs = Math.min(100, Math.abs(arc));
const isArced = arcAbs > 0.5;
const baselineY = approxH * 0.85;
// bulgeMag scales with font size so larger text gets proportionally bigger
// curvature.
const bulgeMag = (arcAbs / 100) * approxH * 1.4;
const controlY = arc > 0 ? baselineY - bulgeMag : baselineY + bulgeMag;
// Tiny overshoot on the path (2%) plus center alignment so a couple of
// pixels of measurement residual don't clip the last glyph.
const pathW = approxW * 1.02;
const arcPath = isArced
? `M 0 ${baselineY} Q ${pathW / 2} ${controlY} ${pathW} ${baselineY}`
: null;
// Visible-ink bbox in node-local coords (Change 23). Computed by
// `computeTextVisibleBbox` in textGeometry.js — see that function's
// docblock for the full geometry derivation. Critically, for arc'd
// text it does PER-CHARACTER measurement along the curve: each
// character's actual ascent/descent is applied at that character's
// baseline-along-the-path, so a string like "Queen" (cap Q at the
// arc endpoint, lowercase 'ee' at the peak) gets a tight bbox at
// the peak rather than the cap-height envelope we used before.
//
// The same function feeds elementBounds.js's OOB check, so the
// canvas's yellow OOB rect (rendered below), the Transformer rect,
// the snap-bbox custom attrs, and the App-level out-of-bounds
// warning all share one calculation. No drift between visual and
// logical bounds.
//
// These values are in the text node's UN-OFFSET local coords —
// origin at the top-left of the line-height envelope, so a typical
// capital letter sits at roughly (0, ascent..approxH-descent). The
// OOB Rect rendered below consumes them directly because the Rect
// is positioned inside the Group at Group-local (0, 0) and
// therefore shares the un-offset frame.
//
// For the dragBoundFunc on the Group, we need PIVOT-RELATIVE coords
// (relative to the offset point, which IS the rotation pivot — see
// the offsetX/Y docblock above). The conversion is just
// `visibleBbox± - offset`, performed below where the snap attrs
// are set on the Group.
//
// Fallback to the old line-height envelope only when the helper
// returns null (empty text). Keeps existing positioning math stable
// before any text has been typed.
const _bbox = computeTextVisibleBbox({ text, fontSize, fontFamily, arc });
const visibleBboxMinX = _bbox ? _bbox.minX : 0;
const visibleBboxMaxX = _bbox ? _bbox.maxX : approxW;
const visibleBboxMinY = _bbox ? _bbox.minY : 0;
const visibleBboxMaxY = _bbox ? _bbox.maxY : approxH;
// Pivot-relative versions of the visible bbox, for the dragBoundFunc
// on the Group. canvasDragBound rotates corners around the local
// origin (= offset point = bbox center). The visible-ink bbox above
// is in un-offset coords; subtracting (offsetX, offsetY) translates
// into the post-offset frame the rotation math expects.
//
// Without this translation, the drag clamp would treat the un-offset
// top-left as the pivot — the same bug that ImageElement had before
// its offset became unconditional. The OOB Rect below still consumes
// the un-offset visibleBbox values because Rect inherits Group's
// offset transform; the snap attrs are the only consumer that needs
// pivot-relative coords.
const snapBboxMinX = visibleBboxMinX - offsetX;
const snapBboxMinY = visibleBboxMinY - offsetY;
const snapBboxMaxX = visibleBboxMaxX - offsetX;
const snapBboxMaxY = visibleBboxMaxY - offsetY;
// Override the Konva node's getSelfRect with our visible-ink bbox.
// Re-applied whenever the bbox-affecting attrs change so the
// DesignCanvas-level Transformer (which lives in a separate layer and
// attaches via `findOne('.' + id)`) sees the right rect when it calls
// `tr.forceUpdate()` on element changes.
//
// The override now lives on the GROUP, not the inner text node.
// Konva.Group.getSelfRect by default returns the union of its
// children's getSelfRects — for us that would be either Konva.Text's
// line-height box (loose at the top) or TextPath's baseline-padded
// approximation. Overriding directly gives the Transformer (which
// reads getSelfRect from the attached node — the Group) the
// pixel-accurate visible-ink bbox that the rest of the system
// (snap math, OOB check, App-level bounds warning) also uses.
//
// Three concerns the override addresses:
//
// 1. arc=0 ↔ arc≠0 toggles swap the children between <Text> and
// <TextPath>. The Group instance is unchanged — the override
// stays attached — but the children's bboxes change shape, so
// the override needs to reflect the new visibleBbox values.
// The deps array catches this.
//
// 2. Stroke vs no-stroke toggles change the Group's child set
// (one node vs two for arc'd text). Same Group instance, but
// its native getSelfRect would change — override still wins.
//
// 3. Konva's defaults for Text / TextPath bboxes are loose
// approximations of actual rendered glyph extent. Our visible-
// ink bbox (from computeTextVisibleBbox) is what the canvas
// drag-snap math centers against (via the _pawSnapBbox*
// custom attrs), so overriding here keeps the Transformer
// rectangle aligned with the snap target.
useEffect(() => {
attachTransformer();
}, [attachTransformer]);
const node = groupRef.current;
if (!node) return;
node.getSelfRect = function () {
return {
x: visibleBboxMinX,
y: visibleBboxMinY,
width: visibleBboxMaxX - visibleBboxMinX,
height: visibleBboxMaxY - visibleBboxMinY,
};
};
}, [
arc,
visibleBboxMinX,
visibleBboxMinY,
visibleBboxMaxX,
visibleBboxMaxY,
]);
// Outline state. The Konva strokeWidth we actually pass to the glyph
// node(s) is DOUBLED because fillAfterStrokeEnabled (and the parallel
// dual-TextPath path for arc'd text) makes only the OUTER half of
// the stroke visible — the inner half gets covered by the fill. So
// the user's strokeWidth setting (which they perceive as the visible
// outline thickness) maps to 2× the Konva strokeWidth.
const hasOutline = Boolean(stroke) && strokeWidth > 0;
const konvaStrokeWidth = hasOutline ? strokeWidth * 2 : 0;
// Group-level transform + interaction attrs. Applied to the
// Konva.Group that wraps the text nodes; children render in Group-
// local coords so Group-level scale/rotation/flip propagates uniformly
// (this is what keeps the stroke and fill TextPath nodes locked
// together during live transforms in the arc'd outlined case).
const groupProps = {
ref: groupRef,
/* Konva `name` attr = element id. Used by DesignCanvas's findOne
* lookups for marquee, single-Transformer, and multi-Transformer
* attachment. See ImageElement.jsx for full rationale. */
name: _id,
x: renderX,
y: renderY,
offsetX,
offsetY,
scaleX: flipX ? -1 : 1,
scaleY: flipY ? -1 : 1,
rotation,
opacity,
draggable: !locked,
dragBoundFunc,
// Custom Konva attrs that DesignCanvas's canvasDragBound reads
// directly via this.attrs.*, encoding the *visible-ink* bbox of
// the rendered text in PIVOT-RELATIVE node-local coords (i.e.
// relative to the offset point, which is the rotation pivot).
// See the docblock in canvasDragBound for the corner-math
// rationale, and the snapBbox* declarations above for why the
// values are pre-shifted by (-offsetX, -offsetY) here. These
// live on the Group (the draggable node) rather than the inner
// text node because Group is what Konva calls `this` in
// dragBoundFunc.
_pawSnapBboxMinX: snapBboxMinX,
_pawSnapBboxMinY: snapBboxMinY,
_pawSnapBboxMaxX: snapBboxMaxX,
_pawSnapBboxMaxY: snapBboxMaxY,
onClick: (e) => { e.cancelBubble = true; onSelect?.(); },
onTap: (e) => { e.cancelBubble = true; onSelect?.(); },
// Bug 6 fix — select on press, not just on click-up. Konva fires
// `click` on the mouseup completing a press-release pair WITHOUT
// intervening drag motion; a press that becomes a drag (the user
// grabs an element and immediately starts moving) never produces
// a click event, so the element wasn't getting selected and the
// DesignCanvas-level Transformer (which attaches to the selected
// node) never mounted handles around it. Selecting on mousedown
// / touchstart ensures the selection state is updated BEFORE
// Konva's drag machinery starts, so the Transformer is attached
// by the next render and its handles appear immediately around
// the dragging element.
//
// Selecting an already-selected element is a no-op at the
// useDesignEditor level, so the click + mousedown pair on a
// simple click doesn't double-commit anything.
onMouseDown: (e) => { e.cancelBubble = true; onSelect?.(); },
onTouchStart: (e) => { e.cancelBubble = true; onSelect?.(); },
onDragEnd: (e) => {
// e.target is the Group (the draggable node). Same shape as
// before — unflipReportedXY just needs an object with x()/y()
// accessors, which Group has.
const { x: reportedX, y: reportedY } = unflipReportedXY(e.target, approxW, approxH, flipX, flipY);
onUpdate({ x: reportedX, y: reportedY });
onCommit?.();
},
onTransformEnd: () => {
// The Transformer attaches to the Group, so transform fires on
// the Group. We bake the live scale into a new fontSize and
// reset the Group's scale to ±1 (preserving flip state); the
// children inherit the reset on re-render.
const node = groupRef.current;
if (!node) return;
const sx = node.scaleX();
// Read fontSize from the prop (not from the node) — Group has
// no `fontSize` attr; that lives on the inner Text/TextPath.
// The prop is the same value react-konva pushed down to the
// child, so this is equivalent to the old node.fontSize() read.
const newFontSize = Math.max(MIN_FONT_SIZE, Math.round(fontSize * Math.abs(sx)));
node.scaleX(flipX ? -1 : 1);
node.scaleY(flipY ? -1 : 1);
// Anchor-preserving commit math.
//
// During the transform Konva scales the Group via scaleX/Y while
// keeping the un-grabbed corner of the Transformer rect anchored
// in stage coords. With center offset (offsetX = approxW/2,
// offsetY = approxH/2), the rendered line-height envelope
// top-left lands at:
//
// node.x() - offsetX * scaleX, node.y() - offsetY * scaleY
//
// That's the position we want element.x/y to reproduce on the
// very next render. Since we're about to commit a new fontSize
// (which will produce new approxW/H of approximately
// approxW*sx / approxH*sx on re-render under linear-in-fontSize
// scaling), we need element.x/y to equal that scaled-offset
// top-left. The naive `unflipReportedXY(node, approxW, approxH,
// ...)` call used the OLD (unscaled) offsets and produced an
// element.x/y that didn't match the visual position the user
// saw at release — the line-height envelope would visibly jump
// by approxW*(sx-1)/2 toward the bottom-right after the user
// lets go. Multiplying by Math.abs(sx) here gives the
// scaled-offset top-left, which the next render reconstructs
// exactly (re-renders the node at that line-height envelope
// origin with the new larger fontSize and matching new offset).
//
// Image elements get this right naturally because their
// onTransformEnd computes `newWidth = node.width() *
// Math.abs(sx)` first and feeds THAT into unflipReportedXY —
// so the helper's W/2 subtraction is already the scaled half-
// width. Text doesn't have a node.width() it controls (Konva
// measures the glyphs internally and approxW is the React-
// render-time measurement), so the equivalent here is to scale
// approxW/H by sx at the call site.
//
// Using sx for both axes — the Transformer has keepRatio set
// in DesignCanvas, so sy ≈ sx every frame, and the post-resize
// fontSize is single-axis (a single scalar that drives both
// measured width AND line-height-derived height). Decoupling
// here would just introduce sub-pixel disagreement between
// axes for no benefit.
const scaledApproxW = approxW * Math.abs(sx);
const scaledApproxH = approxH * Math.abs(sx);
const { x: reportedX, y: reportedY } = unflipReportedXY(node, scaledApproxW, scaledApproxH, flipX, flipY);
onUpdate({
x: reportedX,
y: reportedY,
fontSize: newFontSize,
rotation: node.rotation(),
});
onCommit?.();
},
};
// Glyph-render attrs that go on the inner Text / TextPath children.
// Position attrs are NOT here — those live on the Group above so its
// transform applies uniformly. Children render at Group-local (0, 0).
const sharedGlyphProps = {
fontSize,
fontFamily,
};
return (
<>
<Text
ref={textRef}
x={x}
y={y}
text={text}
fontSize={fontSize}
fontFamily={fontFamily}
fill={fill}
rotation={rotation}
draggable
dragBoundFunc={dragBoundFunc}
onClick={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onTap={(e) => {
e.cancelBubble = true;
onSelect?.();
}}
onDragEnd={(e) => {
onUpdate({ x: e.target.x(), y: e.target.y() });
onCommit?.();
}}
onTransformEnd={() => {
const node = textRef.current;
if (!node) return;
const scaleX = node.scaleX();
node.scaleX(1);
node.scaleY(1);
onUpdate({
x: node.x(),
y: node.y(),
fontSize: Math.max(12, node.fontSize() * scaleX),
rotation: node.rotation(),
});
onCommit?.();
}}
/>
{isSelected && (
<Transformer
ref={setTransformerRef}
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
boundBoxFunc={transformBoundFunc}
anchorSize={8}
anchorCornerRadius={4}
borderStroke="#38bdf8"
anchorStroke="#38bdf8"
anchorFill="#ffffff"
<Group {...groupProps}>
{/* Out-of-bounds warning background (Change 3). Sits BEHIND the
text within the Group, in Group-local coords — the Group's
own transform applies so the highlight tracks the text
through every transform. Position is the visible-ink bbox
origin (which may be non-zero for text with baseline offset
or arc bulge extending above y=0). */}
{isOutOfBounds && (
<Rect
x={visibleBboxMinX}
y={visibleBboxMinY}
width={visibleBboxMaxX - visibleBboxMinX}
height={visibleBboxMaxY - visibleBboxMinY}
fill="#fde68a"
stroke="#f59e0b"
strokeWidth={1.5}
opacity={0.65}
listening={false}
perfectDrawEnabled={false}
/>
)}
</>
{/* Stroke-only layer for ARC'D outlined text. Renders first so
it's visually behind the fill layer. listening=false so the
inflated stroke hit area doesn't shadow the fill node's
(rectangular bbox) hit area — clicks still register on the
fill text and bubble up to the Group.
Flat outlined text uses fillAfterStrokeEnabled on a single
Text node instead (see below), since Konva.Text strokes/fills
a whole line in one shot — no second node needed. */}
{isArced && hasOutline && (
<TextPath
{...sharedGlyphProps}
data={arcPath}
text={text}
align="center"
fill={undefined}
stroke={stroke}
strokeWidth={konvaStrokeWidth}
listening={false}
/>
)}
{/* Fill layer (always rendered). For flat text this is the only
glyph node; outline (when present) rides along on this same
node via fillAfterStrokeEnabled. For arc'd text this is the
top layer; the stroke-only TextPath above provides the
outline silhouette. */}
{isArced ? (
<TextPath
{...sharedGlyphProps}
data={arcPath}
text={text}
align="center"
fill={fill}
// No stroke on the fill layer the outline (when present)
// is drawn by the separate stroke-only TextPath above this
// one, and this layer's job is to cover the inner half of
// that stroke + any of it that's inside the union of glyph
// fills. Default stroke = undefined; strokeWidth omitted.
/>
) : (
<Text
{...sharedGlyphProps}
text={text}
fill={fill}
stroke={hasOutline ? stroke : undefined}
strokeWidth={konvaStrokeWidth}
// fillAfterStrokeEnabled reverses Konva.Text's default fill-
// then-stroke render order. Combined with a 2× strokeWidth
// (so the visible OUTER half matches the user's configured
// strokeWidth), the fill covers (a) the inner half of the
// stroke and (b) any portion of the stroke that's inside
// the union of all glyph fills in this line. Net visible
// outline = the outer silhouette of the whole word, not
// individual glyphs. Solves the overlap-outlines-cutting-
// through-glyphs problem for script fonts like Pacifico and
// Caveat. No-op when fill or stroke is absent.
fillAfterStrokeEnabled
// Explicit width + height + wrap="none". See the long
// docblock at the top of the file for why these three props
// are interdependent in summary, width+height fix the
// snap-to-center bbox getter (which otherwise reads ~0
// before textArr populates), and wrap="none" prevents
// sub-pixel measurement disagreements between us and Konva
// from triggering a word-wrap after a resize.
width={approxW}
height={approxH}
wrap="none"
/>
)}
{/* Per-element Transformer removed (bug 8 fix). Selection chrome
now lives in DesignCanvas's dedicated transformer layer,
which renders ABOVE the elements layer so resize / rotate
handles always draw on top of every element regardless of
z-order. */}
</Group>
);
});

View File

@@ -0,0 +1,100 @@
import '../../styles/ZoomControls.css';
/**
* Inert zoom-percentage display + ± buttons plus the global snap-to-center
* toggle. The Konva canvas is rendered at a fixed 300×300 design-pixel
* resolution; "zoom" here scales the wrapper via CSS transform so the user
* can get a closer or farther view without changing the underlying
* coordinate system. That keeps the export math identical and keeps
* drag/rotate hot-paths untouched.
*
* The snap toggle lives here (Change 5 — see [[Refinements_2026-05-20_Part2]])
* because snap-to-center is a GLOBAL editor preference — enabling /
* disabling it from a per-element panel sent the wrong signal that it
* was a per-element setting. The zoom-controls toolbar is the natural
* home for "editor view" controls that apply to the whole canvas
* regardless of what's selected.
*/
export function ZoomControls({
zoom,
onZoomIn,
onZoomOut,
min = 0.5,
max = 2,
// Snap-to-center toggle. Optional; when omitted the toggle button
// just doesn't render. snapEnabled defaults to true so we match the
// app-level default (snap on) when callers haven't wired it up yet.
snapEnabled = true,
onToggleSnap,
}) {
return (
<div className="zoom-controls" role="toolbar" aria-label="Canvas view">
<button
type="button"
className="zoom-controls__btn"
onClick={onZoomOut}
disabled={zoom <= min + 0.001}
aria-label="Zoom out"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
<path d="M5 12h14" />
</svg>
</button>
<span className="zoom-controls__value" aria-live="polite">{Math.round(zoom * 100)}%</span>
<button
type="button"
className="zoom-controls__btn"
onClick={onZoomIn}
disabled={zoom >= max - 0.001}
aria-label="Zoom in"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
</button>
{/* Snap-to-center toggle (Change 5). Separated from the zoom
buttons by a thin divider so the user reads them as two
distinct affordances on the same toolbar. The button is
marked active when snap is ON (the default), so toggling it
once visibly turns it off — matching the user's mental model
of "disable this feature". */}
{onToggleSnap && (
<>
<span className="zoom-controls__divider" aria-hidden="true" />
<button
type="button"
className={`zoom-controls__btn zoom-controls__btn--snap${snapEnabled ? ' is-active' : ''}`}
onClick={onToggleSnap}
aria-label={snapEnabled ? 'Turn off snap to center' : 'Turn on snap to center'}
aria-pressed={snapEnabled}
title={snapEnabled ? 'Snap to center: on' : 'Snap to center: off'}
>
{snapEnabled ? (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Crosshair + dot — the classic "snap to alignment"
glyph. Center dot signals "this is the target". */}
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<circle cx="12" cy="12" r="2" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
{/* Same crosshair with a slash through it — the
universal "this feature is off" overlay. */}
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
<circle cx="12" cy="12" r="2" />
<line x1="4" y1="4" x2="20" y2="20" />
</svg>
)}
</button>
</>
)}
</div>
);
}

View File

@@ -4,3 +4,9 @@ export { ImageElement } from './ImageElement';
export { TextElement } from './TextElement';
export { TemplateLayer } from './TemplateLayer';
export { SlotPlaceholder, SlotBoundsGuide } from './SlotPlaceholder';
export { CanvasHint } from './CanvasHint';
export { ZoomControls } from './ZoomControls';
export { HistoryControls } from './HistoryControls';
export { ElementToolbar } from './ElementToolbar';
export { ModelSilhouette } from './ModelSilhouette';
export { BackgroundRemovalButton } from './BackgroundRemovalButton';

View File

@@ -2,12 +2,19 @@ import { useEffect, useRef } from 'react';
import FilerobotImageEditor, { TABS } from 'react-filerobot-image-editor';
import { StyleSheetManager } from 'styled-components';
import isPropValid from '@emotion/is-prop-valid';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import '../../styles/PhotoPreEditor.css';
export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
const modalContentRef = useRef(null);
const previousFocusRef = useRef(null);
// Focus trap (S24) — keyboard navigation can't escape the photo
// editor while it's open. Filerobot itself manages focus inside the
// editor; the trap just prevents Tab from leaking back into the
// shell editor underneath.
useFocusTrap(true, modalContentRef);
useEffect(() => {
previousFocusRef.current = document.activeElement;
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose(); };
@@ -77,8 +84,22 @@ export function PhotoPreEditor({ imageSrc, onComplete, onClose }) {
onClose={() => onClose()}
tabsIds={[TABS.ADJUST, TABS.FILTERS, TABS.FINETUNE]}
defaultTabId={TABS.ADJUST}
Crop={{ autoResize: true, defaultSizePercentage: 1, ratio: 'custom' }}
theme={{ accentColor: '#38bdf8', palettePrimary: '#38bdf8' }}
// Crop defaults:
// - ratio: 'original' selects the source image's native ratio so
// the initial crop covers the whole image rather than dropping a
// small "custom" rectangle in the middle.
// - autoResize: false the prior `autoResize: true` together with
// defaultSizePercentage:1 was being interpreted as "size to fit
// the canvas viewport", which on small previews shrank the crop
// to a sub-image region.
// - presetCropAreaPosition: 'cover' explicitly anchors the crop
// to cover the full image on first paint.
Crop={{
autoResize: false,
ratio: 'original',
presetCropAreaPosition: 'cover',
}}
theme={{ accentColor: '#ec4899', palettePrimary: '#ec4899' }}
forceToPngInEllipticalCrop
closeAfterSave
defaultSavedImageName="edited-image"

View File

@@ -0,0 +1,123 @@
import { useEffect, useRef } from 'react';
import { useFocusTrap } from '../../hooks/useFocusTrap';
import '../../styles/PreviewModal.css';
/**
* Print-preview modal.
*
* Shows the server-rendered export image in a fullscreen overlay so the
* user can verify the printable result before adding to cart. Distinct
* from the in-editor canvas because:
*
* • The editor canvas is design-coordinate (300×300) at viewport scale.
* The export is 4500×4500 @ 300 DPI.
* • Server-side rendering applies any text path / layer ordering /
* transparency that the editor's Konva pipeline simulates but doesn't
* necessarily render identically.
* • Some quirks (font fallback on the server, complex stroke handling)
* only manifest in the export pipeline.
*
* Showing the actual exported PNG closes the loop on "what will my
* shirt look like".
*
* Loading state
* ─────────────
* The modal can be shown before the export URL is ready — when the user
* triggers preview, we open the modal immediately with a spinner, then
* swap in the image once `imageUrl` arrives. Closing while loading is
* fine; the in-flight export still completes (and the URL ends up in
* exportUrl state for later use).
*/
export function PreviewModal({ open, imageUrl, loading, error, onClose, onDownload }) {
const panelRef = useRef(null);
// Focus trap (S24) — keyboard users can't tab past the modal back
// into the editor while it's open. Restores focus to the previously
// focused element on close.
useFocusTrap(open, panelRef);
// Lock body scroll while open. Mirrors MobileBottomSheet's pattern so
// the underlying editor doesn't pan when the user pinch-zooms the
// preview image.
useEffect(() => {
if (!open) return;
document.body.classList.add('has-preview-modal');
return () => document.body.classList.remove('has-preview-modal');
}, [open]);
// ESC to dismiss.
useEffect(() => {
if (!open) return;
const onKey = (e) => { if (e.key === 'Escape') onClose?.(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div className="preview-modal" role="dialog" aria-modal="true" aria-label="Print preview">
<button
type="button"
className="preview-modal__backdrop"
aria-label="Close preview"
onClick={onClose}
/>
<div className="preview-modal__panel" ref={panelRef}>
<div className="preview-modal__header">
<h2 className="preview-modal__title">Print preview</h2>
<button
type="button"
className="preview-modal__close"
onClick={onClose}
aria-label="Close"
></button>
</div>
<div className="preview-modal__body">
{loading && (
<div className="preview-modal__loading">
<div className="spinner-small" aria-hidden="true" />
<p>Rendering at print resolution</p>
</div>
)}
{!loading && error && (
<div className="preview-modal__error">
<p> Couldn't render preview: {error}</p>
</div>
)}
{!loading && !error && imageUrl && (
<img
src={imageUrl}
alt="Your design at print resolution"
className="preview-modal__image"
/>
)}
</div>
<div className="preview-modal__footer">
<p className="preview-modal__hint">
This is what will print on your shirt 4500×4500 at 300 DPI.
</p>
<div className="preview-modal__actions">
<button
type="button"
className="preview-modal__btn preview-modal__btn--ghost"
onClick={onClose}
>
Keep editing
</button>
<button
type="button"
className="preview-modal__btn preview-modal__btn--primary"
onClick={onDownload}
disabled={!imageUrl || loading}
>
Download PNG
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,32 +1,404 @@
import { memo } from 'react';
import { memo, useState } from 'react';
import { useCroppedThumbnail } from '../../hooks/useCroppedThumbnail';
import '../../styles/LayersPanel.css';
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
const getIcon = (el) => el.type === 'image' ? (el.bgRemoved ? '🖼️' : '📷') : el.type === 'text' ? '📝' : '🎨';
const getName = (el) => el.type === 'image' ? (el.bgRemoved ? 'Image (BG ✓)' : 'Image') : el.type === 'text' ? (el.text?.substring(0, 20) || 'Text') : 'Sticker';
/**
* Layers panel.
*
* Lists all elements on the canvas in render order (last in array = topmost).
* Click selects, shift-click toggles a row's membership in a multi-selection
* set (S3) — when more than one is selected, the panel shows a "Delete N
* selected" affordance and bulk-deletes on click.
*
* Per-row controls (Change 4 in [[Refinements_2026-05-20_Part2]]):
* • To the LEFT of each row's main button: nothing (the previous ledger-style
* close-X delete affordance moved to the right side and became a proper
* trash-can icon).
* • To the RIGHT of each row's main button, as siblings (NOT nested inside
* the main button), in order: duplicate, lock/unlock, trash-can delete,
* drag grip. These three actions used to live in the floating
* ElementToolbar; co-locating them with the layer they target makes
* them discoverable from the same place the user reasons about layers,
* and makes them reachable without having to click the layer first.
*
* Drag handles on each row let the user reorder items via HTML5 DnD (S3).
* Reordering produces a single history entry (the parent's reorder callback
* uses replaceElements internally).
*
* Accessibility (S24):
* • Each row is a flex container holding multiple <button>s (main row
* selector + per-row actions), so keyboard users can Tab through each
* action independently.
* • Each action button has an explicit aria-label so screen readers
* announce "Duplicate Photo" rather than just "button".
* • Selection is indicated by aria-current="true" PLUS a visible
* check-glyph and a pink ring — not background color alone.
* Colorblind users can tell selected rows apart from unselected.
* • The drag handle is keyboard-skippable (tabindex=-1) since DnD is a
* pointer-only affordance.
*/
export const LayersPanel = memo(function LayersPanel({
elements,
selectedId,
selectedIds, // optional: Set<string> for multi-select (S3)
onSelect,
onToggleInSelection, // optional: shift-click toggle for S3
onDelete,
onDeleteMany, // optional: bulk delete for S3
onReorder, // optional: (sourceId, targetId, position: 'before' | 'after') for S3
// Per-row action callbacks (Change 4). Optional so existing call
// sites that haven't been updated continue to work — the controls
// just don't render when the callback is missing.
onDuplicate,
onUpdate, // (id, attrs) — used by the lock toggle to flip element.locked
}) {
// Icon slot — returns JSX rendered inside `.layers-item-icon`. We use
// real thumbnails for image/sticker layers (mini preview of the actual
// element bytes) so users can identify their content at a glance without
// having to read a generic glyph; text layers get a stylized 'T' icon
// matching the Text tab's nav icon.
//
// For images with a CROP applied, the thumbnail mirrors the cropped
// sub-region rather than the full original source — see <LayerThumb>
// below for the rationale and implementation. Without that, cropping
// to a tight region (e.g. a face) would leave the layers panel
// showing the full uncropped photo, making the cropped layer
// visually identical to the pre-crop layer and defeating the
// thumbnail's purpose of identifying which layer is which.
//
// For images with FILTERS or BG-REMOVAL applied: those are applied
// by Konva at render time over `element.src`. The thumbnail shows
// the cropped (if applicable) but unfiltered version. Mirroring the
// full filter pipeline on a 24px thumbnail would either lag the
// panel or require a separate render path. The canvas itself is
// the source of truth for what the user sees; the thumbnail is a
// "close enough" identifier.
const renderIcon = (el) => {
if (el.type === 'image' || el.type === 'sticker') {
// `el.src` should always be defined for these types — sticker assets
// and image uploads both set it on add. Guard anyway so a malformed
// saved state doesn't crash the panel.
if (!el.src) return <span className="layers-item-icon-fallback" aria-hidden="true">·</span>;
return <LayerThumb element={el} />;
}
if (el.type === 'text') {
// Match the Text-tab nav icon: capital T with serifs. Inlined SVG so
// it picks up `currentColor` from the row text color and inverts
// properly when the row is selected.
return (
<svg className="layers-item-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 7V5h16v2" />
<path d="M9 5v14" />
<path d="M15 5v14" />
<path d="M7 19h4" />
<path d="M13 19h4" />
</svg>
);
}
return <span className="layers-item-icon-fallback" aria-hidden="true">·</span>;
};
const getName = (el) => {
if (el.type === 'text') return el.text || 'Text';
// Stickers all read as 'Sticker' regardless of whether they're an
// image sticker or a rasterized emoji glyph. The renderIcon path
// already shows the actual sticker thumbnail on the left of the
// row, so including the emoji character in the name (e.g.
// 'Sticker 😀') duplicated the same glyph on both sides of the row.
if (el.type === 'sticker') return 'Sticker';
// Photo layers all read as "Photo" regardless of bg-removal state.
// The bg-removed status is visible on the canvas and via the
// ElementToolbar; surfacing it in the layer name (e.g. "Image (BG ✓)")
// added noise to the row without helping the user pick out the layer.
if (el.type === 'image') return 'Photo';
return 'Element';
};
// Resolve the active selection set. If the parent passes `selectedIds`
// we use it (multi-select aware); otherwise fall back to a single-item
// set built from `selectedId` so the rendering path is uniform.
const activeSelectionSet = selectedIds instanceof Set
? selectedIds
: new Set(selectedId ? [selectedId] : []);
const selectedCount = activeSelectionSet.size;
// DnD state. The dragged row's id is in dragId; dragOverId tracks the
// current drop target so we can render an insertion line above or below
// it. We compute insertion position based on whether the cursor is in
// the upper or lower half of the row's bounding rect.
const [dragId, setDragId] = useState(null);
const [dropTarget, setDropTarget] = useState(null); // { id, position }
const handleDragStart = (e, id) => {
if (!onReorder) return;
setDragId(id);
e.dataTransfer.effectAllowed = 'move';
// Setting some data is required for Firefox to actually fire drag events.
try { e.dataTransfer.setData('text/plain', id); } catch { /* ignore */ }
};
const handleDragOver = (e, id) => {
if (!onReorder || !dragId || dragId === id) return;
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const position = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after';
setDropTarget((prev) =>
prev?.id === id && prev?.position === position ? prev : { id, position }
);
};
const handleDragLeave = (e, id) => {
// Only clear if we're actually leaving the row (not just entering a
// child element). currentTarget is the row; relatedTarget is where
// the pointer's heading. If relatedTarget is still inside the row,
// ignore the leave.
if (e.currentTarget.contains(e.relatedTarget)) return;
// `id` is passed in from the JSX rather than read out of the row's
// DOM via e.currentTarget.dataset — React's synthetic events aren't
// safe to read asynchronously, and by the time the setDropTarget
// updater below runs, e.currentTarget has been nulled out. Reading
// e.currentTarget.dataset.layerId there would throw "Cannot read
// properties of null (reading 'dataset')".
setDropTarget((prev) => (prev?.id === id ? null : prev));
};
const handleDrop = (e, targetId) => {
if (!onReorder || !dragId || dragId === targetId) {
setDragId(null);
setDropTarget(null);
return;
}
e.preventDefault();
// `dropTarget.position` is a PANEL position computed from the cursor's
// Y coordinate in handleDragOver:
// 'before' = cursor in upper half of row → visually above the row
// 'after' = cursor in lower half of row → visually below the row
//
// But `onReorder` (→ reorderElement in useDesignEditor) takes ARRAY
// positions, and the panel renders the elements array reversed
// (top-of-panel = last-in-array). So "above in panel" is "after in
// array" and vice-versa — we invert the position here, at the boundary
// between the two coordinate systems. Without this translation,
// dropping A above B in the panel asks reorderElement to put A
// immediately before B in the array, which keeps A below B in the
// panel (the opposite of what the user did) — or no-ops entirely
// when A was already before B, which is the common case and reads
// as "drag does nothing".
const panelPosition = dropTarget?.id === targetId ? dropTarget.position : 'after';
const arrayPosition = panelPosition === 'before' ? 'after' : 'before';
onReorder(dragId, targetId, arrayPosition);
setDragId(null);
setDropTarget(null);
};
const handleDragEnd = () => {
setDragId(null);
setDropTarget(null);
};
const handleRowClick = (e, id) => {
if (e.shiftKey && onToggleInSelection) {
onToggleInSelection(id);
} else {
onSelect(id);
}
};
if (elements.length === 0) {
return <div className="layers-empty">No elements yet. Add images, text, or stickers to your design.</div>;
return <div className="layers-empty">No elements yet. Add photos, text, or stickers to your design.</div>;
}
return (
<div>
<h3 className="layers-title">Layers ({elements.length})</h3>
<div className="layers-list">
{elements.map((element) => (
<div key={element.id} onClick={() => onSelect(element.id)}
className={`layers-item${selectedId === element.id ? ' selected' : ''}`}>
<span className="layers-item-icon">{getIcon(element)}</span>
<span className={`layers-item-name${selectedId === element.id ? ' selected' : ''}`}>
{getName(element)}
</span>
<button onClick={(e) => { e.stopPropagation(); onDelete(element.id); }}
className="layers-item-delete">
×
</button>
</div>
))}
<div className="layers-titlebar">
<h3 className="layers-title">Layers ({elements.length})</h3>
{selectedCount > 1 && onDeleteMany && (
<button
type="button"
className="layers-bulk-delete"
onClick={() => onDeleteMany([...activeSelectionSet])}
>
Delete {selectedCount} selected
</button>
)}
</div>
<ul className="layers-list" role="list">
{/* Render top-down (most recently added on top) — Konva renders
elements later in the array on top, but in a layers panel
users expect the topmost item at the top of the list. */}
{[...elements].reverse().map((element) => {
const isSelected = activeSelectionSet.has(element.id);
const isDragging = dragId === element.id;
const isDropBefore = dropTarget?.id === element.id && dropTarget.position === 'before';
const isDropAfter = dropTarget?.id === element.id && dropTarget.position === 'after';
return (
<li key={element.id} className="layers-row-wrap">
{isDropBefore && <div className="layers-drop-indicator" aria-hidden="true" />}
<div
className={`layers-item${isSelected ? ' selected' : ''}${isDragging ? ' is-dragging' : ''}`}
draggable={!!onReorder}
onDragStart={(e) => handleDragStart(e, element.id)}
onDragOver={(e) => handleDragOver(e, element.id)}
onDragLeave={(e) => handleDragLeave(e, element.id)}
onDrop={(e) => handleDrop(e, element.id)}
onDragEnd={handleDragEnd}
>
<button
type="button"
className="layers-item-main"
onClick={(e) => handleRowClick(e, element.id)}
aria-current={isSelected ? 'true' : undefined}
aria-label={`${isSelected ? 'Selected: ' : ''}${getName(element)}, ${element.type}${onToggleInSelection ? '. Shift-click to add to selection' : ''}`}
>
{/* Visible non-color selection indicator (S24): a small
check chip on the left of selected rows. Colorblind
users can identify selection without relying on the
pink background. */}
<span
className={`layers-item-check${isSelected ? ' is-on' : ''}`}
aria-hidden="true"
>
{isSelected ? '✓' : ''}
</span>
<span className="layers-item-icon" aria-hidden="true">{renderIcon(element)}</span>
<span className="layers-item-name">{getName(element)}</span>
</button>
{/* Per-row actions (Change 4). Siblings of the main row
button, NOT nested inside it — nesting <button>s is
invalid HTML and breaks keyboard / screen-reader
navigation. Each is its own tab stop with its own
aria-label so the user can keyboard through them. */}
{onDuplicate && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDuplicate(element.id); }}
className="layers-item-action"
aria-label={`Duplicate ${getName(element)}`}
title="Duplicate"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Two overlapping rounded squares — the standard
duplicate / copy glyph. Matches the icon used in
the previous ElementToolbar so the muscle memory
transfers. */}
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V6a2 2 0 0 1 2-2h9" />
</svg>
</button>
)}
{onUpdate && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onUpdate(element.id, { locked: !element.locked }); }}
className={`layers-item-action${element.locked ? ' is-active' : ''}`}
aria-label={element.locked ? `Unlock ${getName(element)}` : `Lock ${getName(element)}`}
aria-pressed={!!element.locked}
title={element.locked ? 'Unlock' : 'Lock'}
>
{element.locked ? (
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Closed padlock — base rectangle + bowed shackle
entering both sides of the body. */}
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Open padlock — shackle disconnected from the
right side so the user reads it as "unlocked,
click to lock". */}
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 7-3" />
</svg>
)}
</button>
)}
{/* Delete — now a proper trash-can glyph (Change 4).
Previously this was a bare ✕ close icon which read as
"close this row" rather than "delete this layer". The
classic trash-can with lid + body + three vertical
streaks reads unambiguously as destructive deletion. */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDelete(element.id); }}
className="layers-item-action layers-item-action--delete"
aria-label={`Delete ${getName(element)}`}
title="Delete"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
{/* Trash-can outline: top lid, side handles, body
edges, and three vertical streaks for the trash
"slats". Same path used by the floating ElementToolbar
delete button before Change 4 collapsed that button
into this single location. */}
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
</button>
{onReorder && (
<span
className="layers-item-grip"
aria-hidden="true"
title="Drag to reorder"
tabIndex={-1}
>
</span>
)}
</div>
{isDropAfter && <div className="layers-drop-indicator" aria-hidden="true" />}
</li>
);
})}
</ul>
</div>
);
});
/**
* Per-row thumbnail for image and sticker layers.
*
* Exists as its own component so it can call `useCroppedThumbnail`
* (a React hook, can't live inside the .map() loop in the parent's
* render body). The hook returns a data URL of the cropped sub-region
* when the element has a crop applied, or the raw `element.src`
* otherwise — see the hook's docblock for the rasterization details.
*
* Memoized so a re-render of the parent panel (e.g. when an unrelated
* layer's selection state changes) doesn't redo the thumbnail's load
* and crop computation. The hook itself also caches by (src, crop)
* signature, so even without memo this would be fast — but skipping
* the render path entirely for unchanged elements is faster still.
*
* The visual styling (size, object-fit, etc.) lives on the existing
* `.layers-item-icon-img` CSS class, unchanged from when this was an
* inline <img> in renderIcon. The only thing this wrapper changes
* about the rendered output is which URL ends up in the `src` attr.
*/
const LayerThumb = memo(function LayerThumb({ element }) {
const src = useCroppedThumbnail(element);
// The hook returns null in the rare initial-render-with-no-src case;
// fall back to the fallback dot to avoid emitting an <img> with an
// empty src (which would trigger a broken-image icon in some browsers).
if (!src) {
return <span className="layers-item-icon-fallback" aria-hidden="true">·</span>;
}
return (
<img
src={src}
alt=""
className="layers-item-icon-img"
loading="lazy"
decoding="async"
draggable={false}
/>
);
});

View File

@@ -1,23 +1,11 @@
import { useBackgroundRemoval } from '../../hooks/useBackgroundRemoval';
// MOVED — the canonical source for this component is now
// `../canvas/BackgroundRemovalButton`. It belongs there because it's
// rendered inside the canvas-area's floating element toolbar and operates
// on selected canvas elements; nothing in the sidebar imports it.
//
// This file remains as a thin re-export shim because the local environment
// doesn't expose a delete operation. No internal code imports from this
// path anymore (sidebar/index.js no longer re-exports it, and ElementToolbar
// imports directly from canvas/). Safe to remove in a future cleanup pass.
export function BackgroundRemovalButton({ selectedElement, onUpdate }) {
const { loading, progress, hasModel, loadModel, removeBackground } = useBackgroundRemoval();
const handleRemoveBackground = async () => {
if (!selectedElement || selectedElement.type !== 'image') return;
if (!hasModel) { const loaded = await loadModel(); if (!loaded) return; }
const resultUrl = await removeBackground(selectedElement.src);
if (resultUrl) onUpdate(selectedElement.id, { src: resultUrl, bgRemoved: true });
};
if (!selectedElement || selectedElement.type !== 'image') return null;
return (
<div className="bg-removal-container">
<button className="bg-removal-btn" onClick={handleRemoveBackground} disabled={loading}>
{loading ? (<><div className="spinner-small" />{progress > 0 ? `Loading: ${progress}%` : 'Removing Background...'}</>) : (<> Remove Background</>)}
</button>
{!hasModel && <p className="bg-removal-hint">First use requires downloading ~86MB model. Subsequent uses are cached.</p>}
</div>
);
}
export { BackgroundRemovalButton } from '../canvas/BackgroundRemovalButton';

View File

@@ -0,0 +1,173 @@
import '../../styles/StickersTab.css';
/**
* Emoji tab.
*
* A simple emoji-picker grid. Tapping an emoji rasterizes the glyph onto a
* 100×100 transparent canvas and adds it to the design as a sticker element.
*
* Why rasterize rather than render the glyph live on Konva: emoji rendering
* is OS- and browser-dependent (Apple's Color Emoji vs Segoe UI Emoji vs
* Noto Color Emoji look quite different from each other), and the server's
* node-canvas almost certainly doesn't have any of those fonts installed.
* Rasterizing client-side and shipping the resulting PNG via a data URL
* means the printed output matches what the user designed, regardless of
* which renderer touches it.
*
* This used to live in StickersTab with a `variant` switch. Splitting it
* out cleaned up the conditional rendering and matched the actual data
* separation: image stickers are folder-driven, emojis are a static glyph
* list.
*/
// Inline emoji list. This used to live in constants/stickers.js as the
// `STICKERS` export; that constant has been repurposed for the new
// image-sticker manifest, so the emoji catalog moved here. Keeping it
// local also means the StickersTab build doesn't pay for the emoji
// data and vice-versa — two surfaces that no longer share anything.
const EMOJIS = [
// Faces
'😀','😁','😂','🤣','😃','😄','😅','😆','😉','😊','😋','😎',
'😍','😘','🥰','😗','🤔','🤨','🧐','🤓','😈','🤠','🥳','🤩',
// Animals
'🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮',
'🐷','🐸','🐵','🐔','🐧','🐦','🦄','🐝','🦋','🐌','🐞','🐢',
// Food
'🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🍈','🍒','🍑','🍍',
'🥥','🥝','🍅','🥑','🍆','🥔','🥕','🌽','🍕','🍔','🍟','🌭',
// Sports
'⚽','🏀','🏈','⚾','🥎','🎾','🏐','🏉','🎱','🏓','🏸','🥅',
'⛳','🥊','🥋','🎯','⛹️','🚴','🏆','🥇','🥈','🥉','🏅','🎖️',
// Nature
'🌸','💐','🌹','🌺','🌻','🌼','🌷','🌱','🌲','🌳','🌴','🌵',
'🌾','🌿','☘️','🍀','🍁','🍂','🍃','🌈','☀️','🌙','⭐','🔥',
// Hearts / objects
'❤️','💛','💚','💙','💜','🧡','💔','💯','✨','🌟','💫','🎵',
'🎶','🎸','🎺','🎷','🎹','👑','💎','🎁','🎈','🎉','🎊','🔮',
];
export function EmojiTab({ onAddSticker }) {
const handleAddEmoji = (emoji) => {
// Rasterize the emoji edge-to-edge.
//
// The previous approach drew the glyph at 80% scale on a 100×100
// canvas with `textBaseline: 'middle'`. That left ~10px of
// transparent padding above and below the glyph because:
// (a) the explicit 0.8× scale factor inset the glyph; and
// (b) `textBaseline: 'middle'` centers the em-box, not the
// visual bounds — emoji glyphs are designed with vertical
// metrics that include space for diacritics, descenders,
// etc., most of which a given emoji doesn't actually use.
//
// The transformer handles on the design canvas then sat that
// 10-pixel slack away from the visible glyph on every side, which
// read as sloppy when resizing or rotating.
//
// Robust fix: rasterize generously, then alpha-scan to find the
// true visual bounding box and crop the result to it. This is the
// same approach `useBackgroundRemoval` uses post-mask, just on a
// self-rasterized source instead of an alpha-masked one.
//
// Source canvas is 256×256 (vs the previous 100×100) so the cropped
// result still has enough resolution to print crisply at the 1200px
// print-scale target (see public/stickers/README.md for the math).
const SOURCE = 256;
const FONT_PX = 220; // generous; the alpha-scan crop trims the rest
const src = document.createElement('canvas');
src.width = SOURCE;
src.height = SOURCE;
const sctx = src.getContext('2d');
sctx.font = `${FONT_PX}px Arial`;
sctx.textAlign = 'center';
sctx.textBaseline = 'middle';
sctx.fillText(emoji, SOURCE / 2, SOURCE / 2);
// Alpha-scan for the visible bbox. `data` is a flat RGBA byte array;
// every fourth byte is alpha. ALPHA_THRESHOLD is intentionally low
// (above 0) so faint anti-aliasing pixels at the glyph edge are
// included in the bbox — cropping right at the solid pixels would
// visibly clip the emoji's edges.
const ALPHA_THRESHOLD = 8;
const { data } = sctx.getImageData(0, 0, SOURCE, SOURCE);
let minX = SOURCE, minY = SOURCE, maxX = -1, maxY = -1;
for (let y = 0; y < SOURCE; y++) {
for (let x = 0; x < SOURCE; x++) {
if (data[(y * SOURCE + x) * 4 + 3] > ALPHA_THRESHOLD) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
// Fallback for the degenerate case (no visible pixels — should never
// happen for a real emoji glyph, but keep the path defensive). Just
// ship the uncropped source.
let dataUrl;
let bboxW = SOURCE;
let bboxH = SOURCE;
if (maxX < minX || maxY < minY) {
dataUrl = src.toDataURL('image/png');
} else {
// Add 1px on each side so anti-aliased edges aren't clipped.
const pad = 1;
const cropX = Math.max(0, minX - pad);
const cropY = Math.max(0, minY - pad);
const cropW = Math.min(SOURCE, maxX + 1 + pad) - cropX;
const cropH = Math.min(SOURCE, maxY + 1 + pad) - cropY;
bboxW = cropW;
bboxH = cropH;
const out = document.createElement('canvas');
out.width = cropW;
out.height = cropH;
out.getContext('2d').drawImage(src, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
dataUrl = out.toDataURL('image/png');
}
// Fit the cropped bbox into an 80×80 design-coord box, preserving
// aspect. Same algorithm as StickersTab.handleAddSticker (image
// stickers) and same placement: center on the print zone center
// (150, 150) so non-square emojis don't have their top-left pinned.
// Wide emojis (✨) and tall emojis (⛔) both end up centered.
const fitInto = 80;
const scale = fitInto / Math.max(bboxW, bboxH);
const width = bboxW * scale;
const height = bboxH * scale;
const printCenter = 150;
// Type 'sticker' (not 'image') so the editor can distinguish
// emoji-rasterized stickers from user-uploaded photos. They render
// through the same Konva component (ImageElement) but the type tag
// localizes special-casing — e.g. ElementToolbar's photo-only
// surfaces (filters, background removal, edit photo) gate on type.
onAddSticker({
type: 'sticker',
x: printCenter - width / 2,
y: printCenter - height / 2,
width,
height,
rotation: 0,
src: dataUrl,
emoji,
});
};
return (
<div className="st st--emoji">
<div className="st__grid st__grid--emoji">
{EMOJIS.map((emoji, index) => (
<button
key={`${emoji}-${index}`}
type="button"
onClick={() => handleAddEmoji(emoji)}
className="st__sticker"
aria-label={`Add emoji ${emoji}`}
>
<span className="st__sticker-emoji">{emoji}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
import { useState, useId } from 'react';
import { SHIRT_COLORS, SHIRT_SIZES, formatUsd } from '../../constants/shirt';
import '../../styles/ShirtOptionsPanel.css';
/**
* The first card in the right panel — controls that affect the *garment*,
* not the design overlay: shirt color, size, and the live price.
*
* The shirt color drives the on-screen mockup only. The export endpoint
* receives just the design overlay (no shirt body) since DTG / sublimation
* production pipelines composite onto the chosen shirt downstream.
*
* Collapsed / expanded UX
* ——————————————————————
* The full color + size pickers consume meaningful vertical space at the
* top of the right rail — space that, mid-edit, is more valuable for the
* tools and layers panels below. We default to a compact summary that
* shows the current color (small swatch + label), the current size, and
* the price, with an expand affordance for when the user actually wants
* to change shirt options. After expanding, the panel stays open until
* the user collapses it again — we don't auto-collapse on selection,
* which would punish users who want to compare a few options.
*
* The collapse state is component-local and resets on reload — each
* fresh session of the editor starts collapsed since the user almost
* always cares about the design first and the garment second.
*/
export function ShirtOptionsPanel({
selectedColorId,
onColorChange,
selectedSize,
onSizeChange,
price,
}) {
const [expanded, setExpanded] = useState(false);
// Stable id for aria-controls / id pairing. useId is preferable over a
// hardcoded string because if this component is ever rendered twice in
// the same view (e.g. mobile bottom sheet + desktop sidebar mirroring),
// each instance needs a distinct id.
const contentId = useId();
const selectedColor = SHIRT_COLORS.find((c) => c.id === selectedColorId) ?? SHIRT_COLORS[0];
return (
<section className={`shirt-options${expanded ? ' is-expanded' : ' is-collapsed'}`} aria-labelledby="shirt-options-heading">
{/* The header is the toggle target. Wrapping it in a <button> means
the entire row is clickable and keyboard-focusable. The price and
summary chips inside the button are visual content; the button's
aria-label carries the full state for screen readers. */}
<button
type="button"
className="shirt-options__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls={contentId}
aria-label={`Shirt options. Currently ${selectedColor.label}, size ${selectedSize}, ${formatUsd(price)}. ${expanded ? 'Click to collapse.' : 'Click to change.'}`}
>
<header className="shirt-options__header">
<h2 id="shirt-options-heading" className="shirt-options__title">
<span className="shirt-options__title-icon" aria-hidden="true">👕</span>
Shirt Options
{/* Chevron renders in both expanded and collapsed states so
users always see an affordance for the toggle. CSS
rotates it 180° when the section is expanded — down
arrow says "click to reveal", up arrow says "click to
close". Sits immediately after the title so it reads as
part of the disclosure widget rather than as a
decoration on the price. */}
<span className="shirt-options__chevron" aria-hidden="true">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</h2>
<div className="shirt-options__price" aria-hidden="true">
{formatUsd(price)}
</div>
</header>
{/* Compact summary chips — visible only when collapsed. Shows a
mini swatch + the color label + the size, so the user can
confirm the current selection at a glance without expanding. */}
{!expanded && (
<div className="shirt-options__summary" aria-hidden="true">
<span
className="shirt-options__summary-swatch"
style={{
'--swatch-color': selectedColor.hex,
'--swatch-border': selectedColor.borderHint,
}}
/>
<span className="shirt-options__summary-text">
{selectedColor.label} · Size {selectedSize}
</span>
</div>
)}
</button>
{/* Expanded content. We always render the markup (rather than
short-circuiting on `expanded`) so the CSS max-height transition
has something to animate between. `aria-hidden` + visibility:hidden
(in the CSS) on the collapsed state keep it out of the tab order
and screen-reader tree when it's not visible. */}
<div
id={contentId}
className="shirt-options__content"
aria-hidden={!expanded}
>
<div className="shirt-options__row">
<span className="shirt-options__label">Color</span>
<div className="shirt-options__swatches" role="radiogroup" aria-label="Shirt color">
{SHIRT_COLORS.map((c) => {
const isSelected = c.id === selectedColorId;
return (
<button
key={c.id}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={c.label}
className={`shirt-options__swatch${isSelected ? ' is-selected' : ''}`}
onClick={() => onColorChange?.(c.id)}
// The expanded controls are tabIndex=-1 when collapsed
// so keyboard users don't tab through invisible options.
tabIndex={expanded ? 0 : -1}
style={{
'--swatch-color': c.hex,
'--swatch-border': c.borderHint,
}}
>
<span className="shirt-options__swatch-fill" />
</button>
);
})}
</div>
</div>
<div className="shirt-options__row">
<span className="shirt-options__label">Size</span>
<div className="shirt-options__sizes" role="radiogroup" aria-label="Shirt size">
{SHIRT_SIZES.map((size) => {
const isSelected = size === selectedSize;
return (
<button
key={size}
type="button"
role="radio"
aria-checked={isSelected}
className={`shirt-options__size${isSelected ? ' is-selected' : ''}`}
onClick={() => onSizeChange?.(size)}
tabIndex={expanded ? 0 : -1}
>
{size}
</button>
);
})}
</div>
</div>
</div>
</section>
);
}

View File

@@ -1,42 +1,329 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { ShirtOptionsPanel } from './ShirtOptionsPanel';
import { UploadTab } from './UploadTab';
import { StickersTab } from './StickersTab';
import { EmojiTab } from './EmojiTab';
import { TextTab } from './TextTab';
import { TemplatesTab } from './TemplatesTab';
import { LayersPanel } from '../panels/LayersPanel';
import { formatUsd } from '../../constants/shirt';
import '../../styles/Sidebar.css';
// Tab nav for the right rail. Templates are deliberately absent — templates
// are loaded exclusively via the `?template=X` URL parameter (see
// `readTemplateFromUrl` in App.jsx). The Layers panel used to be a tab here
// too; it's now a permanent section rendered beneath the tab card so the
// user can browse / reorder / delete elements without losing access to the
// upload / stickers / text tools.
const TABS = [
{ id: 'upload', label: 'Upload', icon: '📁' },
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
{ id: 'text', label: 'Text', icon: '📝' },
{ id: 'templates', label: 'Templates', icon: '📋' },
{ id: 'upload', label: 'Upload Photo', icon: UploadIcon },
{ id: 'stickers', label: 'Stickers', icon: StickersIcon },
{ id: 'emoji', label: 'Emoji', icon: EmojiIcon },
{ id: 'text', label: 'Text', icon: TextIcon },
];
export function Sidebar({ onAddImage, onAddSticker, onAddText, onAddTemplate, onSlotImageUpload }) {
const [activeTab, setActiveTab] = useState('upload');
/**
* Right-rail panel.
*
* Top: Shirt Options card (color/size/price)
* Middle: Tab nav + active tab content
* Bottom (sticky): Preview-on-Model toggle + Add to Cart button
*/
export function Sidebar({
// Editor wiring
onAddImage, onAddSticker, onAddText,
recentUploads,
onRemoveUpload,
// Selection-aware editing surface for the text tab. NOTE:
// `selectedElement` is used by the layers panel and the mobile
// bottom-sheet compact mode. The Text tab now reads from a
// SEPARATE prop (`editingTextElement`) so that *selecting* a text
// element on the canvas no longer pulls the Text tab into edit
// mode — only an explicit edit gesture (pencil affordance on
// desktop, Edit-text toolbar button on mobile) sets that prop.
// See the Change 7 follow-up in [[Refinements_2026-05-20_Part2]].
selectedElement, editingTextElement, onUpdateSelected, onCommit,
// Shirt config
shirtColorId, onShirtColorChange,
shirtSize, onShirtSizeChange,
totalPrice, basePrice,
// Cart
onAddToCart,
cartDisabled = false,
cartDisabledReason = null,
// Color helpers for the text tab
recentColors,
onPickColor,
// Font helpers for the text tab (recent fonts dropdown)
recentFonts,
onPickFont,
// Layers tab — needs the full elements list and a delete callback. We
// already have selectedElement; add elements + onSelectElement +
// onDeleteElement so the LayersPanel can show the list and act on it.
elements = [],
onSelectElement,
onDeleteElement,
// Layers-panel per-row actions (Change 4). Optional so existing
// callers continue to work — the buttons just don't render when
// the callback is missing.
onDuplicateElement,
onUpdateElement,
// S3 multi-select wiring. LayersPanel uses these to render the
// ✓ chip per row, the bulk-delete affordance, and the drag-reorder
// grip. All optional — the panel falls back to single-selection
// rendering when they aren't provided.
selectedIds,
onToggleInSelection,
onDeleteMany,
onReorderElement,
// Controlled active-tab API (Change 7 follow-up). Sidebar used to
// own its tab state internally; lifting it to App lets the pencil
// affordance / Edit-text gesture set the tab directly (“clicking
// the pencil should switch to the Text tab and populate the
// form”). Both props are optional so existing call sites that
// didn't pass them fall back to internal state — uncontrolled.
activeTab: controlledActiveTab,
onActiveTabChange,
// Desktop focus signal for the Text tab. Threaded straight through
// to TextTab — see its docblock for how it's consumed. Optional;
// when omitted, TextTab simply never auto-focuses (which is fine
// for any caller that doesn't need the pencil-click behaviour).
textEditFocusToken,
}) {
// Controlled or uncontrolled. When the parent supplies `activeTab`
// and `onActiveTabChange`, those drive the visible tab; otherwise
// we keep our own `useState` and behave as before. Detecting via
// `!== undefined` so a deliberate `null` from the parent (= reset
// to default) is still respected.
const [internalActiveTab, setInternalActiveTab] = useState('upload');
const isControlled = controlledActiveTab !== undefined && typeof onActiveTabChange === 'function';
const activeTab = isControlled ? controlledActiveTab : internalActiveTab;
const setActiveTab = isControlled ? onActiveTabChange : setInternalActiveTab;
// ---------------------------------------------------------------
// Pencil-click focus plumbing (Change 7 follow-up).
//
// The textarea-focus logic LIVES HERE — in Sidebar — rather than
// inside TextTab, on purpose. TextTab is mounted/unmounted as the
// user navigates between tabs; pressing the pencil flips the tab
// to 'text' AND bumps the focus token in the same React batch,
// which means TextTab is mounting FRESH at the moment the token
// first changes. Any `useRef(focusToken)` initializer inside
// TextTab would capture the new value at mount and the
// "transition detection" effect would immediately bail out
// (`1 === 1`) — the user has to click the pencil a second time
// before TextTab's ref carries an old value.
//
// Sidebar, by contrast, is persistently mounted on desktop
// (App.jsx wraps it in an <aside> that lives for the lifetime of
// the editor). Its refs survive every tab switch. The ref
// initializer captures the very-first token value (0) at app
// boot, before any pencil click can have happened — so the next
// bump is correctly detected as a transition.
//
// textareaRef is forwarded down to TextTab via the `textareaRef`
// prop. TextTab attaches it to its <textarea>. When this effect
// fires, the next-frame callback runs after React has mounted
// TextTab and committed its ref — so textareaRef.current is
// populated by the time we call .focus().
// ---------------------------------------------------------------
const textareaRef = useRef(null);
const lastFocusTokenRef = useRef(textEditFocusToken ?? 0);
useEffect(() => {
if (textEditFocusToken == null) return;
if (textEditFocusToken === lastFocusTokenRef.current) return;
lastFocusTokenRef.current = textEditFocusToken;
// requestAnimationFrame defers past the current commit — if the
// same pencil click that bumped this token ALSO switched
// activeTab to 'text', TextTab is mounting on the same render
// and its textarea ref isn't attached yet at the moment React
// fires this effect. Waiting one frame guarantees the textarea
// is in the DOM and the ref is set.
const rafId = window.requestAnimationFrame(() => {
textareaRef.current?.focus();
});
return () => window.cancelAnimationFrame(rafId);
}, [textEditFocusToken]);
// The Text tab's edit mode is gated on `editingTextElement` (NOT
// `selectedElement`) so it only activates when the user has
// explicitly entered text-editing mode via the pencil affordance
// (desktop) or the Edit-text toolbar button (mobile). Selection
// alone is no longer enough — see the Change 7 follow-up in
// [[Refinements_2026-05-20_Part2]] for the rationale.
const selectedTextElement = editingTextElement?.type === 'text' ? editingTextElement : null;
// NOTE: the auto-switch-to-Text-tab effect that used to live here
// moved up to App.jsx — the pencil/Edit-text handler now sets the
// active tab directly via the controlled API. The behaviour is
// identical from the user's perspective (Text tab pops to the
// front on edit gesture, stays put when selection alone changes)
// but the routing is one place instead of two. See Change 7
// follow-up in [[Refinements_2026-05-20_Part2]].
const renderTabContent = () => {
switch (activeTab) {
case 'upload': return <UploadTab onAddImage={onAddImage} />;
case 'upload': return <UploadTab onAddImage={onAddImage} recentUploads={recentUploads} onRemoveUpload={onRemoveUpload} />;
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
case 'text': return <TextTab onAddText={onAddText} />;
case 'templates': return <TemplatesTab onAddTemplate={onAddTemplate} onSlotImageUpload={onSlotImageUpload} />;
case 'emoji': return <EmojiTab onAddSticker={onAddSticker} />;
case 'text': return (
<TextTab
onAddText={onAddText}
selectedTextElement={selectedTextElement}
onUpdateSelected={onUpdateSelected}
onCommit={onCommit}
recentColors={recentColors}
onPickColor={onPickColor}
recentFonts={recentFonts}
onPickFont={onPickFont}
textareaRef={textareaRef}
/* Bug 3 fix forward the full canvas elements list so the
* TextTab can derive sensible draft defaults from any text
* already on the canvas. The tab filters to text-typed
* elements and checks for a shared style; when found, the
* draft inherits that style's font / fill / outline. */
elements={elements}
/>
);
default: return null;
}
};
return (
<div className="sidebar">
<div className="sidebar-tabs">
{TABS.map((tab) => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className={`sidebar-tab-btn${activeTab === tab.id ? ' active' : ''}`}>
<div className="sidebar-tab-icon">{tab.icon}</div>
{tab.label}
</button>
))}
<aside className="rp" aria-label="Customization options">
<div className="rp__scroll">
<ShirtOptionsPanel
selectedColorId={shirtColorId}
onColorChange={onShirtColorChange}
selectedSize={shirtSize}
onSizeChange={onShirtSizeChange}
price={basePrice}
/>
<section className="rp__tools" aria-labelledby="rp-tools-heading">
<h2 id="rp-tools-heading" className="visually-hidden">Design tools</h2>
<div className="rp__tabs" role="tablist" aria-label="Design tools">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
aria-controls={`tab-panel-${tab.id}`}
id={`tab-${tab.id}`}
className={`rp__tab${isActive ? ' is-active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<Icon />
<span>{tab.label}</span>
</button>
);
})}
</div>
<div
role="tabpanel"
id={`tab-panel-${activeTab}`}
aria-labelledby={`tab-${activeTab}`}
className="rp__tabpanel"
>
{renderTabContent()}
</div>
</section>
{/* Layers section — permanent surface beneath the tools card. The
LayersPanel renders its own visible "Layers (N)" titlebar and
handles the empty-state message internally, so this section
just provides a matching card chrome and a screen-reader
heading. */}
<section className="rp__layers" aria-labelledby="rp-layers-heading">
<h2 id="rp-layers-heading" className="visually-hidden">Layers</h2>
<LayersPanel
elements={elements}
selectedId={selectedElement?.id ?? null}
selectedIds={selectedIds}
onSelect={onSelectElement}
onToggleInSelection={onToggleInSelection}
onDelete={onDeleteElement}
onDeleteMany={onDeleteMany}
onReorder={onReorderElement}
onDuplicate={onDuplicateElement}
onUpdate={onUpdateElement}
/>
</section>
</div>
<div className="sidebar-content">{renderTabContent()}</div>
</div>
<div className="rp__cart-bar">
{/* Reason text for the disabled cart, when present. Rendered as a
sibling of the button rather than a tooltip because (a) the
primary surface for the warning is already the canvas-frame
chip, this is a follow-up affordance for the user who has
scrolled to the cart, and (b) tooltips are unreliable on
touch. The button's `disabled` and `aria-disabled` attributes
give assistive tech the actionable signal. */}
{cartDisabled && cartDisabledReason && (
<p className="rp__cart-reason" role="status">
{cartDisabledReason}
</p>
)}
<button
type="button"
className={`rp__add-to-cart${cartDisabled ? ' is-disabled' : ''}`}
onClick={onAddToCart}
disabled={cartDisabled}
aria-disabled={cartDisabled}
title={cartDisabled ? cartDisabledReason || undefined : undefined}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<path d="M12 21s-7-4.5-9.3-9A5.4 5.4 0 0 1 12 5.5 5.4 5.4 0 0 1 21.3 12C19 16.5 12 21 12 21z" />
</svg>
<span>Add to Cart</span>
<span className="rp__add-to-cart-price">{formatUsd(totalPrice)}</span>
</button>
</div>
</aside>
);
}
/* ─── Inline icon set — kept here so each tab definition stays declarative ─── */
function UploadIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M17 8l-5-5-5 5" />
<path d="M12 3v12" />
</svg>
);
}
function StickersIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2.5a8 8 0 1 1-12 12L14.5 2.5z" />
<path d="M14.5 2.5L21.5 9.5a8 8 0 0 1-7 7" />
</svg>
);
}
function EmojiIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<path d="M9 9h.01" />
<path d="M15 9h.01" />
</svg>
);
}
function TextIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7V5h16v2" />
<path d="M9 5v14" />
<path d="M15 5v14" />
<path d="M7 19h4" />
<path d="M13 19h4" />
</svg>
);
}

View File

@@ -1,53 +1,179 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { STICKERS, STICKER_CATEGORIES } from '../../constants/stickers';
import '../../styles/StickersTab.css';
/**
* Stickers tab — image stickers from `public/stickers/`.
*
* Lists every PNG/WebP/JPG/SVG dropped into the public/stickers/ folder,
* grouped by the category prefix in its filename. Tapping a sticker adds it
* to the canvas as a 'sticker'-type element with the file's URL as the src.
*
* Image fetches are lazy: each thumbnail uses `<img loading="lazy">`, so a
* library of 200+ stickers doesn't fire 200 GETs on tab open. The browser
* pulls them in as the user scrolls through the grid.
*
* Adding stickers is folder-driven (see public/stickers/README.md). There's
* no constants file to edit and no manifest to regenerate — a small Vite
* plugin reads the directory at build time and emits the file list.
*
* The Emoji tab (EmojiTab.jsx) used to live here too as a `variant="emoji"`
* mode. It's a separate component now: image stickers and emoji glyphs have
* essentially no shared rendering path beyond the grid CSS, which both reuse.
*/
export function StickersTab({ onAddSticker }) {
const [activeCategory, setActiveCategory] = useState('all');
const [failedIds, setFailedIds] = useState(() => new Set());
const filteredStickers = activeCategory === 'all'
? STICKERS
: STICKERS.filter(s => s.category === activeCategory);
// Categories pill bar. Prepend an "All" entry that the data layer
// intentionally doesn't include — "All" is a UI concept (filter
// disable), not a data category.
const categoryPills = useMemo(
() => [{ id: 'all', label: 'All' }, ...STICKER_CATEGORIES],
[],
);
const handleAddSticker = (emoji) => {
const canvas = document.createElement('canvas');
const size = 100;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.font = `${size * 0.8}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, size / 2, size / 2);
const visibleStickers = useMemo(() => {
if (activeCategory === 'all') return STICKERS;
return STICKERS.filter((s) => s.categoryId === activeCategory);
}, [activeCategory]);
const handleAddSticker = async (sticker) => {
// Read the source image's natural dimensions so the sticker lands on
// the canvas with its true aspect ratio, not as a stretched square.
// The thumbnail's <img> in the panel may not have loaded yet (it's
// lazy) and even if it has, the DOM element isn't a stable place to
// pull `naturalWidth` from — we just construct a fresh Image. If the
// browser already has the file in cache the onload fires immediately;
// otherwise we wait for the network. Either way, the click feels
// responsive because the bottleneck is decode latency, not network.
//
// Falls back to a square 80×80 if the load fails for any reason
// (broken file, CORS oddity, blocked). That's strictly no worse than
// the previous unconditional 80×80 placement.
const fitInto = 80; // design-coord bounding box
let width = fitInto;
let height = fitInto;
try {
const dims = await new Promise((resolveDims, rejectDims) => {
const img = new Image();
img.onload = () => resolveDims({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => rejectDims(new Error('Sticker image failed to load'));
img.src = sticker.url;
});
if (dims.w > 0 && dims.h > 0) {
// Fit (dims.w × dims.h) into an 80×80 box, preserving aspect.
// The longer side becomes 80; the shorter side scales
// proportionally. Equal sides land on 80×80 (matches old
// behavior for square sources).
const scale = fitInto / Math.max(dims.w, dims.h);
width = dims.w * scale;
height = dims.h * scale;
}
} catch {
// Keep the 80×80 fallback. handleImgError will fire from the
// thumbnail render path separately if the file is truly missing.
}
// Center the (width, height) box on the print zone center. Design
// coords are 0300, so center is at 150. With width=height=80, this
// produces (x: 110, y: 110), matching the old fixed placement — so
// square stickers don't appear to move relative to the previous
// behavior. Non-square stickers center properly instead of having
// their top-left pinned (which would have skewed their visual
// center off the print-zone center).
const printCenter = 150;
onAddSticker({
type: 'image',
x: 125, y: 125, width: 80, height: 80, rotation: 0,
src: canvas.toDataURL('image/png'),
emoji,
type: 'sticker',
x: printCenter - width / 2,
y: printCenter - height / 2,
width,
height,
rotation: 0,
src: sticker.url,
});
};
return (
<div>
<h3 className="stickers-title">Stickers</h3>
<div className="stickers-categories">
{STICKER_CATEGORIES.map((cat) => (
<button key={cat} onClick={() => setActiveCategory(cat)}
className={`stickers-category-btn${activeCategory === cat ? ' active' : ''}`}
>
{cat}
</button>
))}
const handleImgError = (id) => {
// Track failed loads so we can hide broken thumbnails. We don't
// remove the sticker entry — the file is presumably there (the
// manifest came from a directory listing), so a 404 likely means a
// dev-server hiccup that'll fix itself on reload. Hiding the broken
// image tile is just polite.
setFailedIds((prev) => {
if (prev.has(id)) return prev;
const next = new Set(prev);
next.add(id);
return next;
});
};
// Empty-state: no stickers in the folder. Friendlier than rendering an
// empty pill bar and a blank grid. The README at public/stickers/README.md
// explains the filename convention; pointing the user there in the
// message would be over-explaining for an end-user-facing surface — this
// copy is intentionally generic.
if (STICKERS.length === 0) {
return (
<div className="st st--full">
<div className="st__header">
<h3 className="st__title">Stickers Library</h3>
</div>
<p className="st__empty">
No stickers available yet. Stickers will appear here once they're
added to the library.
</p>
</div>
<div className="stickers-grid">
{filteredStickers.map((sticker, index) => (
<button key={index} onClick={() => handleAddSticker(sticker.emoji)}
className="sticker-btn"
>
{sticker.emoji}
</button>
))}
);
}
return (
<div className="st st--full">
<div className="st__header">
<h3 className="st__title">Stickers Library</h3>
</div>
<div className="st__categories" role="tablist" aria-label="Sticker categories">
{categoryPills.map((cat) => {
const isActive = cat.id === activeCategory;
return (
<button
key={cat.id}
type="button"
role="tab"
aria-selected={isActive}
className={`st__category${isActive ? ' is-active' : ''}`}
onClick={() => setActiveCategory(cat.id)}
>
{cat.label}
</button>
);
})}
</div>
<div className="st__grid">
{visibleStickers.map((sticker) => {
if (failedIds.has(sticker.id)) return null;
return (
<button
key={sticker.id}
type="button"
onClick={() => handleAddSticker(sticker)}
className="st__sticker st__sticker--image"
aria-label={`Add sticker ${sticker.name}`}
title={sticker.name}
>
<img
src={sticker.url}
alt=""
loading="lazy"
decoding="async"
className="st__sticker-img"
onError={() => handleImgError(sticker.id)}
/>
</button>
);
})}
</div>
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,37 @@
import { useRef, useState } from 'react';
import '../../styles/UploadTab.css';
import { loadImageDimensions } from '../../utils/imageLoading';
import { MIN_ELEMENT_SIZE } from '../../constants/elements';
export function UploadTab({ onAddImage }) {
/**
* Upload Photo tab.
*
* Layout:
* [drop zone] — Click to upload or drop a file
* [Recent Uploads] — quick re-add of previously-used pet photos
*/
export function UploadTab({ onAddImage, recentUploads = [], onRemoveUpload }) {
const fileInputRef = useRef(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const getImageSize = (src) =>
new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => resolve({ width: img.naturalWidth || img.width, height: img.naturalHeight || img.height });
img.onerror = () => reject(new Error('Failed to load image'));
img.src = src;
const placeImage = async (previewUrl, originalUrl) => {
const { width: naturalW, height: naturalH } = await loadImageDimensions(previewUrl);
const maxSide = 150;
const scale = Math.min(maxSide / naturalW, maxSide / naturalH, 1);
const width = Math.max(MIN_ELEMENT_SIZE, Math.round(naturalW * scale));
const height = Math.max(MIN_ELEMENT_SIZE, Math.round(naturalH * scale));
const x = Math.round((300 - width) / 2);
const y = Math.round((300 - height) / 2);
onAddImage({
type: 'image',
x, y, width, height,
rotation: 0,
src: previewUrl,
originalUrl,
});
};
const handleFiles = async (files) => {
const file = files[0];
@@ -28,50 +47,122 @@ export function UploadTab({ onAddImage }) {
const response = await fetch('/api/upload', { method: 'POST', body: formData });
if (!response.ok) throw new Error('Upload failed');
const data = await response.json();
// Preserve aspect ratio by fitting the image into a 150×150 box.
const { width: naturalW, height: naturalH } = await getImageSize(data.preview.url);
const maxSide = 150;
const scale = Math.min(maxSide / naturalW, maxSide / naturalH, 1);
const width = Math.max(20, Math.round(naturalW * scale));
const height = Math.max(20, Math.round(naturalH * scale));
// Canvas is 300×300; start roughly centered.
const x = Math.round((300 - width) / 2);
const y = Math.round((300 - height) / 2);
onAddImage({
type: 'image',
x,
y,
width,
height,
rotation: 0,
src: data.preview.url,
originalUrl: data.original.url,
});
await placeImage(data.preview.url, data.original.url);
} catch (error) {
console.error('Upload error:', error);
alert('Failed to upload image. Please try again.');
} finally {
setIsUploading(false);
// Reset the file input value so picking the same file twice in a row still fires
// the change event. Without this, click-to-pick → delete → click-to-pick the same
// file silently does nothing.
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
return (
<div>
<h3 className="upload-tab-title">Upload Image</h3>
<div onClick={() => fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
className={`upload-dropzone${isDragging ? ' dragging' : ''}`}>
<div className="upload-dropzone-icon">📁</div>
<div className="upload-dropzone-text">Click to upload or drag and drop</div>
<div className="upload-dropzone-hint">JPEG, PNG, WebP (max 20MB)</div>
</div>
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => handleFiles(e.target.files)} className="upload-hidden-input" />
{isUploading && <div className="upload-status">Uploading...</div>}
<div className="upload-tip">
<strong>Tip:</strong> After uploading, you can remove the background using the background removal tool in the properties panel.
<div className="ut">
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
className={`ut__dropzone${isDragging ? ' is-dragging' : ''}`}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInputRef.current?.click(); }
}}
>
<div className="ut__dropzone-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 15a4 4 0 0 1 .9-7.9 5.5 5.5 0 0 1 10.6-1.5A4.5 4.5 0 0 1 22 9.5" />
<path d="M12 12v9" />
<path d="M8 16l4-4 4 4" />
</svg>
</div>
<div className="ut__dropzone-text">Drop your pet photo here</div>
<div className="ut__dropzone-hint">
or <span className="ut__browse">click to browse</span>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleFiles(e.target.files)}
className="ut__hidden-input"
/>
{isUploading && <div className="ut__status">Uploading</div>}
{recentUploads.length > 0 && (
<div className="ut__recent">
<div className="ut__recent-header">
<span className="ut__recent-title">Recent Uploads</span>
<button type="button" className="ut__see-all" onClick={(e) => e.preventDefault()}>See all</button>
</div>
<div className="ut__recent-grid">
{recentUploads.slice(0, 5).map((u) => (
<div key={u.id} className="ut__recent-item">
<button
type="button"
className="ut__recent-thumb"
onClick={() => placeImage(u.previewUrl, u.originalUrl)}
aria-label="Add this photo to canvas"
>
{/* onError prunes entries whose underlying file is
* no longer on the server. The `recentUploads` list
* is persisted to localStorage, but the files behind
* those URLs can vanish between sessions — server
* restart wiping /uploads, manual cleanup, a TTL on
* the preview dir, etc. When that happens the
* thumbnail GET returns 404 and the browser renders
* a broken-image icon.
*
* Calling `onRemoveUpload(u.id)` on error lets the
* parent (App) drop the entry from `recentUploads`,
* which removes the row on the next render and
* triggers a persistence save that cleans the entry
* out of localStorage too. No retry / debouncing
* because the cost of mis-pruning a transient
* network failure is low (user can re-upload from
* the dropzone), while the cost of NOT pruning a
* permanently-dead URL is a persistent broken
* thumbnail across every future session.
*
* Guarded on `onRemoveUpload` being supplied so the
* component still works in callers that don't wire
* the prune callback through (the broken thumb just
* stays, same as the previous behaviour). */}
<img
src={u.previewUrl}
alt=""
onError={() => onRemoveUpload?.(u.id)}
/>
</button>
{onRemoveUpload && (
<button
type="button"
className="ut__recent-delete"
onClick={() => onRemoveUpload(u.id)}
aria-label="Remove this photo from recent uploads"
>
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6l1 14a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-14" />
<path d="M10 11v6" />
<path d="M14 11v6" />
</svg>
<span>Delete</span>
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,9 @@
export { Sidebar } from './Sidebar';
export { ShirtOptionsPanel } from './ShirtOptionsPanel';
export { UploadTab } from './UploadTab';
export { StickersTab } from './StickersTab';
export { EmojiTab } from './EmojiTab';
export { TextTab } from './TextTab';
export { TemplatesTab } from './TemplatesTab';
// BackgroundRemovalButton has moved to ../canvas/. Import it from
// '../canvas/BackgroundRemovalButton' or via canvas/index.js.