FileLinkNode.tsx6.9 KBView on GitHub
'use client';

import { InputRule, Node, nodePasteRule } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import type { Editor, MarkdownParseHelpers, MarkdownToken } from '@tiptap/core';
import { createDeferredAutoConvertPlugin } from '../deferred-auto-convert';
import { FileLinkChip } from './FileLinkChip';
import {
  FILE_LINK_TOKEN_ANCHORED,
  findFileLinkTokenStart,
  serializeFileLinkToken,
} from './markdown-bridge';

// Trailing-anchored versions for input/paste rules — the input rule fires on
// the `]]` keystroke, the paste rule scans the pasted text.
const FILE_LINK_INPUT_REGEX = /\[\[doc:([0-9a-fA-F-]{36})(?:\|[^\]]+)?\]\]$/;
const FILE_LINK_PASTE_REGEX = /\[\[doc:([0-9a-fA-F-]{36})(?:\|[^\]]+)?\]\]/g;
// Global scanner for the auto-convert plugin — finds tokens anywhere in a text run.
const FILE_LINK_SCAN_REGEX = /\[\[doc:([0-9a-fA-F-]{36})(?:\|[^\]]+)?\]\]/g;

/** What a host editor may hang off a chip, resolved per chip at render time. */
export interface FileLinkChipAction {
  /** Segment copy. Short — it shares a pill with a document title. */
  label: string;
  /** Announced to screen readers, where the label alone is not self-explanatory. */
  ariaLabel: string;
  run: () => void;
}

export interface FileLinkOptions {
  /**
   * Optional click handler. When provided (e.g. by the playbook editor), a
   * click opens the referenced document via this callback instead of the
   * default in-place selection. Left null elsewhere to preserve existing
   * docs-side-panel behaviour.
   */
  onOpen: ((documentId: string) => void) | null;
  /**
   * An extra, host-supplied action segment on the chip — the second half of a split
   * button. Returning null (the default, and the answer everywhere but a playbook
   * trigger) renders the chip exactly as it always was.
   *
   * The HOST decides, not this file. The playbook's answer depends on whether the chip
   * sits inside a `<trigger>` block, and teaching a shared document node what a trigger
   * is — so that every other editor carries the concept in order to ignore it — is how a
   * generic node ends up with a playbook's vocabulary in it. Here it is just "does anyone
   * want a segment on this chip".
   */
  chipAction:
    | ((ctx: {
        editor: Editor;
        documentId: string;
        /** Absolute position of the chip, or undefined once the node view is detached. */
        pos: number | undefined;
      }) => FileLinkChipAction | null)
    | null;
}

export const FileLinkNode = Node.create<FileLinkOptions>({
  name: 'fileLink',
  group: 'inline',
  inline: true,
  atom: true,
  selectable: true,
  draggable: true,

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

  addAttributes() {
    return {
      documentId: { default: '' },
      /**
       * Per-ref attributes read by the server's `compile-playbook.ts` off a `<ref>`
       * element (`section` narrows the target doc to one anchor, `when` gates the ref).
       *
       * Declared here purely so they SURVIVE. The playbook round-trip is XML → ProseMirror
       * JSON → XML, and an attribute the schema does not know is dropped on the way in —
       * which meant any playbook using `section`/`when` silently lost them the first time
       * someone opened it in the editor. Nothing in apps/mail reads them; the chip renders
       * identically with or without. `triggerRef` carries the same pair for the shape of
       * `<ref>` that has a body.
       */
      section: { default: null },
      when: { default: null },
    };
  },

  parseHTML() {
    return [
      {
        tag: 'span[data-file-link]',
        getAttrs: (el) => ({
          documentId: (el as HTMLElement).getAttribute('data-document-id') ?? '',
        }),
      },
    ];
  },

  renderHTML({ node }) {
    // Empty content: the NodeView owns the visual entirely. PM and the
    // NodeView agree the DOM is opaque, so the MutationObserver never tries
    // to reconcile and we don't loop. (Mirror of ConversationNode.)
    return [
      'span',
      {
        'data-file-link': '',
        'data-document-id': node.attrs.documentId,
        class: 'cedar-file-link',
      },
    ];
  },

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

  markdownTokenizer: {
    name: 'fileLink',
    level: 'inline',
    start(src: string) {
      return findFileLinkTokenStart(src);
    },
    tokenize(src: string): MarkdownToken | undefined {
      const match = src.match(FILE_LINK_TOKEN_ANCHORED);
      if (!match) return undefined;
      return {
        type: 'fileLink',
        raw: match[0],
        documentId: match[1],
      };
    },
  },

  parseMarkdown(token=[redacted], helpers: MarkdownParseHelpers) {
    return helpers.createNode(
      'fileLink',
      { documentId: token.documentId ?? '' },
      [],
    );
  },

  renderMarkdown(node) {
    return serializeFileLinkToken({
      documentId: String(node.attrs?.documentId ?? ''),
    });
  },

  addInputRules() {
    return [
      new InputRule({
        find: FILE_LINK_INPUT_REGEX,
        handler: ({ state, range, match }) => {
          const node = this.type.create({ documentId: match[1] });
          state.tr.replaceWith(range.from, range.to, node);
        },
      }),
    ];
  },

  addPasteRules() {
    return [
      nodePasteRule({
        find: FILE_LINK_PASTE_REGEX,
        type: this.type,
        getAttributes: (match) => ({ documentId: match[1] }),
      }),
    ];
  },

  // Auto-convert `[[doc:uuid]]` text that arrives via Y.js / programmatic content
  // (server-seeded docs, agent writes) into fileLink nodes. Input/paste rules only
  // fire on live typing/pasting, so loaded content would otherwise render as raw
  // text. The node serializes back to `[[doc:uuid]]`, so the markdown is unchanged.
  // Runs as a deferred dispatch (not appendTransaction) so the conversion lands
  // in the Y.Doc — see deferred-auto-convert.ts for why.
  addProseMirrorPlugins() {
    const type = this.type;
    return [
      createDeferredAutoConvertPlugin('fileLinkAutoConvert', (state) => {
        const matches: { from: number; to: number; documentId: string }[] = [];
        state.doc.descendants((node, pos) => {
          if (!node.isText || !node.text || !node.text.includes('[[doc:')) return;
          FILE_LINK_SCAN_REGEX.lastIndex = 0;
          let m: RegExpExecArray | null;
          while ((m = FILE_LINK_SCAN_REGEX.exec(node.text)) !== null) {
            matches.push({ from: pos + m.index, to: pos + m.index + m[0].length, documentId: m[1] });
          }
        });
        if (matches.length === 0) return null;

        const tr = state.tr;
        // Replace right-to-left so earlier positions stay valid.
        for (const match of matches.sort((a, b) => b.from - a.from)) {
          tr.replaceWith(match.from, match.to, type.create({ documentId: match.documentId }));
        }
        return tr.docChanged ? tr : null;
      }),
    ];
  },
});