From aaad832cc6b1605825340c333af79d050c420a93 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:14:48 -0800 Subject: [PATCH] feat(web): add rich DOCX/TXT workspace editor --- .../workspace/rich-document-editor.tsx | 856 ++++++++++++++++++ apps/web/app/globals.css | 568 +++++++++++- apps/web/html-to-docx.d.ts | 13 + 3 files changed, 1403 insertions(+), 34 deletions(-) create mode 100644 apps/web/app/components/workspace/rich-document-editor.tsx create mode 100644 apps/web/html-to-docx.d.ts diff --git a/apps/web/app/components/workspace/rich-document-editor.tsx b/apps/web/app/components/workspace/rich-document-editor.tsx new file mode 100644 index 00000000000..33fe1632d05 --- /dev/null +++ b/apps/web/app/components/workspace/rich-document-editor.tsx @@ -0,0 +1,856 @@ +"use client"; + +import { useEditor, EditorContent } from "@tiptap/react"; +import { BubbleMenu } from "@tiptap/react/menus"; +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import Link from "@tiptap/extension-link"; +import { Table } from "@tiptap/extension-table"; +import TableRow from "@tiptap/extension-table-row"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import TaskList from "@tiptap/extension-task-list"; +import TaskItem from "@tiptap/extension-task-item"; +import Placeholder from "@tiptap/extension-placeholder"; +import Underline from "@tiptap/extension-underline"; +import TextAlign from "@tiptap/extension-text-align"; +import { TextStyle } from "@tiptap/extension-text-style"; +import Color from "@tiptap/extension-color"; +import Highlight from "@tiptap/extension-highlight"; +import Superscript from "@tiptap/extension-superscript"; +import Subscript from "@tiptap/extension-subscript"; +import CharacterCount from "@tiptap/extension-character-count"; +import { useState, useCallback, useEffect, useRef } from "react"; + +import { + ToolbarGroup, + ToolbarDivider, + ToolbarButton, + BubbleButton, +} from "./editor-toolbar-primitives"; + +// --- Types --- + +export type RichDocumentEditorProps = { + mode: "docx" | "txt"; + initialHtml: string; + filePath: string; + onSave?: () => void; + /** Compact mode for sidebar preview rendering */ + compact?: boolean; +}; + +// --- Helpers --- + +function isDocxFile(name: string): boolean { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + return ext === "docx" || ext === "doc"; +} + +function isTxtFile(name: string): boolean { + return name.split(".").pop()?.toLowerCase() === "txt"; +} + +export { isDocxFile, isTxtFile }; + +/** Convert plain text into simple HTML paragraphs for Tiptap */ +export function textToHtml(text: string): string { + if (!text.trim()) {return "

";} + return text + .split("\n") + .map((line) => `

${line.replace(/&/g, "&").replace(//g, ">") || "
"}

`) + .join(""); +} + +// --- Main component --- + +export function RichDocumentEditor({ + mode, + initialHtml, + filePath, + onSave, + compact, +}: RichDocumentEditorProps) { + const [saving, setSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); + const [isDirty, setIsDirty] = useState(false); + const saveTimerRef = useRef | null>(null); + const imageInputRef = useRef(null); + + const filename = filePath.split("/").pop() ?? filePath; + const isTxt = mode === "txt"; + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + codeBlock: { HTMLAttributes: { class: "code-block" } }, + }), + Underline, + Superscript, + Subscript, + TextStyle, + Color, + Highlight.configure({ multicolor: true }), + TextAlign.configure({ types: ["heading", "paragraph"] }), + Image.configure({ + inline: false, + allowBase64: true, + HTMLAttributes: { class: "editor-image" }, + }), + Link.configure({ + openOnClick: false, + autolink: true, + HTMLAttributes: { class: "editor-link", rel: "noopener" }, + }), + Table.configure({ resizable: false }), + TableRow, + TableCell, + TableHeader, + TaskList, + TaskItem.configure({ nested: true }), + Placeholder.configure({ + placeholder: isTxt + ? "Start typing..." + : "Start writing your document...", + }), + CharacterCount, + ], + content: initialHtml, + immediatelyRender: false, + onUpdate: () => { + setIsDirty(true); + setSaveStatus("idle"); + }, + }); + + // --- Image upload --- + const uploadImage = useCallback(async (file: File): Promise => { + const form = new FormData(); + form.append("file", file); + try { + const res = await fetch("/api/workspace/upload", { method: "POST", body: form }); + if (!res.ok) {return null;} + const data = await res.json(); + return `/api/workspace/assets/${(data.path as string).replace(/^assets\//, "")}`; + } catch { + return null; + } + }, []); + + const insertUploadedImages = useCallback( + async (files: File[]) => { + if (!editor) {return;} + for (const file of files) { + const url = await uploadImage(file); + if (url) {editor.chain().focus().setImage({ src: url, alt: file.name }).run();} + } + }, + [editor, uploadImage], + ); + + // --- Drop & paste handlers for images --- + useEffect(() => { + if (!editor) {return;} + const dom = editor.view.dom; + + const handleDrop = (e: DragEvent) => { + if (!e.dataTransfer?.files?.length) {return;} + const imgs = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/")); + if (imgs.length === 0) {return;} + e.preventDefault(); + e.stopPropagation(); + void insertUploadedImages(imgs); + }; + + const handleDragOver = (e: DragEvent) => { + if (e.dataTransfer?.types?.includes("Files")) {e.preventDefault();} + }; + + const handlePaste = (e: ClipboardEvent) => { + if (!e.clipboardData) {return;} + const imgs = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/")); + if (imgs.length > 0) { + e.preventDefault(); + e.stopPropagation(); + void insertUploadedImages(imgs); + } + }; + + dom.addEventListener("drop", handleDrop); + dom.addEventListener("dragover", handleDragOver); + dom.addEventListener("paste", handlePaste); + return () => { + dom.removeEventListener("drop", handleDrop); + dom.removeEventListener("dragover", handleDragOver); + dom.removeEventListener("paste", handlePaste); + }; + }, [editor, insertUploadedImages]); + + // --- Save --- + const handleSave = useCallback(async () => { + if (!editor || saving) {return;} + setSaving(true); + setSaveStatus("idle"); + + try { + if (isTxt) { + const text = editor.getText(); + const res = await fetch("/api/workspace/file", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: filePath, content: text }), + }); + if (!res.ok) {throw new Error("Save failed");} + } else { + const html = editor.getHTML(); + const { default: htmlToDocx } = await import("html-to-docx"); + const docxBlob = await htmlToDocx(html, undefined, { + table: { row: { cantSplit: true } }, + footer: true, + pageNumber: true, + }); + + const formData = new FormData(); + formData.append("file", docxBlob); + formData.append("path", filePath); + const res = await fetch("/api/workspace/write-binary", { + method: "POST", + body: formData, + }); + if (!res.ok) {throw new Error("Save failed");} + } + + setSaveStatus("saved"); + setIsDirty(false); + onSave?.(); + if (saveTimerRef.current) {clearTimeout(saveTimerRef.current);} + saveTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2000); + } catch { + setSaveStatus("error"); + } finally { + setSaving(false); + } + }, [editor, filePath, saving, isTxt, onSave]); + + // Cmd/Ctrl+S + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "s") { + e.preventDefault(); + void handleSave(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleSave]); + + if (!editor) { + return ( +
+
+
+
+
+ ); + } + + const wordCount = editor.storage.characterCount?.words() ?? 0; + const charCount = editor.storage.characterCount?.characters() ?? 0; + + return ( +
+ {/* Top bar */} +
+
+ + {filename} + {isDirty && Unsaved changes} + {saveStatus === "saved" && !isDirty && ( + Saved + )} + {saveStatus === "error" && ( + Save failed + )} +
+
+ + {typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S + + +
+
+ + {/* Toolbar (hidden for TXT in compact) */} + {!isTxt && ( + + )} + {isTxt && !compact && ( +
+ + editor.chain().focus().undo().run()} title="Undo" disabled={!editor.can().undo()}> + + + editor.chain().focus().redo().run()} title="Redo" disabled={!editor.can().redo()}> + + + +
+ Plain text — formatting not preserved on save +
+ )} + + {/* Bubble menu */} + {!isTxt && ( + +
+ editor.chain().focus().toggleBold().run()} title="Bold"> + B + + editor.chain().focus().toggleItalic().run()} title="Italic"> + I + + editor.chain().focus().toggleUnderline().run()} title="Underline"> + U + + editor.chain().focus().toggleStrike().run()} title="Strikethrough"> + S + + editor.chain().focus().toggleCode().run()} title="Inline code"> + {"<>"} + + { + if (editor.isActive("link")) { + editor.chain().focus().unsetLink().run(); + } else { + const url = window.prompt("URL:"); + if (url) {editor.chain().focus().setLink({ href: url }).run();} + } + }} + title="Link" + > + + +
+
+ )} + + {/* Editor content area -- page-like layout */} +
+
+ +
+
+ + {/* Status bar */} + {!compact && ( +
+ {wordCount.toLocaleString()} word{wordCount !== 1 ? "s" : ""} + + {charCount.toLocaleString()} character{charCount !== 1 ? "s" : ""} +
+ {mode === "docx" ? "DOCX" : "TXT"} +
+ )} + + {/* Hidden file input for image upload */} + { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) {void insertUploadedImages(files);} + e.target.value = ""; + }} + /> +
+ ); +} + +// --- Rich toolbar --- + +function RichToolbar({ + editor, + onUploadImages, + imageInputRef, +}: { + editor: NonNullable>; + onUploadImages?: (files: File[]) => void; + imageInputRef: React.RefObject; +}) { + const [showColorPicker, setShowColorPicker] = useState(false); + const [showHighlightPicker, setShowHighlightPicker] = useState(false); + const colorRef = useRef(null); + const highlightRef = useRef(null); + + // Close popups on outside click + useEffect(() => { + const handle = (e: MouseEvent) => { + if (colorRef.current && !colorRef.current.contains(e.target as Node)) {setShowColorPicker(false);} + if (highlightRef.current && !highlightRef.current.contains(e.target as Node)) {setShowHighlightPicker(false);} + }; + document.addEventListener("mousedown", handle); + return () => document.removeEventListener("mousedown", handle); + }, []); + + return ( +
+ {/* Undo / Redo */} + + editor.chain().focus().undo().run()} title="Undo" disabled={!editor.can().undo()}> + + + editor.chain().focus().redo().run()} title="Redo" disabled={!editor.can().redo()}> + + + + + + + {/* Paragraph style */} + + + + + + + {/* Inline formatting */} + + editor.chain().focus().toggleBold().run()} title="Bold (Cmd+B)"> + B + + editor.chain().focus().toggleItalic().run()} title="Italic (Cmd+I)"> + I + + editor.chain().focus().toggleUnderline().run()} title="Underline (Cmd+U)"> + U + + editor.chain().focus().toggleStrike().run()} title="Strikethrough"> + S + + editor.chain().focus().toggleSuperscript().run()} title="Superscript"> + X2 + + editor.chain().focus().toggleSubscript().run()} title="Subscript"> + X2 + + + + + + {/* Text color / Highlight */} + +
+ { setShowColorPicker(!showColorPicker); setShowHighlightPicker(false); }} + title="Text color" + > + + + {showColorPicker && ( + { + if (c) {editor.chain().focus().setColor(c).run();} + else {editor.chain().focus().unsetColor().run();} + setShowColorPicker(false); + }} + /> + )} +
+
+ { setShowHighlightPicker(!showHighlightPicker); setShowColorPicker(false); }} + title="Highlight color" + > + + + {showHighlightPicker && ( + { + if (c) {editor.chain().focus().toggleHighlight({ color: c }).run();} + else {editor.chain().focus().unsetHighlight().run();} + setShowHighlightPicker(false); + }} + /> + )} +
+
+ + + + {/* Alignment */} + + editor.chain().focus().setTextAlign("left").run()} title="Align left"> + + + editor.chain().focus().setTextAlign("center").run()} title="Align center"> + + + editor.chain().focus().setTextAlign("right").run()} title="Align right"> + + + editor.chain().focus().setTextAlign("justify").run()} title="Justify"> + + + + + + + {/* Lists */} + + editor.chain().focus().toggleBulletList().run()} title="Bullet list"> + + + editor.chain().focus().toggleOrderedList().run()} title="Ordered list"> + + + editor.chain().focus().toggleTaskList().run()} title="Task list"> + + + + + + + {/* Blocks */} + + editor.chain().focus().toggleBlockquote().run()} title="Blockquote"> + + + editor.chain().focus().toggleCodeBlock().run()} title="Code block"> + + + editor.chain().focus().setHorizontalRule().run()} + title="Horizontal rule" + > + + + + + + + {/* Insert: link, image, table */} + + { + if (editor.isActive("link")) { + editor.chain().focus().unsetLink().run(); + } else { + const url = window.prompt("Link URL:"); + if (url) {editor.chain().focus().setLink({ href: url }).run();} + } + }} + title="Insert link" + > + + + { + if (onUploadImages) {imageInputRef.current?.click();} + else { + const url = window.prompt("Image URL:"); + if (url) {editor.chain().focus().setImage({ src: url }).run();} + } + }} + title="Insert image" + > + + + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} + title="Insert table" + > + + + +
+ ); +} + +// --- Paragraph style dropdown --- + +const HEADING_OPTIONS = [ + { label: "Normal text", level: 0 }, + { label: "Heading 1", level: 1 }, + { label: "Heading 2", level: 2 }, + { label: "Heading 3", level: 3 }, + { label: "Heading 4", level: 4 }, + { label: "Heading 5", level: 5 }, + { label: "Heading 6", level: 6 }, +] as const; + +function ParagraphStyleDropdown({ editor }: { editor: NonNullable> }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handle = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) {setOpen(false);} + }; + document.addEventListener("mousedown", handle); + return () => document.removeEventListener("mousedown", handle); + }, []); + + const current = + HEADING_OPTIONS.find((h) => h.level > 0 && editor.isActive("heading", { level: h.level })) + ?? HEADING_OPTIONS[0]; + + return ( +
+ + {open && ( +
+ {HEADING_OPTIONS.map((opt) => ( + + ))} +
+ )} +
+ ); +} + +// --- Color palette popup --- + +const COLORS = [ + null, "#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#ffffff", + "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff", + "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", + "#dd7e6b", "#ea9999", "#f9cb9c", "#ffe599", "#b6d7a8", "#a2c4c9", "#a4c2f4", "#9fc5e8", "#b4a7d6", "#d5a6bd", + "#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0", +]; + +function ColorPalette({ onSelect }: { onSelect: (color: string | null) => void }) { + return ( +
+ {COLORS.map((c) => ( + + ))} +
+ ); +} + +// --- SVG icons (inlined to avoid external deps) --- + +function UndoIcon() { + return ( + + + + ); +} + +function RedoIcon() { + return ( + + + + ); +} + +function AlignLeftIcon() { + return ( + + + + ); +} + +function AlignCenterIcon() { + return ( + + + + ); +} + +function AlignRightIcon() { + return ( + + + + ); +} + +function AlignJustifyIcon() { + return ( + + + + ); +} + +function BulletListIcon() { + return ( + + + + + ); +} + +function OrderedListIcon() { + return ( + + + + + ); +} + +function TaskListIcon() { + return ( + + + + ); +} + +function BlockquoteIcon() { + return ( + + + + + ); +} + +function CodeBlockIcon() { + return ( + + + + ); +} + +function HorizontalRuleIcon() { + return ( + + + + ); +} + +function LinkIcon({ size = 14 }: { size?: number }) { + return ( + + + + + ); +} + +function ImageIcon() { + return ( + + + + ); +} + +function TableIcon() { + return ( + + + + ); +} + +function ChevronDownIcon() { + return ( + + + + ); +} + +function TextColorIcon({ color }: { color: string }) { + return ( + + + + + ); +} + +function HighlightColorIcon() { + return ( + + + + ); +} + +function DocIcon({ mode }: { mode: "docx" | "txt" }) { + if (mode === "txt") { + return ( + + + + + ); + } + return ( + + + + + ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 13a55077943..e9bab61f18c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -142,7 +142,9 @@ /* Disable iframe pointer events during sidebar resize so the drag isn't swallowed by embedded content (e.g. PDF viewer). */ -body.resizing iframe { pointer-events: none; } +body.resizing iframe { + pointer-events: none; +} /* ============================================================ Fonts — Bookerly (local) @@ -359,8 +361,8 @@ a, margin-bottom: 0.25em; } -.workspace-prose li > ul, -.workspace-prose li > ol { +.workspace-prose li>ul, +.workspace-prose li>ol { margin-top: 0.25em; margin-bottom: 0; } @@ -521,12 +523,7 @@ a, margin-top: 0.25em; } -.editor-content-area - .tiptap - ul[data-type="taskList"] - li - label - input[type="checkbox"] { +.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"] { appearance: none; width: 1em; height: 1em; @@ -536,22 +533,12 @@ a, position: relative; } -.editor-content-area - .tiptap - ul[data-type="taskList"] - li - label - input[type="checkbox"]:checked { +.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked { background: var(--color-accent); border-color: var(--color-accent); } -.editor-content-area - .tiptap - ul[data-type="taskList"] - li - label - input[type="checkbox"]:checked::after { +.editor-content-area .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked::after { content: ""; position: absolute; left: 3px; @@ -563,7 +550,7 @@ a, transform: rotate(45deg); } -.editor-content-area .tiptap ul[data-type="taskList"] li > div { +.editor-content-area .tiptap ul[data-type="taskList"] li>div { flex: 1; } @@ -771,6 +758,411 @@ a, cursor: not-allowed; } +/* ======================================================================== + Rich Document Editor (DOCX / TXT) + ======================================================================== */ + +.rich-doc-editor { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--color-bg); +} + +.rich-doc-editor--compact { + height: 100%; +} + +/* Top bar */ +.rich-doc-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + flex-shrink: 0; + gap: 0.75rem; +} + +.rich-doc-topbar-left { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.rich-doc-topbar-right { + display: flex; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; +} + +.rich-doc-filename { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Toolbar */ +.rich-doc-toolbar { + flex-wrap: wrap; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + padding: 0.35rem 0.75rem; +} + +.rich-doc-toolbar-minimal { + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + padding: 0.35rem 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.rich-doc-txt-hint { + font-size: 0.7rem; + color: var(--color-text-muted); + opacity: 0.7; +} + +/* Paragraph style dropdown */ +.rich-doc-style-dropdown { + display: flex; + align-items: center; + gap: 4px; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text); + background: transparent; + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: pointer; + min-width: 110px; + justify-content: space-between; + transition: background 0.1s; +} + +.rich-doc-style-dropdown:hover { + background: var(--color-surface-hover); +} + +.rich-doc-style-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + margin-top: 4px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + padding: 4px; + min-width: 180px; + max-height: 320px; + overflow-y: auto; +} + +.rich-doc-style-option { + display: block; + width: 100%; + text-align: left; + padding: 0.4rem 0.75rem; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + border-radius: 4px; + transition: background 0.1s; +} + +.rich-doc-style-option:hover { + background: var(--color-surface-hover); +} + +.rich-doc-style-option--active { + background: var(--color-accent-light); + color: var(--color-accent); +} + +/* Color palette */ +.rich-doc-color-palette { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + z-index: 50; + margin-top: 4px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + padding: 8px; + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 3px; + width: 228px; +} + +.rich-doc-color-swatch { + width: 18px; + height: 18px; + border-radius: 3px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.1s; +} + +.rich-doc-color-swatch:hover { + transform: scale(1.25); + z-index: 1; +} + +/* Scrollable editor area */ +.rich-doc-scroll { + flex: 1; + overflow-y: auto; + background: var(--color-bg); +} + +/* Page-like centered area */ +.rich-doc-page { + max-width: 816px; + margin: 2rem auto; + background: var(--color-main-bg, #fff); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 3rem 4rem; + min-height: calc(100vh - 200px); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.rich-doc-editor--compact .rich-doc-page { + margin: 0.5rem; + padding: 1.5rem 2rem; + min-height: auto; + border: none; + box-shadow: none; + max-width: none; +} + +.rich-doc-page--txt { + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + font-size: 0.875rem; + line-height: 1.7; +} + +.rich-doc-page .tiptap { + outline: none; + min-height: 200px; + font-size: 0.95rem; + line-height: 1.75; + color: var(--color-text); +} + +.rich-doc-page .tiptap:focus { + outline: none; +} + +.rich-doc-page .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--color-text-muted); + pointer-events: none; + height: 0; + opacity: 0.5; +} + +.rich-doc-page .tiptap h1 { + font-size: 2rem; + font-weight: 700; + margin: 1.5rem 0 0.75rem; + line-height: 1.2; +} + +.rich-doc-page .tiptap h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 1.25rem 0 0.5rem; + line-height: 1.3; +} + +.rich-doc-page .tiptap h3 { + font-size: 1.25rem; + font-weight: 600; + margin: 1rem 0 0.5rem; + line-height: 1.35; +} + +.rich-doc-page .tiptap h4 { + font-size: 1.1rem; + font-weight: 600; + margin: 1rem 0 0.4rem; + line-height: 1.4; +} + +.rich-doc-page .tiptap h5 { + font-size: 1rem; + font-weight: 600; + margin: 0.8rem 0 0.3rem; +} + +.rich-doc-page .tiptap h6 { + font-size: 0.9rem; + font-weight: 600; + margin: 0.8rem 0 0.3rem; + color: var(--color-text-muted); +} + +.rich-doc-page .tiptap p { + margin: 0.4rem 0; +} + +.rich-doc-page .tiptap ul, +.rich-doc-page .tiptap ol { + padding-left: 1.5rem; + margin: 0.5rem 0; +} + +.rich-doc-page .tiptap blockquote { + border-left: 3px solid var(--color-accent); + padding-left: 1rem; + margin: 0.75rem 0; + color: var(--color-text-muted); +} + +.rich-doc-page .tiptap pre { + background: var(--color-surface); + border-radius: 6px; + padding: 0.75rem 1rem; + font-size: 0.85rem; + overflow-x: auto; + margin: 0.75rem 0; +} + +.rich-doc-page .tiptap code { + background: var(--color-surface); + border-radius: 3px; + padding: 0.15rem 0.3rem; + font-size: 0.85em; +} + +.rich-doc-page .tiptap pre code { + background: transparent; + padding: 0; +} + +.rich-doc-page .tiptap hr { + border: none; + border-top: 1px solid var(--color-border); + margin: 1.5rem 0; +} + +.rich-doc-page .tiptap mark { + border-radius: 2px; + padding: 0 2px; +} + +/* Tables */ +.rich-doc-page .tiptap table { + border-collapse: collapse; + width: 100%; + margin: 1em 0; +} + +.rich-doc-page .tiptap th, +.rich-doc-page .tiptap td { + border: 1px solid var(--color-border); + padding: 0.5em 0.75em; + position: relative; +} + +.rich-doc-page .tiptap th { + background: var(--color-surface-hover); + font-weight: 600; +} + +.rich-doc-page .tiptap .selectedCell { + background: var(--color-accent-light); +} + +/* Task list */ +.rich-doc-page .tiptap ul[data-type="taskList"] { + list-style: none; + padding-left: 0; +} + +.rich-doc-page .tiptap ul[data-type="taskList"] li { + display: flex; + align-items: flex-start; + gap: 0.5em; +} + +.rich-doc-page .tiptap ul[data-type="taskList"] li label { + flex-shrink: 0; + margin-top: 0.25em; +} + +.rich-doc-page .tiptap ul[data-type="taskList"] li>div { + flex: 1; +} + +/* Images */ +.rich-doc-page .tiptap .editor-image { + max-width: 100%; + height: auto; + border-radius: 0.5rem; + margin: 0.75rem 0; +} + +.rich-doc-page .tiptap .editor-image.ProseMirror-selectednode { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* Links */ +.rich-doc-page .tiptap a { + color: var(--color-accent); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +/* Status bar */ +.rich-doc-statusbar { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 1rem; + border-top: 1px solid var(--color-border); + background: var(--color-surface); + font-size: 0.7rem; + color: var(--color-text-muted); + flex-shrink: 0; +} + +.rich-doc-statusbar-sep { + width: 1px; + height: 0.75rem; + background: var(--color-border); +} + +.rich-doc-statusbar-mode { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; +} + /* --- Edit / Read mode toggle --- */ .editor-mode-toggle { @@ -904,11 +1296,11 @@ a, font-family: "Bookerly", Georgia, "Times New Roman", serif; } -.chat-prose > *:first-child { +.chat-prose>*:first-child { margin-top: 0; } -.chat-prose > *:last-child { +.chat-prose>*:last-child { margin-bottom: 0; } @@ -987,12 +1379,12 @@ a, margin-bottom: 0.2em; } -.chat-prose li > p { +.chat-prose li>p { margin-bottom: 0.25em; } -.chat-prose li > ul, -.chat-prose li > ol { +.chat-prose li>ul, +.chat-prose li>ol { margin-top: 0.2em; margin-bottom: 0; } @@ -1254,6 +1646,7 @@ a, } @media (prefers-color-scheme: light) { + .shiki, .shiki span { color: var(--shiki-light) !important; @@ -1299,18 +1692,33 @@ a, } @keyframes drawer-fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } @keyframes drawer-slide-left { - from { transform: translateX(-100%); } - to { transform: translateX(0); } + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } } @keyframes drawer-slide-right { - from { transform: translateX(100%); } - to { transform: translateX(0); } + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } } .drawer-left { @@ -1322,6 +1730,98 @@ a, } /* Prevent horizontal overflow on mobile */ -html, body { +html, +body { overflow-x: hidden; } + +/* ============================================================ + Spreadsheet Editor — react-spreadsheet theme overrides + ============================================================ */ + +.spreadsheet-editor-grid.Spreadsheet { + --background-color: var(--color-bg); + --text-color: var(--color-text); + --readonly-text-color: var(--color-text-muted); + --outline-color: var(--color-accent); + --outline-background-color: var(--color-accent-light); + --border-color: var(--color-border); + --header-background-color: var(--color-surface); + --elevation: var(--shadow-md); + + display: block; + width: 100%; + min-width: 100%; + font-size: 13px; + font-family: inherit; +} + +.spreadsheet-editor-grid.Spreadsheet--dark-mode { + --background-color: var(--color-bg); + --text-color: var(--color-text); + --readonly-text-color: var(--color-text-muted); + --border-color: var(--color-border); + --header-background-color: var(--color-surface); +} + +.spreadsheet-editor-grid .Spreadsheet__table { + width: 100%; + table-layout: auto; +} + +.spreadsheet-editor-grid .Spreadsheet__cell, +.spreadsheet-editor-grid .Spreadsheet__header { + min-width: 80px; + min-height: 28px; + height: 28px; + max-height: 28px; + padding: 4px 8px; + font-size: 12px; + border-color: var(--color-border); +} + +.spreadsheet-editor-grid .Spreadsheet__header { + font-size: 11px; + font-weight: 500; + color: var(--color-text-muted); + background: var(--color-surface); + position: sticky; + top: 0; + z-index: 2; +} + +.spreadsheet-editor-grid .Spreadsheet__header--selected { + background: var(--color-accent); + color: #fff; +} + +.spreadsheet-editor-grid .Spreadsheet__active-cell { + border-color: var(--color-accent); + border-width: 2px; +} + +.spreadsheet-editor-grid .Spreadsheet__active-cell--edit { + background: var(--color-bg); + box-shadow: var(--shadow-md); +} + +.spreadsheet-editor-grid .Spreadsheet__floating-rect--selected { + background: var(--color-accent-light); + border-color: var(--color-accent); +} + +.spreadsheet-editor-grid .Spreadsheet__floating-rect--copied { + border-color: var(--color-accent); +} + +.spreadsheet-editor-grid .Spreadsheet__data-editor input { + font-size: 12px; + font-family: inherit; + color: var(--color-text); + padding: 4px 8px; +} + +.spreadsheet-editor-grid .Spreadsheet__data-viewer, +.spreadsheet-editor-grid .Spreadsheet__data-editor input { + padding: 4px 8px; +} diff --git a/apps/web/html-to-docx.d.ts b/apps/web/html-to-docx.d.ts new file mode 100644 index 00000000000..f99b0613a04 --- /dev/null +++ b/apps/web/html-to-docx.d.ts @@ -0,0 +1,13 @@ +declare module "html-to-docx" { + interface HtmlToDocxOptions { + table?: { row?: { cantSplit?: boolean } }; + footer?: boolean; + pageNumber?: boolean; + [key: string]: unknown; + } + export default function htmlToDocx( + htmlString: string, + headerHtml?: string | null, + options?: HtmlToDocxOptions, + ): Promise; +}