apps/web/src/components/ComposerCitationNode.tsx

import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import type { AssistantCitation } from "@t3tools/contracts";
import {
  serializeAssistantCitation,
  withAssistantCitationComment,
} from "@t3tools/shared/assistantCitations";
import {
  $applyNodeReplacement,
  $getNodeByKey,
  $getRoot,
  $isElementNode,
  DecoratorNode,
  HISTORY_PUSH_TAG,
  SKIP_DOM_SELECTION_TAG,
  type NodeKey,
  type SerializedLexicalNode,
  type Spread,
} from "lexical";
import { createContext, use, type ReactElement } from "react";
import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";

import { AssistantCitationChip } from "./chat/AssistantCitationChip";
import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip";

type SerializedComposerCitationNode = Spread<
  {
    citation: AssistantCitation;
    source: string;
    type: "composer-citation";
    version: 1;
  },
  SerializedLexicalNode
>;

export type ComposerCitationCommentRequest = {
  previousValue: string;
  value: string;
  citationStart: number;
  sourceAnchor: AssistantCitationSourceAnchor;
};

export type ComposerCitationCommentTarget = {
  nodeKey: NodeKey;
  sourceAnchor?: AssistantCitationSourceAnchor;
};

export const ComposerCitationCommentContext = createContext<{
  openComment: ComposerCitationCommentTarget | null;
  onOpenChange: (nodeKey: NodeKey, open: boolean) => void;
  onSubmitAndSend: () => void;
}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} });

/** Consume a cite action once its controlled prompt has been committed to the editor. */
export function $consumeComposerCitationCommentRequest(requestRef: {
  current: ComposerCitationCommentRequest | null;
}): ComposerCitationCommentTarget | null {
  const request = requestRef.current;
  if (!request) return null;
  const root = $getRoot();
  const value = root.getTextContent();
  if (value === request.previousValue) return null;
  requestRef.current = null;
  if (value !== request.value) return null;

  // Controlled prompts use one paragraph with inline nodes and explicit line breaks.
  const paragraph = root.getFirstChild();
  if (!$isElementNode(paragraph)) return null;
  let offset = 0;
  for (const node of paragraph.getChildren()) {
    if (offset === request.citationStart && node instanceof ComposerCitationNode) {
      return { nodeKey: node.getKey(), sourceAnchor: request.sourceAnchor };
    }
    offset += node.getTextContentSize();
  }
  return null;
}

function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) {
  const [editor] = useLexicalComposerContext();
  const commentContext = use(ComposerCitationCommentContext);
  const onSaveComment = (comment: string): boolean => {
    if (!editor.isEditable()) return false;
    let accepted = false;
    editor.update(
      () => {
        const node = $getNodeByKey(props.nodeKey);
        if (node instanceof ComposerCitationNode && node.isAttached()) {
          node.setComment(comment);
          accepted = true;
        }
      },
      { discrete: true, tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] },
    );
    return accepted;
  };
  const onRemove = () => {
    if (!editor.isEditable()) return;
    editor.update(
      () => {
        const node = $getNodeByKey(props.nodeKey);
        if (node instanceof ComposerCitationNode) {
          node.selectPrevious();
          node.remove();
        }
      },
      { tag: HISTORY_PUSH_TAG },
    );
    editor.getRootElement()?.focus({ preventScroll: true });
  };

  return (
    <span
      className="inline-flex min-w-0 max-w-full"
      contentEditable={false}
      spellCheck={false}
      data-composer-citation-chip="true"
    >
      <AssistantCitationChip
        citation={props.citation}
        commentEditor={{
          open: commentContext.openComment?.nodeKey === props.nodeKey,
          sourceAnchor:
            commentContext.openComment?.nodeKey === props.nodeKey
              ? commentContext.openComment.sourceAnchor
              : undefined,
          onOpenChange: (open) => {
            if (open && !editor.isEditable()) return;
            commentContext.onOpenChange(props.nodeKey, open);
          },
          onSave: onSaveComment,
          onSaveAndSend: (comment) => {
            if (!onSaveComment(comment)) return false;
            commentContext.onSubmitAndSend();
            return true;
          },
        }}
        onRemove={onRemove}
      />
    </span>
  );
}

export class ComposerCitationNode extends DecoratorNode<ReactElement> {
  __citation: AssistantCitation;
  __source: string;

  static override getType(): string {
    return "composer-citation";
  }

  static override clone(node: ComposerCitationNode): ComposerCitationNode {
    return new ComposerCitationNode(node.__citation, node.__source, node.__key);
  }

  static override importJSON(serializedNode: SerializedComposerCitationNode): ComposerCitationNode {
    return $createComposerCitationNode(
      serializedNode.citation,
      serializedNode.source,
    ).updateFromJSON(serializedNode);
  }

  constructor(citation: AssistantCitation, source: string, key?: NodeKey) {
    super(key);
    this.__citation = citation;
    this.__source = source;
  }

  override exportJSON(): SerializedComposerCitationNode {
    const node = this.getLatest();
    return {
      ...super.exportJSON(),
      citation: node.__citation,
      source: node.__source,
      type: "composer-citation",
      version: 1,
    };
  }

  override createDOM(): HTMLElement {
    const dom = document.createElement("span");
    dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`;
    return dom;
  }

  override updateDOM(): false {
    return false;
  }

  override getTextContent(): string {
    return this.getLatest().__source;
  }

  setComment(comment: string): this {
    const latest = this.getLatest();
    const citation = withAssistantCitationComment(latest.__citation, comment);
    if (citation.comment === latest.__citation.comment) return latest;
    const source = serializeAssistantCitation(citation);
    const writable = this.getWritable();
    writable.__citation = citation;
    writable.__source = source;
    return writable;
  }

  override isInline(): true {
    return true;
  }

  override decorate(): ReactElement {
    return <ComposerCitationDecorator citation={this.__citation} nodeKey={this.__key} />;
  }
}

export function $createComposerCitationNode(
  citation: AssistantCitation,
  source: string,
): ComposerCitationNode {
  return $applyNodeReplacement(new ComposerCitationNode(citation, source));
}