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