ReferenceNode.tsx2.6 KBView on GitHub
'use client';

import { Node, mergeAttributes } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { cn } from '@/lib/utils';
import { referenceMeta, referenceLabel, isOpenableReference } from './references';

export interface ReferenceNodeOptions {
  /** Invoked when an openable `@` reference chip is clicked, with its path. */
  onOpen: ((path: string) => void) | null;
}

/**
 * Inline atom chip representing a playbook `@` reference (e.g.
 * `@resources/templates#discovery-post-demo`, `@crm-updater`,
 * `@knowledge-base/company-background`). Stores only the `path`; icon and accent
 * are derived from the namespace. Openable references (everything except the
 * `@crm-updater` / `@next-steps` system tokens) are clickable and call
 * `options.onOpen` to open the underlying document.
 */
function ReferenceNodeView({ node, extension }: NodeViewProps) {
  const path = (node.attrs.path as string | null) ?? '';
  const meta = referenceMeta(path);
  const Icon = meta.icon;
  const openable = isOpenableReference(path);
  const onOpen = (extension.options as ReferenceNodeOptions).onOpen;

  return (
    <NodeViewWrapper
      as="span"
      data-reference-node=""
      title={openable ? `Open ${path}` : undefined}
      onClick={openable && onOpen ? () => onOpen(path) : undefined}
      className={cn(
        'mx-0.5 inline-flex select-none items-center gap-1 rounded-full px-2 py-0.5 align-baseline text-xs font-medium leading-none transition-colors',
        meta.className,
        openable && onOpen && 'cursor-pointer hover:underline',
      )}
      contentEditable={false}
    >
      <Icon className="h-3 w-3 shrink-0" />
      <span className="truncate">{referenceLabel(path)}</span>
    </NodeViewWrapper>
  );
}

export const ReferenceNode = Node.create<ReferenceNodeOptions>({
  name: 'referenceNode',
  group: 'inline',
  inline: true,
  atom: true,
  selectable: true,
  draggable: false,

  addOptions() {
    return { onOpen: null };
  },

  addAttributes() {
    return {
      path: {
        default: '' as string,
        parseHTML: (element) => element.getAttribute('data-reference-path'),
        renderHTML: (attributes) =>
          attributes.path ? { 'data-reference-path': attributes.path } : {},
      },
    };
  },

  parseHTML() {
    return [{ tag: 'span[data-reference-node]' }];
  },

  renderHTML({ HTMLAttributes }) {
    return ['span', mergeAttributes({ 'data-reference-node': '' }, HTMLAttributes)];
  },

  addNodeView() {
    return ReactNodeViewRenderer(ReferenceNodeView);
  },
});