220 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
}
|