Files
apparel-designer/src/components/panels/LayersPanel.jsx
2026-05-23 03:28:58 -05:00

405 lines
19 KiB
JavaScript

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}
/>
);
});