noz-studio/src/components/editor/floating-toolbar.tsx
kryptomrx 3f9553cff4 feat: initial commit — noz-studio web editor
Full-featured web editor PWA for noz, built with Next.js and CodeMirror 6.

Editor:
- CM6-based Markdown editor with iA Writer aesthetic and Typora-style marker hiding
- Live preview with KaTeX math, highlight.js syntax highlighting, wikilink rendering
- Wikilink autocomplete via server note graph ([[  triggers suggestion list)
- Floating selection toolbar, format toolbar, status bar
- Split / editor-only / preview-only view modes with resizable panels
- Visual frontmatter panel (no raw YAML editing required)

Navigation:
- Folder tree with topics, sub-topics, drafts section
- Create / rename / move / delete notes and folders
- Diagram editor (TOML definition → canvas visualization)

Search:
- ⌘K command palette with scoped search (@topic, @web, @draft)

Preview:
- Broken wikilink detection with visual indicator and toast notification
- Fragment links: [[note#section]] navigates and scrolls to heading
- In-page links: [[#section]] scrolls without switching notes
- Heading anchors auto-generated from heading text

Auth:
- Token / Authentik SSO / both — driven by noosphere.toml
- sessionStorage only (no localStorage — prevents credential leaks on shared devices)

i18n: German + English
2026-05-18 16:05:11 +02:00

251 lines
8.2 KiB
TypeScript

"use client"
import { useEffect, useRef, useState } from "react"
import type { EditorEngine, SelectionCoords } from "./engine"
interface Props {
engineRef: React.RefObject<EditorEngine | null>
coords: SelectionCoords | null
}
const TOOLBAR_WIDTH = 492
const TOOLBAR_HEIGHT = 36
const GAP = 8
export default function FloatingToolbar({ engineRef, coords }: Props) {
const ref = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
useEffect(() => {
if (!coords) {
setVisible(false)
return
}
const vw = window.innerWidth
// Prefer above selection — fallback below if too close to top
const above = coords.top - TOOLBAR_HEIGHT - GAP
const below = coords.bottom + GAP
const top = above < 12 ? below : above
// Horizontal: centered on selection midpoint, clamped to viewport
const mid = (coords.left + coords.right) / 2
const left = Math.max(8, Math.min(vw - TOOLBAR_WIDTH - 8, mid - TOOLBAR_WIDTH / 2))
setPos({ top, left })
setVisible(true)
}, [coords])
if (!visible) return null
const wrap = (before: string, after: string) => {
engineRef.current?.wrapSelection(before, after)
}
const prevent = (e: React.MouseEvent) => e.preventDefault()
return (
<div
ref={ref}
onMouseDown={prevent}
className="fixed z-50 flex items-center gap-0.5 px-1.5 rounded-lg border border-border shadow-lg"
style={{
top: pos.top,
left: pos.left,
width: TOOLBAR_WIDTH,
height: TOOLBAR_HEIGHT,
background: "var(--surface)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
}}
>
<FBtn onClick={() => wrap("**", "**")} title="Fett">
<BoldIcon />
</FBtn>
<FBtn onClick={() => wrap("*", "*")} title="Kursiv">
<ItalicIcon />
</FBtn>
<FBtn onClick={() => wrap("~~", "~~")} title="Durchgestrichen">
<StrikeIcon />
</FBtn>
<FSep />
<FBtn onClick={() => wrap("\n# ", "")} title="H1">
<span className="text-[10px] font-bold font-mono">H1</span>
</FBtn>
<FBtn onClick={() => wrap("\n## ", "")} title="H2">
<span className="text-[10px] font-bold font-mono">H2</span>
</FBtn>
<FBtn onClick={() => wrap("\n### ", "")} title="H3">
<span className="text-[10px] font-bold font-mono">H3</span>
</FBtn>
<FSep />
<FBtn onClick={() => wrap("[", "](url)")} title="Link">
<LinkIcon />
</FBtn>
<FBtn onClick={() => wrap("![", "](url)")} title="Bild">
<ImageIcon />
</FBtn>
<FSep />
<FBtn onClick={() => wrap("`", "`")} title="Code">
<CodeIcon />
</FBtn>
<FBtn onClick={() => wrap("$", "$")} title="Formel">
<MathIcon />
</FBtn>
<FBtn onClick={() => wrap("[[", "]]")} title="Wikilink">
<WikiIcon />
</FBtn>
<FSep />
<FBtn onClick={() => wrap("\n> ", "")} title="Zitat">
<QuoteIcon />
</FBtn>
<FBtn onClick={() => wrap("\n- ", "")} title="Liste">
<BulletIcon />
</FBtn>
<FBtn onClick={() => wrap("\n1. ", "")} title="Nummerierte Liste">
<NumberedIcon />
</FBtn>
</div>
)
}
/* ── Subcomponents ──────────────────────────────────── */
function FBtn({
children,
onClick,
title,
}: {
children: React.ReactNode
onClick: () => void
title?: string
}) {
return (
<button
title={title}
onMouseDown={(e) => {
e.preventDefault() // keep editor focus + selection
onClick()
}}
onTouchEnd={(e) => {
e.preventDefault()
onClick()
}}
className="w-7 h-7 flex items-center justify-center rounded text-muted-fg hover:text-foreground hover:bg-surface-2 transition-colors shrink-0"
>
{children}
</button>
)
}
function FSep() {
return <div className="w-px h-4 bg-border mx-0.5 shrink-0" />
}
/* ── Icons (mini) ───────────────────────────────────── */
function BoldIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
</svg>
)
}
function ItalicIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" />
</svg>
)
}
function StrikeIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12" />
<path d="M16 6C16 6 14.5 4 12 4C9.5 4 7 5.5 7 8" />
<path d="M8 18C8 18 9.5 20 12 20C14.5 20 17 18.5 17 16" />
</svg>
)
}
function LinkIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
)
}
function CodeIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
)
}
function MathIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19 8 5" /><path d="M20 19 16 5" /><path d="M3 12h18" />
</svg>
)
}
function WikiIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="6" width="5" height="12" rx="1" />
<rect x="17" y="6" width="5" height="12" rx="1" />
<line x1="7" y1="12" x2="17" y2="12" />
</svg>
)
}
function ImageIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)
}
function QuoteIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z" />
</svg>
)
}
function BulletIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="9" y1="6" x2="20" y2="6" />
<line x1="9" y1="12" x2="20" y2="12" />
<line x1="9" y1="18" x2="20" y2="18" />
<circle cx="4" cy="6" r="1" fill="currentColor" stroke="none" />
<circle cx="4" cy="12" r="1" fill="currentColor" stroke="none" />
<circle cx="4" cy="18" r="1" fill="currentColor" stroke="none" />
</svg>
)
}
function NumberedIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" y1="6" x2="21" y2="6" />
<line x1="10" y1="12" x2="21" y2="12" />
<line x1="10" y1="18" x2="21" y2="18" />
<path d="M4 6h1v4" />
<path d="M4 10h2" />
<path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</svg>
)
}