'use client'; import { useEditor, EditorContent, type Editor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import Mention from '@tiptap/extension-mention'; import { Markdown } from '@tiptap/markdown'; import type { AnyExtension } from '@tiptap/core'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; import { useHotkey } from '@djangocfg/ui-core/hooks'; import { Bold, Italic, Strikethrough, Heading1, Heading2, Heading3, List, ListOrdered, Quote, Minus, Code, type LucideIcon, } from 'lucide-react'; import { createMentionSuggestion } from './createMentionSuggestion'; import { mentionPresets } from './mentionPresets'; import { SlashCommandNode } from './slash/SlashCommandNode'; import { syncLeadingSlashNode } from './slash/syncSlashNode'; import type { SlashCommandInfo } from './slash/types'; import { SubmitOnEnter } from './submitOnEnter'; import { ChipNode } from './chip/ChipNode'; import { syncChips } from './chip/syncChips'; import type { MentionAttrs, MentionConfig } from './types'; import './styles.css'; // ── Helpers ── /** * Whitelist of URL schemes the autolinker is allowed to turn into a * link. We deliberately keep this to real, clickable transports. */ const AUTO_LINK_PROTOCOL = /^(?:https?|ftp|mailto|file):/i; /** * Decide whether an autolink candidate should actually become a link. * * StarterKit's bundled `Link` extension ships with a permissive default * `shouldAutoLink` that links any token whose "hostname" contains a dot * with a plausible TLD. That over-eagerly turns *bare file paths and * dotted filenames* into `http://…` links — e.g. typing a local path * like `…/positioning/manifest.md` linkifies `manifest.md` as the * Moldova (`.md`) domain, and pasted prose around a dotted token can be * swallowed into one anchor. None of that is wanted in a chat composer. * * We instead require an explicit, known protocol (`http`, `https`, * `ftp`, `mailto`, `file`) or a `www.`-prefixed host. linkifyjs has * already split the candidate on whitespace, so the match is inherently * bounded to a single token — a path like `/a/b/file.md` is never a * `*://` URL and never a `www.` host, so it stays plain text, and any * Unicode/Cyrillic words after a real URL remain plain text too. */ function shouldAutoLinkUrl(url: string): boolean { if (AUTO_LINK_PROTOCOL.test(url)) return true; // Bare `www.host…` (no scheme) — linkify prepends the default // protocol; allow it since it's an unambiguous web address, not a // local path. if (/^www\.[^\s/$.?#].[^\s]*$/i.test(url)) return true; return false; } interface MarkdownManager { serialize: (json: Record) => string; } /** * Serialize the editor document to a markdown string. * * `@tiptap/markdown` v3 augments the `Editor` with a `getMarkdown()` * method; we use it when present and fall back to the storage manager * (and ultimately `getText()`) so the editor still produces *something* * if the extension shape ever drifts. */ function getMarkdown(editor: Editor): string { const withMd = editor as Editor & { getMarkdown?: () => string }; if (typeof withMd.getMarkdown === 'function') return withMd.getMarkdown(); const storage = editor.storage.markdown as { manager?: MarkdownManager } | undefined; if (!storage?.manager) return editor.getText(); return storage.manager.serialize(editor.getJSON()); } function extractMentionIds(editor: Editor): string[] { const ids: string[] = []; editor.state.doc.descendants((node) => { if (node.type.name === 'mention' && node.attrs.id) { ids.push(node.attrs.id as string); } }); return [...new Set(ids)]; } // ── Types ── export interface MarkdownEditorProps { value: string; onChange: (value: string) => void; placeholder?: string; minHeight?: number; className?: string; disabled?: boolean; showToolbar?: boolean; /** * Drop the editor's own border / background / focus ring. Use when * the editor is embedded inside a host surface that already draws * the frame (e.g. the chat composer's textarea slot) — avoids a * double border. */ unstyled?: boolean; /** * `@`-mention autocomplete config. * * IMPORTANT: Tiptap's `useEditor` initialises the editor exactly once. * The `Mention` extension is only registered when `mentions` is truthy * on the FIRST render — handing in a real config later (e.g. after an * async items fetch) silently does nothing, and typing `@` will not * open the popover. * * If you want mentions even with async-loaded items, pass * `{ items: [] }` from the very first render and update the array * when data arrives. Either keep the `MentionConfig` object identity * stable across renders and mutate `items` in place (the suggestion * plugin captures the config by closure and reads `items` on each * query), or accept that swapping the whole object reference is a * no-op for the live editor. */ mentions?: MentionConfig; /** * Slash-command verb list. When set, the editor registers a * `SlashCommandNode` extension and replaces a leading `/verb` (whose * verb is in this list) in the document with an atomic chip — the * TipTap analogue of the plain `` mirror. * * Like `mentions`, this is captured by `useEditor` exactly once on * first render. To register slash commands at all, pass `[]` from * the very first render and mutate / swap as needed. The conversion * effect re-reads the list on every value sync (it lives in a ref), * so verbs added later are recognised next time the buffer is set. * * Lives in `@djangocfg/ui-tools/markdown-editor` (not chat) so the * editor stays a leaf dependency. Structurally compatible with the * chat package's `SlashCommand[]` — pass either directly. */ slashCommands?: readonly SlashCommandInfo[]; /** * Render absolute LOCAL file paths typed/pasted into the editor as * compact, atomic file chips (icon + VSCode-style middle-ellipsis label, * full path on hover) — Cursor/Finder style. Backed by an inline atom * NODE (`editorChip`, `kind: 'path'`), NOT a decoration: the chip owns * its rendering so a long path collapses to ONE line (`…/dev/Map/index.ts`) * instead of wrapping. The node serialises back to the RAW path, so caret * (whole-chip delete), copy, and the submitted markdown all keep the * literal `…/manifest.md`. Detection is conservative (absolute Unix / * macOS / `~` / Windows-drive / UNC / `file://` only — never bare * relative tokens or web URLs). * * Defaults to ON (the chat composer wants it). Pass `false` for editors * that should leave paths as plain text. Like `mentions`/`slashCommands` * this is captured by `useEditor` on first render — flipping it after * mount won't add/remove the extension on the live instance. */ filePathChips?: boolean; /** * Render web URLs typed/pasted into the editor as compact, atomic URL * chips (favicon — degrading to a globe glyph — + domain and a * middle-ellipsis of the path, e.g. `github.com/…/README.md`, full URL on * hover). Clickable (opens in a new tab, `rel=noopener`). Same atom NODE * as the file-path chip (`editorChip`, `kind: 'url'`); it REPLACES the * plain blue underlined link look and serialises back to the RAW URL so * copy / submit / markdown keep the literal address. Uses the same * autolink whitelist as the editor (`http`/`https`/`ftp`/`mailto`/`file` * + bare `www.`). * * Defaults to ON. Captured on first render like `filePathChips`. */ urlChips?: boolean; /** Called when mentioned IDs change */ onMentionIdsChange?: (ids: string[]) => void; /** * Called when the user presses Enter (without Shift, no IME * composition, no mention popover open). When set, Enter submits * and Shift+Enter inserts a newline — ChatGPT / Telegram chat * behaviour. When omitted, Enter behaves as Tiptap default * (HardBreak). * * Implementation lives in `submitOnEnter.ts` — it's a Tiptap * keymap extension, NOT a React wrapper handler. Wrapper-level * onKeyDown fires AFTER ProseMirror's keymap commits HardBreak in * the same tick; routing through the extension lets us intercept * before HardBreak runs. * * Return value (optional): truthy / undefined = consume the key * (default). Return `false` from onSubmit to let Tiptap fall * through to HardBreak — useful for guards like "don't submit an * empty draft". */ onSubmit?: () => boolean | void; /** * Focus the editor on mount. Pair with `key={file}` upstream when the * host wants a fresh focus per file change (inspector / editor tab). */ autoFocus?: boolean; /** * Called when the user presses Cmd/Ctrl+S inside the editor. Receives * the current markdown. The browser's "save page" default is suppressed * only when this handler is supplied — otherwise Cmd+S falls through. */ onSave?: (markdown: string) => void; } /** * Imperative handle exposed via `ref`. Matches `ComposerHandle` from * `@djangocfg/ui-tools/chat` so consumers can forward it straight into * `useRegisterComposer({ focus, moveCursorToEnd })` — that's what makes * voice dictation (`VoiceComposerSlot`) push live text into a TipTap * composer. */ export interface MarkdownEditorHandle { /** Move keyboard focus into the editor. */ focus: () => void; /** Place the caret at the end of the document (and focus). */ moveCursorToEnd: () => void; /** Escape hatch — the underlying TipTap `Editor` instance. */ getEditor: () => Editor | null; } // ── Component ── export const MarkdownEditor = forwardRef(function MarkdownEditor( { value, onChange, placeholder = 'Write markdown...', minHeight = 120, className = '', disabled = false, showToolbar = true, unstyled = false, mentions, slashCommands, filePathChips = true, urlChips = true, onMentionIdsChange, onSubmit, autoFocus = false, onSave, }, ref, ) { // Keep the latest onSubmit in a ref so the Tiptap extension's // keymap closure always calls the freshest handler — Tiptap's // useEditor initialises extensions ONCE on first render. Without // the ref the extension would call a stale onSubmit (e.g. one // that references an outdated `value`). const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; const isExternalUpdate = useRef(false); // Slash list lives in a ref so verbs added at runtime (e.g. context- // aware commands registered after a first reply) are still recognised // by the post-`setContent` conversion pass. The editor extension itself // is registered once on mount — that is intentional and matches how // `mentions` works (Tiptap's `useEditor` captures extensions once). const slashCommandsRef = useRef( slashCommands ?? [], ); slashCommandsRef.current = slashCommands ?? []; const slashEnabledOnMountRef = useRef(slashCommands !== undefined); // File-path / URL chips: captured once on mount (the ChipNode extension is // installed on first render, like mentions/slash). Both default ON. Kept // in refs so the post-`setContent` `syncChips` pass reads the right flags. const filePathChipsOnMountRef = useRef(filePathChips); const urlChipsOnMountRef = useRef(urlChips); const slashWarnedRef = useRef(false); if ( process.env.NODE_ENV !== 'production' && !slashEnabledOnMountRef.current && slashCommands !== undefined && !slashWarnedRef.current ) { slashWarnedRef.current = true; // eslint-disable-next-line no-console console.warn( '[MarkdownEditor] `slashCommands` flipped from undefined to a list ' + 'after mount. Tiptap only installs the SlashCommandNode extension ' + 'on first render — the in-editor chip will NOT appear for this ' + 'editor instance. Pass `[]` from the very first render and mutate ' + 'the array in place (or swap references) so the conversion effect ' + 'sees the new verbs.', ); } // ── Dev-mode trap detector ── // Tiptap initialises the editor once with the extensions array from // first render. If `mentions` is undefined on mount and becomes // truthy later, the Mention extension is never installed and the // user-visible @-trigger silently does nothing. Catch this early so // future consumers don't spend hours debugging why @ does nothing. const initialMentionsDefinedRef = useRef(mentions !== undefined); const warnedRef = useRef(false); if ( process.env.NODE_ENV !== 'production' && !initialMentionsDefinedRef.current && mentions !== undefined && !warnedRef.current ) { warnedRef.current = true; // eslint-disable-next-line no-console console.warn( '[MarkdownEditor] `mentions` flipped from undefined to a config ' + 'after mount. Tiptap only installs the Mention extension on first ' + 'render — the @-popover will NOT work for this editor instance. ' + 'Pass `{ items: [] }` from the very first render and mutate `.items` ' + 'in place instead.', ); } const extensions = useMemo(() => { const exts: AnyExtension[] = [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, // Constrain the bundled Link extension's autolinker. The default // links bare dotted tokens (e.g. a file path's `manifest.md`) // and can over-capture pasted prose; we only auto-link real // URLs (explicit scheme or `www.`). `defaultProtocol: 'https'` // is the modern default for the rare schemeless `www.` case. // // When `urlChips` is ON the ChipNode OWNS URL rendering (a chip, // not a blue underline), so we turn OFF the link autolinker to // avoid the two fighting over the same token. Markdown `[text](url)` // links typed deliberately still render via the link mark — only // the implicit autolink of a bare URL is suppressed. link: { defaultProtocol: 'https', autolink: !urlChipsOnMountRef.current, shouldAutoLink: shouldAutoLinkUrl, }, }), Placeholder.configure({ placeholder }), Markdown, // SubmitOnEnter — when the consumer wired an onSubmit, intercept // Enter at the keymap level (before StarterKit's HardBreak). // The extension calls through `onSubmitRef.current` so handler // identity changes don't require an editor rebuild. See // submitOnEnter.ts for the keymap-vs-wrapper-handler rationale. SubmitOnEnter.configure({ onSubmit: () => { const h = onSubmitRef.current; if (!h) return false; // no handler → let Tiptap insert HardBreak return h(); }, }), ]; // SlashCommandNode — TipTap atom that paints the `/verb` chip. // Registered whenever `slashCommands` was defined on first render // (the empty array still counts as "I want this feature"). The // node has no suggestion plugin of its own — the chat composer's // floating menu drives selection and the editor only needs to // know how to render + serialize the atom. if (slashEnabledOnMountRef.current) { exts.push(SlashCommandNode); } // ChipNode — a single inline ATOM node that renders BOTH file-path // chips (`kind: 'path'`) and URL chips (`kind: 'url'`) with a // VSCode-style middle-ellipsis label, and serialises back to the raw // path / URL. Registered whenever either chip feature is on (the node's // input/paste rules are gated per-kind via `enablePaths`/`enableUrls`). // Replaces the old FilePathChipExtension decoration — a node owns its // rendering, so a long path collapses to ONE line instead of wrapping. if (filePathChipsOnMountRef.current || urlChipsOnMountRef.current) { exts.push( ChipNode.configure({ enablePaths: filePathChipsOnMountRef.current, enableUrls: urlChipsOnMountRef.current, }), ); } if (mentions) { // ── Why .extend() with renderMarkdown ── // // Tiptap's `Mention` extension ships a default markdown serializer // (via `createInlineMarkdownSpec`, see @tiptap/extension-mention) // that emits a shortcode like `[@ id="..." label="..."]`. That's // round-trippable but useless for most consumers: a chat composer // feeding an LLM wants `@