) {
| ) {
| 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 |