Skip to content

Commit d26404d

Browse files
committed
obliterate tiptap out of existence
1 parent 8400f81 commit d26404d

File tree

16 files changed

+992
-1187
lines changed

16 files changed

+992
-1187
lines changed

apps/array/src/renderer/features/message-editor/components/MessageEditor.tsx

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@ import "./message-editor.css";
22
import { ModelSelector } from "@features/sessions/components/ModelSelector";
33
import { ArrowUp, Paperclip, Stop } from "@phosphor-icons/react";
44
import { Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
5-
import type { JSONContent } from "@tiptap/core";
6-
import { EditorContent } from "@tiptap/react";
75
import { forwardRef, useImperativeHandle, useRef } from "react";
86
import {
9-
createEditorHandle,
10-
useMessageEditor,
11-
} from "../hooks/useMessageEditor";
7+
type EditorContent,
8+
type MentionChip,
9+
useContenteditableEditor,
10+
} from "../hooks/useContenteditableEditor";
1211
import { useMessageEditorStore } from "../stores/messageEditorStore";
1312
import { SuggestionPortal } from "./SuggestionPortal";
1413

@@ -17,7 +16,7 @@ export interface MessageEditorHandle {
1716
blur: () => void;
1817
clear: () => void;
1918
isEmpty: () => boolean;
20-
getContent: () => JSONContent | undefined;
19+
getContent: () => EditorContent;
2120
getText: () => string;
2221
}
2322

@@ -39,7 +38,7 @@ export const MessageEditor = forwardRef<
3938
(
4039
{
4140
sessionId,
42-
placeholder,
41+
placeholder = "Type a message... @ to mention files, / for commands",
4342
onSubmit,
4443
onBashCommand,
4544
onBashModeChange,
@@ -50,15 +49,30 @@ export const MessageEditor = forwardRef<
5049
ref,
5150
) => {
5251
const fileInputRef = useRef<HTMLInputElement>(null);
53-
const actions = useMessageEditorStore((s) => s.actions);
5452
const context = useMessageEditorStore((s) => s.contexts[sessionId]);
5553
const taskId = context?.taskId;
5654
const disabled = context?.disabled ?? false;
5755
const isLoading = context?.isLoading ?? false;
5856
const isCloud = context?.isCloud ?? false;
5957
const repoPath = context?.repoPath;
6058

61-
const { editor, isEmpty, isBashMode, submit } = useMessageEditor({
59+
const {
60+
editorRef,
61+
isEmpty,
62+
isBashMode,
63+
submit,
64+
focus,
65+
blur,
66+
clear,
67+
getText,
68+
getContent,
69+
insertChip,
70+
onInput,
71+
onKeyDown,
72+
onPaste,
73+
onCompositionStart,
74+
onCompositionEnd,
75+
} = useContenteditableEditor({
6276
sessionId,
6377
taskId,
6478
placeholder,
@@ -73,27 +87,27 @@ export const MessageEditor = forwardRef<
7387

7488
useImperativeHandle(
7589
ref,
76-
() => createEditorHandle(editor, sessionId, actions),
77-
[editor, sessionId, actions],
90+
() => ({
91+
focus,
92+
blur,
93+
clear,
94+
isEmpty: () => isEmpty,
95+
getContent,
96+
getText,
97+
}),
98+
[focus, blur, clear, isEmpty, getContent, getText],
7899
);
79100

80101
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
81102
const files = e.target.files;
82103
if (files && files.length > 0) {
83104
for (const file of Array.from(files)) {
84-
editor
85-
?.chain()
86-
.focus()
87-
.insertContent({
88-
type: "mention",
89-
attrs: {
90-
id: file.name,
91-
label: file.name,
92-
type: "file",
93-
},
94-
})
95-
.insertContent(" ")
96-
.run();
105+
const chip: MentionChip = {
106+
type: "file",
107+
id: file.name,
108+
label: file.name,
109+
};
110+
insertChip(chip);
97111
}
98112
onAttachFiles?.(Array.from(files));
99113
}
@@ -105,7 +119,7 @@ export const MessageEditor = forwardRef<
105119
const handleContainerClick = (e: React.MouseEvent) => {
106120
const target = e.target as HTMLElement;
107121
if (!target.closest("button")) {
108-
editor?.commands.focus();
122+
focus();
109123
}
110124
};
111125

@@ -117,7 +131,24 @@ export const MessageEditor = forwardRef<
117131
style={{ cursor: "text" }}
118132
>
119133
<div className="max-h-[200px] min-h-[30px] flex-1 overflow-y-auto font-mono text-sm">
120-
<EditorContent editor={editor} />
134+
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable is intentional for rich mention chips */}
135+
<div
136+
ref={editorRef}
137+
className="cli-editor outline-none"
138+
contentEditable={!disabled}
139+
suppressContentEditableWarning
140+
spellCheck={false}
141+
role="textbox"
142+
tabIndex={disabled ? -1 : 0}
143+
aria-multiline="true"
144+
aria-placeholder={placeholder}
145+
data-placeholder={placeholder}
146+
onInput={onInput}
147+
onKeyDown={onKeyDown}
148+
onPaste={onPaste}
149+
onCompositionStart={onCompositionStart}
150+
onCompositionEnd={onCompositionEnd}
151+
/>
121152
</div>
122153

123154
<SuggestionPortal sessionId={sessionId} />

apps/array/src/renderer/features/message-editor/components/SuggestionPortal.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect, useRef } from "react";
12
import { createPortal } from "react-dom";
23
import { useMessageEditorStore } from "../stores/messageEditorStore";
34
import { SuggestionList } from "./SuggestionList";
@@ -12,15 +13,41 @@ export function SuggestionPortal({ sessionId }: SuggestionPortalProps) {
1213
);
1314
const active = useMessageEditorStore((s) => s.suggestion.active);
1415
const position = useMessageEditorStore((s) => s.suggestion.position);
16+
const triggerExit = useMessageEditorStore((s) => s.suggestion.triggerExit);
17+
const popupRef = useRef<HTMLDivElement>(null);
1518

1619
const isActive = active && suggestionSessionId === sessionId;
1720

21+
useEffect(() => {
22+
if (!isActive) return;
23+
24+
const handleClickOutside = (event: MouseEvent) => {
25+
const target = event.target as Node;
26+
const popup = popupRef.current;
27+
28+
// Check if click is outside the popup
29+
if (popup && !popup.contains(target)) {
30+
// Also check if click is inside the editor (don't close if clicking in editor)
31+
const editor = document.querySelector(".cli-editor");
32+
if (!editor?.contains(target)) {
33+
// Use tiptap's exitSuggestion to properly close and reset plugin state
34+
triggerExit?.();
35+
}
36+
}
37+
};
38+
39+
// Use mousedown to catch the click before focus changes
40+
document.addEventListener("mousedown", handleClickOutside);
41+
return () => document.removeEventListener("mousedown", handleClickOutside);
42+
}, [isActive, triggerExit]);
43+
1844
if (!isActive) {
1945
return null;
2046
}
2147

2248
return createPortal(
2349
<div
50+
ref={popupRef}
2451
data-suggestion-popup
2552
style={{
2653
position: "fixed",

apps/array/src/renderer/features/message-editor/components/message-editor.css

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* Message Editor Styles */
22

3-
/* Editor container */
3+
/* Editor container - contenteditable */
44
.cli-editor {
55
font-family: monospace;
66
background-color: transparent;
@@ -12,32 +12,27 @@
1212
overflow-wrap: break-word;
1313
word-wrap: break-word;
1414
word-break: break-word;
15+
white-space: pre-wrap;
16+
min-height: 1.5em;
1517
}
1618

17-
.cli-editor.ProseMirror {
18-
overflow-x: hidden;
19-
}
20-
21-
.cli-editor.ProseMirror p {
22-
margin: 0;
23-
overflow-wrap: break-word;
24-
word-wrap: break-word;
25-
word-break: break-word;
19+
/* Placeholder for empty contenteditable */
20+
.cli-editor:empty::before {
21+
content: attr(data-placeholder);
22+
color: var(--gray-11);
23+
pointer-events: none;
2624
}
2725

28-
/* Placeholder */
29-
.cli-editor.ProseMirror.ProseMirror-focused
30-
p.is-editor-empty:first-child::before {
26+
/* Hide placeholder when focused and has content */
27+
.cli-editor:focus:not(:empty)::before {
3128
content: "";
3229
}
3330

34-
.cli-editor.ProseMirror:not(.ProseMirror-focused)
35-
p.is-editor-empty:first-child::before {
36-
color: var(--gray-11);
37-
content: attr(data-placeholder);
38-
float: left;
39-
height: 0;
40-
pointer-events: none;
31+
/* Mention chip - base styles */
32+
.mention-chip {
33+
display: inline;
34+
user-select: all;
35+
cursor: default;
4136
}
4237

4338
/* File mention */

0 commit comments

Comments
 (0)