From 16f904d25b1c9717011a596dc6658cee8b11ed26 Mon Sep 17 00:00:00 2001 From: Shreekar Date: Tue, 9 Jun 2026 18:11:46 -0500 Subject: [PATCH 01/12] Start issue 58 math renderer rework From f2bfccba3443d75cea7190401ca30ca5d55c6de8 Mon Sep 17 00:00:00 2001 From: Shreekar Date: Tue, 9 Jun 2026 18:33:55 -0500 Subject: [PATCH 02/12] Normalize user math input to LaTeX --- app/flashcards/flashcards-client.test.tsx | 22 +-- app/flashcards/flashcards-client.tsx | 84 +++++----- app/quizzes/quizzes-client.test.tsx | 48 +++++- app/quizzes/quizzes-client.tsx | 145 +++++++++--------- components/math/latex-markdown.tsx | 67 ++++++++ .../note-editor/math-block-tool.test.ts | 12 ++ components/note-editor/math-block-tool.ts | 7 +- components/note-editor/note-editor.tsx | 3 +- lib/math/latex.test.ts | 20 +++ lib/math/latex.ts | 110 +++++++++++++ lib/notes/parse-markdown.test.ts | 25 +++ lib/notes/parse-markdown.ts | 7 +- 12 files changed, 411 insertions(+), 139 deletions(-) create mode 100644 components/math/latex-markdown.tsx create mode 100644 lib/math/latex.test.ts create mode 100644 lib/math/latex.ts create mode 100644 lib/notes/parse-markdown.test.ts 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({ +
diff --git a/lib/notes/markdown.test.ts b/lib/notes/markdown.test.ts index 838786f..18e5e7f 100644 --- a/lib/notes/markdown.test.ts +++ b/lib/notes/markdown.test.ts @@ -170,4 +170,32 @@ describe("serializeNoteDocumentToMarkdown", () => { "Second block moved first\n\nFirst block moved second", ); }); + + it("serializes inline math blocks with adjacent text as one inline sentence", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "paragraph", + data: { + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\sqrt{x^2}", + }, + }, + { + type: "paragraph", + data: { + text: "here.", + }, + }, + ], + }; + + expect(serializeNoteDocumentToMarkdown(document)).toBe("Use $\\sqrt{x^2}$ here."); + }); }); diff --git a/lib/notes/markdown.ts b/lib/notes/markdown.ts index 08bfa80..34e05dc 100644 --- a/lib/notes/markdown.ts +++ b/lib/notes/markdown.ts @@ -134,16 +134,45 @@ function serializeBlock(block: NoteBlock) { } case "math": return `$$\n${block.data.latex}\n$$`; + case "inlineMath": + return `$${block.data.latex}$`; default: return ""; } } +function getBlockSeparator(previous: NoteBlock | undefined, current: NoteBlock) { + if ( + previous && + (previous.type === "inlineMath" || current.type === "inlineMath") && + (previous.type === "paragraph" || previous.type === "inlineMath") && + (current.type === "paragraph" || current.type === "inlineMath") + ) { + return " "; + } + + return "\n\n"; +} + export function serializeNoteDocumentToMarkdown(document: NoteDocument) { - return document.blocks - .map((block) => serializeBlock(block)) - .filter((block) => block.length > 0) - .join("\n\n"); + const sections: string[] = []; + let previousSerializedBlock: NoteBlock | undefined; + + for (const block of document.blocks) { + const serialized = serializeBlock(block); + if (serialized.length === 0) { + continue; + } + + if (sections.length > 0) { + sections.push(getBlockSeparator(previousSerializedBlock, block)); + } + + sections.push(serialized); + previousSerializedBlock = block; + } + + return sections.join(""); } export function createNoteContent(document: NoteDocument): NoteContent { diff --git a/lib/notes/math-regions.test.ts b/lib/notes/math-regions.test.ts index b1fe856..611bf49 100644 --- a/lib/notes/math-regions.test.ts +++ b/lib/notes/math-regions.test.ts @@ -3,7 +3,7 @@ import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; import type { NoteDocument } from "@/lib/notes/types"; describe("normalizeNoteLatexRegions", () => { - it("keeps single-dollar math inline inside paragraph blocks", () => { + it("converts single-dollar math to inline math blocks", () => { const document: NoteDocument = { time: 1, blocks: [ @@ -20,13 +20,25 @@ describe("normalizeNoteLatexRegions", () => { { type: "paragraph", data: { - text: 'Use $x^2 + y^2 = z^2$ for the distance relation.', + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "x^2 + y^2 = z^2", + }, + }, + { + type: "paragraph", + data: { + text: "for the distance relation.", }, }, ]); }); - it("keeps imported inline math spans inline", () => { + it("converts imported inline math spans to inline math blocks", () => { const document: NoteDocument = { time: 1, blocks: [ @@ -43,7 +55,13 @@ describe("normalizeNoteLatexRegions", () => { { type: "paragraph", data: { - text: 'Area $\\pi r^2$', + text: "Area", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\pi r^2", }, }, ]); diff --git a/lib/notes/math-regions.ts b/lib/notes/math-regions.ts index acd1ef0..52516eb 100644 --- a/lib/notes/math-regions.ts +++ b/lib/notes/math-regions.ts @@ -9,6 +9,10 @@ type RegionSegment = | { type: "math"; latex: string; + } + | { + type: "inlineMath"; + latex: string; }; type RichTextNoteBlock = Extract< NoteBlock, @@ -31,19 +35,6 @@ function decodeHtml(value: string) { .replace(/'/g, "'"); } -function escapeHtmlAttr(value: string) { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -function inlineMathSpan(latex: string) { - const escaped = escapeHtmlAttr(latex); - return `$${latex}$`; -} - function hasInlineMathSpan(value: string) { INLINE_MATH_SPAN_RE.lastIndex = 0; return INLINE_MATH_SPAN_RE.test(value); @@ -117,7 +108,9 @@ function splitLatexRegions(value: string): RegionSegment[] { const latex = normalizeLatex(match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""); if (latex) { if (isInline) { - pendingText += inlineMathSpan(latex); + pushTextSegment(segments, pendingText); + pendingText = ""; + segments.push({ type: "inlineMath", latex }); } else { pushTextSegment(segments, pendingText); pendingText = ""; @@ -151,6 +144,15 @@ function mathBlock(latex: string): NoteBlock { }; } +function inlineMathBlock(latex: string): NoteBlock { + return { + type: "inlineMath", + data: { + latex, + }, + }; +} + function richTextBlockLike(block: RichTextNoteBlock, text: string): NoteBlock { if (block.type === "header") { return { @@ -211,9 +213,17 @@ function convertRichTextBlock(block: NoteBlock): NoteBlock[] { return text === sourceText ? [block] : [richTextBlockLike(block, text)]; } - return segments.map((segment) => - segment.type === "math" ? mathBlock(segment.latex) : paragraphBlock(segment.value), - ); + return segments.map((segment) => { + if (segment.type === "math") { + return mathBlock(segment.latex); + } + + if (segment.type === "inlineMath") { + return inlineMathBlock(segment.latex); + } + + return paragraphBlock(segment.value); + }); } export function normalizeNoteLatexRegions(document: NoteDocument): NoteDocument { diff --git a/lib/notes/parse-markdown.test.ts b/lib/notes/parse-markdown.test.ts index 9113167..99c3f71 100644 --- a/lib/notes/parse-markdown.test.ts +++ b/lib/notes/parse-markdown.test.ts @@ -11,7 +11,19 @@ describe("parseMarkdownToNoteDocument", () => { { type: "paragraph", data: { - text: 'Use $\\sqrt{x^2}$ here.', + text: "Use", + }, + }, + { + type: "inlineMath", + data: { + latex: "\\sqrt{x^2}", + }, + }, + { + type: "paragraph", + data: { + text: "here.", }, }, { diff --git a/lib/notes/parse-markdown.ts b/lib/notes/parse-markdown.ts index a2a0d07..5524155 100644 --- a/lib/notes/parse-markdown.ts +++ b/lib/notes/parse-markdown.ts @@ -9,7 +9,7 @@ * - Block math ($$…$$) * - Blockquotes (>) * - Ordered / unordered / checklist lists - * - Inline math ($…$) inside paragraph text + * - Inline math ($…$) as inline math blocks * - Paragraphs (everything else) */ @@ -17,54 +17,6 @@ import type { NoteBlock, NoteDocument, NoteListBlockData, NoteListItem } from "@ import { normalizeLatex } from "@/lib/math/latex"; import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function escapeHtmlAttr(value: string) { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -/** - * Detect and replace inline math ($…$) within a single line of text. - * - * Heuristic: we match $content$ where content is non-empty and contains at - * least one LaTeX-typical character (backslash, caret, underscore, or braces) - * OR is a short single-token that doesn't look like a shell / currency value. - * This avoids false-positives on "$HOME", "$5.99", etc. - * - * The result is a element so - * the serializer and renderer can round-trip it cleanly. - */ -function processInlineMath(line: string): string { - // Avoid matching $$ (block math uses doubled dollar signs) - // Pattern: $$ — negative lookaround for $ - const re = /(? { - const trimmed = content.trim(); - if (!trimmed) return _match; - - // Accept as inline math if content contains any LaTeX-specific char, or - // is a multi-character expression that can't be a bare shell identifier. - const hasLatexChar = /[\\^_{}]/.test(trimmed); - const isBareShellId = /^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed); - const isCurrency = /^\d/.test(trimmed); - - if (!hasLatexChar && (isBareShellId || isCurrency)) { - return _match; // leave untouched - } - - const latex = normalizeLatex(trimmed); - - return `$${latex}$`; - }); -} - function isListLine(line: string) { return /^[-*+]\s/.test(line) || /^\d+\.\s/.test(line); } @@ -149,7 +101,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { blocks.push({ type: "quote", data: { - text: quoteLines.map(processInlineMath).join("
"), + text: quoteLines.join("
"), caption: "", alignment: "left", }, @@ -174,10 +126,9 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { const olMatch = itemLine.match(/^\d+\.\s+(.*)/); const ulMatch = itemLine.match(/^[-*+]\s+(.*)/); const rawContent = (clMatch?.[2] ?? olMatch?.[1] ?? ulMatch?.[1] ?? "").trim(); - const content = processInlineMath(rawContent); const checked = clMatch ? clMatch[1] !== " " : false; items.push({ - content, + content: rawContent, meta: style === "checklist" ? { checked } : {}, items: [], }); @@ -203,7 +154,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { i++; } if (paragraphLines.length > 0) { - const text = paragraphLines.map(processInlineMath).join("
"); + const text = paragraphLines.join("
"); blocks.push({ type: "paragraph", data: { text } }); } } diff --git a/lib/notes/types.ts b/lib/notes/types.ts index 3471e39..0927a59 100644 --- a/lib/notes/types.ts +++ b/lib/notes/types.ts @@ -9,7 +9,8 @@ export type NoteBlockType = | "quote" | "code" | "image" - | "math"; + | "math" + | "inlineMath"; export type NoteParagraphBlockData = { text: string; @@ -68,6 +69,8 @@ export type NoteMathBlockData = { latex: string; }; +export type NoteInlineMathBlockData = NoteMathBlockData; + export type NoteBlock = | OutputBlockData<"paragraph", NoteParagraphBlockData> | OutputBlockData<"header", NoteHeaderBlockData> @@ -75,7 +78,8 @@ export type NoteBlock = | OutputBlockData<"quote", NoteQuoteBlockData> | OutputBlockData<"code", NoteCodeBlockData> | OutputBlockData<"image", NoteImageBlockData> - | OutputBlockData<"math", NoteMathBlockData>; + | OutputBlockData<"math", NoteMathBlockData> + | OutputBlockData<"inlineMath", NoteInlineMathBlockData>; export type NoteDocument = Omit & { blocks: NoteBlock[]; @@ -185,6 +189,15 @@ const mathBlockSchema = z.object({ tunes: z.record(z.string(), z.unknown()).optional(), }); +const inlineMathBlockSchema = z.object({ + id: z.string().optional(), + type: z.literal("inlineMath"), + data: z.object({ + latex: z.string(), + }), + tunes: z.record(z.string(), z.unknown()).optional(), +}); + export const NoteBlockSchema = z.discriminatedUnion("type", [ paragraphBlockSchema, headerBlockSchema, @@ -193,6 +206,7 @@ export const NoteBlockSchema = z.discriminatedUnion("type", [ codeBlockSchema, imageBlockSchema, mathBlockSchema, + inlineMathBlockSchema, ]); export const NoteDocumentSchema: z.ZodType = z From c687dc011aa6478ba2df98c0f54e56963e601af6 Mon Sep 17 00:00:00 2001 From: Shreekar Date: Wed, 2 Sep 2026 19:42:06 -0500 Subject: [PATCH 06/12] Remove the Editor.js note editor ahead of a rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes components/note-editor/ and the editor-harness route, along with the Editor.js CSS in globals.css, its orphaned --note-*/--code-* variables, and the @editorjs/*, mathlive, and highlight.js dependencies. lib/notes/code-highlighter.ts went with them; its only consumers were the deleted code-block files. The issue #58 LaTeX work is kept in full — components/math/latex-markdown.tsx, lib/math/latex.ts, and lib/notes/math-regions.ts are shared with the flashcard and quiz clients, not editor-only. lib/notes/ stays as the data layer. types.ts no longer imports OutputBlockData and OutputData from @editorjs/editorjs; an equivalent NoteBlockShape is defined locally, so stored notes keep parsing and the rebuild is free to use any editor. app/notes/notes-workspace.tsx keeps note CRUD and the title field, with a marked SEAM where the new editor mounts. Until then it renders the stored markdown read-only so notes stay reachable. Also adds docs/note-editor-requirements.md for collecting the rebuild requirements, plus pnpm-workspace.yaml and the pnpm pin so this branch builds from a clean clone. --- app/editor-harness/editor-harness-client.tsx | 12 - app/editor-harness/page.tsx | 20 - app/globals.css | 955 ------- app/layout.tsx | 2 - app/notes/notes-workspace.tsx | 106 +- components/note-editor/code-block-tool.ts | 251 -- components/note-editor/code-block-view.tsx | 28 - .../note-editor/math-block-tool.test.ts | 145 -- components/note-editor/math-block-tool.ts | 197 -- components/note-editor/note-editor.test.tsx | 192 -- components/note-editor/note-editor.tsx | 2311 ----------------- components/note-editor/note-renderer.test.tsx | 48 - components/note-editor/note-renderer.tsx | 142 - components/note-editor/note-surface.test.tsx | 299 --- components/note-editor/note-surface.tsx | 73 - docs/note-editor-requirements.md | 144 + lib/notes/code-highlighter.ts | 37 - lib/notes/types.ts | 43 +- package.json | 10 +- pnpm-lock.yaml | 164 -- pnpm-workspace.yaml | 8 + 21 files changed, 235 insertions(+), 4952 deletions(-) delete mode 100644 app/editor-harness/editor-harness-client.tsx delete mode 100644 app/editor-harness/page.tsx delete mode 100644 components/note-editor/code-block-tool.ts delete mode 100644 components/note-editor/code-block-view.tsx delete mode 100644 components/note-editor/math-block-tool.test.ts delete mode 100644 components/note-editor/math-block-tool.ts delete mode 100644 components/note-editor/note-editor.test.tsx delete mode 100644 components/note-editor/note-editor.tsx delete mode 100644 components/note-editor/note-renderer.test.tsx delete mode 100644 components/note-editor/note-renderer.tsx delete mode 100644 components/note-editor/note-surface.test.tsx delete mode 100644 components/note-editor/note-surface.tsx create mode 100644 docs/note-editor-requirements.md delete mode 100644 lib/notes/code-highlighter.ts create mode 100644 pnpm-workspace.yaml 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/globals.css b/app/globals.css index 9c03607..8de93ce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,27 +26,6 @@ html { --radius-lg: 0.625rem; --radius-xl: 0.75rem; --shadow-card: 0 1px 2px 0 rgb(24 24 27 / 0.06), 0 12px 24px -20px rgb(24 24 27 / 0.22); - --note-surface: var(--surface); - --note-surface-muted: var(--surface-muted); - --note-border: var(--border); - --note-border-strong: var(--border-strong); - --note-muted-foreground: var(--muted-foreground); - --note-toolbar: var(--surface); - --note-toolbar-hover: var(--surface-elevated); - --note-toolbar-active: color-mix(in srgb, var(--accent) 14%, white); - --note-toolbar-active-foreground: var(--accent); - --note-code-bg: #18181b; - --note-code-fg: #fafafa; - --note-selection: color-mix(in srgb, var(--accent) 18%, transparent); - --note-selection-strong: color-mix(in srgb, var(--accent) 28%, transparent); - --code-accent: #0550ae; - --code-comment: #6e7781; - --code-keyword: #cf222e; - --code-string: #0a3069; - --code-number: #0550ae; - --code-function: #8250df; - --code-type: #953800; - --code-property: #116329; } @theme inline { @@ -82,27 +61,6 @@ html.dark { --danger: #f87171; --danger-soft: rgba(239, 68, 68, 0.12); --shadow-card: 0 1px 2px 0 rgb(0 0 0 / 0.2), 0 18px 36px -20px rgb(0 0 0 / 0.5); - --note-surface: var(--surface); - --note-surface-muted: var(--surface-muted); - --note-border: var(--border); - --note-border-strong: var(--border-strong); - --note-muted-foreground: var(--muted-foreground); - --note-toolbar: var(--surface-muted); - --note-toolbar-hover: #27272a; - --note-toolbar-active: color-mix(in srgb, var(--accent) 18%, #111114); - --note-toolbar-active-foreground: #c7d2fe; - --note-code-bg: #09090b; - --note-code-fg: #f4f4f5; - --note-selection: color-mix(in srgb, var(--accent) 20%, transparent); - --note-selection-strong: color-mix(in srgb, var(--accent) 32%, transparent); - --code-accent: #7dd3fc; - --code-comment: #7c8ab8; - --code-keyword: #f472b6; - --code-string: #bef264; - --code-number: #c4b5fd; - --code-function: #5eead4; - --code-type: #fca5a5; - --code-property: #fcd34d; } body { @@ -116,918 +74,5 @@ html.dark body { color-scheme: dark; } -.note-editor, -.note-renderer { - font-family: var(--font-sans), Arial, Helvetica, sans-serif; -} - -.note-editor .ce-block__content, -.note-editor .ce-toolbar__content { - max-width: 100%; - border-radius: 0.75rem; - position: relative; - transition: - background-color 140ms ease, - box-shadow 140ms ease, - color 140ms ease; -} - -.note-editor .ce-block__content, -.note-editor .ce-toolbar__content, -.note-renderer { - color: var(--foreground); -} - -.note-renderer h1, -.note-renderer h2, -.note-renderer h3, -.note-renderer h4 { - margin: 0; - font-weight: 600; - letter-spacing: -0.02em; - color: var(--foreground); -} - -.note-renderer h1 { - font-size: clamp(2rem, 3vw, 2.15rem); - line-height: 1.1; -} - -.note-renderer h2 { - font-size: clamp(1.65rem, 2.4vw, 1.8rem); - line-height: 1.15; -} - -.note-renderer h3 { - font-size: 1.35rem; - line-height: 1.2; -} - -.note-renderer h4 { - font-size: 1.15rem; - line-height: 1.25; -} - -.note-renderer p, -.note-renderer li { - font-size: 1.05rem; - line-height: 1.85; -} - -.note-renderer ul, -.note-renderer ol { - margin: 0 0 0 1.15rem; - padding: 0; -} - -.note-renderer ul { - list-style: disc; -} - -.note-renderer ol { - list-style: decimal; -} - -.note-renderer li + li { - margin-top: 0.25rem; -} - -.note-renderer li::marker { - color: var(--note-muted-foreground); -} - -.note-renderer blockquote { - margin: 0; - border-left: 4px solid var(--note-border); - border-radius: 0.75rem; - background: color-mix(in srgb, var(--note-surface-muted) 88%, transparent); - padding: 0.85rem 1rem; -} - -.note-editor .ce-paragraph, -.note-editor .cdx-block { - color: inherit; -} - -.note-editor .ce-paragraph { - position: relative; - min-height: 1.85em; - font-size: 1.05rem; - line-height: 1.85; -} - -.note-editor .ce-header { - margin: 0 !important; - padding: 0.18em 0 0 !important; - font-weight: 600 !important; - letter-spacing: -0.02em; - color: var(--foreground); -} - -.note-editor .ce-block h1.ce-header { - font-size: clamp(2rem, 3vw, 2.15rem) !important; - line-height: 1.1 !important; -} - -.note-editor .ce-block h2.ce-header { - font-size: clamp(1.65rem, 2.4vw, 1.8rem) !important; - line-height: 1.15 !important; -} - -.note-editor .ce-block h3.ce-header { - font-size: 1.35rem !important; - line-height: 1.2 !important; -} - -.note-editor .ce-block h4.ce-header { - font-size: 1.15rem !important; - line-height: 1.25 !important; -} - -.note-editor .ce-paragraph[data-placeholder]:empty::before, -.note-editor - .codex-editor__redactor - [contenteditable="true"][data-placeholder]:empty::before, -.note-editor .cdx-input[data-placeholder]::before { - position: absolute; - inset: 0 auto auto 0; - color: var(--note-muted-foreground); - pointer-events: none; -} - -.note-editor - .ce-block:first-child - .ce-paragraph[data-placeholder]:empty::before, -.note-editor - .ce-block:first-child - .codex-editor__redactor - [contenteditable="true"][data-placeholder]:empty::before { - font-size: 1.05rem; - font-weight: 400; - line-height: 1.85; - color: color-mix(in srgb, var(--foreground) 28%, transparent); -} - -.note-editor .ce-block:first-child .ce-block__content { - padding-top: 0.25rem; -} - -.note-editor .ce-block { - position: relative; - transition: transform 120ms ease; - will-change: transform; -} - -.note-editor__canvas { - position: relative; - min-height: calc(100dvh - 12rem); - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; -} - -.note-editor, -.note-editor .codex-editor, -.note-editor .codex-editor__redactor { - min-height: 100%; -} - -.note-editor .codex-editor__redactor { - padding-bottom: 0 !important; -} - -.note-editor .ce-block, -.note-editor .ce-block__content, -.note-editor .ce-toolbar__content { - max-width: none; -} - -.note-editor .ce-block__content { - border: 0; - box-shadow: none; -} - -.note-editor__block-selected::after { - position: absolute; - inset: 0.1rem 0; - z-index: 2; - border: 1px solid color-mix(in srgb, var(--accent) 48%, transparent); - border-left-width: 3px; - border-radius: 0.45rem; - background: color-mix(in srgb, var(--accent) 16%, transparent); - content: ""; - pointer-events: none; -} - -.note-editor__block-selected .ce-block__content { - padding-left: 0.95rem; - background: transparent; - border-radius: 0 !important; - box-shadow: none; -} - -.note-editor__block-dragging { - opacity: 0.24; -} - -.note-editor__drop-placeholder { - position: absolute; - right: 1.5rem; - left: 1.5rem; - z-index: 30; - min-height: 2.5rem; - border: 1px dashed color-mix(in srgb, var(--accent) 54%, transparent); - border-radius: 0.5rem; - background: color-mix(in srgb, var(--accent) 9%, transparent); - pointer-events: none; -} - -.note-editor__drop-placeholder::before { - position: absolute; - top: -2px; - right: 0; - left: 0; - height: 2px; - border-radius: 999px; - background: var(--accent); - content: ""; -} - -.note-editor__drag-preview { - position: fixed; - z-index: 9998; - max-height: min(70vh, 42rem); - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--accent) 38%, transparent); - border-radius: 0.6rem; - background: var(--background); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.18), - 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent); - opacity: 0.96; - pointer-events: none; - will-change: top, left; -} - -.note-editor__drag-preview-block { - background: var(--background); -} - -.note-editor__drag-preview-block .ce-block__content { - max-width: none; - background: transparent; -} - -.note-editor__selection-box { - position: fixed; - z-index: 9999; - border: 1px solid color-mix(in srgb, var(--accent) 68%, white 12%); - background: color-mix(in srgb, var(--accent) 18%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent); - pointer-events: none; -} - -.note-editor .ce-toolbar__actions { - display: none !important; -} - -.note-editor .ce-inline-toolbar, -.note-editor .ce-conversion-toolbar, -.note-editor .ce-popover, -.note-editor .ce-toolbar__actions, -.note-editor .cdx-search-field, -.note-editor .image-tool--withBackground .image-tool__image, -.note-editor .ce-settings, -.note-editor .ce-toolbox, -.note-editor .ce-popover__container { - background: var(--note-toolbar); - border-color: var(--note-border); - color: var(--foreground); -} - -.note-editor .ce-popover, -.note-editor .ce-conversion-toolbar, -.note-editor .ce-inline-toolbar { - --color-border: var(--note-border); - --color-shadow: rgba(0, 0, 0, 0.18); - --color-background: var(--note-toolbar); - --color-text-primary: var(--foreground); - --color-text-secondary: var(--note-muted-foreground); - --color-border-icon: var(--note-border); - --color-border-icon-disabled: var(--note-border); - --color-text-icon-active: var(--note-toolbar-active-foreground); - --color-background-icon-active: var(--note-toolbar-active); - --color-background-item-focus: var(--note-selection); - --color-shadow-item-focus: transparent; - --color-background-item-hover: var(--note-toolbar-hover); -} - -.note-editor .ce-toolbar__plus, -.note-editor .ce-toolbar__settings-btn, -.note-editor .ce-inline-toolbar__dropdown, -.note-editor .ce-popover__item, -.note-editor .ce-settings__button, -.note-editor .ce-toolbox__button, -.note-editor .cdx-button, -.note-editor .image-tool__caption, -.note-editor .cdx-input { - color: var(--foreground); - background: transparent; - border-color: var(--note-border); - border-radius: 0.75rem; -} - -.note-editor .ce-toolbar__settings-btn { - cursor: grab; -} - -.note-editor .ce-toolbar__settings-btn:active { - cursor: grabbing; -} - -.note-editor .ce-toolbar__plus:hover, -.note-editor .ce-toolbar__settings-btn:hover, -.note-editor .ce-inline-toolbar__dropdown:hover, -.note-editor .ce-popover__item:hover, -.note-editor .ce-settings__button:hover, -.note-editor .ce-toolbox__button:hover, -.note-editor .cdx-button:hover { - background: var(--note-toolbar-hover); -} - -.note-editor .ce-popover__item--active, -.note-editor .ce-settings__button--focused, -.note-editor .ce-toolbox__button--active, -.note-editor .ce-inline-tool--active { - background: var(--note-selection); -} - -.note-editor .ce-popover-item { - color: var(--foreground); - border-radius: 0.9rem; - transition: - background-color 140ms ease, - color 140ms ease; -} - -.note-editor .ce-popover-item:hover, -.note-editor .ce-popover-item--focused { - background: var(--note-toolbar-hover); -} - -.note-editor .ce-popover-item--active { - background: var(--note-toolbar-active); -} - -.note-editor .ce-popover-item__icon { - border-radius: 0.5rem; - background: var(--note-surface-muted); - color: var(--foreground); - transition: - background-color 140ms ease, - color 140ms ease; -} - -.note-editor .ce-popover-item__title { - color: var(--foreground); - font-size: 0.925rem; - font-weight: 500; - letter-spacing: 0; - line-height: 1.3; -} - -.note-editor .ce-popover-item__secondary-title { - color: var(--note-muted-foreground); - opacity: 1; - letter-spacing: 0; - font-size: 0.75rem; - line-height: 1.35; -} - -.note-editor .ce-popover-item:hover .ce-popover-item__icon, -.note-editor .ce-popover-item--focused .ce-popover-item__icon, -.note-editor .ce-popover-item--active .ce-popover-item__icon, -.note-editor .ce-inline-tool--active, -.note-editor .ce-settings__button--focused, -.note-editor .ce-toolbox__button--active { - background: var(--note-toolbar-active); - color: var(--note-toolbar-active-foreground); -} - -.note-editor .ce-popover-item--active .ce-popover-item__title, -.note-editor .ce-popover-item--active .ce-popover-item__secondary-title, -.note-editor .ce-popover-item--focused .ce-popover-item__title, -.note-editor .ce-popover-item--focused .ce-popover-item__secondary-title, -.note-editor .ce-inline-tool--active, -.note-editor .ce-settings__button--focused, -.note-editor .ce-toolbox__button--active { - color: var(--note-toolbar-active-foreground); -} - -.note-editor .ce-toolbar__plus svg, -.note-editor .ce-toolbar__settings-btn svg, -.note-editor .ce-inline-toolbar__dropdown svg, -.note-editor .ce-popover__item svg, -.note-editor .ce-settings__button svg, -.note-editor .ce-toolbox__button svg { - color: currentColor; -} - -.note-editor .cdx-search-field { - box-shadow: none; -} - -.note-editor .cdx-input, -.note-editor .image-tool__caption { - border-radius: 0.5rem; -} - -.note-editor .cdx-quote__text { - min-height: 7rem; -} - -.note-editor .cdx-input.cdx-quote__caption { - min-height: auto; -} - -.note-editor .ce-code__textarea { - min-height: 8rem; -} - -.note-editor .tc-wrap, -.note-editor .cdx-quote { - border-color: var(--note-border); -} - -.note-editor .ce-popover-item-separator__line { - background: var(--note-border); -} - -.note-editor .ce-block__content:hover { - background: color-mix(in srgb, var(--note-toolbar-hover) 65%, transparent); -} - -.note-editor:has(.ce-block [contenteditable="true"]:focus) .ce-block__content:hover, -.note-editor:has(.ce-block textarea:focus) .ce-block__content:hover, -.note-editor:has(.ce-block math-field:focus) .ce-block__content:hover { - background: transparent; -} - -.note-editor .ce-block__content::before { - position: absolute; - inset-block: 0.35rem; - inset-inline-start: -0.55rem; - width: 2px; - border-radius: 999px; - background: transparent; - content: ""; - transition: background-color 140ms ease; -} - -.note-editor .ce-block:hover .ce-block__content::before, -.note-editor .ce-block--focused .ce-block__content::before { - background: var(--note-border-strong); -} - -.note-editor .ce-block--selected .ce-block__content { - background: transparent !important; - box-shadow: none !important; -} - -.note-editor:has(.ce-block [contenteditable="true"]:focus) - .ce-block:hover - .ce-block__content::before, -.note-editor:has(.ce-block textarea:focus) .ce-block:hover .ce-block__content::before, -.note-editor:has(.ce-block math-field:focus) .ce-block:hover .ce-block__content::before, -.note-editor:has(.ce-block [contenteditable="true"]:focus) - .ce-block--focused - .ce-block__content::before, -.note-editor:has(.ce-block textarea:focus) - .ce-block--focused - .ce-block__content::before, -.note-editor:has(.ce-block math-field:focus) - .ce-block--focused - .ce-block__content::before { - background: transparent; -} - -.note-editor__block-selected .ce-block__content::before { - background: transparent; -} - -.note-editor__block-selected:hover .ce-block__content, -.note-editor__block-selected .ce-block__content:hover { - background: transparent; - border-radius: 0 !important; -} - -.note-editor ::selection { - background: var(--note-selection-strong); -} - -.note-editor__context-menu { - position: fixed; - z-index: 10000; - width: 13.5rem; - max-width: calc(100vw - 1rem); - padding: 0.35rem; - border: 1px solid var(--note-border); - border-radius: 0.65rem; - background: var(--note-toolbar); - color: var(--foreground); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - 0 0 0 1px rgba(255, 255, 255, 0.04); -} - -.note-editor__context-menu button, -.note-editor__context-menu-item { - display: flex; - min-height: 1.9rem; - width: 100%; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - border: 0; - border-radius: 0.45rem; - background: transparent; - padding: 0.35rem 0.5rem; - color: var(--foreground); - font-size: 0.82rem; - line-height: 1.2; - text-align: left; -} - -.note-editor__context-menu button:hover, -.note-editor__context-menu-item:hover { - background: var(--note-toolbar-hover); -} - -.note-editor__context-menu-item--submenu { - position: relative; -} - -.note-editor__context-submenu { - position: absolute; - top: -0.35rem; - left: calc(100% + 0.3rem); - display: none; - width: 13.5rem; - padding: 0.35rem; - border: 1px solid var(--note-border); - border-radius: 0.65rem; - background: var(--note-toolbar); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - 0 0 0 1px rgba(255, 255, 255, 0.04); -} - -.note-editor__context-menu-item--submenu:hover .note-editor__context-submenu, -.note-editor__context-menu-item--submenu:focus-within .note-editor__context-submenu { - display: block; -} - -.note-editor__context-menu-separator { - height: 1px; - margin: 0.3rem 0.25rem; - background: var(--note-border); -} - -.note-editor__context-menu-danger { - color: var(--danger) !important; -} - -.note-editor__context-menu-danger:hover { - background: var(--danger-soft) !important; -} - -.note-editor__context-menu-meta { - padding: 0.3rem 0.5rem 0.15rem; - color: var(--note-muted-foreground); - font-size: 0.68rem; - text-transform: capitalize; -} - -.note-editor__slash-menu { - position: fixed; - z-index: 10000; - width: 18rem; - max-width: calc(100vw - 1rem); - max-height: min(22rem, calc(100vh - 2rem)); - overflow-y: auto; - overscroll-behavior: contain; - padding: 0.35rem; - border: 1px solid var(--note-border); - border-radius: 0.65rem; - background: var(--note-toolbar); - color: var(--foreground); - box-shadow: - 0 18px 48px rgba(0, 0, 0, 0.22), - 0 0 0 1px rgba(255, 255, 255, 0.04); -} - -.note-editor__slash-menu button { - display: grid; - width: 100%; - grid-template-columns: minmax(0, 1fr); - gap: 0.1rem; - border: 0; - border-radius: 0.45rem; - background: transparent; - padding: 0.45rem 0.55rem; - color: var(--foreground); - text-align: left; -} - -.note-editor__slash-menu button:hover, -.note-editor__slash-menu button:focus-visible, -.note-editor__slash-menu-active { - background: var(--note-toolbar-hover); - outline: none; -} - -.note-editor__slash-menu-active { - background: color-mix(in srgb, var(--accent) 14%, transparent); -} - -.note-editor__slash-menu button span:first-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.84rem; - font-weight: 500; - line-height: 1.25; -} - -.note-editor__slash-menu button span:last-child, -.note-editor__slash-menu-empty { - color: var(--note-muted-foreground); - font-size: 0.72rem; - line-height: 1.25; -} - -.note-editor__slash-menu-empty { - padding: 0.5rem 0.55rem; -} - -.note-surface { - height: 100%; - min-height: 100%; - width: 100%; -} - -.note-surface--editing { - height: 100%; - min-height: 100%; -} - -.note-surface__content { - margin: 0 auto; - min-height: 72vh; - width: 100%; - max-width: 56rem; - padding: 2rem 0.5rem 6rem; -} - @media (min-width: 768px) { - .note-surface__content { - padding: 3rem 2rem 8rem; - } -} - -.note-code-block { - display: block; - --note-code-block-padding-y: 1.2rem; - --note-code-block-padding-x: 1.25rem; - --note-code-block-line-height: 1.7; - --note-code-block-min-height: calc( - (var(--note-code-block-padding-y) * 2) + 1em * - var(--note-code-block-line-height) - ); -} - -.note-code-block__textarea { - position: absolute; - top: 0; - left: 0; - width: 100%; - min-height: var(--note-code-block-min-height); - box-sizing: border-box; - resize: none; - border: 0; - margin: 0; - border-radius: 0.95rem; - background: transparent; - color: transparent; - caret-color: var(--foreground); - padding: var(--note-code-block-padding-y) var(--note-code-block-padding-x); - font-family: var(--font-mono), monospace; - font-size: 0.9rem; - line-height: var(--note-code-block-line-height); - tab-size: 2; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: normal; - overflow: hidden; - -webkit-text-fill-color: transparent; -} - -.note-code-block__textarea::placeholder { - color: var(--note-muted-foreground); - -webkit-text-fill-color: var(--note-muted-foreground); -} - -.note-code-block__textarea:focus { - outline: none; -} - -.note-code-block__textarea::selection { - background: var(--note-selection-strong); - -webkit-text-fill-color: transparent; -} - -.note-code-block__editor { - position: relative; -} - -.note-code-block__surface { - overflow-x: auto; - overflow-y: hidden; - border: 1px solid var(--note-border); - border-radius: 0.95rem; - margin: 0; - background: var(--note-surface-muted); - padding: var(--note-code-block-padding-y) var(--note-code-block-padding-x); - color: var(--foreground); - font-family: var(--font-mono), monospace; - font-size: 0.9rem; - line-height: var(--note-code-block-line-height); - tab-size: 2; - min-height: 0; - pointer-events: none; - white-space: pre; -} - -.note-code-block__editor .note-code-block__surface { - min-height: var(--note-code-block-min-height); - overflow: hidden; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: normal; -} - -.note-code-block--read .note-code-block__surface { - min-height: 0; - pointer-events: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: normal; -} - -.note-code-block__code { - display: block; - min-height: calc(1em * var(--note-code-block-line-height)); - white-space: inherit; - overflow-wrap: inherit; - word-break: inherit; -} - -.note-code-block__placeholder, -.note-code-block .hljs-comment, -.note-code-block .hljs-quote { - color: var(--code-comment); -} - -.note-renderer .task-list-item { - list-style: none; -} - -.note-renderer .task-list-item input[type="checkbox"] { - margin: 0.52rem 0 0; - inline-size: 0.95rem; - block-size: 0.95rem; - flex: none; -} - -.note-renderer .task-list-item p { - flex: 1 1 auto; -} - -.note-code-block .hljs-keyword, -.note-code-block .hljs-selector-tag, -.note-code-block .hljs-literal, -.note-code-block .hljs-title.class_, -.note-code-block .hljs-built_in { - color: var(--code-keyword); -} - -.note-code-block .hljs-string, -.note-code-block .hljs-attr, -.note-code-block .hljs-regexp, -.note-code-block .hljs-template-string { - color: var(--code-string); -} - -.note-code-block .hljs-number, -.note-code-block .hljs-symbol, -.note-code-block .hljs-bullet { - color: var(--code-number); -} - -.note-code-block .hljs-function .hljs-title, -.note-code-block .hljs-title.function_, -.note-code-block .hljs-title.function_.invoke__ { - color: var(--code-function); -} - -.note-code-block .hljs-type, -.note-code-block .hljs-class .hljs-title, -.note-code-block .hljs-title.class_.inherited__ { - color: var(--code-type); -} - -.note-code-block .hljs-property, -.note-code-block .hljs-attribute, -.note-code-block .hljs-variable, -.note-code-block .hljs-template-variable { - color: var(--code-property); -} - -.note-code-block .hljs-meta, -.note-code-block .hljs-emphasis, -.note-code-block .hljs-strong { - color: var(--code-accent); -} - -.note-editor math-field, -.note-renderer math-field { - width: 100%; - --contains-highlight-background-color: transparent; - --hue: 210; - --primary: var(--foreground); - --field-border-color: var(--note-border); - --field-border-hover-color: var(--note-border-strong); - --field-focus-border-color: var(--note-border-strong); - --field-text-color: var(--foreground); - --field-background-color: var(--note-surface); - --placeholder-color: var(--note-muted-foreground); - color: var(--foreground); - background: var(--note-surface); -} - -.note-editor math-field::part(container), -.note-renderer math-field::part(container) { - background: var(--note-surface); - color: var(--foreground); -} - -.note-editor .note-inline-math-block math-field, -.note-renderer .note-inline-math-block math-field { - width: auto; - max-width: 100%; - min-width: 4rem; -} - -.note-editor .note-inline-math-block math-field::part(container), -.note-renderer .note-inline-math-block math-field::part(container) { - padding: 0; -} - -.note-renderer .katex-display { - margin: 0.25rem 0; - overflow-x: auto; - border: 1px solid var(--note-border); - border-radius: 0.95rem; - background: color-mix(in srgb, var(--note-surface-muted) 88%, transparent); - padding: 1rem 1.25rem; -} - -.note-renderer blockquote p + p { - margin-top: 0.35rem; -} - -.note-editor pre:not(.note-code-block__surface), -.note-renderer pre:not(.note-code-block__surface) { - background: var(--note-code-bg); - color: var(--note-code-fg); -} - -/* ---- Code block: language label ---- */ -.note-code-block__header { - display: flex; - align-items: center; - justify-content: flex-end; - padding: 0.35rem var(--note-code-block-padding-x) 0; -} - -.note-code-block__lang { - font-family: var(--font-mono), monospace; - font-size: 0.7rem; - letter-spacing: 0.06em; - text-transform: lowercase; - color: var(--note-muted-foreground); - opacity: 0.7; - user-select: none; - pointer-events: none; } diff --git a/app/layout.tsx b/app/layout.tsx index 8d787d0..5dc70fa 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,8 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import "./globals.css"; -import "mathlive/fonts.css"; -import "mathlive/static.css"; import { ThemeInitializer } from "@/components/ui/theme-initializer"; const geistSans = Geist({ diff --git a/app/notes/notes-workspace.tsx b/app/notes/notes-workspace.tsx index d649825..ce630da 100644 --- a/app/notes/notes-workspace.tsx +++ b/app/notes/notes-workspace.tsx @@ -25,7 +25,6 @@ import { Upload, X, } from "lucide-react"; -import { NoteSurface } from "@/components/note-editor/note-surface"; import { Button } from "@/components/ui/button"; import { cx } from "@/lib/utils"; import { @@ -1370,62 +1369,57 @@ export function NotesWorkspace({
- - { - const nextTitle = event.currentTarget.value; - setTitleDraftState({ - noteId: selectedNote.id, - value: nextTitle, - }); - setNotes((current) => - current.map((n) => - n.id === selectedNote.id - ? { ...n, title: nextTitle } - : n, - ), - ); - }} - onBlur={() => void handleTitleCommit()} - onKeyDown={(event) => { - if (event.key === "Enter") event.currentTarget.blur(); - }} - className="w-full border-none bg-transparent p-0 text-4xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/50" - placeholder="Untitled" - /> -
- {formatTimestamp(selectedNote.updatedAt)} - {selectedNote.fileName ? ( - - {selectedNote.fileName} - - ) : null} -
-
- } - onSave={async (nextContent) => { - try { - await saveNote(selectedNote.id, { - title: draftTitle.trim() || "Untitled", - content: nextContent, - }); - } catch (saveError) { - toast.error("Could not save note", { - description: - saveError instanceof Error - ? saveError.message - : undefined, - duration: 5000, +
+ { + const nextTitle = event.currentTarget.value; + setTitleDraftState({ + noteId: selectedNote.id, + value: nextTitle, }); - } - }} - /> + setNotes((current) => + current.map((n) => + n.id === selectedNote.id + ? { ...n, title: nextTitle } + : n, + ), + ); + }} + onBlur={() => void handleTitleCommit()} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className="w-full border-none bg-transparent p-0 text-4xl font-semibold tracking-tight text-foreground outline-none placeholder:text-muted-foreground/50" + placeholder="Untitled" + /> +
+ {formatTimestamp(selectedNote.updatedAt)} + {selectedNote.fileName ? ( + {selectedNote.fileName} + ) : null} +
+
+ + {/* SEAM: the note editor was removed and is being rebuilt. + Mount the new editor here. It should receive + `selectedNote.content.document` and persist through + `saveNote(selectedNote.id, { title, content })`. Until + then the stored markdown is shown read-only so note + content stays reachable. */} +
+

+ The note editor is being rebuilt. Content is read-only for now. +

+ {selectedNote.content.markdown.trim() ? ( +
+                      {selectedNote.content.markdown}
+                    
+ ) : ( +

This note is empty.

+ )} +
) : ( diff --git a/components/note-editor/code-block-tool.ts b/components/note-editor/code-block-tool.ts deleted file mode 100644 index 59b07e5..0000000 --- a/components/note-editor/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/code-block-view.tsx b/components/note-editor/code-block-view.tsx deleted file mode 100644 index 8c9b077..0000000 --- a/components/note-editor/code-block-view.tsx +++ /dev/null @@ -1,28 +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) { - const highlighted = data.code.trim().length > 0 ? highlightCode(data.code) : null; - - return ( -
-
-         0
-                ? highlightCode(data.code).html
-                : "No code yet.",
-          }}
-        />
-      
-
- ); -} diff --git a/components/note-editor/math-block-tool.test.ts b/components/note-editor/math-block-tool.test.ts deleted file mode 100644 index a18ff71..0000000 --- a/components/note-editor/math-block-tool.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - InlineMathBlockTool, - MathBlockTool, -} from "@/components/note-editor/math-block-tool"; - -describe("MathBlockTool", () => { - function createTool() { - const insert = vi.fn(); - const getBlockIndex = vi.fn().mockReturnValue(3); - - const tool = new MathBlockTool({ - data: { - latex: "x^2", - }, - api: { - blocks: { - insert, - getBlockIndex, - }, - } as never, - config: {}, - block: { - id: "math-block", - } as never, - readOnly: false, - }); - - return { - tool, - insert, - getBlockIndex, - }; - } - - it("renders an isolated math field surface", () => { - const { tool } = createTool(); - - const rendered = tool.render(); - - expect(rendered.contentEditable).toBe("false"); - expect(rendered.querySelector("math-field")).not.toBeNull(); - }); - - it("keeps math field events from bubbling to surrounding handlers", () => { - const { tool } = createTool(); - - const rendered = tool.render(); - const mathField = rendered.querySelector("math-field"); - - expect(mathField).not.toBeNull(); - - const keydownListener = vi.fn(); - const pointerListener = vi.fn(); - - document.body.append(rendered); - document.body.addEventListener("keydown", keydownListener); - document.body.addEventListener("pointerdown", pointerListener); - - mathField?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); - mathField?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); - - expect(keydownListener).not.toHaveBeenCalled(); - expect(pointerListener).not.toHaveBeenCalled(); - }); - - it("keeps plain enter inside the math block", () => { - const { tool, insert, getBlockIndex } = createTool(); - const rendered = tool.render(); - const mathField = rendered.querySelector("math-field"); - - expect(mathField).not.toBeNull(); - - const enterEvent = new KeyboardEvent("keydown", { - bubbles: true, - cancelable: true, - key: "Enter", - }); - - mathField?.dispatchEvent(enterEvent); - - expect(getBlockIndex).not.toHaveBeenCalled(); - expect(insert).not.toHaveBeenCalled(); - }); - - it("inserts a paragraph block after the math block on shift enter", () => { - const { tool, insert, getBlockIndex } = createTool(); - const rendered = tool.render(); - const mathField = rendered.querySelector("math-field"); - - expect(mathField).not.toBeNull(); - - const enterEvent = new KeyboardEvent("keydown", { - bubbles: true, - cancelable: true, - key: "Enter", - shiftKey: true, - }); - - mathField?.dispatchEvent(enterEvent); - - expect(getBlockIndex).toHaveBeenCalledWith("math-block"); - expect(insert).toHaveBeenCalledWith("paragraph", { text: "
" }, undefined, 4, true); - expect(enterEvent.defaultPrevented).toBe(true); - }); - - it("saves normalized LaTeX from typed math", () => { - const { tool } = createTool(); - const rendered = tool.render(); - const mathField = rendered.querySelector("math-field") as HTMLElement & { - value: string; - }; - - mathField.value = "√(x²)"; - - expect(tool.save()).toEqual({ latex: "\\sqrt{x^2}" }); - }); - - it("renders inline math with the compact math field variant", () => { - const tool = new InlineMathBlockTool({ - data: { - latex: "x^2", - }, - api: { - blocks: { - insert: vi.fn(), - getBlockIndex: vi.fn(), - }, - } as never, - config: { - variant: "inline", - }, - block: { - id: "inline-math-block", - } as never, - readOnly: false, - }); - - const rendered = tool.render(); - const mathField = rendered.querySelector("math-field"); - - expect(rendered.className).toContain("note-inline-math-block"); - expect(mathField?.getAttribute("placeholder")).toBe("x^2"); - }); -}); diff --git a/components/note-editor/math-block-tool.ts b/components/note-editor/math-block-tool.ts deleted file mode 100644 index a7ff1e7..0000000 --- a/components/note-editor/math-block-tool.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { BlockTool, BlockToolConstructorOptions, ToolboxConfig } from "@editorjs/editorjs"; -import type { API, BlockAPI } from "@editorjs/editorjs"; -import type { MathfieldElement } from "mathlive"; -import { normalizeLatex } from "@/lib/math/latex"; -import type { NoteMathBlockData } from "@/lib/notes/types"; - -const MATH_TOOL_ICON = ` - - - -`; - -const INLINE_MATH_TOOL_ICON = ` - - - -`; - -type MathBlockVariant = "display" | "inline"; - -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 readonly variant: MathBlockVariant; - 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: normalizeLatex(this.mathField.value), - }; - }; - - constructor({ api, block, config, data, readOnly }: BlockToolConstructorOptions) { - this.api = api; - this.block = block; - this.readOnly = readOnly; - this.variant = - (config as { variant?: MathBlockVariant } | undefined)?.variant === "inline" - ? "inline" - : "display"; - this.data = { - latex: normalizeLatex(data.latex ?? ""), - }; - } - - public render() { - const wrapper = document.createElement("div"); - wrapper.className = - this.variant === "inline" - ? "note-inline-math-block inline-flex max-w-full items-center rounded-md border border-zinc-200 bg-zinc-50 px-2 py-1 align-middle dark:border-zinc-800 dark:bg-zinc-950" - : "rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950"; - wrapper.contentEditable = "false"; - - if (this.variant === "display") { - 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 = - this.variant === "inline" - ? "block min-h-8 w-auto rounded bg-white px-2 py-1 text-base dark:bg-zinc-900" - : "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", this.variant === "inline" ? "x^2" : "\\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: normalizeLatex(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); - }); - } -} - -export class InlineMathBlockTool extends MathBlockTool { - public static override get toolbox(): ToolboxConfig { - return { - title: "Inline math", - icon: INLINE_MATH_TOOL_ICON, - }; - } -} diff --git a/components/note-editor/note-editor.test.tsx b/components/note-editor/note-editor.test.tsx deleted file mode 100644 index 5b33d66..0000000 --- a/components/note-editor/note-editor.test.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { act, render, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { NoteDocument } from "@/lib/notes/types"; - -let latestConfig: { - onChange?: () => void | Promise; -} | null = null; -let lastRenderedDocument: NoteDocument | null = null; -let mockSavedDocument: NoteDocument = { - time: 1, - blocks: [], -}; - -class MockEditorJS { - public isReady = Promise.resolve(); - - public constructor(config: { onChange?: () => void | Promise }) { - latestConfig = config; - } - - public async save() { - return mockSavedDocument; - } - - public async render(data: NoteDocument) { - lastRenderedDocument = data; - } - - public destroy() {} -} - -vi.mock("@editorjs/editorjs", () => ({ - default: MockEditorJS, -})); - -vi.mock("@editorjs/paragraph", () => ({ - default: class ParagraphTool {}, -})); - -vi.mock("@editorjs/header", () => ({ - default: class HeaderTool {}, -})); - -vi.mock("@editorjs/list", () => ({ - default: class ListTool {}, -})); - -vi.mock("@editorjs/quote", () => ({ - default: class QuoteTool {}, -})); - -vi.mock("@editorjs/image", () => ({ - default: class ImageTool {}, -})); - -vi.mock("mathlive", () => ({})); - -import { NoteEditor } from "@/components/note-editor/note-editor"; -import { emptyNoteDocument } from "@/lib/notes/types"; - -describe("NoteEditor", () => { - beforeEach(() => { - latestConfig = null; - lastRenderedDocument = null; - mockSavedDocument = { - time: 1, - blocks: [], - }; - vi.useRealTimers(); - }); - - it("emits markdown and document in the save payload", async () => { - const onSave = vi.fn().mockResolvedValue(undefined); - - render(); - - await waitFor(() => expect(latestConfig).not.toBeNull()); - expect(latestConfig?.onChange).toBeTypeOf("function"); - vi.useFakeTimers(); - - mockSavedDocument = { - time: 2, - blocks: [ - { - type: "paragraph", - data: { - text: "Hello markdown", - }, - }, - ], - }; - - await act(async () => { - await latestConfig?.onChange?.(); - await vi.advanceTimersByTimeAsync(181); - await Promise.resolve(); - }); - - expect(onSave).toHaveBeenCalledTimes(1); - expect(onSave).toHaveBeenCalledWith({ - markdown: "Hello **markdown**", - document: mockSavedDocument, - }); - - vi.useRealTimers(); - }); - - it("coalesces overlapping autosaves so only the latest content is persisted after an in-flight save", async () => { - let resolveFirstSave: (() => void) | null = null; - const onSave = vi - .fn<(content: { markdown: string; document: NoteDocument }) => Promise>() - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirstSave = resolve; - }), - ) - .mockResolvedValue(undefined); - - render(); - - await waitFor(() => expect(latestConfig).not.toBeNull()); - vi.useFakeTimers(); - - mockSavedDocument = { - time: 2, - blocks: [ - { - type: "paragraph", - data: { - text: "First version", - }, - }, - ], - }; - - void latestConfig?.onChange?.(); - await vi.advanceTimersByTimeAsync(181); - - expect(onSave).toHaveBeenCalledTimes(1); - expect(onSave.mock.calls[0]?.[0]?.markdown).toBe("First version"); - - mockSavedDocument = { - time: 3, - blocks: [ - { - type: "paragraph", - data: { - text: "Second version", - }, - }, - ], - }; - - void latestConfig?.onChange?.(); - await vi.advanceTimersByTimeAsync(181); - - expect(onSave).toHaveBeenCalledTimes(1); - - await act(async () => { - resolveFirstSave?.(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(onSave).toHaveBeenCalledTimes(2); - expect(onSave.mock.calls[1]?.[0]?.markdown).toBe("Second version"); - }); - - it("renders a new document when the parent swaps notes", async () => { - const { rerender } = render(); - - await waitFor(() => expect(latestConfig).not.toBeNull()); - - const replacementDocument: NoteDocument = { - time: 10, - blocks: [ - { - id: "replacement", - type: "paragraph", - data: { - text: "Replacement note", - }, - }, - ], - }; - - rerender(); - - await waitFor(() => expect(lastRenderedDocument).toEqual(replacementDocument)); - }); -}); diff --git a/components/note-editor/note-editor.tsx b/components/note-editor/note-editor.tsx deleted file mode 100644 index 1b97d27..0000000 --- a/components/note-editor/note-editor.tsx +++ /dev/null @@ -1,2311 +0,0 @@ -"use client"; - -import { - useCallback, - useEffect, - useEffectEvent, - useRef, - useState, - type ReactNode, -} from "react"; -import type EditorJS from "@editorjs/editorjs"; -import type { BlockToolConstructable } from "@editorjs/editorjs"; -import type { - NoteBlock, - NoteBlockType, - NoteContent, - NoteDocument, - NoteImageFileData, -} from "@/lib/notes/types"; -import { emptyNoteDocument, NoteDocumentSchema } from "@/lib/notes/types"; -import { createNoteContent } from "@/lib/notes/markdown"; -import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; -import { normalizeLatex } from "@/lib/math/latex"; -import { CodeBlockTool } from "@/components/note-editor/code-block-tool"; -import { - InlineMathBlockTool, - MathBlockTool, -} from "@/components/note-editor/math-block-tool"; - -export type NoteEditorProps = { - initialDocument: NoteDocument; - onContentChange?: (content: NoteContent) => void; - onSave?: (content: NoteContent) => Promise; - uploadImage?: (file: File) => Promise; - readOnly?: boolean; - selectionPrelude?: ReactNode; -}; - -const MAX_INLINE_IMAGE_BYTES = 2 * 1024 * 1024; -const NOTE_BLOCKS_CLIPBOARD_TYPE = "application/x-taskmaster-note-blocks"; - -type BlockContextMenuState = { - x: number; - y: number; - blockIndex: number; - blockType: NoteBlockType; -}; - -type SlashCommandMenuState = { - x: number; - y: number; - blockIndex: number; - query: string; - activeIndex: number; -}; - -type BlockConversionTarget = - | { type: "paragraph" } - | { type: "header"; level: 1 | 2 | 3 | 4 } - | { type: "list"; style: "ordered" | "unordered" | "checklist" } - | { type: "quote" } - | { type: "code" } - | { type: "math" } - | { type: "inlineMath" }; - -type SlashCommand = { - id: string; - label: string; - hint: string; - keywords: string[]; - target: BlockConversionTarget; -}; - -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: "math", - label: "Math", - hint: "Equation block", - keywords: ["equation", "latex"], - target: { type: "math" }, - }, - { - id: "inline-math", - label: "Inline math", - hint: "Small equation block", - keywords: ["equation", "latex", "inline"], - target: { type: "inlineMath" }, - }, -]; - -function areDocumentsEqual(left: NoteDocument, right: NoteDocument) { - return JSON.stringify(left) === JSON.stringify(right); -} - -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(); -} - -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": - return block.data.code; - case "math": - case "inlineMath": - return block.data.latex; - case "image": - return normalize(block.data.caption); - default: - return ""; - } -} - -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 "math": - return { - type: "math", - data: { - latex: normalizeLatex(plainText), - }, - }; - case "inlineMath": - return { - type: "inlineMath", - data: { - latex: normalizeLatex(plainText), - }, - }; - default: - return block; - } -} - -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 "math": - return { - type: "math", - data: { - latex: "", - }, - }; - case "inlineMath": - return { - type: "inlineMath", - data: { - latex: "", - }, - }; - default: - return { - type: "paragraph", - data: { - text: "", - }, - }; - } -} - -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), - ), - ); -} - -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, - }; -} - -export function NoteEditor({ - initialDocument, - onContentChange, - onSave, - uploadImage, - readOnly = false, - selectionPrelude, -}: NoteEditorProps) { - const normalizedInitialDocument = normalizeNoteLatexRegions(initialDocument); - const selectionScopeRef = useRef(null); - const holderRef = useRef(null); - const editorRef = useRef(null); - const changeTimeoutRef = useRef(null); - const renderedDocumentRef = useRef(normalizedInitialDocument); - 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, options?: { renderNormalized?: boolean }) => { - const saved = NoteDocumentSchema.parse(await editor.save()); - const holder = holderRef.current; - if (!holder) { - return normalizeNoteLatexRegions(saved); - } - - const domBlocks = Array.from(holder.querySelectorAll(".ce-block")); - if (domBlocks.length === 0) { - return normalizeNoteLatexRegions(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)); - - const document = NoteDocumentSchema.parse({ - ...saved, - blocks, - }); - - const normalized = normalizeNoteLatexRegions(document); - if ( - options?.renderNormalized && - !areDocumentsEqual(document, normalized) - ) { - await editor.render( - normalized.blocks.length > 0 - ? normalized - : { ...emptyNoteDocument }, - ); - } - - return normalized; - }, []); - - const emitContentChange = useEffectEvent(async () => { - if (!editorRef.current) { - return; - } - - try { - const document = await saveEditorDocument(editorRef.current, { - renderNormalized: true, - }); - 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), - ); - }); - }, - [], - ); - - const handleConvertBlock = (target: BlockConversionTarget) => { - const targetIndexes = getContextTargetIndexes(); - if (targetIndexes.length === 0) { - return; - } - - const targetIndexSet = new Set(targetIndexes); - void applyDocumentMutation((document) => ({ - ...document, - blocks: document.blocks.map((block, index) => - targetIndexSet.has(index) ? convertBlock(block, target) : block, - ) as NoteBlock[], - })); - }; - - const handleDeleteBlock = () => { - deleteBlocksAtIndexes(getContextTargetIndexes()); - }; - - const handleSlashCommand = useCallback((command: SlashCommand) => { - if (!slashCommandMenu) { - return; - } - - const targetIndex = slashCommandMenu.blockIndex; - void applyDocumentMutation((document) => ({ - ...document, - blocks: document.blocks.map((block, index) => - index === targetIndex - ? getBlockText(block, { plain: true }).startsWith("/") - ? createEmptyBlockForTarget(command.target) - : convertBlock(block, command.target) - : block, - ) as NoteBlock[], - })); - }, [applyDocumentMutation, slashCommandMenu]); - - const installBlockSurfaceDragging = useEffectEvent((editor: EditorJS) => { - if (readOnly || !holderRef.current || !selectionScopeRef.current) { - return () => undefined; - } - - const holder = holderRef.current; - const selectionScope = selectionScopeRef.current; - const dragThreshold = 8; - let candidateBlock: HTMLElement | null = null; - let pressedBlock: HTMLElement | null = null; - let isDraggingBlocks = false; - let isSelectingBlocks = false; - let dropIndex: number | null = null; - let selectedBlocks = new Set(); - let pointerMode: "pointer" | "mouse" | null = null; - let selectionBox: HTMLDivElement | null = null; - let dropIndicator: HTMLDivElement | null = null; - let dragPreview: HTMLDivElement | null = null; - let dragPreviewPointerOffsetX = 0; - let dragPreviewPointerOffsetY = 0; - let selectedGroupHeight = 0; - let dragLayout: Array<{ - block: HTMLElement; - height: number; - index: number; - top: number; - }> = []; - let startX = 0; - let startY = 0; - - const getBlocks = () => - Array.from(holder.querySelectorAll(".ce-block")); - const clearDropIndicator = () => { - dropIndex = null; - dropIndicator?.remove(); - dropIndicator = null; - }; - - const removeDragPreview = () => { - dragPreview?.remove(); - dragPreview = null; - }; - - const clearReflowTransforms = () => { - getBlocks().forEach((block) => { - block.style.removeProperty("transform"); - }); - }; - - const syncSelectionClasses = () => { - const blocks = getBlocks(); - selectedBlockIndexesRef.current = blocks - .map((block, index) => (selectedBlocks.has(block) ? index : -1)) - .filter((index) => index >= 0); - - blocks.forEach((block) => { - block.classList.toggle( - "note-editor__block-selected", - selectedBlocks.has(block), - ); - block.classList.toggle( - "note-editor__block-dragging", - isDraggingBlocks && selectedBlocks.has(block), - ); - }); - }; - - const setSelectedBlocks = (blocks: HTMLElement[]) => { - window.getSelection()?.removeAllRanges(); - if (blocks.length > 0 && document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - selectedBlocks = new Set(blocks); - syncSelectionClasses(); - }; - - const clearSelection = () => { - selectedBlocks = new Set(); - syncSelectionClasses(); - }; - - clearBlockSelectionRef.current = clearSelection; - - const removeSelectionBox = () => { - selectionBox?.remove(); - selectionBox = null; - }; - - const isEditorControlTarget = (target: EventTarget | null) => - target instanceof Element && - !target.closest("[data-note-selection-region]") && - Boolean( - target.closest( - [ - "button", - "input", - "textarea", - "select", - "math-field", - ".ce-toolbar", - ".ce-popover", - ".ce-inline-toolbar", - ".ce-conversion-toolbar", - ".ce-settings", - ".ce-toolbox", - ".ML__keyboard", - ".MLK__backdrop", - ".MLK__plate", - ".MLK__layer", - ].join(", "), - ), - ); - - const isContextMenuControlTarget = (target: EventTarget | null) => - target instanceof Element && - Boolean( - target.closest( - [ - ".ce-toolbar", - ".ce-popover", - ".ce-inline-toolbar", - ".ce-conversion-toolbar", - ".ce-settings", - ".ce-toolbox", - ".ML__keyboard", - ".MLK__backdrop", - ".MLK__plate", - ".MLK__layer", - ].join(", "), - ), - ); - - const getDropIndexFromPoint = (clientY: number) => { - if (dragLayout.length === 0) { - return null; - } - - let nextDropIndex = dragLayout.length; - for (const item of dragLayout) { - if (clientY < item.top + item.height / 2) { - nextDropIndex = item.index; - break; - } - } - - return nextDropIndex; - }; - - const setDropPlaceholderTop = (top: number) => { - dropIndicator ??= document.createElement("div"); - dropIndicator.className = "note-editor__drop-placeholder"; - - if (!dropIndicator.parentElement) { - holder.append(dropIndicator); - } - - const holderRect = holder.getBoundingClientRect(); - dropIndicator.style.top = `${top - holderRect.top + holder.scrollTop}px`; - dropIndicator.style.height = `${Math.max(selectedGroupHeight, 36)}px`; - }; - - const applyDynamicReflow = (clientY: number) => { - const nextDropIndex = getDropIndexFromPoint(clientY); - if (nextDropIndex === null) { - clearDropIndicator(); - return; - } - - dropIndex = nextDropIndex; - const selectedIndexes = dragLayout - .filter((item) => selectedBlocks.has(item.block)) - .map((item) => item.index); - - if (selectedIndexes.length === 0) { - clearDropIndicator(); - return; - } - - const selectedIndexSet = new Set(selectedIndexes); - const removedBeforeDrop = selectedIndexes.filter( - (index) => index < nextDropIndex, - ).length; - const adjustedDropIndex = Math.max(0, nextDropIndex - removedBeforeDrop); - const remaining = dragLayout.filter( - (item) => !selectedIndexSet.has(item.index), - ); - const selected = dragLayout.filter((item) => - selectedIndexSet.has(item.index), - ); - const visualOrder = [ - ...remaining.slice(0, adjustedDropIndex), - ...selected, - ...remaining.slice(adjustedDropIndex), - ]; - - let nextTop = dragLayout[0]?.top ?? 0; - let placeholderTop = nextTop; - const firstSelected = selected[0]; - const visualTops = new Map(); - - for (const item of visualOrder) { - if (firstSelected && item.block === firstSelected.block) { - placeholderTop = nextTop; - } - - visualTops.set(item.block, nextTop); - nextTop += item.height; - } - - dragLayout.forEach((item) => { - if (selectedIndexSet.has(item.index)) { - item.block.style.removeProperty("transform"); - return; - } - - const visualTop = visualTops.get(item.block); - if (visualTop === undefined) { - return; - } - - item.block.style.transform = `translateY(${visualTop - item.top}px)`; - }); - - setDropPlaceholderTop(placeholderTop); - }; - - const resetInteractionState = () => { - candidateBlock = null; - pressedBlock = null; - isDraggingBlocks = false; - isSelectingBlocks = false; - pointerMode = null; - removeSelectionBox(); - clearDropIndicator(); - removeDragPreview(); - clearReflowTransforms(); - dragLayout = []; - syncSelectionClasses(); - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - }; - - const finishDrag = async () => { - if (!isDraggingBlocks) return; - - // Stop live pointer tracking immediately so concurrent move events - // cannot restart a new drag or render stale previews. - isDraggingBlocks = false; - candidateBlock = null; - pointerMode = null; - window.removeEventListener("pointermove", handlePointerMove); - window.removeEventListener("pointerup", handlePointerUp); - window.removeEventListener("pointercancel", handlePointerUp); - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); - removeDragPreview(); - // Capture dropIndex before clearDropIndicator() zeroes it out. - const targetDropIndex = dropIndex; - clearDropIndicator(); - clearReflowTransforms(); - syncSelectionClasses(); - - const blocks = getBlocks(); - const selectedIndexes = blocks - .map((block, index) => (selectedBlocks.has(block) ? index : -1)) - .filter((index) => index >= 0); - - if (targetDropIndex !== null && selectedIndexes.length > 0) { - const firstSelectedIndex = selectedIndexes[0]; - const lastSelectedIndex = selectedIndexes[selectedIndexes.length - 1]; - - if ( - targetDropIndex < firstSelectedIndex || - targetDropIndex > lastSelectedIndex + 1 - ) { - const saved = await saveEditorDocument(editor); - const selectedIndexSet = new Set(selectedIndexes); - const movedBlocks = saved.blocks.filter((_, index) => - selectedIndexSet.has(index), - ); - const remainingBlocks = saved.blocks.filter( - (_, index) => !selectedIndexSet.has(index), - ); - const removedBeforeDrop = selectedIndexes.filter( - (index) => index < targetDropIndex, - ).length; - const adjustedDropIndex = Math.max( - 0, - targetDropIndex - removedBeforeDrop, - ); - const nextDocument = { - ...saved, - blocks: [ - ...remainingBlocks.slice(0, adjustedDropIndex), - ...movedBlocks, - ...remainingBlocks.slice(adjustedDropIndex), - ], - }; - - renderedDocumentRef.current = nextDocument; - await editor.render(nextDocument); - window.setTimeout(() => { - const nextBlocks = getBlocks(); - setSelectedBlocks( - nextBlocks.slice( - adjustedDropIndex, - adjustedDropIndex + movedBlocks.length, - ), - ); - void emitContentChange(); - }, 0); - } - } - - resetInteractionState(); - }; - - const getSelectedBlocksInDocumentOrder = () => - getBlocks().filter((block) => selectedBlocks.has(block)); - - const createDragPreview = () => { - const selected = getSelectedBlocksInDocumentOrder(); - if (selected.length === 0) { - return; - } - - const firstRect = selected[0].getBoundingClientRect(); - const lastRect = selected[selected.length - 1].getBoundingClientRect(); - const widestRect = selected.reduce((widest, block) => { - const rect = block.getBoundingClientRect(); - return rect.width > widest.width ? rect : widest; - }, firstRect); - - selectedGroupHeight = lastRect.bottom - firstRect.top; - dragPreviewPointerOffsetX = startX - widestRect.left; - dragPreviewPointerOffsetY = startY - firstRect.top; - dragPreview = document.createElement("div"); - dragPreview.className = "note-editor__drag-preview"; - dragPreview.style.left = `${startX - dragPreviewPointerOffsetX}px`; - dragPreview.style.top = `${startY - dragPreviewPointerOffsetY}px`; - dragPreview.style.width = `${widestRect.width}px`; - - selected.forEach((block) => { - const clone = block.cloneNode(true); - if (clone instanceof HTMLElement) { - clone.classList.remove( - "note-editor__block-selected", - "note-editor__block-dragging", - ); - clone.classList.add("note-editor__drag-preview-block"); - dragPreview?.append(clone); - } - }); - - document.body.append(dragPreview); - }; - - const updateDragPreview = (clientX: number, clientY: number) => { - if (!dragPreview) { - return; - } - - dragPreview.style.left = `${clientX - dragPreviewPointerOffsetX}px`; - dragPreview.style.top = `${clientY - dragPreviewPointerOffsetY}px`; - }; - - const updateSelectionBox = (clientX: number, clientY: number) => { - if (!selectionBox) { - selectionBox = document.createElement("div"); - selectionBox.className = "note-editor__selection-box"; - document.body.append(selectionBox); - } - - const left = Math.min(startX, clientX); - const top = Math.min(startY, clientY); - const width = Math.abs(clientX - startX); - const height = Math.abs(clientY - startY); - selectionBox.style.left = `${left}px`; - selectionBox.style.top = `${top}px`; - selectionBox.style.width = `${width}px`; - selectionBox.style.height = `${height}px`; - - const selectionRect = new DOMRect(left, top, width, height); - setSelectedBlocks( - getBlocks().filter((block) => { - const rect = block.getBoundingClientRect(); - return ( - rect.left < selectionRect.right && - rect.right > selectionRect.left && - rect.top < selectionRect.bottom && - rect.bottom > selectionRect.top - ); - }), - ); - }; - - const startDrag = (clientY: number) => { - if (!candidateBlock) { - return; - } - - // Auto-select the candidate block if it isn't already part of the - // selection (enables drag-to-reorder without a prior rubber-band select). - if (!selectedBlocks.has(candidateBlock)) { - setSelectedBlocks([candidateBlock]); - } - - isDraggingBlocks = true; - window.getSelection()?.removeAllRanges(); - dragLayout = getBlocks().map((block, index) => { - const rect = block.getBoundingClientRect(); - return { - block, - height: rect.height, - index, - top: rect.top, - }; - }); - syncSelectionClasses(); - createDragPreview(); - updateDragPreview(startX, startY); - applyDynamicReflow(clientY); - }; - - const startSelectionBox = () => { - isSelectingBlocks = true; - clearSelection(); - window.getSelection()?.removeAllRanges(); - }; - - const handlePointerMove = (event: PointerEvent) => { - if (pointerMode !== "pointer") { - return; - } - - const distance = Math.hypot( - event.clientX - startX, - event.clientY - startY, - ); - if (!isDraggingBlocks && !isSelectingBlocks && distance < dragThreshold) { - return; - } - - event.preventDefault(); - if (!candidateBlock && !isSelectingBlocks) { - startSelectionBox(); - } - - if (isSelectingBlocks) { - updateSelectionBox(event.clientX, event.clientY); - return; - } - - if (!isDraggingBlocks) { - startDrag(event.clientY); - return; - } - - updateDragPreview(event.clientX, event.clientY); - applyDynamicReflow(event.clientY); - }; - - const handlePointerUp = (event: PointerEvent) => { - if (pointerMode !== "pointer") { - return; - } - - if (isDraggingBlocks) { - event.preventDefault(); - void finishDrag(); - return; - } - - if (isSelectingBlocks) { - event.preventDefault(); - } else if (pressedBlock) { - if (selectedBlocks.has(pressedBlock)) { - event.preventDefault(); - clearSelection(); - } else { - clearSelection(); - } - } else { - clearSelection(); - } - - resetInteractionState(); - }; - - const handlePointerDown = (event: PointerEvent) => { - if ( - pointerMode || - event.button !== 0 || - isEditorControlTarget(event.target) - ) { - return; - } - - const block = - event.target instanceof Element - ? event.target.closest(".ce-block") - : null; - const isBlockSelected = block && selectedBlocks.has(block); - - if (block && !isBlockSelected) { - // Block is not selected — clear any prior selection. We still set up - // pointer tracking so a drag gesture will work without a prior - // rubber-band select. If the user just clicks (no drag), pointerup - // calls clearSelection and allows native focus/cursor to work. - clearSelection(); - pointerMode = "pointer"; - pressedBlock = block; - candidateBlock = block; // allow immediate drag - startX = event.clientX; - startY = event.clientY; - // No preventDefault — let the click focus the block for text editing. - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerUp); - return; - } - - pointerMode = "pointer"; - pressedBlock = block; - candidateBlock = isBlockSelected ? block : null; - startX = event.clientX; - startY = event.clientY; - if (isBlockSelected) { - event.preventDefault(); - } - window.addEventListener("pointermove", handlePointerMove); - window.addEventListener("pointerup", handlePointerUp); - window.addEventListener("pointercancel", handlePointerUp); - }; - - const handleMouseMove = (event: MouseEvent) => { - if (pointerMode !== "mouse") { - return; - } - - const distance = Math.hypot( - event.clientX - startX, - event.clientY - startY, - ); - if (!isDraggingBlocks && !isSelectingBlocks && distance < dragThreshold) { - return; - } - - event.preventDefault(); - if (!candidateBlock && !isSelectingBlocks) { - startSelectionBox(); - } - - if (isSelectingBlocks) { - updateSelectionBox(event.clientX, event.clientY); - return; - } - - if (!isDraggingBlocks) { - startDrag(event.clientY); - return; - } - - updateDragPreview(event.clientX, event.clientY); - applyDynamicReflow(event.clientY); - }; - - const handleMouseUp = (event: MouseEvent) => { - if (pointerMode !== "mouse") { - return; - } - - if (isDraggingBlocks) { - event.preventDefault(); - void finishDrag(); - return; - } - - if (isSelectingBlocks) { - event.preventDefault(); - } else if (pressedBlock) { - if (selectedBlocks.has(pressedBlock)) { - event.preventDefault(); - clearSelection(); - } else { - clearSelection(); - } - } else { - clearSelection(); - } - - resetInteractionState(); - }; - - const handleMouseDown = (event: MouseEvent) => { - if ( - pointerMode || - event.button !== 0 || - isEditorControlTarget(event.target) - ) { - return; - } - - const block = - event.target instanceof Element - ? event.target.closest(".ce-block") - : null; - const isBlockSelected = block && selectedBlocks.has(block); - - if (block && !isBlockSelected) { - clearSelection(); - pointerMode = "mouse"; - pressedBlock = block; - candidateBlock = block; - startX = event.clientX; - startY = event.clientY; - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - return; - } - - pointerMode = "mouse"; - pressedBlock = block; - candidateBlock = isBlockSelected ? block : null; - startX = event.clientX; - startY = event.clientY; - if (isBlockSelected) { - event.preventDefault(); - } - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); - }; - - const handleContextMenu = (event: MouseEvent) => { - if (isContextMenuControlTarget(event.target)) { - return; - } - - const block = - event.target instanceof Element - ? event.target.closest(".ce-block") - : null; - - if (!block) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - resetInteractionState(); - - void (async () => { - try { - const blockIndex = getBlocks().indexOf(block); - if (blockIndex < 0) { - return; - } - - const saved = await saveEditorDocument(editor); - const noteBlock = saved.blocks[blockIndex]; - if (!noteBlock) { - return; - } - - // 220px wide menu, 320px max height — clamp so it never clips viewport. - const menuW = 220; - const menuH = 320; - setBlockContextMenu({ - x: Math.min(event.clientX, window.innerWidth - menuW - 8), - y: Math.min(event.clientY, window.innerHeight - menuH - 8), - blockIndex, - blockType: noteBlock.type, - }); - } catch (error) { - console.error("Failed to open note block menu.", error); - } - })(); - }; - - selectionScope.addEventListener("pointerdown", handlePointerDown, true); - selectionScope.addEventListener("mousedown", handleMouseDown, true); - selectionScope.addEventListener("contextmenu", handleContextMenu, true); - window.addEventListener("blur", resetInteractionState); - - return () => { - selectionScope.removeEventListener( - "pointerdown", - handlePointerDown, - true, - ); - selectionScope.removeEventListener("mousedown", handleMouseDown, true); - selectionScope.removeEventListener( - "contextmenu", - handleContextMenu, - true, - ); - window.removeEventListener("blur", resetInteractionState); - resetInteractionState(); - clearBlockSelectionRef.current = () => undefined; - selectedBlockIndexesRef.current = []; - }; - }); - - useEffect(() => { - let disposed = false; - - async function initEditor() { - if (!holderRef.current) { - return; - } - - 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"), - ]); - - if (disposed || !holderRef.current) { - return; - } - - 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; - - const editor = new EditorJSClass({ - holder: holderRef.current, - autofocus: !readOnly, - readOnly, - minHeight: 0, - data: - renderedDocumentRef.current.blocks.length > 0 - ? renderedDocumentRef.current - : { ...emptyNoteDocument }, - 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, - }, - 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, - }, - inlineMath: { - class: InlineMathBlockTool as unknown as BlockToolConstructable, - config: { - variant: "inline", - }, - }, - }, - async onChange() { - if (changeTimeoutRef.current) { - window.clearTimeout(changeTimeoutRef.current); - } - - changeTimeoutRef.current = window.setTimeout(() => { - void emitContentChange(); - }, 180); - }, - }); - - editorRef.current = editor; - - try { - await editor.isReady; - if (!disposed) { - dragCleanupRef.current?.(); - dragCleanupRef.current = installBlockSurfaceDragging(editor); - } - } catch (error) { - if (!disposed) { - console.error("Failed to initialize the editor.", error); - } - } - } - - void initEditor(); - - return () => { - disposed = true; - - if (changeTimeoutRef.current) { - window.clearTimeout(changeTimeoutRef.current); - changeTimeoutRef.current = null; - - if (!readOnly) { - void emitContentChange(); - } - } - - const editor = editorRef.current; - editorRef.current = null; - pendingBlockFocusTimersRef.current.forEach((timer) => { - window.clearTimeout(timer); - }); - pendingBlockFocusTimersRef.current = []; - if (shiftEnterChainTimerRef.current !== null) { - window.clearTimeout(shiftEnterChainTimerRef.current); - shiftEnterChainTimerRef.current = null; - } - shiftEnterInsertIndexRef.current = null; - dragCleanupRef.current?.(); - dragCleanupRef.current = null; - - if (editor) { - void editor.isReady.then(() => editor.destroy()).catch(() => undefined); - } - }; - }, [readOnly]); - - useEffect(() => { - const nextDocument = normalizeNoteLatexRegions(initialDocument); - - if (areDocumentsEqual(nextDocument, renderedDocumentRef.current)) { - return; - } - - renderedDocumentRef.current = nextDocument; - pendingSaveRef.current = null; - - const editor = editorRef.current; - - if (!editor) { - return; - } - - void editor.isReady - .then(() => - editor.render( - nextDocument.blocks.length > 0 - ? nextDocument - : { ...emptyNoteDocument }, - ), - ) - .catch((error) => { - console.error("Failed to sync note document.", error); - }); - }, [initialDocument]); - - useEffect(() => { - if (readOnly || !selectionScopeRef.current || !holderRef.current) { - return; - } - - const selectionScope = selectionScopeRef.current; - const holder = holderRef.current; - - const isInsideScope = (target: EventTarget | null) => - target instanceof Node && selectionScope.contains(target); - - const isTypingTarget = (target: EventTarget | null) => - target instanceof Element && - Boolean( - target.closest( - '[contenteditable="true"], textarea, input, select, math-field', - ), - ); - - const hasTextSelection = () => - Boolean(window.getSelection()?.toString().trim()); - - const getTargetBlockIndex = (target: EventTarget | null) => { - if (!(target instanceof Element)) { - return renderedDocumentRef.current.blocks.length - 1; - } - - const block = target.closest(".ce-block"); - if (!block) { - return renderedDocumentRef.current.blocks.length - 1; - } - - return Array.from(holder.querySelectorAll(".ce-block")).indexOf( - block, - ); - }; - - const getEventBlockIndex = (target: EventTarget | null) => { - const blockIndex = getTargetBlockIndex(target); - return blockIndex >= 0 ? blockIndex : renderedDocumentRef.current.blocks.length - 1; - }; - - const writeBlocksToClipboard = ( - clipboardData: DataTransfer, - blocks: NoteBlock[], - ) => { - const document = { - time: Date.now(), - blocks, - }; - const payload = JSON.stringify({ - type: "taskmaster.noteBlocks", - version: 1, - blocks: cloneBlocksForInsert(blocks), - }); - - clipboardData.setData(NOTE_BLOCKS_CLIPBOARD_TYPE, payload); - clipboardData.setData("text/plain", createNoteContent(document).markdown); - }; - - const 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 []; - } - }; - - const getSelectedBlocks = () => { - const indexes = selectedBlockIndexesRef.current; - if (indexes.length === 0) { - return []; - } - - return getBlocksAtIndexes(renderedDocumentRef.current, indexes); - }; - - const handleCopy = (event: ClipboardEvent) => { - if (hasTextSelection() || !event.clipboardData) { - return; - } - - const blocks = getSelectedBlocks(); - if (blocks.length === 0) { - return; - } - - event.preventDefault(); - writeBlocksToClipboard(event.clipboardData, blocks); - }; - - const handleCut = (event: ClipboardEvent) => { - if (hasTextSelection() || !event.clipboardData) { - return; - } - - const selectedIndexes = selectedBlockIndexesRef.current; - const blocks = getSelectedBlocks(); - if (blocks.length === 0) { - return; - } - - event.preventDefault(); - writeBlocksToClipboard(event.clipboardData, blocks); - deleteBlocksAtIndexes(selectedIndexes); - }; - - const handlePaste = (event: ClipboardEvent) => { - const selectedIndexes = selectedBlockIndexesRef.current; - if ( - !event.clipboardData || - (selectedIndexes.length === 0 && !isInsideScope(event.target)) - ) { - return; - } - - if (selectedIndexes.length === 0 && isTypingTarget(event.target)) { - return; - } - - const blocks = readBlocksFromClipboard(event.clipboardData); - if (blocks.length === 0) { - return; - } - - event.preventDefault(); - const targetIndex = - selectedIndexes.length > 0 - ? Math.max(...selectedIndexes) - : getTargetBlockIndex(event.target); - insertBlocksAfterIndex(targetIndex, blocks); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - const selectedIndexes = selectedBlockIndexesRef.current; - const hasSelectedBlocks = selectedIndexes.length > 0; - if (!hasSelectedBlocks && !isInsideScope(event.target)) { - return; - } - - if (event.key === "Enter" && event.shiftKey && isInsideScope(event.target)) { - event.preventDefault(); - event.stopImmediatePropagation(); - insertEmptyParagraphAfterIndex( - shiftEnterInsertIndexRef.current ?? getEventBlockIndex(event.target), - ); - return; - } - - if (event.key !== "Shift") { - shiftEnterInsertIndexRef.current = null; - } - - if (event.key === "Escape") { - if (hasSelectedBlocks) { - event.preventDefault(); - clearBlockSelectionRef.current(); - } - return; - } - - if (!hasSelectedBlocks || hasTextSelection()) { - return; - } - - if (event.key === "Delete" || event.key === "Backspace") { - event.preventDefault(); - deleteBlocksAtIndexes(selectedIndexes); - return; - } - - if (event.altKey && event.key === "ArrowUp") { - event.preventDefault(); - moveBlocksAtIndexes(selectedIndexes, -1); - return; - } - - if (event.altKey && event.key === "ArrowDown") { - event.preventDefault(); - moveBlocksAtIndexes(selectedIndexes, 1); - } - }; - - window.addEventListener("copy", handleCopy, true); - window.addEventListener("cut", handleCut, true); - window.addEventListener("paste", handlePaste, true); - window.addEventListener("keydown", handleKeyDown, true); - - return () => { - window.removeEventListener("copy", handleCopy, true); - window.removeEventListener("cut", handleCut, true); - window.removeEventListener("paste", handlePaste, true); - window.removeEventListener("keydown", handleKeyDown, true); - }; - }, [ - cloneBlocksForInsert, - deleteBlocksAtIndexes, - getBlocksAtIndexes, - insertEmptyParagraphAfterIndex, - insertBlocksAfterIndex, - moveBlocksAtIndexes, - readOnly, - ]); - - useEffect(() => { - if (readOnly || !holderRef.current) { - return; - } - - const holder = holderRef.current; - - const getFocusedBlock = (target: EventTarget | null) => { - if (!(target instanceof Element)) { - return null; - } - - if ( - !target.closest( - '[contenteditable="true"], textarea, input, math-field', - ) - ) { - return null; - } - - return target.closest(".ce-block"); - }; - - const updateSlashMenu = () => { - const activeElement = document.activeElement; - const block = getFocusedBlock(activeElement); - - if (!block) { - setSlashCommandMenu(null); - return; - } - - const blockIndex = Array.from( - holder.querySelectorAll(".ce-block"), - ).indexOf(block); - - if (blockIndex < 0) { - setSlashCommandMenu(null); - return; - } - - const text = stripHtml(block.textContent ?? ""); - const match = text.match(/^\/([^\s]*)?$/); - - if (!match) { - setSlashCommandMenu(null); - return; - } - - const selectionRect = window.getSelection()?.rangeCount - ? window.getSelection()?.getRangeAt(0).getBoundingClientRect() - : null; - const blockRect = block.getBoundingClientRect(); - const rawX = selectionRect && selectionRect.left > 0 ? selectionRect.left : blockRect.left + 32; - const rawY = - selectionRect && selectionRect.bottom > 0 - ? selectionRect.bottom + 8 - : blockRect.top + 36; - // Clamp so the 288px-wide menu doesn't overflow the viewport. - const x = Math.min(rawX, window.innerWidth - 296); - const y = rawY; - - setSlashCommandMenu((current) => { - const query = match[1] ?? ""; - const matches = getSlashCommandMatches(query); - const activeIndex = - current?.blockIndex === blockIndex && current.query === query - ? current.activeIndex - : 0; - - return { - x, - y, - blockIndex, - query, - activeIndex: Math.min(activeIndex, Math.max(matches.length - 1, 0)), - }; - }); - }; - - const handleInput = () => { - window.requestAnimationFrame(updateSlashMenu); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setSlashCommandMenu(null); - return; - } - - if (!slashCommandMenu) { - return; - } - - const matches = getSlashCommandMatches(slashCommandMenu.query); - - if (event.key === "ArrowDown") { - event.preventDefault(); - setSlashCommandMenu((current) => - current - ? { - ...current, - activeIndex: - matches.length > 0 - ? (current.activeIndex + 1) % matches.length - : 0, - } - : current, - ); - return; - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - setSlashCommandMenu((current) => - current - ? { - ...current, - activeIndex: - matches.length > 0 - ? (current.activeIndex - 1 + matches.length) % matches.length - : 0, - } - : current, - ); - return; - } - - if (event.key === "Enter") { - const activeCommand = matches[slashCommandMenu.activeIndex] ?? matches[0]; - if (activeCommand) { - event.preventDefault(); - handleSlashCommand(activeCommand); - } - } - }; - - holder.addEventListener("input", handleInput, true); - holder.addEventListener("keyup", handleInput, true); - holder.addEventListener("focusin", handleInput, true); - holder.addEventListener("keydown", handleKeyDown, true); - - return () => { - holder.removeEventListener("input", handleInput, true); - holder.removeEventListener("keyup", handleInput, true); - holder.removeEventListener("focusin", handleInput, true); - holder.removeEventListener("keydown", handleKeyDown, true); - }; - }, [handleSlashCommand, readOnly, slashCommandMenu]); - - useEffect(() => { - if (!blockContextMenu) { - return; - } - - const closeMenu = (event: Event) => { - if ( - event.target instanceof Element && - event.target.closest("[data-note-block-context-menu]") - ) { - return; - } - - setBlockContextMenu(null); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setBlockContextMenu(null); - } - }; - - window.addEventListener("pointerdown", closeMenu, true); - window.addEventListener("scroll", closeMenu, true); - window.addEventListener("keydown", handleKeyDown); - - return () => { - window.removeEventListener("pointerdown", closeMenu, true); - window.removeEventListener("scroll", closeMenu, true); - window.removeEventListener("keydown", handleKeyDown); - }; - }, [blockContextMenu]); - - return ( -
- {selectionPrelude} -
- {slashCommandMenu ? ( -
- {getSlashCommandMatches(slashCommandMenu.query).length > 0 ? ( - getSlashCommandMatches(slashCommandMenu.query).map((command, index) => ( - - )) - ) : ( -
No commands
- )} -
- ) : null} - {blockContextMenu ? ( -
event.preventDefault()} - > -
- Turn into - -
- - - - - - - - - - - -
-
-
- -
- {blockContextMenu.blockType} -
-
- ) : null} -
- ); -} diff --git a/components/note-editor/note-renderer.test.tsx b/components/note-editor/note-renderer.test.tsx deleted file mode 100644 index 96900fd..0000000 --- a/components/note-editor/note-renderer.test.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { NoteRenderer } from "@/components/note-editor/note-renderer"; - -describe("NoteRenderer", () => { - it("renders saved markdown with task lists, code, images, and math", () => { - const { container } = render( - , - ); - - expect(screen.getByText("Sprint Notes")).toBeInTheDocument(); - expect(container.querySelector('input[type="checkbox"]')).toBeChecked(); - expect(container.querySelector("ol")).not.toBeNull(); - expect(screen.getByText("Work hard")).toBeInTheDocument(); - expect(container.querySelector('img[alt="Roadmap"]')).toHaveAttribute( - "src", - "https://example.com/roadmap.png", - ); - expect(container.querySelector("pre code")?.textContent).toContain('console.log("done");'); - expect(container.querySelector(".katex-display")).not.toBeNull(); - }); - - it("shows an empty state when markdown is blank", () => { - render(); - - expect(screen.getByText("No note content yet.")).toBeInTheDocument(); - }); -}); diff --git a/components/note-editor/note-renderer.tsx b/components/note-editor/note-renderer.tsx deleted file mode 100644 index 15d40da..0000000 --- a/components/note-editor/note-renderer.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import { isValidElement } from "react"; -import type { ReactNode } from "react"; -import ReactMarkdown from "react-markdown"; -import rehypeKatex from "rehype-katex"; -import remarkGfm from "remark-gfm"; -import remarkMath from "remark-math"; -import { CodeBlockView } from "@/components/note-editor/code-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; -} - -export function NoteRenderer({ markdown }: { markdown: string }) { - if (markdown.trim().length === 0) { - return ( -
- No note content yet. -
- ); - } - - return ( -
- { - const { alt, src } = omitNode(props); - if (!src) { - 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)); - 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} - - ); - }, - }} - > - {markdown} - -
- ); -} diff --git a/components/note-editor/note-surface.test.tsx b/components/note-editor/note-surface.test.tsx deleted file mode 100644 index 16be055..0000000 --- a/components/note-editor/note-surface.test.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/components/note-editor/note-editor", () => ({ - NoteEditor: ({ - initialDocument, - onContentChange, - onSave, - }: { - initialDocument: { blocks: Array<{ id?: string; type: string }> }; - onContentChange?: (content: { - markdown: string; - document: { - time: number; - blocks: unknown[]; - }; - }) => void; - onSave?: (content: { - markdown: string; - document: { - time: number; - blocks: unknown[]; - }; - }) => Promise | void; - }) => { - const firstBlock = initialDocument.blocks[0]; - const blockId = firstBlock?.id ?? "note-block"; - - return ( -
-

Mock Editor.js UI

- - -
- ); - }, -})); - -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/note-surface.tsx b/components/note-editor/note-surface.tsx deleted file mode 100644 index 46ab6e1..0000000 --- a/components/note-editor/note-surface.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useState, type ReactNode } from "react"; -import { NoteEditor } from "@/components/note-editor/note-editor"; -import { NoteRenderer } from "@/components/note-editor/note-renderer"; -import { createNoteContent } from "@/lib/notes/markdown"; -import type { NoteContent, NoteDocument, NoteImageFileData } from "@/lib/notes/types"; - -export type NoteSurfaceProps = { - initialDocument: NoteDocument; - onContentChange?: (content: NoteContent) => void; - onSave?: (content: NoteContent) => Promise; - uploadImage?: (file: File) => Promise; - readOnly?: boolean; - keepEditingWhenEmpty?: boolean; - selectionPrelude?: ReactNode; -}; - -function areDocumentsEqual(left: NoteDocument, right: NoteDocument) { - return JSON.stringify(left) === JSON.stringify(right); -} - -export function NoteSurface({ - initialDocument, - onContentChange, - onSave, - uploadImage, - readOnly = false, - selectionPrelude, -}: NoteSurfaceProps) { - const [draftState, setDraftState] = useState(() => ({ - sourceDocument: initialDocument, - content: createNoteContent(initialDocument), - })); - const content = areDocumentsEqual(draftState.sourceDocument, initialDocument) - ? draftState.content - : createNoteContent(initialDocument); - - const updateContent = (nextContent: NoteContent) => { - setDraftState({ - sourceDocument: initialDocument, - content: nextContent, - }); - }; - - if (!readOnly) { - return ( -
- { - updateContent(nextContent); - onContentChange?.(nextContent); - }} - onSave={async (nextContent) => { - updateContent(nextContent); - await onSave?.(nextContent); - }} - uploadImage={uploadImage} - selectionPrelude={selectionPrelude} - /> -
- ); - } - - return ( -
-
- -
-
- ); -} diff --git a/docs/note-editor-requirements.md b/docs/note-editor-requirements.md new file mode 100644 index 0000000..fbdc3ef --- /dev/null +++ b/docs/note-editor-requirements.md @@ -0,0 +1,144 @@ +# Note Editor — Requirements + +Status: **collecting requirements.** The previous Editor.js-based editor was +removed (see [Background](#background)); nothing has been chosen or built yet. + +Requirements go in the section below as they are given. Everything after it is +verified context about the codebase as it stands today — constraints the +rebuild has to live within, not decisions that have been made. + +--- + +## 1. Requirements + +> _Awaiting input. Each requirement gets an ID (`NE-1`, `NE-2`, …) so it can be +> referenced from issues, commits, and tests._ + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| | | | | + +### Notes / detail + +_(Longer explanation for any requirement that needs more than a table row.)_ + +--- + +## 2. Open questions + +_(Things that need a decision before or during the build.)_ + +| # | Question | Blocking? | Resolution | +|---|----------|-----------|------------| +| | | | | + +--- + +## Background + +The prior editor was a ~4,200-line custom shell around Editor.js +(`components/note-editor/`), removed on the `develop` working tree along with +its 1,100 lines of CSS and the `@editorjs/*`, `mathlive`, `mermaid`, and +`highlight.js` dependencies. + +To restore it for reference: `git checkout -- components/note-editor` +(scope the path — do not use a blanket `git checkout .`, it would revert +unrelated uncommitted work). + +What it did, for reference when deciding what to keep: + +- Editor.js core with paragraph / header / list / quote / image tools +- Custom block tools for `code`, `mermaid`, and `math` (MathLive) +- Marquee (rubber-band) block selection, multi-block drag reorder with live + drop indicator, multi-block copy/cut/paste +- Custom slash menu and right-click block menu +- 180 ms debounced autosave with a single-flight save queue +- A DOM-reconciliation step on save to preserve empty paragraphs, which + Editor.js drops from its own output + +--- + +## Where it plugs in + +`app/notes/notes-workspace.tsx:1411` (marked `SEAM:`). The workspace still owns +note CRUD, the sidebar, class grouping, and the title input — the editor is +only responsible for the note **body**. + +Contract the seam expects: + +- **Receives** `selectedNote.content.document` (a `NoteDocument`) +- **Persists** via `saveNote(selectedNote.id, { title, content })` +- Must not save while `isTempNote(id)` is true (optimistic-create guard) + +Currently the seam renders `selectedNote.content.markdown` read-only so notes +stay viewable until the replacement lands. + +--- + +## Data layer (kept — this is the contract) + +`lib/notes/` survived the removal and is what the API, DB, and AI generation +all speak. It is now **editor-agnostic**: `types.ts` used to import +`OutputBlockData`/`OutputData` from `@editorjs/editorjs` and now defines an +equivalent `NoteBlockShape` locally, so any editor may be used. + +| Module | Role | +|--------|------| +| `types.ts` | `NoteDocument` / `NoteBlock` Zod schemas — the stored format | +| `parse-markdown.ts` | Markdown → note blocks | +| `markdown.ts` | Note blocks → Markdown (uses `turndown`) | +| `records.ts` | DB row ⇄ `NoteContent` | +| `persistence.ts` | Used by `/api/notes` and `/api/notes/[id]` | +| `generation.ts` | AI note generation from uploads (`/api/notes/upload`) | + +`NoteContent` is `{ markdown: string; document: NoteDocument }` — both +representations are stored, and the markdown form is what feeds embeddings, +flashcards, and quizzes. + +**Block types currently in the schema:** `paragraph`, `header`, `list`, +`quote`, `code`, `mermaid`, `image`, `math`. + +A new editor does not have to support all of these, but changing or dropping a +block type means deciding what happens to notes already stored in that shape. +Current DB state: **58 notes, 0 using math blocks.** + +--- + +## Constraints that already apply + +From `AGENTS.md` — these hold regardless of what is chosen: + +- **§1** Every async operation needs loading, error, and success states. Toasts + via `sonner` for API results; never render raw API errors or Zod issues. +- **§2** The shell is `h-screen overflow-hidden`. The editor must scroll inside + its own `min-h-0 flex-1 overflow-y-auto` container, never the page body. +- **§7** Semantic tokens only (`bg-surface`, `text-foreground`, …). Must work + in light and dark mode. +- **§9** Extract and reuse components; do not duplicate markup. +- **§10** Derive state during render, not in effects. Event handlers over + effects for interaction. `useRef` for transient values. Dynamic-import heavy + libraries with `ssr: false`. +- **§12** Reuse the `MarkdownText` pattern (ReactMarkdown + `remark-gfm` + + `remark-math` + `rehype-katex`) for LaTeX. Do not add a second math renderer. + +**Still installed and available:** `zod` v4, `sonner`, `lucide-react`, +`react-markdown` + `remark-gfm` + `remark-math` + `rehype-katex`, `katex`, +`turndown` + `turndown-plugin-gfm`, `radix-ui` (installed, unused in app code). + +**Removed — would need re-adding:** `@editorjs/*`, `mathlive`, `mermaid`, +`highlight.js`. + +--- + +## Known issues this rebuild touches + +| Issue | Relevance | +|-------|-----------| +| [#58](https://github.com/hamizfaraz/TaskMaster/issues/58) | Normalize all math input to LaTeX — the old block stored `{ latex }` via MathLive | +| [#86](https://github.com/hamizfaraz/TaskMaster/issues/86) | Editor images were base64-inlined into `note.content` jsonb; needs blob storage | +| [#7](https://github.com/hamizfaraz/TaskMaster/issues/7) | Detect definitions/formulas and highlight them; multi-topic split suggestions | +| [#8](https://github.com/hamizfaraz/TaskMaster/issues/8) | Mind map builder from note content | +| [#89](https://github.com/hamizfaraz/TaskMaster/issues/89) | Flashcard generation should prioritize highlighted sections | + +`#7`, `#8`, and `#89` all depend on the editor exposing *highlights* in some +retrievable form — worth settling early, since it affects the block schema. diff --git a/lib/notes/code-highlighter.ts b/lib/notes/code-highlighter.ts deleted file mode 100644 index 0121d52..0000000 --- a/lib/notes/code-highlighter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import hljs from "highlight.js/lib/common"; - -function escapeHtml(value: string) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -export type HighlightResult = { - html: string; - /** The language actually used (explicit hint or hljs auto-detected). */ - language: string | null; -}; - -/** - * Highlight `code` using highlight.js. - * - * If an explicit `language` is provided and hljs recognises it, that language - * is used directly. Otherwise hljs.highlightAuto() is called and the detected - * language is returned alongside the highlighted HTML. - */ -export function highlightCode(code: string, language?: string): HighlightResult { - if (code.trim().length === 0) { - return { html: escapeHtml(code), language: language ?? null }; - } - - if (language && hljs.getLanguage(language)) { - const result = hljs.highlight(code, { language }); - return { html: result.value, language }; - } - - const result = hljs.highlightAuto(code); - return { html: result.value, language: result.language ?? null }; -} diff --git a/lib/notes/types.ts b/lib/notes/types.ts index 0927a59..419d5d1 100644 --- a/lib/notes/types.ts +++ b/lib/notes/types.ts @@ -1,4 +1,25 @@ -import type { OutputBlockData, OutputData } from "@editorjs/editorjs"; +/** + * Editor-agnostic block/document shape. + * + * Mirrors what the previous Editor.js integration produced so stored notes + * keep parsing, but defined locally so the persistence layer does not depend + * on any editor library. + */ +export type NoteBlockShape< + Type extends string = string, + Data extends object = Record, +> = { + id?: string; + type: Type; + data: Data; + tunes?: Record; +}; + +type NoteDocumentShape = { + version?: string; + time?: number; +}; + import { z } from "zod"; export type RichTextBlockType = "paragraph" | "header" | "quote"; @@ -72,16 +93,16 @@ export type NoteMathBlockData = { export type NoteInlineMathBlockData = NoteMathBlockData; export type NoteBlock = - | OutputBlockData<"paragraph", NoteParagraphBlockData> - | OutputBlockData<"header", NoteHeaderBlockData> - | OutputBlockData<"list", NoteListBlockData> - | OutputBlockData<"quote", NoteQuoteBlockData> - | OutputBlockData<"code", NoteCodeBlockData> - | OutputBlockData<"image", NoteImageBlockData> - | OutputBlockData<"math", NoteMathBlockData> - | OutputBlockData<"inlineMath", NoteInlineMathBlockData>; - -export type NoteDocument = Omit & { + | NoteBlockShape<"paragraph", NoteParagraphBlockData> + | NoteBlockShape<"header", NoteHeaderBlockData> + | NoteBlockShape<"list", NoteListBlockData> + | NoteBlockShape<"quote", NoteQuoteBlockData> + | NoteBlockShape<"code", NoteCodeBlockData> + | NoteBlockShape<"image", NoteImageBlockData> + | NoteBlockShape<"math", NoteMathBlockData> + | NoteBlockShape<"inlineMath", NoteInlineMathBlockData>; + +export type NoteDocument = NoteDocumentShape & { blocks: NoteBlock[]; }; diff --git a/package.json b/package.json index f0235a4..9973fdc 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "taskmaster", "version": "0.1.0", "private": true, + "packageManager": "pnpm@11.23.0", "scripts": { "dev": "next dev", "build": "next build", @@ -15,13 +16,6 @@ "dependencies": { "@ai-sdk/google": "^3.0.58", "@better-auth/drizzle-adapter": "^1.5.6", - "@editorjs/code": "^2.9.4", - "@editorjs/editorjs": "^2.31.5", - "@editorjs/header": "^2.8.8", - "@editorjs/image": "^2.10.3", - "@editorjs/list": "^2.0.9", - "@editorjs/paragraph": "^2.11.7", - "@editorjs/quote": "^2.7.6", "@google/genai": "^1.48.0", "@neondatabase/serverless": "^1.0.2", "ai": "^6.0.146", @@ -29,10 +23,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", - "highlight.js": "^11.11.1", "katex": "^0.16.45", "lucide-react": "^1.9.0", - "mathlive": "^0.109.1", "next": "16.2.2", "pg": "^8.20.0", "radix-ui": "^1.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a6e69..95c82d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,27 +14,6 @@ importers: '@better-auth/drizzle-adapter': specifier: ^1.5.6 version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.20.0)(kysely@0.28.15)(pg@8.20.0)) - '@editorjs/code': - specifier: ^2.9.4 - version: 2.9.4 - '@editorjs/editorjs': - specifier: ^2.31.5 - version: 2.31.5 - '@editorjs/header': - specifier: ^2.8.8 - version: 2.8.8 - '@editorjs/image': - specifier: ^2.10.3 - version: 2.10.3 - '@editorjs/list': - specifier: ^2.0.9 - version: 2.0.9 - '@editorjs/paragraph': - specifier: ^2.11.7 - version: 2.11.7 - '@editorjs/quote': - specifier: ^2.7.6 - version: 2.7.6 '@google/genai': specifier: ^1.48.0 version: 1.49.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) @@ -56,18 +35,12 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@types/pg@8.20.0)(kysely@0.28.15)(pg@8.20.0) - highlight.js: - specifier: ^11.11.1 - version: 11.11.1 katex: specifier: ^0.16.45 version: 0.16.45 lucide-react: specifier: ^1.9.0 version: 1.9.0(react@19.2.4) - mathlive: - specifier: ^0.109.1 - version: 0.109.1 next: specifier: 16.2.2 version: 16.2.2(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -420,19 +393,6 @@ packages: resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true - '@codexteam/icons@0.0.4': - resolution: {integrity: sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q==} - - '@codexteam/icons@0.0.5': - resolution: {integrity: sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==} - - '@codexteam/icons@0.3.3': - resolution: {integrity: sha512-cp7mkZPgmBuSxigTm3Vb+DtVHYeX7qXfQd7o05vcLD8Ag5WvRlol2QSn5P10k0CDAJwmkH9nQGQLBycErS9lsQ==} - - '@cortex-js/compute-engine@0.30.2': - resolution: {integrity: sha512-Zx+iisk9WWdbxjm8EYsneIBszvjfUs7BHNwf1jBtSINIgfWGpHrTTq9vW0J59iGCFt6bOFxbmWyxNMRSmksHMA==} - engines: {node: '>=21.7.3', npm: '>=10.5.0'} - '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -482,42 +442,6 @@ packages: peerDependencies: '@noble/ciphers': ^1.0.0 - '@editorjs/caret@1.0.3': - resolution: {integrity: sha512-VmgwQJZgL/LQjk049JunzRV1YCa0vDi+BNEpbDmr5cp3lGZllq9QQFO1eI71ZPzvFVn3vvhb+eOif4sAEyGgbw==} - - '@editorjs/code@2.9.4': - resolution: {integrity: sha512-c0zyWodNqjL/0WI67sZvACIOFU9IAHG0UeeIpjss8pZGGNBum+UWkh7nKULK0SYvaOrdPdlWWqjuFU1TFA5jUA==} - - '@editorjs/dom@0.0.5': - resolution: {integrity: sha512-SZ78Gwpkp3EUhjBIp0lSojeQ35V9acF8SubJsMeOH/vlOUE40GOnvvwWZnF05lO7bIB0dOHhhJy4N7IIAWxP2w==} - - '@editorjs/dom@1.0.1': - resolution: {integrity: sha512-yLO+86MYOIUr1Jl7SQw23SYT84ggv6aJW0EIRsI3NTHYgnQzmK7Bt2n5ZFupQlB0GJqmKqA5tCue3NKQb+o7Pw==} - - '@editorjs/editorjs@2.31.5': - resolution: {integrity: sha512-pEwYE4HzE63DlSSCErV2foTak7Wp9fd7SGkG+WcwiYD0cPmuCowhEsqL+9MF4/ZIjc/KJzDEvhB3NC1B8gQkpQ==} - - '@editorjs/header@2.8.8': - resolution: {integrity: sha512-bsMSs34u2hoi0UBuRoc5EGWXIFzJiwYgkFUYQGVm63y5FU+s8zPBmVx5Ip2sw1xgs0fqfDROqmteMvvmbCy62w==} - - '@editorjs/helpers@0.0.4': - resolution: {integrity: sha512-ieg3dzo2m1/ELze/RMNADiAiC5amXxIlVXoJ5vvXITOu/p/dPsrF+Oi3h5gBYvtGk9vg5LJUSG5YWU0tBUO1tw==} - - '@editorjs/helpers@1.0.1': - resolution: {integrity: sha512-Lmr8ImoQvoROXtzhsIJsA1ZtXzH46DmE6O8hMjn9/AvQq62UfjREjn+Ewi6KxjIZMay2PsgDEbLlsVyNJGEaxw==} - - '@editorjs/image@2.10.3': - resolution: {integrity: sha512-ekCsGICZOIdghF/U2T34H7CItqaWAoJDXbkRD+x8l/LIo/7Ozf7KovYm21qz+CluArgV4RurVFHqwlz+O0vfJA==} - - '@editorjs/list@2.0.9': - resolution: {integrity: sha512-rUTgDSt5wygD3Dp24bNyp6vvye/Xf4UWju0ZuvWeP13Z4cu2z1Jb5JFSTEhCou72XUGuf4xVhtsd8cm/bwUS1g==} - - '@editorjs/paragraph@2.11.7': - resolution: {integrity: sha512-qD6bbWvRc4VvP0mXDOm+hOhzzhUYR9ZjcAvgCuKWcCbUMpCvhVF1s8NX40zdjekPi6JEnuHTamCncTrSzVsVhw==} - - '@editorjs/quote@2.7.6': - resolution: {integrity: sha512-D01KUMSDj2r+6Z+xjDkQqI+y6URpeHCvj0+P4pah+GtkG040lWjFb2H4pgHFXuol2cbfyAoraYSw85fuPheCvw==} - '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -2983,12 +2907,6 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} - codex-notifier@1.1.2: - resolution: {integrity: sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==} - - codex-tooltip@1.0.5: - resolution: {integrity: sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3011,10 +2929,6 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} - complex-esm@2.1.1-esm1: - resolution: {integrity: sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==} - engines: {node: '>=16.14.2', npm: '>=8.5.0'} - concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3837,10 +3751,6 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} - engines: {node: '>=12.0.0'} - hono@4.12.14: resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} @@ -4333,9 +4243,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mathlive@0.109.1: - resolution: {integrity: sha512-TXNkTzdJnk/6SFt0Oezy3bpZkH7aCDriuh2usLhVX8dMS5TMmx/rLd7+T1W2b9VCbEZcXABzhmm6ZMxYr8sFXg==} - mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -6033,17 +5940,6 @@ snapshots: dependencies: css-tree: 3.2.1 - '@codexteam/icons@0.0.4': {} - - '@codexteam/icons@0.0.5': {} - - '@codexteam/icons@0.3.3': {} - - '@cortex-js/compute-engine@0.30.2': - dependencies: - complex-esm: 2.1.1-esm1 - decimal.js: 10.6.0 - '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -6087,54 +5983,6 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@editorjs/caret@1.0.3': - dependencies: - '@editorjs/dom': 1.0.1 - - '@editorjs/code@2.9.4': - dependencies: - '@codexteam/icons': 0.3.3 - - '@editorjs/dom@0.0.5': - dependencies: - '@editorjs/helpers': 0.0.4 - - '@editorjs/dom@1.0.1': - dependencies: - '@editorjs/helpers': 1.0.1 - - '@editorjs/editorjs@2.31.5': - dependencies: - '@editorjs/caret': 1.0.3 - codex-notifier: 1.1.2 - codex-tooltip: 1.0.5 - - '@editorjs/header@2.8.8': - dependencies: - '@codexteam/icons': 0.0.5 - '@editorjs/editorjs': 2.31.5 - - '@editorjs/helpers@0.0.4': {} - - '@editorjs/helpers@1.0.1': {} - - '@editorjs/image@2.10.3': - dependencies: - '@codexteam/icons': 0.3.3 - - '@editorjs/list@2.0.9': - dependencies: - '@codexteam/icons': 0.3.3 - - '@editorjs/paragraph@2.11.7': - dependencies: - '@codexteam/icons': 0.0.4 - - '@editorjs/quote@2.7.6': - dependencies: - '@codexteam/icons': 0.3.3 - '@editorjs/dom': 0.0.5 - '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -8294,10 +8142,6 @@ snapshots: code-block-writer@13.0.3: {} - codex-notifier@1.1.2: {} - - codex-tooltip@1.0.5: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -8312,8 +8156,6 @@ snapshots: commander@8.3.0: {} - complex-esm@2.1.1-esm1: {} - concat-map@0.0.1: {} content-disposition@1.1.0: {} @@ -9327,8 +9169,6 @@ snapshots: dependencies: hermes-estree: 0.25.1 - highlight.js@11.11.1: {} - hono@4.12.14: {} html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): @@ -9766,10 +9606,6 @@ snapshots: math-intrinsics@1.1.0: {} - mathlive@0.109.1: - dependencies: - '@cortex-js/compute-engine': 0.30.2 - mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a0d1cee --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +allowBuilds: + # Native/binary postinstalls that are genuinely required: + esbuild: true # vitest + tsx need the platform binary + sharp: true # next/image optimization + unrs-resolver: true # eslint-config-next resolver napi bindings + # Not required for dev, test, lint, or build: + msw: false # postinstall only copies the browser service worker + protobufjs: false # postinstall is for its codegen CLI From 131a4b3b0163f523da195c17569700638f45a1de Mon Sep 17 00:00:00 2001 From: Shreekar Date: Thu, 3 Sep 2026 14:19:53 -0500 Subject: [PATCH 07/12] Make Markdown the canonical note format and add GFM table blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes were written as a block document with markdown derived from it, while uploaded notes did the reverse — and the block projection was lossy (tables, images, list nesting, and any heading or quote containing math all degraded). Everything downstream (embeddings, quizzes, flashcards) reads markdown, so the drift between the two representations degraded every AI feature. - POST /api/notes and PATCH /api/notes/[id] accept `markdown`. It is stored as authored (CRLF→LF, typed math normalized to LaTeX inside $…$ regions) and the block document is now a derived cache via parseMarkdownToNoteDocument. The legacy `content` path is kept for callers that still send blocks. - The workspace sends markdown on every write path (save, create, duplicate, import). Body autosaves merge in place instead of re-sorting the sidebar, so the note being edited no longer jumps to the top on every pause. - New `table` block: GFM tables are parsed (header + delimiter + body rows, alignment, escaped pipes), serialized, and validated. Previously a generated table was swallowed into a paragraph of pipe characters. - Inline math followed by punctuation no longer gains a space on serialization (`$x$.` stayed `$x$ .`); opening brackets are handled too. - serializeBlock's silent `default: return ""` — which dropped any unhandled block from the markdown column — is now a compile-time exhaustiveness check. - records.ts prefers the markdown column but falls back to the block cache when the column is empty while blocks exist. Requirements doc records the resolution for Q1–Q8. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/notes/[id]/route.ts | 15 ++- app/api/notes/route.ts | 9 +- app/notes/notes-workspace.tsx | 34 ++--- docs/note-editor-requirements.md | 165 +++++++++++++++++++++--- lib/notes/__tests__/markdown.test.ts | 45 +++++++ lib/notes/__tests__/persistence.test.ts | 38 +++++- lib/notes/__tests__/records.test.ts | 28 ++++ lib/notes/__tests__/table-block.test.ts | 61 +++++++++ lib/notes/markdown.ts | 68 +++++++++- lib/notes/parse-markdown.ts | 93 ++++++++++++- lib/notes/persistence.ts | 26 +++- lib/notes/records.ts | 9 +- lib/notes/types.ts | 28 ++++ 13 files changed, 568 insertions(+), 51 deletions(-) create mode 100644 lib/notes/__tests__/table-block.test.ts 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/notes/notes-workspace.tsx b/app/notes/notes-workspace.tsx index 3c379f6..b29b48b 100644 --- a/app/notes/notes-workspace.tsx +++ b/app/notes/notes-workspace.tsx @@ -35,8 +35,6 @@ import { type NoteRecord, type WorkspaceNote, } from "@/lib/notes/records"; -import { emptyNoteDocument, type NoteContent } from "@/lib/notes/types"; -import { parseMarkdownToNoteDocument } from "@/lib/notes/markdown"; type WorkspaceClass = { id: string; @@ -318,13 +316,18 @@ export function NotesWorkspace({ return noteRecordToWorkspaceNote(payload as NoteRecord); } - function mergeNote(nextNote: WorkspaceNote) { - setNotes((current) => - sortWorkspaceNotes([ + function mergeNote(nextNote: WorkspaceNote, options?: { keepPosition?: boolean }) { + setNotes((current) => { + // Body autosaves would otherwise re-sort by updatedAt and yank the note + // being edited to the top of the sidebar on every pause in typing. + if (options?.keepPosition && current.some((n) => n.id === nextNote.id)) { + return current.map((n) => (n.id === nextNote.id ? nextNote : n)); + } + return sortWorkspaceNotes([ nextNote, ...current.filter((n) => n.id !== nextNote.id), - ]), - ); + ]); + }); } function mergeNotes(nextNotes: WorkspaceNote[]) { @@ -356,7 +359,7 @@ export function NotesWorkspace({ async function saveNote( noteId: string, - patch: { title?: string; content?: NoteContent; classId?: string | null }, + patch: { title?: string; markdown?: string; classId?: string | null }, ) { if (isTempNote(noteId)) return; // creation pending — skip @@ -365,13 +368,13 @@ export function NotesWorkspace({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...(patch.title !== undefined ? { title: patch.title } : {}), - ...(patch.content ? { content: patch.content.document } : {}), + ...(patch.markdown !== undefined ? { markdown: patch.markdown } : {}), ...(patch.classId !== undefined ? { classId: patch.classId } : {}), }), }); const updatedNote = await readNoteRecord(response); - mergeNote(updatedNote); + mergeNote(updatedNote, { keepPosition: patch.markdown !== undefined }); setTitleDraftState((current) => current.noteId === updatedNote.id ? { noteId: updatedNote.id, value: updatedNote.title } @@ -405,7 +408,7 @@ export function NotesWorkspace({ body: JSON.stringify({ title: "Untitled", classId: classId ?? null, - content: { ...emptyNoteDocument, blocks: [] }, + markdown: "", }), }); const created = await readNoteRecord(response); @@ -500,7 +503,7 @@ export function NotesWorkspace({ body: JSON.stringify({ title: temp.title, classId: temp.classId, - content: source.content.document, + markdown: source.content.markdown, }), }); const created = await readNoteRecord(response); @@ -597,7 +600,6 @@ export function NotesWorkspace({ try { const text = await file.text(); - const document = parseMarkdownToNoteDocument(text); const title = file.name.replace(/\.md$/i, "").trim() || "Imported Note"; const response = await fetch("/api/notes", { @@ -606,7 +608,7 @@ export function NotesWorkspace({ body: JSON.stringify({ title, classId: classId ?? null, - content: document, + markdown: text, }), }); @@ -753,7 +755,7 @@ export function NotesWorkspace({ body: JSON.stringify({ title: temp.title, classId: temp.classId, - content: source.content.document, + markdown: source.content.markdown, }), }); const created = await readNoteRecord(response); @@ -808,7 +810,7 @@ export function NotesWorkspace({ body: JSON.stringify({ title: temp.title, classId: temp.classId, - content: source.content.document, + markdown: source.content.markdown, }), }); const created = await readNoteRecord(response); diff --git a/docs/note-editor-requirements.md b/docs/note-editor-requirements.md index fbdc3ef..b9dab33 100644 --- a/docs/note-editor-requirements.md +++ b/docs/note-editor-requirements.md @@ -1,49 +1,176 @@ # Note Editor — Requirements -Status: **collecting requirements.** The previous Editor.js-based editor was -removed (see [Background](#background)); nothing has been chosen or built yet. +Status: **requirements captured (NE-1 – NE-9); all decisions made (Q1 – Q8); +build in progress — data layer and API are done.** The previous Editor.js-based editor was removed on this branch +(see [Background](#background)); nothing has been chosen or built yet. -Requirements go in the section below as they are given. Everything after it is -verified context about the codebase as it stands today — constraints the -rebuild has to live within, not decisions that have been made. +Section 1 is what was asked for. Section 2 is what has to be decided before +building. Everything after that is verified context about the codebase as it +stands today — constraints the rebuild has to live within, not decisions. --- ## 1. Requirements -> _Awaiting input. Each requirement gets an ID (`NE-1`, `NE-2`, …) so it can be -> referenced from issues, commits, and tests._ +Each requirement has an ID (`NE-1`, `NE-2`, …) so it can be referenced from +issues, commits, and tests. Priority is **Must** unless stated otherwise. | ID | Requirement | Priority | Notes | |----|-------------|----------|-------| -| | | | | +| NE-1 | All note text is **Markdown + LaTeX**. No proprietary rich-text format. | Must | Implies Markdown is the canonical representation — see Q1. | +| NE-2 | LaTeX is embedded in the Markdown **exactly as the note generator emits it**: `$…$` inline, `$$` on its own lines for display. | Must | Format pinned in detail below. | +| NE-3 | The editor works **seamlessly with generator output**: a generated note opens, edits, and saves with no lossy transform in either direction. | Must | Round-trip must be lossless. One known defect today — see detail. | +| NE-4 | While typing, the user can **insert a block of any Markdown type**. | Must | Full list in detail. Tables are a gap today. | +| NE-5 | While typing, the user can **insert a math block**, choosing **inline** or **display ("large")**. | Must | Maps directly onto the data layer's `inlineMath` / `math` block types. | +| NE-6 | Take **direct inspiration from Obsidian**. | Must | Markdown-native typing, live preview, `$`/`$$` math syntax. See Q4 on modes. | +| NE-7 | A math block gets a **Desmos / Mathway-style structural input**: the user types characters naturally *or* edits the LaTeX directly, and the two stay in sync. | Must | `lib/math/latex.ts` already normalizes typed math → LaTeX. See detail. | +| NE-8 | **Backspace in a math block deletes structural elements whole** — a fraction, a root, a superscript — not one character at a time. | Must | This is a structural (not character) editing model. Decides the math engine — Q2. | +| NE-9 | A **proper, intuitive, fully featured** note editor — a real editor, not a textarea with a preview. | Must | Quality bar. What this means concretely is in detail. | ### Notes / detail -_(Longer explanation for any requirement that needs more than a table row.)_ +#### NE-2 — the generator format (pinned) + +Verified by running `rewriteParsedTextAsMarkdown` live against Gemini on this +branch. This is what the editor has to accept and produce: + +```markdown +Proof Suppose finitely many $p_1..p_n$. Consider $N = p_1*...*p_n + 1$. +The area of a circle is $A = \pi r^2$ and the sum is $x_1 + x_2$. + +$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$ +``` + +- **Inline:** single `$…$`, on the same line as surrounding prose. +- **Display:** `$$` alone on a line, the LaTeX, `$$` alone on a line. + `parse-markdown.ts` treats a bare `$$` line as a block boundary + (`isBlockStartLine`), so the fence-on-its-own-line form is load-bearing. +- The generator is instructed *"Do not attempt to interpret or rewrite math + expressions"* — so raw, sometimes non-idiomatic LaTeX (`p_1*...*p_n`) will + arrive and must render as-is rather than be "fixed". + +The data layer already distinguishes the two: `lib/notes/math-regions.ts` +converts `$…$` into `inlineMath` blocks and `$$` regions into `math` blocks. + +#### NE-3 — seamless round-trip + +"Seamless" means `markdown → editor → markdown` is the identity for anything +the generator can produce. There **was** one defect in the data layer that +violated this (now fixed — see Q5): + +``` +"The sum is $x_1 + x_2$." → "The sum is $x_1 + x_2$ ." +"Values: $a$, $b$, and $c$." → "Values: $a$ , $b$ , and $c$ ." +``` + +Inline math followed by `.` `,` `!` `?` gains a spurious space on +serialization. It is idempotent (stable after the first pass, so it does not +compound through autosave) but it degrades the `markdown` column, which feeds +embeddings, flashcards, and quizzes. + +Root cause: inline math is its own **block** (`inlineMath`), so one sentence +with two formulas becomes five blocks (`paragraph, inlineMath, paragraph, +inlineMath, paragraph`) and re-joining inserts a space. This is a consequence +of the block design, not a stray bug — the rebuild should either fix the join +or represent inline math *within* a paragraph. See Q1. + +#### NE-4 — block types + +Everything the generator is instructed to emit (`lib/notes/generation.ts`, +the rewrite prompt), so all of these must be insertable and editable: + +| Markdown construct | Data-layer block today | Status | +|---|---|---| +| Paragraph | `paragraph` | ✓ | +| Heading `#`–`####` | `header` | ✓ | +| Bullet / numbered / checklist | `list` | ✓ | +| Blockquote `>` | `quote` | ✓ | +| Fenced code | `code` | ✓ (highlighter removed — Q6) | +| Image `![alt](url)` | `image` | ✓ storage is base64 — #86 | +| Inline math `$…$` | `inlineMath` | ✓ spacing defect — NE-3 | +| Display math `$$` | `math` | ✓ | +| Mermaid fenced block | `mermaid` | schema only; renderer removed — Q6 | +| **Table** | **none** | **gap** — Q3 | + +The generator prompt says *"Convert all parsed tables into Markdown tables"*, +but `parse-markdown.ts` has no table detection and the schema has no table +block, so a generated table currently degrades to a paragraph of pipe +characters. This is the largest NE-3 violation. + +Also required by "any Markdown type" but not emitted by the generator: +horizontal rule, and inline formatting (`**bold**`, `*italic*`, `` `code` ``, +`~~strike~~`, links). `renderInlineMarkdownText` already handles the inline +set. + +#### NE-7 / NE-8 — structural math editing + +These two together define the editing *model* for math, and it is not a +character model. In a character model, backspace after `\frac{a}{b}` deletes +`}`. In a structural model, the cursor is *inside* a fraction object and +backspace removes the fraction as a unit (or steps out of it) — which is what +Desmos and Mathway do and what NE-8 asks for. + +Consequences: + +- The math block needs a **structural editor**, not a text input with a + preview. The rendered formula *is* the editing surface. +- NE-7's "or modify the LaTeX directly" means a second, synchronized view of + the same structure — a LaTeX source toggle. Desmos does not offer one; + Mathway's "show LaTeX" and MathLive's source mode do. +- Typed characters go through `normalizeLatex` (`lib/math/latex.ts`), which + already maps `√(x²) ≤ π` → `\sqrt{x^2} \le \pi`. Reuse it rather than + reimplement. +- **Desmos's input is MathQuill.** The editor that was just removed used + MathLive, which has exactly this behaviour. See Q2 — the choice is between + re-adopting MathLive, adopting MathQuill, or building it. Building a + structural math editor from scratch is not a small task. + +#### NE-9 — what "fully featured" means + +Concretely, the bar the removed editor already met and this one must at +least match, plus what NE-1–NE-8 add: + +- Keyboard-first: every block type insertable without the mouse. Obsidian + does this by typing Markdown; a `/` menu is an addition, not a replacement. +- Undo/redo that treats a math block edit as one step. +- Copy/paste that preserves Markdown + LaTeX across notes and out to other + apps — pasting into a plain text field yields valid Markdown. +- Autosave with visible saving / saved / error state (AGENTS.md §1). The old + editor debounced 180 ms with a single-flight queue; keep that behaviour. +- Selection and reorder of whole blocks. +- Works in light and dark mode, inside the `h-screen overflow-hidden` shell + (AGENTS.md §2, §7). +- Loads lazily — the math engine and any highlighter are `next/dynamic` + with `ssr: false` (AGENTS.md §10). --- ## 2. Open questions -_(Things that need a decision before or during the build.)_ - | # | Question | Blocking? | Resolution | |---|----------|-----------|------------| -| | | | | - +| Q1 | **Canonical storage format.** NE-1 says Markdown is the truth. Today *both* `note.markdown` and `note.content` (block jsonb) are stored and kept in sync. Does the block document stay as a derived cache, or go away? Keeping both means every edit is serialized twice and the two can drift — the NE-3 defect is exactly that drift. | **Yes** — decides the data model | **Markdown is canonical.** `POST/PATCH /api/notes` accept `markdown`; it is stored as authored (CRLF→LF, `normalizeTextMathToLatex`) and the `content` blocks are a derived cache via `parseMarkdownToNoteDocument`. Every client write path sends markdown. The editor edits the markdown string directly, so round-trip is the identity by construction. | +| Q2 | **Math engine** for NE-7/NE-8. (a) **MathLive** — was integrated until this branch removed it; structural editing, LaTeX source mode, virtual keyboard, actively maintained. (b) **MathQuill** — what Desmos actually uses; older, jQuery-era. (c) Build it. | **Yes** — NE-7/8 can't start without it | **MathLive.** Structural backspace (NE-8) is native, it has a LaTeX source view (NE-7), and the prior integration's CSS and pitfalls are documented. Loaded client-only; wait on `customElements.whenDefined("math-field")` before creating fields. | +| Q3 | **Tables.** Generator emits them; schema has no block; parser doesn't detect them. Add a `table` block + parser support, or store tables as raw Markdown inside a paragraph and render them? | **Yes** for NE-3/NE-4 | **Native GFM tables.** A `table` block was added to the data layer (parser detection, serializer, Zod; round-trip tested). In the editor a table is just markdown text; `remark-gfm` already renders it. | +| Q4 | **Obsidian mode.** Obsidian has *Live Preview* (Markdown renders in place as you type; syntax shows when the cursor is on it) and *Source mode* (raw text). NE-6 implies Live Preview. Is Source mode also required? | No — but shapes the architecture | **Live Preview on CodeMirror 6, plus a Source-mode toggle.** Obsidian is built on CM6; syntax is hidden on lines that don't contain the cursor and shown on the active line. | +| Q5 | **Inline math spacing** (the NE-3 defect). Fix the join in `math-regions.ts`, or change the model so inline math lives inside a paragraph rather than as a sibling block? Interacts with Q1. | No | **Both.** `getBlockSeparator` no longer inserts a space before closing punctuation or after an opening bracket (tested), *and* the edit path never serializes blocks anymore, so the defect cannot recur there. | +| Q6 | **Mermaid and code highlighting.** The generator emits both; both renderers were removed. Render them (re-add `mermaid` and a highlighter), or show as plain fenced code for now? | No | **Code highlighting yes** (`@codemirror/language-data`, lazy). **Mermaid deferred** — rendered as a plain fenced block for now; the `mermaid` block type stays so stored notes keep parsing. | +| Q7 | **Images** — #86. Uploads are base64-inlined into jsonb today. Blob storage is its own issue, but the image block should be built against a URL, not bytes. | No | **Build against a URL.** The editor takes an optional `uploadImage(file) → { url }` prop; until #86 lands, the fallback is a base64 data URL. | +| Q8 | **Highlights** — #7, #8, #89 all need the editor to expose highlighted regions. In scope for the first build, or a follow-on? Affects the block schema. | No | **Follow-on.** The editor styles Obsidian's `==highlight==` syntax and stores it as markdown, which gives #7 / #89 a retrievable hook without a schema change now. | --- ## Background The prior editor was a ~4,200-line custom shell around Editor.js -(`components/note-editor/`), removed on the `develop` working tree along with -its 1,100 lines of CSS and the `@editorjs/*`, `mathlive`, `mermaid`, and -`highlight.js` dependencies. +(`components/note-editor/`), removed on this branch in commit `c687dc0` along +with its 1,100 lines of CSS and the `@editorjs/*`, `mathlive`, `mermaid`, and +`highlight.js` dependencies. `develop` still carries it. -To restore it for reference: `git checkout -- components/note-editor` -(scope the path — do not use a blanket `git checkout .`, it would revert -unrelated uncommitted work). +To read it for reference without restoring it: +`git show develop:components/note-editor/note-editor.tsx` (or any file +under that path). What it did, for reference when deciding what to keep: diff --git a/lib/notes/__tests__/markdown.test.ts b/lib/notes/__tests__/markdown.test.ts index e09273a..9aa3515 100644 --- a/lib/notes/__tests__/markdown.test.ts +++ b/lib/notes/__tests__/markdown.test.ts @@ -209,4 +209,49 @@ describe("serializeNoteDocumentToMarkdown", () => { expect(serializeNoteDocumentToMarkdown(document)).toBe("Use $\\sqrt{x^2}$ here."); }); + + it("keeps punctuation attached to inline math instead of inserting a space", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { type: "paragraph", data: { text: "Values: (" } }, + { type: "inlineMath", data: { latex: "a" } }, + { type: "paragraph", data: { text: "," } }, + { type: "inlineMath", data: { latex: "b" } }, + { type: "paragraph", data: { text: ") and" } }, + { type: "inlineMath", data: { latex: "c" } }, + { type: "paragraph", data: { text: "." } }, + ], + }; + + expect(serializeNoteDocumentToMarkdown(document)).toBe("Values: ($a$, $b$) and $c$."); + }); + + it("serializes GFM tables with alignment and escaped pipes", () => { + const document: NoteDocument = { + time: 1, + blocks: [ + { + type: "table", + data: { + rows: [ + ["Symbol", "Meaning", "Note"], + ["$\\pi$", "circle ratio", "a | b"], + ["$e$", "Euler", ""], + ], + align: [null, "center", "right"], + }, + }, + ], + }; + + expect(serializeNoteDocumentToMarkdown(document)).toBe( + [ + "| Symbol | Meaning | Note |", + "| --- | :-: | --: |", + "| $\\pi$ | circle ratio | a \\| b |", + "| $e$ | Euler | |", + ].join("\n"), + ); + }); }); diff --git a/lib/notes/__tests__/persistence.test.ts b/lib/notes/__tests__/persistence.test.ts index c7c6104..031db0a 100644 --- a/lib/notes/__tests__/persistence.test.ts +++ b/lib/notes/__tests__/persistence.test.ts @@ -1,5 +1,41 @@ import { describe, expect, it } from "vitest"; -import { normalizeNoteWriteContent } from "@/lib/notes/persistence"; +import { normalizeNoteWriteContent, normalizeNoteWriteMarkdown } from "@/lib/notes/persistence"; + +describe("normalizeNoteWriteMarkdown", () => { + it("stores markdown as authored and derives the block cache from it", () => { + const markdown = [ + "## Primes", + "", + "The area is $A = \\pi r^2$.", + "", + "| a | b |", + "| --- | --- |", + "| 1 | 2 |", + ].join("\n"); + + const content = normalizeNoteWriteMarkdown(markdown); + + expect(content.markdown).toBe(markdown); + expect(content.document.blocks.map((block) => block.type)).toEqual([ + "header", + "paragraph", + "inlineMath", + "paragraph", + "table", + ]); + }); + + it("normalizes typed math to LaTeX and CRLF to LF, and is idempotent", () => { + const once = normalizeNoteWriteMarkdown("Use $√(x²)$ here.\r\n\r\n$$\r\nα ≤ β\r\n$$"); + + expect(once.markdown).toBe("Use $\\sqrt{x^2}$ here.\n\n$$\n\\alpha \\le \\beta\n$$"); + expect(normalizeNoteWriteMarkdown(once.markdown).markdown).toBe(once.markdown); + }); + + it("rejects non-string markdown", () => { + expect(() => normalizeNoteWriteMarkdown({ blocks: [] })).toThrow(); + }); +}); describe("normalizeNoteWriteContent", () => { it("validates note JSON and derives markdown from block content", () => { diff --git a/lib/notes/__tests__/records.test.ts b/lib/notes/__tests__/records.test.ts index da32644..e250b0f 100644 --- a/lib/notes/__tests__/records.test.ts +++ b/lib/notes/__tests__/records.test.ts @@ -65,6 +65,34 @@ describe("noteRecordToWorkspaceNote", () => { expect(note.content.markdown).toContain("- **Logical view:** Shows abstractions."); }); + it("prefers the markdown column, but falls back to the block cache when it is empty", () => { + const blocks = [{ type: "paragraph" as const, data: { text: "From blocks" } }]; + + const stored = noteRecordToWorkspaceNote({ + ...baseRecord, + sourceType: "manual", + markdown: "# Column wins", + content: { time: 1, blocks }, + }); + expect(stored.content.markdown).toBe("# Column wins"); + + const legacy = noteRecordToWorkspaceNote({ + ...baseRecord, + sourceType: "manual", + markdown: "", + content: { time: 1, blocks }, + }); + expect(legacy.content.markdown).toBe("From blocks"); + + const empty = noteRecordToWorkspaceNote({ + ...baseRecord, + sourceType: "manual", + markdown: "", + content: { time: 1, blocks: [] }, + }); + expect(empty.content.markdown).toBe(""); + }); + it("leaves manual notes with literal markdown markers alone", () => { const note = noteRecordToWorkspaceNote({ ...baseRecord, diff --git a/lib/notes/__tests__/table-block.test.ts b/lib/notes/__tests__/table-block.test.ts new file mode 100644 index 0000000..318f0e9 --- /dev/null +++ b/lib/notes/__tests__/table-block.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { parseMarkdownToNoteDocument, serializeNoteDocumentToMarkdown } from "@/lib/notes/markdown"; + +const table = [ + "| Symbol | Meaning | Note |", + "| --- | :-: | --: |", + "| $\\pi$ | circle ratio | a \\| b |", + "| $e$ | Euler | |", +].join("\n"); + +describe("GFM table blocks", () => { + it("parses a header row, delimiter row, and body rows into one table block", () => { + const document = parseMarkdownToNoteDocument(table); + + expect(document.blocks).toHaveLength(1); + expect(document.blocks[0]).toMatchObject({ + type: "table", + data: { + rows: [ + ["Symbol", "Meaning", "Note"], + ["$\\pi$", "circle ratio", "a | b"], + ["$e$", "Euler", ""], + ], + align: [null, "center", "right"], + }, + }); + }); + + it("round-trips the generator's table shape unchanged", () => { + expect(serializeNoteDocumentToMarkdown(parseMarkdownToNoteDocument(table))).toBe(table); + }); + + it("does not swallow a table into a preceding paragraph", () => { + const markdown = ["Intro line", "| a | b |", "| --- | --- |", "| 1 | 2 |"].join("\n"); + const types = parseMarkdownToNoteDocument(markdown).blocks.map((block) => block.type); + + expect(types).toEqual(["paragraph", "table"]); + }); + + it("stops the table at a blank line or a non-table line", () => { + const markdown = ["| a | b |", "| --- | --- |", "| 1 | 2 |", "", "After"].join("\n"); + const types = parseMarkdownToNoteDocument(markdown).blocks.map((block) => block.type); + + expect(types).toEqual(["table", "paragraph"]); + }); + + it("treats a lone pipe line without a delimiter row as ordinary text", () => { + const document = parseMarkdownToNoteDocument("x | y"); + + expect(document.blocks.map((block) => block.type)).toEqual(["paragraph"]); + }); + + it("pads ragged rows to the header width", () => { + const document = parseMarkdownToNoteDocument(["| a | b | c |", "| --- | --- | --- |", "| 1 |"].join("\n")); + + expect(document.blocks[0]).toMatchObject({ + type: "table", + data: { rows: [["a", "b", "c"], ["1", "", ""]] }, + }); + }); +}); diff --git a/lib/notes/markdown.ts b/lib/notes/markdown.ts index b513369..9336b56 100644 --- a/lib/notes/markdown.ts +++ b/lib/notes/markdown.ts @@ -1,6 +1,13 @@ import TurndownService from "turndown"; import { gfm } from "turndown-plugin-gfm"; -import type { NoteBlock, NoteContent, NoteDocument, NoteListBlockData, NoteListItem } from "@/lib/notes/types"; +import type { + NoteBlock, + NoteContent, + NoteDocument, + NoteListBlockData, + NoteListItem, + NoteTableAlignment, +} from "@/lib/notes/types"; // Re-export the canonical parser from its own module. export { parseMarkdownToNoteDocument } from "@/lib/notes/parse-markdown"; @@ -68,6 +75,23 @@ function restoreInlineMath(html: string): string { ); } +function serializeTableCell(value: string) { + return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " "); +} + +function serializeTableAlignment(alignment: NoteTableAlignment | undefined) { + switch (alignment) { + case "left": + return ":--"; + case "center": + return ":-:"; + case "right": + return "--:"; + default: + return "---"; + } +} + function prefixLines(value: string, prefix: string) { return value .split("\n") @@ -136,22 +160,52 @@ function serializeBlock(block: NoteBlock) { const imageLine = `![${altText}](${block.data.file.url})`; return caption ? `${imageLine}\n\n${caption}` : imageLine; } + case "table": { + const [header = [], ...body] = block.data.rows; + const columnCount = Math.max(header.length, ...body.map((row) => row.length), 1); + const row = (cells: string[]) => + `| ${Array.from({ length: columnCount }, (_, column) => serializeTableCell(cells[column] ?? "")).join(" | ")} |`; + const delimiter = `| ${Array.from({ length: columnCount }, (_, column) => + serializeTableAlignment(block.data.align?.[column]), + ).join(" | ")} |`; + return [row(header), delimiter, ...body.map(row)].join("\n"); + } case "math": return `$$\n${block.data.latex}\n$$`; case "inlineMath": return `$${block.data.latex}$`; - default: - return ""; + default: { + // Every NoteBlock must serialize; a new block type that is not handled + // here would otherwise vanish from the markdown column silently. + const unhandled: never = block; + throw new Error(`Unhandled note block type: ${String((unhandled as NoteBlock).type)}`); + } } } -function getBlockSeparator(previous: NoteBlock | undefined, current: NoteBlock) { +/** Characters that must hug the preceding inline math (`$x$.` not `$x$ .`). */ +const CLOSING_PUNCTUATION_RE = /^[.,;:!?)\]}]/; +/** Characters that must hug the following inline math (`($x$` not `( $x$`). */ +const OPENING_PUNCTUATION_RE = /[(\[{]$/; + +function getBlockSeparator( + previous: NoteBlock | undefined, + current: NoteBlock, + previousText: string, + currentText: string, +) { if ( previous && (previous.type === "inlineMath" || current.type === "inlineMath") && (previous.type === "paragraph" || previous.type === "inlineMath") && (current.type === "paragraph" || current.type === "inlineMath") ) { + if (previous.type === "inlineMath" && CLOSING_PUNCTUATION_RE.test(currentText)) { + return ""; + } + if (current.type === "inlineMath" && OPENING_PUNCTUATION_RE.test(previousText)) { + return ""; + } return " "; } @@ -161,6 +215,7 @@ function getBlockSeparator(previous: NoteBlock | undefined, current: NoteBlock) export function serializeNoteDocumentToMarkdown(document: NoteDocument) { const sections: string[] = []; let previousSerializedBlock: NoteBlock | undefined; + let previousSerializedText = ""; for (const block of document.blocks) { const serialized = serializeBlock(block); @@ -169,11 +224,14 @@ export function serializeNoteDocumentToMarkdown(document: NoteDocument) { } if (sections.length > 0) { - sections.push(getBlockSeparator(previousSerializedBlock, block)); + sections.push( + getBlockSeparator(previousSerializedBlock, block, previousSerializedText, serialized), + ); } sections.push(serialized); previousSerializedBlock = block; + previousSerializedText = serialized; } return sections.join(""); diff --git a/lib/notes/parse-markdown.ts b/lib/notes/parse-markdown.ts index 32f3a76..b4d749d 100644 --- a/lib/notes/parse-markdown.ts +++ b/lib/notes/parse-markdown.ts @@ -14,7 +14,13 @@ * - Paragraphs (everything else) */ -import type { NoteBlock, NoteDocument, NoteListBlockData, NoteListItem } from "@/lib/notes/types"; +import type { + NoteBlock, + NoteDocument, + NoteListBlockData, + NoteListItem, + NoteTableAlignment, +} from "@/lib/notes/types"; import { normalizeLatex } from "@/lib/math/latex"; import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; @@ -135,6 +141,60 @@ function normalizeFenceLanguage(language: string | undefined) { return (language ?? "").trim().toLowerCase().replace(/^language-/, ""); } +// --------------------------------------------------------------------------- +// GFM tables +// --------------------------------------------------------------------------- + +const TABLE_DELIMITER_CELL_RE = /^:?-+:?$/; + +/** Split a GFM table row into trimmed cells, honouring `\|` escapes. */ +function splitTableRow(line: string): string[] { + let body = line.trim(); + if (body.startsWith("|")) body = body.slice(1); + if (body.endsWith("|") && !body.endsWith("\\|")) body = body.slice(0, -1); + + const cells: string[] = []; + let current = ""; + for (let index = 0; index < body.length; index += 1) { + const char = body[index]; + if (char === "\\" && body[index + 1] === "|") { + current += "|"; + index += 1; + } else if (char === "|") { + cells.push(current.trim()); + current = ""; + } else { + current += char; + } + } + cells.push(current.trim()); + return cells; +} + +function parseTableDelimiterRow(line: string): NoteTableAlignment[] | null { + if (!line.includes("-")) return null; + const cells = splitTableRow(line); + if (cells.length === 0 || !cells.every((cell) => TABLE_DELIMITER_CELL_RE.test(cell))) { + return null; + } + + return cells.map((cell) => { + const left = cell.startsWith(":"); + const right = cell.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return null; + }); +} + +/** A table starts at `index` when a `|` row is followed by a delimiter row. */ +function isTableStart(lines: string[], index: number) { + const header = lines[index] ?? ""; + const delimiter = lines[index + 1]; + return header.includes("|") && delimiter !== undefined && parseTableDelimiterRow(delimiter) !== null; +} + // --------------------------------------------------------------------------- // Main parser // --------------------------------------------------------------------------- @@ -255,6 +315,35 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { continue; } + // ---- GFM table (header row + delimiter row + body rows) ---- + if (isTableStart(lines, i)) { + const headerCells = splitTableRow(line); + const align = parseTableDelimiterRow(lines[i + 1] ?? "") ?? []; + const columnCount = Math.max(headerCells.length, align.length); + const fit = (cells: string[]) => + Array.from({ length: columnCount }, (_, column) => cells[column] ?? ""); + const rows: string[][] = [fit(headerCells)]; + i += 2; + + while (i < lines.length) { + const rowLine = lines[i] ?? ""; + if (rowLine.trim() === "" || !rowLine.includes("|") || isBlockStartLine(rowLine)) { + break; + } + rows.push(fit(splitTableRow(rowLine))); + i++; + } + + blocks.push({ + type: "table", + data: { + rows, + align: Array.from({ length: columnCount }, (_, column) => align[column] ?? null), + }, + }); + continue; + } + // ---- Empty line ---- if (line.trim() === "") { i++; @@ -263,7 +352,7 @@ export function parseMarkdownToNoteDocument(markdown: string): NoteDocument { // ---- Paragraph — accumulate until next block-starting line ---- const paragraphLines: string[] = []; - while (i < lines.length && !isBlockStartLine(lines[i] ?? "")) { + while (i < lines.length && !isBlockStartLine(lines[i] ?? "") && !isTableStart(lines, i)) { paragraphLines.push(lines[i] ?? ""); i++; } diff --git a/lib/notes/persistence.ts b/lib/notes/persistence.ts index 7dd2207..f633252 100644 --- a/lib/notes/persistence.ts +++ b/lib/notes/persistence.ts @@ -1,4 +1,5 @@ -import { serializeNoteDocumentToMarkdown } from "@/lib/notes/markdown"; +import { normalizeTextMathToLatex } from "@/lib/math/latex"; +import { parseMarkdownToNoteDocument, serializeNoteDocumentToMarkdown } from "@/lib/notes/markdown"; import { normalizeNoteLatexRegions } from "@/lib/notes/math-regions"; import { emptyNoteDocument, NoteDocumentSchema, type NoteDocument } from "@/lib/notes/types"; @@ -7,6 +8,10 @@ export type NormalizedNoteWriteContent = { markdown: string; }; +/** + * Legacy write path: the client sends a block document and markdown is + * derived from it. Kept for callers that still speak blocks. + */ export function normalizeNoteWriteContent(value: unknown = emptyNoteDocument): NormalizedNoteWriteContent { const document = normalizeNoteLatexRegions( NoteDocumentSchema.parse(value ?? emptyNoteDocument), @@ -17,3 +22,22 @@ export function normalizeNoteWriteContent(value: unknown = emptyNoteDocument): N markdown: serializeNoteDocumentToMarkdown(document), }; } + +/** + * Canonical write path: the client sends Markdown (+ LaTeX) and it is stored + * as-authored, apart from line-ending normalization and typed-math → LaTeX + * normalization inside `$…$` / `$$…$$` regions (issue #58). The block + * document is derived from it as a cache for consumers that still read blocks. + */ +export function normalizeNoteWriteMarkdown(value: unknown): NormalizedNoteWriteContent { + if (typeof value !== "string") { + throw new Error("Note markdown must be a string."); + } + + const markdown = normalizeTextMathToLatex(value.replace(/\r\n?/g, "\n")); + + return { + document: parseMarkdownToNoteDocument(markdown), + markdown, + }; +} diff --git a/lib/notes/records.ts b/lib/notes/records.ts index 58e26ad..bcefa19 100644 --- a/lib/notes/records.ts +++ b/lib/notes/records.ts @@ -211,7 +211,14 @@ export function noteRecordToWorkspaceNote(record: NoteRecord): WorkspaceNote { }); const embedding = normalizeEmbedding(record.embedding); const content = createNoteContent(document); - const markdown = typeof record.markdown === "string" ? record.markdown : content.markdown; + // The markdown column is canonical. Only fall back to serializing the block + // cache when the column is absent, or empty while blocks exist (a row that + // predates markdown-on-write would otherwise render as an empty note). + const markdown = + typeof record.markdown === "string" && + (record.markdown.length > 0 || document.blocks.length === 0) + ? record.markdown + : content.markdown; return { id: record.id, diff --git a/lib/notes/types.ts b/lib/notes/types.ts index ddd98a6..9dbc721 100644 --- a/lib/notes/types.ts +++ b/lib/notes/types.ts @@ -31,6 +31,7 @@ export type NoteBlockType = | "code" | "mermaid" | "image" + | "table" | "math" | "inlineMath"; @@ -92,6 +93,17 @@ export type NoteImageBlockData = { stretched: boolean; }; +export type NoteTableAlignment = "left" | "center" | "right" | null; + +/** + * GFM table. `rows[0]` is the header row (GFM tables always have one); cells + * hold raw inline Markdown so the block round-trips without an HTML detour. + */ +export type NoteTableBlockData = { + rows: string[][]; + align?: NoteTableAlignment[]; +}; + export type NoteMathBlockData = { latex: string; }; @@ -106,6 +118,7 @@ export type NoteBlock = | NoteBlockShape<"code", NoteCodeBlockData> | NoteBlockShape<"image", NoteImageBlockData> | NoteBlockShape<"mermaid", NoteMermaidBlockData> + | NoteBlockShape<"table", NoteTableBlockData> | NoteBlockShape<"math", NoteMathBlockData> | NoteBlockShape<"inlineMath", NoteInlineMathBlockData>; @@ -218,6 +231,20 @@ const imageBlockSchema = z.object({ tunes: z.record(z.string(), z.unknown()).optional(), }); +const tableBlockSchema = z.object({ + id: z.string().optional(), + type: z.literal("table"), + data: z.object({ + rows: z.array(z.array(z.string())).min(1), + align: z + .array( + z.union([z.literal("left"), z.literal("center"), z.literal("right"), z.null()]), + ) + .optional(), + }), + tunes: z.record(z.string(), z.unknown()).optional(), +}); + const mathBlockSchema = z.object({ id: z.string().optional(), type: z.literal("math"), @@ -244,6 +271,7 @@ export const NoteBlockSchema = z.discriminatedUnion("type", [ codeBlockSchema, mermaidBlockSchema, imageBlockSchema, + tableBlockSchema, mathBlockSchema, inlineMathBlockSchema, ]); From 4e8946b83aef2b02fa04923307f4050fbda289e7 Mon Sep 17 00:00:00 2001 From: Shreekar Date: Thu, 3 Sep 2026 14:36:35 -0500 Subject: [PATCH 08/12] Add the CodeMirror-based note editor scaffold with coalescing autosave Mounts a new NoteEditor at the workspace seam. The CodeMirror 6 document is the draft and the markdown string is what gets saved, so there is no block conversion anywhere on the edit path. - components/note-editor/markdown-editor.tsx: DOM-only CM6 host (GFM markdown, lazy code-fence languages, history, line wrapping), loaded with next/dynamic + ssr:false from note-editor.tsx. - use-autosave.ts: 180 ms trailing debounce with a one-slot mailbox and a single-flight latch. Each queued save carries the note id it was typed into, a failed save re-queues the content and surfaces the error (the old editor dropped it silently), pending edits flush on unmount, and edits made to a not-yet-created note are held and saved under the real id once it exists. Covered by tests for coalescing, retry, and the temp-id case. - note-editor.tsx derives the document value during render from the hook's draft instead of mirroring it in state, and only flushes in its effect. - isTempNoteId moves to lib/notes/records.ts so the workspace and editor share one definition. - Adds @codemirror/* and mathlive; restores the MathLive CSS imports. Co-Authored-By: Claude Opus 5 (1M context) --- app/layout.tsx | 2 + app/notes/notes-workspace.tsx | 29 +- components/note-editor/README.md | 16 + components/note-editor/extensions/theme.ts | 80 +++ components/note-editor/markdown-editor.tsx | 115 ++++ components/note-editor/note-editor.tsx | 136 +++++ components/note-editor/use-autosave.test.ts | 134 +++++ components/note-editor/use-autosave.ts | 131 +++++ lib/notes/records.ts | 5 + package.json | 9 + pnpm-lock.yaml | 551 ++++++++++++++++++++ 11 files changed, 1189 insertions(+), 19 deletions(-) create mode 100644 components/note-editor/README.md create mode 100644 components/note-editor/extensions/theme.ts create mode 100644 components/note-editor/markdown-editor.tsx create mode 100644 components/note-editor/note-editor.tsx create mode 100644 components/note-editor/use-autosave.test.ts create mode 100644 components/note-editor/use-autosave.ts diff --git a/app/layout.tsx b/app/layout.tsx index 5dc70fa..8d787d0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import "./globals.css"; +import "mathlive/fonts.css"; +import "mathlive/static.css"; import { ThemeInitializer } from "@/components/ui/theme-initializer"; const geistSans = Geist({ diff --git a/app/notes/notes-workspace.tsx b/app/notes/notes-workspace.tsx index b29b48b..1318f77 100644 --- a/app/notes/notes-workspace.tsx +++ b/app/notes/notes-workspace.tsx @@ -31,10 +31,12 @@ import { Button } from "@/components/ui/button"; import { cx } from "@/lib/utils"; import { noteRecordToWorkspaceNote, + isTempNoteId, sortWorkspaceNotes, type NoteRecord, type WorkspaceNote, } from "@/lib/notes/records"; +import { NoteEditor } from "@/components/note-editor/note-editor"; type WorkspaceClass = { id: string; @@ -99,7 +101,7 @@ function getClassShortLabel(item: WorkspaceClass) { return acronym; } -const isTempNote = (id: string) => id.startsWith("temp-"); +const isTempNote = isTempNoteId; function createTempNote( classId: string | null, @@ -1410,24 +1412,13 @@ export function NotesWorkspace({ - {/* SEAM: the note editor was removed and is being rebuilt. - Mount the new editor here. It should receive - `selectedNote.content.document` and persist through - `saveNote(selectedNote.id, { title, content })`. Until - then the stored markdown is shown read-only so note - content stays reachable. */} -
-

- The note editor is being rebuilt. Content is read-only for now. -

- {selectedNote.content.markdown.trim() ? ( -
-                      {selectedNote.content.markdown}
-                    
- ) : ( -

This note is empty.

- )} -
+ saveNote(noteId, { markdown })} + /> ) : ( diff --git a/components/note-editor/README.md b/components/note-editor/README.md new file mode 100644 index 0000000..71b47bb --- /dev/null +++ b/components/note-editor/README.md @@ -0,0 +1,16 @@ +# Note editor + +Markdown + LaTeX editor for the note body. The markdown string is the source +of truth (see `docs/note-editor-requirements.md`); nothing here converts to or +from a block document. + +- `note-editor.tsx` — public `NoteEditor`. Owns the draft, autosave, and the + save-status pill. Loads the CodeMirror host with `next/dynamic` (`ssr: false`). +- `markdown-editor.tsx` — the CodeMirror 6 host. DOM-only, no app state. +- `use-autosave.ts` — debounced, coalescing, single-flight save queue that + re-queues on failure. +- `extensions/theme.ts` — editor chrome and Markdown typography on the app's + CSS variables. + +Mounted from `app/notes/notes-workspace.tsx`; the workspace owns the title, +sidebar, and CRUD. 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/markdown-editor.tsx b/components/note-editor/markdown-editor.tsx new file mode 100644 index 0000000..1ab4ae7 --- /dev/null +++ b/components/note-editor/markdown-editor.tsx @@ -0,0 +1,115 @@ +"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 { editorTheme, markdownHighlight } from "@/components/note-editor/extensions/theme"; + +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; + 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, + 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; + 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), + 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]); + + return
; +} diff --git a/components/note-editor/note-editor.tsx b/components/note-editor/note-editor.tsx new file mode 100644 index 0000000..cfa54bf --- /dev/null +++ b/components/note-editor/note-editor.tsx @@ -0,0 +1,136 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect, useRef } from "react"; +import { AlertCircle, Check, Loader2 } from "lucide-react"; +import { isTempNoteId } from "@/lib/notes/records"; +import { cx } from "@/lib/utils"; +import { useAutosave, type AutosaveStatus } from "@/components/note-editor/use-autosave"; + +// 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 = { + 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; + className?: string; +}; + +function EditorSkeleton() { + return ( +