Skip to content

Commit dd38d4a

Browse files
committed
Fix text editor wrapping and improve test reliability
- Fix bug where typing in middle of paragraph lost characters during rewrap - Replace setTimeout with deterministic waits in HardWrapPlugin tests - Remove legacy MarkdownTransitionPlugin and simplify initialization - Add tests for automatic wrapping on init (enabled/disabled) - Fix typos and improve null checking in RichTextEditor
1 parent b72fe3e commit dd38d4a

File tree

17 files changed

+342
-361
lines changed

17 files changed

+342
-361
lines changed

apps/desktop/src/components/codegen/CodegenInput.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,6 @@
249249
bind:this={editorRef}
250250
bind:value
251251
namespace="codegen-input"
252-
plaintext={true}
253252
styleContext="chat-input"
254253
placeholder="Use @ to reference files, ↓ and ↑ for prompt history"
255254
minHeight="4rem"

apps/desktop/src/components/editor/MessageEditor.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,6 @@
329329
namespace="CommitMessageEditor"
330330
{placeholder}
331331
bind:this={composer}
332-
plaintext={true}
333332
onError={(e) => console.warn('Editor error', e)}
334333
initialText={initialValue}
335334
onChange={handleChange}

apps/web/src/lib/components/chat/ChatInput.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,6 @@
276276
styleContext="chat-input"
277277
placeholder="Write your message"
278278
bind:this={richText.richTextEditor}
279-
plaintext={true}
280279
namespace="ChatInput"
281280
onError={console.error}
282281
onInput={(text) => messageHandler.update(text)}

apps/web/src/routes/(app)/[ownerSlug]/[projectSlug]/reviews/[branchId]/+page.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,6 @@
283283
<div class="summary-wrapper">
284284
<RichTextEditor
285285
namespace="review-description"
286-
plaintext={true}
287286
onError={console.error}
288287
styleContext="chat-input"
289288
initialText={branch.description}

packages/ui/src/lib/richText/RichTextEditor.svelte

Lines changed: 25 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
import { getCurrentText } from '$lib/richText/getText';
88
// import CodeBlockTypeAhead from '$lib/richText/plugins/CodeBlockTypeAhead.svelte';
99
import EmojiPlugin from '$lib/richText/plugins/Emoji.svelte';
10-
import PlainTextIndentPlugin from '$lib/richText/plugins/PlainTextIndentPlugin.svelte';
11-
import MarkdownTransitionPlugin from '$lib/richText/plugins/markdownTransition';
10+
import IndentPlugin from '$lib/richText/plugins/IndentPlugin.svelte';
1211
import OnChangePlugin, { type OnChangeCallback } from '$lib/richText/plugins/onChange.svelte';
1312
import OnInput, { type OnInputCallback } from '$lib/richText/plugins/onInput.svelte';
1413
import { insertTextAtCaret, setEditorText } from '$lib/richText/selection';
@@ -26,25 +25,14 @@
2625
Composer,
2726
ContentEditable,
2827
RichTextPlugin,
29-
ListPlugin,
30-
CheckListPlugin,
3128
AutoFocusPlugin,
3229
PlaceHolder,
33-
HashtagPlugin,
34-
PlainTextPlugin,
35-
AutoLinkPlugin,
36-
FloatingLinkEditorPlugin,
37-
CodeHighlightPlugin,
38-
CodeActionMenuPlugin,
3930
MarkdownShortcutPlugin,
40-
ALL_TRANSFORMERS,
41-
LinkPlugin,
4231
HistoryPlugin
4332
} from 'svelte-lexical';
4433
4534
interface Props {
4635
namespace: string;
47-
plaintext: boolean;
4836
onError: (error: unknown) => void;
4937
styleContext: 'client-editor' | 'chat-input';
5038
plugins?: Snippet;
@@ -70,7 +58,6 @@
7058
let {
7159
disabled,
7260
namespace,
73-
plaintext,
7461
onError,
7562
minHeight,
7663
maxHeight,
@@ -109,9 +96,6 @@
10996
let editorDiv: HTMLDivElement | undefined = $state();
11097
let emojiPlugin = $state<ReturnType<typeof EmojiPlugin>>();
11198
112-
// TODO: Change this plugin in favor of a toggle button.
113-
const markdownTransitionPlugin = new MarkdownTransitionPlugin(wrapCountValue);
114-
11599
const isDisabled = $derived(disabled ?? false);
116100
117101
$effect(() => {
@@ -128,24 +112,7 @@
128112
$effect(() => {
129113
if (composer) {
130114
const editor = composer.getEditor();
131-
markdownTransitionPlugin.setEditor(editor);
132-
}
133-
});
134-
135-
$effect(() => {
136-
markdownTransitionPlugin.setMarkdown(!plaintext);
137-
});
138-
139-
$effect(() => {
140-
if (wrapCountValue) {
141-
markdownTransitionPlugin.setMaxLength(wrapCountValue);
142-
}
143-
});
144-
145-
$effect(() => {
146-
if (composer) {
147-
const editor = composer.getEditor();
148-
const unregidterKeyDown = editor.registerCommand<KeyboardEvent | null>(
115+
const unregisterKeyDown = editor.registerCommand<KeyboardEvent | null>(
149116
KEY_DOWN_COMMAND,
150117
(e) => {
151118
if (emojiPlugin?.isBusy()) {
@@ -173,7 +140,7 @@
173140
);
174141
175142
return () => {
176-
unregidterKeyDown();
143+
unregisterKeyDown();
177144
unregisterFocus();
178145
unregisterBlur();
179146
};
@@ -187,6 +154,9 @@
187154
});
188155
189156
async function updateInitialtext(initialText: string | undefined) {
157+
if (!composer) return;
158+
159+
// Set initial text if provided and editor is empty
190160
if (initialText) {
191161
const currentText = await getPlaintext();
192162
if (currentText?.trim() === '') {
@@ -197,13 +167,15 @@
197167
198168
export function getPlaintext(): Promise<string | undefined> {
199169
return new Promise((resolve) => {
200-
if (composer) {
201-
const editor = composer.getEditor();
202-
editor?.read(() => {
203-
const text = getCurrentText(!plaintext, wrapCountValue);
204-
resolve(text);
205-
});
170+
if (!composer) {
171+
resolve(undefined);
172+
return;
206173
}
174+
const editor = composer.getEditor();
175+
editor.read(() => {
176+
const text = getCurrentText(true, wrapCountValue);
177+
resolve(text);
178+
});
207179
});
208180
}
209181
@@ -213,7 +185,8 @@
213185
resolve(0);
214186
return;
215187
}
216-
composer.getEditor()?.read(() => {
188+
const editor = composer.getEditor();
189+
editor.read(() => {
217190
const root = getRoot();
218191
const count = root.getChildren().filter(isParagraphNode).length;
219192
resolve(count);
@@ -226,7 +199,7 @@
226199
return;
227200
}
228201
const editor = composer.getEditor();
229-
editor?.update(() => {
202+
editor.update(() => {
230203
const root = getRoot();
231204
root.clear();
232205
});
@@ -239,7 +212,8 @@
239212
const editor = composer.getEditor();
240213
// We should be able to use `editor.focus()` here, but for some reason
241214
// it only works after the input has already been focused.
242-
editor.getRootElement()?.focus();
215+
const rootElement = editor.getRootElement();
216+
rootElement?.focus();
243217
}
244218
245219
export function wrapAll() {
@@ -282,7 +256,6 @@
282256
class="lexical-container lexical-{styleContext} scrollbar"
283257
bind:this={editorDiv}
284258
use:focusable={{ button: true }}
285-
class:plain-text={plaintext}
286259
class:disabled={isDisabled}
287260
style:min-height={minHeight}
288261
style:max-height={maxHeight}
@@ -305,7 +278,7 @@
305278
<EmojiPlugin bind:this={emojiPlugin} />
306279

307280
<OnChangePlugin
308-
markdown={!plaintext}
281+
markdown
309282
onChange={(newValue, changeUpToAnchor, textAfterAnchor) => {
310283
value = newValue;
311284
onChange?.(newValue, changeUpToAnchor, textAfterAnchor);
@@ -314,26 +287,13 @@
314287
/>
315288

316289
{#if onInput}
317-
<OnInput markdown={!plaintext} {onInput} maxLength={wrapCountValue} />
290+
<OnInput markdown {onInput} maxLength={wrapCountValue} />
318291
{/if}
319292

320-
{#if plaintext}
321-
<PlainTextPlugin />
322-
<PlainTextIndentPlugin />
323-
<!-- <CodeBlockTypeAhead /> -->
324-
<MarkdownShortcutPlugin transformers={[INLINE_CODE_TRANSFORMER]} />
325-
{:else}
326-
<AutoLinkPlugin />
327-
<CheckListPlugin />
328-
<CodeActionMenuPlugin anchorElem={editorDiv} />
329-
<CodeHighlightPlugin />
330-
<FloatingLinkEditorPlugin anchorElem={editorDiv} />
331-
<HashtagPlugin />
332-
<ListPlugin />
333-
<LinkPlugin />
334-
<MarkdownShortcutPlugin transformers={ALL_TRANSFORMERS} />
335-
<RichTextPlugin />
336-
{/if}
293+
<RichTextPlugin />
294+
<IndentPlugin />
295+
<MarkdownShortcutPlugin transformers={[INLINE_CODE_TRANSFORMER]} />
296+
337297
{#if autoFocus}
338298
<AutoFocusPlugin />
339299
{/if}
@@ -355,19 +315,6 @@
355315
background-color: var(--clr-bg-1);
356316
}
357317
358-
.editor-scroller {
359-
display: flex;
360-
z-index: 0;
361-
position: relative;
362-
flex-direction: column;
363-
height: 100%;
364-
overflow: auto;
365-
border: 0;
366-
outline: 0;
367-
/* It's unclear why the resizer is on by default on this element. */
368-
resize: none;
369-
}
370-
371318
.editor {
372319
z-index: -1;
373320
position: relative;

packages/ui/src/lib/richText/css/standard-theme.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
}
4646

4747
/* Remove paragraph margins in plain-text mode */
48-
.lexical-container.plain-text .StandardTheme__paragraph {
48+
.lexical-container .StandardTheme__paragraph {
4949
margin-bottom: 0;
5050
}
5151

packages/ui/src/lib/richText/linewrap.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,17 @@ export function parseBullet(text: string): Bullet | undefined {
9595
}
9696

9797
export function wrapIfNecessary({ node, maxLength }: { node: TextNode; maxLength: number }) {
98-
const line = node.getTextContent();
98+
const paragraph = node.getParent();
99+
100+
if (!$isParagraphNode(paragraph)) {
101+
console.warn('[wrapIfNecessary] Node parent is not a paragraph:', paragraph?.getType());
102+
return;
103+
}
104+
105+
// Get the full text content from the paragraph, not just the mutated text node
106+
// This is important when typing in the middle of text, as Lexical may split text nodes
107+
const line = paragraph.getTextContent();
108+
99109
if (line.length <= maxLength) {
100110
return;
101111
}
@@ -108,12 +118,6 @@ export function wrapIfNecessary({ node, maxLength }: { node: TextNode; maxLength
108118

109119
const bullet = parseBullet(line);
110120
const indent = bullet ? bullet.indent : parseIndent(line);
111-
const paragraph = node.getParent();
112-
113-
if (!$isParagraphNode(paragraph)) {
114-
console.warn('[wrapIfNecessary] Node parent is not a paragraph:', paragraph?.getType());
115-
return;
116-
}
117121

118122
const selection = getSelection();
119123
const selectionOffset = isRangeSelection(selection) ? selection.focus.offset : 0;
@@ -126,8 +130,28 @@ export function wrapIfNecessary({ node, maxLength }: { node: TextNode; maxLength
126130
bullet
127131
});
128132

129-
// Update current text node
130-
node.setTextContent(newLine);
133+
// Replace all text nodes in the paragraph with a single text node containing the wrapped text
134+
// This is important because Lexical may have split the text into multiple nodes during typing
135+
const children = paragraph.getChildren();
136+
const firstTextNode = children.find((child) => isTextNode(child)) as TextNode | undefined;
137+
138+
if (firstTextNode) {
139+
// Update the first text node with the new content
140+
firstTextNode.setTextContent(newLine);
141+
// Remove all other children
142+
for (const child of children) {
143+
if (child !== firstTextNode) {
144+
child.remove();
145+
}
146+
}
147+
} else {
148+
// Fallback: no text nodes found, create one
149+
const newTextNode = new TextNode(newLine);
150+
paragraph.append(newTextNode);
151+
}
152+
153+
// Get reference to the text node we'll use for cursor positioning
154+
const currentTextNode = firstTextNode || (paragraph.getFirstChild() as TextNode);
131155

132156
// If there's a remainder, create new paragraphs for it
133157
if (newRemainder) {
@@ -160,7 +184,7 @@ export function wrapIfNecessary({ node, maxLength }: { node: TextNode; maxLength
160184
// If cursor was in the first line
161185
if (remainingOffset <= newLine.length) {
162186
// Keep cursor in the current paragraph at the same position
163-
node.select(remainingOffset, remainingOffset);
187+
currentTextNode.select(remainingOffset, remainingOffset);
164188
} else {
165189
// Cursor should be in one of the wrapped paragraphs
166190
remainingOffset -= newLine.length + 1; // Account for the line and space

packages/ui/src/lib/richText/markdown.ts

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,7 @@
11
import { isWrappingExempt, parseBullet, parseIndent, wrapLine } from '$lib/richText/linewrap';
22
import { $convertToMarkdownString as convertToMarkdownString } from '@lexical/markdown';
3-
import {
4-
$getRoot as getRoot,
5-
type LexicalEditor,
6-
$createParagraphNode as createParagraphNode,
7-
$createTextNode as createTextNode
8-
} from 'lexical';
93
import { ALL_TRANSFORMERS } from 'svelte-lexical';
104

11-
export function updateEditorToRichText(editor: LexicalEditor | undefined) {
12-
editor?.update(() => {
13-
const text = getRoot().getTextContent();
14-
15-
// Clear the editor
16-
getRoot().clear();
17-
18-
// Split text into lines and create a paragraph for each line
19-
// This preserves blank lines unlike markdown conversion
20-
const lines = text.split('\n');
21-
22-
for (const line of lines) {
23-
const paragraph = createParagraphNode();
24-
const textNode = createTextNode(line);
25-
paragraph.append(textNode);
26-
getRoot().append(paragraph);
27-
}
28-
});
29-
}
30-
315
export function getMarkdownString(maxLength?: number): string {
326
const markdown = convertToMarkdownString(ALL_TRANSFORMERS, undefined, true);
337
return maxLength ? wrapIfNecessary(markdown, maxLength) : markdown;
@@ -71,7 +45,7 @@ export function wrapIfNecessary(markdown: string, maxLength: number): string {
7145
let remainder = '';
7246

7347
// We want to consider the modified line, and the remaining lines from
74-
// the same pagraph.
48+
// the same paragraph.
7549
const paragraphLength = getParagraphLength(lines.slice(i));
7650

7751
const { newLine, newRemainder } = wrapLine({
@@ -111,22 +85,3 @@ export function wrapIfNecessary(markdown: string, maxLength: number): string {
11185
}
11286
return newLines.join('\n');
11387
}
114-
115-
export function updateEditorToPlaintext(editor: LexicalEditor | undefined, maxLength?: number) {
116-
editor?.update(() => {
117-
const text = getMarkdownString(maxLength);
118-
if (text.length === 0) {
119-
return;
120-
}
121-
122-
const root = getRoot();
123-
root.clear();
124-
125-
// Create a separate paragraph for each line
126-
for (const line of text.split('\n')) {
127-
const paragraph = createParagraphNode();
128-
paragraph.append(createTextNode(line));
129-
root.append(paragraph);
130-
}
131-
});
132-
}

0 commit comments

Comments
 (0)