(null)
+
+ // Keep a ref so the click handler never has a stale zone name
+ const zoneNameRef = useRef(zone?.name ?? '')
+ useEffect(() => { zoneNameRef.current = zone?.name ?? '' }, [zone?.name])
+
+ // Setup: find the label element, enable pointer events, and hide the
+ // zone-renderer's own text node (children[0]) — we replace it via portal.
+ useEffect(() => {
+ const el = document.getElementById(`${zoneId}-label`)
+ if (!el) return
+ setLabelEl(el)
+ el.style.pointerEvents = 'auto'
+
+ const textEl = el.children[0] as HTMLElement | undefined
+ if (textEl) textEl.style.display = 'none'
+
+ return () => {
+ el.style.pointerEvents = ''
+ if (textEl) textEl.style.display = ''
+ }
+ }, [zoneId])
+
+ // Focus + select-all when entering edit mode
+ useEffect(() => {
+ if (editing) {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }
+ }, [editing])
+
+ const save = useCallback(() => {
+ const trimmed = value.trim()
+ if (trimmed !== (zone?.name ?? '')) {
+ updateNode(zoneId, { name: trimmed || undefined })
+ }
+ setEditing(false)
+ }, [value, zone?.name, updateNode, zoneId])
+
+ const cancel = useCallback(() => {
+ setValue(zone?.name ?? '')
+ setEditing(false)
+ }, [zone?.name])
+
+ if (!labelEl) return null
+
+ const shadowColor = zone?.color ?? '#6366f1'
+ const textShadow = [
+ `-1px -1px 0 ${shadowColor}`,
+ ` 1px -1px 0 ${shadowColor}`,
+ `-1px 1px 0 ${shadowColor}`,
+ ` 1px 1px 0 ${shadowColor}`,
+ ].join(',')
+
+ // order: -1 puts this flex item before children[0] (hidden) and children[1] (pin)
+ const sharedStyle: React.CSSProperties = {
+ order: -1,
+ color: 'white',
+ textShadow,
+ fontSize: 14,
+ fontFamily: 'sans-serif',
+ userSelect: 'none',
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 4,
+ whiteSpace: 'nowrap',
+ }
+
+ return createPortal(
+ editing ? (
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+ setValue(e.target.value)}
+ onKeyDown={(e) => {
+ e.stopPropagation()
+ if (e.key === 'Enter') { e.preventDefault(); save() }
+ if (e.key === 'Escape') { e.preventDefault(); cancel() }
+ }}
+ onBlur={save}
+ onClick={(e) => e.stopPropagation()}
+ style={{
+ width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
+ border: 'none',
+ borderBottom: `1px solid ${shadowColor}`,
+ background: 'transparent',
+ color: 'white',
+ textShadow,
+ outline: 'none',
+ padding: 0,
+ margin: 0,
+ fontSize: 'inherit',
+ lineHeight: 'inherit',
+ fontFamily: 'inherit',
+ textAlign: 'center',
+ }}
+ />
+
+
+ ) : (
+
+ ),
+ labelEl,
+ )
+}
+
+// ─── System: rendered in the main React tree (outside Canvas) ─────────────────
+
+export function ZoneLabelEditorSystem() {
+ const zoneIds = useScene(
+ useShallow((s) =>
+ Object.values(s.nodes)
+ .filter((n) => n.type === 'zone')
+ .map((n) => n.id as ZoneNode['id']),
+ ),
+ )
+ const structureLayer = useEditor((s) => s.structureLayer)
+ const mode = useEditor((s) => s.mode)
+
+ if (structureLayer !== 'zones' || mode !== 'select') return null
+
+ return (
+ <>
+ {zoneIds.map((id) => (
+
+ ))}
+ >
+ )
+}