-
Notifications
You must be signed in to change notification settings - Fork 1
feat(editor): add document header with rich text editing and per-page display #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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(""); | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Correctness] 🟠
|
||||||||||||||
|
|
||||||||||||||
| const bodyEditor = useEditor({ | ||||||||||||||
| extensions: [ | ||||||||||||||
| StarterKit.configure({ | ||||||||||||||
| heading: { levels: [1, 2, 3, 4] }, | ||||||||||||||
| }), | ||||||||||||||
| Link.configure({ openOnClick: false }), | ||||||||||||||
| Underline, | ||||||||||||||
| TextStyle, | ||||||||||||||
| FontFamily, | ||||||||||||||
| FontSize, | ||||||||||||||
| Color, | ||||||||||||||
| Underline.configure(), | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Code Quality] 🟠 |
||||||||||||||
| 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 && <Toolbar editor={editor} />} | ||||||||||||||
| <PageView> | ||||||||||||||
| <EditorContent editor={editor} className="h-full" /> | ||||||||||||||
| {activeEditor && ( | ||||||||||||||
| <Toolbar | ||||||||||||||
| editor={activeEditor} | ||||||||||||||
| isHeaderVisible={isHeaderVisible} | ||||||||||||||
| onToggleHeader={toggleHeader} | ||||||||||||||
| /> | ||||||||||||||
| )} | ||||||||||||||
| <PageView | ||||||||||||||
| documentHeaderSlot={ | ||||||||||||||
| headerEditor && ( | ||||||||||||||
| <EditorContent editor={headerEditor} className="header-editor" /> | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Enterprise Quality] 💬 Minor [nitpick] The new header editor adds a second editable region without a programmatic name, so assistive-tech users get another generic editable surface with no cue that it is the document header rather than the body. Adding an explicit label would make the two editors distinguishable.
Suggested change
|
||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+96
to
+100
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do not use ternary operator.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5ce6dc9 — replaced the ternary with a logical AND ( |
||||||||||||||
| documentHeaderHtml={headerHtml} | ||||||||||||||
| isDocumentHeaderVisible={isHeaderVisible} | ||||||||||||||
| > | ||||||||||||||
| <EditorContent editor={bodyEditor} className="h-full" /> | ||||||||||||||
| </PageView> | ||||||||||||||
| </> | ||||||||||||||
| ); | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,12 +1,18 @@ | ||||||||
| 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; | ||||||||
| 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; | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Correctness] 🟠 In |
||||||||
|
|
||||||||
| 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; | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Correctness] 🟠 if (block.dataset.pageMargin) {
block.style.marginTop = "";
delete block.dataset.pageMargin;
}
|
||||||||
| 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<HTMLDivElement>(null); | ||||||||
| const headerMeasureRef = useRef<HTMLDivElement>(null); | ||||||||
| const [totalPages, setTotalPages] = useState(1); | ||||||||
| const [documentHeaderRenderedHeight, setDocumentHeaderRenderedHeight] = | ||||||||
| useState(0); | ||||||||
| const isAdjustingRef = useRef(false); | ||||||||
|
|
||||||||
| const showHeader = isDocumentHeaderVisible && !!documentHeaderSlot; | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Code Quality] 💬 Minor [nitpick] |
||||||||
| 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 ( | ||||||||
| <div | ||||||||
| className="relative mt-4" | ||||||||
| style={{ width: PAGE_WIDTH }} | ||||||||
| > | ||||||||
| <div className="relative mt-4" style={{ width: PAGE_WIDTH }}> | ||||||||
| {/* First page header */} | ||||||||
| <div | ||||||||
| className="bg-white" | ||||||||
|
|
@@ -125,6 +171,25 @@ function PageView({ children }: PageViewProps) { | |||||||
| }} | ||||||||
| /> | ||||||||
|
|
||||||||
| {/* Document header (editable, first page) */} | ||||||||
| {showHeader && ( | ||||||||
| <div | ||||||||
| ref={headerMeasureRef} | ||||||||
| className="bg-white" | ||||||||
| style={{ | ||||||||
| paddingLeft: PAGE_HORIZONTAL_PADDING, | ||||||||
| paddingRight: PAGE_HORIZONTAL_PADDING, | ||||||||
| boxShadow: PAGE_SHADOW, | ||||||||
| clipPath: "inset(0 -10px)", | ||||||||
| }} | ||||||||
| > | ||||||||
| <div className="document-header bg-[#f8f9fc] rounded-sm px-3 py-2"> | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Code Quality] 🟠 The document-header shell is duplicated almost verbatim in the first-page branch at [Code Quality] 💬 Minor [nitpick] The new header styling introduces raw color literals in multiple places:
|
||||||||
| {documentHeaderSlot} | ||||||||
| </div> | ||||||||
| <div className="border-b border-gray-300 mt-2" /> | ||||||||
| </div> | ||||||||
| )} | ||||||||
|
|
||||||||
| {/* Content area */} | ||||||||
| <div | ||||||||
| ref={contentRef} | ||||||||
|
|
@@ -160,11 +225,12 @@ function PageView({ children }: PageViewProps) { | |||||||
| {/* Page gap overlays at each boundary */} | ||||||||
| {Array.from({ length: totalPages - 1 }, (_, index) => { | ||||||||
| 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 ( | ||||||||
| <div | ||||||||
|
|
@@ -190,10 +256,7 @@ function PageView({ children }: PageViewProps) { | |||||||
| </div> | ||||||||
|
|
||||||||
| {/* Gap between pages */} | ||||||||
| <div | ||||||||
| className="bg-[#F8F9FA]" | ||||||||
| style={{ height: GAP_HEIGHT }} | ||||||||
| /> | ||||||||
| <div className="bg-[#F8F9FA]" style={{ height: GAP_HEIGHT }} /> | ||||||||
|
|
||||||||
| {/* Header of next page */} | ||||||||
| <div | ||||||||
|
|
@@ -206,6 +269,27 @@ function PageView({ children }: PageViewProps) { | |||||||
| borderTopRightRadius: 4, | ||||||||
| }} | ||||||||
| /> | ||||||||
|
|
||||||||
| {/* Document header (read-only copy for subsequent pages) */} | ||||||||
| {showHeader && documentHeaderHtml && ( | ||||||||
| <div | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Enterprise Quality] 🟠 The per-page header clones are visual-only, but they are still exposed to assistive technology. On a multi-page document, screen readers will encounter the same header content once for the editable first-page header and then again for every subsequent page clone, which makes navigation noisy and misleading. These repeated copies should be hidden from the accessibility tree.
Suggested change
|
||||||||
| className="bg-white" | ||||||||
| style={{ | ||||||||
| paddingLeft: PAGE_HORIZONTAL_PADDING, | ||||||||
| paddingRight: PAGE_HORIZONTAL_PADDING, | ||||||||
| boxShadow: PAGE_SHADOW, | ||||||||
| clipPath: "inset(0 -10px)", | ||||||||
| }} | ||||||||
| > | ||||||||
| <div className="document-header bg-[#f8f9fc] rounded-sm px-3 py-2"> | ||||||||
| <div | ||||||||
| className="tiptap" | ||||||||
| dangerouslySetInnerHTML={{ __html: documentHeaderHtml }} | ||||||||
| /> | ||||||||
| </div> | ||||||||
| <div className="border-b border-gray-300 mt-2" /> | ||||||||
| </div> | ||||||||
| )} | ||||||||
| </div> | ||||||||
| ); | ||||||||
| })} | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Code Quality] 💬 Minor [nitpick]
src/components/Editor.tsx:21,src/components/Editor.tsx:43,src/components/Editor.tsx:67, andsrc/components/Editor.tsx:78repeat the editor-mode literals"body"and"header"across state, focus handlers, and toggle logic. Centralizing those values behind a small constant or enum would make this state machine less brittle if another editor mode is added later.