feat: menu and ponters

This commit is contained in:
2026-04-03 16:44:52 +02:00
parent e1c7ea7635
commit 0e7e435edd
8 changed files with 290 additions and 10 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -31,11 +31,24 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
if (update.docChanged) { if (update.docChanged) {
onChangeRef.current(update.state.doc.toString()); onChangeRef.current(update.state.doc.toString());
} }
if (update.selectionSet || update.docChanged) {
const pos = update.state.selection.main.head;
const coords = update.view.coordsAtPos(pos);
if (coords) {
window.dispatchEvent(
new CustomEvent("cm-cursor-move", { detail: { top: coords.top, left: coords.left } }),
);
}
}
}), }),
EditorView.theme({ EditorView.theme({
"&": { height: "100%", fontSize: "14px" }, "&": { height: "100%", fontSize: "14px" },
".cm-scroller": { overflow: "auto" }, ".cm-scroller": { overflow: "auto" },
".cm-content": { fontFamily: "monospace", padding: "16px" }, ".cm-content": { fontFamily: "monospace", padding: "16px" },
".cm-cursor, .cm-cursor-primary": {
borderLeftColor: "var(--primary)",
borderLeftWidth: "0.5em",
},
}), }),
...extensions, ...extensions,
], ],

View File

@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from "react";
import pointerSvg from "@/assets/pointer.min.svg";
/**
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
* Positions itself right next to the left border of the editor container.
*/
export default function EditorPointer() {
const [y, setY] = useState<number | null>(null);
const [editorLeft, setEditorLeft] = useState<number | null>(null);
const targetRef = useRef(0);
const currentRef = useRef(0);
const rafRef = useRef(0);
const activeRef = useRef(false);
useEffect(() => {
const ease = 0.09;
const onCursorMove = (e: Event) => {
const { top } = (e as CustomEvent).detail;
targetRef.current = top;
// Find editor container left edge once (or when it changes)
const cm = document.querySelector(".cm-editor");
if (cm) {
setEditorLeft(cm.getBoundingClientRect().left);
}
if (!activeRef.current) {
currentRef.current = top;
setY(top);
activeRef.current = true;
}
};
const tick = () => {
if (activeRef.current) {
const diff = targetRef.current - currentRef.current;
if (Math.abs(diff) < 0.5) {
currentRef.current = targetRef.current;
} else {
currentRef.current += diff * ease;
}
setY(currentRef.current);
}
rafRef.current = requestAnimationFrame(tick);
};
window.addEventListener("cm-cursor-move", onCursorMove);
rafRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener("cm-cursor-move", onCursorMove);
cancelAnimationFrame(rafRef.current);
};
}, []);
if (y === null || editorLeft === null) return null;
return (
<div
className="editor-pointer"
style={{ top: y, left: editorLeft }}
>
<div
className="editor-pointer-img"
style={{
WebkitMaskImage: `url(${pointerSvg})`,
maskImage: `url(${pointerSvg})`,
}}
/>
</div>
);
}

View File

@@ -6,8 +6,9 @@ export const oneDark = EditorView.theme(
backgroundColor: "#0d1117", backgroundColor: "#0d1117",
color: "#c9d1d9", color: "#c9d1d9",
}, },
".cm-cursor": { ".cm-cursor, .cm-cursor-primary": {
borderLeftColor: "#c9d1d9", borderLeftColor: "var(--primary)",
borderLeftWidth: "0.5em",
}, },
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "#264f78", backgroundColor: "#264f78",

View File

@@ -1,9 +1,10 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useLocation } 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 frameTerraSvg from "@/assets/frame.themed.svg"; import frameTerraSvg from "@/assets/frame.themed.svg";
import frameAzureSvg from "@/assets/frame.azure.svg"; import frameAzureSvg from "@/assets/frame.azure.svg";
import NavMenu from "./NavMenu";
const TITLE = ` const TITLE = `
@@ -82,7 +83,9 @@ function RomanClock() {
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");
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@@ -103,7 +106,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="flex flex-col h-screen bg-background text-foreground"> <div className="flex flex-col h-screen bg-background text-foreground">
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto" data-scroll-root>
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full"> <div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
{/* Frame SVG — background */} {/* Frame SVG — background */}
<img <img
@@ -137,6 +140,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
> >
{children} {children}
</div> </div>
{/* Nav menu — anchored below the frame, left side (hidden on editor) */}
{!isEditor && (
<div
className="absolute z-20"
style={{ top: 200, left: -210 }}
>
<NavMenu />
</div>
)}
</div> </div>
</main> </main>
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase"> <footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import menuSvg from "@/assets/menu.min.svg";
const NAV_ITEMS = [
{ label: "Browse", path: "/browse" },
{ label: "Compose", path: "/" },
] as const;
/**
* Smooth scroll-following: tracks the scroll container and lerps
* the menu's vertical offset so it glides into place with eased acceleration.
*/
function useSmoothScroll(scrollSelector: string, ease = 0.08) {
const [offset, setOffset] = useState(0);
const targetRef = useRef(0);
const currentRef = useRef(0);
const rafRef = useRef(0);
useEffect(() => {
const container = document.querySelector(scrollSelector);
if (!container) return;
const onScroll = () => {
targetRef.current = container.scrollTop;
};
container.addEventListener("scroll", onScroll, { passive: true });
const tick = () => {
const diff = targetRef.current - currentRef.current;
if (Math.abs(diff) < 0.5) {
currentRef.current = targetRef.current;
} else {
currentRef.current += diff * ease;
}
setOffset(currentRef.current);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
container.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
};
}, [scrollSelector, ease]);
return offset;
}
export default function NavMenu() {
const navigate = useNavigate();
const location = useLocation();
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
return (
<div
className="nav-menu-root"
style={{ transform: `translateY(${scrollY}px)` }}
>
{/* Frame — menu.min.svg as mask, colored by theme */}
<div
className="nav-menu-frame"
style={{
WebkitMaskImage: `url(${menuSvg})`,
maskImage: `url(${menuSvg})`,
}}
/>
{/* Navigation links */}
<nav className="nav-menu-links">
{NAV_ITEMS.map(({ label, path }) => {
const active = location.pathname === path;
return (
<button
key={path}
onClick={() => navigate(path)}
className="nav-menu-item"
data-active={active || undefined}
>
{label}
</button>
);
})}
</nav>
</div>
);
}

View File

@@ -6,8 +6,13 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@keyframes romanSlideIn { @keyframes romanSlideIn {
from { transform: translateY(100%); } from {
to { transform: translateY(0); } transform: translateY(100%);
}
to {
transform: translateY(0);
}
} }
@theme inline { @theme inline {
@@ -162,6 +167,13 @@
--sidebar-ring: oklch(0.72 0.12 230); --sidebar-ring: oklch(0.72 0.12 230);
} }
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror's theme */
.cm-cursor,
.cm-cursor-primary {
border-left-color: var(--primary) !important;
border-left-width: 0.5em !important;
}
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
@@ -205,10 +217,7 @@
background: transparent; background: transparent;
} }
/* Terracotta cursor */
.cm-cursor {
border-left-color: oklch(0.7 0.13 55) !important;
}
/* Bold console aesthetic */ /* Bold console aesthetic */
button, button,
@@ -315,4 +324,83 @@
input[data-slot="input"] { input[data-slot="input"] {
border: 2px solid var(--border); border: 2px solid var(--border);
} }
/* ── Floating Nav Menu ── */
.nav-menu-root {
position: relative;
width: 200px;
height: 400px;
will-change: transform;
}
.nav-menu-frame {
position: absolute;
inset: 0;
background: var(--primary);
pointer-events: none;
mask-size: 100% 100%;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
}
.nav-menu-links {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding-top: 168px;
border: none;
}
.nav-menu-item {
width: 100%;
text-align: center;
padding: 6px 12px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted-foreground);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.2s ease, background 0.2s ease;
}
.nav-menu-item:hover {
color: var(--foreground);
}
.nav-menu-item[data-active] {
color: var(--primary);
background: var(--primary) / 0.08;
}
/* ── Editor Pointer ── */
.editor-pointer {
position: fixed;
z-index: 50;
pointer-events: none;
will-change: top;
transform: translateX(-100%);
margin-top: -20px;
}
.editor-pointer-img {
width: 200px;
height: 130px;
background: var(--primary);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
opacity: 1;
}
} }

View File

@@ -13,6 +13,7 @@ import { uframeHighlight } from "@/components/editor/uframeHighlight";
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands"; import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
import { keywordHoverTooltip } from "@/components/editor/uframeHover"; import { keywordHoverTooltip } from "@/components/editor/uframeHover";
import EditorPane from "@/components/editor/EditorPane"; import EditorPane from "@/components/editor/EditorPane";
import EditorPointer from "@/components/editor/EditorPointer";
import PreviewPane from "@/components/editor/PreviewPane"; import PreviewPane from "@/components/editor/PreviewPane";
import ToolBar from "@/components/editor/ToolBar"; import ToolBar from "@/components/editor/ToolBar";
import { EXAMPLES } from "@/components/editor/examples"; import { EXAMPLES } from "@/components/editor/examples";
@@ -134,6 +135,7 @@ export default function EditorView() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<EditorPointer />
<div className="flex flex-col flex-1 min-h-0"> <div className="flex flex-col flex-1 min-h-0">
<ToolBar <ToolBar
pageName={pageName} pageName={pageName}