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

287 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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