import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import type { Node as TiptapNode } from "@tiptap/core"; import { CodeBlockLowlight } from "@tiptap/extension-code-block-lowlight"; import { NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps, } from "@tiptap/react"; import type { createLowlight } from "lowlight"; import { useMemo, useState } from "react"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js"; import { cn } from "../utils.js"; /** * A selectable language for the code-block picker. `value === null` is the * "Auto" sentinel: the block stores no language and the lowlight plugin * auto-detects (biased toward the editor's registered, web-leaning grammar * set). Keeping it first makes Auto the default, matching how plans skew toward * JS/TS/JSON without the author having to choose. */ export interface CodeLanguageOption { value: string | null; label: string; } /** Default picker list — Auto first, then the web-leaning grammars plans use. */ export const DEFAULT_CODE_LANGUAGES: CodeLanguageOption[] = [ { value: null, label: "Auto" }, { value: "typescript", label: "TypeScript" }, { value: "javascript", label: "JavaScript" }, { value: "tsx", label: "TSX" }, { value: "jsx", label: "JSX" }, { value: "json", label: "JSON" }, { value: "html", label: "HTML" }, { value: "css", label: "CSS" }, { value: "bash", label: "Bash" }, { value: "python", label: "Python" }, { value: "sql", label: "SQL" }, { value: "yaml", label: "YAML" }, { value: "markdown", label: "Markdown" }, { value: "graphql", label: "GraphQL" }, { value: "go", label: "Go" }, { value: "rust", label: "Rust" }, { value: "diff", label: "Diff" }, ]; /** Class hooks so each app themes the shared node with its own palette. */ export interface CodeBlockClassNames { wrapper: string; header: string; langButton: string; langButtonReadonly: string; picker: string; search: string; list: string; option: string; optionActive: string; } const DEFAULT_CLASS_NAMES: CodeBlockClassNames = { wrapper: "an-code-block", header: "an-code-block__header", langButton: "an-code-block__lang", langButtonReadonly: "an-code-block__lang--readonly", picker: "an-code-block__picker", search: "an-code-block__search", list: "an-code-block__list", option: "an-code-block__option", optionActive: "is-active", }; export interface CreateCodeBlockNodeOptions { /** Lowlight instance backing syntax highlighting (same one the app registers). */ lowlight: ReturnType; /** Picker languages. Defaults to {@link DEFAULT_CODE_LANGUAGES} (Auto first). */ languages?: CodeLanguageOption[]; /** Class hooks for per-app theming. Defaults to the shared `an-code-block__*`. */ classNames?: Partial; } function optionLabel( language: string | null, languages: CodeLanguageOption[], ): string { if (!language) return languages[0]?.label ?? "Auto"; const match = languages.find((option) => option.value === language); return match?.label ?? language; } interface CodeBlockNodeViewExtraOptions { languagePickerOptions: CodeLanguageOption[]; codeBlockClassNames: CodeBlockClassNames; } function CodeBlockView({ node, updateAttributes, editor, extension, }: NodeViewProps) { const options = extension.options as unknown as CodeBlockNodeViewExtraOptions; const languages = options.languagePickerOptions; const classNames = options.codeBlockClassNames; const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); const isEditable = editor.isEditable; const current = (node.attrs.language as string | null) || null; const label = optionLabel(current, languages); const filtered = useMemo(() => { const query = filter.trim().toLowerCase(); if (!query) return languages; return languages.filter((option) => option.label.toLowerCase().includes(query), ); }, [filter, languages]); const select = (value: string | null) => { // Store `""` for Auto so tiptap-markdown emits a bare fence (no language), // matching the auto-detect read path. updateAttributes({ language: value ?? "" }); setFilter(""); setOpen(false); }; return (
{isEditable ? ( { setOpen(next); if (!next) setFilter(""); }} > setFilter(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && filtered.length > 0) { event.preventDefault(); select(filtered[0].value); } }} placeholder="Search languages…" className={classNames.search} />
{filtered.map((option) => { const active = option.value === current || (!option.value && !current); return ( ); })}
) : ( label && ( {label} ) )}
        
      
); } /** * The shared Notion-style code block: {@link CodeBlockLowlight} (so fenced * markdown round-trips byte-identically — the node name and `language` attr are * unchanged) plus a React node view that adds a language picker header instead * of a bare highlighted `
`. "Auto" stores no language and the lowlight
 * plugin auto-detects.
 *
 * Lifted from the Content editor's bespoke code block so Plans (and any app
 * opting into `features.codeBlock`) share one implementation. Theming is fully
 * class-driven via {@link CreateCodeBlockNodeOptions.classNames}, so each app
 * maps the hooks onto its own palette.
 */
export function createCodeBlockNode({
  lowlight,
  languages = DEFAULT_CODE_LANGUAGES,
  classNames,
}: CreateCodeBlockNodeOptions): TiptapNode {
  const resolvedClassNames: CodeBlockClassNames = {
    ...DEFAULT_CLASS_NAMES,
    ...(classNames ?? {}),
  };

  return CodeBlockLowlight.extend({
    addOptions() {
      return {
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        ...this.parent!(),
        languagePickerOptions: languages,
        codeBlockClassNames: resolvedClassNames,
      };
    },
    addNodeView() {
      return ReactNodeViewRenderer(CodeBlockView);
    },
    addKeyboardShortcuts() {
      return {
        ...this.parent?.(),
        Tab: ({ editor }) => {
          if (editor.isActive(this.name)) {
            editor.commands.insertContent("\t");
            return true;
          }
          return false;
        },
      };
    },
  }).configure({
    lowlight,
    defaultLanguage: null,
  }) as unknown as TiptapNode;
}