Skip to content
Merged
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
251 changes: 251 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The full feature list for DocsReader. The [README](../README.md#features) shows
- **Code blocks:** 20 bundled language grammars via Shiki, twelve highlighter palettes (5 light, 7 dark)
- **Appearance:** light, dark, or follow-system, with six accent hues
- **Type controls:** font family, body size, and reading column width
- **Quick edit:** a pencil on any open doc flips to the raw markdown for fast human fixes; agents stay the primary writers
- **WYSIWYG edit:** a pencil on any open doc opens an in-place editor with a slash menu, block drag handles, a selection toolbar, and live tables - edit the doc as it reads, not raw markdown; agents stay the primary writers. Frontmatter is preserved untouched, an unchanged doc is never rewritten, and a save is refused if an agent changed the file on disk while you were editing

![A rendered doc with a Mermaid diagram, highlighted code, and a clickable checklist](screenshots/main.png)

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.8",
"@milkdown/crepe": "^7.21.2",
"@shikijs/rehype": "^4.0.2",
"@shikijs/transformers": "^4.0.2",
"@tailwindcss/typography": "^0.5.19",
Expand Down
79 changes: 79 additions & 0 deletions src/components/document/CrepeEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { createRef } from "react";
import { vi, describe, it, expect, beforeEach } from "vitest";

let lastCrepe: { markdown: string } | undefined;

vi.mock("@milkdown/crepe", () => {
class Crepe {
static Feature = { AI: "ai" };
markdown: string;
constructor({ defaultValue }: { defaultValue?: string }) {
this.markdown = defaultValue ?? "";
lastCrepe = this;
}
create() {
return Promise.resolve();
}
destroy() {
return Promise.resolve();
}
getMarkdown() {
return this.markdown;
}
}
return { Crepe };
});

import { CrepeEditor, type CrepeEditorHandle } from "./CrepeEditor";

function setup(overrides: Partial<Parameters<typeof CrepeEditor>[0]> = {}) {
const ref = createRef<CrepeEditorHandle>();
const props = {
initialMarkdown: "# hello",
fontSize: "md" as const,
onRequestSave: vi.fn(),
onCancel: vi.fn(),
onReadyChange: vi.fn(),
...overrides,
};
render(<CrepeEditor ref={ref} {...props} />);
return { ref, props };
}

beforeEach(() => {
lastCrepe = undefined;
});

describe("CrepeEditor", () => {
it("reports ready once the editor is created", async () => {
const { props } = setup();
await waitFor(() => expect(props.onReadyChange).toHaveBeenCalledWith(true));
});

it("getResult reports the markdown and dirty=true after an edit", async () => {
const { ref, props } = setup();
await waitFor(() => expect(props.onReadyChange).toHaveBeenCalledWith(true));
lastCrepe!.markdown = "# hello edited";
expect(ref.current?.getResult()).toEqual({
markdown: "# hello edited",
dirty: true,
});
});

it("getResult reports dirty=false when nothing changed", async () => {
const { ref, props } = setup();
await waitFor(() => expect(props.onReadyChange).toHaveBeenCalledWith(true));
expect(ref.current?.getResult()).toEqual({ markdown: "# hello", dirty: false });
});

it("requests save on cmd/ctrl+s and cancels on escape", async () => {
const { props } = setup();
await waitFor(() => expect(props.onReadyChange).toHaveBeenCalledWith(true));
const host = screen.getByLabelText("Edit document");
fireEvent.keyDown(host, { key: "s", metaKey: true });
expect(props.onRequestSave).toHaveBeenCalledTimes(1);
fireEvent.keyDown(host, { key: "Escape" });
expect(props.onCancel).toHaveBeenCalledTimes(1);
});
});
106 changes: 106 additions & 0 deletions src/components/document/CrepeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
type KeyboardEvent,
} from "react";
import { Crepe } from "@milkdown/crepe";
import { FONT_SIZE_PX, type FontSize } from "@/lib/storage";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
import "./crepe-theme.css";

export interface CrepeEditorHandle {
// The current markdown plus whether it differs from what loaded, or null
// while the editor is not ready. Guards getMarkdown, which has no
// created-state check and would throw if called before create() resolves.
getResult: () => { markdown: string; dirty: boolean } | null;
}

interface Props {
initialMarkdown: string;
fontSize: FontSize;
onRequestSave: () => void;
onCancel: () => void;
onReadyChange: (ready: boolean) => void;
}

export const CrepeEditor = forwardRef<CrepeEditorHandle, Props>(function CrepeEditor(
{ initialMarkdown, fontSize, onRequestSave, onCancel, onReadyChange },
ref
) {
const hostRef = useRef<HTMLDivElement>(null);
const crepeRef = useRef<Crepe | null>(null);
const readyRef = useRef(false);
// Markdown Crepe reports right after load, once its serializer has
// normalised the source. Comparing against it tells "unchanged" from
// "edited", so an untouched doc is never rewritten or reformatted.
const baselineRef = useRef(initialMarkdown);

useImperativeHandle(
ref,
() => ({
getResult: () => {
const crepe = crepeRef.current;
if (!crepe || !readyRef.current) return null;
const markdown = crepe.getMarkdown();
return { markdown, dirty: markdown !== baselineRef.current };
},
}),
[]
);

useEffect(() => {
const host = hostRef.current;
if (!host) return;
const crepe = new Crepe({
root: host,
defaultValue: initialMarkdown,
features: { [Crepe.Feature.AI]: false },
});
crepeRef.current = crepe;
let created = false;
let disposed = false;
crepe
.create()
.then(() => {
created = true;
if (disposed) {
void crepe.destroy();
return;
}
baselineRef.current = crepe.getMarkdown();
readyRef.current = true;
onReadyChange(true);
})
.catch((err) => console.error("editor init failed", err));
return () => {
disposed = true;
readyRef.current = false;
crepeRef.current = null;
onReadyChange(false);
if (created) void crepe.destroy();
};
}, [initialMarkdown, onReadyChange]);

const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
onRequestSave();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};

return (
<div
ref={hostRef}
onKeyDown={onKeyDown}
className="crepe-host"
style={{ fontSize: `${FONT_SIZE_PX[fontSize]}px` }}
aria-label="Edit document"
/>
);
});
121 changes: 88 additions & 33 deletions src/components/document/DocumentView.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
import type { MarkdownFile } from "@/lib/scan";
import { splitFrontmatter } from "@/lib/scan";
import type { ViewSettings } from "@/lib/storage";
import type { Tab } from "@/hooks/useTabs";
import { MarkdownViewer } from "@/components/viewer/MarkdownViewer";
import { Button } from "@/components/ui/button";
import { DocumentHeader } from "./DocumentHeader";
import { Frontmatter } from "./Frontmatter";
import { QuickEditor } from "./QuickEditor";
import { TaskHeader } from "./TaskHeader";
import type { CrepeEditorHandle } from "./CrepeEditor";

// The editor bundles ProseMirror + CodeMirror; keep it off the reader's
// initial load path since most sessions never enter edit mode.
const CrepeEditor = lazy(() =>
import("./CrepeEditor").then((m) => ({ default: m.CrepeEditor }))
);

interface Props {
tab: Tab;
Expand All @@ -17,9 +25,8 @@ interface Props {
viewSettings: ViewSettings;
onNavigate: (path: string) => void;
onBeginEdit: () => void;
onDraftChange: (value: string) => void;
onCancelEdit: () => void;
onSaveEdit: () => Promise<void>;
onSaveEdit: (markdown: string) => Promise<void>;
onToggleTask: (index: number) => void;
}

Expand All @@ -30,7 +37,6 @@ export function DocumentView({
viewSettings,
onNavigate,
onBeginEdit,
onDraftChange,
onCancelEdit,
onSaveEdit,
onToggleTask,
Expand All @@ -41,6 +47,32 @@ export function DocumentView({
const editing = tab.draft !== undefined;
const editable = !tab.loading && !tab.error && !editing;

const editorRef = useRef<CrepeEditorHandle>(null);
const [editorReady, setEditorReady] = useState(false);
const [saving, setSaving] = useState(false);

useEffect(() => {
if (!editing) {
setEditorReady(false);
setSaving(false);
}
}, [editing]);

const handleSave = useCallback(async () => {
const result = editorRef.current?.getResult();
if (!result) return;
if (!result.dirty) {
onCancelEdit();
return;
}
setSaving(true);
try {
await onSaveEdit(result.markdown);
} finally {
setSaving(false);
}
}, [onCancelEdit, onSaveEdit]);

return (
<article
className={cn(
Expand All @@ -51,44 +83,67 @@ export function DocumentView({
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<DocumentHeader title={title} tags={tags} modified={modified} />
{!editing && (
<TaskHeader
meta={tab.meta}
relPath={file?.relPath ?? tab.path}
content={tab.content}
/>
)}
<TaskHeader
meta={tab.meta}
relPath={file?.relPath ?? tab.path}
content={tab.content}
/>
</div>
{editable && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Edit document"
title="Edit document"
onClick={onBeginEdit}
>
<Pencil className="size-4" />
</Button>
{editing ? (
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => void handleSave()}
disabled={!editorReady || saving}
>
{saving ? "Saving…" : "Save"}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={onCancelEdit}
disabled={saving}
>
Cancel
</Button>
</div>
) : (
editable && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Edit document"
title="Edit document"
onClick={onBeginEdit}
>
<Pencil className="size-4" />
</Button>
)
)}
</div>
{!editing && tab.draftError && (
<p className="mt-2 text-xs text-destructive">{tab.draftError}</p>
)}
{!editing && <Frontmatter data={tab.meta} />}
{tab.draftError && <p className="mt-2 text-xs text-destructive">{tab.draftError}</p>}
<Frontmatter data={tab.meta} />
<div className="mt-6">
{tab.loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : tab.error ? (
<p className="text-sm text-destructive">{tab.error}</p>
) : editing ? (
<QuickEditor
value={tab.draft ?? ""}
error={tab.draftError}
onChange={onDraftChange}
onSave={onSaveEdit}
onCancel={onCancelEdit}
/>
<Suspense
fallback={<p className="text-sm text-muted-foreground">Loading editor…</p>}
>
<CrepeEditor
ref={editorRef}
initialMarkdown={splitFrontmatter(tab.draft ?? "").body}
fontSize={viewSettings.fontSize}
onRequestSave={handleSave}
onCancel={onCancelEdit}
onReadyChange={setEditorReady}
/>
</Suspense>
) : (
<MarkdownViewer
content={tab.content}
Expand Down
1 change: 0 additions & 1 deletion src/components/document/PaneView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ export function PaneView({
onDiffViewModeChange={onDiffViewModeChange}
onAlwaysAutoReload={onAlwaysAutoReload}
onBeginEdit={pane.beginEdit}
onDraftChange={pane.updateDraft}
onCancelEdit={pane.cancelEdit}
onSaveEdit={pane.saveEdit}
onToggleTask={pane.toggleTaskItem}
Expand Down
Loading
Loading