1093 lines
50 KiB
JavaScript
1093 lines
50 KiB
JavaScript
import { useMemo, useRef } from 'react';
|
||
import { BackgroundRemovalButton } from './BackgroundRemovalButton';
|
||
import { IMAGE_FILTERS } from '../../constants/imageFilters';
|
||
import '../../styles/ElementToolbar.css';
|
||
|
||
/**
|
||
* Floating element toolbar.
|
||
*
|
||
* What's here (image-only items marked):
|
||
*
|
||
* • Flip H / Flip V — image
|
||
* • Crop button — image; enters App's crop mode,
|
||
* toolbar switches to Apply/Cancel
|
||
* • Remove background (compact) — image; lives in the top row as
|
||
* a peer of Flip and Crop now that
|
||
* the old wide pill has been
|
||
* superseded by the on-canvas tools
|
||
* • Edit text (mobile only) — text; pencil equivalent
|
||
* • Opacity slider — any
|
||
* • Size slider (mobile only) — any
|
||
* • Rotation slider (mobile only) — any
|
||
* • Brightness / Contrast — image; stacks on top of filter
|
||
* preset
|
||
* • Filter chips — image
|
||
*
|
||
* Most layer ops (delete, duplicate, lock, up/down) live in the
|
||
* LayersPanel's per-row buttons now. Snap toggle moved to the zoom
|
||
* controls toolbar.
|
||
*
|
||
* What used to be here but isn't anymore
|
||
* ──────────────────────────────────────
|
||
* • "Edit Photo" wide pill button. It launched the Filerobot
|
||
* full-screen photo editor for brightness / contrast / crop /
|
||
* filters. All of those are now reachable directly from this
|
||
* toolbar (sliders + crop overlay + filter chips), so the
|
||
* wide pill became redundant and visually heavy. The
|
||
* `onEditPhoto` prop is still accepted by this component for
|
||
* backward-compat with callers, but it's unused — callers that
|
||
* pass it just get a no-op.
|
||
*/
|
||
export function ElementToolbar({
|
||
element,
|
||
onUpdate,
|
||
onCommit,
|
||
// Accepted but unused — see docblock. Kept in the signature so
|
||
// existing call sites in App.jsx don't error.
|
||
// eslint-disable-next-line no-unused-vars
|
||
onEditPhoto,
|
||
onEditText,
|
||
isMobile = false,
|
||
// Crop API. When `cropping` is true, the toolbar replaces its
|
||
// normal content with Apply / Cancel buttons targeting onApplyCrop
|
||
// and onCancelCrop. When false (default), the Crop button is
|
||
// present alongside the flip controls. Wiring all of these is
|
||
// optional — when the callbacks are missing, the Crop button just
|
||
// doesn't render and the apply/cancel face is unreachable.
|
||
cropping = false,
|
||
onStartCrop,
|
||
onApplyCrop,
|
||
onCancelCrop,
|
||
// Undo crop — reverts the most recent crop operation on the
|
||
// element. Sibling of Crop in the action row, but only shown when
|
||
// there's a crop to undo. Behaviour lives in App.jsx's
|
||
// `handleUndoCrop`: it restores the pre-crop dimensions AND the
|
||
// previous crop value (so multi-crop sequences walk back one step
|
||
// rather than jumping to fully-uncropped), with a fallback to
|
||
// fit-to-bounds when the user resized between the crop and the
|
||
// click. Wiring is optional — when the callback isn't supplied,
|
||
// the button just doesn't render.
|
||
onUndoCrop,
|
||
// Mobile-only layer ops. Desktop users access these via the
|
||
// LayersPanel in the right-rail sidebar (per-row Duplicate / Lock
|
||
// / Up / Down / Delete buttons there); on mobile that panel sits
|
||
// inside the bottom sheet, which is several taps away from the
|
||
// canvas. Surfacing the same operations on the floating mobile
|
||
// toolbar gives the user immediate access to layer lifecycle and
|
||
// z-order ops without ever opening the sheet.
|
||
//
|
||
// All four are wired only for the mobile instance in App.jsx —
|
||
// the desktop instance leaves them undefined and the section
|
||
// doesn't render. Each is element-shaped (receives the full
|
||
// selected element) so the handler in App can read `.id` plus
|
||
// any other state (e.g. current `locked` flag if needed) without
|
||
// a second selector lookup.
|
||
onDuplicate,
|
||
onDelete,
|
||
onBringForward,
|
||
onSendBackward,
|
||
}) {
|
||
// Anchor the slider's "100%" to the *current* element dimensions when it
|
||
// first renders. Without this, repeatedly dragging the slider in opposite
|
||
// directions would compound floating-point error and slowly shrink the
|
||
// element. We capture the anchor lazily — once per element id — via useMemo.
|
||
const sizeAnchor = useMemo(() => {
|
||
if (!element) return null;
|
||
if (element.type === 'image' || element.type === 'sticker') {
|
||
return { width: element.width || 100, height: element.height || 100 };
|
||
}
|
||
if (element.type === 'text') {
|
||
return { fontSize: element.fontSize || 32 };
|
||
}
|
||
return null;
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [element?.id]);
|
||
|
||
// Per-drag-session center reference (image/sticker only).
|
||
//
|
||
// Why this exists: the size slider on mobile can fire many onChange
|
||
// events per second during a drag. Each event recomputes the
|
||
// element's new x/y from the CURRENT element state's center
|
||
// (`element.x + element.width / 2`). That's correct in isolation —
|
||
// each frame preserves the center it observes — but the round-to-
|
||
// integer math has a subtle bias: JS's `Math.round` rounds half-
|
||
// values AWAY FROM ZERO for positive numbers, so any input like
|
||
// X.5 systematically becomes X+1. There's no compensating round-
|
||
// down path, so over many up-down cycles the per-frame bias
|
||
// accumulates in one direction (toward +x, +y for positive
|
||
// coordinates) and the element visibly drifts toward the bottom-
|
||
// right. With small ratios (e.g. anchor 158×240, slider at pct=103
|
||
// → odd widths/heights produce X.5 half-widths) a few up-down
|
||
// cycles can shift the element 20-30px.
|
||
//
|
||
// The fix is to NOT recompute the center from compounding frame
|
||
// state during a continuous drag. Instead, capture the center ONCE
|
||
// at the start of the drag session and reuse it for every frame
|
||
// until release. No chain of compounding rounds; the center is
|
||
// re-anchored on each fresh drag (so user moves between drags
|
||
// don't cause snap-back).
|
||
//
|
||
// Session start is detected by checking whether the ref is null
|
||
// at the top of a slider onChange call — it's cleared by the
|
||
// commit handler (pointerup / touchend). The ref carries the
|
||
// captured (centerX, centerY) plus the element id so a selection
|
||
// change between drag-start and drag-end doesn't accidentally
|
||
// reuse a stale center for the new element.
|
||
const sliderCenterRef = useRef(null);
|
||
|
||
if (!element) return null;
|
||
|
||
// Crop-mode UI — replaces everything else. Two prominent buttons so
|
||
// the user can't miss the resolution of the in-progress crop. Apply
|
||
// is the primary action (filled pink), Cancel is secondary (outlined).
|
||
// Title bar keeps the user oriented in what is effectively a modal
|
||
// state on the toolbar surface.
|
||
if (cropping) {
|
||
return (
|
||
<div className="el-toolbar el-toolbar--crop" role="region" aria-label="Crop image">
|
||
<div className="el-toolbar__crop-title">
|
||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<path d="M6 2v14a2 2 0 0 0 2 2h14" />
|
||
<path d="M18 22V8a2 2 0 0 0-2-2H2" />
|
||
</svg>
|
||
Crop image
|
||
</div>
|
||
<div className="el-toolbar__crop-actions">
|
||
<button
|
||
type="button"
|
||
onClick={onCancelCrop}
|
||
className="el-toolbar__crop-btn el-toolbar__crop-btn--secondary"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onApplyCrop}
|
||
className="el-toolbar__crop-btn el-toolbar__crop-btn--primary"
|
||
>
|
||
Apply crop
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const opacity = element.opacity ?? 1;
|
||
const brightness = element.brightness ?? 0;
|
||
const contrast = element.contrast ?? 0;
|
||
const rotation = element.rotation ?? 0;
|
||
|
||
// Rotation slider range (mobile only). ±90° covers half a turn,
|
||
// which is the band where rotation-as-fine-tuning is most useful;
|
||
// beyond that the user typically wants the on-canvas rotate handle
|
||
// which can swing through the full 360°. Same legacy-aware
|
||
// clamping pattern as the brightness/contrast sliders: an element
|
||
// that's been rotated to e.g. 180° via the rotate handle will
|
||
// show the slider visually pegged at ±90 (whichever endpoint is
|
||
// closer) and the readout matches, but element.rotation in state
|
||
// stays at 180° until the user drags the slider. This means the
|
||
// first drag interaction will jump rotation to whatever the
|
||
// slider says — acceptable, because the slider IS the user's
|
||
// explicit "I want this rotation" gesture at that point.
|
||
const ROTATION_SLIDER_MAX = 90;
|
||
const rotationDisplay = Math.min(
|
||
ROTATION_SLIDER_MAX,
|
||
Math.max(-ROTATION_SLIDER_MAX, Math.round(rotation)),
|
||
);
|
||
|
||
// Brightness/contrast slider range. Originally ±100 (the full
|
||
// range Konva's filters accept), but in practice values beyond
|
||
// ±20 weren't producing useful adjustments — they just blew out
|
||
// the image. Narrowing the slider to ±20 gives finer control in
|
||
// the band where the adjustments actually read as photo edits
|
||
// rather than "that's broken" filter abuse. The underlying state
|
||
// still uses Konva's native scale (-1..+1 for brightness, ±100
|
||
// for contrast); we just don't expose the extremes through the
|
||
// slider.
|
||
//
|
||
// For legacy elements whose saved values still exceed ±20 (from
|
||
// designs created before this narrowing), clamp both the slider
|
||
// position AND the displayed integer so the UI stays consistent.
|
||
// The underlying state is left alone until the user actually
|
||
// interacts with the slider; on first onChange the value commits
|
||
// within the new range and the legacy out-of-range value is
|
||
// overwritten. Clamping the DISPLAY (not just the slider) avoids
|
||
// the awkward case where a legacy brightness=0.5 element would
|
||
// show the slider pegged at 20 but the numeric readout saying
|
||
// "50" — looks like a bug, even though it's technically the
|
||
// true state.
|
||
const BC_SLIDER_MAX = 20;
|
||
const brightnessDisplay = Math.min(
|
||
BC_SLIDER_MAX,
|
||
Math.max(-BC_SLIDER_MAX, Math.round(brightness * 100)),
|
||
);
|
||
const contrastDisplay = Math.min(
|
||
BC_SLIDER_MAX,
|
||
Math.max(-BC_SLIDER_MAX, Math.round(contrast)),
|
||
);
|
||
|
||
const sizePct = (() => {
|
||
if (!sizeAnchor) return 100;
|
||
if ((element.type === 'image' || element.type === 'sticker')
|
||
&& element.width && sizeAnchor.width) {
|
||
return Math.round((element.width / sizeAnchor.width) * 100);
|
||
}
|
||
if (element.type === 'text' && element.fontSize && sizeAnchor.fontSize) {
|
||
return Math.round((element.fontSize / sizeAnchor.fontSize) * 100);
|
||
}
|
||
return 100;
|
||
})();
|
||
|
||
const handleSizeChange = (pct) => {
|
||
if (!sizeAnchor) return;
|
||
const ratio = pct / 100;
|
||
if (element.type === 'image' || element.type === 'sticker') {
|
||
// Center-preserving scale.
|
||
//
|
||
// The slider's anchor is captured once per element id and the
|
||
// raw target dimensions are computed from it — same as before.
|
||
// What's new is that we ALSO emit x/y so the element's visual
|
||
// center stays fixed across the resize. Without this, raw
|
||
// width/height updates anchor at the element's top-left (x/y
|
||
// are unchanged), so dragging the slider up makes the element
|
||
// appear to grow rightward and downward; dragging down shrinks
|
||
// toward the top-left. Users consistently expect a size slider
|
||
// to scale from the center the way the rotate handle pivots
|
||
// around the center.
|
||
//
|
||
// Math: current center is (x + W/2, y + H/2) — the same point
|
||
// that's the rotation pivot (offset is always-center, see the
|
||
// docblock in ImageElement.jsx). We keep that center fixed by
|
||
// computing newX = center.x - newW/2, newY = center.y - newH/2.
|
||
// Works identically for rotated and unrotated elements: the
|
||
// center point is rotation-invariant under the center-offset
|
||
// convention.
|
||
//
|
||
// Per-frame behavior during a continuous drag: the center is
|
||
// captured ONCE at the start of the drag session
|
||
// (sliderCenterRef.current is null → capture it) and reused
|
||
// for every subsequent frame in that session. Using a fixed
|
||
// captured center, rather than recomputing from each frame's
|
||
// committed state, avoids cumulative drift from Math.round's
|
||
// round-half-away-from-zero bias: each per-frame computation
|
||
// is independent of the previous frame's rounded result, so
|
||
// there's no chain of X.5 → X+1 promotions accumulating in a
|
||
// single direction. The captured center is cleared on commit
|
||
// (pointerup / touchend), so the next drag session captures
|
||
// afresh — picking up any position changes the user made
|
||
// between sessions (e.g. dragging the element on canvas
|
||
// between two slider sessions).
|
||
//
|
||
// Floor at 20px matches the existing MIN_ELEMENT_SIZE
|
||
// convention; rounding to integers matches the rest of the
|
||
// codebase (handleApplyCrop, handlePhotoEditComplete, etc.)
|
||
// — sub-pixel x/y values don't render visibly differently and
|
||
// clutter the state shape unnecessarily.
|
||
const newWidth = Math.max(20, Math.round(sizeAnchor.width * ratio));
|
||
const newHeight = Math.max(20, Math.round(sizeAnchor.height * ratio));
|
||
|
||
// Capture the center on the first frame of a new drag session.
|
||
// The element-id check guards against a selection change mid-
|
||
// session (the previous element's center would be stale for
|
||
// the new selection — reset and recapture).
|
||
const capturedSession = sliderCenterRef.current;
|
||
let centerX, centerY;
|
||
if (
|
||
capturedSession
|
||
&& capturedSession.elementId === element.id
|
||
) {
|
||
centerX = capturedSession.centerX;
|
||
centerY = capturedSession.centerY;
|
||
} else {
|
||
const currentWidth = element.width ?? sizeAnchor.width;
|
||
const currentHeight = element.height ?? sizeAnchor.height;
|
||
centerX = (element.x ?? 0) + currentWidth / 2;
|
||
centerY = (element.y ?? 0) + currentHeight / 2;
|
||
sliderCenterRef.current = {
|
||
elementId: element.id,
|
||
centerX,
|
||
centerY,
|
||
};
|
||
}
|
||
|
||
onUpdate({
|
||
x: Math.round(centerX - newWidth / 2),
|
||
y: Math.round(centerY - newHeight / 2),
|
||
width: newWidth,
|
||
height: newHeight,
|
||
});
|
||
} else if (element.type === 'text') {
|
||
// Text doesn't need explicit center-preservation math here —
|
||
// the App-level updateSelectedElement wrapper runs
|
||
// withTextCenterPreservation on every text patch, and that
|
||
// helper detects a fontSize change and injects matching x/y
|
||
// that preserve the rendered bbox's visual center. So sending
|
||
// just { fontSize } from here is equivalent to the explicit
|
||
// x/y math above — without duplicating the text-width
|
||
// measurement logic (which depends on font metrics and lives
|
||
// in textGeometry.js).
|
||
onUpdate({ fontSize: Math.max(12, Math.round(sizeAnchor.fontSize * ratio)) });
|
||
}
|
||
};
|
||
|
||
// Commit handler for the size slider. Released on pointerup /
|
||
// touchend, it pushes the in-progress state to history (so undo
|
||
// collapses the whole drag into one entry) AND clears the captured-
|
||
// center ref so the next drag session re-anchors against the current
|
||
// element state. Wrapping onCommit + ref-clear together ensures the
|
||
// ref doesn't leak past the gesture boundary in any release path.
|
||
const handleSizeCommit = () => {
|
||
sliderCenterRef.current = null;
|
||
onCommit?.();
|
||
};
|
||
|
||
// Per-slider "is this changed from default" flags, used to
|
||
// conditionally render a small reset button on the right of each
|
||
// slider row. The float tolerances on opacity / brightness /
|
||
// contrast guard against floating-point noise creeping in via the
|
||
// slider's /100 division — without tolerance, dragging a slider
|
||
// back to 0 (or 100% for opacity) could leave a residual 0.000003
|
||
// that keeps the reset button stuck visible. Size uses an exact
|
||
// check because sizePct is already rounded to an int above.
|
||
//
|
||
// "Default" semantics per slider:
|
||
// • Opacity → 1 (fully opaque)
|
||
// • Size → 100% of the sizeAnchor captured at first
|
||
// render. Reset = handleSizeChange(100), which
|
||
// restores the element to the dimensions it
|
||
// had when the toolbar first mounted for it.
|
||
// Not "original-at-upload-time" if the user
|
||
// resized via canvas drag before opening the
|
||
// toolbar — the anchor is the toolbar-mount
|
||
// reference, same as the slider's 100% point.
|
||
// • Brightness → 0 (no shift). Detection compares against the
|
||
// underlying state, not the clamped display, so
|
||
// legacy brightness=0.5 elements still surface
|
||
// the reset button even though the slider is
|
||
// pegged at the new ±20 limit.
|
||
// • Contrast → 0 (no shift). Same legacy-aware detection as
|
||
// brightness.
|
||
// • Rotation → 0 (no rotation). Detected against the raw
|
||
// underlying state for the same reason as
|
||
// brightness/contrast — a legacy element
|
||
// rotated past ±90 still surfaces the reset.
|
||
const opacityChanged = Math.abs(opacity - 1) > 0.001;
|
||
const sizeChanged = sizePct !== 100;
|
||
const brightnessChanged = Math.abs(brightness) > 0.001;
|
||
const contrastChanged = Math.abs(contrast) > 0.5;
|
||
const rotationChanged = Math.abs(rotation) > 0.5;
|
||
|
||
const isText = element.type === 'text';
|
||
const isImage = element.type === 'image';
|
||
const isSticker = element.type === 'sticker';
|
||
const allowFlip = isImage;
|
||
const allowCrop = isImage && !!onStartCrop;
|
||
// Undo-crop is only meaningful when there's an existing crop to
|
||
// revert. element.crop is set by handleApplyCrop in App.jsx to a
|
||
// {sx, sy, sWidth, sHeight} object; absent (or undefined) means
|
||
// the image is rendering its full natural pixels and there's
|
||
// nothing for "undo" to do.
|
||
const allowUndoCrop = isImage && !!element.crop && !!onUndoCrop;
|
||
// Photo surfaces (filters, background removal, brightness/contrast)
|
||
// are image-only.
|
||
const isPhoto = isImage;
|
||
// Sliders apply to any element with a size dimension.
|
||
const hasSlidableSize = isText || isImage || isSticker;
|
||
|
||
return (
|
||
<div className="el-toolbar" role="region" aria-label="Element controls">
|
||
<div className="el-toolbar__row el-toolbar__actions">
|
||
{isText && isMobile && onEditText && (
|
||
<ToolbarButton label="Edit text" onClick={() => onEditText(element)}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<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>
|
||
<span className="el-toolbar__btn-label">Edit text</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{allowFlip && (
|
||
<ToolbarButton label="Flip horizontally" onClick={() => onUpdate({ flipX: !element.flipX })}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12 3v18" />
|
||
<path d="M3 8l4-4v16l-4-4" />
|
||
<path d="M21 8l-4-4v16l4-4" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Flip H</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{allowFlip && (
|
||
<ToolbarButton label="Flip vertically" onClick={() => onUpdate({ flipY: !element.flipY })}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 12h18" />
|
||
<path d="M8 3l-4 4h16l-4-4" />
|
||
<path d="M8 21l-4-4h16l-4 4" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Flip V</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{/* Crop button — opens on-canvas crop UI. Image elements only.
|
||
Sits next to Flip H/V because conceptually they're all
|
||
"geometry of the rendered image" controls. */}
|
||
{allowCrop && (
|
||
<ToolbarButton label="Crop image" onClick={() => onStartCrop(element)}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
{/* Crop glyph — two L-shapes (corner marks) that read as
|
||
"select a region to keep". Standard crop icon used by
|
||
Photoshop, Lightroom, Figma, etc. */}
|
||
<path d="M6 2v14a2 2 0 0 0 2 2h14" />
|
||
<path d="M18 22V8a2 2 0 0 0-2-2H2" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Crop</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{/* Undo crop — only visible when the element has an existing
|
||
crop applied. Reverts the most recent crop operation:
|
||
restores the pre-crop dimensions and (for multi-crop
|
||
sequences) the prior crop value, so the user walks back
|
||
one step rather than unwinding all the way to uncropped.
|
||
Targeted alternative to Cmd+Z, which would also undo any
|
||
unrelated edits made after the crop. */}
|
||
{allowUndoCrop && (
|
||
<ToolbarButton label="Undo crop" onClick={() => onUndoCrop(element)}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
{/* Counter-clockwise curved arrow — standard "undo /
|
||
revert" glyph. Reads as "go back one step", which
|
||
matches the single-step undo semantic this button
|
||
implements. */}
|
||
<path d="M3 12 A 9 9 0 1 0 6 5.3" />
|
||
<path d="M3 4 v6 h6" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Undo crop</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{/* Remove Background — image-only. Moved here from the bottom
|
||
"advanced" section so the user can reach it at the same
|
||
visual level as Flip and Crop. The compact variant renders
|
||
as a `.el-toolbar__btn` matching the buttons around it; the
|
||
wider pill was replaced after brightness/contrast/crop
|
||
became reachable on-canvas, since the previous "advanced"
|
||
section is now sparse enough that a single dominant pill
|
||
looked out of place. */}
|
||
{isPhoto && (
|
||
<BackgroundRemovalButton
|
||
selectedElement={element}
|
||
onUpdate={(_id, attrs) => onUpdate(attrs)}
|
||
variant="compact"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Layer controls — MOBILE ONLY.
|
||
*
|
||
* Desktop users have the LayersPanel in the right-rail sidebar
|
||
* with per-row Duplicate / Lock / Up / Down / Delete buttons,
|
||
* and keyboard shortcuts (Cmd+D, [, ], Backspace) covering the
|
||
* same ground. On mobile, the LayersPanel lives inside the
|
||
* bottom sheet — several taps away from the canvas and not
|
||
* visible while the user is working with the selected element.
|
||
* Surfacing the same operations as a labelled section on the
|
||
* floating toolbar removes that round trip.
|
||
*
|
||
* Section is gated on `isMobile` so it's invisible on desktop
|
||
* (where it would be redundant with the sidebar layers panel)
|
||
* and per-button on each handler's presence so a caller that
|
||
* forgets to wire a single op gets a quietly-shorter row
|
||
* rather than a broken click. Lock is always present because
|
||
* it uses the always-wired onUpdate path — same pattern as
|
||
* the Flip H / Flip V buttons in the action row above. */}
|
||
{isMobile && (
|
||
<div className="el-toolbar__layer-section" role="region" aria-label="Layer controls">
|
||
<div className="el-toolbar__section-title">Layer controls</div>
|
||
<div className="el-toolbar__row el-toolbar__layer-actions">
|
||
{onDuplicate && (
|
||
<ToolbarButton label="Duplicate" onClick={() => onDuplicate(element)}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
{/* Two overlapping rounded squares — the classic
|
||
"copy" / "duplicate" glyph. Front square solid,
|
||
back square's visible edges sketched so the
|
||
stacking reads even at 18×18. */}
|
||
<rect x="8" y="8" width="13" height="13" rx="2" />
|
||
<path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Duplicate</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{/* Lock toggle. Uses the existing onUpdate/onCommit path
|
||
* rather than a dedicated handler — same pattern as the
|
||
* Flip H / Flip V buttons in the action row above. Icon
|
||
* mirrors the CURRENT state (closed lock when locked,
|
||
* open lock when unlocked) and the label reads as the
|
||
* action the click will perform, so the affordance is
|
||
* "I am [state], click to [other state]". */}
|
||
<ToolbarButton
|
||
label={element.locked ? 'Unlock layer' : 'Lock layer'}
|
||
onClick={() => { onUpdate({ locked: !element.locked }); onCommit?.(); }}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||
{element.locked ? (
|
||
/* Closed shackle — full arch over the lock body. */
|
||
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||
) : (
|
||
/* Open shackle — arch swung partway open, only the
|
||
left side still attached to the body. */
|
||
<path d="M8 10V7a4 4 0 0 1 7.4-2.3" />
|
||
)}
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">{element.locked ? 'Unlock' : 'Lock'}</span>
|
||
</ToolbarButton>
|
||
|
||
{onBringForward && (
|
||
<ToolbarButton label="Move above" onClick={() => onBringForward(element)}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
{/* Rounded square (the layer) with a chevron-up
|
||
inside (the direction). Matches the down-chevron
|
||
sibling below for visual symmetry. */}
|
||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||
<polyline points="8 13 12 9 16 13" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Above</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{onSendBackward && (
|
||
<ToolbarButton label="Move below" onClick={() => onSendBackward(element)}>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||
<polyline points="8 11 12 15 16 11" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Below</span>
|
||
</ToolbarButton>
|
||
)}
|
||
|
||
{onDelete && (
|
||
<ToolbarButton variant="danger" label="Delete layer" onClick={() => onDelete(element)}>
|
||
{/* Trash glyph — same shape used by the UploadTab's
|
||
per-thumb delete and the LayersPanel row delete,
|
||
so users see one consistent "delete" affordance
|
||
across the editor. */}
|
||
<svg viewBox="0 0 24 24" width="18" height="18" 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" />
|
||
</svg>
|
||
<span className="el-toolbar__btn-label">Delete</span>
|
||
</ToolbarButton>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(hasSlidableSize) && (
|
||
<div className="el-toolbar__sliders">
|
||
<div className="el-toolbar__slider-row">
|
||
<label className="el-toolbar__slider-label">Opacity</label>
|
||
<div className="el-toolbar__slider-track">
|
||
<SliderEndIcon position="start" kind="opacity" />
|
||
<input
|
||
type="range"
|
||
min="10"
|
||
max="100"
|
||
step="1"
|
||
value={Math.round(opacity * 100)}
|
||
onChange={(e) => onUpdate({ opacity: parseInt(e.target.value, 10) / 100 })}
|
||
onMouseUp={() => onCommit?.()}
|
||
onTouchEnd={() => onCommit?.()}
|
||
className="el-toolbar__slider"
|
||
/>
|
||
<SliderEndIcon position="end" kind="opacity" />
|
||
</div>
|
||
<div className="el-toolbar__slider-end">
|
||
<span className="el-toolbar__slider-value">{Math.round(opacity * 100)}%</span>
|
||
{opacityChanged && (
|
||
<SliderResetButton
|
||
label="Reset opacity"
|
||
onClick={() => { onUpdate({ opacity: 1 }); onCommit?.(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="el-toolbar__slider-row el-toolbar__slider-row--size">
|
||
<label className="el-toolbar__slider-label">Size</label>
|
||
<div className="el-toolbar__slider-track">
|
||
<SliderEndIcon position="start" kind="size" />
|
||
<input
|
||
type="range"
|
||
min="10"
|
||
max="200"
|
||
step="1"
|
||
value={Math.min(200, Math.max(10, sizePct))}
|
||
onChange={(e) => handleSizeChange(parseInt(e.target.value, 10))}
|
||
onMouseUp={handleSizeCommit}
|
||
onTouchEnd={handleSizeCommit}
|
||
className="el-toolbar__slider"
|
||
/>
|
||
<SliderEndIcon position="end" kind="size" />
|
||
</div>
|
||
<div className="el-toolbar__slider-end">
|
||
<span className="el-toolbar__slider-value">{Math.min(200, Math.max(10, sizePct))}%</span>
|
||
{sizeChanged && (
|
||
<SliderResetButton
|
||
label="Reset size"
|
||
onClick={() => { handleSizeChange(100); handleSizeCommit(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Rotation — MOBILE ONLY.
|
||
*
|
||
* Desktop users have the on-canvas rotate handle on the
|
||
* Konva Transformer, plus shift-to-snap-to-15° for
|
||
* keyboard-precision rotation. On mobile the rotate handle
|
||
* sits above the bbox in a position that competes with the
|
||
* top-of-screen toolbar at smaller element sizes; this
|
||
* slider is the alternative entry point for rotation
|
||
* without needing pixel-precise touch on the handle.
|
||
*
|
||
* Range is ±90° (see ROTATION_SLIDER_MAX docblock). For
|
||
* larger rotations the user falls back to the canvas
|
||
* handle. Same per-slider grid + reset pattern as the
|
||
* other rows.
|
||
*
|
||
* Gated on isMobile so the desktop floating toolbar stays
|
||
* unchanged — desktop rotation lives entirely on the
|
||
* canvas surface. The same row would visually crowd the
|
||
* already-busy desktop toolbar with a control desktop
|
||
* users don't need. */}
|
||
{isMobile && (
|
||
<div className="el-toolbar__slider-row">
|
||
<label className="el-toolbar__slider-label">Rotation</label>
|
||
<div className="el-toolbar__slider-track">
|
||
<SliderEndIcon position="start" kind="rotation" />
|
||
<input
|
||
type="range"
|
||
min={-ROTATION_SLIDER_MAX}
|
||
max={ROTATION_SLIDER_MAX}
|
||
step="1"
|
||
value={rotationDisplay}
|
||
onChange={(e) => onUpdate({ rotation: parseInt(e.target.value, 10) })}
|
||
onMouseUp={() => onCommit?.()}
|
||
onTouchEnd={() => onCommit?.()}
|
||
className="el-toolbar__slider"
|
||
/>
|
||
<SliderEndIcon position="end" kind="rotation" />
|
||
</div>
|
||
<div className="el-toolbar__slider-end">
|
||
<span className="el-toolbar__slider-value">{rotationDisplay}°</span>
|
||
{rotationChanged && (
|
||
<SliderResetButton
|
||
label="Reset rotation"
|
||
onClick={() => { onUpdate({ rotation: 0 }); onCommit?.(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Brightness + Contrast — image-only, ride the same slider
|
||
grid as Opacity/Size for visual consistency. Slider range
|
||
is ±20 (narrowed from Konva's native ±100 / ±1 ranges
|
||
because values beyond ~20 stopped reading as photo edits
|
||
and started looking like filter abuse). Underlying state
|
||
still uses Konva's scale: brightness ÷ 100 to land in
|
||
-1..+1, contrast direct. The displayed integer is
|
||
clamped via BC_SLIDER_MAX so legacy elements with
|
||
out-of-range saved values render with the slider and
|
||
readout in agreement — the underlying state isn't
|
||
touched until the user actually drags. */}
|
||
{isPhoto && (
|
||
<div className="el-toolbar__slider-row">
|
||
<label className="el-toolbar__slider-label">Brightness</label>
|
||
<div className="el-toolbar__slider-track">
|
||
<SliderEndIcon position="start" kind="brightness" />
|
||
<input
|
||
type="range"
|
||
min={-BC_SLIDER_MAX}
|
||
max={BC_SLIDER_MAX}
|
||
step="1"
|
||
value={brightnessDisplay}
|
||
onChange={(e) => onUpdate({ brightness: parseInt(e.target.value, 10) / 100 })}
|
||
onMouseUp={() => onCommit?.()}
|
||
onTouchEnd={() => onCommit?.()}
|
||
className="el-toolbar__slider"
|
||
/>
|
||
<SliderEndIcon position="end" kind="brightness" />
|
||
</div>
|
||
<div className="el-toolbar__slider-end">
|
||
<span className="el-toolbar__slider-value">{brightnessDisplay}</span>
|
||
{brightnessChanged && (
|
||
<SliderResetButton
|
||
label="Reset brightness"
|
||
onClick={() => { onUpdate({ brightness: 0 }); onCommit?.(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isPhoto && (
|
||
<div className="el-toolbar__slider-row">
|
||
<label className="el-toolbar__slider-label">Contrast</label>
|
||
<div className="el-toolbar__slider-track">
|
||
<SliderEndIcon position="start" kind="contrast" />
|
||
<input
|
||
type="range"
|
||
min={-BC_SLIDER_MAX}
|
||
max={BC_SLIDER_MAX}
|
||
step="1"
|
||
value={contrastDisplay}
|
||
onChange={(e) => onUpdate({ contrast: parseInt(e.target.value, 10) })}
|
||
onMouseUp={() => onCommit?.()}
|
||
onTouchEnd={() => onCommit?.()}
|
||
className="el-toolbar__slider"
|
||
/>
|
||
<SliderEndIcon position="end" kind="contrast" />
|
||
</div>
|
||
<div className="el-toolbar__slider-end">
|
||
<span className="el-toolbar__slider-value">{contrastDisplay}</span>
|
||
{contrastChanged && (
|
||
<SliderResetButton
|
||
label="Reset contrast"
|
||
onClick={() => { onUpdate({ contrast: 0 }); onCommit?.(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter chips — image-only AND desktop-only. The "advanced"
|
||
section used to also hold the Edit Photo wide pill and the
|
||
wide Background Removal pill; both are gone now (Edit Photo
|
||
replaced by on-canvas tools, BG removal moved to the top row
|
||
in compact form), so this section is just the filter chips.
|
||
Kept as its own row with the dashed top border for visual
|
||
grouping.
|
||
|
||
Hidden on mobile per user feedback: the four presets
|
||
(original, B&W, sepia, warm) weren't seeing meaningful use,
|
||
and the section's vertical footprint was eating space on
|
||
the floating mobile toolbar that's better spent on the
|
||
more-used controls (layer ops, sliders). Slight degradation
|
||
in mobile functionality — mobile users can still apply
|
||
filters from the bottom-sheet sidebar if they need them —
|
||
accepted in exchange for a tighter floating toolbar.
|
||
|
||
The dashed top border lives on `.el-toolbar__advanced`
|
||
itself, so hiding the section also removes its separator,
|
||
and the sliders above end without a trailing rule on
|
||
mobile (cleaner endpoint for the toolbar). */}
|
||
{isPhoto && !isMobile && (
|
||
<div className="el-toolbar__advanced">
|
||
<div className="el-toolbar__filters" role="radiogroup" aria-label="Image filter">
|
||
{IMAGE_FILTERS.map((preset) => {
|
||
const isActive = (element.filter ?? 'none') === preset.id;
|
||
return (
|
||
<button
|
||
key={preset.id}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={isActive}
|
||
className={`el-toolbar__filter${isActive ? ' is-active' : ''}`}
|
||
onClick={() => { onUpdate({ filter: preset.id }); onCommit?.(); }}
|
||
title={preset.label}
|
||
>
|
||
<span className="el-toolbar__filter-icon" aria-hidden="true">{preset.icon}</span>
|
||
<span className="el-toolbar__filter-label">{preset.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ToolbarButton({ children, onClick, label, variant }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
className={`el-toolbar__btn${variant ? ` el-toolbar__btn--${variant}` : ''}`}
|
||
aria-label={label}
|
||
title={label}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Small icon-only button rendered in the 4th grid column of a slider
|
||
* row when the slider's value differs from its default. Single-tap
|
||
* resets that slider to default (1 for opacity, 100% of anchor for
|
||
* size, 0 for brightness/contrast). The caller passes both the
|
||
* click handler and the aria/title label — we don't infer the
|
||
* action label from context here because it needs to read
|
||
* meaningfully to screen readers ("Reset opacity", "Reset brightness")
|
||
* rather than a generic "Reset".
|
||
*
|
||
* Icon is the rotate-ccw glyph (counter-clockwise circular arrow
|
||
* with a tail-arrowhead at the top-left), the conventional "reset
|
||
* to default" / "undo state" visual in design tools. Distinct from
|
||
* the toolbar's Undo Crop icon (which is a different shape) so the
|
||
* two don't blur together: Undo Crop reverts one crop op on the
|
||
* whole element, while this reset reverts a single slider on the
|
||
* selected element.
|
||
*
|
||
* 14×14 SVG is small enough to fit comfortably in the 24px-wide
|
||
* reset column on the slider row, with the button's currentColor
|
||
* inheriting the muted secondary text color until hover.
|
||
*/
|
||
function SliderResetButton({ onClick, label }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="el-toolbar__slider-reset"
|
||
onClick={onClick}
|
||
aria-label={label}
|
||
title={label}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||
<path d="M3 3v5h5" />
|
||
</svg>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Tiny informational icon rendered flanking the slider track on each
|
||
* end. Indicates direction ("this end of the slider produces less of
|
||
* the thing; the other end produces more") so the user can read the
|
||
* control's behavior at a glance without having to drag it first.
|
||
*
|
||
* Five `kind` values, each with start (left/min) and end (right/max)
|
||
* variants:
|
||
*
|
||
* • opacity — dim square → solid square
|
||
* • size — small square → large square
|
||
* • rotation — ccw curved arrow → cw curved arrow
|
||
* • brightness — sun with few rays → sun with many rays
|
||
* • contrast — soft-split half-circle → hard-split half-circle
|
||
*
|
||
* Styling notes:
|
||
* • Not interactive (no click handler, no hover, no focus ring,
|
||
* no cursor change). Pure visual hint, like a slider tick mark.
|
||
* `aria-hidden="true"` plus no tabindex keeps them out of the
|
||
* focus order; screen readers get the meaning from the slider
|
||
* row's label ("Opacity", "Size", etc.) which already conveys
|
||
* what the control does. Adding redundant aria descriptions for
|
||
* the end icons would just make the AT readout chatty without
|
||
* adding information.
|
||
* • Same 14×14 SVG size as the reset button, sharing the same
|
||
* muted text-secondary color, so the slider row's right-hand
|
||
* icons (reset) and inner icons (end hints) read as part of
|
||
* the same visual family.
|
||
* • The CSS class `.el-toolbar__slider-end-icon` carries the
|
||
* layout + sizing rules. The `--start` and `--end` modifier
|
||
* classes are present in case future styling needs to
|
||
* distinguish them (e.g. mirroring an asymmetric glyph), but
|
||
* today they share the same rules.
|
||
*/
|
||
function SliderEndIcon({ kind, position }) {
|
||
const className = `el-toolbar__slider-end-icon el-toolbar__slider-end-icon--${position}`;
|
||
const svgProps = {
|
||
viewBox: '0 0 24 24',
|
||
width: 14,
|
||
height: 14,
|
||
fill: 'none',
|
||
stroke: 'currentColor',
|
||
strokeWidth: 2,
|
||
strokeLinecap: 'round',
|
||
strokeLinejoin: 'round',
|
||
'aria-hidden': true,
|
||
};
|
||
|
||
if (kind === 'opacity') {
|
||
// Two filled circles of identical geometric size, differing only
|
||
// in fill opacity. The start (less opaque) variant is currentColor
|
||
// at ~30% opacity — reads as a faded version of the same shape;
|
||
// the end (fully opaque) variant is currentColor at 100%. The
|
||
// icon itself IS its own legend: the user sees a faded dot and a
|
||
// solid dot, immediately matching the slider's behavior.
|
||
//
|
||
// No stroke on either version. Earlier iterations used a dashed
|
||
// stroke on the start variant and a solid fill on the end
|
||
// variant, but (a) dashed-outline vs solid-fill compared two
|
||
// different visual languages rather than two states of the same
|
||
// shape, making the relationship less clear; and (b) the
|
||
// stroked circle ended up visually LARGER than the filled
|
||
// circle, because a stroke sits half outside the geometric
|
||
// radius — a `r=7` stroked circle spans ~16px diameter (14 + 2
|
||
// for the stroke), while a `r=7` filled circle spans 14px
|
||
// diameter. Dropping the stroke on both makes the two circles
|
||
// identical except for opacity, which is exactly the visual
|
||
// contract the slider promises.
|
||
//
|
||
// currentColor at 0.3 on the start variant means the icon
|
||
// inherits the muted text-secondary color set on the end-icon
|
||
// container, then attenuates it to ~30% — enough to read as
|
||
// "present but faded" without disappearing into the toolbar
|
||
// background. The end variant gets currentColor at 1.0 (the
|
||
// default), so it lands at the same muted color as the rest of
|
||
// the slider end icons.
|
||
return position === 'start' ? (
|
||
<span className={className}>
|
||
<svg {...svgProps} fill="currentColor" fillOpacity="0.3" stroke="none">
|
||
<circle cx="12" cy="12" r="7" />
|
||
</svg>
|
||
</span>
|
||
) : (
|
||
<span className={className}>
|
||
<svg {...svgProps} fill="currentColor" stroke="none">
|
||
<circle cx="12" cy="12" r="7" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (kind === 'size') {
|
||
// Small circle (start = smaller) vs large circle (end = larger).
|
||
// Circle outlines (not filled) so the icon doesn't read as a solid
|
||
// dot at small sizes — the user's attention should be on the
|
||
// size comparison, not the contrast against the toolbar background.
|
||
// Matching the circle vocabulary used by opacity / brightness /
|
||
// contrast in this same row.
|
||
return position === 'start' ? (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
<circle cx="12" cy="12" r="4" />
|
||
</svg>
|
||
</span>
|
||
) : (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
<circle cx="12" cy="12" r="9" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (kind === 'rotation') {
|
||
// Curved arrows showing rotation direction. The slider's left end
|
||
// (start) is the smaller numerical value and the right end (end)
|
||
// is the larger one, but rotation icons are about *direction* not
|
||
// magnitude — so we pair each side with the rotation sense the
|
||
// user expects to see when dragging toward that end.
|
||
//
|
||
// Empirically: the user reads the left icon as "this end rotates
|
||
// it this way" and expects the curved arrow's direction to match
|
||
// their mental model of which way the element will spin. The
|
||
// previous mapping had these swapped (left showed CCW, right
|
||
// showed CW); switching to left=CW / right=CCW matches what the
|
||
// user expects.
|
||
//
|
||
// The SVG paths themselves are unchanged — only which side gets
|
||
// which path swapped. Tail-arrow positions encode direction:
|
||
// • "M3 12a9 9 0 1 0 9-9 ..." + "M3 3v5h5" — arc traveled
|
||
// counter-clockwise from the start point, tail at top-left.
|
||
// • "M21 12a9 9 0 1 1-9-9 ..." + "M21 3v5h-5" — arc traveled
|
||
// clockwise from the start point, tail at top-right.
|
||
return position === 'start' ? (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
{/* Clockwise arrow: tail-arrowhead at top-left. */}
|
||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||
<path d="M3 3v5h5" />
|
||
</svg>
|
||
</span>
|
||
) : (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
{/* Counter-clockwise arrow: tail-arrowhead at top-right. */}
|
||
<path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||
<path d="M21 3v5h-5" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (kind === 'brightness') {
|
||
// Sun glyphs with different ray densities. Start (less bright) =
|
||
// sun with 4 rays at cardinal positions; end (more bright) = sun
|
||
// with 8 rays. Same center circle radius for both so the
|
||
// comparison reads as "the same sun, brighter or dimmer" rather
|
||
// than "two different suns."
|
||
return position === 'start' ? (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
<circle cx="12" cy="12" r="4" />
|
||
{/* 4 rays — N, E, S, W. */}
|
||
<path d="M12 3v2" />
|
||
<path d="M12 19v2" />
|
||
<path d="M3 12h2" />
|
||
<path d="M19 12h2" />
|
||
</svg>
|
||
</span>
|
||
) : (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
<circle cx="12" cy="12" r="4" />
|
||
{/* 8 rays — cardinal + diagonal. */}
|
||
<path d="M12 3v2" />
|
||
<path d="M12 19v2" />
|
||
<path d="M3 12h2" />
|
||
<path d="M19 12h2" />
|
||
<path d="M5.6 5.6l1.4 1.4" />
|
||
<path d="M17 17l1.4 1.4" />
|
||
<path d="M5.6 18.4l1.4-1.4" />
|
||
<path d="M17 7l1.4-1.4" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (kind === 'contrast') {
|
||
// Half-filled circle, the standard contrast glyph. Start (less
|
||
// contrast) shows a thin diagonal stripe shaded inside the circle,
|
||
// suggesting a softer dark/light split; end (more contrast) shows
|
||
// the full half-filled circle with a sharp vertical split between
|
||
// the dark and light halves. Both share the same outer circle so
|
||
// they read as the same "contrast indicator," different intensities.
|
||
return position === 'start' ? (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
<circle cx="12" cy="12" r="9" />
|
||
{/* Partial fill on the left half, with a softer (smaller)
|
||
area than the end variant — reads as "some contrast but
|
||
not maximal." Implemented as a clipped half-disk via a
|
||
path drawing the left semicircle. Stroke removed on the
|
||
fill path so it blends visually with the circle outline
|
||
rather than doubling up the silhouette. */}
|
||
<path d="M12 6 a 6 6 0 0 0 0 12 z" fill="currentColor" stroke="none" opacity="0.55" />
|
||
</svg>
|
||
</span>
|
||
) : (
|
||
<span className={className}>
|
||
<svg {...svgProps}>
|
||
<circle cx="12" cy="12" r="9" />
|
||
{/* Full-strength half-fill: left semicircle solid, right
|
||
left empty. The hard split between filled and empty
|
||
halves at the vertical centerline reads as the maximum-
|
||
contrast state. */}
|
||
<path d="M12 3 a 9 9 0 0 0 0 18 z" fill="currentColor" stroke="none" />
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|