mosaicmap/components/tiptap-templates/simple/enhanced-simple-editor.tsx
2025-08-18 00:03:16 +08:00

302 lines
8.7 KiB
TypeScript

"use client"
import * as React from "react"
import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
// --- Tiptap Core Extensions ---
import { StarterKit } from "@tiptap/starter-kit"
import { Image } from "@tiptap/extension-image"
import { TaskItem, TaskList } from "@tiptap/extension-list"
import { TextAlign } from "@tiptap/extension-text-align"
import { Typography } from "@tiptap/extension-typography"
import { Highlight } from "@tiptap/extension-highlight"
import { Subscript } from "@tiptap/extension-subscript"
import { Superscript } from "@tiptap/extension-superscript"
import { Selection } from "@tiptap/extensions"
// --- UI Primitives ---
import { Button } from "@/components/tiptap-ui-primitive/button"
import { Spacer } from "@/components/tiptap-ui-primitive/spacer"
import {
Toolbar,
ToolbarGroup,
ToolbarSeparator,
} from "@/components/tiptap-ui-primitive/toolbar"
// --- Tiptap Node ---
import { ImageUploadNode } from "@/components/tiptap-node/image-upload-node/image-upload-node-extension"
import { HorizontalRule } from "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
import "@/components/tiptap-node/blockquote-node/blockquote-node.scss"
import "@/components/tiptap-node/code-block-node/code-block-node.scss"
import "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss"
import "@/components/tiptap-node/list-node/list-node.scss"
import "@/components/tiptap-node/image-node/image-node.scss"
import "@/components/tiptap-node/heading-node/heading-node.scss"
import "@/components/tiptap-node/paragraph-node/paragraph-node.scss"
// --- Tiptap UI ---
import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
import {
ColorHighlightPopover,
ColorHighlightPopoverContent,
ColorHighlightPopoverButton,
} from "@/components/tiptap-ui/color-highlight-popover"
import {
LinkPopover,
LinkContent,
LinkButton,
} from "@/components/tiptap-ui/link-popover"
import { MarkButton } from "@/components/tiptap-ui/mark-button"
import { TextAlignButton } from "@/components/tiptap-ui/text-align-button"
import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button"
// --- Icons ---
import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
import { LinkIcon } from "@/components/tiptap-icons/link-icon"
// --- Hooks ---
import { useIsMobile } from "@/hooks/use-mobile"
import { useWindowSize } from "@/hooks/use-window-size"
import { useCursorVisibility } from "@/hooks/use-cursor-visibility"
// --- Components ---
import { ThemeToggle } from "@/components/tiptap-templates/simple/theme-toggle"
// --- Lib ---
import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils"
// --- Styles ---
import "@/components/tiptap-templates/simple/simple-editor.scss"
const MainToolbarContent = ({
onHighlighterClick,
onLinkClick,
isMobile,
}: {
onHighlighterClick: () => void
onLinkClick: () => void
isMobile: boolean
}) => {
return (
<>
<Spacer />
<ToolbarGroup>
<UndoRedoButton action="undo" />
<UndoRedoButton action="redo" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<HeadingDropdownMenu levels={[1, 2, 3, 4]} portal={isMobile} />
<ListDropdownMenu
types={["bulletList", "orderedList", "taskList"]}
portal={isMobile}
/>
<BlockquoteButton />
<CodeBlockButton />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<MarkButton type="bold" />
<MarkButton type="italic" />
<MarkButton type="strike" />
<MarkButton type="code" />
<MarkButton type="underline" />
{!isMobile ? (
<ColorHighlightPopover />
) : (
<ColorHighlightPopoverButton onClick={onHighlighterClick} />
)}
{!isMobile ? <LinkPopover /> : <LinkButton onClick={onLinkClick} />}
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<MarkButton type="superscript" />
<MarkButton type="subscript" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<TextAlignButton align="left" />
<TextAlignButton align="center" />
<TextAlignButton align="right" />
<TextAlignButton align="justify" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<ImageUploadButton text="Add" />
</ToolbarGroup>
<Spacer />
{isMobile && <ToolbarSeparator />}
<ToolbarGroup>
<ThemeToggle />
</ToolbarGroup>
</>
)
}
const MobileToolbarContent = ({
type,
onBack,
}: {
type: "highlighter" | "link"
onBack: () => void
}) => (
<>
<ToolbarGroup>
<Button data-style="ghost" onClick={onBack}>
<ArrowLeftIcon className="tiptap-button-icon" />
{type === "highlighter" ? (
<HighlighterIcon className="tiptap-button-icon" />
) : (
<LinkIcon className="tiptap-button-icon" />
)}
</Button>
</ToolbarGroup>
<ToolbarSeparator />
{type === "highlighter" ? (
<ColorHighlightPopoverContent />
) : (
<LinkContent />
)}
</>
)
interface EnhancedSimpleEditorProps {
content?: any
onChange?: (content: any) => void
}
export function EnhancedSimpleEditor({ content, onChange }: EnhancedSimpleEditorProps) {
const isMobile = useIsMobile()
const { height } = useWindowSize()
const [mobileView, setMobileView] = React.useState<
"main" | "highlighter" | "link"
>("main")
const toolbarRef = React.useRef<HTMLDivElement>(null)
const editor = useEditor({
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
"aria-label": "Main content area, start typing to enter text.",
class: "simple-editor",
},
},
extensions: [
StarterKit.configure({
horizontalRule: false,
link: {
openOnClick: false,
enableClickSelection: true,
},
}),
HorizontalRule,
TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList,
TaskItem.configure({ nested: true }),
Highlight.configure({ multicolor: true }),
Image,
Typography,
Superscript,
Subscript,
Selection,
ImageUploadNode.configure({
accept: "image/*",
maxSize: MAX_FILE_SIZE,
limit: 3,
upload: handleImageUpload,
onError: (error) => console.error("Upload failed:", error),
}),
],
content: content || "",
onUpdate: ({ editor }) => {
if (onChange) {
onChange(editor.getJSON())
}
}
})
// Update editor content when prop changes
React.useEffect(() => {
if (editor && content !== undefined) {
const currentContent = editor.getJSON()
const newContent = content || ""
// Only update if content actually changed to avoid infinite loops
if (JSON.stringify(currentContent) !== JSON.stringify(newContent)) {
editor.commands.setContent(newContent, { emitUpdate: false })
}
}
}, [editor, content])
const rect = useCursorVisibility({
editor,
overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0,
})
React.useEffect(() => {
if (!isMobile && mobileView !== "main") {
setMobileView("main")
}
}, [isMobile, mobileView])
return (
<div className="simple-editor-wrapper">
<EditorContext.Provider value={{ editor }}>
<Toolbar
ref={toolbarRef}
style={{
...(isMobile
? {
bottom: `calc(100% - ${height - rect.y}px)`,
}
: {}),
}}
>
{mobileView === "main" ? (
<MainToolbarContent
onHighlighterClick={() => setMobileView("highlighter")}
onLinkClick={() => setMobileView("link")}
isMobile={isMobile}
/>
) : (
<MobileToolbarContent
type={mobileView === "highlighter" ? "highlighter" : "link"}
onBack={() => setMobileView("main")}
/>
)}
</Toolbar>
<EditorContent
editor={editor}
role="presentation"
className="simple-editor-content"
/>
</EditorContext.Provider>
</div>
)
}