diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 4de2d84..a3148a2 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,7 +1,13 @@ +import { useState, useCallback } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; -import { TextStyle, FontFamily, FontSize, Color } from "@tiptap/extension-text-style"; +import { + TextStyle, + FontFamily, + FontSize, + Color, +} from "@tiptap/extension-text-style"; import Highlight from "@tiptap/extension-highlight"; import TextAlign from "@tiptap/extension-text-align"; import Link from "@tiptap/extension-link"; @@ -11,30 +17,91 @@ import Toolbar from "./Toolbar"; import PageView from "./PageView"; function Editor() { - const editor = useEditor({ + const [isHeaderVisible, setIsHeaderVisible] = useState(false); + const [activeEditorType, setActiveEditorType] = useState<"body" | "header">( + "body" + ); + const [headerHtml, setHeaderHtml] = useState(""); + + const bodyEditor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3, 4] }, }), Link.configure({ openOnClick: false }), - Underline, - TextStyle, - FontFamily, - FontSize, - Color, + Underline.configure(), + TextStyle.configure(), + FontFamily.configure(), + FontSize.configure(), + Color.configure(), Highlight.configure({ multicolor: true }), TextAlign.configure({ types: ["heading", "paragraph"] }), Image.configure({ inline: true }), Placeholder.configure({ placeholder: "Start typing..." }), ], autofocus: true, + onFocus: () => setActiveEditorType("body"), + }); + + const headerEditor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: false, + bulletList: false, + orderedList: false, + listItem: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + }), + Link.configure({ openOnClick: false }), + Underline.configure(), + TextStyle.configure(), + FontFamily.configure(), + FontSize.configure(), + Color.configure(), + Highlight.configure({ multicolor: true }), + TextAlign.configure({ types: ["paragraph"] }), + Placeholder.configure({ placeholder: "Click here to add a header" }), + ], + onFocus: () => setActiveEditorType("header"), + onUpdate: ({ editor }) => { + setHeaderHtml(editor.getHTML()); + }, }); + const activeEditor = + activeEditorType === "header" && headerEditor ? headerEditor : bodyEditor; + + const toggleHeader = useCallback(() => { + setIsHeaderVisible((previous) => { + if (previous && activeEditorType === "header") { + setActiveEditorType("body"); + bodyEditor?.commands.focus(); + } + return !previous; + }); + }, [activeEditorType, bodyEditor]); + return ( <> - {editor && } - - + {activeEditor && ( + + )} + + ) + } + documentHeaderHtml={headerHtml} + isDocumentHeaderVisible={isHeaderVisible} + > + ); diff --git a/src/components/PageView.tsx b/src/components/PageView.tsx index b8aecc7..ba30c6d 100644 --- a/src/components/PageView.tsx +++ b/src/components/PageView.tsx @@ -1,4 +1,10 @@ -import { useRef, useState, useEffect, useCallback, type ReactNode } from "react"; +import { + useRef, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; const PAGE_WIDTH = 816; const HEADER_HEIGHT = 48; @@ -6,7 +12,7 @@ const FOOTER_HEIGHT = 48; const GAP_HEIGHT = 40; const PAGE_CONTENT_HEIGHT = 1056 - HEADER_HEIGHT - FOOTER_HEIGHT; const PAGE_HORIZONTAL_PADDING = 96; -const OVERLAY_HEIGHT = HEADER_HEIGHT + FOOTER_HEIGHT + GAP_HEIGHT; +const BASE_OVERLAY_HEIGHT = HEADER_HEIGHT + FOOTER_HEIGHT + GAP_HEIGHT; const PAGE_SHADOW = "0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)"; @@ -18,14 +24,20 @@ const pageNumberStyle = { interface PageViewProps { children: ReactNode; + documentHeaderSlot?: ReactNode; + documentHeaderHtml?: string; + isDocumentHeaderVisible?: boolean; } /** - * Finds the .tiptap element inside the content ref and adds margin-bottom + * Finds the .tiptap element inside the content ref and adds margin-top * to block elements that would straddle a page boundary, pushing them to * the next page. Returns the total number of pages. */ -function adjustBlockMarginsForPageBreaks(contentElement: HTMLElement): number { +function adjustBlockMarginsForPageBreaks( + contentElement: HTMLElement, + overlayHeight: number +): number { const tiptapElement = contentElement.querySelector(".tiptap"); if (!tiptapElement) return 1; @@ -47,17 +59,17 @@ function adjustBlockMarginsForPageBreaks(contentElement: HTMLElement): number { const blockTop = blockRect.top - contentTop; const blockBottom = blockRect.bottom - contentTop; - // Account for margins already added (each previous page break adds OVERLAY_HEIGHT) + // Account for margins already added (each previous page break adds overlayHeight) const previousBreaks = currentPage - 1; - const adjustedTop = blockTop - previousBreaks * OVERLAY_HEIGHT; - const adjustedBottom = blockBottom - previousBreaks * OVERLAY_HEIGHT; + const adjustedTop = blockTop - previousBreaks * overlayHeight; + const adjustedBottom = blockBottom - previousBreaks * overlayHeight; const pageBoundary = currentPage * PAGE_CONTENT_HEIGHT; // If this block crosses a page boundary if (adjustedTop < pageBoundary && adjustedBottom > pageBoundary) { // Add margin to push it to the next page's content area - const spaceNeeded = pageBoundary - adjustedTop + OVERLAY_HEIGHT; + const spaceNeeded = pageBoundary - adjustedTop + overlayHeight; block.style.marginTop = `${spaceNeeded}px`; block.dataset.pageMargin = "true"; currentPage++; @@ -70,27 +82,59 @@ function adjustBlockMarginsForPageBreaks(contentElement: HTMLElement): number { // Calculate total pages from the final content height const finalHeight = tiptapElement.getBoundingClientRect().height; const totalBreaks = currentPage - 1; - const adjustedHeight = finalHeight - totalBreaks * OVERLAY_HEIGHT; + const adjustedHeight = finalHeight - totalBreaks * overlayHeight; return Math.max(1, Math.ceil(adjustedHeight / PAGE_CONTENT_HEIGHT)); } -function PageView({ children }: PageViewProps) { +function PageView({ + children, + documentHeaderSlot, + documentHeaderHtml, + isDocumentHeaderVisible = false, +}: PageViewProps) { const contentRef = useRef(null); + const headerMeasureRef = useRef(null); const [totalPages, setTotalPages] = useState(1); + const [documentHeaderRenderedHeight, setDocumentHeaderRenderedHeight] = + useState(0); const isAdjustingRef = useRef(false); + const showHeader = isDocumentHeaderVisible && !!documentHeaderSlot; + const headerHeight = showHeader ? documentHeaderRenderedHeight : 0; + const effectiveOverlayHeight = BASE_OVERLAY_HEIGHT + headerHeight; + + // Measure document header height + useEffect(() => { + const element = headerMeasureRef.current; + if (!element || !showHeader) { + setDocumentHeaderRenderedHeight(0); + return; + } + const measureHeight = () => { + const height = element.getBoundingClientRect().height; + setDocumentHeaderRenderedHeight(height); + }; + measureHeight(); + const observer = new ResizeObserver(measureHeight); + observer.observe(element); + return () => observer.disconnect(); + }, [showHeader]); + const recalculatePages = useCallback(() => { if (isAdjustingRef.current) return; const element = contentRef.current; if (!element) return; isAdjustingRef.current = true; - const pages = adjustBlockMarginsForPageBreaks(element); + const pages = adjustBlockMarginsForPageBreaks( + element, + effectiveOverlayHeight + ); if (pages !== totalPages) { setTotalPages(pages); } isAdjustingRef.current = false; - }, [totalPages]); + }, [totalPages, effectiveOverlayHeight]); useEffect(() => { const element = contentRef.current; @@ -108,11 +152,13 @@ function PageView({ children }: PageViewProps) { return () => observer.disconnect(); }, [recalculatePages]); + // Recalculate when header height changes + useEffect(() => { + recalculatePages(); + }, [headerHeight, recalculatePages]); + return ( -
+
{/* First page header */}
+ {/* Document header (editable, first page) */} + {showHeader && ( +
+
+ {documentHeaderSlot} +
+
+
+ )} + {/* Content area */}
{ const pageNumber = index + 1; - // Each overlay sits at: header + N page-content-heights + previous overlays + // Each overlay sits at: first page header + document header + N page-content-heights + previous overlays const topPosition = HEADER_HEIGHT + + headerHeight + PAGE_CONTENT_HEIGHT * pageNumber + - index * OVERLAY_HEIGHT; + index * effectiveOverlayHeight; return (
{/* Gap between pages */} -
+
{/* Header of next page */}
+ + {/* Document header (read-only copy for subsequent pages) */} + {showHeader && documentHeaderHtml && ( +
+
+
+
+
+
+ )}
); })} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 65cbd22..a2aa239 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -22,6 +22,7 @@ import { Link, ImageIcon, MinusIcon, + PanelTop, } from "lucide-react"; import ColorPicker from "./ColorPicker"; import LinkPopover from "./LinkPopover"; @@ -57,6 +58,8 @@ type PopoverName = interface ToolbarProps { editor: Editor; + isHeaderVisible: boolean; + onToggleHeader: () => void; } function ToolbarButton({ @@ -94,9 +97,17 @@ function ToolbarDivider() { return
; } -function Toolbar({ editor }: ToolbarProps) { +function Toolbar({ editor, isHeaderVisible, onToggleHeader }: ToolbarProps) { const [activePopover, setActivePopover] = useState(null); + // Check which node types are available in the active editor's schema + const hasHeading = !!editor.schema.nodes.heading; + const hasListItem = !!editor.schema.nodes.listItem; + const hasBulletList = !!editor.schema.nodes.bulletList; + const hasOrderedList = !!editor.schema.nodes.orderedList; + const hasHorizontalRule = !!editor.schema.nodes.horizontalRule; + const hasImage = !!editor.schema.nodes.image; + const togglePopover = useCallback( (name: PopoverName) => { setActivePopover((current) => (current === name ? null : name)); @@ -198,17 +209,18 @@ function Toolbar({ editor }: ToolbarProps) {
- {activePopover === "heading" && ( + {activePopover === "heading" && hasHeading && (
editor.chain().focus().toggleBulletList().run()} - isActive={editor.isActive("bulletList")} + isActive={hasBulletList && editor.isActive("bulletList")} + disabled={!hasBulletList} > editor.chain().focus().toggleOrderedList().run()} - isActive={editor.isActive("orderedList")} + isActive={hasOrderedList && editor.isActive("orderedList")} + disabled={!hasOrderedList} > @@ -465,14 +479,14 @@ function Toolbar({ editor }: ToolbarProps) { editor.chain().focus().liftListItem("listItem").run()} - disabled={!editor.can().liftListItem("listItem")} + disabled={!hasListItem || !editor.can().liftListItem("listItem")} > editor.chain().focus().sinkListItem("listItem").run()} - disabled={!editor.can().sinkListItem("listItem")} + disabled={!hasListItem || !editor.can().sinkListItem("listItem")} > @@ -501,10 +515,11 @@ function Toolbar({ editor }: ToolbarProps) { togglePopover("image")} + disabled={!hasImage} > - {activePopover === "image" && ( + {activePopover === "image" && hasImage && ( editor.chain().focus().setHorizontalRule().run()} + disabled={!hasHorizontalRule} > + + + + {/* Toggle Document Header */} + + +
); diff --git a/src/index.css b/src/index.css index a8cdcc1..6c46d80 100644 --- a/src/index.css +++ b/src/index.css @@ -60,3 +60,20 @@ html { margin: 0px; } + +/* Document header editor styles */ +.document-header .tiptap { + outline: none; + font-family: Arial, sans-serif; + font-size: 9pt; + line-height: 1.4; + color: #555; +} + +.document-header .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: #adb5bd; + pointer-events: none; + height: 0; +}