diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..6527ef8 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000, + "autoPort": true + } + ] +} diff --git a/app/editor/[name]/page.tsx b/app/editor/[name]/page.tsx new file mode 100644 index 0000000..d5c724e --- /dev/null +++ b/app/editor/[name]/page.tsx @@ -0,0 +1,18 @@ +import EditorApp from "@/components/editor/editor-app"; + +export default async function EditorPage({ + params, + searchParams, +}: { + params: Promise<{ name: string }>; + searchParams: Promise<{ mode?: string }>; +}) { + const { name } = await params; + const { mode } = await searchParams; + return ( + + ); +} diff --git a/app/globals.css b/app/globals.css index 208ce9b..fde56d5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -130,4 +130,25 @@ html { @apply font-sans; } -} \ No newline at end of file +} +/* UltimateUI-Editor: interaktive Elemente im Preview-Modus */ +.uui-interactive { + cursor: pointer; +} +.uui-interactive:hover { + filter: brightness(1.35); +} + +/* Minecraft-Look für Canvas-Texte (Monocraft, SIL OFL — public/fonts/) */ +@font-face { + font-family: "Monocraft"; + src: url("/fonts/Monocraft.ttf") format("truetype"); + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: "Monocraft"; + src: url("/fonts/Monocraft-Bold.ttf") format("truetype"); + font-weight: 700; + font-display: swap; +} diff --git a/app/layout.tsx b/app/layout.tsx index 2455dcc..4665b14 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -42,9 +42,11 @@ export default function RootLayout({ suppressHydrationWarning > - {children} - - + + {children} + + + ); diff --git a/app/page.tsx b/app/page.tsx index 5966bac..a8f680e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,14 @@ "use client"; +import CreateUiDialog from "@/components/home/create-ui-dialog"; import HomeCard from "@/components/home/home-card"; import SelectPageDialog from "@/components/page/select-page-dialog"; import { Button } from "@/components/ui/button"; import { CirclePlus, ExternalLink, Pen } from "lucide-react"; -import Link from "next/link"; export default function Home() { return ( -
+
} title="Edit existing UI" @@ -17,8 +17,8 @@ export default function Home() { Edit} title="Edit existing UI" - description="Edit the UI you previously created in the editor by clicking here" - footer={
} + description="Pick one of your saved UIs to continue editing." + mode="edit" /> } /> @@ -27,7 +27,7 @@ export default function Home() { icon={} title="Create UI" description="Create a new UI that your players will be able to use while playing on the server" - button={} + button={Create} />} /> Edit} + trigger={} title="Open UI" - description="Open the UI and see how it looks after finishing it in the editor" - footer={
} + description="Pick a saved UI to preview it like in game." + mode="preview" /> } /> diff --git a/app/providers.tsx b/app/providers.tsx index ba76cd5..33adb98 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -12,7 +12,7 @@ export default function Providers({ children }: PropsWithChildren) { defaultTheme="system" disableTransitionOnChange > - {children}; + {children} ); } diff --git a/components/editor/canvas.tsx b/components/editor/canvas.tsx new file mode 100644 index 0000000..d07b287 --- /dev/null +++ b/components/editor/canvas.tsx @@ -0,0 +1,1556 @@ +"use client"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + readImageSize, + sanitizeImageName, + saveAsset, + useAssetUrl, +} from "@/lib/uui/assets"; +import { type CreateKind, newElement } from "@/lib/uui/defaults"; +import { evalNumExpr } from "@/lib/uui/expr"; +import { parseTextMarkup, patchTextMarkup } from "@/lib/uui/textstyle"; +import { + cloneWithNewUids, + findByUid, + nestInto, + updateByUid, +} from "@/lib/uui/tree"; +import type { EditorElement } from "@/lib/uui/types"; +import { cn } from "@/lib/utils"; +import { Lock, MousePointerClick, Zap } from "lucide-react"; +import React from "react"; +import { useEditor } from "./store"; + +/* ------------------------------------------------------------------ */ +/* Konstanten (Werte wie im Ingame-Editor) */ +/* ------------------------------------------------------------------ */ + +/** Snap-Toleranz für Smart Guides in Screen-Pixeln (Photoshop-artig) */ +const GUIDE_SNAP_PX = 8; +/** Zoom-Faktoren: Linksklick ×1.12, Rechtsklick ×0.8929 */ +const ZOOM_IN_FACTOR = 1.12; +const ZOOM_OUT_FACTOR = 0.8929; +/** Bewegung in px (Screen), ab der ein Klick zum Drag wird */ +const DRAG_THRESHOLD = 3; + +/* ------------------------------------------------------------------ */ +/* Geometrie */ +/* ------------------------------------------------------------------ */ + +interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +function elementRect(el: EditorElement, parent?: Rect): Rect { + const px = parent?.x ?? 0; + const py = parent?.y ?? 0; + return { + x: px + evalNumExpr(el.position?.x), + y: py + evalNumExpr(el.position?.y), + w: evalNumExpr(el.size?.width, 0), + h: evalNumExpr(el.size?.height, 0), + }; +} + +function collectRects( + elements: EditorElement[], + parent?: Rect, + out: { uid: string; rect: Rect; el: EditorElement }[] = [], +): { uid: string; rect: Rect; el: EditorElement }[] { + for (const el of elements) { + const rect = elementRect(el, parent); + out.push({ uid: el._uid, rect, el }); + if (el.children) collectRects(el.children, rect, out); + } + return out; +} + +function stripMarkup(text: string): string { + return text + .replace(/<[^>]*>/g, "") + .replace(/[&§][0-9a-fk-orx]/gi, "") + .replace(/%%?[^%]*?%%?/g, (m) => m.replaceAll("%", "")); +} + +/** Findet das Eltern-Element eines uid (null = Top-Level). */ +function findParentOf( + elements: EditorElement[], + uid: string, + parent: EditorElement | null = null, +): EditorElement | null | undefined { + for (const el of elements) { + if (el._uid === uid) return parent; + if (el.children) { + const found = findParentOf(el.children, uid, el); + if (found !== undefined) return found; + } + } + return undefined; +} + +/** Hat das Element Maus-Interaktion im Spiel? */ +function isInteractive(el: EditorElement): boolean { + if (el["disable-hitbox"]) return false; + return !!( + (el.actions && el.actions.length > 0) || + el.hover?.effect || + el.hover?.image || + (el.hover?.color && el.hover.color !== "none") + ); +} + +/* ------------------------------------------------------------------ */ +/* Element-Rendering */ +/* ------------------------------------------------------------------ */ + +/** Sichtbarer Ecken-Radius je Rounding-Modus (Annäherung an die Glyphen). */ +function roundingRadius( + rounding: EditorElement["rounding"], + w: number, + h: number, +): number { + const cap = Math.min(w, h) / 2; + let base = 12; // default: Ecken-Unicode-Glyphen + if (typeof rounding === "string") { + const m = rounding.toLowerCase(); + if (["none", "off", "disabled", "false", "0"].includes(m)) base = 0; + else if (m === "small") base = 6; + else if (m === "medium") base = 18; + else if (m === "large") base = 26; + else base = 12; // regular/default/normal + } + return Math.min(base, cap); +} + +function ElementView({ + el, + editMode, + anchorDx = 0, + inGrid = false, +}: { + el: EditorElement; + editMode: boolean; + /** horizontale Verschiebung durch HUD-Anker-Simulation (nur Top-Level) */ + anchorDx?: number; + /** Kind eines grid_block: Position wird vom Layout bestimmt (ignoriert) */ + inGrid?: boolean; +}) { + // Hooks vor jedem early-return (Hook-Regeln) + const imgUrl = useAssetUrl( + el.type === "image" && el.image ? `img:${el.image}` : undefined, + ); + const hoverImgUrl = useAssetUrl( + el.type === "image" && el.hover?.image + ? `img:${el.hover.image}` + : undefined, + ); + const [hovering, setHovering] = React.useState(false); + + if (el._hidden || el.enabled === false) return null; + + // im Grid bestimmt das Layout die Position — eigene x/y werden ignoriert + const x = inGrid ? 0 : evalNumExpr(el.position?.x) + anchorDx; + const y = inGrid ? 0 : evalNumExpr(el.position?.y); + const w = evalNumExpr(el.size?.width, 0); + const h = evalNumExpr(el.size?.height, 0); + const rawOpacity = evalNumExpr(el.opacity ?? 255, 255) / 255; + // Edit-Modus: unsichtbare Elemente (z.B. Image-Anker mit opacity 0) + // bleiben mit Mindest-Deckkraft greifbar; Preview zeigt echte Werte. + const opacity = editMode ? Math.max(rawOpacity, 0.3) : rawOpacity; + const ghost = editMode && rawOpacity < 0.05; + const color = `#${(el.color ?? "ffffff").replace(/[^0-9a-fA-F]/g, "").padEnd(6, "0").slice(0, 6)}`; + const rotation = evalNumExpr(el.rotation, 0); + const zIndex = Math.round(evalNumExpr(el.layer) * 10) + 50000; + const interactive = !editMode && isInteractive(el); + + const common: React.CSSProperties = { + left: x, + top: y, + zIndex, + transform: rotation ? `rotate(${rotation}deg)` : undefined, + }; + const boxStyle: React.CSSProperties = { ...common, width: w, height: h }; + + let body: React.ReactNode = null; + + if (el.component) { + body = ( +
+ component: {el.component} +
+ ); + } else { + switch (el.type) { + case "text": { + const fontSize = Math.max(8, h * 0.16); + const style = parseTextMarkup(el.text); + const textColor = style.color ? `#${style.color}` : color; + const decorations = [ + style.underlined && "underline", + style.strikethrough && "line-through", + ] + .filter(Boolean) + .join(" "); + body = ( +
+ {stripMarkup(style.body) || " "} +
+ ); + break; + } + case "hitbox": + body = ( +
+ ); + break; + case "image": { + // Ingame rendert das Bild via Resource-Pack unabhängig von der + // Anker-Opacity — hochgeladene Bilder daher immer sichtbar zeigen. + const activeUrl = + !editMode && hovering && hoverImgUrl ? hoverImgUrl : imgUrl; + body = activeUrl ? ( + {el.image} setHovering(true)} + onMouseLeave={() => setHovering(false)} + /> + ) : ( +
+ 🖼 {el.image || "image"} +
+ ); + break; + } + case "grid_block": { + // Layout-Container: ordnet Kinder automatisch an (Row/Column + Gap); + // Kind-Positionen werden ignoriert, loop: N erzeugt N Kopien. + const kids: React.ReactNode[] = []; + for (const child of el.children ?? []) { + if (child._hidden || child.enabled === false) continue; + const copies = + child.loop !== undefined + ? Math.max( + 1, + Math.min(16, Math.floor(evalNumExpr(child.loop, 1))), + ) + : 1; + const cw = evalNumExpr(child.size?.width, 20); + const ch = evalNumExpr( + el.element_h !== undefined + ? el.element_h + : child.size?.height, + 20, + ); + for (let i = 0; i < copies; i++) { + kids.push( +
0 ? 0.55 : 1, + }} + > + +
, + ); + } + } + body = ( +
+ {editMode && kids.length === 0 && ( + + grid ({el.direction ?? "row"}) — Layers-Panel: Elemente per + ⋯-Menü einhängen + + )} + {kids} +
+ ); + break; + } + default: { + // block / rounded / block_rounded (Items = block mit item-Feld) + if (el.item?.material) { + body = ( +
+ {el.item.material.slice(0, 28)} +
+ ); + break; + } + const isRounded = + el.type === "rounded" || el.type === "block_rounded"; + body = ( +
+ ); + } + } + } + + return ( + <> + {body} + {/* grid_block rendert seine Kinder selbst (Flex-Layout) */} + {el.type !== "grid_block" && el.children && el.children.length > 0 && ( +
+ {el.children.map((c) => ( + + ))} +
+ )} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Stage-Hintergrund (umschaltbar: Farben, Checker, Sky, Screenshot) */ +/* ------------------------------------------------------------------ */ + +const BG_PRESETS: Record = { + dark: { backgroundColor: "#080808" }, + black: { backgroundColor: "#000000" }, + white: { backgroundColor: "#ffffff" }, + checker: { + backgroundColor: "#1a1a1a", + backgroundImage: + "linear-gradient(45deg, #262626 25%, transparent 25%, transparent 75%, #262626 75%), linear-gradient(45deg, #262626 25%, transparent 25%, transparent 75%, #262626 75%)", + backgroundSize: "32px 32px", + backgroundPosition: "0 0, 16px 16px", + }, + sky: { + // grober Minecraft-Tag-Himmel + Plains-Boden + background: + "linear-gradient(180deg, #78a7ff 0%, #9dc2ff 55%, #b7d5a8 55.5%, #7da453 62%, #6c9147 100%)", + }, +}; + +function StageBackground({ bg }: { bg: string }) { + const assetKey = bg.startsWith("asset:") ? bg.slice(6) : undefined; + const url = useAssetUrl(assetKey); + const style: React.CSSProperties = + assetKey && url + ? { + backgroundImage: `url(${url})`, + backgroundSize: "cover", + backgroundPosition: "center", + } + : (BG_PRESETS[bg] ?? BG_PRESETS.dark); + return ( +
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Drag-Zustände */ +/* ------------------------------------------------------------------ */ + +type DragMode = + | { kind: "none" } + | { + kind: "pending-move"; + pointerId: number; + screenX: number; + screenY: number; + startX: number; + startY: number; + origins: Map; + /** Bounding-Box der Selektion beim Drag-Start (für Smart Guides) */ + originBox: Rect; + } + | { + kind: "move"; + pointerId: number; + startX: number; + startY: number; + origins: Map; + originBox: Rect; + } + | { + kind: "resize"; + pointerId: number; + handle: string; + startX: number; + startY: number; + origin: Rect; + /** relative Ausgangsposition des Elements (Kinder: relativ zum Parent) */ + originRel: { x: number; y: number }; + uid: string; + } + | { + kind: "pending-marquee"; + pointerId: number; + screenX: number; + screenY: number; + startX: number; + startY: number; + } + | { + kind: "marquee"; + pointerId: number; + startX: number; + startY: number; + curX: number; + curY: number; + } + | { + kind: "pan"; + pointerId: number; + startX: number; + startY: number; + origin: { x: number; y: number }; + }; + +/* ------------------------------------------------------------------ */ +/* Canvas */ +/* ------------------------------------------------------------------ */ + +export default function Canvas() { + const { state, dispatch } = useEditor(); + const viewportRef = React.useRef(null); + // Drag-Zustand als Ref: Pointer-Events feuern schneller als React-State + // aktualisiert — der Ref ist immer aktuell, ein Spiegel-State rendert Overlays. + const dragRef = React.useRef({ kind: "none" }); + const [drag, setDragUi] = React.useState({ kind: "none" }); + const setDrag = React.useCallback((d: DragMode) => { + dragRef.current = d; + setDragUi(d); + }, []); + const [hovered, setHovered] = React.useState(null); + /** aktive Smart-Guide-Linien während eines Drags (Stage-Koordinaten) */ + const [guides, setGuides] = React.useState<{ + x: number | null; + y: number | null; + } | null>(null); + const [textEdit, setTextEdit] = React.useState<{ + uid: string; + value: string; + } | null>(null); + + const { page, zoom, pan, tool, selection, preview } = state; + const screenW = page.screen.width ?? 1920; + const screenH = page.screen.height ?? 1060; + // HUD-Anker-Simulation: 1920 = Design-Ansicht, sonst simulierte Breite + const viewW = state.simWidth === 1920 ? screenW : state.simWidth; + const anchorDxFor = (el: EditorElement): number => { + if (viewW === screenW) return 0; + const a = (el.aligned ?? "").toLowerCase(); + if (a === "left") return 0; + if (a === "right") return viewW - screenW; + return (viewW - screenW) / 2; + }; + + const toStage = React.useCallback( + (clientX: number, clientY: number) => { + const vp = viewportRef.current!.getBoundingClientRect(); + return { + x: (clientX - vp.left - pan.x) / zoom, + y: (clientY - vp.top - pan.y) / zoom, + }; + }, + [pan, zoom], + ); + + React.useEffect(() => { + const vp = viewportRef.current; + if (!vp) return; + const rect = vp.getBoundingClientRect(); + // Fit-to-Screen: ganze Stage sichtbar, max. defaultZoom (0.8 wie ingame) + const fit = Math.min( + 0.8, + (rect.width - 40) / viewW, + (rect.height - 40) / screenH, + ); + dispatch({ + type: "set-zoom", + zoom: fit, + pan: { + x: (rect.width - viewW * fit) / 2, + y: (rect.height - screenH * fit) / 2, + }, + }); + // beim Wechsel der Sim-Breite neu einpassen + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewW]); + + // Escape bricht den Pick-Modus ab + React.useEffect(() => { + if (!state.picking) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") + dispatch({ type: "set-picking", picking: null }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [state.picking, dispatch]); + + // Escape bricht laufenden Drag ab + React.useEffect(() => { + if (drag.kind === "none") return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + if (drag.kind === "move" || drag.kind === "resize") + dispatch({ type: "tx-cancel" }); + setGuides(null); + setDrag({ kind: "none" }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [drag.kind, dispatch]); + + /** logische Rects (position/size) — für Resize-Ausgang und Snapping */ + const rects = React.useMemo(() => collectRects(page.blocks), [page.blocks]); + + /** tatsächlich gerenderte Rects (Stage-Koordinaten) — Text kann durch + * align/center verschoben rendern; Hit-Test & Overlays folgen dem Auge. */ + const [domRects, setDomRects] = React.useState>( + new Map(), + ); + React.useLayoutEffect(() => { + const vp = viewportRef.current; + if (!vp) return; + const vpr = vp.getBoundingClientRect(); + const map = new Map(); + for (const node of vp.querySelectorAll("[data-uid]")) { + const uid = node.dataset.uid; + if (!uid) continue; + const r = node.getBoundingClientRect(); + map.set(uid, { + x: (r.left - vpr.left - pan.x) / zoom, + y: (r.top - vpr.top - pan.y) / zoom, + w: r.width / zoom, + h: r.height / zoom, + }); + } + setDomRects(map); + }, [page.blocks, zoom, pan]); + + const stageRect = (uid: string): Rect | null => + domRects.get(uid) ?? rects.find((r) => r.uid === uid)?.rect ?? null; + + /** Hit-Test über die echte Render-Reihenfolge (elementsFromPoint): + * oberstes sichtbares Element gewinnt; gesperrte werden übersprungen. */ + const hitTestClient = (clientX: number, clientY: number): string | null => { + for (const node of document.elementsFromPoint(clientX, clientY)) { + const uid = (node as HTMLElement).dataset?.uid; + if (!uid) continue; + const el = findByUid(page.blocks, uid); + if (!el || el._locked || el._hidden || el.enabled === false) continue; + return uid; + } + return null; + }; + + const capture = (e: React.PointerEvent) => { + try { + viewportRef.current?.setPointerCapture(e.pointerId); + } catch { + /* Pointer-ID nicht aktiv (z.B. synthetische Events) */ + } + }; + const release = (pointerId: number) => { + try { + viewportRef.current?.releasePointerCapture(pointerId); + } catch { + /* schon released */ + } + }; + + const beginTextEdit = (uid: string) => { + const el = findByUid(page.blocks, uid); + if (el?.type === "text" && !el._locked) + // nur der reine Text — das Markup-Präfix bleibt beim Commit erhalten + setTextEdit({ uid, value: parseTextMarkup(el.text).body }); + }; + + /* ---------------- Pointer-Handler ---------------- */ + + const onPointerDown = (e: React.PointerEvent) => { + if (preview) return; + if (textEdit) return; // Textfeld hat Fokus + if (e.button === 1) { + capture(e); + setDrag({ + kind: "pan", + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + origin: { ...pan }, + }); + return; + } + if (e.button !== 0) return; + const { x: sx, y: sy } = toStage(e.clientX, e.clientY); + + if (tool === "zoom") { + zoomAt(e.clientX, e.clientY, zoom * ZOOM_IN_FACTOR); + return; + } + + const hitUid = hitTestClient(e.clientX, e.clientY); + + // Pick-Modus: angeklicktes Element in den Ziel-Container einhängen + if (state.picking) { + if (hitUid && hitUid !== state.picking.containerUid) { + dispatch({ + type: "set-page", + page: { + ...page, + blocks: nestInto( + page.blocks, + hitUid, + state.picking.containerUid, + ), + }, + }); + dispatch({ type: "select", uids: [state.picking.containerUid] }); + } + dispatch({ type: "set-picking", picking: null }); + return; + } + + if (tool === "fill") { + if (hitUid) + dispatch({ + type: "set-page", + page: { + ...page, + blocks: updateByUid(page.blocks, hitUid, { + color: state.fillColor, + }), + }, + }); + return; + } + if (tool === "picker") { + if (hitUid) { + const el = findByUid(page.blocks, hitUid); + if (el?.color) + dispatch({ type: "set-fill-color", color: el.color }); + } + return; + } + if (tool === "text") { + if (hitUid) beginTextEdit(hitUid); + return; + } + + // move / scale / align / actions / animation + if (hitUid) { + const already = selection.includes(hitUid); + let uids: string[]; + if (e.shiftKey) { + uids = already + ? selection.filter((u) => u !== hitUid) + : [...selection, hitUid]; + dispatch({ type: "select", uids }); + return; // Shift-Klick = nur Auswahl togglen, kein Drag + } + uids = already ? selection : [hitUid]; + if (!already) dispatch({ type: "select", uids }); + + // Drag vormerken — startet erst nach DRAG_THRESHOLD. + // Grid-Kinder sind nicht verschiebbar (das Layout positioniert sie). + const origins = new Map(); + for (const uid of uids) { + const el = findByUid(page.blocks, uid); + if (!el || el._locked) continue; + const parent = findParentOf(page.blocks, uid); + if (parent && parent.type === "grid_block") continue; + origins.set(uid, { + x: evalNumExpr(el.position?.x), + y: evalNumExpr(el.position?.y), + }); + } + if (origins.size > 0) { + // Bounding-Box der zu ziehenden Elemente (für Smart Guides) + let bx0 = Infinity; + let by0 = Infinity; + let bx1 = -Infinity; + let by1 = -Infinity; + for (const uid of origins.keys()) { + const r = rects.find((rr) => rr.uid === uid)?.rect; + if (!r) continue; + bx0 = Math.min(bx0, r.x); + by0 = Math.min(by0, r.y); + bx1 = Math.max(bx1, r.x + r.w); + by1 = Math.max(by1, r.y + r.h); + } + const originBox: Rect = Number.isFinite(bx0) + ? { x: bx0, y: by0, w: bx1 - bx0, h: by1 - by0 } + : { x: sx, y: sy, w: 0, h: 0 }; + capture(e); + setDrag({ + kind: "pending-move", + pointerId: e.pointerId, + screenX: e.clientX, + screenY: e.clientY, + startX: sx, + startY: sy, + origins, + originBox, + }); + } + } else { + if (!e.shiftKey) dispatch({ type: "select", uids: [] }); + capture(e); + setDrag({ + kind: "pending-marquee", + pointerId: e.pointerId, + screenX: e.clientX, + screenY: e.clientY, + startX: sx, + startY: sy, + }); + } + }; + + const onPointerMove = (e: React.PointerEvent) => { + let drag = dragRef.current; + if (drag.kind === "none") { + if (preview) return; + setHovered(hitTestClient(e.clientX, e.clientY)); + return; + } + if (drag.kind === "pan") { + dispatch({ + type: "set-pan", + pan: { + x: drag.origin.x + (e.clientX - drag.startX), + y: drag.origin.y + (e.clientY - drag.startY), + }, + }); + return; + } + const { x: sx, y: sy } = toStage(e.clientX, e.clientY); + + // Schwellen-Konvertierung: danach direkt weiterverarbeiten, damit + // die erste Bewegung nicht verloren geht + if (drag.kind === "pending-move") { + if ( + Math.hypot(e.clientX - drag.screenX, e.clientY - drag.screenY) < + DRAG_THRESHOLD + ) + return; + dispatch({ type: "tx-begin" }); + const next: DragMode = { + kind: "move", + pointerId: drag.pointerId, + startX: drag.startX, + startY: drag.startY, + origins: drag.origins, + originBox: drag.originBox, + }; + setDrag(next); + drag = next; + } + if (drag.kind === "pending-marquee") { + if ( + Math.hypot(e.clientX - drag.screenX, e.clientY - drag.screenY) < + DRAG_THRESHOLD + ) + return; + const next: DragMode = { + kind: "marquee", + pointerId: drag.pointerId, + startX: drag.startX, + startY: drag.startY, + curX: sx, + curY: sy, + }; + setDrag(next); + drag = next; + } + + if (drag.kind === "move") { + let dx = sx - drag.startX; + let dy = sy - drag.startY; + // Shift = Achsen-Sperre (wie in Photoshop) + if (e.shiftKey) { + if (Math.abs(dx) >= Math.abs(dy)) dy = 0; + else dx = 0; + } + // Smart Guides: Kanten/Mitten der Selektion snappen an Kanten/Mitten + // anderer Elemente + Screen-Rand/-Mitte. Alt hält das Snapping aus. + let gx: number | null = null; + let gy: number | null = null; + if (state.snapping && !e.altKey) { + const threshold = GUIDE_SNAP_PX / zoom; + const box = drag.originBox; + const newX = box.x + dx; + const newY = box.y + dy; + const targetsX: number[] = [0, screenW / 2, screenW]; + const targetsY: number[] = [0, screenH / 2, screenH]; + for (const r of rects) { + if (drag.origins.has(r.uid) || r.el._hidden) continue; + targetsX.push( + r.rect.x, + r.rect.x + r.rect.w / 2, + r.rect.x + r.rect.w, + ); + targetsY.push( + r.rect.y, + r.rect.y + r.rect.h / 2, + r.rect.y + r.rect.h, + ); + } + let bestX: { d: number; adjust: number; line: number } | null = + null; + for (const t of targetsX) { + for (const edge of [newX, newX + box.w / 2, newX + box.w]) { + const d = Math.abs(edge - t); + if (d < threshold && (!bestX || d < bestX.d)) + bestX = { d, adjust: t - edge, line: t }; + } + } + let bestY: { d: number; adjust: number; line: number } | null = + null; + for (const t of targetsY) { + for (const edge of [newY, newY + box.h / 2, newY + box.h]) { + const d = Math.abs(edge - t); + if (d < threshold && (!bestY || d < bestY.d)) + bestY = { d, adjust: t - edge, line: t }; + } + } + if (bestX) { + dx += bestX.adjust; + gx = bestX.line; + } + if (bestY) { + dy += bestY.adjust; + gy = bestY.line; + } + } + setGuides(gx !== null || gy !== null ? { x: gx, y: gy } : null); + // Pixel-Snapping: Positionen immer auf ganze Pixel + let blocks = page.blocks; + for (const [uid, origin] of drag.origins) { + blocks = updateByUid(blocks, uid, { + position: { + x: Math.round(origin.x + dx), + y: Math.round(origin.y + dy), + }, + }); + } + dispatch({ type: "tx-set-page", page: { ...page, blocks } }); + return; + } + + if (drag.kind === "resize") { + // absolut vom Ausgangszustand rechnen — robust gegen Event-Bursts + const dx = sx - drag.startX; + const dy = sy - drag.startY; + const o = drag.origin; + let { x, y, w, h } = o; + const has = (s: string) => drag.handle.includes(s); + if (has("e")) w = o.w + dx; + if (has("s")) h = o.h + dy; + if (has("w")) { + w = o.w - dx; + x = o.x + dx; + } + if (has("n")) { + h = o.h - dy; + y = o.y + dy; + } + // Shift = proportional, nur an Ecken (wie ingame) + if (e.shiftKey && drag.handle.length === 2) { + const k = Math.max(w / o.w, h / o.h); + w = o.w * k; + h = o.h * k; + if (has("w")) x = o.x + (o.w - w); + if (has("n")) y = o.y + (o.h - h); + } + w = Math.max(1, Math.round(w)); + h = Math.max(1, Math.round(h)); + const el = findByUid(page.blocks, drag.uid); + if (!el) return; + const relX = drag.originRel.x + (x - o.x); + const relY = drag.originRel.y + (y - o.y); + dispatch({ + type: "tx-set-page", + page: { + ...page, + blocks: updateByUid(page.blocks, drag.uid, { + position: { x: Math.round(relX), y: Math.round(relY) }, + size: { ...el.size, width: w, height: h }, + }), + }, + }); + return; + } + + if (drag.kind === "marquee") { + setDrag({ ...drag, curX: sx, curY: sy }); + } + }; + + const onPointerUp = (e: React.PointerEvent) => { + const drag = dragRef.current; + if (drag.kind === "none") return; + setGuides(null); + release(drag.pointerId); + if (drag.kind === "move" || drag.kind === "resize") + dispatch({ type: "tx-end" }); + if (drag.kind === "marquee") { + const x0 = Math.min(drag.startX, drag.curX); + const y0 = Math.min(drag.startY, drag.curY); + const x1 = Math.max(drag.startX, drag.curX); + const y1 = Math.max(drag.startY, drag.curY); + // wie ingame: alle schneidenden, nicht gesperrten Elemente + // (auf Basis der sichtbaren Rects) + const uids = rects + .filter(({ uid, el }) => { + if (el._hidden || el._locked) return false; + const rect = stageRect(uid); + if (!rect) return false; + return ( + rect.x < x1 && + rect.x + rect.w > x0 && + rect.y < y1 && + rect.y + rect.h > y0 + ); + }) + .map((r) => r.uid); + dispatch({ type: "select", uids }); + } + setDrag({ kind: "none" }); + }; + + const onDoubleClick = (e: React.MouseEvent) => { + if (preview) return; + const uid = hitTestClient(e.clientX, e.clientY); + if (uid) beginTextEdit(uid); + }; + + const zoomAt = (clientX: number, clientY: number, nextZoom: number) => { + const vp = viewportRef.current!.getBoundingClientRect(); + const clamped = Math.min(4, Math.max(0.1, nextZoom)); + const px = clientX - vp.left; + const py = clientY - vp.top; + const sx = (px - pan.x) / zoom; + const sy = (py - pan.y) / zoom; + dispatch({ + type: "set-zoom", + zoom: clamped, + pan: { x: px - sx * clamped, y: py - sy * clamped }, + }); + }; + + const onWheel = (e: React.WheelEvent) => { + zoomAt(e.clientX, e.clientY, zoom * (e.deltaY < 0 ? 1.1 : 0.9)); + }; + + const onContextMenuCapture = (e: React.MouseEvent) => { + if (tool === "zoom") { + e.preventDefault(); + e.stopPropagation(); + zoomAt(e.clientX, e.clientY, zoom * ZOOM_OUT_FACTOR); + } + }; + + /* ---------------- Kontextmenü ---------------- */ + + const ctxPos = React.useRef({ x: 0, y: 0 }); + const imageInputRef = React.useRef(null); + + const addElement = (kind: CreateKind) => { + // "New image" öffnet wie ingame einen Datei-Dialog + if (kind === "image") { + imageInputRef.current?.click(); + return; + } + const el = newElement(page, kind, ctxPos.current); + dispatch({ + type: "set-page", + page: { ...page, blocks: [...page.blocks, el] }, + }); + dispatch({ type: "select", uids: [el._uid] }); + }; + + const addImageFromFile = async (file: File) => { + const name = sanitizeImageName(file.name) || "image"; + await saveAsset(`img:${name}`, file); + let size = { width: 128, height: 128 }; + try { + const natural = await readImageSize(file); + // auf max. 512px skaliert, Seitenverhältnis erhalten + const k = Math.min(1, 512 / Math.max(natural.width, natural.height)); + size = { + width: Math.round(natural.width * k), + height: Math.round(natural.height * k), + }; + } catch { + /* Größe nicht lesbar → Default */ + } + const el = { + ...newElement(page, "image", ctxPos.current), + image: name, + size, + }; + dispatch({ + type: "set-page", + page: { ...page, blocks: [...page.blocks, el] }, + }); + dispatch({ type: "select", uids: [el._uid] }); + }; + + const pasteClipboard = () => { + if (state.clipboard.length === 0) return; + const clones = cloneWithNewUids(state.clipboard).map((el, i) => ({ + ...el, + position: { + x: Math.round(ctxPos.current.x) + i * 10, + y: Math.round(ctxPos.current.y) + i * 10, + }, + })); + dispatch({ + type: "set-page", + page: { ...page, blocks: [...page.blocks, ...clones] }, + }); + dispatch({ type: "select", uids: clones.map((c) => c._uid) }); + }; + + /* ---------------- Overlays ---------------- */ + + const selRects = selection + .map((uid) => { + const el = findByUid(page.blocks, uid); + const rect = stageRect(uid); + return el && rect ? { uid, rect, el } : null; + }) + .filter(Boolean) as { uid: string; rect: Rect; el: EditorElement }[]; + + const hoveredRect = + hovered && !selection.includes(hovered) + ? (() => { + const rect = stageRect(hovered); + return rect ? { uid: hovered, rect } : null; + })() + : null; + + const showHandles = + !preview && + selection.length === 1 && + (tool === "move" || tool === "scale" || tool === "align") && + selRects.length === 1 && + !selRects[0].el._locked; + + const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]; + const handlePos = (h: string, r: Rect) => ({ + left: r.x + (h.includes("w") ? 0 : h.includes("e") ? r.w : r.w / 2), + top: r.y + (h.includes("n") ? 0 : h.includes("s") ? r.h : r.h / 2), + }); + + const alignElement = ( + hz?: "left" | "center" | "right", + vt?: "top" | "center" | "bottom", + ) => { + if (selRects.length === 0) return; + let blocks = page.blocks; + for (const { uid, rect, el } of selRects) { + if (el._locked) continue; + const relX = evalNumExpr(el.position?.x); + const relY = evalNumExpr(el.position?.y); + const offX = rect.x - relX; + const offY = rect.y - relY; + let nx = relX; + let ny = relY; + if (hz === "left") nx = 0 - offX; + if (hz === "center") nx = (screenW - rect.w) / 2 - offX; + if (hz === "right") nx = screenW - rect.w - offX; + if (vt === "top") ny = 0 - offY; + if (vt === "center") ny = (screenH - rect.h) / 2 - offY; + if (vt === "bottom") ny = screenH - rect.h - offY; + blocks = updateByUid(blocks, uid, { + position: { x: Math.round(nx), y: Math.round(ny) }, + }); + } + dispatch({ type: "set-page", page: { ...page, blocks } }); + }; + + const sortedBlocks = React.useMemo( + () => + [...page.blocks].sort( + (a, b) => evalNumExpr(a.layer) - evalNumExpr(b.layer), + ), + [page.blocks], + ); + + const cursorClass = preview + ? "" + : state.picking + ? "cursor-crosshair" + : tool === "zoom" + ? "cursor-zoom-in" + : tool === "fill" || tool === "picker" + ? "cursor-crosshair" + : tool === "text" + ? "cursor-text" + : drag.kind === "move" + ? "cursor-grabbing" + : hovered + ? "cursor-move" + : "cursor-default"; + + const hoveredEl = hovered ? findByUid(page.blocks, hovered) : null; + + return ( + + +
setHovered(null)} + onDoubleClick={onDoubleClick} + onWheel={onWheel} + onContextMenuCapture={onContextMenuCapture} + onContextMenu={(e) => { + ctxPos.current = toStage(e.clientX, e.clientY); + }} + data-testid="uui-canvas" + > + {state.picking && ( +
+ Element anklicken, um es einzuhängen — Esc bricht ab +
+ )} + { + const file = e.target.files?.[0]; + if (file) addImageFromFile(file); + e.target.value = ""; + }} + /> +
+ + {sortedBlocks.map((el) => ( + + ))} + + {/* Hover-Highlight */} + {!preview && hoveredRect && drag.kind === "none" && ( +
+ )} + + {/* Interaktivitäts-Badge am gehoverten Element */} + {!preview && hoveredRect && hoveredEl && drag.kind === "none" && ( +
+ {isInteractive(hoveredEl) ? ( + <> + + + {hoveredEl.actions?.length + ? `${hoveredEl.actions.length} action${hoveredEl.actions.length > 1 ? "s" : ""}` + : "hover"} + + + ) : ( + <> + + no interaction + + )} + + {hoveredEl.name || hoveredEl.id || hoveredEl.type} + +
+ )} + + {/* Selektionsrahmen */} + {!preview && + selRects.map(({ uid, rect, el }) => ( + +
+ {el._locked && ( +
+ +
+ )} + + ))} + + {/* Resize-Handles (bei Selektion in Move/Scale/Align) */} + {showHandles && + HANDLES.map((h) => ( +
{ + e.stopPropagation(); + const { x: sx, y: sy } = toStage(e.clientX, e.clientY); + capture(e); + // Resize rechnet auf der LOGISCHEN Box (position/size) + const logical = + rects.find((r) => r.uid === selRects[0].uid)?.rect ?? + selRects[0].rect; + dispatch({ type: "tx-begin" }); + setDrag({ + kind: "resize", + pointerId: e.pointerId, + handle: h, + startX: sx, + startY: sy, + origin: logical, + originRel: { + x: evalNumExpr(selRects[0].el.position?.x), + y: evalNumExpr(selRects[0].el.position?.y), + }, + uid: selRects[0].uid, + }); + }} + /> + ))} + + {/* Align-Buttons (Align-Tool) */} + {!preview && tool === "align" && selRects.length > 0 && ( +
e.stopPropagation()} + > + {( + [ + ["⇤", () => alignElement("left", undefined)], + ["↔", () => alignElement("center", undefined)], + ["⇥", () => alignElement("right", undefined)], + ["⤒", () => alignElement(undefined, "top")], + ["↕", () => alignElement(undefined, "center")], + ["⤓", () => alignElement(undefined, "bottom")], + ] as const + ).map(([label, fn], i) => ( + + ))} +
+ )} + + {/* Smart Guides (Photoshop-artige Ausrichtlinien) */} + {guides?.x !== null && guides?.x !== undefined && ( +
+ )} + {guides?.y !== null && guides?.y !== undefined && ( +
+ )} + + {/* Marquee */} + {drag.kind === "marquee" && ( +
+ )} + + {/* Inline-Text-Editing */} + {textEdit && + (() => { + const r = rects.find((r) => r.uid === textEdit.uid); + if (!r) return null; + const commit = () => { + dispatch({ + type: "set-page", + page: { + ...page, + blocks: updateByUid(page.blocks, textEdit.uid, { + text: patchTextMarkup( + findByUid(page.blocks, textEdit.uid)?.text, + { body: textEdit.value }, + ), + }), + }, + }); + setTextEdit(null); + }; + return ( +