Major update for v1 and tests
This commit is contained in:
142
src/components/Header.jsx
Normal file
142
src/components/Header.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/components/MobileBottomSheet.jsx
Normal file
78
src/components/MobileBottomSheet.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
188
src/components/canvas/BackgroundRemovalButton.jsx
Normal file
188
src/components/canvas/BackgroundRemovalButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/canvas/CanvasHint.jsx
Normal file
19
src/components/canvas/CanvasHint.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
src/components/canvas/CropOverlay.jsx
Normal file
283
src/components/canvas/CropOverlay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
996
src/components/canvas/DebugOverlay.jsx
Normal file
996
src/components/canvas/DebugOverlay.jsx
Normal 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
1092
src/components/canvas/ElementToolbar.jsx
Normal file
1092
src/components/canvas/ElementToolbar.jsx
Normal file
File diff suppressed because it is too large
Load Diff
70
src/components/canvas/HistoryControls.jsx
Normal file
70
src/components/canvas/HistoryControls.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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. */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
128
src/components/canvas/InlineTextEditor.jsx
Normal file
128
src/components/canvas/InlineTextEditor.jsx
Normal 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,
|
||||
);
|
||||
}
|
||||
128
src/components/canvas/ModelSilhouette.jsx
Normal file
128
src/components/canvas/ModelSilhouette.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
286
src/components/canvas/TextEditAffordance.jsx
Normal file
286
src/components/canvas/TextEditAffordance.jsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
100
src/components/canvas/ZoomControls.jsx
Normal file
100
src/components/canvas/ZoomControls.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
123
src/components/editor/PreviewModal.jsx
Normal file
123
src/components/editor/PreviewModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
173
src/components/sidebar/EmojiTab.jsx
Normal file
173
src/components/sidebar/EmojiTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/sidebar/ShirtOptionsPanel.jsx
Normal file
162
src/components/sidebar/ShirtOptionsPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 0–300, 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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user