1import { ComposerContextId } from "@t3tools/contracts";
2import type {
3 ComposerContextClipboardFragment,
4 ComposerContextRecord,
5 ScopedThreadRef,
6} from "@t3tools/contracts";
7import {
8 COMPOSER_CONTEXT_CLIPBOARD_MIME,
9 decodeComposerContextFragment,
10 decodeComposerContextClipboardHtml,
11} from "@t3tools/shared/composerContextClipboard";
12import {
13 collectComposerContextReferences,
14 formatComposerContextReference,
15 replaceComposerContextReferences,
16 threadLinkContextReferences,
17} from "@t3tools/shared/composerContextReferences";
18
19import { readThreadShell } from "~/state/entities";
20
21const threadTitle = (ref: ScopedThreadRef) => readThreadShell(ref)?.title;
22
23// Matches ProseMirror's default clipboard parser, which a custom handlePaste bypasses.
24export function readPastedPlainText(clipboardData: Pick<DataTransfer, "getData">): string {
25 return clipboardData.getData("text/plain").replace(/\r\n?/g, "\n");
26}
27
28/** Pasted thread links count as context too, so they become chips like a copied chip does. */
29export function readPastedComposerContext(
30 clipboardData: Pick<DataTransfer, "getData">,
31): ComposerContextClipboardFragment | null {
32 const pastedText = clipboardData.getData("text/plain");
33 const decodedFragment =
34 decodeComposerContextFragment(clipboardData.getData(COMPOSER_CONTEXT_CLIPBOARD_MIME)) ??
35 decodeComposerContextClipboardHtml(clipboardData.getData("text/html"));
36 const threads = threadLinkContextReferences(
37 pastedText,
38 decodedFragment?.records ?? [],
39 threadTitle,
40 );
41 const source =
42 decodedFragment?.source ??
43 (threads.records[0] ? { environmentId: threads.records[0].environmentId } : null);
44 if (source === null) return null;
45 const pastedIds = new Set<string>(
46 collectComposerContextReferences(threads.text).map((occurrence) => occurrence.contextId),
47 );
48 const records = new Map<string, ComposerContextRecord>();
49 for (const record of [...(decodedFragment?.records ?? []), ...threads.records]) {
50 if (
51 record.kind === "preview-annotation" &&
52 !("payload" in record) &&
53 pastedIds.has(record.contextId) &&
54 record.screenshotContextId
55 ) {
56 pastedIds.add(record.screenshotContextId);
57 }
58 records.set(record.contextId, record);
59 }
60 return {
61 version: 1,
62 source,
63 records: [...records.values()].filter((record) => pastedIds.has(record.contextId)),
64 };
65}
66
67export function importPastedComposerText(
68 clipboardData: Pick<DataTransfer, "getData">,
69 importContextFragment?: (
70 fragment: ComposerContextClipboardFragment,
71 ) => ReadonlyMap<string, string>,
72): string {
73 const pastedText = readPastedPlainText(clipboardData);
74 const fragment = importContextFragment ? readPastedComposerContext(clipboardData) : null;
75 const rewrittenIds =
76 fragment && fragment.records.length > 0 ? importContextFragment!(fragment) : null;
77 if (!rewrittenIds || rewrittenIds.size === 0) return pastedText;
78 const nextId = (contextId: string) => {
79 const id = rewrittenIds.get(contextId);
80 return id ? ComposerContextId.make(id) : undefined;
81 };
82 const text = replaceComposerContextReferences(pastedText, (occurrence) => {
83 const contextId = nextId(occurrence.contextId);
84 return contextId
85 ? formatComposerContextReference({
86 ...occurrence,
87 contextId,
88 kind: occurrence.kind === "element" ? "preview-annotation" : occurrence.kind,
89 })
90 : occurrence.source;
91 });
92 // A thread the import refused, on a machine this agent cannot read, stays the link it was.
93 return threadLinkContextReferences(text, fragment?.records ?? [], threadTitle, (record) =>
94 nextId(record.contextId),
95 ).text;
96}