CellEditor.tsx8.5 KBView on GitHub
'use client';

/**
 * The open cell's editor: ONE single-paragraph TipTap instance, mounted when a cell is opened
 * for editing and destroyed when it closes (apps/mail/docs/table-documents.md §3.2 step 10).
 *
 * ── Why an editor at all ──
 * So that typing `@` in a table cell runs the SAME suggestion extension, hits the same
 * search cache, and inserts the same node as typing `@` in a prose paragraph. That is why
 * the extension list comes from `useRichTextExtensions` — the hook the document editor and
 * the chat composer already use — rather than from a second wiring of the same three
 * factories (`createConversationMention`, `createEventMention`,
 * `createFileLinkSuggestionExtension`): a re-wiring is a thing that can drift, a shared hook
 * is not.
 *
 * ── Why exactly one ──
 * ProseMirror's virtualization and descriptor-tree costs scale with the number of rows
 * rendered AS EDITORS. Mounting only on the cell the selection has OPENED makes that number
 * one, so they never apply. The editor owns its text and nothing else: the cell shell around
 * it owns width, borders and the selection outline, which is what keeps opening a cell from
 * shifting anything by a pixel.
 *
 * ── Keyboard ──
 * `Escape` reverts, `Enter` commits and moves down, `Tab` commits and moves right (Shift
 * reverses either direction) — handled here rather than left to the editor, which would
 * otherwise swallow all three. The one
 * exception is while a suggestion popover is open: then those keys belong to the popover
 * (Enter picks, Escape closes), so the guard below defers to it.
 */

import { useMemo, useRef } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import DocumentExtension from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import type { EditorView } from '@tiptap/pm/view';

import { cn } from '@/lib/utils';
import { useRichTextExtensions } from '@/modules/documents/use-rich-text-extensions';
import { cellValueToContent, contentToCellValue } from './cell-content';
import type { EditExit } from './cell-selection';

/**
 * Where the selection goes after a commit. `null` leaves it on this cell — a click away, or a
 * commit with no directional key behind it.
 */
export type CellCommitMove = EditExit;

export interface CellEditorProps {
  ariaLabel: string;
  /** The cell's stored string, tokens and all. */
  value: string;
  /**
   * The character that opened the editor, when the edit began by TYPING. Spreadsheets replace
   * the cell with what you typed, so a seed opens the editor holding just that character
   * rather than the old value — and typing over a cell needs no separate "clear first" step.
   */
  seed?: string | null;
  /** Classes for the editor's own box. The cell shell owns width and borders; this owns text. */
  className?: string;
  /**
   * Called once with the serialized value.
   *
   * `dirty` is whether the value differs from what the editor OPENED with. The caller must use
   * this rather than comparing `next` to its own current `value`: the editor is deliberately
   * never re-seeded (so a remote write cannot move the cursor), so its content reflects the
   * value at mount, and the caller's `value` may have moved on underneath it.
   */
  onCommit: (next: string, move: CellCommitMove, dirty: boolean) => void;
  /** Escape — leave edit mode without writing. */
  onCancel: () => void;
}

export function CellEditor({
  ariaLabel,
  value,
  seed,
  className,
  onCommit,
  onCancel,
}: CellEditorProps) {
  const extensions = useRichTextExtensions();
  // The value the editor opened with. A ref so an agent writing this same cell mid-edit
  // cannot re-seed the editor under the cursor. A typed seed REPLACES that value, which is
  // also what makes the commit dirty — typing `x` over `x` still leaves the cell as it was.
  const initialValue = useRef(seed ?? value);
  // Commit exactly once: `Enter` commits, and unmounting then fires blur.
  const committed = useRef(false);
  // Latest handlers, read at event time. The editor's `editorProps` are captured once (the
  // editor is deliberately not recreated per render), so calling through refs is what keeps
  // a keystroke from reaching a stale `onCommit`.
  const handlers = useRef({ onCommit, onCancel });
  handlers.current = { onCommit, onCancel };

  const editorExtensions = useMemo(
    () => [
      // A one-paragraph document IS the single-line constraint: with `content: 'paragraph'`
      // there is no second block for Enter or a paste to create.
      DocumentExtension.extend({ content: 'paragraph' }),
      Paragraph,
      Text,
      ...extensions,
    ],
    [extensions],
  );

  const commitRef = useRef<(move: CellCommitMove) => void>(() => {});

  // No dependency array: the editor is created ONCE per mount and lives exactly as long as
  // the cell has focus. Recreating it when the extension array's identity changed would
  // destroy the cursor mid-edit — and, because `useRichTextExtensions` memoizes off hook
  // identities, would loop if any of those ever became unstable.
  const editor = useEditor({
    extensions: editorExtensions,
    content: cellValueToContent(initialValue.current),
    autofocus: 'end',
    immediatelyRender: true,
    editorProps: {
      attributes: {
        // `pre-wrap`, not `nowrap`: the editor is the surface that shows a value in FULL —
        // inline for a wrapping column, in a popover over the grid for a clipping one — so it
        // is the one place a long value must be allowed to break onto further lines.
        class:
          'w-full whitespace-pre-wrap break-words outline-none [&_p]:m-0 [&_p]:leading-5',
        'aria-label': ariaLabel,
      },
      handleKeyDown: (view, event) => {
        if (isSuggestionActive(view)) return false;
        const handled = event.key === 'Escape' || event.key === 'Enter' || event.key === 'Tab';
        if (!handled) return false;
        event.preventDefault();
        // The grid root listens for these keys too, and this editor is INSIDE it. ProseMirror's
        // listener sits on the editor element and runs first, so by the time the event reached
        // the grid the edit had already closed — and the grid then read Enter as "open the cell
        // below". Stopping here is what makes "the open editor owns the keyboard" true rather
        // than merely intended.
        event.stopPropagation();
        if (event.key === 'Escape') {
          committed.current = true;
          handlers.current.onCancel();
          return true;
        }
        // Shift reverses the direction, the way it does everywhere else in a grid.
        if (event.key === 'Enter') commitRef.current(event.shiftKey ? 'up' : 'down');
        else commitRef.current(event.shiftKey ? 'left' : 'right');
        return true;
      },
    },
    onBlur: () => commitRef.current(null),
  });

  commitRef.current = (move) => {
    if (committed.current) return;
    committed.current = true;
    const next = contentToCellValue(editor?.getJSON());
    // Dirtiness is decided HERE, against what the editor opened with — the caller cannot do it,
    // because by commit time its `value` prop may hold a remote write the editor never saw.
    // Comparing `next` to that live value inverted the intended guard into "write precisely
    // when someone else changed it": clicking into a cell an agent then filled, and clicking
    // away without typing, wrote the stale text back over the agent's value and flushed it to
    // the server. During a fan-out — the workflow this grid exists for — that was every cell.
    handlers.current.onCommit(next, move, next !== initialValue.current);
  };

  return (
    <EditorContent
      editor={editor}
      data-cell-editor=""
      className={cn('w-full cursor-text overflow-y-auto', className)}
    />
  );
}

/**
 * True while any `@tiptap/suggestion` plugin has an open match.
 *
 * Duck-typed on the plugin state rather than on a plugin key, because the three suggestion
 * plugin keys (`conversationMentionSuggestion`, `eventMentionSuggestion`,
 * `fileLinkSuggestion`) are module-private to their factories. Every Suggestion plugin
 * stores `{ active, range, query, … }`, so `active === true` is the contract — and a fourth
 * suggestion extension is covered the moment it is added, with no list to keep in sync.
 */
function isSuggestionActive(view: EditorView): boolean {
  return view.state.plugins.some((plugin) => {
    const state = plugin.getState(view.state) as { active?: unknown } | undefined;
    return !!state && state.active === true;
  });
}