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) {
|
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,
|
||||||
],
|
],
|
||||||
|
|||||||
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",
|
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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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 *));
|
@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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user