Break editor into a module
This commit is contained in:
321
MIGRATION_RUNBOOK.md
Normal file
321
MIGRATION_RUNBOOK.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Migration runbook — apparel-designer → goods-editor module
|
||||
|
||||
Step-by-step instructions for executing the module extraction. This
|
||||
document is intentionally minimal-fluff: do each step in order, verify
|
||||
the marker at each checkpoint, move to the next.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Verify both repos exist as siblings:
|
||||
|
||||
```
|
||||
/Users/khalid/Documents/Claude-Desktop/
|
||||
├── apparel-designer/ (the host app, in working state)
|
||||
└── goods-editor-module/ (the empty module repo we just scaffolded)
|
||||
```
|
||||
|
||||
## Phase 1 — Module scaffolding (already done)
|
||||
|
||||
These were created during the planning session and are ready to use:
|
||||
|
||||
```
|
||||
goods-editor-module/
|
||||
├── package.json
|
||||
├── vite.config.js (library mode)
|
||||
├── vite.server.config.js (Node server entry)
|
||||
├── vitest.config.js
|
||||
├── .gitignore
|
||||
├── README.md
|
||||
├── src/
|
||||
│ ├── ApparelDesigner.jsx (~30KB orchestration absorbed from App.jsx)
|
||||
│ ├── index.js (public API barrel)
|
||||
│ └── server/
|
||||
│ ├── index.js
|
||||
│ └── mountEditorApi.js (Express middleware factory)
|
||||
├── examples/dev-host/ (standalone dev playground)
|
||||
└── scripts/
|
||||
├── release.sh
|
||||
└── post-migrate-patches.mjs
|
||||
```
|
||||
|
||||
**Verification:** `ls goods-editor-module/src/index.js` should succeed.
|
||||
|
||||
## Phase 2 — File migration
|
||||
|
||||
Copy all the editor source from the host into the module.
|
||||
|
||||
```bash
|
||||
cd /Users/khalid/Documents/Claude-Desktop
|
||||
bash apparel-designer/scripts/migrate-to-module.sh
|
||||
```
|
||||
|
||||
**What this does:** Copies utils, hooks, constants, components,
|
||||
styles, fonts, stickers, tests from `apparel-designer/src/` (and
|
||||
related directories) into `goods-editor-module/src/`. Source is left
|
||||
intact in `apparel-designer/` — nothing is deleted yet.
|
||||
|
||||
**Verification:** `ls goods-editor-module/src/components/canvas/`
|
||||
should show DesignCanvas.jsx, ElementToolbar.jsx, etc.
|
||||
|
||||
## Phase 3 — Post-copy patches
|
||||
|
||||
The copied files import constants like `SHIRT_COLORS` directly. Inside
|
||||
the module, those need to come from props. Run the patch script:
|
||||
|
||||
```bash
|
||||
cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module
|
||||
node scripts/post-migrate-patches.mjs
|
||||
```
|
||||
|
||||
**What this does:** Modifies a handful of files (`ShirtOptionsPanel`,
|
||||
`Sidebar`, `UploadTab`) to accept their data as props instead of
|
||||
importing constants.
|
||||
|
||||
**Verification:** Search the module source for `SHIRT_COLORS` — should
|
||||
return 0 hits in component files (still present as a `constants/shirt.js`
|
||||
default that's no longer imported by components).
|
||||
|
||||
## Phase 4 — Module install + build
|
||||
|
||||
```bash
|
||||
cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Verification:** `ls dist/` should show:
|
||||
|
||||
- `goods-editor.es.js`
|
||||
- `goods-editor.cjs.js`
|
||||
- `style.css`
|
||||
- `server/index.js`
|
||||
- `server/index.cjs`
|
||||
|
||||
If the build fails:
|
||||
|
||||
- **"Cannot find module 'X'"** — the migration may have missed a file.
|
||||
Check `goods-editor-module/src/` for the missing path and copy it
|
||||
from `apparel-designer/src/` manually.
|
||||
- **"SHIRT_COLORS is not defined"** — patch script didn't run or didn't
|
||||
fully match. Manually search for the symbol in the offending file
|
||||
and replace with the prop.
|
||||
|
||||
## Phase 5 — Module dev-host smoke test (optional but recommended)
|
||||
|
||||
Verify the module works in isolation before wiring it into the host:
|
||||
|
||||
```bash
|
||||
cd /Users/khalid/Documents/Claude-Desktop/goods-editor-module
|
||||
npm run dev
|
||||
# open http://localhost:3000 in a browser
|
||||
```
|
||||
|
||||
You should see the editor with the dev host's minimal top bar. If
|
||||
something's broken it's a module issue; fix here before touching the
|
||||
host.
|
||||
|
||||
The dev host doesn't have an /api/upload endpoint, so uploads will
|
||||
fail — that's expected. Text, stickers, emoji, and templates work
|
||||
without a server.
|
||||
|
||||
## Phase 6 — Host cutover
|
||||
|
||||
Replace the host's monolithic App.jsx + server.js with the new
|
||||
module-consuming versions:
|
||||
|
||||
```bash
|
||||
cd /Users/khalid/Documents/Claude-Desktop/apparel-designer
|
||||
bash scripts/cutover-host.sh
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
|
||||
1. Renames the monolithic files to `*.monolith.*` (kept for reference)
|
||||
2. Promotes `App.host.jsx` → `App.jsx`, `server.host.js` → `server.js`,
|
||||
`package.host.json` → `package.json`
|
||||
3. Deletes the module-owned source from `src/`
|
||||
|
||||
**Verification:** `ls apparel-designer/src/` should show only:
|
||||
|
||||
- `App.jsx`
|
||||
- `App.monolith.jsx` (backup)
|
||||
- `main.jsx`
|
||||
- `index.css`
|
||||
- `components/Header.jsx`, `components/PWAInstall.jsx`,
|
||||
`components/OfflineIndicator.jsx`
|
||||
- `styles/Header.css`, `styles/PWAInstall.css` (or however they
|
||||
organize after cleanup)
|
||||
|
||||
(The host's `src/styles/` and `src/components/` may still have
|
||||
host-specific CSS that wasn't deleted. That's expected — only
|
||||
module-owned files were removed.)
|
||||
|
||||
## Phase 7 — Host install + run
|
||||
|
||||
```bash
|
||||
cd /Users/khalid/Documents/Claude-Desktop/apparel-designer
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The `file:../goods-editor-module` reference in package.json points at
|
||||
the local module directory. npm install will copy it into
|
||||
`node_modules/goods-editor/`.
|
||||
|
||||
### Important: host-owned constants survive cutover
|
||||
|
||||
The `cutover-host.sh` script preserves three files in `src/constants/`:
|
||||
|
||||
- `shirt.js` — host's color / size / price catalog
|
||||
- `templates.js` — host's template definitions
|
||||
- `products.js` — host's product configuration
|
||||
|
||||
These are imported by the new `App.jsx` and passed into
|
||||
`<ApparelDesigner>` as props. Other constants (fonts, stickers,
|
||||
elements, etc.) are module-owned and get deleted.
|
||||
|
||||
If you customize the host's catalog (different shirt colors, new
|
||||
templates), you edit those files — not the module.
|
||||
|
||||
### Important: host-required native deps for server-side export
|
||||
|
||||
`goods-editor/server`'s `mountEditorApi()` accepts the host's `canvas`
|
||||
and `sharp` modules via options:
|
||||
|
||||
```js
|
||||
import canvas from 'canvas';
|
||||
import sharp from 'sharp';
|
||||
import { mountEditorApi } from 'goods-editor/server';
|
||||
|
||||
mountEditorApi(app, { canvas, sharp, exportsDir, fontsDir, ... });
|
||||
```
|
||||
|
||||
This injection pattern means:
|
||||
|
||||
- The module declares both as **optional peer dependencies** — listed
|
||||
for tooling visibility but not auto-installed.
|
||||
- Hosts that mount the server install canvas + sharp in their OWN
|
||||
`package.json` (as regular deps) and import them at the top of
|
||||
their `server.js`.
|
||||
- Hosts that DON'T mount the server (editor-only, preview-only)
|
||||
don't install them at all. The React side of `goods-editor` doesn't
|
||||
need them.
|
||||
|
||||
Why injection rather than `require()` inside the module: when the
|
||||
module is consumed via `file:` (local dev) or `git+ssh://` (release),
|
||||
the module's built server bundle lives in the consuming app's
|
||||
`node_modules`. A `require('canvas')` from inside that bundle would
|
||||
resolve from the MODULE'S directory chain, not the host's. The
|
||||
injection pattern sidesteps this entirely — the host imports canvas
|
||||
from its own `node_modules` and passes the imported object in.
|
||||
|
||||
**System libs:** `canvas` builds native bindings against cairo/pango.
|
||||
On macOS:
|
||||
|
||||
```
|
||||
brew install pkg-config cairo pango libpng jpeg giflib librsvg pixman
|
||||
```
|
||||
|
||||
If `npm install` fails on the canvas build step, those libs are
|
||||
usually the missing piece.
|
||||
|
||||
If the host doesn't need server-side export (e.g. editor-only flows),
|
||||
skip the `mountEditorApi(app, ...)` call in `server.js` entirely and
|
||||
don't install canvas/sharp — the editor still works as a pure React
|
||||
component.
|
||||
|
||||
### Important: the host's `virtual:sticker-manifest` setup stays in place
|
||||
|
||||
The module's `src/constants/stickers.js` imports from
|
||||
`virtual:sticker-manifest` — a Vite virtual module that the HOST'S
|
||||
`vite.config.js` provides via `stickerManifestPlugin()`. This is
|
||||
intentional: it makes the host the source-of-truth for which stickers
|
||||
are available.
|
||||
|
||||
**Don't delete `stickerManifestPlugin()` from the host's
|
||||
`vite.config.js`,** and don't delete `public/stickers/`. The cutover
|
||||
script leaves both alone. If you accidentally remove the plugin, you
|
||||
will see this error when running the editor:
|
||||
|
||||
```
|
||||
Failed to resolve import "virtual:sticker-manifest" from
|
||||
"node_modules/goods-editor/dist/goods-editor.es.js".
|
||||
```
|
||||
|
||||
The fix is to restore the plugin (it's the `stickerManifestPlugin()`
|
||||
function at the top of `vite.config.js`, plus its inclusion in the
|
||||
`plugins:` array).
|
||||
|
||||
If you want to ship the editor on a host that doesn't use Vite, or
|
||||
that has a fundamentally different sticker layout, you can pass
|
||||
stickers in via props on `<ApparelDesigner>` instead and provide a
|
||||
no-op virtual module that exports an empty `STICKER_FILES` array.
|
||||
(That extension point doesn't exist yet — file an issue / add a prop
|
||||
when the second host needs it.)
|
||||
|
||||
**Verification:** `http://localhost:3000` should show the editor with
|
||||
the host's full Header and the editor mounted below it. Functionality
|
||||
should match the pre-migration version exactly.
|
||||
|
||||
## Phase 8 — Final checks
|
||||
|
||||
Things to verify in the running app:
|
||||
|
||||
- [ ] Can add text from the Text tab
|
||||
- [ ] Can upload a photo and see it on the canvas
|
||||
- [ ] Can add a sticker / emoji
|
||||
- [ ] Cmd+Z undoes; Cmd+Shift+Z redoes
|
||||
- [ ] Crop an image, Cmd+Z restores pre-crop (the May 22 regression)
|
||||
- [ ] Save button triggers an export (download via the export pipeline)
|
||||
- [ ] Reload the page — designs persist via localStorage
|
||||
- [ ] Production build: `npm run build && NODE_ENV=production npm start`
|
||||
|
||||
If everything works:
|
||||
|
||||
1. `git add -A && git commit -m "Migrate to goods-editor module"` in both
|
||||
repos
|
||||
2. Cut a release of the module:
|
||||
```bash
|
||||
cd goods-editor-module
|
||||
./scripts/release.sh --version=0.1.0-alpha.0
|
||||
```
|
||||
3. In the host, swap the dep from `file:` to `git+ssh://` once you've
|
||||
pushed to Gitea.
|
||||
|
||||
## Rollback
|
||||
|
||||
If anything goes sideways and you need to undo the cutover:
|
||||
|
||||
```bash
|
||||
cd apparel-designer
|
||||
mv src/App.jsx src/App.host.jsx
|
||||
mv server.js server.host.js
|
||||
mv package.json package.host.json
|
||||
mv src/App.monolith.jsx src/App.jsx
|
||||
mv server.monolith.js server.js
|
||||
mv package.monolith.json package.json
|
||||
# Restore deleted module-owned files from git:
|
||||
git checkout -- src/ fonts/ e2e/ playwright.config.js vitest.config.js
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The `goods-editor-module/` repo stays put — rollback only affects the
|
||||
host. You can iterate on the module separately and try the cutover
|
||||
again later.
|
||||
|
||||
## Done
|
||||
|
||||
After successful cutover, future development looks like:
|
||||
|
||||
- **Editor changes** (canvas behavior, sidebar UX, hooks, utilities):
|
||||
edit in `goods-editor-module/`. Test via `npm run dev` (dev host).
|
||||
When happy, `./scripts/release.sh --version=X.Y.Z` and bump the
|
||||
host's `package.json`.
|
||||
|
||||
- **Host changes** (homepage, marketing, cart, top bar): edit in
|
||||
`apparel-designer/`. AI tools pointed at this directory only see
|
||||
the host's ~10 files plus the editor's public API surface, not the
|
||||
editor's ~100 internal files. That's the win.
|
||||
235
e2e/crop.spec.js
235
e2e/crop.spec.js
@@ -1,235 +0,0 @@
|
||||
// E2E: crop flow + the May 22 "canvas blanks on Cmd+Z after crop"
|
||||
// regression.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// The headline bug from the May 22 cleanup arc: cropping an image and
|
||||
// then pressing Cmd+Z would land on an empty canvas instead of
|
||||
// restoring the pre-crop state. Root cause was in the initializeHistory
|
||||
// path — see Refinements_2026-05-22.md for the full story. The unit
|
||||
// tests cover the math (cropMath) and the history-pointer behaviour
|
||||
// (useDesignEditor) at the function level; this spec exercises the
|
||||
// full pipeline end-to-end so a future regression that breaks any
|
||||
// link in the chain (the Konva stage walk for naturalW/H, the
|
||||
// updateAndCommit wiring, the keyboard handler firing on undo) gets
|
||||
// caught.
|
||||
//
|
||||
// Why this spec is more complex than the other three
|
||||
// ──────────────────────────────────────────────────
|
||||
// Cropping is image-only — ElementToolbar's `allowCrop = isImage &&
|
||||
// !!onStartCrop` gates the Crop button on type === 'image'. Stickers
|
||||
// don't get a Crop button (even though App's handleStartCrop accepts
|
||||
// them). That means this spec can't use a one-click sticker add; it
|
||||
// needs a real file upload via the Upload Photo tab.
|
||||
//
|
||||
// Rather than committing a binary fixture file to the repo (and
|
||||
// dealing with Git LFS or just bloat), we build the upload payload
|
||||
// inline as a base64-encoded 1×1 transparent PNG. Playwright's
|
||||
// `setInputFiles({ name, mimeType, buffer })` accepts an in-memory
|
||||
// buffer with no file on disk. The PNG is the smallest valid PNG
|
||||
// the spec can use — 67 bytes — which keeps the upload fast and
|
||||
// avoids any minimum-dimension validation surprises.
|
||||
//
|
||||
// Image-load timing
|
||||
// ─────────────────
|
||||
// The Konva Image node loads the src URL asynchronously after the
|
||||
// element is added to state. handleApplyCrop walks the stage to
|
||||
// find the image node and read naturalWidth/Height; if those are
|
||||
// 0 (image not yet decoded), it surfaces the
|
||||
// "Photo is still loading. Try Apply again in a moment." toast
|
||||
// and keeps crop mode active. Our helper below clicks Apply, then
|
||||
// waits for crop mode to exit (Apply button vanishes); if it
|
||||
// doesn't within a short window, that means the toast fired and
|
||||
// we should wait + retry. This is the same UX a human user
|
||||
// follows ("oh, didn't work, let me click again in a second").
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
gotoFreshEditor,
|
||||
getElementsCount,
|
||||
pressUndo,
|
||||
} from './fixtures/editor.js';
|
||||
|
||||
// Smallest valid PNG: 1×1 transparent. 67 bytes raw, ~90 base64
|
||||
// chars. Decoded to a buffer for the upload. The image is too small
|
||||
// to be visually meaningful but that's fine — the bug we're guarding
|
||||
// against is history-shaped, not pixel-shaped. Crop math reduces to
|
||||
// an identity crop on a 1×1 source, which still pushes a real
|
||||
// history entry that undo must restore from.
|
||||
const TINY_PNG_BASE64
|
||||
= 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
||||
|
||||
/**
|
||||
* Click "Apply crop" and wait for crop mode to exit. If the
|
||||
* "Photo is still loading" toast appears (because the Konva
|
||||
* Image node hasn't decoded the src yet), wait briefly and try
|
||||
* again — up to a small attempt budget.
|
||||
*
|
||||
* Success signal: the Apply button stops being visible (because
|
||||
* the ElementToolbar drops back to its non-crop face).
|
||||
*
|
||||
* This mirrors the toast-and-retry guidance in messages.js:
|
||||
* 'toast.crop-not-ready': 'Photo is still loading. Try Apply
|
||||
* again in a moment.'
|
||||
*/
|
||||
async function applyCropWithRetry(page) {
|
||||
const applyButton = page.getByRole('button', { name: 'Apply crop' });
|
||||
// 8 attempts × ~1.5s budget per attempt + intermediate waits =
|
||||
// up to ~20s for the image to decode. In practice the buffer is
|
||||
// already in memory so this resolves on the first attempt; the
|
||||
// retry budget is purely defensive against slow CI environments.
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
await applyButton.click();
|
||||
// If crop mode exits within the timeout, Apply succeeded.
|
||||
// We use `waitFor` on the button's hidden state because
|
||||
// toBeHidden() polls but doesn't return a boolean.
|
||||
const exited = await applyButton
|
||||
.waitFor({ state: 'hidden', timeout: 1500 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (exited) return;
|
||||
// Apply button still visible → toast probably fired. Wait
|
||||
// for the image to finish loading. The toast itself
|
||||
// auto-dismisses; we just need the underlying load to
|
||||
// complete before the next click.
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
throw new Error('Apply crop did not exit crop mode after 8 attempts');
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoFreshEditor(page);
|
||||
});
|
||||
|
||||
test('cropping an uploaded image and then Cmd/Ctrl+Z does not blank the canvas (May 22 regression)', async ({ page }) => {
|
||||
// Step 1 — Switch to the Upload Photo tab. In a fresh editor
|
||||
// the default tab is Upload, so this is mostly a defensive
|
||||
// click in case that default changes; either way it makes the
|
||||
// file input findable.
|
||||
await page.getByRole('tab', { name: /upload/i }).click();
|
||||
|
||||
// Step 2 — Upload the PNG. The file input is hidden via CSS
|
||||
// (`ut__hidden-input`); Playwright's setInputFiles bypasses
|
||||
// visibility checks. The upload triggers a POST to /api/upload,
|
||||
// which is processed by the Node server (Vite proxies the
|
||||
// /api/* prefix to localhost:3001). The server's multer+sharp
|
||||
// pipeline writes a preview and original to disk and returns
|
||||
// their URLs; UploadTab's placeImage then adds the element.
|
||||
//
|
||||
// The selector targets `.ut__hidden-input` rather than a bare
|
||||
// `input[type="file"]` because the page also contains a slot-
|
||||
// upload hidden input (from the template-slot system) with
|
||||
// accept="image/*" — a bare query would resolve to two elements
|
||||
// and trip Playwright's strict mode. The class selector is
|
||||
// unique to UploadTab.
|
||||
await page.locator('input.ut__hidden-input').setInputFiles({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
});
|
||||
|
||||
// Step 3 — Wait for the upload to complete and the element
|
||||
// to be added. expect.poll handles the upload's async chain
|
||||
// (server roundtrip + image dimension probe + addElement
|
||||
// commit). The default expect timeout (5s) is plenty for a
|
||||
// 67-byte upload; if it ever flakes here, CI is overloaded
|
||||
// and the test budget should grow rather than this assertion.
|
||||
await expect.poll(() => getElementsCount(page)).toBe(1);
|
||||
|
||||
// Step 4 — Make sure the element is selected so the
|
||||
// ElementToolbar renders. The upload flow may or may not
|
||||
// auto-select (depending on App's handleAddImage); clicking
|
||||
// the layer row to select is defensive and explicit. The
|
||||
// row's main button has class `.layers-item-main` and
|
||||
// contains the element's display name (typically "Photo"
|
||||
// or a filename-derived label for uploads).
|
||||
await page.locator('.layers-item-main').first().click();
|
||||
|
||||
// Step 5 — Click the Crop button. It only renders for
|
||||
// type==='image' elements (allowCrop guard in
|
||||
// ElementToolbar). The button's accessible name comes from
|
||||
// its ToolbarButton's `label` prop: "Crop image".
|
||||
await page.getByRole('button', { name: /crop image/i }).click();
|
||||
|
||||
// Step 6 — The toolbar swapped to its crop-mode face;
|
||||
// confirm by waiting for the Apply button before we drive it.
|
||||
await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();
|
||||
|
||||
// Step 7 — Apply the crop. We don't modify the crop rect —
|
||||
// this is an IDENTITY crop, which still pushes a real
|
||||
// history entry (the element gets a crop attribute set even
|
||||
// though sx/sy=0 and sWidth/sHeight match naturalW/H). The
|
||||
// headline bug doesn't depend on a meaningful crop; it
|
||||
// depends on undo restoring the pre-crop snapshot.
|
||||
await applyCropWithRetry(page);
|
||||
|
||||
// Step 8 — Sanity: still one element on canvas (the crop
|
||||
// committed without removing the image).
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
// Step 9 — THE BUG CHECK. Cmd/Ctrl+Z must walk history back
|
||||
// to the pre-crop snapshot. Pre-fix, this would land on an
|
||||
// empty canvas (element count → 0); post-fix, the element
|
||||
// is still there.
|
||||
await pressUndo(page);
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
// Step 10 — Double check: the LayersPanel row for the
|
||||
// image is still visible. A bug variant where the element
|
||||
// exists in state but is invisible on canvas would slip
|
||||
// past the count check alone; asserting the panel row
|
||||
// catches the case where elements is still length 1 but
|
||||
// populated with garbage.
|
||||
await expect(page.locator('.layers-item-main')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('cancelling crop leaves the original element untouched', async ({ page }) => {
|
||||
// Sanity test for the cancel path. Not a regression check —
|
||||
// just confirming the crop UI is reversible without going
|
||||
// through history. If a future change accidentally pushes a
|
||||
// history entry on Cancel (or worse, applies the crop
|
||||
// anyway), this test catches it.
|
||||
await page.getByRole('tab', { name: /upload/i }).click();
|
||||
await page.locator('input.ut__hidden-input').setInputFiles({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
|
||||
});
|
||||
await expect.poll(() => getElementsCount(page)).toBe(1);
|
||||
|
||||
// History-state baseline. After adding ONE element, history is
|
||||
// `[empty, [img]]` at idx=1 — the Undo button is ENABLED
|
||||
// ("step back to the empty floor"). We capture this enabled
|
||||
// state so we can verify Cancel doesn't ADD another history
|
||||
// entry on top.
|
||||
//
|
||||
// The trip wire isn't "Undo disabled" — it's enabled either
|
||||
// way. The trip wire is the count of clickable history steps:
|
||||
// a single Undo click should land us at the empty canvas
|
||||
// (0 elements). If Cancel had pushed a history entry, Undo
|
||||
// would walk back to that entry first (still 1 element, just
|
||||
// a different snapshot) and only the SECOND undo would empty
|
||||
// the canvas. We test the count-after-one-undo as the
|
||||
// definitive check.
|
||||
const undoButton = page.getByRole('button', { name: 'Undo' });
|
||||
await expect(undoButton).toBeEnabled();
|
||||
|
||||
await page.locator('.layers-item-main').first().click();
|
||||
await page.getByRole('button', { name: /crop image/i }).click();
|
||||
await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();
|
||||
|
||||
// Cancel rather than Apply. The toolbar should drop back to
|
||||
// its non-crop face; the element stays as it was.
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Apply crop' })).not.toBeVisible();
|
||||
|
||||
// Element count unchanged.
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
// The definitive "Cancel didn't push history" check. ONE undo
|
||||
// step should reach the empty floor. If Cancel had pushed a
|
||||
// (no-op) entry, we'd undo to that intermediate state and still
|
||||
// have 1 element here.
|
||||
await undoButton.click();
|
||||
expect(await getElementsCount(page)).toBe(0);
|
||||
});
|
||||
@@ -1,281 +0,0 @@
|
||||
// Shared Playwright helpers for the editor tests.
|
||||
//
|
||||
// What's here
|
||||
// ───────────
|
||||
// • `gotoFreshEditor(page)` — navigate to the editor with a clean
|
||||
// localStorage and a freshly-unregistered service worker, so each
|
||||
// test starts from a known empty canvas regardless of state left
|
||||
// behind by previous tests or local browsing.
|
||||
//
|
||||
// • `addTextViaSidebar(page, { text })` — drive the Text tab to add
|
||||
// a text element. Used by multiple specs so the pattern lives in
|
||||
// one place.
|
||||
//
|
||||
// • `getElementsCount(page)` — read the LayersPanel's "Layers (N)"
|
||||
// title to assert how many elements exist on the canvas. The
|
||||
// panel is the canonical user-visible source of truth for that
|
||||
// count.
|
||||
//
|
||||
// • `pressUndo(page)` / `pressRedo(page)` — Cmd+Z / Cmd+Shift+Z
|
||||
// wrappers that send the same keystrokes the user would. Modifier
|
||||
// is meta on macOS, control elsewhere; we detect via the
|
||||
// userAgent. (The app's keyboard handler accepts either modifier
|
||||
// so we could hardcode one, but using the platform-correct one
|
||||
// keeps the tests honest about what real users do.)
|
||||
//
|
||||
// Why these live here rather than inline in each spec
|
||||
// ───────────────────────────────────────────────────
|
||||
// Three reasons. (1) Several specs need fresh-canvas setup; if it
|
||||
// drifts inline-in-each-test, "fresh" stops meaning the same thing
|
||||
// across the suite. (2) The localStorage + service-worker reset is
|
||||
// non-obvious and easy to skip; centralising it ensures every test
|
||||
// gets the full reset rather than just the obvious bits. (3) When
|
||||
// the app's selectors change (e.g. someone renames the LayersPanel
|
||||
// title), one edit here updates every test rather than touching each
|
||||
// spec file.
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Navigate to the editor with a fully fresh state.
|
||||
*
|
||||
* The order matters:
|
||||
* 1. goto('/') first so we have a page context to evaluate against.
|
||||
* Without this, `evaluate` has nowhere to run.
|
||||
* 2. Clear localStorage AND unregister any service workers AND
|
||||
* clear caches. The PWA's service worker caches static assets;
|
||||
* stale caches between test runs can mask real regressions
|
||||
* (e.g. an app code change that breaks the editor but a cached
|
||||
* SW returns the old version).
|
||||
* 3. Reload to apply the wipe — the first goto loaded the app
|
||||
* against whatever state was there, so step 2's wipe wouldn't
|
||||
* affect the current page without a reload.
|
||||
* 4. Wait for the canvas to mount before returning. The Konva
|
||||
* Stage takes a few frames after the first paint to be ready
|
||||
* for interaction; clicking buttons before then can race.
|
||||
*
|
||||
* The wait targets are:
|
||||
* • `.canvas-area` — the DOM container is present immediately
|
||||
* once React mounts.
|
||||
* • A document-fonts-ready check — without this, text-related
|
||||
* assertions can race against the woff2 fetches that
|
||||
* useFontsReady waits for. measureTextWidth returns
|
||||
* fallback-font values until the real fonts arrive.
|
||||
*/
|
||||
export async function gotoFreshEditor(page) {
|
||||
// Step 1: land on the editor so we have storage access.
|
||||
await page.goto('/');
|
||||
|
||||
// Step 2: wipe persisted state + SW caches.
|
||||
await page.evaluate(async () => {
|
||||
try { localStorage.clear(); } catch { /* ignore */ }
|
||||
try { sessionStorage.clear(); } catch { /* ignore */ }
|
||||
// Unregister all service workers. The app's PWA registers one
|
||||
// on first load; without unregistering, a SW from a previous
|
||||
// test run can serve stale assets.
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(regs.map((r) => r.unregister()));
|
||||
}
|
||||
// Wipe Cache API entries (the SW's storage layer).
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
}
|
||||
});
|
||||
|
||||
// Step 3: reload to apply the wipe.
|
||||
await page.reload();
|
||||
|
||||
// Step 4: wait for the editor's primary surfaces.
|
||||
// canvas-area exists once App.jsx renders. The stage's <canvas>
|
||||
// tag is inside but doesn't need to be specifically awaited —
|
||||
// it's children of canvas-area.
|
||||
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
||||
|
||||
// Wait for fonts so the text-width measurements settle. This
|
||||
// mirrors what useFontsReady does inside the app — both run
|
||||
// until document.fonts.ready resolves. Without it,
|
||||
// placeTextCentered uses fallback widths and the placement-
|
||||
// assertion in tests can be off by tens of pixels.
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a text element via the Text tab. Returns once the new element
|
||||
* has appeared in the LayersPanel — at which point it's safe to
|
||||
* make assertions about its presence on the canvas.
|
||||
*
|
||||
* The flow is:
|
||||
* 1. Click the Text tab in the sidebar to make sure it's active.
|
||||
* In a fresh editor the default tab is Upload, so we have to
|
||||
* switch.
|
||||
* 2. Type into the "Your message" textarea, replacing the
|
||||
* pre-populated "Your text here" draft.
|
||||
* 3. Click "Add text to canvas".
|
||||
* 4. Wait for the Layers panel to show the new layer.
|
||||
*
|
||||
* We don't change fontFamily / fontSize / fill here — they default
|
||||
* to whatever the draft has (DEFAULT_DRAFT in TextTab.jsx). Tests
|
||||
* that care about a specific font / size can call the relevant
|
||||
* controls afterward.
|
||||
*/
|
||||
export async function addTextViaSidebar(page, { text }) {
|
||||
// Ordering matters: deselect FIRST, switch to Text tab SECOND.
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// On desktop, App.jsx derives `editingTextElement` directly from
|
||||
// `selectedElement?.type === 'text'`, AND it has an auto-tab-switch
|
||||
// effect that runs on every text-selection transition:
|
||||
//
|
||||
// text → not-text : auto-switches sidebar from 'text' → 'upload'
|
||||
// anything → text : auto-switches sidebar to 'text'
|
||||
//
|
||||
// So after a previous addTextViaSidebar:
|
||||
// • A text element is selected.
|
||||
// • The sidebar is on the 'text' tab.
|
||||
// • TextTab is rendered in EDIT MODE (the Add button is gone,
|
||||
// replaced by fields editing the selected element).
|
||||
//
|
||||
// The naive ordering (click Text tab → deselect → wait for Add
|
||||
// button) doesn't work, because the deselect ALSO fires the
|
||||
// auto-tab-switch which snaps the sidebar back to 'upload'. The
|
||||
// Text tab unmounts entirely; the Add button isn't hidden, it's
|
||||
// gone with the rest of the tab. The waitFor times out looking
|
||||
// for a button that doesn't exist anywhere on the page.
|
||||
//
|
||||
// Correct ordering: deselect FIRST (sidebar auto-snaps to 'upload',
|
||||
// any prior selection clears), THEN click the Text tab to switch
|
||||
// back into it. Our explicit click is the LAST tab interaction,
|
||||
// so it wins; TextTab mounts in draft mode (no selected text
|
||||
// element to bind to) and renders the Add button.
|
||||
//
|
||||
// Deselection strategy
|
||||
// ────────────────────
|
||||
// We tried three approaches; only the third works, and the journey
|
||||
// is documented so a future maintainer doesn't repeat it.
|
||||
//
|
||||
// 1. Press Escape. App.jsx wires Escape to `deselectAll`, but the
|
||||
// keyboard handler has an input-gate early return on INPUT /
|
||||
// TEXTAREA targets. After Playwright's previous `.fill()` the
|
||||
// textarea owns focus, so bubbled keydown's `e.target` is the
|
||||
// textarea and the handler bails. Programmatic blur didn't
|
||||
// help — Playwright tracks focus internally and routes
|
||||
// keystrokes through that internal state, not document.activeElement.
|
||||
//
|
||||
// 2. Click `.canvas-area` at `{x:5, y:5}`. App.jsx's mousedown
|
||||
// deselect handler skips clicks inside the Konva stage container
|
||||
// (stageContainerRef). The stage container fills most of
|
||||
// canvas-area, so corner positions land inside it and the
|
||||
// handler bails. The pink-frame deselect zone is narrower than
|
||||
// it looks from the docblock alone.
|
||||
//
|
||||
// 3. (Current) Dispatch a real `mousedown` event with the
|
||||
// canvas-area element itself as `e.target`. The handler's
|
||||
// early-return checks pass cleanly: canvasRef.contains(canvas-area)
|
||||
// is true (a node contains itself), and none of the excluded
|
||||
// refs (toolbar / zoom / stage container) CONTAIN their
|
||||
// ancestor canvas-area. The handler proceeds to deselectAll,
|
||||
// which clears selectedId AND triggers the auto-tab-switch to
|
||||
// 'upload'.
|
||||
const addButton = page.getByRole('button', { name: 'Add text to canvas' });
|
||||
|
||||
// Cheap selection probe: if there's no LayersPanel content (count is
|
||||
// zero) OR the Text tab isn't even active, there's no selection to
|
||||
// clear and we can skip the deselect path entirely. The probe avoids
|
||||
// a 300ms isVisible wait on every call when we'd just no-op.
|
||||
const currentCount = await getElementsCount(page);
|
||||
if (currentCount > 0) {
|
||||
// Deselect via a dispatched mousedown on canvas-area itself. This
|
||||
// is the only reliable path; see strategy notes above.
|
||||
await page.evaluate(() => {
|
||||
const area = document.querySelector('.canvas-area');
|
||||
if (!area) return;
|
||||
const event = new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
});
|
||||
area.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
// Now switch to the Text tab. After the deselect above, sidebar
|
||||
// is on 'upload' (auto-snapped); our explicit click moves it to
|
||||
// 'text' for the add. TextTab mounts in draft mode and renders
|
||||
// the Add button.
|
||||
await page.getByRole('tab', { name: /text/i }).click();
|
||||
|
||||
// Wait for the Add button. With the correct tab+deselect order,
|
||||
// this should be near-instant.
|
||||
await addButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Fill the textarea. The label is "Your message" per TextTab.jsx
|
||||
// (`<label htmlFor="tt-text-input">Your message</label>`).
|
||||
const textarea = page.getByLabel('Your message');
|
||||
await textarea.fill(text);
|
||||
|
||||
// Capture the current layer count so we can wait for it to
|
||||
// increment. Reading the title text avoids us having to count
|
||||
// rendered <li> rows, which can be slower to read than the
|
||||
// pre-aggregated count in the title.
|
||||
const before = await getElementsCount(page);
|
||||
|
||||
// Step 3: submit. The button is exposed by its visible text.
|
||||
await addButton.click();
|
||||
|
||||
// Step 4: wait for the count to increment. expect.poll handles
|
||||
// the race between the click and the React commit + Konva mount.
|
||||
await expect.poll(() => getElementsCount(page)).toBe(before + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current element count from the LayersPanel title.
|
||||
* Returns 0 when no layers exist (the panel renders an empty-state
|
||||
* banner instead of the "Layers (N)" title in that case).
|
||||
*
|
||||
* The panel's title is `Layers (N)` per LayersPanel.jsx:
|
||||
* <h3 className="layers-title">Layers ({elements.length})</h3>
|
||||
*
|
||||
* We parse the N out of the title rather than counting <li>
|
||||
* elements because the panel's empty state doesn't render any
|
||||
* <li>s OR the title — we'd need two different code paths for
|
||||
* "zero" vs "non-zero" if we counted rows.
|
||||
*/
|
||||
export async function getElementsCount(page) {
|
||||
// Title may not exist if the panel is showing its empty state.
|
||||
// Use .count() rather than waitFor to make this non-blocking.
|
||||
const title = page.locator('.layers-title');
|
||||
if ((await title.count()) === 0) return 0;
|
||||
const text = await title.first().textContent();
|
||||
// Title format: "Layers (N)". Extract N. Defensive fallback to 0
|
||||
// if the format changes (test still fails downstream with a
|
||||
// useful message rather than throwing here).
|
||||
const match = /\((\d+)\)/.exec(text || '');
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Cmd+Z (macOS) / Ctrl+Z (other) keystroke. The app's
|
||||
* keyboard handler in App.jsx accepts either modifier, but using
|
||||
* the platform-correct one keeps the test mirror of real user
|
||||
* behaviour.
|
||||
*
|
||||
* The keystroke is sent to `document.body` (Playwright's default
|
||||
* for page.keyboard.press) rather than a specific element. The
|
||||
* app's handler is attached to `window`, so the focus target
|
||||
* doesn't matter — as long as it isn't inside an <input> or
|
||||
* <textarea>, where the handler's early-return skips the
|
||||
* shortcut.
|
||||
*/
|
||||
export async function pressUndo(page) {
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z');
|
||||
}
|
||||
|
||||
export async function pressRedo(page) {
|
||||
// The handler accepts either Cmd+Shift+Z or Cmd+Y; we use
|
||||
// Shift+Z because it's the more conventional shortcut and
|
||||
// covers the same code path.
|
||||
const isMac = process.platform === 'darwin';
|
||||
await page.keyboard.press(isMac ? 'Meta+Shift+z' : 'Control+Shift+z');
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// E2E: undo/redo flow.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// The history subsystem is the most bug-bitten area of the codebase
|
||||
// (see the May 22 / May 23 Refinements notes). useDesignEditor.test.js
|
||||
// covers the hook-level behaviour exhaustively, but the keyboard
|
||||
// shortcuts (Cmd+Z / Cmd+Shift+Z) are attached to the window via a
|
||||
// global useEffect in App.jsx — that wiring isn't exercised by Vitest.
|
||||
// This spec drives the actual keystrokes and verifies the right
|
||||
// things happen.
|
||||
//
|
||||
// Two scenarios in particular are worth catching at this layer:
|
||||
//
|
||||
// 1. The keyboard handler's input/textarea exclusion. The handler
|
||||
// bails out early when the focused element is an <input> or
|
||||
// <textarea>, so the user can type a `z` into the text field
|
||||
// without accidentally undoing. Vitest can't test this because
|
||||
// it doesn't render real form elements with focus state.
|
||||
//
|
||||
// 2. The HistoryControls buttons disabling/enabling correctly. The
|
||||
// pills have `disabled` attributes wired to canUndo/canRedo,
|
||||
// and the visual state of those buttons is what tells the
|
||||
// user "you can / can't go back further." A bug where the
|
||||
// buttons stay enabled past the history floor would only be
|
||||
// visible at the DOM-attribute level.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
gotoFreshEditor,
|
||||
addTextViaSidebar,
|
||||
getElementsCount,
|
||||
pressUndo,
|
||||
pressRedo,
|
||||
} from './fixtures/editor.js';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoFreshEditor(page);
|
||||
});
|
||||
|
||||
test('Cmd/Ctrl+Z removes the most recently added element', async ({ page }) => {
|
||||
// The headline undo test. Add an element, undo, verify it's gone.
|
||||
// This single keystroke covers the whole pipeline: the keyboard
|
||||
// handler in App.jsx fires undo(), useDesignEditor walks the
|
||||
// history pointer back, setElements applies the previous snapshot,
|
||||
// LayersPanel re-renders.
|
||||
await addTextViaSidebar(page, { text: 'Will be undone' });
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
await pressUndo(page);
|
||||
// The deleted element is gone; count drops to 0.
|
||||
expect(await getElementsCount(page)).toBe(0);
|
||||
});
|
||||
|
||||
test('Cmd/Ctrl+Shift+Z restores an undone element', async ({ page }) => {
|
||||
// Redo after undo. The post-redo state must match the original
|
||||
// (same text on the same element) — a regression where redo
|
||||
// restored a different snapshot than what was undone would fail
|
||||
// the layer-name assertion.
|
||||
await addTextViaSidebar(page, { text: 'Resurrected' });
|
||||
await pressUndo(page);
|
||||
expect(await getElementsCount(page)).toBe(0);
|
||||
|
||||
await pressRedo(page);
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Resurrected' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('typing `z` in the text field does NOT undo (keyboard handler input gate)', async ({ page }) => {
|
||||
// The keyboard handler in App.jsx has an explicit early-return:
|
||||
// if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
// Without this, typing a `z` into the textarea while holding a
|
||||
// modifier would hijack the keystroke. (Just typing `z` without
|
||||
// a modifier doesn't trigger the undo branch anyway, but Cmd+Z
|
||||
// is the danger — except even Cmd+Z is suppressed because the
|
||||
// user's cursor is in a field where Cmd+Z is the OS-native
|
||||
// "undo last typed character" shortcut, which the browser
|
||||
// handles.)
|
||||
//
|
||||
// We test the simpler case here: pressing Cmd+Z while focused
|
||||
// on the textarea should NOT remove the element. The OS-native
|
||||
// textarea undo may or may not fire — we don't care; we care
|
||||
// that OUR keyboard handler doesn't fire and remove an element
|
||||
// from the canvas.
|
||||
await addTextViaSidebar(page, { text: 'Safe' });
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
// Focus the textarea by clicking it explicitly. addTextViaSidebar
|
||||
// leaves focus elsewhere by the time it returns; we have to
|
||||
// re-focus deterministically.
|
||||
const textarea = page.getByLabel('Your message');
|
||||
await textarea.click();
|
||||
await expect(textarea).toBeFocused();
|
||||
|
||||
// Now press Cmd+Z. The app's handler should bail out at the
|
||||
// tagName check. The element on the canvas stays.
|
||||
await pressUndo(page);
|
||||
|
||||
// Verification: element count is still 1.
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
});
|
||||
|
||||
test('Undo and Redo buttons in the bottom bar reflect history state', async ({ page }) => {
|
||||
// HistoryControls renders two buttons with aria-label "Undo" /
|
||||
// "Redo" and a `disabled` attribute wired to canUndo / canRedo.
|
||||
// The button states are how users (and screen readers) see
|
||||
// whether stepping further is available.
|
||||
//
|
||||
// History initialization model (confirmed by running the suite):
|
||||
// a fresh editor's history is seeded with the empty snapshot at
|
||||
// idx=0. canUndo = `idx > 0`, so a fresh editor has canUndo false.
|
||||
// The FIRST add pushes a new snapshot and advances idx to 1 — at
|
||||
// which point canUndo flips to true because the user can step
|
||||
// back to the empty floor. This contradicted my initial mental
|
||||
// model ("first add gives idx=0") but matches what the app
|
||||
// actually does; the May 22 cleanup arc relies on that empty
|
||||
// floor being there as a restoration target.
|
||||
const undoButton = page.getByRole('button', { name: 'Undo' });
|
||||
const redoButton = page.getByRole('button', { name: 'Redo' });
|
||||
|
||||
// Fresh editor: history=[empty], idx=0.
|
||||
// canUndo = 0 > 0 = false (we're at the floor)
|
||||
// canRedo = 0 < length-1=0 = false (nothing to redo)
|
||||
await expect(undoButton).toBeDisabled();
|
||||
await expect(redoButton).toBeDisabled();
|
||||
|
||||
// After one add: history=[empty, [A]], idx=1.
|
||||
// canUndo = 1 > 0 = true (can step back to empty)
|
||||
// canRedo = 1 < length-1=1 = false (we're at the head)
|
||||
await addTextViaSidebar(page, { text: 'First' });
|
||||
await expect(undoButton).toBeEnabled();
|
||||
await expect(redoButton).toBeDisabled();
|
||||
|
||||
// After two adds: history=[empty, [A], [A,B]], idx=2.
|
||||
// canUndo = 2 > 0 = true
|
||||
// canRedo = 2 < length-1=2 = false
|
||||
await addTextViaSidebar(page, { text: 'Second' });
|
||||
await expect(undoButton).toBeEnabled();
|
||||
await expect(redoButton).toBeDisabled();
|
||||
|
||||
// Click Undo: idx 2 → 1. Now we're mid-history with both
|
||||
// directions available.
|
||||
// canUndo = 1 > 0 = true
|
||||
// canRedo = 1 < length-1=2 = true (can re-apply the second add)
|
||||
await undoButton.click();
|
||||
await expect(undoButton).toBeEnabled();
|
||||
await expect(redoButton).toBeEnabled();
|
||||
|
||||
// Click Redo: idx 1 → 2. Back to post-two-adds state.
|
||||
await redoButton.click();
|
||||
await expect(undoButton).toBeEnabled();
|
||||
await expect(redoButton).toBeDisabled();
|
||||
|
||||
// Finally, undo twice to reach the floor and confirm undo
|
||||
// disables at idx=0. This is the assertion the first-add test
|
||||
// ABOVE used to (incorrectly) make against a one-add history;
|
||||
// the only way to actually get there is to wind all the way
|
||||
// back to the seeded empty floor.
|
||||
await undoButton.click(); // idx 2 → 1
|
||||
await undoButton.click(); // idx 1 → 0 (the floor)
|
||||
await expect(undoButton).toBeDisabled();
|
||||
await expect(redoButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('a new action after undo discards the redo stack', async ({ page }) => {
|
||||
// Standard editor behaviour: after undo, redo is available. But
|
||||
// a new edit branches history forward from the current idx,
|
||||
// discarding anything that was previously to the right of it.
|
||||
// This is the redo-stack truncation tested in
|
||||
// useDesignEditor.test.js at the hook level; here we confirm
|
||||
// it works end-to-end through the real keyboard handler.
|
||||
await addTextViaSidebar(page, { text: 'A' });
|
||||
await addTextViaSidebar(page, { text: 'B' });
|
||||
await pressUndo(page);
|
||||
// Now redo would restore "B".
|
||||
await expect(page.getByRole('button', { name: 'Redo' })).toBeEnabled();
|
||||
|
||||
// New branch: add a different element. This should truncate the
|
||||
// redo stack — "B" is gone from history.
|
||||
await addTextViaSidebar(page, { text: 'C' });
|
||||
await expect(page.getByRole('button', { name: 'Redo' })).toBeDisabled();
|
||||
|
||||
// Undoing now should land on the "just A" state, NOT "A + B".
|
||||
await pressUndo(page);
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'A' })).toBeVisible();
|
||||
// "B" should not be findable — it was truncated when we added C.
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'B' })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
// E2E: state persistence across page reloads.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// The persistence layer (src/utils/persistence.js + App.jsx's restore
|
||||
// effect) is responsible for keeping the user's work alive across
|
||||
// reloads. The hook-level tests cover the save/load round-trip; the
|
||||
// utility tests cover the JSON shape. What's NOT covered there: the
|
||||
// actual restore effect in App.jsx, the timing of replaceElements
|
||||
// + initializeHistory, and the StrictMode dual-mount we fixed on
|
||||
// May 23 (which only manifests in dev mode and was the motivation
|
||||
// for the replaceElements ID-preservation work).
|
||||
//
|
||||
// This spec drives a real reload and asserts state survives. It also
|
||||
// exercises the post-May-23 initializeHistory + replaceElements
|
||||
// pipeline end-to-end: if a future regression brings back the
|
||||
// blank-canvas-on-undo bug, the "undo doesn't blank the canvas after
|
||||
// reload" test below will catch it.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
gotoFreshEditor,
|
||||
addTextViaSidebar,
|
||||
getElementsCount,
|
||||
pressUndo,
|
||||
} from './fixtures/editor.js';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoFreshEditor(page);
|
||||
});
|
||||
|
||||
test('elements survive a page reload', async ({ page }) => {
|
||||
// The basic persistence promise: add elements, reload, find them
|
||||
// still there.
|
||||
await addTextViaSidebar(page, { text: 'Surviving text' });
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
// Reload. Note we use page.reload() rather than gotoFreshEditor —
|
||||
// we want to PRESERVE state across the reload, not wipe it. The
|
||||
// localStorage save happens via the auto-save effect in App.jsx.
|
||||
// That effect skips its first run; subsequent state changes
|
||||
// trigger savePersistedState. By the time we reload, the addText
|
||||
// change has been saved.
|
||||
await page.reload();
|
||||
|
||||
// Wait for the editor to remount + restore. We can't use
|
||||
// gotoFreshEditor's wait helpers directly; just wait for the
|
||||
// canvas-area and the restored layer to appear.
|
||||
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Surviving text' })).toBeVisible();
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
});
|
||||
|
||||
test('post-restore undo lands on the restored snapshot, not an empty canvas (May 22 regression)', async ({ page }) => {
|
||||
// The headline May 22 bug: after restoration, the FIRST user
|
||||
// action followed by Cmd+Z would walk past the restored snapshot
|
||||
// and land on an empty canvas. Root cause was initializeHistory
|
||||
// unconditionally wiping history to [empty], destroying the
|
||||
// snapshot replaceElements had pushed during restoration.
|
||||
//
|
||||
// Post-fix sequence:
|
||||
// 1. Add element (saved via auto-save).
|
||||
// 2. Reload — restoration runs, replaceElements pushes the
|
||||
// restored snapshot at idx 0, initializeHistory is a no-op.
|
||||
// 3. Add another element — idx 1 (the just-added).
|
||||
// 4. Cmd+Z — walks idx 1 → 0, restores the snapshot from
|
||||
// step 2 (one element), NOT an empty canvas.
|
||||
//
|
||||
// Pre-fix, step 4 would land on the empty canvas because the
|
||||
// floor at idx 0 was an empty array (wiped by initializeHistory).
|
||||
await addTextViaSidebar(page, { text: 'Restored on reload' });
|
||||
await page.reload();
|
||||
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
|
||||
|
||||
// Now add a second element. This is the "first user action after
|
||||
// restoration" the bug was triggered by.
|
||||
await addTextViaSidebar(page, { text: 'New action' });
|
||||
expect(await getElementsCount(page)).toBe(2);
|
||||
|
||||
// Cmd+Z: must land on the post-restore state (one element), not
|
||||
// empty. This is the assertion that would fail pre-fix.
|
||||
await pressUndo(page);
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'New action' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('multiple elements all survive a reload', async ({ page }) => {
|
||||
// Persistence isn't a single-element special case — the entire
|
||||
// elements array gets saved. We add several with distinct text
|
||||
// so we can verify each one survives by name.
|
||||
await addTextViaSidebar(page, { text: 'Alpha' });
|
||||
await addTextViaSidebar(page, { text: 'Bravo' });
|
||||
await addTextViaSidebar(page, { text: 'Charlie' });
|
||||
expect(await getElementsCount(page)).toBe(3);
|
||||
|
||||
await page.reload();
|
||||
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
||||
|
||||
// All three should be in the layers panel.
|
||||
expect(await getElementsCount(page)).toBe(3);
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Alpha' })).toBeVisible();
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Bravo' })).toBeVisible();
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Charlie' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shirt color preference survives a reload', async ({ page }) => {
|
||||
// Non-elements persistence: shirt color, size, recent colors,
|
||||
// etc. are also saved. Picking a non-default shirt color and
|
||||
// verifying it survives confirms the preferences slice of the
|
||||
// save shape works.
|
||||
//
|
||||
// Two UI quirks worth knowing for this test:
|
||||
//
|
||||
// 1. ShirtOptionsPanel is COLLAPSED by default — it shows only
|
||||
// the summary chip + price until the user clicks to expand.
|
||||
// The color radios live in the expanded content with
|
||||
// `tabIndex={expanded ? 0 : -1}` so they're not even
|
||||
// focusable when collapsed. We have to expand the panel
|
||||
// before driving the radios. The collapse state is
|
||||
// component-local and resets to collapsed on every fresh
|
||||
// mount, so we have to re-expand after the reload too.
|
||||
//
|
||||
// 2. The color picker uses `role="radio"` with `aria-label`
|
||||
// matching the bare color name (e.g. "Black") and uses
|
||||
// `aria-checked` for the selected indicator. Earlier I
|
||||
// guessed at `role="button"` + `aria-pressed`; both were
|
||||
// wrong. The constants/shirt.js label is the source of
|
||||
// truth for the accessible name.
|
||||
const optionsToggle = page.getByRole('button', { name: /shirt options/i });
|
||||
await optionsToggle.click();
|
||||
|
||||
const blackRadio = page.getByRole('radio', { name: 'Black' });
|
||||
await blackRadio.click();
|
||||
// Wait for the aria-checked update before reloading so we know
|
||||
// the click registered and the save effect has had a chance to
|
||||
// fire.
|
||||
await expect(blackRadio).toHaveAttribute('aria-checked', 'true');
|
||||
|
||||
await page.reload();
|
||||
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
||||
|
||||
// After reload the panel is collapsed again (state is
|
||||
// component-local). Expand to inspect the restored selection.
|
||||
await page.getByRole('button', { name: /shirt options/i }).click();
|
||||
await expect(page.getByRole('radio', { name: 'Black' })).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
// E2E: text element CRUD via the Text tab.
|
||||
//
|
||||
// What this guards
|
||||
// ────────────────
|
||||
// The most common editing flow in the app: open the Text tab, type a
|
||||
// message, add it to the canvas, optionally edit or delete it.
|
||||
// Vitest covers each piece (TextTab's local state, useDesignEditor's
|
||||
// addElement, LayersPanel rendering) but not the whole pipeline as
|
||||
// the user experiences it. This spec runs the actual integration.
|
||||
//
|
||||
// Why E2E rather than RTL component tests
|
||||
// ───────────────────────────────────────
|
||||
// TextTab is leaf-ish but composed with Sidebar, App's selectedElement
|
||||
// logic, and useDesignEditor — testing it in RTL would require either
|
||||
// mocking half the app (brittle) or rendering the real App with jsdom
|
||||
// (which then breaks on Konva, because jsdom doesn't have a real
|
||||
// canvas). Playwright drives the actual Chromium where everything
|
||||
// works, including Konva.
|
||||
//
|
||||
// Selector strategy
|
||||
// ─────────────────
|
||||
// We use accessible queries throughout — getByRole, getByLabel,
|
||||
// getByText. CSS class selectors would be tighter to the
|
||||
// implementation and break on any styling refactor; the accessible
|
||||
// roles describe what the user is looking at, which is stable across
|
||||
// styling changes. The cost is occasional verbosity (`getByRole('tab',
|
||||
// { name: 'Text' })` vs `.tab-text`) but the test stays valid as
|
||||
// styles iterate.
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
gotoFreshEditor,
|
||||
addTextViaSidebar,
|
||||
getElementsCount,
|
||||
} from './fixtures/editor.js';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Every test starts on the editor with empty state. See
|
||||
// gotoFreshEditor's docblock for the full reset sequence —
|
||||
// localStorage + service workers + caches all wiped.
|
||||
await gotoFreshEditor(page);
|
||||
});
|
||||
|
||||
test('starts with zero elements', async ({ page }) => {
|
||||
// Empty-state check. The LayersPanel renders a banner instead of
|
||||
// a title when there are no elements, so getElementsCount returns
|
||||
// 0 by checking for the title's absence.
|
||||
expect(await getElementsCount(page)).toBe(0);
|
||||
// The empty-state message is the canonical "no layers" indicator.
|
||||
// We assert it's visible to confirm we're really on an empty
|
||||
// editor — a partially-loaded editor without elements would also
|
||||
// have a 0 count but no empty-state banner.
|
||||
await expect(page.getByText('No elements yet')).toBeVisible();
|
||||
});
|
||||
|
||||
test('adds a text element via the sidebar and shows it in the Layers panel', async ({ page }) => {
|
||||
// The headline text-editing flow. addTextViaSidebar drives the
|
||||
// Text tab → fill textarea → click Add → wait for the layer count
|
||||
// to increment. After it returns, we make user-observable
|
||||
// assertions about what's on the canvas.
|
||||
await addTextViaSidebar(page, { text: 'Hello world' });
|
||||
|
||||
// Count is now 1.
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
|
||||
// The LayersPanel row for the new element. LayersPanel uses
|
||||
// `getName(el)` which returns the text content for text elements,
|
||||
// so "Hello world" should appear as the row label.
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Hello world' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('adding two text elements lists both in the Layers panel, newest on top', async ({ page }) => {
|
||||
// LayersPanel docblock: "Lists all elements on the canvas in
|
||||
// render order (last in array = topmost)." The panel renders
|
||||
// top-down with the most recently added on top, mirroring how
|
||||
// users mentally stack layers in design tools.
|
||||
await addTextViaSidebar(page, { text: 'First' });
|
||||
await addTextViaSidebar(page, { text: 'Second' });
|
||||
|
||||
expect(await getElementsCount(page)).toBe(2);
|
||||
|
||||
// Order check: read all row names and verify "Second" precedes
|
||||
// "First". allTextContents resolves to an array in DOM order;
|
||||
// for a flex column that's top-to-bottom.
|
||||
const names = await page.locator('.layers-item-name').allTextContents();
|
||||
expect(names.indexOf('Second')).toBeLessThan(names.indexOf('First'));
|
||||
});
|
||||
|
||||
test('editing the textarea after selecting a text element updates the layer name', async ({ page }) => {
|
||||
// Bidirectional editing: selecting an existing text element on
|
||||
// the canvas (or via the layers panel, which is what we do here
|
||||
// since we don't want to deal with canvas pixel coordinates)
|
||||
// should put the Text tab into edit mode against that element.
|
||||
// Typing into the textarea then flows back to update the element's
|
||||
// text on canvas AND in the layers panel.
|
||||
await addTextViaSidebar(page, { text: 'Original' });
|
||||
|
||||
// Select the layer's row to make it the active element.
|
||||
await page.locator('.layers-item-main', { hasText: 'Original' }).click();
|
||||
|
||||
// The Text tab auto-switches on selection (desktop auto-tab-switch
|
||||
// effect in App.jsx). The textarea should now contain the
|
||||
// selected element's text.
|
||||
const textarea = page.getByLabel('Your message');
|
||||
await expect(textarea).toHaveValue('Original');
|
||||
|
||||
// Edit it. fill() clears + types, mirroring a user selecting all
|
||||
// and re-typing.
|
||||
await textarea.fill('Updated');
|
||||
|
||||
// The layer name in the panel should reflect the new text. The
|
||||
// update is debounced (300ms in useDesignEditor.updateElement),
|
||||
// and the layer name reads from element.text directly, so it
|
||||
// shows the new value as soon as state propagates.
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Updated' })).toBeVisible();
|
||||
|
||||
// And the count is still 1 — editing doesn't add a new element.
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
});
|
||||
|
||||
test('deleting a text element from the Layers panel removes it', async ({ page }) => {
|
||||
await addTextViaSidebar(page, { text: 'Doomed' });
|
||||
await addTextViaSidebar(page, { text: 'Survivor' });
|
||||
|
||||
expect(await getElementsCount(page)).toBe(2);
|
||||
|
||||
// Each row has a trash-can delete button as a sibling of the
|
||||
// main row button. LayersPanel labels it
|
||||
// `aria-label="Delete ${getName(element)}"`, so we can target
|
||||
// the specific row's delete button by accessible name.
|
||||
await page.getByRole('button', { name: 'Delete Doomed' }).click();
|
||||
|
||||
expect(await getElementsCount(page)).toBe(1);
|
||||
// The remaining row is the one we didn't touch.
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Survivor' })).toBeVisible();
|
||||
// And the deleted row is gone.
|
||||
await expect(page.locator('.layers-item-name', { hasText: 'Doomed' })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
# Server-side font files
|
||||
|
||||
This directory is scanned by `server.js` at startup. Each `.ttf` or `.otf` file
|
||||
is registered with node-canvas via `registerFont()` so that exports render
|
||||
text in the same fonts the editor previews.
|
||||
|
||||
## Filename convention
|
||||
|
||||
```
|
||||
<Family_Name>-<Variant>.ttf
|
||||
```
|
||||
|
||||
- Spaces in family names become underscores in the filename so the file is
|
||||
portable across operating systems. The server replaces them back to spaces
|
||||
at registration time.
|
||||
- The variant is parsed for the substrings `Bold` and `Italic` to set the
|
||||
`weight` and `style` registration options. `Regular` (or anything else) is
|
||||
treated as the default weight.
|
||||
|
||||
Examples:
|
||||
| File | Family | Weight | Style |
|
||||
|---|---|---|---|
|
||||
| `Roboto-Regular.ttf` | Roboto | normal | normal |
|
||||
| `Roboto-Bold.ttf` | Roboto | bold | normal |
|
||||
| `DM_Sans-Regular.ttf` | DM Sans | normal | normal |
|
||||
| `Open_Sans-BoldItalic.ttf` | Open Sans | bold | italic |
|
||||
|
||||
## Populating this directory
|
||||
|
||||
```
|
||||
npm run fetch-fonts
|
||||
```
|
||||
|
||||
This downloads TTFs for the editor's font list from the Fontsource jsDelivr
|
||||
CDN. Re-running is a no-op for already-present files; pass `--force` to
|
||||
re-download.
|
||||
|
||||
## What if I leave it empty?
|
||||
|
||||
The server still starts and exports still work, but every text element will
|
||||
render in whatever fallback font the host system happens to have for the
|
||||
requested family. On a stock Alpine container that's a generic sans-serif. On
|
||||
macOS dev machines, system-installed fonts usually fill in for the common
|
||||
names but not the more specialized ones.
|
||||
2883
package-lock.json
generated
2883
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "apparel-designer",
|
||||
"version": "1.0.0",
|
||||
"description": "T-shirt customization editor with background removal, stickers, text, and export",
|
||||
"description": "Pawfectly Yours storefront — host application for the goods-editor module",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -11,42 +11,24 @@
|
||||
"start": "node server.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"fetch-fonts": "node scripts/fetch-fonts.mjs",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"e2e": "playwright test --ui",
|
||||
"e2e:run": "playwright test",
|
||||
"e2e:install": "playwright install chromium"
|
||||
"test": "echo 'Editor tests live in the goods-editor module. Run them from there.' && exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@huggingface/transformers": "^3.4.0",
|
||||
"canvas": "^3.1.0",
|
||||
"goods-editor": "git+ssh://git@git.kadil.dev/khalidadil/goods-editor-module.git#v0.1.0-alpha.2",
|
||||
"canvas": "^3.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"konva": "^10.0.0",
|
||||
"multer": "^2.0.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-filerobot-image-editor": "^4.8.1",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-select": "^5.8.0",
|
||||
"sharp": "^0.33.2",
|
||||
"styled-components": "^6.4.1",
|
||||
"use-image": "^1.1.1",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8"
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^8.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
@@ -54,10 +36,8 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"vite": "^8.0.9",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.0.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
72
package.monolith.json
Normal file
72
package.monolith.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "apparel-designer",
|
||||
"version": "1.0.0",
|
||||
"description": "T-shirt customization editor with background removal, stickers, text, and export",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite\" \"node --watch server.js\"",
|
||||
"dev:win": "concurrently \"vite\" \"node --watch server.js\"",
|
||||
"build": "vite build",
|
||||
"start": "node server.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"fetch-fonts": "node scripts/fetch-fonts.mjs",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"e2e": "playwright test --ui",
|
||||
"e2e:run": "playwright test",
|
||||
"e2e:install": "playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@huggingface/transformers": "^3.4.0",
|
||||
"canvas": "^3.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"konva": "^10.0.0",
|
||||
"multer": "^2.0.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-filerobot-image-editor": "^4.8.1",
|
||||
"react-konva": "^19.2.3",
|
||||
"react-select": "^5.8.0",
|
||||
"sharp": "^0.33.2",
|
||||
"styled-components": "^6.4.1",
|
||||
"use-image": "^1.1.1",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^8.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"vite": "^8.0.9",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^3.0.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"vite-plugin-pwa": {
|
||||
"vite": "$vite"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// Playwright configuration for end-to-end tests.
|
||||
//
|
||||
// What this covers vs. Vitest
|
||||
// ───────────────────────────
|
||||
// Vitest (in src/**/*.test.js) covers pure functions and the React
|
||||
// hook-state portion of useDesignEditor — anything testable without
|
||||
// mounting Konva. Playwright covers what Vitest can't: real Konva
|
||||
// stages, the actual keyboard handler attached to window, persistence
|
||||
// across reloads, the multi-component flows where state has to thread
|
||||
// through App.jsx, the Sidebar, the canvas, and back.
|
||||
//
|
||||
// Test layout
|
||||
// ───────────
|
||||
// /e2e/
|
||||
// fixtures/ ← shared setup helpers
|
||||
// text-editing.spec.js ← adding/editing/deleting text elements
|
||||
// history.spec.js ← undo/redo across operations
|
||||
// persistence.spec.js ← state survives page reload
|
||||
// crop.spec.js ← the headline May 22 bug — crop + Cmd-Z
|
||||
// must restore the uncropped image
|
||||
//
|
||||
// Webserver
|
||||
// ─────────
|
||||
// `npm run dev` runs Vite (3000) + the Node server (3001) via
|
||||
// concurrently. Playwright waits for the Vite URL to be reachable
|
||||
// before starting tests. The 120-second timeout accommodates cold
|
||||
// starts on first run (Vite optimizes deps, the Node server's
|
||||
// dependencies are heavy).
|
||||
//
|
||||
// `reuseExistingServer: !CI` means if a dev server is already running
|
||||
// locally, Playwright connects to that rather than starting a second
|
||||
// one (which would fail on the port collision). In CI we always start
|
||||
// a fresh one for hermetic runs.
|
||||
//
|
||||
// Browsers
|
||||
// ────────
|
||||
// Chromium only. The app's behaviour is engine-dependent in subtle
|
||||
// places (Konva canvas pixel rounding, native EyeDropper API is
|
||||
// Chromium-only), so Firefox/WebKit coverage would either flake or
|
||||
// have to skip large chunks of the suite. Chromium is what the
|
||||
// dominant user base will hit; adding more engines is a future
|
||||
// quality decision once Chromium is solid.
|
||||
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const PORT = 3000;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// The dev server is shared across tests in a file; running them in
|
||||
// parallel within a file would mean simultaneous localStorage
|
||||
// wipes and weird cross-test state. Within a file, tests run
|
||||
// sequentially (default). Across files, parallel is fine — each
|
||||
// file gets a fresh browser context.
|
||||
fullyParallel: true,
|
||||
// Hard fail in CI on .only(). Locally, .only() is a useful
|
||||
// workflow for iterating on a single failing test.
|
||||
forbidOnly: !!process.env.CI,
|
||||
// Retry once in CI for flakes (Konva mount timing, font load
|
||||
// races). Locally, no retries — a flake should fail and be
|
||||
// investigated, not papered over.
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
// CI: single worker for hermetic runs. Local: Playwright picks
|
||||
// based on cores (typically 50% of available, capped at 8).
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
// Capture trace on first retry so flaky tests in CI leave us
|
||||
// breadcrumbs (full DOM snapshot, network log, screenshots).
|
||||
// Tracing is expensive — we don't want it on every run.
|
||||
trace: 'on-first-retry',
|
||||
// Screenshot on failure for the same reason. The screenshot is
|
||||
// attached to the test report automatically.
|
||||
screenshot: 'only-on-failure',
|
||||
// Don't record video by default — produces large files and is
|
||||
// rarely the right tool. Enable per-test if needed.
|
||||
video: 'off',
|
||||
// Default timeout for individual actions (click, fill, etc.).
|
||||
// The app's UI is responsive; 10s is a generous ceiling for
|
||||
// anything that should be near-instant.
|
||||
actionTimeout: 10_000,
|
||||
// Navigation timeout — `goto`/reload waits up to 30s. Cold
|
||||
// first paint can be slow when Vite is also compiling deps.
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: BASE_URL,
|
||||
// 120s for cold starts (Vite optimizes deps, fonts load, etc).
|
||||
timeout: 120 * 1000,
|
||||
// Locally, if a dev server is already up, just connect to it.
|
||||
// In CI, always spin a fresh one.
|
||||
reuseExistingServer: !process.env.CI,
|
||||
// Pipe server output to stdout for debugging when tests fail —
|
||||
// a 500 from the API or a Vite compile error becomes visible
|
||||
// in the Playwright output rather than disappearing into the
|
||||
// background process.
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
168
scripts/cutover-host.sh
Normal file
168
scripts/cutover-host.sh
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
# Host cutover script: switch from monolithic to module-consuming.
|
||||
#
|
||||
# Run AFTER:
|
||||
# 1. ./scripts/migrate-to-module.sh has copied files to goods-editor-module/
|
||||
# 2. You've cd'd into goods-editor-module/, npm install'd, and npm run build'd
|
||||
#
|
||||
# What this does
|
||||
# ──────────────
|
||||
# 1. Renames the current monolithic files to *.monolith.* (kept for reference)
|
||||
# 2. Renames the *.host.* files to their canonical names
|
||||
# 3. Deletes the module-owned source from src/ (it now lives in the
|
||||
# module). The deletion is the actual cutover; until this runs, both
|
||||
# the inline copy and the module import would conflict.
|
||||
#
|
||||
# Run from the apparel-designer root:
|
||||
# bash scripts/cutover-host.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
ROOT_DIR="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "Cutting over apparel-designer to consume goods-editor module..."
|
||||
echo ""
|
||||
|
||||
# Step 1: Backup the current monolithic files.
|
||||
echo "→ Backing up monolithic files"
|
||||
[ -f src/App.jsx ] && mv src/App.jsx src/App.monolith.jsx
|
||||
[ -f server.js ] && mv server.js server.monolith.js
|
||||
[ -f package.json ] && mv package.json package.monolith.json
|
||||
echo " src/App.jsx → src/App.monolith.jsx"
|
||||
echo " server.js → server.monolith.js"
|
||||
echo " package.json → package.monolith.json"
|
||||
echo ""
|
||||
|
||||
# Step 2: Promote the *.host.* files to canonical names.
|
||||
echo "→ Promoting host files"
|
||||
[ -f src/App.host.jsx ] && mv src/App.host.jsx src/App.jsx
|
||||
[ -f server.host.js ] && mv server.host.js server.js
|
||||
[ -f package.host.json ] && mv package.host.json package.json
|
||||
echo " src/App.host.jsx → src/App.jsx"
|
||||
echo " server.host.js → server.js"
|
||||
echo " package.host.json → package.json"
|
||||
echo ""
|
||||
|
||||
# Step 3: Delete module-owned source.
|
||||
#
|
||||
# Per the architecture decisions, the host keeps:
|
||||
# • src/constants/shirt.js — host's shirt color/size catalog
|
||||
# • src/constants/templates.js — host's template definitions
|
||||
# • src/constants/products.js — host's product config
|
||||
# • src/styles/Header.css — styles the host's Header.jsx
|
||||
# • src/styles/PWAInstall.css — styles the host's PWAInstall.jsx
|
||||
#
|
||||
# These are referenced by host-owned components and the new
|
||||
# App.host.jsx. We delete the OTHER files but preserve these.
|
||||
|
||||
echo "→ Removing module-owned source directories from src/"
|
||||
DIRS_TO_DELETE=(
|
||||
"src/components/canvas"
|
||||
"src/components/sidebar"
|
||||
"src/components/panels"
|
||||
"src/components/editor"
|
||||
"src/hooks"
|
||||
"src/utils"
|
||||
"src/i18n"
|
||||
"src/test"
|
||||
)
|
||||
for dir in "${DIRS_TO_DELETE[@]}"; do
|
||||
if [ -d "$dir" ]; then
|
||||
rm -rf "$dir"
|
||||
echo " rm -rf $dir"
|
||||
fi
|
||||
done
|
||||
|
||||
# Module-owned constants files (delete individually). The host
|
||||
# keeps shirt.js, templates.js, and products.js — those carry
|
||||
# data App.host.jsx imports.
|
||||
MODULE_OWNED_CONSTANTS=(
|
||||
"src/constants/fonts.js"
|
||||
"src/constants/stickers.js"
|
||||
"src/constants/elements.js"
|
||||
"src/constants/imageFilters.js"
|
||||
"src/constants/textMetrics.js"
|
||||
"src/constants/transformer.js"
|
||||
)
|
||||
for file in "${MODULE_OWNED_CONSTANTS[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
echo " rm $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# Module-owned CSS files (delete individually). The host keeps
|
||||
# Header.css and PWAInstall.css — those style host-owned components.
|
||||
MODULE_OWNED_CSS=(
|
||||
"src/styles/CanvasHint.css"
|
||||
"src/styles/DesignCanvas.css"
|
||||
"src/styles/ElementToolbar.css"
|
||||
"src/styles/LayersPanel.css"
|
||||
"src/styles/MobileBottomSheet.css"
|
||||
"src/styles/ModelSilhouette.css"
|
||||
"src/styles/PhotoPreEditor.css"
|
||||
"src/styles/PreviewModal.css"
|
||||
"src/styles/PropertiesPanel.css"
|
||||
"src/styles/ShirtOptionsPanel.css"
|
||||
"src/styles/Sidebar.css"
|
||||
"src/styles/StickersTab.css"
|
||||
"src/styles/TShirtSVG.css"
|
||||
"src/styles/TemplatesTab.css"
|
||||
"src/styles/TextTab.css"
|
||||
"src/styles/UploadTab.css"
|
||||
"src/styles/ZoomControls.css"
|
||||
"src/styles/editor-shell.css"
|
||||
)
|
||||
for file in "${MODULE_OWNED_CSS[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
echo " rm $file"
|
||||
fi
|
||||
done
|
||||
|
||||
FILES_TO_DELETE=(
|
||||
"src/components/MobileBottomSheet.jsx"
|
||||
"src/App.css"
|
||||
)
|
||||
for file in "${FILES_TO_DELETE[@]}"; do
|
||||
if [ -f "$file" ]; then
|
||||
rm "$file"
|
||||
echo " rm $file"
|
||||
fi
|
||||
done
|
||||
|
||||
# E2E directory — tests live in the module now.
|
||||
if [ -d "e2e" ]; then
|
||||
rm -rf e2e
|
||||
echo " rm -rf e2e"
|
||||
fi
|
||||
[ -f "playwright.config.js" ] && rm playwright.config.js && echo " rm playwright.config.js"
|
||||
[ -f "vitest.config.js" ] && rm vitest.config.js && echo " rm vitest.config.js"
|
||||
|
||||
# Fonts moved to the module too. Note that `public/stickers/`
|
||||
# stays in place — the host's vite.config.js still has the
|
||||
# `stickerManifestPlugin` that reads from `public/stickers/`
|
||||
# and exposes the `virtual:sticker-manifest` import that
|
||||
# goods-editor/src/constants/stickers.js consumes. Don't delete
|
||||
# either the public/stickers/ directory OR the plugin from
|
||||
# vite.config.js.
|
||||
if [ -d "fonts" ]; then
|
||||
rm -rf fonts
|
||||
echo " rm -rf fonts"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Cutover complete. Next steps:"
|
||||
echo " 1. rm -rf node_modules package-lock.json"
|
||||
echo " 2. npm install"
|
||||
echo " 3. npm run dev"
|
||||
echo ""
|
||||
echo "If something's broken, restore the monolith with:"
|
||||
echo " mv src/App.monolith.jsx src/App.jsx"
|
||||
echo " mv server.monolith.js server.js"
|
||||
echo " mv package.monolith.json package.json"
|
||||
echo " # (Then npm install to restore old deps; src/ won't have the"
|
||||
echo " # module-owned files anymore, but you can restore those from"
|
||||
echo " # the goods-editor-module/ copy or from git.)"
|
||||
161
scripts/migrate-to-module.sh
Normal file
161
scripts/migrate-to-module.sh
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
# Migration script: copy files from apparel-designer into goods-editor-module.
|
||||
#
|
||||
# Why this exists
|
||||
# ───────────────
|
||||
# The module-extraction refactor moves ~80 files from an existing app
|
||||
# into a new module repo. Most files copy verbatim — no surgery needed.
|
||||
# Doing this via shell is dramatically faster than reading/writing each
|
||||
# file through an AI tool.
|
||||
#
|
||||
# What this script does
|
||||
# ─────────────────────
|
||||
# 1. Copies utils, hooks, constants, i18n, styles, test setup, scripts
|
||||
# 2. Copies the canvas / sidebar / panels / editor / mobile components
|
||||
# 3. Copies the fonts/ directory and public/stickers/
|
||||
# 4. Copies all test files (Vitest *.test.js + e2e/)
|
||||
# 5. Reports what was moved
|
||||
#
|
||||
# What this script does NOT do
|
||||
# ────────────────────────────
|
||||
# - It does not copy App.jsx, main.jsx, index.html (host-side files)
|
||||
# - It does not copy Header.jsx, PWAInstall.jsx, OfflineIndicator.jsx (host-side)
|
||||
# - It does not copy server.js (refactored separately into the module's
|
||||
# server/ directory)
|
||||
# - It does not run npm install or build anything
|
||||
# - It does not delete anything from the source — the apparel-designer
|
||||
# project is left intact until you've verified the module works.
|
||||
#
|
||||
# Run from the parent directory of both repos:
|
||||
# cd /Users/khalid/Documents/Claude-Desktop
|
||||
# bash apparel-designer/scripts/migrate-to-module.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve paths relative to this script's location so it doesn't matter
|
||||
# where you invoke it from.
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
SOURCE_DIR="$( cd "$SCRIPT_DIR/.." &> /dev/null && pwd )"
|
||||
PARENT_DIR="$( cd "$SOURCE_DIR/.." &> /dev/null && pwd )"
|
||||
DEST_DIR="$PARENT_DIR/goods-editor-module"
|
||||
|
||||
if [ ! -d "$DEST_DIR" ]; then
|
||||
echo "Error: goods-editor-module not found at $DEST_DIR"
|
||||
echo "Expected layout:"
|
||||
echo " $PARENT_DIR/"
|
||||
echo " apparel-designer/ (source, where this script lives)"
|
||||
echo " goods-editor-module/ (destination)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Source: $SOURCE_DIR"
|
||||
echo "Destination: $DEST_DIR"
|
||||
echo ""
|
||||
|
||||
# Helper: copy a directory's contents, preserving structure.
|
||||
# Usage: copy_dir <source-relative> <dest-relative>
|
||||
copy_dir() {
|
||||
local src="$SOURCE_DIR/$1"
|
||||
local dst="$DEST_DIR/$2"
|
||||
if [ ! -d "$src" ]; then
|
||||
echo " skip (missing): $1"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$dst"
|
||||
cp -R "$src/." "$dst/"
|
||||
local count
|
||||
count=$(find "$dst" -type f | wc -l | tr -d ' ')
|
||||
echo " $1 → $2 ($count files)"
|
||||
}
|
||||
|
||||
# Helper: copy a single file.
|
||||
# Usage: copy_file <source-relative> <dest-relative>
|
||||
copy_file() {
|
||||
local src="$SOURCE_DIR/$1"
|
||||
local dst="$DEST_DIR/$2"
|
||||
if [ ! -f "$src" ]; then
|
||||
echo " skip (missing): $1"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
cp "$src" "$dst"
|
||||
echo " $1 → $2"
|
||||
}
|
||||
|
||||
echo "=== Phase 1: Utilities, hooks, constants, i18n, styles ==="
|
||||
copy_dir "src/utils" "src/utils"
|
||||
copy_dir "src/hooks" "src/hooks"
|
||||
copy_dir "src/constants" "src/constants"
|
||||
copy_dir "src/i18n" "src/i18n"
|
||||
copy_dir "src/styles" "src/styles"
|
||||
copy_dir "src/test" "src/test"
|
||||
echo ""
|
||||
|
||||
echo "=== Phase 2: Canvas / sidebar / panels / editor components ==="
|
||||
copy_dir "src/components/canvas" "src/components/canvas"
|
||||
copy_dir "src/components/sidebar" "src/components/sidebar"
|
||||
copy_dir "src/components/panels" "src/components/panels"
|
||||
copy_dir "src/components/editor" "src/components/editor"
|
||||
copy_file "src/components/MobileBottomSheet.jsx" "src/components/MobileBottomSheet.jsx"
|
||||
echo ""
|
||||
|
||||
echo "=== Phase 3: App.css (canvas-area styles, etc.) ==="
|
||||
# App.css contains styles used by DesignCanvas / the canvas-area frame.
|
||||
# These are module concerns even though the file is named "App.css".
|
||||
# We copy it into src/styles/ with a clearer name.
|
||||
copy_file "src/App.css" "src/styles/editor-shell.css"
|
||||
echo ""
|
||||
|
||||
echo "=== Phase 4: Fonts and the font-fetch script ==="
|
||||
copy_dir "fonts" "fonts"
|
||||
if [ -f "$SOURCE_DIR/scripts/fetch-fonts.mjs" ]; then
|
||||
mkdir -p "$DEST_DIR/scripts"
|
||||
cp "$SOURCE_DIR/scripts/fetch-fonts.mjs" "$DEST_DIR/scripts/fetch-fonts.mjs"
|
||||
echo " scripts/fetch-fonts.mjs → scripts/fetch-fonts.mjs"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Phase 5: Default sticker library ==="
|
||||
# The module ships defaults; host can extend/replace via props.
|
||||
if [ -d "$SOURCE_DIR/public/stickers" ]; then
|
||||
mkdir -p "$DEST_DIR/src/assets/stickers"
|
||||
cp -R "$SOURCE_DIR/public/stickers/." "$DEST_DIR/src/assets/stickers/"
|
||||
count=$(find "$DEST_DIR/src/assets/stickers" -type f | wc -l | tr -d ' ')
|
||||
echo " public/stickers → src/assets/stickers ($count files)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Phase 6: Playwright E2E tests ==="
|
||||
if [ -d "$SOURCE_DIR/e2e" ]; then
|
||||
cp -R "$SOURCE_DIR/e2e" "$DEST_DIR/e2e"
|
||||
count=$(find "$DEST_DIR/e2e" -type f | wc -l | tr -d ' ')
|
||||
echo " e2e/ → e2e/ ($count files)"
|
||||
fi
|
||||
if [ -f "$SOURCE_DIR/playwright.config.js" ]; then
|
||||
cp "$SOURCE_DIR/playwright.config.js" "$DEST_DIR/playwright.config.js"
|
||||
echo " playwright.config.js → playwright.config.js"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Phase 7: ESLint config ==="
|
||||
if [ -f "$SOURCE_DIR/eslint.config.js" ]; then
|
||||
cp "$SOURCE_DIR/eslint.config.js" "$DEST_DIR/eslint.config.js"
|
||||
echo " eslint.config.js → eslint.config.js"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Done. Summary ==="
|
||||
echo ""
|
||||
echo "Files in $DEST_DIR:"
|
||||
find "$DEST_DIR/src" -type f 2>/dev/null | wc -l | xargs echo " src/ files:"
|
||||
find "$DEST_DIR/e2e" -type f 2>/dev/null | wc -l | xargs echo " e2e/ files:"
|
||||
find "$DEST_DIR/fonts" -type f 2>/dev/null | wc -l | xargs echo " fonts/ files:"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. cd $DEST_DIR"
|
||||
echo " 2. npm install"
|
||||
echo " 3. The surgical-new-files (index.js, ApparelDesigner.jsx, server,"
|
||||
echo " dev-host, release script, README) are already in place."
|
||||
echo " 4. npm run build # builds the library to dist/"
|
||||
echo " 5. npm run dev # runs the example dev host"
|
||||
echo ""
|
||||
1103
server.monolith.js
Normal file
1103
server.monolith.js
Normal file
File diff suppressed because it is too large
Load Diff
453
src/App.css
453
src/App.css
@@ -1,453 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.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;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
2277
src/App.jsx
2277
src/App.jsx
File diff suppressed because it is too large
Load Diff
2086
src/App.monolith.jsx
Normal file
2086
src/App.monolith.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,75 @@
|
||||
import '../styles/Header.css';
|
||||
import { t } from '../i18n/t';
|
||||
import '../styles/Header.css';
|
||||
|
||||
/**
|
||||
* Pawfectly Yours top bar.
|
||||
*
|
||||
* Layout:
|
||||
* [logo + tagline] [page title] [clear] [preview] [download] [Save] [Share] [cart]
|
||||
* [logo + tagline] [page title] [preview] [download] [Share] [cart]
|
||||
*
|
||||
* On mobile (<768px) collapses to:
|
||||
* [back arrow] [page title] [clear] [preview] [download] [menu]
|
||||
* [logo] [cart]
|
||||
*
|
||||
* 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.
|
||||
* What's here vs what's gone
|
||||
* ──────────────────────────
|
||||
* Buttons removed during the module-split cleanup (May 2026):
|
||||
*
|
||||
* • Save — was redundant with Download (both fired the same export
|
||||
* pipeline). The module's onSave callback prop on <ApparelDesigner>
|
||||
* remains as the right integration point for real "commit this
|
||||
* design" semantics when there's a backend to commit to.
|
||||
*
|
||||
* • Clear — clearing the canvas is editor state, not host chrome.
|
||||
* Re-introducing it requires the module to expose a clear()
|
||||
* method on its editor ref and a canClear callback so the host
|
||||
* can disable the button when there's nothing to clear. Until
|
||||
* that lands, removing the button is better than leaving a
|
||||
* non-functional one in the chrome.
|
||||
*
|
||||
* • Menu — opened the host's mobile bottom sheet, which now lives
|
||||
* inside the module. The module owns its own mobile UI; the
|
||||
* host had nothing left to put behind a menu.
|
||||
*
|
||||
* Preview / Download are still here because they're host-surfaced
|
||||
* controls that drive the editor via the editorRef.preview() and
|
||||
* editorRef.testDownload() imperative API. They're disabled during
|
||||
* an in-flight export (`isExporting`).
|
||||
*
|
||||
* i18n
|
||||
* ────
|
||||
* All visible text comes from the host's message catalog via t().
|
||||
* Catalog lives in src/i18n/messages.js — host-owned, separate from
|
||||
* the editor module's internal catalog.
|
||||
*/
|
||||
export function Header({
|
||||
cartCount = 0,
|
||||
onSave, onShare, onOpenCart, onOpenMobileMenu,
|
||||
onShare, onOpenCart,
|
||||
onTestDownload, isExporting = false,
|
||||
onClear, canClear = false,
|
||||
onPreview,
|
||||
}) {
|
||||
// App name is reused for both the logo and the aria-label, so we
|
||||
// resolve it once and interpolate into the aria-label below.
|
||||
const appName = t('header.app-name');
|
||||
|
||||
// Cart aria has two distinct keys for singular vs plural so the
|
||||
// entire phrase translates as a unit ("Cart, 1 item" / "Cart,
|
||||
// 3 items"). See pluralization note in messages.js.
|
||||
const cartAria = t(
|
||||
cartCount === 1 ? 'header.cart-aria.singular' : 'header.cart-aria.plural',
|
||||
{ count: cartCount },
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="ph-header" role="banner">
|
||||
<a className="ph-logo" href="#" onClick={(e) => e.preventDefault()} aria-label={`${t('header.app-name')} home`}>
|
||||
<a
|
||||
className="ph-logo"
|
||||
href="#"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
aria-label={t('header.logo-home-aria', { app: appName })}
|
||||
>
|
||||
<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__name">{appName}</span>
|
||||
<span className="ph-logo__tagline">{t('header.app-tagline')}</span>
|
||||
</span>
|
||||
</a>
|
||||
@@ -38,30 +79,10 @@ export function Header({
|
||||
</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.) */}
|
||||
* export is in flight. */}
|
||||
<button
|
||||
type="button"
|
||||
className="ph-iconbtn"
|
||||
@@ -76,12 +97,10 @@ export function Header({
|
||||
</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. */}
|
||||
{/* Download — dedicated print-test button. Same export pipeline
|
||||
* as Preview but auto-downloads instead of opening the modal.
|
||||
* Disabled while an export is in flight so rapid clicks don't
|
||||
* fire parallel POSTs. */}
|
||||
<button
|
||||
type="button"
|
||||
className="ph-iconbtn"
|
||||
@@ -97,13 +116,9 @@ export function Header({
|
||||
</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>
|
||||
|
||||
{/* Share — opens the OS share sheet (or copies URL to clipboard
|
||||
* as a fallback). Handler lives in App.jsx with access to the
|
||||
* toast surface for "link copied" / "copy failed" feedback. */}
|
||||
<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" />
|
||||
@@ -115,7 +130,7 @@ export function Header({
|
||||
<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' })}>
|
||||
<button type="button" className="ph-cart" onClick={onOpenCart} aria-label={cartAria}>
|
||||
<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" />
|
||||
@@ -123,19 +138,6 @@ export function Header({
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { t } from '../i18n/t';
|
||||
|
||||
export function OfflineIndicator() {
|
||||
const [isOffline, setIsOffline] = useState(!navigator.onLine);
|
||||
@@ -12,5 +13,5 @@ export function OfflineIndicator() {
|
||||
}, []);
|
||||
|
||||
if (!isOffline) return null;
|
||||
return <div className="offline-indicator"><span>⚠️</span><span>You're offline — changes are saved locally</span></div>;
|
||||
return <div className="offline-indicator"><span>⚠️</span><span>{t('offline.message')}</span></div>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||
import { t } from '../i18n/t';
|
||||
import '../styles/PWAInstall.css';
|
||||
|
||||
export function PWAInstall() {
|
||||
@@ -51,18 +52,18 @@ export function PWAInstall() {
|
||||
<>
|
||||
{showInstall && (
|
||||
<div className="pwa-install-banner">
|
||||
<p>Install Apparel Designer for offline access!</p>
|
||||
<p>{t('pwa.install-message')}</p>
|
||||
<div className="pwa-install-actions">
|
||||
<button onClick={handleInstall} className="install-btn">Install</button>
|
||||
<button onClick={() => setShowInstall(false)} className="dismiss-btn">Later</button>
|
||||
<button onClick={handleInstall} className="install-btn">{t('pwa.install')}</button>
|
||||
<button onClick={() => setShowInstall(false)} className="dismiss-btn">{t('pwa.dismiss')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{needRefresh && (
|
||||
<div className="pwa-update-banner">
|
||||
<span>🔄 New version available!</span>
|
||||
<button onClick={handleUpdate} className="refresh-btn">Refresh</button>
|
||||
<button onClick={() => setNeedRefresh(false)} className="close-btn">✕</button>
|
||||
<span>🔄 {t('pwa.update-message')}</span>
|
||||
<button onClick={handleUpdate} className="refresh-btn">{t('pwa.refresh')}</button>
|
||||
<button onClick={() => setNeedRefresh(false)} className="close-btn" aria-label={t('pwa.close')}>✕</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
82
src/components/Toast.jsx
Normal file
82
src/components/Toast.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { t } from '../i18n/t';
|
||||
import '../styles/Toast.css';
|
||||
|
||||
/**
|
||||
* Toast — host-level notification surface.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* The host occasionally needs to tell the user something: "link
|
||||
* copied", "upload failed", "add something to your design first."
|
||||
* Before the module split there was an in-App toast system that did
|
||||
* this; it got stripped along with the editor surface during the
|
||||
* split, and host operations have been silently throwing or
|
||||
* console.logging ever since. This restores the surface but scopes
|
||||
* it strictly to host concerns \u2014 the editor module has its own
|
||||
* internal toast catalog for editor concerns (crop blocked, etc.)
|
||||
* and that is deliberately not shared.
|
||||
*
|
||||
* Architecture
|
||||
* ────────────
|
||||
* State + scheduling live in App.jsx (showToast / dismissToast); this
|
||||
* file is just the renderer. App owns the state because:
|
||||
* \u2022 Multiple host components could need to toast eventually
|
||||
* (currently only App's own handlers do).
|
||||
* \u2022 Lifting matches the existing host pattern (cart, isExporting,
|
||||
* etc. all live in App) and avoids introducing a Context for a
|
||||
* surface this small.
|
||||
* \u2022 Timer lifecycle is co-located with the state it modifies,
|
||||
* which keeps the cancel-on-unmount + re-schedule-cancels-prior
|
||||
* semantics straightforward.
|
||||
*
|
||||
* If the toast surface grows (more callers, error boundaries that
|
||||
* need to surface from anywhere in the tree), refactor to a
|
||||
* ToastContext + useToast() hook. For now, prop-passing the
|
||||
* showToast callback to anything outside App that needs it is the
|
||||
* simpler path.
|
||||
*
|
||||
* Toast shape
|
||||
* ───────────
|
||||
* { message: string, kind: 'info' | 'success' | 'error', id: number }
|
||||
*
|
||||
* The `id` field is a freshly-stamped Date.now() per call \u2014 used as
|
||||
* a React key so that a new toast retriggers the fade-in animation
|
||||
* even when the previous toast is still on screen.
|
||||
*
|
||||
* Rendering null
|
||||
* ──────────────
|
||||
* Renders null when toast is null so the consumer can always include
|
||||
* <Toast toast={toast} onDismiss={dismissToast} /> in their JSX
|
||||
* without an outer conditional. Simpler caller, same DOM.
|
||||
*/
|
||||
export function Toast({ toast, onDismiss }) {
|
||||
if (!toast) return null;
|
||||
|
||||
return (
|
||||
// Wrap exists so the inner pill can use a fade-in animation that
|
||||
// includes a horizontal translateX(-50%) for centering \u2014 if the
|
||||
// animation were on the pill itself, the centering math would
|
||||
// collide with the keyframe transforms. The wrap holds the
|
||||
// positioning, the pill holds the visual chrome.
|
||||
<div className="app-toast-wrap" key={toast.id}>
|
||||
<div
|
||||
className={`app-toast app-toast--${toast.kind}`}
|
||||
role="status"
|
||||
// 'polite' so screen readers announce the message after the
|
||||
// current utterance finishes \u2014 'assertive' would interrupt,
|
||||
// which is too aggressive for "link copied" type messages.
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="app-toast__message">{toast.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="app-toast__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('toast.dismiss')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,996 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Konva from 'konva';
|
||||
|
||||
/**
|
||||
* Position / transform debug overlay.
|
||||
*
|
||||
* What this is
|
||||
* ────────────
|
||||
* A developer-only HUD that reports the selected element's live
|
||||
* coordinate state in a fixed bottom-left panel. Built originally
|
||||
* for diagnosing a rotation-pivot bug where bbox-clamping in
|
||||
* `constrainTransform` operated on a different coord system than
|
||||
* the wrapper bounds it was comparing against (stage vs design),
|
||||
* producing per-frame drift on rotation gestures near canvas edges.
|
||||
* The bug is fixed; the overlay is kept as a permanent diagnostic
|
||||
* surface since rotation/clamp/offset interactions are the most
|
||||
* likely re-failure area as the editor evolves and a future
|
||||
* regression will be much faster to diagnose if the readouts are
|
||||
* already wired up.
|
||||
*
|
||||
* What it shows
|
||||
* ─────────────
|
||||
* Four sections, all updated on requestAnimationFrame while mounted:
|
||||
*
|
||||
* • element (state) — React state for the selected element. The
|
||||
* numbers fed BACK into the next render. If state and node
|
||||
* disagree about position/rotation, the next paint will jump.
|
||||
*
|
||||
* • node (live konva) — the live Konva node's attrs. node.x/y is
|
||||
* the pivot position in layer-local coords (with always-center
|
||||
* offset, this equals element.x + W/2, element.y + H/2 by
|
||||
* construction). Offset should be glued to (W/2, H/2).
|
||||
*
|
||||
* • derived — the comparison section. `visual TL (konva)` is the
|
||||
* ground truth: where Konva's own getClientRect says the visible
|
||||
* top-left lands. `simple formula` is what unflipReportedXY
|
||||
* computes when committing position changes. `rot-aware` is
|
||||
* what the formula would be if you naively rotated the offset
|
||||
* vector before subtracting (kept around as a foil to remind
|
||||
* you the simple formula is the round-trip-correct one, even
|
||||
* though it doesn't match visual TL in any obvious way).
|
||||
*
|
||||
* • last gesture — summary of the most recent drag/transform. Most
|
||||
* useful field is `bound calls (N clamped)`: if N > 0 during a
|
||||
* gesture that should have been free, the wrapper clamp is
|
||||
* interfering and the user will see drift.
|
||||
*
|
||||
* Activation
|
||||
* ──────────
|
||||
* Two ways to turn it on, neither requiring a code edit:
|
||||
*
|
||||
* • URL: append `?debug=1` to the editor URL.
|
||||
* • LocalStorage: `localStorage.setItem('paw_debug_overlay', '1')`,
|
||||
* then reload.
|
||||
*
|
||||
* The URL path also persists to localStorage so subsequent reloads
|
||||
* keep it on without re-appending the param. The on-panel ×
|
||||
* button clears both and reloads.
|
||||
*
|
||||
* The entire overlay component is gated behind a `DEBUG_OVERLAY_ENABLED`
|
||||
* constant in App.jsx (read once at module load). When the flag is
|
||||
* off, the conditional render short-circuits and this file is dead
|
||||
* code from the user's perspective; the bundler will not tree-shake
|
||||
* the import itself, but the component never mounts and the only
|
||||
* runtime cost is the import being parsed.
|
||||
*
|
||||
* UX
|
||||
* ───
|
||||
* Fixed bottom-left, semi-transparent dark panel with mono-font
|
||||
* readouts. A small "×" closes it for the rest of the session
|
||||
* (clears the URL param via history.replaceState and the
|
||||
* localStorage key, then reloads). A "copy" button next to the
|
||||
* close emits a markdown-formatted snapshot to the clipboard for
|
||||
* paste-into-chat diagnostics. The panel doesn't intercept canvas
|
||||
* pointer events outside its own box, so it never gets in the way
|
||||
* of an interaction.
|
||||
*
|
||||
* Performance
|
||||
* ──────────
|
||||
* Polls the Konva node every animation frame while mounted. That's
|
||||
* fine for a developer overlay — adds one rAF per frame on top of
|
||||
* Konva's own rendering, well below the budget — but it's the reason
|
||||
* the component is opt-in. We deliberately don't subscribe to Konva
|
||||
* events for the rAF poll, because we want the readouts to update
|
||||
* during continuous transforms, when no React state changes are
|
||||
* firing. (Gesture event subscriptions DO exist, separately, to
|
||||
* capture transform start/move/end snapshots for the "last gesture"
|
||||
* summary.)
|
||||
*/
|
||||
export function DebugOverlay({ element, stageContainerRef }) {
|
||||
// Live readouts. Updated on each rAF tick while mounted; null when
|
||||
// there's no element selected or no node found.
|
||||
const [readout, setReadout] = useState(null);
|
||||
const rafRef = useRef(null);
|
||||
|
||||
// Gesture-tracking state. Captured by listeners on the Konva node's
|
||||
// transformstart / transform / transformend events. Refs (not
|
||||
// state) because we want the per-frame `transform` handler to be
|
||||
// allocation-light — only the React state we render from updates
|
||||
// when there's meaningful new info.
|
||||
//
|
||||
// Shape:
|
||||
// gestureRef.current = {
|
||||
// startedAt: Date.now() at transformstart
|
||||
// startState: { element snapshot, node snapshot, visual TL }
|
||||
// firstFrame: same shape, captured on FIRST transform event
|
||||
// (i.e. the very first frame after start)
|
||||
// lastFrame: same shape, captured on EVERY transform event
|
||||
// endState: captured on transformend
|
||||
// boundRejects: number of times constrainTransform returned
|
||||
// oldBox during this gesture (i.e. clamped)
|
||||
// boundCalls: total number of constrainTransform calls
|
||||
// }
|
||||
//
|
||||
// The overlay displays the latest completed gesture's delta plus a
|
||||
// running count of bound-rejects on the active gesture. That's
|
||||
// enough to distinguish "first-frame jump" from "clamp-induced
|
||||
// jump" from "transform-end round-trip jump".
|
||||
const gestureRef = useRef(null);
|
||||
const [gestureSummary, setGestureSummary] = useState(null);
|
||||
|
||||
// Hook the active Konva node's transform events. Re-runs whenever
|
||||
// the selected element changes (so we re-attach to the new node).
|
||||
// We use a separate effect from the rAF poll so the event
|
||||
// subscription doesn't churn when the rAF restarts.
|
||||
useEffect(() => {
|
||||
if (!element) return undefined;
|
||||
|
||||
const stageContainer = stageContainerRef?.current;
|
||||
if (!stageContainer) return undefined;
|
||||
|
||||
// Resolve the node. If it isn't mounted yet (e.g. a freshly-added
|
||||
// element that hasn't hit Konva's render pass), retry on the next
|
||||
// frame. The retry loop is bounded by the effect's cleanup.
|
||||
let cancelled = false;
|
||||
let retryRaf = null;
|
||||
let attachedNode = null;
|
||||
|
||||
const snapshot = (node) => {
|
||||
// Best-effort snapshot of everything we care about. Returns null
|
||||
// if the node has vanished (e.g. element deleted mid-gesture);
|
||||
// callers tolerate null.
|
||||
if (!node) return null;
|
||||
const layer = node.getLayer?.();
|
||||
let clientRect = null;
|
||||
try {
|
||||
clientRect = layer
|
||||
? node.getClientRect({ relativeTo: layer })
|
||||
: node.getClientRect();
|
||||
} catch { /* ignore */ }
|
||||
return {
|
||||
t: Date.now(),
|
||||
elementX: element.x ?? 0,
|
||||
elementY: element.y ?? 0,
|
||||
elementRotation: element.rotation ?? 0,
|
||||
nodeX: node.x(),
|
||||
nodeY: node.y(),
|
||||
nodeRotation: node.rotation(),
|
||||
offsetX: node.offsetX(),
|
||||
offsetY: node.offsetY(),
|
||||
visualX: clientRect?.x,
|
||||
visualY: clientRect?.y,
|
||||
visualW: clientRect?.width,
|
||||
visualH: clientRect?.height,
|
||||
};
|
||||
};
|
||||
|
||||
const onStart = () => {
|
||||
gestureRef.current = {
|
||||
startedAt: Date.now(),
|
||||
startState: snapshot(attachedNode),
|
||||
firstFrame: null,
|
||||
lastFrame: null,
|
||||
endState: null,
|
||||
boundRejects: 0,
|
||||
boundCalls: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const onTransform = () => {
|
||||
const g = gestureRef.current;
|
||||
if (!g) return;
|
||||
const snap = snapshot(attachedNode);
|
||||
if (!g.firstFrame) g.firstFrame = snap;
|
||||
g.lastFrame = snap;
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
const g = gestureRef.current;
|
||||
if (!g) return;
|
||||
g.endState = snapshot(attachedNode);
|
||||
// Materialize a frozen summary for the overlay to render. The
|
||||
// ref-tracked gestureRef gets reset on the next gesture's start.
|
||||
setGestureSummary({
|
||||
durationMs: g.endState ? g.endState.t - g.startedAt : null,
|
||||
boundCalls: g.boundCalls,
|
||||
boundRejects: g.boundRejects,
|
||||
startState: g.startState,
|
||||
firstFrame: g.firstFrame,
|
||||
endState: g.endState,
|
||||
});
|
||||
};
|
||||
|
||||
const tryAttach = () => {
|
||||
if (cancelled) return;
|
||||
const stage = (() => {
|
||||
if (Konva && Array.isArray(Konva.stages)) {
|
||||
for (const s of Konva.stages) {
|
||||
try { if (s.container() === stageContainer) return s; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const node = stage?.findOne('.' + element.id);
|
||||
if (!node) {
|
||||
retryRaf = requestAnimationFrame(tryAttach);
|
||||
return;
|
||||
}
|
||||
attachedNode = node;
|
||||
// Namespace events with .pawdbg so we can off() them cleanly
|
||||
// without disturbing anything else Konva or the app has
|
||||
// attached.
|
||||
node.on('transformstart.pawdbg', onStart);
|
||||
node.on('transform.pawdbg', onTransform);
|
||||
node.on('transformend.pawdbg', onEnd);
|
||||
// Drag events too — the same "jump" symptom could appear on
|
||||
// drag if the bound function clamps an early frame.
|
||||
node.on('dragstart.pawdbg', onStart);
|
||||
node.on('dragmove.pawdbg', onTransform);
|
||||
node.on('dragend.pawdbg', onEnd);
|
||||
};
|
||||
tryAttach();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryRaf != null) cancelAnimationFrame(retryRaf);
|
||||
if (attachedNode) {
|
||||
try {
|
||||
attachedNode.off('transformstart.pawdbg');
|
||||
attachedNode.off('transform.pawdbg');
|
||||
attachedNode.off('transformend.pawdbg');
|
||||
attachedNode.off('dragstart.pawdbg');
|
||||
attachedNode.off('dragmove.pawdbg');
|
||||
attachedNode.off('dragend.pawdbg');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
}, [element, stageContainerRef]);
|
||||
|
||||
// Bound-function instrumentation. We expose a global hook the
|
||||
// production canvasDragBound / constrainTransform can call into IF
|
||||
// the debug overlay has set window.__pawDebugBound. DesignCanvas
|
||||
// checks the global before each bound-call increment, so the
|
||||
// instrumentation is a no-op when the overlay isn't mounted.
|
||||
//
|
||||
// The hook surface is intentionally minimal: a single boolean
|
||||
// `rejected` argument indicating whether constrainTransform
|
||||
// modified or reverted the user-proposed box. Per-call payloads
|
||||
// were tried during the original diagnostic session but added
|
||||
// allocation pressure to the bound hot path with little additional
|
||||
// diagnostic value over the count-of-clamps. If a future
|
||||
// investigation needs more detail it can be re-added — the
|
||||
// existing hook is a stable insertion point.
|
||||
//
|
||||
// Why a global rather than React context: the bound functions live
|
||||
// inside DesignCanvas's useCallback with an empty dep array (Konva
|
||||
// binds them by identity at mount time, and changing identity would
|
||||
// force a re-attach). Reading the latest hook ref through a global
|
||||
// avoids both the re-bind cost and the gymnastics of passing a ref
|
||||
// through props.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
window.__pawDebugBound = {
|
||||
noteBoundCall: (rejected) => {
|
||||
const g = gestureRef.current;
|
||||
if (!g) return;
|
||||
g.boundCalls += 1;
|
||||
if (rejected) g.boundRejects += 1;
|
||||
},
|
||||
};
|
||||
return () => {
|
||||
delete window.__pawDebugBound;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
// Resolve the Konva stage owning our container. Same pattern used
|
||||
// by TextEditAffordance and the crop handler in App.jsx.
|
||||
const resolveStage = () => {
|
||||
const container = stageContainerRef?.current;
|
||||
if (!container) return null;
|
||||
if (Konva && Array.isArray(Konva.stages)) {
|
||||
for (const s of Konva.stages) {
|
||||
try { if (s.container() === container) return s; } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
const canvas = container.querySelector?.('canvas');
|
||||
return canvas?._konvaNode?.getStage?.() ?? null;
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (!element) {
|
||||
setReadout(null);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
const stage = resolveStage();
|
||||
const node = stage?.findOne('.' + element.id);
|
||||
|
||||
if (!node) {
|
||||
// No node — but we still have an element. Element data alone
|
||||
// is useful; mark the node-derived fields as missing.
|
||||
setReadout({
|
||||
elementId: element.id,
|
||||
element: pickElementFields(element),
|
||||
node: null,
|
||||
derived: null,
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
// Node-reported values. All of these come straight from Konva's
|
||||
// attrs — node.x() / .y() / .rotation() / .offsetX() / .offsetY()
|
||||
// / .scaleX() / .scaleY() / .width() / .height() are accessors.
|
||||
const nodeX = node.x();
|
||||
const nodeY = node.y();
|
||||
const nodeW = node.width?.() ?? 0;
|
||||
const nodeH = node.height?.() ?? 0;
|
||||
const rotDeg = node.rotation();
|
||||
const offsetX = node.offsetX();
|
||||
const offsetY = node.offsetY();
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
|
||||
// Stage-coord visual AABB. Definitive "where is this element on
|
||||
// screen" — Konva computes this from the full transform chain.
|
||||
//
|
||||
// IMPORTANT: relativeTo here is the NODE'S OWN LAYER, not the
|
||||
// stage. The layer is offset by LAYER_OFFSET_X/Y in stage coords
|
||||
// (set in DesignCanvas via `<Layer x={LAYER_OFFSET_X} ... />`),
|
||||
// while node.x() reports layer-LOCAL coords. Asking for the
|
||||
// client rect relative to the stage would add the layer offset
|
||||
// back in and we'd be comparing two different coord systems.
|
||||
// relativeTo the layer keeps everything in the same
|
||||
// (~design-coord) space as node.x/y, snap bbox attrs, and
|
||||
// element.x/y.
|
||||
let clientRect = null;
|
||||
try {
|
||||
const layer = node.getLayer?.();
|
||||
clientRect = layer
|
||||
? node.getClientRect({ relativeTo: layer })
|
||||
: node.getClientRect();
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Derived: what the CURRENT unflipReportedXY would write back
|
||||
// to element.x/y if a drag/transform ended right now. The
|
||||
// production code path is unflipReportedXY(node, width, height,
|
||||
// flipX, flipY); since the helper now ignores flip flags, this
|
||||
// simplifies to (nodeX - W/2, nodeY - H/2) using whichever width
|
||||
// the onTransformEnd handler reads (the freshly-computed
|
||||
// newWidth, not node.width()).
|
||||
//
|
||||
// For diagnostic purposes we report the SIMPLE formula's output
|
||||
// using element.width as the size source (matches the rotation
|
||||
// case which doesn't change W/H, just the rotation angle).
|
||||
const simpleReportedX = nodeX - (element.width ?? nodeW) / 2;
|
||||
const simpleReportedY = nodeY - (element.height ?? nodeH) / 2;
|
||||
|
||||
// CORRECT formula: rotate the offset vector by the node's
|
||||
// rotation before subtracting. For a node with offsetX/Y at
|
||||
// (W/2, H/2) and rotation R, the visual top-left in stage
|
||||
// coords is:
|
||||
// visualX = nodeX - (W/2)·cos(R) + (H/2)·sin(R)
|
||||
// visualY = nodeY - (W/2)·sin(R) - (H/2)·cos(R)
|
||||
// Equivalent to: rotate the local offset point (W/2, H/2)
|
||||
// by R, then subtract from nodeX/Y to get where (0,0) in
|
||||
// local space lands. That (0,0) is the visual top-left of
|
||||
// an axis-aligned bbox spanning (0,0) → (W,H) in local space.
|
||||
//
|
||||
// The clientRect.x/y values above are what Konva itself says
|
||||
// the visual top-left is, so if our formula is right these
|
||||
// two should match to within float noise. Comparing them is
|
||||
// the single most useful check for "is the rotation-aware
|
||||
// formula correct?"
|
||||
const rad = (rotDeg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const W = element.width ?? nodeW;
|
||||
const H = element.height ?? nodeH;
|
||||
const correctReportedX = nodeX - (W / 2) * cos + (H / 2) * sin;
|
||||
const correctReportedY = nodeY - (W / 2) * sin - (H / 2) * cos;
|
||||
|
||||
// Round-trip predictions: if we write {simpleReportedX} into
|
||||
// element.x, next render will draw the node at renderX =
|
||||
// element.x + W/2 = nodeX. So the node's position is
|
||||
// preserved — but its bbox is NOT, because the rotation pivot
|
||||
// moved between the old and new state. The visible top-left
|
||||
// jumps by (correctReportedX - simpleReportedX, ...) on the
|
||||
// next paint. That delta is what the user sees as "jumping."
|
||||
const simpleDeltaX = simpleReportedX - correctReportedX;
|
||||
const simpleDeltaY = simpleReportedY - correctReportedY;
|
||||
const predictedJumpPx = Math.hypot(simpleDeltaX, simpleDeltaY);
|
||||
|
||||
setReadout({
|
||||
elementId: element.id,
|
||||
element: pickElementFields(element),
|
||||
node: {
|
||||
x: nodeX,
|
||||
y: nodeY,
|
||||
width: nodeW,
|
||||
height: nodeH,
|
||||
rotation: rotDeg,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
},
|
||||
derived: {
|
||||
clientRect,
|
||||
simpleReportedX,
|
||||
simpleReportedY,
|
||||
correctReportedX,
|
||||
correctReportedY,
|
||||
simpleDeltaX,
|
||||
simpleDeltaY,
|
||||
predictedJumpPx,
|
||||
},
|
||||
});
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [element, stageContainerRef]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
try {
|
||||
localStorage.removeItem('paw_debug_overlay');
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('debug');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch { /* ignore */ }
|
||||
// Force a reload so the App-level gate re-evaluates and unmounts
|
||||
// this component cleanly. Simpler than threading a callback up.
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// "Just copied" flash state for the copy button. Flips true on a
|
||||
// successful clipboard write, back to false ~1.2s later via a
|
||||
// timeout. The button reads its label from this so the user sees
|
||||
// immediate feedback that the click landed — clipboard writes are
|
||||
// invisible otherwise. Stored as state (not a ref) so the label
|
||||
// re-renders.
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef(null);
|
||||
useEffect(() => () => {
|
||||
// Cleanup on unmount: the timeout would harmlessly fire after
|
||||
// unmount and setState into a stale tree if we didn't clear it.
|
||||
if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
|
||||
}, []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
// Build the markdown payload from the current readout +
|
||||
// gestureSummary. Both can be null (e.g. nothing selected, or no
|
||||
// gesture has happened yet); the formatter tolerates that and
|
||||
// omits sections rather than emitting empty tables.
|
||||
const text = formatReadoutForClipboard(readout, gestureSummary);
|
||||
if (!text) return;
|
||||
|
||||
// Two-tier write: modern Clipboard API first, falling back to the
|
||||
// legacy execCommand path if the modern one is unavailable (older
|
||||
// browsers, insecure contexts, some embedded webviews). The
|
||||
// fallback uses a hidden textarea + selectAll + execCommand which
|
||||
// is the documented compat technique. Either way we flip the
|
||||
// "just copied" flash on success.
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
}
|
||||
} catch { /* fall through to fallback */ }
|
||||
|
||||
if (!ok) {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
setJustCopied(true);
|
||||
if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current);
|
||||
copyTimeoutRef.current = setTimeout(() => setJustCopied(false), 1200);
|
||||
}
|
||||
};
|
||||
|
||||
// Panel styles inline. The overlay is a developer tool; not worth
|
||||
// adding a CSS file we'd want to delete when the bug is fixed.
|
||||
const panelStyle = {
|
||||
position: 'fixed',
|
||||
left: '0.5rem',
|
||||
bottom: '0.5rem',
|
||||
zIndex: 9999,
|
||||
background: 'rgba(15, 23, 42, 0.92)',
|
||||
color: '#e2e8f0',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '11px',
|
||||
lineHeight: 1.45,
|
||||
padding: '0.6rem 0.75rem',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '320px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.35)',
|
||||
pointerEvents: 'auto',
|
||||
userSelect: 'text',
|
||||
};
|
||||
|
||||
const headerStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '0.4rem',
|
||||
fontWeight: 600,
|
||||
color: '#f59e0b',
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
const dismissBtnStyle = {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
};
|
||||
|
||||
// Copy button. Slightly larger hit target than the dismiss button
|
||||
// because it's the action the user reaches for most often during
|
||||
// a debug session, and uses a subtle pink tint when not flashing
|
||||
// so it reads as the primary affordance on this panel. When
|
||||
// `justCopied` is true, the button briefly swaps its label to
|
||||
// "copied!" with a green tint as confirmation.
|
||||
const copyBtnStyle = {
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(148, 163, 184, 0.35)',
|
||||
color: justCopied ? '#86efac' : '#cbd5e1',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
cursor: 'pointer',
|
||||
padding: '0.15rem 0.4rem',
|
||||
borderRadius: '4px',
|
||||
lineHeight: 1.3,
|
||||
transition: 'color 0.15s ease, border-color 0.15s ease',
|
||||
borderColor: justCopied ? 'rgba(134, 239, 172, 0.55)' : 'rgba(148, 163, 184, 0.35)',
|
||||
};
|
||||
|
||||
const headerActionsStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const sectionStyle = {
|
||||
borderTop: '1px solid rgba(148, 163, 184, 0.18)',
|
||||
paddingTop: '0.35rem',
|
||||
marginTop: '0.35rem',
|
||||
};
|
||||
|
||||
const sectionTitle = {
|
||||
color: '#94a3b8',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
marginBottom: '0.15rem',
|
||||
};
|
||||
|
||||
const highlightDelta = (val) => {
|
||||
// Color-code the delta so a glance tells you whether it's a real
|
||||
// discrepancy or float noise. Threshold at 0.1px — anything below
|
||||
// that is rounding, anything above is the bug.
|
||||
const abs = Math.abs(val);
|
||||
if (abs < 0.1) return '#94a3b8';
|
||||
if (abs < 2) return '#fbbf24';
|
||||
return '#f87171';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={panelStyle} role="region" aria-label="Debug overlay">
|
||||
<div style={headerStyle}>
|
||||
<span>🐛 element debug</span>
|
||||
<div style={headerActionsStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
style={copyBtnStyle}
|
||||
aria-label="Copy debug data to clipboard"
|
||||
title="Copy as markdown"
|
||||
>
|
||||
{justCopied ? 'copied!' : 'copy'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
style={dismissBtnStyle}
|
||||
aria-label="Close debug overlay"
|
||||
title="Close (clears flag + reloads)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readout && (
|
||||
<div style={{ color: '#94a3b8' }}>No element selected.</div>
|
||||
)}
|
||||
|
||||
{readout && (
|
||||
<>
|
||||
<div>
|
||||
<span style={{ color: '#94a3b8' }}>id: </span>
|
||||
<span>{readout.elementId.slice(0, 12)}</span>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitle}>element (state)</div>
|
||||
<Row label="x" value={readout.element.x} />
|
||||
<Row label="y" value={readout.element.y} />
|
||||
<Row label="w" value={readout.element.width} />
|
||||
<Row label="h" value={readout.element.height} />
|
||||
<Row label="rot°" value={readout.element.rotation} />
|
||||
<Row label="flip" value={`${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'}`} mono />
|
||||
</div>
|
||||
|
||||
{readout.node && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitle}>node (live konva)</div>
|
||||
<Row label="x" value={readout.node.x} />
|
||||
<Row label="y" value={readout.node.y} />
|
||||
<Row label="off" value={`${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)}`} mono />
|
||||
<Row label="scale" value={`${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)}`} mono />
|
||||
<Row label="rot°" value={readout.node.rotation} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{readout.derived && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitle}>derived</div>
|
||||
{readout.derived.clientRect && (
|
||||
<Row
|
||||
label="visual TL (konva)"
|
||||
value={`${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)}`}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="simple formula"
|
||||
value={`${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)}`}
|
||||
mono
|
||||
hint="node.x - W/2 / node.y - H/2"
|
||||
/>
|
||||
<Row
|
||||
label="rot-aware"
|
||||
value={`${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)}`}
|
||||
mono
|
||||
hint="subtracts rotated offset vector"
|
||||
/>
|
||||
<Row
|
||||
label="Δ x,y"
|
||||
value={`${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)}`}
|
||||
mono
|
||||
color={highlightDelta(readout.derived.predictedJumpPx)}
|
||||
/>
|
||||
<Row
|
||||
label="predicted jump"
|
||||
value={`${fmt(readout.derived.predictedJumpPx)}px`}
|
||||
mono
|
||||
color={highlightDelta(readout.derived.predictedJumpPx)}
|
||||
hint="how far the bbox will visibly jump on transform-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gestureSummary && (
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionTitle}>last gesture</div>
|
||||
<Row
|
||||
label="duration"
|
||||
value={gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'}
|
||||
mono
|
||||
/>
|
||||
<Row
|
||||
label="bound calls"
|
||||
value={`${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped)`}
|
||||
mono
|
||||
color={gestureSummary.boundRejects > 0 ? '#fbbf24' : undefined}
|
||||
hint="how many times canvasDragBound/constrainTransform fired; clamped = returned oldBox or a slid box"
|
||||
/>
|
||||
{gestureSummary.startState && gestureSummary.firstFrame && (
|
||||
<>
|
||||
<Row
|
||||
label="first-frame Δ visual"
|
||||
value={fmtDeltaPair(
|
||||
gestureSummary.firstFrame.visualX,
|
||||
gestureSummary.startState.visualX,
|
||||
gestureSummary.firstFrame.visualY,
|
||||
gestureSummary.startState.visualY,
|
||||
)}
|
||||
mono
|
||||
color={highlightDelta(
|
||||
hypotPair(
|
||||
gestureSummary.firstFrame.visualX,
|
||||
gestureSummary.startState.visualX,
|
||||
gestureSummary.firstFrame.visualY,
|
||||
gestureSummary.startState.visualY,
|
||||
),
|
||||
)}
|
||||
hint="how far visual TL moved between gesture-start and FIRST frame; big = jump on grab"
|
||||
/>
|
||||
<Row
|
||||
label="first-frame Δ rot"
|
||||
value={fmtDelta(
|
||||
gestureSummary.firstFrame.nodeRotation,
|
||||
gestureSummary.startState.nodeRotation,
|
||||
)}
|
||||
mono
|
||||
hint="how much rotation changed between gesture-start and FIRST frame"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{gestureSummary.startState && gestureSummary.endState && (
|
||||
<>
|
||||
<Row
|
||||
label="total Δ visual"
|
||||
value={fmtDeltaPair(
|
||||
gestureSummary.endState.visualX,
|
||||
gestureSummary.startState.visualX,
|
||||
gestureSummary.endState.visualY,
|
||||
gestureSummary.startState.visualY,
|
||||
)}
|
||||
mono
|
||||
hint="visual TL drift from start to end of gesture"
|
||||
/>
|
||||
<Row
|
||||
label="total Δ rot"
|
||||
value={fmtDelta(
|
||||
gestureSummary.endState.nodeRotation,
|
||||
gestureSummary.startState.nodeRotation,
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Helpers ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function pickElementFields(el) {
|
||||
return {
|
||||
x: el.x ?? 0,
|
||||
y: el.y ?? 0,
|
||||
width: el.width ?? el.fontSize ?? 0,
|
||||
height: el.height ?? el.fontSize ?? 0,
|
||||
rotation: el.rotation ?? 0,
|
||||
flipX: !!el.flipX,
|
||||
flipY: !!el.flipY,
|
||||
};
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
if (n == null || Number.isNaN(n)) return '—';
|
||||
// Two decimal places — enough resolution to spot sub-pixel drift
|
||||
// without making numbers visually noisy. Negative zero collapses
|
||||
// to plain "0.00".
|
||||
const v = Math.round(n * 100) / 100;
|
||||
return Object.is(v, -0) ? '0.00' : v.toFixed(2);
|
||||
}
|
||||
|
||||
function fmtDelta(after, before) {
|
||||
if (after == null || before == null) return '—';
|
||||
const d = after - before;
|
||||
const sign = d > 0 ? '+' : '';
|
||||
return `${sign}${fmt(d)}`;
|
||||
}
|
||||
|
||||
function fmtDeltaPair(ax, bx, ay, by) {
|
||||
return `${fmtDelta(ax, bx)}, ${fmtDelta(ay, by)}`;
|
||||
}
|
||||
|
||||
function hypotPair(ax, bx, ay, by) {
|
||||
if (ax == null || bx == null || ay == null || by == null) return 0;
|
||||
return Math.hypot(ax - bx, ay - by);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the current readout + gesture summary into a markdown
|
||||
* payload suitable for pasting into chat (issue tracker, doc, etc.).
|
||||
*
|
||||
* Format choices
|
||||
* ──────────────
|
||||
* One markdown table per logical section, with a small header
|
||||
* preamble carrying timestamp + element id. Tables render cleanly
|
||||
* in most chat clients (this one included) and remain readable as
|
||||
* plain text if the renderer doesn't pick them up.
|
||||
*
|
||||
* Numbers go through the same `fmt` helper used by the on-screen
|
||||
* rows so the clipboard contents match what the user sees in the
|
||||
* panel character-for-character (two decimals, signed deltas, em-
|
||||
* dash for missing values). No surprise reformat between visual
|
||||
* and pasted data.
|
||||
*
|
||||
* Each section is independently optional — if there's no node, the
|
||||
* "node (live konva)" table is omitted entirely rather than rendered
|
||||
* with em-dashes everywhere. If no gesture has happened yet, the
|
||||
* "last gesture" section is omitted. The header line is always
|
||||
* present so a paste with just the element-state table is still
|
||||
* self-describing.
|
||||
*
|
||||
* Returns the empty string when there's nothing meaningful to copy
|
||||
* (no readout at all); the caller bails before writing to the
|
||||
* clipboard in that case so we don't replace whatever the user had
|
||||
* there before with empty text.
|
||||
*/
|
||||
function formatReadoutForClipboard(readout, gestureSummary) {
|
||||
if (!readout) return '';
|
||||
|
||||
const lines = [];
|
||||
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
lines.push(`### element debug — ${ts}`);
|
||||
lines.push(`**id**: \`${readout.elementId}\``);
|
||||
lines.push('');
|
||||
|
||||
// element (state)
|
||||
lines.push('**element (state)**');
|
||||
lines.push('');
|
||||
lines.push('| field | value |');
|
||||
lines.push('|---|---|');
|
||||
lines.push(`| x | ${fmt(readout.element.x)} |`);
|
||||
lines.push(`| y | ${fmt(readout.element.y)} |`);
|
||||
lines.push(`| w | ${fmt(readout.element.width)} |`);
|
||||
lines.push(`| h | ${fmt(readout.element.height)} |`);
|
||||
lines.push(`| rot° | ${fmt(readout.element.rotation)} |`);
|
||||
lines.push(`| flip | ${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'} |`);
|
||||
lines.push('');
|
||||
|
||||
// node (live konva)
|
||||
if (readout.node) {
|
||||
lines.push('**node (live konva)**');
|
||||
lines.push('');
|
||||
lines.push('| field | value |');
|
||||
lines.push('|---|---|');
|
||||
lines.push(`| x | ${fmt(readout.node.x)} |`);
|
||||
lines.push(`| y | ${fmt(readout.node.y)} |`);
|
||||
lines.push(`| offset | ${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)} |`);
|
||||
lines.push(`| scale | ${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)} |`);
|
||||
lines.push(`| rot° | ${fmt(readout.node.rotation)} |`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// derived
|
||||
if (readout.derived) {
|
||||
lines.push('**derived**');
|
||||
lines.push('');
|
||||
lines.push('| field | value |');
|
||||
lines.push('|---|---|');
|
||||
if (readout.derived.clientRect) {
|
||||
lines.push(`| visual TL (konva) | ${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)} |`);
|
||||
}
|
||||
lines.push(`| simple formula | ${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)} |`);
|
||||
lines.push(`| rot-aware | ${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)} |`);
|
||||
lines.push(`| Δ x,y | ${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)} |`);
|
||||
lines.push(`| predicted jump | ${fmt(readout.derived.predictedJumpPx)}px |`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// last gesture
|
||||
if (gestureSummary) {
|
||||
lines.push('**last gesture**');
|
||||
lines.push('');
|
||||
lines.push('| field | value |');
|
||||
lines.push('|---|---|');
|
||||
lines.push(`| duration | ${gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'} |`);
|
||||
lines.push(`| bound calls | ${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped) |`);
|
||||
if (gestureSummary.startState && gestureSummary.firstFrame) {
|
||||
lines.push(`| first-frame Δ visual | ${fmtDeltaPair(
|
||||
gestureSummary.firstFrame.visualX,
|
||||
gestureSummary.startState.visualX,
|
||||
gestureSummary.firstFrame.visualY,
|
||||
gestureSummary.startState.visualY,
|
||||
)} |`);
|
||||
lines.push(`| first-frame Δ rot | ${fmtDelta(
|
||||
gestureSummary.firstFrame.nodeRotation,
|
||||
gestureSummary.startState.nodeRotation,
|
||||
)} |`);
|
||||
}
|
||||
if (gestureSummary.startState && gestureSummary.endState) {
|
||||
lines.push(`| total Δ visual | ${fmtDeltaPair(
|
||||
gestureSummary.endState.visualX,
|
||||
gestureSummary.startState.visualX,
|
||||
gestureSummary.endState.visualY,
|
||||
gestureSummary.startState.visualY,
|
||||
)} |`);
|
||||
lines.push(`| total Δ rot | ${fmtDelta(
|
||||
gestureSummary.endState.nodeRotation,
|
||||
gestureSummary.startState.nodeRotation,
|
||||
)} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Trim the trailing blank line so the payload ends without an
|
||||
// awkward extra newline when pasted into a tight text field.
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function Row({ label, value, mono, hint, color }) {
|
||||
const rowStyle = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
const labelStyle = {
|
||||
color: '#94a3b8',
|
||||
fontSize: '10.5px',
|
||||
};
|
||||
const valueStyle = {
|
||||
fontFamily: mono ? 'inherit' : undefined,
|
||||
color: color ?? '#e2e8f0',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
};
|
||||
return (
|
||||
<div style={rowStyle} title={hint || undefined}>
|
||||
<span style={labelStyle}>{label}</span>
|
||||
<span style={valueStyle}>{typeof value === 'number' ? fmt(value) : value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the debug-flag state from URL and localStorage. Either source
|
||||
* is enough to enable. Called once at App mount; the result drives
|
||||
* conditional rendering of <DebugOverlay /> for the rest of the session
|
||||
* (a reload re-reads, so toggles take effect on next page load).
|
||||
*
|
||||
* Defensive try/catches because SSR and storage-disabled environments
|
||||
* (private windows in some browsers) throw on access.
|
||||
*/
|
||||
export function readDebugFlag() {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.href);
|
||||
const param = url.searchParams.get('debug');
|
||||
if (param === '1' || param === 'true') {
|
||||
// Persist so subsequent reloads keep it on without the URL.
|
||||
try { localStorage.setItem('paw_debug_overlay', '1'); } catch { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('paw_debug_overlay') === '1';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
||||
// 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,374 +0,0 @@
|
||||
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,
|
||||
y = 0,
|
||||
width = 100,
|
||||
height = 100,
|
||||
rotation = 0,
|
||||
src,
|
||||
crop,
|
||||
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,
|
||||
}) {
|
||||
const shapeRef = useRef(null);
|
||||
|
||||
// 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(() => {
|
||||
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}
|
||||
/* 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={!locked}
|
||||
dragBoundFunc={dragBoundFunc}
|
||||
onClick={(e) => {
|
||||
e.cancelBubble = true;
|
||||
onSelect?.();
|
||||
}}
|
||||
onTap={(e) => {
|
||||
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) => {
|
||||
// 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;
|
||||
// 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: reportedX,
|
||||
y: reportedY,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
rotation: node.rotation(),
|
||||
});
|
||||
onCommit?.();
|
||||
}}
|
||||
/>
|
||||
{/* 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. */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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,103 +0,0 @@
|
||||
import { Group, Rect, Text, Line } from 'react-konva';
|
||||
|
||||
/**
|
||||
* 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}`}
|
||||
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} />
|
||||
{/* 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} />
|
||||
<Line points={[x + width, y, x + width, y + 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x, y + height, x + 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x, y + height, x, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x + width, y + height, x + width - 20, y + height]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
<Line points={[x + width, y + height, x + width, y + height - 20]} stroke="#38bdf8" strokeWidth={3} lineCap="round" listening={false} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlotBoundsGuide({ slot }) {
|
||||
const { bounds, id } = slot;
|
||||
return (
|
||||
<Group name={`slot-bounds-${id}`} listening={false}>
|
||||
<Rect x={bounds.x} y={bounds.y} width={bounds.width} height={bounds.height} stroke="rgba(56, 189, 248, 0.3)" strokeWidth={1} dash={[4, 4]} cornerRadius={2} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import '../../styles/TShirtSVG.css';
|
||||
|
||||
/**
|
||||
* 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
|
||||
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;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Group, Image as KonvaImage, Rect, Text as KonvaText } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
|
||||
function TemplateImage({ src, x, y, width, height, opacity = 1, listening = false }) {
|
||||
const [img] = useImage(src, 'anonymous');
|
||||
return <KonvaImage image={img} x={x} y={y} width={width} height={height} opacity={opacity} listening={listening} />;
|
||||
}
|
||||
|
||||
function TemplateText({ text, x, y, fontSize, fontFamily, fill, rotation = 0 }) {
|
||||
return <KonvaText text={text} x={x} y={y} fontSize={fontSize} fontFamily={fontFamily} fill={fill} rotation={rotation} listening={false} />;
|
||||
}
|
||||
|
||||
export function TemplateLayer({ template, canvasSize = 300 }) {
|
||||
if (!template) return null;
|
||||
const { background, overlay } = template;
|
||||
|
||||
return (
|
||||
<Group name="template-layer">
|
||||
{background && (
|
||||
<Group name="template-background">
|
||||
{background.type === 'color' ? (
|
||||
<Rect x={0} y={0} width={canvasSize} height={canvasSize} fill={background.color} listening={false} />
|
||||
) : background.type === 'image' ? (
|
||||
<TemplateImage src={background.src} x={0} y={0} width={canvasSize} height={canvasSize} listening={false} />
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
{overlay && overlay.map((el, index) => {
|
||||
if (el.nonPrintable) return null;
|
||||
const key = `overlay-${index}`;
|
||||
if (el.type === 'image') return <TemplateImage key={key} src={el.src} x={el.x || 0} y={el.y || 0} width={el.width || 100} height={el.height || 100} opacity={el.opacity} listening={false} />;
|
||||
if (el.type === 'text') return <TemplateText key={key} text={el.text} x={el.x || 0} y={el.y || 0} fontSize={el.fontSize} fontFamily={el.fontFamily} fill={el.fill} rotation={el.rotation} />;
|
||||
return null;
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
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,557 +0,0 @@
|
||||
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 = DEFAULT_BODY_FONT,
|
||||
fill = '#0f172a',
|
||||
stroke = null,
|
||||
strokeWidth = 0,
|
||||
rotation = 0,
|
||||
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,
|
||||
}) {
|
||||
// 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);
|
||||
|
||||
// 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();
|
||||
|
||||
// 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;
|
||||
|
||||
// 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(() => {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export { DesignCanvas } from './DesignCanvas';
|
||||
export { TShirtSVG } from './TShirtSVG';
|
||||
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';
|
||||
@@ -1,116 +0,0 @@
|
||||
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(); };
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const base64ToBlob = (base64DataUrl) => {
|
||||
const [header, data] = base64DataUrl.split(',');
|
||||
const mimeMatch = header?.match(/data:(.*?);base64/);
|
||||
const mime = mimeMatch?.[1] || 'image/png';
|
||||
const binary = atob(data || '');
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new Blob([bytes], { type: mime });
|
||||
};
|
||||
|
||||
const handleSave = async (savedImageData) => {
|
||||
try {
|
||||
// Prefer base64 when available (works without CORS/network).
|
||||
if (savedImageData?.imageBase64) {
|
||||
const blob = base64ToBlob(savedImageData.imageBase64);
|
||||
onComplete(URL.createObjectURL(blob));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to canvas when provided by the library.
|
||||
if (savedImageData?.imageCanvas instanceof HTMLCanvasElement) {
|
||||
const blob = await new Promise((resolve) =>
|
||||
savedImageData.imageCanvas.toBlob(resolve, savedImageData.mimeType || 'image/png', savedImageData.quality),
|
||||
);
|
||||
if (blob) onComplete(URL.createObjectURL(blob));
|
||||
else throw new Error('Canvas export failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Final fallback: cloudimageUrl (fetch then blob).
|
||||
if (savedImageData?.cloudimageUrl) {
|
||||
const res = await fetch(savedImageData.cloudimageUrl);
|
||||
const blob = await res.blob();
|
||||
onComplete(URL.createObjectURL(blob));
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('No export data returned from image editor');
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="filerobot-overlay" role="dialog" aria-modal="true" aria-labelledby="photo-editor-title">
|
||||
<div className="filerobot-container" ref={modalContentRef} role="document">
|
||||
<StyleSheetManager
|
||||
// Filerobot/@scaleflex styled-components pass a bunch of styling props to DOM nodes (e.g. isPhoneScreen).
|
||||
// Filtering them here prevents noisy React console warnings.
|
||||
shouldForwardProp={(prop, element) => (typeof element === 'string' ? isPropValid(prop) : true)}
|
||||
>
|
||||
<FilerobotImageEditor
|
||||
source={imageSrc}
|
||||
onBeforeSave={() => false}
|
||||
onSave={handleSave}
|
||||
onClose={() => onClose()}
|
||||
tabsIds={[TABS.ADJUST, TABS.FILTERS, TABS.FINETUNE]}
|
||||
defaultTabId={TABS.ADJUST}
|
||||
// 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"
|
||||
defaultSavedImageType="png"
|
||||
defaultSavedImageQuality={1}
|
||||
savingPixelRatio={4}
|
||||
previewPixelRatio={4}
|
||||
/>
|
||||
</StyleSheetManager>
|
||||
</div>
|
||||
<h2 id="photo-editor-title" className="sr-only">Photo Editor</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { PhotoPreEditor } from './PhotoPreEditor';
|
||||
@@ -1,404 +0,0 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { useCroppedThumbnail } from '../../hooks/useCroppedThumbnail';
|
||||
import '../../styles/LayersPanel.css';
|
||||
|
||||
/**
|
||||
* 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 photos, text, or stickers to your design.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<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,110 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { BackgroundRemovalButton } from '../sidebar/BackgroundRemovalButton';
|
||||
import '../../styles/PropertiesPanel.css';
|
||||
|
||||
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete, onEditPhoto }) {
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<div className="properties-panel__header">
|
||||
<h3 className="properties-panel__title">Properties</h3>
|
||||
</div>
|
||||
<div className="properties-panel__empty">
|
||||
Select an element to edit its properties
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePositionChange = (axis, value) => onUpdate({ [axis]: parseFloat(value) || 0 });
|
||||
const handleSizeChange = (axis, value) => onUpdate({ [axis]: Math.max(20, parseFloat(value) || 20) });
|
||||
const handleRotationChange = (value) => onUpdate({ rotation: Math.max(-180, Math.min(180, parseFloat(value) || 0)) });
|
||||
|
||||
return (
|
||||
<div className="properties-panel">
|
||||
<div className="properties-panel__header">
|
||||
<h3 className="properties-panel__title">Properties</h3>
|
||||
</div>
|
||||
|
||||
<div className="properties-panel__body">
|
||||
<div className="properties-panel__type-badge">
|
||||
{element.type}
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div className="properties-panel__section">
|
||||
<label className="properties-panel__label">Position</label>
|
||||
<div className="properties-panel__row">
|
||||
<div className="properties-panel__field">
|
||||
<label className="properties-panel__axis-label">X</label>
|
||||
<input type="number" value={Math.round(element.x)} onChange={(e) => handlePositionChange('x', e.target.value)} className="properties-panel__input" />
|
||||
</div>
|
||||
<div className="properties-panel__field">
|
||||
<label className="properties-panel__axis-label">Y</label>
|
||||
<input type="number" value={Math.round(element.y)} onChange={(e) => handlePositionChange('y', e.target.value)} className="properties-panel__input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size (for images and stickers) */}
|
||||
{(element.type === 'image' || element.type === 'sticker') && (
|
||||
<div className="properties-panel__section">
|
||||
<label className="properties-panel__label">Size</label>
|
||||
<div className="properties-panel__row">
|
||||
<div className="properties-panel__field">
|
||||
<label className="properties-panel__axis-label">W</label>
|
||||
<input type="number" value={Math.round(element.width)} onChange={(e) => handleSizeChange('width', e.target.value)} className="properties-panel__input" />
|
||||
</div>
|
||||
<div className="properties-panel__field">
|
||||
<label className="properties-panel__axis-label">H</label>
|
||||
<input type="number" value={Math.round(element.height)} onChange={(e) => handleSizeChange('height', e.target.value)} className="properties-panel__input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Photo button (user uploads only, not stickers) */}
|
||||
{element.type === 'image' && !element.emoji && onEditPhoto && (
|
||||
<div className="properties-panel__section">
|
||||
<button onClick={() => onEditPhoto(element)} className="properties-panel__edit-btn">
|
||||
✏️ Edit Photo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text-specific controls */}
|
||||
{element.type === 'text' && (
|
||||
<>
|
||||
<div className="properties-panel__section">
|
||||
<label className="properties-panel__label">Font Size: {Math.round(element.fontSize)}px</label>
|
||||
<input type="range" min="12" max="120" value={element.fontSize} onChange={(e) => onUpdate({ fontSize: parseInt(e.target.value, 10) })} className="properties-panel__range" />
|
||||
</div>
|
||||
<div className="properties-panel__section">
|
||||
<label className="properties-panel__label">Color</label>
|
||||
<input type="color" value={element.fill} onChange={(e) => onUpdate({ fill: e.target.value })} className="properties-panel__color-input" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Rotation */}
|
||||
<div className="properties-panel__section">
|
||||
<label className="properties-panel__label">Rotation: {Math.round(element.rotation)}°</label>
|
||||
<input type="range" min="-180" max="180" value={element.rotation} onChange={(e) => handleRotationChange(e.target.value)} className="properties-panel__range" />
|
||||
</div>
|
||||
|
||||
{/* Background Removal (user uploads only, not stickers) */}
|
||||
{element.type === 'image' && !element.emoji && (
|
||||
<BackgroundRemovalButton
|
||||
selectedElement={element}
|
||||
onUpdate={(_id, attrs) => onUpdate(attrs)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete */}
|
||||
<button onClick={() => onDelete(element.id)} className="properties-panel__delete-btn">
|
||||
Delete Element
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export { LayersPanel } from './LayersPanel';
|
||||
export { PropertiesPanel } from './PropertiesPanel';
|
||||
@@ -1,11 +0,0 @@
|
||||
// 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 { BackgroundRemovalButton } from '../canvas/BackgroundRemovalButton';
|
||||
@@ -1,173 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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,329 +0,0 @@
|
||||
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 { 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 Photo', icon: UploadIcon },
|
||||
{ id: 'stickers', label: 'Stickers', icon: StickersIcon },
|
||||
{ id: 'emoji', label: 'Emoji', icon: EmojiIcon },
|
||||
{ id: 'text', label: 'Text', icon: TextIcon },
|
||||
];
|
||||
|
||||
/**
|
||||
* 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} recentUploads={recentUploads} onRemoveUpload={onRemoveUpload} />;
|
||||
case 'stickers': return <StickersTab onAddSticker={onAddSticker} />;
|
||||
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 (
|
||||
<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="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,180 +0,0 @@
|
||||
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());
|
||||
|
||||
// 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 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: 'sticker',
|
||||
x: printCenter - width / 2,
|
||||
y: printCenter - height / 2,
|
||||
width,
|
||||
height,
|
||||
rotation: 0,
|
||||
src: sticker.url,
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||
import '../../styles/TemplatesTab.css';
|
||||
|
||||
function getCategoryEmoji(category) {
|
||||
const emojis = {
|
||||
Sports: '⚽', Music: '🎸', Quotes: '💬', Animals: '🐱',
|
||||
Abstract: '🌈', Vintage: '🏅', Nature: '🏔️', Tech: '💻',
|
||||
};
|
||||
return emojis[category] || '🎨';
|
||||
}
|
||||
|
||||
export function TemplatesTab({ onAddTemplate, onSlotImageUpload }) {
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(null);
|
||||
const [uploadSlotId, setUploadSlotId] = useState(null);
|
||||
|
||||
const templates = [
|
||||
{ id: 'freeform', name: 'Freeform', description: 'No template - design freely', thumbnail: '🎨' },
|
||||
...TEMPLATES.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
thumbnail: getCategoryEmoji(t.category),
|
||||
hasSlots: !!t.slots,
|
||||
})),
|
||||
];
|
||||
|
||||
const handleSelectTemplate = (template) => {
|
||||
setSelectedTemplateId(template.id);
|
||||
onAddTemplate(template.id);
|
||||
};
|
||||
|
||||
const handleSlotClick = (slotId) => {
|
||||
setUploadSlotId(slotId);
|
||||
document.getElementById('slot-file-input')?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && uploadSlotId) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
onSlotImageUpload?.(uploadSlotId, event.target.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = '';
|
||||
setUploadSlotId(null);
|
||||
};
|
||||
|
||||
const selectedTemplate = TEMPLATES.find(t => t.id === selectedTemplateId);
|
||||
const slots = selectedTemplate?.slots || [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input id="slot-file-input" type="file" accept="image/*" onChange={handleFileChange} className="templates-hidden-input" />
|
||||
|
||||
<h3 className="templates-title">Templates</h3>
|
||||
|
||||
<div className="templates-description">
|
||||
Choose a template to get started or design freely.
|
||||
</div>
|
||||
|
||||
<div className="templates-list">
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
className={`template-btn${template.id === selectedTemplateId ? ' selected' : ''}`}
|
||||
>
|
||||
<div className="template-thumbnail">
|
||||
{template.thumbnail}
|
||||
</div>
|
||||
<div className="template-info">
|
||||
<div className="template-name">{template.name}</div>
|
||||
<div className="template-desc">{template.description}</div>
|
||||
</div>
|
||||
{template.hasSlots && (
|
||||
<span className="template-slots-badge">SLOTS</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTemplateId && selectedTemplateId !== 'freeform' && slots.length > 0 && (
|
||||
<div className="template-slots-section">
|
||||
<h4 className="template-slots-title">Template Slots</h4>
|
||||
<div className="template-slots-list">
|
||||
{slots.map((slot) => (
|
||||
<button
|
||||
key={slot.id}
|
||||
onClick={() => handleSlotClick(slot.id)}
|
||||
className="template-slot-btn"
|
||||
>
|
||||
<span className="template-slot-icon">📷</span>
|
||||
<span>{slot.label}</span>
|
||||
<span className="template-slot-dimensions">
|
||||
{slot.bounds.width}×{slot.bounds.height}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import '../../styles/UploadTab.css';
|
||||
import { loadImageDimensions } from '../../utils/imageLoading';
|
||||
import { MIN_ELEMENT_SIZE } from '../../constants/elements';
|
||||
|
||||
/**
|
||||
* 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 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];
|
||||
if (!file) return;
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!validTypes.includes(file.type)) { alert('Please upload a JPEG, PNG, or WebP image'); return; }
|
||||
if (file.size > 20 * 1024 * 1024) { alert('File size must be under 20MB'); return; }
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await fetch('/api/upload', { method: 'POST', body: formData });
|
||||
if (!response.ok) throw new Error('Upload failed');
|
||||
const data = await response.json();
|
||||
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 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,9 +0,0 @@
|
||||
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.
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,24 +0,0 @@
|
||||
export const FONTS = [
|
||||
{ name: 'Roboto', family: 'Roboto' },
|
||||
{ name: 'Open Sans', family: 'Open Sans' },
|
||||
{ name: 'Lato', family: 'Lato' },
|
||||
{ name: 'Montserrat', family: 'Montserrat' },
|
||||
{ name: 'Oswald', family: 'Oswald' },
|
||||
{ name: 'Raleway', family: 'Raleway' },
|
||||
{ name: 'Poppins', family: 'Poppins' },
|
||||
{ name: 'Roboto Condensed', family: 'Roboto Condensed' },
|
||||
{ name: 'Source Sans 3', family: 'Source Sans 3' },
|
||||
{ name: 'Roboto Slab', family: 'Roboto Slab' },
|
||||
{ name: 'Merriweather', family: 'Merriweather' },
|
||||
{ name: 'Ubuntu', family: 'Ubuntu' },
|
||||
{ name: 'Playfair Display', family: 'Playfair Display' },
|
||||
{ name: 'Nunito', family: 'Nunito' },
|
||||
{ name: 'Rubik', family: 'Rubik' },
|
||||
{ name: 'Work Sans', family: 'Work Sans' },
|
||||
{ name: 'Lora', family: 'Lora' },
|
||||
{ name: 'Fira Sans', family: 'Fira Sans' },
|
||||
{ name: 'Barlow', family: 'Barlow' },
|
||||
{ name: 'Bebas Neue', family: 'Bebas Neue' },
|
||||
{ name: 'Pacifico', family: 'Pacifico' },
|
||||
{ name: 'Caveat', family: 'Caveat' },
|
||||
];
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { STICKER_FILES } from 'virtual:sticker-manifest';
|
||||
|
||||
// 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/';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
})();
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useDesignEditor } from './useDesignEditor';
|
||||
export { useBackgroundRemoval } from './useBackgroundRemoval';
|
||||
export { useExport } from './useExport';
|
||||
export { useTemplate } from './useTemplate';
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { AutoModel, AutoProcessor, RawImage, env } from '@huggingface/transformers';
|
||||
|
||||
export function useBackgroundRemoval() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [hasModel, setHasModel] = useState(false);
|
||||
const modelRef = useRef(null);
|
||||
const processorRef = useRef(null);
|
||||
|
||||
const loadModel = useCallback(async () => {
|
||||
if (modelRef.current && processorRef.current) return true;
|
||||
|
||||
setLoading(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
// Reduce ONNX Runtime Web console noise (node assignment warnings, etc.).
|
||||
// Note: depending on ONNX Runtime build/version, some warnings may still appear.
|
||||
env.backends.onnx.logLevel = 'error';
|
||||
|
||||
modelRef.current = await AutoModel.from_pretrained('briaai/RMBG-1.4', {
|
||||
dtype: 'q8',
|
||||
device: navigator.gpu ? 'webgpu' : 'wasm',
|
||||
progress_callback: (p) => {
|
||||
if (p.progress != null) setProgress(Math.round(p.progress * 50));
|
||||
},
|
||||
});
|
||||
|
||||
processorRef.current = await AutoProcessor.from_pretrained('briaai/RMBG-1.4');
|
||||
|
||||
setProgress(50);
|
||||
setLoading(false);
|
||||
setHasModel(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load background removal model:', error);
|
||||
setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeBackground = useCallback(async (imageSrc) => {
|
||||
if (!modelRef.current || !processorRef.current) {
|
||||
const loaded = await loadModel();
|
||||
if (!loaded) return null;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setProgress(50);
|
||||
|
||||
try {
|
||||
const image = await RawImage.fromURL(imageSrc);
|
||||
const { pixel_values } = await processorRef.current(image);
|
||||
|
||||
setProgress(70);
|
||||
|
||||
const { output } = await modelRef.current({ input: pixel_values });
|
||||
|
||||
setProgress(90);
|
||||
|
||||
const mask = await RawImage.fromTensor(
|
||||
output[0].mul(255).to('uint8')
|
||||
).resize(image.width, image.height);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
ctx.drawImage(await image.toCanvas(), 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let i = 0; i < mask.data.length; i++) {
|
||||
imageData.data[i * 4 + 3] = mask.data[i];
|
||||
}
|
||||
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 {
|
||||
url: resultCanvas.toDataURL('image/png'),
|
||||
bbox,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Background removal failed:', error);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
}, [loadModel]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
progress,
|
||||
hasModel,
|
||||
loadModel,
|
||||
removeBackground,
|
||||
};
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
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,592 +0,0 @@
|
||||
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([]);
|
||||
// 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([]);
|
||||
const historyIndexRef = useRef(-1);
|
||||
const historyTimerRef = useRef(null);
|
||||
const pendingChangesRef = useRef(null);
|
||||
|
||||
const syncUndoRedo = useCallback(() => {
|
||||
setCanUndo(historyIndexRef.current > 0);
|
||||
setCanRedo(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);
|
||||
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) {
|
||||
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: 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));
|
||||
pendingChangesRef.current = newElements;
|
||||
if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
|
||||
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();
|
||||
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]);
|
||||
|
||||
// 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--;
|
||||
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++;
|
||||
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(() => {
|
||||
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, selectedIds,
|
||||
addElement, addElements, replaceElements,
|
||||
updateElement, updateAndCommit, deleteElement, deleteMany,
|
||||
duplicateElement, bringForward, sendBackward, reorderElement,
|
||||
selectElement, deselectAll, toggleInSelection, setSelection, commitHistory,
|
||||
undo, redo, canUndo, canRedo, initializeHistory,
|
||||
};
|
||||
}
|
||||
@@ -1,837 +0,0 @@
|
||||
// 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,66 +0,0 @@
|
||||
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,
|
||||
{ autoDownload = true } = {},
|
||||
) => {
|
||||
setExporting(true); setProgress(0); setError(null); setExportUrl(null);
|
||||
|
||||
let progressInterval = null;
|
||||
try {
|
||||
progressInterval = setInterval(() => { setProgress((prev) => Math.min(prev + 10, 90)); }, 200);
|
||||
|
||||
const response = await fetch('/api/export', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ elements, designName, template }),
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
if (autoDownload) {
|
||||
const link = document.createElement('a');
|
||||
link.href = data.export.url;
|
||||
link.download = data.export.filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
setError(err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
setExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearExport = useCallback(() => { setExportUrl(null); setError(null); }, []);
|
||||
|
||||
return { exporting, progress, exportUrl, error, exportDesign, clearExport };
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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]);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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,61 +0,0 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { loadImageDimensions } from '../utils/imageLoading';
|
||||
|
||||
export function calculateAutoCrop(imageSize, slotSize) {
|
||||
const imageRatio = imageSize.width / imageSize.height;
|
||||
const slotRatio = slotSize.width / slotSize.height;
|
||||
let sx, sy, sWidth, sHeight;
|
||||
if (imageRatio > slotRatio) { sHeight = imageSize.height; sWidth = imageSize.height * slotRatio; sx = (imageSize.width - sWidth) / 2; sy = 0; }
|
||||
else { sWidth = imageSize.width; sHeight = imageSize.width / slotRatio; sx = 0; sy = (imageSize.height - sHeight) / 2; }
|
||||
return { sx, sy, sWidth, sHeight };
|
||||
}
|
||||
|
||||
export function useTemplate(templates = []) {
|
||||
const [currentTemplateId, setCurrentTemplateId] = useState(null);
|
||||
const templateRef = useRef(null);
|
||||
|
||||
const currentTemplate = templates.find(t => t.id === currentTemplateId) || null;
|
||||
const getSlots = useCallback(() => currentTemplate?.slots || [], [currentTemplate]);
|
||||
|
||||
const loadTemplate = useCallback((templateId) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
if (template) { setCurrentTemplateId(templateId); templateRef.current = template; return true; }
|
||||
return false;
|
||||
}, [templates]);
|
||||
|
||||
const clearTemplate = useCallback(() => { setCurrentTemplateId(null); templateRef.current = null; }, []);
|
||||
|
||||
// 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 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]);
|
||||
|
||||
return { currentTemplateId, currentTemplate, loadTemplate, clearTemplate, getSlots, assignImageToSlot };
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,144 +1,109 @@
|
||||
/**
|
||||
* Message catalog (S23).
|
||||
* Host message catalog.
|
||||
*
|
||||
* 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.
|
||||
* Scope
|
||||
* ─────
|
||||
* HOST-OWNED strings only — the chrome around the editor (header,
|
||||
* page title, PWA install banner, offline indicator, share-sheet
|
||||
* title). Strings inside the goods-editor module (sidebar tabs,
|
||||
* toolbar buttons, toasts, modal copy) have their OWN catalog
|
||||
* inside the module and are not re-exported. That separation is
|
||||
* deliberate: the host shouldn't be able to silently change the
|
||||
* module's user-facing copy, and the module shouldn't be able to
|
||||
* silently change the host's.
|
||||
*
|
||||
* 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.
|
||||
* Convention — flat keys
|
||||
* ──────────────────────
|
||||
* The dot in `header.save` is punctuation, not nesting. The lookup
|
||||
* is a single object access. This makes:
|
||||
* • grepping ("where do we use this string?") trivial,
|
||||
* • diffs readable in code review,
|
||||
* • the t() helper a one-liner with no recursion or path syntax.
|
||||
*
|
||||
* Parameter syntax: `{name}` placeholders inside a value are replaced by
|
||||
* the matching key in the params object passed to t():
|
||||
* Adding a key
|
||||
* ────────────
|
||||
* 1. Append below with a key matching the namespace it belongs to
|
||||
* (header.*, pwa.*, offline.*, share.*).
|
||||
* 2. Use it via t('namespace.key') in the component.
|
||||
* 3. When new locales land, copy this file (es.js, fr.js…) and
|
||||
* translate the values. The t() helper picks the active catalog
|
||||
* via the import in t.js.
|
||||
*
|
||||
* 'cart.items-count': 'You have {count} items in your cart.'
|
||||
* t('cart.items-count', { count: 3 }) → 'You have 3 items in your cart.'
|
||||
* Placeholders
|
||||
* ────────────
|
||||
* `{name}` placeholders are substituted with the matching key in the
|
||||
* params object passed to t():
|
||||
*
|
||||
* 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.
|
||||
* 'header.logo-home-aria': '{app} home'
|
||||
* t('header.logo-home-aria', { app: 'Pawfectly Yours' }) → 'Pawfectly Yours home'
|
||||
*
|
||||
* Pluralization
|
||||
* ─────────────
|
||||
* No ICU plurals — we use the explicit `.singular` / `.plural` key
|
||||
* convention, same pattern as the module's `bounds.singular` /
|
||||
* `bounds.plural`. Caller picks the key based on count:
|
||||
*
|
||||
* t(count === 1 ? 'header.cart-aria.singular' : 'header.cart-aria.plural',
|
||||
* { count })
|
||||
*
|
||||
* If we ever need real plural rules (Polish, Arabic, etc.) we'll move
|
||||
* to formatjs/intl-messageformat then. For en-only at this scale,
|
||||
* .singular/.plural is enough.
|
||||
*/
|
||||
|
||||
export const en = {
|
||||
// Header
|
||||
// ── Header ──────────────────────────────────────────────────
|
||||
// Save / Clear / Menu were removed from the header chrome during the
|
||||
// module split — Save was redundant with Download (same export
|
||||
// pipeline), Clear needs a module-side `clear()` method on the editor
|
||||
// ref before it can be reintroduced, and Menu opened the host's own
|
||||
// bottom sheet which is now inside the module. Their i18n keys are
|
||||
// gone with them; readd if you put any of those buttons back.
|
||||
'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.logo-home-aria': '{app} home',
|
||||
'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',
|
||||
// Cart aria-label has two forms because pluralization affects the
|
||||
// whole phrase. The caller picks the key based on count.
|
||||
'header.cart-aria.singular': 'Cart, {count} item',
|
||||
'header.cart-aria.plural': 'Cart, {count} items',
|
||||
|
||||
// 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.',
|
||||
// ── Offline indicator ───────────────────────────────────────
|
||||
'offline.message': 'You\u2019re offline \u2014 changes are saved locally',
|
||||
|
||||
// ── PWA install / update prompts ────────────────────────────
|
||||
'pwa.install-message': 'Install Apparel Designer for offline access!',
|
||||
'pwa.install': 'Install',
|
||||
'pwa.dismiss': 'Later',
|
||||
'pwa.update-message': 'New version available!',
|
||||
'pwa.refresh': 'Refresh',
|
||||
'pwa.close': 'Close',
|
||||
|
||||
// ── Share sheet ─────────────────────────────────────────────
|
||||
// Title and body for the OS share sheet (iOS / Android / Web Share
|
||||
// API). `share.title` is also passed to <ApparelDesigner shareTitle=…/>
|
||||
// so the module uses the same string when invoking navigator.share()
|
||||
// from inside its own save / share flows.
|
||||
'share.title': 'My Pawfectly Yours design',
|
||||
'share.text': 'Check out the shirt I made!',
|
||||
|
||||
// ── Toasts ──────────────────────────────────────────────────
|
||||
// The host's toast surface (see src/components/Toast.jsx). The
|
||||
// editor module has its own toast catalog (`toast.crop-…` etc.)
|
||||
// inside the module — these are only the ones the HOST surfaces.
|
||||
// Kept narrowly scoped so we don't drift from the module's copy
|
||||
// for the same situation.
|
||||
'toast.dismiss': 'Dismiss',
|
||||
'toast.link-copied': 'Link copied to clipboard \u2728',
|
||||
// 'toast.copy-failed' interpolates the URL so the user can read
|
||||
// and copy it manually when both navigator.share and
|
||||
// navigator.clipboard are unavailable. Longer auto-dismiss (8s)
|
||||
// because the user needs time to read and copy.
|
||||
'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',
|
||||
'toast.upload-failed': 'Upload failed: {error}',
|
||||
};
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
import { en } from './messages';
|
||||
|
||||
/**
|
||||
* Tiny i18n helper (S23).
|
||||
* Tiny i18n helper for the host application.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
* Mirrors the same helper inside the goods-editor module — kept as a
|
||||
* separate file (rather than re-exported from the module) so the host
|
||||
* can evolve its i18n independently. If the host ever needs different
|
||||
* placeholder syntax, plural rules, or a different message format,
|
||||
* it can change here without touching the module.
|
||||
*
|
||||
* Usage:
|
||||
* import { t } from '@/i18n/t';
|
||||
* t('header.save-button') // → 'Save'
|
||||
* t('toast.cart-items', { count: 3, plural: 'items' }) // simple sub
|
||||
* Usage
|
||||
* ─────
|
||||
* import { t } from './i18n/t';
|
||||
* t('header.save') // \u2192 'Save'
|
||||
* t('header.logo-home-aria', { app: 'Pawfectly Yours' })
|
||||
*
|
||||
* 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.
|
||||
* Why no library
|
||||
* ──────────────
|
||||
* react-intl / i18next solve problems we don\u2019t have yet: complex
|
||||
* plural rules, gender, number/date formatting, lazy-loaded locale
|
||||
* bundles, message extraction tooling. For an en-only catalog with
|
||||
* ~25 keys, a 10-line helper is the right size. When we need ICU
|
||||
* MessageFormat (e.g. for Polish/Arabic plural rules) or DateTime
|
||||
* formatting, we 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.
|
||||
// Single-locale for now. When more land, this becomes a runtime
|
||||
// dispatch on a locale value \u2014 see the docblock in messages.js for
|
||||
// the rough shape of that evolution.
|
||||
const ACTIVE_CATALOG = en;
|
||||
|
||||
const PLACEHOLDER_RE = /\{(\w+)\}/g;
|
||||
@@ -35,7 +40,9 @@ 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.
|
||||
// Surface the missing key directly so it stands out in the UI
|
||||
// ('header.save-button' is obviously not English) rather than
|
||||
// silently rendering empty strings that look like a CSS issue.
|
||||
return key;
|
||||
}
|
||||
if (!params) return raw;
|
||||
|
||||
117
src/index.css
117
src/index.css
@@ -122,6 +122,37 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The page-level chrome wrapper. Holds the header on top and the
|
||||
* editor mount underneath, in a flex column that fills #root.
|
||||
*
|
||||
* Why this matters
|
||||
* ────────────────
|
||||
* Without these styles, .app-shell would render as a plain block
|
||||
* div sized to its content. The <main flex: 1> child inside would
|
||||
* then have no flex context to grow into, so `flex: 1` would be a
|
||||
* no-op and <main> would shrink to its content's natural height.
|
||||
* Since the editor inside <main> uses `height: 100%`, it would
|
||||
* resolve against <main>'s zero height and collapse.
|
||||
*
|
||||
* Making .app-shell its own flex column gives <main> a definite
|
||||
* vertical slot to grow into, which then gives the editor a
|
||||
* definite height to fill. The header takes its natural height
|
||||
* (no flex), <main> claims everything else (`flex: 1`), and the
|
||||
* editor inside renders at exactly viewport-minus-header pixels
|
||||
* — no `calc()` involved.
|
||||
*
|
||||
* `min-height: 0` is the flexbox shrink-fix: without it, a flex
|
||||
* item's minimum size is its content's intrinsic min size, which
|
||||
* means a tall editor could push the shell past 100vh even though
|
||||
* #root is capped there. min-height: 0 lets <main> actually obey
|
||||
* the flex distribution. */
|
||||
.app-shell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Generic interactive elements */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
@@ -187,17 +218,13 @@ input[type='range']::-moz-range-thumb {
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* 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. */
|
||||
/* Shared utilities */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* 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). */
|
||||
* different parts of the codebase grew up using one or the other —
|
||||
* unifying them as a single rule prevents drift. */
|
||||
.visually-hidden,
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
@@ -212,9 +239,8 @@ input[type='range']::-moz-range-thumb {
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
* surface that still gets keyboard accessibility for free. Pair with
|
||||
* explicit `:focus-visible` styling so keyboard users still see focus. */
|
||||
.btn-reset {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
@@ -229,7 +255,7 @@ input[type='range']::-moz-range-thumb {
|
||||
|
||||
/* 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
|
||||
* (e.g. the centered .app-toast which is translateX(-50%)) keep their
|
||||
* own bespoke @keyframes since this one only handles Y. */
|
||||
@keyframes fade-up-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
@@ -237,7 +263,11 @@ input[type='range']::-moz-range-thumb {
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Spinner — used by background removal and uploads */
|
||||
/* Spinner — generic small loading indicator */
|
||||
/* */
|
||||
/* White ring on translucent background, sized for use inside buttons or */
|
||||
/* chips during async operations. The export-status pill in the editor */
|
||||
/* module uses this same class (re-rendered against the host's stylesheet). */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.spinner-small {
|
||||
@@ -332,66 +362,3 @@ input[type='range']::-moz-range-thumb {
|
||||
gap: 0.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* Background removal button (rendered inside ElementToolbar) */
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.bg-removal-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.bg-removal-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 16px -4px rgba(236, 72, 153, 0.55);
|
||||
}
|
||||
|
||||
.bg-removal-btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────── */
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
.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,189 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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;
|
||||
/* 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 {
|
||||
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;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -108,10 +108,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ph-iconbtn--menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ph-pillbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -128,16 +124,6 @@
|
||||
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;
|
||||
@@ -222,11 +208,17 @@
|
||||
.ph-title {
|
||||
display: none;
|
||||
}
|
||||
.ph-pillbtn,
|
||||
.ph-cart {
|
||||
/* Hide the Share pill on mobile — we don't have an icon-only
|
||||
* variant for it yet, and the cart icon button is the priority
|
||||
* action on the right side of the bar. Re-adding requires a
|
||||
* mobile redesign of the action cluster (e.g. converting Share
|
||||
* to an icon-button, possibly moving Preview / Download into
|
||||
* the editor module's mobile UI). */
|
||||
.ph-pillbtn {
|
||||
display: none;
|
||||
}
|
||||
.ph-iconbtn--menu {
|
||||
display: inline-flex;
|
||||
}
|
||||
/* Cart stays visible on mobile — previously hidden because the
|
||||
* Menu button opened a bottom sheet that contained the cart, but
|
||||
* the Menu and that sheet are both gone. Cart needs a direct
|
||||
* tap target on mobile. */
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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;
|
||||
color: var(--text-muted);
|
||||
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;
|
||||
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: stretch;
|
||||
gap: 0.25rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
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 {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layers-item.selected .layers-item-name {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
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);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.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); }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
.properties-panel__header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.properties-panel__title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.properties-panel__empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.properties-panel__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.properties-panel__type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: var(--accent-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.properties-panel__section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.properties-panel__label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.properties-panel__axis-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.properties-panel__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.properties-panel__field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.properties-panel__input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.properties-panel__range {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.properties-panel__color-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.properties-panel__edit-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.properties-panel__delete-btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
.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,248 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rp__scroll {
|
||||
flex: 1;
|
||||
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;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
.rp__tab svg {
|
||||
color: var(--text-muted);
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.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,234 +0,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);
|
||||
}
|
||||
|
||||
/* 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: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
max-height: 96px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--brand-pink-soft) transparent;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 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(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;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.tshirt-svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* 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 0.4rem 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.templates-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.templates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.template-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
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(--brand-pink-soft);
|
||||
border-color: var(--brand-pink);
|
||||
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.12);
|
||||
}
|
||||
|
||||
.template-thumbnail {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--brand-pink-tint);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.template-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 13px;
|
||||
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 7px;
|
||||
background: var(--brand-pink);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.template-slots-section {
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.85rem;
|
||||
background: var(--brand-pink-wash);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--brand-pink);
|
||||
}
|
||||
|
||||
.template-slots-title {
|
||||
margin: 0 0 0.55rem 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.template-slots-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.template-slot-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
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 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.template-slot-dimensions {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* TextTab — Pawfectly Yours
|
||||
*
|
||||
* Class names match the .tt__ scheme used in TextTab.jsx.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
.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: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@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.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;
|
||||
}
|
||||
|
||||
.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.7rem 0.85rem;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.tt__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-pink);
|
||||
box-shadow: 0 0 0 3px var(--brand-pink-soft);
|
||||
}
|
||||
|
||||
/* 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.55rem;
|
||||
margin-bottom: 0.4rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tt__outline-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tt__outline-toggle-track {
|
||||
display: block;
|
||||
width: 100%;
|
||||
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: #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;
|
||||
}
|
||||
}
|
||||
98
src/styles/Toast.css
Normal file
98
src/styles/Toast.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
* Toast — host-level notification pill
|
||||
*
|
||||
* Top-center, fixed positioning so it sits above the editor's own canvas
|
||||
* UI without occluding the action it's commenting on. The editor module
|
||||
* has its own internal toast surface for editor concerns (rendered
|
||||
* inside the canvas area); this one is anchored to the viewport so
|
||||
* host-originated toasts are visually distinct from editor-originated
|
||||
* ones.
|
||||
*
|
||||
* Z-index sits just under the PWA install banner (1000) and above the
|
||||
* canvas. The offline indicator (z 9999) intentionally outranks toasts
|
||||
* — being offline is more important than transient feedback.
|
||||
* ────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.app-toast-wrap {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 1rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 900;
|
||||
pointer-events: none;
|
||||
/* Wrap is pointer-events:none so it doesn't intercept clicks on the
|
||||
* area behind it; the inner pill re-enables pointer events for its
|
||||
* own dismiss button. */
|
||||
}
|
||||
|
||||
.app-toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 0.9rem 0.65rem 1.1rem;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-pop);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
pointer-events: auto;
|
||||
max-width: min(560px, calc(100vw - 2rem));
|
||||
animation: app-toast-in 0.22s ease-out;
|
||||
}
|
||||
|
||||
@keyframes app-toast-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.app-toast__message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-toast__close {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.app-toast__close:hover {
|
||||
background: var(--brand-pink-soft);
|
||||
color: var(--brand-pink-strong);
|
||||
}
|
||||
|
||||
/* Variants — info is the default styling above; success and error tint
|
||||
* the border + add a subtle background wash so the kind reads at a
|
||||
* glance without leaning on color alone. */
|
||||
|
||||
.app-toast--success {
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
background: linear-gradient(180deg, rgba(34, 197, 94, 0.06), var(--bg-primary));
|
||||
}
|
||||
|
||||
.app-toast--error {
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
background: linear-gradient(180deg, rgba(239, 68, 68, 0.06), var(--bg-primary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Mobile — the toast lives below the smaller mobile header. */
|
||||
@media (max-width: 768px) {
|
||||
.app-toast-wrap {
|
||||
top: calc(var(--header-height-mobile) + 0.75rem);
|
||||
}
|
||||
.app-toast {
|
||||
font-size: 13px;
|
||||
padding: 0.55rem 0.75rem 0.55rem 0.95rem;
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,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);
|
||||
}
|
||||
|
||||
.ut__dropzone-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ut__browse {
|
||||
color: var(--brand-pink);
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.ut__hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status ------------------------------------------------------------------ */
|
||||
|
||||
.ut__status {
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--brand-pink-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
color: var(--brand-pink-strong);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* 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. */
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user