diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8101e4a2..b75d7296 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -914,11 +914,31 @@ }, "editor": { "emptyState": "Select a note or create a new one to get started.", - "titlePlaceholder": "Untitled" + "titlePlaceholder": "Untitled", + "saving": "Saving…", + "ready": "Ready", + "saved": "Saved {time}", + "stats": "{words} words · {read} min read", + "template": "Template", + "applyTemplate": "Apply template", + "exportMdLabel": ".md", + "exportMd": "Export as Markdown", + "exportHtmlLabel": ".html", + "exportHtml": "Export as HTML", + "focusMode": "Focus mode", + "exitFocusMode": "Exit focus mode", + "save": "Save", + "addTag": "Add tag…", + "removeTag": "Remove tag {tag}", + "loading": "Loading…", + "searchHint": "to search", + "focusHint": "focus mode", + "notePath": "Note path" }, "context": { "authRequiredError": "User not authenticated", - "defaultTitle": "Untitled" + "defaultTitle": "Untitled", + "cloudSyncImageError": "Turn on Cloud Sync to store note images. Text notes stay local." } }, "PasswordManager": { diff --git a/apps/web/package.json b/apps/web/package.json index ab1ed774..374280c3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,8 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3", "@microsoft/clarity": "^1.0.2", + "@milkdown/crepe": "7.21.3", + "@milkdown/kit": "7.21.3", "@monaco-editor/react": "^4.7.0", "@peculiar/x509": "^2.0.0", "@radix-ui/react-accordion": "^1.2.12", diff --git a/apps/web/src/app/app/notes/types/Note.ts b/apps/web/src/app/app/notes/types/Note.ts index bcebe4ce..d1078b66 100644 --- a/apps/web/src/app/app/notes/types/Note.ts +++ b/apps/web/src/app/app/notes/types/Note.ts @@ -1,7 +1,10 @@ export interface Note { id: string; title: string; - content: unknown; // JSON content from rich editor + // Markdown string. Legacy notes may still hold the old rich-editor tree + // (a JSON object); those are converted to markdown on read and re-saved as + // a string on next edit. Typed `unknown` so both shapes are handled safely. + content: unknown; parentId: string | null; icon?: string; pinned?: boolean; diff --git a/apps/web/src/app/app/notes/utils/__tests__/noteContentUtils.test.ts b/apps/web/src/app/app/notes/utils/__tests__/noteContentUtils.test.ts new file mode 100644 index 00000000..5bcb4406 --- /dev/null +++ b/apps/web/src/app/app/notes/utils/__tests__/noteContentUtils.test.ts @@ -0,0 +1,138 @@ +import { + noteContentToMarkdown, + contentToMarkdown, + extractPlainText, + countWords, + readingTimeMinutes, +} from "../noteContentUtils"; + +// Helpers to build legacy rich-editor tree nodes concisely. +const text = (type: string, content: string, extra: Record = {}) => ({ + type, + content, + attributes: {}, + ...extra, +}); +const container = (children: unknown[], attributes: Record = {}) => ({ + type: "container", + children, + attributes, +}); + +describe("noteContentToMarkdown", () => { + it("passes markdown strings through untouched", () => { + expect(noteContentToMarkdown("# Hello\n\nworld")).toBe("# Hello\n\nworld"); + }); + + it("returns empty string for null/undefined/garbage", () => { + expect(noteContentToMarkdown(null)).toBe(""); + expect(noteContentToMarkdown(undefined)).toBe(""); + expect(noteContentToMarkdown(42)).toBe(""); + }); + + it("converts a legacy tree of headings/paragraphs/quotes", () => { + const tree = container([ + text("h1", "Title"), + text("p", "A paragraph."), + text("h2", "Sub"), + text("blockquote", "quoted"), + text("hr", ""), + ]); + expect(noteContentToMarkdown(tree)).toBe( + "# Title\nA paragraph.\n## Sub\n> quoted\n---" + ); + }); + + it("converts bullet list items", () => { + const tree = container([text("li", "one"), text("li", "two")]); + expect(noteContentToMarkdown(tree)).toBe("- one\n- two"); + }); + + it("numbers ordered lists via listType attribute", () => { + const tree = container( + [text("li", "first"), text("li", "second")], + { listType: "ordered" } + ); + expect(noteContentToMarkdown(tree)).toBe("1. first\n2. second"); + }); + + it("converts images and links", () => { + const tree = container([ + text("img", "", { attributes: { src: "https://x/y.png", alt: "pic" } }), + text("a", "click", { attributes: { href: "https://x" } }), + ]); + expect(noteContentToMarkdown(tree)).toBe("![pic](https://x/y.png)\n[click](https://x)"); + }); + + it("converts inline-formatted children", () => { + const tree = container([ + { + type: "p", + attributes: {}, + children: [ + { content: "bold", bold: true }, + { content: " and " }, + { content: "code", code: true }, + ], + }, + ]); + expect(noteContentToMarkdown(tree)).toBe("**bold** and `code`"); + }); + + it("converts code blocks", () => { + const tree = container([text("pre", "const a = 1")]); + expect(noteContentToMarkdown(tree)).toBe("```\nconst a = 1\n```"); + }); + + it("converts a table to GFM", () => { + const tree = container([ + { + type: "table", + attributes: {}, + children: [ + { + type: "tbody", + attributes: {}, + children: [ + { type: "tr", attributes: {}, children: [text("p", "A"), text("p", "B")] }, + { type: "tr", attributes: {}, children: [text("p", "1"), text("p", "2")] }, + ], + }, + ], + }, + ]); + expect(noteContentToMarkdown(tree)).toBe( + "| A | B |\n| --- | --- |\n| 1 | 2 |" + ); + }); +}); + +describe("contentToMarkdown (export)", () => { + it("prepends the title as an h1", () => { + expect(contentToMarkdown("My Note", "body text")).toBe("# My Note\n\nbody text"); + }); + it("works with legacy trees too", () => { + const tree = container([text("p", "hi")]); + expect(contentToMarkdown("T", tree)).toBe("# T\n\nhi"); + }); +}); + +describe("extractPlainText", () => { + it("strips markdown syntax", () => { + const md = "# H\n\nsome **bold** and `code` and [link](https://x) and ![i](y.png)\n\n- a\n- b"; + const plain = extractPlainText(md); + expect(plain).toContain("some bold and code and link"); + expect(plain).not.toContain("**"); + expect(plain).not.toContain("!["); + expect(plain).not.toContain("#"); + }); + it("extracts text from legacy trees", () => { + const tree = container([text("h1", "Hello"), text("p", "world")]); + expect(extractPlainText(tree)).toBe("Hello world"); + }); + it("counts words and reading time", () => { + expect(countWords("one two three")).toBe(3); + expect(readingTimeMinutes(0)).toBe(1); + expect(readingTimeMinutes(400)).toBe(2); + }); +}); diff --git a/apps/web/src/app/app/notes/utils/noteContentUtils.ts b/apps/web/src/app/app/notes/utils/noteContentUtils.ts index 2cfcc1a4..5c23f2ba 100644 --- a/apps/web/src/app/app/notes/utils/noteContentUtils.ts +++ b/apps/web/src/app/app/notes/utils/noteContentUtils.ts @@ -1,26 +1,105 @@ -import { - EditorNode, - getNodeTextContent, - isContainerNode, - isStructuralNode, - isTextNode, -} from "@/components/ui/rich-editor/types"; - -/** Recursively extract all plain text from rich editor content JSON. */ +// Note content helpers. +// +// Notes are stored as a **markdown string** in `Note.content`. Older notes were +// stored as the legacy rich-editor tree (a JSON object rooted at a "container" +// node); those are converted to markdown on read (lazy migration) and re-saved +// as a string on the next edit. Every function here therefore accepts either +// shape and normalizes internally — no dependency on the (removed) rich-editor. + +// ─── Legacy tree shape (minimal, self-contained) ───────────────────────────── + +interface LegacyInline { + content: string; + bold?: boolean; + italic?: boolean; + code?: boolean; + underline?: boolean; + strikethrough?: boolean; + href?: string; +} +interface LegacyLine { + content?: string; + children?: LegacyInline[]; +} +interface LegacyNode { + type: string; + content?: string; + children?: LegacyNode[] | LegacyInline[]; + lines?: LegacyLine[]; + attributes?: Record; +} + +function isLegacyTree(content: unknown): content is LegacyNode { + return ( + !!content && + typeof content === "object" && + typeof (content as LegacyNode).type === "string" + ); +} + +const CONTAINER_TYPES = new Set(["container", "table", "thead", "tbody", "tr"]); + +function isInlineArray(children: unknown): children is LegacyInline[] { + return ( + Array.isArray(children) && + (children.length === 0 || + (typeof children[0] === "object" && + children[0] !== null && + "content" in children[0] && + !("type" in children[0]))) + ); +} + +/** Plain text of a single legacy node (recursively, inline markers stripped). */ +function legacyNodeText(n: LegacyNode): string { + if (n.lines && n.lines.length) { + return n.lines + .map((l) => (l.children ? l.children.map((c) => c.content).join("") : l.content ?? "")) + .join(" "); + } + if (n.children && isInlineArray(n.children)) { + return n.children.map((c) => c.content).join(""); + } + if (n.children && Array.isArray(n.children)) { + return (n.children as LegacyNode[]).map(legacyNodeText).join(" "); + } + return n.content ?? ""; +} + +// ─── Plain text extraction (markdown OR legacy tree) ───────────────────────── + +/** Strip markdown syntax down to readable plain text. Good enough for search + * indexing, word counts, and snippets — not a full parser. */ +function markdownToPlainText(md: string): string { + return md + .replace(/```[\s\S]*?```/g, " ") // fenced code + .replace(/`([^`]+)`/g, "$1") // inline code + .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links -> text + .replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings + .replace(/^\s{0,3}>\s?/gm, "") // blockquotes + .replace(/^\s*([-*+]|\d+\.)\s+/gm, "") // list markers + .replace(/^\s*\|.*\|\s*$/gm, (row) => row.replace(/\|/g, " ")) // table pipes + .replace(/[*_~]{1,3}/g, "") // emphasis markers + .replace(/^\s*[-*_]{3,}\s*$/gm, " ") // hr + .replace(/\s+/g, " ") + .trim(); +} + +/** Recursively extract all plain text from note content (markdown or legacy). */ export function extractPlainText(content: unknown): string { - if (!content || typeof content !== "object") return ""; + if (typeof content === "string") return markdownToPlainText(content); + if (!isLegacyTree(content)) return ""; const parts: string[] = []; - - function traverse(n: EditorNode) { - if (isTextNode(n)) { - const text = getNodeTextContent(n).trim(); + function traverse(n: LegacyNode) { + if (CONTAINER_TYPES.has(n.type) && Array.isArray(n.children) && !isInlineArray(n.children)) { + (n.children as LegacyNode[]).forEach(traverse); + } else { + const text = legacyNodeText(n).trim(); if (text) parts.push(text); - } else if (isContainerNode(n) || isStructuralNode(n)) { - (n.children as EditorNode[]).forEach(traverse); } } - - traverse(content as EditorNode); + traverse(content); return parts.join(" "); } @@ -42,40 +121,98 @@ export function extractSnippet(text: string, query: string, maxLen = 80): string return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : ""); } -// ─── Markdown export ───────────────────────────────────────────────────────── +// ─── Legacy tree → markdown (migration + export) ───────────────────────────── -function inlineToMd(children: { content: string; bold?: boolean; italic?: boolean; code?: boolean; href?: string }[]): string { +function inlineToMd(children: LegacyInline[]): string { return children .map((c) => { - let t = c.content; + const t = c.content; if (c.code) return "`" + t + "`"; + if (c.href) return `[${t}](${c.href})`; if (c.bold && c.italic) return `***${t}***`; if (c.bold) return `**${t}**`; if (c.italic) return `_${t}_`; - if (c.href) return `[${t}](${c.href})`; return t; }) .join(""); } -function nodeToMd(n: EditorNode, listDepth = 0): string { - if (isContainerNode(n) || isStructuralNode(n)) { - return (n.children as EditorNode[]).map((c) => nodeToMd(c, listDepth)).join("\n"); +/** Text of a legacy node with inline markdown markers preserved. */ +function legacyNodeMd(n: LegacyNode): string { + if (n.lines && n.lines.length) { + return n.lines + .map((l) => (l.children ? inlineToMd(l.children) : l.content ?? "")) + .join("\n"); + } + if (n.children && isInlineArray(n.children)) return inlineToMd(n.children); + return n.content ?? ""; +} + +/** Collect visible cell text from a table row's children. */ +function rowCells(tr: LegacyNode): string[] { + const cells = (tr.children as LegacyNode[]) ?? []; + return cells.map((c) => legacyNodeText(c).replace(/\|/g, "\\|").trim()); +} + +function tableToMd(table: LegacyNode): string { + const rows: string[][] = []; + function collectRows(n: LegacyNode) { + if (n.type === "tr") { + rows.push(rowCells(n)); + } else if (Array.isArray(n.children) && !isInlineArray(n.children)) { + (n.children as LegacyNode[]).forEach(collectRows); + } + } + collectRows(table); + if (!rows.length) return ""; + const width = Math.max(...rows.map((r) => r.length)); + const pad = (r: string[]) => Array.from({ length: width }, (_, i) => r[i] ?? ""); + const header = pad(rows[0]); + const lines = [ + `| ${header.join(" | ")} |`, + `| ${header.map(() => "---").join(" | ")} |`, + ...rows.slice(1).map((r) => `| ${pad(r).join(" | ")} |`), + ]; + return lines.join("\n"); +} + +function nodeToMd(n: LegacyNode, listDepth = 0, orderedIndex?: number): string { + if (n.type === "table") return tableToMd(n); + + if (CONTAINER_TYPES.has(n.type) && Array.isArray(n.children) && !isInlineArray(n.children)) { + // Ordered list container: number its direct list-item children. + if (n.type === "container" && n.attributes?.listType === "ordered") { + let i = 0; + return (n.children as LegacyNode[]) + .map((c) => nodeToMd(c, listDepth, c.type === "li" ? ++i : undefined)) + .join("\n"); + } + return (n.children as LegacyNode[]).map((c) => nodeToMd(c, listDepth)).join("\n"); } - if (!isTextNode(n)) return ""; - - const raw = n.content ?? ""; - const inlineText = n.children ? inlineToMd(n.children) : raw; - const linesText = n.lines - ? n.lines - .map((l) => - l.children ? inlineToMd(l.children) : (l.content ?? "") - ) - .join("\n") - : null; - const text = linesText ?? inlineText; + const indent = " ".repeat(listDepth); + const src = n.attributes ?? {}; + switch (n.type) { + case "img": { + const alt = (src.alt as string) ?? ""; + const url = (src.src as string) ?? ""; + return url ? `![${alt}](${url})` : ""; + } + case "a": { + const href = (src.href as string) ?? ""; + const text = legacyNodeMd(n) || href; + return href ? `[${text}](${href})` : text; + } + case "hr": + return "---"; + case "br": + return ""; + default: + break; + } + + const text = legacyNodeMd(n); switch (n.type) { case "h1": return `# ${text}`; case "h2": return `## ${text}`; @@ -84,20 +221,33 @@ function nodeToMd(n: EditorNode, listDepth = 0): string { case "h5": return `##### ${text}`; case "h6": return `###### ${text}`; case "blockquote": return `> ${text}`; - case "li": return `${indent}- ${text}`; + case "li": + return orderedIndex != null ? `${indent}${orderedIndex}. ${text}` : `${indent}- ${text}`; case "pre": case "code": return "```\n" + text + "\n```"; - case "hr": return "---"; - case "br": return ""; default: return text; } } +/** Convert legacy tree content to a markdown body (no title heading). */ +function legacyTreeToMarkdown(content: LegacyNode): string { + // nodeToMd handles both containers (joining children, honoring ordered lists) + // and leaf nodes, so the root routes through it uniformly. + return nodeToMd(content).replace(/\n{3,}/g, "\n\n").trim(); +} + +/** + * Normalize any stored `Note.content` to the markdown string the editor loads. + * New notes are already strings; legacy tree notes are converted on the fly. + */ +export function noteContentToMarkdown(content: unknown): string { + if (typeof content === "string") return content; + if (isLegacyTree(content)) return legacyTreeToMarkdown(content); + return ""; +} + +/** Full markdown document for export: `# title` + body. */ export function contentToMarkdown(title: string, content: unknown): string { - if (!content || typeof content !== "object") return `# ${title}\n`; - const body = (content as EditorNode); - const lines = isContainerNode(body) - ? body.children.map((c) => nodeToMd(c)).join("\n") - : nodeToMd(body); - return `# ${title}\n\n${lines}`.replace(/\n{3,}/g, "\n\n").trim(); + const body = noteContentToMarkdown(content); + return `# ${title}\n\n${body}`.replace(/\n{3,}/g, "\n\n").trim(); } diff --git a/apps/web/src/app/app/notes/utils/noteTemplates.ts b/apps/web/src/app/app/notes/utils/noteTemplates.ts index a934a873..0eec35e6 100644 --- a/apps/web/src/app/app/notes/utils/noteTemplates.ts +++ b/apps/web/src/app/app/notes/utils/noteTemplates.ts @@ -1,32 +1,11 @@ -import { ContainerNode, TextNode } from "@/components/ui/rich-editor/types"; - -function id() { - return Math.random().toString(36).slice(2, 10); -} - -function p(text = ""): TextNode { - return { id: id(), type: "p", content: text, attributes: {} }; -} -function h2(text: string): TextNode { - return { id: id(), type: "h2", content: text, attributes: {} }; -} -function li(text: string): TextNode { - return { id: id(), type: "li", content: text, attributes: {} }; -} -function hr(): TextNode { - return { id: id(), type: "hr", content: "", attributes: {} }; -} - -function container(children: (TextNode | ContainerNode)[]): ContainerNode { - return { id: id(), type: "container", children, attributes: {} }; -} +// Note templates. `content` is a markdown string applied into the editor. export interface NoteTemplate { id: string; label: string; icon: string; description: string; - content: ContainerNode; + content: string; defaultTitle: string; } @@ -37,7 +16,7 @@ export const NOTE_TEMPLATES: NoteTemplate[] = [ icon: "📄", description: "Start from scratch", defaultTitle: "Untitled", - content: container([p(), p(), p()]), + content: "", }, { id: "meeting", @@ -45,16 +24,19 @@ export const NOTE_TEMPLATES: NoteTemplate[] = [ icon: "🤝", description: "Attendees, agenda, action items", defaultTitle: "Meeting Notes", - content: container([ - h2("Attendees"), - li(""), - h2("Agenda"), - li(""), - h2("Notes"), - p(""), - h2("Action Items"), - li(""), - ]), + content: [ + "## Attendees", + "- ", + "", + "## Agenda", + "- ", + "", + "## Notes", + "", + "## Action Items", + "- ", + "", + ].join("\n"), }, { id: "daily", @@ -62,16 +44,18 @@ export const NOTE_TEMPLATES: NoteTemplate[] = [ icon: "📅", description: "Goals, notes, reflection", defaultTitle: "Daily Journal", - content: container([ - h2("Today's Goals"), - li(""), - li(""), - h2("Notes"), - p(""), - hr(), - h2("Reflection"), - p(""), - ]), + content: [ + "## Today's Goals", + "- ", + "- ", + "", + "## Notes", + "", + "---", + "", + "## Reflection", + "", + ].join("\n"), }, { id: "bug", @@ -79,18 +63,19 @@ export const NOTE_TEMPLATES: NoteTemplate[] = [ icon: "🐛", description: "Summary, steps, expected/actual", defaultTitle: "Bug Report", - content: container([ - h2("Summary"), - p(""), - h2("Steps to Reproduce"), - li(""), - li(""), - h2("Expected Behaviour"), - p(""), - h2("Actual Behaviour"), - p(""), - h2("Fix / Notes"), - p(""), - ]), + content: [ + "## Summary", + "", + "## Steps to Reproduce", + "1. ", + "2. ", + "", + "## Expected Behaviour", + "", + "## Actual Behaviour", + "", + "## Fix / Notes", + "", + ].join("\n"), }, ]; diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index c0c074c5..159a8f12 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -23,7 +23,6 @@ import { recordMetric, recordLog } from "@/lib/observability/metrics" import { OfflineIndicator } from "./offline-indicator" import { putCachedResponse, getCachedResponse } from "@/lib/cache/response-cache" import { registerApiClientServiceWorker } from "@/lib/sw/register-api-client-sw" -import { P2pSyncDialog } from "./p2p-sync-dialog" import { FuzzRunDialog } from "./fuzz-run-dialog" import { RecorderDialog } from "./recorder-dialog" import { listenForExtensionImports, capturedToTab } from "@/lib/extension/listen" @@ -51,7 +50,7 @@ import { useIsMobile } from "@/components/hooks/use-mobile" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { FolderOpen, PanelRight, MoreVertical, Cookie, Download, Gauge } from "lucide-react" +import { FolderOpen, PanelRight, MoreVertical, MoreHorizontal, Cookie, Download, Gauge, Shuffle, Puzzle, Activity, Server, Radio, Keyboard } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" import { IconCode, IconSettings } from "@tabler/icons-react" import { cn } from "@/lib/utils" @@ -147,7 +146,6 @@ function ApiClientInner() { const [publicMocksOpen, setPublicMocksOpen] = React.useState(false) const [pluginsOpen, setPluginsOpen] = React.useState(false) const [metricsOpen, setMetricsOpen] = React.useState(false) - const [p2pOpen, setP2pOpen] = React.useState(false) const [fuzzOpen, setFuzzOpen] = React.useState(false) const [recorderOpen, setRecorderOpen] = React.useState(false) @@ -158,8 +156,8 @@ function ApiClientInner() { React.useEffect(() => listenForExtensionImports((captured) => { const tab = createNewTab() appendTab({ ...tab, name: API_CLIENT_IMPORTED_TAB_NAME, ...capturedToTab(captured) }) - toast.success(`Imported ${captured.method} ${captured.url} from extension`) - }), [appendTab]) + toast.success(t("toolbar.extensionImported", { method: captured.method, url: captured.url })) + }), [appendTab, t]) /** Rehydrate the active tab's response from IndexedDB if missing. * Bodies are stripped from localStorage tabs to dodge the 5MB quota; this @@ -1138,7 +1136,8 @@ function ApiClientInner() { {t("toolbar.importCurl")} setImportOpen(true)}> - Import collection + + {t("toolbar.importCollection")} setEnvMgrOpen(true)}> @@ -1147,31 +1146,36 @@ function ApiClientInner() { setCookieJarOpen(true)}> - Cookies + {t("toolbar.cookies")} + setPerfOpen(true)}> - Perf run + {t("toolbar.perfRun")} + + setFuzzOpen(true)}> + + {t("toolbar.fuzzRun")} setPublicMocksOpen(true)}> - Public mocks + + {t("toolbar.publicMocks")} setPluginsOpen(true)}> - Plugins + + {t("toolbar.plugins")} setMetricsOpen(true)}> - Metrics - - setP2pOpen(true)}> - Peer sync (WebRTC) - - setFuzzOpen(true)}> - Fuzz run + + {t("toolbar.metrics")} setRecorderOpen(true)}> - Capture & replay + + {t("toolbar.recorder")} + setHelpOpen(true)}> + {t("toolbar.shortcuts")} @@ -1238,39 +1242,54 @@ function ApiClientInner() { variant="ghost" size="icon" className="h-8 w-8" - title="Import collection (Postman / HAR)" + title={t("toolbar.importCollection")} onClick={() => setImportOpen(true)} > - - - - + + + + + + setCookieJarOpen(true)}> + + {t("toolbar.cookies")} + + + setPerfOpen(true)}> + + {t("toolbar.perfRun")} + + setFuzzOpen(true)}> + + {t("toolbar.fuzzRun")} + + setPublicMocksOpen(true)}> + + {t("toolbar.publicMocks")} + + setPluginsOpen(true)}> + + {t("toolbar.plugins")} + + setMetricsOpen(true)}> + + {t("toolbar.metrics")} + + setRecorderOpen(true)}> + + {t("toolbar.recorder")} + + + setHelpOpen(true)}> + + {t("toolbar.shortcuts")} + + +
diff --git a/apps/web/src/components/api-client/generate-code.ts b/apps/web/src/components/api-client/generate-code.ts index 47223f64..edad3894 100644 --- a/apps/web/src/components/api-client/generate-code.ts +++ b/apps/web/src/components/api-client/generate-code.ts @@ -75,6 +75,16 @@ export function generateCode(request: ApiRequestState, language: CodeLanguage): } else if (body.type === "form-data") { bodyCtx.type = "form-data" bodyCtx.formData = (body.formData ?? []).filter(item => item.active && item.key) + } else if (body.type === "graphql") { + // GraphQL travels as { query, variables } JSON — mirror the send pipeline. + let variables: unknown + const rawVars = (body.graphqlVariables ?? "").trim() + if (rawVars) { + try { variables = JSON.parse(rawVars) } catch { /* invalid variables: emit query only */ } + } + bodyCtx.type = "json" + bodyCtx.content = JSON.stringify(variables !== undefined ? { query: body.content, variables } : { query: body.content }) + if (!hasContentType) headerObj["Content-Type"] = "application/json" } else { bodyCtx.type = "text" bodyCtx.content = body.content diff --git a/apps/web/src/components/notes/NotionEditor.tsx b/apps/web/src/components/notes/NotionEditor.tsx index f5e1a34b..2d0c8a5e 100644 --- a/apps/web/src/components/notes/NotionEditor.tsx +++ b/apps/web/src/components/notes/NotionEditor.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { useNotesData, useNotesUI, useNotesActions } from "@/app/app/notes/context/NotesContext"; -import { Editor, EditorProvider, createEmptyContent } from "@/components/ui/rich-editor"; -import { useDebounce, useDebouncedCallback } from "use-debounce"; +import { NoteMarkdownEditor } from "@/components/notes/markdown-editor/NoteMarkdownEditor"; +import { useDebouncedCallback } from "use-debounce"; import { Input } from "@/components/ui/input"; -import { ContainerNode, EditorState } from "@/components/ui/rich-editor/types"; import { storage } from "@/database/firebase"; import { ref, uploadBytes, getDownloadURL } from "firebase/storage"; import useAuth from "@/utils/useAuth"; @@ -23,20 +22,19 @@ import { Loader2, ChevronRight, } from "lucide-react"; -import { serializeToHtml } from "@/components/ui/rich-editor/utils/serialize-to-html"; +import { marked } from "marked"; import { cn } from "@/lib/utils"; import { TemplatePickerDialog } from "./template-picker-dialog"; import { type NoteTemplate } from "@/app/app/notes/utils/noteTemplates"; -import { contentToMarkdown, countWords, extractPlainText, readingTimeMinutes } from "@/app/app/notes/utils/noteContentUtils"; +import { + contentToMarkdown, + noteContentToMarkdown, + countWords, + extractPlainText, + readingTimeMinutes, +} from "@/app/app/notes/utils/noteContentUtils"; import type { Note } from "@/app/app/notes/types/Note"; -function sanitizeFileName(name: string): string { - return name - .replace(/[^a-zA-Z0-9._-]/g, "_") - .replace(/_{2,}/g, "_") - .slice(0, 200); -} - export default function NotionEditor() { const tEditor = useTranslations("Notes.editor"); const tCtx = useTranslations("Notes.context"); @@ -51,13 +49,24 @@ export default function NotionEditor() { const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle"); const [lastSavedAt, setLastSavedAt] = useState(null); const [templateDialogOpen, setTemplateDialogOpen] = useState(false); - const [currentContent, setCurrentContent] = useState(null); + // Latest editor markdown, kept in a ref so typing doesn't re-render the + // toolbar/breadcrumb/tags on every keystroke. `wordCountSource` is a + // debounced snapshot that DOES drive a render (word count / read time). + const latestMarkdownRef = useRef(null); + const [wordCountSource, setWordCountSource] = useState(null); + const pushWordCount = useDebouncedCallback( + (md: string) => setWordCountSource(md), + 500, + ); const [editorKey, setEditorKey] = useState(0); const isDirtyRef = useRef(false); const [tagInput, setTagInput] = useState(""); const [tags, setTags] = useState([]); - // Holds template content until EditorProvider mounts with it; cleared on note switch - const [pendingTemplateContent, setPendingTemplateContent] = useState(null); + // Holds template markdown (scoped to the note it was applied to) until the + // editor remounts with it; cleared on note switch. Scoping prevents a + // just-applied template from leaking into a different note during a switch. + const [pendingTemplateContent, setPendingTemplateContent] = + useState<{ noteId: string; markdown: string } | null>(null); useEffect(() => { setIsMounted(true); @@ -71,6 +80,8 @@ export default function NotionEditor() { setTags(activeNote.tags ?? []); setLastSyncedNoteId(activeNote.id); setPendingTemplateContent(null); + latestMarkdownRef.current = null; + setWordCountSource(null); } }, [activeNoteId, activeNote, lastSyncedNoteId]); @@ -80,23 +91,11 @@ export default function NotionEditor() { return () => clearTimeout(timeout); }, [saveState]); - const stripUndefined = (obj: any): any => { - if (obj === null || obj === undefined) return null; - if (typeof obj !== 'object') return obj; - if (Array.isArray(obj)) return obj.map(stripUndefined); - const out: any = {}; - for (const key in obj) { - const value = obj[key]; - if (value !== undefined) out[key] = stripUndefined(value); - } - return out; - }; - - const handleUpdate = useCallback(async (id: string, updates: any) => { + const handleUpdate = useCallback(async (id: string, updates: Partial) => { if (id) { setSaveState("saving"); isDirtyRef.current = false; - await updateNote(id, stripUndefined(updates)); + await updateNote(id, updates); setLastSavedAt(new Date()); setSaveState("saved"); } @@ -113,13 +112,13 @@ export default function NotionEditor() { } }; - const handleEditorChange = (state: EditorState) => { - const container = state.history[state.historyIndex]; - setCurrentContent(container); + const handleEditorChange = (markdown: string) => { + latestMarkdownRef.current = markdown; + pushWordCount(markdown); if (activeNoteId) { isDirtyRef.current = true; setSaveState("saving"); - debouncedUpdate(activeNoteId, { content: container }); + debouncedUpdate(activeNoteId, { content: markdown }); } }; @@ -134,7 +133,7 @@ export default function NotionEditor() { const { isSyncEnabled } = await import("@/lib/desktop/sync-engine"); const allowed = hasRemoteSession() && (await isSyncEnabled()); if (user.uid === "desktop-local" || !allowed) { - throw new Error("Turn on Cloud Sync to store note images. Text notes stay local."); + throw new Error(tCtx("cloudSyncImageError")); } } const timestamp = Date.now(); @@ -144,24 +143,15 @@ export default function NotionEditor() { return getDownloadURL(storageRef); }; - const initialContent = useMemo(() => { - // Template was just applied — use it directly (activeNote.content not updated yet) - if (pendingTemplateContent) return pendingTemplateContent; - if (activeNote && activeNote.content) { - const content = activeNote.content as any; - if (content.type === 'container' && Array.isArray(content.children)) { - return content as ContainerNode; - } + const initialMarkdown = useMemo(() => { + // A template applied to THIS note takes precedence (activeNote.content + // isn't updated until the debounced save round-trips). + if (pendingTemplateContent?.noteId === activeNoteId) { + return pendingTemplateContent.markdown; } - return { - id: "root", - type: "container", - children: createEmptyContent(), - attributes: {}, - } as ContainerNode; - // editorKey in deps so memo re-runs when template forces remount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeNoteId, editorKey]); + // Normalizes both new (string) and legacy (tree) content to markdown. + return noteContentToMarkdown(activeNote?.content); + }, [activeNoteId, editorKey, activeNote?.content, pendingTemplateContent]); const activePath = useMemo(() => { if (!activeNote) return []; @@ -198,18 +188,17 @@ export default function NotionEditor() { if (activeNoteId) debouncedUpdate(activeNoteId, { tags: next }); }, [tags, activeNoteId, debouncedUpdate]); - // Defer word-count compute: heavy extractPlainText runs at most every 500ms + // Defer word-count compute: the heavy extract runs at most every 500ms // instead of every keystroke. - const [debouncedContent] = useDebounce(currentContent, 500); const { wordCount, readTime } = useMemo(() => { - const src = debouncedContent ?? (activeNote?.content as ContainerNode | undefined); + const src = wordCountSource ?? activeNote?.content; const text = extractPlainText(src); const wc = countWords(text); return { wordCount: wc, readTime: readingTimeMinutes(wc) }; - }, [debouncedContent, activeNote?.content]); + }, [wordCountSource, activeNote?.content]); const handleExportMarkdown = useCallback(() => { - const src = currentContent ?? (activeNote?.content as ContainerNode | undefined); + const src = latestMarkdownRef.current ?? activeNote?.content; const md = contentToMarkdown(title || "Untitled", src); const blob = new Blob([md], { type: "text/markdown" }); const url = URL.createObjectURL(blob); @@ -218,12 +207,12 @@ export default function NotionEditor() { a.download = `${(title || "note").replace(/\s+/g, "-")}.md`; a.click(); URL.revokeObjectURL(url); - }, [currentContent, activeNote?.content, title]); + }, [activeNote?.content, title]); const handleExportHtml = useCallback(() => { - const src = currentContent ?? (activeNote?.content as ContainerNode | undefined); - if (!src) return; - const bodyHtml = serializeToHtml(src, { wrapperClass: "note-content max-w-3xl mx-auto px-6 py-8 font-sans" }); + const src = latestMarkdownRef.current ?? activeNote?.content; + const body = noteContentToMarkdown(src); + const bodyHtml = marked.parse(body, { async: false }) as string; const html = `\n\n\n\n${title || "Note"}\n\n\n

${title || "Untitled"}

\n${bodyHtml}\n`; const blob = new Blob([html], { type: "text/html" }); const url = URL.createObjectURL(blob); @@ -232,17 +221,20 @@ export default function NotionEditor() { a.download = `${(title || "note").replace(/\s+/g, "-")}.html`; a.click(); URL.revokeObjectURL(url); - }, [currentContent, activeNote?.content, title]); + }, [activeNote?.content, title]); const handleApplyTemplate = useCallback((tpl: NoteTemplate) => { if (!activeNoteId) return; - const newContent = { ...tpl.content, id: "root" } as ContainerNode; setSaveState("saving"); - debouncedUpdate(activeNoteId, { content: newContent, title: tpl.defaultTitle }); + debouncedUpdate(activeNoteId, { content: tpl.content, title: tpl.defaultTitle }); setTitle(tpl.defaultTitle); - setCurrentContent(newContent); - setPendingTemplateContent(newContent); // initialContent reads this on remount - setLastSyncedNoteId(null); + latestMarkdownRef.current = tpl.content; + setWordCountSource(tpl.content); + // Scope the pending template to this note; initialMarkdown reads it on the + // remount forced by bumping editorKey. We deliberately do NOT reset + // lastSyncedNoteId here — doing so would re-fire the note-sync effect and + // clobber the title/content we just set. + setPendingTemplateContent({ noteId: activeNoteId, markdown: tpl.content }); setEditorKey(k => k + 1); }, [activeNoteId, debouncedUpdate]); @@ -283,10 +275,10 @@ export default function NotionEditor() {
⌘K - to search + {tEditor("searchHint")} · ⌘⇧F - focus mode + {tEditor("focusHint")}
@@ -303,7 +295,7 @@ export default function NotionEditor() { )}> {/* Breadcrumb */} {!focusMode && activePath.length > 1 && ( -