Files
llink/js/desktop/src/features/compose/text-editor.tsx
T

220 lines
7.1 KiB
TypeScript

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';
import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint';
import { MarkdownEditor } from '@/features/compose/markdown-editor';
export interface TextEditorAttachmentProps {
attachments: PendingAttachment[];
onRemoveAttachment: (id: string) => void;
onAddFiles: () => void;
isDragging: boolean;
dropZoneProps: {
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
};
}
interface TextEditorProps {
textContent: string;
onTextChange: (text: string) => void;
onSubmit: () => void;
onCancel: () => void;
/** Label on the ⌘+Enter hint. Defaults to "next". */
submitHint?: string;
/** When omitted, the editor renders without attachment support (no attach button, no drop zone, no strip). */
attachmentProps?: TextEditorAttachmentProps;
}
const IMMERSIVE_CHAR_LIMIT = 120;
export function TextEditor({
textContent,
onTextChange,
onSubmit,
onCancel,
submitHint = 'next',
attachmentProps,
}: TextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 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(() => {
const t = setTimeout(() => setDebouncedText(textContent), 500);
return () => clearTimeout(t);
}, [textContent]);
const linkPreviews = useAllLinkMetadata(debouncedText);
const attachmentCount = attachmentProps?.attachments.length ?? 0;
const hasEnrichments = attachmentCount > 0 || linkPreviews.length > 0;
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 on mount.
// The card-mode editor manages its own focus.
useEffect(() => {
if (!immersive) return;
const t = setTimeout(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.selectionStart = el.selectionEnd = el.value.length;
}
}, 0);
return () => clearTimeout(t);
}, [immersive]);
// Attached in the capture phase so our shortcuts win before the card-mode
// editor's own key handling (e.g. ⌘+Enter must submit, not insert a break).
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onCancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
if (textContent.trim()) onSubmit();
} else if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
setCarded(true);
}
},
[onCancel, onSubmit, textContent],
);
const strip = attachmentProps && (
<AttachmentStrip
attachments={attachmentProps.attachments}
onRemove={attachmentProps.onRemoveAttachment}
onAddClick={attachmentProps.onAddFiles}
linkPreviews={linkPreviews}
/>
);
const keyboardHints = (
<div className="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={() => {
if (textContent.trim()) onSubmit();
}}
title={`Submit (or press ${metaKey}+Enter)`}
>
{submitHint}
</KeyHint>
{immersive && (
<KeyHint
keys={`${metaKey}+M`}
onClick={() => setCarded(true)}
title={`Switch to markdown editor (or press ${metaKey}+M)`}
>
markdown
</KeyHint>
)}
{attachmentProps && (
<span>
<Button
variant="ghost"
type="button"
onClick={(e) => {
e.stopPropagation();
attachmentProps.onAddFiles();
}}
title="Attach files"
>
<Paperclip className="size-4" />
attach
</Button>
</span>
)}
</div>
);
const dropZoneProps = attachmentProps?.dropZoneProps;
const isDragging = attachmentProps?.isDragging ?? false;
if (immersive) {
const style = getImmersiveTextStyle(textContent.length);
return (
<div
className={cn(
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
isDragging && 'ring-primary/40 ring-2 ring-inset',
)}
{...dropZoneProps}
>
<div className="flex w-full flex-col items-center justify-center gap-6 px-6">
<textarea
ref={textareaRef}
value={textContent}
onChange={(e) => onTextChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className={cn(
'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
style.size,
style.weight,
)}
rows={4}
/>
</div>
{keyboardHints}
</div>
);
}
return (
<div
className={cn(
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
isDragging && 'ring-primary/40 ring-2 ring-inset',
)}
{...dropZoneProps}
onKeyDownCapture={handleKeyDown}
>
<div className="bg-card text-card-foreground border-border animate-in fade-in zoom-in-95 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 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. */}
<div className="min-h-0 flex-1">
<MarkdownEditor
value={textContent}
onChange={onTextChange}
placeholder="Hit / to see options"
/>
</div>
{hasEnrichments && strip && (
<div className="border-border shrink-0 border-t px-5 py-3">
{strip}
</div>
)}
</div>
{keyboardHints}
</div>
);
}