feat: editor in popover
This commit is contained in:
@@ -2,7 +2,6 @@ import { Routes, Route } from "react-router-dom";
|
|||||||
import AppShell from "./components/shared/AppShell";
|
import AppShell from "./components/shared/AppShell";
|
||||||
import ComposeView from "./routes/ComposeView";
|
import ComposeView from "./routes/ComposeView";
|
||||||
import BrowseView from "./routes/BrowseView";
|
import BrowseView from "./routes/BrowseView";
|
||||||
import EditorView from "./routes/EditorView";
|
|
||||||
import SettingsView from "./routes/SettingsView";
|
import SettingsView from "./routes/SettingsView";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -12,8 +11,6 @@ export default function App() {
|
|||||||
<Route path="/" element={<ComposeView />} />
|
<Route path="/" element={<ComposeView />} />
|
||||||
<Route path="/browse" element={<BrowseView />} />
|
<Route path="/browse" element={<BrowseView />} />
|
||||||
<Route path="/settings" element={<SettingsView />} />
|
<Route path="/settings" element={<SettingsView />} />
|
||||||
<Route path="/editor/new" element={<EditorView />} />
|
|
||||||
<Route path="/editor/:name" element={<EditorView />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import pointerSvg from "@/assets/pointer.min.svg";
|
|||||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
|
* Floating pointer that tracks the CodeMirror cursor vertically,
|
||||||
* Positions itself right next to the left border of the editor container.
|
* pinned to the left edge of the editor. Positions relative to
|
||||||
* Uses a ResizeObserver to keep horizontal position synced on window resize.
|
* containerRef (for floating windows).
|
||||||
*/
|
*/
|
||||||
export default function EditorPointer() {
|
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
||||||
const [y, setY] = useState<number | null>(null);
|
const [y, setY] = useState<number | null>(null);
|
||||||
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
||||||
const targetRef = useRef(0);
|
const targetRef = useRef(0);
|
||||||
@@ -18,17 +18,21 @@ export default function EditorPointer() {
|
|||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const cmRef = useRef<Element | null>(null);
|
const cmRef = useRef<Element | null>(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 eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
|
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ease = 0.09;
|
const ease = 0.09;
|
||||||
|
|
||||||
|
const getContainerRect = () =>
|
||||||
|
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||||
|
|
||||||
const updateLeft = () => {
|
const updateLeft = () => {
|
||||||
if (cmRef.current) {
|
if (!cmRef.current || !containerRef?.current) return;
|
||||||
setEditorLeft(cmRef.current.getBoundingClientRect().left);
|
const cmLeft = cmRef.current.getBoundingClientRect().left;
|
||||||
}
|
const containerLeft = containerRef.current.getBoundingClientRect().left;
|
||||||
|
setEditorLeft(cmLeft - containerLeft);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEditorClick = () => {
|
const onEditorClick = () => {
|
||||||
@@ -39,22 +43,26 @@ export default function EditorPointer() {
|
|||||||
|
|
||||||
const onCursorMove = (e: Event) => {
|
const onCursorMove = (e: Event) => {
|
||||||
const { top } = (e as CustomEvent).detail;
|
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) {
|
if (!cmRef.current) {
|
||||||
const cm = document.querySelector(".cm-editor");
|
const scope = containerRef?.current ?? document;
|
||||||
|
const cm = scope.querySelector(".cm-editor");
|
||||||
if (cm) {
|
if (cm) {
|
||||||
cmRef.current = cm;
|
cmRef.current = cm;
|
||||||
cm.addEventListener("mousedown", onEditorClick);
|
cm.addEventListener("mousedown", onEditorClick);
|
||||||
ro.observe(cm);
|
ro.observe(cm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert viewport top to container-relative top
|
||||||
|
const containerTop = getContainerRect().top;
|
||||||
|
targetRef.current = top - containerTop;
|
||||||
updateLeft();
|
updateLeft();
|
||||||
|
|
||||||
if (!activeRef.current) {
|
if (!activeRef.current) {
|
||||||
currentRef.current = top;
|
currentRef.current = targetRef.current;
|
||||||
setY(top);
|
setY(targetRef.current);
|
||||||
activeRef.current = true;
|
activeRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -69,9 +77,10 @@ export default function EditorPointer() {
|
|||||||
}
|
}
|
||||||
setY(currentRef.current);
|
setY(currentRef.current);
|
||||||
|
|
||||||
// Update eye anchor to match pointer position
|
// Eye anchor in viewport coords for useLazyEyes
|
||||||
const left = cmRef.current?.getBoundingClientRect().left ?? 0;
|
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
eyeAnchorRef.current = { x: left - 100, y: currentRef.current };
|
const containerTop = getContainerRect().top;
|
||||||
|
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
|
||||||
}
|
}
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
};
|
};
|
||||||
@@ -81,7 +90,8 @@ export default function EditorPointer() {
|
|||||||
|
|
||||||
// Keep left position updated on resize
|
// Keep left position updated on resize
|
||||||
const ro = new ResizeObserver(() => updateLeft());
|
const ro = new ResizeObserver(() => updateLeft());
|
||||||
const existingCm = document.querySelector(".cm-editor");
|
const scope = containerRef?.current ?? document;
|
||||||
|
const existingCm = scope.querySelector(".cm-editor");
|
||||||
if (existingCm) {
|
if (existingCm) {
|
||||||
cmRef.current = existingCm;
|
cmRef.current = existingCm;
|
||||||
ro.observe(existingCm);
|
ro.observe(existingCm);
|
||||||
@@ -95,9 +105,9 @@ export default function EditorPointer() {
|
|||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [containerRef]);
|
||||||
|
|
||||||
if (y === null || editorLeft === null) return null;
|
if (!focused || y === null || editorLeft === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
11
frontend/src/components/editor/EditorStoreContext.tsx
Normal file
11
frontend/src/components/editor/EditorStoreContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { useStore, type StoreApi } from "zustand";
|
||||||
|
import type { EditorStore } from "@/stores/editorStore";
|
||||||
|
|
||||||
|
export const EditorStoreContext = createContext<StoreApi<EditorStore> | null>(null);
|
||||||
|
|
||||||
|
export function useEditorCtx<T>(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);
|
||||||
|
}
|
||||||
255
frontend/src/components/editor/EditorWindow.tsx
Normal file
255
frontend/src/components/editor/EditorWindow.tsx
Normal file
@@ -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<EditorWinData>;
|
||||||
|
focused: boolean;
|
||||||
|
onUpdate: (id: string, patch: Partial<ManagedWindow<EditorWinData>>) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
onFocus: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus }: EditorWindowProps) {
|
||||||
|
const storeRef = useRef<StoreApi<EditorStore>>(null);
|
||||||
|
if (!storeRef.current) storeRef.current = createEditorStore();
|
||||||
|
const store = storeRef.current;
|
||||||
|
const windowRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<FloatingWindow
|
||||||
|
id={win.id}
|
||||||
|
title={pageName || "new page"}
|
||||||
|
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||||
|
zIndex={win.zIndex}
|
||||||
|
focused={focused}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onClose={handleClose}
|
||||||
|
onFocus={onFocus}
|
||||||
|
minW={480} minH={300}
|
||||||
|
containerRef={windowRef}
|
||||||
|
>
|
||||||
|
<EditorStoreContext.Provider value={store}>
|
||||||
|
<EditorPointer containerRef={windowRef} focused={focused} />
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<ToolBar
|
||||||
|
pageName={pageName}
|
||||||
|
onNameChange={win.data.isNew ? setPageName : undefined}
|
||||||
|
onSaveDraft={() => handleSave(false)}
|
||||||
|
onPublish={() => handleSave(true)}
|
||||||
|
saving={saving}
|
||||||
|
isDirty={isDirty}
|
||||||
|
/>
|
||||||
|
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0">
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20}>
|
||||||
|
<SourcePane ufSource={ufSource} setSource={setSource} extensions={extensions} />
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={50} minSize={20}>
|
||||||
|
<PreviewPane />
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</EditorStoreContext.Provider>
|
||||||
|
</FloatingWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function SourcePane({
|
||||||
|
ufSource,
|
||||||
|
setSource,
|
||||||
|
extensions,
|
||||||
|
}: {
|
||||||
|
ufSource: string;
|
||||||
|
setSource: (s: string) => void;
|
||||||
|
extensions: Extension[];
|
||||||
|
}) {
|
||||||
|
const [examplesOpen, setExamplesOpen] = useState(false);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||||
|
<span className="font-medium text-foreground flex-1">Source</span>
|
||||||
|
|
||||||
|
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
|
Examples
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent side="bottom" align="end" sideOffset={8}>
|
||||||
|
<PopoverHeader>
|
||||||
|
<PopoverTitle>Insert Example</PopoverTitle>
|
||||||
|
</PopoverHeader>
|
||||||
|
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
|
||||||
|
{EXAMPLES.map((ex) => (
|
||||||
|
<button
|
||||||
|
key={ex.name}
|
||||||
|
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
|
||||||
|
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{ex.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Upload className="h-3 w-3" />
|
||||||
|
{uploading ? "Uploading…" : "Image"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
|
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { useEditorStore } from "@/stores/editorStore";
|
import { useEditorCtx } from "./EditorStoreContext";
|
||||||
import { renderMicron } from "./micronRenderer";
|
import { renderMicron } from "./micronRenderer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type PreviewMode = "micron" | "raw" | "script";
|
type PreviewMode = "micron" | "raw" | "script";
|
||||||
|
|
||||||
export default function PreviewPane() {
|
export default function PreviewPane() {
|
||||||
const previewMode = useEditorStore((s) => s.previewMode);
|
const previewMode = useEditorCtx((s) => s.previewMode);
|
||||||
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
|
const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
|
||||||
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
const compiledMicron = useEditorCtx((s) => s.compiledMicron);
|
||||||
const compiledScript = useEditorStore((s) => s.compiledScript);
|
const compiledScript = useEditorCtx((s) => s.compiledScript);
|
||||||
const isDynamic = useEditorStore((s) => s.isDynamic);
|
const isDynamic = useEditorCtx((s) => s.isDynamic);
|
||||||
const isCompiling = useEditorStore((s) => s.isCompiling);
|
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
||||||
const compileError = useEditorStore((s) => s.compileError);
|
const compileError = useEditorCtx((s) => s.compileError);
|
||||||
|
|
||||||
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
|
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
|
||||||
{ value: "micron", label: "Micron", show: true },
|
{ value: "micron", label: "Micron", show: true },
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Pencil } from "lucide-react";
|
||||||
import { ChevronLeft, Pencil } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
@@ -21,20 +20,8 @@ export default function ToolBar({
|
|||||||
saving,
|
saving,
|
||||||
isDirty,
|
isDirty,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b-2 border-border shrink-0">
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-border shrink-0">
|
||||||
<button
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 text-xs uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-3.5 w-3.5" />
|
|
||||||
Pages
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground/30 text-xs">│</span>
|
|
||||||
|
|
||||||
<PageNameField pageName={pageName} onNameChange={onNameChange} />
|
<PageNameField pageName={pageName} onNameChange={onNameChange} />
|
||||||
|
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
@@ -43,10 +30,10 @@ export default function ToolBar({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Button variant="outline" onClick={onSaveDraft} disabled={saving}>
|
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||||
Save Draft
|
Save Draft
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onPublish} disabled={saving}>
|
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||||
Publish
|
Publish
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
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 { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import frameSvg from "@/assets/frame.themed.svg";
|
|
||||||
import browserSvg from "@/assets/browser.min.svg";
|
import browserSvg from "@/assets/browser.min.svg";
|
||||||
import NavMenu from "./NavMenu";
|
import NavMenu from "./NavMenu";
|
||||||
import LazyEyes from "./LazyEyes";
|
import LazyEyes from "./LazyEyes";
|
||||||
@@ -96,17 +95,7 @@ interface FrameLayout {
|
|||||||
nav: { top: number; left: number };
|
nav: { top: number; left: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultLayout: FrameLayout = {
|
const frameLayout: 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 = {
|
|
||||||
svg: browserSvg,
|
svg: browserSvg,
|
||||||
frameHeight: 884,
|
frameHeight: 884,
|
||||||
containerMaxW: "max-w-5xl",
|
containerMaxW: "max-w-5xl",
|
||||||
@@ -118,11 +107,8 @@ const browseLayout: FrameLayout = {
|
|||||||
|
|
||||||
export default function AppShell({ children }: { children: ReactNode }) {
|
export default function AppShell({ children }: { children: ReactNode }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
|
||||||
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||||
const isEditor = location.pathname.startsWith("/editor");
|
const layout = frameLayout;
|
||||||
const isBrowse = location.pathname === "/browse";
|
|
||||||
const layout = isBrowse ? browseLayout : defaultLayout;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -161,30 +147,28 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Lazy eyes on the browse frame figure */}
|
{/* Lazy eyes on the frame figure */}
|
||||||
{isBrowse && (
|
<LazyEyes
|
||||||
<LazyEyes
|
eyes={[
|
||||||
eyes={[
|
{ top: 81, left: 554, size: 5, irisSize: 2 },
|
||||||
{ top: 81, left: 554, size: 5, irisSize: 2 },
|
{ top: 85, left: 562, size: 5, irisSize: 2 },
|
||||||
{ top: 85, left: 562, size: 5, irisSize: 2 },
|
]}
|
||||||
]}
|
maxShift={2}
|
||||||
maxShift={2}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Top hole — ASCII title */}
|
{/* Top hole — ASCII title */}
|
||||||
<div
|
<div
|
||||||
className="relative z-10 flex flex-col cursor-pointer overflow-hidden"
|
className="relative z-10 flex flex-col overflow-hidden"
|
||||||
style={layout.title}
|
style={layout.title}
|
||||||
>
|
>
|
||||||
<pre
|
<pre
|
||||||
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center bg-background"
|
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center cursor-pointer bg-background"
|
||||||
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
|
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
|
||||||
onClick={() => navigate("/")}
|
onClick={() => navigate("/")}
|
||||||
>
|
>
|
||||||
{TITLE}
|
{TITLE}
|
||||||
</pre>
|
</pre>
|
||||||
<span className="font-mono text-[10px] text-muted-foreground"><RomanClock /></span>
|
<span className="font-mono text-[10px] w-fit text-muted-foreground bg-background"><RomanClock /></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom hole — main content */}
|
{/* Bottom hole — main content */}
|
||||||
@@ -195,15 +179,13 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nav menu — anchored below the frame, left side (hidden on editor) */}
|
{/* Nav menu — anchored below the frame, left side */}
|
||||||
{!isEditor && (
|
<div
|
||||||
<div
|
className="absolute z-20"
|
||||||
className="absolute z-20"
|
style={layout.nav}
|
||||||
style={layout.nav}
|
>
|
||||||
>
|
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
86
frontend/src/components/shared/FloatingWindow.tsx
Normal file
86
frontend/src/components/shared/FloatingWindow.tsx
Normal file
@@ -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<HTMLDivElement | null>;
|
||||||
|
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<HTMLDivElement>(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(
|
||||||
|
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { 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 */}
|
||||||
|
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
|
||||||
|
</div>
|
||||||
|
{/* Optional address bar */}
|
||||||
|
{addressBar}
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{/* Optional footer */}
|
||||||
|
{footer}
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
|
||||||
|
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
|
||||||
|
</div>
|
||||||
|
</div>, document.body);
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ function PopoverContent({
|
|||||||
alignOffset={alignOffset}
|
alignOffset={alignOffset}
|
||||||
side={side}
|
side={side}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className="isolate z-50"
|
className="isolate z-9999"
|
||||||
>
|
>
|
||||||
<PopoverPrimitive.Popup
|
<PopoverPrimitive.Popup
|
||||||
data-slot="popover-content"
|
data-slot="popover-content"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
"h-7 px-1.5 text-left align-middle font-medium whitespace-nowrap text-foreground text-xs [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -81,7 +81,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
"px-1.5 py-1 align-middle whitespace-nowrap text-xs [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useStore, type StoreApi } from "zustand";
|
||||||
import { useEditorStore } from "@/stores/editorStore";
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
import type { EditorStore } from "@/stores/editorStore";
|
||||||
import { compile } from "@/api/client";
|
import { compile } from "@/api/client";
|
||||||
|
|
||||||
const DEBOUNCE_MS = 400;
|
const DEBOUNCE_MS = 400;
|
||||||
@@ -7,12 +9,23 @@ const DEBOUNCE_MS = 400;
|
|||||||
/**
|
/**
|
||||||
* Debounced hook that compiles µFrame source via the API.
|
* Debounced hook that compiles µFrame source via the API.
|
||||||
* Automatically triggers on ufSource changes.
|
* Automatically triggers on ufSource changes.
|
||||||
|
* Accepts an optional store API for per-window instances; falls back to the global singleton.
|
||||||
*/
|
*/
|
||||||
export function useCompile() {
|
export function useCompile(storeApi?: StoreApi<EditorStore>) {
|
||||||
const ufSource = useEditorStore((s) => s.ufSource);
|
const globalSource = useEditorStore((s) => s.ufSource);
|
||||||
const setCompileResult = useEditorStore((s) => s.setCompileResult);
|
const globalSetResult = useEditorStore((s) => s.setCompileResult);
|
||||||
const setCompiling = useEditorStore((s) => s.setCompiling);
|
const globalSetCompiling = useEditorStore((s) => s.setCompiling);
|
||||||
const setCompileError = useEditorStore((s) => s.setCompileError);
|
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<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useStore, type StoreApi } from "zustand";
|
||||||
import { useEditorStore } from "@/stores/editorStore";
|
import { useEditorStore } from "@/stores/editorStore";
|
||||||
|
import type { EditorStore } from "@/stores/editorStore";
|
||||||
|
|
||||||
export function useUnsavedGuard() {
|
export function useUnsavedGuard(storeApi?: StoreApi<EditorStore>) {
|
||||||
const isDirty = useEditorStore((s) => s.isDirty);
|
const globalDirty = useEditorStore((s) => s.isDirty);
|
||||||
|
const localDirty = useStore(storeApi ?? useEditorStore, (s) => s.isDirty);
|
||||||
|
const isDirty = storeApi ? localDirty : globalDirty;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
|
|||||||
63
frontend/src/hooks/useWindowManager.ts
Normal file
63
frontend/src/hooks/useWindowManager.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
let nextWinZ = 1;
|
||||||
|
|
||||||
|
export interface ManagedWindow<T> {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
zIndex: number;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWindowManager<T>(defaults?: { w: number; h: number }) {
|
||||||
|
const defaultW = defaults?.w ?? 480;
|
||||||
|
const defaultH = defaults?.h ?? 400;
|
||||||
|
const [windows, setWindows] = useState<ManagedWindow<T>[]>([]);
|
||||||
|
const [focusedId, setFocusedId] = useState<string | null>(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<T> = {
|
||||||
|
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<ManagedWindow<T>>) => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -405,8 +405,8 @@
|
|||||||
/* ── Editor Pointer ── */
|
/* ── Editor Pointer ── */
|
||||||
|
|
||||||
.editor-pointer {
|
.editor-pointer {
|
||||||
position: fixed;
|
position: absolute;
|
||||||
z-index: 50;
|
z-index: 9999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
will-change: top;
|
will-change: top;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
@@ -441,7 +441,6 @@
|
|||||||
-webkit-mask-size: contain;
|
-webkit-mask-size: contain;
|
||||||
-webkit-mask-repeat: no-repeat;
|
-webkit-mask-repeat: no-repeat;
|
||||||
-webkit-mask-position: center;
|
-webkit-mask-position: center;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-pointer-eye {
|
.editor-pointer-eye {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createPortal } from "react-dom";
|
|||||||
import { Graph } from "@cosmos.gl/graph";
|
import { Graph } from "@cosmos.gl/graph";
|
||||||
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
|
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
|
||||||
import { renderMicron } from "@/components/editor/micronRenderer";
|
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
|
// Constants — matching cosmos.gl clusters-with-labels example
|
||||||
@@ -162,86 +164,14 @@ function buildGraphArrays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per-window state & BrowserWindow
|
// Browse window data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface BrowserWin {
|
interface BrowseWinData {
|
||||||
id: string; node: NetworkNode;
|
node: NetworkNode;
|
||||||
pageHtml: string | null; pageLoading: boolean; pageError: string | null;
|
pageHtml: string | null;
|
||||||
x: number; y: number; w: number; h: number; zIndex: number;
|
pageLoading: boolean;
|
||||||
}
|
pageError: string | null;
|
||||||
|
|
||||||
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<BrowserWin>) => void;
|
|
||||||
onClose: (id: string) => void; onFocus: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const ref = useRef<HTMLDivElement>(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(
|
|
||||||
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { 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 }}>
|
|
||||||
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button onClick={() => onClose(win.id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
|
|
||||||
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{win.node.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15">
|
|
||||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span>
|
|
||||||
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">{win.node.hash}</div>
|
|
||||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
|
||||||
{win.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
|
||||||
: win.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
|
||||||
: win.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
|
||||||
{win.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
|
||||||
{win.pageError && <span className="text-destructive text-xs">{win.pageError}</span>}
|
|
||||||
{win.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.pageHtml }} />}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
|
||||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{win.node.type ?? "peer"}</span>
|
|
||||||
{win.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.node.interface}</span>}
|
|
||||||
</div>
|
|
||||||
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
|
|
||||||
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
|
|
||||||
</div>
|
|
||||||
</div>, document.body);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -251,8 +181,7 @@ function BrowserWindow({
|
|||||||
export default function BrowseView() {
|
export default function BrowseView() {
|
||||||
const [nodes, setNodes] = useState<NetworkNode[]>([]);
|
const [nodes, setNodes] = useState<NetworkNode[]>([]);
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [windows, setWindows] = useState<BrowserWin[]>([]);
|
const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
|
||||||
const [focusedWinId, setFocusedWinId] = useState<string | null>(null);
|
|
||||||
const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null);
|
const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null);
|
||||||
|
|
||||||
const [themeRev, setThemeRev] = useState(0);
|
const [themeRev, setThemeRev] = useState(0);
|
||||||
@@ -289,7 +218,7 @@ export default function BrowseView() {
|
|||||||
clusterNamesRef.current = graphData.clusterNames;
|
clusterNamesRef.current = graphData.clusterNames;
|
||||||
}, [nodes, graphData]);
|
}, [nodes, graphData]);
|
||||||
|
|
||||||
// Search
|
// Search — matches + autocomplete suggestions
|
||||||
const searchMatchIndices = useMemo(() => {
|
const searchMatchIndices = useMemo(() => {
|
||||||
if (!filter.trim()) return null;
|
if (!filter.trim()) return null;
|
||||||
const q = filter.trim().toLowerCase();
|
const q = filter.trim().toLowerCase();
|
||||||
@@ -300,6 +229,19 @@ export default function BrowseView() {
|
|||||||
return indices.length > 0 ? indices : null;
|
return indices.length > 0 ? indices : null;
|
||||||
}, [filter, graphData]);
|
}, [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 ──
|
// ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ──
|
||||||
const labelDivsRef = useRef<HTMLDivElement[]>([]);
|
const labelDivsRef = useRef<HTMLDivElement[]>([]);
|
||||||
const updateClusterLabels = useCallback(() => {
|
const updateClusterLabels = useCallback(() => {
|
||||||
@@ -363,6 +305,7 @@ export default function BrowseView() {
|
|||||||
renderHoveredPointRing: true,
|
renderHoveredPointRing: true,
|
||||||
hoveredPointRingColor: "#ffffff",
|
hoveredPointRingColor: "#ffffff",
|
||||||
scalePointsOnZoom: true,
|
scalePointsOnZoom: true,
|
||||||
|
pointGreyoutOpacity: 0.1,
|
||||||
// Simulation defaults — dynamically adjusted by node count in data update
|
// Simulation defaults — dynamically adjusted by node count in data update
|
||||||
simulationGravity: 0.5,
|
simulationGravity: 0.5,
|
||||||
simulationRepulsion: 1,
|
simulationRepulsion: 1,
|
||||||
@@ -387,9 +330,9 @@ export default function BrowseView() {
|
|||||||
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
|
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
|
||||||
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
||||||
},
|
},
|
||||||
onSimulationTick: () => updateClusterLabels(),
|
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); },
|
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
|
||||||
onZoom: () => updateClusterLabels(),
|
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
graphRef.current = graph;
|
graphRef.current = graph;
|
||||||
@@ -437,64 +380,79 @@ export default function BrowseView() {
|
|||||||
}, [graphData]);
|
}, [graphData]);
|
||||||
|
|
||||||
// ── Search highlighting + labels ──
|
// ── Search highlighting + labels ──
|
||||||
const searchLabelDivsRef = useRef<HTMLDivElement[]>([]);
|
const searchLabelDivsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchIndicesRef = useRef<number[] | null>(null);
|
||||||
|
|
||||||
const clearSearchLabels = useCallback(() => {
|
const clearSearchLabels = useCallback(() => {
|
||||||
if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); searchTimerRef.current = null; }
|
|
||||||
searchLabelDivsRef.current.forEach(d => d.remove());
|
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(() => {
|
useEffect(() => {
|
||||||
const graph = graphRef.current;
|
const graph = graphRef.current;
|
||||||
const container = containerRef.current;
|
if (!graph) return;
|
||||||
if (!graph || !container) return;
|
|
||||||
|
|
||||||
clearSearchLabels();
|
clearSearchLabels();
|
||||||
|
|
||||||
if (searchMatchIndices) {
|
if (searchMatchIndices) {
|
||||||
|
searchIndicesRef.current = searchMatchIndices;
|
||||||
graph.selectPointsByIndices(searchMatchIndices);
|
graph.selectPointsByIndices(searchMatchIndices);
|
||||||
if (searchMatchIndices.length <= 10) {
|
if (searchMatchIndices.length <= 10) {
|
||||||
graph.fitViewByPointIndices(searchMatchIndices, 500);
|
graph.fitViewByPointIndices(searchMatchIndices, 500);
|
||||||
}
|
}
|
||||||
|
// Initial position — will be continuously updated on tick/zoom
|
||||||
searchTimerRef.current = setTimeout(() => {
|
updateSearchLabels();
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
graph.unselectPoints();
|
graph.unselectPoints();
|
||||||
graph.fitView(300, 0.2);
|
graph.fitView(300, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => clearSearchLabels();
|
return () => clearSearchLabels();
|
||||||
}, [searchMatchIndices, clearSearchLabels]);
|
}, [searchMatchIndices, clearSearchLabels, updateSearchLabels]);
|
||||||
|
|
||||||
// ── SSE stream ──
|
// ── SSE stream ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -516,27 +474,13 @@ export default function BrowseView() {
|
|||||||
|
|
||||||
// ── Node click ──
|
// ── Node click ──
|
||||||
const handleNodeClick = useCallback((node: NetworkNode) => {
|
const handleNodeClick = useCallback((node: NetworkNode) => {
|
||||||
setWindows((prev) => {
|
const id = node.hash;
|
||||||
const existing = prev.find((w) => w.node.hash === node.hash);
|
const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null };
|
||||||
if (existing) { const z = ++nextWinZ; setFocusedWinId(existing.id); return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w); }
|
openWindow(id, data);
|
||||||
const winW = 480, winH = 400, margin = 20, z = ++nextWinZ;
|
fetchRemotePage(node.hash)
|
||||||
const win: BrowserWin = {
|
.then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } }))
|
||||||
id: `${node.hash}-${Date.now()}`, node, pageHtml: null, pageLoading: true, pageError: null,
|
.catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } }));
|
||||||
x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)),
|
}, [openWindow, updateWindow]);
|
||||||
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<BrowserWin>) => 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 clearSearch = useCallback(() => {
|
const clearSearch = useCallback(() => {
|
||||||
setFilter("");
|
setFilter("");
|
||||||
@@ -548,10 +492,29 @@ export default function BrowseView() {
|
|||||||
const nodeCount = nodes.filter(n => n.type !== "interface").length;
|
const nodeCount = nodes.filter(n => n.type !== "interface").length;
|
||||||
const ifaceCount = 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 ──
|
// ── Draggable search bar ──
|
||||||
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
|
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: 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) => {
|
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||||
@@ -578,10 +541,21 @@ export default function BrowseView() {
|
|||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
{searchPos && createPortal(
|
{searchPos && createPortal(
|
||||||
<div onMouseDown={onSearchDragStart}
|
<div onMouseDown={onSearchDragStart}
|
||||||
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border rounded-lg cursor-grab active:cursor-grabbing"
|
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
|
||||||
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
|
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
|
||||||
<input ref={searchInputRef} type="text" value={filter} onChange={(e) => setFilter(e.target.value)}
|
<input ref={searchInputRef} type="text" value={filter}
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); } }}
|
onChange={(e) => 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..."
|
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" />
|
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 && (
|
{filter && (
|
||||||
@@ -589,9 +563,34 @@ export default function BrowseView() {
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
|
{/* Autocomplete dropdown */}
|
||||||
{nodeCount} node{nodeCount !== 1 && "s"} · {ifaceCount} iface{ifaceCount !== 1 && "s"}
|
{suggestions.length > 0 && (
|
||||||
</span>
|
<div className="absolute left-0 right-0 top-full mt-1 bg-popover border border-border rounded-lg overflow-hidden"
|
||||||
|
style={{ boxShadow: "0 4px 12px rgba(0,0,0,0.4)" }}>
|
||||||
|
{suggestions.map((entry, i) => (
|
||||||
|
<button
|
||||||
|
key={entry.hash}
|
||||||
|
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { handleNodeClick(entry); clearSearch(); } }}
|
||||||
|
onMouseEnter={() => setSelectedSuggestion(i)}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-xs font-mono flex items-center gap-2 transition-colors ${i === selectedSuggestion ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{
|
||||||
|
backgroundColor: (() => {
|
||||||
|
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||||
|
if (age < 300) return "var(--primary)";
|
||||||
|
if (age < 3600) return "var(--muted-foreground)";
|
||||||
|
return "var(--border)";
|
||||||
|
})(),
|
||||||
|
}} />
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
<span className="ml-auto text-[9px] text-muted-foreground uppercase shrink-0">
|
||||||
|
{entry.interface ?? "peer"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>, document.body)}
|
</div>, document.body)}
|
||||||
|
|
||||||
{nodes.length === 0 && (
|
{nodes.length === 0 && (
|
||||||
@@ -601,8 +600,40 @@ export default function BrowseView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{windows.map((win) => (
|
{windows.map((win) => (
|
||||||
<BrowserWindow key={win.id} win={win} focused={focusedWinId === win.id}
|
<FloatingWindow
|
||||||
onUpdate={updateWindow} onClose={closeWindowById} onFocus={focusWindow} />
|
key={win.id}
|
||||||
|
id={win.id}
|
||||||
|
title={win.data.node.name}
|
||||||
|
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||||
|
zIndex={win.zIndex}
|
||||||
|
focused={focusedWinId === win.id}
|
||||||
|
onUpdate={updateWindow}
|
||||||
|
onClose={closeWindowById}
|
||||||
|
onFocus={focusWindow}
|
||||||
|
addressBar={
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||||
|
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span>
|
||||||
|
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">{win.data.node.hash}</div>
|
||||||
|
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||||
|
{win.data.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||||
|
: win.data.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||||
|
: win.data.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
||||||
|
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{win.data.node.type ?? "peer"}</span>
|
||||||
|
{win.data.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.data.node.interface}</span>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-3 h-full overflow-auto">
|
||||||
|
{win.data.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
||||||
|
{win.data.pageError && <span className="text-destructive text-xs">{win.data.pageError}</span>}
|
||||||
|
{win.data.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.data.pageHtml }} />}
|
||||||
|
</div>
|
||||||
|
</FloatingWindow>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
|
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
|
||||||
import { usePagesStore } from "@/stores/pagesStore";
|
import { usePagesStore } from "@/stores/pagesStore";
|
||||||
@@ -29,13 +28,20 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { useWindowManager } from "@/hooks/useWindowManager";
|
||||||
|
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
|
||||||
|
|
||||||
export default function ComposeView() {
|
export default function ComposeView() {
|
||||||
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
|
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
|
||||||
usePagesStore();
|
usePagesStore();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const { windows, focusedId, open, update, close, focus } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
|
||||||
|
|
||||||
|
const openEditor = (name: string, isNew: boolean) => {
|
||||||
|
const id = isNew ? `new-${Date.now()}` : name;
|
||||||
|
open(id, { pageName: isNew ? "" : name, isNew });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPages();
|
fetchPages();
|
||||||
@@ -89,15 +95,15 @@ export default function ComposeView() {
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div className="flex items-center px-4 py-2 border-b-2 border-border">
|
<div className="flex items-center px-2 py-1.5 border-b-2 border-border">
|
||||||
<h1 className="text-sm font-semibold flex-1">Compose</h1>
|
<h1 className="text-xs font-semibold flex-1">Compose</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleRestart} disabled={restarting}>
|
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
<RotateCcw className="w-3 h-3 mr-1.5" />
|
||||||
Restart
|
Restart
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate("/editor/new")}>
|
<Button size="sm" onClick={() => openEditor("", true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-3 h-3 mr-1.5" />
|
||||||
New Page
|
New Page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +125,7 @@ export default function ComposeView() {
|
|||||||
<TableRow
|
<TableRow
|
||||||
key={p.name}
|
key={p.name}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => navigate(`/editor/${p.name}`)}
|
onClick={() => openEditor(p.name, false)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono">
|
<TableCell className="font-mono">
|
||||||
{p.name}
|
{p.name}
|
||||||
@@ -138,8 +144,8 @@ export default function ComposeView() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right w-8">
|
<TableCell className="text-right w-8">
|
||||||
<PageActions
|
<PageActions
|
||||||
name={p.name}
|
|
||||||
published={p.published}
|
published={p.published}
|
||||||
|
onEdit={() => openEditor(p.name, false)}
|
||||||
onPublish={() => handlePublish(p.name)}
|
onPublish={() => handlePublish(p.name)}
|
||||||
onUnpublish={() => handleUnpublish(p.name)}
|
onUnpublish={() => handleUnpublish(p.name)}
|
||||||
onDelete={() => setPageToDelete(p.name)}
|
onDelete={() => setPageToDelete(p.name)}
|
||||||
@@ -184,6 +190,18 @@ export default function ComposeView() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Floating editor windows */}
|
||||||
|
{windows.map((win) => (
|
||||||
|
<EditorWindow
|
||||||
|
key={win.id}
|
||||||
|
win={win}
|
||||||
|
focused={focusedId === win.id}
|
||||||
|
onUpdate={update}
|
||||||
|
onClose={close}
|
||||||
|
onFocus={focus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,20 +209,18 @@ export default function ComposeView() {
|
|||||||
|
|
||||||
/** Per-row action menu for a page. */
|
/** Per-row action menu for a page. */
|
||||||
function PageActions({
|
function PageActions({
|
||||||
name,
|
|
||||||
published,
|
published,
|
||||||
|
onEdit,
|
||||||
onPublish,
|
onPublish,
|
||||||
onUnpublish,
|
onUnpublish,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
onPublish: () => void;
|
onPublish: () => void;
|
||||||
onUnpublish: () => void;
|
onUnpublish: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
@@ -219,7 +235,7 @@ function PageActions({
|
|||||||
/>
|
/>
|
||||||
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
|
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${name}`); }}
|
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||||
>Edit</button>
|
>Edit</button>
|
||||||
{published ? (
|
{published ? (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PageMeta } from "@/api/client";
|
import type { PageMeta } from "@/api/client";
|
||||||
|
|
||||||
interface EditorStore {
|
export interface EditorStore {
|
||||||
// Source
|
// Source
|
||||||
ufSource: string;
|
ufSource: string;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
@@ -30,6 +30,41 @@ interface EditorStore {
|
|||||||
reset: () => void;
|
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<EditorStore>) => 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<EditorStore>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
...makeActions(set),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export const useEditorStore = create<EditorStore>((set) => ({
|
export const useEditorStore = create<EditorStore>((set) => ({
|
||||||
ufSource: "",
|
ufSource: "",
|
||||||
isDirty: false,
|
isDirty: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user