Major update for v1 and tests
This commit is contained in:
286
src/components/canvas/TextEditAffordance.jsx
Normal file
286
src/components/canvas/TextEditAffordance.jsx
Normal file
@@ -0,0 +1,286 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user