From 7e6684bbc8b7946a35584698e3bd1cf0d699aa6a Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 6 Mar 2026 10:34:31 +0100 Subject: [PATCH] zone label edition --- apps/editor/components/editor/index.tsx | 2 + .../systems/zone/zone-label-editor-system.tsx | 184 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 apps/editor/components/systems/zone/zone-label-editor-system.tsx diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index ff8af373..ee41bdd3 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -11,6 +11,7 @@ import useEditor from '@/store/use-editor' import { FeedbackDialog } from '../feedback-dialog' import { PascalRadio } from '../pascal-radio' import { CeilingSystem } from '../systems/ceiling/ceiling-system' +import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system' import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' @@ -153,6 +154,7 @@ export default function Editor({ projectId }: EditorProps) { + ) diff --git a/apps/editor/components/systems/zone/zone-label-editor-system.tsx b/apps/editor/components/systems/zone/zone-label-editor-system.tsx new file mode 100644 index 00000000..33881f0e --- /dev/null +++ b/apps/editor/components/systems/zone/zone-label-editor-system.tsx @@ -0,0 +1,184 @@ +'use client' + +import { useScene, type ZoneNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Check, Pencil } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' +import useEditor from '@/store/use-editor' + +// ─── Per-zone label editor ──────────────────────────────────────────────────── + +function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { + const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined) + const updateNode = useScene((s) => s.updateNode) + const setSelection = useViewer((s) => s.setSelection) + const [editing, setEditing] = useState(false) + const [value, setValue] = useState('') + const inputRef = useRef(null) + const [labelEl, setLabelEl] = useState(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) => ( + + ))} + + ) +}