fix: text compose papercuts (#270)

* make fixes

* cleanup
This commit was merged in pull request #270.
This commit is contained in:
Arjun Patel
2026-06-11 12:08:34 -07:00
committed by GitHub
parent 66d53497c5
commit ca3bbf204e
12 changed files with 220 additions and 60 deletions
+17 -16
View File
@@ -1,6 +1,8 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Paperclip } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getImmersiveTextStyle } from '@/lib/immersive-text';
import { hasMarkdownFormatting } from '@/lib/markdown';
import { metaKey } from '@/lib/platform';
import { useAllLinkMetadata } from '@/hooks/use-link-metadata';
import { AttachmentStrip } from '@/features/compose/attachment-strip';
@@ -35,12 +37,6 @@ interface TextEditorProps {
const IMMERSIVE_CHAR_LIMIT = 120;
function getImmersiveTextStyle(length: number) {
if (length < 70) return { size: 'text-5xl', weight: 'font-semibold' };
if (length < 130) return { size: 'text-3xl', weight: 'font-semibold' };
return { size: 'text-2xl', weight: 'font-normal' };
}
export function TextEditor({
textContent,
onTextChange,
@@ -50,7 +46,9 @@ export function TextEditor({
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [forceCardMode, setForceCardMode] = useState(false);
// Card mode latches: once the message needs the full editor, snapping back
// to immersive mid-edit would be jarring, so it stays for the session.
const [carded, setCarded] = useState(false);
const [debouncedText, setDebouncedText] = useState(textContent);
useEffect(() => {
@@ -61,13 +59,16 @@ export function TextEditor({
const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
const immersive =
textContent.length < IMMERSIVE_CHAR_LIMIT &&
!hasEnrichments &&
!forceCardMode;
const shouldCard =
!carded &&
(textContent.length >= IMMERSIVE_CHAR_LIMIT ||
hasEnrichments ||
hasMarkdownFormatting(textContent));
if (shouldCard) setCarded(true);
const immersive = !carded;
// Keep the immersive textarea focused with the caret at the end when we
// (re)enter it. The card-mode editor manages its own focus.
// Keep the immersive textarea focused with the caret at the end on mount.
// The card-mode editor manages its own focus.
useEffect(() => {
if (!immersive) return;
const t = setTimeout(() => {
@@ -95,7 +96,7 @@ export function TextEditor({
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
setForceCardMode(true);
setCarded(true);
}
},
[onCancel, onSubmit, textContent],
@@ -127,7 +128,7 @@ export function TextEditor({
{immersive && (
<KeyHint
keys={`${metaKey}+M`}
onClick={() => setForceCardMode(true)}
onClick={() => setCarded(true)}
title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown
@@ -194,7 +195,7 @@ export function TextEditor({
{...dropZoneProps}
onKeyDownCapture={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl">
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200">
{/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */}