feat: menu and ponters
This commit is contained in:
1
frontend/src/assets/pointer.min.svg
Normal file
1
frontend/src/assets/pointer.min.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.1 MiB |
@@ -31,11 +31,24 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
|
||||
if (update.docChanged) {
|
||||
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({
|
||||
"&": { height: "100%", fontSize: "14px" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
||||
".cm-cursor, .cm-cursor-primary": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
borderLeftWidth: "0.5em",
|
||||
},
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
|
||||
74
frontend/src/components/editor/EditorPointer.tsx
Normal file
74
frontend/src/components/editor/EditorPointer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ export const oneDark = EditorView.theme(
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#c9d1d9",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "#c9d1d9",
|
||||
".cm-cursor, .cm-cursor-primary": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
borderLeftWidth: "0.5em",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "#264f78",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { Toaster } from "@/components/ui/sonner";
|
||||
import frameTerraSvg from "@/assets/frame.themed.svg";
|
||||
import frameAzureSvg from "@/assets/frame.azure.svg";
|
||||
import NavMenu from "./NavMenu";
|
||||
|
||||
const TITLE = `
|
||||
|
||||
@@ -82,7 +83,9 @@ function RomanClock() {
|
||||
|
||||
export default function AppShell({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||
const isEditor = location.pathname.startsWith("/editor");
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -103,7 +106,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<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">
|
||||
{/* Frame SVG — background */}
|
||||
<img
|
||||
@@ -137,6 +140,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
</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>
|
||||
</main>
|
||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||
|
||||
87
frontend/src/components/shared/NavMenu.tsx
Normal file
87
frontend/src/components/shared/NavMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,13 @@
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@keyframes romanSlideIn {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -162,6 +167,13 @@
|
||||
--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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -205,10 +217,7 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Terracotta cursor */
|
||||
.cm-cursor {
|
||||
border-left-color: oklch(0.7 0.13 55) !important;
|
||||
}
|
||||
|
||||
|
||||
/* Bold console aesthetic */
|
||||
button,
|
||||
@@ -315,4 +324,83 @@
|
||||
input[data-slot="input"] {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
|
||||
import { keywordHoverTooltip } from "@/components/editor/uframeHover";
|
||||
import EditorPane from "@/components/editor/EditorPane";
|
||||
import EditorPointer from "@/components/editor/EditorPointer";
|
||||
import PreviewPane from "@/components/editor/PreviewPane";
|
||||
import ToolBar from "@/components/editor/ToolBar";
|
||||
import { EXAMPLES } from "@/components/editor/examples";
|
||||
@@ -134,6 +135,7 @@ export default function EditorView() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<EditorPointer />
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<ToolBar
|
||||
pageName={pageName}
|
||||
|
||||
Reference in New Issue
Block a user