Major update for v1 and tests
This commit is contained in:
480
src/App.css
480
src/App.css
@@ -1,37 +1,453 @@
|
||||
.canvas-header {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* App layout — Pawfectly Yours
|
||||
*
|
||||
* Top-level structure:
|
||||
* .editor-shell
|
||||
* .ph-header (top, full width — defined in Header.css)
|
||||
* .editor-body (flex row)
|
||||
* .canvas-area (flex: 1)
|
||||
* .canvas-area__stage (the Konva canvas frame, fills space)
|
||||
* .canvas-area__bottom-bar (absolute, pinned to bottom)
|
||||
* .canvas-area__toolbar (left of the bar; desktop, when selected)
|
||||
* .canvas-area__right-stack (right cluster, stacked vertically)
|
||||
* .canvas-area__history (undo/redo pill, on top)
|
||||
* .canvas-area__zoom (zoom+snap pill, below)
|
||||
* .right-panel-wrap (fixed width, desktop only)
|
||||
*
|
||||
* On mobile the right panel becomes a bottom-sheet modal, the element
|
||||
* toolbar is pinned to the TOP of the canvas area (via .mobile-toolbar-wrap,
|
||||
* NOT via .canvas-area__bottom-bar), .canvas-area__bottom-bar flips to
|
||||
* static positioning to flow below the stage with the history + zoom
|
||||
* pills centered, and a FAB toggles the sheet.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.canvas-header h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.canvas-header p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.canvas-toolbar {
|
||||
position: absolute;
|
||||
top: 100px;
|
||||
.editor-shell {
|
||||
/* Definite viewport height — see the #root comment in index.css for
|
||||
* why this is `height` and not `min-height`. Removed the prior
|
||||
* `flex: 1` because with #root now `height: 100vh`, editor-shell
|
||||
* being its only child means a height-of-100vh matches naturally;
|
||||
* `flex: 1` + `min-height: 100vh` together used to coexist as
|
||||
* "grow to fill #root, but at least 100vh," which let the box stretch
|
||||
* past 100vh when content pushed for more. */
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layers-panel-wrapper {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Canvas area ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canvas-area__stage {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Element toolbar + zoom/snap controls sit inside a shared bottom bar
|
||||
* (.canvas-area__bottom-bar) that's pinned to the bottom of canvas-area.
|
||||
* Bundling them into a single absolute-positioned flex row — instead
|
||||
* of having each one independently `position: absolute` — means they
|
||||
* can't collide as the canvas-area narrows: the previous implementation
|
||||
* had the toolbar at left:1rem and zoom at right:1rem, and once
|
||||
* canvas-area dipped below ~880px (toolbar 380 + zoom ~150 + gaps) they
|
||||
* overlapped. The bar's flex layout keeps them spaced cleanly and on a
|
||||
* single horizontal line until the narrow-screen media query flips
|
||||
* them to stacked.
|
||||
*
|
||||
* pointer-events trick: the bar visually spans the full canvas width,
|
||||
* but the user must be able to click through the empty middle to drag
|
||||
* elements near the bottom of the canvas. Setting `pointer-events: none`
|
||||
* on the bar and re-enabling it on the toolbar / zoom children gives us
|
||||
* that pass-through. */
|
||||
.canvas-area__bottom-bar {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-area__toolbar {
|
||||
width: min(100%, 380px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* History pill (undo/redo) and zoom pill share a vertical stack
|
||||
* wrapper on the right side of the bottom bar, with history sitting
|
||||
* ABOVE zoom. The previous layout had them side-by-side in a single
|
||||
* flex row — which read as "undo/redo is a secondary affordance
|
||||
* attached to zoom". Stacking gives undo/redo its own visual weight
|
||||
* (the more important of the two for editing flow) and is the
|
||||
* standard hierarchy used by Figma / Sketch / Adobe Express.
|
||||
*
|
||||
* `margin-left: auto` lives on the stack wrapper, not the children,
|
||||
* so the whole cluster gets pushed to the right edge whether the
|
||||
* left-side toolbar is present or not. `align-items: flex-end`
|
||||
* keeps both pills right-aligned within the column even if they
|
||||
* differ in width (history is 2 buttons, zoom is 4 — zoom is
|
||||
* naturally wider). The narrow-screen and mobile breakpoints below
|
||||
* flatten this back to a row to avoid eating extra vertical space
|
||||
* when the bar is already in column mode.
|
||||
*
|
||||
* pointer-events: none on the wrapper, auto on the children — same
|
||||
* pass-through pattern as the parent bottom bar. The wrapper has no
|
||||
* pixels of its own; the pills inside accept the clicks. */
|
||||
.canvas-area__right-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
align-items: flex-end;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canvas-area__history {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.canvas-area__zoom {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Right panel ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.right-panel-wrap {
|
||||
width: var(--right-panel-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--brand-cream);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── Export toast ────────────────────────────────────────────────────────── */
|
||||
/* Floats above the canvas in the top-left corner so it doesn't compete with
|
||||
* the toolbar (bottom-left) or zoom controls (bottom-right). */
|
||||
|
||||
.export-toast-wrap {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
z-index: 12;
|
||||
pointer-events: none;
|
||||
/* When the canvas hint is shown it occupies top-left too; the hint hides
|
||||
* itself once an element is selected, and the export toast only appears
|
||||
* during/after a save action — they rarely overlap visually. */
|
||||
}
|
||||
|
||||
.export-toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
pointer-events: auto;
|
||||
/* Shared keyframe in index.css — see S13 utility section. */
|
||||
animation: fade-up-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.export-toast--progress {
|
||||
border-color: var(--brand-pink);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.export-toast--success {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.export-toast--error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.export-toast__link {
|
||||
color: inherit;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.export-toast__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0 0 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── App-wide toast (replaces alert() calls) ─────────────────────────────── */
|
||||
/* Centered at the top of the canvas area, distinct from the export-toast
|
||||
* which sits in the top-left. Auto-dismisses after a few seconds; the user
|
||||
* can also dismiss manually with the close button. Three kinds matching
|
||||
* the export-toast color language so the warning vocabulary stays
|
||||
* consistent: success = green, error = red, info = brand pink. */
|
||||
|
||||
.app-toast-wrap {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 13;
|
||||
pointer-events: none;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
pointer-events: auto;
|
||||
animation: app-toast-in 0.18s ease-out;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-toast > span {
|
||||
/* Wrap long messages (e.g. the share-fallback URL toast) instead of
|
||||
* letting them stretch the toast off-screen. */
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@keyframes app-toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-4px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.app-toast--success { border-color: #bbf7d0; background: #f0fdf4; color: #15803d; }
|
||||
.app-toast--error { border-color: #fecaca; background: #fef2f2; color: #b91c1c; }
|
||||
.app-toast--info { border-color: var(--brand-pink); background: var(--brand-pink-soft); color: var(--brand-pink-strong); }
|
||||
|
||||
.app-toast__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0 0 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-toast-wrap {
|
||||
top: calc(var(--header-height-mobile) + 0.6rem);
|
||||
width: calc(100% - 1rem);
|
||||
}
|
||||
.app-toast {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── FAB (mobile only) ───────────────────────────────────────────────────── */
|
||||
|
||||
.app-fab {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 700;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 22px -6px rgba(236, 72, 153, 0.6), 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.app-fab:hover {
|
||||
background: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.app-fab:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
/* ── Mobile element toolbar wrapper ──────────────────────────────────────── */
|
||||
|
||||
.mobile-toolbar-wrap {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: calc(var(--header-height-mobile) + 0.5rem);
|
||||
transform: translateX(-50%);
|
||||
z-index: 600;
|
||||
width: calc(100vw - 1.5rem);
|
||||
max-width: 380px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Tablet — narrower right panel */
|
||||
@media (max-width: 1100px) {
|
||||
.right-panel-wrap {
|
||||
width: var(--right-panel-width-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* Below the tablet breakpoint where the right panel might crowd the canvas:
|
||||
* keep the bottom bar pinned to the bottom of canvas-area (still snapped
|
||||
* to the screen bottom), but flip it to column direction so the toolbar
|
||||
* and zoom stack instead of competing for horizontal room. The toolbar's
|
||||
* 380px max width plus the zoom widget plus gaps adds up to roughly 540px;
|
||||
* canvas-area drops below that around 880px viewport width once the right
|
||||
* panel takes its share, which is what this breakpoint catches. */
|
||||
@media (max-width: 900px) and (min-width: 769px) {
|
||||
.canvas-area__bottom-bar {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.canvas-area__toolbar {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
/* Flatten the right-stack to a horizontal row at this breakpoint.
|
||||
* The parent bar is already column-direction, so a vertically-
|
||||
* stacked right-stack inside that would push the bottom bar tall
|
||||
* enough to compete with the canvas for vertical space. A row
|
||||
* keeps history + zoom on the same line, centered along with the
|
||||
* toolbar that sits above them. margin-left: auto becomes a no-op
|
||||
* in column-direction so we reset it for clarity. */
|
||||
.canvas-area__right-stack {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
.canvas-area__history,
|
||||
.canvas-area__zoom {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile — bottom sheet handles the right panel */
|
||||
@media (max-width: 768px) {
|
||||
.editor-body {
|
||||
padding: 0 0.5rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.canvas-area__stage {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile bottom bar — pulled out of absolute positioning so it
|
||||
* participates in canvas-area's flex column. Sits below the stage,
|
||||
* centered horizontally, with the bottom margin clearing the FAB.
|
||||
* History + zoom pills sit side-by-side, centered as a pair.
|
||||
* Reset the pointer-events:none / margin-left:auto from the desktop
|
||||
* defaults so clicks land normally and centering works. */
|
||||
.canvas-area__bottom-bar {
|
||||
position: static;
|
||||
align-self: center;
|
||||
margin-bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
|
||||
margin-top: 0.4rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Element toolbar is rendered via .mobile-toolbar-wrap on mobile,
|
||||
* not via the bottom bar. */
|
||||
.canvas-area__toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Same flatten as the 900px breakpoint above — mobile bottom bar
|
||||
* is a horizontal row, so the right-stack inside follows suit
|
||||
* rather than stacking vertically. */
|
||||
.canvas-area__right-stack {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.canvas-area__history,
|
||||
.canvas-area__zoom {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.export-toast-wrap {
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
top: calc(var(--header-height-mobile) + 0.6rem);
|
||||
}
|
||||
|
||||
.export-toast {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tighten on tiny screens */
|
||||
@media (max-width: 480px) {
|
||||
.editor-body {
|
||||
padding: 0 0.4rem 0.4rem;
|
||||
}
|
||||
|
||||
.app-fab {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
2133
src/App.jsx
2133
src/App.jsx
File diff suppressed because it is too large
Load Diff
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.
|
||||
|
||||
23
src/constants/elements.js
Normal file
23
src/constants/elements.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Element-sizing constants shared across components and bounds-fitting.
|
||||
*
|
||||
* Before this module, `Math.max(20, ...)` appeared inline in
|
||||
* `ImageElement.onTransformEnd`, `elementBounds.fitElementToBounds`,
|
||||
* `UploadTab.placeImage`, and several other places. The 20 isn't a guess —
|
||||
* it's the smallest box the user can still hit with the transformer
|
||||
* handles on a touch device — so changing it would change UX. Pulling it
|
||||
* here makes that explicit and prevents drift.
|
||||
*/
|
||||
|
||||
// Floor for image / sticker width and height. Below this, transformer
|
||||
// corner handles overlap and the element becomes effectively un-targetable
|
||||
// on touch.
|
||||
export const MIN_ELEMENT_SIZE = 20;
|
||||
|
||||
// Floor for inline-text-editor textarea overlay dimensions. Bigger than the
|
||||
// canvas-element minimum because the user is interacting with a real DOM
|
||||
// textarea (text selection, IME composition) where 20px would be too small
|
||||
// to be usable. Width gets a higher floor than height because empty/short
|
||||
// strings still need a recognizable click target.
|
||||
export const MIN_INLINE_EDIT_WIDTH = 60;
|
||||
export const MIN_INLINE_EDIT_HEIGHT = 20;
|
||||
@@ -19,4 +19,6 @@ export const FONTS = [
|
||||
{ name: 'Fira Sans', family: 'Fira Sans' },
|
||||
{ name: 'Barlow', family: 'Barlow' },
|
||||
{ name: 'Bebas Neue', family: 'Bebas Neue' },
|
||||
{ name: 'Pacifico', family: 'Pacifico' },
|
||||
{ name: 'Caveat', family: 'Caveat' },
|
||||
];
|
||||
|
||||
60
src/constants/imageFilters.js
Normal file
60
src/constants/imageFilters.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Image filter presets for photo elements.
|
||||
*
|
||||
* Each preset can specify ONE of two ways to identify its filter:
|
||||
* - `konva`: a string name looked up on `Konva.Filters` at apply time.
|
||||
* Used for built-in Konva filters (Grayscale, Sepia, etc.). We avoid
|
||||
* importing Konva here so consumers (e.g. ElementToolbar) that only
|
||||
* iterate the list for UI don't pay the bundle cost.
|
||||
* - `customFn`: a direct function reference (a Konva-compatible filter
|
||||
* that mutates ImageData in place). Used for filters that don't ship
|
||||
* with Konva — e.g. the `warm` preset uses Filerobot's Kelvin filter.
|
||||
*
|
||||
* Lookup order in ImageElement: `customFn ?? Konva.Filters[konva] ?? null`.
|
||||
*
|
||||
* Each preset can also carry default attribute values that Konva reads
|
||||
* during filter application — `Brighten` reads `brightness`, `Contrast`
|
||||
* reads `contrast`, etc. Presets currently only use the basic toggle-style
|
||||
* filters; the schema supports parameter passing for future presets.
|
||||
*
|
||||
* Server-side note
|
||||
* ────────────────
|
||||
* The print export pipeline (server.js → applyFilterToSharp) currently
|
||||
* mirrors grayscale, sepia, and invert via sharp. The `warm`/Kelvin preset
|
||||
* is editor-only — exports silently skip it — so the printed shirt won't
|
||||
* match the on-screen preview for warm-filtered photos until a sharp
|
||||
* implementation is added.
|
||||
*/
|
||||
|
||||
import KelvinFilter from 'react-filerobot-image-editor/lib/custom/filters/Kelvin';
|
||||
|
||||
export const IMAGE_FILTERS = [
|
||||
{
|
||||
id: 'none',
|
||||
label: 'Original',
|
||||
konva: null,
|
||||
icon: '◯',
|
||||
},
|
||||
{
|
||||
id: 'grayscale',
|
||||
label: 'B&W',
|
||||
konva: 'Grayscale',
|
||||
icon: '◐',
|
||||
},
|
||||
{
|
||||
id: 'sepia',
|
||||
label: 'Sepia',
|
||||
konva: 'Sepia',
|
||||
icon: '◑',
|
||||
},
|
||||
{
|
||||
id: 'warm',
|
||||
label: 'Warm',
|
||||
customFn: KelvinFilter,
|
||||
icon: '◓',
|
||||
},
|
||||
];
|
||||
|
||||
export function getFilterPreset(id) {
|
||||
return IMAGE_FILTERS.find((f) => f.id === id) || IMAGE_FILTERS[0];
|
||||
}
|
||||
89
src/constants/products.js
Normal file
89
src/constants/products.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Product type configuration for the Pawfectly Yours editor.
|
||||
*
|
||||
* The frontend rendering is parameterized by these objects so that the same
|
||||
* editor code can drive shirts, magnets, earrings, stickers, and so on. Only
|
||||
* code reads this — there is intentionally no UI for picking the active
|
||||
* product.
|
||||
*
|
||||
* Coordinate systems
|
||||
* ──────────────────
|
||||
* Every product has TWO coordinate systems:
|
||||
*
|
||||
* 1. **Design coordinates** (`canvasSize`) — the integer space in which
|
||||
* individual elements (images, text, stickers) live. Element x/y/width/
|
||||
* height are in these units. The server export pipeline scales from
|
||||
* `canvasSize` to the final 4500×4500 print resolution.
|
||||
*
|
||||
* 2. **Product SVG coordinates** (`productSvg.width / .height`) — the
|
||||
* viewBox of the product mockup that's drawn behind the design canvas.
|
||||
* The `printZone` field positions the design canvas inside the product
|
||||
* mockup, in these units.
|
||||
*
|
||||
* Keeping `canvasSize` at 300 (the historical value) means existing templates
|
||||
* and the server-side export math do not need to change. The visual fit
|
||||
* (canvas overlapping the print zone of the product) is achieved by CSS
|
||||
* scaling the rendered Konva Stage at runtime.
|
||||
*/
|
||||
|
||||
export const PRODUCT_TYPES = {
|
||||
shirt: {
|
||||
id: 'shirt',
|
||||
label: 'T-Shirt',
|
||||
canvasSize: 300,
|
||||
// Where on the product mockup the design canvas should overlay, in
|
||||
// product SVG coordinates. Sized roughly to a real DTG print zone for
|
||||
// a youth-medium tee.
|
||||
//
|
||||
// Note: the product SVG's viewBox is offset (starts at 40, 50) to crop
|
||||
// empty margin around the shirt outline. printZone coords are in that
|
||||
// same viewBox space — i.e. (90, 130) is what the chest landed at
|
||||
// after the crop, not the path's raw (130, 180).
|
||||
printZone: { x: 90, y: 130, width: 220, height: 220 },
|
||||
productSvg: {
|
||||
// Component name (looked up by DesignCanvas) and the dimensions of its
|
||||
// viewBox. Must match the actual SVG.
|
||||
component: 'shirt',
|
||||
width: 400,
|
||||
height: 470,
|
||||
},
|
||||
background: {
|
||||
// 'color' = user can pick from SHIRT_COLORS
|
||||
// 'fixed' = use background.color as a constant fill
|
||||
mode: 'color',
|
||||
defaultColorId: 'white',
|
||||
},
|
||||
},
|
||||
|
||||
// Reference shapes for future products. None of these are wired into the
|
||||
// UI yet, but the DesignCanvas/ProductSVG plumbing is ready to handle them.
|
||||
//
|
||||
// magnet: {
|
||||
// id: 'magnet',
|
||||
// label: 'Magnet',
|
||||
// canvasSize: 300,
|
||||
// printZone: { x: 30, y: 30, width: 240, height: 240 },
|
||||
// productSvg: { component: 'magnet', width: 300, height: 300 },
|
||||
// background: { mode: 'fixed', color: '#f4f1ee' },
|
||||
// },
|
||||
// earring: { ... },
|
||||
// sticker: { ... },
|
||||
};
|
||||
|
||||
/**
|
||||
* Which product the editor is currently configured for. Change this constant
|
||||
* to point at a different `PRODUCT_TYPES` entry. There's intentionally no UI
|
||||
* exposure — switching products is a code change.
|
||||
*/
|
||||
export const ACTIVE_PRODUCT_ID = 'shirt';
|
||||
|
||||
export function getActiveProduct() {
|
||||
const product = PRODUCT_TYPES[ACTIVE_PRODUCT_ID];
|
||||
if (!product) {
|
||||
throw new Error(
|
||||
`Unknown ACTIVE_PRODUCT_ID="${ACTIVE_PRODUCT_ID}". `
|
||||
+ `Valid ids: ${Object.keys(PRODUCT_TYPES).join(', ')}`
|
||||
);
|
||||
}
|
||||
return product;
|
||||
}
|
||||
26
src/constants/shirt.js
Normal file
26
src/constants/shirt.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Shirt color and size catalog for the Pawfectly Yours editor.
|
||||
*
|
||||
* The "color" here drives the on-screen shirt mockup only — the export render
|
||||
* is the print-ready design overlay (no shirt body), which matches how DTG /
|
||||
* sublimation production actually works. Including the shirt color in the
|
||||
* cart payload would be the responsibility of the order-fulfilment layer,
|
||||
* not the export endpoint.
|
||||
*/
|
||||
|
||||
export const SHIRT_COLORS = [
|
||||
{ id: 'white', label: 'White', hex: '#ffffff', borderHint: '#e8e8ec' },
|
||||
{ id: 'black', label: 'Black', hex: '#1c1b20', borderHint: '#1c1b20' },
|
||||
{ id: 'pink', label: 'Pink', hex: '#f5a3b8', borderHint: '#e98aa3' },
|
||||
{ id: 'blue', label: 'Powder', hex: '#b6cef0', borderHint: '#9bbbe5' },
|
||||
{ id: 'peach', label: 'Peach', hex: '#fcd7b6', borderHint: '#f0c39a' },
|
||||
];
|
||||
|
||||
export const SHIRT_SIZES = ['S', 'M', 'L', 'XL'];
|
||||
|
||||
// Base price; cart total in this iteration is just price × quantity.
|
||||
export const SHIRT_BASE_PRICE_USD = 28.0;
|
||||
|
||||
export function formatUsd(value) {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
@@ -1,159 +1,154 @@
|
||||
export const STICKER_CATEGORIES = ['all', 'faces', 'animals', 'food', 'sports', 'nature', 'objects'];
|
||||
/**
|
||||
* Image-sticker manifest.
|
||||
*
|
||||
* Stickers are PNG/WebP/JPG/SVG files dropped into `public/stickers/` with a
|
||||
* specific filename convention:
|
||||
*
|
||||
* <category>__<sticker_name>.<ext>
|
||||
*
|
||||
* Examples:
|
||||
* hearts__pink_heart.png → category: Hearts, name: Pink Heart
|
||||
* pets__golden_retriever.png → category: Pets, name: Golden Retriever
|
||||
*
|
||||
* Adding a sticker is zero-config: drop the file in `public/stickers/`, name
|
||||
* it correctly, refresh the page. No imports to edit, no manifest to update.
|
||||
*
|
||||
* The filename list comes from a Vite virtual module (see
|
||||
* `stickerManifestPlugin` in vite.config.js). Vite's import.meta.glob can't
|
||||
* see `public/`, so the plugin reads the directory at build time and emits
|
||||
* the list. URLs are stable and unhashed (`/stickers/<filename>`), which
|
||||
* lets the server also read these files during export — see
|
||||
* `resolveImageSource` in server.js for that path.
|
||||
*
|
||||
* If `public/stickers/` is empty, this module exports empty arrays and the
|
||||
* StickersTab renders a friendly empty state. No crash, no noisy console.
|
||||
*/
|
||||
|
||||
export const STICKERS = [
|
||||
// Faces
|
||||
{ emoji: '😀', category: 'faces' },
|
||||
{ emoji: '😁', category: 'faces' },
|
||||
{ emoji: '😂', category: 'faces' },
|
||||
{ emoji: '🤣', category: 'faces' },
|
||||
{ emoji: '😃', category: 'faces' },
|
||||
{ emoji: '😄', category: 'faces' },
|
||||
{ emoji: '😅', category: 'faces' },
|
||||
{ emoji: '😆', category: 'faces' },
|
||||
{ emoji: '😉', category: 'faces' },
|
||||
{ emoji: '😊', category: 'faces' },
|
||||
{ emoji: '😋', category: 'faces' },
|
||||
{ emoji: '😎', category: 'faces' },
|
||||
{ emoji: '😍', category: 'faces' },
|
||||
{ emoji: '😘', category: 'faces' },
|
||||
{ emoji: '🥰', category: 'faces' },
|
||||
{ emoji: '😗', category: 'faces' },
|
||||
{ emoji: '🤔', category: 'faces' },
|
||||
{ emoji: '🤨', category: 'faces' },
|
||||
{ emoji: '🧐', category: 'faces' },
|
||||
{ emoji: '🤓', category: 'faces' },
|
||||
{ emoji: '😈', category: 'faces' },
|
||||
{ emoji: '🤠', category: 'faces' },
|
||||
{ emoji: '🥳', category: 'faces' },
|
||||
{ emoji: '🤩', category: 'faces' },
|
||||
import { STICKER_FILES } from 'virtual:sticker-manifest';
|
||||
|
||||
// Animals
|
||||
{ emoji: '🐶', category: 'animals' },
|
||||
{ emoji: '🐱', category: 'animals' },
|
||||
{ emoji: '🐭', category: 'animals' },
|
||||
{ emoji: '🐹', category: 'animals' },
|
||||
{ emoji: '🐰', category: 'animals' },
|
||||
{ emoji: '🦊', category: 'animals' },
|
||||
{ emoji: '🐻', category: 'animals' },
|
||||
{ emoji: '🐼', category: 'animals' },
|
||||
{ emoji: '🐨', category: 'animals' },
|
||||
{ emoji: '🐯', category: 'animals' },
|
||||
{ emoji: '🦁', category: 'animals' },
|
||||
{ emoji: '🐮', category: 'animals' },
|
||||
{ emoji: '🐷', category: 'animals' },
|
||||
{ emoji: '🐸', category: 'animals' },
|
||||
{ emoji: '🐵', category: 'animals' },
|
||||
{ emoji: '🐔', category: 'animals' },
|
||||
{ emoji: '🐧', category: 'animals' },
|
||||
{ emoji: '🐦', category: 'animals' },
|
||||
{ emoji: '🦄', category: 'animals' },
|
||||
{ emoji: '🐝', category: 'animals' },
|
||||
{ emoji: '🦋', category: 'animals' },
|
||||
{ emoji: '🐌', category: 'animals' },
|
||||
{ emoji: '🐞', category: 'animals' },
|
||||
{ emoji: '🐢', category: 'animals' },
|
||||
// Public URL base for sticker assets. The Vite plugin watches the matching
|
||||
// directory on disk; the server reads from the same prefix on its side.
|
||||
const STICKER_URL_PREFIX = '/stickers/';
|
||||
|
||||
// Food
|
||||
{ emoji: '🍎', category: 'food' },
|
||||
{ emoji: '🍐', category: 'food' },
|
||||
{ emoji: '🍊', category: 'food' },
|
||||
{ emoji: '🍋', category: 'food' },
|
||||
{ emoji: '🍌', category: 'food' },
|
||||
{ emoji: '🍉', category: 'food' },
|
||||
{ emoji: '🍇', category: 'food' },
|
||||
{ emoji: '🍓', category: 'food' },
|
||||
{ emoji: '🍈', category: 'food' },
|
||||
{ emoji: '🍒', category: 'food' },
|
||||
{ emoji: '🍑', category: 'food' },
|
||||
{ emoji: '🍍', category: 'food' },
|
||||
{ emoji: '🥥', category: 'food' },
|
||||
{ emoji: '🥝', category: 'food' },
|
||||
{ emoji: '🍅', category: 'food' },
|
||||
{ emoji: '🥑', category: 'food' },
|
||||
{ emoji: '🍆', category: 'food' },
|
||||
{ emoji: '🥔', category: 'food' },
|
||||
{ emoji: '🥕', category: 'food' },
|
||||
{ emoji: '🌽', category: 'food' },
|
||||
{ emoji: '🍕', category: 'food' },
|
||||
{ emoji: '🍔', category: 'food' },
|
||||
{ emoji: '🍟', category: 'food' },
|
||||
{ emoji: '🌭', category: 'food' },
|
||||
/**
|
||||
* Parse a filename into display metadata.
|
||||
*
|
||||
* Filename shape: `[<sort>-]<category>__<name>.<ext>` where:
|
||||
* - <sort> (optional) leading "<digits>-" prefix used only for category ordering
|
||||
* - <category> lowercase word, no underscores within
|
||||
* - <name> one or more underscore-separated words
|
||||
*
|
||||
* Returns null for files that don't match (e.g. files lacking `__`). Returning
|
||||
* null rather than throwing lets the module evaluate cleanly even if someone
|
||||
* accidentally drops a stray file into the folder.
|
||||
*/
|
||||
function parseStickerFilename(filename) {
|
||||
const dotIdx = filename.lastIndexOf('.');
|
||||
if (dotIdx <= 0) return null;
|
||||
const base = filename.slice(0, dotIdx);
|
||||
|
||||
// Sports
|
||||
{ emoji: '⚽', category: 'sports' },
|
||||
{ emoji: '🏀', category: 'sports' },
|
||||
{ emoji: '🏈', category: 'sports' },
|
||||
{ emoji: '⚾', category: 'sports' },
|
||||
{ emoji: '🥎', category: 'sports' },
|
||||
{ emoji: '🎾', category: 'sports' },
|
||||
{ emoji: '🏐', category: 'sports' },
|
||||
{ emoji: '🏉', category: 'sports' },
|
||||
{ emoji: '🎱', category: 'sports' },
|
||||
{ emoji: '🏓', category: 'sports' },
|
||||
{ emoji: '🏸', category: 'sports' },
|
||||
{ emoji: '🥅', category: 'sports' },
|
||||
{ emoji: '⛳', category: 'sports' },
|
||||
{ emoji: '🥊', category: 'sports' },
|
||||
{ emoji: '🥋', category: 'sports' },
|
||||
{ emoji: '🎯', category: 'sports' },
|
||||
{ emoji: '⛹️', category: 'sports' },
|
||||
{ emoji: '🚴', category: 'sports' },
|
||||
{ emoji: '🏆', category: 'sports' },
|
||||
{ emoji: '🥇', category: 'sports' },
|
||||
{ emoji: '🥈', category: 'sports' },
|
||||
{ emoji: '🥉', category: 'sports' },
|
||||
{ emoji: '🏅', category: 'sports' },
|
||||
{ emoji: '🎖️', category: 'sports' },
|
||||
// Optional "<digits>-" prefix for explicit ordering.
|
||||
let sortKey = '';
|
||||
let rest = base;
|
||||
const dashMatch = base.match(/^(\d+)-(.+)$/);
|
||||
if (dashMatch) {
|
||||
// Pad so '2' sorts before '10' lexicographically.
|
||||
sortKey = dashMatch[1].padStart(6, '0');
|
||||
rest = dashMatch[2];
|
||||
}
|
||||
|
||||
// Nature
|
||||
{ emoji: '🌸', category: 'nature' },
|
||||
{ emoji: '💐', category: 'nature' },
|
||||
{ emoji: '🌹', category: 'nature' },
|
||||
{ emoji: '🌺', category: 'nature' },
|
||||
{ emoji: '🌻', category: 'nature' },
|
||||
{ emoji: '🌼', category: 'nature' },
|
||||
{ emoji: '🌷', category: 'nature' },
|
||||
{ emoji: '🌱', category: 'nature' },
|
||||
{ emoji: '🌲', category: 'nature' },
|
||||
{ emoji: '🌳', category: 'nature' },
|
||||
{ emoji: '🌴', category: 'nature' },
|
||||
{ emoji: '🌵', category: 'nature' },
|
||||
{ emoji: '🌾', category: 'nature' },
|
||||
{ emoji: '🌿', category: 'nature' },
|
||||
{ emoji: '☘️', category: 'nature' },
|
||||
{ emoji: '🍀', category: 'nature' },
|
||||
{ emoji: '🍁', category: 'nature' },
|
||||
{ emoji: '🍂', category: 'nature' },
|
||||
{ emoji: '🍃', category: 'nature' },
|
||||
{ emoji: '🌈', category: 'nature' },
|
||||
{ emoji: '☀️', category: 'nature' },
|
||||
{ emoji: '🌙', category: 'nature' },
|
||||
{ emoji: '⭐', category: 'nature' },
|
||||
{ emoji: '🔥', category: 'nature' },
|
||||
// Split on the DOUBLE underscore. Exactly one expected; if missing or
|
||||
// duplicated, the file isn't a valid sticker.
|
||||
const parts = rest.split('__');
|
||||
if (parts.length !== 2) return null;
|
||||
const [categorySlug, nameSlug] = parts;
|
||||
if (!categorySlug || !nameSlug) return null;
|
||||
|
||||
// Objects
|
||||
{ emoji: '❤️', category: 'objects' },
|
||||
{ emoji: '💛', category: 'objects' },
|
||||
{ emoji: '💚', category: 'objects' },
|
||||
{ emoji: '💙', category: 'objects' },
|
||||
{ emoji: '💜', category: 'objects' },
|
||||
{ emoji: '🧡', category: 'objects' },
|
||||
{ emoji: '💔', category: 'objects' },
|
||||
{ emoji: '💯', category: 'objects' },
|
||||
{ emoji: '✨', category: 'objects' },
|
||||
{ emoji: '🌟', category: 'objects' },
|
||||
{ emoji: '💫', category: 'objects' },
|
||||
{ emoji: '🎵', category: 'objects' },
|
||||
{ emoji: '🎶', category: 'objects' },
|
||||
{ emoji: '🎸', category: 'objects' },
|
||||
{ emoji: '🎺', category: 'objects' },
|
||||
{ emoji: '🎷', category: 'objects' },
|
||||
{ emoji: '🎹', category: 'objects' },
|
||||
{ emoji: '👑', category: 'objects' },
|
||||
{ emoji: '💎', category: 'objects' },
|
||||
{ emoji: '🎁', category: 'objects' },
|
||||
{ emoji: '🎈', category: 'objects' },
|
||||
{ emoji: '🎉', category: 'objects' },
|
||||
{ emoji: '🎊', category: 'objects' },
|
||||
{ emoji: '🔮', category: 'objects' },
|
||||
];
|
||||
return {
|
||||
categoryId: categorySlug.toLowerCase(),
|
||||
categoryLabel: titleCase(categorySlug),
|
||||
name: titleCase(nameSlug),
|
||||
sortKey,
|
||||
};
|
||||
}
|
||||
|
||||
function titleCase(slug) {
|
||||
return slug
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sticker list. One pass over the filename list from the virtual
|
||||
* module; each valid file becomes a sticker with `{ id, url, name,
|
||||
* categoryId, categoryLabel }`.
|
||||
*
|
||||
* `id` is derived from the URL because the URL is unique per file and stable
|
||||
* enough across reloads to use as a React key. We don't persist sticker ids
|
||||
* anywhere — once a sticker is added to the canvas, the relevant identity is
|
||||
* the canvas element's id, not the source sticker's.
|
||||
*/
|
||||
function buildStickerList() {
|
||||
const out = [];
|
||||
for (const filename of STICKER_FILES) {
|
||||
const parsed = parseStickerFilename(filename);
|
||||
if (!parsed) continue;
|
||||
const url = STICKER_URL_PREFIX + filename;
|
||||
out.push({
|
||||
id: url,
|
||||
url,
|
||||
name: parsed.name,
|
||||
categoryId: parsed.categoryId,
|
||||
categoryLabel: parsed.categoryLabel,
|
||||
sortKey: parsed.sortKey,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const _all = buildStickerList();
|
||||
|
||||
/**
|
||||
* All sticker entries, sorted by (category sortKey, sticker name). The
|
||||
* category sort key is derived from the lowest `sortKey` seen for that
|
||||
* category — if any file in the category has a `<digits>-` prefix, the
|
||||
* whole category gets ordered by it; otherwise alphabetical.
|
||||
*/
|
||||
export const STICKERS = (() => {
|
||||
// Resolve a stable sort key per category: lowest seen `sortKey` (already
|
||||
// zero-padded) for files in that category. Falls back to the category
|
||||
// label for purely alphabetical ordering.
|
||||
const catKey = new Map();
|
||||
for (const s of _all) {
|
||||
const existing = catKey.get(s.categoryId);
|
||||
if (!existing || (s.sortKey && (!existing.sortKey || s.sortKey < existing.sortKey))) {
|
||||
catKey.set(s.categoryId, { sortKey: s.sortKey || '', label: s.categoryLabel });
|
||||
}
|
||||
}
|
||||
return [..._all].sort((a, b) => {
|
||||
const ka = catKey.get(a.categoryId);
|
||||
const kb = catKey.get(b.categoryId);
|
||||
const keyA = (ka.sortKey || 'zzz') + '|' + ka.label.toLowerCase();
|
||||
const keyB = (kb.sortKey || 'zzz') + '|' + kb.label.toLowerCase();
|
||||
if (keyA !== keyB) return keyA < keyB ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
})();
|
||||
|
||||
/**
|
||||
* Deduped list of categories, in display order. Each entry is
|
||||
* `{ id, label }`. The Stickers tab adds an implicit "All" pill at the
|
||||
* front itself — that's a tab concern, not a data one.
|
||||
*/
|
||||
export const STICKER_CATEGORIES = (() => {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const s of STICKERS) {
|
||||
if (seen.has(s.categoryId)) continue;
|
||||
seen.add(s.categoryId);
|
||||
out.push({ id: s.categoryId, label: s.categoryLabel });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
|
||||
54
src/constants/textMetrics.js
Normal file
54
src/constants/textMetrics.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Text-rendering constants shared across measurement, bounds, and render code.
|
||||
*
|
||||
* Before this module: `1.2`, `12`, and similar magic numbers were repeated
|
||||
* across `textGeometry.js`, `elementBounds.js`, and `TextElement.jsx`. If
|
||||
* Konva's defaults ever changed (or the design system picked a different
|
||||
* line-height ratio), every site had to be updated in lockstep — and there
|
||||
* was no way to grep for "the line-height ratio" since it was just `1.2`.
|
||||
*
|
||||
* Centralizing here keeps those callers honest. New text-related constants
|
||||
* that show up in 2+ places should land here.
|
||||
*
|
||||
* Note on per-component font-family defaults
|
||||
* ──────────────────────────────────────────
|
||||
* `TextElement.jsx` defaults `fontFamily = 'DM Sans'` (the body face) for
|
||||
* its prop. `textGeometry.js` defaulted to `'Pacifico'` (the display face)
|
||||
* for its measurement-fallback path. Those are intentionally different —
|
||||
* an element rendered without a fontFamily uses DM Sans, but the
|
||||
* measurement helpers fall back to the display face that matches new-text
|
||||
* placement defaults. We keep that distinction by exposing both names
|
||||
* here and letting each call site pick the one that matches its semantics.
|
||||
*/
|
||||
|
||||
// Konva's default line-height ratio. Multiply by fontSize to get the
|
||||
// rendered height of a single text line.
|
||||
export const LINE_HEIGHT_RATIO = 1.2;
|
||||
|
||||
// Floor for fontSize during scale-down operations. Below this, text becomes
|
||||
// hard to read and the rounded display in the Text tab starts losing
|
||||
// precision (12 → 11 → 10 each cost ~10% of legibility).
|
||||
export const MIN_FONT_SIZE = 12;
|
||||
|
||||
// Ceiling for auto-computed fontSize values (e.g. width-preservation
|
||||
// when swapping to a narrower font). The Text tab's slider tops out at
|
||||
// 120 for typical fine-tuning, but width-preservation can legitimately
|
||||
// land above that when going from a wide display face to a very
|
||||
// condensed one. 200 gives plenty of headroom (about 2/3 of the
|
||||
// 300-design-unit canvas height) while preventing pathological growth
|
||||
// on a font whose measureText returns near-zero for a particular glyph.
|
||||
// The user can still corner-drag the text on canvas past this value;
|
||||
// the cap only governs the auto-preserve math.
|
||||
export const MAX_FONT_SIZE = 200;
|
||||
|
||||
// Default fontSize when an element doesn't specify one. Matches the
|
||||
// historical TEXT default in textGeometry.placeTextCentered.
|
||||
export const DEFAULT_FONT_SIZE = 32;
|
||||
|
||||
// Display face used by the freeform "Add text" button and as the
|
||||
// measurement fallback when an element has no explicit fontFamily.
|
||||
export const DEFAULT_DISPLAY_FONT = 'Pacifico';
|
||||
|
||||
// Body face used by TextElement's prop default (so Konva renders something
|
||||
// reasonable when the prop is omitted).
|
||||
export const DEFAULT_BODY_FONT = 'DM Sans';
|
||||
62
src/constants/transformer.js
Normal file
62
src/constants/transformer.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Konva Transformer style + behavior shared by ImageElement and TextElement.
|
||||
*
|
||||
* Before this module: both files independently declared identical
|
||||
* `ROTATION_SNAPS_15` arrays and identical visual config (anchor size /
|
||||
* stroke / fill / corner radius / rotate-anchor offset). Two sites with
|
||||
* the same constants is a drift risk — and the brand pink (#ec4899) was
|
||||
* spelled as a literal in both rather than referenced from anywhere
|
||||
* central.
|
||||
*
|
||||
* Centralizing here means changing the brand pink, the snap granularity,
|
||||
* or the anchor look-and-feel is a one-file edit. Per-element behavior
|
||||
* (the bound functions, lock state, ratio-keep logic) stays at the call
|
||||
* site since those legitimately differ.
|
||||
*/
|
||||
|
||||
// Pre-computed rotation snap angles — every 15°. Used when the user holds
|
||||
// shift while rotating, so multi-element layouts can be aligned to common
|
||||
// angles without pixel-perfect dragging. We exclude 360 because Konva's
|
||||
// Transformer normalizes 360 → 0 anyway.
|
||||
export const ROTATION_SNAPS_15 = (() => {
|
||||
const out = [];
|
||||
for (let a = 0; a < 360; a += 15) out.push(a);
|
||||
return out;
|
||||
})();
|
||||
|
||||
// Angular window inside which Konva pulls the rotation to the nearest
|
||||
// snap when shift is held. 7° gives a comfortable "magnetic" feel
|
||||
// without making it hard to land between snaps.
|
||||
export const ROTATION_SNAP_TOLERANCE = 7;
|
||||
|
||||
// Corner-only anchors. Combined with `keepRatio` on the consumer this
|
||||
// gives uniform scaling that always preserves aspect ratio — what we
|
||||
// want for both photos (no squish) and text (no letter-stretching).
|
||||
export const CORNER_ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||
|
||||
// Visual style for selection bbox + handles. Spread into the Transformer
|
||||
// element at the call site. `borderDash` is intentionally NOT here
|
||||
// because it varies (solid for unlocked, dashed when locked), and is a
|
||||
// per-render decision.
|
||||
//
|
||||
// `rotateAnchorOffset` controls the distance (in design-coord px) between
|
||||
// the top edge of the bbox and the rotate handle. Was 28 originally,
|
||||
// which created a visible gap between the element and its rotate handle
|
||||
// that read as "the rotate handle belongs to no one" — especially after
|
||||
// the move to a dedicated transformer layer, where the handle floats
|
||||
// above unrelated content in z-order and the long offset made the
|
||||
// association ambiguous. 14 keeps the handle clearly outside the bbox
|
||||
// border (so it doesn't overlap a corner anchor at typical zoom levels)
|
||||
// while making the "this handle controls THIS element" association
|
||||
// unambiguous. Reduce further only if corner anchors and the rotate
|
||||
// handle start visually colliding for very small elements; the current
|
||||
// value tested fine across the editor's MIN_ELEMENT_SIZE (20px) range.
|
||||
export const TRANSFORMER_VISUAL_STYLE = {
|
||||
anchorSize: 9,
|
||||
anchorCornerRadius: 6,
|
||||
borderStroke: '#ec4899',
|
||||
borderStrokeWidth: 1.5,
|
||||
anchorStroke: '#ec4899',
|
||||
anchorFill: '#ffffff',
|
||||
rotateAnchorOffset: 14,
|
||||
};
|
||||
@@ -76,9 +76,57 @@ export function useBackgroundRemoval() {
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Tighten the result to its visible (non-transparent) pixels so the
|
||||
// returned image's bounding box is the subject, not the original
|
||||
// photo's mostly-transparent canvas. Callers use the returned `bbox`
|
||||
// to shrink the element's transform handles around the remaining
|
||||
// content. ALPHA_THRESHOLD = 8 (≈3% opacity) skips near-invisible
|
||||
// model noise at edges without clipping visible details.
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
const ALPHA_THRESHOLD = 8;
|
||||
const pixels = imageData.data;
|
||||
let minX = W;
|
||||
let minY = H;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < H; y++) {
|
||||
for (let x = 0; x < W; x++) {
|
||||
if (pixels[(y * W + 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resultCanvas = canvas;
|
||||
let bbox;
|
||||
if (maxX < 0) {
|
||||
// Entirely transparent — the model decided the whole image was
|
||||
// background. Return the (empty) full canvas rather than a 0×0
|
||||
// crop, so the caller can still produce a sensible element
|
||||
// update if it wants to.
|
||||
bbox = { sx: 0, sy: 0, sw: W, sh: H, srcWidth: W, srcHeight: H };
|
||||
} else {
|
||||
const cropW = maxX - minX + 1;
|
||||
const cropH = maxY - minY + 1;
|
||||
if (cropW !== W || cropH !== H) {
|
||||
resultCanvas = document.createElement('canvas');
|
||||
resultCanvas.width = cropW;
|
||||
resultCanvas.height = cropH;
|
||||
resultCanvas.getContext('2d').drawImage(canvas, -minX, -minY);
|
||||
}
|
||||
bbox = { sx: minX, sy: minY, sw: cropW, sh: cropH, srcWidth: W, srcHeight: H };
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setLoading(false);
|
||||
return canvas.toDataURL('image/png');
|
||||
return {
|
||||
url: resultCanvas.toDataURL('image/png'),
|
||||
bbox,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Background removal failed:', error);
|
||||
setLoading(false);
|
||||
|
||||
214
src/hooks/useCroppedThumbnail.js
Normal file
214
src/hooks/useCroppedThumbnail.js
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Produce a data URL for the cropped sub-region of an image element,
|
||||
* suitable for use as the `src` of a thumbnail <img> in the layers
|
||||
* panel (or anywhere else we render a small representative bitmap of
|
||||
* a cropped photo).
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* Image elements with a crop applied store the crop separately from
|
||||
* the source pixels:
|
||||
*
|
||||
* element.src = URL of the ORIGINAL (uncropped) image
|
||||
* element.crop = { sx, sy, sWidth, sHeight } // sub-region to render
|
||||
*
|
||||
* Konva.Image consumes both via its native `crop` attr — it renders
|
||||
* only the sub-region. But anywhere else we display the image as a
|
||||
* plain HTML <img> (the LayersPanel thumbnail, the recent-uploads
|
||||
* thumbnails), `<img src={element.src} />` shows the WHOLE original,
|
||||
* not the crop. The user sees their cropped photo on canvas but the
|
||||
* uncropped version in the layers panel, which is confusing — the
|
||||
* layer-panel thumbnail is meant to read as "this is the layer I'm
|
||||
* looking at," and if the cropped face becomes a tiny corner of an
|
||||
* un-cropped landscape in the thumbnail, the user can't pick out the
|
||||
* layer at a glance.
|
||||
*
|
||||
* This hook closes the gap by rendering the cropped sub-region into
|
||||
* an offscreen <canvas> and returning its toDataURL(). The data URL
|
||||
* is a drop-in replacement for `element.src` in any <img> we render.
|
||||
*
|
||||
* Fast path
|
||||
* ─────────
|
||||
* Elements WITHOUT a crop bypass canvas work entirely — the hook
|
||||
* returns `element.src` as-is. There's no perf cost for the common
|
||||
* case of uncropped photos. Canvas work only runs when:
|
||||
*
|
||||
* 1. The element has a non-null `element.crop`, AND
|
||||
* 2. The (src, crop) signature differs from the last successful
|
||||
* render. The signature lives on the ref alongside the data URL
|
||||
* so re-renders triggered by unrelated state changes don't redo
|
||||
* the rasterization.
|
||||
*
|
||||
* Returns
|
||||
* ───────
|
||||
* The string passed back is always usable as an <img src>. While the
|
||||
* canvas render is in flight we return the previous successful URL
|
||||
* (or `element.src` if there isn't one yet) so the thumbnail doesn't
|
||||
* flicker to an empty placeholder mid-update. On error (image fails
|
||||
* to load, taint from cross-origin, etc.) we fall back to
|
||||
* `element.src` and let the browser show whatever it would have
|
||||
* shown before this hook existed.
|
||||
*
|
||||
* Implementation notes
|
||||
* ────────────────────
|
||||
* • The image is loaded with `crossOrigin = 'anonymous'` so the
|
||||
* canvas remains untainted and `toDataURL()` succeeds for both
|
||||
* local blob: URLs and remote https URLs (when the server sends
|
||||
* permissive CORS headers, which our upload backend does). If a
|
||||
* future URL source is added that doesn't support CORS, the catch
|
||||
* handler quietly falls back to the original src — no crash, just
|
||||
* no crop in the thumbnail for that one image.
|
||||
*
|
||||
* • Output dimensions are clamped via the `maxSize` parameter (default
|
||||
* 96px) so the data URL stays small even for high-resolution source
|
||||
* images. The thumbnail viewport is 24px square, so 96px is 4x the
|
||||
* display size — enough resolution for HiDPI screens without
|
||||
* bloating the data URL.
|
||||
*
|
||||
* • The aspect ratio of the output matches the crop region's aspect
|
||||
* ratio (sWidth / sHeight). The <img> in the layers panel uses
|
||||
* `object-fit: cover`, so a non-square crop will be center-cropped
|
||||
* inside the square 24px viewport. That matches how Konva renders
|
||||
* the cropped photo on canvas (the canvas shows the entire crop
|
||||
* region within the element's bounds), so the thumbnail and the
|
||||
* on-canvas photo share visual content even if neither's aspect
|
||||
* matches the row's icon slot exactly.
|
||||
*
|
||||
* • An AbortController-style cancellation flag handles unmount during
|
||||
* in-flight loads: setState after unmount is a memory leak warning
|
||||
* in dev and an undefined-behavior risk in production. The
|
||||
* cancelled-flag pattern is the lightest-weight way to short-circuit
|
||||
* the load-then-setState chain without requiring AbortController
|
||||
* support on the underlying `new Image()` API (which doesn't
|
||||
* accept one).
|
||||
*/
|
||||
export function useCroppedThumbnail(element, maxSize = 96) {
|
||||
const [thumb, setThumb] = useState(() => {
|
||||
// Initial value: if there's no crop, the source IS the thumbnail.
|
||||
// If there is a crop, return null so the consumer can show a
|
||||
// placeholder or fall back until the canvas render completes.
|
||||
if (!element) return null;
|
||||
if (!element.crop || !element.src) return element.src ?? null;
|
||||
return element.src ?? null;
|
||||
});
|
||||
|
||||
// Signature cache. Avoids redoing canvas work when the consumer
|
||||
// re-renders for reasons unrelated to the cropped image — e.g. the
|
||||
// selection state changed, or a sibling layer was added. Keyed on
|
||||
// (src, crop) because the same source can be cropped differently
|
||||
// across duplicated layers, and the same crop value can be applied
|
||||
// to a freshly-uploaded source.
|
||||
const lastRenderRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return undefined;
|
||||
const { src, crop } = element;
|
||||
|
||||
// No source → nothing to render. Clear state so a stale thumb
|
||||
// from a previous element doesn't leak across.
|
||||
if (!src) {
|
||||
setThumb(null);
|
||||
lastRenderRef.current = null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// No crop → the source IS the thumbnail. Bypass canvas entirely.
|
||||
if (!crop) {
|
||||
setThumb(src);
|
||||
lastRenderRef.current = null; // clear so a future crop re-renders
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Build the signature. Object equality on crop would force a
|
||||
// re-render every time the parent re-renders (new object each
|
||||
// time), but the actual values rarely change — stringifying
|
||||
// gives us value equality without committing to a deep-equal
|
||||
// helper.
|
||||
const signature = `${src}::${crop.sx}::${crop.sy}::${crop.sWidth}::${crop.sHeight}`;
|
||||
if (lastRenderRef.current?.signature === signature) {
|
||||
// Already rendered this exact (src, crop) combination. The
|
||||
// cached data URL is in state already; nothing to do.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
// Compute output dimensions: keep the crop's aspect ratio,
|
||||
// clamp the longest side to maxSize. Going smaller than the
|
||||
// source crop is fine — the canvas downsamples cleanly. Going
|
||||
// LARGER would blow up file pixels with no visual benefit.
|
||||
const sW = Math.max(1, crop.sWidth);
|
||||
const sH = Math.max(1, crop.sHeight);
|
||||
const scale = Math.min(1, maxSize / Math.max(sW, sH));
|
||||
const outW = Math.max(1, Math.round(sW * scale));
|
||||
const outH = Math.max(1, Math.round(sH * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
// No 2D context (vanishingly rare — Safari private mode in
|
||||
// some old versions, headless browsers without canvas). Fall
|
||||
// back to the original src so the thumbnail still shows
|
||||
// SOMETHING, just uncropped.
|
||||
setThumb(src);
|
||||
return;
|
||||
}
|
||||
|
||||
// drawImage(sx, sy, sW, sH, dx, dy, dW, dH) — the 9-arg form
|
||||
// maps a source sub-rect onto a destination rect. This is
|
||||
// exactly the same math Konva does internally via the `crop`
|
||||
// attribute on Image nodes, so the thumbnail content matches
|
||||
// the on-canvas photo for any crop value.
|
||||
ctx.drawImage(img, crop.sx, crop.sy, sW, sH, 0, 0, outW, outH);
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
lastRenderRef.current = { signature, dataUrl };
|
||||
setThumb(dataUrl);
|
||||
} catch {
|
||||
// Canvas was tainted (cross-origin without CORS), toDataURL
|
||||
// failed, or some other unexpected failure. Fall back to the
|
||||
// original source so the thumbnail still renders SOMETHING —
|
||||
// just without the crop applied. Better degradation than a
|
||||
// missing image.
|
||||
if (!cancelled) setThumb(src);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
// Image failed to load entirely. Fall back to the raw src; the
|
||||
// <img> tag in the consumer will surface the same load error
|
||||
// there. The hook's job is to provide a string; whether it
|
||||
// actually loads is the renderer's problem.
|
||||
if (!cancelled) setThumb(src);
|
||||
};
|
||||
|
||||
img.src = src;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// We deliberately depend on the granular crop fields rather than
|
||||
// the crop object itself — the App-level state replaces the crop
|
||||
// object on every update, so depending on `element.crop`
|
||||
// (reference) would re-run the effect every render. Field-level
|
||||
// deps re-run only when the actual values change.
|
||||
}, [
|
||||
element?.src,
|
||||
element?.crop?.sx,
|
||||
element?.crop?.sy,
|
||||
element?.crop?.sWidth,
|
||||
element?.crop?.sHeight,
|
||||
maxSize,
|
||||
]);
|
||||
|
||||
return thumb;
|
||||
}
|
||||
@@ -1,11 +1,76 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { makeElementId } from '../utils/makeId';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
const DEBOUNCE_DELAY_MS = 300;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Diagnostic history logging.
|
||||
//
|
||||
// Enable by running this in the browser console, then reload:
|
||||
// localStorage.setItem('paw_debug_history', '1')
|
||||
// Disable with:
|
||||
// localStorage.removeItem('paw_debug_history')
|
||||
//
|
||||
// Every line is prefixed `[history]` so you can filter in DevTools.
|
||||
// Designed specifically for diagnosing the crop+undo "canvas ends up
|
||||
// blank" bug: when undo lands on an unexpected snapshot, this log
|
||||
// reveals which push got missed or deduped, or whether history was
|
||||
// wiped between the action and the undo.
|
||||
//
|
||||
// Each event logs `{ idx, len, source, ...details }` so a single
|
||||
// scroll-through tells you:
|
||||
// • What the index was at each step — if undo walks from N to N-1
|
||||
// and N-1 is unexpectedly an empty snapshot, the wipe happened
|
||||
// earlier in the log.
|
||||
// • What the history length was — truncations (redo-stack discard)
|
||||
// and the MAX_HISTORY shift are visible.
|
||||
// • Where the call came from — every push includes `source` so you
|
||||
// can tell addElement vs replaceElements vs updateAndCommit vs
|
||||
// the debounced updateElement timer apart.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
const HISTORY_DEBUG = (() => {
|
||||
try {
|
||||
return typeof localStorage !== 'undefined'
|
||||
&& localStorage.getItem('paw_debug_history') === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
// Short summary of an elements array — we don't dump the full JSON in
|
||||
// every log line because it's huge and obscures the signal. We do show
|
||||
// element count and first-three IDs, which together are enough to
|
||||
// distinguish "the image array", "the cropped array", and "empty"
|
||||
// in a glance.
|
||||
function summarizeElements(arr) {
|
||||
if (!Array.isArray(arr)) return '<not-array>';
|
||||
if (arr.length === 0) return '[] (empty)';
|
||||
const ids = arr.slice(0, 3).map((el) => el?.id ?? '?').join(', ');
|
||||
const tail = arr.length > 3 ? `, …+${arr.length - 3}` : '';
|
||||
return `${arr.length} els [${ids}${tail}]`;
|
||||
}
|
||||
|
||||
function hlog(event, info) {
|
||||
if (!HISTORY_DEBUG) return;
|
||||
// Using a single console.log call (not console.group) keeps the lines
|
||||
// copyable as a single block when the user wants to share the log.
|
||||
// The leading prefix is intentionally verbose so it survives DevTools
|
||||
// filter-by-text searches even when the rest of the line wraps.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[history] ${event}`, info);
|
||||
}
|
||||
|
||||
export function useDesignEditor() {
|
||||
const [elements, setElements] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
// Multi-select state (S3). The authoritative selection is a Set of element
|
||||
// ids. Single-selection callers read `selectedId` (a derived value that's
|
||||
// non-null only when exactly one element is selected) and use it like
|
||||
// before — that path is unchanged. Multi-select callers (LayersPanel) read
|
||||
// `selectedIds` directly.
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
const selectedId = selectedIds.size === 1 ? [...selectedIds][0] : null;
|
||||
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const historyRef = useRef([]);
|
||||
@@ -18,31 +83,158 @@ export function useDesignEditor() {
|
||||
setCanRedo(historyIndexRef.current < historyRef.current.length - 1);
|
||||
}, []);
|
||||
|
||||
const saveToHistory = useCallback((newElements) => {
|
||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||
// `source` is a label identifying who called us (addElement,
|
||||
// replaceElements, updateAndCommit, flushPendingChanges-timer, etc.).
|
||||
// It's only consulted by the diagnostic logger — production behaviour
|
||||
// is unchanged. Defaults to '?' so callers that don't bother to pass
|
||||
// it still work.
|
||||
const saveToHistory = useCallback((newElements, source = '?') => {
|
||||
const json = JSON.stringify(newElements);
|
||||
const beforeIdx = historyIndexRef.current;
|
||||
const beforeLen = historyRef.current.length;
|
||||
|
||||
// Dedupe consecutive identical snapshots. The primary motivation is React
|
||||
// StrictMode, which double-invokes the setElements updater in dev to detect
|
||||
// impure updaters; the inner saveToHistory side-effect runs twice with the
|
||||
// same input and used to push two identical entries (so undo had to be
|
||||
// pressed twice to undo one action). This dedupe is also a real correctness
|
||||
// improvement outside StrictMode — calling saveToHistory with no actual
|
||||
// change is a no-op rather than a bloated history slot.
|
||||
if (historyIndexRef.current >= 0 && historyRef.current[historyIndexRef.current] === json) {
|
||||
hlog('saveToHistory DEDUPE', {
|
||||
source,
|
||||
idx: beforeIdx,
|
||||
len: beforeLen,
|
||||
snapshot: summarizeElements(newElements),
|
||||
reason: 'identical to current entry',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const willTruncate = historyIndexRef.current < historyRef.current.length - 1;
|
||||
if (willTruncate) {
|
||||
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
|
||||
}
|
||||
historyRef.current.push(JSON.stringify(newElements));
|
||||
if (historyRef.current.length > MAX_HISTORY) { historyRef.current.shift(); }
|
||||
else { historyIndexRef.current++; }
|
||||
historyRef.current.push(json);
|
||||
let shifted = false;
|
||||
if (historyRef.current.length > MAX_HISTORY) {
|
||||
historyRef.current.shift();
|
||||
shifted = true;
|
||||
} else {
|
||||
historyIndexRef.current++;
|
||||
}
|
||||
syncUndoRedo();
|
||||
|
||||
hlog('saveToHistory PUSH', {
|
||||
source,
|
||||
idx: `${beforeIdx} → ${historyIndexRef.current}`,
|
||||
len: `${beforeLen} → ${historyRef.current.length}`,
|
||||
snapshot: summarizeElements(newElements),
|
||||
truncated: willTruncate ? `dropped ${beforeLen - 1 - beforeIdx} redo entries` : false,
|
||||
shifted: shifted ? `MAX_HISTORY exceeded; oldest entry dropped` : false,
|
||||
});
|
||||
}, [syncUndoRedo]);
|
||||
|
||||
const flushPendingChanges = useCallback(() => {
|
||||
if (pendingChangesRef.current) { saveToHistory(pendingChangesRef.current); pendingChangesRef.current = null; }
|
||||
if (pendingChangesRef.current) {
|
||||
hlog('flushPendingChanges', {
|
||||
reason: 'draining pending debounced updateElement',
|
||||
snapshot: summarizeElements(pendingChangesRef.current),
|
||||
});
|
||||
saveToHistory(pendingChangesRef.current, 'flushPendingChanges');
|
||||
pendingChangesRef.current = null;
|
||||
}
|
||||
if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
|
||||
}, [saveToHistory]);
|
||||
|
||||
useEffect(() => { return () => { if (historyTimerRef.current) clearTimeout(historyTimerRef.current); }; }, []);
|
||||
|
||||
const makeId = makeElementId;
|
||||
|
||||
const addElement = useCallback((element) => {
|
||||
flushPendingChanges();
|
||||
const newElement = { ...element, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||
setElements((prev) => { const newElements = [...prev, newElement]; saveToHistory(newElements); return newElements; });
|
||||
setSelectedId(newElement.id);
|
||||
const newElement = { ...element, id: makeId() };
|
||||
hlog('addElement', { type: element?.type, newId: newElement.id });
|
||||
setElements((prev) => {
|
||||
const newElements = [...prev, newElement];
|
||||
saveToHistory(newElements, 'addElement');
|
||||
return newElements;
|
||||
});
|
||||
setSelectedIds(new Set([newElement.id]));
|
||||
return newElement.id;
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
// Add many elements as a single history entry.
|
||||
// Used for template loading so an undo collapses the whole template back at once,
|
||||
// and so the elements appear atomically rather than racing each other through
|
||||
// setTimeout-based scheduling.
|
||||
//
|
||||
// ID handling: preserves the input element's `id` if present, generates a
|
||||
// fresh one if not. See replaceElements below for the rationale on why we
|
||||
// preserve rather than regenerate — the short version is that regeneration
|
||||
// breaks saveToHistory's dedupe when StrictMode invokes the path twice.
|
||||
const addElements = useCallback((items) => {
|
||||
if (!items || items.length === 0) return [];
|
||||
flushPendingChanges();
|
||||
const newOnes = items.map((el, i) => ({
|
||||
...el,
|
||||
id: el?.id || makeElementId(i),
|
||||
}));
|
||||
setElements((prev) => { const newElements = [...prev, ...newOnes]; saveToHistory(newElements); return newElements; });
|
||||
setSelectedIds(new Set());
|
||||
return newOnes.map((e) => e.id);
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
// Replace all elements in one history entry.
|
||||
//
|
||||
// Used for two flows:
|
||||
// 1. Template loading (App.jsx restore effect, the urlTemplateValid /
|
||||
// no-saved-state branch). Template constants ship elements WITHOUT
|
||||
// ids — we generate them here.
|
||||
// 2. Session restoration (App.jsx restore effect, the saved-state
|
||||
// branch). Restored elements come from localStorage with their
|
||||
// original ids intact — we preserve them.
|
||||
//
|
||||
// ID handling: preserves the input element's `id` if present, generates a
|
||||
// fresh one with `makeElementId(i)` if not.
|
||||
//
|
||||
// Why preserve rather than always regenerate
|
||||
// ───────────────────────────────────────────────────
|
||||
// Pre-May 23 2026 this function unconditionally regenerated all ids via
|
||||
// `makeElementId(i)` (which embeds Date.now()). That made the function
|
||||
// non-idempotent: two calls with the same input produced two outputs with
|
||||
// DIFFERENT ids (different millisecond timestamps).
|
||||
//
|
||||
// The visible consequence: React 18 StrictMode artificially double-mounts
|
||||
// in dev, so the session-restoration effect ran twice, both calls to
|
||||
// replaceElements pushed snapshots, and saveToHistory's dedupe couldn't
|
||||
// catch them because their JSON differed (different ids). Result: two
|
||||
// history entries for the same visual state, so undoing a crop required
|
||||
// two Cmd+Z presses in dev to walk past both restoration entries.
|
||||
//
|
||||
// Preserving input ids when present makes the function idempotent for the
|
||||
// session-restoration path — second call produces the SAME JSON, dedupe
|
||||
// catches it, single history entry. Template loading still generates
|
||||
// fresh ids because template constants don't ship with them; that path
|
||||
// doesn't trip the dedupe regression because templates are loaded once
|
||||
// per URL navigation, not by an effect that StrictMode double-runs.
|
||||
//
|
||||
// The fallback to `makeElementId(i)` rather than throwing on missing id
|
||||
// is deliberate: callers shouldn't need to know whether their inputs
|
||||
// have ids, just hand us elements and let us sort it out.
|
||||
const replaceElements = useCallback((items) => {
|
||||
flushPendingChanges();
|
||||
const next = (items || []).map((el, i) => ({
|
||||
...el,
|
||||
id: el?.id || makeElementId(i),
|
||||
}));
|
||||
hlog('replaceElements', { snapshot: summarizeElements(next) });
|
||||
setElements(next);
|
||||
saveToHistory(next, 'replaceElements');
|
||||
setSelectedIds(new Set());
|
||||
return next.map((e) => e.id);
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
const updateElement = useCallback((id, attrs) => {
|
||||
setElements((prev) => {
|
||||
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
||||
@@ -51,41 +243,350 @@ export function useDesignEditor() {
|
||||
historyTimerRef.current = setTimeout(() => { flushPendingChanges(); }, DEBOUNCE_DELAY_MS);
|
||||
return newElements;
|
||||
});
|
||||
// Logged AFTER the setElements call so the timer reset is visible
|
||||
// — a rapid burst of updates collapses into a single history push
|
||||
// when the timer finally fires, and this log helps confirm that.
|
||||
hlog('updateElement (debounced)', { id, attrs: Object.keys(attrs || {}) });
|
||||
}, [flushPendingChanges]);
|
||||
|
||||
// Immediate-commit variant of updateElement — applies the change AND
|
||||
// pushes the new state to history within the same React commit, no
|
||||
// debounce. Use this for atomic one-shot mutations where the caller
|
||||
// is certain the change is final (crop apply, photo-edit complete,
|
||||
// etc.) and needs the history snapshot to be available right away.
|
||||
//
|
||||
// Why this exists (the bug that motivated it)
|
||||
// ───────────────────────────────────────────────
|
||||
// The natural pattern `updateElement(...); commitHistory();` doesn't
|
||||
// work for synchronous immediate-commit, because updateElement uses
|
||||
// a debounced path: it queues a setElements updater that sets
|
||||
// pendingChangesRef + schedules a 300ms timer. React doesn't run
|
||||
// the updater synchronously — it runs it during the next commit
|
||||
// phase — so when commitHistory's flushPendingChanges runs in the
|
||||
// same synchronous block, pendingChangesRef is still null and the
|
||||
// flush is a no-op. The actual history push happens 300ms later
|
||||
// via the timer.
|
||||
//
|
||||
// If the user clicks Undo within that 300ms window, undo() walks
|
||||
// historyIndexRef back to the previous snapshot — which is the
|
||||
// PRE-change state. The post-change snapshot was never written, so
|
||||
// the user appears to lose more state than they undid.
|
||||
//
|
||||
// For the crop-apply case specifically, the user could legitimately
|
||||
// click Apply then immediately Undo to undo an accidental crop. With
|
||||
// the debounce-then-flush path, that produced a confusing "undo wiped
|
||||
// everything" outcome because the post-crop snapshot was never
|
||||
// captured.
|
||||
//
|
||||
// This method follows addElement's pattern (saveToHistory inside the
|
||||
// updater) so the history push happens during React's commit, before
|
||||
// any subsequent user event can fire undo().
|
||||
const updateAndCommit = useCallback((id, attrs) => {
|
||||
hlog('updateAndCommit', { id, attrs: Object.keys(attrs || {}) });
|
||||
flushPendingChanges();
|
||||
setElements((prev) => {
|
||||
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
||||
saveToHistory(newElements, 'updateAndCommit');
|
||||
return newElements;
|
||||
});
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
const deleteElement = useCallback((id) => {
|
||||
flushPendingChanges();
|
||||
setElements((prev) => { const newElements = prev.filter((el) => el.id !== id); saveToHistory(newElements); return newElements; });
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
}, [selectedId, flushPendingChanges, saveToHistory]);
|
||||
hlog('deleteElement', { id });
|
||||
setElements((prev) => {
|
||||
const newElements = prev.filter((el) => el.id !== id);
|
||||
saveToHistory(newElements, 'deleteElement');
|
||||
return newElements;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
const selectElement = useCallback((id) => setSelectedId(id), []);
|
||||
const deselectAll = useCallback(() => setSelectedId(null), []);
|
||||
// Bulk-delete (S3). Wipes every id in `ids` from the elements array as a
|
||||
// single history entry, and clears them from the selection set. No-op if
|
||||
// `ids` is empty.
|
||||
const deleteMany = useCallback((ids) => {
|
||||
if (!ids || ids.length === 0) return;
|
||||
const idSet = new Set(ids);
|
||||
flushPendingChanges();
|
||||
hlog('deleteMany', { count: ids.length });
|
||||
setElements((prev) => {
|
||||
const next = prev.filter((el) => !idSet.has(el.id));
|
||||
saveToHistory(next, 'deleteMany');
|
||||
return next;
|
||||
});
|
||||
setSelectedIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const id of ids) {
|
||||
if (next.delete(id)) changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
// Duplicate the given element. The copy lands a few pixels offset so it's
|
||||
// visually distinct from the original, and becomes the new selection so the
|
||||
// user can immediately drag/edit it.
|
||||
//
|
||||
// Both the existence check AND the new-id generation happen synchronously
|
||||
// BEFORE setElements is called. The previous version of this function set a
|
||||
// `didDuplicate` flag inside the setElements updater — but in React 18,
|
||||
// updater functions run asynchronously when the update queue is processed,
|
||||
// not at the call site. So `if (didDuplicate)` observed the stale (false)
|
||||
// value, and selection never flipped to the copy.
|
||||
const duplicateElement = useCallback((id) => {
|
||||
flushPendingChanges();
|
||||
const sourceIdx = elements.findIndex((e) => e.id === id);
|
||||
if (sourceIdx === -1) return null;
|
||||
const newId = makeId();
|
||||
hlog('duplicateElement', { sourceId: id, newId });
|
||||
setElements((prev) => {
|
||||
const idx = prev.findIndex((e) => e.id === id);
|
||||
if (idx === -1) return prev;
|
||||
const original = prev[idx];
|
||||
const copy = {
|
||||
...original,
|
||||
id: newId,
|
||||
x: (original.x || 0) + 12,
|
||||
y: (original.y || 0) + 12,
|
||||
// Fresh slot relationship: a duplicated element shouldn't claim the
|
||||
// same template slot as its source — that would double-fill the slot.
|
||||
slotId: undefined,
|
||||
};
|
||||
// Insert immediately above the source so it visually appears on top.
|
||||
const next = [...prev.slice(0, idx + 1), copy, ...prev.slice(idx + 1)];
|
||||
saveToHistory(next, 'duplicateElement');
|
||||
return next;
|
||||
});
|
||||
setSelectedIds(new Set([newId]));
|
||||
return newId;
|
||||
}, [elements, flushPendingChanges, saveToHistory]);
|
||||
|
||||
// Z-order helpers. Konva's render order matches array order: elements later
|
||||
// in the array paint on top. "Bring forward" moves the element one slot
|
||||
// higher in the array; "send backward" moves it one slot lower.
|
||||
const bringForward = useCallback((id) => {
|
||||
flushPendingChanges();
|
||||
setElements((prev) => {
|
||||
const idx = prev.findIndex((e) => e.id === id);
|
||||
if (idx === -1 || idx === prev.length - 1) return prev;
|
||||
const next = prev.slice();
|
||||
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
||||
saveToHistory(next, 'bringForward');
|
||||
return next;
|
||||
});
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
const sendBackward = useCallback((id) => {
|
||||
flushPendingChanges();
|
||||
setElements((prev) => {
|
||||
const idx = prev.findIndex((e) => e.id === id);
|
||||
if (idx <= 0) return prev;
|
||||
const next = prev.slice();
|
||||
[next[idx], next[idx - 1]] = [next[idx - 1], next[idx]];
|
||||
saveToHistory(next, 'sendBackward');
|
||||
return next;
|
||||
});
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
// Drag-reorder (S3). Moves the source element to immediately before or
|
||||
// after the target in the elements array, producing a single history
|
||||
// entry. The LayersPanel renders top-down (last-in-array on top), so
|
||||
// "before" in the panel UI means "after" in the array, and vice-versa
|
||||
// — the caller is responsible for that translation. We just take the
|
||||
// semantic position and apply it directly.
|
||||
const reorderElement = useCallback((sourceId, targetId, position = 'after') => {
|
||||
if (sourceId === targetId) return;
|
||||
flushPendingChanges();
|
||||
setElements((prev) => {
|
||||
const sourceIdx = prev.findIndex((e) => e.id === sourceId);
|
||||
const targetIdx = prev.findIndex((e) => e.id === targetId);
|
||||
if (sourceIdx === -1 || targetIdx === -1) return prev;
|
||||
const next = prev.slice();
|
||||
const [source] = next.splice(sourceIdx, 1);
|
||||
// After the splice, the target's index may have shifted by one if
|
||||
// it was after the source's original position. Re-find by id.
|
||||
const newTargetIdx = next.findIndex((e) => e.id === targetId);
|
||||
if (newTargetIdx === -1) return prev;
|
||||
const insertAt = position === 'before' ? newTargetIdx : newTargetIdx + 1;
|
||||
next.splice(insertAt, 0, source);
|
||||
saveToHistory(next, 'reorderElement');
|
||||
return next;
|
||||
});
|
||||
}, [flushPendingChanges, saveToHistory]);
|
||||
|
||||
// Selection setters. `selectElement` and `deselectAll` keep the same
|
||||
// single-select semantics as before. `toggleInSelection` is the
|
||||
// multi-select primitive: toggles the id's membership in the set.
|
||||
// `setSelection` replaces the entire set with a fresh one built from
|
||||
// the provided id array — used by canvas marquee drag and the Cmd+A
|
||||
// "select all" keyboard shortcut to commit a many-id selection in a
|
||||
// single state update (vs. clearing + toggling N times, which would
|
||||
// produce N+1 renders and could flicker the Transformer's bound nodes
|
||||
// mid-update).
|
||||
const selectElement = useCallback((id) => {
|
||||
setSelectedIds(id ? new Set([id]) : new Set());
|
||||
}, []);
|
||||
const deselectAll = useCallback(() => setSelectedIds(new Set()), []);
|
||||
const toggleInSelection = useCallback((id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const setSelection = useCallback((ids) => {
|
||||
setSelectedIds(new Set(ids || []));
|
||||
}, []);
|
||||
const commitHistory = useCallback(() => flushPendingChanges(), [flushPendingChanges]);
|
||||
|
||||
// Undo / redo restore the elements snapshot at the new history index AND
|
||||
// try to preserve selection. The previous version unconditionally cleared
|
||||
// selection, which felt punishing — undo a property tweak and you'd lose
|
||||
// your selection too, so refining further required reselecting first.
|
||||
//
|
||||
// We preserve any ids in the current selection that still exist in the
|
||||
// restored snapshot. Selections of already-deleted elements naturally
|
||||
// drop out.
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndexRef.current > 0) {
|
||||
const beforeIdx = historyIndexRef.current;
|
||||
historyIndexRef.current--;
|
||||
setElements(JSON.parse(historyRef.current[historyIndexRef.current]));
|
||||
setSelectedId(null);
|
||||
const next = JSON.parse(historyRef.current[historyIndexRef.current]);
|
||||
hlog('undo', {
|
||||
idx: `${beforeIdx} → ${historyIndexRef.current}`,
|
||||
len: historyRef.current.length,
|
||||
restored: summarizeElements(next),
|
||||
});
|
||||
setElements(next);
|
||||
setSelectedIds((prev) => {
|
||||
const liveIds = new Set(next.map((e) => e.id));
|
||||
const filtered = new Set();
|
||||
for (const id of prev) if (liveIds.has(id)) filtered.add(id);
|
||||
return filtered.size === prev.size ? prev : filtered;
|
||||
});
|
||||
syncUndoRedo();
|
||||
} else {
|
||||
hlog('undo NO-OP', {
|
||||
idx: historyIndexRef.current,
|
||||
len: historyRef.current.length,
|
||||
reason: 'already at history floor',
|
||||
});
|
||||
}
|
||||
}, [syncUndoRedo]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||
const beforeIdx = historyIndexRef.current;
|
||||
historyIndexRef.current++;
|
||||
setElements(JSON.parse(historyRef.current[historyIndexRef.current]));
|
||||
setSelectedId(null);
|
||||
const next = JSON.parse(historyRef.current[historyIndexRef.current]);
|
||||
hlog('redo', {
|
||||
idx: `${beforeIdx} → ${historyIndexRef.current}`,
|
||||
len: historyRef.current.length,
|
||||
restored: summarizeElements(next),
|
||||
});
|
||||
setElements(next);
|
||||
setSelectedIds((prev) => {
|
||||
const liveIds = new Set(next.map((e) => e.id));
|
||||
const filtered = new Set();
|
||||
for (const id of prev) if (liveIds.has(id)) filtered.add(id);
|
||||
return filtered.size === prev.size ? prev : filtered;
|
||||
});
|
||||
syncUndoRedo();
|
||||
} else {
|
||||
hlog('redo NO-OP', {
|
||||
idx: historyIndexRef.current,
|
||||
len: historyRef.current.length,
|
||||
reason: 'no redo entries available',
|
||||
});
|
||||
}
|
||||
}, [syncUndoRedo]);
|
||||
|
||||
// Seed an initial empty-canvas entry as the history floor IF AND ONLY
|
||||
// IF history is empty. When session restoration has already pushed
|
||||
// snapshots via replaceElements (URL template, saved localStorage state,
|
||||
// etc.), those entries ARE the correct floor and must not be touched.
|
||||
//
|
||||
// History of this function
|
||||
// ────────────────────────────────────────────────────────────────────────────────────
|
||||
// Previously this function UNCONDITIONALLY set history to
|
||||
// `[JSON.stringify([])]`, regardless of what was already there. That was
|
||||
// the root cause of the "crop + Cmd+Z = blank canvas" bug for any user
|
||||
// with a restored session:
|
||||
//
|
||||
// 1. App.jsx's persistence effect calls replaceElements(saved.elements)
|
||||
// — saveToHistory pushes the restored 5-element snapshot at idx 0.
|
||||
// 2. React 18 StrictMode artificially unmounts and remounts the
|
||||
// component in development. The persistence effect re-runs.
|
||||
// replaceElements regenerates element IDs (makeElementId uses fresh
|
||||
// timestamps), so the new snapshot's JSON differs from the first
|
||||
// one and saveToHistory's dedupe doesn't catch it. Second push at
|
||||
// idx 1.
|
||||
// 3. App.jsx's setRestorationComplete(true) effect fires once, after
|
||||
// both mounts have settled. initializeHistory ran here and WIPED
|
||||
// both snapshots, leaving history = [[]] at idx 0 — even though
|
||||
// `elements` was still showing the 5 restored items.
|
||||
// 4. User crops a photo. updateAndCommit's saveToHistory pushes the
|
||||
// cropped state at idx 1. History = [[], [cropped]], idx = 1.
|
||||
// 5. User presses Cmd+Z. undo walks idx 1 → 0, sets elements = [],
|
||||
// blanking the canvas.
|
||||
//
|
||||
// The fix is the guard below: skip the seed when history already has
|
||||
// entries. That keeps the restored snapshot(s) intact as the undo floor.
|
||||
//
|
||||
// The fresh-session case (no URL template, no saved state) still works:
|
||||
// history is empty when this fires, so we push the [] floor. The first
|
||||
// user action then sits at idx 1, and Cmd+Z takes the user back to the
|
||||
// empty starting canvas — the expected behavior.
|
||||
//
|
||||
// The StrictMode duplicate snapshot (idx 0 = old-ID set, idx 1 = new-ID
|
||||
// set in the example above) is a separate pre-existing oddity: in dev,
|
||||
// the user's history will contain TWO entries representing the same
|
||||
// visual state with different element IDs. Their first Cmd+Z lands on
|
||||
// the still-uncropped state (visually correct — different IDs but the
|
||||
// same picture), a second Cmd+Z lands on the OTHER uncropped state
|
||||
// (visually identical), and a third is a no-op at the floor. Annoying
|
||||
// in dev but harmless; in production builds StrictMode's double-mount
|
||||
// doesn't run, so there's only one restored snapshot and one Cmd+Z to
|
||||
// undo the crop. Fixing the dev-only duplicate would require making
|
||||
// replaceElements idempotent (deterministic IDs across calls) and is
|
||||
// out of scope for this fix.
|
||||
const initializeHistory = useCallback(() => {
|
||||
historyRef.current = [JSON.stringify([])];
|
||||
historyIndexRef.current = 0;
|
||||
const beforeIdx = historyIndexRef.current;
|
||||
const beforeLen = historyRef.current.length;
|
||||
if (historyRef.current.length === 0) {
|
||||
historyRef.current = [JSON.stringify([])];
|
||||
historyIndexRef.current = 0;
|
||||
hlog('initializeHistory SEED', {
|
||||
reason: 'history was empty; seeding [] as the undo floor',
|
||||
newState: 'history = [[]] (single empty entry), idx = 0',
|
||||
});
|
||||
} else {
|
||||
hlog('initializeHistory NO-OP', {
|
||||
reason: 'history already has entries; restored snapshots preserved',
|
||||
idx: beforeIdx,
|
||||
len: beforeLen,
|
||||
currentEntry: beforeIdx >= 0
|
||||
? summarizeElements(JSON.parse(historyRef.current[beforeIdx]))
|
||||
: '<none>',
|
||||
});
|
||||
}
|
||||
syncUndoRedo();
|
||||
}, [syncUndoRedo]);
|
||||
|
||||
return { elements, selectedId, addElement, updateElement, deleteElement, selectElement, deselectAll, commitHistory, undo, redo, canUndo, canRedo, initializeHistory };
|
||||
return {
|
||||
elements, selectedId, selectedIds,
|
||||
addElement, addElements, replaceElements,
|
||||
updateElement, updateAndCommit, deleteElement, deleteMany,
|
||||
duplicateElement, bringForward, sendBackward, reorderElement,
|
||||
selectElement, deselectAll, toggleInSelection, setSelection, commitHistory,
|
||||
undo, redo, canUndo, canRedo, initializeHistory,
|
||||
};
|
||||
}
|
||||
|
||||
837
src/hooks/useDesignEditor.test.js
Normal file
837
src/hooks/useDesignEditor.test.js
Normal file
@@ -0,0 +1,837 @@
|
||||
// Test suite for useDesignEditor's history subsystem.
|
||||
//
|
||||
// What this covers
|
||||
// ────────────────
|
||||
// The history system is the most bug-bitten area of the codebase. This
|
||||
// file exists primarily as a regression net for those bugs:
|
||||
//
|
||||
// 1. The May 22 "crop + Cmd+Z = blank canvas" bug. Root cause was
|
||||
// `initializeHistory` unconditionally wiping history to [empty],
|
||||
// destroying snapshots that `replaceElements` had already pushed
|
||||
// during session restoration. See the dedicated test block below
|
||||
// titled "initializeHistory + replaceElements regression"; the
|
||||
// named test there is the smoking-gun reproduction.
|
||||
//
|
||||
// 2. The debounce-then-undo race that motivated `updateAndCommit`'s
|
||||
// existence. The `updateElement` docblock describes it: a
|
||||
// debounced setElements queues a 300 ms timer; if the user clicks
|
||||
// undo within that window, the post-update snapshot was never
|
||||
// written and undo walks back further than intended. The
|
||||
// "updateAndCommit pushes synchronously" test below is the
|
||||
// regression test for the fix.
|
||||
//
|
||||
// 3. The `didDuplicate`-stale-flag bug in `duplicateElement` (per
|
||||
// its docblock). The "duplicateElement selects the copy" test
|
||||
// pins the fix.
|
||||
//
|
||||
// 4. The `reorderElement` splice-then-re-find: target index shifts
|
||||
// after the source splice. The reorder tests cover this.
|
||||
//
|
||||
// What this doesn't cover
|
||||
// ───────────────────────
|
||||
// Anything that requires Konva or the actual canvas to be mounted —
|
||||
// transformBoundFunc behavior, snap math, the visible-ink text bbox
|
||||
// integration with Transformer handles. Those need a different test
|
||||
// setup (Playwright or a Konva mock) and are out of scope here. This
|
||||
// file is purely about the pure-state portion of useDesignEditor.
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDesignEditor } from './useDesignEditor';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Test helpers.
|
||||
//
|
||||
// `setupEditor()` wraps renderHook + an element factory so each test
|
||||
// can read like a flat sequence of editor operations rather than
|
||||
// boilerplate around the act() invocations. Returns the same object
|
||||
// renderHook does (so callers can access `result.current.elements`
|
||||
// directly), plus the helpers we use most often.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupEditor() {
|
||||
const view = renderHook(() => useDesignEditor());
|
||||
return {
|
||||
...view,
|
||||
get current() {
|
||||
return view.result.current;
|
||||
},
|
||||
/**
|
||||
* Wrap any synchronous editor mutation in act() and return the
|
||||
* result. Keeps individual assertions noise-free.
|
||||
*/
|
||||
do(fn) {
|
||||
let returnValue;
|
||||
act(() => {
|
||||
returnValue = fn(view.result.current);
|
||||
});
|
||||
return returnValue;
|
||||
},
|
||||
/**
|
||||
* Advance fake timers inside an act() boundary.
|
||||
*
|
||||
* Why this exists: when the debounced timer fires, its callback
|
||||
* calls saveToHistory which in turn calls syncUndoRedo which
|
||||
* setCanUndo/setCanRedo — a React state update. If we advance
|
||||
* timers OUTSIDE act(), the state update happens outside React's
|
||||
* batched-update window and triggers the "not wrapped in act"
|
||||
* warning. Wrapping the advance in act() makes the timer's
|
||||
* downstream setState calls visible to React's scheduler.
|
||||
*
|
||||
* The alternative — wrapping every advanceTimersByTime call site
|
||||
* in act() inline — works but adds visual noise to each test
|
||||
* body. Centralising on this helper keeps the timer tests
|
||||
* scannable.
|
||||
*/
|
||||
advanceTimers(ms) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal element factory. We don't care about realistic image dims
|
||||
// for these tests — we only need objects that flow through the
|
||||
// elements pipeline and end up in JSON snapshots. Each invocation
|
||||
// produces a fresh object so callers don't accidentally share
|
||||
// references across snapshots (which would invalidate the dedupe
|
||||
// invariant we're checking).
|
||||
function makeImage(overrides = {}) {
|
||||
return {
|
||||
type: 'image',
|
||||
src: 'data:image/png;base64,_test_',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeText(overrides = {}) {
|
||||
return {
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
fontSize: 32,
|
||||
fontFamily: 'Pacifico',
|
||||
x: 0,
|
||||
y: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Initial state.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — initial state', () => {
|
||||
it('starts with an empty elements array', () => {
|
||||
const editor = setupEditor();
|
||||
expect(editor.current.elements).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts with no selection', () => {
|
||||
const editor = setupEditor();
|
||||
expect(editor.current.selectedId).toBeNull();
|
||||
expect(editor.current.selectedIds.size).toBe(0);
|
||||
});
|
||||
|
||||
it('starts with canUndo and canRedo both false', () => {
|
||||
const editor = setupEditor();
|
||||
// The history is empty; undo at idx -1 has nowhere to go. canRedo
|
||||
// is bounded by history length so it's also false.
|
||||
expect(editor.current.canUndo).toBe(false);
|
||||
expect(editor.current.canRedo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// addElement: the simplest history-push path.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — addElement', () => {
|
||||
it('pushes the element into state', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
expect(editor.current.elements[0].type).toBe('image');
|
||||
});
|
||||
|
||||
it('assigns a unique id (overrides any provided id)', () => {
|
||||
// `addElement` always calls makeId() and overwrites any incoming
|
||||
// id. This is by design — callers don't get to dictate ids — but
|
||||
// worth pinning so a future refactor that wanted to honour
|
||||
// incoming ids would have to explicitly opt out of this test.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement({ ...makeImage(), id: 'caller-supplied' }));
|
||||
expect(editor.current.elements[0].id).not.toBe('caller-supplied');
|
||||
expect(editor.current.elements[0].id).toMatch(/^element-/);
|
||||
});
|
||||
|
||||
it('selects the newly added element', () => {
|
||||
const editor = setupEditor();
|
||||
const newId = editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.selectedId).toBe(newId);
|
||||
});
|
||||
|
||||
it('after one add, canUndo is false (we are at the history floor)', () => {
|
||||
// The first push lands at history idx 0. canUndo = (idx > 0)
|
||||
// evaluates false. This protects against users accidentally
|
||||
// undoing past their first action into an empty-canvas state
|
||||
// they never explicitly created.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('after two adds, canUndo is true', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'data:image/png;base64,_two_' })));
|
||||
expect(editor.current.canUndo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// undo / redo: the read side of history.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — undo / redo', () => {
|
||||
it('undo restores the previous snapshot', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
editor.do((e) => e.addElement(makeText()));
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
expect(editor.current.elements[0].type).toBe('image');
|
||||
});
|
||||
|
||||
it('undo is a no-op at the history floor', () => {
|
||||
// After one push, idx is 0 — the floor. Pressing undo should
|
||||
// leave state unchanged rather than walk into idx -1 territory
|
||||
// (which previously could have restored an undefined snapshot).
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
const before = editor.current.elements;
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toBe(before);
|
||||
expect(editor.current.canUndo).toBe(false);
|
||||
});
|
||||
|
||||
it('redo walks back up after undo', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
editor.do((e) => e.addElement(makeText()));
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
expect(editor.current.canRedo).toBe(true);
|
||||
editor.do((e) => e.redo());
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
expect(editor.current.canRedo).toBe(false);
|
||||
});
|
||||
|
||||
it('redo is a no-op when there are no redo entries', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.canRedo).toBe(false);
|
||||
editor.do((e) => e.redo());
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a new action after undo discards redo entries (redo-stack truncation)', () => {
|
||||
// Standard editor behavior: if you undo and then make a new edit,
|
||||
// the redo stack is gone. The new action becomes the only
|
||||
// future from the current idx.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage())); // idx 0
|
||||
editor.do((e) => e.addElement(makeText())); // idx 1
|
||||
editor.do((e) => e.undo()); // idx 0, redo to text available
|
||||
expect(editor.current.canRedo).toBe(true);
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'data:image/png;base64,_3_' }))); // idx 1, new branch
|
||||
expect(editor.current.canRedo).toBe(false);
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
// The redo target (the text element) is gone — undo from the new
|
||||
// branch's tip lands on the single-image state, not the text state.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
expect(editor.current.elements[0].type).toBe('image');
|
||||
});
|
||||
|
||||
it('undo preserves selection when the restored snapshot still contains the selected id', () => {
|
||||
const editor = setupEditor();
|
||||
const firstId = editor.do((e) => e.addElement(makeImage()));
|
||||
editor.do((e) => e.addElement(makeText()));
|
||||
editor.do((e) => e.selectElement(firstId));
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.selectedId).toBe(firstId);
|
||||
});
|
||||
|
||||
it('undo drops selection when the restored snapshot no longer contains the selected id', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
const secondId = editor.do((e) => e.addElement(makeText()));
|
||||
expect(editor.current.selectedId).toBe(secondId);
|
||||
editor.do((e) => e.undo());
|
||||
// The second element doesn't exist in the restored snapshot,
|
||||
// so it gets filtered out of the selection set.
|
||||
expect(editor.current.selectedIds.has(secondId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// initializeHistory — the May 22 regression.
|
||||
//
|
||||
// This is the headline reason the test file exists.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — initializeHistory', () => {
|
||||
it('seeds an empty-array entry when history is empty (fresh-session path)', () => {
|
||||
// Fresh session: no replaceElements, no template. initializeHistory
|
||||
// creates the history floor so the user's first action becomes
|
||||
// undoable back to the empty starting canvas.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.initializeHistory());
|
||||
// After init, history = [[]] at idx 0. Adding an element pushes
|
||||
// to idx 1; undo walks back to idx 0 (empty); canUndo flips off.
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.canUndo).toBe(true);
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toEqual([]);
|
||||
});
|
||||
|
||||
it('does NOT wipe history when entries already exist (regression for crop+undo blank-canvas bug)', () => {
|
||||
// This is THE regression test. Pre-fix, `initializeHistory`
|
||||
// unconditionally set history to [JSON.stringify([])], which
|
||||
// destroyed the snapshot that `replaceElements` had pushed
|
||||
// during session restoration. Sequence reproducing the bug:
|
||||
//
|
||||
// 1. replaceElements pushes the restored 5-element snapshot.
|
||||
// 2. initializeHistory wipes history to [[]] at idx 0.
|
||||
// 3. User crops → updateAndCommit pushes the cropped state
|
||||
// at idx 1. History = [[], [cropped]].
|
||||
// 4. User presses Cmd+Z → undo walks 1 → 0 → restores [],
|
||||
// blanking the canvas.
|
||||
//
|
||||
// Post-fix, step 2 is a no-op when history has entries. The
|
||||
// assertion below is on step 4's outcome: undo must restore the
|
||||
// PRE-CROP state, not the empty array.
|
||||
const editor = setupEditor();
|
||||
|
||||
// Step 1: session restoration via replaceElements.
|
||||
editor.do((e) =>
|
||||
e.replaceElements([
|
||||
makeImage({ src: 'data:image/png;base64,_a_' }),
|
||||
makeImage({ src: 'data:image/png;base64,_b_' }),
|
||||
])
|
||||
);
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
|
||||
// Step 2: initializeHistory runs (App.jsx's restorationComplete
|
||||
// effect). Pre-fix this would wipe; post-fix it's a no-op.
|
||||
editor.do((e) => e.initializeHistory());
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
|
||||
// Step 3: user performs an action. We use updateAndCommit because
|
||||
// that's the path the crop button takes, but any committing path
|
||||
// would expose the same bug. Capture the post-restore ids so we
|
||||
// can target one with the update.
|
||||
const firstId = editor.current.elements[0].id;
|
||||
editor.do((e) =>
|
||||
e.updateAndCommit(firstId, {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 80,
|
||||
height: 80,
|
||||
crop: { sx: 10, sy: 10, sWidth: 50, sHeight: 50 },
|
||||
})
|
||||
);
|
||||
expect(editor.current.elements.find((el) => el.id === firstId).crop).toBeDefined();
|
||||
|
||||
// Step 4: undo. The crucial assertion.
|
||||
//
|
||||
// Pre-fix: undo walks idx 1 → 0, restores [], elements.length === 0.
|
||||
// Post-fix: undo walks idx 1 → 0, restores the replaceElements
|
||||
// snapshot, elements.length === 2 with no crop on the first.
|
||||
expect(editor.current.canUndo).toBe(true);
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
expect(editor.current.elements.find((el) => el.id === firstId)?.crop).toBeUndefined();
|
||||
});
|
||||
|
||||
it('multiple initializeHistory calls after the first push are no-ops', () => {
|
||||
// Defends against a future refactor that might call
|
||||
// initializeHistory more than once — for instance if React 18
|
||||
// StrictMode causes the App-level restorationComplete effect to
|
||||
// fire twice. Each subsequent call must remain a no-op so the
|
||||
// floor stays correct.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.replaceElements([makeImage()]));
|
||||
editor.do((e) => e.initializeHistory());
|
||||
editor.do((e) => e.initializeHistory());
|
||||
editor.do((e) => e.initializeHistory());
|
||||
editor.do((e) => e.addElement(makeText()));
|
||||
expect(editor.current.elements).toHaveLength(2);
|
||||
editor.do((e) => e.undo());
|
||||
// Restored snapshot is the replaceElements one, not empty.
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// updateAndCommit — the synchronous-push escape hatch.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — replaceElements id preservation', () => {
|
||||
it('preserves input ids when present (regression: StrictMode dual-restoration)', () => {
|
||||
// The session-restoration case. Elements come back from localStorage
|
||||
// with their original ids; replaceElements must preserve them so a
|
||||
// second call with the same input produces the same JSON and gets
|
||||
// deduped by saveToHistory.
|
||||
const editor = setupEditor();
|
||||
const items = [
|
||||
{ ...makeImage(), id: 'persisted-1' },
|
||||
{ ...makeText(), id: 'persisted-2' },
|
||||
];
|
||||
editor.do((e) => e.replaceElements(items));
|
||||
expect(editor.current.elements[0].id).toBe('persisted-1');
|
||||
expect(editor.current.elements[1].id).toBe('persisted-2');
|
||||
});
|
||||
|
||||
it('generates ids for elements that do not have one (template-loading path)', () => {
|
||||
// Template constants ship elements without ids — replaceElements
|
||||
// generates them. This test verifies the generation path still works
|
||||
// after the id-preservation refactor.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.replaceElements([makeImage(), makeText()]));
|
||||
expect(editor.current.elements[0].id).toBeTruthy();
|
||||
expect(editor.current.elements[1].id).toBeTruthy();
|
||||
expect(editor.current.elements[0].id).toMatch(/^element-/);
|
||||
expect(editor.current.elements[1].id).toMatch(/^element-/);
|
||||
expect(editor.current.elements[0].id).not.toBe(editor.current.elements[1].id);
|
||||
});
|
||||
|
||||
it('mixed input (some with ids, some without) preserves and generates as needed', () => {
|
||||
// Defensive: a future caller might pass a partially-ided array.
|
||||
// Per-element decision means elements with ids keep them, elements
|
||||
// without get fresh generation.
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.replaceElements([
|
||||
{ ...makeImage(), id: 'keeper' },
|
||||
makeText(), // no id
|
||||
{ ...makeImage(), id: 'also-keeper' },
|
||||
]));
|
||||
expect(editor.current.elements[0].id).toBe('keeper');
|
||||
expect(editor.current.elements[1].id).toMatch(/^element-/);
|
||||
expect(editor.current.elements[2].id).toBe('also-keeper');
|
||||
});
|
||||
|
||||
it('second call with the same id-bearing input dedupes in history (the StrictMode case)', () => {
|
||||
// The headline regression test. Before the fix:
|
||||
// call 1 → replaceElements assigns fresh ids → pushes snapshot A at idx 0
|
||||
// call 2 → replaceElements assigns DIFFERENT fresh ids → pushes
|
||||
// snapshot B at idx 1 (dedupe misses because JSON differs)
|
||||
// undo → walks idx 1 → 0, restores snapshot A (different ids than
|
||||
// what's currently on canvas). Visually equivalent but
|
||||
// produces two undos for one logical action.
|
||||
//
|
||||
// After the fix:
|
||||
// call 1 → preserves input ids → pushes at idx 0
|
||||
// call 2 → preserves the SAME input ids → saveToHistory dedupes →
|
||||
// no idx change
|
||||
// undo → no-op at floor; canUndo stays false
|
||||
const editor = setupEditor();
|
||||
const items = [
|
||||
{ ...makeImage(), id: 'restored-1' },
|
||||
{ ...makeImage({ src: 'data:image/png;base64,_b_' }), id: 'restored-2' },
|
||||
];
|
||||
editor.do((e) => e.replaceElements(items));
|
||||
editor.do((e) => e.replaceElements(items));
|
||||
// After two identical calls, canUndo must still be false — only one
|
||||
// entry exists, dedupe caught the second. (canUndo = idx > 0; idx
|
||||
// stays at 0.)
|
||||
expect(editor.current.canUndo).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDesignEditor — updateAndCommit', () => {
|
||||
it('pushes the new state synchronously (no debounce)', () => {
|
||||
// The whole point of updateAndCommit existing separately from
|
||||
// updateElement is that its history push happens during React's
|
||||
// commit, before any subsequent user event. This test verifies
|
||||
// that contract: an undo immediately after updateAndCommit
|
||||
// restores the PRE-update state. Pre-fix, an immediate undo
|
||||
// would walk past the never-written post-update snapshot and
|
||||
// restore the snapshot before THAT.
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
|
||||
editor.do((e) => e.updateAndCommit(id, { x: 50 }));
|
||||
expect(editor.current.elements[0].x).toBe(50);
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements[0].x).toBe(0);
|
||||
});
|
||||
|
||||
it('flushes pending debounced changes before its own push', () => {
|
||||
// updateElement queues a 300 ms timer. If updateAndCommit fires
|
||||
// before the timer, the pending change would be lost without an
|
||||
// explicit flush. The fix is the leading flushPendingChanges()
|
||||
// inside updateAndCommit. After updateAndCommit completes,
|
||||
// both the debounced update AND the commit should be undoable
|
||||
// as separate history entries.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
|
||||
// Debounced update — timer scheduled but NOT yet fired.
|
||||
editor.do((e) => e.updateElement(id, { x: 25 }));
|
||||
expect(editor.current.elements[0].x).toBe(25);
|
||||
// Immediate commit — should flush the pending x=25 to history,
|
||||
// then push x=50 on top.
|
||||
editor.do((e) => e.updateAndCommit(id, { x: 50 }));
|
||||
expect(editor.current.elements[0].x).toBe(50);
|
||||
// Two undos should land at x=25, then x=0 — proving both
|
||||
// snapshots made it to history.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements[0].x).toBe(25);
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements[0].x).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// updateElement — the debounced path.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — updateElement (debounced)', () => {
|
||||
it('updates state immediately but defers the history push', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
|
||||
editor.do((e) => e.updateElement(id, { x: 10 }));
|
||||
// State is updated synchronously…
|
||||
expect(editor.current.elements[0].x).toBe(10);
|
||||
// …but the history push hasn't happened. Undoing right now
|
||||
// would restore the pre-update state (the addElement snapshot).
|
||||
// We confirm by NOT advancing the timer and checking that the
|
||||
// state is exposed but the snapshot isn't committed yet.
|
||||
// (canUndo is true because the addElement snapshot is still
|
||||
// undoable; we can't directly inspect history length, but we
|
||||
// can verify the commitHistory behavior by advancing time.)
|
||||
editor.advanceTimers(299);
|
||||
expect(editor.current.elements[0].x).toBe(10);
|
||||
editor.advanceTimers(2); // total 301 ms, timer fired
|
||||
expect(editor.current.elements[0].x).toBe(10);
|
||||
// After flush, undoing should restore the addElement snapshot.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements[0].x).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('rapid updates collapse into a single history entry', () => {
|
||||
// A drag handler that fires updateElement on every mousemove
|
||||
// shouldn't fill history with 60 entries per second. The
|
||||
// debounce timer collapses a burst of updates into one push.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
editor.do((e) => e.updateElement(id, { x: i * 5 }));
|
||||
}
|
||||
expect(editor.current.elements[0].x).toBe(50);
|
||||
editor.advanceTimers(300);
|
||||
// One history entry from the burst. Undo lands at x=0.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements[0].x).toBe(0);
|
||||
// Redo restores the final x=50 (not an intermediate value).
|
||||
editor.do((e) => e.redo());
|
||||
expect(editor.current.elements[0].x).toBe(50);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('commitHistory drains pending changes synchronously', () => {
|
||||
// commitHistory is the user-facing flush. Callers use it after
|
||||
// a continuous interaction (transform-end, blur on text input)
|
||||
// to commit the latest state to history without waiting for
|
||||
// the 300 ms timer.
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage({ x: 0 })));
|
||||
editor.do((e) => e.updateElement(id, { x: 42 }));
|
||||
editor.do((e) => e.commitHistory());
|
||||
// Without fake timers, the debounced timer hasn't fired —
|
||||
// commitHistory should be what got the snapshot in.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements[0].x).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// deleteElement — selection cleanup.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — deleteElement', () => {
|
||||
it('removes the element from state and history', () => {
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage()));
|
||||
editor.do((e) => e.deleteElement(id));
|
||||
expect(editor.current.elements).toHaveLength(0);
|
||||
// Undo restores it.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clears the deleted id from selection', () => {
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.selectedId).toBe(id);
|
||||
editor.do((e) => e.deleteElement(id));
|
||||
expect(editor.current.selectedId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// duplicateElement — regression for the didDuplicate stale-flag bug.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — duplicateElement', () => {
|
||||
it('inserts the copy immediately after the source in the array', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
editor.do((e) => e.duplicateElement(a));
|
||||
// Order: a, a-copy, b. The copy lands at index 1.
|
||||
const srcs = editor.current.elements.map((el) => el.src);
|
||||
expect(srcs[0]).toBe('a');
|
||||
expect(srcs[1]).toBe('a');
|
||||
expect(srcs[2]).toBe('b');
|
||||
expect(editor.current.elements[2].id).toBe(b);
|
||||
});
|
||||
|
||||
it('selects the copy synchronously (regression for didDuplicate stale-flag bug)', () => {
|
||||
// The pre-fix bug: selection assignment lived inside an async
|
||||
// setElements updater, so `didDuplicate` was read before the
|
||||
// updater ran and selection stayed on the source. The fix
|
||||
// moved the id generation and selection assignment outside the
|
||||
// updater. This test pins that behavior.
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage()));
|
||||
const newId = editor.do((e) => e.duplicateElement(a));
|
||||
expect(newId).toBeTruthy();
|
||||
expect(newId).not.toBe(a);
|
||||
expect(editor.current.selectedId).toBe(newId);
|
||||
});
|
||||
|
||||
it('returns null and is a no-op when the source id does not exist', () => {
|
||||
const editor = setupEditor();
|
||||
const ret = editor.do((e) => e.duplicateElement('nonexistent-id'));
|
||||
expect(ret).toBeNull();
|
||||
expect(editor.current.elements).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears the source-element slotId on the copy', () => {
|
||||
// Documented behavior: a duplicated element shouldn't claim the
|
||||
// same template slot as its source. Without this, a slot would
|
||||
// appear filled twice (once by the original, once by the copy).
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ slotId: 'slot-1' })));
|
||||
editor.do((e) => e.duplicateElement(a));
|
||||
const copy = editor.current.elements[1];
|
||||
expect(copy.slotId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// reorderElement — splice-then-re-find target.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — reorderElement', () => {
|
||||
it('moves source AFTER target', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
|
||||
// Move a (currently first) to AFTER c (currently last). Result: b, c, a.
|
||||
editor.do((e) => e.reorderElement(a, c, 'after'));
|
||||
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
|
||||
it('moves source BEFORE target', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
|
||||
// Move c to BEFORE b. Result: a, c, b.
|
||||
editor.do((e) => e.reorderElement(c, b, 'before'));
|
||||
expect(editor.current.elements.map((el) => el.src)).toEqual(['a', 'c', 'b']);
|
||||
});
|
||||
|
||||
it('correctly accounts for target-index shift after source splice', () => {
|
||||
// Documented bug source: when source is BEFORE target in the
|
||||
// array, splicing source out reduces target's index by one.
|
||||
// The reorder logic re-finds target by id after the splice
|
||||
// instead of using the cached index. This test covers the
|
||||
// case where the cached-index approach would have been wrong:
|
||||
// start: [a, b, c, d]
|
||||
// reorder a after d
|
||||
// if we used target's original index (3) the splice would
|
||||
// place a at index 4, beyond the array length
|
||||
// correct behavior: re-find d at index 2 (after a's removal),
|
||||
// insert a at index 3
|
||||
// result: [b, c, d, a]
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'c' })));
|
||||
const d = editor.do((e) => e.addElement(makeImage({ src: 'd' })));
|
||||
editor.do((e) => e.reorderElement(a, d, 'after'));
|
||||
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'c', 'd', 'a']);
|
||||
});
|
||||
|
||||
it('is a no-op when source === target', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const before = editor.current.elements;
|
||||
editor.do((e) => e.reorderElement(a, a, 'after'));
|
||||
expect(editor.current.elements).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Z-order: bringForward / sendBackward.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — z-order', () => {
|
||||
it('bringForward swaps with the next element', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
editor.do((e) => e.bringForward(a));
|
||||
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('bringForward is a no-op for the last element', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
const before = editor.current.elements;
|
||||
editor.do((e) => e.bringForward(b));
|
||||
expect(editor.current.elements).toBe(before);
|
||||
});
|
||||
|
||||
it('sendBackward swaps with the previous element', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
editor.do((e) => e.sendBackward(b));
|
||||
expect(editor.current.elements.map((el) => el.src)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('sendBackward is a no-op for the first element', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
const before = editor.current.elements;
|
||||
editor.do((e) => e.sendBackward(a));
|
||||
expect(editor.current.elements).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Selection setters.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — selection', () => {
|
||||
it('selectElement sets a single-element selection', () => {
|
||||
const editor = setupEditor();
|
||||
const id = editor.do((e) => e.addElement(makeImage()));
|
||||
editor.do((e) => e.addElement(makeText()));
|
||||
editor.do((e) => e.selectElement(id));
|
||||
expect(editor.current.selectedId).toBe(id);
|
||||
expect(editor.current.selectedIds.size).toBe(1);
|
||||
});
|
||||
|
||||
it('deselectAll clears the selection', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
expect(editor.current.selectedIds.size).toBe(1);
|
||||
editor.do((e) => e.deselectAll());
|
||||
expect(editor.current.selectedIds.size).toBe(0);
|
||||
expect(editor.current.selectedId).toBeNull();
|
||||
});
|
||||
|
||||
it('toggleInSelection adds and removes ids', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
// After two adds, b is selected (addElement selects the new id).
|
||||
expect(editor.current.selectedIds.has(b)).toBe(true);
|
||||
editor.do((e) => e.toggleInSelection(a));
|
||||
expect(editor.current.selectedIds.has(a)).toBe(true);
|
||||
expect(editor.current.selectedIds.has(b)).toBe(true);
|
||||
expect(editor.current.selectedIds.size).toBe(2);
|
||||
// selectedId is null when more than one element is selected.
|
||||
expect(editor.current.selectedId).toBeNull();
|
||||
editor.do((e) => e.toggleInSelection(a));
|
||||
expect(editor.current.selectedIds.has(a)).toBe(false);
|
||||
expect(editor.current.selectedIds.size).toBe(1);
|
||||
});
|
||||
|
||||
it('setSelection replaces the selection set with the given ids', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
|
||||
editor.do((e) => e.setSelection([a, c]));
|
||||
expect(editor.current.selectedIds.size).toBe(2);
|
||||
expect(editor.current.selectedIds.has(a)).toBe(true);
|
||||
expect(editor.current.selectedIds.has(c)).toBe(true);
|
||||
expect(editor.current.selectedIds.has(b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// deleteMany — bulk delete + selection cleanup.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useDesignEditor — deleteMany', () => {
|
||||
it('removes all listed ids as a single history entry', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
const c = editor.do((e) => e.addElement(makeImage({ src: 'c' })));
|
||||
editor.do((e) => e.deleteMany([a, c]));
|
||||
expect(editor.current.elements).toHaveLength(1);
|
||||
expect(editor.current.elements[0].src).toBe('b');
|
||||
// A single undo restores both. If it took two undos that would
|
||||
// mean deleteMany pushed two history entries — wrong, since it's
|
||||
// documented as a single-entry operation.
|
||||
editor.do((e) => e.undo());
|
||||
expect(editor.current.elements).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('clears deleted ids from selection', () => {
|
||||
const editor = setupEditor();
|
||||
const a = editor.do((e) => e.addElement(makeImage({ src: 'a' })));
|
||||
const b = editor.do((e) => e.addElement(makeImage({ src: 'b' })));
|
||||
editor.do((e) => e.setSelection([a, b]));
|
||||
editor.do((e) => e.deleteMany([a]));
|
||||
expect(editor.current.selectedIds.has(a)).toBe(false);
|
||||
expect(editor.current.selectedIds.has(b)).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op for an empty ids array', () => {
|
||||
const editor = setupEditor();
|
||||
editor.do((e) => e.addElement(makeImage()));
|
||||
const before = editor.current.elements;
|
||||
editor.do((e) => e.deleteMany([]));
|
||||
expect(editor.current.elements).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,34 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Server export pipeline wrapper.
|
||||
*
|
||||
* Posts the design payload to /api/export, receives back a URL to the
|
||||
* 4500×4500 PNG render, and (by default) triggers a browser download.
|
||||
*
|
||||
* `autoDownload` option
|
||||
* ─────────────────────
|
||||
* Save and the test-download button want auto-download. Print-preview
|
||||
* wants the URL but no download. Threading a flag through `exportDesign`
|
||||
* is cleaner than maintaining two parallel pipelines.
|
||||
*/
|
||||
export function useExport() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [exportUrl, setExportUrl] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const exportDesign = useCallback(async (elements, designName = 'design', template = null) => {
|
||||
const exportDesign = useCallback(async (
|
||||
elements,
|
||||
designName = 'design',
|
||||
template = null,
|
||||
{ autoDownload = true } = {},
|
||||
) => {
|
||||
setExporting(true); setProgress(0); setError(null); setExportUrl(null);
|
||||
|
||||
let progressInterval = null;
|
||||
try {
|
||||
const progressInterval = setInterval(() => { setProgress((prev) => Math.min(prev + 10, 90)); }, 200);
|
||||
progressInterval = setInterval(() => { setProgress((prev) => Math.min(prev + 10, 90)); }, 200);
|
||||
|
||||
const response = await fetch('/api/export', {
|
||||
method: 'POST',
|
||||
@@ -18,26 +36,27 @@ export function useExport() {
|
||||
body: JSON.stringify({ elements, designName, template }),
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
|
||||
if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Export failed'); }
|
||||
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Export failed'); }
|
||||
|
||||
const data = await response.json();
|
||||
setProgress(100);
|
||||
setExportUrl(data.export.url);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = data.export.url;
|
||||
link.download = data.export.filename;
|
||||
link.click();
|
||||
if (autoDownload) {
|
||||
const link = document.createElement('a');
|
||||
link.href = data.export.url;
|
||||
link.download = data.export.filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
setExporting(false);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
setError(err.message);
|
||||
setExporting(false);
|
||||
throw err;
|
||||
} finally {
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
setExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
91
src/hooks/useFocusTrap.js
Normal file
91
src/hooks/useFocusTrap.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Focus-trap hook for modal-like surfaces (S24).
|
||||
*
|
||||
* Pass `active` (the modal's open state) and a ref to the container
|
||||
* element. While active:
|
||||
*
|
||||
* • Focus is moved into the container on mount (first tabbable child,
|
||||
* or the container itself if it has tabindex).
|
||||
* • Tab and Shift+Tab cycle through tabbable descendants only — they
|
||||
* can't escape to elements behind the modal.
|
||||
* • On deactivation (close), focus is returned to wherever it was
|
||||
* before the modal opened.
|
||||
*
|
||||
* The set of tabbable selectors here covers the common interactive
|
||||
* elements; we intentionally exclude `area` and `iframe` since they're
|
||||
* unusual in a modal context. `[tabindex="-1"]` is excluded — those are
|
||||
* focus targets but not Tab targets.
|
||||
*
|
||||
* Reading focused elements via `document.activeElement` is the standard
|
||||
* pattern; React refs aren't enough because focus may live on an element
|
||||
* inside a third-party library (e.g. Filerobot) that doesn't expose its
|
||||
* own ref.
|
||||
*/
|
||||
const TABBABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
export function useFocusTrap(active, containerRef) {
|
||||
const previousFocusRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
|
||||
// Remember where focus was before so we can restore it on close.
|
||||
previousFocusRef.current = document.activeElement;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return undefined;
|
||||
|
||||
// Move focus to the first tabbable element inside the container,
|
||||
// falling back to the container itself if it's focusable.
|
||||
const focusables = container.querySelectorAll(TABBABLE_SELECTOR);
|
||||
const first = focusables[0];
|
||||
if (first instanceof HTMLElement) {
|
||||
first.focus();
|
||||
} else if (container.tabIndex >= 0) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const live = container.querySelectorAll(TABBABLE_SELECTOR);
|
||||
if (live.length === 0) {
|
||||
// Nothing tabbable — keep focus on the container.
|
||||
e.preventDefault();
|
||||
if (container.tabIndex >= 0) container.focus();
|
||||
return;
|
||||
}
|
||||
const firstEl = live[0];
|
||||
const lastEl = live[live.length - 1];
|
||||
const activeEl = document.activeElement;
|
||||
|
||||
if (e.shiftKey && activeEl === firstEl) {
|
||||
e.preventDefault();
|
||||
lastEl.focus();
|
||||
} else if (!e.shiftKey && activeEl === lastEl) {
|
||||
e.preventDefault();
|
||||
firstEl.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
// Restore focus to where it was before, if that element still
|
||||
// exists in the DOM. Wrapped in a try/catch because focusing a
|
||||
// detached node throws in some browsers.
|
||||
const prev = previousFocusRef.current;
|
||||
if (prev && typeof prev.focus === 'function') {
|
||||
try { prev.focus(); } catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
}, [active, containerRef]);
|
||||
}
|
||||
50
src/hooks/useFontsReady.js
Normal file
50
src/hooks/useFontsReady.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Trigger a re-render once web fonts have finished loading.
|
||||
*
|
||||
* Why
|
||||
* ───
|
||||
* `measureTextWidth` (used by the canvas-based text measurement utilities)
|
||||
* asks the browser for a rendered glyph width with a specific `font-family`.
|
||||
* Before the font's `.woff2` arrives over the network and is parsed, the
|
||||
* browser falls back to a system font and reports its width — almost
|
||||
* always wrong.
|
||||
*
|
||||
* On a fast connection that happens once at app start, before the user
|
||||
* notices. On a slow connection, or for fonts loaded via `@font-face`
|
||||
* with `font-display: swap`, the measurement can lag the eventual render
|
||||
* by several hundred milliseconds — long enough for arc-text paths and
|
||||
* bounds-violation flags to be computed against fallback metrics.
|
||||
*
|
||||
* The fix is to re-render once `document.fonts.ready` resolves, so any
|
||||
* `measureTextWidth` calls in render bodies retry with the now-loaded
|
||||
* fonts. This hook returns a boolean for components that want to
|
||||
* conditionally render a placeholder, but most callers can just call it
|
||||
* for the side effect (the hook subscription causes the re-render).
|
||||
*
|
||||
* Resolves once per app lifetime
|
||||
* ──────────────────────────────
|
||||
* `document.fonts.ready` resolves once and stays resolved. After that,
|
||||
* this hook does nothing — no listener overhead. If new fonts are loaded
|
||||
* dynamically later (e.g. user picks a font we have to fetch on demand),
|
||||
* the consuming component would need its own re-render trigger.
|
||||
*/
|
||||
export function useFontsReady() {
|
||||
const [ready, setReady] = useState(() => {
|
||||
if (typeof document === 'undefined' || !document.fonts) return true;
|
||||
return document.fonts.status === 'loaded';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) return;
|
||||
if (typeof document === 'undefined' || !document.fonts) return;
|
||||
let cancelled = false;
|
||||
document.fonts.ready.then(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [ready]);
|
||||
|
||||
return ready;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { loadImageDimensions } from '../utils/imageLoading';
|
||||
|
||||
export function calculateAutoCrop(imageSize, slotSize) {
|
||||
const imageRatio = imageSize.width / imageSize.height;
|
||||
@@ -9,17 +10,8 @@ export function calculateAutoCrop(imageSize, slotSize) {
|
||||
return { sx, sy, sWidth, sHeight };
|
||||
}
|
||||
|
||||
export function createDragBoundFunc(slot, elementSize) {
|
||||
const { bounds } = slot;
|
||||
const minX = bounds.x, minY = bounds.y;
|
||||
const maxX = bounds.x + bounds.width - elementSize.width;
|
||||
const maxY = bounds.y + bounds.height - elementSize.height;
|
||||
return (_oldBox, newBox) => ({ x: Math.max(minX, Math.min(newBox.x, maxX)), y: Math.max(minY, Math.min(newBox.y, maxY)), width: newBox.width, height: newBox.height });
|
||||
}
|
||||
|
||||
export function useTemplate(templates = []) {
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState(null);
|
||||
const [assignedSlots, setAssignedSlots] = useState({});
|
||||
const templateRef = useRef(null);
|
||||
|
||||
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
|
||||
@@ -27,31 +19,43 @@ export function useTemplate(templates = []) {
|
||||
|
||||
const loadTemplate = useCallback((templateId) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
if (template) { setCurrentTemplateId(templateId); setAssignedSlots({}); templateRef.current = template; return true; }
|
||||
if (template) { setCurrentTemplateId(templateId); templateRef.current = template; return true; }
|
||||
return false;
|
||||
}, [templates]);
|
||||
|
||||
const clearTemplate = useCallback(() => { setCurrentTemplateId(null); setAssignedSlots({}); templateRef.current = null; }, []);
|
||||
const clearTemplate = useCallback(() => { setCurrentTemplateId(null); templateRef.current = null; }, []);
|
||||
|
||||
const assignImageToSlot = useCallback((slotId, imageData) => {
|
||||
// Build the element data for an image dropped into a slot. Resolves only after
|
||||
// the image has loaded so the auto-crop reflects the image's real dimensions
|
||||
// (returning early with crop:null lets the caller create an element that never
|
||||
// gets its crop populated, since the mutation arrives after React already
|
||||
// captured the value). Caller is responsible for adding the returned element
|
||||
// to the editor state, which makes that state the single source of truth for
|
||||
// "is this slot filled" — see DesignCanvas, which derives slot occupancy from
|
||||
// the elements array.
|
||||
const assignImageToSlot = useCallback(async (slotId, imageData) => {
|
||||
const slots = getSlots();
|
||||
const slot = slots.find(s => s.id === slotId);
|
||||
if (!slot) return null;
|
||||
const elementData = { type: 'image', src: imageData, x: slot.bounds.x, y: slot.bounds.y, width: slot.bounds.width, height: slot.bounds.height, slotId, crop: null };
|
||||
const img = new Image();
|
||||
img.src = imageData;
|
||||
img.onload = () => { elementData.crop = calculateAutoCrop({ width: img.width, height: img.height }, { width: slot.bounds.width, height: slot.bounds.height }); };
|
||||
setAssignedSlots(prev => ({ ...prev, [slotId]: elementData }));
|
||||
return elementData;
|
||||
|
||||
const dimensions = await loadImageDimensions(imageData);
|
||||
|
||||
const crop = calculateAutoCrop(
|
||||
{ width: dimensions.width, height: dimensions.height },
|
||||
{ width: slot.bounds.width, height: slot.bounds.height },
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'image',
|
||||
src: imageData,
|
||||
x: slot.bounds.x,
|
||||
y: slot.bounds.y,
|
||||
width: slot.bounds.width,
|
||||
height: slot.bounds.height,
|
||||
slotId,
|
||||
crop,
|
||||
};
|
||||
}, [getSlots]);
|
||||
|
||||
const getDragBoundFunc = useCallback((slotId, elementSize) => {
|
||||
const slot = getSlots().find(s => s.id === slotId);
|
||||
if (!slot) return null;
|
||||
return createDragBoundFunc(slot, elementSize);
|
||||
}, [getSlots]);
|
||||
|
||||
const isSlotFilled = useCallback((slotId) => !!assignedSlots[slotId], [assignedSlots]);
|
||||
|
||||
return { currentTemplateId, currentTemplate, assignedSlots, loadTemplate, clearTemplate, getSlots, assignImageToSlot, getDragBoundFunc, isSlotFilled };
|
||||
return { currentTemplateId, currentTemplate, loadTemplate, clearTemplate, getSlots, assignImageToSlot };
|
||||
}
|
||||
|
||||
62
src/hooks/useTimer.js
Normal file
62
src/hooks/useTimer.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* useTimer — auto-cleaning setTimeout helper.
|
||||
*
|
||||
* Background
|
||||
* ──────────
|
||||
* `App.jsx` had two timer use cases (clear-canvas two-tap arming and toast
|
||||
* auto-dismiss) that each followed the same shape:
|
||||
*
|
||||
* const timerRef = useRef(null);
|
||||
* // schedule:
|
||||
* if (timerRef.current) clearTimeout(timerRef.current);
|
||||
* timerRef.current = setTimeout(() => { ...; timerRef.current = null }, ms);
|
||||
* // unmount cleanup:
|
||||
* useEffect(() => () => {
|
||||
* if (timerRef.current) clearTimeout(timerRef.current);
|
||||
* }, []);
|
||||
*
|
||||
* The same boilerplate, three side-effects (arm, cancel-and-rearm, cleanup),
|
||||
* one bug surface — forgetting any of them strands a timer or fails to
|
||||
* cancel a previous one. This hook collapses it to:
|
||||
*
|
||||
* const timer = useTimer();
|
||||
* timer.schedule(() => { ... }, 3000); // auto-cancels any pending one
|
||||
* timer.cancel(); // explicit early cancellation
|
||||
*
|
||||
* Cleanup on unmount is automatic.
|
||||
*
|
||||
* Why not setInterval-style
|
||||
* ─────────────────────────
|
||||
* setTimeout is one-shot, which matches both current callers' semantics:
|
||||
* "after N ms unless something else cancels me first". An interval helper
|
||||
* would be a different shape — leaving it for when there's a second
|
||||
* caller that needs it.
|
||||
*/
|
||||
export function useTimer() {
|
||||
const idRef = useRef(null);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (idRef.current !== null) {
|
||||
clearTimeout(idRef.current);
|
||||
idRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback((fn, ms) => {
|
||||
cancel();
|
||||
idRef.current = setTimeout(() => {
|
||||
idRef.current = null;
|
||||
fn();
|
||||
}, ms);
|
||||
}, [cancel]);
|
||||
|
||||
// Unmount cleanup. Returning the cleanup directly from useEffect rather
|
||||
// than capturing `cancel` in deps because the ref-based cancel is
|
||||
// identity-stable, so the effect can run once on mount and once on
|
||||
// unmount.
|
||||
useEffect(() => () => cancel(), [cancel]);
|
||||
|
||||
return { schedule, cancel };
|
||||
}
|
||||
144
src/i18n/messages.js
Normal file
144
src/i18n/messages.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Message catalog (S23).
|
||||
*
|
||||
* Flat key → string map. Intentionally not nested — the dot in a key is
|
||||
* just punctuation, the lookup is a plain object access. Flat keys are
|
||||
* easier to grep ("where do we use this string?"), easier to diff in
|
||||
* code review, and avoid the bikeshedding around when to nest vs flatten
|
||||
* a category. They also work transparently with the lightweight `t()`
|
||||
* helper in `./t.js` — no recursion, no path syntax to learn.
|
||||
*
|
||||
* Adding a key:
|
||||
* 1. Append it to the `en` map below with a key that matches the
|
||||
* strings in code (kebab namespace, e.g. `header.save-button`).
|
||||
* 2. Use it via `t('header.save-button')` in the component.
|
||||
* 3. When future locales are added, copy the en map to a new file
|
||||
* (es.js, fr.js…) and translate the values.
|
||||
*
|
||||
* Parameter syntax: `{name}` placeholders inside a value are replaced by
|
||||
* the matching key in the params object passed to t():
|
||||
*
|
||||
* 'cart.items-count': 'You have {count} items in your cart.'
|
||||
* t('cart.items-count', { count: 3 }) → 'You have 3 items in your cart.'
|
||||
*
|
||||
* If a key is missing, t() returns the key string itself — that's
|
||||
* obvious in the UI ("header.save-button" is not English) so missing
|
||||
* keys surface immediately in QA rather than silently rendering empty.
|
||||
*/
|
||||
|
||||
export const en = {
|
||||
// Header
|
||||
'header.app-name': 'Pawfectly Yours',
|
||||
'header.app-tagline': 'Wear Your Pet. Share Your Story.',
|
||||
'header.page-title': 'Customize Your Shirt',
|
||||
'header.undo': 'Undo',
|
||||
'header.undo-tooltip': 'Undo (\u2318Z)',
|
||||
'header.redo': 'Redo',
|
||||
'header.redo-tooltip': 'Redo (\u2318\u21E7Z)',
|
||||
'header.clear': 'Clear canvas',
|
||||
'header.clear-tooltip': 'Clear all elements',
|
||||
'header.preview': 'Print preview',
|
||||
'header.preview-tooltip': 'See your design at print resolution',
|
||||
'header.download': 'Download print file',
|
||||
'header.download-tooltip': 'Download the print-resolution PNG',
|
||||
'header.save': 'Save',
|
||||
'header.share': 'Share',
|
||||
'header.cart-aria': 'Cart, {count} {plural}',
|
||||
'header.menu': 'Menu',
|
||||
|
||||
// Toasts
|
||||
'toast.add-something-first': 'Add something to your design first.',
|
||||
'toast.cart-empty': 'Your cart is empty. Customize your shirt and add it to cart!',
|
||||
'toast.cart-items': 'You have {count} {plural} in your cart.',
|
||||
'toast.link-copied': 'Link copied to clipboard \u2728',
|
||||
'toast.copy-failed': 'Couldn\u2019t auto-copy. URL: {url}',
|
||||
'toast.cleared': 'Canvas cleared. Use Undo to restore.',
|
||||
'toast.clear-confirm': 'Click Clear again to confirm.',
|
||||
'toast.unknown-template': 'Unknown template: {id}',
|
||||
'toast.image-load-failed': 'Failed to load image. Try a different file.',
|
||||
'toast.save-failed': 'Save failed: {error}',
|
||||
'toast.saved': 'Saved!',
|
||||
// Crop refuses on rotated elements because the overlay (dim mask,
|
||||
// crop rect, transformer anchors) is axis-aligned and wouldn't
|
||||
// visually match a rotated image. Surfaced as an info toast rather
|
||||
// than silently hiding the Crop button — the user gets a clear
|
||||
// explanation of why nothing happened and what to try instead.
|
||||
// 4-second duration in App.jsx so the user has time to read the
|
||||
// suggested action.
|
||||
'toast.crop-rotation-blocked': 'Reset rotation to 0° before cropping. Press ⌘Z to undo any rotation first.',
|
||||
|
||||
// Apply Crop fired before the image had finished loading its pixel
|
||||
// data into the Konva node — we can't compute the source crop
|
||||
// region without natural dimensions, so the apply is a no-op and
|
||||
// we surface this so the user knows to try again in a moment.
|
||||
// App.jsx's handleApplyCrop also keeps crop mode active in this
|
||||
// case (rather than silently closing it), so the user can simply
|
||||
// click Apply again once the image has decoded.
|
||||
'toast.crop-not-ready': 'Photo is still loading. Try Apply again in a moment.',
|
||||
|
||||
// Cart
|
||||
'cart.disabled-out-of-bounds': 'Move everything inside the print area to add to cart.',
|
||||
'cart.add': 'Add to Cart',
|
||||
|
||||
// Sidebar tabs
|
||||
'sidebar.tab.upload': 'Upload Photo',
|
||||
'sidebar.tab.stickers': 'Stickers',
|
||||
'sidebar.tab.emoji': 'Emoji',
|
||||
'sidebar.tab.text': 'Text',
|
||||
'sidebar.tab.layers': 'Layers',
|
||||
'sidebar.preview-on-model': 'Preview on Model',
|
||||
|
||||
// Text tab
|
||||
'text.field.message': 'Your message',
|
||||
'text.placeholder': 'e.g. Best Pup Ever',
|
||||
'text.field.font': 'Font',
|
||||
'text.field.size': 'Size',
|
||||
'text.field.color': 'Color',
|
||||
'text.field.outline': 'Outline',
|
||||
'text.field.arc': 'Arc',
|
||||
'text.editing-banner': 'Editing selected text',
|
||||
'text.recent-colors': 'Recent',
|
||||
'text.contrast-warning': 'Low contrast with shirt color \u2014 your text may be hard to see.',
|
||||
'text.arc-flat': 'Flat',
|
||||
'text.arc-slight-up': 'Slight curve up',
|
||||
'text.arc-slight-down': 'Slight curve down',
|
||||
'text.arc-medium-up': 'Medium curve up',
|
||||
'text.arc-medium-down': 'Medium curve down',
|
||||
'text.arc-strong-up': 'Strong curve up',
|
||||
'text.arc-strong-down': 'Strong curve down',
|
||||
'text.arc-reset': 'Reset to flat',
|
||||
'text.preview-fallback': 'Preview',
|
||||
'text.add-button': 'Add text to canvas',
|
||||
|
||||
// Element toolbar
|
||||
'toolbar.delete': 'Delete',
|
||||
'toolbar.duplicate': 'Duplicate',
|
||||
'toolbar.bring-forward': 'Bring forward',
|
||||
'toolbar.send-backward': 'Send backward',
|
||||
'toolbar.flip-h': 'Flip horizontally',
|
||||
'toolbar.flip-v': 'Flip vertically',
|
||||
'toolbar.lock': 'Lock',
|
||||
'toolbar.unlock': 'Unlock',
|
||||
'toolbar.opacity': 'Opacity',
|
||||
'toolbar.size': 'Size',
|
||||
'toolbar.edit-photo': 'Edit Photo',
|
||||
|
||||
// Layers
|
||||
'layers.title': 'Layers ({count})',
|
||||
'layers.empty': 'No elements yet. Add images, text, or stickers to your design.',
|
||||
'layers.delete-many': 'Delete {count} selected',
|
||||
'layers.dragHandle-tooltip': 'Drag to reorder',
|
||||
|
||||
// Bounds warning
|
||||
'bounds.singular': '1 element is outside the print area and won\u2019t be printed.',
|
||||
'bounds.plural': '{count} elements are outside the print area and won\u2019t be printed.',
|
||||
|
||||
// Preview modal
|
||||
'preview.title': 'Print preview',
|
||||
'preview.close': 'Close',
|
||||
'preview.loading': 'Rendering at print resolution\u2026',
|
||||
'preview.error': 'Couldn\u2019t render preview: {error}',
|
||||
'preview.hint': 'This is what will print on your shirt \u2014 4500\u00d74500 at 300 DPI.',
|
||||
'preview.keep-editing': 'Keep editing',
|
||||
'preview.download-png': 'Download PNG',
|
||||
};
|
||||
46
src/i18n/t.js
Normal file
46
src/i18n/t.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { en } from './messages';
|
||||
|
||||
/**
|
||||
* Tiny i18n helper (S23).
|
||||
*
|
||||
* Looks up `key` in the active catalog and substitutes any `{name}`
|
||||
* placeholders with values from `params`. Returns the key itself if no
|
||||
* entry exists, so missing translations are visible in the UI rather
|
||||
* than silently rendering empty strings.
|
||||
*
|
||||
* Single-locale for now — `en` is the only catalog. Adding more is a
|
||||
* matter of importing them here and exposing a `setLocale` hook (or
|
||||
* reading from the browser's `navigator.language`); the call sites of
|
||||
* `t()` don't need to change.
|
||||
*
|
||||
* Usage:
|
||||
* import { t } from '@/i18n/t';
|
||||
* t('header.save-button') // → 'Save'
|
||||
* t('toast.cart-items', { count: 3, plural: 'items' }) // simple sub
|
||||
*
|
||||
* Why not react-intl / i18next? At this scale a flat catalog plus a
|
||||
* 10-line helper covers the actual feature surface (button labels,
|
||||
* toasts, modal copy) without pulling in a dependency that has its own
|
||||
* upgrade story, message-format dialect, and bundle weight. If we hit
|
||||
* full ICU MessageFormat needs (gender, complex plurals, RTL with
|
||||
* inline number formatting), revisit the library question then.
|
||||
*/
|
||||
|
||||
// Currently we only ship one catalog. If/when more land, this would
|
||||
// become a dispatch on a runtime locale value.
|
||||
const ACTIVE_CATALOG = en;
|
||||
|
||||
const PLACEHOLDER_RE = /\{(\w+)\}/g;
|
||||
|
||||
export function t(key, params) {
|
||||
const raw = ACTIVE_CATALOG[key];
|
||||
if (raw === undefined) {
|
||||
// Surface the missing key directly so it stands out in the UI.
|
||||
return key;
|
||||
}
|
||||
if (!params) return raw;
|
||||
return raw.replace(PLACEHOLDER_RE, (match, name) => {
|
||||
const v = params[name];
|
||||
return v !== undefined ? String(v) : match;
|
||||
});
|
||||
}
|
||||
480
src/index.css
480
src/index.css
@@ -1,32 +1,82 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Design tokens — Pawfectly Yours
|
||||
*
|
||||
* Soft pink / cream watercolor aesthetic. The legacy `--accent` token is kept
|
||||
* as an alias to `--brand-pink` so older components that still reference it
|
||||
* (e.g. some sticker-tab styles) keep working without modification.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--accent: #38bdf8;
|
||||
--accent-hover: #0ea5e9;
|
||||
--accent-bg: rgba(56, 189, 248, 0.1);
|
||||
/* Brand palette */
|
||||
--brand-pink: #ec4899;
|
||||
--brand-pink-strong: #db2777;
|
||||
--brand-pink-soft: #fbe2ec;
|
||||
--brand-pink-tint: #fde8ef;
|
||||
--brand-pink-wash: #fdf2f4;
|
||||
--brand-cream: #fffaf5;
|
||||
--brand-lavender: #c4b5e5;
|
||||
--brand-lavender-soft: #ede7f6;
|
||||
--brand-peach: #fcd7b6;
|
||||
--brand-blue: #b6cef0;
|
||||
|
||||
/* Surfaces */
|
||||
--bg-app: var(--brand-pink-wash);
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--border: #e2e8f0;
|
||||
--border-focus: #38bdf8;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--bg-secondary: var(--brand-cream);
|
||||
--bg-tertiary: var(--brand-pink-tint);
|
||||
|
||||
/* Text */
|
||||
--text-primary: #1f1d23;
|
||||
--text-secondary: #5d5763;
|
||||
--text-muted: #9a9099;
|
||||
--text-on-brand: #ffffff;
|
||||
|
||||
/* Borders */
|
||||
--border: #fde2ea;
|
||||
--border-strong: #f9c8d6;
|
||||
--border-focus: var(--brand-pink);
|
||||
|
||||
/* Status */
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Radii — gentle, never sharp. Cards lean larger. */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 28px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* Shadows — soft, pink-tinted */
|
||||
--shadow-xs: 0 1px 2px rgba(236, 72, 153, 0.06);
|
||||
--shadow-sm: 0 2px 4px rgba(236, 72, 153, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 6px 12px -2px rgba(236, 72, 153, 0.10), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 12px 24px -6px rgba(236, 72, 153, 0.18), 0 6px 10px -4px rgba(0, 0, 0, 0.05);
|
||||
--shadow-pop: 0 18px 36px -10px rgba(236, 72, 153, 0.32);
|
||||
|
||||
/* Fonts */
|
||||
--font-display: 'Pacifico', 'Caveat', 'DM Sans', cursive;
|
||||
--font-body: 'Nunito', 'DM Sans', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'Space Mono', ui-monospace, Consolas, monospace;
|
||||
|
||||
/* Layout rhythm */
|
||||
--header-height: 72px;
|
||||
--header-height-mobile: 56px;
|
||||
--right-panel-width: 420px;
|
||||
--right-panel-width-md: 360px;
|
||||
|
||||
/* Legacy aliases — keep older components working */
|
||||
--accent: var(--brand-pink);
|
||||
--accent-hover: var(--brand-pink-strong);
|
||||
--accent-bg: var(--brand-pink-soft);
|
||||
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
background-color: var(--bg-app);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -40,179 +90,155 @@
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-app);
|
||||
/* Subtle grid texture echoing the screenshot's notebook backdrop. */
|
||||
background-image:
|
||||
linear-gradient(rgba(236, 72, 153, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(236, 72, 153, 0.04) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position: -1px -1px;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
/* `height: 100vh` (not `min-height`) is intentional. The editor is a
|
||||
* single-screen app — every panel is meant to live within the
|
||||
* viewport, with internal scrolling where needed (the sidebar's
|
||||
* `.rp__scroll`, the layers panel, etc.). With `min-height: 100vh`,
|
||||
* if any descendant's intrinsic content was taller than the viewport
|
||||
* (e.g. the sidebar with many layers), #root would grow past 100vh,
|
||||
* the page would scroll, and the canvas-area would extend below the
|
||||
* visible viewport — taking the absolutely-positioned bottom bar
|
||||
* with it. Capping at exactly 100vh gives every descendant a
|
||||
* definite height to resolve `height: 100%` against, so
|
||||
* `.rp__scroll`'s `overflow-y: auto` actually engages and the
|
||||
* canvas-area stays viewport-sized.
|
||||
*
|
||||
* `overflow: hidden` belts-and-suspenders the cap so anything that
|
||||
* sneaks past (e.g. a wide popover) gets clipped rather than
|
||||
* scrolling the page. */
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Generic interactive elements */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--brand-pink);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
overflow: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.properties-panel {
|
||||
width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.export-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.export-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: var(--radius-md);
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.export-error p {
|
||||
margin: 0;
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
height: 6px;
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.close-error {
|
||||
input[type='range']::-moz-range-track {
|
||||
height: 6px;
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-pink);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-top: -6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-pink);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Shared utilities (S13) */
|
||||
/* */
|
||||
/* Pulled out of individual component stylesheets so the same pattern */
|
||||
/* doesn't get re-defined in 5 places. Add new utilities here when a */
|
||||
/* CSS rule shows up in 3+ files unchanged. */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Visually hide content while keeping it accessible to screen readers.
|
||||
* The classic 1px clipping trick. `.sr-only` is an alias because
|
||||
* different parts of the codebase grew up using one or the other (S24
|
||||
* audit referenced both — unifying them prevents drift). */
|
||||
.visually-hidden,
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Strip native <button> chrome so you can build a custom-styled clickable
|
||||
* surface that still gets keyboard accessibility for free. Used by
|
||||
* LayersPanel rows, Sidebar tabs, and similar. Pair with explicit
|
||||
* `:focus-visible` styling so keyboard users still see focus. */
|
||||
.btn-reset {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bg-removal-container {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.bg-removal-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bg-removal-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
/* Generic fade+slide-up entrance for toasts, banners, and other
|
||||
* top-anchored chips. Components that need an X-translate variant
|
||||
* (e.g. .app-toast which is centered via translateX(-50%)) keep their
|
||||
* own bespoke @keyframes since this one only handles Y. */
|
||||
@keyframes fade-up-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bg-removal-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bg-removal-hint {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Spinner — used by background removal and uploads */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner-small {
|
||||
width: 16px;
|
||||
@@ -229,26 +255,9 @@ select {
|
||||
}
|
||||
}
|
||||
|
||||
.filerobot-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.filerobot-container {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
background: #1e1e1e;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* PWA install banner + offline indicator */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
@@ -256,7 +265,7 @@ select {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--accent);
|
||||
border: 1px solid var(--brand-pink);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -272,7 +281,6 @@ select {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
@@ -287,12 +295,12 @@ select {
|
||||
|
||||
.install-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
background: var(--brand-pink);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -325,65 +333,65 @@ select {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) and (min-width: 768px) {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
}
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Background removal button (rendered inside ElementToolbar) */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.properties-panel {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.bg-removal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.editor-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 50vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.properties-panel {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.pwa-install-banner {
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
transform: none;
|
||||
width: auto;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
.bg-removal-btn {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
background: linear-gradient(135deg, var(--brand-pink), var(--brand-lavender));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 4px 10px -2px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
max-height: 40vh;
|
||||
}
|
||||
.bg-removal-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px -4px rgba(236, 72, 153, 0.55);
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.bg-removal-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Filerobot photo editor modal */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.filerobot-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(31, 29, 35, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.filerobot-container {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
background: #1e1e1e;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
19
src/main.jsx
19
src/main.jsx
@@ -3,20 +3,11 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.ready.then((registration) => {
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.dispatchEvent(new CustomEvent('swUpdated', { detail: newWorker }));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
// Service worker registration is handled inside <PWAInstall /> via the
|
||||
// `useRegisterSW` hook from `virtual:pwa-register/react`. We removed the
|
||||
// hand-rolled `navigator.serviceWorker.ready` listener that used to live here
|
||||
// because (a) it didn't actually register the SW — it just listened for updates
|
||||
// — and (b) it duplicated state the plugin already exposes via the hook.
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
|
||||
39
src/styles/CanvasHint.css
Normal file
39
src/styles/CanvasHint.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.canvas-hint {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.5rem 0.75rem;
|
||||
box-shadow: var(--shadow-xs);
|
||||
color: var(--brand-pink-strong);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.canvas-hint__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.canvas-hint__text strong {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.canvas-hint__text span {
|
||||
font-size: 10.5px;
|
||||
color: var(--brand-pink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.canvas-hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,189 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* DesignCanvas — Pawfectly Yours
|
||||
*
|
||||
* The canvas frame is a soft pink stage. The wrapper inside is sized to the
|
||||
* product mockup's aspect ratio (e.g. shirt = 480/540) and contains:
|
||||
* 1. ModelSilhouette (z=0, conditional on Preview-on-Model)
|
||||
* 2. Product mockup SVG (z=1)
|
||||
* 3. Print-zone dashed border (z=3, when selected)
|
||||
* 4. Konva Stage, positioned absolutely at the print zone (z=2, see JSX
|
||||
* inline styles for the exact placement math)
|
||||
*
|
||||
* Why this layout
|
||||
* ───────────────
|
||||
* The Konva Stage is the editing canvas. Aligning it visually with the print
|
||||
* zone of the product means the user designs *exactly* where the design will
|
||||
* print. The Stage's internal coordinate system is unchanged (still 300×300
|
||||
* design units with HANDLE_PADDING for transformer handles); only the visual
|
||||
* scale changes via CSS, driven by ResizeObserver in DesignCanvas.jsx.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.design-canvas-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--brand-pink-tint);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
/* Subtle grid texture inside the canvas card */
|
||||
background-image:
|
||||
linear-gradient(rgba(236, 72, 153, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(236, 72, 153, 0.05) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
/* Wrapper — the host element for both the product mockup and the Konva Stage.
|
||||
*
|
||||
* Width and height are set inline by JS each time the frame's available area
|
||||
* changes. The JS computes the LARGEST size that fits inside the frame while
|
||||
* preserving the product mockup's aspect ratio (e.g. shirt = 480/540), so
|
||||
* the canvas grows to fill the entire left side of the editor on big monitors
|
||||
* and shrinks gracefully on smaller ones.
|
||||
*
|
||||
* The transition rule below animates width/height/transform changes — BUT
|
||||
* only after `.design-canvas-frame--ready` has been added to the parent
|
||||
* (one frame after mount). The initial sizing pass (default-state → fitted
|
||||
* size) happens in a useLayoutEffect before paint, and we don't want it to
|
||||
* animate; the transition only kicks in for subsequent resize events.
|
||||
*
|
||||
* `will-change` hints the compositor that width/height/transform are about
|
||||
* to change so it can promote this layer ahead of the first resize. We only
|
||||
* apply it once `--ready` is set so we don't pay the layer-promotion cost
|
||||
* for the entire app lifetime when the user never resizes the window. */
|
||||
.design-canvas-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
/* width and height are set inline from JS — do not set them here */
|
||||
transform-origin: center center;
|
||||
/* Clip elements to the visible shirt. The Konva stage now extends past
|
||||
* the print zone to cover the whole wrapper plus HANDLE_PADDING of
|
||||
* slop on each side (so the user can drag/transform anywhere on the
|
||||
* shirt). overflow:hidden ensures anything outside the wrapper itself
|
||||
* is clipped — a defensive guard against rounding artifacts and a
|
||||
* visual containment for elements positioned very close to the
|
||||
* wrapper edge. */
|
||||
overflow: hidden;
|
||||
/* zoom-only transition; layout transitions are added by the --ready class */
|
||||
transition: transform 0.18s ease-out;
|
||||
}
|
||||
|
||||
.design-canvas-frame--ready .design-canvas-wrapper {
|
||||
/* Animate width/height as well as transform once we're past the initial
|
||||
* sizing. cubic-bezier(0.32, 0.72, 0, 1) is a soft ease-out that feels
|
||||
* natural for size changes — a hair faster at the start, glides to a
|
||||
* stop. 220ms is short enough to feel responsive yet long enough to read
|
||||
* as an animation rather than a snap. */
|
||||
transition: width 0.22s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
height 0.22s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
transform 0.18s ease-out;
|
||||
will-change: width, height;
|
||||
}
|
||||
|
||||
/* Print-zone border. Always visible at low opacity as a placement guide so
|
||||
* the user knows where the design will print, even with nothing selected.
|
||||
* When an element is selected the border darkens to give selection feedback.
|
||||
* Position + size are set inline via percentages in JSX so the border tracks
|
||||
* the print zone exactly. */
|
||||
.design-canvas-border {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.design-canvas-border.selected {
|
||||
border: 2px solid #38bdf8;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Konva Stage — absolutely positioned and CSS-scaled to overlay the print
|
||||
* zone. The exact position/scale is set inline in JSX from runtime measurements;
|
||||
* this rule just provides defaults for cases where inline styles haven't
|
||||
* applied yet (e.g. SSR/hydration).
|
||||
*
|
||||
* The transition on `transform` animates the stage's CSS-scale so resizes
|
||||
* are visually continuous — the stage grows/shrinks together with its
|
||||
* wrapper rather than snapping to the new scale on the next render.
|
||||
* Konva's INTERNAL coords are unaffected; the bound functions, drag
|
||||
* positions, and export pipeline still operate in the canonical 300×300
|
||||
* space throughout the animation. The `transition` here is gated behind
|
||||
* the same `--ready` class as the wrapper so the initial scale doesn't
|
||||
* animate. */
|
||||
.design-canvas-stage {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.design-canvas-info {
|
||||
position: absolute;
|
||||
bottom: -200px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
.design-canvas-frame--ready .design-canvas-stage {
|
||||
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
left 0.22s cubic-bezier(0.32, 0.72, 0, 1),
|
||||
top 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
/* During an active drag/transform, suppress the stage transition so input
|
||||
* stays maximally responsive. The wrapper's transform stays animated
|
||||
* (the user can still resize the window mid-edit) but the stage's CSS
|
||||
* scale changes won't lag behind — they apply instantly. We rely on
|
||||
* Konva adding `konvajs-content` to its container during interaction;
|
||||
* during normal idle state both transitions run as defined above. */
|
||||
|
||||
/* Bounds-violation warning chip ------------------------------------------
|
||||
*
|
||||
* Pinned at the TOP of the canvas frame (Change 22), full-width inside
|
||||
* the frame's padding. Previously sat at the bottom, but designs that
|
||||
* extended into the lower half of the print area visually conflicted
|
||||
* with the chip — the per-element yellow out-of-bounds tint behind the
|
||||
* offending element + the warning chip overlapping it read as a single
|
||||
* blob of yellow. Anchoring at the top gives the chip its own zone
|
||||
* away from typical design placement.
|
||||
*
|
||||
* Visually distinct from the pink brand color because the cart
|
||||
* disable that pairs with it is destructive — amber/orange reads as
|
||||
* "caution" without competing with the rest of the pink UI. */
|
||||
.design-canvas-warning {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
top: 0.75rem;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 251, 235, 0.97);
|
||||
border: 1px solid #fbbf24;
|
||||
color: #92400e;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
box-shadow: 0 6px 14px -4px rgba(146, 64, 14, 0.18);
|
||||
pointer-events: none; /* informational only — cart-bar is the action */
|
||||
animation: dcw-fade-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
.design-canvas-warning svg {
|
||||
flex-shrink: 0;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
/* Fade-in slides down from above now that the chip is anchored at the
|
||||
* top of the frame — visually consistent with "appears from off-screen
|
||||
* top" rather than the previous "appears from off-screen bottom". */
|
||||
@keyframes dcw-fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.design-canvas-frame {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
/* width/height are set from JS now — no media-query overrides needed.
|
||||
* The fit-to-frame math automatically produces the right size on any
|
||||
* viewport, including mobile portrait/landscape. */
|
||||
.design-canvas-warning {
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
font-size: 12px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
569
src/styles/ElementToolbar.css
Normal file
569
src/styles/ElementToolbar.css
Normal file
@@ -0,0 +1,569 @@
|
||||
.el-toolbar {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.85rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
/* Action row --------------------------------------------------------------- */
|
||||
|
||||
.el-toolbar__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-toolbar__actions {
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
/* Left-justified, not space-between. With the toolbar slimmed down to
|
||||
* Flip H + Flip V (+ optional mobile-only Edit-text), space-between
|
||||
* pushed two buttons to opposite ends of the toolbar with a large
|
||||
* empty gap in the middle, which read as broken spacing rather than
|
||||
* deliberate layout. flex-start groups the action buttons together
|
||||
* on the left so the empty space on the right is the natural
|
||||
* unused area of a small action row. */
|
||||
justify-content: flex-start;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
padding-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.el-toolbar__btn {
|
||||
flex: 0 1 auto;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0.5rem 0.5rem;
|
||||
min-width: 48px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.el-toolbar__btn:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* Busy state for action-row buttons that have an async backing
|
||||
* operation (currently just Background Removal). Reduces opacity and
|
||||
* disables hover so the button reads as "working, not interactive",
|
||||
* matches the native :disabled semantics, and keeps the cursor as
|
||||
* `wait` until the work completes. The spinner glyph below stands
|
||||
* in for the normal SVG icon during this state — vertical centering
|
||||
* is preserved because both icons render in roughly the same
|
||||
* 18×18 region. */
|
||||
.el-toolbar__btn--busy,
|
||||
.el-toolbar__btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
.el-toolbar__btn--busy:hover,
|
||||
.el-toolbar__btn:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Spinner used inside `.el-toolbar__btn` when an async operation is
|
||||
* in flight. Sized to roughly match the action-row SVG icons so the
|
||||
* layout doesn't jump when swapping between the two states. */
|
||||
.el-toolbar__spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--brand-pink-soft);
|
||||
border-top-color: var(--brand-pink);
|
||||
border-radius: 50%;
|
||||
animation: el-toolbar-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes el-toolbar-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.el-toolbar__btn--danger {
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__btn--danger:hover {
|
||||
background: #ffe4eb;
|
||||
color: #be1259;
|
||||
}
|
||||
|
||||
/* Active-state variant. Used by toggle-shaped buttons (currently the
|
||||
* Snap-to-center toggle) to visually indicate "this mode is ON". The
|
||||
* palette mirrors the filter-chip active state below so the toolbar's
|
||||
* "a thing is currently turned on" affordance reads consistently no
|
||||
* matter which control it's attached to. Hover lands on the same
|
||||
* brand-pink-soft surface, so hovering an already-active button just
|
||||
* stays active rather than flashing into a different state. */
|
||||
.el-toolbar__btn--active {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__btn--active:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__btn-label {
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Layer controls section (mobile only) ----------------------------------
|
||||
*
|
||||
* Renders below the action row, above the sliders. Contains a small
|
||||
* uppercase label followed by a row of five buttons (Duplicate, Lock,
|
||||
* Move above, Move below, Delete). The wrapping container stacks
|
||||
* label-over-buttons; the inner row is flex with `space-between` so
|
||||
* the five buttons spread across the toolbar width rather than
|
||||
* clustering on the left like the action row does — the section is
|
||||
* its own thing with a clear visual boundary, not a continuation of
|
||||
* the actions above it.
|
||||
*
|
||||
* No own border/separator: the action row above already has
|
||||
* `border-bottom: 1px dashed` and `padding-bottom: 0.85rem`, plus
|
||||
* the parent's `gap: 0.85rem`, which together give a visible break
|
||||
* between actions and this section. Adding a top border here would
|
||||
* read as a doubled-up divider.
|
||||
*
|
||||
* The action buttons inside (.el-toolbar__btn) carry their own
|
||||
* styling from the rule above. At ≤480px the existing media query
|
||||
* hides `.el-toolbar__btn-label`, so on phones this row collapses
|
||||
* to icons only — by design, since five labelled buttons would not
|
||||
* fit on a narrow toolbar without wrapping. The icons (copy, lock,
|
||||
* chevron-up-in-square, chevron-down-in-square, trash) are chosen
|
||||
* to be legible without their labels. */
|
||||
.el-toolbar__layer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.el-toolbar__section-title {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.el-toolbar__layer-actions {
|
||||
gap: 0.4rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Crop-mode toolbar face --------------------------------------------------
|
||||
*
|
||||
* When App enters crop mode for an image, ElementToolbar replaces its
|
||||
* normal layout with a title + Cancel / Apply pair. The same outer
|
||||
* .el-toolbar shell is kept so the floating panel's position and
|
||||
* shadow don't visibly shift when entering/leaving crop mode. */
|
||||
.el-toolbar--crop {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.el-toolbar__crop-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.el-toolbar__crop-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.el-toolbar__crop-btn {
|
||||
flex: 1;
|
||||
padding: 0.65rem 0.8rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
/* Primary action — Apply crop. Filled pink so the user reads it as
|
||||
* "this is the action that commits the in-progress change". */
|
||||
.el-toolbar__crop-btn--primary {
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
border: 1px solid var(--brand-pink);
|
||||
}
|
||||
|
||||
.el-toolbar__crop-btn--primary:hover {
|
||||
background: var(--brand-pink-strong);
|
||||
border-color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
/* Secondary action — Cancel. Outlined so it reads as the safe escape
|
||||
* route rather than competing visually with Apply. */
|
||||
.el-toolbar__crop-btn--secondary {
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.el-toolbar__crop-btn--secondary:hover {
|
||||
border-color: var(--brand-pink);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
/* Sliders ------------------------------------------------------------------ */
|
||||
|
||||
.el-toolbar__sliders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.el-toolbar__slider-row {
|
||||
display: grid;
|
||||
/* Three columns: label | slider | end-cluster (value + optional
|
||||
* reset button). The end cluster lives inside a single grid cell
|
||||
* so the value text and reset button can share their column via
|
||||
* an inner flex container (.el-toolbar__slider-end). Sizing the
|
||||
* end column differs between mobile and desktop — see the desktop
|
||||
* override below for the tightening rationale.
|
||||
*
|
||||
* Mobile default: fixed 72px reserves room for value (~44px) +
|
||||
* gap (~4px) + reset (24px), so the slider's effective width
|
||||
* doesn't jump as the user drags past a "changed from default"
|
||||
* threshold and the reset button appears or disappears. A jittery
|
||||
* slider under an active drag reads as a bug; the cost is ~24px
|
||||
* less horizontal room for the track, accepted on the narrow
|
||||
* mobile toolbar (where slider precision matters less than
|
||||
* stable layout). */
|
||||
grid-template-columns: 60px 1fr 72px;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Desktop — tighten the end column.
|
||||
*
|
||||
* Above the mobile breakpoint (matches App.jsx's MOBILE_QUERY of
|
||||
* 768px), the slider track gets the extra room back via
|
||||
* grid-template-columns ending in `auto`. The end cluster's width
|
||||
* is sized to its content: ~44px when only the value is visible,
|
||||
* ~72px when value + reset are both there. So:
|
||||
*
|
||||
* • No reset shown → slider has 28px more room than mobile.
|
||||
* • Reset shown → slider has the same room as mobile.
|
||||
*
|
||||
* This means the slider WILL jitter ~28px when the user drags
|
||||
* across the "changed from default" threshold and the reset button
|
||||
* appears/disappears. Acceptable on desktop because (a) mouse
|
||||
* precision makes the crossing event rare during a drag, and (b)
|
||||
* the desktop bottom toolbar has more horizontal real estate that
|
||||
* benefits from the extra slider width when no reset is shown.
|
||||
* On mobile the jitter would be more annoying (touch is less
|
||||
* precise, more likely to cross thresholds) and the toolbar is
|
||||
* narrower, so the trade-off lands the other way — see above. */
|
||||
@media (min-width: 769px) {
|
||||
.el-toolbar__slider-row {
|
||||
grid-template-columns: 60px 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* End cluster — holds the numeric value and (when changed-from-
|
||||
* default) the reset icon button. Flex so they sit side-by-side
|
||||
* with the value right-aligned within the cluster. The min-width
|
||||
* on the value (set below) keeps single/double/triple-digit
|
||||
* readouts from shifting the reset button's position as the user
|
||||
* drags.
|
||||
*
|
||||
* Fixed `min-height` matches the reset button's height (24px).
|
||||
* Without this, the row collapses to the slider+value's natural
|
||||
* height when no reset is visible (slider input ≈20px, value text
|
||||
* ≈15px), then expands to 24px the instant the reset appears as
|
||||
* the user crosses the "changed from default" threshold. That
|
||||
* vertical jiggle is especially visible when a user drags an
|
||||
* opacity / brightness / contrast / rotation slider back and forth
|
||||
* across the default value — the toolbar visibly grows and shrinks
|
||||
* by ~4px on each crossing, which reads as a glitch. Reserving the
|
||||
* larger of the two heights up-front keeps the row stable. The
|
||||
* extra height when no reset is showing is invisible (transparent
|
||||
* empty space at the right edge of the row) and harmless. */
|
||||
.el-toolbar__slider-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.el-toolbar__slider-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-toolbar__slider {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Slider track wrapper — holds [start-icon] [slider] [end-icon].
|
||||
*
|
||||
* The native <input type="range"> is the only flex item that grows;
|
||||
* the two end icons take their content-width (~14px) and stay
|
||||
* pinned to the slider's left and right edges. `min-width: 0` on
|
||||
* this flex container lets the slider shrink with the parent grid
|
||||
* column rather than overflow it (without it, the implicit
|
||||
* min-content of <input type=range> is ~150px and would refuse to
|
||||
* shrink below that, breaking the toolbar layout on narrow screens).
|
||||
*
|
||||
* The wrapper itself occupies the same grid cell the bare <input>
|
||||
* used to occupy — it's a drop-in container around the existing
|
||||
* slider input. */
|
||||
.el-toolbar__slider-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.el-toolbar__slider-track .el-toolbar__slider {
|
||||
/* Slider takes all the remaining width inside the track wrapper.
|
||||
* Explicit min-width: 0 because flex items default to min-width:
|
||||
* auto, which for an <input> resolves to the input's intrinsic
|
||||
* min-content size and refuses to shrink. */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Slider end-icon hints (the dim/solid squares, sun glyphs, etc.
|
||||
* flanking each slider). See <SliderEndIcon> in ElementToolbar.jsx
|
||||
* for what gets rendered.
|
||||
*
|
||||
* Sized to match the reset button's footprint vertically (24px tall)
|
||||
* so the slider row's min-height check doesn't fluctuate between
|
||||
* "reset present" and "reset absent" states. The icon's 14×14 SVG
|
||||
* sits centered inside this 24×24 box, giving ~5px of padding on
|
||||
* each side — enough that the icon doesn't visually crowd the slider
|
||||
* track but not so much that the row stretches horizontally.
|
||||
*
|
||||
* Color matches `.el-toolbar__slider-label` (text-secondary) so the
|
||||
* icons read as informational labels rather than interactive
|
||||
* affordances. No hover state, no cursor: pointer — these are
|
||||
* decorative direction hints, not buttons.
|
||||
*
|
||||
* `flex: 0 0 auto` pins them to their natural width inside the flex
|
||||
* track wrapper, leaving the slider input to take the remaining
|
||||
* room.
|
||||
*
|
||||
* `pointer-events: none` so any stray click or tap intended for the
|
||||
* slider track edge passes through to the slider rather than getting
|
||||
* swallowed by the icon. The user expectation is that this end of
|
||||
* the track behaves like the slider itself for grab-and-drag
|
||||
* purposes — the icon shouldn't be a dead zone. */
|
||||
.el-toolbar__slider-end-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 24px;
|
||||
color: var(--text-secondary);
|
||||
flex: 0 0 auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.el-toolbar__slider-value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
/* Reserve min-width so single-digit ("5") vs triple-digit ("100")
|
||||
* readouts don't push the reset button around as the user drags.
|
||||
* 36px fits "100%" comfortably at the toolbar's 12px font size
|
||||
* with the right-aligned text settling consistently on the
|
||||
* right edge of the cell. */
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
/* Per-slider reset button — small icon-only affordance rendered
|
||||
* conditionally by ElementToolbar's <SliderResetButton> when the
|
||||
* slider's current value differs from its default (see
|
||||
* ElementToolbar.jsx for the detection logic). Lives inside
|
||||
* `.el-toolbar__slider-end` next to the numeric value, sharing
|
||||
* the slider row's end-cluster column via a flex container.
|
||||
*
|
||||
* No visible chrome until hover, so the button doesn't compete
|
||||
* with the slider/value for visual attention; on hover it gets
|
||||
* the same brand-pink-soft wash + brand-pink-strong ink that the
|
||||
* action-row buttons use, keeping the toolbar's interaction
|
||||
* language consistent.
|
||||
*
|
||||
* 24×24 square with the 14×14 SVG centered inside, leaving
|
||||
* roughly 5px of padding on each side — enough that the click
|
||||
* target stays comfortably tappable on mobile even though the
|
||||
* icon itself is small. */
|
||||
.el-toolbar__slider-reset {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.el-toolbar__slider-reset:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__slider-reset:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* Advanced (image-only) ---------------------------------------------------- */
|
||||
|
||||
.el-toolbar__advanced {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.el-toolbar__advanced-btn {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--brand-pink);
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.el-toolbar__advanced-btn:hover {
|
||||
background: #fad0e0;
|
||||
}
|
||||
|
||||
/* Background removal button (rendered inside this toolbar) ---------------- */
|
||||
|
||||
.el-toolbar__advanced .bg-removal-container {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Image filter chips (image-only) -----------------------------------------
|
||||
*
|
||||
* Compact horizontal row of toggleable filter presets. Selected chip uses
|
||||
* the brand-pink fill; unselected chips use a neutral wash. The icon glyph
|
||||
* is decorative — the label carries the meaning, since glyph aesthetics
|
||||
* are arbitrary. */
|
||||
.el-toolbar__filters {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.el-toolbar__filter {
|
||||
flex: 1 1 auto;
|
||||
min-width: 56px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0.45rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.el-toolbar__filter:hover {
|
||||
border-color: var(--brand-pink);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__filter.is-active {
|
||||
background: var(--brand-pink-soft);
|
||||
border-color: var(--brand-pink);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.el-toolbar__filter-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.el-toolbar__filter-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.el-toolbar {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.el-toolbar__btn {
|
||||
min-width: 42px;
|
||||
padding: 0.45rem 0.35rem;
|
||||
}
|
||||
.el-toolbar__btn-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the Size slider on desktop.
|
||||
*
|
||||
* On desktop the user has fine-grained corner-drag handles on the canvas
|
||||
* (the Konva transformer's keepRatio scale) plus arrow-key nudge, so the
|
||||
* slider is a redundant control. Hiding it tightens the floating toolbar
|
||||
* vertically without losing any capability. Mobile users can't easily
|
||||
* corner-drag a small touch target on a small screen, so the slider
|
||||
* stays as the primary size affordance there.
|
||||
*
|
||||
* The Opacity slider is intentionally kept on both desktop and mobile —
|
||||
* opacity has no corner-handle equivalent and the slider is its only
|
||||
* control surface. Only the Size row is hidden.
|
||||
*
|
||||
* Breakpoint matches App.jsx's MOBILE_QUERY (max-width: 768px) — the
|
||||
* same line that switches between mobile bottom-sheet and desktop
|
||||
* right-rail. Anything at ≥769px is the desktop layout, where the
|
||||
* canvas is the primary surface and corner drag is available. */
|
||||
@media (min-width: 769px) {
|
||||
.el-toolbar__slider-row--size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
232
src/styles/Header.css
Normal file
232
src/styles/Header.css
Normal file
@@ -0,0 +1,232 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Header — Pawfectly Yours
|
||||
*
|
||||
* Three-zone flex bar: brand on the left, page title in the center, action
|
||||
* cluster on the right. Wraps to two zones on mobile (back-arrow style).
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ph-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto minmax(220px, 1fr);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
margin: 0.75rem;
|
||||
background: var(--brand-pink-tint);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
min-height: var(--header-height);
|
||||
}
|
||||
|
||||
/* Brand --------------------------------------------------------------------- */
|
||||
|
||||
.ph-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ph-logo__mark {
|
||||
font-size: 28px;
|
||||
filter: drop-shadow(0 2px 4px rgba(236, 72, 153, 0.18));
|
||||
}
|
||||
|
||||
.ph-logo__wordmark {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.05;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ph-logo__name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 26px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.ph-logo__tagline {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Title --------------------------------------------------------------------- */
|
||||
|
||||
.ph-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.ph-title span {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Actions ------------------------------------------------------------------- */
|
||||
|
||||
.ph-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ph-iconbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
transition: transform 0.12s ease, background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.ph-iconbtn:hover:not(:disabled) {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.ph-iconbtn:active:not(:disabled) {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.ph-iconbtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ph-iconbtn--menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ph-pillbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1.1rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.ph-pillbtn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.ph-pillbtn--filled {
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 12px -4px rgba(236, 72, 153, 0.55);
|
||||
}
|
||||
|
||||
.ph-pillbtn--filled:hover {
|
||||
background: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.ph-pillbtn--ghost {
|
||||
background: var(--brand-lavender-soft);
|
||||
color: #6b50a4;
|
||||
border-color: rgba(107, 80, 164, 0.18);
|
||||
}
|
||||
|
||||
.ph-pillbtn--ghost:hover {
|
||||
background: #e0d7f3;
|
||||
}
|
||||
|
||||
.ph-cart {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ph-cart:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.ph-cart__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(236, 72, 153, 0.4);
|
||||
border: 2px solid var(--brand-pink-tint);
|
||||
}
|
||||
|
||||
/* Responsive ---------------------------------------------------------------- */
|
||||
|
||||
/* Tablet — title can hide if the grid would crush */
|
||||
@media (max-width: 1100px) {
|
||||
.ph-header {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
}
|
||||
.ph-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ph-pillbtn span {
|
||||
display: none;
|
||||
}
|
||||
.ph-pillbtn {
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ph-header {
|
||||
grid-template-columns: 1fr auto;
|
||||
margin: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
min-height: var(--header-height-mobile);
|
||||
}
|
||||
.ph-logo__tagline {
|
||||
display: none;
|
||||
}
|
||||
.ph-logo__name {
|
||||
font-size: 20px;
|
||||
}
|
||||
.ph-logo__mark {
|
||||
font-size: 22px;
|
||||
}
|
||||
.ph-title {
|
||||
display: none;
|
||||
}
|
||||
.ph-pillbtn,
|
||||
.ph-cart {
|
||||
display: none;
|
||||
}
|
||||
.ph-iconbtn--menu {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* LayersPanel — Pawfectly Yours
|
||||
*
|
||||
* Class names match the rewritten LayersPanel.jsx (S3 + S24):
|
||||
* • Each row is a real <button> for keyboard access (S24).
|
||||
* • A ✓ chip + ring communicates selection without relying on color (S24).
|
||||
* • Drag handle (⋮⋮) per row enables HTML5 DnD reorder (S3).
|
||||
* • Bulk-delete affordance appears in the titlebar when ≥2 are selected (S3).
|
||||
* • Drop indicator (.layers-drop-indicator) is the pink insertion line that
|
||||
* appears above or below the hovered row during a drag.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.layers-empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
@@ -5,38 +17,214 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Titlebar — title on the left, bulk-delete on the right. The bulk-delete
|
||||
* only renders when 2+ rows are selected; in single-select state the bar
|
||||
* is just the title and looks identical to before. */
|
||||
.layers-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.layers-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.layers-bulk-delete {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 1px solid #fecaca;
|
||||
color: #b91c1c;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.layers-bulk-delete:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.layers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Row wrapper: container for the row plus optional drop-indicator lines
|
||||
* above and below it. Using a wrapper rather than positioning the
|
||||
* indicator inside the row makes it easier to render before/after the
|
||||
* row's bbox without margin tricks. */
|
||||
.layers-row-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* The row itself — a flex row holding [delete] [main button] [grip]. It's
|
||||
* a div (not a button) because the row contains two distinct interactive
|
||||
* elements; the row-level click target is .layers-item-main. */
|
||||
.layers-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.layers-item.selected {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
/* Visible ring around selected rows so colorblind users can see
|
||||
* selection without depending on the pink fill alone. (S24.) */
|
||||
box-shadow: 0 0 0 2px rgba(236, 72, 153, 0.18);
|
||||
}
|
||||
|
||||
/* While being dragged, dim the row so the user can see what's happening
|
||||
* and so the drop indicator lines below stand out by contrast. */
|
||||
.layers-item.is-dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Drag grip — vertical dots glyph on the row's right edge. Decorative for
|
||||
* keyboard users (tabIndex=-1 in the JSX); actually pickable for mouse
|
||||
* via HTML5 DnD on the parent .layers-item. */
|
||||
.layers-item-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: -2px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.layers-item.is-dragging .layers-item-grip {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Main row button — wraps the check chip, icon, and name. Reset native
|
||||
* button styling so it fits the row visually but inherits keyboard
|
||||
* accessibility from the underlying <button>. */
|
||||
.layers-item-main {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.5rem 0.5rem;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 0; /* allow ellipsis truncation inside flex */
|
||||
}
|
||||
|
||||
.layers-item-main:focus-visible {
|
||||
outline: 2px solid var(--brand-pink);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Selection ✓ chip — rendered as a small circle on the left of selected
|
||||
* rows. Empty (no glyph, no border) when the row isn't selected so the
|
||||
* left edge of unselected rows reads cleanly. (S24.) */
|
||||
.layers-item-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: transparent;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.layers-item-check.is-on {
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Icon slot — fixed 24×24 box on the left of the row's main button.
|
||||
* Holds one of three things depending on element type:
|
||||
* - <img> for image and sticker layers (a mini preview of the actual
|
||||
* pixel content; see renderIcon in LayersPanel.jsx)
|
||||
* - <svg> for text layers (a T glyph matching the Text tab nav icon)
|
||||
* - <span> fallback dot for layers we don't have a special icon for
|
||||
*
|
||||
* `overflow: hidden` + rounded corners give image thumbnails a polished
|
||||
* tile look; the soft tint background ensures transparent stickers still
|
||||
* read against the row. */
|
||||
.layers-item-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: var(--brand-pink-tint);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Image / sticker thumbnail. object-fit: contain keeps the artwork's
|
||||
* aspect ratio without cropping inside the square slot. */
|
||||
.layers-item-icon-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Text-layer 'T' icon. Sized slightly smaller than the slot so it doesn't
|
||||
* touch the rounded edges; inherits currentColor from the row text so it
|
||||
* inverts cleanly when the row is selected. */
|
||||
.layers-item-icon-svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Fallback dot for any element type without a custom icon. */
|
||||
.layers-item-icon-fallback {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* When the row is selected, switch the icon slot's tint to read against
|
||||
* the pink selection background — a translucent white tile that lets the
|
||||
* thumbnail content show through and lifts the SVG T icon's contrast. */
|
||||
.layers-item.selected .layers-item-icon {
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.layers-item-name {
|
||||
@@ -47,23 +235,79 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layers-item-name.selected {
|
||||
.layers-item.selected .layers-item-name {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layers-item-delete {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
/* Per-row action buttons (Change 4). Sit on the RIGHT side of each
|
||||
* row as siblings of .layers-item-main (the row's main click target),
|
||||
* stacked horizontally: [main] [duplicate] [lock] [delete] [grip].
|
||||
*
|
||||
* Sizing matches the previous .layers-item-delete (28×row-height) so
|
||||
* the row maintains its original visual weight; each button is just
|
||||
* a touch slimmer than the main click target so the three icons
|
||||
* don't dominate the row. Color states:
|
||||
* • idle: muted grey, same as old delete X
|
||||
* • hover: brand-pink tint
|
||||
* • active (lock when locked): brand-pink fill + white icon —
|
||||
* gives the user instant feedback that the toggle is on
|
||||
* • delete hover: red, retaining the destructive-action colour
|
||||
* signal from the previous design
|
||||
*/
|
||||
.layers-item-action {
|
||||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.layers-item-action:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.layers-item-action:focus-visible {
|
||||
outline: 2px solid var(--brand-pink);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.layers-item-action.is-active {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.layers-item-action.is-active:hover {
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Delete variant — same base + destructive-red hover so the user
|
||||
* gets the same colour-coded warning the previous bare ✕ button had. */
|
||||
.layers-item-action--delete:hover {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Drop indicator — a thin pink line that appears above or below the
|
||||
* hovered row during a drag, communicating where the dragged row will
|
||||
* land if dropped. Rendered as a sibling of the row inside the row
|
||||
* wrapper so its position is unambiguous. (S3.) */
|
||||
.layers-drop-indicator {
|
||||
height: 2px;
|
||||
background: var(--brand-pink);
|
||||
border-radius: 1px;
|
||||
margin: 0 4px;
|
||||
box-shadow: 0 0 0 2px rgba(236, 72, 153, 0.18);
|
||||
}
|
||||
|
||||
102
src/styles/MobileBottomSheet.css
Normal file
102
src/styles/MobileBottomSheet.css
Normal file
@@ -0,0 +1,102 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* MobileBottomSheet — slide-up modal sheet for mobile.
|
||||
*
|
||||
* The sheet uses transform-only animation (no width/height/top changes) so it
|
||||
* stays on the compositor. Backdrop is a full-screen <button> for free
|
||||
* dismiss-on-tap accessibility. When closed the sheet sits *just* below the
|
||||
* viewport (translateY(100%)) so it isn't focusable but is paint-ready.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.mbs {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 800;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mbs.is-open {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mbs__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(31, 29, 35, 0);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.22s ease;
|
||||
}
|
||||
|
||||
.mbs.is-open .mbs__backdrop {
|
||||
background: rgba(31, 29, 35, 0.45);
|
||||
}
|
||||
|
||||
.mbs__sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-height: 78vh;
|
||||
background: var(--brand-cream);
|
||||
border-top-left-radius: var(--radius-xl);
|
||||
border-top-right-radius: var(--radius-xl);
|
||||
box-shadow: 0 -16px 40px rgba(31, 29, 35, 0.18);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0.16, 1), max-height 0.22s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Respect iOS home indicator. */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Compact mode — sheet shrinks to leave room for the canvas above. The
|
||||
* `max-height` transition is on the same property as the regular sheet so
|
||||
* height changes (e.g. user selecting a text element while the sheet is
|
||||
* already open) animate rather than snap. */
|
||||
.mbs.is-compact .mbs__sheet {
|
||||
max-height: 55vh;
|
||||
}
|
||||
|
||||
.mbs.is-open .mbs__sheet {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mbs__handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mbs__handle-grip {
|
||||
display: block;
|
||||
width: 44px;
|
||||
height: 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-strong);
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.mbs__handle:hover .mbs__handle-grip {
|
||||
background: var(--brand-pink);
|
||||
}
|
||||
|
||||
.mbs__content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* let internal scroll containers take over */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Body scroll-lock helper — applied while a sheet is open. */
|
||||
body.has-bottom-sheet {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
16
src/styles/ModelSilhouette.css
Normal file
16
src/styles/ModelSilhouette.css
Normal file
@@ -0,0 +1,16 @@
|
||||
.model-silhouette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
/* Fade in/out smoothly when the toggle flips. */
|
||||
animation: model-fade-in 0.32s ease-out;
|
||||
}
|
||||
|
||||
@keyframes model-fade-in {
|
||||
from { opacity: 0; transform: scale(0.985); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
226
src/styles/PreviewModal.css
Normal file
226
src/styles/PreviewModal.css
Normal file
@@ -0,0 +1,226 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* PreviewModal — fullscreen print-preview overlay.
|
||||
*
|
||||
* Z-index 1000 to sit above the bottom sheet (800), FAB (700), and any
|
||||
* canvas-area floating UI (export-toast, app-toast at 12-13). Only the
|
||||
* photo-pre-editor goes higher.
|
||||
*
|
||||
* Body-scroll lock is applied via the `has-preview-modal` class to prevent
|
||||
* pan-while-zoomed on touch devices.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.preview-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(31, 29, 35, 0.62);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
animation: pm-backdrop-in 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes pm-backdrop-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.preview-modal__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--brand-cream);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
|
||||
width: min(720px, 100%);
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: pm-panel-in 0.22s cubic-bezier(0.32, 0.72, 0.16, 1);
|
||||
}
|
||||
|
||||
@keyframes pm-panel-in {
|
||||
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.preview-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.9rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-modal__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.preview-modal__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0.4rem 0.55rem;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.preview-modal__close:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.preview-modal__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--brand-pink-tint);
|
||||
padding: 1.5rem;
|
||||
min-height: 280px;
|
||||
/* Subtle checkerboard so transparent PNG areas read as transparent
|
||||
* instead of looking like white-on-white. */
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(0, 0, 0, 0.04) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0, 0, 0, 0.04) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.04) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.04) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
}
|
||||
|
||||
.preview-modal__image {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.preview-modal__loading,
|
||||
.preview-modal__error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-modal__error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.preview-modal__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.95rem 1.2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-modal__hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-modal__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-modal__btn {
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.preview-modal__btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.preview-modal__btn--ghost:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.preview-modal__btn--primary {
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
border-color: var(--brand-pink);
|
||||
box-shadow: 0 6px 12px -3px rgba(236, 72, 153, 0.5);
|
||||
}
|
||||
|
||||
.preview-modal__btn--primary:hover:not(:disabled) {
|
||||
background: var(--brand-pink-strong);
|
||||
border-color: var(--brand-pink-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.preview-modal__btn--primary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.has-preview-modal {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Mobile — full-bleed panel, footer wraps if needed. */
|
||||
@media (max-width: 600px) {
|
||||
.preview-modal {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.preview-modal__panel {
|
||||
max-height: 96vh;
|
||||
}
|
||||
.preview-modal__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.preview-modal__actions {
|
||||
width: 100%;
|
||||
}
|
||||
.preview-modal__btn {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.preview-modal__hint {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
263
src/styles/ShirtOptionsPanel.css
Normal file
263
src/styles/ShirtOptionsPanel.css
Normal file
@@ -0,0 +1,263 @@
|
||||
.shirt-options {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem 1.4rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Toggle wrapper — the entire header + summary block is a single button
|
||||
* so clicking anywhere on the visible (collapsed) panel expands it.
|
||||
* Reset all the browser's default button chrome so it visually matches
|
||||
* the rest of the panel: no background, no border, no padding, full
|
||||
* width, inherit font and text alignment from the parent. */
|
||||
.shirt-options__toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shirt-options__toggle:focus-visible {
|
||||
outline: 2px solid var(--brand-pink);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.shirt-options__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
/* No bottom margin when collapsed — the summary chip sits directly
|
||||
* under the title. The expanded content has its own top margin that
|
||||
* separates it from the header (see .shirt-options__content). */
|
||||
}
|
||||
|
||||
.shirt-options.is-expanded .shirt-options__header {
|
||||
/* When expanded, the summary chip is gone and the color row needs
|
||||
* breathing room from the title. Restore the original spacing. */
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.shirt-options__title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.shirt-options__title-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.shirt-options__price {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--brand-pink);
|
||||
letter-spacing: -0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Compact summary — visible only when collapsed. A single row showing
|
||||
* a mini color swatch and the color label · size text. The chevron
|
||||
* affordance lives in the header (always visible) so the user has the
|
||||
* same expand/collapse signal in both states. */
|
||||
.shirt-options__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shirt-options__summary-swatch {
|
||||
--swatch-color: #fff;
|
||||
--swatch-border: #e8e8ec;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--swatch-color);
|
||||
border: 1px solid var(--swatch-border);
|
||||
box-shadow: inset 0 -1px 2px rgba(0, 0, 0, 0.06);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shirt-options__summary-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shirt-options__chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
/* Rotates 180° when the section is expanded so the chevron points
|
||||
* up ("click to close") instead of down ("click to reveal"). The
|
||||
* transition pairs with the content's max-height transition so the
|
||||
* arrow and the content animate together. */
|
||||
transition: color 0.12s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.shirt-options.is-expanded .shirt-options__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.shirt-options__toggle:hover .shirt-options__chevron {
|
||||
color: var(--brand-pink);
|
||||
}
|
||||
|
||||
/* Expanded content wrapper. The max-height transition is what produces
|
||||
* the collapse / expand animation. We render the markup regardless of
|
||||
* state (rather than conditional rendering) so the transition has
|
||||
* something to interpolate between.
|
||||
*
|
||||
* 240px comfortably fits the color swatches row + size pills row at
|
||||
* typical sidebar widths. If a future redesign adds more rows this
|
||||
* cap may need to grow; values much larger than the content's natural
|
||||
* height slow the perceived animation because max-height interpolates
|
||||
* across the full declared range. */
|
||||
.shirt-options__content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.18s ease, opacity 0.12s ease, margin-top 0.18s ease;
|
||||
}
|
||||
|
||||
.shirt-options.is-collapsed .shirt-options__content {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
/* visibility:hidden after the transition completes pulls the content
|
||||
* out of the tab order and the accessibility tree. We don't use
|
||||
* `display:none` because that would prevent the transition from
|
||||
* running at all. */
|
||||
visibility: hidden;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.shirt-options.is-expanded .shirt-options__content {
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.shirt-options__row + .shirt-options__row {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.shirt-options__label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* Color swatches ----------------------------------------------------------- */
|
||||
|
||||
.shirt-options__swatches {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shirt-options__swatch {
|
||||
--swatch-color: #fff;
|
||||
--swatch-border: #e8e8ec;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 2px solid transparent;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.shirt-options__swatch:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.shirt-options__swatch:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.shirt-options__swatch-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--swatch-color);
|
||||
border: 1px solid var(--swatch-border);
|
||||
box-shadow: inset 0 -2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.shirt-options__swatch.is-selected {
|
||||
border-color: var(--brand-pink);
|
||||
}
|
||||
|
||||
/* Size pills --------------------------------------------------------------- */
|
||||
|
||||
.shirt-options__sizes {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shirt-options__size {
|
||||
min-width: 56px;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1.5px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.4px;
|
||||
transition: background 0.12s ease, border 0.12s ease, color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.shirt-options__size:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
}
|
||||
|
||||
.shirt-options__size:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.shirt-options__size.is-selected {
|
||||
border-color: var(--brand-pink);
|
||||
color: var(--brand-pink-strong);
|
||||
background: var(--brand-pink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.shirt-options {
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.shirt-options__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.shirt-options__price {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,248 @@
|
||||
.sidebar-tabs {
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Right panel ("rp") — Pawfectly Yours
|
||||
*
|
||||
* Vertically scrollable container with:
|
||||
* - Shirt Options card (top)
|
||||
* - Tab nav + active tab panel (middle, fills available space)
|
||||
* - Sticky Add-to-Cart bar (bottom)
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.rp {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-tab-btn {
|
||||
.rp__scroll {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.85rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--brand-pink-soft) transparent;
|
||||
}
|
||||
|
||||
/* Direct children of .rp__scroll must NOT shrink.
|
||||
*
|
||||
* Without this, every section in the scroll column (Shirt Options card,
|
||||
* the .rp__tools tab card, .rp__layers) is a flex item with the default
|
||||
* `flex-shrink: 1` plus `min-height: auto`. When the total intrinsic
|
||||
* height of those sections exceeds .rp__scroll's available height, flex
|
||||
* layout tries to SHRINK them proportionally to fit — rather than
|
||||
* leaving them at natural size and letting the container scroll. That
|
||||
* collides badly with .rp__tools having its own `overflow: hidden`:
|
||||
* once .rp__tools is forced shorter than its intrinsic content, the
|
||||
* bottom of the active tab (e.g. the Text tab's "Add text to canvas"
|
||||
* submit button) gets clipped by the card's own overflow, even though
|
||||
* .rp__scroll would happily provide a scrollbar if asked.
|
||||
*
|
||||
* Setting `flex-shrink: 0` keeps each section at its natural height.
|
||||
* The combined column then legitimately overflows .rp__scroll, the
|
||||
* container's `overflow-y: auto` kicks in, and the user scrolls to
|
||||
* reach the bottom of the tab card. This invariant only became
|
||||
* visible after Change 13 in [[Refinements_2026-05-20_Part2]] gave
|
||||
* .rp__scroll a definite height for the first time — before that,
|
||||
* the chain was all min-height and the scroll container just expanded
|
||||
* to fit. */
|
||||
.rp__scroll > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rp__scroll::-webkit-scrollbar { width: 8px; }
|
||||
.rp__scroll::-webkit-scrollbar-track { background: transparent; }
|
||||
.rp__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.rp__tools {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Layers card — sibling of .rp__tools, sits below it inside .rp__scroll.
|
||||
* Same card chrome (white bg, rounded, hairline border, soft shadow) so
|
||||
* the two surfaces read as a stacked pair. Padding matches the
|
||||
* .rp__tabpanel inset so the LayersPanel's titlebar lines up with the
|
||||
* tab panel content visually. The LayersPanel itself handles its own
|
||||
* empty state and titlebar, so this section just provides the frame. */
|
||||
.rp__layers {
|
||||
background: #fff;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Tabs --------------------------------------------------------------------- */
|
||||
|
||||
.rp__tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-secondary);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.rp__tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.rp__tab {
|
||||
flex: 1 1 auto;
|
||||
min-width: 84px;
|
||||
padding: 0.85rem 0.4rem 0.7rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-tab-btn.active {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-bottom: 2.5px solid transparent;
|
||||
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-tab-icon {
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
.rp__tab svg {
|
||||
color: var(--text-muted);
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
.rp__tab:hover {
|
||||
color: var(--brand-pink-strong);
|
||||
background: rgba(236, 72, 153, 0.04);
|
||||
}
|
||||
|
||||
.rp__tab:hover svg {
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.rp__tab.is-active {
|
||||
color: var(--brand-pink);
|
||||
border-bottom-color: var(--brand-pink);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rp__tab.is-active svg {
|
||||
color: var(--brand-pink);
|
||||
}
|
||||
|
||||
.rp__tabpanel {
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
}
|
||||
|
||||
/* Sticky cart bar ---------------------------------------------------------- */
|
||||
|
||||
.rp__cart-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rp__add-to-cart {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.95rem 1.4rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.2px;
|
||||
box-shadow: 0 8px 16px -4px rgba(236, 72, 153, 0.55);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
.rp__add-to-cart:hover {
|
||||
background: var(--brand-pink-strong);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 20px -6px rgba(236, 72, 153, 0.6);
|
||||
}
|
||||
|
||||
.rp__add-to-cart:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Disabled state — paired with bounds-violation warning. The button is
|
||||
* dimmed, not-allowed cursor, and the colored shadow drops away so it
|
||||
* stops looking like a primary CTA. The :hover and :active rules above
|
||||
* are overridden so the user gets no visual response that suggests
|
||||
* clicking it would do something. */
|
||||
.rp__add-to-cart:disabled,
|
||||
.rp__add-to-cart.is-disabled {
|
||||
background: #e5e7eb;
|
||||
color: #9ca3af;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto; /* keep the title tooltip working on hover */
|
||||
}
|
||||
|
||||
.rp__add-to-cart:disabled:hover,
|
||||
.rp__add-to-cart.is-disabled:hover {
|
||||
background: #e5e7eb;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rp__add-to-cart:disabled:active,
|
||||
.rp__add-to-cart.is-disabled:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Reason caption shown above the disabled button. */
|
||||
.rp__cart-reason {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 251, 235, 0.95);
|
||||
border: 1px solid #fbbf24;
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rp__add-to-cart-price {
|
||||
font-weight: 800;
|
||||
margin-left: 0.5rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.rp__scroll { padding: 0.6rem; gap: 0.6rem; }
|
||||
.rp__tabpanel { padding: 0.85rem 0.85rem 1rem; }
|
||||
.rp__layers { padding: 0.85rem 0.85rem 1rem; }
|
||||
.rp__tab { min-width: 76px; font-size: 11.5px; }
|
||||
.rp__tab svg { width: 16px; height: 16px; }
|
||||
}
|
||||
|
||||
@@ -1,47 +1,234 @@
|
||||
.stickers-title {
|
||||
margin: 0 0 1rem 0;
|
||||
/* Stickers / Emoji tabs.
|
||||
*
|
||||
* Two tabs share these styles: <StickersTab> (image stickers from
|
||||
* public/stickers/) and <EmojiTab> (rasterized emoji glyphs). They render
|
||||
* the same grid layout; only the cell contents differ. Image stickers use
|
||||
* `<img>` inside the cell button; emoji use a `<span>` with a glyph.
|
||||
*
|
||||
* Class naming convention: `.st__*` everywhere; `.st--full` modifier on
|
||||
* the Stickers tab root for header / category bar, `.st--emoji` modifier
|
||||
* on the Emoji tab root. The grid modifier `.st__grid--emoji` tightens
|
||||
* the grid for the higher-density emoji layout.
|
||||
*/
|
||||
|
||||
.st {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
/* Header — Stickers tab only ---------------------------------------------- */
|
||||
|
||||
.st__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.st__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stickers-categories {
|
||||
/* Empty-state copy for when public/stickers/ has no images yet. Centered
|
||||
* and muted so it sits in the panel quietly rather than reading as an
|
||||
* error. */
|
||||
.st__empty {
|
||||
margin: 0;
|
||||
padding: 1.5rem 0.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Category pills (Stickers tab only) -------------------------------------
|
||||
*
|
||||
* Pills wrap to as many rows as needed (`flex-wrap: wrap`) and a max-height
|
||||
* + vertical scroll kicks in only when there are *so many* categories that
|
||||
* even wrapped they'd consume too much of the panel. This avoids the prior
|
||||
* behavior of horizontal overflow that hid pills off-screen on the right.
|
||||
*/
|
||||
|
||||
.st__categories {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 1rem;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
max-height: 96px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--brand-pink-soft) transparent;
|
||||
}
|
||||
|
||||
.stickers-category-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-primary);
|
||||
.st__categories::-webkit-scrollbar { width: 6px; }
|
||||
.st__categories::-webkit-scrollbar-track { background: transparent; }
|
||||
.st__categories::-webkit-scrollbar-thumb {
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.st__category {
|
||||
flex-shrink: 0;
|
||||
padding: 0.4rem 0.95rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.stickers-category-btn.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
.st__category:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
border-color: var(--brand-pink);
|
||||
}
|
||||
|
||||
.st__category.is-active {
|
||||
background: var(--brand-pink);
|
||||
border-color: var(--brand-pink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stickers-grid {
|
||||
/* Grid --------------------------------------------------------------------
|
||||
*
|
||||
* The grid has its OWN scroll container (max-height + overflow-y: auto) so
|
||||
* loading hundreds of stickers doesn't expand the right panel and force the
|
||||
* main area to scroll.
|
||||
*
|
||||
* Image stickers (.st__grid in <StickersTab>) lay out as 47×47 fixed-size
|
||||
* cells via `auto-fill` columns — the source artwork is 47px native, so
|
||||
* rendering thumbnails larger would just upscale a small bitmap.
|
||||
* Emoji stickers (.st__grid--emoji in <EmojiTab>) keep the original 6-up
|
||||
* fluid grid: emoji glyphs are vector-rendered by the browser and read
|
||||
* better at a larger cell size. */
|
||||
|
||||
.st__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, 47px);
|
||||
justify-content: start;
|
||||
gap: 0.55rem;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--brand-pink-soft) transparent;
|
||||
}
|
||||
|
||||
.sticker-btn {
|
||||
aspect-ratio: 1;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
.st__grid::-webkit-scrollbar { width: 6px; }
|
||||
.st__grid::-webkit-scrollbar-track { background: transparent; }
|
||||
.st__grid::-webkit-scrollbar-thumb {
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.st__grid--emoji {
|
||||
/* Emoji grid overrides the image-grid layout. Glyphs render best at
|
||||
* a larger cell size, so we keep the original fluid 6-column grid
|
||||
* here rather than the 47px fixed cells used for image stickers.
|
||||
*
|
||||
* `minmax(0, 1fr)` (rather than plain `1fr`) is important: `1fr`
|
||||
* tracks size to their items' max-content by default, and our
|
||||
* `.st__sticker` base rule sets `width: 47px` — so `1fr` would
|
||||
* resolve to 47px per track and the cells would crowd at the
|
||||
* left of the grid. The `0` minimum lets the tracks shrink below
|
||||
* their items' intrinsic size, so the six tracks actually
|
||||
* partition the grid's full width equally. */
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
justify-content: stretch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Cell --------------------------------------------------------------------
|
||||
*
|
||||
* Image-sticker cells are pinned to 47×47 (the source asset's native
|
||||
* size). Emoji cells inherit the cell base styles but override with
|
||||
* `aspect-ratio: 1 / 1` so they fill whatever 1fr column width they get.
|
||||
* Background is a soft pink tint so transparent stickers have something
|
||||
* to read against. Hover lifts the cell slightly via a scale transform
|
||||
* and a stronger background tint. */
|
||||
|
||||
.st__sticker {
|
||||
width: 47px;
|
||||
height: 47px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--brand-pink-tint);
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease;
|
||||
padding: 0;
|
||||
/* `overflow: hidden` so the inner <img> can use object-fit without
|
||||
* spilling past the rounded corners on hover. */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.st__grid--emoji .st__sticker {
|
||||
/* Emoji cells fill their fluid grid columns rather than being
|
||||
* pinned to the 47px used for image-sticker tiles. With
|
||||
* `grid-template-columns: repeat(6, minmax(0, 1fr))` on the
|
||||
* grid above, each track is now a real fluid width, so the cell
|
||||
* just needs `width: auto` to stretch into it (grid items stretch
|
||||
* to fill their tracks by default when `width` isn't explicit).
|
||||
* `aspect-ratio: 1/1` derives the cell height from its rendered
|
||||
* width — height stays `auto` for the aspect rule to take over. */
|
||||
width: auto;
|
||||
height: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.st__sticker:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
border-color: var(--brand-pink);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.st__sticker:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.st__sticker-emoji {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.st__grid--emoji .st__sticker-emoji {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
/* Image sticker thumbnails. `object-fit: contain` keeps the artwork's
|
||||
* aspect ratio without cropping; at 47×47 the source asset's native
|
||||
* size we fill the full cell rather than insetting (the inset was
|
||||
* needed when cells were ~50–60px and the image was meant to read as
|
||||
* "sticker on a pink tile"). */
|
||||
.st__sticker-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* The browser sometimes runs an implicit fade-in when a lazy image
|
||||
* decodes; opt out for snappier feel. */
|
||||
display: block;
|
||||
/* Prevent the image from being dragged out of the panel as a
|
||||
* standalone DOM drag — the user's drag should reach the parent
|
||||
* button's click handler instead. */
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.st__grid {
|
||||
/* Fixed 47px cells already wrap naturally via auto-fill, so no
|
||||
* mobile-specific override is needed here. */
|
||||
}
|
||||
.st__grid--emoji {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,6 @@
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,140 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* TemplatesTab — Pawfectly Yours pink restyle.
|
||||
* Class names already match the JSX (legacy .templates-* / .template-*),
|
||||
* so this just re-skins them in the brand palette.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.templates-hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.templates-title {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0 0 0.4rem 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.templates-description {
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.templates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.template-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.7rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.template-btn:hover {
|
||||
background: var(--brand-pink-wash);
|
||||
border-color: var(--brand-pink);
|
||||
}
|
||||
|
||||
.template-btn.selected {
|
||||
background: var(--bg-secondary);
|
||||
background: var(--brand-pink-soft);
|
||||
border-color: var(--brand-pink);
|
||||
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.12);
|
||||
}
|
||||
|
||||
.template-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--brand-pink-tint);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 24px;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.template-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.template-slots-badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
background: var(--accent);
|
||||
padding: 2px 7px;
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.template-slots-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-tertiary);
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.85rem;
|
||||
background: var(--brand-pink-wash);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px dashed var(--brand-pink);
|
||||
}
|
||||
|
||||
.template-slots-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
margin: 0 0 0.55rem 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.template-slots-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.template-slot-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.template-slot-btn:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
border-color: var(--brand-pink);
|
||||
}
|
||||
|
||||
.template-slot-icon {
|
||||
@@ -115,4 +145,5 @@
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,99 +1,472 @@
|
||||
.text-tab-title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* TextTab — Pawfectly Yours
|
||||
*
|
||||
* Class names match the .tt__ scheme used in TextTab.jsx.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.text-tab-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Edit-mode banner — small pink chip that appears above the form when a
|
||||
* text element is selected on the canvas. Tells the user the form's edits
|
||||
* will live-update the selection. */
|
||||
|
||||
.text-tab-label {
|
||||
display: block;
|
||||
.tt__mode-banner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
align-self: flex-start;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.text-tab-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-body);
|
||||
.tt__mode-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--brand-pink);
|
||||
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.18);
|
||||
animation: tt-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.text-tab-textarea {
|
||||
@keyframes tt-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.18); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(236, 72, 153, 0.08); }
|
||||
}
|
||||
|
||||
.tt__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tt__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.tt__label-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.tt__value {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tt__textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-body);
|
||||
resize: vertical;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.text-tab-select {
|
||||
.tt__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-pink);
|
||||
box-shadow: 0 0 0 3px var(--brand-pink-soft);
|
||||
}
|
||||
|
||||
.tt__select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
background: var(--bg-primary);
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.text-tab-range {
|
||||
width: 100%;
|
||||
.tt__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-pink);
|
||||
box-shadow: 0 0 0 3px var(--brand-pink-soft);
|
||||
}
|
||||
|
||||
.text-tab-color-group {
|
||||
/* Color row --------------------------------------------------------------- */
|
||||
|
||||
.tt__colors {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tt__colors--inline {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
/* Breathing room so the selected swatch's pink ring (a 2px outward
|
||||
* box-shadow) isn't clipped by the scroll container's edges.
|
||||
* `overflow-x: auto` implicitly forces `overflow-y` to auto/hidden
|
||||
* per the CSS spec, so without padding the ring gets sliced on all
|
||||
* four sides — top, bottom, and at the leftmost/rightmost swatches
|
||||
* (which are pinned against the container's content edges).
|
||||
* 3px > 2px ring width gives the shadow room to render fully. */
|
||||
padding: 3px 4px;
|
||||
}
|
||||
|
||||
.tt__colors--inline::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tt__color {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px var(--border-strong);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.tt__color--small {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tt__color:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.tt__color.is-selected {
|
||||
box-shadow: 0 0 0 2px var(--brand-pink);
|
||||
}
|
||||
|
||||
.tt__color--custom {
|
||||
background: linear-gradient(135deg, #fff, var(--brand-pink-soft));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
color: var(--brand-pink-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tt__color--custom input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Eyedropper button — sized like a swatch but with a stroke icon and a
|
||||
* cool/lavender gradient that's distinct from both the curated palette
|
||||
* and the custom-color square. Hover reveals the brand-pink to match
|
||||
* other interactive controls. */
|
||||
.tt__color--eyedropper {
|
||||
background: linear-gradient(135deg, #fff, var(--brand-lavender-soft, #e9defc));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.tt__color--eyedropper:hover {
|
||||
color: var(--brand-pink-strong);
|
||||
box-shadow: 0 0 0 2px var(--brand-pink);
|
||||
}
|
||||
|
||||
/* Recent-colors row — sits above the QUICK_COLORS swatches, separated by a
|
||||
* dashed underline so the two sets of swatches read as distinct: "colors
|
||||
* you've used" vs "the curated palette". */
|
||||
.tt__recent-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.4rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.text-tab-color-input {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
.tt__recent-caption {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Outline (stroke) controls (S8) ----------------------------------------- */
|
||||
|
||||
.tt__outline-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.text-tab-color-text {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
.tt__outline-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text-tab-preview {
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-tab-preview-text {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.text-tab-submit {
|
||||
.tt__outline-toggle-track {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem;
|
||||
border: none;
|
||||
height: 100%;
|
||||
background: var(--border-strong);
|
||||
border-radius: 999px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.tt__outline-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tt__outline-toggle input:checked ~ .tt__outline-toggle-track {
|
||||
background: var(--brand-pink);
|
||||
}
|
||||
|
||||
.tt__outline-toggle input:checked ~ .tt__outline-toggle-track .tt__outline-toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.tt__outline-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.tt__outline-swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tt__outline-swatch-fill {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.tt__outline-slider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tt__outline-width {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
min-width: 2.5em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Arc controls -----------------------------------------------------------
|
||||
*
|
||||
* Three-button picker. Each button stacks an SVG curve icon over a
|
||||
* short text label so the user can read both at-a-glance. The buttons
|
||||
* are a segmented control: equal widths, mutually exclusive selection,
|
||||
* one always active. Selected button uses the brand-pink fill + white
|
||||
* foreground so it reads as the chosen state without ambiguity.
|
||||
*
|
||||
* Replaced an earlier bidirectional slider. Horizontal sliders don't
|
||||
* have an innate up/down semantic, so requiring users to internalize
|
||||
* "drag right = curve down" was a learnability tax. The buttons make
|
||||
* the mapping obvious from the icon shapes alone. */
|
||||
|
||||
.tt__arc-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.tt__arc-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.6rem 0.4rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease, transform 0.08s ease;
|
||||
}
|
||||
|
||||
.tt__arc-btn:hover {
|
||||
border-color: var(--brand-pink);
|
||||
color: var(--brand-pink-strong);
|
||||
background: var(--brand-pink-soft);
|
||||
}
|
||||
|
||||
.tt__arc-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.tt__arc-btn.is-selected {
|
||||
background: var(--brand-pink);
|
||||
border-color: var(--brand-pink);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Icon takes its color from the button's currentColor so it inverts
|
||||
* cleanly on selected state. Fixed dimensions keep the three buttons
|
||||
* vertically aligned regardless of label length. */
|
||||
.tt__arc-btn-icon {
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tt__arc-btn-label {
|
||||
line-height: 1;
|
||||
/* Tiny letter-spacing makes the all-caps-ish labels read cleanly at
|
||||
* 11px without looking cramped against the icon above. */
|
||||
letter-spacing: 0.2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Label-row variant that holds the label alongside a small inline
|
||||
* widget on the LEFT side of the row (rather than the right). The
|
||||
* base `.tt__label-row` uses `space-between` to push the secondary
|
||||
* widget to the right — that's right for the Outline row (label +
|
||||
* toggle), and for the Size row (label + value). For the Color row
|
||||
* the indicator sits IMMEDIATELY next to the label as a left-aligned
|
||||
* pair, so we wrap them together in this container and let the row
|
||||
* itself handle no further layout. Centered alignment keeps a small
|
||||
* swatch (~14px) visually balanced with the label text. */
|
||||
.tt__label-with-swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Current-color indicator next to the "Color" label. Small filled
|
||||
* circle with a thin border so a white fill stays distinguishable
|
||||
* from the white sidebar surface (otherwise white-on-white reads as
|
||||
* "no indicator at all"). The border colour matches the curated
|
||||
* palette swatches' outer ring so the indicator and palette read as
|
||||
* the same visual family. Inline `background-color` is set by the
|
||||
* React component from the current `fill` value. */
|
||||
.tt__current-swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
/* Smooth swap when the user picks a different colour — same
|
||||
* timing as the preview swatch's background transition so the
|
||||
* indicator and preview animate in lockstep when both update. */
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
/* Preview ----------------------------------------------------------------- */
|
||||
|
||||
.tt__preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 64px;
|
||||
padding: 1rem;
|
||||
/* Brand pink wash. Same colour used as the soft pink surface
|
||||
* elsewhere in the app, kept stable here for ALL fills so the
|
||||
* preview section doesn't visually pop relative to the rest of
|
||||
* the Text tab.
|
||||
*
|
||||
* Earlier iterations explored swapping the background to a
|
||||
* darker pink when the user's chosen fill had bad contrast
|
||||
* against the wash (white on near-white, peach on near-white,
|
||||
* etc). The contrast was great, but the saturated dark pink
|
||||
* drew too much attention to the section and broke the quiet
|
||||
* "swatch confirming styling" role the preview is supposed to
|
||||
* play. The fix moved to TextTab.jsx: when the fill has low
|
||||
* contrast against this wash, the React component applies a
|
||||
* soft dark `text-shadow` to the preview text itself (see the
|
||||
* LOW_CONTRAST_TEXT_SHADOW constant). The shadow gives each
|
||||
* glyph a visible silhouette without recolouring the surface. */
|
||||
background: var(--brand-pink-wash);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Submit ------------------------------------------------------------------ */
|
||||
|
||||
.tt__submit {
|
||||
width: 100%;
|
||||
padding: 0.85rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.tt__submit:hover {
|
||||
background: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.tt__submit:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Hide the font-size slider on desktop.
|
||||
*
|
||||
* On desktop the user has fine-grained corner-drag handles on the canvas
|
||||
* (the Konva transformer's keepRatio scale) plus arrow-key nudge, so the
|
||||
* slider is a redundant control that just takes vertical space in the
|
||||
* right-rail Text tab. Mobile users can't easily corner-drag a small
|
||||
* touch target on a small screen, so the slider stays as the primary
|
||||
* size affordance there.
|
||||
*
|
||||
* Breakpoint matches App.jsx's MOBILE_QUERY (max-width: 768px) — the
|
||||
* same line that switches the layout between mobile bottom-sheet and
|
||||
* desktop right-rail. Anything at ≥769px is the desktop layout, where
|
||||
* the canvas is the primary surface and corner drag is available. */
|
||||
@media (min-width: 769px) {
|
||||
.tt__field--size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,201 @@
|
||||
.upload-tab-title {
|
||||
margin: 0 0 1rem 0;
|
||||
.ut {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Drop zone --------------------------------------------------------------- */
|
||||
|
||||
.ut__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--brand-pink-wash);
|
||||
border: 2px dashed var(--brand-pink);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ut__dropzone:hover,
|
||||
.ut__dropzone:focus-visible {
|
||||
background: var(--brand-pink-soft);
|
||||
border-color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.ut__dropzone.is-dragging {
|
||||
background: var(--brand-pink-soft);
|
||||
border-color: var(--brand-pink-strong);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.ut__dropzone-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: var(--brand-pink);
|
||||
margin-bottom: 0.25rem;
|
||||
box-shadow: 0 2px 6px rgba(236, 72, 153, 0.18);
|
||||
}
|
||||
|
||||
.ut__dropzone-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-dropzone.dragging {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.upload-dropzone-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-dropzone-text {
|
||||
.ut__dropzone-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.upload-dropzone-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
.ut__browse {
|
||||
color: var(--brand-pink);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.upload-hidden-input {
|
||||
.ut__hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent-bg);
|
||||
/* Status ------------------------------------------------------------------ */
|
||||
|
||||
.ut__status {
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
color: var(--brand-pink-strong);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
/* Recent Uploads ---------------------------------------------------------- */
|
||||
|
||||
.ut__recent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.ut__recent-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ut__recent-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ut__see-all {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--brand-pink);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ut__see-all:hover {
|
||||
color: var(--brand-pink-strong);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ut__recent-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Card wrapper so the thumb and the delete bar read as a single unit.
|
||||
* Hover lifts and tints the whole card; the delete button still gets its
|
||||
* own red-tint hover on top of that so the destructive affordance is
|
||||
* obvious. */
|
||||
.ut__recent-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
|
||||
.ut__recent-item:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.ut__recent-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: var(--brand-pink-tint);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.ut__recent-item:hover .ut__recent-thumb,
|
||||
.ut__recent-item:focus-within .ut__recent-thumb {
|
||||
border-color: var(--brand-pink);
|
||||
}
|
||||
|
||||
.ut__recent-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ut__recent-delete {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.25rem;
|
||||
background: var(--brand-pink-soft);
|
||||
border: 2px solid transparent;
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.ut__recent-item:hover .ut__recent-delete,
|
||||
.ut__recent-item:focus-within .ut__recent-delete {
|
||||
border-color: var(--brand-pink);
|
||||
}
|
||||
|
||||
.ut__recent-delete:hover,
|
||||
.ut__recent-delete:focus-visible {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ut__recent-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
.ut__dropzone {
|
||||
padding: 1.5rem 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
66
src/styles/ZoomControls.css
Normal file
66
src/styles/ZoomControls.css
Normal file
@@ -0,0 +1,66 @@
|
||||
.zoom-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.zoom-controls__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.zoom-controls__btn:hover:not(:disabled) {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.zoom-controls__btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.zoom-controls__value {
|
||||
min-width: 48px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Divider between zoom controls and snap toggle (Change 5). 1px hairline
|
||||
* in the border colour, sized to match the height of the buttons minus a
|
||||
* little vertical padding so it doesn't reach all the way to the pill's
|
||||
* rounded edge. */
|
||||
.zoom-controls__divider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: var(--border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Snap toggle. Reuses the base zoom-controls__btn styling for size /
|
||||
* hover, plus an active variant so the user can see at a glance
|
||||
* whether snap is on. Active = soft pink fill + strong pink icon,
|
||||
* matching the rest of the editor's "selected" / "active" affordances. */
|
||||
.zoom-controls__btn--snap.is-active {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
.zoom-controls__btn--snap.is-active:hover:not(:disabled) {
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
}
|
||||
45
src/test/setup.js
Normal file
45
src/test/setup.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Vitest setup file — runs once per test file, before any test.
|
||||
//
|
||||
// Two responsibilities:
|
||||
//
|
||||
// 1. Register @testing-library/jest-dom's custom matchers so tests
|
||||
// can use `expect(node).toBeInTheDocument()`, `toHaveTextContent()`,
|
||||
// `toHaveAttribute()`, etc. Without this import, those matchers
|
||||
// throw "expected jest-dom matcher" at assertion time.
|
||||
//
|
||||
// 2. Run RTL's `cleanup()` after every test. RTL renders into a
|
||||
// shared DOM container; without explicit cleanup, components
|
||||
// from one test stick around for the next, producing
|
||||
// false-positive "found multiple instances" errors and
|
||||
// memory-grow bugs in long suites. Vitest with `globals: true`
|
||||
// exposes `afterEach` so the import below works without
|
||||
// naming it on the import side.
|
||||
//
|
||||
// What's NOT here (intentionally)
|
||||
// ─────────────────────────────────
|
||||
// • localStorage clearing. jsdom gives each test file a fresh
|
||||
// localStorage, and our tests that touch localStorage (history
|
||||
// debug flag) explicitly set+clear their own keys per test.
|
||||
// A blanket `localStorage.clear()` in afterEach would mask
|
||||
// setup bugs in tests that depend on a clean key.
|
||||
//
|
||||
// • Konva mocking. We don't yet have tests that mount React-Konva
|
||||
// components — those will need Stage/Layer stubs and will live
|
||||
// in a follow-up batch once we know the test setup story for
|
||||
// canvas-dependent code. For pure utility and hook tests
|
||||
// covered in this initial batch, Konva isn't loaded.
|
||||
//
|
||||
// • Timer mocking globals. Individual tests that need
|
||||
// `vi.useFakeTimers()` (debounce tests for updateElement, the
|
||||
// auto-dismiss toast timer, etc.) call it themselves and pair
|
||||
// with `vi.useRealTimers()` in their own afterEach. Keeping
|
||||
// it test-local rather than global makes the timing intent
|
||||
// visible at the test site.
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
36
src/utils/blobUrl.js
Normal file
36
src/utils/blobUrl.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Blob URL helpers — used by the photo edit flow (which creates blob: URLs
|
||||
* from cropped images and needs to revoke them once the canvas swaps to a
|
||||
* new src) and by persistence (which strips blob: URLs out of the saved
|
||||
* blob because they don't survive a reload).
|
||||
*
|
||||
* Before this module these checks were inline string-prefix tests —
|
||||
* `previousSrc.startsWith('blob:')`, `el.src.startsWith('blob:')`. Wrapping
|
||||
* the prefix check in a name makes it greppable and prevents accidental
|
||||
* "starts with 'blob'" false positives (matching e.g. "blob_url_str").
|
||||
*/
|
||||
|
||||
/**
|
||||
* True if the given value is a string that looks like a `blob:` URL.
|
||||
* Returns false for non-strings rather than throwing — callers usually
|
||||
* test optional fields like `element.src` and shouldn't have to null-check
|
||||
* twice.
|
||||
*/
|
||||
export function isBlobUrl(url) {
|
||||
return typeof url === 'string' && url.startsWith('blob:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a blob: URL if it is one; no-op for any other input. The actual
|
||||
* `URL.revokeObjectURL` call is wrapped in try/catch because some browsers
|
||||
* throw on already-revoked URLs and we don't want to crash the photo-edit
|
||||
* cleanup path over a double-revoke.
|
||||
*/
|
||||
export function revokeBlobUrl(url) {
|
||||
if (!isBlobUrl(url)) return;
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
/* Already-revoked, missing URL global, etc. — ignore. */
|
||||
}
|
||||
}
|
||||
103
src/utils/blobUrl.test.js
Normal file
103
src/utils/blobUrl.test.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// Tests for blobUrl.js — the isBlobUrl predicate and revokeBlobUrl
|
||||
// wrapper used by persistence stripping and the photo-edit cleanup
|
||||
// path.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// isBlobUrl is consulted in two places that both have correctness
|
||||
// consequences:
|
||||
//
|
||||
// 1. `persistence.savePersistedState` strips elements whose `src`
|
||||
// is a blob: URL before saving to localStorage. Blob URLs go
|
||||
// stale on reload — keeping them would produce broken-image
|
||||
// elements on next session restore. If isBlobUrl returns false
|
||||
// for a real blob URL, those broken images appear after refresh.
|
||||
//
|
||||
// 2. The photo-edit flow revokes the previous blob URL when the
|
||||
// user applies an edit so the browser frees the underlying
|
||||
// memory. If isBlobUrl returns true for a non-blob URL, we'd
|
||||
// try to revoke a regular https:// URL, which is at best a no-op
|
||||
// and at worst a deopt the browser logs.
|
||||
//
|
||||
// Both call sites benefit from a stable predicate, so the tests pin
|
||||
// the exact behavior: string-typed AND starts with literal "blob:".
|
||||
|
||||
import { isBlobUrl, revokeBlobUrl } from './blobUrl';
|
||||
|
||||
describe('isBlobUrl', () => {
|
||||
it('returns true for a string starting with "blob:"', () => {
|
||||
// Real blob URLs look like blob:https://example.com/<uuid>. The
|
||||
// predicate only checks the prefix, not the suffix shape — any
|
||||
// string starting with "blob:" matches.
|
||||
expect(isBlobUrl('blob:https://example.com/abc-123')).toBe(true);
|
||||
expect(isBlobUrl('blob:')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-blob URLs', () => {
|
||||
// These are the URL shapes we DON'T want to revoke:
|
||||
// - data: URLs (survive a reload, persist fine)
|
||||
// - https:// URLs (CDN-hosted images)
|
||||
// - file:// URLs (local file pickers)
|
||||
// - relative paths
|
||||
expect(isBlobUrl('data:image/png;base64,iVBORw0KG')).toBe(false);
|
||||
expect(isBlobUrl('https://example.com/photo.jpg')).toBe(false);
|
||||
expect(isBlobUrl('file:///Users/khalid/photo.jpg')).toBe(false);
|
||||
expect(isBlobUrl('/uploads/photo.jpg')).toBe(false);
|
||||
expect(isBlobUrl('photo.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-strings (no throw)', () => {
|
||||
// The docblock specifies: "Returns false for non-strings rather
|
||||
// than throwing — callers usually test optional fields like
|
||||
// `element.src` and shouldn't have to null-check twice." This
|
||||
// is the regression test for that contract.
|
||||
expect(isBlobUrl(null)).toBe(false);
|
||||
expect(isBlobUrl(undefined)).toBe(false);
|
||||
expect(isBlobUrl(42)).toBe(false);
|
||||
expect(isBlobUrl({})).toBe(false);
|
||||
expect(isBlobUrl([])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for the empty string', () => {
|
||||
expect(isBlobUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-sensitive ("BLOB:" is not "blob:")', () => {
|
||||
// Real browser-issued blob URLs are always lowercase, so this
|
||||
// is the desirable behavior. Pinning it here so a future
|
||||
// "normalize case" refactor doesn't silently expand the
|
||||
// predicate's surface to include uppercase variants we never
|
||||
// intended to support.
|
||||
expect(isBlobUrl('BLOB:https://example.com/abc')).toBe(false);
|
||||
expect(isBlobUrl('Blob:https://example.com/abc')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeBlobUrl', () => {
|
||||
// jsdom does not provide URL.revokeObjectURL by default. The
|
||||
// function under test wraps the call in try/catch precisely to
|
||||
// handle environments where it's missing (or where the URL has
|
||||
// already been revoked). These tests verify the no-throw contract
|
||||
// across the various inputs callers actually send.
|
||||
|
||||
it('does not throw for a blob: URL even if revokeObjectURL is missing', () => {
|
||||
// The cleanup path in the photo editor will call this on every
|
||||
// edit. It MUST NOT throw — that would interrupt the edit flow
|
||||
// and leave the user staring at a half-applied state.
|
||||
expect(() => revokeBlobUrl('blob:https://example.com/abc')).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw for non-blob URLs (no-op)', () => {
|
||||
// Defensive: if a caller accidentally passes a data: URL, we
|
||||
// shouldn't try to revoke it. The isBlobUrl guard inside
|
||||
// revokeBlobUrl short-circuits this case.
|
||||
expect(() => revokeBlobUrl('data:image/png;base64,abc')).not.toThrow();
|
||||
expect(() => revokeBlobUrl('https://example.com/photo.jpg')).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw for non-string inputs', () => {
|
||||
expect(() => revokeBlobUrl(null)).not.toThrow();
|
||||
expect(() => revokeBlobUrl(undefined)).not.toThrow();
|
||||
expect(() => revokeBlobUrl(42)).not.toThrow();
|
||||
});
|
||||
});
|
||||
206
src/utils/colorUtils.js
Normal file
206
src/utils/colorUtils.js
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Color utilities for the editor.
|
||||
*
|
||||
* Currently exports:
|
||||
* • `darkenHexColor` — used by the Text tab to derive a default
|
||||
* outline color from the user's chosen fill color.
|
||||
* • `relativeLuminance` / `getContrastRatio` — WCAG-style contrast
|
||||
* math, used by the Text tab's preview swatch to pick a readable
|
||||
* background based on the current fill (light pink wash by
|
||||
* default; darker pink fallback when the fill would be invisible
|
||||
* against the wash).
|
||||
* • `LOW_CONTRAST_THRESHOLD` / `isLowContrast` — convenience
|
||||
* predicate + threshold for "these two colors are too similar to
|
||||
* be safely distinguishable." No production callers as of writing;
|
||||
* kept as a public API surface for future warning paths so the
|
||||
* threshold lives in one canonical place rather than being
|
||||
* redefined per call site. The Text tab uses a locally-named
|
||||
* `LOW_PREVIEW_CONTRAST_THRESHOLD` (same 2.0 value) for its own
|
||||
* preview-surface check — leaving that local because the preview
|
||||
* threshold and a global "warn the user" threshold may diverge.
|
||||
*
|
||||
* We keep these in their own module rather than inlining them because
|
||||
* the same transforms may end up wanted elsewhere (hover states,
|
||||
* accessibility checks on imported palettes, gradient stops, etc.),
|
||||
* and a one-line import keeps downstream call sites tidy.
|
||||
*
|
||||
* History
|
||||
* ───────
|
||||
* Pre-May 23 2026 the contrast logic was split across two files:
|
||||
* `colorUtils.js` (this file, with darkenHexColor + getContrastRatio +
|
||||
* relativeLuminance) and `colorContrast.js` (a separate module with a
|
||||
* redundant `contrastRatio` plus `isLowContrast` and the threshold
|
||||
* constant). The two contrast implementations differed only in their
|
||||
* malformed-input fallback — colorUtils returned NaN ("unknown, caller
|
||||
* decides"), colorContrast returned 1 ("treat as identical"). The
|
||||
* split was speculative API; the only production caller (TextTab) used
|
||||
* colorUtils' NaN-aware path. Merged into this file with NaN as the
|
||||
* canonical fallback (see `isLowContrast` below).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Darken a hex color by scaling each RGB channel toward zero.
|
||||
*
|
||||
* Why RGB scaling and not HSL lightness reduction:
|
||||
* • Visually equivalent for the small (≤ 30%) darkening this app
|
||||
* uses — the perceptual difference between HSL-based and
|
||||
* RGB-scaled darkening is negligible below ~40%.
|
||||
* • Cheaper: no RGB → HSL → RGB round-trip, no math.atan2 calls.
|
||||
* • Handles edge cases naturally: pure black stays pure black
|
||||
* (0 × anything = 0), pure white darkens uniformly to grey,
|
||||
* coloured fills keep their hue cleanly because every channel
|
||||
* scales by the same factor.
|
||||
*
|
||||
* Input must be a 3- or 6-digit hex string with or without the
|
||||
* leading `#`. Anything else is returned unchanged so a malformed
|
||||
* color in state can't crash a render — the caller's UI just
|
||||
* shows the original color and the user can pick a new one.
|
||||
*
|
||||
* `amount` is a fraction in [0, 1]:
|
||||
* amount = 0 → returns the input unchanged
|
||||
* amount = 0.2 → 20% darker (the Text tab's outline default)
|
||||
* amount = 1 → returns #000000
|
||||
*
|
||||
* Values outside [0, 1] are clamped so a typo in a caller can't
|
||||
* produce out-of-gamut values like negative channels.
|
||||
*/
|
||||
export function darkenHexColor(hex, amount = 0.2) {
|
||||
if (typeof hex !== 'string') return hex;
|
||||
let h = hex.trim();
|
||||
if (h.startsWith('#')) h = h.slice(1);
|
||||
// Expand short-form (#abc) to long-form (#aabbcc) so the parse below
|
||||
// is uniform. Browsers accept both; canvas APIs sometimes don't.
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return hex;
|
||||
|
||||
const clamped = Math.max(0, Math.min(1, amount));
|
||||
const factor = 1 - clamped;
|
||||
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
|
||||
const nr = Math.round(r * factor);
|
||||
const ng = Math.round(g * factor);
|
||||
const nb = Math.round(b * factor);
|
||||
|
||||
const toHex = (v) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(nr)}${toHex(ng)}${toHex(nb)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a hex color string into a canonical 6-char hex (no #).
|
||||
* Returns null on malformed input. Shared by relativeLuminance and
|
||||
* any future callers that need normalized hex.
|
||||
*/
|
||||
function normalizeHex(hex) {
|
||||
if (typeof hex !== 'string') return null;
|
||||
let h = hex.trim();
|
||||
if (h.startsWith('#')) h = h.slice(1);
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
if (h.length !== 6 || /[^0-9a-fA-F]/.test(h)) return null;
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative luminance of an sRGB color per the WCAG 2.x formula.
|
||||
*
|
||||
* Returns a value in [0, 1]: 0 = black, 1 = white, mid-grey ≈ 0.18.
|
||||
* The non-linear `channel` transform is the sRGB gamma curve — it
|
||||
* converts each 0–255 channel into linear-light space before the
|
||||
* weighted sum, which is what gives WCAG contrast ratios their
|
||||
* perceptual accuracy. Skipping the gamma step (i.e. using raw
|
||||
* RGB averages) under-estimates contrast on light backgrounds and
|
||||
* over-estimates it on dark ones.
|
||||
*
|
||||
* Returns NaN for malformed input. Callers should treat NaN as
|
||||
* "unknown" and either bail or fall back to a safe default rather
|
||||
* than passing the value into further math.
|
||||
*/
|
||||
export function relativeLuminance(hex) {
|
||||
const h = normalizeHex(hex);
|
||||
if (!h) return NaN;
|
||||
|
||||
// sRGB → linear-light gamma curve (the WCAG canonical form).
|
||||
const channel = (v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
|
||||
const r = channel(parseInt(h.slice(0, 2), 16));
|
||||
const g = channel(parseInt(h.slice(2, 4), 16));
|
||||
const b = channel(parseInt(h.slice(4, 6), 16));
|
||||
|
||||
// Weighting coefficients are the standard sRGB → luminance matrix
|
||||
// row — green dominates because the human eye is most sensitive to
|
||||
// mid-green wavelengths, blue contributes the least.
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG contrast ratio between two colors.
|
||||
*
|
||||
* Returns a value in [1, 21]:
|
||||
* 1 = identical luminance (invisible)
|
||||
* 21 = pure black on pure white (theoretical max)
|
||||
*
|
||||
* WCAG AA passing thresholds:
|
||||
* 4.5:1 for normal text
|
||||
* 3.0:1 for large text (18pt+ regular, or 14pt+ bold)
|
||||
*
|
||||
* Returns NaN if either input is malformed (so the caller can pick
|
||||
* a safe fallback rather than producing garbage UI). Order of
|
||||
* arguments doesn't matter — the formula is symmetric, with the
|
||||
* lighter colour always going on top.
|
||||
*/
|
||||
export function getContrastRatio(hex1, hex2) {
|
||||
const l1 = relativeLuminance(hex1);
|
||||
const l2 = relativeLuminance(hex2);
|
||||
if (Number.isNaN(l1) || Number.isNaN(l2)) return NaN;
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold below which two colors are considered "too similar to
|
||||
* safely distinguish." WCAG AA proper requires ≥4.5:1 for body text
|
||||
* and ≥3.0:1 for large text; 2.0 is a relaxed threshold appropriate
|
||||
* for decorative print where the user is making an aesthetic choice
|
||||
* rather than a readability-critical one. "You can still see it but
|
||||
* it's getting hard" is the right level for a warning surface; a
|
||||
* proper AA threshold would warn so frequently on stylish colour
|
||||
* combinations that users would tune it out.
|
||||
*
|
||||
* Lives here rather than in a caller-local constant so any future
|
||||
* call site that wants "the project's low-contrast threshold" has
|
||||
* one canonical reference. Call sites with a more specific need
|
||||
* (e.g. TextTab's preview-surface check) can still define their
|
||||
* own.
|
||||
*/
|
||||
export const LOW_CONTRAST_THRESHOLD = 2.0;
|
||||
|
||||
/**
|
||||
* Predicate: do these two colors fall below the low-contrast threshold?
|
||||
*
|
||||
* NaN handling
|
||||
* ────────────
|
||||
* Returns FALSE when either color is malformed (getContrastRatio
|
||||
* returns NaN). "Unknown contrast" reads as "don't warn" rather than
|
||||
* "warn just in case" — a malformed colour in state is more likely
|
||||
* to be a stale value the user is about to overwrite than a real
|
||||
* low-contrast pair, and warning on it would produce a false positive
|
||||
* the user can't act on (they didn't pick a colour; they're picking
|
||||
* one right now). The TextTab caller pattern
|
||||
* (`!Number.isNaN(contrast) && contrast < threshold`) had the same
|
||||
* semantics inline; consolidating it here makes the choice explicit.
|
||||
*
|
||||
* Argument order is `(fg, bg)` by convention — foreground first —
|
||||
* but the underlying WCAG math is symmetric, so callers passing them
|
||||
* in the other order get the same answer.
|
||||
*/
|
||||
export function isLowContrast(fg, bg, threshold = LOW_CONTRAST_THRESHOLD) {
|
||||
const ratio = getContrastRatio(fg, bg);
|
||||
if (Number.isNaN(ratio)) return false;
|
||||
return ratio < threshold;
|
||||
}
|
||||
392
src/utils/colorUtils.test.js
Normal file
392
src/utils/colorUtils.test.js
Normal file
@@ -0,0 +1,392 @@
|
||||
// Tests for colorUtils.js — darkenHexColor, relativeLuminance, getContrastRatio.
|
||||
//
|
||||
// Why this file matters for the v1 cleanup
|
||||
// ────────────────────────────────────────
|
||||
// `colorUtils.js` and `colorContrast.js` (tested separately in
|
||||
// colorContrast.test.js) BOTH implement the WCAG relative-luminance
|
||||
// and contrast-ratio formulas, with subtly different APIs:
|
||||
//
|
||||
// | colorUtils | colorContrast
|
||||
// ─────────────────┼─────────────────────────────┼──────────────────────────
|
||||
// relativeLuminance| takes hex string | takes parsed {r,g,b}
|
||||
// | returns NaN on bad input | (internal helper only)
|
||||
// contrast getter | getContrastRatio (NaN) | contrastRatio (returns 1)
|
||||
// threshold helper | (none) | isLowContrast(default 2)
|
||||
// color transforms | darkenHexColor | (none)
|
||||
//
|
||||
// Same underlying WCAG math, two slightly different surfaces. The
|
||||
// upcoming cleanup is to consolidate them into a single module. These
|
||||
// tests (plus the colorContrast.test.js sibling) pin BOTH current
|
||||
// behaviors so the consolidation can be done as a green refactor:
|
||||
// either adopt one module's API and update the other's callers, or
|
||||
// introduce a single new API and migrate both — in either case the
|
||||
// test suite tells us when behavior matches.
|
||||
//
|
||||
// Specifically the "returns NaN on bad input" vs "returns 1 on bad
|
||||
// input" divergence: callers of colorUtils.getContrastRatio currently
|
||||
// expect to test for NaN; callers of colorContrast.contrastRatio
|
||||
// currently expect 1 as a safe-fallback value. Whichever direction
|
||||
// we pick during cleanup, the side that changes will need its callers
|
||||
// audited. The malformed-input tests below flag this divergence.
|
||||
//
|
||||
// UPDATE (May 23 2026): the consolidation landed. colorContrast.js
|
||||
// and colorContrast.test.js are deleted; everything lives here and
|
||||
// NaN-on-bad-input is the canonical fallback throughout. The header
|
||||
// above is preserved so commits referencing "the divergence" still
|
||||
// make sense; the post-merge behaviour is documented in
|
||||
// colorUtils.js's module docblock.
|
||||
|
||||
import {
|
||||
darkenHexColor,
|
||||
relativeLuminance,
|
||||
getContrastRatio,
|
||||
LOW_CONTRAST_THRESHOLD,
|
||||
isLowContrast,
|
||||
} from './colorUtils';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// darkenHexColor — RGB-scaling darken.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('darkenHexColor', () => {
|
||||
describe('boundary amounts', () => {
|
||||
it('amount=0 returns the input unchanged', () => {
|
||||
expect(darkenHexColor('#ff0000', 0)).toBe('#ff0000');
|
||||
expect(darkenHexColor('#abc123', 0)).toBe('#abc123');
|
||||
});
|
||||
|
||||
it('amount=1 returns #000000 (everything scaled to zero)', () => {
|
||||
expect(darkenHexColor('#ff0000', 1)).toBe('#000000');
|
||||
expect(darkenHexColor('#ffffff', 1)).toBe('#000000');
|
||||
expect(darkenHexColor('#abc123', 1)).toBe('#000000');
|
||||
});
|
||||
|
||||
it('default amount is 0.2 (matches the Text tab outline darken)', () => {
|
||||
// Caller-side: `darkenHexColor(fill)` produces the default outline
|
||||
// colour for the Text tab. The default amount must stay 0.2 or
|
||||
// the visual relationship between fill and outline changes.
|
||||
expect(darkenHexColor('#ffffff')).toBe('#cccccc');
|
||||
// 0xff * 0.8 = 204 = 0xcc.
|
||||
});
|
||||
|
||||
it('clamps amounts above 1 to 1', () => {
|
||||
expect(darkenHexColor('#ff0000', 5)).toBe('#000000');
|
||||
expect(darkenHexColor('#ff0000', 1.5)).toBe('#000000');
|
||||
});
|
||||
|
||||
it('clamps negative amounts to 0', () => {
|
||||
// A typo in a caller passing a negative amount shouldn't produce
|
||||
// out-of-gamut channels (e.g. `-0.2` would lighten if we didn't
|
||||
// clamp). Clamping to 0 preserves input.
|
||||
expect(darkenHexColor('#ff0000', -0.2)).toBe('#ff0000');
|
||||
expect(darkenHexColor('#ff0000', -1)).toBe('#ff0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel math', () => {
|
||||
it('20% darker on pure red is #cc0000', () => {
|
||||
// 0xff (255) * 0.8 = 204 = 0xcc.
|
||||
expect(darkenHexColor('#ff0000', 0.2)).toBe('#cc0000');
|
||||
});
|
||||
|
||||
it('20% darker on pure white is #cccccc', () => {
|
||||
expect(darkenHexColor('#ffffff', 0.2)).toBe('#cccccc');
|
||||
});
|
||||
|
||||
it('pure black stays pure black at any amount', () => {
|
||||
// 0 * anything = 0. This is a useful invariant — callers that
|
||||
// chain darken operations shouldn't ever produce a non-zero
|
||||
// channel from a zero source.
|
||||
expect(darkenHexColor('#000000', 0.5)).toBe('#000000');
|
||||
expect(darkenHexColor('#000000', 1)).toBe('#000000');
|
||||
});
|
||||
|
||||
it('rounds channel values rather than truncating', () => {
|
||||
// Math.round, not Math.floor — 0xab (171) * 0.8 = 136.8 should
|
||||
// round to 137 (0x89), not floor to 136 (0x88).
|
||||
expect(darkenHexColor('#ababab', 0.2)).toBe('#898989');
|
||||
});
|
||||
|
||||
it('treats channels independently (preserves hue at small darken amounts)', () => {
|
||||
// Every channel scales by the same factor, so the ratios between
|
||||
// them are preserved. A 50%-red, 50%-green-blue color stays
|
||||
// 50%-red, 50%-green-blue after darkening.
|
||||
const result = darkenHexColor('#80c0ff', 0.5);
|
||||
// 0x80 = 128 → 64 = 0x40
|
||||
// 0xc0 = 192 → 96 = 0x60
|
||||
// 0xff = 255 → 128 (round of 127.5) = 0x80
|
||||
expect(result).toBe('#406080');
|
||||
});
|
||||
});
|
||||
|
||||
describe('input formats', () => {
|
||||
it('accepts hex with leading #', () => {
|
||||
expect(darkenHexColor('#ff0000', 0.2)).toBe('#cc0000');
|
||||
});
|
||||
|
||||
it('accepts hex without leading # and adds # to output', () => {
|
||||
// The output always has a leading # for consistency, even if
|
||||
// the input didn't. Callers serializing back to CSS or hex
|
||||
// pickers should not have to test which case they got.
|
||||
expect(darkenHexColor('ff0000', 0.2)).toBe('#cc0000');
|
||||
});
|
||||
|
||||
it('expands 3-char hex shorthand to 6-char before darkening', () => {
|
||||
// #abc → expand to #aabbcc, then darken.
|
||||
// 0xaa=170 * 0.8 = 136 = 0x88
|
||||
// 0xbb=187 * 0.8 = 149.6 → 150 = 0x96
|
||||
// 0xcc=204 * 0.8 = 163.2 → 163 = 0xa3
|
||||
expect(darkenHexColor('#abc', 0.2)).toBe('#8896a3');
|
||||
});
|
||||
|
||||
it('accepts uppercase hex and emits lowercase', () => {
|
||||
// The browser and Konva both accept either case, but a single
|
||||
// canonical case in storage makes equality checks on `fill ===
|
||||
// stroke` reliable (the sharedStyle comparison in TextTab.jsx
|
||||
// depends on this).
|
||||
expect(darkenHexColor('#FF0000', 0.2)).toBe('#cc0000');
|
||||
});
|
||||
|
||||
it('trims whitespace before parsing', () => {
|
||||
expect(darkenHexColor(' #ff0000 ', 0.2)).toBe('#cc0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed input passthrough', () => {
|
||||
// Per docblock: malformed input is returned UNCHANGED rather than
|
||||
// throwing or returning a sentinel. The caller's UI shows the
|
||||
// original (broken) color and the user can pick a new one. This
|
||||
// is a deliberate non-failure mode — never crash a render over a
|
||||
// typo in state.
|
||||
|
||||
it('non-string input is returned unchanged', () => {
|
||||
expect(darkenHexColor(null, 0.2)).toBe(null);
|
||||
expect(darkenHexColor(undefined, 0.2)).toBe(undefined);
|
||||
expect(darkenHexColor(42, 0.2)).toBe(42);
|
||||
expect(darkenHexColor({}, 0.2)).toEqual({});
|
||||
});
|
||||
|
||||
it('hex with wrong length is returned unchanged', () => {
|
||||
// 4 chars, 5 chars, 7 chars — none are valid hex.
|
||||
expect(darkenHexColor('#abcd', 0.2)).toBe('#abcd');
|
||||
expect(darkenHexColor('#abcde', 0.2)).toBe('#abcde');
|
||||
expect(darkenHexColor('#abcdef0', 0.2)).toBe('#abcdef0');
|
||||
});
|
||||
|
||||
it('hex containing non-hex characters is returned unchanged', () => {
|
||||
expect(darkenHexColor('#gghhii', 0.2)).toBe('#gghhii');
|
||||
expect(darkenHexColor('#zz0000', 0.2)).toBe('#zz0000');
|
||||
});
|
||||
|
||||
it('empty string is returned unchanged', () => {
|
||||
// After stripping #, the empty string has length 0 — not 3, not 6.
|
||||
expect(darkenHexColor('', 0.2)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// relativeLuminance — WCAG canonical formula.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('relativeLuminance', () => {
|
||||
it('pure black is 0', () => {
|
||||
expect(relativeLuminance('#000000')).toBe(0);
|
||||
});
|
||||
|
||||
it('pure white is 1', () => {
|
||||
expect(relativeLuminance('#ffffff')).toBe(1);
|
||||
});
|
||||
|
||||
it('mid-grey is approximately 0.215', () => {
|
||||
// The exact WCAG-canonical mid-grey (#808080) luminance, computed
|
||||
// via the sRGB → linear-light gamma curve. Useful as a known
|
||||
// intermediate to confirm the gamma transform is wired correctly —
|
||||
// a non-gamma-corrected formula would give ~0.5 here.
|
||||
const result = relativeLuminance('#808080');
|
||||
expect(result).toBeGreaterThan(0.21);
|
||||
expect(result).toBeLessThan(0.22);
|
||||
});
|
||||
|
||||
it('channel weighting: pure green > pure red > pure blue', () => {
|
||||
// The luminance weights are G=0.7152, R=0.2126, B=0.0722. The
|
||||
// ordering reflects human perception (green-dominant cones), and
|
||||
// confirms the per-channel coefficients aren't swapped — a bug
|
||||
// we'd want to catch.
|
||||
const lG = relativeLuminance('#00ff00');
|
||||
const lR = relativeLuminance('#ff0000');
|
||||
const lB = relativeLuminance('#0000ff');
|
||||
expect(lG).toBeGreaterThan(lR);
|
||||
expect(lR).toBeGreaterThan(lB);
|
||||
expect(lG).toBeCloseTo(0.7152, 4);
|
||||
expect(lR).toBeCloseTo(0.2126, 4);
|
||||
expect(lB).toBeCloseTo(0.0722, 4);
|
||||
});
|
||||
|
||||
it('accepts shorthand 3-char hex (expands to 6-char)', () => {
|
||||
expect(relativeLuminance('#fff')).toBe(1);
|
||||
expect(relativeLuminance('#000')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns NaN for malformed input', () => {
|
||||
// colorUtils' convention: NaN means "unknown — bail or fall back".
|
||||
// Compare with colorContrast.relativeLuminance which uses parseHex
|
||||
// and returns from a higher-level helper.
|
||||
expect(relativeLuminance('not a color')).toBeNaN();
|
||||
expect(relativeLuminance('#zz0000')).toBeNaN();
|
||||
expect(relativeLuminance(null)).toBeNaN();
|
||||
expect(relativeLuminance(undefined)).toBeNaN();
|
||||
expect(relativeLuminance('')).toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// getContrastRatio — WCAG contrast formula.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getContrastRatio', () => {
|
||||
it('pure black on pure white is 21 (theoretical max)', () => {
|
||||
// (1 + 0.05) / (0 + 0.05) = 21
|
||||
expect(getContrastRatio('#000000', '#ffffff')).toBe(21);
|
||||
});
|
||||
|
||||
it('identical colors have ratio 1', () => {
|
||||
expect(getContrastRatio('#ff0000', '#ff0000')).toBe(1);
|
||||
expect(getContrastRatio('#808080', '#808080')).toBe(1);
|
||||
expect(getContrastRatio('#000000', '#000000')).toBe(1);
|
||||
});
|
||||
|
||||
it('is symmetric (order of arguments does not matter)', () => {
|
||||
// The formula picks max/min internally, so swapping args is a
|
||||
// no-op. Worth pinning because a "swap them later" optimization
|
||||
// could accidentally break symmetry.
|
||||
const a = getContrastRatio('#ff0000', '#00ff00');
|
||||
const b = getContrastRatio('#00ff00', '#ff0000');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('returns NaN if either input is malformed', () => {
|
||||
// This is colorUtils' convention. colorContrast.contrastRatio
|
||||
// returns 1 instead — that divergence is flagged in this file's
|
||||
// header comment.
|
||||
//
|
||||
// Be careful picking "malformed" inputs: 3-char strings like 'bad',
|
||||
// 'fed', 'cab', 'dad', etc. are VALID hex shorthand because b, a,
|
||||
// d, c, e, f are all hex digits — they parse to #bbaadd, #ffeedd,
|
||||
// etc. and produce real contrast ratios. The malformed-input
|
||||
// assertions need strings that contain non-hex characters or
|
||||
// wrong-length sequences.
|
||||
expect(getContrastRatio('#ff0000', 'not a color')).toBeNaN();
|
||||
expect(getContrastRatio('not a color', '#ff0000')).toBeNaN();
|
||||
expect(getContrastRatio('xyz', 'qrs')).toBeNaN(); // 3 chars but non-hex
|
||||
expect(getContrastRatio('zzzzzz', '#ff0000')).toBeNaN(); // 6 chars but non-hex
|
||||
expect(getContrastRatio(null, '#000000')).toBeNaN();
|
||||
expect(getContrastRatio(undefined, '#000000')).toBeNaN();
|
||||
});
|
||||
|
||||
it('black on mid-grey is between WCAG AA-large (3:1) and AA-normal (4.5:1) thresholds', () => {
|
||||
// (~0.215 + 0.05) / (0 + 0.05) = ~5.3 — passes both AA thresholds.
|
||||
// Range check rather than exact value so jsdom precision quirks
|
||||
// don't break the test.
|
||||
const ratio = getContrastRatio('#000000', '#808080');
|
||||
expect(ratio).toBeGreaterThan(5);
|
||||
expect(ratio).toBeLessThan(6);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// LOW_CONTRAST_THRESHOLD + isLowContrast — merged from colorContrast.js
|
||||
// on May 23 2026. The semantic carried over from colorContrast was the
|
||||
// 2.0 threshold value; the NaN-aware fallback is new (the old
|
||||
// isLowContrast returned `true` on bad input because the old
|
||||
// contrastRatio returned 1, which the threshold check then flagged
|
||||
// as low contrast). See colorUtils.js's isLowContrast docblock for
|
||||
// the full rationale on switching to NaN-returns-false.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('LOW_CONTRAST_THRESHOLD', () => {
|
||||
it('is 2.0', () => {
|
||||
// Pinned because the value affects which color combinations
|
||||
// trigger downstream warning surfaces. Bumping it (or a future
|
||||
// caller using a different threshold) is a deliberate decision,
|
||||
// not a stealth refactor.
|
||||
expect(LOW_CONTRAST_THRESHOLD).toBe(2.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLowContrast', () => {
|
||||
describe('threshold behavior', () => {
|
||||
it('returns true when the ratio is below the default threshold', () => {
|
||||
// Very-similar colors. Two mid-greys with a small luminance
|
||||
// delta produce a low ratio well below 2.0.
|
||||
expect(isLowContrast('#777777', '#888888')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the ratio meets or exceeds the threshold', () => {
|
||||
// Pure black on pure white has ratio 21 — nowhere near low.
|
||||
expect(isLowContrast('#000000', '#ffffff')).toBe(false);
|
||||
// Black on mid-grey is ~5.3, still well above 2.0.
|
||||
expect(isLowContrast('#000000', '#808080')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for identical colors (ratio 1)', () => {
|
||||
// Identical colors have ratio exactly 1, which is < 2.0.
|
||||
// Useful as a guard against "the user picked the same colour
|
||||
// for foreground and background" — the strictest version of
|
||||
// the low-contrast case.
|
||||
expect(isLowContrast('#ec4899', '#ec4899')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a custom threshold', () => {
|
||||
// A stricter check (WCAG AA for normal text is 4.5) flags more
|
||||
// combinations as low-contrast.
|
||||
const blackOnGrey = getContrastRatio('#000000', '#808080');
|
||||
// ~5.3, so AA-normal (4.5) lets it pass, but a strict 6.0
|
||||
// threshold flags it as low.
|
||||
expect(isLowContrast('#000000', '#808080', 4.5)).toBe(false);
|
||||
expect(isLowContrast('#000000', '#808080', 6.0)).toBe(true);
|
||||
expect(blackOnGrey).toBeGreaterThan(4.5);
|
||||
expect(blackOnGrey).toBeLessThan(6.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NaN-aware fallback (regression for the merge)', () => {
|
||||
// The headline behavioral change from the pre-merge colorContrast
|
||||
// module. Old colorContrast.isLowContrast returned TRUE on bad
|
||||
// input (contrastRatio fell back to 1 → 1 < 2.0 → true). New
|
||||
// isLowContrast returns FALSE on bad input — "unknown contrast,
|
||||
// don't warn" rather than "unknown, warn just in case." These
|
||||
// tests pin the new behaviour; a future change that switches
|
||||
// back to true-on-NaN would fail here.
|
||||
|
||||
it('returns false when fg is malformed', () => {
|
||||
expect(isLowContrast('not a color', '#ffffff')).toBe(false);
|
||||
expect(isLowContrast('zzzzzz', '#ffffff')).toBe(false);
|
||||
expect(isLowContrast(null, '#ffffff')).toBe(false);
|
||||
expect(isLowContrast(undefined, '#ffffff')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when bg is malformed', () => {
|
||||
expect(isLowContrast('#000000', 'not a color')).toBe(false);
|
||||
expect(isLowContrast('#000000', null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when both are malformed', () => {
|
||||
expect(isLowContrast('xyz', 'qrs')).toBe(false); // 3 chars but non-hex
|
||||
expect(isLowContrast(null, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('symmetry', () => {
|
||||
it('argument order does not matter (mirrors getContrastRatio symmetry)', () => {
|
||||
// The function's docblock pins (fg, bg) as the canonical order,
|
||||
// but the WCAG math is symmetric so swapping must give the same
|
||||
// answer. A future refactor that introduced order-dependent
|
||||
// logic (e.g. checking foreground luminance specifically) would
|
||||
// break this and need to update the docblock.
|
||||
const a = isLowContrast('#777777', '#888888');
|
||||
const b = isLowContrast('#888888', '#777777');
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
});
|
||||
183
src/utils/cropMath.js
Normal file
183
src/utils/cropMath.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// Pure crop math for image elements.
|
||||
//
|
||||
// Why this exists
|
||||
// ───────────────
|
||||
// Until May 22 2026 this math lived inline in App.jsx's
|
||||
// `handleApplyCrop` callback. The function intermingled three concerns:
|
||||
//
|
||||
// 1. Resolving the natural image dimensions via a Konva.stages walk
|
||||
// (impure, depends on the DOM).
|
||||
// 2. Reading the live `cropping` state and exiting crop mode
|
||||
// (React state, impure).
|
||||
// 3. The actual math: canvas-coords cropRect + current element +
|
||||
// naturals → new crop record + tracking fields (pure).
|
||||
//
|
||||
// (1) and (2) can't move out of App — they depend on React state and
|
||||
// the live Konva stage. But (3) is a closed-form function: same
|
||||
// inputs always produce the same outputs, no side effects. Moving it
|
||||
// here makes it testable without mounting React or a real canvas,
|
||||
// which matters because the math is responsible for two bug-prone
|
||||
// behaviors:
|
||||
//
|
||||
// • flipX/flipY mirror correction. The crop rect comes from a
|
||||
// canvas-coord overlay that doesn't know about the element's
|
||||
// flip state, so the local-coord conversion has to undo the
|
||||
// mirror. Pre-extraction, getting this wrong meant the user
|
||||
// would visually crop to the cat's face and end up with a crop
|
||||
// pointing at the cat's tail.
|
||||
//
|
||||
// • Multi-crop composition. Cropping an already-cropped image
|
||||
// means the new crop's source-image coordinates have to be
|
||||
// expressed relative to the FULL natural source, not the
|
||||
// current crop window. The scaleX/Y calculation handles this
|
||||
// by reading the current element.crop's sWidth/sHeight rather
|
||||
// than naturalW/H when present.
|
||||
//
|
||||
// What this function returns
|
||||
// ──────────────────────────
|
||||
// The full `attrs` patch that the caller should pass to
|
||||
// `updateAndCommit`. Includes:
|
||||
//
|
||||
// • Geometry (x, y, width, height) — the element takes the crop
|
||||
// rect's position and size.
|
||||
// • crop — the new source-image sub-region.
|
||||
// • preCrop, postCrop, previousCrop — the tracking fields that
|
||||
// power the "Undo crop" button's single-step rollback. See the
|
||||
// App-side docblock on those fields in handleApplyCrop for the
|
||||
// full mental model; the short version is "remember enough to
|
||||
// go back one step, even after intervening resizes."
|
||||
//
|
||||
// What it does NOT do
|
||||
// ───────────────────
|
||||
// • No rounding. Konva.Image accepts sub-pixel source coords;
|
||||
// rounding here would accumulate drift across multi-crop
|
||||
// sequences. The caller can round the element bounds if needed
|
||||
// for cleaner storage.
|
||||
// • No clamping to natural dims. We trust the cropRect to be
|
||||
// within the element's bounds — the crop overlay's boundBoxFunc
|
||||
// enforces that at drag time. A cropRect that pokes outside
|
||||
// would produce a sub-region partially outside the source
|
||||
// image; Konva's draw handles that gracefully by clipping.
|
||||
// • No validation of element shape (image vs sticker vs text).
|
||||
// Caller is responsible for filtering by type. The math itself
|
||||
// is type-agnostic — it just needs x/y/width/height/optional
|
||||
// crop/optional flip flags.
|
||||
|
||||
/**
|
||||
* Compute the update patch for applying a crop to an image-like element.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} params.element Element being cropped. Required fields:
|
||||
* `x`, `y`, `width`, `height`. Optional:
|
||||
* `flipX`, `flipY`, `crop` (with
|
||||
* `sx`/`sy`/`sWidth`/`sHeight`).
|
||||
* @param {Object} params.cropRect Crop rectangle in canvas/design coords.
|
||||
* `{x, y, width, height}`.
|
||||
* @param {number} params.naturalW Source image's natural width (px).
|
||||
* @param {number} params.naturalH Source image's natural height (px).
|
||||
*
|
||||
* @returns {Object|null} Patch for `updateAndCommit`, or `null` if any
|
||||
* required input is missing or zero-dimensioned.
|
||||
* The null path matches what handleApplyCrop
|
||||
* should do when its natural-dims resolution
|
||||
* fails — bail without committing.
|
||||
*/
|
||||
export function computeCropUpdate({ element, cropRect, naturalW, naturalH }) {
|
||||
// Guard inputs. Returning null tells the caller "couldn't compute,
|
||||
// don't commit a partial patch." Equivalent to handleApplyCrop's
|
||||
// bail paths for "image still loading" and "element vanished."
|
||||
if (!element || !cropRect) return null;
|
||||
if (!naturalW || !naturalH) return null;
|
||||
if (!element.width || !element.height) return null;
|
||||
if (!cropRect.width || !cropRect.height) return null;
|
||||
|
||||
// Read the current source-image sub-region. If the element has no
|
||||
// crop yet, the "current" sub-region is the whole natural image —
|
||||
// sx/sy start at 0, sWidth/sHeight at naturalW/H.
|
||||
const currentSrcX = element.crop?.sx ?? 0;
|
||||
const currentSrcY = element.crop?.sy ?? 0;
|
||||
const currentSrcW = element.crop?.sWidth ?? naturalW;
|
||||
const currentSrcH = element.crop?.sHeight ?? naturalH;
|
||||
|
||||
// Per-axis scale from canvas (element-local) coords to source-image
|
||||
// coords. For an uncropped element this equals naturalW/element.width
|
||||
// and naturalH/element.height — the element is just a scaled
|
||||
// rendering of the full source. For an already-cropped element, the
|
||||
// scale is currentSrcW/element.width — the element renders the
|
||||
// current sub-region at the element's displayed size.
|
||||
const scaleX = currentSrcW / element.width;
|
||||
const scaleY = currentSrcH / element.height;
|
||||
|
||||
// Convert cropRect from canvas coords to element-local coords.
|
||||
// localX/localY are the upper-left of the crop within the element's
|
||||
// (0..element.width, 0..element.height) box.
|
||||
const localX = cropRect.x - element.x;
|
||||
const localY = cropRect.y - element.y;
|
||||
|
||||
// Flip correction. The crop overlay renders in canvas coords, so a
|
||||
// user clicking on the visually-displayed "left ear" of a horizontally
|
||||
// flipped image is selecting the corresponding region in CANVAS space.
|
||||
// But the source image isn't flipped — Konva applies the flip at
|
||||
// render time. To select the right region of the SOURCE we have to
|
||||
// mirror back across the local axis.
|
||||
//
|
||||
// The mirror formula for X: with the element's local box being
|
||||
// [0..element.width], the canvas-space localX corresponds to
|
||||
// source-space (element.width - localX - cropRect.width) — the
|
||||
// crop's RIGHT edge in canvas space is its LEFT edge in source
|
||||
// space (and vice versa for the width). Same logic for Y when
|
||||
// flipY is set.
|
||||
const localCropX = element.flipX
|
||||
? element.width - localX - cropRect.width
|
||||
: localX;
|
||||
const localCropY = element.flipY
|
||||
? element.height - localY - cropRect.height
|
||||
: localY;
|
||||
|
||||
// Compose with currentSrcX/Y so multi-crop sequences land in the
|
||||
// right source-coord region. Without the addition, a second crop
|
||||
// would treat its localCropX as an offset from sx=0 instead of
|
||||
// from the previous crop's sx — the new crop would jump back to
|
||||
// the upper-left of the SOURCE rather than narrowing into the
|
||||
// previous crop's window.
|
||||
const newCrop = {
|
||||
sx: currentSrcX + localCropX * scaleX,
|
||||
sy: currentSrcY + localCropY * scaleY,
|
||||
sWidth: cropRect.width * scaleX,
|
||||
sHeight: cropRect.height * scaleY,
|
||||
};
|
||||
|
||||
return {
|
||||
// The element's display geometry becomes the crop rect — same
|
||||
// x/y/width/height as the user dragged. The cropped sub-region
|
||||
// (crop) renders inside this box.
|
||||
x: cropRect.x,
|
||||
y: cropRect.y,
|
||||
width: cropRect.width,
|
||||
height: cropRect.height,
|
||||
|
||||
crop: newCrop,
|
||||
|
||||
// Tracking fields powering the "Undo crop" button's single-step
|
||||
// rollback. See App-side handleApplyCrop / handleUndoCrop for
|
||||
// the full rationale; the short version:
|
||||
//
|
||||
// preCrop: the dims immediately BEFORE this crop. Used by
|
||||
// "Undo crop" to restore bounds when the user hasn't
|
||||
// resized since this crop apply.
|
||||
//
|
||||
// postCrop: the dims immediately AFTER this crop. Compared
|
||||
// against current bounds at undo time to decide whether the
|
||||
// user resized between the crop and the undo — if they did,
|
||||
// undo falls through to the fit-to-bounds branch instead of
|
||||
// literal restore.
|
||||
//
|
||||
// previousCrop: the crop value that was active immediately
|
||||
// before this apply (null for a first-time crop). Restored
|
||||
// on undo so multi-crop sequences go back ONE level rather
|
||||
// than jumping to fully-uncropped.
|
||||
preCrop: { width: element.width, height: element.height },
|
||||
postCrop: { width: cropRect.width, height: cropRect.height },
|
||||
previousCrop: element.crop ?? null,
|
||||
};
|
||||
}
|
||||
479
src/utils/cropMath.test.js
Normal file
479
src/utils/cropMath.test.js
Normal file
@@ -0,0 +1,479 @@
|
||||
// Tests for cropMath.js — the pure crop math extracted from
|
||||
// App.handleApplyCrop on May 23 2026.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// Three bug-prone behaviors that lived inline in App.jsx until the
|
||||
// extraction:
|
||||
//
|
||||
// 1. Multi-crop composition. A second crop's source coordinates
|
||||
// must be expressed relative to the FULL natural image, not the
|
||||
// current crop window. Pre-extraction this was correct (the
|
||||
// `currentSrcX + localCropX * scaleX` formula composes properly)
|
||||
// but the math was buried in a 200-line component method; a
|
||||
// future refactor accidentally simplifying to
|
||||
// `localCropX * scaleX` would silently break multi-crop.
|
||||
//
|
||||
// 2. flipX/flipY mirror correction. The crop overlay renders in
|
||||
// canvas coords without knowing about the flip state, so the
|
||||
// function has to mirror back across the local axis. Getting
|
||||
// this wrong meant cropping a horizontally-flipped image to a
|
||||
// visual "ear" would produce a crop pointing at the opposite
|
||||
// ear. (Hard to notice in screenshots; the cropped result still
|
||||
// "looks like a face," just the wrong part of it.)
|
||||
//
|
||||
// 3. Tracking-field invariants. preCrop / postCrop / previousCrop
|
||||
// power the "Undo crop" button. Each has a specific semantic
|
||||
// contract (preCrop = dims BEFORE this apply; postCrop = dims
|
||||
// AFTER; previousCrop = the crop record we replaced, null for
|
||||
// first-time crops) and a regression that conflated them
|
||||
// would break single-step undo without breaking any visual
|
||||
// check at the moment of crop.
|
||||
|
||||
import { computeCropUpdate } from './cropMath';
|
||||
|
||||
// Small helpers so each test reads like "given X, expect Y" without
|
||||
// long inline object literals.
|
||||
|
||||
function image(overrides = {}) {
|
||||
return {
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function rect(x, y, width, height) {
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// First crop on an uncropped element.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeCropUpdate — first crop (no existing crop)', () => {
|
||||
it('maps cropRect canvas coords into source coords via natural/element scale', () => {
|
||||
// Element 100×100 displays a 200×300 source. scaleX = 2, scaleY = 3.
|
||||
// Crop the visible region (10, 20) → (60, 80) on canvas, which is
|
||||
// local (10, 20) → (60, 80) since the element is at origin. In
|
||||
// source coords that becomes (20, 60) → (120, 240) — i.e.
|
||||
// sx=20, sy=60, sWidth=100, sHeight=180.
|
||||
const el = image({ width: 100, height: 100 });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 20, 50, 60),
|
||||
naturalW: 200,
|
||||
naturalH: 300,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 20,
|
||||
sy: 60,
|
||||
sWidth: 100,
|
||||
sHeight: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the new element geometry to match the crop rect', () => {
|
||||
const el = image({ x: 5, y: 5, width: 100, height: 100 });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 20, 50, 60),
|
||||
naturalW: 200,
|
||||
naturalH: 300,
|
||||
});
|
||||
expect(patch.x).toBe(10);
|
||||
expect(patch.y).toBe(20);
|
||||
expect(patch.width).toBe(50);
|
||||
expect(patch.height).toBe(60);
|
||||
});
|
||||
|
||||
it('records previousCrop as null on a first-time crop', () => {
|
||||
// Element has no .crop, so the new crop replaces nothing —
|
||||
// previousCrop must be null so a follow-up undo lands in the
|
||||
// fully-uncropped branch instead of trying to restore a
|
||||
// non-existent prior crop.
|
||||
const el = image();
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 10, 50, 50),
|
||||
naturalW: 200,
|
||||
naturalH: 200,
|
||||
});
|
||||
expect(patch.previousCrop).toBeNull();
|
||||
});
|
||||
|
||||
it('records preCrop and postCrop matching the before/after element dims', () => {
|
||||
const el = image({ width: 100, height: 80 });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(0, 0, 40, 30),
|
||||
naturalW: 200,
|
||||
naturalH: 160,
|
||||
});
|
||||
expect(patch.preCrop).toEqual({ width: 100, height: 80 });
|
||||
expect(patch.postCrop).toEqual({ width: 40, height: 30 });
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Second (and Nth) crop — composes with previousCrop.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeCropUpdate — second crop (composes with existing crop)', () => {
|
||||
it('expresses the new source region relative to the FULL natural image', () => {
|
||||
// First crop scenario from earlier ended with element at (10, 20)
|
||||
// size 50×60 displaying source region (20, 60) → (120, 240). Now
|
||||
// the user further crops to (15, 25) size 20×30 on canvas.
|
||||
//
|
||||
// Within the element's local box, that's (5, 5) → (25, 35).
|
||||
// The element's current crop has sWidth=100 over element.width=50
|
||||
// (scaleX=2), and sHeight=180 over element.height=60 (scaleY=3).
|
||||
//
|
||||
// newCrop.sx = 20 (current sx) + 5 * 2 (local * scale) = 30
|
||||
// newCrop.sy = 60 + 5 * 3 = 75
|
||||
// newCrop.sWidth = 20 * 2 = 40
|
||||
// newCrop.sHeight = 30 * 3 = 90
|
||||
//
|
||||
// Crucially these are coords into the ORIGINAL 200×300 source,
|
||||
// not into the first-crop's sub-region — that's what makes
|
||||
// multi-crop compose correctly.
|
||||
const el = image({
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 50,
|
||||
height: 60,
|
||||
crop: { sx: 20, sy: 60, sWidth: 100, sHeight: 180 },
|
||||
});
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(15, 25, 20, 30),
|
||||
naturalW: 200,
|
||||
naturalH: 300,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 30,
|
||||
sy: 75,
|
||||
sWidth: 40,
|
||||
sHeight: 90,
|
||||
});
|
||||
});
|
||||
|
||||
it('captures the existing crop as previousCrop for one-step undo', () => {
|
||||
// The previous crop must survive into the patch so the
|
||||
// "Undo crop" button can restore it. The fields are pulled
|
||||
// straight from element.crop with no transformation.
|
||||
const existing = { sx: 20, sy: 60, sWidth: 100, sHeight: 180 };
|
||||
const el = image({ width: 50, height: 60, crop: existing });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(0, 0, 20, 30),
|
||||
naturalW: 200,
|
||||
naturalH: 300,
|
||||
});
|
||||
expect(patch.previousCrop).toEqual(existing);
|
||||
});
|
||||
|
||||
it('preCrop captures the current element dims, not the original natural dims', () => {
|
||||
// preCrop is "the dims immediately before this apply", which on
|
||||
// the second crop means the FIRST CROP'S dims, not the
|
||||
// original-image dims. This is the contract that lets undo go
|
||||
// back ONE step (to the first crop) rather than all the way to
|
||||
// uncropped.
|
||||
const el = image({
|
||||
width: 50,
|
||||
height: 60,
|
||||
crop: { sx: 20, sy: 60, sWidth: 100, sHeight: 180 },
|
||||
});
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(0, 0, 20, 30),
|
||||
naturalW: 200,
|
||||
naturalH: 300,
|
||||
});
|
||||
expect(patch.preCrop).toEqual({ width: 50, height: 60 });
|
||||
});
|
||||
|
||||
it('multi-crop sequences do not drift across many steps', () => {
|
||||
// Simulates a user cropping repeatedly. Each step should produce
|
||||
// a sub-region of the PREVIOUS sub-region (not the original).
|
||||
// This test sanity-checks composition over multiple iterations —
|
||||
// a bug where the scale was computed from naturalW/element.width
|
||||
// instead of currentSrcW/element.width would produce wildly
|
||||
// wrong sx values by the third or fourth crop.
|
||||
let el = image({ x: 0, y: 0, width: 100, height: 100 });
|
||||
const naturalW = 1024;
|
||||
const naturalH = 1024;
|
||||
|
||||
// Crop 1: inner half. Local (25, 25) → (75, 75). Source coords
|
||||
// (256, 256) → (768, 768).
|
||||
const p1 = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(25, 25, 50, 50),
|
||||
naturalW,
|
||||
naturalH,
|
||||
});
|
||||
el = { ...el, ...p1 };
|
||||
expect(p1.crop.sx).toBe(256);
|
||||
expect(p1.crop.sWidth).toBe(512);
|
||||
|
||||
// Crop 2: another inner half of the first crop. Element is now
|
||||
// at (25, 25) size 50×50, displaying source region (256, 256) →
|
||||
// (768, 768). Crop the inner half of that — canvas (37.5, 37.5)
|
||||
// → (62.5, 62.5). Local (12.5, 12.5) → (37.5, 37.5). Source
|
||||
// scaleX = 512/50 = 10.24, so sx = 256 + 12.5 * 10.24 = 384,
|
||||
// sWidth = 25 * 10.24 = 256. Effectively the inner half of the
|
||||
// first crop, exactly as expected.
|
||||
const p2 = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(37.5, 37.5, 25, 25),
|
||||
naturalW,
|
||||
naturalH,
|
||||
});
|
||||
el = { ...el, ...p2 };
|
||||
expect(p2.crop.sx).toBe(384);
|
||||
expect(p2.crop.sWidth).toBe(256);
|
||||
|
||||
// Crop 3: inner half of THAT. Element at (37.5, 37.5) size 25×25
|
||||
// displaying source (384, 384) → (640, 640). Inner half → source
|
||||
// (448, 448) → (576, 576), sWidth=128.
|
||||
const p3 = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(43.75, 43.75, 12.5, 12.5),
|
||||
naturalW,
|
||||
naturalH,
|
||||
});
|
||||
expect(p3.crop.sx).toBe(448);
|
||||
expect(p3.crop.sWidth).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Flip correction.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeCropUpdate — flipX correction', () => {
|
||||
it('mirrors the X axis when element.flipX is true', () => {
|
||||
// Element 100×100 at origin, flipX=true. User visually selects
|
||||
// (10, 20) → (40, 60) on canvas. In LOCAL coords that's (10, 20)
|
||||
// → (40, 60); in SOURCE coords we need to mirror across the
|
||||
// local X axis, so the source-X range becomes
|
||||
// left = element.width - localX - cropRect.width = 100 - 10 - 30 = 60
|
||||
// right = 60 + 30 = 90
|
||||
// Multiply by scaleX (naturalW/element.width = 200/100 = 2):
|
||||
// source sx = 60 * 2 = 120
|
||||
// source sWidth = 30 * 2 = 60
|
||||
// Y is unaffected: localY = 20, sy = 20 * 2 = 40, sHeight = 40 * 2 = 80.
|
||||
const el = image({ flipX: true });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 20, 30, 40),
|
||||
naturalW: 200,
|
||||
naturalH: 200,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 120,
|
||||
sy: 40,
|
||||
sWidth: 60,
|
||||
sHeight: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors the Y axis when element.flipY is true', () => {
|
||||
// Y mirror analog of the X test. Source Y range:
|
||||
// top = element.height - localY - cropRect.height = 100 - 20 - 40 = 40
|
||||
// sy = 40 * scaleY (naturalH/element.height = 2) = 80
|
||||
// sHeight = 40 * 2 = 80
|
||||
// X unaffected: sx = 10 * 2 = 20, sWidth = 30 * 2 = 60.
|
||||
const el = image({ flipY: true });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 20, 30, 40),
|
||||
naturalW: 200,
|
||||
naturalH: 200,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 20,
|
||||
sy: 80,
|
||||
sWidth: 60,
|
||||
sHeight: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors both axes when both flips are set', () => {
|
||||
// Compose both formulas.
|
||||
// localCropX = 100 - 10 - 30 = 60, sx = 120
|
||||
// localCropY = 100 - 20 - 40 = 40, sy = 80
|
||||
const el = image({ flipX: true, flipY: true });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 20, 30, 40),
|
||||
naturalW: 200,
|
||||
naturalH: 200,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 120,
|
||||
sy: 80,
|
||||
sWidth: 60,
|
||||
sHeight: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it('the flip+previousCrop combo composes correctly', () => {
|
||||
// Edge case: an already-cropped, also-flipped image gets cropped
|
||||
// again. The math must apply flip correction in LOCAL coords AND
|
||||
// compose with previousCrop in source coords. Pulling this
|
||||
// through:
|
||||
// element at (0, 0) size 50×50, flipX=true,
|
||||
// crop = { sx: 50, sy: 50, sWidth: 100, sHeight: 100 }
|
||||
// (source region 50..150 of a 200-wide image, displayed
|
||||
// mirror-image on a 50px-wide element)
|
||||
// user crops canvas (10, 10) → (40, 40), which is
|
||||
// local (10, 10) → (40, 40)
|
||||
// flipX correction: localCropX = 50 - 10 - 30 = 10
|
||||
// scaleX = 100/50 = 2
|
||||
// sx = 50 (currentSrcX) + 10 * 2 = 70
|
||||
// sWidth = 30 * 2 = 60
|
||||
// Y is unaffected (no flipY): sy = 50 + 10 * 2 = 70, sHeight = 60.
|
||||
const el = image({
|
||||
width: 50,
|
||||
height: 50,
|
||||
flipX: true,
|
||||
crop: { sx: 50, sy: 50, sWidth: 100, sHeight: 100 },
|
||||
});
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(10, 10, 30, 30),
|
||||
naturalW: 200,
|
||||
naturalH: 200,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 70,
|
||||
sy: 70,
|
||||
sWidth: 60,
|
||||
sHeight: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Null guards.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeCropUpdate — null returns on bad input', () => {
|
||||
// The null return matches handleApplyCrop's bail-out paths. Returning
|
||||
// null tells the caller "couldn't compute, don't commit." The caller
|
||||
// is responsible for surfacing the user-facing toast.
|
||||
|
||||
it('returns null for missing element', () => {
|
||||
expect(computeCropUpdate({
|
||||
element: null,
|
||||
cropRect: rect(0, 0, 10, 10),
|
||||
naturalW: 100,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing cropRect', () => {
|
||||
expect(computeCropUpdate({
|
||||
element: image(),
|
||||
cropRect: null,
|
||||
naturalW: 100,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when natural dims are missing or zero (image not loaded)', () => {
|
||||
// The headline "image still loading" case. handleApplyCrop's
|
||||
// Konva stage walk can return 0/0 if the underlying HTMLImage
|
||||
// hasn't yet fired its load event. Returning null is what makes
|
||||
// the silent-failure mode impossible — the caller can't commit
|
||||
// a partial patch from a null.
|
||||
expect(computeCropUpdate({
|
||||
element: image(),
|
||||
cropRect: rect(0, 0, 10, 10),
|
||||
naturalW: 0,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
expect(computeCropUpdate({
|
||||
element: image(),
|
||||
cropRect: rect(0, 0, 10, 10),
|
||||
naturalW: 100,
|
||||
naturalH: 0,
|
||||
})).toBeNull();
|
||||
expect(computeCropUpdate({
|
||||
element: image(),
|
||||
cropRect: rect(0, 0, 10, 10),
|
||||
naturalW: undefined,
|
||||
naturalH: undefined,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when element width or height is zero', () => {
|
||||
// Would cause divide-by-zero in scaleX/Y. Defensive guard.
|
||||
expect(computeCropUpdate({
|
||||
element: image({ width: 0 }),
|
||||
cropRect: rect(0, 0, 10, 10),
|
||||
naturalW: 100,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
expect(computeCropUpdate({
|
||||
element: image({ height: 0 }),
|
||||
cropRect: rect(0, 0, 10, 10),
|
||||
naturalW: 100,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when cropRect width or height is zero', () => {
|
||||
// A zero-area crop wouldn't visually represent anything; better
|
||||
// to bail than to commit a crop with sWidth=0 that would render
|
||||
// as an invisible image.
|
||||
expect(computeCropUpdate({
|
||||
element: image(),
|
||||
cropRect: rect(0, 0, 0, 10),
|
||||
naturalW: 100,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
expect(computeCropUpdate({
|
||||
element: image(),
|
||||
cropRect: rect(0, 0, 10, 0),
|
||||
naturalW: 100,
|
||||
naturalH: 100,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Identity / no-op crop.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('computeCropUpdate — identity crop (full element bounds)', () => {
|
||||
it('produces a crop covering the full source when cropRect equals element bounds', () => {
|
||||
// Cropping the entire element is a no-op visually but should
|
||||
// still produce a valid crop record. The result equals the
|
||||
// natural-dims rectangle: sx=0, sy=0, sWidth=naturalW,
|
||||
// sHeight=naturalH.
|
||||
const el = image({ x: 5, y: 5, width: 100, height: 100 });
|
||||
const patch = computeCropUpdate({
|
||||
element: el,
|
||||
cropRect: rect(5, 5, 100, 100),
|
||||
naturalW: 200,
|
||||
naturalH: 300,
|
||||
});
|
||||
expect(patch.crop).toEqual({
|
||||
sx: 0,
|
||||
sy: 0,
|
||||
sWidth: 200,
|
||||
sHeight: 300,
|
||||
});
|
||||
// Element geometry unchanged (the crop rect is the same as the
|
||||
// existing element bounds).
|
||||
expect(patch.x).toBe(5);
|
||||
expect(patch.y).toBe(5);
|
||||
expect(patch.width).toBe(100);
|
||||
expect(patch.height).toBe(100);
|
||||
});
|
||||
});
|
||||
237
src/utils/elementBounds.js
Normal file
237
src/utils/elementBounds.js
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Element bounds checking.
|
||||
*
|
||||
* The "print area" is the design canvas — a CANVAS_SIZE × CANVAS_SIZE square
|
||||
* (currently 300×300 in design units for shirts). Any element whose rotated
|
||||
* axis-aligned bounding box extends outside that square will be cropped at
|
||||
* print time. We surface a warning + block Add-to-Cart when that happens, so
|
||||
* a user never accidentally buys a shirt with their pet's ears guillotined.
|
||||
*
|
||||
* The math here mirrors the AABB calculation in DesignCanvas's
|
||||
* `constrainTransform` (the M4 hot-path bound function), but operates on
|
||||
* stored element state instead of mid-transform Konva data:
|
||||
*
|
||||
* • Element x/y is the top-left corner in design coordinates.
|
||||
* • For images: width/height come straight from element.
|
||||
* • For text: we delegate to `computeTextVisibleBbox` in textGeometry.js,
|
||||
* which returns the visible-ink envelope (the rectangle hugging the
|
||||
* actual rendered glyphs, not the line-height box that contains
|
||||
* substantial empty space above the baseline). For arc'd text the
|
||||
* envelope is computed per-character along the curve, so a mixed-case
|
||||
* string like "Queen" with lowercase letters at the arc peak gets a
|
||||
* tight bbox there rather than the over-conservative whole-string
|
||||
* cap-height ascent we used before (Change 23).
|
||||
* • The same `computeTextVisibleBbox` feeds TextElement.jsx's in-canvas
|
||||
* OOB indicator rect, snap-bbox custom attrs, and Transformer getSelfRect
|
||||
* override — so the warning chip, the yellow tint rect, the
|
||||
* selection handles, and the snap center are all driven by one
|
||||
* calculation. No drift between visual and bounds.
|
||||
* • Rotation is applied around (x, y) — the same convention Konva uses
|
||||
* when no offset is set, which is how unflipped elements render.
|
||||
* • Flipped elements have render-time offset adjustments, but element.x
|
||||
* / element.y are stored as the un-flipped top-left, so this math is
|
||||
* correct for them too.
|
||||
*
|
||||
* The tolerance (BOUNDS_EPSILON) absorbs sub-pixel rounding artifacts so
|
||||
* an element snapped exactly to the canvas edge isn't flagged due to a
|
||||
* 0.0001-unit floating-point residual.
|
||||
*/
|
||||
|
||||
import { computeTextVisibleBbox } from './textGeometry';
|
||||
import {
|
||||
DEFAULT_DISPLAY_FONT,
|
||||
DEFAULT_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from '../constants/textMetrics';
|
||||
import { MIN_ELEMENT_SIZE } from '../constants/elements';
|
||||
|
||||
const BOUNDS_EPSILON = 0.5;
|
||||
|
||||
/**
|
||||
* Compute the rotated AABB of an element, in design coordinates.
|
||||
* Returns null for element types we don't render (so they're treated as
|
||||
* "in bounds" by callers — no false warnings for unknown types).
|
||||
*
|
||||
* For text elements, the unrotated rect is the VISIBLE-INK envelope from
|
||||
* `computeTextVisibleBbox`. See that function's docblock for the
|
||||
* per-character arc geometry.
|
||||
*/
|
||||
function getElementAABB(element) {
|
||||
if (!element) return null;
|
||||
|
||||
// Unrotated rect corners in node-local coords. For images this is
|
||||
// simply [0, w] × [0, h]. For text it's the visible-ink envelope.
|
||||
let minLocalX, maxLocalX, minLocalY, maxLocalY;
|
||||
|
||||
if (element.type === 'image' || element.type === 'sticker') {
|
||||
const w = element.width || 0;
|
||||
const h = element.height || 0;
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
minLocalX = 0;
|
||||
maxLocalX = w;
|
||||
minLocalY = 0;
|
||||
maxLocalY = h;
|
||||
} else if (element.type === 'text') {
|
||||
const bbox = computeTextVisibleBbox({
|
||||
text: element.text ?? '',
|
||||
fontSize: element.fontSize ?? DEFAULT_FONT_SIZE,
|
||||
fontFamily: element.fontFamily ?? DEFAULT_DISPLAY_FONT,
|
||||
arc: element.arc ?? 0,
|
||||
});
|
||||
if (!bbox) return null;
|
||||
minLocalX = bbox.minX;
|
||||
maxLocalX = bbox.maxX;
|
||||
minLocalY = bbox.minY;
|
||||
maxLocalY = bbox.maxY;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x = element.x ?? 0;
|
||||
const y = element.y ?? 0;
|
||||
const rad = ((element.rotation ?? 0) * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
// Rotate the four corners of the unrotated rect around (x, y).
|
||||
// (dx, dy) → (x + dx*cos - dy*sin, y + dx*sin + dy*cos)
|
||||
// For non-text elements minLocalY=0 and maxLocalY=h, which reduces
|
||||
// to the original four-corner computation. For text the local Y
|
||||
// range may include negative values (arc-up rising above y=0 in
|
||||
// node coords) and the rotation math handles that fine.
|
||||
const tlX = x + minLocalX * cos - minLocalY * sin; const tlY = y + minLocalX * sin + minLocalY * cos;
|
||||
const trX = x + maxLocalX * cos - minLocalY * sin; const trY = y + maxLocalX * sin + minLocalY * cos;
|
||||
const blX = x + minLocalX * cos - maxLocalY * sin; const blY = y + minLocalX * sin + maxLocalY * cos;
|
||||
const brX = x + maxLocalX * cos - maxLocalY * sin; const brY = y + maxLocalX * sin + maxLocalY * cos;
|
||||
|
||||
return {
|
||||
minX: Math.min(tlX, trX, blX, brX),
|
||||
maxX: Math.max(tlX, trX, blX, brX),
|
||||
minY: Math.min(tlY, trY, blY, brY),
|
||||
maxY: Math.max(tlY, trY, blY, brY),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the element's rotated AABB extends outside the [0, canvasSize]²
|
||||
* print area, beyond the small floating-point tolerance.
|
||||
*/
|
||||
export function isElementOutOfBounds(element, canvasSize) {
|
||||
const aabb = getElementAABB(element);
|
||||
if (!aabb) return false;
|
||||
return (
|
||||
aabb.minX < -BOUNDS_EPSILON
|
||||
|| aabb.maxX > canvasSize + BOUNDS_EPSILON
|
||||
|| aabb.minY < -BOUNDS_EPSILON
|
||||
|| aabb.maxY > canvasSize + BOUNDS_EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of elements currently outside the print area. Returns 0 for an
|
||||
* empty array, so callers can treat 0 as "no warning, cart enabled".
|
||||
*/
|
||||
export function countOutOfBounds(elements, canvasSize) {
|
||||
if (!elements || elements.length === 0) return 0;
|
||||
let count = 0;
|
||||
for (const el of elements) {
|
||||
if (isElementOutOfBounds(el, canvasSize)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust an element so its rotated AABB fits inside [0, canvasSize]².
|
||||
*
|
||||
* Strategy
|
||||
* ────────
|
||||
* 1. If the AABB is already inside the canvas (within tolerance), the
|
||||
* element is returned unchanged.
|
||||
* 2. If the AABB is wider or taller than the canvas, scale the element
|
||||
* down uniformly so the AABB fits with a 5% margin. Scaling drives
|
||||
* width/height for images and fontSize for text — both preserve
|
||||
* aspect ratio implicitly.
|
||||
* 3. After any scale, translate (x, y) so any remaining out-of-bounds
|
||||
* AABB shifts back inside.
|
||||
*
|
||||
* Used by template loading to keep authoring mistakes from breaking the
|
||||
* cart-disable check on first paint. A console.warn fires when fitting
|
||||
* actually changes anything so template authors notice during development.
|
||||
*/
|
||||
export function fitElementToBounds(element, canvasSize) {
|
||||
const aabb = getElementAABB(element);
|
||||
if (!aabb) return element;
|
||||
|
||||
const inside =
|
||||
aabb.minX >= -BOUNDS_EPSILON
|
||||
&& aabb.maxX <= canvasSize + BOUNDS_EPSILON
|
||||
&& aabb.minY >= -BOUNDS_EPSILON
|
||||
&& aabb.maxY <= canvasSize + BOUNDS_EPSILON;
|
||||
if (inside) return element;
|
||||
|
||||
const updates = {};
|
||||
|
||||
// Step 1: uniform scale-down if AABB is bigger than the canvas.
|
||||
const aabbW = aabb.maxX - aabb.minX;
|
||||
const aabbH = aabb.maxY - aabb.minY;
|
||||
const maxW = canvasSize * 0.95;
|
||||
const maxH = canvasSize * 0.95;
|
||||
let scale = 1;
|
||||
if (aabbW > maxW || aabbH > maxH) {
|
||||
scale = Math.min(maxW / aabbW, maxH / aabbH);
|
||||
}
|
||||
|
||||
if (scale < 1) {
|
||||
if (element.type === 'image' || element.type === 'sticker') {
|
||||
updates.width = Math.max(MIN_ELEMENT_SIZE, Math.round((element.width || 100) * scale));
|
||||
updates.height = Math.max(MIN_ELEMENT_SIZE, Math.round((element.height || 100) * scale));
|
||||
} else if (element.type === 'text') {
|
||||
updates.fontSize = Math.max(MIN_FONT_SIZE, Math.round((element.fontSize || DEFAULT_FONT_SIZE) * scale));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: translate to bring the (possibly scaled) AABB inside the
|
||||
// canvas. We re-measure after scaling so the translate uses the new
|
||||
// dimensions, otherwise we'd over-shift.
|
||||
const scaled = Object.keys(updates).length > 0 ? { ...element, ...updates } : element;
|
||||
const newAabb = getElementAABB(scaled);
|
||||
if (newAabb) {
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (newAabb.minX < 0) dx = -newAabb.minX;
|
||||
else if (newAabb.maxX > canvasSize) dx = canvasSize - newAabb.maxX;
|
||||
if (newAabb.minY < 0) dy = -newAabb.minY;
|
||||
else if (newAabb.maxY > canvasSize) dy = canvasSize - newAabb.maxY;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
updates.x = (element.x ?? 0) + dx;
|
||||
updates.y = (element.y ?? 0) + dy;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(updates).length > 0 ? { ...element, ...updates } : element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map `fitElementToBounds` over an array, returning a new array. If any
|
||||
* element actually changes, log a console.warn so template authors notice
|
||||
* the runtime correction — these are usually authoring bugs that should
|
||||
* be fixed at the template-data level rather than relied on as runtime
|
||||
* safety nets.
|
||||
*/
|
||||
export function fitElementsToBounds(elements, canvasSize) {
|
||||
if (!elements || elements.length === 0) return elements || [];
|
||||
let mutatedCount = 0;
|
||||
const out = elements.map((el) => {
|
||||
const fitted = fitElementToBounds(el, canvasSize);
|
||||
if (fitted !== el) mutatedCount++;
|
||||
return fitted;
|
||||
});
|
||||
if (mutatedCount > 0 && typeof console !== 'undefined') {
|
||||
console.warn(
|
||||
`[elementBounds] Auto-fit ${mutatedCount} element${mutatedCount === 1 ? '' : 's'} `
|
||||
+ `to canvas bounds. The source data has elements outside [0, ${canvasSize}]² — `
|
||||
+ 'this is usually a template authoring bug worth fixing at the data layer.'
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
497
src/utils/elementBounds.test.js
Normal file
497
src/utils/elementBounds.test.js
Normal file
@@ -0,0 +1,497 @@
|
||||
// Tests for elementBounds.js — rotated AABB math, out-of-bounds
|
||||
// detection, and fit-to-canvas correction.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// elementBounds powers the "your design extends past the print area"
|
||||
// warning and the Add-to-Cart disable. False negatives let users buy
|
||||
// shirts with their pet's ears cropped; false positives nag users
|
||||
// whose designs are actually fine. The rotation math is the most
|
||||
// likely source of either — and mirrors the constrainTransform math
|
||||
// in DesignCanvas's boundBoxFunc, so any drift here also drifts the
|
||||
// runtime drag constraint.
|
||||
//
|
||||
// Key invariants tested
|
||||
// ─────────────────────
|
||||
// • Image AABB is straightforward [0, w] × [0, h] rotated around
|
||||
// (x, y) — element.x/y is the top-left, not the center, and the
|
||||
// rotation pivot for bounds purposes is the top-left (matching
|
||||
// Konva's behavior when no offset is set, which is how unflipped
|
||||
// elements render their bounds).
|
||||
// • BOUNDS_EPSILON (0.5) absorbs sub-pixel rounding so an element
|
||||
// snapped exactly to the canvas edge isn't flagged due to a
|
||||
// floating-point residual.
|
||||
// • fitElementToBounds returns the SAME REFERENCE when nothing
|
||||
// changed — important for React render perf (a new object every
|
||||
// check would invalidate downstream memos).
|
||||
// • fitElementsToBounds prints a console.warn when anything was
|
||||
// fitted, since these are usually template authoring bugs.
|
||||
//
|
||||
// What this doesn't test deeply
|
||||
// ─────────────────────────────
|
||||
// Text AABB delegates to `computeTextVisibleBbox`, which depends on
|
||||
// canvas measureText. jsdom's canvas measureText (when the `canvas`
|
||||
// package is loaded) is approximate and font-dependent. We test text
|
||||
// at the element-shape level (a text element WAY outside the canvas
|
||||
// is out; a text element near origin is in) rather than asserting
|
||||
// pixel-exact text AABB values.
|
||||
|
||||
import {
|
||||
isElementOutOfBounds,
|
||||
countOutOfBounds,
|
||||
fitElementToBounds,
|
||||
fitElementsToBounds,
|
||||
} from './elementBounds';
|
||||
|
||||
const CANVAS_SIZE = 300;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// isElementOutOfBounds — axis-aligned cases first.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isElementOutOfBounds — images (no rotation)', () => {
|
||||
it('returns false for an image fully inside the canvas', () => {
|
||||
const el = { type: 'image', x: 50, y: 50, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when minX is past the left edge', () => {
|
||||
const el = { type: 'image', x: -10, y: 50, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when maxX is past the right edge', () => {
|
||||
// x=250, width=100 → maxX=350 > 300.
|
||||
const el = { type: 'image', x: 250, y: 50, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when minY is past the top edge', () => {
|
||||
const el = { type: 'image', x: 50, y: -10, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when maxY is past the bottom edge', () => {
|
||||
const el = { type: 'image', x: 50, y: 250, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats sticker type the same as image', () => {
|
||||
// The AABB math branches on `image | sticker` together — both
|
||||
// are rectangular elements with the same width/height geometry.
|
||||
// Pinning this so a future refactor that separated them would
|
||||
// surface the divergence here.
|
||||
const el = { type: 'sticker', x: -10, y: 0, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isElementOutOfBounds — BOUNDS_EPSILON boundary', () => {
|
||||
// BOUNDS_EPSILON = 0.5 absorbs sub-pixel rounding. The exact value
|
||||
// is encapsulated inside the module, but its EFFECT is visible
|
||||
// through these boundary tests: a tiny float residual at the edge
|
||||
// doesn't flip the result, but a half-pixel or more does.
|
||||
|
||||
it('counts as inside when an edge is within 0.5 of the boundary', () => {
|
||||
// x=-0.4 means minX is -0.4. With epsilon=0.5, the test
|
||||
// `aabb.minX < -BOUNDS_EPSILON` becomes `-0.4 < -0.5`, false.
|
||||
const el = { type: 'image', x: -0.4, y: 0, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('counts as out when an edge exceeds the boundary by more than 0.5', () => {
|
||||
// x=-0.6 means minX is -0.6. `-0.6 < -0.5` is true → out.
|
||||
const el = { type: 'image', x: -0.6, y: 0, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('counts as inside when maxX is exactly at the canvas edge', () => {
|
||||
// x=200, width=100 → maxX=300. `300 > 300 + 0.5` is false → in.
|
||||
// Common case: a user dragged an element flush to the right edge.
|
||||
const el = { type: 'image', x: 200, y: 0, width: 100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isElementOutOfBounds — rotated images', () => {
|
||||
// Rotation is around (x, y) — the unrotated top-left. The function
|
||||
// rotates all four corners and takes min/max for the AABB.
|
||||
//
|
||||
// These tests pre-compute the expected geometry so the assertion
|
||||
// values are independent of the AABB internals.
|
||||
|
||||
it('a 90° rotation pivots the rect into a different bounding box', () => {
|
||||
// 100×100 image at (150, 150) rotated 90°.
|
||||
// cos=0, sin=1
|
||||
// TL: (150 + 0*0 - 0*1, 150 + 0*1 + 0*0) = (150, 150)
|
||||
// TR: (150 + 100*0 - 0*1, 150 + 100*1 + 0*0) = (150, 250)
|
||||
// BL: (150 + 0*0 - 100*1, 150 + 0*1 + 100*0) = (50, 150)
|
||||
// BR: (150 + 100*0 - 100*1, 150 + 100*1 + 100*0) = (50, 250)
|
||||
// AABB: minX=50, maxX=150, minY=150, maxY=250.
|
||||
// Fully inside the 300×300 canvas.
|
||||
const el = {
|
||||
type: 'image',
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
rotation: 90,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('a 45° rotation around (0,0) extends the bbox into negative coords (out)', () => {
|
||||
// 100×100 at (0, 0) rotated 45°.
|
||||
// cos = sin = √2/2 ≈ 0.7071
|
||||
// TL: (0, 0)
|
||||
// TR: (100*c, 100*s) = (70.7, 70.7)
|
||||
// BL: (-100*s, 100*c) = (-70.7, 70.7)
|
||||
// BR: (100*c - 100*s, 100*s + 100*c) = (0, 141.4)
|
||||
// minX = -70.7 → past the left edge by way more than epsilon → out.
|
||||
const el = {
|
||||
type: 'image',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
rotation: 45,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('a 45° rotation around (150, 150) fits inside if the radius is small enough', () => {
|
||||
// 50×50 at (150, 150) rotated 45°.
|
||||
// cos = sin ≈ 0.7071
|
||||
// TL: (150, 150)
|
||||
// TR: (150 + 35.4, 150 + 35.4) ≈ (185.4, 185.4)
|
||||
// BL: (150 - 35.4, 150 + 35.4) ≈ (114.6, 185.4)
|
||||
// BR: (150, 150 + 70.7) = (150, 220.7)
|
||||
// AABB: minX≈114.6, maxX≈185.4, minY=150, maxY≈220.7. All inside.
|
||||
const el = {
|
||||
type: 'image',
|
||||
x: 150,
|
||||
y: 150,
|
||||
width: 50,
|
||||
height: 50,
|
||||
rotation: 45,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('rotation that pushes a corner outside the canvas flags as out', () => {
|
||||
// 100×100 at (250, 250) rotated 30°.
|
||||
// cos ≈ 0.866, sin = 0.5
|
||||
// BR: (250 + 100*c - 100*s, 250 + 100*s + 100*c)
|
||||
// = (250 + 86.6 - 50, 250 + 50 + 86.6)
|
||||
// = (286.6, 386.6)
|
||||
// maxY ≈ 386.6 > 300+0.5 → out.
|
||||
const el = {
|
||||
type: 'image',
|
||||
x: 250,
|
||||
y: 250,
|
||||
width: 100,
|
||||
height: 100,
|
||||
rotation: 30,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles negative rotation angles symmetrically', () => {
|
||||
// Rotation -45° should produce the mirror of the 45°-around-(0,0)
|
||||
// case — the rect tips the other way. Specifically:
|
||||
// 100×100 at (100, 0) rotated -45°.
|
||||
// cos ≈ 0.7071, sin ≈ -0.7071
|
||||
// TL: (100, 0)
|
||||
// TR: (100 + 70.7, -70.7) ≈ (170.7, -70.7)
|
||||
// minY ≈ -70.7 → past the top edge → out.
|
||||
const el = {
|
||||
type: 'image',
|
||||
x: 100,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
rotation: -45,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isElementOutOfBounds — degenerate inputs', () => {
|
||||
it('returns false for null/undefined elements (no AABB)', () => {
|
||||
expect(isElementOutOfBounds(null, CANVAS_SIZE)).toBe(false);
|
||||
expect(isElementOutOfBounds(undefined, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unknown element types', () => {
|
||||
// Defensive: a future element type that doesn't have AABB
|
||||
// shouldn't false-flag as OOB. Returning null from
|
||||
// getElementAABB → the caller treats it as in-bounds.
|
||||
const el = { type: 'video', x: -100, y: -100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for zero-dimensioned images', () => {
|
||||
// An image element mid-load can briefly have width=0/height=0.
|
||||
// We want it treated as "no bbox available" rather than getting
|
||||
// false-flagged.
|
||||
const el = { type: 'image', x: -100, y: -100, width: 0, height: 0 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for negative dimensioned images', () => {
|
||||
// Shouldn't happen in practice (the slider clamps to MIN_ELEMENT_SIZE)
|
||||
// but defensive — negative dims would produce a degenerate
|
||||
// AABB. Treating them as "no AABB" is the safe fallback.
|
||||
const el = { type: 'image', x: 0, y: 0, width: -100, height: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// isElementOutOfBounds — text elements (coarse).
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isElementOutOfBounds — text elements (coarse-grained)', () => {
|
||||
// Text AABB depends on font measurement which is canvas-dependent.
|
||||
// We don't pin pixel values — only the element-shape behavior:
|
||||
// a text element clearly inside is in, one clearly outside is out.
|
||||
|
||||
it('returns false for empty text (null AABB falls through)', () => {
|
||||
// computeTextVisibleBbox returns null for empty/zero-width text.
|
||||
// The bounds check should treat null AABB as in-bounds.
|
||||
const el = { type: 'text', text: '', fontSize: 32, x: 100, y: 100 };
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for text positioned way past the right edge', () => {
|
||||
// x=500 with any positive-width text → minX > canvas → out, no
|
||||
// matter what the font metrics return for the width.
|
||||
const el = {
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
fontSize: 32,
|
||||
x: 500,
|
||||
y: 100,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for text positioned at a deeply negative coord', () => {
|
||||
const el = {
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
fontSize: 32,
|
||||
x: -500,
|
||||
y: 100,
|
||||
};
|
||||
expect(isElementOutOfBounds(el, CANVAS_SIZE)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// countOutOfBounds — straightforward mapping.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('countOutOfBounds', () => {
|
||||
it('returns 0 for an empty array', () => {
|
||||
expect(countOutOfBounds([], CANVAS_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for null/undefined', () => {
|
||||
// Defensive: callers may pass `elements` before it's been
|
||||
// initialised. countOutOfBounds drives the cart-disable boolean,
|
||||
// so a crash here would gate the entire purchase flow.
|
||||
expect(countOutOfBounds(null, CANVAS_SIZE)).toBe(0);
|
||||
expect(countOutOfBounds(undefined, CANVAS_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it('counts mixed in/out elements correctly', () => {
|
||||
const els = [
|
||||
{ type: 'image', x: 0, y: 0, width: 100, height: 100 }, // in
|
||||
{ type: 'image', x: -50, y: 0, width: 100, height: 100 }, // out (left)
|
||||
{ type: 'image', x: 250, y: 250, width: 100, height: 100 }, // out (br)
|
||||
{ type: 'image', x: 100, y: 100, width: 50, height: 50 }, // in
|
||||
];
|
||||
expect(countOutOfBounds(els, CANVAS_SIZE)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns elements.length when all are out', () => {
|
||||
const els = [
|
||||
{ type: 'image', x: -100, y: 0, width: 50, height: 50 },
|
||||
{ type: 'image', x: -200, y: 0, width: 50, height: 50 },
|
||||
];
|
||||
expect(countOutOfBounds(els, CANVAS_SIZE)).toBe(2);
|
||||
});
|
||||
|
||||
it('does not count unknown element types as out (graceful skip)', () => {
|
||||
const els = [
|
||||
{ type: 'image', x: 0, y: 0, width: 100, height: 100 },
|
||||
{ type: 'mystery', x: -100, y: -100 },
|
||||
];
|
||||
expect(countOutOfBounds(els, CANVAS_SIZE)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// fitElementToBounds — single-element correction.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fitElementToBounds', () => {
|
||||
it('returns the same reference when the element is already inside', () => {
|
||||
// No-mutation contract. React downstream consumers memoize on
|
||||
// element identity; returning a fresh object when nothing
|
||||
// changed would invalidate those memos and force unnecessary
|
||||
// re-renders.
|
||||
const el = { type: 'image', x: 50, y: 50, width: 100, height: 100 };
|
||||
expect(fitElementToBounds(el, CANVAS_SIZE)).toBe(el);
|
||||
});
|
||||
|
||||
it('translates the element back when it is positioned past an edge', () => {
|
||||
// Element fits dimensionally but is positioned past the left
|
||||
// edge. Strategy 2 (translate) applies, but no scale.
|
||||
const el = { type: 'image', x: -50, y: 100, width: 100, height: 100 };
|
||||
const fitted = fitElementToBounds(el, CANVAS_SIZE);
|
||||
expect(fitted).not.toBe(el);
|
||||
expect(fitted.width).toBe(100); // unchanged
|
||||
expect(fitted.height).toBe(100);
|
||||
expect(fitted.x).toBe(0); // translated to 0
|
||||
expect(fitted.y).toBe(100); // unchanged
|
||||
});
|
||||
|
||||
it('translates back when an element extends past the right edge', () => {
|
||||
// x=250, width=100 → maxX=350. Shift left by 50 to land maxX=300.
|
||||
const el = { type: 'image', x: 250, y: 100, width: 100, height: 100 };
|
||||
const fitted = fitElementToBounds(el, CANVAS_SIZE);
|
||||
expect(fitted.x).toBe(200);
|
||||
expect(fitted.y).toBe(100);
|
||||
});
|
||||
|
||||
it('scales an oversized element down to fit (with 5% margin)', () => {
|
||||
// 500×500 element on a 300×300 canvas. max usable = 285 (95% of 300).
|
||||
// scale = 285/500 = 0.57. New dims = round(500 * 0.57) = 285.
|
||||
const el = { type: 'image', x: 0, y: 0, width: 500, height: 500 };
|
||||
const fitted = fitElementToBounds(el, CANVAS_SIZE);
|
||||
expect(fitted.width).toBe(285);
|
||||
expect(fitted.height).toBe(285);
|
||||
// After scaling, the AABB still starts at (0, 0) and now ends at
|
||||
// (285, 285) — fits in the canvas, no further translation needed.
|
||||
expect(fitted.x).toBe(0);
|
||||
expect(fitted.y).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves aspect ratio when scaling down a non-square element', () => {
|
||||
// 600 wide × 300 tall. The 95%-of-canvas limit is 285 in both
|
||||
// dimensions. scaleW = 285/600 = 0.475. scaleH = 285/300 = 0.95.
|
||||
// Uniform scale picks the smaller: 0.475.
|
||||
// New: round(600 * 0.475) = 285, round(300 * 0.475) = 143.
|
||||
const el = { type: 'image', x: 0, y: 0, width: 600, height: 300 };
|
||||
const fitted = fitElementToBounds(el, CANVAS_SIZE);
|
||||
expect(fitted.width).toBe(285);
|
||||
expect(fitted.height).toBe(143);
|
||||
// Aspect ratio preserved within rounding tolerance.
|
||||
expect(fitted.width / fitted.height).toBeCloseTo(2.0, 1);
|
||||
});
|
||||
|
||||
it('returns null/undefined unchanged when there is no AABB to fit', () => {
|
||||
// No AABB means we can't compute the correction. The function
|
||||
// should return the element as-is rather than throwing.
|
||||
const unknown = { type: 'mystery', x: -100, y: -100 };
|
||||
expect(fitElementToBounds(unknown, CANVAS_SIZE)).toBe(unknown);
|
||||
});
|
||||
|
||||
it('scales text fontSize when the rendered text is too wide', () => {
|
||||
// We don't know the exact text width because of font measurement
|
||||
// variance, but we DO know: a fontSize=200 with a long string at
|
||||
// canvas size 300 will overflow → scale should reduce fontSize.
|
||||
// Just assert fontSize decreased.
|
||||
const el = {
|
||||
type: 'text',
|
||||
text: 'A very long string of text',
|
||||
fontSize: 200,
|
||||
x: 0,
|
||||
y: 100,
|
||||
fontFamily: 'Pacifico',
|
||||
};
|
||||
const fitted = fitElementToBounds(el, CANVAS_SIZE);
|
||||
if (fitted !== el) {
|
||||
// If a fit happened, fontSize should be smaller.
|
||||
expect(fitted.fontSize).toBeLessThan(200);
|
||||
}
|
||||
// Else the text measurement happened to fit — also acceptable
|
||||
// in jsdom where font metrics are approximate. The test passes
|
||||
// either way; the asymmetric assertion documents that scaling
|
||||
// CAN happen but is font-dependent.
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// fitElementsToBounds — array-level wrapper.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fitElementsToBounds', () => {
|
||||
// We spy on console.warn for tests that check whether the warning
|
||||
// fired. Restore after each test so other suites' console output
|
||||
// is untouched.
|
||||
|
||||
let warnSpy;
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns an empty array for empty/null/undefined input', () => {
|
||||
expect(fitElementsToBounds([], CANVAS_SIZE)).toEqual([]);
|
||||
expect(fitElementsToBounds(null, CANVAS_SIZE)).toEqual([]);
|
||||
expect(fitElementsToBounds(undefined, CANVAS_SIZE)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns elements unchanged when all are already inside', () => {
|
||||
const els = [
|
||||
{ type: 'image', x: 0, y: 0, width: 100, height: 100 },
|
||||
{ type: 'image', x: 150, y: 150, width: 50, height: 50 },
|
||||
];
|
||||
const out = fitElementsToBounds(els, CANVAS_SIZE);
|
||||
// Each individual element keeps reference identity.
|
||||
expect(out[0]).toBe(els[0]);
|
||||
expect(out[1]).toBe(els[1]);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a new array with corrections when any element was off', () => {
|
||||
const inElement = { type: 'image', x: 0, y: 0, width: 100, height: 100 };
|
||||
const offElement = { type: 'image', x: -50, y: 100, width: 100, height: 100 };
|
||||
const out = fitElementsToBounds([inElement, offElement], CANVAS_SIZE);
|
||||
expect(out[0]).toBe(inElement); // unchanged → same ref
|
||||
expect(out[1]).not.toBe(offElement); // corrected → new ref
|
||||
expect(out[1].x).toBe(0);
|
||||
});
|
||||
|
||||
it('logs a console.warn when at least one element was fitted', () => {
|
||||
// The warning is intentionally noisy in development — it surfaces
|
||||
// template authoring bugs that should be fixed at the source
|
||||
// data level, not silently corrected at load time.
|
||||
const off = { type: 'image', x: -50, y: 0, width: 100, height: 100 };
|
||||
fitElementsToBounds([off], CANVAS_SIZE);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/\[elementBounds\]/);
|
||||
});
|
||||
|
||||
it('singularizes the warning text for a single-element correction', () => {
|
||||
// 1 element → "Auto-fit 1 element to canvas bounds" (no 's').
|
||||
// Multi-element → "Auto-fit N elements". The text matters less
|
||||
// than the count being right, but pinning the grammar helps the
|
||||
// log read naturally.
|
||||
const off = { type: 'image', x: -50, y: 0, width: 100, height: 100 };
|
||||
fitElementsToBounds([off], CANVAS_SIZE);
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/1 element /); // trailing space catches the singular
|
||||
});
|
||||
|
||||
it('pluralizes the warning text for multiple corrections', () => {
|
||||
const off1 = { type: 'image', x: -50, y: 0, width: 100, height: 100 };
|
||||
const off2 = { type: 'image', x: 0, y: -50, width: 100, height: 100 };
|
||||
fitElementsToBounds([off1, off2], CANVAS_SIZE);
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/2 elements /);
|
||||
});
|
||||
});
|
||||
67
src/utils/flipOffset.js
Normal file
67
src/utils/flipOffset.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Pivot-aware coordinate adjustment.
|
||||
*
|
||||
* Background
|
||||
* ──────────
|
||||
* Both `ImageElement` and `TextElement` render with `offsetX/Y` set to
|
||||
* the bbox CENTER unconditionally — see the docblocks in those files
|
||||
* for the full rationale. The short version: Konva rotates a node
|
||||
* around its local origin (i.e. the point at `offsetX, offsetY`); with
|
||||
* offset at the bbox center, rotations pivot around the center, which
|
||||
* is what users expect from a rotate handle. Flipping (scaleX/Y = -1)
|
||||
* also reflects around the local origin, so a center-aligned offset
|
||||
* gives in-place flip as a bonus.
|
||||
*
|
||||
* Consequence for coordinate accounting: Konva reports `node.x()` /
|
||||
* `node.y()` in the post-offset frame — the node's drawn top-left
|
||||
* corner ends up at `(node.x() - offsetX, node.y() - offsetY)` on the
|
||||
* stage. To recover the un-offset "top-left of element" coords that
|
||||
* are stored on the element, we subtract `W/2, H/2` from the reported
|
||||
* x/y. This is true regardless of flip state, because the offset is
|
||||
* set unconditionally.
|
||||
*
|
||||
* This pattern appears four times — once each in
|
||||
* `ImageElement.onDragEnd` / `onTransformEnd` and
|
||||
* `TextElement.onDragEnd` / `onTransformEnd` — with `width`/`height`
|
||||
* coming from the element props for images and from the locally-
|
||||
* computed `approxW`/`approxH` for text. Same shape, two callers.
|
||||
*
|
||||
* Centralizing keeps the four sites in lockstep: any future change to
|
||||
* the offset convention only needs to update this one helper.
|
||||
*
|
||||
* Note on the `flipX` / `flipY` arguments
|
||||
* ───────────────────────────────────────
|
||||
* Previously the offset was only applied when EITHER axis was flipped
|
||||
* (the historical "flip via offset" trick); the offset was zero
|
||||
* otherwise, and this helper conditionally subtracted W/2, H/2 only in
|
||||
* the flipped case. That gave the user-facing "rotation pivots from
|
||||
* top-left for non-flipped, center for flipped" inconsistency — most
|
||||
* visible on touch devices where the rotate handle has inherent
|
||||
* sub-pixel offset on contact, swinging the bbox visibly on the
|
||||
* very first frame of rotation around the top-left pivot.
|
||||
*
|
||||
* Now the offset is always center, so the subtraction is unconditional
|
||||
* and the flipX/flipY arguments are unused. Kept in the signature so
|
||||
* call sites don't need to change — minor API stability over a
|
||||
* micro-cleanup that would be re-broken by any future per-axis offset
|
||||
* scheme.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert a Konva node's reported (x, y) back to the un-offset top-left
|
||||
* coordinate that's stored on the element.
|
||||
*
|
||||
* @param {Konva.Node} node - the Konva node to read x/y from
|
||||
* @param {number} width - the element's bbox width
|
||||
* @param {number} height - the element's bbox height
|
||||
* @param {boolean} _flipX - element.flipX (unused; see docblock)
|
||||
* @param {boolean} _flipY - element.flipY (unused; see docblock)
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
export function unflipReportedXY(node, width, height, _flipX, _flipY) {
|
||||
return {
|
||||
x: node.x() - width / 2,
|
||||
y: node.y() - height / 2,
|
||||
};
|
||||
}
|
||||
105
src/utils/flipOffset.test.js
Normal file
105
src/utils/flipOffset.test.js
Normal file
@@ -0,0 +1,105 @@
|
||||
// Tests for flipOffset.js — unflipReportedXY.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// `unflipReportedXY` is the single source of truth for converting
|
||||
// Konva's reported `node.x()` / `node.y()` (in the post-offset frame)
|
||||
// back to the un-offset top-left coordinates we store on the element.
|
||||
// Four call sites use it: ImageElement.onDragEnd, ImageElement
|
||||
// .onTransformEnd, TextElement.onDragEnd, TextElement.onTransformEnd.
|
||||
//
|
||||
// Any drift between this helper's math and what those call sites
|
||||
// expect produces drag jumps or transform-end position jumps —
|
||||
// exactly the bugs documented in [[Refinements_2026-05-22]] Section 8
|
||||
// (text transformer scale fix) and the broader rotation-pivot
|
||||
// center-offset refactor (Section 3 of the same note).
|
||||
//
|
||||
// The contract being tested
|
||||
// ─────────────────────────
|
||||
// After the May 22 refactor that made the offset unconditionally
|
||||
// bbox-center (instead of conditional-on-flip), this helper does an
|
||||
// UNCONDITIONAL `node.x() - W/2`, `node.y() - H/2`. The flipX/flipY
|
||||
// arguments are unused. The unconditional behavior is the heart of
|
||||
// the fix; if a future refactor accidentally re-introduced a
|
||||
// conditional branch, these tests would catch it.
|
||||
//
|
||||
// We construct a minimal Konva-node-like stub rather than mounting
|
||||
// a real Konva Stage. The function only calls `.x()` and `.y()` on
|
||||
// the node, so a tiny stub is sufficient.
|
||||
|
||||
import { unflipReportedXY } from './flipOffset';
|
||||
|
||||
// Tiny stub mimicking the relevant slice of Konva's Node API. The
|
||||
// real Node has dozens of methods we don't care about here.
|
||||
function makeNodeStub(x, y) {
|
||||
return {
|
||||
x: () => x,
|
||||
y: () => y,
|
||||
};
|
||||
}
|
||||
|
||||
describe('unflipReportedXY', () => {
|
||||
it('subtracts half-width from node.x and half-height from node.y', () => {
|
||||
// With offset at bbox center, a Konva node whose top-left should
|
||||
// render at (10, 20) reports x() / y() in the post-offset frame.
|
||||
// For a 100×60 element, that means node.x() = 60 and node.y() =
|
||||
// 50 (top-left + offset). The helper inverts the offset:
|
||||
// 60 - 50 = 10, 50 - 30 = 20.
|
||||
const node = makeNodeStub(60, 50);
|
||||
expect(unflipReportedXY(node, 100, 60, false, false)).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('produces identical output regardless of flipX/flipY flags (the regression contract)', () => {
|
||||
// This is the core invariant the May 22 rotation-pivot refactor
|
||||
// established: offset is always center, so the unflip math is
|
||||
// unconditional. Previously the helper returned different values
|
||||
// depending on flip state, which gave the rotation-pivot bug
|
||||
// (pivot was top-left for non-flipped, center for flipped).
|
||||
//
|
||||
// If a future change re-introduces a conditional branch, this
|
||||
// test fails for the flipped cases.
|
||||
const node = makeNodeStub(60, 50);
|
||||
const baseline = unflipReportedXY(node, 100, 60, false, false);
|
||||
expect(unflipReportedXY(node, 100, 60, true, false)).toEqual(baseline);
|
||||
expect(unflipReportedXY(node, 100, 60, false, true)).toEqual(baseline);
|
||||
expect(unflipReportedXY(node, 100, 60, true, true)).toEqual(baseline);
|
||||
});
|
||||
|
||||
it('handles odd dimensions cleanly (half-pixel math)', () => {
|
||||
// 99 / 2 = 49.5 — the helper doesn't round; callers store the
|
||||
// fractional value and Konva handles sub-pixel positioning. This
|
||||
// pins the no-rounding behavior; a future refactor adding
|
||||
// Math.round here would shift elements by half a pixel on every
|
||||
// drag-end, accumulating over many drags.
|
||||
const node = makeNodeStub(50, 30);
|
||||
expect(unflipReportedXY(node, 99, 61, false, false)).toEqual({
|
||||
x: 50 - 49.5,
|
||||
y: 30 - 30.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles zero dimensions (no division-by-zero or NaN)', () => {
|
||||
// Empty text elements (no text typed yet) can briefly have
|
||||
// width=0 mid-render. The helper shouldn't blow up — it should
|
||||
// just return node.x() and node.y() unchanged (minus 0 = same).
|
||||
const node = makeNodeStub(42, 17);
|
||||
expect(unflipReportedXY(node, 0, 0, false, false)).toEqual({
|
||||
x: 42,
|
||||
y: 17,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles negative node coordinates (off-canvas positions)', () => {
|
||||
// An element dragged outside the print zone can end up at
|
||||
// negative coords. The math should still work — subtraction is
|
||||
// signed.
|
||||
const node = makeNodeStub(-10, -20);
|
||||
expect(unflipReportedXY(node, 100, 60, false, false)).toEqual({
|
||||
x: -60,
|
||||
y: -50,
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/utils/fontPreload.js
Normal file
61
src/utils/fontPreload.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { FONTS } from '../constants/fonts';
|
||||
|
||||
/**
|
||||
* Force-load every design-system font at app startup.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* `index.html` declares all fonts via the Google Fonts stylesheet with
|
||||
* `font-display: swap`. The `swap` directive tells the browser:
|
||||
* "render text in a fallback font immediately, then swap to the real
|
||||
* font once its woff2 file arrives." The CONSEQUENCE is that each
|
||||
* font's woff2 is only fetched when something on the page first
|
||||
* tries to render text in that family. Until then, the font is
|
||||
* *declared* (via @font-face) but not *available*.
|
||||
*
|
||||
* `measureTextWidth` (utils/textMetrics.js) uses the canvas 2D
|
||||
* `measureText` API. If the requested font isn't available, the
|
||||
* canvas context silently falls back to a system default font and
|
||||
* returns ITS metrics. That breaks any width-preservation math:
|
||||
* `fontSizeToPreserveWidth` computes the ratio between old-font
|
||||
* width and new-font width, but if the new font isn't loaded yet,
|
||||
* the "new-font width" is actually fallback-font width — usually
|
||||
* MUCH wider than a condensed face like Bebas Neue. The ratio is
|
||||
* wrong, the computed fontSize is wrong, and once the real font
|
||||
* arrives the text renders at the wrong size.
|
||||
*
|
||||
* Preloading every font upfront eliminates the race entirely: by
|
||||
* the time the user opens the Text tab and starts picking fonts,
|
||||
* the browser has all the woff2 files and `measureText` returns
|
||||
* real metrics.
|
||||
*
|
||||
* Cost: ~30KB × ~22 fonts = ~600KB total, fetched in parallel over
|
||||
* HTTP/2 and cached by the browser for subsequent sessions. Trivial
|
||||
* on broadband, tolerable on cellular. Runs in a post-paint
|
||||
* useEffect so it doesn't delay first paint.
|
||||
*
|
||||
* Integration with useFontsReady
|
||||
* ──────────────────────────────
|
||||
* `document.fonts.load()` adds each font to the document's
|
||||
* in-flight font set; `document.fonts.ready` doesn't resolve until
|
||||
* all in-flight fonts settle. So `useFontsReady`'s re-render
|
||||
* signal naturally waits for our preload to finish — out-of-bounds
|
||||
* measurements and other consumers of useFontsReady don't need any
|
||||
* special wiring.
|
||||
*/
|
||||
export async function preloadAllFonts() {
|
||||
if (typeof document === 'undefined' || !document.fonts) return;
|
||||
// Google Fonts serves a single woff2 file per family/weight/style.
|
||||
// The size in the spec string is only used to match @font-face
|
||||
// descriptors and doesn't affect which file gets fetched — 16px
|
||||
// matches every default declaration, so one load per family is
|
||||
// enough to make every size measurable.
|
||||
//
|
||||
// The family name is quoted so multi-word names (e.g. "Bebas
|
||||
// Neue", "Roboto Slab") parse as a single identifier rather than
|
||||
// two separate font names.
|
||||
const promises = FONTS.map((f) =>
|
||||
document.fonts.load(`16px "${f.family}"`).catch(() => null),
|
||||
);
|
||||
await Promise.all(promises);
|
||||
}
|
||||
52
src/utils/imageLoading.js
Normal file
52
src/utils/imageLoading.js
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Image-loading helpers used by photo upload and slot assignment.
|
||||
*
|
||||
* Before this module: `useTemplate.assignImageToSlot` and `UploadTab.getImageSize`
|
||||
* each had their own inline `new Image() + onload + onerror` Promise wrapper,
|
||||
* and `App.handlePhotoEditComplete` had a third variant using callbacks
|
||||
* instead of a Promise. The three wrappers had subtly different error
|
||||
* handling — one rejected with a generic message, another logged to the
|
||||
* console, the third silently swallowed errors and used a fallback path.
|
||||
*
|
||||
* Centralizing here gives every caller the same behavior: a clean Promise
|
||||
* that resolves to dimensions or rejects with a descriptive Error. The
|
||||
* App.jsx photo-edit flow still uses the lower-level callback form because
|
||||
* its onload handler runs synchronously before its setEditingElement call
|
||||
* (timing matters for the Filerobot modal teardown sequence) — but the
|
||||
* other two consumers go through `loadImageDimensions`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load an image and resolve to its natural pixel dimensions.
|
||||
*
|
||||
* Resolves with `{ width, height }` from `naturalWidth`/`naturalHeight`,
|
||||
* falling back to `width`/`height` for the rare case where naturalWidth is 0
|
||||
* (some Safari versions report this for SVG sources without intrinsic size).
|
||||
*
|
||||
* Rejects with a descriptive Error if the image fails to load. Callers
|
||||
* should `await` and use try/catch — not `.then(undefined, () => fallback)`,
|
||||
* which would silently mask bugs.
|
||||
*
|
||||
* @param {string} src - data:, blob:, or remote URL
|
||||
* @returns {Promise<{ width: number, height: number }>}
|
||||
*/
|
||||
export function loadImageDimensions(src) {
|
||||
return 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: ${truncate(src, 80)}`));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a URL for use in error messages. Long data: URLs in stack traces
|
||||
* blow up log readers; the first 80 chars are enough to identify the type.
|
||||
*/
|
||||
function truncate(s, n) {
|
||||
if (typeof s !== 'string') return String(s);
|
||||
return s.length > n ? `${s.slice(0, n)}…` : s;
|
||||
}
|
||||
60
src/utils/konvaCoords.js
Normal file
60
src/utils/konvaCoords.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { MIN_INLINE_EDIT_HEIGHT, MIN_INLINE_EDIT_WIDTH } from '../constants/elements';
|
||||
|
||||
/**
|
||||
* Konva node coordinate helpers.
|
||||
*
|
||||
* The inline text editor (S2) needs to know where, in browser-page pixels,
|
||||
* a Konva node is currently rendered. The math involves three layers:
|
||||
*
|
||||
* 1. Konva internal coordinates (the canonical 300×300 design space)
|
||||
* 2. The Stage container's CSS scale (the design-canvas frame is
|
||||
* transform-scaled to fit the available DOM area)
|
||||
* 3. The Stage container's page-level position (relative to the viewport)
|
||||
*
|
||||
* The two TextElement double-tap handlers (`onDblClick`, `onDblTap`) had
|
||||
* identical 12-line copies of this calculation. Centralizing here makes
|
||||
* the conversion testable and prevents drift between mouse and touch paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the page-level rectangle of a Konva node, post-CSS-scale.
|
||||
*
|
||||
* Returns `null` if the node has no stage attached (e.g. unmounted) or if
|
||||
* the stage's internal width is 0 (avoids a NaN cssScale). Callers should
|
||||
* treat null as "skip the overlay this time" — the next render will give
|
||||
* them another chance.
|
||||
*
|
||||
* The returned `width`/`height` apply two floors taken from
|
||||
* `constants/elements`:
|
||||
* - width >= MIN_INLINE_EDIT_WIDTH (so an empty textarea is still hittable)
|
||||
* - height >= MIN_INLINE_EDIT_HEIGHT
|
||||
*
|
||||
* `scale` is included so the caller can scale font-size / padding to match
|
||||
* the on-screen rendition (the inline editor multiplies fontSize by scale
|
||||
* so the textarea text aligns with the canvas text it overlays).
|
||||
*
|
||||
* @param {Konva.Node} node - any Konva node with a parent stage
|
||||
* @returns {{ left, top, width, height, scale } | null}
|
||||
*/
|
||||
export function getNodePageRect(node) {
|
||||
if (!node) return null;
|
||||
const stage = typeof node.getStage === 'function' ? node.getStage() : null;
|
||||
if (!stage) return null;
|
||||
const stageWidth = stage.width();
|
||||
if (!(stageWidth > 0)) return null;
|
||||
|
||||
// getClientRect with `relativeTo: stage` returns the node's bounding box
|
||||
// in stage-internal (design) coordinates, accounting for any rotation /
|
||||
// scale on intermediate layers but NOT the Stage's outer CSS transform.
|
||||
const internal = node.getClientRect({ relativeTo: stage });
|
||||
const stageBox = stage.container().getBoundingClientRect();
|
||||
const cssScale = stageBox.width / stageWidth;
|
||||
|
||||
return {
|
||||
left: stageBox.left + internal.x * cssScale,
|
||||
top: stageBox.top + internal.y * cssScale,
|
||||
width: Math.max(MIN_INLINE_EDIT_WIDTH, internal.width * cssScale),
|
||||
height: Math.max(MIN_INLINE_EDIT_HEIGHT, internal.height * cssScale),
|
||||
scale: cssScale,
|
||||
};
|
||||
}
|
||||
36
src/utils/makeId.js
Normal file
36
src/utils/makeId.js
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Element-id generator. Centralizes the `element-${ts}-${random}` pattern
|
||||
* that appeared in 5+ places across `useDesignEditor.js` and
|
||||
* `App.handleAddImage` (with subtle variations — some included an index
|
||||
* for batch uniqueness, some didn't).
|
||||
*
|
||||
* Two forms:
|
||||
* - `makeElementId()` → unique within a single tick
|
||||
* - `makeElementId(suffix)` → unique even when called in a tight loop,
|
||||
* by mixing a caller-supplied suffix
|
||||
* (typically a loop index) into the id.
|
||||
*
|
||||
* Why both forms
|
||||
* ──────────────
|
||||
* `Date.now()` has millisecond resolution, so a `for` loop creating
|
||||
* elements would collide on the timestamp. Adding the loop index resolves
|
||||
* that without depending on `performance.now()` (which has been deliberately
|
||||
* coarsened by browsers to mitigate Spectre, so its sub-ms resolution
|
||||
* isn't reliable). The random suffix protects against process-restart
|
||||
* collisions in test environments where Date.now is mocked.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a unique element id.
|
||||
*
|
||||
* @param {number|string} [suffix] - Optional disambiguator for batch
|
||||
* creation. When omitted, the timestamp + random alone are unique
|
||||
* enough.
|
||||
* @returns {string} `element-${timestamp}-[suffix-]${random}`
|
||||
*/
|
||||
export function makeElementId(suffix) {
|
||||
const ts = Date.now();
|
||||
const rand = Math.random().toString(36).slice(2, 11);
|
||||
if (suffix !== undefined) return `element-${ts}-${suffix}-${rand}`;
|
||||
return `element-${ts}-${rand}`;
|
||||
}
|
||||
107
src/utils/makeId.test.js
Normal file
107
src/utils/makeId.test.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// Tests for makeElementId — ID generation for design elements.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// makeElementId is called from inside hot paths: every addElement,
|
||||
// every duplicateElement, every replaceElements iteration during
|
||||
// session restoration, every template-loading entry. A regression
|
||||
// that produced colliding ids would cause React key warnings, lost
|
||||
// drag/selection state when keys collide on re-render, and confusion
|
||||
// in undo/redo (history snapshots would have two entries with the
|
||||
// same id).
|
||||
//
|
||||
// We test:
|
||||
// • The id format is grep-able (starts with "element-") so existing
|
||||
// log filters and the diagnostic-history `summarizeElements`
|
||||
// helper continue to recognise our ids.
|
||||
// • Suffix-less ids are unique enough for non-loop callers.
|
||||
// • Suffix-mixed ids are unique even in a tight loop where
|
||||
// Date.now() can return the same millisecond for every iteration.
|
||||
// The suffix-uniqueness test is the regression test for the bug the
|
||||
// suffix parameter was added to fix — see the docblock in makeId.js.
|
||||
|
||||
import { makeElementId } from './makeId';
|
||||
|
||||
describe('makeElementId', () => {
|
||||
describe('format', () => {
|
||||
it('produces an id starting with "element-"', () => {
|
||||
// Required so the `summarizeElements` helper in useDesignEditor's
|
||||
// diagnostic logging produces useful output, and so call sites
|
||||
// that filter logs by `[element-` find what they expect.
|
||||
expect(makeElementId()).toMatch(/^element-/);
|
||||
});
|
||||
|
||||
it('embeds the timestamp', () => {
|
||||
// Format: element-<ms-timestamp>-<random>. Pinning the timestamp
|
||||
// position helps debugging — if an id appears in two different
|
||||
// snapshots, the timestamps tell you which mount produced it.
|
||||
const before = Date.now();
|
||||
const id = makeElementId();
|
||||
const after = Date.now();
|
||||
// Extract the timestamp segment.
|
||||
const match = id.match(/^element-(\d+)-/);
|
||||
expect(match).not.toBeNull();
|
||||
const ts = Number(match[1]);
|
||||
expect(ts).toBeGreaterThanOrEqual(before);
|
||||
expect(ts).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('embeds the suffix when provided', () => {
|
||||
// Suffix lands between the timestamp and the random segment, so
|
||||
// a caller passing a loop index produces something like
|
||||
// `element-1779491484151-3-ip8fyoztd`. The position is documented
|
||||
// in the makeId.js docblock and visible in the history-log
|
||||
// snapshots from the May 22 crop+undo investigation.
|
||||
const id = makeElementId(3);
|
||||
expect(id).toMatch(/^element-\d+-3-[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('handles string suffixes (not just numeric)', () => {
|
||||
// The signature accepts `number|string`. We don't currently have
|
||||
// a caller using a string suffix, but the public type allows it
|
||||
// so we test that it doesn't break.
|
||||
const id = makeElementId('slot');
|
||||
expect(id).toMatch(/^element-\d+-slot-[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueness', () => {
|
||||
it('two suffix-less calls produce different ids', () => {
|
||||
// Date.now() may return the same value for two close calls on
|
||||
// fast machines, but the 9-char random suffix makes a collision
|
||||
// astronomically unlikely (2^46 entropy per id).
|
||||
const a = makeElementId();
|
||||
const b = makeElementId();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('a tight loop produces all-unique ids when suffix is used (regression test)', () => {
|
||||
// The bug that motivated the suffix parameter: a `for` loop
|
||||
// creating elements in one tick produces multiple ids that
|
||||
// share the same Date.now() value. Pre-suffix, the random
|
||||
// segment alone made collisions rare-but-possible; the suffix
|
||||
// makes them impossible (each iteration has a different
|
||||
// suffix), even if Math.random somehow returned the same
|
||||
// sequence twice in a row.
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
ids.add(makeElementId(i));
|
||||
}
|
||||
expect(ids.size).toBe(1000);
|
||||
});
|
||||
|
||||
it('suffix-less ids in a tight loop are very-likely-unique (statistical, not guaranteed)', () => {
|
||||
// 1000 ids without suffix. With ~2^46 random entropy, the
|
||||
// birthday-paradox collision probability at N=1000 is
|
||||
// approximately N^2 / (2 * 2^46) = 1.4e-8. Astronomically
|
||||
// unlikely, but pin the statistical expectation here so a
|
||||
// future regression that, say, dropped Math.random precision
|
||||
// or hardcoded a constant random suffix gets caught.
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
ids.add(makeElementId());
|
||||
}
|
||||
expect(ids.size).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
src/utils/persistence.js
Normal file
80
src/utils/persistence.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* localStorage-backed persistence for the editor.
|
||||
*
|
||||
* Persists across reloads:
|
||||
* - The current design (elements + active template id)
|
||||
* - User preferences (last shirt color, last shirt size, last font)
|
||||
* - Recent picks (recent colors, recent fonts)
|
||||
*
|
||||
* Doesn't persist:
|
||||
* - Selection (transient)
|
||||
* - Mobile sheet open/closed (transient)
|
||||
* - Undo history (would balloon storage; user gets a fresh history on
|
||||
* reload, which is the standard expectation)
|
||||
* - Element images whose `src` is a `blob:` URL (those URLs become
|
||||
* invalid the moment the tab refreshes, so saving them just produces
|
||||
* broken images on restore). Image elements with `data:` URLs or
|
||||
* remote URLs are kept.
|
||||
*
|
||||
* Single key, single JSON blob — keeps reads/writes atomic and the
|
||||
* implementation small. The `version` field lets us migrate the schema
|
||||
* later (or just bail out cleanly when we encounter old data).
|
||||
*
|
||||
* Storage details (SSR guard, JSON parsing, quota errors) live in
|
||||
* `utils/storage.js` so other persistence-shaped consumers can reuse the
|
||||
* same wrapper without re-deriving the safety net.
|
||||
*/
|
||||
|
||||
import { readJson, writeJson, removeKey } from './storage';
|
||||
import { isBlobUrl } from './blobUrl';
|
||||
|
||||
const STORAGE_KEY = 'pawfectly-yours-state-v1';
|
||||
|
||||
/**
|
||||
* Load whatever was last persisted. Returns `null` if storage isn't
|
||||
* available, the key isn't set, or the stored data is malformed —
|
||||
* callers should treat any of these the same as "fresh start".
|
||||
*/
|
||||
export function loadPersistedState() {
|
||||
const parsed = readJson(STORAGE_KEY);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the given state, after stripping anything we know won't
|
||||
* round-trip cleanly (notably blob: URLs from photo uploads).
|
||||
*
|
||||
* Silent no-op on errors: storage may be full, disabled (Safari private
|
||||
* mode), or otherwise unavailable. We never want a save attempt to
|
||||
* crash the app, so failures are swallowed inside `writeJson`.
|
||||
*/
|
||||
export function savePersistedState(state) {
|
||||
writeJson(STORAGE_KEY, stripUnpersistable(state));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe persisted state. Used by the "Clear canvas" action when the user
|
||||
* wants a fresh start that survives a reload.
|
||||
*/
|
||||
export function clearPersistedState() {
|
||||
removeKey(STORAGE_KEY);
|
||||
}
|
||||
|
||||
function stripUnpersistable(state) {
|
||||
if (!state || typeof state !== 'object') return state;
|
||||
const out = { ...state };
|
||||
if (Array.isArray(out.elements)) {
|
||||
out.elements = out.elements.filter((el) => {
|
||||
// blob: URLs go stale immediately on reload; persisting them
|
||||
// produces broken-image elements that the user has to manually
|
||||
// delete. data: and remote URLs survive a reload, so those stay.
|
||||
// Filtering on the URL prefix (rather than the element type) means
|
||||
// any future element type that ends up holding a blob URL gets the
|
||||
// same treatment automatically.
|
||||
if (el && isBlobUrl(el.src)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
209
src/utils/persistence.test.js
Normal file
209
src/utils/persistence.test.js
Normal file
@@ -0,0 +1,209 @@
|
||||
// Tests for persistence.js — the editor-state save/load layer.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// persistence.js is one layer above storage.js. It adds the
|
||||
// editor-specific knowledge of WHAT shape gets saved and which
|
||||
// fields shouldn't survive a reload. The crucial logic is:
|
||||
//
|
||||
// 1. Round-trip integrity: elements + preferences come back exactly
|
||||
// as they went in, modulo the next point.
|
||||
// 2. Blob URL stripping: elements whose `src` is a `blob:` URL
|
||||
// are dropped at save time. Blob URLs go stale immediately on
|
||||
// reload, so persisting them produces broken-image elements
|
||||
// after refresh.
|
||||
//
|
||||
// The stripping is the rule with the most blast radius — if it ever
|
||||
// regresses, every user who'd uploaded a photo would see broken-image
|
||||
// placeholders on reload. The tests below pin that behavior precisely
|
||||
// (including the negative case: data: and https:// URLs must survive).
|
||||
//
|
||||
// Test isolation
|
||||
// ──────────────
|
||||
// Same pattern as storage.test.js: clear localStorage in beforeEach
|
||||
// so the missing-state assertions actually see a clean slate.
|
||||
|
||||
import {
|
||||
loadPersistedState,
|
||||
savePersistedState,
|
||||
clearPersistedState,
|
||||
} from './persistence';
|
||||
|
||||
// The storage key is private to persistence.js. We don't import it;
|
||||
// we test through the public API instead. The only time we touch
|
||||
// localStorage directly is in beforeEach to clear state.
|
||||
|
||||
describe('persistence', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('loadPersistedState on empty storage', () => {
|
||||
it('returns null when nothing has been saved', () => {
|
||||
// Fresh user, first visit. Caller treats null as "start with
|
||||
// defaults". If this regressed to returning an empty object,
|
||||
// the caller would think there's a saved session and skip
|
||||
// the default-template-load path.
|
||||
expect(loadPersistedState()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('savePersistedState / loadPersistedState round-trip', () => {
|
||||
it('round-trips a typical editor state', () => {
|
||||
const state = {
|
||||
elements: [
|
||||
{
|
||||
id: 'a',
|
||||
type: 'image',
|
||||
src: 'data:image/png;base64,abc',
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
type: 'text',
|
||||
text: 'Hello',
|
||||
fontSize: 48,
|
||||
fontFamily: 'Pacifico',
|
||||
x: 50,
|
||||
y: 200,
|
||||
},
|
||||
],
|
||||
activeTemplateId: 'tee-001',
|
||||
preferences: { lastColor: '#ec4899', lastSize: 'M' },
|
||||
};
|
||||
savePersistedState(state);
|
||||
expect(loadPersistedState()).toEqual(state);
|
||||
});
|
||||
|
||||
it('preserves remote URL src values', () => {
|
||||
// Sticker library elements have remote URLs like `/stickers/foo.svg`
|
||||
// — these survive reload and must be kept.
|
||||
const state = {
|
||||
elements: [
|
||||
{ id: 'a', type: 'sticker', src: '/stickers/star.svg' },
|
||||
],
|
||||
};
|
||||
savePersistedState(state);
|
||||
const loaded = loadPersistedState();
|
||||
expect(loaded.elements).toHaveLength(1);
|
||||
expect(loaded.elements[0].src).toBe('/stickers/star.svg');
|
||||
});
|
||||
|
||||
it('preserves data: URL src values', () => {
|
||||
// Cropped photos get saved as data: URLs after the crop apply
|
||||
// (they're rasterized from canvas → base64). These survive
|
||||
// reload and must be kept.
|
||||
const state = {
|
||||
elements: [
|
||||
{ id: 'a', type: 'image', src: 'data:image/png;base64,iVBOR' },
|
||||
],
|
||||
};
|
||||
savePersistedState(state);
|
||||
const loaded = loadPersistedState();
|
||||
expect(loaded.elements).toHaveLength(1);
|
||||
expect(loaded.elements[0].src).toMatch(/^data:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blob URL stripping at save time', () => {
|
||||
it('strips elements whose src is a blob: URL', () => {
|
||||
// The headline rule: blob: URLs don't survive reload. The
|
||||
// user has uploaded a photo, the editor created an
|
||||
// `Object URL` for the file, the URL became `blob:https://...`,
|
||||
// and persisting it would result in a 404'd image on reload.
|
||||
// The fix is to drop the element entirely so the saved state
|
||||
// doesn't contain a broken reference.
|
||||
const state = {
|
||||
elements: [
|
||||
{ id: 'a', type: 'image', src: 'blob:https://example.com/abc-123' },
|
||||
{ id: 'b', type: 'text', text: 'Hello', x: 0, y: 0 },
|
||||
],
|
||||
};
|
||||
savePersistedState(state);
|
||||
const loaded = loadPersistedState();
|
||||
expect(loaded.elements).toHaveLength(1);
|
||||
expect(loaded.elements[0].id).toBe('b');
|
||||
});
|
||||
|
||||
it('keeps multiple non-blob elements alongside the stripped blob element', () => {
|
||||
// The filter is per-element. Adjacent good elements must be
|
||||
// unaffected even when one is stripped.
|
||||
const state = {
|
||||
elements: [
|
||||
{ id: 'a', type: 'image', src: 'data:image/png;base64,abc' },
|
||||
{ id: 'b', type: 'image', src: 'blob:https://example.com/x' },
|
||||
{ id: 'c', type: 'sticker', src: '/stickers/star.svg' },
|
||||
{ id: 'd', type: 'text', text: 'Hi' },
|
||||
],
|
||||
};
|
||||
savePersistedState(state);
|
||||
const loaded = loadPersistedState();
|
||||
expect(loaded.elements.map((el) => el.id)).toEqual(['a', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('preserves non-element fields when filtering elements', () => {
|
||||
// Stripping happens inside the elements array only; the rest
|
||||
// of the state object (preferences, activeTemplateId, etc.)
|
||||
// is unchanged.
|
||||
const state = {
|
||||
elements: [
|
||||
{ id: 'a', type: 'image', src: 'blob:https://example.com/x' },
|
||||
],
|
||||
preferences: { lastColor: '#ec4899' },
|
||||
activeTemplateId: 'tee-001',
|
||||
};
|
||||
savePersistedState(state);
|
||||
const loaded = loadPersistedState();
|
||||
expect(loaded.elements).toEqual([]);
|
||||
expect(loaded.preferences).toEqual({ lastColor: '#ec4899' });
|
||||
expect(loaded.activeTemplateId).toBe('tee-001');
|
||||
});
|
||||
|
||||
it('handles a state with no elements field gracefully', () => {
|
||||
// Pre-elements state, or state from a future schema where
|
||||
// elements moved elsewhere. The stripping function should
|
||||
// not crash — it should just pass the state through.
|
||||
const state = { preferences: { lastColor: '#000000' } };
|
||||
savePersistedState(state);
|
||||
expect(loadPersistedState()).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearPersistedState', () => {
|
||||
it('removes saved state so subsequent loads return null', () => {
|
||||
savePersistedState({ elements: [{ id: 'a', type: 'text', text: 'Hi' }] });
|
||||
expect(loadPersistedState()).not.toBeNull();
|
||||
clearPersistedState();
|
||||
expect(loadPersistedState()).toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when nothing was saved', () => {
|
||||
// Defensive — calling clear on an empty store shouldn't throw.
|
||||
expect(() => clearPersistedState()).not.toThrow();
|
||||
expect(loadPersistedState()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed saved data', () => {
|
||||
it('treats a non-object saved value as no saved state', () => {
|
||||
// If something other than our save format ends up at the
|
||||
// storage key (e.g. an older app version wrote a string), the
|
||||
// load path must return null rather than hand back a value
|
||||
// the caller can't safely destructure.
|
||||
window.localStorage.setItem('pawfectly-yours-state-v1', JSON.stringify('not an object'));
|
||||
expect(loadPersistedState()).toBeNull();
|
||||
});
|
||||
|
||||
it('treats malformed JSON at the storage key as no saved state', () => {
|
||||
// Same protection one layer up — storage.readJson already
|
||||
// returns null on JSON.parse failure, persistence inherits
|
||||
// that. Pinning the behavior here ensures the contract holds
|
||||
// end-to-end through both layers.
|
||||
window.localStorage.setItem('pawfectly-yours-state-v1', '{not valid');
|
||||
expect(loadPersistedState()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/utils/storage.js
Normal file
64
src/utils/storage.js
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Safe localStorage wrapper.
|
||||
*
|
||||
* Before this module, `persistence.js` had three try/catch blocks each with
|
||||
* a separate `typeof window === 'undefined' || !window.localStorage` guard
|
||||
* — the same logic three times. Wrapping them once makes the persistence
|
||||
* code read like the actual save/load shape it expresses, not the
|
||||
* defensive coding around it.
|
||||
*
|
||||
* Failure modes we silently absorb:
|
||||
* - SSR (no `window`)
|
||||
* - Browser private mode where storage is disabled (Safari throws)
|
||||
* - Quota exceeded (full storage)
|
||||
* - JSON parse errors when reading back malformed values
|
||||
*
|
||||
* In all cases callers see `null` (for read) or false (for write) — never
|
||||
* an exception. Persistence shouldn't crash the app, ever.
|
||||
*/
|
||||
|
||||
function hasStorage() {
|
||||
return typeof window !== 'undefined' && !!window.localStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and JSON-parse the value at `key`. Returns null when storage is
|
||||
* unavailable, the key is missing, or the stored value isn't valid JSON.
|
||||
*/
|
||||
export function readJson(key) {
|
||||
if (!hasStorage()) return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-stringify and store `value` at `key`. Returns true on success,
|
||||
* false on any failure.
|
||||
*/
|
||||
export function writeJson(key, value) {
|
||||
if (!hasStorage()) return false;
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete `key`. Returns true on success, false on any failure.
|
||||
*/
|
||||
export function removeKey(key) {
|
||||
if (!hasStorage()) return false;
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
126
src/utils/storage.test.js
Normal file
126
src/utils/storage.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// Tests for storage.js — the safe localStorage wrapper.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// `storage.js` is the bottom layer of persistence. Every read/write
|
||||
// of saved design state, user preferences, and recent picks flows
|
||||
// through these three functions. Their no-throw contract is the
|
||||
// foundation that lets `persistence.js` not worry about quota
|
||||
// errors, Safari private mode, SSR, or malformed JSON.
|
||||
//
|
||||
// The tests cover:
|
||||
// • Round-trip — write → read returns the original value.
|
||||
// • Missing key — read returns null.
|
||||
// • Malformed JSON in storage — read returns null (no throw).
|
||||
// • removeKey — actually deletes; subsequent read returns null.
|
||||
// • All three operations return their documented success/failure
|
||||
// value rather than throwing.
|
||||
//
|
||||
// Test isolation
|
||||
// ──────────────
|
||||
// jsdom gives each test file a single shared localStorage. We clear
|
||||
// it in beforeEach so tests don't bleed state into each other (a
|
||||
// missing-key assertion in one test would otherwise fail if a
|
||||
// previous test had set the key).
|
||||
|
||||
import { readJson, writeJson, removeKey } from './storage';
|
||||
|
||||
describe('storage', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('writeJson / readJson round-trip', () => {
|
||||
it('writes a value and reads it back', () => {
|
||||
const value = { foo: 'bar', count: 42 };
|
||||
const ok = writeJson('test-key', value);
|
||||
expect(ok).toBe(true);
|
||||
expect(readJson('test-key')).toEqual(value);
|
||||
});
|
||||
|
||||
it('round-trips arrays', () => {
|
||||
const arr = [1, 'two', { three: 3 }];
|
||||
writeJson('arr-key', arr);
|
||||
expect(readJson('arr-key')).toEqual(arr);
|
||||
});
|
||||
|
||||
it('round-trips deeply nested structures', () => {
|
||||
// Persistence saves the full editor state including the elements
|
||||
// array, each element with its own crop / previousCrop sub-objects.
|
||||
// The wrapper must handle arbitrary nesting cleanly.
|
||||
const nested = {
|
||||
elements: [
|
||||
{ id: 'a', crop: { sx: 10, sy: 20, sWidth: 100, sHeight: 80 } },
|
||||
{ id: 'b', previousCrop: { sx: 5, sy: 5, sWidth: 50, sHeight: 50 } },
|
||||
],
|
||||
preferences: { font: 'Pacifico', color: '#ec4899' },
|
||||
};
|
||||
writeJson('nested-key', nested);
|
||||
expect(readJson('nested-key')).toEqual(nested);
|
||||
});
|
||||
|
||||
it('round-trips primitive values', () => {
|
||||
writeJson('num-key', 42);
|
||||
expect(readJson('num-key')).toBe(42);
|
||||
writeJson('str-key', 'hello');
|
||||
expect(readJson('str-key')).toBe('hello');
|
||||
writeJson('bool-key', true);
|
||||
expect(readJson('bool-key')).toBe(true);
|
||||
writeJson('null-key', null);
|
||||
// Note: writing null and reading it back gives null. But missing
|
||||
// keys also return null. Callers can't distinguish these — by
|
||||
// design, since persistence.loadPersistedState treats both
|
||||
// as "no saved state" and starts fresh.
|
||||
expect(readJson('null-key')).toBe(null);
|
||||
});
|
||||
|
||||
it('writing the same key twice overwrites', () => {
|
||||
writeJson('overwritten', { v: 1 });
|
||||
writeJson('overwritten', { v: 2 });
|
||||
expect(readJson('overwritten')).toEqual({ v: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('readJson on missing data', () => {
|
||||
it('returns null for an unset key', () => {
|
||||
// No write before read. The wrapper must return null rather
|
||||
// than undefined or throw — callers do `if (!parsed) return null;`
|
||||
// and expect the null path to handle missing keys.
|
||||
expect(readJson('never-written')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for the empty string stored at a key', () => {
|
||||
// Edge case: if something writes an empty string directly via
|
||||
// localStorage.setItem, our wrapper's `if (!raw) return null`
|
||||
// catches it (empty string is falsy).
|
||||
window.localStorage.setItem('empty-string', '');
|
||||
expect(readJson('empty-string')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed JSON (no throw)', () => {
|
||||
// Defensive — if storage gets corrupted somehow (manual edit,
|
||||
// a crashed half-write from an older version), the read path
|
||||
// shouldn't crash the app. Treating malformed JSON as
|
||||
// "no saved state" is the same fallback as missing key.
|
||||
window.localStorage.setItem('malformed', '{not valid json');
|
||||
expect(readJson('malformed')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeKey', () => {
|
||||
it('deletes a key so subsequent reads return null', () => {
|
||||
writeJson('to-delete', { v: 1 });
|
||||
expect(readJson('to-delete')).toEqual({ v: 1 });
|
||||
const ok = removeKey('to-delete');
|
||||
expect(ok).toBe(true);
|
||||
expect(readJson('to-delete')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns true even when the key was already absent', () => {
|
||||
// localStorage.removeItem is a no-op for missing keys (doesn't
|
||||
// throw). The wrapper preserves that — caller gets `true`
|
||||
// either way. Useful for "best-effort" cleanup paths.
|
||||
expect(removeKey('never-existed')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
412
src/utils/textGeometry.js
Normal file
412
src/utils/textGeometry.js
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Text-element geometry helpers.
|
||||
*
|
||||
* These keep edits to a text element's `text` / `fontFamily` / `fontSize`
|
||||
* feeling stable to the user:
|
||||
*
|
||||
* • `withTextCenterPreservation` recomputes (x, y) on each bbox-changing
|
||||
* edit so the visual center of the text stays put. Without this, typing
|
||||
* into a textarea would make on-canvas text drift to the right (since x
|
||||
* is the top-left anchor and the bbox grows rightward as text gets
|
||||
* longer). Same for switching to a wider font, or bumping the size.
|
||||
*
|
||||
* • `fontSizeToPreserveWidth` returns a fontSize that, when paired with a
|
||||
* new font family, keeps the bbox width approximately equal to the
|
||||
* current bbox width. Bidirectional: a swap to a wider font shrinks
|
||||
* the size, a swap to a narrower font grows it. Clamped to
|
||||
* [MIN_FONT_SIZE, MAX_FONT_SIZE] so a pathological measurement
|
||||
* can't produce 4px or 600px text.
|
||||
*
|
||||
* Both helpers are pure: they take old-state + a partial attrs patch and
|
||||
* return a (possibly augmented) attrs patch ready to feed to `updateElement`.
|
||||
*/
|
||||
|
||||
import { measureTextWidth, measureTextMetrics } from './textMetrics';
|
||||
import {
|
||||
DEFAULT_DISPLAY_FONT,
|
||||
DEFAULT_FONT_SIZE,
|
||||
LINE_HEIGHT_RATIO,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
} from '../constants/textMetrics';
|
||||
|
||||
// Aliased here so the existing local-name references below keep working.
|
||||
// Centralizing the actual values in constants/textMetrics.js prevents
|
||||
// drift between this module, elementBounds.js, and TextElement.jsx.
|
||||
const DEFAULT_FONT_FAMILY = DEFAULT_DISPLAY_FONT;
|
||||
|
||||
// Konva.Text places the baseline at this fraction of the line-height
|
||||
// box's height. Used below by computeTextVisibleBbox; mirrored in
|
||||
// TextElement.jsx as a local `baselineY = approxH * 0.85`. Pulling
|
||||
// it out here as a named constant makes the cross-file pairing
|
||||
// explicit.
|
||||
const BASELINE_Y_RATIO = 0.85;
|
||||
|
||||
// Arc bulge magnitude as a fraction of approxH × |arc|. Mirrors
|
||||
// TextElement.jsx's `bulgeMag = (arcAbs / 100) * approxH * 1.4`.
|
||||
const ARC_BULGE_PER_UNIT = 1.4;
|
||||
|
||||
/**
|
||||
* If the patch changes any bbox-affecting attribute (text, fontFamily,
|
||||
* fontSize), augment it with x/y values that keep the visual center of the
|
||||
* text element constant.
|
||||
*
|
||||
* Pass-through cases:
|
||||
* • non-text element → returned unchanged
|
||||
* • caller already set x or y → returned unchanged (caller wins)
|
||||
* • patch doesn't change the bbox → returned unchanged
|
||||
*/
|
||||
export function withTextCenterPreservation(element, attrs) {
|
||||
if (!element || element.type !== 'text') return attrs;
|
||||
if (attrs.x !== undefined || attrs.y !== undefined) return attrs;
|
||||
|
||||
const textChanged = attrs.text !== undefined;
|
||||
const fontChanged = attrs.fontFamily !== undefined;
|
||||
const sizeChanged = attrs.fontSize !== undefined;
|
||||
if (!textChanged && !fontChanged && !sizeChanged) return attrs;
|
||||
|
||||
const oldText = element.text ?? '';
|
||||
const oldFamily = element.fontFamily ?? DEFAULT_FONT_FAMILY;
|
||||
const oldSize = element.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const newText = textChanged ? attrs.text : oldText;
|
||||
const newFamily = fontChanged ? attrs.fontFamily : oldFamily;
|
||||
const newSize = sizeChanged ? attrs.fontSize : oldSize;
|
||||
|
||||
const oldW = measureTextWidth(oldText, oldSize, oldFamily);
|
||||
const newW = measureTextWidth(newText, newSize, newFamily);
|
||||
const oldH = oldSize * LINE_HEIGHT_RATIO;
|
||||
const newH = newSize * LINE_HEIGHT_RATIO;
|
||||
|
||||
const oldX = element.x ?? 0;
|
||||
const oldY = element.y ?? 0;
|
||||
const centerX = oldX + oldW / 2;
|
||||
const centerY = oldY + oldH / 2;
|
||||
|
||||
return {
|
||||
...attrs,
|
||||
x: centerX - newW / 2,
|
||||
y: centerY - newH / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a fontSize that keeps the rendered text width unchanged when
|
||||
* switching fonts — *bidirectional*. A swap to a wider font shrinks the
|
||||
* size; a swap to a narrower font grows it. Both directions are clamped
|
||||
* to [MIN_FONT_SIZE, MAX_FONT_SIZE].
|
||||
*
|
||||
* Earlier versions of this helper only shrank — the user's chosen size
|
||||
* was treated as a ceiling and narrower fonts were allowed to render
|
||||
* at a smaller visual area. That produced the exact "why did my text
|
||||
* just get tiny?" complaint the rename addressed: pick wide font at
|
||||
* size 48, see a comfortable bbox; pick a condensed font, see the
|
||||
* same 48 but rendered at half the visual width. Users expect the
|
||||
* area to feel constant across font swaps; this function delivers
|
||||
* that symmetrically.
|
||||
*
|
||||
* Returns `oldFontSize` unchanged if `text` is empty or the measurement
|
||||
* subsystem returns 0 for the new font (defensive — avoids division
|
||||
* by zero and avoids resetting to MAX_FONT_SIZE on a measurement glitch).
|
||||
*/
|
||||
export function fontSizeToPreserveWidth(text, oldFontFamily, oldFontSize, newFontFamily) {
|
||||
if (!text) return oldFontSize;
|
||||
const currentW = measureTextWidth(text, oldFontSize, oldFontFamily);
|
||||
const newWAtSameSize = measureTextWidth(text, oldFontSize, newFontFamily);
|
||||
if (currentW <= 0 || newWAtSameSize <= 0) return oldFontSize;
|
||||
// Same ratio formula both ways: target = oldSize * (currentW / newWAtSameSize).
|
||||
// newWAtSameSize > currentW → new font is WIDER → ratio < 1 → shrink
|
||||
// newWAtSameSize < currentW → new font is NARROWER → ratio > 1 → grow
|
||||
// newWAtSameSize === currentW → returns oldSize unchanged (the ratio is 1)
|
||||
const targetSize = oldFontSize * (currentW / newWAtSameSize);
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, Math.round(targetSize)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute initial placement for a newly-created text element so that it
|
||||
* lands centered in the working area. Auto-shrinks fontSize when the text
|
||||
* at the requested size would be wider than the canvas (with a small
|
||||
* margin), so the new element is fully visible from the moment it appears.
|
||||
*
|
||||
* @param {Object} draft - { text, fontFamily, fontSize }
|
||||
* @param {number} canvasSize - design canvas size in design units
|
||||
* @param {number} marginRatio - max content width as fraction of canvas
|
||||
* (default 0.9 — 5% margin on each side)
|
||||
* @returns {{ x, y, fontSize }} - top-left position + (possibly clamped) size
|
||||
*/
|
||||
export function placeTextCentered(draft, canvasSize, marginRatio = 0.9) {
|
||||
const text = draft.text ?? '';
|
||||
const fontFamily = draft.fontFamily ?? DEFAULT_FONT_FAMILY;
|
||||
let fontSize = draft.fontSize ?? DEFAULT_FONT_SIZE;
|
||||
|
||||
let measuredW = measureTextWidth(text, fontSize, fontFamily);
|
||||
const maxW = canvasSize * marginRatio;
|
||||
|
||||
if (measuredW > maxW && measuredW > 0) {
|
||||
fontSize = Math.max(MIN_FONT_SIZE, Math.floor(fontSize * (maxW / measuredW)));
|
||||
measuredW = measureTextWidth(text, fontSize, fontFamily);
|
||||
}
|
||||
const measuredH = fontSize * LINE_HEIGHT_RATIO;
|
||||
|
||||
const x = Math.max(0, Math.round((canvasSize - measuredW) / 2));
|
||||
const y = Math.max(0, Math.round((canvasSize - measuredH) / 2));
|
||||
|
||||
return { x, y, fontSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the VISIBLE-INK bounding box of a text element in node-local
|
||||
* coordinates. This is the rectangle that hugs the actual rendered
|
||||
* glyphs — NOT the line-height box (which contains substantial empty
|
||||
* space above the baseline and below the descent).
|
||||
*
|
||||
* Single source of truth for two consumers:
|
||||
*
|
||||
* 1. TextElement.jsx: drives the in-canvas yellow OOB indicator rect,
|
||||
* the Konva node's getSelfRect override (which controls Transformer
|
||||
* handle positions), and the _pawSnapBbox custom attrs that
|
||||
* DesignCanvas's snap-to-center math reads.
|
||||
* 2. elementBounds.js: drives the print-area out-of-bounds check that
|
||||
* flags elements as OOB and disables Add-to-Cart.
|
||||
*
|
||||
* Both consumers need the SAME envelope or the user sees inconsistency:
|
||||
* "the yellow rect looks fine but the warning still fires," or vice-
|
||||
* versa. Sharing this function eliminates the drift risk.
|
||||
*
|
||||
* Returns null for empty or zero-width text.
|
||||
*
|
||||
* Per-character iteration for arc'd text
|
||||
* ────────────────────────────────────────
|
||||
* Earlier versions of this calculation used the WHOLE-STRING ascent
|
||||
* (max ascent of any character) at the arc's peak, plus the whole-string
|
||||
* descent at the trough. That over-estimated the bbox for the common
|
||||
* case of mixed-case text:
|
||||
*
|
||||
* For "Queen" arc-up at fontSize 50 with arc=80:
|
||||
* baselineY = 51, bulgeMag/2 = 33.6, peakY = 17.4
|
||||
* whole-string ascent (the "Q") = ~35
|
||||
* peakY - wholeAscent = -17.6 ← OLD top of bbox
|
||||
* But the letter AT the peak is an "e" with ascent ~25:
|
||||
* peakY - eAscent = -7.6 ← ACTUAL top of visible glyphs
|
||||
*
|
||||
* A 10-unit overestimate — enough to false-flag arc-up text
|
||||
* positioned within the print area but visually safe.
|
||||
*
|
||||
* The fix: iterate per character. For each character, compute its baseline
|
||||
* y-position along the quadratic Bezier path, then add its OWN ascent/
|
||||
* descent (measured for that specific character). Take min/max over all
|
||||
* characters. This naturally handles:
|
||||
*
|
||||
* • Tall capitals at the endpoints (no overestimate at the bulge tip)
|
||||
* • Tall capitals at the peak (correctly extends the bbox)
|
||||
* • Short lowercase at the peak with tall caps at endpoints (the
|
||||
* screenshotted "Queen" case)
|
||||
* • Spaces and punctuation contributing zero ink at their positions
|
||||
*
|
||||
* Cost: O(N) measureText calls per evaluation where N is text length.
|
||||
* For typical strings (5-20 chars), this is sub-millisecond. The flat-
|
||||
* text path (no arc) short-circuits to a single measureText for the
|
||||
* whole string.
|
||||
*
|
||||
* Curve geometry and arc-length positioning (Change 25)
|
||||
* ──────────────────────────────────────────────────
|
||||
* The path is a quadratic Bezier from (0, baselineY) through
|
||||
* (W/2, controlY) to (W, baselineY). The baseline at parameter t is:
|
||||
*
|
||||
* y(t) = (1-t)² × baselineY + 2(1-t)t × controlY + t² × baselineY
|
||||
*
|
||||
* CRITICAL: Konva.TextPath with align="center" distributes characters
|
||||
* along the path by ARC LENGTH, not by uniform parameter t. For a
|
||||
* quadratic Bezier the velocity |B'(t)| = sqrt(W² + (2(1-2t)(controlY -
|
||||
* baselineY))²) is HIGH at endpoints (t=0, t=1) and LOW at the middle
|
||||
* (t=0.5). So arc length covered per unit t is largest at the ends,
|
||||
* smallest at the middle.
|
||||
*
|
||||
* An earlier version of this code approximated u_i ≈ (i + 0.5) / N
|
||||
* (uniform-t spacing). That pushed endpoint characters TOO FAR toward
|
||||
* the middle of the path: for the "Bun-teria" arc=-50 case, char 0 (B)
|
||||
* actually sits near t ≈ 0.11, but the approximation placed it at
|
||||
* t = 0.056. The 0.05 difference at the endpoints translated to ~3.6
|
||||
* design units of charBaselineY error, which made the per-char top
|
||||
* appear ~3.6 units higher than where the glyph actually renders — and
|
||||
* the same error on the opposite endpoint. Visually: the Transformer
|
||||
* rect's top sat above the actual B cap (empty space above), and the
|
||||
* rect's bottom sat above the actual peak descender (letters extended
|
||||
* below).
|
||||
*
|
||||
* The fix: numerically build an arc-length-vs-t table by sampling the
|
||||
* Bezier at 100 t values, then for each character compute its desired
|
||||
* arc length along the path (text start offset + cumulative char widths
|
||||
* + char's own width / 2) and binary-search the table for the
|
||||
* corresponding t. The result matches Konva's internal char placement
|
||||
* to within sub-pixel precision for typical arcs/text lengths.
|
||||
*
|
||||
* Cost: O(SAMPLES + N log SAMPLES) per evaluation. With SAMPLES = 100
|
||||
* and N ≤ ~30 in practice, this is still well under a millisecond.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.text
|
||||
* @param {number} params.fontSize
|
||||
* @param {string} params.fontFamily
|
||||
* @param {number} [params.arc] - in [-100, 100]
|
||||
* @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number } | null}
|
||||
*/
|
||||
export function computeTextVisibleBbox({ text, fontSize, fontFamily, arc = 0 }) {
|
||||
const t = text ?? '';
|
||||
const fSize = fontSize ?? DEFAULT_FONT_SIZE;
|
||||
const fFamily = fontFamily ?? DEFAULT_FONT_FAMILY;
|
||||
|
||||
const wholeMetrics = measureTextMetrics(t, fSize, fFamily);
|
||||
const approxW = Math.max(fSize, wholeMetrics.width);
|
||||
const approxH = fSize * LINE_HEIGHT_RATIO;
|
||||
|
||||
if (approxW <= 0 || approxH <= 0) return null;
|
||||
|
||||
const baselineY = approxH * BASELINE_Y_RATIO;
|
||||
const arcAbs = Math.min(100, Math.abs(arc ?? 0));
|
||||
const isArced = arcAbs > 0.5;
|
||||
const bulgeMag = (arcAbs / 100) * approxH * ARC_BULGE_PER_UNIT;
|
||||
const controlY = (arc ?? 0) > 0 ? baselineY - bulgeMag : baselineY + bulgeMag;
|
||||
|
||||
let minY, maxY;
|
||||
|
||||
if (!isArced || t.length === 0) {
|
||||
// Flat text (or empty arc'd text): single baseline at baselineY,
|
||||
// whole-string ascent/descent envelopes the bbox.
|
||||
minY = baselineY - wholeMetrics.ascent;
|
||||
maxY = baselineY + wholeMetrics.descent;
|
||||
} else {
|
||||
// Arc'd text: per-character envelope with arc-length-based
|
||||
// positioning to match Konva.TextPath's internal placement.
|
||||
//
|
||||
// Path geometry mirrors TextElement.jsx: same `pathW = approxW * 1.02`
|
||||
// overshoot, same quadratic Bezier from (0, baselineY) through
|
||||
// (pathW/2, controlY) to (pathW, baselineY). align="center" centers
|
||||
// the rendered text along the path.
|
||||
const pathW = approxW * 1.02;
|
||||
|
||||
// Step 1: Build an arc-length-vs-t lookup table by sampling the
|
||||
// Bezier curve. 100 samples gives sub-pixel accuracy for typical
|
||||
// arcs. Storage: ~1.6 KiB transient. Build time: ~0.1 ms.
|
||||
const SAMPLES = 100;
|
||||
const arcTable = new Array(SAMPLES + 1);
|
||||
arcTable[0] = { t: 0, s: 0 };
|
||||
let cumLen = 0;
|
||||
let prevX = 0;
|
||||
let prevY = baselineY;
|
||||
for (let k = 1; k <= SAMPLES; k++) {
|
||||
const tk = k / SAMPLES;
|
||||
const oneMinusTk = 1 - tk;
|
||||
// Bezier(tk) for x: P0.x=0, P1.x=pathW/2, P2.x=pathW
|
||||
// x(tk) = 2*tk*(1-tk) * (pathW/2) + tk² * pathW
|
||||
// = tk*(1-tk) * pathW + tk² * pathW
|
||||
// = tk * pathW * ((1-tk) + tk)
|
||||
// = tk * pathW
|
||||
// (For symmetric x endpoints with control at midpoint, the
|
||||
// x-coordinate is exactly linear in t — the curvature is
|
||||
// entirely in y.)
|
||||
const xk = tk * pathW;
|
||||
const yk = oneMinusTk * oneMinusTk * baselineY +
|
||||
2 * tk * oneMinusTk * controlY +
|
||||
tk * tk * baselineY;
|
||||
const dx = xk - prevX;
|
||||
const dy = yk - prevY;
|
||||
cumLen += Math.sqrt(dx * dx + dy * dy);
|
||||
arcTable[k] = { t: tk, s: cumLen };
|
||||
prevX = xk;
|
||||
prevY = yk;
|
||||
}
|
||||
const totalArcLen = cumLen;
|
||||
|
||||
// Step 2: Compute the text's start offset along the path. With
|
||||
// align="center", the rendered text occupies the middle of the
|
||||
// path, with equal slack on either side. The slack is the
|
||||
// difference between the path's arc length and the text's total
|
||||
// rendered width.
|
||||
const textTotalWidth = wholeMetrics.width;
|
||||
const textStartS = (totalArcLen - textTotalWidth) / 2;
|
||||
|
||||
// Step 3: Iterate characters. For each char, compute its center's
|
||||
// arc length position along the path, then binary-search the
|
||||
// table to find the corresponding t. Use that t to evaluate the
|
||||
// Bezier y, giving the char's baseline-along-the-path.
|
||||
//
|
||||
// Whole-string descent floor (Change 27)
|
||||
// ----------------------------------------
|
||||
// Per-character `measureTextMetrics(singleChar)` for cursive fonts
|
||||
// like Pacifico under-reports the actual rendered descent: when a
|
||||
// character is rendered in isolation the browser draws just the
|
||||
// base glyph, but inside a word/path the cursive flourishes and
|
||||
// bottom swirls add several units of depth that the standalone
|
||||
// measurement misses. Even the whole-string actualBoundingBoxDescent
|
||||
// can under-report for cursive faces because the browser measures
|
||||
// the visible ink envelope, and stroke joins / hairlines along the
|
||||
// baseline don't always count.
|
||||
//
|
||||
// Use fontBoundingBoxDescent as a more conservative floor. This is
|
||||
// the font's DECLARED max descent across all glyphs in the font
|
||||
// — for cursive faces that includes the deepest possible flourish,
|
||||
// so it captures the visual extent that actualBoundingBoxDescent
|
||||
// misses. For non-cursive fonts the gap between actual and font
|
||||
// descent is smaller (closer to the descender of g/p/q/y), so this
|
||||
// adds a small safety margin but doesn't over-extend dramatically.
|
||||
//
|
||||
// (We do NOT apply a parallel ascent floor: for mixed-case strings
|
||||
// like "Queen" we WANT lowercase peak letters to use their own
|
||||
// smaller ascent so the bbox top doesn't extend up to cap-height
|
||||
// when only an 'e' is at the peak. Cursive flourishes happen
|
||||
// overwhelmingly at the bottom, not the top.)
|
||||
const wholeDescentFloor = Math.max(
|
||||
wholeMetrics.descent,
|
||||
wholeMetrics.fontDescent,
|
||||
);
|
||||
minY = Infinity;
|
||||
maxY = -Infinity;
|
||||
let cumCharS = 0;
|
||||
const N = t.length;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const ch = t[i];
|
||||
const cm = measureTextMetrics(ch, fSize, fFamily);
|
||||
const charCenterS = textStartS + cumCharS + cm.width / 2;
|
||||
cumCharS += cm.width;
|
||||
|
||||
// Binary search arcTable for the entry pair bracketing charCenterS.
|
||||
let lo = 0;
|
||||
let hi = arcTable.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arcTable[mid].s <= charCenterS) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const lo_s = arcTable[lo].s;
|
||||
const hi_s = arcTable[hi].s;
|
||||
const lo_t = arcTable[lo].t;
|
||||
const hi_t = arcTable[hi].t;
|
||||
let u_i;
|
||||
if (hi_s === lo_s) {
|
||||
u_i = lo_t;
|
||||
} else {
|
||||
u_i = lo_t + (hi_t - lo_t) * (charCenterS - lo_s) / (hi_s - lo_s);
|
||||
}
|
||||
// Clamp to [0, 1] in case the text overflows the path (shouldn't
|
||||
// happen with the 2% overshoot, but defensive).
|
||||
if (u_i < 0) u_i = 0;
|
||||
else if (u_i > 1) u_i = 1;
|
||||
|
||||
const charBaselineY = baselineY - 2 * u_i * (1 - u_i) * (baselineY - controlY);
|
||||
const effectiveDescent = Math.max(cm.descent, wholeDescentFloor);
|
||||
const top = charBaselineY - cm.ascent;
|
||||
const bot = charBaselineY + effectiveDescent;
|
||||
if (top < minY) minY = top;
|
||||
if (bot > maxY) maxY = bot;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
minX: 0,
|
||||
minY,
|
||||
maxX: approxW,
|
||||
maxY,
|
||||
width: approxW,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
415
src/utils/textGeometry.test.js
Normal file
415
src/utils/textGeometry.test.js
Normal file
@@ -0,0 +1,415 @@
|
||||
// Tests for textGeometry.js — the bbox-preserving and initial-placement
|
||||
// helpers for text elements.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// These helpers keep typing into a text element from making the text
|
||||
// drift across the canvas (`withTextCenterPreservation`), keep font
|
||||
// swaps from producing wildly different rendered sizes
|
||||
// (`fontSizeToPreserveWidth`), and place new text neatly centered on
|
||||
// creation (`placeTextCentered`). Subtle bugs in any of them produce
|
||||
// the kind of "ugh, why did it move" UX moment that's hard to bisect
|
||||
// after the fact.
|
||||
//
|
||||
// Why we mock textMetrics
|
||||
// ───────────────────────
|
||||
// All three functions ultimately call `measureTextWidth`/`measureText
|
||||
// Metrics`, which in production uses canvas measureText. In jsdom the
|
||||
// canvas measurement is either approximate (canvas package, font-
|
||||
// dependent) or geometric-fallback (no canvas, `fontSize * length * 0.55`
|
||||
// for every font). Neither lets us assert exact behavior cleanly:
|
||||
//
|
||||
// • Approximate: machine-dependent based on installed fonts.
|
||||
// • Fallback: all fonts measure identical → can't test font-swap math.
|
||||
//
|
||||
// Mocking lets us assert the EXACT math we care about. The mock
|
||||
// returns `text.length * fontSize * familyFactor`, where familyFactor
|
||||
// distinguishes wide / narrow / default fonts. This is a faithful
|
||||
// model of how text width actually scales (it's linear in both length
|
||||
// and fontSize, and font choice acts as a multiplicative factor), so
|
||||
// the math being tested is the same math the production code runs.
|
||||
//
|
||||
// `vi` is a Vitest global (config has `globals: true`), so no import
|
||||
// needed.
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Mock setup. Must be declared BEFORE the import of the module under
|
||||
// test, otherwise Vitest will hoist the import and the real
|
||||
// textMetrics will be loaded first.
|
||||
//
|
||||
// The family-factor model:
|
||||
// 'WideFont' → factor 0.80 (broad serif-ish)
|
||||
// 'NarrowFont' → factor 0.30 (condensed display face)
|
||||
// anything else → factor 0.50 (a reasonable middle)
|
||||
//
|
||||
// So a 10-char string at fontSize 20 measures:
|
||||
// WideFont: 10 * 20 * 0.80 = 160
|
||||
// NarrowFont: 10 * 20 * 0.30 = 60
|
||||
// default: 10 * 20 * 0.50 = 100
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('./textMetrics', () => {
|
||||
const factorFor = (family) => {
|
||||
if (family === 'WideFont') return 0.80;
|
||||
if (family === 'NarrowFont') return 0.30;
|
||||
return 0.50;
|
||||
};
|
||||
const measureTextWidth = (text, fontSize, fontFamily) => {
|
||||
if (!text) return 0;
|
||||
return text.length * fontSize * factorFor(fontFamily);
|
||||
};
|
||||
const measureTextMetrics = (text, fontSize, fontFamily) => ({
|
||||
width: measureTextWidth(text, fontSize, fontFamily),
|
||||
ascent: fontSize * 0.7,
|
||||
descent: fontSize * 0.2,
|
||||
fontAscent: fontSize * 0.85,
|
||||
fontDescent: fontSize * 0.25,
|
||||
});
|
||||
return { measureTextWidth, measureTextMetrics };
|
||||
});
|
||||
|
||||
import {
|
||||
withTextCenterPreservation,
|
||||
fontSizeToPreserveWidth,
|
||||
placeTextCentered,
|
||||
} from './textGeometry';
|
||||
import { measureTextWidth } from './textMetrics';
|
||||
import {
|
||||
DEFAULT_DISPLAY_FONT,
|
||||
DEFAULT_FONT_SIZE,
|
||||
LINE_HEIGHT_RATIO,
|
||||
MIN_FONT_SIZE,
|
||||
MAX_FONT_SIZE,
|
||||
} from '../constants/textMetrics';
|
||||
|
||||
// Helper: compute the visual center of a text element given its
|
||||
// current state, using the SAME mocked measureTextWidth the function
|
||||
// under test uses. Lets us verify the center-preservation invariant
|
||||
// without recomputing the formula at the assertion site.
|
||||
function visualCenter(element) {
|
||||
const w = measureTextWidth(
|
||||
element.text ?? '',
|
||||
element.fontSize ?? DEFAULT_FONT_SIZE,
|
||||
element.fontFamily ?? DEFAULT_DISPLAY_FONT
|
||||
);
|
||||
const h = (element.fontSize ?? DEFAULT_FONT_SIZE) * LINE_HEIGHT_RATIO;
|
||||
return {
|
||||
x: (element.x ?? 0) + w / 2,
|
||||
y: (element.y ?? 0) + h / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// withTextCenterPreservation — passes attrs through when nothing's
|
||||
// changed, augments them with x/y when text/font/size changed.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('withTextCenterPreservation — passthrough cases', () => {
|
||||
it('returns attrs unchanged for non-text elements', () => {
|
||||
// Image elements have their own center-preservation logic in
|
||||
// ElementToolbar's handleSizeChange. This helper is text-only.
|
||||
const el = { type: 'image', x: 0, y: 0, width: 100, height: 100 };
|
||||
const attrs = { width: 150 };
|
||||
expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
|
||||
});
|
||||
|
||||
it('returns attrs unchanged when caller supplies x (caller wins)', () => {
|
||||
// The function's contract: an explicit x/y from the caller takes
|
||||
// precedence over the center-preservation calculation. Used when
|
||||
// the user drags a text element — DesignCanvas commits the new
|
||||
// position via updateAndCommit({ x, y, ... }), and we don't want
|
||||
// the center-preservation to fight the drag's commit.
|
||||
const el = { type: 'text', text: 'hello', fontSize: 32, x: 50, y: 50 };
|
||||
const attrs = { text: 'world', x: 100 };
|
||||
expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
|
||||
});
|
||||
|
||||
it('returns attrs unchanged when caller supplies y', () => {
|
||||
const el = { type: 'text', text: 'hello', fontSize: 32, x: 50, y: 50 };
|
||||
const attrs = { fontSize: 48, y: 100 };
|
||||
expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
|
||||
});
|
||||
|
||||
it('returns attrs unchanged when no bbox-affecting attrs are present', () => {
|
||||
// Changing fill/stroke/rotation doesn't affect bbox — no
|
||||
// center-preservation needed.
|
||||
const el = { type: 'text', text: 'hello', fontSize: 32, x: 50, y: 50 };
|
||||
const attrs = { fill: '#ff0000', rotation: 45 };
|
||||
expect(withTextCenterPreservation(el, attrs)).toBe(attrs);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withTextCenterPreservation — bbox-affecting changes', () => {
|
||||
// Each test asserts the CENTER-PRESERVATION INVARIANT: after the
|
||||
// function returns, the visual center computed from the new
|
||||
// element matches the visual center of the original. This is the
|
||||
// contract; specific x/y deltas are derived.
|
||||
|
||||
it('preserves the center when text gets longer', () => {
|
||||
const el = { type: 'text', text: 'hi', fontSize: 20, x: 100, y: 100, fontFamily: 'default' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, { text: 'hello world' });
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
// x should have decreased to move the element leftward as the
|
||||
// text grew rightward.
|
||||
expect(attrs.x).toBeLessThan(el.x);
|
||||
});
|
||||
|
||||
it('preserves the center when text gets shorter', () => {
|
||||
const el = { type: 'text', text: 'hello world', fontSize: 20, x: 100, y: 100, fontFamily: 'default' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, { text: 'hi' });
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
expect(attrs.x).toBeGreaterThan(el.x);
|
||||
});
|
||||
|
||||
it('preserves the center when fontSize increases', () => {
|
||||
const el = { type: 'text', text: 'hello', fontSize: 20, x: 100, y: 100, fontFamily: 'default' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, { fontSize: 40 });
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
// Both axes adjust — text grows in both width and height.
|
||||
expect(attrs.x).toBeLessThan(el.x);
|
||||
expect(attrs.y).toBeLessThan(el.y);
|
||||
});
|
||||
|
||||
it('preserves the center when fontSize decreases', () => {
|
||||
const el = { type: 'text', text: 'hello', fontSize: 40, x: 100, y: 100, fontFamily: 'default' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, { fontSize: 20 });
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
});
|
||||
|
||||
it('preserves the center when switching to a wider font', () => {
|
||||
const el = { type: 'text', text: 'hello', fontSize: 20, x: 100, y: 100, fontFamily: 'NarrowFont' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, { fontFamily: 'WideFont' });
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
// Wider font → element shifts leftward.
|
||||
expect(attrs.x).toBeLessThan(el.x);
|
||||
});
|
||||
|
||||
it('preserves the center when switching to a narrower font', () => {
|
||||
const el = { type: 'text', text: 'hello', fontSize: 20, x: 100, y: 100, fontFamily: 'WideFont' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, { fontFamily: 'NarrowFont' });
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
expect(attrs.x).toBeGreaterThan(el.x);
|
||||
});
|
||||
|
||||
it('preserves the center when text AND fontSize AND fontFamily all change', () => {
|
||||
// Stacked bbox changes — the math should compose. Specifically
|
||||
// the function reads the new text + new size + new family in one
|
||||
// pass, computes the new width once, and centers around the
|
||||
// original center. Verifying with a multi-axis change pins that
|
||||
// composition.
|
||||
const el = { type: 'text', text: 'hi', fontSize: 20, x: 100, y: 100, fontFamily: 'NarrowFont' };
|
||||
const before = visualCenter(el);
|
||||
const attrs = withTextCenterPreservation(el, {
|
||||
text: 'hello world',
|
||||
fontSize: 40,
|
||||
fontFamily: 'WideFont',
|
||||
});
|
||||
const after = visualCenter({ ...el, ...attrs });
|
||||
expect(after.x).toBeCloseTo(before.x, 6);
|
||||
expect(after.y).toBeCloseTo(before.y, 6);
|
||||
});
|
||||
|
||||
it('uses defaults when element fields are missing', () => {
|
||||
// Edge case: element has no x/y/text/fontSize/fontFamily. The
|
||||
// function should treat missing fields as the documented
|
||||
// defaults rather than throwing.
|
||||
const el = { type: 'text' };
|
||||
const attrs = withTextCenterPreservation(el, { text: 'hello' });
|
||||
expect(attrs.x).toBeDefined();
|
||||
expect(attrs.y).toBeDefined();
|
||||
expect(Number.isFinite(attrs.x)).toBe(true);
|
||||
expect(Number.isFinite(attrs.y)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// fontSizeToPreserveWidth — bidirectional clamped width-preserving
|
||||
// fontSize calculator.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fontSizeToPreserveWidth', () => {
|
||||
it('returns oldFontSize unchanged when text is empty', () => {
|
||||
// No text → no width to preserve → no-op. The defensive bail
|
||||
// also prevents the division-by-zero that an empty-string
|
||||
// measurement would produce.
|
||||
expect(fontSizeToPreserveWidth('', 'WideFont', 32, 'NarrowFont')).toBe(32);
|
||||
});
|
||||
|
||||
it('returns oldFontSize unchanged when fonts are the same width', () => {
|
||||
// Two different family names with the same factor in our mock
|
||||
// would also return identical widths; the "same font" case is a
|
||||
// subset of this where ratio = 1 cleanly.
|
||||
expect(fontSizeToPreserveWidth('hello', 'default', 32, 'default')).toBe(32);
|
||||
});
|
||||
|
||||
it('shrinks fontSize when swapping to a wider font', () => {
|
||||
// 'hello' at fontSize 32 in NarrowFont (factor 0.3) → width 48.
|
||||
// The SAME width at fontSize 32 in WideFont (factor 0.8) → 128.
|
||||
// ratio = 48 / 128 = 0.375.
|
||||
// targetSize = 32 * 0.375 = 12, clamped to MIN_FONT_SIZE.
|
||||
const result = fontSizeToPreserveWidth('hello', 'NarrowFont', 32, 'WideFont');
|
||||
expect(result).toBeLessThan(32);
|
||||
});
|
||||
|
||||
it('grows fontSize when swapping to a narrower font', () => {
|
||||
// 'hello' at 32 in WideFont → 128 width.
|
||||
// SAME width at 32 in NarrowFont → 48.
|
||||
// ratio = 128 / 48 ≈ 2.667.
|
||||
// targetSize = 32 * 2.667 ≈ 85.3 → 85 after round.
|
||||
const result = fontSizeToPreserveWidth('hello', 'WideFont', 32, 'NarrowFont');
|
||||
expect(result).toBeGreaterThan(32);
|
||||
expect(result).toBeLessThanOrEqual(MAX_FONT_SIZE);
|
||||
});
|
||||
|
||||
it('clamps result to MAX_FONT_SIZE when ratio would exceed it', () => {
|
||||
// Push the math past MAX_FONT_SIZE: start at a large fontSize in
|
||||
// WideFont, swap to NarrowFont. The unclamped target would be
|
||||
// 120 * (128/48) ≈ 320 — well past the 200 cap.
|
||||
const result = fontSizeToPreserveWidth('hello', 'WideFont', 120, 'NarrowFont');
|
||||
expect(result).toBe(MAX_FONT_SIZE);
|
||||
});
|
||||
|
||||
it('clamps result to MIN_FONT_SIZE when ratio would fall below it', () => {
|
||||
// Tiny fontSize in NarrowFont swapping to WideFont. Mock factors
|
||||
// mean swap shrinks by ~2.67x. From fontSize 20 the unclamped
|
||||
// target is 7.5 — below MIN_FONT_SIZE (12).
|
||||
const result = fontSizeToPreserveWidth('hello', 'NarrowFont', 20, 'WideFont');
|
||||
expect(result).toBe(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it('rounds to integer fontSize', () => {
|
||||
// The function calls Math.round on the target. CSS happily
|
||||
// accepts fractional font sizes but a non-integer in storage
|
||||
// makes JSON snapshots awkward and produces noisy diffs.
|
||||
const result = fontSizeToPreserveWidth('hello', 'NarrowFont', 32, 'WideFont');
|
||||
expect(Number.isInteger(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('is approximately symmetric: shrink-then-grow returns near original', () => {
|
||||
// Numerical sanity check. Two opposite swaps should land close to
|
||||
// the starting fontSize, modulo rounding.
|
||||
const start = 48;
|
||||
const shrunk = fontSizeToPreserveWidth('hello', 'NarrowFont', start, 'WideFont');
|
||||
const grown = fontSizeToPreserveWidth('hello', 'WideFont', shrunk, 'NarrowFont');
|
||||
// Within 1 of original (Math.round at each step can accumulate
|
||||
// up to half a unit per step → 1 max).
|
||||
expect(Math.abs(grown - start)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// placeTextCentered — initial placement for new text elements.
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('placeTextCentered', () => {
|
||||
const CANVAS = 300;
|
||||
|
||||
it('centers the text within the canvas', () => {
|
||||
// 'hi' at fontSize 20 in default font → width = 2 * 20 * 0.5 = 20.
|
||||
// x = round((300 - 20) / 2) = 140.
|
||||
// h = 20 * LINE_HEIGHT_RATIO (default 1.2 in textGeometry).
|
||||
// y = round((300 - h) / 2).
|
||||
const result = placeTextCentered(
|
||||
{ text: 'hi', fontSize: 20, fontFamily: 'default' },
|
||||
CANVAS
|
||||
);
|
||||
expect(result.x).toBe(140);
|
||||
// y is centered on the line-height box, not asserted exactly
|
||||
// because LINE_HEIGHT_RATIO is in constants/ — but it should be
|
||||
// a reasonable positive number.
|
||||
expect(result.y).toBeGreaterThan(0);
|
||||
expect(result.y).toBeLessThan(CANVAS);
|
||||
expect(result.fontSize).toBe(20);
|
||||
});
|
||||
|
||||
it('auto-shrinks fontSize when text would overflow the margin', () => {
|
||||
// 'hello world' (11 chars) at fontSize 100 in WideFont (factor 0.8)
|
||||
// → width = 880. Margin = 0.9 * 300 = 270.
|
||||
// ratio = 270/880 ≈ 0.307.
|
||||
// new fontSize = floor(100 * 0.307) = 30.
|
||||
const result = placeTextCentered(
|
||||
{ text: 'hello world', fontSize: 100, fontFamily: 'WideFont' },
|
||||
CANVAS
|
||||
);
|
||||
expect(result.fontSize).toBeLessThan(100);
|
||||
expect(result.fontSize).toBeGreaterThanOrEqual(MIN_FONT_SIZE);
|
||||
});
|
||||
|
||||
it('respects a custom margin ratio', () => {
|
||||
// Tighter margin = less room = more shrink. Verify that a smaller
|
||||
// ratio produces smaller fontSize (or smaller x offset for
|
||||
// already-fitting text).
|
||||
const tight = placeTextCentered(
|
||||
{ text: 'hello world', fontSize: 100, fontFamily: 'WideFont' },
|
||||
CANVAS,
|
||||
0.5
|
||||
);
|
||||
const loose = placeTextCentered(
|
||||
{ text: 'hello world', fontSize: 100, fontFamily: 'WideFont' },
|
||||
CANVAS,
|
||||
0.9
|
||||
);
|
||||
expect(tight.fontSize).toBeLessThanOrEqual(loose.fontSize);
|
||||
});
|
||||
|
||||
it('uses defaults for missing fields', () => {
|
||||
// Empty draft should still produce a sensible placement using the
|
||||
// default font and DEFAULT_FONT_SIZE.
|
||||
const result = placeTextCentered({}, CANVAS);
|
||||
expect(result.fontSize).toBe(DEFAULT_FONT_SIZE);
|
||||
expect(Number.isFinite(result.x)).toBe(true);
|
||||
expect(Number.isFinite(result.y)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns integer x and y (no fractional placement)', () => {
|
||||
// Stored as integers via Math.round in the function. Avoids
|
||||
// half-pixel placements in stored data.
|
||||
const result = placeTextCentered(
|
||||
{ text: 'hello', fontSize: 20, fontFamily: 'default' },
|
||||
CANVAS
|
||||
);
|
||||
expect(Number.isInteger(result.x)).toBe(true);
|
||||
expect(Number.isInteger(result.y)).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps x and y to non-negative values', () => {
|
||||
// Pathological case: text wider than canvas even after
|
||||
// auto-shrink. The shrink should usually make this impossible,
|
||||
// but defensively Math.max(0, ...) protects against negative
|
||||
// x/y in stored state.
|
||||
const result = placeTextCentered(
|
||||
{ text: 'a'.repeat(100), fontSize: 80, fontFamily: 'WideFont' },
|
||||
CANVAS
|
||||
);
|
||||
expect(result.x).toBeGreaterThanOrEqual(0);
|
||||
expect(result.y).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('shrinks fontSize but not below MIN_FONT_SIZE', () => {
|
||||
// Massively-overflowing text at a tiny canvas. Even after the
|
||||
// shrink ratio, the fontSize floor is MIN_FONT_SIZE.
|
||||
const result = placeTextCentered(
|
||||
{ text: 'a'.repeat(500), fontSize: 200, fontFamily: 'WideFont' },
|
||||
CANVAS
|
||||
);
|
||||
expect(result.fontSize).toBeGreaterThanOrEqual(MIN_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
166
src/utils/textMetrics.js
Normal file
166
src/utils/textMetrics.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Synchronous text-width measurement.
|
||||
*
|
||||
* Uses a single shared offscreen canvas's 2D context to measure exactly how
|
||||
* wide a piece of text will render at a given font size and family. This is
|
||||
* the same pixel-perfect measurement the browser uses for `<canvas>` text
|
||||
* rendering, so it matches what Konva ends up drawing.
|
||||
*
|
||||
* Why we need this
|
||||
* ────────────────
|
||||
* Konva's `<TextPath>` renders text along a path. If the path is shorter
|
||||
* than the rendered text width, Konva silently clips the trailing
|
||||
* characters; if the path is longer, the visual midpoint of the path (and
|
||||
* any curvature peak) lands past the visual midpoint of the text.
|
||||
*
|
||||
* Both failure modes are font-dependent: a wide serif overflows, a
|
||||
* condensed display face like Bebas Neue under-flows. The fix is to ask the
|
||||
* browser for the actual rendered width at the given font and use that as
|
||||
* the path length.
|
||||
*
|
||||
* Caching
|
||||
* ───────
|
||||
* The canvas + 2D context is created lazily on first call and reused for
|
||||
* every subsequent call — measureText is a hot-path operation in some text
|
||||
* components, and creating canvases per call would be wasteful.
|
||||
*
|
||||
* SSR / non-DOM safety
|
||||
* ────────────────────
|
||||
* In environments without `document` (SSR, tests), we fall back to a rough
|
||||
* geometric estimate (`fontSize × length × 0.55`). The estimate is good
|
||||
* enough to hydrate without layout-shift and is overwritten by the real
|
||||
* measurement on the first client render.
|
||||
*/
|
||||
|
||||
let _measureCtx = null;
|
||||
|
||||
function getMeasureCtx() {
|
||||
if (_measureCtx) return _measureCtx;
|
||||
if (typeof document === 'undefined') return null;
|
||||
const canvas = document.createElement('canvas');
|
||||
// Tiny canvas — we only use it for measureText, never for drawing.
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
_measureCtx = canvas.getContext('2d');
|
||||
return _measureCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a font-family string for use as the family slot in CSS's font
|
||||
* shorthand. Multi-word names (e.g. "Bebas Neue", "Roboto Slab") must
|
||||
* be quoted or the parser treats them as two separate identifiers, and
|
||||
* canvas `ctx.font` is stricter about this than `style.fontFamily`.
|
||||
* Names that already contain quotes are passed through unchanged so
|
||||
* callers who pre-quote don't end up double-wrapped. Single-word
|
||||
* generic families like `serif` / `sans-serif` / `monospace` are
|
||||
* passed through as-is so they keep their generic-family semantics.
|
||||
*/
|
||||
const GENERIC_FAMILIES = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui']);
|
||||
function quoteFontFamily(family) {
|
||||
if (typeof family !== 'string') return family;
|
||||
const trimmed = family.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
// Already quoted (caller wrapped it). Leave alone.
|
||||
if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed;
|
||||
// Generic CSS family keywords — don't quote, or canvas treats them
|
||||
// as a literal name and falls back to default.
|
||||
if (GENERIC_FAMILIES.has(trimmed)) return trimmed;
|
||||
return `"${trimmed}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the width in CSS pixels of `text` rendered at `fontSize` in
|
||||
* `fontFamily`. Falls back to a geometric approximation when no DOM is
|
||||
* available.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} fontSize - in pixels
|
||||
* @param {string} fontFamily - any CSS font-family value (will be passed
|
||||
* through; commas/quotes are fine)
|
||||
* @returns {number} width in pixels
|
||||
*/
|
||||
export function measureTextWidth(text, fontSize, fontFamily) {
|
||||
if (!text) return 0;
|
||||
const ctx = getMeasureCtx();
|
||||
if (!ctx) {
|
||||
// Fallback approximation; close enough to keep layout stable until
|
||||
// hydration completes and the real measurement takes over.
|
||||
return fontSize * Math.max(1, text.length) * 0.55;
|
||||
}
|
||||
// Match Konva's default style so our measurement aligns with what Konva
|
||||
// actually renders. Konva defaults to non-bold non-italic.
|
||||
// Family name is quoted via quoteFontFamily — ctx.font requires
|
||||
// quotes around multi-word names or it silently falls back to the
|
||||
// default system font, returning wrong metrics.
|
||||
ctx.font = `${fontSize}px ${quoteFontFamily(fontFamily)}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return width, ascent, and descent for `text` rendered at `fontSize` in
|
||||
* `fontFamily`. Same canvas-measurement basis as measureTextWidth, but
|
||||
* also returns the actual rendered glyph ascent/descent from the
|
||||
* TextMetrics object — i.e. how far the glyphs reach above and below the
|
||||
* baseline for THIS specific string.
|
||||
*
|
||||
* Why this exists separately from measureTextWidth
|
||||
* ──────────────────────────────────────────────
|
||||
* Snap-to-center math in DesignCanvas needs the visible-ink bbox so the
|
||||
* snap centers what the user actually sees, not the abstract Konva.Text
|
||||
* line-height bbox (which has empty space above the baseline). For that
|
||||
* we need ascent/descent specifically. fontSize × 0.7 and fontSize × 0.2
|
||||
* are reasonable estimates, but they're wrong for fonts with unusual
|
||||
* proportions — script faces with tall capitals, condensed display
|
||||
* faces, or text containing tall punctuation. The TextMetrics API gives
|
||||
* us the real values per-text, per-font.
|
||||
*
|
||||
* Browser support: actualBoundingBoxAscent/Descent are available in
|
||||
* Chrome 77+, Firefox 74+, Safari 11.1+. Fallback to fontSize ratios
|
||||
* preserves correctness on older engines (none of which we actually
|
||||
* target, but defensive defaults are cheap).
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {number} fontSize - in pixels
|
||||
* @param {string} fontFamily
|
||||
* @returns {{ width: number, ascent: number, descent: number }}
|
||||
*/
|
||||
export function measureTextMetrics(text, fontSize, fontFamily) {
|
||||
// Fallbacks used when DOM is unavailable or when the TextMetrics API
|
||||
// doesn't return the bounding-box fields (very old browsers).
|
||||
const fallbackAscent = fontSize * 0.7;
|
||||
const fallbackDescent = fontSize * 0.2;
|
||||
// fontBoundingBox* are the font's declared max ascent/descent across
|
||||
// ALL its glyphs — useful as a floor for cursive faces where
|
||||
// actualBoundingBoxDescent under-reports the rendered tails/swirls.
|
||||
const fallbackFontAscent = fontSize * 0.85;
|
||||
const fallbackFontDescent = fontSize * 0.25;
|
||||
|
||||
if (!text) {
|
||||
return {
|
||||
width: 0,
|
||||
ascent: fallbackAscent,
|
||||
descent: fallbackDescent,
|
||||
fontAscent: fallbackFontAscent,
|
||||
fontDescent: fallbackFontDescent,
|
||||
};
|
||||
}
|
||||
const ctx = getMeasureCtx();
|
||||
if (!ctx) {
|
||||
return {
|
||||
width: fontSize * Math.max(1, text.length) * 0.55,
|
||||
ascent: fallbackAscent,
|
||||
descent: fallbackDescent,
|
||||
fontAscent: fallbackFontAscent,
|
||||
fontDescent: fallbackFontDescent,
|
||||
};
|
||||
}
|
||||
ctx.font = `${fontSize}px ${quoteFontFamily(fontFamily)}`;
|
||||
const m = ctx.measureText(text);
|
||||
return {
|
||||
width: m.width,
|
||||
ascent: m.actualBoundingBoxAscent ?? fallbackAscent,
|
||||
descent: m.actualBoundingBoxDescent ?? fallbackDescent,
|
||||
fontAscent: m.fontBoundingBoxAscent ?? fallbackFontAscent,
|
||||
fontDescent: m.fontBoundingBoxDescent ?? fallbackFontDescent,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user