From 53ede1c14df2dc22300a75437cb7535238b49ae2 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Tue, 14 Jul 2026 00:18:05 +0530 Subject: [PATCH 1/3] Replace homegrown block editor with Milkdown (Crepe) in Notes Notes previously used a ~27k-line custom "Notion-style" block editor that stored a bespoke ContainerNode tree as Note.content. Replace it with Milkdown Crepe, storing plain markdown. - Add NoteMarkdownEditor (Crepe): slash menu, toolbar, tables, code, images (onUpload -> existing Firebase handler), placeholder, class-based dark mode. - Rewire NotionEditor to markdown in/out: save, template apply, .md/.html export (HTML via marked), word count; all surrounding chrome preserved. - Make noteContentUtils self-contained and markdown-aware, with a hardened legacy tree->markdown converter (headings, ordered/unordered lists, links, images, code, GFM tables, inline formatting) for lazy migration of old notes. - Convert noteTemplates to markdown strings; document Note.content as markdown. - Delete components/ui/rich-editor entirely (69 files). - Add unit tests for the converter (14 cases). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/package.json | 2 + apps/web/src/app/app/notes/types/Note.ts | 5 +- .../utils/__tests__/noteContentUtils.test.ts | 138 + .../app/app/notes/utils/noteContentUtils.ts | 242 +- .../src/app/app/notes/utils/noteTemplates.ts | 99 +- .../web/src/components/notes/NotionEditor.tsx | 115 +- .../markdown-editor/NoteMarkdownEditor.tsx | 103 + .../notes/markdown-editor/crepe-theme.css | 37 + .../components/ui/rich-editor/CoverImage.tsx | 231 - .../ui/rich-editor/ElementSelector.tsx | 173 - .../ui/rich-editor/ExportFloatingButton.tsx | 243 - .../ui/rich-editor/FreeImageBlock.tsx | 340 -- .../ui/rich-editor/InsertComponentsModal.tsx | 125 - .../ui/rich-editor/MediaUploadPopover.tsx | 92 - .../ui/rich-editor/QuickModeToggle.tsx | 133 - .../ui/rich-editor/SelectionToolbar.tsx | 427 -- .../ui/rich-editor/TemplateSwitcherButton.tsx | 393 -- .../CustomClassPopoverContent.tsx | 124 - .../_toolbar-components/FormatButtons.tsx | 147 - .../LinkPopoverContent.tsx | 76 - .../rich-editor/_toolbar-components/index.ts | 3 - .../ui/rich-editor/add-block-button.tsx | 49 - .../ui/rich-editor/block-context-menu.tsx | 254 -- .../src/components/ui/rich-editor/block.tsx | 1138 ----- .../ui/rich-editor/class-mappings.ts | 1061 ----- .../ui/rich-editor/color-picker-index.tsx | 472 -- .../ui/rich-editor/color-picker-interface.tsx | 156 - .../ui/rich-editor/color-picker.tsx | 156 - .../ui/rich-editor/command-menu.tsx | 512 --- .../ui/rich-editor/custom-class-popover.tsx | 365 -- .../components/ui/rich-editor/demo-content.ts | 2573 ----------- .../ui/rich-editor/editor-toolbar.tsx | 183 - .../src/components/ui/rich-editor/editor.tsx | 1361 ------ .../src/components/ui/rich-editor/elements.ts | 104 - .../ui/rich-editor/empty-content.ts | 48 - .../ui/rich-editor/flex-container.tsx | 127 - .../ui/rich-editor/font-size-picker.tsx | 115 - .../ui/rich-editor/group-images-button.tsx | 93 - .../handlers/block/block-drag-handlers.ts | 40 - .../handlers/block/block-event-handlers.ts | 347 -- .../handlers/block/block-renderer.ts | 100 - .../handlers/block/block-styles.ts | 110 - .../rich-editor/handlers/block/block-utils.ts | 305 -- .../ui/rich-editor/handlers/block/index.ts | 7 - .../handlers/drag-drop-handlers.ts | 807 ---- .../handlers/file-upload-handlers.ts | 359 -- .../handlers/flex-container-handlers.ts | 202 - .../handlers/image-selection-handlers.ts | 368 -- .../ui/rich-editor/handlers/index.ts | 13 - .../rich-editor/handlers/keyboard-handlers.ts | 540 --- .../handlers/node-operation-handlers.ts | 651 --- .../handlers/selection-handlers.ts | 335 -- .../ui/rich-editor/hooks/use-mobile.ts | 19 - .../ui/rich-editor/hooks/use-toast.ts | 23 - .../components/ui/rich-editor/image-block.tsx | 189 - .../src/components/ui/rich-editor/index.ts | 128 - .../ui/rich-editor/insert-components-data.ts | 51 - .../ui/rich-editor/lib/reducer/actions.ts | 736 --- .../rich-editor/lib/reducer/editor-reducer.ts | 1241 ----- .../components/ui/rich-editor/lib/utils.ts | 6 - .../ui/rich-editor/link-popover.tsx | 227 - .../ui/rich-editor/media-upload-popover.tsx | 94 - .../ui/rich-editor/store/editor-store.ts | 345 -- .../ui/rich-editor/table-builder.tsx | 762 ---- .../ui/rich-editor/table-dialog.tsx | 199 - .../ui/rich-editor/tailwind-classes.ts | 670 --- .../components/ui/rich-editor/templates.ts | 4031 ----------------- .../src/components/ui/rich-editor/types.ts | 423 -- .../ui/rich-editor/utils/class-replacement.ts | 130 - .../ui/rich-editor/utils/drag-auto-scroll.ts | 202 - .../ui/rich-editor/utils/editor-helpers.ts | 544 --- .../ui/rich-editor/utils/image-upload.ts | 99 - .../ui/rich-editor/utils/inline-formatting.ts | 203 - .../utils/markdown-table-parser.ts | 203 - .../ui/rich-editor/utils/serialize-to-html.ts | 614 --- .../ui/rich-editor/utils/tree-operations.ts | 448 -- .../components/ui/rich-editor/video-block.tsx | 216 - 77 files changed, 570 insertions(+), 27432 deletions(-) create mode 100644 apps/web/src/app/app/notes/utils/__tests__/noteContentUtils.test.ts create mode 100644 apps/web/src/components/notes/markdown-editor/NoteMarkdownEditor.tsx create mode 100644 apps/web/src/components/notes/markdown-editor/crepe-theme.css delete mode 100644 apps/web/src/components/ui/rich-editor/CoverImage.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/ElementSelector.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/ExportFloatingButton.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/FreeImageBlock.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/InsertComponentsModal.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/MediaUploadPopover.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/QuickModeToggle.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/SelectionToolbar.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/TemplateSwitcherButton.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/_toolbar-components/CustomClassPopoverContent.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/_toolbar-components/FormatButtons.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/_toolbar-components/LinkPopoverContent.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/_toolbar-components/index.ts delete mode 100644 apps/web/src/components/ui/rich-editor/add-block-button.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/block-context-menu.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/block.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/class-mappings.ts delete mode 100644 apps/web/src/components/ui/rich-editor/color-picker-index.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/color-picker-interface.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/color-picker.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/command-menu.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/custom-class-popover.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/demo-content.ts delete mode 100644 apps/web/src/components/ui/rich-editor/editor-toolbar.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/editor.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/elements.ts delete mode 100644 apps/web/src/components/ui/rich-editor/empty-content.ts delete mode 100644 apps/web/src/components/ui/rich-editor/flex-container.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/font-size-picker.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/group-images-button.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/block/block-drag-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/block/block-event-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/block/block-renderer.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/block/block-styles.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/block/block-utils.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/block/index.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/drag-drop-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/file-upload-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/flex-container-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/image-selection-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/index.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/keyboard-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/node-operation-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/handlers/selection-handlers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/hooks/use-mobile.ts delete mode 100644 apps/web/src/components/ui/rich-editor/hooks/use-toast.ts delete mode 100644 apps/web/src/components/ui/rich-editor/image-block.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/index.ts delete mode 100644 apps/web/src/components/ui/rich-editor/insert-components-data.ts delete mode 100644 apps/web/src/components/ui/rich-editor/lib/reducer/actions.ts delete mode 100644 apps/web/src/components/ui/rich-editor/lib/reducer/editor-reducer.ts delete mode 100644 apps/web/src/components/ui/rich-editor/lib/utils.ts delete mode 100644 apps/web/src/components/ui/rich-editor/link-popover.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/media-upload-popover.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/store/editor-store.ts delete mode 100644 apps/web/src/components/ui/rich-editor/table-builder.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/table-dialog.tsx delete mode 100644 apps/web/src/components/ui/rich-editor/tailwind-classes.ts delete mode 100644 apps/web/src/components/ui/rich-editor/templates.ts delete mode 100644 apps/web/src/components/ui/rich-editor/types.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/class-replacement.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/drag-auto-scroll.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/editor-helpers.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/image-upload.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/inline-formatting.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/markdown-table-parser.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/serialize-to-html.ts delete mode 100644 apps/web/src/components/ui/rich-editor/utils/tree-operations.ts delete mode 100644 apps/web/src/components/ui/rich-editor/video-block.tsx 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/notes/NotionEditor.tsx b/apps/web/src/components/notes/NotionEditor.tsx index f5e1a34b..74f9aeae 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 { NoteMarkdownEditor } from "@/components/notes/markdown-editor/NoteMarkdownEditor"; import { useDebounce, 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,13 @@ 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); + const [currentMarkdown, setCurrentMarkdown] = useState(null); 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 until the editor remounts with it; cleared on note switch. + const [pendingTemplateContent, setPendingTemplateContent] = useState(null); useEffect(() => { setIsMounted(true); @@ -71,6 +69,7 @@ export default function NotionEditor() { setTags(activeNote.tags ?? []); setLastSyncedNoteId(activeNote.id); setPendingTemplateContent(null); + setCurrentMarkdown(null); } }, [activeNoteId, activeNote, lastSyncedNoteId]); @@ -80,23 +79,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 +100,12 @@ export default function NotionEditor() { } }; - const handleEditorChange = (state: EditorState) => { - const container = state.history[state.historyIndex]; - setCurrentContent(container); + const handleEditorChange = (markdown: string) => { + setCurrentMarkdown(markdown); if (activeNoteId) { isDirtyRef.current = true; setSaveState("saving"); - debouncedUpdate(activeNoteId, { content: container }); + debouncedUpdate(activeNoteId, { content: markdown }); } }; @@ -144,22 +130,12 @@ 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; - } - } - return { - id: "root", - type: "container", - children: createEmptyContent(), - attributes: {}, - } as ContainerNode; - // editorKey in deps so memo re-runs when template forces remount + const initialMarkdown = useMemo(() => { + // Template just applied — use it directly (activeNote.content not updated yet). + if (pendingTemplateContent != null) return pendingTemplateContent; + // Normalizes both new (string) and legacy (tree) content to markdown. + return noteContentToMarkdown(activeNote?.content); + // editorKey in deps so the memo re-runs when a template forces a remount. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeNoteId, editorKey]); @@ -198,18 +174,18 @@ 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 [debouncedMarkdown] = useDebounce(currentMarkdown, 500); const { wordCount, readTime } = useMemo(() => { - const src = debouncedContent ?? (activeNote?.content as ContainerNode | undefined); + const src = debouncedMarkdown ?? activeNote?.content; const text = extractPlainText(src); const wc = countWords(text); return { wordCount: wc, readTime: readingTimeMinutes(wc) }; - }, [debouncedContent, activeNote?.content]); + }, [debouncedMarkdown, activeNote?.content]); const handleExportMarkdown = useCallback(() => { - const src = currentContent ?? (activeNote?.content as ContainerNode | undefined); + const src = currentMarkdown ?? activeNote?.content; const md = contentToMarkdown(title || "Untitled", src); const blob = new Blob([md], { type: "text/markdown" }); const url = URL.createObjectURL(blob); @@ -218,12 +194,12 @@ export default function NotionEditor() { a.download = `${(title || "note").replace(/\s+/g, "-")}.md`; a.click(); URL.revokeObjectURL(url); - }, [currentContent, activeNote?.content, title]); + }, [currentMarkdown, 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 = currentMarkdown ?? 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,16 +208,15 @@ export default function NotionEditor() { a.download = `${(title || "note").replace(/\s+/g, "-")}.html`; a.click(); URL.revokeObjectURL(url); - }, [currentContent, activeNote?.content, title]); + }, [currentMarkdown, 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 + setCurrentMarkdown(tpl.content); + setPendingTemplateContent(tpl.content); // initialMarkdown reads this on remount setLastSyncedNoteId(null); setEditorKey(k => k + 1); }, [activeNoteId, debouncedUpdate]); @@ -477,13 +452,12 @@ export default function NotionEditor() { Loading… ) : ( - - - + onUploadImage={handleUploadImage} + /> )} @@ -495,3 +469,10 @@ export default function NotionEditor() { ); } + +function sanitizeFileName(name: string): string { + return name + .replace(/[^a-zA-Z0-9._-]/g, "_") + .replace(/_{2,}/g, "_") + .slice(0, 200); +} diff --git a/apps/web/src/components/notes/markdown-editor/NoteMarkdownEditor.tsx b/apps/web/src/components/notes/markdown-editor/NoteMarkdownEditor.tsx new file mode 100644 index 00000000..17345841 --- /dev/null +++ b/apps/web/src/components/notes/markdown-editor/NoteMarkdownEditor.tsx @@ -0,0 +1,103 @@ +"use client" + +import { useEffect, useRef } from "react" +import { Crepe } from "@milkdown/crepe" + +import "@milkdown/crepe/theme/common/style.css" +import "@milkdown/crepe/theme/nord.css" +import "./crepe-theme.css" + +export interface NoteMarkdownEditorProps { + /** Initial markdown. The editor is uncontrolled after mount — change the + * `key` (e.g. on note switch) to load different content. */ + defaultValue?: string + readOnly?: boolean + placeholder?: string + /** Fires (debounced by the editor's own listener) with the full markdown + * document on every change. */ + onChange?: (markdown: string) => void + /** Upload a pasted/dropped/selected image and return its URL. */ + onUploadImage?: (file: File) => Promise +} + +/** + * Markdown editor for Notes, built on Milkdown Crepe. Stores and emits plain + * markdown (what gets saved to `Note.content`). + * + * MUST be loaded with `dynamic(..., { ssr: false })` — Crepe/ProseMirror are + * DOM-only. (NotesPage already loads NotionEditor that way, so this is covered.) + */ +export function NoteMarkdownEditor({ + defaultValue = "", + readOnly = false, + placeholder = "Write something, or press '/' for commands…", + onChange, + onUploadImage, +}: NoteMarkdownEditorProps) { + const rootRef = useRef(null) + // Keep the latest callbacks reachable without re-creating the editor. + const onChangeRef = useRef(onChange) + const onUploadRef = useRef(onUploadImage) + useEffect(() => { + onChangeRef.current = onChange + onUploadRef.current = onUploadImage + }) + + useEffect(() => { + const root = rootRef.current + if (!root) return + + let destroyed = false + let crepe: Crepe | null = null + + const upload = async (file: File): Promise => { + if (!onUploadRef.current) return "" + return onUploadRef.current(file) + } + + crepe = new Crepe({ + root, + defaultValue, + featureConfigs: { + [Crepe.Feature.Placeholder]: { text: placeholder }, + [Crepe.Feature.ImageBlock]: { + onUpload: upload, + blockOnUpload: upload, + inlineOnUpload: upload, + }, + }, + }) + + crepe.on((api) => { + api.markdownUpdated((_ctx, markdown) => { + onChangeRef.current?.(markdown) + }) + }) + + crepe + .create() + .then(() => { + if (destroyed) { + void crepe?.destroy() + return + } + crepe?.setReadonly(readOnly) + }) + .catch((err) => { + console.error("[NoteMarkdownEditor] failed to create editor", err) + }) + + return () => { + destroyed = true + void crepe?.destroy() + crepe = null + } + // Re-create when the loaded content identity changes. Consumers should + // also bump the React `key` on note switch so state fully resets. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValue]) + + return
+} + +export default NoteMarkdownEditor diff --git a/apps/web/src/components/notes/markdown-editor/crepe-theme.css b/apps/web/src/components/notes/markdown-editor/crepe-theme.css new file mode 100644 index 00000000..df44a17d --- /dev/null +++ b/apps/web/src/components/notes/markdown-editor/crepe-theme.css @@ -0,0 +1,37 @@ +/* Dark-mode layer for the Crepe (nord) theme. + * + * The prebuilt nord theme scopes every color as a CSS variable under + * `.milkdown`. The app toggles dark mode with a `.dark` class on + * (Tailwind darkMode: ["class"]), so we load nord (light) as the base and + * re-declare only the color variables under `.dark .milkdown` — values lifted + * from the nord-dark theme. This avoids shipping two conflicting full themes. */ + +.dark .milkdown { + --crepe-color-background: #1b1c1d; + --crepe-color-on-background: #f8f9ff; + --crepe-color-surface: #111418; + --crepe-color-surface-low: #191c20; + --crepe-color-on-surface: #e1e2e8; + --crepe-color-on-surface-variant: #c3c6cf; + --crepe-color-outline: #8d9199; + --crepe-color-primary: #a1c9fd; + --crepe-color-secondary: #3c4858; + --crepe-color-on-secondary: #d7e3f8; + --crepe-color-inverse: #e1e2e8; + --crepe-color-on-inverse: #2e3135; + --crepe-color-inline-code: #ffb4ab; + --crepe-color-error: #ffb4ab; + --crepe-color-hover: #1d2024; + --crepe-color-selected: #32353a; + --crepe-color-inline-area: #111418; +} + +/* Let the editor fill the notes scroll container and drop the default card frame + * so it blends with the surrounding Notes chrome. */ +.note-markdown-editor .milkdown { + background: transparent; +} + +.note-markdown-editor .milkdown .ProseMirror { + padding: 0; +} diff --git a/apps/web/src/components/ui/rich-editor/CoverImage.tsx b/apps/web/src/components/ui/rich-editor/CoverImage.tsx deleted file mode 100644 index 072027fc..00000000 --- a/apps/web/src/components/ui/rich-editor/CoverImage.tsx +++ /dev/null @@ -1,231 +0,0 @@ -"use client" - -import { useEffect, useRef, useState } from "react" -import { ImageIcon, MoveVertical, Trash2, Upload, X } from "lucide-react" - -import { cn } from "@/lib/utils" - -import { EditorActions } from "." -import { Button } from "../button" -import { useEditorDispatch, useEditorState } from "./store/editor-store" - -interface CoverImageProps { - onUploadImage?: (file: File) => Promise - readOnly?: boolean -} - -export function CoverImage({ - onUploadImage, - readOnly = false, -}: CoverImageProps) { - const state = useEditorState() - const dispatch = useEditorDispatch() - const { coverImage } = state - const [isHovered, setIsHovered] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const [isUploading, setIsUploading] = useState(false) - const [dragPosition, setDragPosition] = useState(coverImage?.position ?? 50) - const fileInputRef = useRef(null) - const containerRef = useRef(null) - - // Update drag position when coverImage changes - useEffect(() => { - if (coverImage?.position !== undefined) { - setDragPosition(coverImage.position) - } - }, [coverImage?.position]) - - const handleFileSelect = async (file: File) => { - if (!file.type.startsWith("image/")) { - console.warn("Selected file is not an image") - return - } - - setIsUploading(true) - - try { - let url: string - - if (onUploadImage) { - // Use custom upload handler - url = await onUploadImage(file) - } else { - // Fallback to data URL - url = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject - reader.readAsDataURL(file) - }) - } - - dispatch( - EditorActions.setCoverImage({ - url, - alt: file.name, - position: 50, - }) - ) - } catch (error) { - console.error("Failed to upload cover image:", error) - } finally { - setIsUploading(false) - } - } - - const handleFileInputChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - handleFileSelect(file) - } - } - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - - const file = e.dataTransfer.files?.[0] - if (file) { - await handleFileSelect(file) - } - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - } - - const handleRemove = () => { - dispatch(EditorActions.removeCoverImage()) - } - - const handlePositionDragStart = (e: React.MouseEvent) => { - e.preventDefault() - setIsDragging(true) - } - - const handlePositionDrag = (e: MouseEvent) => { - if (!isDragging || !containerRef.current) return - - const rect = containerRef.current.getBoundingClientRect() - const y = e.clientY - rect.top - const percentage = Math.max(0, Math.min(100, (y / rect.height) * 100)) - - setDragPosition(percentage) - // Update state immediately so position is always saved - dispatch(EditorActions.updateCoverImagePosition(percentage)) - } - - const handlePositionDragEnd = () => { - if (isDragging) { - setIsDragging(false) - // Position is already saved in state during drag - } - } - - useEffect(() => { - if (isDragging) { - window.addEventListener("mousemove", handlePositionDrag) - window.addEventListener("mouseup", handlePositionDragEnd) - - return () => { - window.removeEventListener("mousemove", handlePositionDrag) - window.removeEventListener("mouseup", handlePositionDragEnd) - } - } - }, [isDragging, dragPosition]) - - const handleChangeImage = () => { - fileInputRef.current?.click() - } - - // If no cover image, don't render anything - if (!coverImage) { - return null - } - - // Show cover image with controls - return ( -
!readOnly && setIsHovered(true)} - onMouseLeave={() => !readOnly && setIsHovered(false)} - > - - - {/* Cover Image */} -
- {coverImage.alt -
- - {/* Overlay with controls */} - {!readOnly && ( -
- - - - - -
- )} - - {/* Dragging indicator */} - {isDragging && ( -
-
- {Math.round(dragPosition)}% -
-
- )} -
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/ElementSelector.tsx b/apps/web/src/components/ui/rich-editor/ElementSelector.tsx deleted file mode 100644 index 8f53bd41..00000000 --- a/apps/web/src/components/ui/rich-editor/ElementSelector.tsx +++ /dev/null @@ -1,173 +0,0 @@ -"use client" - -import React, { useMemo } from "react" -import { useTranslations } from "next-intl" -import { - Code, - Heading1, - Heading2, - Heading3, - List, - ListOrdered, - Quote, - Type, -} from "lucide-react" - -import { cn } from "@/lib/utils" - -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../select" -import { - ELEMENT_OPTIONS, - type ElementOption, - type ElementType, -} from "./elements" - -// Icon mapping -const iconMap: Record = { - Type: , - Heading1: , - Heading2: , - Heading3: , - Code: , - Quote: , - List: , - ListOrdered: , -} - -// Helper to get icon with custom size -const getIcon = (iconName?: string, iconSize?: string) => { - if (!iconName) return null - const IconComponent = iconMap[iconName] - if (!IconComponent) return null - - // Clone the icon with custom size if provided - if (iconSize && React.isValidElement(IconComponent)) { - return React.cloneElement(IconComponent, { className: iconSize } as any) - } - return IconComponent -} - -interface ElementSelectorProps { - value: ElementType | null - onValueChange: (value: ElementType) => void - elements?: ElementOption[] - variant?: "default" | "compact" | "icon-only" - className?: string - disabled?: boolean - showDescription?: boolean - showIcon?: boolean -} - -export function ElementSelector({ - value, - onValueChange, - elements = ELEMENT_OPTIONS, - variant = "default", - className, - disabled = false, - showDescription = true, - showIcon = true, -}: ElementSelectorProps) { - const tCmd = useTranslations("RichEditor.command") - - const localizedElements = useMemo( - () => - elements.map((el) => ({ - ...el, - label: tCmd(`${el.value}.label`), - description: el.description - ? tCmd(`${el.value}.description`) - : undefined, - })), - [elements, tCmd] - ) - - // Get the current element option - const currentElement = - localizedElements.find((el) => el.value === value) || - localizedElements[0] - - // Variant-specific styling - const triggerClassName = cn( - "transition-colors", - variant === "compact" && - "h-8 min-w-[90px] border-0 bg-transparent hover:bg-accent/50 focus:ring-0 text-xs rounded-md px-2 gap-1.5", - variant === "icon-only" && - "h-9 w-9 border-0 bg-transparent hover:bg-accent/50 focus:ring-0 p-0", - variant === "default" && "min-w-[140px]", - className - ) - - return ( - - ) -} - -// Re-export for convenience -export { ELEMENT_OPTIONS } -export type { ElementType, ElementOption } diff --git a/apps/web/src/components/ui/rich-editor/ExportFloatingButton.tsx b/apps/web/src/components/ui/rich-editor/ExportFloatingButton.tsx deleted file mode 100644 index 800e1c6a..00000000 --- a/apps/web/src/components/ui/rich-editor/ExportFloatingButton.tsx +++ /dev/null @@ -1,243 +0,0 @@ -"use client" - -import React, { useState } from "react" -import { - Check, - Code2, - Copy, - Download, - Eye, - FileJson, - Sparkles, -} from "lucide-react" - -import { cn } from "@/lib/utils" - -import { Button } from "../button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../dialog" -import { Label } from "../label" -import { Switch } from "../switch" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs" -import { type ContainerNode } from "./types" -import { serializeToHtml } from "./utils/serialize-to-html" -import { useTranslations } from "next-intl" - -interface ExportFloatingButtonProps { - container: ContainerNode - onCopyHtml: () => void - onCopyJson: () => void - copiedHtml: boolean - copiedJson: boolean - enhanceSpaces: boolean - onEnhanceSpacesChange: (checked: boolean) => void -} - -export function ExportFloatingButton({ - container, - onCopyHtml, - onCopyJson, - copiedHtml, - copiedJson, - enhanceSpaces, - onEnhanceSpacesChange, -}: ExportFloatingButtonProps) { - const t = useTranslations("RichEditor.export") - const [isOpen, setIsOpen] = useState(false) - const [isHovered, setIsHovered] = useState(false) - - return ( - <> - {/* Floating Button */} -
- -
- - {/* Export Dialog */} - - - - - - {t("title")} - - - {t("description")} - - - - - - - - {t("preview")} - - - - {t("html")} - - - - {t("json")} - - - - {/* Enhance Spaces Toggle */} -
-

{t("previewOptions")}

-
- - -
-
- - {/* Preview Tab */} - -
-

- {t("livePreview")} -

-
-
${serializeToHtml( - container - )}
` - : serializeToHtml(container), - }} - /> -
- - {/* HTML Tab */} - -
-

- {t("htmlTailwind")} -

- -
-
-                {enhanceSpaces
-                  ? `
\n${serializeToHtml( - container - )}\n
` - : serializeToHtml(container)} -
-
- - {/* JSON Tab */} - -
-

- {t("editorJson")} -

- -
-
-                {JSON.stringify(container.children, null, 2)}
-              
-
-
-
-
- - ) -} diff --git a/apps/web/src/components/ui/rich-editor/FreeImageBlock.tsx b/apps/web/src/components/ui/rich-editor/FreeImageBlock.tsx deleted file mode 100644 index 92e0666b..00000000 --- a/apps/web/src/components/ui/rich-editor/FreeImageBlock.tsx +++ /dev/null @@ -1,340 +0,0 @@ -"use client" - -import React, { useEffect, useRef, useState } from "react" -import { ImageIcon, Loader2, Move, X } from "lucide-react" - -import { EditorActions } from "." -import { Button } from "../button" -import { useEditorDispatch } from "./store/editor-store" -import { TextNode } from "./types" - -interface FreeImageBlockProps { - node: TextNode - isActive: boolean - onClick: () => void - onDelete?: () => void - readOnly?: boolean -} - -export function FreeImageBlock({ - node, - isActive, - onClick, - onDelete, - readOnly = false, -}: FreeImageBlockProps) { - const dispatch = useEditorDispatch() - const [imageError, setImageError] = useState(false) - const [isDragging, setIsDragging] = useState(false) - const [isResizing, setIsResizing] = useState(false) - const [resizeSide, setResizeSide] = useState<"left" | "right" | null>(null) - const [position, setPosition] = useState({ - x: parseFloat((node.attributes?.styles as any)?.left || "100") || 0, - y: parseFloat((node.attributes?.styles as any)?.top || "100") || 0, - }) - const [size, setSize] = useState<{ width: number; height: number | "auto" }>({ - width: parseFloat((node.attributes?.styles as any)?.width || "400") || 400, - height: "auto", - }) - const dragRef = useRef(null) - const startPosRef = useRef({ x: 0, y: 0, mouseX: 0, mouseY: 0 }) - const startSizeRef = useRef({ width: 0, height: 0, mouseX: 0, mouseY: 0 }) - - const imageUrl = node.attributes?.src as string | undefined - const altText = node.attributes?.alt as string | undefined - const caption = node.content || "" - const isUploading = - node.attributes?.loading === "true" || node.attributes?.loading === true - const hasError = - node.attributes?.error === "true" || node.attributes?.error === true - - const handleImageLoad = () => { - setImageError(false) - } - - const handleImageError = () => { - setImageError(true) - } - - const handleDragStart = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(true) - startPosRef.current = { - x: position.x, - y: position.y, - mouseX: e.clientX, - mouseY: e.clientY, - } - } - - const handleResizeStart = (e: React.MouseEvent, side: "left" | "right") => { - e.preventDefault() - e.stopPropagation() - setIsResizing(true) - setResizeSide(side) - startSizeRef.current = { - width: size.width, - height: typeof size.height === "number" ? size.height : 0, - mouseX: e.clientX, - mouseY: e.clientY, - } - startPosRef.current = { - x: position.x, - y: position.y, - mouseX: e.clientX, - mouseY: e.clientY, - } - } - - useEffect(() => { - if (!isDragging) return - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = e.clientX - startPosRef.current.mouseX - const deltaY = e.clientY - startPosRef.current.mouseY - - const newX = startPosRef.current.x + deltaX - const newY = startPosRef.current.y + deltaY - - setPosition({ x: newX, y: newY }) - } - - const handleMouseUp = () => { - setIsDragging(false) - - // Save position to node attributes - const currentStyles = (node.attributes?.styles || {}) as Record< - string, - string - > - const newStyles = { - ...currentStyles, - left: `${position.x}px`, - top: `${position.y}px`, - position: "fixed", - zIndex: currentStyles.zIndex || "10", - } - - dispatch( - EditorActions.updateNode(node.id, { - attributes: { - ...node.attributes, - styles: newStyles, - }, - }) - ) - } - - window.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - - return () => { - window.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - } - }, [isDragging, position, node.id, node.attributes, dispatch]) - - useEffect(() => { - if (!isResizing) return - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = e.clientX - startSizeRef.current.mouseX - - if (resizeSide === "right") { - // Resize from right side - only width changes - const newWidth = Math.max( - 200, - Math.min(800, startSizeRef.current.width + deltaX) - ) - setSize({ width: newWidth, height: "auto" }) - } else if (resizeSide === "left") { - // Resize from left side - width and position change - const newWidth = Math.max( - 200, - Math.min(800, startSizeRef.current.width - deltaX) - ) - const widthDiff = startSizeRef.current.width - newWidth - const newX = startPosRef.current.x + widthDiff - - setSize({ width: newWidth, height: "auto" }) - setPosition({ x: newX, y: position.y }) - } - } - - const handleMouseUp = () => { - setIsResizing(false) - setResizeSide(null) - - // Save size and position to node attributes - const currentStyles = (node.attributes?.styles || {}) as Record< - string, - string - > - const newStyles = { - ...currentStyles, - width: `${size.width}px`, - height: "auto", - left: `${position.x}px`, - top: `${position.y}px`, - position: "fixed", - zIndex: currentStyles.zIndex || "10", - } - - dispatch( - EditorActions.updateNode(node.id, { - attributes: { - ...node.attributes, - styles: newStyles, - }, - }) - ) - } - - window.addEventListener("mousemove", handleMouseMove) - window.addEventListener("mouseup", handleMouseUp) - - return () => { - window.removeEventListener("mousemove", handleMouseMove) - window.removeEventListener("mouseup", handleMouseUp) - } - }, [ - isResizing, - resizeSide, - size, - position, - node.id, - node.attributes, - dispatch, - ]) - - const handleClick = (e: React.MouseEvent) => { - if (!isDragging && !isResizing) { - onClick() - } - } - - return ( -
-
- {/* Drag handle - only in edit mode */} - {!readOnly && ( -
- -
- )} - - {/* Delete button - only in edit mode */} - {!readOnly && onDelete && ( - - )} - - {/* Image container */} -
- {/* Uploading state */} - {isUploading && ( -
- -

- Uploading image... -

-
- )} - - {/* Error state */} - {!isUploading && hasError && ( -
- -

- Upload Failed -

-
- )} - - {/* Normal image */} - {!isUploading && !hasError && ( - <> - {imageError && ( -
- -

- Failed to load image -

-
- )} - - {imageUrl && ( - {altText - )} - - {caption && ( -

- {caption} -

- )} - - )} -
- - {/* Resize handles - only in edit mode */} - {!readOnly && !isUploading && !hasError && imageUrl && ( - <> - {/* Right resize handle */} -
handleResizeStart(e, "right")} - > -
-
- - {/* Left resize handle */} -
handleResizeStart(e, "left")} - > -
-
- - )} -
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/InsertComponentsModal.tsx b/apps/web/src/components/ui/rich-editor/InsertComponentsModal.tsx deleted file mode 100644 index f33f232b..00000000 --- a/apps/web/src/components/ui/rich-editor/InsertComponentsModal.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client" - -import React from "react" -import { ImagePlus } from "lucide-react" -import { useTranslations } from "next-intl" - -import { cn } from "@/lib/utils" - -import { Button } from "../button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../dialog" -import { - INSERT_COMPONENTS, - type InsertComponent, -} from "./insert-components-data" - -interface InsertComponentsModalProps { - open: boolean - onOpenChange: (open: boolean) => void - onSelect: (componentId: string) => void -} - -// Icon mapping for components -const componentIcons: Record< - string, - React.ComponentType<{ className?: string }> -> = { - "free-image": ImagePlus, -} - -export function InsertComponentsModal({ - open, - onOpenChange, - onSelect, -}: InsertComponentsModalProps) { - const t = useTranslations("RichEditor.insertComponents") - const handleSelect = (componentId: string) => { - onSelect(componentId) - onOpenChange(false) - } - - return ( - - - - -
- -
- {t("title")} -
- - {t("description")} - -
- - {/* Components Grid */} -
- {INSERT_COMPONENTS.map((component) => { - const Icon = componentIcons[component.id] || ImagePlus - const title = - component.id === "free-image" - ? t("freeImage.name") - : component.name - const description = - component.id === "free-image" - ? t("freeImage.description") - : component.description - - return ( - - ) - })} -
- - {/* Empty state for future */} - {INSERT_COMPONENTS.length === 0 && ( -
- -

{t("noComponents")}

-
- )} -
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/MediaUploadPopover.tsx b/apps/web/src/components/ui/rich-editor/MediaUploadPopover.tsx deleted file mode 100644 index db1f01b3..00000000 --- a/apps/web/src/components/ui/rich-editor/MediaUploadPopover.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client" - -import React from "react" -import { Image as ImageIcon, ImagePlus, LayoutGrid, Video } from "lucide-react" - -import { Button } from "../button" -import { Popover, PopoverContent, PopoverTrigger } from "../popover" - -interface MediaUploadPopoverProps { - isUploading: boolean - onImageUploadClick: () => void - onMultipleImagesUploadClick: () => void - onVideoUploadClick: () => void -} - -export function MediaUploadPopover({ - isUploading, - onImageUploadClick, - onMultipleImagesUploadClick, - onVideoUploadClick, -}: MediaUploadPopoverProps) { - const [open, setOpen] = React.useState(false) - - const handleOptionClick = (action: () => void) => { - action() - setOpen(false) - } - - return ( - - - - - -
- - - - - -
-
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/QuickModeToggle.tsx b/apps/web/src/components/ui/rich-editor/QuickModeToggle.tsx deleted file mode 100644 index c943d09d..00000000 --- a/apps/web/src/components/ui/rich-editor/QuickModeToggle.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client" - -import Image from "next/image" -import { Eye, EyeOff, FileText, Moon, Sun } from "lucide-react" -import { useTheme } from "next-themes" - -import { Button } from "../button" -import { Separator } from "../separator" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../tooltip" - -interface ToolbarProps { - readOnly: boolean - onReadOnlyChange: (readOnly: boolean) => void - notionBased?: boolean - onNotionBasedChange?: (notionBased: boolean) => void -} - -export function QuickModeToggle({ - readOnly, - onReadOnlyChange, - notionBased, - onNotionBasedChange, -}: ToolbarProps) { - const { theme, setTheme } = useTheme() - - const toggleTheme = () => { - setTheme(theme === "dark" ? "light" : "dark") - } - - return null - - return ( - -
- {/* Editor Mode Toggle - Only show if handler is provided */} - {onNotionBasedChange && ( - <> - - - - - -

- {notionBased ? ( - <> - Notion Mode -
- With cover & header - - ) : ( - <> - Rich Editor Mode -
- Clean blocks - - )} -

-
-
- - - - )} - - {/* Read-only toggle */} - - - - - -

{readOnly ? "View Only Mode" : "Edit Mode"}

-
-
- - - - {/* Theme toggle */} - - - - - -

Toggle Theme

-
-
-
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/SelectionToolbar.tsx b/apps/web/src/components/ui/rich-editor/SelectionToolbar.tsx deleted file mode 100644 index a09c25b4..00000000 --- a/apps/web/src/components/ui/rich-editor/SelectionToolbar.tsx +++ /dev/null @@ -1,427 +0,0 @@ -"use client" - -import React, { useEffect, useRef, useState } from "react" -import { AnimatePresence, motion } from "framer-motion" -import { Link as LinkIcon, Type } from "lucide-react" -import { useTranslations } from "next-intl" - -import { cn } from "@/lib/utils" -import { useToast } from "@/hooks/use-toast" - -import { Button } from "../button" -import { Popover, PopoverContent, PopoverTrigger } from "../popover" -import { Separator } from "../separator" -import { - CustomClassPopoverContent, - FormatButtons, - LinkPopoverContent, -} from "./_toolbar-components" -import { - getUserFriendlyClasses, - searchUserFriendlyClasses, -} from "./class-mappings" -import { ColorPickerComponent } from "./color-picker" -import { ElementSelector, ElementType } from "./ElementSelector" -import { FontSizePicker } from "./font-size-picker" -import { EditorActions } from "./lib/reducer/actions" -import { useEditorDispatch, useEditorState } from "./store/editor-store" -import { tailwindClasses } from "./tailwind-classes" -import { SelectionInfo } from "./types" -import { getReplacementInfo, mergeClasses } from "./utils/class-replacement" - -interface SelectionToolbarProps { - selection: SelectionInfo | null - selectedColor: string - editorRef: React.RefObject - onFormat: ( - format: "bold" | "italic" | "underline" | "strikethrough" | "code" - ) => void - onTypeChange: (type: string) => void - onColorSelect: (color: string) => void - onFontSizeSelect: (fontSize: string) => void -} - -export function SelectionToolbar({ - selection, - selectedColor, - editorRef, - onFormat, - onTypeChange, - onColorSelect, - onFontSizeSelect, -}: SelectionToolbarProps) { - const tLink = useTranslations("RichEditor.link") - const tCustomClass = useTranslations("RichEditor.customClass") - const state = useEditorState() - const dispatch = useEditorDispatch() - const { toast } = useToast() - const [position, setPosition] = useState({ top: 0, left: 0 }) - const [isVisible, setIsVisible] = useState(false) - const toolbarRef = useRef(null) - - // Link popover state - const [linkPopoverOpen, setLinkPopoverOpen] = useState(false) - const [hrefInput, setHrefInput] = useState("") - - // Custom class popover state - const [customClassPopoverOpen, setCustomClassPopoverOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState("") - const [devMode, setDevMode] = useState(false) - - // Store selection for link/class application - const savedSelectionRef = useRef(null) - - useEffect(() => { - // Keep toolbar visible and position stable if either popover is open - if (linkPopoverOpen || customClassPopoverOpen) { - return - } - - if (!selection || selection.text.length === 0) { - setIsVisible(false) - return - } - - // Save selection for later use in popovers - savedSelectionRef.current = selection - - // Pre-fill link input if selection has an existing link - if (selection.href && !linkPopoverOpen) { - setHrefInput(selection.href) - } - - // Get the current selection range - const domSelection = window.getSelection() - if (!domSelection || domSelection.rangeCount === 0) { - // Don't hide if we already have a position and saved selection - if (savedSelectionRef.current && position.top !== 0) { - return - } - setIsVisible(false) - return - } - - const range = domSelection.getRangeAt(0) - const rect = range.getBoundingClientRect() - - // Don't update position if rect is empty/collapsed and we already have a good position - if (rect.width === 0 && rect.height === 0 && position.top !== 0) { - return - } - - // Get the editor container from ref - if (!editorRef.current) { - setIsVisible(false) - return - } - - const editorRect = editorRef.current.getBoundingClientRect() - - // Calculate position above the selection - const toolbarHeight = toolbarRef.current?.offsetHeight || 44 // Use actual toolbar height - const gap = 8 // Gap between selection and toolbar - - // Position toolbar centered above the selection, relative to editor container - let left = rect.left - editorRect.left + rect.width / 2 - const top = rect.top - editorRect.top - toolbarHeight - gap - - // Adjust horizontal position if toolbar would go off-screen - if (toolbarRef.current) { - const toolbarWidth = toolbarRef.current.offsetWidth - left = left - toolbarWidth / 2 - - // Keep toolbar within editor container bounds - const padding = 16 - if (left < padding) { - left = padding - } else if (left + toolbarWidth > editorRect.width - padding) { - left = editorRect.width - toolbarWidth - padding - } - } - - setPosition({ top, left }) - setIsVisible(true) - }, [ - selection, - linkPopoverOpen, - customClassPopoverOpen, - position.top, - editorRef, - ]) - - // Link handlers - const handleApplyLink = () => { - if (!savedSelectionRef.current || !hrefInput.trim()) return - - dispatch(EditorActions.setCurrentSelection(savedSelectionRef.current)) - - setTimeout(() => { - dispatch(EditorActions.applyLink(hrefInput.trim())) - - toast({ - title: tLink("toastApplied"), - description: tLink("toastLinkedTo", { url: hrefInput }), - }) - - setHrefInput("") - setLinkPopoverOpen(false) - }, 0) - } - - const handleRemoveLink = () => { - if (!savedSelectionRef.current) return - - dispatch(EditorActions.setCurrentSelection(savedSelectionRef.current)) - - setTimeout(() => { - dispatch(EditorActions.removeLink()) - - toast({ - title: tLink("toastRemoved"), - description: tLink("toastRemovedDesc"), - }) - - setHrefInput("") - setLinkPopoverOpen(false) - }, 0) - } - - // Custom class handlers with smart replacement - const handleApplyCustomClass = (className: string) => { - if (!savedSelectionRef.current) return - - // Get current classes from selection - const currentClassName = savedSelectionRef.current.className || "" - - // Get replacement info - const replacementInfo = getReplacementInfo(currentClassName, className) - - // Merge classes intelligently (replaces same-category classes) - const mergedClasses = mergeClasses(currentClassName, className) - - dispatch( - EditorActions.setCurrentSelection({ - ...savedSelectionRef.current, - formats: { - bold: false, - italic: false, - underline: false, - strikethrough: false, - code: false, - }, - }) - ) - - setTimeout(() => { - dispatch(EditorActions.applyCustomClass(mergedClasses)) - - // Show appropriate toast message - if ( - replacementInfo.willReplace && - replacementInfo.replacedClasses.length > 0 - ) { - toast({ - title: tCustomClass("toastClassReplaced"), - description: tCustomClass("toastClassReplacedDesc", { - replaced: replacementInfo.replacedClasses.join(", "), - className, - }), - }) - } else { - toast({ - title: tCustomClass("toastCustomClassApplied"), - description: tCustomClass("toastAppliedClass", { className }), - }) - } - - setCustomClassPopoverOpen(false) - setSearchQuery("") - }, 0) - } - - // Filter classes for custom class popover - const filteredClasses = devMode - ? searchQuery - ? tailwindClasses - .map((group) => ({ - ...group, - classes: group.classes.filter((cls) => - cls.toLowerCase().includes(searchQuery.toLowerCase()) - ), - })) - .filter((group) => group.classes.length > 0) - : tailwindClasses - : searchQuery - ? searchUserFriendlyClasses(searchQuery) - : getUserFriendlyClasses() - - // Use savedSelection if current selection is lost but popovers are open - const activeSelection = selection || savedSelectionRef.current - - if (!activeSelection && !linkPopoverOpen && !customClassPopoverOpen) { - return null - } - - const { formats } = activeSelection || { - formats: { - bold: false, - italic: false, - underline: false, - strikethrough: false, - code: false, - }, - } - const hasExistingLink = Boolean(savedSelectionRef.current?.href) - - return ( - - {position && - isVisible && ( // Keep toolbar visible if either popover is open, even if selection is lost - - {/* Text Type Selector */} - onTypeChange(value)} - variant="compact" - showDescription={false} - showIcon={false} - className="mr-2" - /> - - {/* Format Buttons */} - - - - - {/* Color Picker */} - - - {/* Font Size Picker */} - - - - - {/* Link Popover */} - - - - - e.preventDefault()} - onInteractOutside={(e) => { - // Prevent closing when clicking on the toolbar - const target = e.target as HTMLElement - if (toolbarRef.current?.contains(target)) { - e.preventDefault() - } - }} - > - - - - - {/* Custom Class Popover */} - - - - - e.preventDefault()} - onInteractOutside={(e) => { - // Prevent closing when clicking on the toolbar - const target = e.target as HTMLElement - if (toolbarRef.current?.contains(target)) { - e.preventDefault() - } - }} - > - - - - - - - )} - - ) -} diff --git a/apps/web/src/components/ui/rich-editor/TemplateSwitcherButton.tsx b/apps/web/src/components/ui/rich-editor/TemplateSwitcherButton.tsx deleted file mode 100644 index 6fa8e361..00000000 --- a/apps/web/src/components/ui/rich-editor/TemplateSwitcherButton.tsx +++ /dev/null @@ -1,393 +0,0 @@ -"use client" - -import React, { useEffect, useRef, useState } from "react" -import { - BookOpen, - Briefcase, - FileText, - Loader2, - Plus, - Sparkles, - User, - Zap, -} from "lucide-react" - -import { cn } from "@/lib/utils" - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../alert-dialog" -import { Button } from "../button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../dialog" -import { - getAllTemplateMetadata, - getTemplateById, - type TemplateMetadata, -} from "./templates" -import type { EditorState } from "./types" -import { useTranslations } from "next-intl" - -interface TemplateSwitcherButtonProps { - onTemplateChange: (state: EditorState) => void - currentState: EditorState -} - -// Category icons -const categoryIcons: Record< - TemplateMetadata["category"], - React.ComponentType<{ className?: string }> -> = { - productivity: Zap, - creative: Sparkles, - business: Briefcase, - personal: User, -} - -// Category colors (solid colors for cleaner look) -const categoryColors: Record = { - productivity: "bg-blue-500 hover:bg-blue-600", - creative: "bg-purple-500 hover:bg-purple-600", - business: "bg-green-500 hover:bg-green-600", - personal: "bg-orange-500 hover:bg-orange-600", -} - -export function TemplateSwitcherButton({ - onTemplateChange, - currentState, -}: TemplateSwitcherButtonProps) { - const t = useTranslations("RichEditor.template") - const [isOpen, setIsOpen] = useState(false) - const [isHovered, setIsHovered] = useState(false) - const [selectedCategory, setSelectedCategory] = useState< - TemplateMetadata["category"] | "all" - >("all") - const [showConfirmDialog, setShowConfirmDialog] = useState(false) - const [pendingTemplateId, setPendingTemplateId] = useState( - null - ) - const [isApplying, setIsApplying] = useState(false) - const scrollContainerRef = useRef(null) - - const allTemplates = getAllTemplateMetadata() - - // Reset scroll position when category changes or dialog opens - useEffect(() => { - if (scrollContainerRef.current && isOpen) { - scrollContainerRef.current.scrollTop = 0 - } - }, [selectedCategory, isOpen]) - - const filteredTemplates = - selectedCategory === "all" - ? allTemplates - : allTemplates.filter((t) => t.category === selectedCategory) - - const categories: Array<{ - value: "all" | TemplateMetadata["category"] - label: string - }> = [ - { value: "all", label: t("catAll") }, - { value: "productivity", label: t("catProductivity") }, - { value: "creative", label: t("catCreative") }, - { value: "business", label: t("catBusiness") }, - { value: "personal", label: t("catPersonal") }, - ] - - // Check if there's existing content - const hasExistingContent = () => { - const container = currentState.history[currentState.historyIndex] - if (!container || !container.children || container.children.length === 0) { - return false - } - - // Check if there's any non-empty content - return container.children.some((child) => { - if ("content" in child && child.content && child.content.trim() !== "") { - return true - } - if ("children" in child && child.children && child.children.length > 0) { - return true - } - return false - }) - } - - const handleTemplateSelect = (templateId: string) => { - // Set the pending template ID immediately for UI feedback - setPendingTemplateId(templateId) - - // Check if there's existing content - if (hasExistingContent()) { - setShowConfirmDialog(true) - return - } - - // No existing content, proceed directly - applyTemplate(templateId) - } - - const applyTemplate = async (templateId: string) => { - const template = getTemplateById(templateId) - if (!template) return - - setIsApplying(true) - - // Small delay for smoother UX - await new Promise((resolve) => setTimeout(resolve, 150)) - - // Create completely fresh state with template content - // This ensures all editor state is properly reset - const newState: EditorState = { - version: "1.0.0", - history: [ - { - id: "root", - type: "container", - children: template.content, - attributes: {}, - }, - ], - historyIndex: 0, - activeNodeId: null, - hasSelection: false, - selectionKey: 0, - currentSelection: null, - selectedBlocks: new Set(), - coverImage: template.coverImage || null, - metadata: { - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - templateId: template.metadata.id, - templateName: template.metadata.name, - }, - } - - // Apply the new state - onTemplateChange(newState) - - // Close dialogs - setIsOpen(false) - setShowConfirmDialog(false) - setPendingTemplateId(null) - setIsApplying(false) - - // Force a complete cleanup and re-render - setTimeout(() => { - // Clear any browser selection and focus - const selection = window.getSelection() - if (selection) { - selection.removeAllRanges() - } - - // Remove focus from any active element - const activeElement = document.activeElement as HTMLElement - if (activeElement && activeElement.blur) { - activeElement.blur() - } - - // Force scroll to top to show new template from the beginning - window.scrollTo({ top: 0, behavior: "smooth" }) - }, 100) - } - - const handleConfirmReplace = () => { - if (pendingTemplateId) { - applyTemplate(pendingTemplateId) - } - } - - const handleCancelReplace = () => { - setShowConfirmDialog(false) - setPendingTemplateId(null) - } - - return ( - <> - {/* Floating Button */} -
- -
- - {/* Template Selector Dialog */} - - - - -
- -
- {t("title")} -
- - {t("description")} - -
- - {/* Category Filter */} -
- {categories.map((category) => { - const Icon = - category.value === "all" - ? BookOpen - : categoryIcons[ - category.value as TemplateMetadata["category"] - ] - const isActive = selectedCategory === category.value - - return ( - - ) - })} -
- - {/* Templates Grid */} -
-
- {filteredTemplates.map((template) => { - const CategoryIcon = categoryIcons[template.category] - const isCurrentlyApplying = - isApplying && pendingTemplateId === template.id - - return ( - - ) - })} -
- - {filteredTemplates.length === 0 && ( -
-
- -
-

{t("noTemplates")}

-
- )} -
-
-
- - {/* Confirmation Dialog */} - - - - -
- -
- {t("confirmTitle")} -
- - {t("confirmDescription")} -
- - {t("cannotUndo")} - -
-
- - - {t("cancel")} - - - {t("replaceContent")} - - -
-
- - ) -} diff --git a/apps/web/src/components/ui/rich-editor/_toolbar-components/CustomClassPopoverContent.tsx b/apps/web/src/components/ui/rich-editor/_toolbar-components/CustomClassPopoverContent.tsx deleted file mode 100644 index ab73171c..00000000 --- a/apps/web/src/components/ui/rich-editor/_toolbar-components/CustomClassPopoverContent.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client" - -import React from "react" -import { Code2, Search } from "lucide-react" -import { useTranslations } from "next-intl" - -import { Button } from "../../button" -import { Input } from "../../input" -import { ScrollArea } from "../../scroll-area" -import { Switch } from "../../switch" - -interface CustomClassPopoverContentProps { - searchQuery: string - setSearchQuery: (value: string) => void - devMode: boolean - setDevMode: (value: boolean) => void - filteredClasses: any[] - onApplyClass: (className: string) => void -} - -export function CustomClassPopoverContent({ - searchQuery, - setSearchQuery, - devMode, - setDevMode, - filteredClasses, - onApplyClass, -}: CustomClassPopoverContentProps) { - const t = useTranslations("RichEditor.customClass") - return ( -
- {/* Dev Mode Toggle */} -
-
- - {t("devMode")} -
- -
- -
- - setSearchQuery(e.target.value)} - className="pl-8" - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - /> -
- -
- {devMode ? ( - // Dev Mode: Show Tailwind classes - <> - {filteredClasses.map((group) => ( -
-

- {group.category} -

-
- {(group as any).classes.map((cls: string) => ( - - ))} -
-
- ))} - - ) : ( - // User Mode: Show user-friendly names - <> - {filteredClasses.map((group) => ( -
-

- {group.category} -

-
- {(group as any).items.map( - (item: { label: string; value: string }) => ( - - ) - )} -
-
- ))} - - )} - {filteredClasses.length === 0 && ( -
- {t("noMatches", { query: searchQuery })} -
- )} -
-
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/_toolbar-components/FormatButtons.tsx b/apps/web/src/components/ui/rich-editor/_toolbar-components/FormatButtons.tsx deleted file mode 100644 index 6fece97c..00000000 --- a/apps/web/src/components/ui/rich-editor/_toolbar-components/FormatButtons.tsx +++ /dev/null @@ -1,147 +0,0 @@ -"use client" - -import React from "react" -import { Bold, Code, Italic, Strikethrough, Underline } from "lucide-react" - -import { cn } from "@/lib/utils" - -import { Button } from "../../button" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../../tooltip" - -interface FormatButtonsProps { - formats: { - bold: boolean - italic: boolean - underline: boolean - strikethrough: boolean - code: boolean - } - onFormat: ( - format: "bold" | "italic" | "underline" | "strikethrough" | "code" - ) => void - size?: "sm" | "md" -} - -export function FormatButtons({ - formats, - onFormat, - size = "md", -}: FormatButtonsProps) { - const buttonSize = size === "sm" ? "h-7 w-7" : "h-8 w-8" - - return ( - -
- - - - - -

Bold (Ctrl+B)

-
-
- - - - - - -

Italic (Ctrl+I)

-
-
- - - - - - -

Underline (Ctrl+U)

-
-
- - - - - - -

Strikethrough (Ctrl+Shift+S)

-
-
- - - - - - -

Inline Code (Ctrl+E)

-
-
-
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/_toolbar-components/LinkPopoverContent.tsx b/apps/web/src/components/ui/rich-editor/_toolbar-components/LinkPopoverContent.tsx deleted file mode 100644 index 82e4a1ab..00000000 --- a/apps/web/src/components/ui/rich-editor/_toolbar-components/LinkPopoverContent.tsx +++ /dev/null @@ -1,76 +0,0 @@ -"use client" - -import React from "react" -import { Link as LinkIcon, Trash2 } from "lucide-react" -import { useTranslations } from "next-intl" - -import { Button } from "../../button" -import { Input } from "../../input" -import { Label } from "../../label" - -interface LinkPopoverContentProps { - hrefInput: string - setHrefInput: (value: string) => void - hasExistingLink: boolean - selectedText: string - onApply: () => void - onRemove: () => void -} - -export function LinkPopoverContent({ - hrefInput, - setHrefInput, - hasExistingLink, - selectedText, - onApply, - onRemove, -}: LinkPopoverContentProps) { - const t = useTranslations("RichEditor.link") - return ( -
-
-

- {hasExistingLink ? t("editLink") : t("addLink")} -

-

- {t("selectedText", { text: selectedText })} -

-
-
- - setHrefInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - onApply() - } - }} - className="flex-1" - onMouseDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - /> -
-
- - {hasExistingLink && ( - - )} -
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/_toolbar-components/index.ts b/apps/web/src/components/ui/rich-editor/_toolbar-components/index.ts deleted file mode 100644 index 38942b0d..00000000 --- a/apps/web/src/components/ui/rich-editor/_toolbar-components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { LinkPopoverContent } from "./LinkPopoverContent" -export { CustomClassPopoverContent } from "./CustomClassPopoverContent" -export { FormatButtons } from "./FormatButtons" diff --git a/apps/web/src/components/ui/rich-editor/add-block-button.tsx b/apps/web/src/components/ui/rich-editor/add-block-button.tsx deleted file mode 100644 index 450781fd..00000000 --- a/apps/web/src/components/ui/rich-editor/add-block-button.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client" - -import React, { useState } from "react" -import { Plus } from "lucide-react" - -import { Button } from "../button" -import { useTranslations } from "next-intl" - -interface AddBlockButtonProps { - onAdd: () => void - position?: "before" | "after" -} - -export function AddBlockButton({ - onAdd, - position = "after", -}: AddBlockButtonProps) { - const t = useTranslations("RichEditor") - const [isHovered, setIsHovered] = useState(false) - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {/* Hover area - full width */} -
- - {/* Add button - shows on hover */} - -
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/block-context-menu.tsx b/apps/web/src/components/ui/rich-editor/block-context-menu.tsx deleted file mode 100644 index 3c2a620d..00000000 --- a/apps/web/src/components/ui/rich-editor/block-context-menu.tsx +++ /dev/null @@ -1,254 +0,0 @@ -"use client" - -import React, { useMemo, useState } from "react" -import { PaintBucket } from "lucide-react" -import { useTheme } from "next-themes" -import { useTranslations } from "next-intl" - -import { Button } from "../button" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "../context-menu" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs" -import { - ColorPickerAlpha, - ColorPickerEyeDropper, - ColorPickerFormat, - ColorPickerHue, - ColorPickerOutput, - ColorPickerSelection, - ColorPicker as ShadcnColorPicker, -} from "./color-picker-index" - -interface BlockContextMenuProps { - children: React.ReactNode - onBackgroundColorChange: (color: string) => void - currentBackgroundColor?: string - readOnly?: boolean -} - -type ColorNameKey = - | "none" - | "red" - | "orange" - | "yellow" - | "green" - | "blue" - | "indigo" - | "purple" - | "pink" - | "teal" - | "cyan" - | "gray" - -// Light mode colors - subtle, light backgrounds -const lightModeColors: { nameKey: ColorNameKey; hex: string }[] = [ - { nameKey: "none", hex: "transparent" }, - { nameKey: "red", hex: "#fef2f2" }, - { nameKey: "orange", hex: "#fff7ed" }, - { nameKey: "yellow", hex: "#fefce8" }, - { nameKey: "green", hex: "#f0fdf4" }, - { nameKey: "blue", hex: "#eff6ff" }, - { nameKey: "indigo", hex: "#eef2ff" }, - { nameKey: "purple", hex: "#faf5ff" }, - { nameKey: "pink", hex: "#fdf2f8" }, - { nameKey: "teal", hex: "#f0fdfa" }, - { nameKey: "cyan", hex: "#ecfeff" }, - { nameKey: "gray", hex: "#f9fafb" }, -] - -// Dark mode colors - darker, more saturated backgrounds -const darkModeColors: { nameKey: ColorNameKey; hex: string }[] = [ - { nameKey: "none", hex: "transparent" }, - { nameKey: "red", hex: "#450a0a" }, - { nameKey: "orange", hex: "#431407" }, - { nameKey: "yellow", hex: "#422006" }, - { nameKey: "green", hex: "#052e16" }, - { nameKey: "blue", hex: "#172554" }, - { nameKey: "indigo", hex: "#1e1b4b" }, - { nameKey: "purple", hex: "#2e1065" }, - { nameKey: "pink", hex: "#500724" }, - { nameKey: "teal", hex: "#042f2e" }, - { nameKey: "cyan", hex: "#164e63" }, - { nameKey: "gray", hex: "#1f2937" }, -] - -export function BlockContextMenu({ - children, - onBackgroundColorChange, - currentBackgroundColor, - readOnly = false, -}: BlockContextMenuProps) { - const t = useTranslations("RichEditor.blockBackground") - const tColors = useTranslations("RichEditor.blockBackground.colors") - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [customColor, setCustomColor] = useState("#ffffff") - const [displayColor, setDisplayColor] = useState("#ffffff") - - // Get current theme - const { theme, resolvedTheme } = useTheme() - - // Determine which color preset to use based on theme - // resolvedTheme is 'light' or 'dark' (resolves 'system' to actual theme) - const presetColors = useMemo(() => { - const currentTheme = resolvedTheme || theme - return currentTheme === "dark" ? darkModeColors : lightModeColors - }, [theme, resolvedTheme]) - - const handleCustomColorChange = (value: any) => { - let hexColor = "#ffffff" - - if (typeof value === "string") { - hexColor = value - } else if (Array.isArray(value)) { - // Extract RGB values (ignore alpha - it's the 4th element) - const [r, g, b] = value - - // Ensure RGB values are valid numbers, clamp to 0-255 range - const rValue = Math.max(0, Math.min(255, Math.round(r || 0))) - const gValue = Math.max(0, Math.min(255, Math.round(g || 0))) - const bValue = Math.max(0, Math.min(255, Math.round(b || 0))) - - hexColor = `#${rValue.toString(16).padStart(2, "0")}${gValue - .toString(16) - .padStart(2, "0")}${bValue.toString(16).padStart(2, "0")}` - } - - setCustomColor(hexColor) - setDisplayColor(hexColor) - } - - const handlePresetColorSelect = (color: string) => { - onBackgroundColorChange(color) - setIsDialogOpen(false) - } - - const handleApplyCustomColor = () => { - onBackgroundColorChange(customColor) - setIsDialogOpen(false) - } - - return ( - - {readOnly ? ( - children - ) : ( - - {children} - - setIsDialogOpen(true)} - className="gap-2" - > - - {t("menuItem")} - - - - )} - - {!readOnly && ( - - - - {t("dialogTitle")} - {t("dialogDescription")} - - - - - {t("tabPreset")} - {t("tabCustom")} - - - -

{t("sectionHeading")}

-
- {presetColors.map((color) => ( - - ))} -
-
- - - - -
- -
- - -
-
-
- - -
-
- -
-
-
- - {displayColor} - -
- -
- - - -
- )} -
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/block.tsx b/apps/web/src/components/ui/rich-editor/block.tsx deleted file mode 100644 index e59ec1ae..00000000 --- a/apps/web/src/components/ui/rich-editor/block.tsx +++ /dev/null @@ -1,1138 +0,0 @@ -"use client" - -import React, { useCallback, useEffect, useRef, useState } from "react" -import { - Code, - GripVertical, - Heading1, - Heading2, - Heading3, - ImageIcon, - List, - ListOrdered, - Plus, - Quote, - Type, -} from "lucide-react" - -import { Button } from "../button" -import { Popover, PopoverContent, PopoverTrigger } from "../popover" -import { BlockContextMenu } from "./block-context-menu" -import { CommandMenu } from "./command-menu" -import { ELEMENT_OPTIONS } from "./elements" -import { FlexContainer } from "./flex-container" -// Import all block handlers and utilities -import { - buildHTML, - createHandleBackgroundColorChange, - createHandleBlockDragEnd, - createHandleBlockDragStart, - createHandleClick, - createHandleCommandSelect, - createHandleCompositionEnd, - createHandleCompositionStart, - createHandleInput, - createHandleKeyDown, - getTypeClassName, - restoreSelection, - saveSelection, -} from "./handlers/block" -import { - getContainerClasses, - getElementType, - getNodeRenderType, -} from "./handlers/block/block-renderer" -import { ImageBlock } from "./image-block" -import { - useBlockNode, - useEditorDispatch, - useEditorStore, - useIsNodeActive, -} from "./store/editor-store" -import { TableBuilder } from "./table-builder" -import { - ContainerNode, - EditorNode, - getNodeTextContent, - isContainerNode, - TextNode, -} from "./types" -import { VideoBlock } from "./video-block" - -// Icon mapping -const iconMap: Record> = { - Type, - Heading1, - Heading2, - Heading3, - Code, - Quote, - List, - ListOrdered, -} - -interface BlockProps { - nodeId: string // Changed: pass ID instead of full node to prevent re-renders - isActive: boolean - nodeRef: (el: HTMLElement | null) => void - onInput: (element: HTMLElement) => void - onKeyDown: (e: React.KeyboardEvent) => void - onClick: () => void - onDelete?: (nodeId?: string) => void - onCreateNested?: (nodeId: string) => void - depth?: number - readOnly?: boolean - onImageDragStart?: (nodeId: string) => void - onChangeBlockType?: (nodeId: string, newType: string) => void - onInsertImage?: (nodeId: string) => void - onCreateList?: (nodeId: string, listType: string) => void - onCreateTable?: (nodeId: string) => void - onUploadImage?: (file: File) => Promise - onBlockDragStart?: (nodeId: string) => void - selectedImageIds?: Set - onToggleImageSelection?: (nodeId: string) => void - onClickWithModifier?: (e: React.MouseEvent, nodeId: string) => void - onFlexContainerDragOver?: ( - e: React.DragEvent, - flexId: string, - position: "left" | "right" | null - ) => void - onFlexContainerDragLeave?: (e: React.DragEvent) => void - onFlexContainerDrop?: ( - e: React.DragEvent, - flexId: string, - position: "left" | "right" | null - ) => void - dragOverFlexId?: string | null - flexDropPosition?: "left" | "right" | null - isFirstBlock?: boolean - notionBased?: boolean - hasCoverImage?: boolean - onUploadCoverImage?: (file: File) => Promise - onSetDragOverNodeId?: (nodeId: string | null) => void - onSetDropPosition?: ( - position: "before" | "after" | "left" | "right" | null - ) => void - draggingNodeId?: string | null - onSetDraggingNodeId?: (nodeId: string | null) => void -} - -// Cache for tracking node changes across renders -const nodeCache = new Map() - -export const Block = React.memo( - function Block({ - nodeId, - isActive, - nodeRef, - onInput, - onKeyDown, - onClick, - onDelete, - onCreateNested, - depth = 0, - readOnly = false, - onImageDragStart, - onChangeBlockType, - onInsertImage, - onCreateList, - onCreateTable, - onUploadImage, - onBlockDragStart, - selectedImageIds, - onToggleImageSelection, - onClickWithModifier, - onFlexContainerDragOver, - onFlexContainerDragLeave, - onFlexContainerDrop, - dragOverFlexId, - flexDropPosition, - isFirstBlock = false, - notionBased = true, - hasCoverImage = false, - onUploadCoverImage, - onSetDragOverNodeId, - onSetDropPosition, - draggingNodeId, - onSetDraggingNodeId, - }: BlockProps) { - // ✅ OPTIMIZATION: Subscribe to ONLY this node's data - // Thanks to structural sharing, this only causes re-render when THIS node changes - const node = useBlockNode(nodeId) - - // All hooks must be called before any conditional returns - const localRef = useRef(null) - const isComposingRef = useRef(false) - const shouldPreserveSelectionRef = useRef(false) - const [isHovering, setIsHovering] = useState(false) - const coverImageInputRef = useRef(null) - const [isUploadingCover, setIsUploadingCover] = useState(false) - - // DEV: Track renders to verify optimization - const renderCountRef = useRef(0) - renderCountRef.current += 1 - - if (process.env.NODE_ENV === "development") { - console.log(`🔄testBlock ${nodeId} render #${renderCountRef.current}`) - } - - // ZUSTAND: Get dispatch function (never changes, no re-renders) - const dispatch = useEditorDispatch() - - // Command menu state - const [showCommandMenu, setShowCommandMenu] = useState(false) - const [commandMenuAnchor, setCommandMenuAnchor] = - useState(null) - - // Add block popover state - const [addBlockPopoverOpen, setAddBlockPopoverOpen] = useState(false) - - // Touch/drag state for mobile - const touchStartRef = useRef<{ x: number; y: number } | null>(null) - const [isDraggingTouch, setIsDraggingTouch] = useState(false) - - // If node not found, return null AFTER all hooks are called - if (!node) { - console.warn(`Block: Node ${nodeId} not found`) - return null - } - - // Determine how to render this node - const renderType = getNodeRenderType(node) - - // Handle container nodes (recursive rendering) - switch (renderType) { - case "table": { - const containerNode = node as ContainerNode - return ( - { - if (dispatch) { - dispatch({ - type: "UPDATE_NODE", - payload: { id, updates }, - }) - } - }} - readOnly={readOnly} - onBlockDragStart={onBlockDragStart} - onDelete={onDelete} - /> - ) - } - - case "flex": { - const containerNode = node as ContainerNode - return ( - { - if (onFlexContainerDragOver) { - onFlexContainerDragOver(e, node.id, position) - } - }} - onDragLeave={onFlexContainerDragLeave} - onDrop={(e, position) => { - if (onFlexContainerDrop) { - onFlexContainerDrop(e, node.id, position) - } - }} - dragOverPosition={ - dragOverFlexId === node.id ? flexDropPosition : null - } - > - {containerNode.children.map((childNode) => { - const isChildMedia = - childNode && - "type" in childNode && - (childNode.type === "img" || childNode.type === "video") - - const blockContent = ( - { - onKeyDown(e) - }} - onClick={onClick} - onDelete={ - isChildMedia && onDelete - ? () => onDelete(childNode.id) - : undefined - } - onCreateNested={onCreateNested} - depth={depth + 1} - readOnly={readOnly} - onImageDragStart={onImageDragStart} - onChangeBlockType={onChangeBlockType} - onInsertImage={onInsertImage} - onCreateList={onCreateList} - onCreateTable={onCreateTable} - onUploadImage={onUploadImage} - selectedImageIds={selectedImageIds} - onToggleImageSelection={onToggleImageSelection} - onClickWithModifier={onClickWithModifier} - onFlexContainerDragOver={onFlexContainerDragOver} - onFlexContainerDragLeave={onFlexContainerDragLeave} - onFlexContainerDrop={onFlexContainerDrop} - dragOverFlexId={dragOverFlexId} - flexDropPosition={flexDropPosition} - onSetDragOverNodeId={onSetDragOverNodeId} - onSetDropPosition={onSetDropPosition} - draggingNodeId={draggingNodeId} - onSetDraggingNodeId={onSetDraggingNodeId} - /> - ) - - // Wrap in flex item div - return ( -
- {blockContent} -
- ) - })} -
- ) - } - - case "nested-container": { - const containerNode = node as ContainerNode - - // Get container classes - const containerClasses = getContainerClasses(false, isActive) - - return ( -
- {containerNode.children.map((childNode: EditorNode) => { - const isChildMedia = - childNode && - "type" in childNode && - (childNode.type === "img" || childNode.type === "video") - - return ( - { - onKeyDown(e) - }} - onClick={onClick} - onDelete={ - isChildMedia && onDelete - ? () => onDelete(childNode.id) - : undefined - } - onCreateNested={onCreateNested} - depth={depth + 1} - readOnly={readOnly} - onImageDragStart={onImageDragStart} - onChangeBlockType={onChangeBlockType} - onInsertImage={onInsertImage} - onCreateList={onCreateList} - onCreateTable={onCreateTable} - onUploadImage={onUploadImage} - selectedImageIds={selectedImageIds} - onToggleImageSelection={onToggleImageSelection} - onClickWithModifier={onClickWithModifier} - onFlexContainerDragOver={onFlexContainerDragOver} - onFlexContainerDragLeave={onFlexContainerDragLeave} - onFlexContainerDrop={onFlexContainerDrop} - dragOverFlexId={dragOverFlexId} - flexDropPosition={flexDropPosition} - onSetDragOverNodeId={onSetDragOverNodeId} - onSetDropPosition={onSetDropPosition} - draggingNodeId={draggingNodeId} - onSetDraggingNodeId={onSetDraggingNodeId} - /> - ) - })} -
- ) - } - } - - // Cast to TextNode for remaining cases - const textNode = node as TextNode - - // BR elements render as empty space - if (textNode.type === "br") { - return ( -
- ) - } - - // Image nodes render as ImageBlock - if (textNode.type === "img") { - return ( - - ) - } - - // Video nodes render as VideoBlock - if (textNode.type === "video") { - return ( - - ) - } - - // Build HTML callback - const memoizedBuildHTML = useCallback(() => { - return buildHTML(textNode, readOnly) - }, [textNode, readOnly]) - - // Save selection callback - const memoizedSaveSelection = useCallback(() => { - return saveSelection(localRef) - }, []) - - // Restore selection callback - const memoizedRestoreSelection = useCallback( - ( - savedSelection: { - start: number - end: number - collapsed: boolean - } | null - ) => { - restoreSelection(localRef, savedSelection) - }, - [] - ) - - // Update content when needed - useEffect(() => { - if (!localRef.current) return - - if (isComposingRef.current || shouldPreserveSelectionRef.current) { - return - } - - const element = localRef.current - const newHTML = memoizedBuildHTML() - - if (element.innerHTML !== newHTML) { - const hadFocus = document.activeElement === element - const savedSelectionData = hadFocus ? memoizedSaveSelection() : null - - element.innerHTML = newHTML - - if (hadFocus && savedSelectionData) { - memoizedRestoreSelection(savedSelectionData) - } - } - }, [memoizedBuildHTML, memoizedSaveSelection, memoizedRestoreSelection]) - - // Create all handlers - const handleCompositionStart = useCallback( - createHandleCompositionStart()(isComposingRef), - [] - ) - - const handleCompositionEnd = useCallback( - createHandleCompositionEnd()(isComposingRef), - [] - ) - - const handleInput = useCallback( - createHandleInput({ - textNode, - readOnly, - onInput, - onChangeBlockType, - showCommandMenu, - setShowCommandMenu, - setCommandMenuAnchor, - shouldPreserveSelectionRef, - }), - [textNode, readOnly, onInput, onChangeBlockType, showCommandMenu] - ) - - const handleKeyDown = useCallback( - createHandleKeyDown({ - textNode, - readOnly, - onInput, - onKeyDown, - onClick, - onCreateNested, - onChangeBlockType, - onInsertImage, - onCreateList, - // ✅ Pass getter function - only called when needed, doesn't cause re-renders - currentContainer: () => - useEditorStore.getState().history[ - useEditorStore.getState().historyIndex - ], - dispatch, - localRef, - isComposingRef, - shouldPreserveSelectionRef, - showCommandMenu, - setShowCommandMenu, - setCommandMenuAnchor, - }), - [textNode, readOnly, onKeyDown, onCreateNested, showCommandMenu, dispatch] - ) - - const handleClick = useCallback(createHandleClick({ readOnly, onClick }), [ - readOnly, - onClick, - ]) - - const handleCommandSelect = useCallback( - createHandleCommandSelect({ - textNode, - onChangeBlockType, - onInsertImage, - onCreateList, - onCreateTable, - localRef, - setShowCommandMenu, - setCommandMenuAnchor, - }), - [textNode, onChangeBlockType, onInsertImage, onCreateList, onCreateTable] - ) - - const handleBackgroundColorChange = useCallback( - createHandleBackgroundColorChange(textNode, dispatch), - [textNode, dispatch] - ) - - const handleBlockDragStartFn = useCallback( - createHandleBlockDragStart(textNode, onBlockDragStart), - [textNode, onBlockDragStart] - ) - - const handleBlockDragEndFn = useCallback( - createHandleBlockDragEnd(() => { - // Clear all drag states when drag ends (including cancelled drags) - if (onSetDragOverNodeId && onSetDropPosition && onSetDraggingNodeId) { - onSetDragOverNodeId(null) - onSetDropPosition(null) - onSetDraggingNodeId(null) - } - }), - [onSetDragOverNodeId, onSetDropPosition, onSetDraggingNodeId] - ) - - // Touch handlers for mobile drag support - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - // Prevent default to stop scrolling - e.preventDefault() - e.stopPropagation() - - const touch = e.touches[0] - touchStartRef.current = { x: touch.clientX, y: touch.clientY } - setIsDraggingTouch(true) - - // Trigger drag start - if (onBlockDragStart && textNode?.id) { - onBlockDragStart(textNode.id) - } - }, - [onBlockDragStart, textNode?.id] - ) - - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - if (!touchStartRef.current || !isDraggingTouch) return - - // Prevent default scrolling while dragging - e.preventDefault() - e.stopPropagation() - - // Highlight the drop target using the same state as desktop - const touch = e.touches[0] - const elementBelow = document.elementFromPoint( - touch.clientX, - touch.clientY - ) - const targetBlock = elementBelow?.closest("[data-node-id]") - - if (targetBlock && onSetDragOverNodeId && onSetDropPosition) { - const targetId = targetBlock.getAttribute("data-node-id") - if (targetId && targetId !== textNode?.id) { - onSetDragOverNodeId(targetId) - onSetDropPosition("after") // Default to after position on mobile - } else { - onSetDragOverNodeId(null) - onSetDropPosition(null) - } - } - }, - [isDraggingTouch, textNode?.id, onSetDragOverNodeId, onSetDropPosition] - ) - - const handleTouchEnd = useCallback( - (e: React.TouchEvent) => { - const touch = e.changedTouches[0] - const elementBelow = document.elementFromPoint( - touch.clientX, - touch.clientY - ) - - // Find the closest block node - const targetBlock = elementBelow?.closest("[data-node-id]") - if (targetBlock && textNode?.id) { - const targetId = targetBlock.getAttribute("data-node-id") - if (targetId && targetId !== textNode.id && dispatch) { - // Move the block - dispatch({ - type: "MOVE_NODE", - payload: { - nodeId: textNode.id, - targetId, - position: "after", - }, - }) - } - } - - // Clean up - touchStartRef.current = null - setIsDraggingTouch(false) - - // Clear drop indicators using the same state as desktop - if (onSetDragOverNodeId && onSetDropPosition) { - onSetDragOverNodeId(null) - onSetDropPosition(null) - } - }, - [textNode?.id, dispatch, onSetDragOverNodeId, onSetDropPosition] - ) - - const handleTouchCancel = useCallback(() => { - // Clean up on touch cancel (e.g., user scrolled or drag was interrupted) - touchStartRef.current = null - setIsDraggingTouch(false) - - // Clear drop indicators - if (onSetDragOverNodeId && onSetDropPosition) { - onSetDragOverNodeId(null) - onSetDropPosition(null) - } - }, [onSetDragOverNodeId, onSetDropPosition]) - - // Handle cover image upload - const handleCoverImageUpload = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file || !onUploadCoverImage) return - - setIsUploadingCover(true) - try { - const url = await onUploadCoverImage(file) - const { EditorActions } = await import("./lib/reducer/actions") - dispatch( - EditorActions.setCoverImage({ - url, - alt: file.name, - position: 50, - }) - ) - } catch (error) { - console.error("Failed to upload cover image:", error) - } finally { - setIsUploadingCover(false) - // Reset input value so the same file can be selected again - if (coverImageInputRef.current) { - coverImageInputRef.current.value = "" - } - } - }, - [onUploadCoverImage, dispatch] - ) - - // Check if block is empty - const textContent = getNodeTextContent(textNode) - const isEmpty = !textContent || textContent.trim() === "" - - // Get placeholder from attributes - const placeholder = textNode.attributes?.placeholder as string | undefined - - // Determine if this is a header block (h1) - headers don't show command menu - const isHeaderBlock = textNode.type === "h1" - - // Show command menu placeholder only if no custom placeholder is set and not a header block - const showCommandPlaceholder = - isEmpty && - isActive && - !readOnly && - onChangeBlockType && - !placeholder && - !isHeaderBlock - - // Determine which HTML element to render based on type - const ElementType = - textNode.type === "li" - ? "li" - : textNode.type === "ol" - ? "ol" - : textNode.type === "h1" - ? "h1" - : textNode.type === "h2" - ? "h2" - : textNode.type === "h3" - ? "h3" - : textNode.type === "h4" - ? "h4" - : textNode.type === "h5" - ? "h5" - : textNode.type === "h6" - ? "h6" - : textNode.type === "p" - ? "p" - : textNode.type === "blockquote" - ? "blockquote" - : textNode.type === "code" - ? "pre" - : "div" - - const isListItem = textNode.type === "li" || textNode.type === "ol" - - // Get custom class from attributes - const customClassName = textNode.attributes?.className || "" - const isHexColor = - typeof customClassName === "string" && customClassName.startsWith("#") - const textColor = isHexColor ? customClassName : "" - const className = isHexColor ? "" : customClassName - - // Get background color from attributes - const backgroundColor = textNode.attributes?.backgroundColor as - | string - | undefined - - // Common props for all elements - const commonProps = { - key: textNode.id, - "data-node-id": textNode.id, - "data-node-type": textNode.type, - "data-show-command-placeholder": showCommandPlaceholder - ? "true" - : undefined, - contentEditable: !readOnly, - suppressContentEditableWarning: true, - ...(placeholder ? { placeholder } : {}), - className: `!ml-6 - ${isListItem ? "relative" : ""} - ${getTypeClassName(textNode.type)} - ${className} - ${readOnly ? "" : "outline-none focus:outline-none"} - ${isListItem ? "px-3 py-0.5 mb-1 list-disc pl-6" : textNode.type.startsWith("h") ? "px-3 py-2 mb-2" : "px-3 py-1.5 mb-2"} - ${textNode.type === "ol" ? "list-decimal" : ""} - ${notionBased && isFirstBlock && textNode.type === "h1" ? "pb-4" : ""} - transition-all - ${!readOnly && isActive ? "border-b bg-accent/5" : ""} - ${!readOnly ? "hover:bg-accent/5" : ""} - ${readOnly ? "cursor-default" : ""} - empty:before:content-[attr(placeholder)] empty:before:text-muted-foreground empty:before:opacity-40 empty:before:pointer-events-none - ${isListItem ? "empty:before:inline-block empty:before:pl-1" : ""} - [&[data-show-command-placeholder='true']]:empty:before:content-['Type_/_for_commands...'] [&[data-show-command-placeholder='true']]:empty:before:text-muted-foreground [&[data-show-command-placeholder='true']]:empty:before:opacity-50 - ${isListItem ? "[&[data-show-command-placeholder='true']]:empty:before:inline-block [&[data-show-command-placeholder='true']]:empty:before:pl-1" : ""} - selection:bg-blue-100 selection:text-blue-900 - [&::marker]:mr-2 - `, - style: { - marginLeft: isListItem - ? `${depth * 0.5 + 1.5}rem` - : `${depth * 0.5}rem`, - ...(textColor ? { color: textColor as string } : {}), - ...(backgroundColor ? { backgroundColor: backgroundColor } : {}), - }, - spellCheck: false, - } - - return ( - <> - -
!readOnly && setIsHovering(true)} - onMouseLeave={() => !readOnly && setIsHovering(false)} - style={{ - borderTop: "2px solid transparent", - borderBottom: "2px solid transparent", - }} - > - {/* Drag Handle & Add Button - Mobile: inline, Desktop: absolute positioned */} - {!readOnly && onBlockDragStart && ( -
- {/* Add Cover Button - Only show on first block in Notion mode if no cover */} - {notionBased && - isFirstBlock && - !hasCoverImage && - onUploadCoverImage && ( - <> - - - - )} - - {/* Add Block Button */} - - - - - -
- {ELEMENT_OPTIONS.map((element) => { - const IconComponent = element.icon - ? iconMap[element.icon] - : null - return ( - - ) - })} -
-
-
- - {/* Drag Handle */} -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - > - -
-
- )} - - { - localRef.current = el - nodeRef(el) - }} - onInput={readOnly ? undefined : (e) => handleInput(e as any)} - onKeyDown={readOnly ? undefined : (e) => handleKeyDown(e as any)} - onClick={(e) => handleClick(e as any)} - onCompositionStart={readOnly ? undefined : handleCompositionStart} - onCompositionEnd={readOnly ? undefined : handleCompositionEnd} - /> -
-
- - {/* Command Menu */} - {!readOnly && ( - setShowCommandMenu(false)} - onSelect={handleCommandSelect} - anchorElement={commandMenuAnchor} - nodeId={textNode.id} - onUploadImage={onUploadImage} - /> - )} - - ) - }, - (prevProps, nextProps) => { - // Custom comparison function for React.memo - // Return true if props are equal (component should NOT re-render) - // Return false if props are different (component SHOULD re-render) - - // IMPORTANT: We must check if node content changed, because if memo returns true, - // the component body never executes, so useBlockNode() never runs, and content - // changes would never be detected! - - const DEBUG = process.env.NODE_ENV === "development" - - // Check if the node ID changed (critical prop) - if (prevProps.nodeId !== nextProps.nodeId) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → nodeId changed:`, - prevProps.nodeId, - "→", - nextProps.nodeId - ) - return false - } - - // Get the current node from store to check if its content changed - const { useEditorStore } = require("./store/editor-store") - const store = useEditorStore.getState() - const currentNode = store.getNode(nextProps.nodeId) - - // Check if node reference changed (thanks to Zustand structural sharing) - const cachedNode = nodeCache.get(nextProps.nodeId) - - // Update cache with current node for next comparison - nodeCache.set(nextProps.nodeId, currentNode) - - // If node reference changed, content must have changed - // This is the KEY optimization - structural sharing ensures same reference = same data - if (cachedNode !== undefined && cachedNode !== currentNode) { - if (DEBUG) { - console.log(`🔄 Block ${nextProps.nodeId} → node data changed in store`) - console.log(" Previous node:", cachedNode) - console.log(" Current node:", currentNode) - } - return false - } - - // Check if active state changed - if (prevProps.isActive !== nextProps.isActive) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → isActive changed:`, - prevProps.isActive, - "→", - nextProps.isActive - ) - return false - } - - // Check if read-only state changed - if (prevProps.readOnly !== nextProps.readOnly) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → readOnly changed:`, - prevProps.readOnly, - "→", - nextProps.readOnly - ) - return false - } - - // Check if depth changed - if (prevProps.depth !== nextProps.depth) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → depth changed:`, - prevProps.depth, - "→", - nextProps.depth - ) - return false - } - - // Check if drag-related props changed - if (prevProps.draggingNodeId !== nextProps.draggingNodeId) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → draggingNodeId changed:`, - prevProps.draggingNodeId, - "→", - nextProps.draggingNodeId - ) - return false - } - if (prevProps.dragOverFlexId !== nextProps.dragOverFlexId) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → dragOverFlexId changed:`, - prevProps.dragOverFlexId, - "→", - nextProps.dragOverFlexId - ) - return false - } - if (prevProps.flexDropPosition !== nextProps.flexDropPosition) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → flexDropPosition changed:`, - prevProps.flexDropPosition, - "→", - nextProps.flexDropPosition - ) - return false - } - - // Check if first block status changed - if (prevProps.isFirstBlock !== nextProps.isFirstBlock) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → isFirstBlock changed:`, - prevProps.isFirstBlock, - "→", - nextProps.isFirstBlock - ) - return false - } - if (prevProps.notionBased !== nextProps.notionBased) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → notionBased changed:`, - prevProps.notionBased, - "→", - nextProps.notionBased - ) - return false - } - if (prevProps.hasCoverImage !== nextProps.hasCoverImage) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → hasCoverImage changed:`, - prevProps.hasCoverImage, - "→", - nextProps.hasCoverImage - ) - return false - } - - // Check if selectedImageIds set changed - if (prevProps.selectedImageIds !== nextProps.selectedImageIds) { - // Deep comparison for Set - if ( - prevProps.selectedImageIds?.size !== nextProps.selectedImageIds?.size - ) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → selectedImageIds size changed:`, - prevProps.selectedImageIds?.size, - "→", - nextProps.selectedImageIds?.size - ) - return false - } - if (prevProps.selectedImageIds && nextProps.selectedImageIds) { - for (const id of prevProps.selectedImageIds) { - if (!nextProps.selectedImageIds.has(id)) { - if (DEBUG) - console.log( - `🔄 Block ${prevProps.nodeId} → selectedImageIds content changed` - ) - return false - } - } - } - } - - // All callback functions are stable references from parent, no need to compare - // The node data itself is fetched via useBlockNode(nodeId) inside the component - // so changes to the node will be detected via Zustand subscriptions - - if (DEBUG) - console.log( - `✅ Block ${prevProps.nodeId} → memo skipped re-render (props unchanged)` - ) - return true // Props are equal, skip re-render - } -) diff --git a/apps/web/src/components/ui/rich-editor/class-mappings.ts b/apps/web/src/components/ui/rich-editor/class-mappings.ts deleted file mode 100644 index dd55a4c6..00000000 --- a/apps/web/src/components/ui/rich-editor/class-mappings.ts +++ /dev/null @@ -1,1061 +0,0 @@ -export interface ClassMapping { - userFriendly: string - tailwindClass: string - category: string -} - -export const classMappings: ClassMapping[] = [ - // Text Color - { - userFriendly: "Red Text", - tailwindClass: "text-red-500", - category: "Text Color", - }, - { - userFriendly: "Dark Red Text", - tailwindClass: "text-red-600", - category: "Text Color", - }, - { - userFriendly: "Darker Red Text", - tailwindClass: "text-red-700", - category: "Text Color", - }, - { - userFriendly: "Blue Text", - tailwindClass: "text-blue-500", - category: "Text Color", - }, - { - userFriendly: "Dark Blue Text", - tailwindClass: "text-blue-600", - category: "Text Color", - }, - { - userFriendly: "Darker Blue Text", - tailwindClass: "text-blue-700", - category: "Text Color", - }, - { - userFriendly: "Green Text", - tailwindClass: "text-green-500", - category: "Text Color", - }, - { - userFriendly: "Dark Green Text", - tailwindClass: "text-green-600", - category: "Text Color", - }, - { - userFriendly: "Darker Green Text", - tailwindClass: "text-green-700", - category: "Text Color", - }, - { - userFriendly: "Yellow Text", - tailwindClass: "text-yellow-500", - category: "Text Color", - }, - { - userFriendly: "Dark Yellow Text", - tailwindClass: "text-yellow-600", - category: "Text Color", - }, - { - userFriendly: "Darker Yellow Text", - tailwindClass: "text-yellow-700", - category: "Text Color", - }, - { - userFriendly: "Purple Text", - tailwindClass: "text-purple-500", - category: "Text Color", - }, - { - userFriendly: "Dark Purple Text", - tailwindClass: "text-purple-600", - category: "Text Color", - }, - { - userFriendly: "Darker Purple Text", - tailwindClass: "text-purple-700", - category: "Text Color", - }, - { - userFriendly: "Pink Text", - tailwindClass: "text-pink-500", - category: "Text Color", - }, - { - userFriendly: "Dark Pink Text", - tailwindClass: "text-pink-600", - category: "Text Color", - }, - { - userFriendly: "Darker Pink Text", - tailwindClass: "text-pink-700", - category: "Text Color", - }, - { - userFriendly: "Indigo Text", - tailwindClass: "text-indigo-500", - category: "Text Color", - }, - { - userFriendly: "Dark Indigo Text", - tailwindClass: "text-indigo-600", - category: "Text Color", - }, - { - userFriendly: "Darker Indigo Text", - tailwindClass: "text-indigo-700", - category: "Text Color", - }, - { - userFriendly: "Gray Text", - tailwindClass: "text-gray-500", - category: "Text Color", - }, - { - userFriendly: "Dark Gray Text", - tailwindClass: "text-gray-600", - category: "Text Color", - }, - { - userFriendly: "Darker Gray Text", - tailwindClass: "text-gray-700", - category: "Text Color", - }, - { - userFriendly: "Very Dark Gray Text", - tailwindClass: "text-gray-800", - category: "Text Color", - }, - { - userFriendly: "Black Gray Text", - tailwindClass: "text-gray-900", - category: "Text Color", - }, - { - userFriendly: "White Text", - tailwindClass: "text-white", - category: "Text Color", - }, - { - userFriendly: "Black Text", - tailwindClass: "text-black", - category: "Text Color", - }, - - // Background Color - { - userFriendly: "Light Red Background", - tailwindClass: "bg-red-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Red Background", - tailwindClass: "bg-red-100", - category: "Background Color", - }, - { - userFriendly: "Soft Red Background", - tailwindClass: "bg-red-200", - category: "Background Color", - }, - { - userFriendly: "Red Background", - tailwindClass: "bg-red-500", - category: "Background Color", - }, - { - userFriendly: "Light Blue Background", - tailwindClass: "bg-blue-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Blue Background", - tailwindClass: "bg-blue-100", - category: "Background Color", - }, - { - userFriendly: "Soft Blue Background", - tailwindClass: "bg-blue-200", - category: "Background Color", - }, - { - userFriendly: "Blue Background", - tailwindClass: "bg-blue-500", - category: "Background Color", - }, - { - userFriendly: "Light Green Background", - tailwindClass: "bg-green-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Green Background", - tailwindClass: "bg-green-100", - category: "Background Color", - }, - { - userFriendly: "Soft Green Background", - tailwindClass: "bg-green-200", - category: "Background Color", - }, - { - userFriendly: "Green Background", - tailwindClass: "bg-green-500", - category: "Background Color", - }, - { - userFriendly: "Light Yellow Background", - tailwindClass: "bg-yellow-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Yellow Background", - tailwindClass: "bg-yellow-100", - category: "Background Color", - }, - { - userFriendly: "Soft Yellow Background", - tailwindClass: "bg-yellow-200", - category: "Background Color", - }, - { - userFriendly: "Yellow Background", - tailwindClass: "bg-yellow-500", - category: "Background Color", - }, - { - userFriendly: "Light Purple Background", - tailwindClass: "bg-purple-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Purple Background", - tailwindClass: "bg-purple-100", - category: "Background Color", - }, - { - userFriendly: "Soft Purple Background", - tailwindClass: "bg-purple-200", - category: "Background Color", - }, - { - userFriendly: "Purple Background", - tailwindClass: "bg-purple-500", - category: "Background Color", - }, - { - userFriendly: "Light Pink Background", - tailwindClass: "bg-pink-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Pink Background", - tailwindClass: "bg-pink-100", - category: "Background Color", - }, - { - userFriendly: "Soft Pink Background", - tailwindClass: "bg-pink-200", - category: "Background Color", - }, - { - userFriendly: "Pink Background", - tailwindClass: "bg-pink-500", - category: "Background Color", - }, - { - userFriendly: "Light Indigo Background", - tailwindClass: "bg-indigo-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Indigo Background", - tailwindClass: "bg-indigo-100", - category: "Background Color", - }, - { - userFriendly: "Soft Indigo Background", - tailwindClass: "bg-indigo-200", - category: "Background Color", - }, - { - userFriendly: "Indigo Background", - tailwindClass: "bg-indigo-500", - category: "Background Color", - }, - { - userFriendly: "Light Gray Background", - tailwindClass: "bg-gray-50", - category: "Background Color", - }, - { - userFriendly: "Lighter Gray Background", - tailwindClass: "bg-gray-100", - category: "Background Color", - }, - { - userFriendly: "Soft Gray Background", - tailwindClass: "bg-gray-200", - category: "Background Color", - }, - { - userFriendly: "Gray Background", - tailwindClass: "bg-gray-500", - category: "Background Color", - }, - { - userFriendly: "White Background", - tailwindClass: "bg-white", - category: "Background Color", - }, - { - userFriendly: "Black Background", - tailwindClass: "bg-black", - category: "Background Color", - }, - { - userFriendly: "Transparent Background", - tailwindClass: "bg-transparent", - category: "Background Color", - }, - - // Font Size - { - userFriendly: "Extra Small Text", - tailwindClass: "text-xs", - category: "Font Size", - }, - { - userFriendly: "Small Text", - tailwindClass: "text-sm", - category: "Font Size", - }, - { - userFriendly: "Normal Text", - tailwindClass: "text-base", - category: "Font Size", - }, - { - userFriendly: "Large Text", - tailwindClass: "text-lg", - category: "Font Size", - }, - { - userFriendly: "Extra Large Text", - tailwindClass: "text-xl", - category: "Font Size", - }, - { - userFriendly: "2X Large Text", - tailwindClass: "text-2xl", - category: "Font Size", - }, - { - userFriendly: "3X Large Text", - tailwindClass: "text-3xl", - category: "Font Size", - }, - { - userFriendly: "4X Large Text", - tailwindClass: "text-4xl", - category: "Font Size", - }, - { - userFriendly: "5X Large Text", - tailwindClass: "text-5xl", - category: "Font Size", - }, - { - userFriendly: "6X Large Text", - tailwindClass: "text-6xl", - category: "Font Size", - }, - { - userFriendly: "7X Large Text", - tailwindClass: "text-7xl", - category: "Font Size", - }, - { - userFriendly: "8X Large Text", - tailwindClass: "text-8xl", - category: "Font Size", - }, - { - userFriendly: "9X Large Text", - tailwindClass: "text-9xl", - category: "Font Size", - }, - - // Font Weight - { - userFriendly: "Thin Weight", - tailwindClass: "font-thin", - category: "Font Weight", - }, - { - userFriendly: "Extra Light Weight", - tailwindClass: "font-extralight", - category: "Font Weight", - }, - { - userFriendly: "Light Weight", - tailwindClass: "font-light", - category: "Font Weight", - }, - { - userFriendly: "Normal Weight", - tailwindClass: "font-normal", - category: "Font Weight", - }, - { - userFriendly: "Medium Weight", - tailwindClass: "font-medium", - category: "Font Weight", - }, - { - userFriendly: "Semi Bold", - tailwindClass: "font-semibold", - category: "Font Weight", - }, - { userFriendly: "Bold", tailwindClass: "font-bold", category: "Font Weight" }, - { - userFriendly: "Extra Bold", - tailwindClass: "font-extrabold", - category: "Font Weight", - }, - { - userFriendly: "Black Weight", - tailwindClass: "font-black", - category: "Font Weight", - }, - - // Text Decoration - { - userFriendly: "Underline", - tailwindClass: "underline", - category: "Text Decoration", - }, - { - userFriendly: "Overline", - tailwindClass: "overline", - category: "Text Decoration", - }, - { - userFriendly: "Line Through", - tailwindClass: "line-through", - category: "Text Decoration", - }, - { - userFriendly: "No Underline", - tailwindClass: "no-underline", - category: "Text Decoration", - }, - { - userFriendly: "Solid Decoration", - tailwindClass: "decoration-solid", - category: "Text Decoration", - }, - { - userFriendly: "Double Decoration", - tailwindClass: "decoration-double", - category: "Text Decoration", - }, - { - userFriendly: "Dotted Decoration", - tailwindClass: "decoration-dotted", - category: "Text Decoration", - }, - { - userFriendly: "Dashed Decoration", - tailwindClass: "decoration-dashed", - category: "Text Decoration", - }, - { - userFriendly: "Wavy Decoration", - tailwindClass: "decoration-wavy", - category: "Text Decoration", - }, - { - userFriendly: "Small Offset", - tailwindClass: "underline-offset-1", - category: "Text Decoration", - }, - { - userFriendly: "Medium Offset", - tailwindClass: "underline-offset-2", - category: "Text Decoration", - }, - { - userFriendly: "Large Offset", - tailwindClass: "underline-offset-4", - category: "Text Decoration", - }, - { - userFriendly: "Extra Large Offset", - tailwindClass: "underline-offset-8", - category: "Text Decoration", - }, - - // Text Alignment - { - userFriendly: "Align Left", - tailwindClass: "text-left", - category: "Text Alignment", - }, - { - userFriendly: "Align Center", - tailwindClass: "text-center", - category: "Text Alignment", - }, - { - userFriendly: "Align Right", - tailwindClass: "text-right", - category: "Text Alignment", - }, - { - userFriendly: "Justify", - tailwindClass: "text-justify", - category: "Text Alignment", - }, - { - userFriendly: "Align Start", - tailwindClass: "text-start", - category: "Text Alignment", - }, - { - userFriendly: "Align End", - tailwindClass: "text-end", - category: "Text Alignment", - }, - - // Text Transform - { - userFriendly: "Uppercase", - tailwindClass: "uppercase", - category: "Text Transform", - }, - { - userFriendly: "Lowercase", - tailwindClass: "lowercase", - category: "Text Transform", - }, - { - userFriendly: "Capitalize", - tailwindClass: "capitalize", - category: "Text Transform", - }, - { - userFriendly: "Normal Case", - tailwindClass: "normal-case", - category: "Text Transform", - }, - - // Padding (Simplified - showing most common ones) - { userFriendly: "No Padding", tailwindClass: "p-0", category: "Padding" }, - { userFriendly: "Tiny Padding", tailwindClass: "p-1", category: "Padding" }, - { userFriendly: "Small Padding", tailwindClass: "p-2", category: "Padding" }, - { userFriendly: "Medium Padding", tailwindClass: "p-4", category: "Padding" }, - { userFriendly: "Large Padding", tailwindClass: "p-6", category: "Padding" }, - { - userFriendly: "Extra Large Padding", - tailwindClass: "p-8", - category: "Padding", - }, - { - userFriendly: "Small Horizontal Padding", - tailwindClass: "px-2", - category: "Padding", - }, - { - userFriendly: "Medium Horizontal Padding", - tailwindClass: "px-4", - category: "Padding", - }, - { - userFriendly: "Large Horizontal Padding", - tailwindClass: "px-6", - category: "Padding", - }, - { - userFriendly: "Small Vertical Padding", - tailwindClass: "py-2", - category: "Padding", - }, - { - userFriendly: "Medium Vertical Padding", - tailwindClass: "py-4", - category: "Padding", - }, - { - userFriendly: "Large Vertical Padding", - tailwindClass: "py-6", - category: "Padding", - }, - - // Margin (Simplified - showing most common ones) - { userFriendly: "No Margin", tailwindClass: "m-0", category: "Margin" }, - { userFriendly: "Tiny Margin", tailwindClass: "m-1", category: "Margin" }, - { userFriendly: "Small Margin", tailwindClass: "m-2", category: "Margin" }, - { userFriendly: "Medium Margin", tailwindClass: "m-4", category: "Margin" }, - { userFriendly: "Large Margin", tailwindClass: "m-6", category: "Margin" }, - { - userFriendly: "Extra Large Margin", - tailwindClass: "m-8", - category: "Margin", - }, - { - userFriendly: "Center Horizontally", - tailwindClass: "mx-auto", - category: "Margin", - }, - { - userFriendly: "Small Horizontal Margin", - tailwindClass: "mx-2", - category: "Margin", - }, - { - userFriendly: "Medium Horizontal Margin", - tailwindClass: "mx-4", - category: "Margin", - }, - { - userFriendly: "Small Vertical Margin", - tailwindClass: "my-2", - category: "Margin", - }, - { - userFriendly: "Medium Vertical Margin", - tailwindClass: "my-4", - category: "Margin", - }, - - // Border - { userFriendly: "Border", tailwindClass: "border", category: "Border" }, - { userFriendly: "No Border", tailwindClass: "border-0", category: "Border" }, - { - userFriendly: "Thick Border", - tailwindClass: "border-2", - category: "Border", - }, - { - userFriendly: "Very Thick Border", - tailwindClass: "border-4", - category: "Border", - }, - { userFriendly: "Top Border", tailwindClass: "border-t", category: "Border" }, - { - userFriendly: "Bottom Border", - tailwindClass: "border-b", - category: "Border", - }, - { - userFriendly: "Left Border", - tailwindClass: "border-l", - category: "Border", - }, - { - userFriendly: "Right Border", - tailwindClass: "border-r", - category: "Border", - }, - { - userFriendly: "Solid Border", - tailwindClass: "border-solid", - category: "Border", - }, - { - userFriendly: "Dashed Border", - tailwindClass: "border-dashed", - category: "Border", - }, - { - userFriendly: "Dotted Border", - tailwindClass: "border-dotted", - category: "Border", - }, - - // Border Color - { - userFriendly: "Red Border", - tailwindClass: "border-red-500", - category: "Border Color", - }, - { - userFriendly: "Blue Border", - tailwindClass: "border-blue-500", - category: "Border Color", - }, - { - userFriendly: "Green Border", - tailwindClass: "border-green-500", - category: "Border Color", - }, - { - userFriendly: "Yellow Border", - tailwindClass: "border-yellow-500", - category: "Border Color", - }, - { - userFriendly: "Purple Border", - tailwindClass: "border-purple-500", - category: "Border Color", - }, - { - userFriendly: "Gray Border", - tailwindClass: "border-gray-300", - category: "Border Color", - }, - - // Border Radius - { - userFriendly: "No Rounded", - tailwindClass: "rounded-none", - category: "Border Radius", - }, - { - userFriendly: "Small Rounded", - tailwindClass: "rounded-sm", - category: "Border Radius", - }, - { - userFriendly: "Rounded", - tailwindClass: "rounded", - category: "Border Radius", - }, - { - userFriendly: "Medium Rounded", - tailwindClass: "rounded-md", - category: "Border Radius", - }, - { - userFriendly: "Large Rounded", - tailwindClass: "rounded-lg", - category: "Border Radius", - }, - { - userFriendly: "Extra Large Rounded", - tailwindClass: "rounded-xl", - category: "Border Radius", - }, - { - userFriendly: "Full Rounded (Circle)", - tailwindClass: "rounded-full", - category: "Border Radius", - }, - - // Shadow - { - userFriendly: "No Shadow", - tailwindClass: "shadow-none", - category: "Shadow", - }, - { - userFriendly: "Small Shadow", - tailwindClass: "shadow-sm", - category: "Shadow", - }, - { userFriendly: "Shadow", tailwindClass: "shadow", category: "Shadow" }, - { - userFriendly: "Medium Shadow", - tailwindClass: "shadow-md", - category: "Shadow", - }, - { - userFriendly: "Large Shadow", - tailwindClass: "shadow-lg", - category: "Shadow", - }, - { - userFriendly: "Extra Large Shadow", - tailwindClass: "shadow-xl", - category: "Shadow", - }, - { - userFriendly: "2X Large Shadow", - tailwindClass: "shadow-2xl", - category: "Shadow", - }, - - // Opacity - { - userFriendly: "Invisible", - tailwindClass: "opacity-0", - category: "Opacity", - }, - { - userFriendly: "Quarter Visible", - tailwindClass: "opacity-25", - category: "Opacity", - }, - { - userFriendly: "Half Visible", - tailwindClass: "opacity-50", - category: "Opacity", - }, - { - userFriendly: "Three-Quarters Visible", - tailwindClass: "opacity-75", - category: "Opacity", - }, - { - userFriendly: "Fully Visible", - tailwindClass: "opacity-100", - category: "Opacity", - }, - - // Display - { userFriendly: "Block", tailwindClass: "block", category: "Display" }, - { - userFriendly: "Inline Block", - tailwindClass: "inline-block", - category: "Display", - }, - { userFriendly: "Inline", tailwindClass: "inline", category: "Display" }, - { userFriendly: "Flex", tailwindClass: "flex", category: "Display" }, - { userFriendly: "Grid", tailwindClass: "grid", category: "Display" }, - { userFriendly: "Hidden", tailwindClass: "hidden", category: "Display" }, - - // Flex - { userFriendly: "Flex Row", tailwindClass: "flex-row", category: "Flex" }, - { userFriendly: "Flex Column", tailwindClass: "flex-col", category: "Flex" }, - { userFriendly: "Flex Wrap", tailwindClass: "flex-wrap", category: "Flex" }, - { userFriendly: "No Wrap", tailwindClass: "flex-nowrap", category: "Flex" }, - - // Justify Content - { - userFriendly: "Justify Start", - tailwindClass: "justify-start", - category: "Justify Content", - }, - { - userFriendly: "Justify Center", - tailwindClass: "justify-center", - category: "Justify Content", - }, - { - userFriendly: "Justify End", - tailwindClass: "justify-end", - category: "Justify Content", - }, - { - userFriendly: "Justify Between", - tailwindClass: "justify-between", - category: "Justify Content", - }, - { - userFriendly: "Justify Around", - tailwindClass: "justify-around", - category: "Justify Content", - }, - - // Align Items - { - userFriendly: "Align Start", - tailwindClass: "items-start", - category: "Align Items", - }, - { - userFriendly: "Align Center", - tailwindClass: "items-center", - category: "Align Items", - }, - { - userFriendly: "Align End", - tailwindClass: "items-end", - category: "Align Items", - }, - { - userFriendly: "Align Stretch", - tailwindClass: "items-stretch", - category: "Align Items", - }, - - // Gap - { userFriendly: "No Gap", tailwindClass: "gap-0", category: "Gap" }, - { userFriendly: "Tiny Gap", tailwindClass: "gap-1", category: "Gap" }, - { userFriendly: "Small Gap", tailwindClass: "gap-2", category: "Gap" }, - { userFriendly: "Medium Gap", tailwindClass: "gap-4", category: "Gap" }, - { userFriendly: "Large Gap", tailwindClass: "gap-6", category: "Gap" }, - { userFriendly: "Extra Large Gap", tailwindClass: "gap-8", category: "Gap" }, - - // Transitions - { - userFriendly: "No Transition", - tailwindClass: "transition-none", - category: "Transitions", - }, - { - userFriendly: "Transition All", - tailwindClass: "transition-all", - category: "Transitions", - }, - { - userFriendly: "Transition", - tailwindClass: "transition", - category: "Transitions", - }, - { - userFriendly: "Transition Colors", - tailwindClass: "transition-colors", - category: "Transitions", - }, - { - userFriendly: "Fast Duration", - tailwindClass: "duration-150", - category: "Transitions", - }, - { - userFriendly: "Normal Duration", - tailwindClass: "duration-300", - category: "Transitions", - }, - { - userFriendly: "Slow Duration", - tailwindClass: "duration-500", - category: "Transitions", - }, - - // Cursor - { - userFriendly: "Pointer Cursor", - tailwindClass: "cursor-pointer", - category: "Cursor", - }, - { - userFriendly: "Default Cursor", - tailwindClass: "cursor-default", - category: "Cursor", - }, - { - userFriendly: "Not Allowed Cursor", - tailwindClass: "cursor-not-allowed", - category: "Cursor", - }, - - // Position - { userFriendly: "Static", tailwindClass: "static", category: "Position" }, - { userFriendly: "Relative", tailwindClass: "relative", category: "Position" }, - { userFriendly: "Absolute", tailwindClass: "absolute", category: "Position" }, - { userFriendly: "Fixed", tailwindClass: "fixed", category: "Position" }, - { userFriendly: "Sticky", tailwindClass: "sticky", category: "Position" }, - - // Overflow - { - userFriendly: "Overflow Auto", - tailwindClass: "overflow-auto", - category: "Overflow", - }, - { - userFriendly: "Overflow Hidden", - tailwindClass: "overflow-hidden", - category: "Overflow", - }, - { - userFriendly: "Overflow Scroll", - tailwindClass: "overflow-scroll", - category: "Overflow", - }, - - // Letter Spacing - { - userFriendly: "Tight Spacing", - tailwindClass: "tracking-tight", - category: "Letter Spacing", - }, - { - userFriendly: "Normal Spacing", - tailwindClass: "tracking-normal", - category: "Letter Spacing", - }, - { - userFriendly: "Wide Spacing", - tailwindClass: "tracking-wide", - category: "Letter Spacing", - }, - { - userFriendly: "Wider Spacing", - tailwindClass: "tracking-wider", - category: "Letter Spacing", - }, - - // Line Height - { - userFriendly: "Tight Line Height", - tailwindClass: "leading-tight", - category: "Line Height", - }, - { - userFriendly: "Normal Line Height", - tailwindClass: "leading-normal", - category: "Line Height", - }, - { - userFriendly: "Relaxed Line Height", - tailwindClass: "leading-relaxed", - category: "Line Height", - }, - { - userFriendly: "Loose Line Height", - tailwindClass: "leading-loose", - category: "Line Height", - }, -] - -/** - * Get grouped classes with user-friendly names - */ -export function getUserFriendlyClasses(): { - category: string - items: { label: string; value: string }[] -}[] { - const grouped = new Map() - - classMappings.forEach(({ category, userFriendly, tailwindClass }) => { - if (!grouped.has(category)) { - grouped.set(category, []) - } - grouped.get(category)!.push({ - label: userFriendly, - value: tailwindClass, - }) - }) - - return Array.from(grouped.entries()).map(([category, items]) => ({ - category, - items, - })) -} - -/** - * Search user-friendly classes - */ -export function searchUserFriendlyClasses(query: string) { - const lowerQuery = query.toLowerCase() - const grouped = new Map() - - classMappings - .filter( - ({ userFriendly, tailwindClass }) => - userFriendly.toLowerCase().includes(lowerQuery) || - tailwindClass.toLowerCase().includes(lowerQuery) - ) - .forEach(({ category, userFriendly, tailwindClass }) => { - if (!grouped.has(category)) { - grouped.set(category, []) - } - grouped.get(category)!.push({ - label: userFriendly, - value: tailwindClass, - }) - }) - - return Array.from(grouped.entries()).map(([category, items]) => ({ - category, - items, - })) -} diff --git a/apps/web/src/components/ui/rich-editor/color-picker-index.tsx b/apps/web/src/components/ui/rich-editor/color-picker-index.tsx deleted file mode 100644 index 65dc8ace..00000000 --- a/apps/web/src/components/ui/rich-editor/color-picker-index.tsx +++ /dev/null @@ -1,472 +0,0 @@ -"use client" - -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, - type HTMLAttributes, -} from "react" -import * as Slider from "@radix-ui/react-slider" -import Color from "color" -import { PipetteIcon } from "lucide-react" - -import { Button } from "../button" -import { Input } from "../input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../select" -import { cn } from "./lib/utils" - -interface ColorPickerContextValue { - hue: number - saturation: number - lightness: number - alpha: number - mode: string - setHue: (hue: number) => void - setSaturation: (saturation: number) => void - setLightness: (lightness: number) => void - setAlpha: (alpha: number) => void - setMode: (mode: string) => void -} - -const ColorPickerContext = createContext( - undefined -) - -export const useColorPicker = () => { - const context = useContext(ColorPickerContext) - - if (!context) { - throw new Error("useColorPicker must be used within a ColorPickerProvider") - } - - return context -} - -export type ColorPickerProps = HTMLAttributes & { - value?: Parameters[0] - defaultValue?: Parameters[0] - onChange?: (value: Parameters[0]) => void -} - -export const ColorPicker = ({ - value, - defaultValue = "#000000", - onChange, - className, - ...props -}: ColorPickerProps) => { - const selectedColor = Color(value) - const defaultColor = Color(defaultValue) - - const [hue, setHue] = useState(selectedColor.hue() || defaultColor.hue() || 0) - const [saturation, setSaturation] = useState( - selectedColor.saturationl() || defaultColor.saturationl() || 100 - ) - const [lightness, setLightness] = useState( - selectedColor.lightness() || defaultColor.lightness() || 50 - ) - const [alpha, setAlpha] = useState( - selectedColor.alpha() * 100 || defaultColor.alpha() * 100 - ) - const [mode, setMode] = useState("hex") - - // Update color when controlled value changes - useEffect(() => { - if (value) { - const color = Color.rgb(value).rgb().object() - - setHue(color.r) - setSaturation(color.g) - setLightness(color.b) - setAlpha(color.a) - } - }, [value]) - - // Notify parent of changes - useEffect(() => { - if (onChange) { - const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100) - const rgba = color.rgb().array() - - onChange([rgba[0], rgba[1], rgba[2], alpha / 100]) - } - }, [hue, saturation, lightness, alpha, onChange]) - - return ( - -
- - ) -} - -export type ColorPickerSelectionProps = HTMLAttributes - -export const ColorPickerSelection = memo( - ({ className, ...props }: ColorPickerSelectionProps) => { - const containerRef = useRef(null) - const [isDragging, setIsDragging] = useState(false) - const [positionX, setPositionX] = useState(0) - const [positionY, setPositionY] = useState(0) - const { hue, setSaturation, setLightness } = useColorPicker() - - const backgroundGradient = useMemo(() => { - return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)), - linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)), - hsl(${hue}, 100%, 50%)` - }, [hue]) - - const handlePointerMove = useCallback( - (event: PointerEvent) => { - if (!(isDragging && containerRef.current)) { - return - } - const rect = containerRef.current.getBoundingClientRect() - const x = Math.max( - 0, - Math.min(1, (event.clientX - rect.left) / rect.width) - ) - const y = Math.max( - 0, - Math.min(1, (event.clientY - rect.top) / rect.height) - ) - setPositionX(x) - setPositionY(y) - setSaturation(x * 100) - const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x) - const lightness = topLightness * (1 - y) - - setLightness(lightness) - }, - [isDragging, setSaturation, setLightness] - ) - - useEffect(() => { - const handlePointerUp = () => setIsDragging(false) - - if (isDragging) { - window.addEventListener("pointermove", handlePointerMove) - window.addEventListener("pointerup", handlePointerUp) - } - - return () => { - window.removeEventListener("pointermove", handlePointerMove) - window.removeEventListener("pointerup", handlePointerUp) - } - }, [isDragging, handlePointerMove]) - - return ( -
{ - e.preventDefault() - setIsDragging(true) - handlePointerMove(e.nativeEvent) - }} - ref={containerRef} - style={{ - background: backgroundGradient, - }} - {...props} - > -
-
- ) - } -) - -ColorPickerSelection.displayName = "ColorPickerSelection" - -export type ColorPickerHueProps = ComponentProps - -export const ColorPickerHue = ({ - className, - ...props -}: ColorPickerHueProps) => { - const { hue, setHue } = useColorPicker() - - return ( - setHue(hue)} - step={1} - value={[hue]} - {...props} - > - - - - - - ) -} - -export type ColorPickerAlphaProps = ComponentProps - -export const ColorPickerAlpha = ({ - className, - ...props -}: ColorPickerAlphaProps) => { - const { alpha, setAlpha } = useColorPicker() - - return ( - setAlpha(alpha)} - step={1} - value={[alpha]} - {...props} - > - -
- - - - - ) -} - -export type ColorPickerEyeDropperProps = ComponentProps - -export const ColorPickerEyeDropper = ({ - className, - ...props -}: ColorPickerEyeDropperProps) => { - const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker() - - const handleEyeDropper = async () => { - try { - // @ts-expect-error - EyeDropper API is experimental - const eyeDropper = new EyeDropper() - const result = await eyeDropper.open() - const color = Color(result.sRGBHex) - const [h, s, l] = color.hsl().array() - - setHue(h) - setSaturation(s) - setLightness(l) - setAlpha(100) - } catch (error) { - console.error("EyeDropper failed:", error) - } - } - - return ( - - ) -} - -export type ColorPickerOutputProps = ComponentProps - -const formats = ["hex", "rgb", "css", "hsl"] - -export const ColorPickerOutput = ({ - className, - ...props -}: ColorPickerOutputProps) => { - const { mode, setMode } = useColorPicker() - - return ( - - ) -} - -type PercentageInputProps = ComponentProps - -const PercentageInput = ({ className, ...props }: PercentageInputProps) => { - return ( -
- - - % - -
- ) -} - -export type ColorPickerFormatProps = HTMLAttributes - -export const ColorPickerFormat = ({ - className, - ...props -}: ColorPickerFormatProps) => { - const { hue, saturation, lightness, alpha, mode } = useColorPicker() - const color = Color.hsl(hue, saturation, lightness, alpha / 100) - - if (mode === "hex") { - const hex = color.hex() - - return ( -
- - -
- ) - } - - if (mode === "rgb") { - const rgb = color - .rgb() - .array() - .map((value) => Math.round(value)) - - return ( -
- {rgb.map((value, index) => ( - - ))} - -
- ) - } - - if (mode === "css") { - const rgb = color - .rgb() - .array() - .map((value) => Math.round(value)) - - return ( -
- -
- ) - } - - if (mode === "hsl") { - const hsl = color - .hsl() - .array() - .map((value) => Math.round(value)) - - return ( -
- {hsl.map((value, index) => ( - - ))} - -
- ) - } - - return null -} diff --git a/apps/web/src/components/ui/rich-editor/color-picker-interface.tsx b/apps/web/src/components/ui/rich-editor/color-picker-interface.tsx deleted file mode 100644 index 67024e4b..00000000 --- a/apps/web/src/components/ui/rich-editor/color-picker-interface.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client" - -import { useRef, useState } from "react" -import { Palette } from "lucide-react" - -import { Button } from "../button" -import { Popover, PopoverContent, PopoverTrigger } from "../popover" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs" -import { - ColorPickerAlpha, - ColorPickerEyeDropper, - ColorPickerFormat, - ColorPickerHue, - ColorPickerOutput, - ColorPickerSelection, - ColorPicker as ShadcnColorPicker, -} from "./color-picker-index" - -interface ColorPickerComponentProps { - disabled?: boolean - onColorSelect: (color: string) => void - selectedColor?: string -} - -const presetColors = [ - { name: "Red", hex: "#ef4444" }, - { name: "Orange", hex: "#f97316" }, - { name: "Yellow", hex: "#eab308" }, - { name: "Green", hex: "#22c55e" }, - { name: "Blue", hex: "#3b82f6" }, - { name: "Indigo", hex: "#6366f1" }, - { name: "Purple", hex: "#a855f7" }, - { name: "Pink", hex: "#ec4899" }, - { name: "Teal", hex: "#14b8a6" }, - { name: "Cyan", hex: "#06b6d4" }, -] - -export function ColorPickerComponent({ - disabled, - onColorSelect, - selectedColor, -}: ColorPickerComponentProps) { - // Start with a vibrant blue so it's clearly not white - const [displayColor, setDisplayColor] = useState("rgb(59, 130, 246)") - const customColorRef = useRef("#3b82f6") - - const handleCustomColorChange = (value: any) => { - let hexColor = "#000000" - - if (typeof value === "string") { - hexColor = value - } else if (Array.isArray(value)) { - // Extract RGB values (ignore alpha - it's the 4th element and can be NaN) - const [r, g, b] = value - - // Ensure RGB values are valid numbers, clamp to 0-255 range - const rValue = Math.max(0, Math.min(255, Math.round(r || 0))) - const gValue = Math.max(0, Math.min(255, Math.round(g || 0))) - const bValue = Math.max(0, Math.min(255, Math.round(b || 0))) - - hexColor = `#${rValue.toString(16).padStart(2, "0")}${gValue.toString(16).padStart(2, "0")}${bValue.toString(16).padStart(2, "0")}` - } - - // Update ref with the current color - customColorRef.current = hexColor - // Update display - setDisplayColor(hexColor) - } - - const handleApplyCustomColor = () => { - // Send the hex color directly (we'll handle it as inline style) - const hexColor = customColorRef.current - onColorSelect(hexColor) - } - - return ( - - - - - - - - Preset Colors - Custom Color - - - -

Text Colors

-
- {presetColors.map((color) => ( - - ))} -
-
- - -

Custom Color Picker

- - -
- -
- - -
-
-
- - -
-
- -
-
- {displayColor} -
- - - - - ) -} diff --git a/apps/web/src/components/ui/rich-editor/color-picker.tsx b/apps/web/src/components/ui/rich-editor/color-picker.tsx deleted file mode 100644 index 67024e4b..00000000 --- a/apps/web/src/components/ui/rich-editor/color-picker.tsx +++ /dev/null @@ -1,156 +0,0 @@ -"use client" - -import { useRef, useState } from "react" -import { Palette } from "lucide-react" - -import { Button } from "../button" -import { Popover, PopoverContent, PopoverTrigger } from "../popover" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs" -import { - ColorPickerAlpha, - ColorPickerEyeDropper, - ColorPickerFormat, - ColorPickerHue, - ColorPickerOutput, - ColorPickerSelection, - ColorPicker as ShadcnColorPicker, -} from "./color-picker-index" - -interface ColorPickerComponentProps { - disabled?: boolean - onColorSelect: (color: string) => void - selectedColor?: string -} - -const presetColors = [ - { name: "Red", hex: "#ef4444" }, - { name: "Orange", hex: "#f97316" }, - { name: "Yellow", hex: "#eab308" }, - { name: "Green", hex: "#22c55e" }, - { name: "Blue", hex: "#3b82f6" }, - { name: "Indigo", hex: "#6366f1" }, - { name: "Purple", hex: "#a855f7" }, - { name: "Pink", hex: "#ec4899" }, - { name: "Teal", hex: "#14b8a6" }, - { name: "Cyan", hex: "#06b6d4" }, -] - -export function ColorPickerComponent({ - disabled, - onColorSelect, - selectedColor, -}: ColorPickerComponentProps) { - // Start with a vibrant blue so it's clearly not white - const [displayColor, setDisplayColor] = useState("rgb(59, 130, 246)") - const customColorRef = useRef("#3b82f6") - - const handleCustomColorChange = (value: any) => { - let hexColor = "#000000" - - if (typeof value === "string") { - hexColor = value - } else if (Array.isArray(value)) { - // Extract RGB values (ignore alpha - it's the 4th element and can be NaN) - const [r, g, b] = value - - // Ensure RGB values are valid numbers, clamp to 0-255 range - const rValue = Math.max(0, Math.min(255, Math.round(r || 0))) - const gValue = Math.max(0, Math.min(255, Math.round(g || 0))) - const bValue = Math.max(0, Math.min(255, Math.round(b || 0))) - - hexColor = `#${rValue.toString(16).padStart(2, "0")}${gValue.toString(16).padStart(2, "0")}${bValue.toString(16).padStart(2, "0")}` - } - - // Update ref with the current color - customColorRef.current = hexColor - // Update display - setDisplayColor(hexColor) - } - - const handleApplyCustomColor = () => { - // Send the hex color directly (we'll handle it as inline style) - const hexColor = customColorRef.current - onColorSelect(hexColor) - } - - return ( - - - - - - - - Preset Colors - Custom Color - - - -

Text Colors

-
- {presetColors.map((color) => ( - - ))} -
-
- - -

Custom Color Picker

- - -
- -
- - -
-
-
- - -
-
- -
-
- {displayColor} -
- - - - - ) -} diff --git a/apps/web/src/components/ui/rich-editor/command-menu.tsx b/apps/web/src/components/ui/rich-editor/command-menu.tsx deleted file mode 100644 index c0b4a407..00000000 --- a/apps/web/src/components/ui/rich-editor/command-menu.tsx +++ /dev/null @@ -1,512 +0,0 @@ -"use client" - -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { - Code, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - Image, - List, - ListOrdered, - Quote, - Table, - Type, - Video, -} from "lucide-react" - -import { EditorActions } from "." -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "../command" -import { Popover, PopoverAnchor, PopoverContent } from "../popover" -import { useEditorDispatch } from "./store/editor-store" -import { useTranslations } from "next-intl" - -export interface CommandOption { - label: string - value: string - icon: React.ReactNode - description?: string - keywords?: string[] -} - -interface CommandMenuProps { - isOpen: boolean - onClose: () => void - onSelect: (value: string) => void - anchorElement: HTMLElement | null - nodeId: string // ID of the block being transformed - onUploadImage?: (file: File) => Promise // Custom image upload handler -} - -export function CommandMenu({ - isOpen, - onClose, - onSelect, - anchorElement, - nodeId, - onUploadImage, -}: CommandMenuProps) { - const [selectedIndex, setSelectedIndex] = useState(0) - const [search, setSearch] = useState("") - const [isUploading, setIsUploading] = useState(false) - const commandRef = useRef(null) - - const dispatch = useEditorDispatch() - const t = useTranslations("RichEditor") - const tMedia = useTranslations("RichEditor.media") - const tToasts = useTranslations("RichEditor.toasts") - - const commands = useMemo( - () => [ - { - label: t("command.h1.label"), - value: "h1", - icon: , - description: t("command.h1.description"), - keywords: t("command.h1.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.h2.label"), - value: "h2", - icon: , - description: t("command.h2.description"), - keywords: t("command.h2.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.h3.label"), - value: "h3", - icon: , - description: t("command.h3.description"), - keywords: t("command.h3.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.h4.label"), - value: "h4", - icon: , - description: t("command.h4.description"), - keywords: t("command.h4.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.h5.label"), - value: "h5", - icon: , - description: t("command.h5.description"), - keywords: t("command.h5.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.h6.label"), - value: "h6", - icon: , - description: t("command.h6.description"), - keywords: t("command.h6.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.p.label"), - value: "p", - icon: , - description: t("command.p.description"), - keywords: t("command.p.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.code.label"), - value: "code", - icon: , - description: t("command.code.description"), - keywords: t("command.code.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.blockquote.label"), - value: "blockquote", - icon: , - description: t("command.blockquote.description"), - keywords: t("command.blockquote.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.li.label"), - value: "li", - icon: , - description: t("command.li.description"), - keywords: t("command.li.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.ol.label"), - value: "ol", - icon: , - description: t("command.ol.description"), - keywords: t("command.ol.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.img.label"), - value: "img", - icon: , - description: t("command.img.description"), - keywords: t("command.img.keywords").split(",").map((s) => s.trim()), - }, - { - label: t("command.video.label"), - value: "video", - icon:
-
-
- ) -} diff --git a/apps/web/src/components/ui/rich-editor/table-dialog.tsx b/apps/web/src/components/ui/rich-editor/table-dialog.tsx deleted file mode 100644 index 384ee00b..00000000 --- a/apps/web/src/components/ui/rich-editor/table-dialog.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client" - -import React, { useState } from "react" -import { AlertCircle, Table } from "lucide-react" -import { useTranslations } from "next-intl" - -import { Button } from "../button" -import { Checkbox } from "../checkbox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../dialog" -import { Input } from "../input" -import { Label } from "../label" -import { Textarea } from "../textarea" -import { StructuralNode } from "./types" -import { - isMarkdownTable, - parseMarkdownTable, -} from "./utils/markdown-table-parser" - -interface TableDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - onCreateTable: (rows: number, cols: number) => void - onImportMarkdown: (table: StructuralNode) => void -} - -export function TableDialog({ - open, - onOpenChange, - onCreateTable, - onImportMarkdown, -}: TableDialogProps) { - const t = useTranslations("RichEditor.tableDialog") - const tParse = useTranslations("RichEditor.tableParse.errors") - const [rows, setRows] = useState(3) - const [cols, setCols] = useState(3) - const [useMarkdown, setUseMarkdown] = useState(false) - const [markdownText, setMarkdownText] = useState("") - const [error, setError] = useState(null) - - const handleCreate = () => { - if (useMarkdown) { - // Parse and import markdown - const result = parseMarkdownTable(markdownText) - if (result.success && result.table) { - onImportMarkdown(result.table) - onOpenChange(false) - // Reset - setMarkdownText("") - setUseMarkdown(false) - setError(null) - } else if (!result.success) { - const { errorKey, params } = result - if (errorKey === "rowColumnMismatch" && params) { - setError( - tParse(errorKey, { - row: params.row!, - got: params.got!, - expected: params.expected!, - }) - ) - } else { - setError(tParse(errorKey)) - } - } - } else { - // Create empty table - if (rows > 0 && cols > 0 && rows <= 20 && cols <= 10) { - onCreateTable(rows, cols) - onOpenChange(false) - // Reset to defaults - setRows(3) - setCols(3) - setError(null) - } - } - } - - const handleMarkdownChange = (value: string) => { - setMarkdownText(value) - setError(null) - } - - return ( - - - - - - {t("title")} - - {t("description")} - -
- {/* Markdown checkbox */} -
- { - setUseMarkdown(checked as boolean) - setError(null) - }} - /> - -
- - {useMarkdown ? ( - <> - {/* Markdown input */} -
- -