diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e9a24f1..980dd85 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,6 +6,15 @@ * (e.g. when adding multi-node support with /api/nodes/{id}/...). */ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function json(res: Response): Promise { + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + // --------------------------------------------------------------------------- // Pages // --------------------------------------------------------------------------- @@ -26,12 +35,12 @@ export interface PageDetail { export async function fetchPages(): Promise { const res = await fetch("/api/pages"); - return res.json(); + return json(res); } export async function fetchPage(name: string): Promise { const res = await fetch(`/api/pages/${name}`); - return res.json(); + return json(res); } export async function savePage( @@ -44,12 +53,12 @@ export async function savePage( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source, publish }), }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); + return json(res); } export async function deletePage(name: string): Promise { - await fetch(`/api/pages/${name}`, { method: "DELETE" }); + const res = await fetch(`/api/pages/${name}`, { method: "DELETE" }); + if (!res.ok) throw new Error(await res.text()); } // --------------------------------------------------------------------------- @@ -101,7 +110,7 @@ export interface DslMeta { export async function fetchDslMeta(): Promise { const res = await fetch("/api/dsl-meta"); - return res.json(); + return json(res); } // --------------------------------------------------------------------------- @@ -134,7 +143,7 @@ export interface NetworkNode { export async function fetchBrowseNodes(): Promise { const res = await fetch("/api/browse/nodes"); - const data = await res.json(); + const data = await json(res); return Array.isArray(data) ? data : []; } @@ -157,7 +166,7 @@ export async function fetchRemotePage( const res = await fetch( `/api/browse/page/${hash}?path=${encodeURIComponent(path)}`, ); - return res.json(); + return json(res); } // --------------------------------------------------------------------------- @@ -177,7 +186,7 @@ export interface FileEntry { export async function fetchFiles(path: string = ""): Promise { const params = path ? `?path=${encodeURIComponent(path)}` : ""; const res = await fetch(`/api/files${params}`); - return res.json(); + return json(res); } export async function createFolder(path: string): Promise { @@ -200,7 +209,7 @@ export async function moveFile(from: string, to: string): Promise { export async function fetchEnv(): Promise { const res = await fetch("/api/files/env"); - const data = await res.json(); + const data = await json<{ content: string }>(res); return data.content; } @@ -226,6 +235,5 @@ export async function uploadImage(file: File): Promise { const form = new FormData(); form.append("file", file); const res = await fetch("/api/upload-image", { method: "POST", body: form }); - if (!res.ok) throw new Error(await res.text()); - return res.json(); + return json(res); } diff --git a/frontend/src/components/browse/BrowseNodeWindow.tsx b/frontend/src/components/browse/BrowseNodeWindow.tsx new file mode 100644 index 0000000..e710f26 --- /dev/null +++ b/frontend/src/components/browse/BrowseNodeWindow.tsx @@ -0,0 +1,74 @@ +import FloatingWindow from "@/components/shared/FloatingWindow"; +import type { ManagedWindow } from "@/hooks/useWindowManager"; +import type { BrowseWinData } from "./types"; + +interface BrowseNodeWindowProps { + win: ManagedWindow; + focused: boolean; + onUpdate: (id: string, patch: Partial>) => void; + onClose: (id: string) => void; + onFocus: (id: string) => void; + onNavBack: (winId: string, data: BrowseWinData) => void; + onNavForward: (winId: string, data: BrowseWinData) => void; + onNavReload: (winId: string, data: BrowseWinData) => void; + onContentClick: (e: React.MouseEvent, winId: string, data: BrowseWinData) => void; +} + +export default function BrowseNodeWindow({ + win, focused, onUpdate, onClose, onFocus, + onNavBack, onNavForward, onNavReload, onContentClick, +}: BrowseNodeWindowProps) { + const d = win.data; + const canBack = d.historyIndex > 0; + const canFwd = d.historyIndex < d.history.length - 1; + + return ( + + + + +
+ {d.node.hash.slice(0, 12)}\u2026/{d.currentPath} +
+ + {d.pageLoading ? loading + : d.pageError ? <>error + : d.pageHtml ? <>ok : null} + + + } + footer={ +
+ {d.node.type ?? "peer"} + {d.node.interface && via {d.node.interface}} +
+ } + > + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
onContentClick(e, win.id, d)}> + {d.pageLoading && Requesting page...} + {d.pageError && {d.pageError}} + {d.pageHtml &&
} +
+ + ); +} diff --git a/frontend/src/components/browse/BrowseSearchBar.tsx b/frontend/src/components/browse/BrowseSearchBar.tsx new file mode 100644 index 0000000..8f5f966 --- /dev/null +++ b/frontend/src/components/browse/BrowseSearchBar.tsx @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { DITHERED_SHADOW } from "@/components/shared/FloatingWindow"; +import type { NetworkNode } from "@/api/client"; + +interface BrowseSearchBarProps { + filter: string; + onFilterChange: (value: string) => void; + onClear: () => void; + suggestions: NetworkNode[]; + onSelectNode: (node: NetworkNode) => void; + focusedWinId: string | null; + windowCount: number; +} + +export default function BrowseSearchBar({ + filter, onFilterChange, onClear, + suggestions, onSelectNode, + focusedWinId, windowCount, +}: BrowseSearchBarProps) { + const searchInputRef = useRef(null); + const [selectedSuggestion, setSelectedSuggestion] = useState(-1); + + // Reset selection when suggestions change + useEffect(() => { setSelectedSuggestion(-1); }, [suggestions]); + + // Auto-focus search when no windows open + useEffect(() => { searchInputRef.current?.focus(); }, []); + useEffect(() => { + if (windowCount === 0) searchInputRef.current?.focus(); + }, [windowCount]); + + // Capture typing into search when no window is focused + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (focusedWinId) return; + if (document.activeElement === searchInputRef.current) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (e.key.length !== 1) return; + searchInputRef.current?.focus(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [focusedWinId]); + + // Draggable position + 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 - 307 }); }, []); + + const onSearchDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).tagName === "INPUT") return; + e.preventDefault(); + const pos = searchPos ?? { x: 0, y: 0 }; + searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; + const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); }; + const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; + document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); + }, [searchPos]); + + if (!searchPos) return null; + + return createPortal( +
+ onFilterChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { onClear(); 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") { onSelectNode(entry); onClear(); } + 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 && ( + + )} + {suggestions.length > 0 && ( +
+ {suggestions.map((entry, i) => ( + + ))} +
+ )} +
, document.body); +} diff --git a/frontend/src/components/browse/buildGraph.ts b/frontend/src/components/browse/buildGraph.ts new file mode 100644 index 0000000..038090d --- /dev/null +++ b/frontend/src/components/browse/buildGraph.ts @@ -0,0 +1,98 @@ +import type { NetworkNode } from "@/api/client"; +import { statusRGBA, type RGBA, type StatusColors } from "./graphColors"; + +export const SPACE_SIZE = 4096; +const CENTER = SPACE_SIZE / 2; + +export interface BuiltGraph { + entries: NetworkNode[]; + positions: Float32Array; + colors: Float32Array; + sizes: Float32Array; + clusterIndices: (number | undefined)[]; + clusterPositions: (number | undefined)[]; + clusterStrength: Float32Array; + clusterNames: string[]; + hashToIndex: Map; +} + +function findParentIface( + entry: NetworkNode, + interfaces: NetworkNode[], + peerIndex: number, +): string | undefined { + if (entry.interface) { + const iface = interfaces.find(i => i.name === entry.interface); + if (iface) return iface.hash; + } + if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash; + return undefined; +} + +export function buildGraphArrays( + rawNodes: NetworkNode[], + prevHashToIndex: Map, + prevPositions: number[], + theme: StatusColors, +): BuiltGraph { + const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name)); + const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface"); + const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface"); + + const entries = selfNode ? [selfNode, ...peers] : peers; + const selfIndex = selfNode ? 0 : -1; + const n = entries.length; + const nClusters = interfaces.length; + const hashToIndex = new Map(); + const positions = new Float32Array(n * 2); + const colors = new Float32Array(n * 4); + const sizes = new Float32Array(n); + const clusterIndices: (number | undefined)[] = []; + const clusterStrength = new Float32Array(n); + + const clusterMap = new Map(); + interfaces.forEach((iface, i) => clusterMap.set(iface.name, i)); + const selfClusterIndex = interfaces.length; + + const clusterNames = [...interfaces.map(i => i.name), ...(selfNode ? [selfNode.name] : [])]; + const clusterPositions: (number | undefined)[] = []; + + entries.forEach((entry, i) => { + hashToIndex.set(entry.hash, i); + + // Preserve existing position, or jitter near center for new points + const prevIdx = prevHashToIndex.get(entry.hash); + if (prevIdx !== undefined && prevPositions.length >= (prevIdx + 1) * 2) { + positions[i * 2] = prevPositions[prevIdx * 2]!; + positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!; + } else { + positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; + positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; + } + + // Self node = bright white, others by status + const rgba: RGBA = i === selfIndex + ? [1, 1, 1, 1] + : statusRGBA(entry, theme); + colors[i * 4 + 0] = rgba[0]; + colors[i * 4 + 1] = rgba[1]; + colors[i * 4 + 2] = rgba[2]; + colors[i * 4 + 3] = rgba[3]; + + sizes[i] = 3; + + // Cluster assignment + if (i === selfIndex) { + clusterIndices.push(selfClusterIndex); + } else { + const pi = peers.indexOf(entry); + const parentHash = findParentIface(entry, interfaces, pi); + const parentName = parentHash ? interfaces.find(f => f.hash === parentHash)?.name : undefined; + clusterIndices.push(parentName !== undefined ? clusterMap.get(parentName) : undefined); + } + + clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1; + }); + + return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex }; +} diff --git a/frontend/src/components/browse/graphColors.ts b/frontend/src/components/browse/graphColors.ts new file mode 100644 index 0000000..ad88704 --- /dev/null +++ b/frontend/src/components/browse/graphColors.ts @@ -0,0 +1,57 @@ +import type { NetworkNode } from "@/api/client"; + +export type RGBA = [number, number, number, number]; + +export function cssVarToRGBA(varName: string): RGBA { + const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + if (!raw) return [0.5, 0.5, 0.5, 1]; + const ctx = document.createElement("canvas").getContext("2d")!; + ctx.fillStyle = raw; + // ctx.fillStyle normalizes to #rrggbb + const hex = ctx.fillStyle; + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + return [r, g, b, 1]; +} + +export function lerpRGBA(a: RGBA, b: RGBA, t: number): RGBA { + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + a[3] + (b[3] - a[3]) * t, + ]; +} + +export function brighten(c: RGBA, amount: number): RGBA { + return [ + Math.min(1, c[0] + amount), + Math.min(1, c[1] + amount), + Math.min(1, c[2] + amount), + c[3], + ]; +} + +export interface StatusColors { + online: RGBA; + stale: RGBA; + offline: RGBA; +} + +export function getThemeStatusColors(): StatusColors { + const primary = brighten(cssVarToRGBA("--primary"), 0.15); + const muted = cssVarToRGBA("--muted-foreground"); + return { + online: primary, + stale: lerpRGBA(primary, muted, 0.4), + offline: muted, + }; +} + +export function statusRGBA(entry: NetworkNode, theme: StatusColors): RGBA { + const age = Date.now() / 1000 - (entry.last_seen ?? 0); + if (age < 300) return theme.online; + if (age < 3600) return theme.stale; + return theme.offline; +} diff --git a/frontend/src/components/browse/types.ts b/frontend/src/components/browse/types.ts new file mode 100644 index 0000000..c8bef91 --- /dev/null +++ b/frontend/src/components/browse/types.ts @@ -0,0 +1,17 @@ +import type { NetworkNode } from "@/api/client"; + +export interface HistoryEntry { + path: string; + html: string | null; + error: string | null; +} + +export interface BrowseWinData { + node: NetworkNode; + pageHtml: string | null; + pageLoading: boolean; + pageError: string | null; + currentPath: string; + history: HistoryEntry[]; + historyIndex: number; +} diff --git a/frontend/src/components/editor/EditorWindow.tsx b/frontend/src/components/editor/EditorWindow.tsx index 8dd87fe..c9445d6 100644 --- a/frontend/src/components/editor/EditorWindow.tsx +++ b/frontend/src/components/editor/EditorWindow.tsx @@ -1,8 +1,6 @@ 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"; @@ -10,24 +8,17 @@ import { createEditorStore, type EditorStore } from "@/stores/editorStore"; import { usePagesStore } from "@/stores/pagesStore"; import { useCompile } from "@/hooks/useCompile"; import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; +import { useKeyboardSave } from "@/hooks/useKeyboardSave"; 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 SourcePane from "./SourcePane"; 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, @@ -116,16 +107,11 @@ export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus [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]); + useKeyboardSave( + useCallback(() => handleSave(false), [handleSave]), + useCallback(() => handleSave(true), [handleSave]), + focused, + ); // Confirm close if dirty const handleClose = useCallback((id: string) => { @@ -175,81 +161,3 @@ export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus } -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/SourcePane.tsx b/frontend/src/components/editor/SourcePane.tsx new file mode 100644 index 0000000..c68f8c1 --- /dev/null +++ b/frontend/src/components/editor/SourcePane.tsx @@ -0,0 +1,91 @@ +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { BookOpen, Upload } from "lucide-react"; +import type { Extension } from "@codemirror/state"; +import * as api from "@/api/client"; +import EditorPane from "./EditorPane"; +import { EXAMPLES } from "./examples"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverHeader, + PopoverTitle, +} from "@/components/ui/popover"; + +interface SourcePaneProps { + ufSource: string; + setSource: (s: string) => void; + extensions: Extension[]; +} + +export default function SourcePane({ ufSource, setSource, extensions }: SourcePaneProps) { + 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/hooks/useCompile.ts b/frontend/src/hooks/useCompile.ts index b1c8eb0..80e4b8d 100644 --- a/frontend/src/hooks/useCompile.ts +++ b/frontend/src/hooks/useCompile.ts @@ -12,20 +12,11 @@ const DEBOUNCE_MS = 400; * Accepts an optional store API for per-window instances; falls back to the global singleton. */ 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 store = storeApi ?? useEditorStore; + const ufSource = useStore(store, (s) => s.ufSource); + const setCompileResult = useStore(store, (s) => s.setCompileResult); + const setCompiling = useStore(store, (s) => s.setCompiling); + const setCompileError = useStore(store, (s) => s.setCompileError); const timerRef = useRef | null>(null); const abortRef = useRef(null); diff --git a/frontend/src/hooks/useKeyboardSave.ts b/frontend/src/hooks/useKeyboardSave.ts new file mode 100644 index 0000000..ccd3a1d --- /dev/null +++ b/frontend/src/hooks/useKeyboardSave.ts @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +/** + * Registers Ctrl/Cmd+S (save) and optionally Ctrl/Cmd+P (publish) keyboard shortcuts. + * Pass `enabled = false` to temporarily disable (e.g. when a window is not focused). + */ +export function useKeyboardSave( + onSave: () => void, + onPublish?: () => void, + enabled = true, +) { + useEffect(() => { + if (!enabled) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + onSave(); + } + if (onPublish && (e.metaKey || e.ctrlKey) && e.key === "p") { + e.preventDefault(); + onPublish(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onSave, onPublish, enabled]); +} diff --git a/frontend/src/hooks/useUnsavedGuard.ts b/frontend/src/hooks/useUnsavedGuard.ts index 05605d0..a8e72e5 100644 --- a/frontend/src/hooks/useUnsavedGuard.ts +++ b/frontend/src/hooks/useUnsavedGuard.ts @@ -4,9 +4,8 @@ import { useEditorStore } from "@/stores/editorStore"; import type { EditorStore } from "@/stores/editorStore"; export function useUnsavedGuard(storeApi?: StoreApi) { - const globalDirty = useEditorStore((s) => s.isDirty); - const localDirty = useStore(storeApi ?? useEditorStore, (s) => s.isDirty); - const isDirty = storeApi ? localDirty : globalDirty; + const store = storeApi ?? useEditorStore; + const isDirty = useStore(store, (s) => s.isDirty); useEffect(() => { const handler = (e: BeforeUnloadEvent) => { diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index eb99b69..e62b7f8 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -1,198 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -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 -// --------------------------------------------------------------------------- - -const SPACE_SIZE = 4096; -const CENTER = SPACE_SIZE / 2; - -// --------------------------------------------------------------------------- -// Theme-aware status colors — reads CSS variables, returns 0–1 RGBA -// --------------------------------------------------------------------------- - -function cssVarToRGBA(varName: string): [number, number, number, number] { - const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); - if (!raw) return [0.5, 0.5, 0.5, 1]; - const ctx = document.createElement("canvas").getContext("2d")!; - ctx.fillStyle = raw; - // ctx.fillStyle normalizes to #rrggbb - const hex = ctx.fillStyle; - const r = parseInt(hex.slice(1, 3), 16) / 255; - const g = parseInt(hex.slice(3, 5), 16) / 255; - const b = parseInt(hex.slice(5, 7), 16) / 255; - return [r, g, b, 1]; -} - -function lerpRGBA( - a: [number, number, number, number], - b: [number, number, number, number], - t: number, -): [number, number, number, number] { - return [ - a[0] + (b[0] - a[0]) * t, - a[1] + (b[1] - a[1]) * t, - a[2] + (b[2] - a[2]) * t, - a[3] + (b[3] - a[3]) * t, - ]; -} - -function brighten(c: [number, number, number, number], amount: number): [number, number, number, number] { - return [ - Math.min(1, c[0] + amount), - Math.min(1, c[1] + amount), - Math.min(1, c[2] + amount), - c[3], - ]; -} - -function getThemeStatusColors() { - const primary = brighten(cssVarToRGBA("--primary"), 0.15); - const muted = cssVarToRGBA("--muted-foreground"); - return { - online: primary, // --primary brightened - stale: lerpRGBA(primary, muted, 0.4), // blend, closer to primary - offline: muted, // --muted-foreground - }; -} - -function statusRGBA(entry: NetworkNode, theme: ReturnType): [number, number, number, number] { - const age = Date.now() / 1000 - (entry.last_seen ?? 0); - if (age < 300) return theme.online; - if (age < 3600) return theme.stale; - return theme.offline; -} - -// --------------------------------------------------------------------------- -// Build flat arrays for cosmos — positions around spaceSize/2 -// --------------------------------------------------------------------------- - -interface BuiltGraph { - entries: NetworkNode[]; - positions: Float32Array; - colors: Float32Array; - sizes: Float32Array; - clusterIndices: (number | undefined)[]; - clusterPositions: (number | undefined)[]; - clusterStrength: Float32Array; - clusterNames: string[]; - hashToIndex: Map; -} - -function findParentIface( - entry: NetworkNode, - interfaces: NetworkNode[], - peerIndex: number, -): string | undefined { - if (entry.interface) { - const iface = interfaces.find(i => i.name === entry.interface); - if (iface) return iface.hash; - } - if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash; - return undefined; -} - -function buildGraphArrays( - rawNodes: NetworkNode[], - prevHashToIndex: Map, - prevPositions: number[], - theme: ReturnType, -): BuiltGraph { - const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name)); - const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface"); - const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface"); - - // Peers + self node are points — interfaces are clusters, not nodes - // Self node goes first so it's easy to find by index - const entries = selfNode ? [selfNode, ...peers] : peers; - const selfIndex = selfNode ? 0 : -1; - const n = entries.length; - const nClusters = interfaces.length; - const hashToIndex = new Map(); - const positions = new Float32Array(n * 2); - const colors = new Float32Array(n * 4); - const sizes = new Float32Array(n); - const clusterIndices: (number | undefined)[] = []; - const clusterStrength = new Float32Array(n); - - // Cluster map: interface name → cluster index - // Self node gets its own cluster at the end - const clusterMap = new Map(); - interfaces.forEach((iface, i) => clusterMap.set(iface.name, i)); - const selfClusterIndex = interfaces.length; - - // Cluster names for labels - const clusterNames = [...interfaces.map(i => i.name), ...(selfNode ? [selfNode.name] : [])]; - - // No explicit cluster positions — let cosmos use centermass - const clusterPositions: (number | undefined)[] = []; - - entries.forEach((entry, i) => { - hashToIndex.set(entry.hash, i); - - // Position: preserve existing, or jitter near center for new points - const prevIdx = prevHashToIndex.get(entry.hash); - if (prevIdx !== undefined && prevPositions.length >= (prevIdx + 1) * 2) { - positions[i * 2] = prevPositions[prevIdx * 2]!; - positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!; - } else { - positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; - positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5; - } - - // Colors — self node uses bright white, others by status - const rgba = i === selfIndex - ? [1, 1, 1, 1] as [number, number, number, number] - : statusRGBA(entry, theme); - colors[i * 4 + 0] = rgba[0]; - colors[i * 4 + 1] = rgba[1]; - colors[i * 4 + 2] = rgba[2]; - colors[i * 4 + 3] = rgba[3]; - - sizes[i] = 3; - - // Cluster assignment — self node gets its own cluster, peers map to parent interface - if (i === selfIndex) { - clusterIndices.push(selfClusterIndex); - } else { - const pi = peers.indexOf(entry); - const parentHash = findParentIface(entry, interfaces, pi); - const parentName = parentHash ? interfaces.find(f => f.hash === parentHash)?.name : undefined; - clusterIndices.push(parentName !== undefined ? clusterMap.get(parentName) : undefined); - } - - // Cluster strength - clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1; - }); - - return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex }; -} - -// --------------------------------------------------------------------------- -// Browse window data -// --------------------------------------------------------------------------- - -interface HistoryEntry { - path: string; - html: string | null; - error: string | null; -} - -interface BrowseWinData { - node: NetworkNode; - pageHtml: string | null; - pageLoading: boolean; - pageError: string | null; - currentPath: string; - history: HistoryEntry[]; - historyIndex: number; -} +import { getThemeStatusColors } from "@/components/browse/graphColors"; +import { buildGraphArrays, SPACE_SIZE } from "@/components/browse/buildGraph"; +import type { BrowseWinData, HistoryEntry } from "@/components/browse/types"; +import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow"; +import BrowseSearchBar from "@/components/browse/BrowseSearchBar"; // --------------------------------------------------------------------------- // Main component @@ -214,7 +29,6 @@ export default function BrowseView() { }, []); const containerRef = useRef(null); - const searchInputRef = useRef(null); const graphRef = useRef(null); const entriesRef = useRef([]); const nodesMapRef = useRef>(new Map()); @@ -257,11 +71,6 @@ export default function BrowseView() { .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(() => { @@ -299,7 +108,6 @@ export default function BrowseView() { } } - // Update positions for (let i = 0; i < nClusters; i++) { const x = positions[i * 2]; const y = positions[i * 2 + 1]; @@ -311,97 +119,7 @@ export default function BrowseView() { } }, []); - // ── Init Cosmos — matching the example's create-cosmos.ts ── - useEffect(() => { - if (!containerRef.current) return; - - const graph = new Graph(containerRef.current, { - spaceSize: SPACE_SIZE, - backgroundColor: "transparent", - pointDefaultColor: "#888888", - pointDefaultSize: 10, - renderLinks: false, - fitViewOnInit: true, - fitViewDelay: 1500, - fitViewPadding: 0.2, - renderHoveredPointRing: true, - hoveredPointRingColor: "#ffffff", - scalePointsOnZoom: true, - pointGreyoutOpacity: 0.1, - // Simulation defaults — dynamically adjusted by node count in data update - simulationGravity: 0.5, - simulationRepulsion: 1, - simulationCluster: 0.5, - simulationDecay: 5000, - simulationFriction: 0.85, - simulationLinkSpring: 0, - simulationLinkDistance: 1, - // Events - onClick: (index, _pos, _event) => { - if (index === undefined) return; - const entry = entriesRef.current[index]; - if (entry && entry.type !== "interface") handleNodeClick(entry); - }, - onMouseMove: (index, pointPosition) => { - if (index === undefined || !pointPosition || !graphRef.current) { - setHoveredLabel(null); - return; - } - const entry = entriesRef.current[index]; - if (!entry) return; - const screen = graphRef.current.spaceToScreenPosition(pointPosition); - setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] }); - }, - onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); }, - onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); }, - onZoom: () => { updateClusterLabels(); updateSearchLabels(); }, - }); - - graphRef.current = graph; - - return () => { - labelDivsRef.current.forEach(d => d.remove()); - labelDivsRef.current = []; - graph.destroy(); - graphRef.current = null; - }; - }, []); - - // ── Update data — following the example's exact call order ── - const isFirstLoadRef = useRef(true); - useEffect(() => { - const graph = graphRef.current; - if (!graph || graphData.entries.length === 0) return; - - // Scale simulation params by node count - // Reference: 10k nodes → repulsion 10, cluster 0.25, gravity 2 - // Scale logarithmically so it works from 10 to 10k nodes - const n = graphData.entries.length; - const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); // 0..1 - graph.setConfig({ - simulationRepulsion: 0.5 + scale * 9.5, // 0.5 → 10 - simulationCluster: 1.0 - scale * 0.75, // 1.0 → 0.25 - simulationGravity: 0.25 + scale * 1.75, // 0.25 → 2 - }); - - graph.setPointPositions(graphData.positions); - graph.setPointColors(graphData.colors); - graph.setPointSizes(graphData.sizes); - graph.setPointClusters(graphData.clusterIndices); - graph.setClusterPositions(graphData.clusterPositions); - graph.setPointClusterStrength(graphData.clusterStrength); - graph.setLinks(new Float32Array(0)); - - if (isFirstLoadRef.current) { - graph.render(1); - isFirstLoadRef.current = false; - } else { - graph.render(0.1); - } - updateClusterLabels(); - }, [graphData]); - - // ── Search highlighting + labels ── + // ── Search highlighting labels ── const searchLabelDivsRef = useRef>(new Map()); const searchIndicesRef = useRef(null); @@ -411,7 +129,6 @@ export default function BrowseView() { searchIndicesRef.current = null; }, []); - // Reposition search labels (called on tick/zoom alongside cluster labels) const updateSearchLabels = useCallback(() => { const graph = graphRef.current; const indices = searchIndicesRef.current; @@ -454,6 +171,92 @@ export default function BrowseView() { } }, []); + // ── Init Cosmos ── + useEffect(() => { + if (!containerRef.current) return; + + const graph = new Graph(containerRef.current, { + spaceSize: SPACE_SIZE, + backgroundColor: "transparent", + pointDefaultColor: "#888888", + pointDefaultSize: 10, + renderLinks: false, + fitViewOnInit: true, + fitViewDelay: 1500, + fitViewPadding: 0.2, + renderHoveredPointRing: true, + hoveredPointRingColor: "#ffffff", + scalePointsOnZoom: true, + pointGreyoutOpacity: 0.1, + simulationGravity: 0.5, + simulationRepulsion: 1, + simulationCluster: 0.5, + simulationDecay: 5000, + simulationFriction: 0.85, + simulationLinkSpring: 0, + simulationLinkDistance: 1, + onClick: (index, _pos, _event) => { + if (index === undefined) return; + const entry = entriesRef.current[index]; + if (entry && entry.type !== "interface") handleNodeClick(entry); + }, + onMouseMove: (index, pointPosition) => { + if (index === undefined || !pointPosition || !graphRef.current) { + setHoveredLabel(null); + return; + } + const entry = entriesRef.current[index]; + if (!entry) return; + const screen = graphRef.current.spaceToScreenPosition(pointPosition); + setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] }); + }, + onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); }, + onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); }, + onZoom: () => { updateClusterLabels(); updateSearchLabels(); }, + }); + + graphRef.current = graph; + + return () => { + labelDivsRef.current.forEach(d => d.remove()); + labelDivsRef.current = []; + graph.destroy(); + graphRef.current = null; + }; + }, []); + + // ── Update graph data ── + const isFirstLoadRef = useRef(true); + useEffect(() => { + const graph = graphRef.current; + if (!graph || graphData.entries.length === 0) return; + + const n = graphData.entries.length; + const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); + graph.setConfig({ + simulationRepulsion: 0.5 + scale * 9.5, + simulationCluster: 1.0 - scale * 0.75, + simulationGravity: 0.25 + scale * 1.75, + }); + + graph.setPointPositions(graphData.positions); + graph.setPointColors(graphData.colors); + graph.setPointSizes(graphData.sizes); + graph.setPointClusters(graphData.clusterIndices); + graph.setClusterPositions(graphData.clusterPositions); + graph.setPointClusterStrength(graphData.clusterStrength); + graph.setLinks(new Float32Array(0)); + + if (isFirstLoadRef.current) { + graph.render(1); + isFirstLoadRef.current = false; + } else { + graph.render(0.1); + } + updateClusterLabels(); + }, [graphData]); + + // ── Search highlighting ── useEffect(() => { const graph = graphRef.current; if (!graph) return; @@ -466,7 +269,6 @@ export default function BrowseView() { if (searchMatchIndices.length <= 10) { graph.fitViewByPointIndices(searchMatchIndices, 500); } - // Initial position — will be continuously updated on tick/zoom updateSearchLabels(); } else { graph.unselectPoints(); @@ -510,7 +312,6 @@ export default function BrowseView() { const error = res.content ? null : (res.error ?? "No content"); const entry: HistoryEntry = { path, html, error }; - // Build new history: truncate any forward entries, push new const prevHistory = loading.history.slice(0, loading.historyIndex + 1); const newHistory = [...prevHistory, entry]; const newIndex = newHistory.length - 1; @@ -562,11 +363,8 @@ export default function BrowseView() { const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? ""; if (!dest) return; - // Strip nomadnetwork:// prefix if present, normalize path let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, ""); - // If it looks like a hash (hex, 32 chars), it's a node link — not a page path if (/^[0-9a-f]{32}$/i.test(path)) return; - // Ensure .mu extension if (!path.endsWith(".mu")) path += ".mu"; navigateTo(winId, data.node, path, data); @@ -579,41 +377,6 @@ export default function BrowseView() { updateClusterLabels(); }, [updateClusterLabels]); - - // ── 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 - 307 }); }, []); - - const onSearchDragStart = useCallback((e: React.MouseEvent) => { - if ((e.target as HTMLElement).tagName === "INPUT") return; - e.preventDefault(); - const pos = searchPos ?? { x: 0, y: 0 }; - searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y }; - const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); }; - const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; - document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); - }, [searchPos]); - return (
@@ -626,60 +389,15 @@ export default function BrowseView() {
)} - {/* Search bar */} - {searchPos && createPortal( -
- 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 && ( - - )} - {/* Autocomplete dropdown */} - {suggestions.length > 0 && ( -
- {suggestions.map((entry, i) => ( - - ))} -
- )} -
, document.body)} + {nodes.length === 0 && (
@@ -687,63 +405,20 @@ export default function BrowseView() {
)} - {windows.map((win) => { - const d = win.data; - const canBack = d.historyIndex > 0; - const canFwd = d.historyIndex < d.history.length - 1; - return ( - - {/* Nav buttons */} - - - - {/* Address */} -
- {d.node.hash.slice(0, 12)}…/{d.currentPath} -
- - {d.pageLoading ? loading - : d.pageError ? <>error - : d.pageHtml ? <>ok : null} - -
- } - footer={ -
- {d.node.type ?? "peer"} - {d.node.interface && via {d.node.interface}} -
- } - > - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
handleContentClick(e, win.id, d)}> - {d.pageLoading && Requesting page...} - {d.pageError && {d.pageError}} - {d.pageHtml &&
} -
- - ); - })} + {windows.map((win) => ( + + ))}
); } diff --git a/frontend/src/routes/ComposeView.tsx b/frontend/src/routes/ComposeView.tsx index 4ba33b4..490551a 100644 --- a/frontend/src/routes/ComposeView.tsx +++ b/frontend/src/routes/ComposeView.tsx @@ -3,6 +3,7 @@ import { toast } from "sonner"; import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react"; import { usePagesStore } from "@/stores/pagesStore"; import * as api from "@/api/client"; +import { useKeyboardSave } from "@/hooks/useKeyboardSave"; import StatusBadge from "@/components/dashboard/StatusBadge"; import { Button } from "@/components/ui/button"; import { @@ -39,6 +40,8 @@ import type { ManagedWindow } from "@/hooks/useWindowManager"; // Env editor window data // --------------------------------------------------------------------------- +type SortKey = "name" | "size" | "modified"; + interface EnvWinData { kind: "env"; } @@ -57,7 +60,7 @@ export default function ComposeView() { const [pageToDelete, setPageToDelete] = useState(null); const [newFolderName, setNewFolderName] = useState(""); const [showNewFolder, setShowNewFolder] = useState(false); - const [sortKey, setSortKey] = useState<"name" | "size" | "modified">("name"); + const [sortKey, setSortKey] = useState("name"); const [sortAsc, setSortAsc] = useState(true); const sortedFiles = useMemo(() => { @@ -76,7 +79,7 @@ export default function ComposeView() { return [...folders, ...rest]; }, [files, sortKey, sortAsc]); - const toggleSort = (key: "name" | "size" | "modified") => { + const toggleSort = (key: SortKey) => { if (sortKey === key) setSortAsc(!sortAsc); else { setSortKey(key); setSortAsc(true); } }; @@ -97,7 +100,7 @@ export default function ComposeView() { } }, [currentPath]); - useEffect(() => { loadFiles(currentPath); }, [currentPath]); + useEffect(() => { loadFiles(currentPath); }, [currentPath, loadFiles]); const navigateTo = (path: string) => setCurrentPath(path); @@ -424,6 +427,8 @@ function FileActions({ moveTargets.push({ label: f.name + "/", path: f.path + "/" + entry.name }); } + const menuItem = "w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"; + return ( - + {entry.published ? ( - + ) : ( - + )} {moveTargets.length > 0 && ( <> + ); @@ -493,10 +494,10 @@ function FileActions({ function SortableHead({ label, sortKey, currentKey, asc, onToggle }: { label: string; - sortKey: "name" | "size" | "modified"; + sortKey: SortKey; currentKey: string; asc: boolean; - onToggle: (key: "name" | "size" | "modified") => void; + onToggle: (key: SortKey) => void; }) { const active = currentKey === sortKey; return ( @@ -560,17 +561,7 @@ function EnvEditorWindow({ } }, [content]); - useEffect(() => { - if (!focused) return; - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "s") { - e.preventDefault(); - handleSave(); - } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [focused, handleSave]); + useKeyboardSave(handleSave, undefined, focused); const handleClose = useCallback((id: string) => { if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return; diff --git a/frontend/src/routes/EditorView.tsx b/frontend/src/routes/EditorView.tsx index 086eaf6..1e86e36 100644 --- a/frontend/src/routes/EditorView.tsx +++ b/frontend/src/routes/EditorView.tsx @@ -1,29 +1,20 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { useEffect, useState, useCallback, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { BookOpen, Upload } from "lucide-react"; import * as api from "@/api/client"; import { autocompletion } from "@codemirror/autocomplete"; -import type { Extension } from "@codemirror/state"; import { useEditorStore } from "@/stores/editorStore"; import { usePagesStore } from "@/stores/pagesStore"; import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; import { useCompile } from "@/hooks/useCompile"; +import { useKeyboardSave } from "@/hooks/useKeyboardSave"; import { uframeHighlight } from "@/components/editor/uframeHighlight"; import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands"; import { keywordHoverTooltip } from "@/components/editor/uframeHover"; -import EditorPane from "@/components/editor/EditorPane"; import EditorPointer from "@/components/editor/EditorPointer"; import PreviewPane from "@/components/editor/PreviewPane"; +import SourcePane from "@/components/editor/SourcePane"; import ToolBar from "@/components/editor/ToolBar"; -import { EXAMPLES } from "@/components/editor/examples"; -import { - Popover, - PopoverTrigger, - PopoverContent, - PopoverHeader, - PopoverTitle, -} from "@/components/ui/popover"; import { ResizablePanelGroup, ResizablePanel, @@ -81,10 +72,8 @@ export default function EditorView() { setPageName(name); api.fetchPage(name).then((data) => { if (data.source != null) { - useEditorStore.setState({ - ufSource: data.source, - isDirty: false, - }); + setSource(data.source); + setDirty(false); } }); } @@ -118,20 +107,10 @@ export default function EditorView() { [pageName, ufSource, isNew, navigate], ); - useEffect(() => { - 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); - }, [handleSave]); + useKeyboardSave( + useCallback(() => handleSave(false), [handleSave]), + useCallback(() => handleSave(true), [handleSave]), + ); return (
@@ -164,82 +143,3 @@ export default function EditorView() { } -/** Source pane — editor with header bar matching the Preview pane */ -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/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index b02d52c..64bf3ac 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -65,49 +65,4 @@ export function createEditorStore() { })); } -export const useEditorStore = create((set) => ({ - ufSource: "", - isDirty: false, - currentPage: null, - - compiledAscii: "", - compiledMicron: "", - compiledScript: "", - isDynamic: false, - compileWarnings: [], - isCompiling: false, - compileError: null, - - previewMode: "micron", - - setSource: (s) => set({ ufSource: s, isDirty: true }), - setCurrentPage: (p) => set({ currentPage: p }), - setDirty: (v) => set({ isDirty: v }), - setCompileResult: (ascii, micron, script, isDynamic, warnings) => - set({ - compiledAscii: ascii, - compiledMicron: micron, - compiledScript: script, - isDynamic, - compileWarnings: warnings, - isCompiling: false, - compileError: null, - }), - setCompiling: (v) => set({ isCompiling: v }), - setCompileError: (e) => set({ compileError: e, isCompiling: false }), - setPreviewMode: (mode) => set({ previewMode: mode }), - reset: () => - set({ - ufSource: "", - isDirty: false, - currentPage: null, - compiledAscii: "", - compiledMicron: "", - compiledScript: "", - isDynamic: false, - compileWarnings: [], - isCompiling: false, - compileError: null, - previewMode: "micron", - }), -})); +export const useEditorStore = createEditorStore();