feat(web): add rich DOCX/TXT workspace editor
This commit is contained in:
parent
da440735d4
commit
aaad832cc6
856
apps/web/app/components/workspace/rich-document-editor.tsx
Normal file
856
apps/web/app/components/workspace/rich-document-editor.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">") || "<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 — 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)" }}>×</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>
|
||||
);
|
||||
}
|
||||
@ -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
13
apps/web/html-to-docx.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user