feat(web): add rich DOCX/TXT workspace editor

This commit is contained in:
kumarabhirup 2026-03-04 11:14:48 -08:00
parent da440735d4
commit aaad832cc6
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
3 changed files with 1403 additions and 34 deletions

View File

@ -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 "<p></p>";}
return text
.split("\n")
.map((line) => `<p>${line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") || "<br>"}</p>`)
.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<ReturnType<typeof setTimeout> | null>(null);
const imageInputRef = useRef<HTMLInputElement>(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<string | null> => {
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 (
<div className="animate-pulse space-y-3 py-4 px-6">
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
</div>
);
}
const wordCount = editor.storage.characterCount?.words() ?? 0;
const charCount = editor.storage.characterCount?.characters() ?? 0;
return (
<div className={`rich-doc-editor ${compact ? "rich-doc-editor--compact" : ""}`}>
{/* Top bar */}
<div className="rich-doc-topbar">
<div className="rich-doc-topbar-left">
<DocIcon mode={mode} />
<span className="rich-doc-filename">{filename}</span>
{isDirty && <span className="editor-save-indicator editor-save-unsaved">Unsaved changes</span>}
{saveStatus === "saved" && !isDirty && (
<span className="editor-save-indicator editor-save-saved">Saved</span>
)}
{saveStatus === "error" && (
<span className="editor-save-indicator editor-save-error">Save failed</span>
)}
</div>
<div className="rich-doc-topbar-right">
<span className="editor-save-hint">
{typeof navigator !== "undefined" && navigator.platform?.includes("Mac") ? "\u2318" : "Ctrl"}+S
</span>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving || !isDirty}
className="editor-save-button"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
{/* Toolbar (hidden for TXT in compact) */}
{!isTxt && (
<RichToolbar editor={editor} onUploadImages={insertUploadedImages} imageInputRef={imageInputRef} />
)}
{isTxt && !compact && (
<div className="editor-toolbar rich-doc-toolbar-minimal">
<ToolbarGroup>
<ToolbarButton active={false} onClick={() => editor.chain().focus().undo().run()} title="Undo" disabled={!editor.can().undo()}>
<UndoIcon />
</ToolbarButton>
<ToolbarButton active={false} onClick={() => editor.chain().focus().redo().run()} title="Redo" disabled={!editor.can().redo()}>
<RedoIcon />
</ToolbarButton>
</ToolbarGroup>
<div className="flex-1" />
<span className="rich-doc-txt-hint">Plain text &mdash; formatting not preserved on save</span>
</div>
)}
{/* Bubble menu */}
{!isTxt && (
<BubbleMenu editor={editor}>
<div className="bubble-menu">
<BubbleButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()} title="Bold">
<strong>B</strong>
</BubbleButton>
<BubbleButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()} title="Italic">
<em>I</em>
</BubbleButton>
<BubbleButton active={editor.isActive("underline")} onClick={() => editor.chain().focus().toggleUnderline().run()} title="Underline">
<span style={{ textDecoration: "underline" }}>U</span>
</BubbleButton>
<BubbleButton active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()} title="Strikethrough">
<s>S</s>
</BubbleButton>
<BubbleButton active={editor.isActive("code")} onClick={() => editor.chain().focus().toggleCode().run()} title="Inline code">
{"<>"}
</BubbleButton>
<BubbleButton
active={editor.isActive("link")}
onClick={() => {
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"
>
<LinkIcon size={14} />
</BubbleButton>
</div>
</BubbleMenu>
)}
{/* Editor content area -- page-like layout */}
<div className="rich-doc-scroll">
<div className={`rich-doc-page ${isTxt ? "rich-doc-page--txt" : ""}`}>
<EditorContent editor={editor} />
</div>
</div>
{/* Status bar */}
{!compact && (
<div className="rich-doc-statusbar">
<span>{wordCount.toLocaleString()} word{wordCount !== 1 ? "s" : ""}</span>
<span className="rich-doc-statusbar-sep" />
<span>{charCount.toLocaleString()} character{charCount !== 1 ? "s" : ""}</span>
<div className="flex-1" />
<span className="rich-doc-statusbar-mode">{mode === "docx" ? "DOCX" : "TXT"}</span>
</div>
)}
{/* Hidden file input for image upload */}
<input
ref={imageInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) {void insertUploadedImages(files);}
e.target.value = "";
}}
/>
</div>
);
}
// --- Rich toolbar ---
function RichToolbar({
editor,
onUploadImages,
imageInputRef,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>;
onUploadImages?: (files: File[]) => void;
imageInputRef: React.RefObject<HTMLInputElement | null>;
}) {
const [showColorPicker, setShowColorPicker] = useState(false);
const [showHighlightPicker, setShowHighlightPicker] = useState(false);
const colorRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(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 (
<div className="editor-toolbar rich-doc-toolbar">
{/* Undo / Redo */}
<ToolbarGroup>
<ToolbarButton active={false} onClick={() => editor.chain().focus().undo().run()} title="Undo" disabled={!editor.can().undo()}>
<UndoIcon />
</ToolbarButton>
<ToolbarButton active={false} onClick={() => editor.chain().focus().redo().run()} title="Redo" disabled={!editor.can().redo()}>
<RedoIcon />
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Paragraph style */}
<ToolbarGroup>
<ParagraphStyleDropdown editor={editor} />
</ToolbarGroup>
<ToolbarDivider />
{/* Inline formatting */}
<ToolbarGroup>
<ToolbarButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()} title="Bold (Cmd+B)">
<strong>B</strong>
</ToolbarButton>
<ToolbarButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()} title="Italic (Cmd+I)">
<em>I</em>
</ToolbarButton>
<ToolbarButton active={editor.isActive("underline")} onClick={() => editor.chain().focus().toggleUnderline().run()} title="Underline (Cmd+U)">
<span style={{ textDecoration: "underline" }}>U</span>
</ToolbarButton>
<ToolbarButton active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()} title="Strikethrough">
<s>S</s>
</ToolbarButton>
<ToolbarButton active={editor.isActive("superscript")} onClick={() => editor.chain().focus().toggleSuperscript().run()} title="Superscript">
<span>X<sup style={{ fontSize: "0.6em" }}>2</sup></span>
</ToolbarButton>
<ToolbarButton active={editor.isActive("subscript")} onClick={() => editor.chain().focus().toggleSubscript().run()} title="Subscript">
<span>X<sub style={{ fontSize: "0.6em" }}>2</sub></span>
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Text color / Highlight */}
<ToolbarGroup>
<div className="relative" ref={colorRef}>
<ToolbarButton
active={showColorPicker}
onClick={() => { setShowColorPicker(!showColorPicker); setShowHighlightPicker(false); }}
title="Text color"
>
<TextColorIcon color={editor.getAttributes("textStyle").color ?? "currentColor"} />
</ToolbarButton>
{showColorPicker && (
<ColorPalette
onSelect={(c) => {
if (c) {editor.chain().focus().setColor(c).run();}
else {editor.chain().focus().unsetColor().run();}
setShowColorPicker(false);
}}
/>
)}
</div>
<div className="relative" ref={highlightRef}>
<ToolbarButton
active={showHighlightPicker}
onClick={() => { setShowHighlightPicker(!showHighlightPicker); setShowColorPicker(false); }}
title="Highlight color"
>
<HighlightColorIcon />
</ToolbarButton>
{showHighlightPicker && (
<ColorPalette
onSelect={(c) => {
if (c) {editor.chain().focus().toggleHighlight({ color: c }).run();}
else {editor.chain().focus().unsetHighlight().run();}
setShowHighlightPicker(false);
}}
/>
)}
</div>
</ToolbarGroup>
<ToolbarDivider />
{/* Alignment */}
<ToolbarGroup>
<ToolbarButton active={editor.isActive({ textAlign: "left" })} onClick={() => editor.chain().focus().setTextAlign("left").run()} title="Align left">
<AlignLeftIcon />
</ToolbarButton>
<ToolbarButton active={editor.isActive({ textAlign: "center" })} onClick={() => editor.chain().focus().setTextAlign("center").run()} title="Align center">
<AlignCenterIcon />
</ToolbarButton>
<ToolbarButton active={editor.isActive({ textAlign: "right" })} onClick={() => editor.chain().focus().setTextAlign("right").run()} title="Align right">
<AlignRightIcon />
</ToolbarButton>
<ToolbarButton active={editor.isActive({ textAlign: "justify" })} onClick={() => editor.chain().focus().setTextAlign("justify").run()} title="Justify">
<AlignJustifyIcon />
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Lists */}
<ToolbarGroup>
<ToolbarButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()} title="Bullet list">
<BulletListIcon />
</ToolbarButton>
<ToolbarButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()} title="Ordered list">
<OrderedListIcon />
</ToolbarButton>
<ToolbarButton active={editor.isActive("taskList")} onClick={() => editor.chain().focus().toggleTaskList().run()} title="Task list">
<TaskListIcon />
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Blocks */}
<ToolbarGroup>
<ToolbarButton active={editor.isActive("blockquote")} onClick={() => editor.chain().focus().toggleBlockquote().run()} title="Blockquote">
<BlockquoteIcon />
</ToolbarButton>
<ToolbarButton active={editor.isActive("codeBlock")} onClick={() => editor.chain().focus().toggleCodeBlock().run()} title="Code block">
<CodeBlockIcon />
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal rule"
>
<HorizontalRuleIcon />
</ToolbarButton>
</ToolbarGroup>
<ToolbarDivider />
{/* Insert: link, image, table */}
<ToolbarGroup>
<ToolbarButton
active={editor.isActive("link")}
onClick={() => {
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"
>
<LinkIcon />
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => {
if (onUploadImages) {imageInputRef.current?.click();}
else {
const url = window.prompt("Image URL:");
if (url) {editor.chain().focus().setImage({ src: url }).run();}
}
}}
title="Insert image"
>
<ImageIcon />
</ToolbarButton>
<ToolbarButton
active={false}
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
title="Insert table"
>
<TableIcon />
</ToolbarButton>
</ToolbarGroup>
</div>
);
}
// --- 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<ReturnType<typeof useEditor>> }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
type="button"
className="rich-doc-style-dropdown"
onClick={() => setOpen(!open)}
title="Paragraph style"
>
<span>{current.label}</span>
<ChevronDownIcon />
</button>
{open && (
<div className="rich-doc-style-menu">
{HEADING_OPTIONS.map((opt) => (
<button
key={opt.level}
type="button"
className={`rich-doc-style-option ${opt.level === current.level ? "rich-doc-style-option--active" : ""}`}
onClick={() => {
if (opt.level === 0) {
editor.chain().focus().setParagraph().run();
} else {
editor.chain().focus().toggleHeading({ level: opt.level }).run();
}
setOpen(false);
}}
>
<span
style={{
fontSize: opt.level === 0 ? undefined : `${Math.max(0.75, 1.4 - opt.level * 0.1)}rem`,
fontWeight: opt.level > 0 ? 600 : 400,
}}
>
{opt.label}
</span>
</button>
))}
</div>
)}
</div>
);
}
// --- 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 (
<div className="rich-doc-color-palette">
{COLORS.map((c) => (
<button
key={c ?? "none"}
type="button"
className="rich-doc-color-swatch"
style={{
background: c ?? "transparent",
border: c ? "1px solid var(--color-border)" : "2px dashed var(--color-border)",
}}
onClick={() => onSelect(c)}
title={c ?? "Remove color"}
>
{!c && <span style={{ fontSize: "10px", color: "var(--color-text-muted)" }}>&times;</span>}
</button>
))}
</div>
);
}
// --- SVG icons (inlined to avoid external deps) ---
function UndoIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" />
</svg>
);
}
function RedoIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13" />
</svg>
);
}
function AlignLeftIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="17" x2="3" y1="10" y2="10" /><line x1="21" x2="3" y1="6" y2="6" /><line x1="21" x2="3" y1="14" y2="14" /><line x1="17" x2="3" y1="18" y2="18" />
</svg>
);
}
function AlignCenterIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" x2="6" y1="10" y2="10" /><line x1="21" x2="3" y1="6" y2="6" /><line x1="21" x2="3" y1="14" y2="14" /><line x1="18" x2="6" y1="18" y2="18" />
</svg>
);
}
function AlignRightIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="21" x2="7" y1="10" y2="10" /><line x1="21" x2="3" y1="6" y2="6" /><line x1="21" x2="3" y1="14" y2="14" /><line x1="21" x2="7" y1="18" y2="18" />
</svg>
);
}
function AlignJustifyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="21" x2="3" y1="10" y2="10" /><line x1="21" x2="3" y1="6" y2="6" /><line x1="21" x2="3" y1="14" y2="14" /><line x1="21" x2="3" y1="18" y2="18" />
</svg>
);
}
function BulletListIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="8" x2="21" y1="6" y2="6" /><line x1="8" x2="21" y1="12" y2="12" /><line x1="8" x2="21" y1="18" y2="18" />
<line x1="3" x2="3.01" y1="6" y2="6" /><line x1="3" x2="3.01" y1="12" y2="12" /><line x1="3" x2="3.01" y1="18" y2="18" />
</svg>
);
}
function OrderedListIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="10" x2="21" y1="6" y2="6" /><line x1="10" x2="21" y1="12" y2="12" /><line x1="10" x2="21" y1="18" y2="18" />
<path d="M4 6h1v4" /><path d="M4 10h2" /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" />
</svg>
);
}
function TaskListIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="6" height="6" rx="1" /><path d="m3 17 2 2 4-4" /><line x1="13" x2="21" y1="6" y2="6" /><line x1="13" x2="21" y1="18" y2="18" />
</svg>
);
}
function BlockquoteIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z" />
</svg>
);
}
function CodeBlockIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
);
}
function HorizontalRuleIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="2" x2="22" y1="12" y2="12" />
</svg>
);
}
function LinkIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
function ImageIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
function TableIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
);
}
function ChevronDownIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function TextColorIcon({ color }: { color: string }) {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 20h16" stroke={color} strokeWidth="3" />
<path d="m8.5 3 3.5 11 3.5-11" />
</svg>
);
}
function HighlightColorIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 11-6 6v3h9l3-3" /><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4" />
</svg>
);
}
function DocIcon({ mode }: { mode: "docx" | "txt" }) {
if (mode === "txt") {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<path d="M14 2v6h6" /><line x1="8" x2="16" y1="13" y2="13" /><line x1="8" x2="16" y1="17" y2="17" /><line x1="8" x2="10" y1="9" y2="9" />
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#2b579a" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<path d="M14 2v6h6" /><line x1="8" x2="16" y1="13" y2="13" /><line x1="8" x2="16" y1="17" y2="17" /><line x1="8" x2="10" y1="9" y2="9" />
</svg>
);
}

View File

@ -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;
}

13
apps/web/html-to-docx.d.ts vendored Normal file
View File

@ -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<Blob>;
}