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( , document.body, ); }