Skip to content

Commit 2c3a3b1

Browse files
authored
fix(message-editor): restore pasted text chip expansion
Keep repeat-paste undo while restoring click-to-expand for pasted text chips created in the editor. Generated-By: PostHog Code Task-Id: 184d0714-e375-4e46-b080-685247045bcb
1 parent fc33dfb commit 2c3a3b1

5 files changed

Lines changed: 199 additions & 13 deletions

File tree

packages/ui/src/features/message-editor/tiptap/MentionChipNode.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface MentionChipAttrs {
2727
skillName?: string;
2828
}
2929

30+
export interface MentionChipOptions {
31+
getPastedText: (chipId: string) => string | null;
32+
forgetPastedText: (chipId: string) => void;
33+
}
34+
3035
declare module "@tiptap/core" {
3136
interface Commands<ReturnType> {
3237
mentionChip: {
@@ -40,13 +45,20 @@ declare module "@tiptap/core" {
4045
}
4146
}
4247

43-
export const MentionChipNode = Node.create({
48+
export const MentionChipNode = Node.create<MentionChipOptions>({
4449
name: "mentionChip",
4550
group: "inline",
4651
inline: true,
4752
selectable: true,
4853
atom: true,
4954

55+
addOptions() {
56+
return {
57+
getPastedText: () => null,
58+
forgetPastedText: () => {},
59+
};
60+
},
61+
5062
addAttributes() {
5163
return {
5264
type: { default: "file" as ChipType },
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { fireEvent, render, screen } from "@testing-library/react";
2+
import type { NodeViewProps } from "@tiptap/react";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
import { usePasteUndoStore } from "../pasteUndoStore";
5+
import { MentionChipView } from "./MentionChipView";
6+
7+
vi.mock("@posthog/quill", () => ({
8+
Chip: ({
9+
children,
10+
onClick,
11+
}: React.PropsWithChildren<{ onClick?: () => void }>) => (
12+
<>
13+
{children}
14+
<button type="button" onClick={onClick}>
15+
Expand pasted text
16+
</button>
17+
</>
18+
),
19+
}));
20+
21+
vi.mock("@posthog/ui/primitives/Tooltip", () => ({
22+
Tooltip: ({ children }: React.PropsWithChildren) => children,
23+
}));
24+
25+
vi.mock("@tiptap/react", async (importOriginal) => {
26+
const actual = await importOriginal<typeof import("@tiptap/react")>();
27+
return {
28+
...actual,
29+
NodeViewWrapper: ({ children }: React.PropsWithChildren) => (
30+
<span>{children}</span>
31+
),
32+
};
33+
});
34+
35+
describe("MentionChipView", () => {
36+
beforeEach(() => {
37+
usePasteUndoStore.getState().setUndoableChipId(null);
38+
});
39+
40+
it("expands pasted text when the chip is clicked", () => {
41+
const content = "first line\nsecond line";
42+
const forgetPastedText = vi.fn();
43+
const insertText = vi.fn();
44+
const chain = {
45+
focus: vi.fn(),
46+
command: vi.fn(),
47+
run: vi.fn(),
48+
};
49+
chain.focus.mockReturnValue(chain);
50+
chain.command.mockImplementation(
51+
(
52+
callback: (props: { tr: { insertText: typeof insertText } }) => boolean,
53+
) => {
54+
callback({ tr: { insertText } });
55+
return chain;
56+
},
57+
);
58+
chain.run.mockReturnValue(true);
59+
60+
render(
61+
<MentionChipView
62+
{...({
63+
node: {
64+
attrs: {
65+
type: "file",
66+
id: "/tmp/pasted.txt",
67+
label: "Pasted text #1 (2 lines)",
68+
pastedText: true,
69+
chipId: "paste-1",
70+
},
71+
nodeSize: 1,
72+
},
73+
getPos: () => 4,
74+
editor: { chain: () => chain },
75+
extension: {
76+
options: {
77+
getPastedText: () => content,
78+
forgetPastedText,
79+
},
80+
},
81+
selected: false,
82+
} as unknown as NodeViewProps)}
83+
/>,
84+
);
85+
86+
fireEvent.click(screen.getByRole("button", { name: "Expand pasted text" }));
87+
88+
expect(insertText).toHaveBeenCalledWith(content, 4, 5);
89+
expect(chain.run).toHaveBeenCalled();
90+
expect(forgetPastedText).toHaveBeenCalledWith("paste-1");
91+
});
92+
});

packages/ui/src/features/message-editor/tiptap/MentionChipView.tsx

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ import {
1111
XIcon,
1212
} from "@phosphor-icons/react";
1313
import { Chip } from "@posthog/quill";
14+
import { useSettingsStore as useFeatureSettingsStore } from "@posthog/ui/features/settings/settingsStore";
1415
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
1516
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
1617
import { usePasteUndoStore } from "../pasteUndoStore";
17-
import type { ChipType, MentionChipAttrs } from "./MentionChipNode";
18+
import type {
19+
ChipType,
20+
MentionChipAttrs,
21+
MentionChipOptions,
22+
} from "./MentionChipNode";
1823

1924
const chipBase = "group/chip relative top-px active:translate-y-0 pl-1";
2025

@@ -67,16 +72,20 @@ function DefaultChip({
6772
label,
6873
chipId,
6974
pastedText,
75+
canExpandPastedText,
7076
selected,
7177
onRemove,
78+
onExpandPastedText,
7279
}: {
7380
type: string;
7481
id: string;
7582
label: string;
7683
chipId: string | null;
7784
pastedText: boolean;
85+
canExpandPastedText: boolean;
7886
selected: boolean;
7987
onRemove: () => void;
88+
onExpandPastedText: () => void;
8089
}) {
8190
const undoableChipId = usePasteUndoStore((state) => state.undoableChipId);
8291
const canUndoPaste =
@@ -87,13 +96,20 @@ function DefaultChip({
8796
const isFolder = type === "folder";
8897
const isGithubRef = type === "github_issue" || type === "github_pr";
8998
const canOpenUrl = isGithubRef && /^https:\/\//.test(id);
99+
const isClickable = canOpenUrl || canExpandPastedText;
90100

91101
const chipContent = (
92102
<Chip
93103
size="xs"
94104
contentEditable={false}
95-
onClick={canOpenUrl ? () => window.open(id, "_blank") : undefined}
96-
className={`${chipBase} max-w-full whitespace-nowrap ${isGithubRef ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`}
105+
onClick={
106+
canOpenUrl
107+
? () => window.open(id, "_blank")
108+
: canExpandPastedText
109+
? onExpandPastedText
110+
: undefined
111+
}
112+
className={`${chipBase} max-w-full whitespace-nowrap ${isClickable ? "cursor-pointer!" : "cursor-default! active:translate-y-0!"} ${isCommand ? "cli-slash-command" : "cli-file-mention"} ${selected ? selectedRing : ""}`}
97113
>
98114
<IconCloseButton type={type as ChipType} onRemove={onRemove} />
99115
{isGithubRef ? (
@@ -105,11 +121,12 @@ function DefaultChip({
105121
);
106122

107123
if (isFile || isFolder) {
108-
return (
109-
<Tooltip content={canUndoPaste ? "Paste again to expand as text" : id}>
110-
{chipContent}
111-
</Tooltip>
112-
);
124+
const tooltip = canExpandPastedText
125+
? canUndoPaste
126+
? "Click or paste again to expand as text"
127+
: "Click to expand as text"
128+
: id;
129+
return <Tooltip content={tooltip}>{chipContent}</Tooltip>;
113130
}
114131

115132
return chipContent;
@@ -119,10 +136,15 @@ export function MentionChipView({
119136
node,
120137
getPos,
121138
editor,
139+
extension,
122140
selected,
123141
}: NodeViewProps) {
124142
const { type, id, label, pastedText, chipId } =
125143
node.attrs as MentionChipAttrs;
144+
const { getPastedText, forgetPastedText } =
145+
extension.options as MentionChipOptions;
146+
const canExpandPastedText =
147+
pastedText && chipId != null && getPastedText(chipId) !== null;
126148

127149
const handleRemove = () => {
128150
const pos = getPos();
@@ -132,6 +154,28 @@ export function MentionChipView({
132154
.focus()
133155
.deleteRange({ from: pos, to: pos + node.nodeSize })
134156
.run();
157+
if (chipId) forgetPastedText(chipId);
158+
};
159+
160+
const handleExpandPastedText = () => {
161+
if (!chipId) return;
162+
const content = getPastedText(chipId);
163+
if (content === null) return;
164+
165+
const pos = getPos();
166+
if (pos == null) return;
167+
168+
editor
169+
.chain()
170+
.focus()
171+
.command(({ tr }) => {
172+
tr.insertText(content, pos, pos + node.nodeSize);
173+
return true;
174+
})
175+
.run();
176+
forgetPastedText(chipId);
177+
usePasteUndoStore.getState().setUndoableChipId(null);
178+
useFeatureSettingsStore.getState().markHintLearned("paste-as-file");
135179
};
136180

137181
return (
@@ -142,8 +186,10 @@ export function MentionChipView({
142186
label={label}
143187
chipId={chipId ?? null}
144188
pastedText={pastedText}
189+
canExpandPastedText={canExpandPastedText}
145190
selected={selected}
146191
onRemove={handleRemove}
192+
onExpandPastedText={handleExpandPastedText}
147193
/>
148194
</NodeViewWrapper>
149195
);

packages/ui/src/features/message-editor/tiptap/extensions.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import type { AnyExtension } from "@tiptap/core";
12
import Placeholder from "@tiptap/extension-placeholder";
23
import StarterKit from "@tiptap/starter-kit";
34
import { createCommandMention } from "./CommandMention";
45
import { createFileMention } from "./FileMention";
56
import { createIssueMention } from "./IssueMention";
6-
import { MentionChipNode } from "./MentionChipNode";
7+
import { MentionChipNode, type MentionChipOptions } from "./MentionChipNode";
78

89
export interface EditorExtensionsOptions {
910
sessionId: string;
1011
placeholder?: string;
1112
fileMentions?: boolean;
1213
issueMentions?: boolean;
1314
commands?: boolean;
15+
getPastedText?: MentionChipOptions["getPastedText"];
16+
forgetPastedText?: MentionChipOptions["forgetPastedText"];
1417
}
1518

1619
export function getEditorExtensions(options: EditorExtensionsOptions) {
@@ -20,9 +23,11 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
2023
fileMentions = true,
2124
issueMentions = true,
2225
commands = true,
26+
getPastedText = () => null,
27+
forgetPastedText = () => {},
2328
} = options;
2429

25-
const extensions = [
30+
const extensions: AnyExtension[] = [
2631
StarterKit.configure({
2732
heading: false,
2833
blockquote: false,
@@ -37,7 +42,7 @@ export function getEditorExtensions(options: EditorExtensionsOptions) {
3742
code: false,
3843
}),
3944
Placeholder.configure({ placeholder }),
40-
MentionChipNode,
45+
MentionChipNode.configure({ getPastedText, forgetPastedText }),
4146
];
4247

4348
if (fileMentions) {

packages/ui/src/features/message-editor/tiptap/useTiptapEditor.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,13 @@ async function pasteTextAsFile(
114114
text: string,
115115
pasteCountRef: React.MutableRefObject<number>,
116116
tracked?: TrackedAutoConvertedPaste,
117+
rememberPastedText?: (chipId: string, text: string) => void,
117118
): Promise<void> {
118119
const result = await persistTextContent(text);
119120
if (tracked?.status === "canceled") return;
120121
pasteCountRef.current += 1;
121122
const lineCount = text.split("\n").length;
123+
if (tracked) rememberPastedText?.(tracked.chipId, text);
122124
insertChipWithTrailingSpace(view, {
123125
type: "file",
124126
id: result.path,
@@ -319,6 +321,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
319321
const lastAutoConvertedPasteRef = useRef<TrackedAutoConvertedPaste | null>(
320322
null,
321323
);
324+
const pastedTextByChipIdRef = useRef(new Map<string, string>());
322325
useEffect(() => {
323326
return () => {
324327
if (lastAutoConvertedPasteRef.current) {
@@ -339,6 +342,11 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
339342
placeholder,
340343
fileMentions,
341344
commands,
345+
getPastedText: (chipId) =>
346+
pastedTextByChipIdRef.current.get(chipId) ?? null,
347+
forgetPastedText: (chipId) => {
348+
pastedTextByChipIdRef.current.delete(chipId);
349+
},
342350
}),
343351
editable: !disabled,
344352
autofocus: autoFocus ? "end" : false,
@@ -368,7 +376,24 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
368376
useFeatureSettingsStore
369377
.getState()
370378
.markHintLearned("paste-inline");
371-
await pasteTextAsFile(view, text, pasteCountRef);
379+
const tracked: TrackedAutoConvertedPaste = {
380+
clipboardText: text,
381+
insertText: text,
382+
chipId: crypto.randomUUID(),
383+
kind: "file",
384+
status: "pending",
385+
};
386+
lastAutoConvertedPasteRef.current = tracked;
387+
usePasteUndoStore.getState().setUndoableChipId(tracked.chipId);
388+
await pasteTextAsFile(
389+
view,
390+
text,
391+
pasteCountRef,
392+
tracked,
393+
(chipId, pastedText) => {
394+
pastedTextByChipIdRef.current.set(chipId, pastedText);
395+
},
396+
);
372397
} catch (_error) {
373398
toast.error("Failed to paste as file attachment");
374399
}
@@ -549,6 +574,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
549574
)
550575
) {
551576
event.preventDefault();
577+
pastedTextByChipIdRef.current.delete(lastConverted.chipId);
552578
if (lastConverted.kind === "file") {
553579
useFeatureSettingsStore
554580
.getState()
@@ -652,6 +678,9 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
652678
effectiveText,
653679
pasteCountRef,
654680
tracked,
681+
(chipId, text) => {
682+
pastedTextByChipIdRef.current.set(chipId, text);
683+
},
655684
);
656685
if (tracked.status !== "canceled") {
657686
showPasteHint(
@@ -780,6 +809,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
780809
editor.commands.clearContent();
781810
prevBashModeRef.current = false;
782811
pasteCountRef.current = 0;
812+
pastedTextByChipIdRef.current.clear();
783813
setAttachments([]);
784814
draft.clearDraft();
785815
};
@@ -832,6 +862,7 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) {
832862
const clear = useCallback(() => {
833863
editor?.commands.clearContent();
834864
prevBashModeRef.current = false;
865+
pastedTextByChipIdRef.current.clear();
835866
setAttachments([]);
836867
draft.clearDraft();
837868
}, [editor, draft]);

0 commit comments

Comments
 (0)