287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
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,
|
||
);
|
||
}
|