/** * Shared extension factory for ContentEditor. * * One function builds the extension array for BOTH edit and readonly modes. * This ensures visual consistency — the same extensions parse and render * content identically regardless of mode. * * Split: * - Both modes: StarterKit, CodeBlock, Link, Image, Table, Markdown, Mention * - Edit only: Typography, Placeholder, markdownPaste, submitShortcut, * fileUpload, Mention suggestion popup * * Link config differs: edit mode has autolink (detects URLs while typing), * readonly does not (prevents false positives on display). * * Mention suggestion is only attached in edit mode — readonly doesn't need * the autocomplete popup. * * All link styling is controlled by content-editor.css (var(--brand) color), * not Tailwind HTMLAttributes, to keep a single source of truth. */ import type { RefObject } from "react"; import StarterKit from "@tiptap/starter-kit"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { common, createLowlight } from "lowlight"; import Placeholder from "@tiptap/extension-placeholder"; import Link from "@tiptap/extension-link"; import Typography from "@tiptap/extension-typography"; import Image from "@tiptap/extension-image"; import TableRow from "@tiptap/extension-table-row"; import TableHeader from "@tiptap/extension-table-header"; import TableCell from "@tiptap/extension-table-cell"; import { Table } from "@tiptap/extension-table"; import { Markdown } from "@tiptap/markdown"; import { ReactNodeViewRenderer } from "@tiptap/react"; import type { AnyExtension } from "@tiptap/core"; import type { UploadResult } from "@/shared/hooks/use-file-upload"; import { BaseMentionExtension } from "./mention-extension"; import { createMentionSuggestion } from "./mention-suggestion"; import { CodeBlockView } from "./code-block-view"; import { createMarkdownPasteExtension } from "./markdown-paste"; import { createSubmitExtension } from "./submit-shortcut"; import { createFileUploadExtension } from "./file-upload"; const lowlight = createLowlight(common); const LinkEditable = Link.extend({ inclusive: false }).configure({ openOnClick: true, autolink: true, linkOnPaste: false, }); const LinkReadonly = Link.configure({ openOnClick: false, autolink: false, }); const ImageExtension = Image.extend({ addAttributes() { return { ...this.parent?.(), uploading: { default: false, renderHTML: (attrs: Record) => attrs.uploading ? { "data-uploading": "" } : {}, parseHTML: (el: HTMLElement) => el.hasAttribute("data-uploading"), }, }; }, }).configure({ inline: false, allowBase64: false, HTMLAttributes: { style: "max-width: 100%; height: auto;" }, }); export interface EditorExtensionsOptions { editable: boolean; placeholder?: string; onSubmitRef?: RefObject<(() => void) | undefined>; onUploadFileRef?: RefObject< ((file: File) => Promise) | undefined >; } export function createEditorExtensions( options: EditorExtensionsOptions, ): AnyExtension[] { const { editable, placeholder: placeholderText } = options; const extensions: AnyExtension[] = [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, codeBlock: false, }), CodeBlockLowlight.extend({ addNodeView() { return ReactNodeViewRenderer(CodeBlockView); }, }).configure({ lowlight }), editable ? LinkEditable : LinkReadonly, ImageExtension, Table.configure({ resizable: false }), TableRow, TableHeader, TableCell, Markdown, BaseMentionExtension.configure({ HTMLAttributes: { class: "mention" }, ...(editable ? { suggestion: createMentionSuggestion() } : {}), }), ]; if (editable) { extensions.push( Typography, Placeholder.configure({ placeholder: placeholderText }), createMarkdownPasteExtension(), createSubmitExtension(() => options.onSubmitRef?.current?.()), createFileUploadExtension(options.onUploadFileRef!), ); } return extensions; }