405 lines
19 KiB
JavaScript
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}
|
|
/>
|
|
);
|
|
});
|