diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 69f7271..3c18825 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,6 @@ import { Routes, Route } from "react-router-dom"; import AppShell from "./components/shared/AppShell"; import ComposeView from "./routes/ComposeView"; import BrowseView from "./routes/BrowseView"; -import EditorView from "./routes/EditorView"; import SettingsView from "./routes/SettingsView"; export default function App() { @@ -12,8 +11,6 @@ export default function App() { } /> } /> } /> - } /> - } /> ); diff --git a/frontend/src/components/editor/EditorPointer.tsx b/frontend/src/components/editor/EditorPointer.tsx index 2e6afb6..14c0cd0 100644 --- a/frontend/src/components/editor/EditorPointer.tsx +++ b/frontend/src/components/editor/EditorPointer.tsx @@ -3,11 +3,11 @@ import pointerSvg from "@/assets/pointer.min.svg"; import { useLazyEyes } from "@/hooks/useLazyEyes"; /** - * Floating pointer that tracks the CodeMirror cursor with smooth lerp animation. - * Positions itself right next to the left border of the editor container. - * Uses a ResizeObserver to keep horizontal position synced on window resize. + * Floating pointer that tracks the CodeMirror cursor vertically, + * pinned to the left edge of the editor. Positions relative to + * containerRef (for floating windows). */ -export default function EditorPointer() { +export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject; focused?: boolean }) { const [y, setY] = useState(null); const [editorLeft, setEditorLeft] = useState(null); const targetRef = useRef(0); @@ -18,17 +18,21 @@ export default function EditorPointer() { const clickTimerRef = useRef>(undefined); const cmRef = useRef(null); - // Eye anchor tracks the pointer's viewport position + // Eye anchor must be in viewport coords (useLazyEyes compares against e.clientX/Y) const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null); const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef }); useEffect(() => { const ease = 0.09; + const getContainerRect = () => + containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 }; + const updateLeft = () => { - if (cmRef.current) { - setEditorLeft(cmRef.current.getBoundingClientRect().left); - } + if (!cmRef.current || !containerRef?.current) return; + const cmLeft = cmRef.current.getBoundingClientRect().left; + const containerLeft = containerRef.current.getBoundingClientRect().left; + setEditorLeft(cmLeft - containerLeft); }; const onEditorClick = () => { @@ -39,22 +43,26 @@ export default function EditorPointer() { const onCursorMove = (e: Event) => { const { top } = (e as CustomEvent).detail; - targetRef.current = top; - // Find editor container and attach click listener lazily + // Find editor container lazily, scoped to our window if (!cmRef.current) { - const cm = document.querySelector(".cm-editor"); + const scope = containerRef?.current ?? document; + const cm = scope.querySelector(".cm-editor"); if (cm) { cmRef.current = cm; cm.addEventListener("mousedown", onEditorClick); ro.observe(cm); } } + + // Convert viewport top to container-relative top + const containerTop = getContainerRect().top; + targetRef.current = top - containerTop; updateLeft(); if (!activeRef.current) { - currentRef.current = top; - setY(top); + currentRef.current = targetRef.current; + setY(targetRef.current); activeRef.current = true; } }; @@ -69,9 +77,10 @@ export default function EditorPointer() { } setY(currentRef.current); - // Update eye anchor to match pointer position - const left = cmRef.current?.getBoundingClientRect().left ?? 0; - eyeAnchorRef.current = { x: left - 100, y: currentRef.current }; + // Eye anchor in viewport coords for useLazyEyes + const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0; + const containerTop = getContainerRect().top; + eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current }; } rafRef.current = requestAnimationFrame(tick); }; @@ -81,7 +90,8 @@ export default function EditorPointer() { // Keep left position updated on resize const ro = new ResizeObserver(() => updateLeft()); - const existingCm = document.querySelector(".cm-editor"); + const scope = containerRef?.current ?? document; + const existingCm = scope.querySelector(".cm-editor"); if (existingCm) { cmRef.current = existingCm; ro.observe(existingCm); @@ -95,9 +105,9 @@ export default function EditorPointer() { cancelAnimationFrame(rafRef.current); ro.disconnect(); }; - }, []); + }, [containerRef]); - if (y === null || editorLeft === null) return null; + if (!focused || y === null || editorLeft === null) return null; return (
| null>(null); + +export function useEditorCtx(selector: (s: EditorStore) => T): T { + const store = useContext(EditorStoreContext); + if (!store) throw new Error("useEditorCtx must be used within EditorStoreContext.Provider"); + return useStore(store, selector); +} diff --git a/frontend/src/components/editor/EditorWindow.tsx b/frontend/src/components/editor/EditorWindow.tsx new file mode 100644 index 0000000..8dd87fe --- /dev/null +++ b/frontend/src/components/editor/EditorWindow.tsx @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { BookOpen, Upload } from "lucide-react"; +import { autocompletion } from "@codemirror/autocomplete"; +import type { Extension } from "@codemirror/state"; +import type { StoreApi } from "zustand"; +import { useStore } from "zustand"; +import * as api from "@/api/client"; +import { createEditorStore, type EditorStore } from "@/stores/editorStore"; +import { usePagesStore } from "@/stores/pagesStore"; +import { useCompile } from "@/hooks/useCompile"; +import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; +import { uframeHighlight } from "./uframeHighlight"; +import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "./uframeCommands"; +import { keywordHoverTooltip } from "./uframeHover"; +import { EditorStoreContext } from "./EditorStoreContext"; +import EditorPane from "./EditorPane"; +import EditorPointer from "./EditorPointer"; +import PreviewPane from "./PreviewPane"; +import ToolBar from "./ToolBar"; +import { EXAMPLES } from "./examples"; +import FloatingWindow from "@/components/shared/FloatingWindow"; +import type { ManagedWindow } from "@/hooks/useWindowManager"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverHeader, + PopoverTitle, +} from "@/components/ui/popover"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; + +export interface EditorWinData { + pageName: string; + isNew: boolean; +} + +interface EditorWindowProps { + win: ManagedWindow; + focused: boolean; + onUpdate: (id: string, patch: Partial>) => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; +} + +export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus }: EditorWindowProps) { + const storeRef = useRef>(null); + if (!storeRef.current) storeRef.current = createEditorStore(); + const store = storeRef.current; + const windowRef = useRef(null); + + const ufSource = useStore(store, (s) => s.ufSource); + const isDirty = useStore(store, (s) => s.isDirty); + const setSource = useStore(store, (s) => s.setSource); + const setDirty = useStore(store, (s) => s.setDirty); + const setCurrentPage = useStore(store, (s) => s.setCurrentPage); + + const { fetchPages } = usePagesStore(); + const [pageName, setPageName] = useState(win.data.pageName); + const [saving, setSaving] = useState(false); + + const extensions = useMemo( + () => [ + ...uframeHighlight(), + autocompletion({ + override: [uframeCommandSource, uframeValueHintSource], + icons: false, + activateOnTyping: true, + }), + keywordHoverTooltip, + ], + [], + ); + + useEffect(() => { loadCommandsFromApi(); }, []); + useCompile(store); + useUnsavedGuard(store); + + // Load page on mount + useEffect(() => { + if (!win.data.isNew && win.data.pageName) { + api.fetchPage(win.data.pageName).then((data) => { + if (data.source != null) { + store.setState({ ufSource: data.source, isDirty: false }); + } + }); + } + return () => store.getState().reset(); + }, []); + + const handleSave = useCallback( + async (publish: boolean) => { + const slug = pageName.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-"); + if (!slug) { toast.error("Enter a page name."); return; } + setSaving(true); + try { + const meta = await api.savePage(slug, ufSource, publish); + setCurrentPage(meta); + setDirty(false); + fetchPages(); + toast.success(publish ? "Published" : "Draft saved"); + if (win.data.isNew) { + setPageName(slug); + onUpdate(win.id, { data: { pageName: slug, isNew: false } }); + } + } catch (e) { + toast.error(`Save failed: ${e}`); + } finally { + setSaving(false); + } + }, + [pageName, ufSource, win.data.isNew, win.id], + ); + + // Keyboard shortcuts — only fire when this window is focused + useEffect(() => { + if (!focused) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); handleSave(false); } + if ((e.metaKey || e.ctrlKey) && e.key === "p") { e.preventDefault(); handleSave(true); } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [focused, handleSave]); + + // Confirm close if dirty + const handleClose = useCallback((id: string) => { + if (isDirty) { + if (!window.confirm("You have unsaved changes. Close anyway?")) return; + } + onClose(id); + }, [isDirty, onClose]); + + return ( + + + +
+ handleSave(false)} + onPublish={() => handleSave(true)} + saving={saving} + isDirty={isDirty} + /> + + + + + + + + + +
+
+
+ ); +} + + +function SourcePane({ + ufSource, + setSource, + extensions, +}: { + ufSource: string; + setSource: (s: string) => void; + extensions: Extension[]; +}) { + const [examplesOpen, setExamplesOpen] = useState(false); + const fileRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + const data = await api.uploadImage(file); + toast.success(`Uploaded ${data.filename}`); + setSource(`image "${data.path}" braille 30\n align center`); + } catch (err) { + toast.error(`Upload failed: ${err}`); + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + return ( +
+
+ Source + + + + + Examples + + } + /> + + + Insert Example + +
+ {EXAMPLES.map((ex) => ( + + ))} +
+
+
+ + + +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/editor/PreviewPane.tsx b/frontend/src/components/editor/PreviewPane.tsx index 7e0a8e9..d1efe1f 100644 --- a/frontend/src/components/editor/PreviewPane.tsx +++ b/frontend/src/components/editor/PreviewPane.tsx @@ -1,17 +1,17 @@ -import { useEditorStore } from "@/stores/editorStore"; +import { useEditorCtx } from "./EditorStoreContext"; import { renderMicron } from "./micronRenderer"; import { cn } from "@/lib/utils"; type PreviewMode = "micron" | "raw" | "script"; export default function PreviewPane() { - const previewMode = useEditorStore((s) => s.previewMode); - const setPreviewMode = useEditorStore((s) => s.setPreviewMode); - const compiledMicron = useEditorStore((s) => s.compiledMicron); - const compiledScript = useEditorStore((s) => s.compiledScript); - const isDynamic = useEditorStore((s) => s.isDynamic); - const isCompiling = useEditorStore((s) => s.isCompiling); - const compileError = useEditorStore((s) => s.compileError); + const previewMode = useEditorCtx((s) => s.previewMode); + const setPreviewMode = useEditorCtx((s) => s.setPreviewMode); + const compiledMicron = useEditorCtx((s) => s.compiledMicron); + const compiledScript = useEditorCtx((s) => s.compiledScript); + const isDynamic = useEditorCtx((s) => s.isDynamic); + const isCompiling = useEditorCtx((s) => s.isCompiling); + const compileError = useEditorCtx((s) => s.compileError); const tabs: { value: PreviewMode; label: string; show: boolean }[] = [ { value: "micron", label: "Micron", show: true }, diff --git a/frontend/src/components/editor/ToolBar.tsx b/frontend/src/components/editor/ToolBar.tsx index 8cd61c8..07a2691 100644 --- a/frontend/src/components/editor/ToolBar.tsx +++ b/frontend/src/components/editor/ToolBar.tsx @@ -1,6 +1,5 @@ import { useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { ChevronLeft, Pencil } from "lucide-react"; +import { Pencil } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -21,20 +20,8 @@ export default function ToolBar({ saving, isDirty, }: Props) { - const navigate = useNavigate(); - return ( -
- - - - +
{isDirty && ( @@ -43,10 +30,10 @@ export default function ToolBar({
- -
diff --git a/frontend/src/components/shared/AppShell.tsx b/frontend/src/components/shared/AppShell.tsx index 97b49c2..5d71158 100644 --- a/frontend/src/components/shared/AppShell.tsx +++ b/frontend/src/components/shared/AppShell.tsx @@ -1,8 +1,7 @@ import { useEffect, useState, type ReactNode } from "react"; -import { useNavigate, useLocation } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "@/components/ui/sonner"; -import frameSvg from "@/assets/frame.themed.svg"; import browserSvg from "@/assets/browser.min.svg"; import NavMenu from "./NavMenu"; import LazyEyes from "./LazyEyes"; @@ -96,17 +95,7 @@ interface FrameLayout { nav: { top: number; left: number }; } -const defaultLayout: FrameLayout = { - svg: frameSvg, - frameHeight: 1315, - containerMaxW: "max-w-5xl", - containerMinW: "min-w-5xl", - title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }, - content: { marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 }, - nav: { top: 150, left: -210 }, -}; - -const browseLayout: FrameLayout = { +const frameLayout: FrameLayout = { svg: browserSvg, frameHeight: 884, containerMaxW: "max-w-5xl", @@ -118,11 +107,8 @@ const browseLayout: FrameLayout = { export default function AppShell({ children }: { children: ReactNode }) { const navigate = useNavigate(); - const location = useLocation(); const [theme, setTheme] = useState(getStoredTheme); - const isEditor = location.pathname.startsWith("/editor"); - const isBrowse = location.pathname === "/browse"; - const layout = isBrowse ? browseLayout : defaultLayout; + const layout = frameLayout; useEffect(() => { const root = document.documentElement; @@ -161,30 +147,28 @@ export default function AppShell({ children }: { children: ReactNode }) { }} /> - {/* Lazy eyes on the browse frame figure */} - {isBrowse && ( - - )} + {/* Lazy eyes on the frame figure */} + {/* Top hole — ASCII title */}
 navigate("/")}
               >
                 {TITLE}
               
- +
{/* Bottom hole — main content */} @@ -195,15 +179,13 @@ export default function AppShell({ children }: { children: ReactNode }) { {children}
- {/* Nav menu — anchored below the frame, left side (hidden on editor) */} - {!isEditor && ( -
- -
- )} + {/* Nav menu — anchored below the frame, left side */} +
+ +
diff --git a/frontend/src/components/shared/FloatingWindow.tsx b/frontend/src/components/shared/FloatingWindow.tsx new file mode 100644 index 0000000..7897363 --- /dev/null +++ b/frontend/src/components/shared/FloatingWindow.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useRef, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +export const DITHERED_SHADOW = ` + 3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border), + 4px 4px 0 0 transparent, 6px 4px 0 0 var(--border), + 3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border), + 4px 6px 0 0 var(--border), 6px 6px 0 0 transparent +`; + +export interface FloatingWindowProps { + id: string; + title: string; + x: number; + y: number; + w: number; + h: number; + zIndex: number; + focused: boolean; + onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; + minW?: number; + minH?: number; + addressBar?: ReactNode; + footer?: ReactNode; + containerRef?: React.RefObject; + children: ReactNode; +} + +export default function FloatingWindow({ + id, title, x, y, w, h, zIndex, focused, + onUpdate, onClose, onFocus, + minW = 320, minH = 200, + addressBar, footer, containerRef, children, +}: FloatingWindowProps) { + const internalRef = useRef(null); + const ref = containerRef ?? internalRef; + const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null); + + useEffect(() => { if (focused) ref.current?.focus(); }, [focused]); + + const onDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + e.preventDefault(); onFocus(id); + dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y }; + const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; onUpdate(id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)) }); }; + const onUp = () => { dragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [id, x, y, onUpdate, onFocus]); + + const onResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); e.stopPropagation(); onFocus(id); + resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h }; + const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); }; + const onUp = () => { resizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [id, w, h, minW, minH, onUpdate, onFocus]); + + return createPortal( +
{ if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)} + className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150" + style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}> + {/* Title bar */} +
+
+
+ {title} +
+ {/* Optional address bar */} + {addressBar} + {/* Content */} +
+ {children} +
+ {/* Optional footer */} + {footer} + {/* Resize handle */} +
+ +
+
, document.body); +} diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx index 3d43562..35ef0fe 100644 --- a/frontend/src/components/ui/popover.tsx +++ b/frontend/src/components/ui/popover.tsx @@ -30,7 +30,7 @@ function PopoverContent({ alignOffset={alignOffset} side={side} sideOffset={sideOffset} - className="isolate z-50" + className="isolate z-9999" > ) { ) { s.ufSource); - const setCompileResult = useEditorStore((s) => s.setCompileResult); - const setCompiling = useEditorStore((s) => s.setCompiling); - const setCompileError = useEditorStore((s) => s.setCompileError); +export function useCompile(storeApi?: StoreApi) { + const globalSource = useEditorStore((s) => s.ufSource); + const globalSetResult = useEditorStore((s) => s.setCompileResult); + const globalSetCompiling = useEditorStore((s) => s.setCompiling); + const globalSetError = useEditorStore((s) => s.setCompileError); + + const localSource = useStore(storeApi ?? useEditorStore, (s) => s.ufSource); + const localSetResult = useStore(storeApi ?? useEditorStore, (s) => s.setCompileResult); + const localSetCompiling = useStore(storeApi ?? useEditorStore, (s) => s.setCompiling); + const localSetError = useStore(storeApi ?? useEditorStore, (s) => s.setCompileError); + + const ufSource = storeApi ? localSource : globalSource; + const setCompileResult = storeApi ? localSetResult : globalSetResult; + const setCompiling = storeApi ? localSetCompiling : globalSetCompiling; + const setCompileError = storeApi ? localSetError : globalSetError; const timerRef = useRef | null>(null); const abortRef = useRef(null); diff --git a/frontend/src/hooks/useUnsavedGuard.ts b/frontend/src/hooks/useUnsavedGuard.ts index c6cc42a..05605d0 100644 --- a/frontend/src/hooks/useUnsavedGuard.ts +++ b/frontend/src/hooks/useUnsavedGuard.ts @@ -1,8 +1,12 @@ import { useEffect } from "react"; +import { useStore, type StoreApi } from "zustand"; import { useEditorStore } from "@/stores/editorStore"; +import type { EditorStore } from "@/stores/editorStore"; -export function useUnsavedGuard() { - const isDirty = useEditorStore((s) => s.isDirty); +export function useUnsavedGuard(storeApi?: StoreApi) { + const globalDirty = useEditorStore((s) => s.isDirty); + const localDirty = useStore(storeApi ?? useEditorStore, (s) => s.isDirty); + const isDirty = storeApi ? localDirty : globalDirty; useEffect(() => { const handler = (e: BeforeUnloadEvent) => { diff --git a/frontend/src/hooks/useWindowManager.ts b/frontend/src/hooks/useWindowManager.ts new file mode 100644 index 0000000..f63d665 --- /dev/null +++ b/frontend/src/hooks/useWindowManager.ts @@ -0,0 +1,63 @@ +import { useCallback, useState } from "react"; + +let nextWinZ = 1; + +export interface ManagedWindow { + id: string; + x: number; + y: number; + w: number; + h: number; + zIndex: number; + data: T; +} + +export function useWindowManager(defaults?: { w: number; h: number }) { + const defaultW = defaults?.w ?? 480; + const defaultH = defaults?.h ?? 400; + const [windows, setWindows] = useState[]>([]); + const [focusedId, setFocusedId] = useState(null); + + const open = useCallback((id: string, data: T, size?: { w: number; h: number }) => { + setWindows((prev) => { + const existing = prev.find((w) => w.id === id); + if (existing) { + const z = ++nextWinZ; + setFocusedId(existing.id); + return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w); + } + const winW = size?.w ?? defaultW; + const winH = size?.h ?? defaultH; + const margin = 20; + const z = ++nextWinZ; + const win: ManagedWindow = { + id, data, + x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)), + y: Math.round(margin + Math.random() * (Math.max(margin, window.innerHeight - winH - margin) - margin)), + w: winW, h: winH, zIndex: z, + }; + setFocusedId(win.id); + return [...prev, win]; + }); + }, [defaultW, defaultH]); + + const update = useCallback((id: string, patch: Partial>) => { + setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w))); + }, []); + + const close = useCallback((id: string) => { + setWindows((prev) => prev.filter((w) => w.id !== id)); + setFocusedId((cur) => cur === id ? null : cur); + }, []); + + const focus = useCallback((id: string) => { + setFocusedId((cur) => { + if (cur === id) return cur; + const z = ++nextWinZ; + setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, zIndex: z } : w))); + return id; + }); + }, []); + + return { windows, focusedId, open, update, close, focus }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 5d060b2..94456e5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -405,8 +405,8 @@ /* ── Editor Pointer ── */ .editor-pointer { - position: fixed; - z-index: 50; + position: absolute; + z-index: 9999; pointer-events: none; will-change: top; transform: translateX(-100%); @@ -441,7 +441,6 @@ -webkit-mask-size: contain; -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; - opacity: 1; } .editor-pointer-eye { diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index 810e326..0e159b0 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -3,6 +3,8 @@ import { createPortal } from "react-dom"; import { Graph } from "@cosmos.gl/graph"; import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import { renderMicron } from "@/components/editor/micronRenderer"; +import FloatingWindow, { DITHERED_SHADOW } from "@/components/shared/FloatingWindow"; +import { useWindowManager } from "@/hooks/useWindowManager"; // --------------------------------------------------------------------------- // Constants — matching cosmos.gl clusters-with-labels example @@ -162,86 +164,14 @@ function buildGraphArrays( } // --------------------------------------------------------------------------- -// Per-window state & BrowserWindow +// Browse window data // --------------------------------------------------------------------------- -interface BrowserWin { - id: string; node: NetworkNode; - pageHtml: string | null; pageLoading: boolean; pageError: string | null; - x: number; y: number; w: number; h: number; zIndex: number; -} - -let nextWinZ = 1; - -const DITHERED_SHADOW = ` - 3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border), - 4px 4px 0 0 transparent, 6px 4px 0 0 var(--border), - 3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border), - 4px 6px 0 0 var(--border), 6px 6px 0 0 transparent -`; - -function BrowserWindow({ - win, focused, onUpdate, onClose, onFocus, -}: { - win: BrowserWin; focused: boolean; - onUpdate: (id: string, patch: Partial) => void; - onClose: (id: string) => void; onFocus: (id: string) => void; -}) { - const ref = useRef(null); - const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); - const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null); - useEffect(() => { if (focused) ref.current?.focus(); }, [focused]); - - const onDragStart = useCallback((e: React.MouseEvent) => { - if ((e.target as HTMLElement).closest("button")) return; - e.preventDefault(); onFocus(win.id); - dragRef.current = { startX: e.clientX, startY: e.clientY, origX: win.x, origY: win.y }; - const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; onUpdate(win.id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)) }); }; - const onUp = () => { dragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; - document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); - }, [win.id, win.x, win.y, onUpdate, onFocus]); - - const onResizeStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); e.stopPropagation(); onFocus(win.id); - resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: win.w, origH: win.h }; - const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(win.id, { w: Math.max(320, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(200, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); }; - const onUp = () => { resizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; - document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); - }, [win.id, win.w, win.h, onUpdate, onFocus]); - - return createPortal( -
{ if (e.key === "Escape") onClose(win.id); }} onMouseDown={() => onFocus(win.id)} - className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150" - style={{ left: win.x, top: win.y, width: win.w, height: win.h, zIndex: 999 + win.zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}> -
-
-
- {win.node.name} -
-
- addr -
{win.node.hash}
- - {win.pageLoading ? loading - : win.pageError ? <>error - : win.pageHtml ? <>ok : null} - -
-
- {win.pageLoading && Requesting page...} - {win.pageError && {win.pageError}} - {win.pageHtml &&
} -
-
- {win.node.type ?? "peer"} - {win.node.interface && via {win.node.interface}} -
-
- -
-
, document.body); +interface BrowseWinData { + node: NetworkNode; + pageHtml: string | null; + pageLoading: boolean; + pageError: string | null; } // --------------------------------------------------------------------------- @@ -251,8 +181,7 @@ function BrowserWindow({ export default function BrowseView() { const [nodes, setNodes] = useState([]); const [filter, setFilter] = useState(""); - const [windows, setWindows] = useState([]); - const [focusedWinId, setFocusedWinId] = useState(null); + const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager(); const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null); const [themeRev, setThemeRev] = useState(0); @@ -289,7 +218,7 @@ export default function BrowseView() { clusterNamesRef.current = graphData.clusterNames; }, [nodes, graphData]); - // Search + // Search — matches + autocomplete suggestions const searchMatchIndices = useMemo(() => { if (!filter.trim()) return null; const q = filter.trim().toLowerCase(); @@ -300,6 +229,19 @@ export default function BrowseView() { return indices.length > 0 ? indices : null; }, [filter, graphData]); + const suggestions = useMemo(() => { + if (!filter.trim()) return []; + const q = filter.trim().toLowerCase(); + return graphData.entries + .filter(e => e.name.toLowerCase().includes(q)) + .slice(0, 8); + }, [filter, graphData]); + + const [selectedSuggestion, setSelectedSuggestion] = useState(-1); + + // Reset selection when suggestions change + useEffect(() => { setSelectedSuggestion(-1); }, [suggestions]); + // ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ── const labelDivsRef = useRef([]); const updateClusterLabels = useCallback(() => { @@ -363,6 +305,7 @@ export default function BrowseView() { renderHoveredPointRing: true, hoveredPointRingColor: "#ffffff", scalePointsOnZoom: true, + pointGreyoutOpacity: 0.1, // Simulation defaults — dynamically adjusted by node count in data update simulationGravity: 0.5, simulationRepulsion: 1, @@ -387,9 +330,9 @@ export default function BrowseView() { const screen = graphRef.current.spaceToScreenPosition(pointPosition); setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] }); }, - onSimulationTick: () => updateClusterLabels(), - onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); }, - onZoom: () => updateClusterLabels(), + onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); }, + onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); }, + onZoom: () => { updateClusterLabels(); updateSearchLabels(); }, }); graphRef.current = graph; @@ -437,64 +380,79 @@ export default function BrowseView() { }, [graphData]); // ── Search highlighting + labels ── - const searchLabelDivsRef = useRef([]); - const searchTimerRef = useRef | null>(null); + const searchLabelDivsRef = useRef>(new Map()); + const searchIndicesRef = useRef(null); const clearSearchLabels = useCallback(() => { - if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); searchTimerRef.current = null; } searchLabelDivsRef.current.forEach(d => d.remove()); - searchLabelDivsRef.current = []; + searchLabelDivsRef.current.clear(); + searchIndicesRef.current = null; + }, []); + + // Reposition search labels (called on tick/zoom alongside cluster labels) + const updateSearchLabels = useCallback(() => { + const graph = graphRef.current; + const indices = searchIndicesRef.current; + if (!graph || !indices || indices.length === 0) return; + + const positions = graph.getPointPositions(); + const entries = entriesRef.current; + const containerEl = containerRef.current; + if (!containerEl) return; + const rect = containerEl.getBoundingClientRect(); + + for (const idx of indices) { + const x = positions[idx * 2]; + const y = positions[idx * 2 + 1]; + if (x === undefined || y === undefined) continue; + const screen = graph.spaceToScreenPosition([x, y]); + + let div = searchLabelDivsRef.current.get(idx); + if (!div) { + div = document.createElement("div"); + div.style.position = "fixed"; + div.style.pointerEvents = "none"; + div.style.padding = "2px 8px"; + div.style.borderRadius = "4px"; + div.style.background = "var(--popover)"; + div.style.border = "1px solid var(--primary)"; + div.style.color = "var(--primary)"; + div.style.fontFamily = "JetBrains Mono, monospace"; + div.style.fontWeight = "bold"; + div.style.fontSize = "11px"; + div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.4)"; + div.style.whiteSpace = "nowrap"; + div.style.zIndex = "998"; + div.textContent = entries[idx]?.name ?? ""; + document.body.appendChild(div); + searchLabelDivsRef.current.set(idx, div); + } + div.style.left = `${rect.left + screen[0] + 10}px`; + div.style.top = `${rect.top + screen[1] - 8}px`; + } }, []); useEffect(() => { const graph = graphRef.current; - const container = containerRef.current; - if (!graph || !container) return; + if (!graph) return; clearSearchLabels(); if (searchMatchIndices) { + searchIndicesRef.current = searchMatchIndices; graph.selectPointsByIndices(searchMatchIndices); if (searchMatchIndices.length <= 10) { graph.fitViewByPointIndices(searchMatchIndices, 500); } - - searchTimerRef.current = setTimeout(() => { - searchTimerRef.current = null; - if (!graphRef.current) return; - const positions = graphRef.current.getPointPositions(); - const entries = entriesRef.current; - for (const idx of searchMatchIndices) { - const x = positions[idx * 2]; - const y = positions[idx * 2 + 1]; - if (x === undefined || y === undefined) continue; - const screen = graphRef.current.spaceToScreenPosition([x, y]); - const div = document.createElement("div"); - div.style.position = "absolute"; - div.style.pointerEvents = "none"; - div.style.left = `${screen[0] + 10}px`; - div.style.top = `${screen[1] - 8}px`; - div.style.padding = "2px 8px"; - div.style.borderRadius = "4px"; - div.style.background = "var(--popover)"; - div.style.border = "1px solid var(--border)"; - div.style.color = "var(--foreground)"; - div.style.fontFamily = "JetBrains Mono, monospace"; - div.style.fontSize = "11px"; - div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)"; - div.style.whiteSpace = "nowrap"; - div.textContent = entries[idx]?.name ?? ""; - container.appendChild(div); - searchLabelDivsRef.current.push(div); - } - }, 600); + // Initial position — will be continuously updated on tick/zoom + updateSearchLabels(); } else { graph.unselectPoints(); graph.fitView(300, 0.2); } return () => clearSearchLabels(); - }, [searchMatchIndices, clearSearchLabels]); + }, [searchMatchIndices, clearSearchLabels, updateSearchLabels]); // ── SSE stream ── useEffect(() => { @@ -516,27 +474,13 @@ export default function BrowseView() { // ── Node click ── const handleNodeClick = useCallback((node: NetworkNode) => { - setWindows((prev) => { - const existing = prev.find((w) => w.node.hash === node.hash); - if (existing) { const z = ++nextWinZ; setFocusedWinId(existing.id); return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w); } - const winW = 480, winH = 400, margin = 20, z = ++nextWinZ; - const win: BrowserWin = { - id: `${node.hash}-${Date.now()}`, node, pageHtml: null, pageLoading: true, pageError: null, - x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)), - y: Math.round(margin + Math.random() * (Math.max(margin, window.innerHeight - winH - margin) - margin)), - w: winW, h: winH, zIndex: z, - }; - setFocusedWinId(win.id); - fetchRemotePage(node.hash) - .then((res) => setWindows((ws) => ws.map((w) => w.id === win.id ? { ...w, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } : w))) - .catch((e) => setWindows((ws) => ws.map((w) => w.id === win.id ? { ...w, pageError: String(e), pageLoading: false } : w))); - return [...prev, win]; - }); - }, []); - - const updateWindow = useCallback((id: string, patch: Partial) => setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w))), []); - const closeWindowById = useCallback((id: string) => { setWindows((prev) => prev.filter((w) => w.id !== id)); setFocusedWinId((cur) => cur === id ? null : cur); }, []); - const focusWindow = useCallback((id: string) => { setFocusedWinId((cur) => { if (cur === id) return cur; const z = ++nextWinZ; setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, zIndex: z } : w))); return id; }); }, []); + const id = node.hash; + const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null }; + openWindow(id, data); + fetchRemotePage(node.hash) + .then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } })) + .catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } })); + }, [openWindow, updateWindow]); const clearSearch = useCallback(() => { setFilter(""); @@ -548,10 +492,29 @@ export default function BrowseView() { const nodeCount = nodes.filter(n => n.type !== "interface").length; const ifaceCount = nodes.filter(n => n.type === "interface").length; + // ── Capture typing into search when no window is focused ── + useEffect(() => { searchInputRef.current?.focus(); }, []); + useEffect(() => { + if (windows.length === 0) searchInputRef.current?.focus(); + }, [windows.length]); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + // Skip if a window is focused, or already in the search input, or modifier keys + if (focusedWinId) return; + if (document.activeElement === searchInputRef.current) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key.length !== 1) return; // only printable characters + + searchInputRef.current?.focus(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [focusedWinId]); + // ── Draggable search bar ── const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null); const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); - useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 130 }); }, []); + useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 307 }); }, []); const onSearchDragStart = useCallback((e: React.MouseEvent) => { if ((e.target as HTMLElement).tagName === "INPUT") return; @@ -578,10 +541,21 @@ export default function BrowseView() { {/* Search bar */} {searchPos && createPortal(
- setFilter(e.target.value)} - onKeyDown={(e) => { if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); } }} + setFilter(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); return; } + if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, suggestions.length - 1)); return; } + if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; } + if (e.key === "Enter") { + e.preventDefault(); + const entry = selectedSuggestion >= 0 ? suggestions[selectedSuggestion] : suggestions[0]; + if (entry && entry.type !== "interface") { handleNodeClick(entry); clearSearch(); } + return; + } + }} placeholder="Search nodes..." className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" /> {filter && ( @@ -589,9 +563,34 @@ export default function BrowseView() { × )} - - {nodeCount} node{nodeCount !== 1 && "s"} · {ifaceCount} iface{ifaceCount !== 1 && "s"} - + {/* Autocomplete dropdown */} + {suggestions.length > 0 && ( +
+ {suggestions.map((entry, i) => ( + + ))} +
+ )}
, document.body)} {nodes.length === 0 && ( @@ -601,8 +600,40 @@ export default function BrowseView() { )} {windows.map((win) => ( - + + addr +
{win.data.node.hash}
+ + {win.data.pageLoading ? loading + : win.data.pageError ? <>error + : win.data.pageHtml ? <>ok : null} + +
+ } + footer={ +
+ {win.data.node.type ?? "peer"} + {win.data.node.interface && via {win.data.node.interface}} +
+ } + > +
+ {win.data.pageLoading && Requesting page...} + {win.data.pageError && {win.data.pageError}} + {win.data.pageHtml &&
} +
+ ))}
); diff --git a/frontend/src/routes/ComposeView.tsx b/frontend/src/routes/ComposeView.tsx index af5b4c8..da1b6e5 100644 --- a/frontend/src/routes/ComposeView.tsx +++ b/frontend/src/routes/ComposeView.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { MoreVertical, Plus, RotateCcw } from "lucide-react"; import { usePagesStore } from "@/stores/pagesStore"; @@ -29,13 +28,20 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { useWindowManager } from "@/hooks/useWindowManager"; +import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow"; export default function ComposeView() { const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } = usePagesStore(); - const navigate = useNavigate(); const [pageToDelete, setPageToDelete] = useState(null); const [restarting, setRestarting] = useState(false); + const { windows, focusedId, open, update, close, focus } = useWindowManager({ w: 720, h: 520 }); + + const openEditor = (name: string, isNew: boolean) => { + const id = isNew ? `new-${Date.now()}` : name; + open(id, { pageName: isNew ? "" : name, isNew }); + }; useEffect(() => { fetchPages(); @@ -89,15 +95,15 @@ export default function ComposeView() {
{/* Header row */} -
-

Compose

+
+

Compose

- -
@@ -119,7 +125,7 @@ export default function ComposeView() { navigate(`/editor/${p.name}`)} + onClick={() => openEditor(p.name, false)} > {p.name} @@ -138,8 +144,8 @@ export default function ComposeView() { openEditor(p.name, false)} onPublish={() => handlePublish(p.name)} onUnpublish={() => handleUnpublish(p.name)} onDelete={() => setPageToDelete(p.name)} @@ -184,6 +190,18 @@ export default function ComposeView() { + + {/* Floating editor windows */} + {windows.map((win) => ( + + ))}
); } @@ -191,20 +209,18 @@ export default function ComposeView() { /** Per-row action menu for a page. */ function PageActions({ - name, published, + onEdit, onPublish, onUnpublish, onDelete, }: { - name: string; published: boolean; + onEdit: () => void; onPublish: () => void; onUnpublish: () => void; onDelete: () => void; }) { - const navigate = useNavigate(); - return ( {published ? ( diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index 931fd39..b02d52c 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import type { PageMeta } from "@/api/client"; -interface EditorStore { +export interface EditorStore { // Source ufSource: string; isDirty: boolean; @@ -30,6 +30,41 @@ interface EditorStore { reset: () => void; } +const initialState = { + ufSource: "", + isDirty: false, + currentPage: null as PageMeta | null, + compiledAscii: "", + compiledMicron: "", + compiledScript: "", + isDynamic: false, + compileWarnings: [] as string[], + isCompiling: false, + compileError: null as string | null, + previewMode: "micron" as const, +}; + +function makeActions(set: (partial: Partial) => void) { + return { + setSource: (s: string) => set({ ufSource: s, isDirty: true }), + setCurrentPage: (p: PageMeta | null) => set({ currentPage: p }), + setDirty: (v: boolean) => set({ isDirty: v }), + setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => + set({ compiledAscii: ascii, compiledMicron: micron, compiledScript: script, isDynamic, compileWarnings: warnings, isCompiling: false, compileError: null }), + setCompiling: (v: boolean) => set({ isCompiling: v }), + setCompileError: (e: string | null) => set({ compileError: e, isCompiling: false }), + setPreviewMode: (mode: "micron" | "raw" | "script") => set({ previewMode: mode }), + reset: () => set({ ...initialState }), + }; +} + +export function createEditorStore() { + return create((set) => ({ + ...initialState, + ...makeActions(set), + })); +} + export const useEditorStore = create((set) => ({ ufSource: "", isDirty: false,