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
251 lines
8.2 KiB
TypeScript
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("")} 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>
|
|
)
|
|
}
|