zone label edition

This commit is contained in:
wass08
2026-03-06 13:09:23 +01:00
parent 849baa0a0e
commit 7e6684bbc8
2 changed files with 186 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ import useEditor from '@/store/use-editor'
import { FeedbackDialog } from '../feedback-dialog' import { FeedbackDialog } from '../feedback-dialog'
import { PascalRadio } from '../pascal-radio' import { PascalRadio } from '../pascal-radio'
import { CeilingSystem } from '../systems/ceiling/ceiling-system' import { CeilingSystem } from '../systems/ceiling/ceiling-system'
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
import { ZoneSystem } from '../systems/zone/zone-system' import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager' import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu' import { ActionMenu } from '../ui/action-menu'
@@ -153,6 +154,7 @@ export default function Editor({ projectId }: EditorProps) {
<PresetThumbnailGenerator /> <PresetThumbnailGenerator />
<SiteEdgeLabels /> <SiteEdgeLabels />
</Viewer> </Viewer>
<ZoneLabelEditorSystem />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
) )
@@ -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<HTMLInputElement>(null)
const [labelEl, setLabelEl] = useState<HTMLElement | null>(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 ? (
<div
style={sharedStyle}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => 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',
}}
/>
<button
type="button"
onClick={(e) => { e.stopPropagation(); save() }}
onMouseDown={(e) => e.stopPropagation()}
style={{
background: 'none',
border: 'none',
color: 'white',
cursor: 'pointer',
padding: 0,
display: 'inline-flex',
alignItems: 'center',
}}
>
<Check size={12} />
</button>
</div>
) : (
<button
type="button"
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
onClick={(e) => {
e.stopPropagation()
setSelection({ zoneId })
setValue(zoneNameRef.current)
setEditing(true)
}}
onMouseDown={(e) => e.stopPropagation()}
>
<span>{zone?.name}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
<Pencil size={10} />
</span>
</button>
),
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) => (
<ZoneLabelEditor key={id} zoneId={id} />
))}
</>
)
}