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;
});
}