Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
@import "./styles/chrome/breadcrumb.css";
@import "./styles/chrome/statusbar.css";
@import "./styles/editor/panes.css";
@import "./styles/editor/heading-bar.css";
@import "./styles/editor/prose.css";
@import "./styles/editor/reading-find.css";
@import "./styles/files/sidebar.css";
Expand Down
6 changes: 3 additions & 3 deletions src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import type { EditorView } from "@codemirror/view";
import { Breadcrumb, StatusBar, TitleBar, type VimMode } from "@/components/chrome";
import { Editor, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor";
import { EditorPane, OpenTabs, Preview, ReadingFind, Splitter } from "@/components/editor";
import { ContextMenu, Sidebar, type ContextMenuItem } from "@/components/files";
import { AboutOverlay, CommandPalette, DropOverlay, HelpOverlay, Toast, WelcomeOverlay } from "@/components/overlays";
import { TooltipRoot } from "@/components/primitives";
Expand Down Expand Up @@ -985,11 +985,11 @@ export function App() {
/>
{editorOnly ? (
<div className="mdv-shell__editor-solo">
<Editor value={source} onChange={setSource} vimOn={vimOn} onVimMode={setVimMode} viewRef={editorViewRef} />
<EditorPane value={source} onChange={setSource} vimOn={vimOn} onVimMode={setVimMode} viewRef={editorViewRef} />
</div>
) : (
<Splitter
left={<Editor value={source} onChange={setSource} vimOn={vimOn} onVimMode={setVimMode} viewRef={editorViewRef} />}
left={<EditorPane value={source} onChange={setSource} vimOn={vimOn} onVimMode={setVimMode} viewRef={editorViewRef} />}
right={<Preview source={debouncedPreview} filePath={activePath} />}
/>
)}
Expand Down
30 changes: 30 additions & 0 deletions src/components/editor/editor-pane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useState, type RefObject } from "react";
import { EditorView } from "@codemirror/view";
import { Editor } from "./editor";
import { HeadingBreadcrumb } from "./heading-breadcrumb";

type EditorPaneProps = {
value: string;
onChange: (next: string) => void;
vimOn?: boolean;
onVimMode?: (mode: "normal" | "insert" | "visual" | "replace" | null) => void;
viewRef: RefObject<EditorView | null>;
};

export function EditorPane({ value, onChange, vimOn, onVimMode, viewRef }: EditorPaneProps) {
const [cursorLine, setCursorLine] = useState(1);

return (
<div className="mdv-editor-pane">
<HeadingBreadcrumb source={value} cursorLine={cursorLine} viewRef={viewRef} />
<Editor
value={value}
onChange={onChange}
vimOn={vimOn}
onVimMode={onVimMode}
viewRef={viewRef}
onCursorLine={setCursorLine}
/>
</div>
);
}
10 changes: 9 additions & 1 deletion src/components/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type EditorProps = {
onVimMode?: (mode: "normal" | "insert" | "visual" | "replace" | null) => void;
/** shared ref populated with the EditorView once it mounts */
viewRef?: RefObject<EditorView | null>;
/** fired with the 1-based line number whenever the cursor moves */
onCursorLine?: (line: number) => void;
};

function buildTheme() {
Expand Down Expand Up @@ -87,11 +89,13 @@ function buildTheme() {
);
}

export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: externalViewRef }: EditorProps) {
export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: externalViewRef, onCursorLine }: EditorProps) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onCursorLineRef = useRef(onCursorLine);
onCursorLineRef.current = onCursorLine;
// Compartment lets us swap the vim extension at runtime without rebuilding
// the EditorState (preserves doc, history, selection, undo stack).
const vimCompartment = useRef(new Compartment());
Expand Down Expand Up @@ -119,6 +123,10 @@ export function Editor({ value, onChange, vimOn = false, onVimMode, viewRef: ext
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const line = update.state.doc.lineAt(update.state.selection.main.head).number;
onCursorLineRef.current?.(line);
}
}),
],
});
Expand Down
235 changes: 235 additions & 0 deletions src/components/editor/heading-breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { useState, useMemo, useRef, useEffect, type RefObject } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import { EditorView } from "@codemirror/view";

interface Heading {
level: number;
text: string;
line: number;
}

interface HeadingNode {
heading: Heading;
children: HeadingNode[];
}

function parseHeadings(source: string): Heading[] {
const lines = source.split("\n");
const result: Heading[] = [];
let inFence = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = line.match(/^(#{1,6})\s+(.+)/);
if (m) result.push({ level: m[1].length, text: m[2].trim(), line: i + 1 });
}
return result;
}

function buildTree(headings: Heading[]): HeadingNode[] {
const roots: HeadingNode[] = [];
const stack: HeadingNode[] = [];
for (const h of headings) {
const node: HeadingNode = { heading: h, children: [] };
while (stack.length > 0 && stack[stack.length - 1].heading.level >= h.level) {
stack.pop();
}
if (stack.length === 0) {
roots.push(node);
} else {
stack[stack.length - 1].children.push(node);
}
stack.push(node);
}
return roots;
}

function getCurrentPath(headings: Heading[], cursorLine: number): Heading[] {
const path: Heading[] = [];
for (const h of headings) {
if (h.line > cursorLine) break;
while (path.length > 0 && path[path.length - 1].level >= h.level) path.pop();
path.push(h);
}
return path;
}

function trunc(text: string, max = 15): string {
return text.length > max ? text.slice(0, max) + "…" : text;
}

type TocNodeProps = {
node: HeadingNode;
activeLine: number;
ancestorLines: Set<number>;
collapsed: Set<number>;
onToggleCollapse: (line: number) => void;
onJump: (line: number) => void;
activeRef: RefObject<HTMLButtonElement | null>;
};

function TocNode({ node, activeLine, ancestorLines, collapsed, onToggleCollapse, onJump, activeRef }: TocNodeProps) {
const { heading, children } = node;
const isActive = heading.line === activeLine;
const isAncestor = ancestorLines.has(heading.line);
const hasChildren = children.length > 0;
const isCollapsed = collapsed.has(heading.line);

return (
<li className="mdv-hbar__toc-node">
<div
className={`mdv-hbar__toc-row${isActive ? " is-active" : ""}${isAncestor ? " is-ancestor" : ""}`}
style={{ paddingLeft: `${(heading.level - 1) * 14 + 4}px` }}
>
<button
type="button"
className="mdv-hbar__toc-fold"
onClick={() => hasChildren && onToggleCollapse(heading.line)}
aria-label={isCollapsed ? "펼치기" : "접기"}
tabIndex={hasChildren ? 0 : -1}
>
{hasChildren ? (
isCollapsed
? <ChevronRight size={11} strokeWidth={2} />
: <ChevronDown size={11} strokeWidth={2} />
) : (
<span className="mdv-hbar__toc-leaf-dot" />
)}
</button>
<button
ref={isActive ? activeRef : undefined}
type="button"
className="mdv-hbar__toc-label"
onClick={() => onJump(heading.line)}
>
<span className="mdv-hbar__toc-marker">{"#".repeat(heading.level)}</span>
<span className="mdv-hbar__toc-text">{heading.text}</span>
</button>
</div>
{hasChildren && !isCollapsed && (
<ul className="mdv-hbar__toc-children">
{children.map((child) => (
<TocNode
key={child.heading.line}
node={child}
activeLine={activeLine}
ancestorLines={ancestorLines}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
onJump={onJump}
activeRef={activeRef}
/>
))}
</ul>
)}
</li>
);
}

type Props = {
source: string;
cursorLine: number;
viewRef: RefObject<EditorView | null>;
};

export function HeadingBreadcrumb({ source, cursorLine, viewRef }: Props) {
const [tocOpen, setTocOpen] = useState(false);
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
const rootRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLButtonElement | null>(null);

const headings = useMemo(() => parseHeadings(source), [source]);
const tree = useMemo(() => buildTree(headings), [headings]);
const path = useMemo(() => getCurrentPath(headings, cursorLine), [headings, cursorLine]);
const ancestorLines = useMemo(() => new Set(path.map((h) => h.line)), [path]);

// scroll active item into view when TOC opens
useEffect(() => {
if (tocOpen && activeRef.current) {
activeRef.current.scrollIntoView({ block: "nearest" });
}
}, [tocOpen]);

// close on outside click
useEffect(() => {
if (!tocOpen) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setTocOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [tocOpen]);

if (headings.length === 0) return null;

const jumpTo = (line: number) => {
const view = viewRef.current;
if (!view) return;
const lineObj = view.state.doc.line(Math.min(line, view.state.doc.lines));
view.dispatch({
selection: { anchor: lineObj.from },
effects: EditorView.scrollIntoView(lineObj.from, { y: "start", yMargin: 40 }),
});
view.focus();
setTocOpen(false);
};

const toggleCollapse = (line: number) => {
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(line)) next.delete(line);
else next.add(line);
return next;
});
};

const activeLine = path.length > 0 ? path[path.length - 1].line : -1;

return (
<div className="mdv-hbar" ref={rootRef}>
<button
type="button"
className={`mdv-hbar__trigger${tocOpen ? " is-open" : ""}`}
onClick={() => setTocOpen((v) => !v)}
title="목차 보기"
>
{path.length === 0 ? (
<span className="mdv-hbar__empty">—</span>
) : (
path.map((h, i) => (
<span key={h.line} className="mdv-hbar__crumb-row">
{i > 0 && (
<span className="mdv-hbar__chevron" aria-hidden>
<ChevronRight size={10} strokeWidth={2} />
</span>
)}
<span className="mdv-hbar__crumb">{trunc(h.text)}</span>
</span>
))
)}
</button>

{tocOpen && (
<div className="mdv-hbar__toc" role="tree">
<ul className="mdv-hbar__toc-list">
{tree.map((node) => (
<TocNode
key={node.heading.line}
node={node}
activeLine={activeLine}
ancestorLines={ancestorLines}
collapsed={collapsed}
onToggleCollapse={toggleCollapse}
onJump={jumpTo}
activeRef={activeRef}
/>
))}
</ul>
</div>
)}
</div>
);
}
1 change: 1 addition & 0 deletions src/components/editor/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Editor } from "./editor";
export { EditorPane } from "./editor-pane";
export { CsvPreview } from "./csv-preview";
export { OpenTabs } from "./open-tabs";
export { Preview } from "./preview";
Expand Down
Loading
Loading