diff --git a/AGENTS.md b/AGENTS.md index 08bda48..5234de5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,7 @@ Use these before reaching for anything external. Do **not** add Radix primitives | `sonner` | Toasts — `import { toast } from "sonner"` | | `lucide-react` | Icons — use `Loader2 animate-spin` for loading | | `react-markdown` + `remark-gfm` + `remark-math` + `rehype-katex` | Markdown with math — reuse the `MarkdownText` component pattern from quizzes/flashcards | +| `@codemirror/*` + `mathlive` + `katex` | The note editor: live-preview Markdown (CM6), structural math editing (MathLive), math rendering (KaTeX). Do not add a second editor or math engine. | | `zod` v4 | Validation — `safeParse` on API bodies | | `drizzle-orm` | DB ORM | | `better-auth` | Auth — `lib/auth.ts`, `lib/auth-client.ts` | @@ -374,7 +375,7 @@ These rules come from production React at scale. Violating them causes bugs that 7. **Many study routes are scaffolds** — `/resources`, most `/study/*` pages use `ScaffoldPage` and are not implemented. Don't assume they have real functionality. -8. **Note editor is Editor.js** — complex, debounced, with custom blocks. Hundreds of CSS lines in `globals.css`. Do not touch it without reading `components/note-editor/`. +8. **Note editor is CodeMirror 6 + MathLive** (`components/note-editor/`). Markdown is the canonical note format; the block document in `note.content` is a derived cache. Math regions come from the shared scanner in `lib/notes/math-ranges.ts`, so the server normalizes exactly what the editor renders. Read `components/note-editor/README.md` and `docs/note-editor-requirements.md` before changing either. 9. **Quiz storage guard** — `hasQuizStorage()` returns false if DB migrations haven't run; quiz APIs return 503. The page handles it gracefully — keep that check when adding quiz API routes. diff --git a/app/api/notes/[id]/route.ts b/app/api/notes/[id]/route.ts index ac5b3c5..4fcc2a1 100644 --- a/app/api/notes/[id]/route.ts +++ b/app/api/notes/[id]/route.ts @@ -5,7 +5,7 @@ import { db } from "@/lib/db"; import { note } from "@/lib/db/schema"; import { and, eq } from "drizzle-orm"; import { assertClassBelongsToUser } from "@/lib/classes/queries"; -import { normalizeNoteWriteContent } from "@/lib/notes/persistence"; +import { normalizeNoteWriteContent, normalizeNoteWriteMarkdown } from "@/lib/notes/persistence"; export const runtime = "nodejs"; @@ -50,7 +50,7 @@ export async function PATCH(req: Request, ctx: RouteContext) { return NextResponse.json({ error: "Note not found" }, { status: 404 }); } - let body: { title?: string; content?: unknown; classId?: string | null }; + let body: { title?: string; content?: unknown; markdown?: unknown; classId?: string | null }; try { body = await req.json(); } catch { @@ -62,7 +62,16 @@ export async function PATCH(req: Request, ctx: RouteContext) { const updates: Record = {}; if (typeof body.title === "string") updates.title = body.title.trim() || "Untitled"; - if (body.content !== undefined) { + if (body.markdown !== undefined) { + // Canonical path: markdown is stored as authored; blocks are derived. + try { + const content = normalizeNoteWriteMarkdown(body.markdown); + updates.content = content.document; + updates.markdown = content.markdown; + } catch { + return NextResponse.json({ error: "Invalid note markdown" }, { status: 400 }); + } + } else if (body.content !== undefined) { try { const content = normalizeNoteWriteContent(body.content); updates.content = content.document; diff --git a/app/api/notes/route.ts b/app/api/notes/route.ts index f7b522d..1b62f0f 100644 --- a/app/api/notes/route.ts +++ b/app/api/notes/route.ts @@ -5,7 +5,7 @@ import { db } from "@/lib/db"; import { note } from "@/lib/db/schema"; import { and, desc, eq } from "drizzle-orm"; import { assertClassBelongsToUser } from "@/lib/classes/queries"; -import { normalizeNoteWriteContent } from "@/lib/notes/persistence"; +import { normalizeNoteWriteContent, normalizeNoteWriteMarkdown } from "@/lib/notes/persistence"; export const runtime = "nodejs"; @@ -59,7 +59,7 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - let body: { title?: string; content?: unknown; classId?: string | null }; + let body: { title?: string; content?: unknown; markdown?: unknown; classId?: string | null }; try { body = await req.json(); } catch { @@ -84,7 +84,10 @@ export async function POST(req: Request) { let content: ReturnType; try { - content = normalizeNoteWriteContent(body.content); + content = + body.markdown !== undefined + ? normalizeNoteWriteMarkdown(body.markdown) + : normalizeNoteWriteContent(body.content); } catch { return NextResponse.json( { error: "Invalid note content" }, diff --git a/app/editor-harness/editor-harness-client.tsx b/app/editor-harness/editor-harness-client.tsx deleted file mode 100644 index 5afc021..0000000 --- a/app/editor-harness/editor-harness-client.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -import { NoteSurface } from "@/components/note-editor/note-surface"; -import type { NoteDocument } from "@/lib/notes/types"; - -type EditorHarnessClientProps = { - initialDocument: NoteDocument; -}; - -export function EditorHarnessClient({ initialDocument }: EditorHarnessClientProps) { - return ; -} diff --git a/app/editor-harness/page.tsx b/app/editor-harness/page.tsx deleted file mode 100644 index 2bdcffe..0000000 --- a/app/editor-harness/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { EditorHarnessClient } from "@/app/editor-harness/editor-harness-client"; -import { emptyNoteDocument } from "@/lib/notes/types"; - -export default async function EditorHarnessPage() { - return ( -
-
-
-

Editor Harness

-

- Rendered note by default. Click the note to open the full Editor.js editor, then click - away to return to rendered Markdown. -

-
- - -
-
- ); -} diff --git a/app/flashcards/flashcards-client.test.tsx b/app/flashcards/flashcards-client.test.tsx index 4e7e271..036cdaa 100644 --- a/app/flashcards/flashcards-client.test.tsx +++ b/app/flashcards/flashcards-client.test.tsx @@ -77,11 +77,9 @@ describe("FlashcardsClient", () => { it("keeps My Flashcards focused on saved deck management", () => { render(); - expect(screen.getByRole("heading", { name: "My Flashcards" })).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: "Biology deck" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Review" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Edit/ })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /Delete/ })).toBeInTheDocument(); + expect(screen.getByText("1 decks")).toBeInTheDocument(); + expect(screen.getByText("Biology deck")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Open actions for Biology deck/ })).toBeInTheDocument(); expect(screen.queryByRole("checkbox", { name: /Lecture One/ })).not.toBeInTheDocument(); }); @@ -131,7 +129,7 @@ describe("FlashcardsClient", () => { await user.clear(screen.getByLabelText("Deck name")); await user.type(screen.getByLabelText("Deck name"), "Edited deck"); await user.clear(screen.getByLabelText("Front")); - await user.type(screen.getByLabelText("Front"), "Edited front"); + await user.type(screen.getByLabelText("Front"), "Edited front $√(x²)$"); await user.type(screen.getByLabelText("Tags"), "core"); await user.click(screen.getByRole("button", { name: /Save deck/ })); @@ -148,7 +146,7 @@ describe("FlashcardsClient", () => { title: "Edited deck", cards: [ { - front: "Edited front", + front: "Edited front $\\sqrt{x^2}$", back: "Generated back", tags: ["core"], }, @@ -175,7 +173,8 @@ describe("FlashcardsClient", () => { render(); - await user.click(screen.getByRole("button", { name: /Edit/ })); + await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ })); + await user.click(screen.getByRole("button", { name: "Edit" })); await user.clear(screen.getByLabelText("Deck name")); await user.type(screen.getByLabelText("Deck name"), "Renamed deck"); await user.clear(screen.getByLabelText("Back")); @@ -213,12 +212,13 @@ describe("FlashcardsClient", () => { render(); - await user.click(screen.getByRole("button", { name: /Delete/ })); + await user.click(screen.getByRole("button", { name: /Open actions for Biology deck/ })); + await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(screen.queryByRole("heading", { name: "Biology deck" })).not.toBeInTheDocument(); + expect(screen.queryByText("Biology deck")).not.toBeInTheDocument(); }); - expect(screen.getByText("No flashcard decks")).toBeInTheDocument(); + expect(screen.getByText("No saved decks")).toBeInTheDocument(); expect(fetch).toHaveBeenCalledWith("/api/flashcards?deckId=deck-1", { method: "DELETE" }); }); }); diff --git a/app/flashcards/flashcards-client.tsx b/app/flashcards/flashcards-client.tsx index 1546dc8..ccc4f8a 100644 --- a/app/flashcards/flashcards-client.tsx +++ b/app/flashcards/flashcards-client.tsx @@ -10,7 +10,6 @@ import { Edit3, Loader2, MoreHorizontal, - Play, Plus, RotateCcw, Save, @@ -18,10 +17,7 @@ import { X, } from "lucide-react"; import { toast } from "sonner"; -import ReactMarkdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; +import { LatexMarkdown } from "@/components/math/latex-markdown"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -32,6 +28,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Input, Textarea } from "@/components/ui/input"; +import { normalizeTextMathToLatex } from "@/lib/math/latex"; import { cx } from "@/lib/utils"; import type { FlashcardDeck, FlashcardItem } from "@/lib/flashcards/types"; @@ -89,6 +86,21 @@ function cloneDeck(deck: FlashcardDeck): FlashcardDeck { }; } +function normalizeCardMath(card: FlashcardItem): FlashcardItem { + return { + ...card, + front: normalizeTextMathToLatex(card.front), + back: normalizeTextMathToLatex(card.back), + }; +} + +function normalizeDeckMath(deck: FlashcardDeck): FlashcardDeck { + return { + ...deck, + cards: deck.cards.map(normalizeCardMath), + }; +} + function withCardCount(deck: FlashcardDeck): FlashcardDeck { return { ...deck, @@ -112,39 +124,6 @@ function parseTags(value: string) { .slice(0, 8); } -function MarkdownText({ - markdown, - className, -}: { - markdown: string; - className?: string; -}) { - return ( -
-

{children}

, - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), - code: ({ children }) => ( - - {children} - - ), - }} - > - {markdown} -
-
- ); -} - function StatPill({ children }: { children: ReactNode }) { return ( @@ -252,6 +231,11 @@ function DeckEditor({ onChange={(event) => updateCard(index, { front: event.target.value }) } + onBlur={(event) => + updateCard(index, { + front: normalizeTextMathToLatex(event.target.value), + }) + } /> @@ -409,6 +398,7 @@ export function FlashcardsClient({ return; } + const normalizedDeck = normalizeDeckMath(draftDeck); setIsSaving(true); try { const payload = await readJsonResponse<{ deck: FlashcardDeck }>( @@ -417,9 +407,9 @@ export function FlashcardsClient({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "save", - title: draftDeck.title, - sourceNoteIds: draftDeck.sourceNoteIds, - cards: draftDeck.cards, + title: normalizedDeck.title, + sourceNoteIds: normalizedDeck.sourceNoteIds, + cards: normalizedDeck.cards, }), }), ); @@ -444,6 +434,7 @@ export function FlashcardsClient({ return; } + const normalizedDeck = normalizeDeckMath(editingDeck); setIsSaving(true); try { const payload = await readJsonResponse<{ deck: FlashcardDeck }>( @@ -451,10 +442,10 @@ export function FlashcardsClient({ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - deckId: editingDeck.id, - title: editingDeck.title, - sourceNoteIds: editingDeck.sourceNoteIds, - cards: editingDeck.cards, + deckId: normalizedDeck.id, + title: normalizedDeck.title, + sourceNoteIds: normalizedDeck.sourceNoteIds, + cards: normalizedDeck.cards, }), }), ); @@ -591,6 +582,7 @@ export function FlashcardsClient({ - - - ); - }, -})); - -import { NoteSurface } from "@/components/note-editor/note-surface"; - -describe("NoteSurface", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - cleanup(); - }); - - it("opens directly in full-note editor mode and preserves Editor.js-added blocks", async () => { - const onSave = vi.fn().mockResolvedValue(undefined); - - render( - , - ); - - expect(screen.getByTestId("mock-note-editor")).toBeInTheDocument(); - expect(screen.getByText("Mock Editor.js UI")).toBeInTheDocument(); - expect(screen.queryByLabelText("Add block at end")).not.toBeInTheDocument(); - - fireEvent.click(screen.getByLabelText("Commit paragraph header-1")); - - await act(async () => { - await vi.advanceTimersByTimeAsync(181); - }); - - expect(onSave).toHaveBeenCalledTimes(1); - expect(onSave.mock.calls.at(-1)?.[0]?.markdown).toContain("Updated block"); - - fireEvent.click(screen.getByLabelText(/Commit paragraph and ordered list/)); - - await act(async () => { - await vi.advanceTimersByTimeAsync(181); - }); - - expect(onSave).toHaveBeenCalledTimes(2); - expect(onSave.mock.calls.at(-1)?.[0]?.markdown).toContain("1. Inserted item"); - }); - - it("resyncs the editor when the parent supplies a different note", () => { - const { rerender } = render( - , - ); - - expect(screen.getByLabelText("Commit paragraph note-a")).toBeInTheDocument(); - - rerender( - , - ); - - expect(screen.queryByLabelText("Commit paragraph note-a")).not.toBeInTheDocument(); - expect(screen.getByLabelText("Commit paragraph note-b")).toBeInTheDocument(); - }); - - it("does not double-fire onContentChange when a save completes", async () => { - const onContentChange = vi.fn(); - - render( - , - ); - - fireEvent.click(screen.getByLabelText("Commit paragraph header-1")); - - await act(async () => { - await vi.advanceTimersByTimeAsync(181); - }); - - expect(onContentChange).toHaveBeenCalledTimes(1); - }); - - it("renders markdown in read-only mode", () => { - render( - Open link', - }, - }, - ], - }} - readOnly - />, - ); - - expect(screen.queryByTestId("mock-note-editor")).not.toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open link" })).toBeInTheDocument(); - }); - - it("stays in editor mode for empty notes", () => { - render( - , - ); - - expect(screen.getByTestId("mock-note-editor")).toBeInTheDocument(); - expect(screen.queryByText("No note content yet.")).not.toBeInTheDocument(); - - expect(screen.getByTestId("mock-note-editor")).toBeInTheDocument(); - }); - - it("keeps the editor open when interacting outside the editor", () => { - render( - , - ); - - expect(screen.getByTestId("mock-note-editor")).toBeInTheDocument(); - fireEvent.pointerDown(document.body); - - expect(screen.getByTestId("mock-note-editor")).toBeInTheDocument(); - }); -}); diff --git a/components/note-editor/__tests__/selection-geometry.test.ts b/components/note-editor/__tests__/selection-geometry.test.ts deleted file mode 100644 index a712b55..0000000 --- a/components/note-editor/__tests__/selection-geometry.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isPointInHorizontalEdgeGutter } from "@/components/note-editor/selection-geometry"; - -describe("note editor selection geometry", () => { - const rect = { - left: 100, - right: 900, - top: 50, - bottom: 1250, - }; - - it("accepts points in the left and right editor gutters across a tall note", () => { - expect(isPointInHorizontalEdgeGutter(rect, 120, 1100)).toBe(true); - expect(isPointInHorizontalEdgeGutter(rect, 880, 1100)).toBe(true); - }); - - it("rejects middle content points and points outside the editor surface", () => { - expect(isPointInHorizontalEdgeGutter(rect, 500, 1100)).toBe(false); - expect(isPointInHorizontalEdgeGutter(rect, 120, 1300)).toBe(false); - expect(isPointInHorizontalEdgeGutter(rect, 40, 1100)).toBe(false); - }); - - it("bounds the gutter width for narrow editor surfaces", () => { - const narrowRect = { - left: 0, - right: 150, - top: 0, - bottom: 300, - }; - - expect(isPointInHorizontalEdgeGutter(narrowRect, 49, 20)).toBe(true); - expect(isPointInHorizontalEdgeGutter(narrowRect, 75, 20)).toBe(false); - expect(isPointInHorizontalEdgeGutter(narrowRect, 101, 20)).toBe(true); - }); -}); diff --git a/components/note-editor/block-clipboard.ts b/components/note-editor/block-clipboard.ts deleted file mode 100644 index 1b11a56..0000000 --- a/components/note-editor/block-clipboard.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createNoteContent } from "@/lib/notes/markdown"; -import { NoteDocumentSchema, type NoteBlock } from "@/lib/notes/types"; - -const NOTE_BLOCKS_CLIPBOARD_TYPE = "application/x-taskmaster-note-blocks"; - -export function writeBlocksToClipboard( - clipboardData: DataTransfer, - blocks: NoteBlock[], - cloneBlocks: (blocks: NoteBlock[]) => NoteBlock[], -) { - const document = { - time: Date.now(), - blocks, - }; - const payload = JSON.stringify({ - type: "taskmaster.noteBlocks", - version: 1, - blocks: cloneBlocks(blocks), - }); - - clipboardData.setData(NOTE_BLOCKS_CLIPBOARD_TYPE, payload); - clipboardData.setData("text/plain", createNoteContent(document).markdown); -} - -export function readBlocksFromClipboard(clipboardData: DataTransfer) { - const raw = - clipboardData.getData(NOTE_BLOCKS_CLIPBOARD_TYPE) || - clipboardData.getData("text/plain"); - if (!raw) { - return []; - } - - try { - const payload = JSON.parse(raw) as { - type?: string; - blocks?: unknown; - }; - if ( - payload.type !== "taskmaster.noteBlocks" || - !Array.isArray(payload.blocks) - ) { - return []; - } - - return NoteDocumentSchema.parse({ - time: Date.now(), - blocks: payload.blocks, - }).blocks; - } catch { - return []; - } -} diff --git a/components/note-editor/block-transforms.ts b/components/note-editor/block-transforms.ts deleted file mode 100644 index 5ad6cf4..0000000 --- a/components/note-editor/block-transforms.ts +++ /dev/null @@ -1,230 +0,0 @@ -import type { NoteBlock } from "@/lib/notes/types"; - -export type BlockConversionTarget = - | { type: "paragraph" } - | { type: "header"; level: 1 | 2 | 3 | 4 } - | { type: "list"; style: "ordered" | "unordered" | "checklist" } - | { type: "quote" } - | { type: "code" } - | { type: "mermaid" } - | { type: "math" }; - -export type SlashCommand = { - id: string; - label: string; - hint: string; - keywords: string[]; - target: BlockConversionTarget; -}; - -export const SLASH_COMMANDS: SlashCommand[] = [ - { - id: "text", - label: "Text", - hint: "Plain text block", - keywords: ["paragraph", "plain"], - target: { type: "paragraph" }, - }, - { - id: "h1", - label: "Heading 1", - hint: "Large section heading", - keywords: ["heading", "title", "h1"], - target: { type: "header", level: 1 }, - }, - { - id: "h2", - label: "Heading 2", - hint: "Medium section heading", - keywords: ["heading", "subtitle", "h2"], - target: { type: "header", level: 2 }, - }, - { - id: "h3", - label: "Heading 3", - hint: "Small section heading", - keywords: ["heading", "h3"], - target: { type: "header", level: 3 }, - }, - { - id: "bullet", - label: "Bulleted list", - hint: "Simple unordered list", - keywords: ["list", "ul", "bullet"], - target: { type: "list", style: "unordered" }, - }, - { - id: "number", - label: "Numbered list", - hint: "Ordered list", - keywords: ["list", "ol", "number"], - target: { type: "list", style: "ordered" }, - }, - { - id: "todo", - label: "Checklist", - hint: "Track tasks", - keywords: ["todo", "task", "check"], - target: { type: "list", style: "checklist" }, - }, - { - id: "quote", - label: "Quote", - hint: "Callout text", - keywords: ["blockquote", "callout"], - target: { type: "quote" }, - }, - { - id: "code", - label: "Code", - hint: "Code block", - keywords: ["pre", "snippet"], - target: { type: "code" }, - }, - { - id: "mermaid", - label: "Mermaid", - hint: "Diagram block", - keywords: ["diagram", "flowchart", "chart", "graph"], - target: { type: "mermaid" }, - }, - { - id: "math", - label: "Math", - hint: "Equation block", - keywords: ["equation", "latex"], - target: { type: "math" }, - }, -]; - -export function stripHtml(value: string) { - return value - .replace(//gi, "\n") - .replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/ /g, " ") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/\n{3,}/g, "\n\n") - .trim(); -} - -export function getBlockText(block: NoteBlock, options?: { plain?: boolean }) { - const normalize = options?.plain ? stripHtml : (value: string) => value; - - switch (block.type) { - case "paragraph": - case "header": - return normalize(block.data.text); - case "quote": - return normalize(block.data.text); - case "list": - return normalize(block.data.items.map((item) => item.content).join("\n")); - case "code": - case "mermaid": - return block.data.code; - case "math": - return block.data.latex; - case "image": - return normalize(block.data.caption); - default: - return ""; - } -} - -export function convertBlock( - block: NoteBlock, - target: BlockConversionTarget, -): NoteBlock { - const richText = getBlockText(block); - const plainText = getBlockText(block, { plain: true }); - - switch (target.type) { - case "paragraph": - return { type: "paragraph", data: { text: richText } }; - case "header": - return { type: "header", data: { text: richText, level: target.level } }; - case "list": - return { - type: "list", - data: { - style: target.style, - items: plainText - .split("\n") - .map((item) => item.trim()) - .filter(Boolean) - .map((item) => ({ - content: item, - meta: target.style === "checklist" ? { checked: false } : {}, - items: [], - })), - }, - }; - case "quote": - return { - type: "quote", - data: { text: richText, caption: "", alignment: "left" }, - }; - case "code": - return { type: "code", data: { code: plainText } }; - case "mermaid": - return { type: "mermaid", data: { code: plainText } }; - case "math": - return { type: "math", data: { latex: plainText } }; - default: - return block; - } -} - -export function createEmptyBlockForTarget(target: BlockConversionTarget): NoteBlock { - switch (target.type) { - case "paragraph": - return { type: "paragraph", data: { text: "" } }; - case "header": - return { type: "header", data: { text: "", level: target.level } }; - case "list": - return { - type: "list", - data: { - style: target.style, - items: [ - { - content: "", - meta: target.style === "checklist" ? { checked: false } : {}, - items: [], - }, - ], - }, - }; - case "quote": - return { - type: "quote", - data: { text: "", caption: "", alignment: "left" }, - }; - case "code": - return { type: "code", data: { code: "" } }; - case "mermaid": - return { type: "mermaid", data: { code: "" } }; - case "math": - return { type: "math", data: { latex: "" } }; - default: - return { type: "paragraph", data: { text: "" } }; - } -} - -export function getSlashCommandMatches(query: string) { - const normalizedQuery = query.trim().toLowerCase(); - - if (!normalizedQuery) { - return SLASH_COMMANDS; - } - - return SLASH_COMMANDS.filter((command) => - [command.label, command.id, ...command.keywords].some((value) => - value.toLowerCase().includes(normalizedQuery), - ), - ); -} diff --git a/components/note-editor/blocks/code-block-tool.ts b/components/note-editor/blocks/code-block-tool.ts deleted file mode 100644 index 59b07e5..0000000 --- a/components/note-editor/blocks/code-block-tool.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { BlockTool, BlockToolConstructorOptions, ToolboxConfig } from "@editorjs/editorjs"; -import { highlightCode } from "@/lib/notes/code-highlighter"; -import type { NoteCodeBlockData } from "@/lib/notes/types"; - -const CODE_TOOL_ICON = ` - - - - - -`; - -export class CodeBlockTool implements BlockTool { - public static get toolbox(): ToolboxConfig { - return { - title: "Code", - icon: CODE_TOOL_ICON, - }; - } - - public static get enableLineBreaks() { - return true; - } - - public static get isReadOnlySupported() { - return true; - } - - private readonly readOnly: boolean; - private data: NoteCodeBlockData; - private textarea: HTMLTextAreaElement | null = null; - private highlightSurface: HTMLPreElement | null = null; - private highlightCodeNode: HTMLElement | null = null; - private langLabelNode: HTMLSpanElement | null = null; - private layoutFrameId: number | null = null; - - private readonly handleTextareaInput = () => { - this.syncData(); - this.syncHighlight(); - this.syncTextareaHeight(); - }; - - private readonly handleTextareaScroll = () => { - if (!this.textarea || !this.highlightSurface) { - return; - } - - this.highlightSurface.scrollTop = this.textarea.scrollTop; - this.highlightSurface.scrollLeft = this.textarea.scrollLeft; - }; - - private readonly handleTextareaKeyDown = (event: KeyboardEvent) => { - if (!this.textarea) { - return; - } - - if (event.key === "Enter") { - event.preventDefault(); - event.stopPropagation(); - - const { selectionStart, selectionEnd, value } = this.textarea; - const nextValue = `${value.slice(0, selectionStart)}\n${value.slice(selectionEnd)}`; - - this.textarea.value = nextValue; - this.textarea.selectionStart = selectionStart + 1; - this.textarea.selectionEnd = selectionStart + 1; - - this.syncData(); - this.syncHighlight(); - this.syncTextareaHeight(); - return; - } - - if (event.key === "Tab") { - event.preventDefault(); - event.stopPropagation(); - - const { selectionStart, selectionEnd, value } = this.textarea; - const nextValue = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`; - - this.textarea.value = nextValue; - this.textarea.selectionStart = selectionStart + 2; - this.textarea.selectionEnd = selectionStart + 2; - - this.syncData(); - this.syncHighlight(); - this.syncTextareaHeight(); - } - }; - - constructor({ data, readOnly }: BlockToolConstructorOptions) { - this.readOnly = readOnly; - this.data = { - code: data.code ?? "", - language: data.language, - }; - } - - public render() { - const wrapper = document.createElement("div"); - wrapper.className = "note-code-block"; - - // ---- Header row: language label ---- - const header = document.createElement("div"); - header.className = "note-code-block__header"; - - const langLabel = document.createElement("span"); - langLabel.className = "note-code-block__lang"; - this.langLabelNode = langLabel; - header.append(langLabel); - - wrapper.append(header); - - // ---- Editor area ---- - const editorSurface = document.createElement("div"); - editorSurface.className = "note-code-block__editor"; - - const surface = document.createElement("pre"); - surface.className = "note-code-block__surface"; - surface.setAttribute("aria-hidden", "true"); - this.highlightSurface = surface; - - this.highlightCodeNode = document.createElement("code"); - this.highlightCodeNode.className = "hljs note-code-block__code"; - surface.append(this.highlightCodeNode); - editorSurface.append(surface); - - if (!this.readOnly) { - this.textarea = document.createElement("textarea"); - this.textarea.className = "note-code-block__textarea"; - this.textarea.value = this.data.code; - this.textarea.placeholder = "Paste or write code here."; - this.textarea.rows = 1; - this.textarea.wrap = "soft"; - this.textarea.spellcheck = false; - this.textarea.addEventListener("input", this.handleTextareaInput); - this.textarea.addEventListener("scroll", this.handleTextareaScroll); - this.textarea.addEventListener("keydown", this.handleTextareaKeyDown); - editorSurface.append(this.textarea); - } - - wrapper.append(editorSurface); - - this.syncHighlight(); - this.syncTextareaHeight(); - this.handleTextareaScroll(); - this.scheduleLayoutSync(); - - return wrapper; - } - - public save() { - this.syncData(); - return this.data; - } - - public validate(blockData: NoteCodeBlockData) { - return typeof blockData.code === "string"; - } - - public destroy() { - if (this.layoutFrameId !== null) { - window.cancelAnimationFrame(this.layoutFrameId); - this.layoutFrameId = null; - } - - this.textarea?.removeEventListener("input", this.handleTextareaInput); - this.textarea?.removeEventListener("scroll", this.handleTextareaScroll); - this.textarea?.removeEventListener("keydown", this.handleTextareaKeyDown); - - this.textarea = null; - this.highlightSurface = null; - this.highlightCodeNode = null; - this.langLabelNode = null; - } - - private scheduleLayoutSync() { - if (typeof window === "undefined") { - return; - } - - const runLayoutSync = () => { - this.syncTextareaHeight(); - this.handleTextareaScroll(); - }; - - if (this.layoutFrameId !== null) { - window.cancelAnimationFrame(this.layoutFrameId); - } - - this.layoutFrameId = window.requestAnimationFrame(() => { - runLayoutSync(); - this.layoutFrameId = window.requestAnimationFrame(() => { - runLayoutSync(); - this.layoutFrameId = null; - }); - }); - } - - private syncData() { - this.data = { - code: this.textarea?.value ?? this.data.code, - language: this.data.language, - }; - } - - private syncHighlight() { - if (!this.highlightCodeNode) { - return; - } - - if (this.data.code.trim().length === 0) { - this.highlightCodeNode.innerHTML = this.readOnly - ? "No code content." - : ""; - this.updateLangLabel(null); - return; - } - - const result = highlightCode(this.data.code, this.data.language); - this.highlightCodeNode.innerHTML = `${result.html}${ - this.data.code.endsWith("\n") ? "\n " : "" - }`; - this.updateLangLabel(result.language); - } - - private updateLangLabel(detected: string | null) { - if (!this.langLabelNode) return; - const lang = this.data.language ?? detected; - this.langLabelNode.textContent = lang ?? ""; - this.langLabelNode.style.display = lang ? "" : "none"; - } - - private syncTextareaHeight() { - if (!this.textarea) { - return; - } - - this.textarea.style.height = "auto"; - if (this.highlightSurface) { - this.highlightSurface.style.height = "auto"; - } - - const nextHeight = this.textarea.scrollHeight; - this.textarea.style.height = `${nextHeight}px`; - - if (this.highlightSurface) { - this.highlightSurface.style.height = `${nextHeight}px`; - } - } -} diff --git a/components/note-editor/blocks/code-block-view.tsx b/components/note-editor/blocks/code-block-view.tsx deleted file mode 100644 index 93d627c..0000000 --- a/components/note-editor/blocks/code-block-view.tsx +++ /dev/null @@ -1,26 +0,0 @@ -"use client"; - -import { highlightCode } from "@/lib/notes/code-highlighter"; -import type { NoteCodeBlockData } from "@/lib/notes/types"; - -type CodeBlockViewProps = { - data: NoteCodeBlockData; -}; - -export function CodeBlockView({ data }: CodeBlockViewProps) { - return ( -
-
-         0
-                ? highlightCode(data.code).html
-                : "No code yet.",
-          }}
-        />
-      
-
- ); -} diff --git a/components/note-editor/blocks/math-block-tool.ts b/components/note-editor/blocks/math-block-tool.ts deleted file mode 100644 index 786ee77..0000000 --- a/components/note-editor/blocks/math-block-tool.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { BlockTool, BlockToolConstructorOptions, ToolboxConfig } from "@editorjs/editorjs"; -import type { API, BlockAPI } from "@editorjs/editorjs"; -import type { MathfieldElement } from "mathlive"; -import type { NoteMathBlockData } from "@/lib/notes/types"; - -const MATH_TOOL_ICON = ` - - - -`; - -export class MathBlockTool implements BlockTool { - public static get toolbox(): ToolboxConfig { - return { - title: "Math", - icon: MATH_TOOL_ICON, - }; - } - - public static get isReadOnlySupported() { - return true; - } - - private readonly readOnly: boolean; - private readonly api: API; - private readonly block: BlockAPI; - private data: NoteMathBlockData; - private mathField: MathfieldElement | null = null; - private wrapper: HTMLDivElement | null = null; - private readonly stopEditorEventPropagation = (event: Event) => { - event.stopPropagation(); - }; - private readonly handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" && event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) { - event.preventDefault(); - event.stopPropagation(); - this.handleInput(); - - const currentBlockIndex = this.api.blocks.getBlockIndex(this.block.id); - this.api.blocks.insert("paragraph", { text: "
" }, undefined, currentBlockIndex + 1, true); - this.focusNextBlock(); - return; - } - - this.stopEditorEventPropagation(event); - }; - private readonly handleInput = () => { - if (!this.mathField) { - return; - } - - this.data = { - latex: this.mathField.value, - }; - }; - - constructor({ api, block, data, readOnly }: BlockToolConstructorOptions) { - this.api = api; - this.block = block; - this.readOnly = readOnly; - this.data = { - latex: data.latex ?? "", - }; - } - - public render() { - const wrapper = document.createElement("div"); - wrapper.className = - "rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"; - wrapper.contentEditable = "false"; - - const label = document.createElement("div"); - label.className = "mb-2 text-xs font-medium uppercase tracking-[0.2em] text-zinc-500"; - label.textContent = this.readOnly ? "Equation" : "Math"; - wrapper.append(label); - - const mathField = document.createElement("math-field") as unknown as MathfieldElement; - mathField.className = "block min-h-12 w-full rounded-md bg-white px-3 py-2 text-lg dark:bg-zinc-900"; - mathField.setAttribute("math-virtual-keyboard-policy", "manual"); - mathField.setAttribute("smart-mode", "on"); - mathField.setAttribute("default-mode", "math"); - mathField.setAttribute("placeholder", "\\frac{a}{b}"); - mathField.value = this.data.latex; - - if (this.readOnly) { - mathField.setAttribute("read-only", ""); - mathField.tabIndex = -1; - } else { - mathField.addEventListener("beforeinput", this.stopEditorEventPropagation); - mathField.addEventListener("input", this.handleInput); - mathField.addEventListener("change", this.handleInput); - mathField.addEventListener("keydown", this.handleKeyDown); - mathField.addEventListener("keyup", this.stopEditorEventPropagation); - mathField.addEventListener("pointerdown", this.stopEditorEventPropagation); - mathField.addEventListener("click", this.stopEditorEventPropagation); - } - - wrapper.append(mathField); - - this.mathField = mathField; - this.wrapper = wrapper; - - return wrapper; - } - - public save() { - return { - latex: this.mathField?.value ?? this.data.latex, - }; - } - - public validate(blockData: NoteMathBlockData) { - return typeof blockData.latex === "string"; - } - - public destroy() { - if (this.mathField) { - this.mathField.removeEventListener("beforeinput", this.stopEditorEventPropagation); - this.mathField.removeEventListener("input", this.handleInput); - this.mathField.removeEventListener("change", this.handleInput); - this.mathField.removeEventListener("keydown", this.handleKeyDown); - this.mathField.removeEventListener("keyup", this.stopEditorEventPropagation); - this.mathField.removeEventListener("pointerdown", this.stopEditorEventPropagation); - this.mathField.removeEventListener("click", this.stopEditorEventPropagation); - } - - this.mathField = null; - this.wrapper = null; - } - - private focusNextBlock() { - const focus = () => { - if (typeof document === "undefined") { - return; - } - - const nextBlock = this.wrapper?.closest(".ce-block")?.nextElementSibling; - const focusTarget = nextBlock?.querySelector( - '[contenteditable="true"], textarea, input, math-field', - ); - - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - focusTarget?.focus({ preventScroll: true }); - - if (focusTarget?.isContentEditable) { - const range = document.createRange(); - range.selectNodeContents(focusTarget); - range.collapse(false); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } - }; - - window.requestAnimationFrame(() => { - focus(); - window.setTimeout(focus, 0); - window.setTimeout(focus, 50); - window.setTimeout(focus, 150); - window.setTimeout(focus, 350); - window.setTimeout(focus, 650); - }); - } -} diff --git a/components/note-editor/blocks/mermaid-block-tool.ts b/components/note-editor/blocks/mermaid-block-tool.ts deleted file mode 100644 index 3344ff8..0000000 --- a/components/note-editor/blocks/mermaid-block-tool.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { - API, - BlockAPI, - BlockTool, - BlockToolConstructorOptions, - ToolboxConfig, -} from "@editorjs/editorjs"; -import { - getMermaidErrorMessage, - renderMermaidSvg, -} from "@/components/note-editor/blocks/mermaid-renderer"; -import type { NoteMermaidBlockData } from "@/lib/notes/types"; - -const MERMAID_TOOL_ICON = ` - - - - - - -`; - -const SHOW_SOURCE_ICON = ` - -`; - -const HIDE_SOURCE_ICON = ` - -`; - -export class MermaidBlockTool implements BlockTool { - public static get toolbox(): ToolboxConfig { - return { - title: "Mermaid", - icon: MERMAID_TOOL_ICON, - }; - } - - public static get enableLineBreaks() { - return true; - } - - public static get isReadOnlySupported() { - return true; - } - - private readonly readOnly: boolean; - private readonly api: API; - private readonly block: BlockAPI; - private data: NoteMermaidBlockData; - private textarea: HTMLTextAreaElement | null = null; - private toggleButton: HTMLButtonElement | null = null; - private previewNode: HTMLDivElement | null = null; - private renderTimer: number | null = null; - private renderVersion = 0; - - private readonly stopEditorEventPropagation = (event: Event) => { - event.stopPropagation(); - }; - - private readonly handleTextareaInput = () => { - this.syncData(); - this.syncTextareaHeight(); - this.schedulePreviewRender(); - }; - - private readonly handleTextareaKeyDown = (event: KeyboardEvent) => { - if (!this.textarea) { - return; - } - - if (event.key === "Tab") { - event.preventDefault(); - event.stopPropagation(); - - const { selectionStart, selectionEnd, value } = this.textarea; - const nextValue = `${value.slice(0, selectionStart)} ${value.slice(selectionEnd)}`; - - this.textarea.value = nextValue; - this.textarea.selectionStart = selectionStart + 2; - this.textarea.selectionEnd = selectionStart + 2; - - this.syncData(); - this.syncTextareaHeight(); - this.schedulePreviewRender(); - return; - } - - this.stopEditorEventPropagation(event); - }; - - constructor({ api, block, data, readOnly }: BlockToolConstructorOptions) { - this.api = api; - this.block = block; - this.readOnly = readOnly; - this.data = { - code: data.code ?? "", - sourceCollapsed: data.sourceCollapsed ?? false, - }; - } - - public render() { - const wrapper = document.createElement("div"); - wrapper.className = "note-mermaid-block"; - - const header = document.createElement("div"); - header.className = "note-mermaid-block__header"; - - const langLabel = document.createElement("span"); - langLabel.className = "note-mermaid-block__lang"; - langLabel.textContent = "mermaid"; - header.append(langLabel); - - if (!this.readOnly) { - const toggleButton = document.createElement("button"); - toggleButton.type = "button"; - toggleButton.className = "note-mermaid-block__toggle"; - toggleButton.addEventListener("click", this.handleToggleSource); - this.toggleButton = toggleButton; - header.append(toggleButton); - } - - wrapper.append(header); - - if (!this.readOnly) { - this.textarea = document.createElement("textarea"); - this.textarea.className = "note-mermaid-block__textarea"; - this.textarea.value = this.data.code; - this.textarea.placeholder = "graph TD\n A --> B"; - this.textarea.rows = 3; - this.textarea.spellcheck = false; - this.textarea.addEventListener("beforeinput", this.stopEditorEventPropagation); - this.textarea.addEventListener("input", this.handleTextareaInput); - this.textarea.addEventListener("keydown", this.handleTextareaKeyDown); - this.textarea.addEventListener("keyup", this.stopEditorEventPropagation); - wrapper.append(this.textarea); - } - - this.previewNode = document.createElement("div"); - this.previewNode.className = "note-mermaid-block__surface"; - wrapper.append(this.previewNode); - - this.syncTextareaHeight(); - this.syncSourceVisibility(); - this.renderPreview(); - - return wrapper; - } - - public save() { - this.syncData(); - return this.data; - } - - public validate(blockData: NoteMermaidBlockData) { - return typeof blockData.code === "string"; - } - - public destroy() { - if (this.renderTimer !== null) { - window.clearTimeout(this.renderTimer); - this.renderTimer = null; - } - - this.textarea?.removeEventListener("beforeinput", this.stopEditorEventPropagation); - this.textarea?.removeEventListener("input", this.handleTextareaInput); - this.textarea?.removeEventListener("keydown", this.handleTextareaKeyDown); - this.textarea?.removeEventListener("keyup", this.stopEditorEventPropagation); - this.toggleButton?.removeEventListener("click", this.handleToggleSource); - - this.renderVersion += 1; - this.textarea = null; - this.toggleButton = null; - this.previewNode = null; - } - - private readonly handleToggleSource = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - - this.syncData(); - this.data = { - ...this.data, - sourceCollapsed: !this.data.sourceCollapsed, - }; - this.syncSourceVisibility(); - void this.api.blocks.update(this.block.id, this.data).catch(() => undefined); - }; - - private syncData() { - this.data = { - code: this.textarea?.value ?? this.data.code, - sourceCollapsed: this.data.sourceCollapsed, - }; - } - - private syncTextareaHeight() { - if (!this.textarea) { - return; - } - - this.textarea.style.height = "auto"; - this.textarea.style.height = `${Math.max(this.textarea.scrollHeight, 120)}px`; - } - - private syncSourceVisibility() { - const isCollapsed = Boolean(this.data.sourceCollapsed); - - if (this.textarea) { - this.textarea.hidden = isCollapsed; - } - - if (this.toggleButton) { - this.toggleButton.innerHTML = isCollapsed ? SHOW_SOURCE_ICON : HIDE_SOURCE_ICON; - this.toggleButton.setAttribute( - "aria-label", - isCollapsed ? "Show Mermaid code" : "Hide Mermaid code", - ); - this.toggleButton.setAttribute("aria-pressed", String(isCollapsed)); - } - } - - private schedulePreviewRender() { - if (this.renderTimer !== null) { - window.clearTimeout(this.renderTimer); - } - - this.renderTimer = window.setTimeout(() => { - this.renderTimer = null; - this.renderPreview(); - }, 300); - } - - private renderPreview() { - const previewNode = this.previewNode; - if (!previewNode) { - return; - } - - const code = this.data.code.trim(); - const version = this.renderVersion + 1; - this.renderVersion = version; - - if (!code) { - previewNode.innerHTML = '
No diagram yet.
'; - return; - } - - previewNode.innerHTML = '
Rendering diagram...
'; - - void renderMermaidSvg(code) - .then((svg) => { - if (this.renderVersion !== version || !this.previewNode) { - return; - } - - this.previewNode.innerHTML = ""; - const diagram = document.createElement("div"); - diagram.className = "note-mermaid-block__diagram"; - diagram.innerHTML = svg; - this.previewNode.append(diagram); - }) - .catch((error: unknown) => { - if (this.renderVersion !== version || !this.previewNode) { - return; - } - - const errorNode = document.createElement("div"); - errorNode.className = "note-mermaid-block__error"; - const title = document.createElement("strong"); - title.textContent = "Could not render this Mermaid diagram."; - const message = document.createElement("span"); - message.textContent = getMermaidErrorMessage(error); - errorNode.append(title, message); - this.previewNode.innerHTML = ""; - this.previewNode.append(errorNode); - }); - } -} diff --git a/components/note-editor/blocks/mermaid-block-view.tsx b/components/note-editor/blocks/mermaid-block-view.tsx deleted file mode 100644 index c6d3997..0000000 --- a/components/note-editor/blocks/mermaid-block-view.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Loader2 } from "lucide-react"; -import type { NoteMermaidBlockData } from "@/lib/notes/types"; -import { - getMermaidErrorMessage, - renderMermaidSvg, -} from "@/components/note-editor/blocks/mermaid-renderer"; - -type MermaidBlockViewProps = { - data: NoteMermaidBlockData; -}; - -type MermaidRenderState = - | { code: string; status: "success"; svg: string } - | { code: string; status: "error"; message: string }; - -export function MermaidBlockView({ data }: MermaidBlockViewProps) { - const code = data.code.trim(); - const [renderState, setRenderState] = useState(null); - const visibleState = !code - ? ({ status: "idle" } as const) - : renderState?.code === code - ? renderState - : ({ status: "loading" } as const); - - useEffect(() => { - let disposed = false; - - if (!code) { - return; - } - - renderMermaidSvg(code) - .then((svg) => { - if (!disposed) { - setRenderState({ code, status: "success", svg }); - } - }) - .catch((error: unknown) => { - if (!disposed) { - setRenderState({ - code, - status: "error", - message: getMermaidErrorMessage(error), - }); - } - }); - - return () => { - disposed = true; - }; - }, [code]); - - return ( -
-
- mermaid -
-
- {visibleState.status === "idle" ? ( -
No diagram yet.
- ) : null} - {visibleState.status === "loading" ? ( -
- - Rendering diagram... -
- ) : null} - {visibleState.status === "error" ? ( -
- Could not render this Mermaid diagram. - {visibleState.message} -
{data.code}
-
- ) : null} - {visibleState.status === "success" ? ( -
- ) : null} -
-
- ); -} diff --git a/components/note-editor/blocks/mermaid-renderer.ts b/components/note-editor/blocks/mermaid-renderer.ts deleted file mode 100644 index bb73540..0000000 --- a/components/note-editor/blocks/mermaid-renderer.ts +++ /dev/null @@ -1,34 +0,0 @@ -let renderCounter = 0; - -export function getMermaidErrorMessage(error: unknown) { - if (error instanceof Error) { - return error.message.replace(/\s+at new Promise[\s\S]*$/g, "").trim(); - } - - return "The Mermaid diagram has invalid syntax."; -} - -export async function renderMermaidSvg(code: string) { - const { default: mermaid } = await import("mermaid"); - const id = `taskmaster-mermaid-${Date.now()}-${renderCounter}`; - renderCounter += 1; - - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - theme: "base", - themeVariables: { - background: "transparent", - fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif", - primaryColor: "#eef2ff", - primaryTextColor: "#18181b", - primaryBorderColor: "#c7d2fe", - lineColor: "#64748b", - secondaryColor: "#f8fafc", - tertiaryColor: "#ffffff", - }, - }); - - const { svg } = await mermaid.render(id, code); - return svg; -} diff --git a/components/note-editor/document-utils.ts b/components/note-editor/document-utils.ts deleted file mode 100644 index 1cde975..0000000 --- a/components/note-editor/document-utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { NoteDocument } from "@/lib/notes/types"; - -export function areDocumentsEqual(left: NoteDocument, right: NoteDocument) { - return JSON.stringify(left) === JSON.stringify(right); -} diff --git a/components/note-editor/editor-menus.tsx b/components/note-editor/editor-menus.tsx deleted file mode 100644 index d4e6bda..0000000 --- a/components/note-editor/editor-menus.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import type { NoteBlockType } from "@/lib/notes/types"; -import type { BlockConversionTarget, SlashCommand } from "@/components/note-editor/block-transforms"; -import { getSlashCommandMatches } from "@/components/note-editor/block-transforms"; - -export type BlockContextMenuState = { - x: number; - y: number; - blockIndex: number; - blockType: NoteBlockType; -}; - -export type SlashCommandMenuState = { - x: number; - y: number; - blockIndex: number; - query: string; - activeIndex: number; -}; - -type SlashCommandMenuProps = { - state: SlashCommandMenuState; - onSelect: (command: SlashCommand) => void; -}; - -export function SlashCommandMenu({ state, onSelect }: SlashCommandMenuProps) { - const matches = getSlashCommandMatches(state.query); - - return ( -
- {matches.length > 0 ? ( - matches.map((command, index) => ( - - )) - ) : ( -
No commands
- )} -
- ); -} - -const conversionTargets: Array<{ label: string; target: BlockConversionTarget }> = [ - { label: "Text", target: { type: "paragraph" } }, - { label: "Heading 1", target: { type: "header", level: 1 } }, - { label: "Heading 2", target: { type: "header", level: 2 } }, - { label: "Heading 3", target: { type: "header", level: 3 } }, - { label: "Bulleted list", target: { type: "list", style: "unordered" } }, - { label: "Numbered list", target: { type: "list", style: "ordered" } }, - { label: "Checklist", target: { type: "list", style: "checklist" } }, - { label: "Quote", target: { type: "quote" } }, - { label: "Code", target: { type: "code" } }, - { label: "Mermaid", target: { type: "mermaid" } }, - { label: "Math", target: { type: "math" } }, -]; - -type BlockContextMenuProps = { - state: BlockContextMenuState; - onConvert: (target: BlockConversionTarget) => void; - onDelete: () => void; -}; - -export function BlockContextMenu({ - state, - onConvert, - onDelete, -}: BlockContextMenuProps) { - return ( -
event.preventDefault()} - > -
- Turn into - -
- {conversionTargets.map((item) => ( - - ))} -
-
-
- -
{state.blockType}
-
- ); -} diff --git a/components/note-editor/editorjs-tools.ts b/components/note-editor/editorjs-tools.ts deleted file mode 100644 index e9297ed..0000000 --- a/components/note-editor/editorjs-tools.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { BlockToolConstructable } from "@editorjs/editorjs"; -import { CodeBlockTool } from "@/components/note-editor/blocks/code-block-tool"; -import { MathBlockTool } from "@/components/note-editor/blocks/math-block-tool"; -import { MermaidBlockTool } from "@/components/note-editor/blocks/mermaid-block-tool"; -import type { NoteImageFileData } from "@/lib/notes/types"; - -export async function loadEditorJsClassAndTools( - resolveImageUpload: (file: File) => Promise<{ - success: 1; - file: NoteImageFileData; - }>, -) { - const [ - { default: EditorJSClass }, - { default: Paragraph }, - { default: Header }, - { default: EditorjsList }, - { default: Quote }, - { default: ImageTool }, - ] = await Promise.all([ - import("@editorjs/editorjs"), - import("@editorjs/paragraph"), - import("@editorjs/header"), - import("@editorjs/list"), - import("@editorjs/quote"), - import("@editorjs/image"), - import("mathlive"), - ]); - - const paragraphTool = Paragraph as unknown as BlockToolConstructable; - const headerTool = Header as unknown as BlockToolConstructable; - const listTool = EditorjsList as unknown as BlockToolConstructable; - const quoteTool = Quote as unknown as BlockToolConstructable; - const imageTool = ImageTool as unknown as BlockToolConstructable; - - return { - EditorJSClass, - tools: { - paragraph: { - class: paragraphTool, - inlineToolbar: true, - config: { - placeholder: "Type '/' for commands", - }, - }, - header: { - class: headerTool, - inlineToolbar: true, - config: { - levels: [1, 2, 3, 4], - defaultLevel: 2, - }, - }, - list: { - class: listTool, - inlineToolbar: true, - config: { - defaultStyle: "unordered", - }, - }, - quote: { - class: quoteTool, - inlineToolbar: true, - }, - code: { - class: CodeBlockTool as unknown as BlockToolConstructable, - }, - mermaid: { - class: MermaidBlockTool as unknown as BlockToolConstructable, - }, - image: { - class: imageTool, - config: { - features: { - border: false, - background: false, - caption: "optional", - stretch: true, - }, - uploader: { - uploadByFile: async (file: Blob) => { - if (!(file instanceof File)) { - throw new Error("Only file uploads are supported."); - } - - return resolveImageUpload(file); - }, - }, - }, - }, - math: { - class: MathBlockTool as unknown as BlockToolConstructable, - }, - }, - }; -} diff --git a/components/note-editor/extensions/image-drop.test.ts b/components/note-editor/extensions/image-drop.test.ts new file mode 100644 index 0000000..28d7a66 --- /dev/null +++ b/components/note-editor/extensions/image-drop.test.ts @@ -0,0 +1,55 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +import { toast } from "sonner"; +import { fileToDataUrl, insertImages } from "@/components/note-editor/extensions/image-drop"; + +function view(doc: string) { + return new EditorView({ state: EditorState.create({ doc }) }); +} + +describe("insertImages", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("inserts a markdown image on its own line using the uploader's URL", async () => { + const editor = view("Notes so far"); + const upload = vi.fn().mockResolvedValue({ url: "https://cdn.example/diagram.png" }); + + await insertImages(editor, [new File(["x"], "diagram.png", { type: "image/png" })], 12, upload); + + expect(editor.state.doc.toString()).toBe("Notes so far\n![diagram](https://cdn.example/diagram.png)\n"); + expect(upload).toHaveBeenCalledTimes(1); + editor.destroy(); + }); + + it("inserts several files in order and reports a failed one without stopping", async () => { + const editor = view(""); + const upload = vi + .fn() + .mockResolvedValueOnce({ url: "a.png" }) + .mockRejectedValueOnce(new Error("too big")) + .mockResolvedValueOnce({ url: "c.png" }); + const files = ["a", "b", "c"].map((name) => new File(["x"], `${name}.png`, { type: "image/png" })); + + await insertImages(editor, files, 0, upload); + + expect(editor.state.doc.toString()).toBe("![a](a.png)\n![c](c.png)\n"); + expect(toast.error).toHaveBeenCalledWith("Could not add image", expect.objectContaining({ description: "too big" })); + editor.destroy(); + }); +}); + +describe("fileToDataUrl", () => { + it("encodes small images and refuses large ones", async () => { + const small = new File(["hello"], "s.png", { type: "image/png" }); + await expect(fileToDataUrl(small)).resolves.toEqual({ url: expect.stringMatching(/^data:image\/png;base64,/) }); + + const large = new File([new Uint8Array(2 * 1024 * 1024 + 1)], "l.png", { type: "image/png" }); + await expect(fileToDataUrl(large)).rejects.toThrow(/2 MB/); + }); +}); diff --git a/components/note-editor/extensions/image-drop.ts b/components/note-editor/extensions/image-drop.ts new file mode 100644 index 0000000..4b9619c --- /dev/null +++ b/components/note-editor/extensions/image-drop.ts @@ -0,0 +1,84 @@ +import { EditorView } from "@codemirror/view"; +import { toast } from "sonner"; + +export type ImageUploader = (file: File) => Promise<{ url: string }>; + +const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024; + +/** + * Fallback uploader until blob storage lands (issue #86): inline the image + * as a data URL, capped so a note cannot balloon by tens of megabytes. + */ +export function fileToDataUrl(file: File): Promise<{ url: string }> { + if (file.size > MAX_INLINE_IMAGE_BYTES) { + return Promise.reject(new Error("Images over 2 MB need image storage, which is not set up yet.")); + } + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve({ url: String(reader.result) }); + reader.onerror = () => reject(reader.error ?? new Error("Could not read the image.")); + reader.readAsDataURL(file); + }); +} + +function imageFiles(transfer: DataTransfer | null): File[] { + return Array.from(transfer?.files ?? []).filter((file) => file.type.startsWith("image/")); +} + +/** Upload each file and insert `![name](url)` on its own line at `pos`. */ +export async function insertImages( + view: EditorView, + files: readonly File[], + pos: number, + upload: ImageUploader, +) { + let at = pos; + for (const file of files) { + try { + const { url } = await upload(file); + at = Math.min(at, view.state.doc.length); + const line = view.state.doc.lineAt(at); + const lead = line.from === at ? "" : "\n"; + const alt = file.name.replace(/\.[^.]+$/, "") || "image"; + const text = `${lead}![${alt}](${url})\n`; + view.dispatch({ + changes: { from: at, insert: text }, + selection: { anchor: at + text.length }, + scrollIntoView: true, + }); + at += text.length; + } catch (error) { + toast.error("Could not add image", { + description: error instanceof Error ? error.message : undefined, + duration: 5000, + }); + } + } +} + +/** Drop or paste image files into the note as Markdown images. */ +export function imageDrop(upload: ImageUploader = fileToDataUrl) { + return EditorView.domEventHandlers({ + drop(event, view) { + const files = imageFiles(event.dataTransfer); + if (files.length === 0) { + return false; + } + event.preventDefault(); + const pos = + view.posAtCoords({ x: event.clientX, y: event.clientY }) ?? view.state.selection.main.head; + void insertImages(view, files, pos, upload); + return true; + }, + paste(event, view) { + const files = imageFiles(event.clipboardData); + if (files.length === 0) { + return false; + } + event.preventDefault(); + void insertImages(view, files, view.state.selection.main.head, upload); + return true; + }, + }); +} diff --git a/components/note-editor/extensions/live-preview.ts b/components/note-editor/extensions/live-preview.ts new file mode 100644 index 0000000..87b69b6 --- /dev/null +++ b/components/note-editor/extensions/live-preview.ts @@ -0,0 +1,340 @@ +import { syntaxTree } from "@codemirror/language"; +import { type EditorState, type Range } from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from "@codemirror/view"; +import { findMathRanges, type MathRange } from "@/lib/notes/math-ranges"; + +// --------------------------------------------------------------------------- +// Widgets +// --------------------------------------------------------------------------- + +class BulletWidget extends WidgetType { + eq() { + return true; + } + + toDOM() { + const element = document.createElement("span"); + element.className = "cm-note-bullet"; + element.textContent = "•"; + return element; + } +} + +class CheckboxWidget extends WidgetType { + constructor( + readonly checked: boolean, + readonly from: number, + readonly to: number, + ) { + super(); + } + + eq(other: CheckboxWidget) { + return other.checked === this.checked && other.from === this.from && other.to === this.to; + } + + toDOM(view: EditorView) { + const element = document.createElement("input"); + element.type = "checkbox"; + element.checked = this.checked; + element.className = "cm-note-checkbox"; + element.setAttribute("aria-label", this.checked ? "Mark task incomplete" : "Mark task complete"); + element.addEventListener("mousedown", (event) => { + event.preventDefault(); + view.dispatch({ + changes: { from: this.from, to: this.to, insert: this.checked ? "[ ]" : "[x]" }, + }); + }); + return element; + } + + ignoreEvent(event: Event) { + return event.type === "mousedown"; + } +} + +class RuleWidget extends WidgetType { + eq() { + return true; + } + + toDOM() { + // An inline replacement styled as a rule; a real
would need a block + // decoration, which a ViewPlugin may not provide. + const element = document.createElement("span"); + element.className = "cm-note-rule"; + element.setAttribute("role", "separator"); + return element; + } +} + +class ImageWidget extends WidgetType { + constructor( + readonly url: string, + readonly alt: string, + ) { + super(); + } + + eq(other: ImageWidget) { + return other.url === this.url && other.alt === this.alt; + } + + toDOM() { + const wrapper = document.createElement("span"); + wrapper.className = "cm-note-image"; + const image = document.createElement("img"); + image.src = this.url; + image.alt = this.alt; + image.loading = "lazy"; + wrapper.append(image); + return wrapper; + } + + ignoreEvent() { + return false; + } +} + +// --------------------------------------------------------------------------- +// Decoration computation +// --------------------------------------------------------------------------- + +const hide = Decoration.replace({}); +const quoteLine = Decoration.line({ class: "cm-note-quote-line" }); +const codeLine = Decoration.line({ class: "cm-note-code-line" }); +const tableLine = Decoration.line({ class: "cm-note-table-line" }); +const highlightMark = Decoration.mark({ class: "cm-note-highlight" }); + +const HIGHLIGHT_RE = /==([^=\n]+?)==/g; + +/** Lines that contain a cursor or selection show their raw syntax. */ +function activeLines(state: EditorState) { + const lines = new Set(); + for (const range of state.selection.ranges) { + const first = state.doc.lineAt(range.from).number; + const last = state.doc.lineAt(range.to).number; + for (let line = first; line <= last; line += 1) { + lines.add(line); + } + } + return lines; +} + +function insideAny(ranges: readonly { from: number; to: number }[], from: number, to: number) { + return ranges.some((range) => from >= range.from && to <= range.to); +} + +export function buildLivePreviewDecorations(view: EditorView): DecorationSet { + const { state } = view; + const tree = syntaxTree(state); + const active = activeLines(state); + const decorations: Range[] = []; + const codeRanges: { from: number; to: number }[] = []; + // Math is rendered by the math StateField; never decorate inside it, so no + // two replacements ever overlap. + const mathRanges: MathRange[] = findMathRanges(state.doc.toString()); + + const isActiveAt = (pos: number) => active.has(state.doc.lineAt(pos).number); + const hideRange = (from: number, to: number) => { + if (to > from) { + decorations.push(hide.range(from, to)); + } + }; + const forEachLineIn = (from: number, to: number, decoration: Decoration) => { + let pos = from; + for (;;) { + const line = state.doc.lineAt(pos); + decorations.push(decoration.range(line.from)); + if (line.to >= to) break; + pos = line.to + 1; + } + }; + + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter: (node) => { + const name = node.name; + + if (name === "FencedCode") { + codeRanges.push({ from: node.from, to: node.to }); + forEachLineIn(node.from, node.to, codeLine); + return; + } + if (name === "InlineCode") { + codeRanges.push({ from: node.from, to: node.to }); + } + if (name === "Table") { + forEachLineIn(node.from, node.to, tableLine); + return; + } + if (name === "Blockquote") { + forEachLineIn(node.from, node.to, quoteLine); + } + + if (isActiveAt(node.from) || insideAny(mathRanges, node.from, node.to)) { + return; + } + + switch (name) { + case "HeaderMark": + case "EmphasisMark": + case "StrikethroughMark": + case "QuoteMark": + case "CodeMark": + case "CodeInfo": + case "LinkMark": + case "URL": + hideRange(node.from, node.to); + return; + case "ListMark": { + if (node.node.nextSibling?.name === "Task") { + hideRange(node.from, node.to); // the checkbox stands in for the bullet + return; + } + if (/^[-*+]$/.test(state.doc.sliceString(node.from, node.to))) { + decorations.push( + Decoration.replace({ widget: new BulletWidget() }).range(node.from, node.to), + ); + } + return; + } + case "TaskMarker": { + const checked = /\[[xX]\]/.test(state.doc.sliceString(node.from, node.to)); + decorations.push( + Decoration.replace({ + widget: new CheckboxWidget(checked, node.from, node.to), + }).range(node.from, node.to), + ); + return; + } + case "HorizontalRule": + decorations.push( + Decoration.replace({ widget: new RuleWidget() }).range(node.from, node.to), + ); + return; + case "Image": { + const source = state.doc.sliceString(node.from, node.to); + const match = source.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/); + if (match) { + decorations.push( + Decoration.replace({ widget: new ImageWidget(match[2]!, match[1] ?? "") }).range( + node.from, + node.to, + ), + ); + return false; // the replacement covers the children + } + return; + } + default: + return; + } + }, + }); + } + + // `==highlight==` is editor-side sugar (not in lezer or remark-gfm); it is + // stored as plain markdown, which gives issues #7/#89 a retrievable hook. + for (const { from, to } of view.visibleRanges) { + const text = state.doc.sliceString(from, to); + for (const match of text.matchAll(HIGHLIGHT_RE)) { + const start = from + match.index; + const end = start + match[0].length; + if (insideAny(codeRanges, start, end) || insideAny(mathRanges, start, end)) { + continue; + } + decorations.push(highlightMark.range(start, end)); + if (!isActiveAt(start)) { + hideRange(start, start + 2); + hideRange(end - 2, end); + } + } + } + + return Decoration.set(decorations, true); +} + +// --------------------------------------------------------------------------- +// Plugin + styles +// --------------------------------------------------------------------------- + +const livePreviewPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildLivePreviewDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = buildLivePreviewDecorations(update.view); + } + } + }, + { decorations: (plugin) => plugin.decorations }, +); + +const livePreviewTheme = EditorView.baseTheme({ + ".cm-note-bullet": { + display: "inline-block", + width: "1ch", + color: "var(--muted-foreground)", + }, + ".cm-note-checkbox": { + verticalAlign: "-0.1em", + marginRight: "0.15em", + accentColor: "var(--accent)", + }, + ".cm-note-rule": { + display: "block", + height: "0", + borderTop: "1px solid var(--border-strong)", + margin: "0.6em 0", + }, + ".cm-note-image img": { + display: "block", + maxWidth: "100%", + borderRadius: "var(--radius-lg)", + margin: "0.35em 0", + }, + ".cm-note-quote-line": { + borderLeft: "3px solid var(--border-strong)", + paddingLeft: "0.75em !important", + color: "var(--muted-foreground)", + }, + ".cm-note-code-line": { + backgroundColor: "var(--surface-muted)", + fontFamily: "var(--font-mono), ui-monospace, SFMono-Regular, monospace", + fontSize: "0.9em", + padding: "0 0.75em !important", + }, + ".cm-note-table-line": { + fontFamily: "var(--font-mono), ui-monospace, SFMono-Regular, monospace", + fontSize: "0.9em", + }, + ".cm-note-highlight": { + backgroundColor: "color-mix(in srgb, var(--accent) 22%, transparent)", + borderRadius: "3px", + padding: "0.05em 0", + }, +}); + +/** + * Obsidian-style live preview: Markdown syntax is hidden and rendered on + * every line except the ones containing the cursor or a selection. Math is + * handled by `mathWidgets()`; this plugin only stays out of its way. + */ +export function livePreview() { + return [livePreviewPlugin, livePreviewTheme]; +} diff --git a/components/note-editor/extensions/math-field-widget.test.tsx b/components/note-editor/extensions/math-field-widget.test.tsx new file mode 100644 index 0000000..99a97fd --- /dev/null +++ b/components/note-editor/extensions/math-field-widget.test.tsx @@ -0,0 +1,234 @@ +import { act, cleanup, render } from "@testing-library/react"; +import { undo, undoDepth } from "@codemirror/commands"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/note-editor/extensions/mathlive-loader", () => ({ + loadMathLive: () => Promise.resolve(), +})); + +import { enterMath, mathSessionField } from "@/components/note-editor/extensions/math-field-widget"; +import MarkdownEditor from "@/components/note-editor/markdown-editor"; + +/** Enough of MathLive's element for the bridge: a value, setValue, focus, and events. */ +class StubMathField extends HTMLElement { + private stored = ""; + defaultMode = "math"; + get value() { + return this.stored; + } + set value(next: string) { + this.stored = next; + } + setValue(next: string) { + this.stored = next; + } + focus() {} +} + +function mount(doc: string) { + const onChange = vi.fn(); + const utils = render(); + const host = utils.getByTestId("markdown-editor"); + const view = EditorView.findFromDOM(host)!; + return { ...utils, host, view, onChange }; +} + +function field(host: HTMLElement) { + return host.querySelector("math-field") as (HTMLElement & { value: string }) | null; +} + +function typeIntoField(host: HTMLElement, latex: string) { + const element = field(host)!; + element.value = latex; + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +function pressInField(host: HTMLElement, key: string, init: KeyboardEventInit = {}) { + field(host)!.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init })); +} + +describe("MathFieldWidget bridge", () => { + beforeAll(() => { + if (!customElements.get("math-field")) { + customElements.define("math-field", StubMathField); + } + }); + + afterEach(() => { + cleanup(); + }); + + it("opens a region into a field holding its LaTeX, and edits flow into the document without history", () => { + const doc = "Area $x^2$ here"; + const { host, view } = mount(doc); + + act(() => { + view.dispatch({ effects: enterMath.of({ from: 5, to: 10 }) }); + }); + + expect(view.state.field(mathSessionField)).toMatchObject({ from: 5, to: 10, display: false, originalLatex: "x^2" }); + expect(field(host)?.value).toBe("x^2"); + expect(host.querySelector(".cm-note-math")).toBeNull(); // no KaTeX for the open region + + act(() => typeIntoField(host, "x^3")); + + expect(view.state.doc.toString()).toBe("Area $x^3$ here"); + expect(undoDepth(view.state)).toBe(0); + expect(view.state.field(mathSessionField)).toMatchObject({ contentFrom: 6, contentTo: 9 }); + }); + + it("exiting commits one undo step, normalizes the LaTeX, and places the cursor after the region", () => { + const { host, view } = mount("Area $x^2$ here"); + + act(() => view.dispatch({ effects: enterMath.of({ from: 5, to: 10 }) })); + act(() => typeIntoField(host, "√(y²)")); + act(() => pressInField(host, "Escape")); + + expect(view.state.field(mathSessionField)).toBeNull(); + expect(field(host)).toBeNull(); + expect(view.state.doc.toString()).toBe("Area $\\sqrt{y^2}$ here"); + expect(undoDepth(view.state)).toBe(1); + expect(view.state.selection.main.head).toBe("Area $\\sqrt{y^2}$".length); + + act(() => { + undo(view); + }); + expect(view.state.doc.toString()).toBe("Area $x^2$ here"); + }); + + it("keeps the generator's $$ fence layout and lands on the next line after a display formula", () => { + const doc = "Before\n\n$$\na^2\n$$\n\nAfter"; + const { host, view } = mount(doc); + const from = doc.indexOf("$$"); + const to = doc.indexOf("$$", from + 2) + 2; + + act(() => view.dispatch({ effects: enterMath.of({ from, to }) })); + expect(view.state.field(mathSessionField)?.display).toBe(true); + + act(() => typeIntoField(host, "b^2")); + act(() => pressInField(host, "Enter")); + + expect(view.state.doc.toString()).toBe("Before\n\n$$\nb^2\n$$\n\nAfter"); + expect(view.state.selection.main.head).toBe("Before\n\n$$\nb^2\n$$\n".length); + }); + + it("removes the delimiters when a formula is left empty, so no stray $$ fence survives", () => { + const { host, view } = mount("A $x$ B"); + + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + act(() => typeIntoField(host, "")); + act(() => pressInField(host, "Escape")); + + expect(view.state.doc.toString()).toBe("A B"); + expect(view.state.selection.main.head).toBe(2); + }); + + it("Shift+Tab and move-out backward exit with the cursor before the region", () => { + const { host, view } = mount("A $x$ B"); + + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + act(() => pressInField(host, "Tab", { shiftKey: true })); + expect(view.state.selection.main.head).toBe(2); + + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + act(() => { + field(host)!.dispatchEvent( + new CustomEvent("move-out", { detail: { direction: "forward" }, bubbles: true, cancelable: true }), + ); + }); + expect(view.state.field(mathSessionField)).toBeNull(); + expect(view.state.selection.main.head).toBe(5); + }); + + it("clicking a rendered formula opens it", async () => { + const { host, view } = mount("# Title\n\nArea $x^2$ here"); + const rendered = host.querySelector(".cm-note-math")!; + expect(rendered).not.toBeNull(); + + await act(async () => { + rendered.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); + await Promise.resolve(); + }); + + expect(view.state.field(mathSessionField)).toMatchObject({ originalLatex: "x^2" }); + expect(field(host)?.value).toBe("x^2"); + }); + + it("the LaTeX toggle exposes a source textarea that writes back to the field and document", () => { + const { host, view } = mount("A $x$ B"); + + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + const toggle = host.querySelector(".cm-note-mathfield-toggle")!; + const source = host.querySelector(".cm-note-mathfield-source")!; + expect(source.hidden).toBe(true); + + act(() => toggle.click()); + expect(source.hidden).toBe(false); + expect(source.value).toBe("x"); + + act(() => { + source.value = "\\frac{1}{2}"; + source.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(field(host)?.value).toBe("\\frac{1}{2}"); + expect(view.state.doc.toString()).toBe("A $\\frac{1}{2}$ B"); + }); + + it("does not close the session on a focusout whose relatedTarget is null while focus is still inside", async () => { + // Firefox reports relatedTarget as null when MathLive moves focus into + // its shadow-DOM sink; that used to tear the field down mid-focus. + const { host, view } = mount("A $x$ B"); + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + + const toggle = host.querySelector(".cm-note-mathfield-toggle")!; + toggle.focus(); // activeElement is inside the wrapper, as the shadow host would be + expect(document.activeElement).toBe(toggle); + + await act(async () => { + field(host)!.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: null })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(view.state.field(mathSessionField)).not.toBeNull(); + expect(field(host)).not.toBeNull(); + }); + + it("closes the session once focus has genuinely moved outside the widget", async () => { + const { host, view } = mount("A $x$ B"); + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + act(() => typeIntoField(host, "y")); + + const outside = document.createElement("input"); + document.body.append(outside); + outside.focus(); + expect(document.activeElement).toBe(outside); + + await act(async () => { + field(host)!.dispatchEvent(new FocusEvent("focusout", { bubbles: true, relatedTarget: null })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(view.state.field(mathSessionField)).toBeNull(); + expect(view.state.doc.toString()).toBe("A $y$ B"); + outside.remove(); + }); + + it("never throws when the field is disposed before its deferred focus runs", async () => { + // Simulate MathLive's disposed state: focus() throws like its + // keyboardDelegate being undefined. + const { host, view } = mount("A $x$ B"); + act(() => view.dispatch({ effects: enterMath.of({ from: 2, to: 5 }) })); + const element = field(host)! as HTMLElement & { focus: () => void }; + element.focus = () => { + throw new TypeError("can't access property \"focus\", this.keyboardDelegate is undefined"); + }; + + expect(() => { + element.dispatchEvent(new Event("mount")); + host.querySelector(".cm-note-mathfield-toggle")!.click(); // shows source + host.querySelector(".cm-note-mathfield-toggle")!.click(); // hides it → focusField() + }).not.toThrow(); + }); +}); diff --git a/components/note-editor/extensions/math-field-widget.ts b/components/note-editor/extensions/math-field-widget.ts new file mode 100644 index 0000000..98eaed1 --- /dev/null +++ b/components/note-editor/extensions/math-field-widget.ts @@ -0,0 +1,370 @@ +import { type EditorState, StateEffect, StateField, Transaction } from "@codemirror/state"; +import { EditorView, WidgetType } from "@codemirror/view"; +import type { MathfieldElement } from "mathlive"; +import { normalizeLatex } from "@/lib/math/latex"; + +// --------------------------------------------------------------------------- +// Session: which math region is currently open in a +// --------------------------------------------------------------------------- + +export type MathSession = { + id: number; + /** Region including delimiters. */ + from: number; + to: number; + /** Document text between the delimiters (may include fence newlines). */ + contentFrom: number; + contentTo: number; + display: boolean; + /** Content when the session opened, verbatim (for one-step undo and fence layout). */ + originalLatex: string; +}; + +/** Open the region `[from, to)` (including its `$` / `$$` delimiters). */ +export const enterMath = StateEffect.define<{ from: number; to: number }>({ + map: (value, mapping) => ({ from: mapping.mapPos(value.from), to: mapping.mapPos(value.to) }), +}); +export const exitMath = StateEffect.define(); + +let nextSessionId = 1; + +function delimiterLength(text: string) { + return text.startsWith("$$") && text.endsWith("$$") && text.length >= 4 ? 2 : 1; +} + +function sessionFromRange(state: EditorState, from: number, to: number): MathSession | null { + const text = state.doc.sliceString(from, to); + const length = delimiterLength(text); + const delimiter = "$".repeat(length); + if (text.length < length * 2 || !text.startsWith(delimiter) || !text.endsWith(delimiter)) { + return null; + } + + return { + id: nextSessionId++, + from, + to, + contentFrom: from + length, + contentTo: to - length, + display: length === 2, + originalLatex: text.slice(length, -length), + }; +} + +export const mathSessionField = StateField.define({ + create: () => null, + update(session, transaction) { + let next = session; + + if (next && transaction.docChanged) { + // Grow the content range around insertions at either edge; drop the + // session if the delimiters themselves were touched. + const mapped: MathSession = { + ...next, + from: transaction.changes.mapPos(next.from, -1), + contentFrom: transaction.changes.mapPos(next.contentFrom, -1), + contentTo: transaction.changes.mapPos(next.contentTo, 1), + to: transaction.changes.mapPos(next.to, 1), + }; + const delimiter = "$".repeat(mapped.display ? 2 : 1); + const doc = transaction.state.doc; + const intact = + mapped.contentFrom <= mapped.contentTo && + doc.sliceString(mapped.from, mapped.contentFrom) === delimiter && + doc.sliceString(mapped.contentTo, mapped.to) === delimiter; + next = intact ? mapped : null; + } + + for (const effect of transaction.effects) { + if (effect.is(exitMath)) { + next = null; + } else if (effect.is(enterMath)) { + next = sessionFromRange(transaction.state, effect.value.from, effect.value.to); + } + } + + return next; + }, +}); + +// --------------------------------------------------------------------------- +// Widget +// --------------------------------------------------------------------------- + +let lastFocusedSessionId = 0; + +const STOP_PROPAGATION_EVENTS = ["beforeinput", "keyup", "pointerdown", "mousedown", "click"] as const; + +/** + * The generator writes display math as `$$\n…\n$$`. The fence newlines live + * outside the field: they are fixed when a session opens and re-applied on + * every write, so the document keeps its shape while the user edits. + */ +function fenceLayout(originalLatex: string) { + const lead = /^\s*\n/.test(originalLatex) ? "\n" : ""; + const trail = /\n\s*$/.test(originalLatex) ? "\n" : ""; + return (latex: string) => `${lead}${latex}${trail}`; +} + +/** + * Hosts a MathLive `` in place of a math region. MathLive owns + * the keyboard while focused (so Backspace deletes a fraction or root as a + * unit); every edit is written straight back into the document with + * `addToHistory: false`, and leaving the field turns the whole session into + * a single undo step. + */ +export class MathFieldWidget extends WidgetType { + constructor( + readonly session: MathSession, + readonly latex: string, + ) { + super(); + } + + eq(other: MathFieldWidget) { + return ( + other.session.id === this.session.id && + other.session.display === this.session.display && + other.latex === this.latex + ); + } + + /** Same session, new LaTeX (undo/redo while unfocused): sync the field without recreating it. */ + updateDOM(dom: HTMLElement) { + const latex = this.latex.trim(); + const field = dom.querySelector("math-field") as MathfieldElement | null; + if (field && field.value !== latex) { + field.setValue(latex, { silenceNotifications: true }); + } + const source = dom.querySelector(".cm-note-mathfield-source"); + if (source && source.value !== latex) { + source.value = latex; + } + return true; + } + + ignoreEvent() { + return true; // the field handles its own events; CodeMirror stays out + } + + toDOM(view: EditorView) { + const wrap = fenceLayout(this.session.originalLatex); + const initialLatex = this.latex.trim(); + + const wrapper = document.createElement(this.session.display ? "div" : "span"); + wrapper.className = this.session.display + ? "cm-note-mathfield cm-note-mathfield-display" + : "cm-note-mathfield"; + wrapper.contentEditable = "false"; + + const field = document.createElement("math-field") as MathfieldElement; + field.setAttribute("math-virtual-keyboard-policy", "manual"); + field.setAttribute("smart-mode", "on"); + field.setAttribute("placeholder", "\\frac{a}{b}"); + field.defaultMode = this.session.display ? "math" : "inline-math"; + field.value = initialLatex; + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "cm-note-mathfield-toggle"; + toggle.textContent = "LaTeX"; + toggle.title = "Edit the LaTeX directly"; + toggle.setAttribute("aria-pressed", "false"); + + const source = document.createElement("textarea"); + source.className = "cm-note-mathfield-source"; + source.rows = this.session.display ? 3 : 1; + source.spellcheck = false; + source.value = initialLatex; + source.hidden = true; + source.setAttribute("aria-label", "LaTeX source"); + + wrapper.append(field, toggle, source); + + /** + * MathLive nulls its keyboard delegate when the element is disconnected, + * and its focus() does not guard against that — so a focus call that + * races a teardown throws. Never let that reach the editor. + */ + const focusField = () => { + if (!field.isConnected) { + return; + } + try { + field.focus(); + } catch { + // disposed mid-flight; the session is already closing + } + }; + + const currentSession = () => { + const session = view.state.field(mathSessionField, false) ?? null; + return session && session.id === this.session.id ? session : null; + }; + + /** Write LaTeX into the document without touching history (the exit commits one step). */ + const writeToDocument = (latex: string) => { + const session = currentSession(); + if (!session) { + return; + } + const content = wrap(latex); + if (view.state.doc.sliceString(session.contentFrom, session.contentTo) === content) { + return; + } + view.dispatch({ + changes: { from: session.contentFrom, to: session.contentTo, insert: content }, + annotations: [Transaction.addToHistory.of(false), Transaction.userEvent.of("input.math")], + }); + }; + + const exit = (placeCursor: "after" | "before" | "keep") => { + const session = currentSession(); + if (!session) { + return; + } + + const { originalLatex, display } = session; + const delimiters = display ? 2 : 1; + const latex = normalizeLatex(field.value.trim()); + const nextChar = view.state.doc.sliceString(session.to, session.to + 1); + + // Empty formula: remove the delimiters too. A stray `$$` alone on a + // line would otherwise become a display-math fence that swallows the + // following paragraph on the server. + if (!latex) { + view.dispatch({ + changes: { from: session.from, to: session.to, insert: "" }, + effects: exitMath.of(null), + ...(placeCursor === "keep" ? {} : { selection: { anchor: session.from } }), + }); + view.focus(); + return; + } + + const finalContent = wrap(latex); + const afterRegion = session.from + delimiters + finalContent.length + delimiters; + const cursor = + placeCursor === "before" + ? session.from + : display && nextChar === "\n" + ? afterRegion + 1 + : afterRegion; + const selection = + placeCursor === "keep" ? {} : { selection: { anchor: cursor }, scrollIntoView: true }; + + // Two transactions, one history entry: silently restore the original + // content, then apply the final LaTeX with history on. Ctrl+Z then + // reverts the whole session at once. (A single multi-spec dispatch + // could not do this — it composes into one change with one history + // setting.) + const current = view.state.doc.sliceString(session.contentFrom, session.contentTo); + if (current !== originalLatex) { + view.dispatch({ + changes: { from: session.contentFrom, to: session.contentTo, insert: originalLatex }, + annotations: Transaction.addToHistory.of(false), + }); + } + + if (finalContent !== originalLatex) { + view.dispatch({ + changes: { + from: session.contentFrom, + to: session.contentFrom + originalLatex.length, + insert: finalContent, + }, + effects: exitMath.of(null), + annotations: Transaction.userEvent.of("input.math"), + ...selection, + }); + } else { + view.dispatch({ effects: exitMath.of(null), ...selection }); + } + view.focus(); + }; + + for (const type of STOP_PROPAGATION_EVENTS) { + wrapper.addEventListener(type, (event) => event.stopPropagation()); + } + + field.addEventListener("input", () => { + writeToDocument(field.value); + if (!source.hidden) { + source.value = field.value; + } + }); + + field.addEventListener("keydown", (event) => { + event.stopPropagation(); + const plain = !event.ctrlKey && !event.metaKey && !event.altKey; + if (event.key === "Escape" || (event.key === "Enter" && plain)) { + event.preventDefault(); + exit("after"); + } else if (event.key === "Tab") { + event.preventDefault(); + exit(event.shiftKey ? "before" : "after"); + } + }); + + field.addEventListener("move-out", (event) => { + const detail = (event as CustomEvent<{ direction: string }>).detail; + event.preventDefault(); + exit(detail?.direction === "backward" || detail?.direction === "upward" ? "before" : "after"); + }); + + toggle.addEventListener("mousedown", (event) => event.preventDefault()); + toggle.addEventListener("click", () => { + const show = source.hidden; + source.hidden = !show; + toggle.setAttribute("aria-pressed", show ? "true" : "false"); + if (show) { + source.value = field.value; + source.focus(); + } else { + focusField(); + } + }); + + source.addEventListener("input", () => { + field.setValue(source.value, { silenceNotifications: true }); + writeToDocument(source.value); + }); + source.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Escape") { + event.preventDefault(); + exit("after"); + } + }); + + // Clicking elsewhere in the note commits the formula but leaves the + // cursor wherever the click put it. `relatedTarget` is not usable here: + // when MathLive moves focus into its shadow-DOM keyboard sink, Firefox + // reports it as null across the shadow boundary, which looked like + // "focus left" and tore the field down mid-focus. `document.activeElement` + // reports the shadow host, so check that once focus has settled. + wrapper.addEventListener("focusout", () => { + setTimeout(() => { + if (!wrapper.isConnected) { + return; + } + const active = document.activeElement; + if (active && wrapper.contains(active)) { + return; // still inside: the field's host, the toggle, or the textarea + } + exit("keep"); + }, 0); + }); + + if (lastFocusedSessionId !== this.session.id) { + lastFocusedSessionId = this.session.id; + // MathLive creates its internals in connectedCallback and announces + // them with "mount"; focusing before that is a no-op, after a teardown + // it throws. The frame fallback covers a build without the event. + field.addEventListener("mount", focusField, { once: true }); + requestAnimationFrame(focusField); + } + + return wrapper; + } +} diff --git a/components/note-editor/extensions/math-widgets.ts b/components/note-editor/extensions/math-widgets.ts new file mode 100644 index 0000000..fd00fe2 --- /dev/null +++ b/components/note-editor/extensions/math-widgets.ts @@ -0,0 +1,206 @@ +import { type EditorState, Prec, StateField } from "@codemirror/state"; +import { Decoration, type DecorationSet, EditorView, keymap, WidgetType } from "@codemirror/view"; +import katex from "katex"; +import { findMathRanges, type MathRange } from "@/lib/notes/math-ranges"; +import { + enterMath, + exitMath, + MathFieldWidget, + mathSessionField, +} from "@/components/note-editor/extensions/math-field-widget"; +import { loadMathLive } from "@/components/note-editor/extensions/mathlive-loader"; + +// --------------------------------------------------------------------------- +// Opening a region for structural editing +// --------------------------------------------------------------------------- + +/** The math range containing or touching `pos`, if any. */ +export function mathRangeAt(state: EditorState, pos: number): MathRange | null { + return findMathRanges(state.doc.toString()).find((range) => range.from <= pos && pos <= range.to) ?? null; +} + +/** + * Swap a region's KaTeX rendering for a MathLive field. MathLive is fetched + * on first use, so the first open in a session can take a moment. + */ +export function openMathRegion(view: EditorView, range: { from: number; to: number }) { + void loadMathLive() + .then(() => { + if (view.dom.isConnected) { + view.dispatch({ effects: enterMath.of({ from: range.from, to: range.to }) }); + } + }) + .catch(() => { + // The region simply stays as source text; nothing to recover. + }); +} + +// --------------------------------------------------------------------------- +// KaTeX rendering (regions not being edited) +// --------------------------------------------------------------------------- + +const katexCache = new Map(); + +function renderKatex(latex: string, display: boolean) { + const key = `${display ? "D" : "I"}${latex}`; + let html = katexCache.get(key); + if (html === undefined) { + html = katex.renderToString(latex, { + displayMode: display, + throwOnError: false, + output: "htmlAndMathml", + }); + katexCache.set(key, html); + } + return html; +} + +class KatexWidget extends WidgetType { + constructor( + readonly latex: string, + readonly display: boolean, + ) { + super(); + } + + eq(other: KatexWidget) { + return other.latex === this.latex && other.display === this.display; + } + + toDOM(view: EditorView) { + const element = document.createElement(this.display ? "div" : "span"); + element.className = this.display ? "cm-note-math cm-note-math-display" : "cm-note-math"; + element.setAttribute("role", "button"); + element.setAttribute("title", "Edit formula"); + element.innerHTML = renderKatex(this.latex, this.display); + element.addEventListener("mousedown", (event) => { + event.preventDefault(); + // Positions are read from the DOM at click time, so edits elsewhere in + // the note never leave this widget holding stale offsets. + const range = mathRangeAt(view.state, view.posAtDOM(element)); + if (range) { + openMathRegion(view, range); + } + }); + return element; + } + + ignoreEvent(event: Event) { + return event.type === "mousedown"; + } +} + +// --------------------------------------------------------------------------- +// Decorations +// --------------------------------------------------------------------------- + +/** A region the selection touches (inclusive) shows its raw `$…$` source. */ +function touchesSelection(state: EditorState, from: number, to: number) { + return state.selection.ranges.some((range) => range.from <= to && range.to >= from); +} + +/** Block widgets must cover whole lines; the generator's `$$` fence form does. */ +function isBlockShaped(state: EditorState, from: number, to: number) { + const doc = state.doc; + const start = doc.lineAt(from); + const end = doc.lineAt(to); + return start.number !== end.number && start.from === from && end.to === to; +} + +function buildMathDecorations(state: EditorState): DecorationSet { + const doc = state.doc; + const session = state.field(mathSessionField, false) ?? null; + const decorations = []; + + for (const range of findMathRanges(doc.toString())) { + if (session && range.from < session.to && range.to > session.from) { + continue; // rendered as the open field below + } + if (touchesSelection(state, range.from, range.to)) { + continue; + } + decorations.push( + Decoration.replace({ + widget: new KatexWidget(range.latex, range.display), + block: range.display && isBlockShaped(state, range.from, range.to), + }).range(range.from, range.to), + ); + } + + if (session) { + decorations.push( + Decoration.replace({ + widget: new MathFieldWidget(session, doc.sliceString(session.contentFrom, session.contentTo)), + block: session.display && isBlockShaped(state, session.from, session.to), + }).range(session.from, session.to), + ); + } + + return Decoration.set(decorations, true); +} + +/** + * Math rendering lives in a StateField (not a ViewPlugin) because display + * math spanning lines needs block-level replace decorations, which only + * fields may provide. The same set feeds `atomicRanges` so the cursor steps + * over a rendered formula as one unit. + */ +const mathDecorationsField = StateField.define({ + create: buildMathDecorations, + update(decorations, transaction) { + const sessionChanged = transaction.effects.some( + (effect) => effect.is(enterMath) || effect.is(exitMath), + ); + return transaction.docChanged || transaction.selection || sessionChanged + ? buildMathDecorations(transaction.state) + : decorations; + }, + provide: (field) => [ + EditorView.decorations.from(field), + EditorView.atomicRanges.of((view) => view.state.field(field)), + ], +}); + +const mathKeymap = Prec.high( + keymap.of([ + { + key: "Mod-e", + run(view) { + const range = mathRangeAt(view.state, view.state.selection.main.head); + if (!range) { + return false; + } + openMathRegion(view, range); + return true; + }, + }, + ]), +); + +const mathTheme = EditorView.baseTheme({ + ".cm-note-math": { + display: "inline-block", + verticalAlign: "baseline", + cursor: "pointer", + borderRadius: "3px", + }, + ".cm-note-math:hover": { + backgroundColor: "color-mix(in srgb, var(--accent) 10%, transparent)", + }, + ".cm-note-math-display": { + display: "block", + padding: "0.35em 0", + textAlign: "center", + }, + ".cm-note-math .katex": { + fontSize: "1.05em", + }, + ".cm-note-math .katex-display": { + margin: "0", + }, +}); + +/** Field order matters: the decorations field reads the session field. */ +export function mathWidgets() { + return [mathSessionField, mathDecorationsField, mathKeymap, mathTheme]; +} diff --git a/components/note-editor/extensions/mathlive-loader.ts b/components/note-editor/extensions/mathlive-loader.ts new file mode 100644 index 0000000..aa04cc4 --- /dev/null +++ b/components/note-editor/extensions/mathlive-loader.ts @@ -0,0 +1,35 @@ +/** + * Loads MathLive on demand and resolves once `` is a defined + * custom element. Memoized so the ~200 KB engine is fetched once, and only + * when the user first edits a formula — never on editor mount. + * + * The previous integration relied on import order alone and never waited on + * `customElements.whenDefined`, which meant an un-upgraded element could be + * handed a `.value` that silently became a plain expando property. + */ + +let loading: Promise | null = null; + +export function loadMathLive(): Promise { + if (typeof window === "undefined") { + return Promise.reject(new Error("MathLive is browser-only.")); + } + + if (!loading) { + loading = import("mathlive") + .then(async ({ MathfieldElement }) => { + // Fonts come from `mathlive/fonts.css` (imported in app/layout.tsx), + // and the click/plonk sounds are not wanted in a note editor. + MathfieldElement.fontsDirectory = null; + MathfieldElement.soundsDirectory = null; + MathfieldElement.plonkSound = null; + await customElements.whenDefined("math-field"); + }) + .catch((error: unknown) => { + loading = null; // allow a retry after a failed network fetch + throw error; + }); + } + + return loading; +} diff --git a/components/note-editor/extensions/slash-menu.test.ts b/components/note-editor/extensions/slash-menu.test.ts new file mode 100644 index 0000000..990ed30 --- /dev/null +++ b/components/note-editor/extensions/slash-menu.test.ts @@ -0,0 +1,82 @@ +import { CompletionContext } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/note-editor/extensions/mathlive-loader", () => ({ + loadMathLive: () => Promise.resolve(), +})); + +import { slashCommands, slashSource } from "@/components/note-editor/extensions/slash-menu"; + +function contextAt(doc: string, pos = doc.length, explicit = false) { + return new CompletionContext(EditorState.create({ doc }), pos, explicit); +} + +function run(doc: string, label: string, pos = doc.length) { + const view = new EditorView({ state: EditorState.create({ doc }) }); + const result = slashSource(new CompletionContext(view.state, pos, false)); + if (!result) { + throw new Error("slash menu did not open"); + } + const command = slashCommands.find((option) => option.label === label)!; + const apply = command.apply as (view: EditorView, c: typeof command, from: number, to: number) => void; + apply(view, command, result.from, pos); + const out = { doc: view.state.doc.toString(), cursor: view.state.selection.main.head }; + view.destroy(); + return out; +} + +describe("slashSource", () => { + it("opens on a slash at line start or after whitespace, filtered by the typed query", () => { + expect(slashSource(contextAt("/"))?.from).toBe(0); + expect(slashSource(contextAt("hello /tab"))?.from).toBe(6); + expect(slashSource(contextAt("some text\n/hea"))?.from).toBe(10); + }); + + it("stays closed mid-word, inside code, and inside math", () => { + expect(slashSource(contextAt("a/b"))).toBeNull(); + expect(slashSource(contextAt("```\n/\n```", 5))).toBeNull(); + expect(slashSource(contextAt("$x /y$", 4))).toBeNull(); + }); + + it("offers every block type from the requirements", () => { + const labels = slashCommands.map((command) => command.label); + for (const expected of [ + "Text", "Heading 1", "Heading 2", "Heading 3", "Bulleted list", "Numbered list", + "Checklist", "Quote", "Code block", "Table", "Image", "Divider", "Math block", "Inline math", + ]) { + expect(labels).toContain(expected); + } + }); +}); + +describe("slash commands", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("turns the current line into a heading, replacing any existing marker", () => { + expect(run("- item /h", "Heading 2")).toEqual({ doc: "## item ", cursor: 3 }); + expect(run("### old /", "Text")).toEqual({ doc: "old ", cursor: 0 }); + expect(run(" /", "Checklist")).toEqual({ doc: " - [ ] ", cursor: 8 }); + }); + + it("inserts block snippets in place of a bare slash line and lands the cursor inside", () => { + expect(run("/", "Code block")).toEqual({ doc: "```\n\n```", cursor: 4 }); + expect(run("/", "Divider")).toEqual({ doc: "---", cursor: 3 }); + expect(run("/", "Table")).toEqual({ + doc: "| Column | Column |\n| --- | --- |\n| | |", + cursor: 2, + }); + }); + + it("breaks out to a new line when the slash follows other text", () => { + expect(run("intro /", "Math block")).toEqual({ doc: "intro \n$$\n\n$$", cursor: 10 }); + }); + + it("inserts inline math and an image template at the cursor", () => { + expect(run("say /", "Inline math")).toEqual({ doc: "say $$", cursor: 5 }); + expect(run("see /", "Image")).toEqual({ doc: "see ![alt](https://)", cursor: 19 }); + }); +}); diff --git a/components/note-editor/extensions/slash-menu.ts b/components/note-editor/extensions/slash-menu.ts new file mode 100644 index 0000000..0d7a449 --- /dev/null +++ b/components/note-editor/extensions/slash-menu.ts @@ -0,0 +1,175 @@ +import { + autocompletion, + type Completion, + type CompletionContext, + type CompletionResult, +} from "@codemirror/autocomplete"; +import { type EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { findCodeRanges, findMathRanges } from "@/lib/notes/math-ranges"; +import { openMathRegion } from "@/components/note-editor/extensions/math-widgets"; + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** Markers a line-transform command replaces: heading, list, task, quote. */ +const LINE_MARKER_RE = /^(\s*)(?:#{1,6}\s+|[-*+]\s+(?:\[[ xX]\]\s+)?|\d+\.\s+|>\s+)?/; + +/** + * Rewrite the current line's block marker. `from`/`to` are the `/query` + * being replaced; everything else on the line is kept. + */ +function transformLine(prefix: string) { + return (view: EditorView, _completion: Completion, from: number, to: number) => { + const line = view.state.doc.lineAt(from); + const before = line.text.slice(0, from - line.from); + const after = line.text.slice(to - line.from); + const match = before.match(LINE_MARKER_RE); + const indent = match?.[1] ?? ""; + const rest = before.slice(match?.[0].length ?? 0) + after; + const text = `${indent}${prefix}${rest}`; + + view.dispatch({ + changes: { from: line.from, to: line.to, insert: text }, + selection: { anchor: line.from + indent.length + prefix.length }, + scrollIntoView: true, + }); + }; +} + +/** + * Insert a block snippet on its own lines. `cursorOffset` is where the cursor + * lands inside the inserted text. + */ +function insertBlock(snippet: string, cursorOffset: number, onInsert?: (view: EditorView, from: number, to: number) => void) { + return (view: EditorView, _completion: Completion, from: number, to: number) => { + const doc = view.state.doc; + const line = doc.lineAt(from); + const lineIsBare = line.text.slice(0, from - line.from).trim() === "" && line.text.slice(to - line.from).trim() === ""; + + // Replace a line that only held the slash query; otherwise break out to a new line. + const start = lineIsBare ? line.from : from; + const end = lineIsBare ? line.to : to; + const lead = lineIsBare || start === line.from ? "" : "\n"; + const insert = `${lead}${snippet}`; + + view.dispatch({ + changes: { from: start, to: end, insert }, + selection: { anchor: start + lead.length + cursorOffset }, + scrollIntoView: true, + }); + onInsert?.(view, start + lead.length, start + lead.length + snippet.length); + }; +} + +function insertInline(snippet: string, cursorOffset: number, onInsert?: (view: EditorView, from: number, to: number) => void) { + return (view: EditorView, _completion: Completion, from: number, to: number) => { + view.dispatch({ + changes: { from, to, insert: snippet }, + selection: { anchor: from + cursorOffset }, + }); + onInsert?.(view, from, from + snippet.length); + }; +} + +const openMath = (view: EditorView, from: number, to: number) => openMathRegion(view, { from, to }); + +export const slashCommands: readonly Completion[] = [ + { label: "Text", detail: "Plain paragraph", apply: transformLine("") }, + { label: "Heading 1", detail: "Large section heading", apply: transformLine("# ") }, + { label: "Heading 2", detail: "Section heading", apply: transformLine("## ") }, + { label: "Heading 3", detail: "Subsection heading", apply: transformLine("### ") }, + { label: "Bulleted list", detail: "Unordered list", apply: transformLine("- ") }, + { label: "Numbered list", detail: "Ordered list", apply: transformLine("1. ") }, + { label: "Checklist", detail: "Task list", apply: transformLine("- [ ] ") }, + { label: "Quote", detail: "Block quote", apply: transformLine("> ") }, + { label: "Code block", detail: "Fenced code", apply: insertBlock("```\n\n```", 4) }, + { + label: "Table", + detail: "2×2 table", + apply: insertBlock("| Column | Column |\n| --- | --- |\n| | |", 2), + }, + { label: "Image", detail: "Image from a URL", apply: insertInline("![alt](https://)", 15) }, + { label: "Divider", detail: "Horizontal rule", apply: insertBlock("---", 3) }, + { label: "Math block", detail: "Display equation", apply: insertBlock("$$\n\n$$", 3, openMath) }, + { label: "Inline math", detail: "Formula in the text", apply: insertInline("$$", 1, openMath) }, +]; + +// --------------------------------------------------------------------------- +// Source +// --------------------------------------------------------------------------- + +const SLASH_QUERY_RE = /\/[\w-]*$/; + +function insideCodeOrMath(state: EditorState, pos: number) { + const text = state.doc.toString(); + return ( + findCodeRanges(text).some((range) => pos > range.from && pos < range.to) || + findMathRanges(text).some((range) => pos > range.from && pos < range.to) + ); +} + +/** `/` at the start of a line or after whitespace opens the menu. */ +export function slashSource(context: CompletionContext): CompletionResult | null { + const match = context.matchBefore(SLASH_QUERY_RE); + if (!match) { + return null; + } + + const line = context.state.doc.lineAt(match.from); + const before = line.text.slice(0, match.from - line.from); + if ((before !== "" && !/\s$/.test(before)) || insideCodeOrMath(context.state, match.from)) { + return null; + } + + return { + from: match.from, + options: slashCommands, + validFor: SLASH_QUERY_RE, + }; +} + +const slashTheme = EditorView.baseTheme({ + ".cm-tooltip.cm-tooltip-autocomplete": { + border: "1px solid var(--border)", + borderRadius: "var(--radius-lg)", + backgroundColor: "var(--surface-elevated)", + boxShadow: "var(--shadow-card)", + overflow: "hidden", + }, + ".cm-tooltip-autocomplete > ul": { + fontFamily: "var(--font-sans), ui-sans-serif, system-ui, sans-serif", + maxHeight: "18em", + }, + ".cm-tooltip-autocomplete > ul > li": { + padding: "0.35em 0.75em", + color: "var(--foreground)", + }, + ".cm-tooltip-autocomplete > ul > li[aria-selected]": { + backgroundColor: "var(--accent-soft)", + color: "var(--foreground)", + }, + ".cm-completionDetail": { + marginLeft: "0.75em", + fontStyle: "normal", + color: "var(--muted-foreground)", + }, + ".cm-completionMatchedText": { + textDecoration: "none", + fontWeight: "600", + }, +}); + +/** Notion/Obsidian-style `/` command menu for inserting any block type. */ +export function slashMenu() { + return [ + autocompletion({ + override: [slashSource], + activateOnTyping: true, + icons: false, + closeOnBlur: true, + }), + slashTheme, + ]; +} diff --git a/components/note-editor/extensions/theme.ts b/components/note-editor/extensions/theme.ts new file mode 100644 index 0000000..b52898b --- /dev/null +++ b/components/note-editor/extensions/theme.ts @@ -0,0 +1,80 @@ +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { tags } from "@lezer/highlight"; + +/** + * Chrome for the editor surface. Everything maps to the app's semantic CSS + * variables so light/dark follow `.dark` on with no extra work here. + */ +export const editorTheme = EditorView.theme({ + "&": { + backgroundColor: "transparent", + color: "var(--foreground)", + fontSize: "1rem", + }, + ".cm-scroller": { + fontFamily: "var(--font-sans), ui-sans-serif, system-ui, sans-serif", + lineHeight: "1.75", + overflow: "visible", + }, + ".cm-content": { + padding: "0", + caretColor: "var(--accent)", + }, + ".cm-line": { + padding: "0", + }, + "&.cm-focused": { + outline: "none", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "var(--accent)", + borderLeftWidth: "2px", + }, + "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "color-mix(in srgb, var(--accent) 18%, transparent)", + }, + ".cm-activeLine": { + backgroundColor: "transparent", + }, + ".cm-gutters": { + display: "none", + }, + ".cm-placeholder": { + color: "var(--muted-foreground)", + fontStyle: "normal", + }, +}); + +/** + * Typography for Markdown tokens. lang-markdown tags syntax markers + * (`#`, `*`, `>`, `-`, backticks) as `processingInstruction`; live preview + * hides them off the active line, and this keeps them quiet when visible. + */ +export const markdownHighlight = syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.heading1, fontSize: "1.9em", fontWeight: "700", lineHeight: "1.25" }, + { tag: tags.heading2, fontSize: "1.5em", fontWeight: "700", lineHeight: "1.3" }, + { tag: tags.heading3, fontSize: "1.25em", fontWeight: "600", lineHeight: "1.4" }, + { tag: tags.heading4, fontSize: "1.1em", fontWeight: "600" }, + { tag: [tags.heading5, tags.heading6], fontWeight: "600" }, + { tag: tags.strong, fontWeight: "700" }, + { tag: tags.emphasis, fontStyle: "italic" }, + { + tag: tags.strikethrough, + textDecoration: "line-through", + color: "var(--muted-foreground)", + }, + { + tag: tags.monospace, + fontFamily: "var(--font-mono), ui-monospace, SFMono-Regular, monospace", + fontSize: "0.92em", + }, + { tag: tags.link, color: "var(--accent)", textDecoration: "underline" }, + { tag: tags.url, color: "var(--muted-foreground)" }, + { tag: tags.quote, color: "var(--muted-foreground)" }, + { tag: tags.processingInstruction, color: "var(--muted-foreground)" }, + { tag: tags.contentSeparator, color: "var(--border-strong)" }, + { tag: tags.list, color: "var(--muted-foreground)" }, + ]), +); diff --git a/components/note-editor/image-upload.ts b/components/note-editor/image-upload.ts deleted file mode 100644 index d2a45b3..0000000 --- a/components/note-editor/image-upload.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { NoteImageFileData } from "@/lib/notes/types"; - -const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024; - -export async function uploadImageToDataUrl(file: File): Promise { - if (!file.type.startsWith("image/")) { - throw new Error("Only image uploads are supported."); - } - - if (file.size > MAX_INLINE_IMAGE_BYTES) { - throw new Error( - "Images larger than 2 MB are not supported by the temporary inline uploader.", - ); - } - - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - if (typeof reader.result === "string") { - resolve(reader.result); - return; - } - - reject(new Error("Could not convert image to a data URL.")); - }; - - reader.onerror = () => { - reject(reader.error ?? new Error("Could not read the image file.")); - }; - - reader.readAsDataURL(file); - }); - - return { - url: dataUrl, - name: file.name, - size: file.size, - type: file.type, - }; -} diff --git a/components/note-editor/markdown-components.tsx b/components/note-editor/markdown-components.tsx deleted file mode 100644 index 0defc5c..0000000 --- a/components/note-editor/markdown-components.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { isValidElement, type ReactNode } from "react"; -import type { Components } from "react-markdown"; -import { CodeBlockView } from "@/components/note-editor/blocks/code-block-view"; -import { MermaidBlockView } from "@/components/note-editor/blocks/mermaid-block-view"; - -function omitNode(props: T): Omit { - const { node, ...rest } = props; - void node; - return rest; -} - -function getTextContent(value: ReactNode): string { - if (typeof value === "string" || typeof value === "number") { - return String(value); - } - - if (Array.isArray(value)) { - return value.map(getTextContent).join(""); - } - - if (isValidElement<{ children?: ReactNode }>(value)) { - return getTextContent(value.props.children); - } - - if (value && typeof value === "object") { - const candidate = value as { - children?: unknown; - props?: { children?: ReactNode }; - value?: unknown; - }; - - if (candidate.props?.children) { - return getTextContent(candidate.props.children); - } - - if (typeof candidate.value === "string" || typeof candidate.value === "number") { - return String(candidate.value); - } - - if (candidate.children) { - return getNodeText(candidate.children); - } - } - - return ""; -} - -function getNodeText(value: unknown): string { - if (typeof value === "string" || typeof value === "number") { - return String(value); - } - - if (Array.isArray(value)) { - return value.map(getNodeText).join(""); - } - - if (value && typeof value === "object") { - const candidate = value as { value?: unknown; children?: unknown }; - if (typeof candidate.value === "string" || typeof candidate.value === "number") { - return String(candidate.value); - } - - return getNodeText(candidate.children); - } - - return ""; -} - -function extractCode(children: ReactNode) { - return getTextContent(children).replace(/\n$/, ""); -} - -function cleanMarkdownText(value: string) { - return value === "[object Object]" ? "" : value; -} - -function getCodeLanguage(value: unknown): string | undefined { - if (isValidElement<{ className?: string }>(value)) { - const match = value.props.className?.match(/language-([^\s]+)/); - return match?.[1]?.toLowerCase(); - } - - if (Array.isArray(value)) { - return value.map(getCodeLanguage).find(Boolean); - } - - if (value && typeof value === "object") { - const candidate = value as { - properties?: { className?: string | string[] }; - props?: { className?: string }; - children?: unknown; - }; - const className = Array.isArray(candidate.properties?.className) - ? candidate.properties.className.join(" ") - : candidate.properties?.className ?? candidate.props?.className; - const match = className?.match(/language-([^\s]+)/); - return match?.[1]?.toLowerCase() ?? getCodeLanguage(candidate.children); - } - - return undefined; -} - -export const markdownComponents: Components = { - img: (props) => { - const { alt, src } = omitNode(props); - if (typeof src !== "string") { - return null; - } - - return ( - // eslint-disable-next-line @next/next/no-img-element - {alt - ); - }, - pre: (props) => { - const { children, node } = props; - const code = - cleanMarkdownText(extractCode(children)) || - cleanMarkdownText(getTextContent(node as ReactNode)); - const language = getCodeLanguage(children) ?? getCodeLanguage(node); - if (language === "mermaid" || language === "mmd") { - return ; - } - - return ; - }, - code: (props) => { - const { children, className, node, ...rest } = props; - const codeText = - cleanMarkdownText(getTextContent(children)) || - cleanMarkdownText(getTextContent(node as ReactNode)); - if (className) { - return ( - - {codeText} - - ); - } - - return ( - - {codeText} - - ); - }, -}; diff --git a/components/note-editor/markdown-editor.test.tsx b/components/note-editor/markdown-editor.test.tsx new file mode 100644 index 0000000..832ea3e --- /dev/null +++ b/components/note-editor/markdown-editor.test.tsx @@ -0,0 +1,102 @@ +import { act, cleanup, render } from "@testing-library/react"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import MarkdownEditor from "@/components/note-editor/markdown-editor"; + +const sample = [ + "# Primes", + "", + "The area is $A = \\pi r^2$ and **bold** text.", + "", + "- [x] done", + "- todo", + "", + "$$", + "\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}", + "$$", + "", + "==important== and `$not math$`", +].join("\n"); + +function mount(props: Partial> = {}) { + const onChange = vi.fn(); + const utils = render(); + const host = utils.getByTestId("markdown-editor"); + const view = EditorView.findFromDOM(host); + if (!view) { + throw new Error("EditorView did not mount"); + } + return { ...utils, host, view, onChange }; +} + +describe("MarkdownEditor", () => { + afterEach(() => { + cleanup(); + }); + + it("mounts a real CodeMirror view holding the markdown verbatim", () => { + const { view } = mount(); + + expect(view.state.doc.toString()).toBe(sample); + }); + + it("renders inactive math as KaTeX and hides syntax off the active line", () => { + const { host, view } = mount(); + + // Cursor starts on line 1 (the heading), so everything else is inactive. + expect(view.state.selection.main.head).toBe(0); + expect(host.querySelectorAll(".cm-note-math").length).toBe(2); // inline + display + expect(host.querySelector(".cm-note-math .katex")).not.toBeNull(); + expect(host.querySelectorAll(".cm-note-bullet").length).toBe(1); // "- todo" (the task line gets a checkbox) + expect(host.querySelectorAll(".cm-note-checkbox").length).toBe(1); + expect(host.querySelector(".cm-note-highlight")).not.toBeNull(); + // The `$not math$` inside inline code must not become a widget: 2 widgets total, counted above. + }); + + it("reveals a math region's source when the selection touches it", () => { + const { host, view } = mount(); + const inlineStart = sample.indexOf("$A = "); + + act(() => { + view.dispatch({ selection: { anchor: inlineStart + 1 } }); + }); + + // Only the display formula stays rendered now. + expect(host.querySelectorAll(".cm-note-math").length).toBe(1); + expect(host.querySelector(".cm-note-math-display")).not.toBeNull(); + }); + + it("reports edits through onChange with the full document", () => { + const { view, onChange } = mount(); + + act(() => { + view.dispatch({ changes: { from: sample.length, insert: "\n\nNew line" } }); + }); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenLastCalledWith(`${sample}\n\nNew line`); + }); + + it("applies an external value only when it differs from the document", () => { + const onChange = vi.fn(); + const { rerender, getByTestId } = render(); + const view = EditorView.findFromDOM(getByTestId("markdown-editor"))!; + + rerender(); + expect(view.state.doc.toString()).toBe(sample); + expect(onChange).not.toHaveBeenCalled(); // an identical value never round-trips through onChange + + rerender(); + expect(view.state.doc.toString()).toBe("Switched note"); + }); + + it("source mode shows raw markdown with no widgets", () => { + const { host, rerender, onChange } = mount(); + expect(host.querySelectorAll(".cm-note-math").length).toBe(2); + + rerender(); + + expect(host.querySelectorAll(".cm-note-math").length).toBe(0); + expect(host.querySelectorAll(".cm-note-bullet").length).toBe(0); + }); +}); diff --git a/components/note-editor/markdown-editor.tsx b/components/note-editor/markdown-editor.tsx new file mode 100644 index 0000000..bd5c085 --- /dev/null +++ b/components/note-editor/markdown-editor.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { languages } from "@codemirror/language-data"; +import { Compartment, EditorState } from "@codemirror/state"; +import { + EditorView, + drawSelection, + dropCursor, + keymap, + placeholder as placeholderExtension, +} from "@codemirror/view"; +import { imageDrop, type ImageUploader } from "@/components/note-editor/extensions/image-drop"; +import { livePreview } from "@/components/note-editor/extensions/live-preview"; +import { mathWidgets } from "@/components/note-editor/extensions/math-widgets"; +import { slashMenu } from "@/components/note-editor/extensions/slash-menu"; +import { editorTheme, markdownHighlight } from "@/components/note-editor/extensions/theme"; + +function previewExtensions(sourceMode: boolean) { + return sourceMode ? [] : [livePreview(), mathWidgets()]; +} + +export type MarkdownEditorProps = { + /** + * The markdown to show. Applied to the document only when it differs from + * what the editor already holds, so echoing `onChange` back is a no-op and + * only a genuine external change (switching notes) replaces the text. + */ + value: string; + onChange: (markdown: string) => void; + readOnly?: boolean; + /** Show raw Markdown everywhere instead of live preview. */ + sourceMode?: boolean; + /** Handles dropped/pasted image files; defaults to an inline data URL. */ + uploadImage?: ImageUploader; + placeholder?: string; + autoFocus?: boolean; + className?: string; +}; + +/** + * CodeMirror 6 host. Deliberately DOM-only and free of app state so it can be + * loaded with `next/dynamic` + `ssr: false` by `NoteEditor`. + */ +export default function MarkdownEditor({ + value, + onChange, + readOnly = false, + sourceMode = false, + uploadImage, + placeholder = "Start writing…", + autoFocus = false, + className, +}: MarkdownEditorProps) { + const hostRef = useRef(null); + const viewRef = useRef(null); + const onChangeRef = useRef(onChange); + const readOnlyCompartment = useRef(new Compartment()).current; + const previewCompartment = useRef(new Compartment()).current; + const uploadCompartment = useRef(new Compartment()).current; + onChangeRef.current = onChange; + + useEffect(() => { + const host = hostRef.current; + if (!host) { + return; + } + + const view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: value, + extensions: [ + history(), + drawSelection(), + dropCursor(), + EditorView.lineWrapping, + markdown({ base: markdownLanguage, codeLanguages: languages }), + markdownHighlight, + editorTheme, + placeholderExtension(placeholder), + slashMenu(), + uploadCompartment.of(imageDrop(uploadImage)), + previewCompartment.of(previewExtensions(sourceMode)), + readOnlyCompartment.of(EditorState.readOnly.of(readOnly)), + keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + EditorView.updateListener.of((update) => { + if (update.docChanged) { + onChangeRef.current(update.state.doc.toString()); + } + }), + ], + }), + }); + + viewRef.current = view; + if (autoFocus) { + view.focus(); + } + + return () => { + view.destroy(); + viewRef.current = null; + }; + // The view is created once; later prop changes are applied by the effects + // below via transactions rather than by rebuilding the editor. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const view = viewRef.current; + if (!view) { + return; + } + + const current = view.state.doc.toString(); + if (current !== value) { + view.dispatch({ + changes: { from: 0, to: current.length, insert: value }, + selection: { anchor: 0 }, + }); + } + }, [value]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(readOnly)), + }); + }, [readOnly, readOnlyCompartment]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: previewCompartment.reconfigure(previewExtensions(sourceMode)), + }); + }, [sourceMode, previewCompartment]); + + useEffect(() => { + viewRef.current?.dispatch({ + effects: uploadCompartment.reconfigure(imageDrop(uploadImage)), + }); + }, [uploadImage, uploadCompartment]); + + return
; +} diff --git a/components/note-editor/note-editor.tsx b/components/note-editor/note-editor.tsx index aa1d316..dddefa6 100644 --- a/components/note-editor/note-editor.tsx +++ b/components/note-editor/note-editor.tsx @@ -1,1785 +1,155 @@ "use client"; -import { - useCallback, - useEffect, - useEffectEvent, - useRef, - useState, - type ReactNode, -} from "react"; -import type EditorJS from "@editorjs/editorjs"; -import type { - NoteBlock, - NoteContent, - NoteDocument, - NoteImageFileData, -} from "@/lib/notes/types"; -import { emptyNoteDocument, NoteDocumentSchema } from "@/lib/notes/types"; -import { createNoteContent } from "@/lib/notes/markdown"; -import { - convertBlock, - createEmptyBlockForTarget, - getBlockText, - getSlashCommandMatches, - stripHtml, - type BlockConversionTarget, - type SlashCommand, -} from "@/components/note-editor/block-transforms"; -import { areDocumentsEqual } from "@/components/note-editor/document-utils"; -import { - BlockContextMenu, - SlashCommandMenu, - type BlockContextMenuState, - type SlashCommandMenuState, -} from "@/components/note-editor/editor-menus"; -import { uploadImageToDataUrl } from "@/components/note-editor/image-upload"; -import { - readBlocksFromClipboard, - writeBlocksToClipboard, -} from "@/components/note-editor/block-clipboard"; -import { loadEditorJsClassAndTools } from "@/components/note-editor/editorjs-tools"; -import { isPointInHorizontalEdgeGutter } from "@/components/note-editor/selection-geometry"; +import dynamic from "next/dynamic"; +import { useEffect, useRef, useState } from "react"; +import { AlertCircle, Check, Code2, Eye, Loader2 } from "lucide-react"; +import type { ImageUploader } from "@/components/note-editor/extensions/image-drop"; +import { useAutosave, type AutosaveStatus } from "@/components/note-editor/use-autosave"; +import { isTempNoteId } from "@/lib/notes/records"; +import { cx } from "@/lib/utils"; + +// CodeMirror is DOM-only; keep it out of the server bundle and initial paint. +const MarkdownEditor = dynamic(() => import("@/components/note-editor/markdown-editor"), { + ssr: false, + loading: () => , +}); export type NoteEditorProps = { - initialDocument: NoteDocument; - onContentChange?: (content: NoteContent) => void; - onSave?: (content: NoteContent) => Promise; - uploadImage?: (file: File) => Promise; + noteId: string; + initialMarkdown: string; + onSave: (noteId: string, markdown: string) => Promise; + /** False while the note is still being created (temporary client id). */ + saveEnabled?: boolean; readOnly?: boolean; - selectionPrelude?: ReactNode; + /** Where dropped/pasted images go. Defaults to an inline data URL (see #86). */ + uploadImage?: ImageUploader; + className?: string; }; -export function NoteEditor({ - initialDocument, - onContentChange, - onSave, - uploadImage, - readOnly = false, - selectionPrelude, -}: NoteEditorProps) { - const selectionScopeRef = useRef(null); - const holderRef = useRef(null); - const editorRef = useRef(null); - const changeTimeoutRef = useRef(null); - const renderedDocumentRef = useRef(initialDocument); - const pendingSaveRef = useRef(null); - const isFlushingSaveRef = useRef(false); - const dragCleanupRef = useRef<(() => void) | null>(null); - const selectedBlockIndexesRef = useRef([]); - const clearBlockSelectionRef = useRef<() => void>(() => undefined); - const pendingBlockFocusTimersRef = useRef([]); - const shiftEnterInsertIndexRef = useRef(null); - const shiftEnterChainTimerRef = useRef(null); - const [blockContextMenu, setBlockContextMenu] = - useState(null); - const [slashCommandMenu, setSlashCommandMenu] = - useState(null); - - const flushPendingSaves = useEffectEvent(async () => { - if (!onSave || isFlushingSaveRef.current) { - return; - } - - isFlushingSaveRef.current = true; - - try { - while (pendingSaveRef.current) { - const nextContent = pendingSaveRef.current; - pendingSaveRef.current = null; - await onSave(nextContent); - } - } finally { - isFlushingSaveRef.current = false; - } - }); - - const publishContent = useEffectEvent(async (content: NoteContent) => { - renderedDocumentRef.current = content.document; - onContentChange?.(content); - - if (!onSave) { - return; - } - - pendingSaveRef.current = content; - await flushPendingSaves(); - }); - - const saveEditorDocument = useCallback(async (editor: EditorJS) => { - const saved = NoteDocumentSchema.parse(await editor.save()); - const holder = holderRef.current; - if (!holder) { - return saved; - } - - const domBlocks = Array.from(holder.querySelectorAll(".ce-block")); - if (domBlocks.length === 0) { - return saved; - } - - const savedBlocksById = new Map( - saved.blocks - .map((block, index) => [block.id, { block, index }] as const) - .filter((entry): entry is readonly [string, { block: NoteBlock; index: number }] => - Boolean(entry[0]), - ), - ); - const usedSavedIndexes = new Set(); - let nextSavedIndex = 0; - - const takeNextSavedBlock = () => { - while (usedSavedIndexes.has(nextSavedIndex)) { - nextSavedIndex += 1; - } - - const block = saved.blocks[nextSavedIndex]; - if (block) { - usedSavedIndexes.add(nextSavedIndex); - nextSavedIndex += 1; - } - - return block; - }; - - const blocks = domBlocks - .map((domBlock) => { - const id = domBlock.dataset.id; - const savedEntry = id ? savedBlocksById.get(id) : undefined; - if (savedEntry) { - usedSavedIndexes.add(savedEntry.index); - return savedEntry.block; - } - - const paragraph = domBlock.querySelector(".ce-paragraph"); - const isEmptyParagraph = - paragraph && - paragraph.getAttribute("contenteditable") === "true" && - stripHtml(paragraph.innerHTML).length === 0; - - if (isEmptyParagraph) { - return { - id, - type: "paragraph", - data: { - text: "
", - }, - } satisfies NoteBlock; - } - - return takeNextSavedBlock(); - }) - .filter((block): block is NoteBlock => Boolean(block)); - - return NoteDocumentSchema.parse({ - ...saved, - blocks, - }); - }, []); - - const emitContentChange = useEffectEvent(async () => { - if (!editorRef.current) { - return; - } - - try { - const document = await saveEditorDocument(editorRef.current); - const content = createNoteContent(document); - await publishContent(content); - } catch (error) { - console.error("Failed to persist note changes.", error); - } - }); - - const applyDocumentMutation = useCallback( - async (mutate: (document: NoteDocument) => NoteDocument) => { - const editor = editorRef.current; - if (!editor) { - return; - } - - try { - const saved = await saveEditorDocument(editor); - const nextDocument = NoteDocumentSchema.parse(mutate(saved)); - renderedDocumentRef.current = nextDocument; - await editor.render( - nextDocument.blocks.length > 0 - ? nextDocument - : { ...emptyNoteDocument }, - ); - const content = createNoteContent(nextDocument); - onContentChange?.(content); - - if (onSave) { - pendingSaveRef.current = content; - - if (!isFlushingSaveRef.current) { - isFlushingSaveRef.current = true; - - try { - while (pendingSaveRef.current) { - const nextContent = pendingSaveRef.current; - pendingSaveRef.current = null; - await onSave(nextContent); - } - } finally { - isFlushingSaveRef.current = false; - } - } - } - } catch (error) { - console.error("Failed to update note block.", error); - } finally { - setBlockContextMenu(null); - setSlashCommandMenu(null); - } - }, - [onContentChange, onSave, saveEditorDocument], - ); - - const resolveImageUpload = useEffectEvent(async (file: File) => { - const resolvedFile = uploadImage - ? await uploadImage(file) - : await uploadImageToDataUrl(file); - - return { - success: 1 as const, - file: resolvedFile, - }; - }); - - const getContextTargetIndexes = useCallback(() => { - if (!blockContextMenu) { - return []; - } - - const selectedIndexes = selectedBlockIndexesRef.current; - return selectedIndexes.includes(blockContextMenu.blockIndex) - ? selectedIndexes - : [blockContextMenu.blockIndex]; - }, [blockContextMenu]); - - const cloneBlocksForInsert = useCallback((blocks: NoteBlock[]) => { - return blocks.map((block) => { - const cloned = structuredClone(block); - delete cloned.id; - return cloned; - }) as NoteBlock[]; - }, []); - - const getBlocksAtIndexes = useCallback( - (document: NoteDocument, indexes: number[]) => { - const indexSet = new Set(indexes); - return document.blocks.filter((_, index) => indexSet.has(index)); - }, - [], - ); - - const deleteBlocksAtIndexes = useCallback( - (indexes: number[]) => { - if (indexes.length === 0) { - return; - } - - const indexSet = new Set(indexes); - void applyDocumentMutation((document) => ({ - ...document, - blocks: document.blocks.filter( - (_, index) => !indexSet.has(index), - ) as NoteBlock[], - })); - clearBlockSelectionRef.current(); - }, - [applyDocumentMutation], - ); - - const moveBlocksAtIndexes = useCallback( - (indexes: number[], direction: -1 | 1) => { - if (indexes.length === 0) { - return; - } - - void applyDocumentMutation((document) => { - const blocks = [...document.blocks]; - const sortedIndexes = [...new Set(indexes)].sort((a, b) => a - b); - const indexSet = new Set(sortedIndexes); - - if (direction === -1) { - if (sortedIndexes[0] <= 0) { - return document; - } - - for (const index of sortedIndexes) { - const previousIndex = index - 1; - if (indexSet.has(previousIndex)) { - continue; - } - - const currentBlock = blocks[index]; - const previousBlock = blocks[previousIndex]; - if (!currentBlock || !previousBlock) { - continue; - } - - blocks[previousIndex] = currentBlock; - blocks[index] = previousBlock; - } - } else { - if (sortedIndexes[sortedIndexes.length - 1] >= blocks.length - 1) { - return document; - } - - for (const index of [...sortedIndexes].reverse()) { - const nextIndex = index + 1; - if (indexSet.has(nextIndex)) { - continue; - } - - const currentBlock = blocks[index]; - const nextBlock = blocks[nextIndex]; - if (!currentBlock || !nextBlock) { - continue; - } - - blocks[nextIndex] = currentBlock; - blocks[index] = nextBlock; - } - } - - return { - ...document, - blocks, - }; - }); - }, - [applyDocumentMutation], - ); - - const insertBlocksAfterIndex = useCallback( - (targetIndex: number, blocksToInsert: NoteBlock[]) => { - if (blocksToInsert.length === 0) { - return; - } - - const nextBlocks = cloneBlocksForInsert(blocksToInsert); - void applyDocumentMutation((document) => { - const boundedIndex = Math.min( - Math.max(targetIndex, -1), - document.blocks.length - 1, - ); - - return { - ...document, - blocks: [ - ...document.blocks.slice(0, boundedIndex + 1), - ...nextBlocks, - ...document.blocks.slice(boundedIndex + 1), - ] as NoteBlock[], - }; - }); - clearBlockSelectionRef.current(); - }, - [applyDocumentMutation, cloneBlocksForInsert], - ); - - const insertEmptyParagraphAfterIndex = useCallback( - (blockIndex: number) => { - const editor = editorRef.current; - if (!editor) { - return; - } - - const boundedIndex = Math.max(-1, blockIndex); - editor.blocks.insert( - "paragraph", - { text: "
" }, - undefined, - boundedIndex + 1, - true, - ); - clearBlockSelectionRef.current(); - setSlashCommandMenu(null); - shiftEnterInsertIndexRef.current = boundedIndex + 1; - if (shiftEnterChainTimerRef.current !== null) { - window.clearTimeout(shiftEnterChainTimerRef.current); - } - shiftEnterChainTimerRef.current = window.setTimeout(() => { - shiftEnterInsertIndexRef.current = null; - shiftEnterChainTimerRef.current = null; - }, 1200); - - pendingBlockFocusTimersRef.current.forEach((timer) => { - window.clearTimeout(timer); - }); - pendingBlockFocusTimersRef.current = []; - - const focusInsertedBlock = () => { - const holder = holderRef.current; - const nextBlock = holder?.querySelectorAll(".ce-block")[ - boundedIndex + 1 - ]; - const focusTarget = nextBlock?.querySelector( - '[contenteditable="true"], textarea, input, math-field', - ); - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - - focusTarget?.focus({ preventScroll: true }); - - if (focusTarget?.isContentEditable) { - const range = document.createRange(); - range.selectNodeContents(focusTarget); - range.collapse(false); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } - }; - - window.requestAnimationFrame(() => { - focusInsertedBlock(); - pendingBlockFocusTimersRef.current = [0, 50, 150, 350, 650].map((delay) => - window.setTimeout(focusInsertedBlock, delay), - ); - }); - }, - [], +function EditorSkeleton() { + return ( +