Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

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