From 7568ceb80d1f8a71bffc93d0ae234466e81de1cb Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 12 Apr 2026 12:13:25 -0700 Subject: [PATCH] feat: extract text editor and allow editing (#150) --- js/src/api/types.ts | 1 + js/src/features/compose/text-compose-step.tsx | 277 +--------------- js/src/features/compose/text-editor.tsx | 304 ++++++++++++++++++ .../features/particles/text-edit-overlay.tsx | 65 ++++ .../features/particles/text-particle-view.tsx | 66 +++- js/src/lib/firestore-particles.ts | 29 +- 6 files changed, 471 insertions(+), 271 deletions(-) create mode 100644 js/src/features/compose/text-editor.tsx create mode 100644 js/src/features/particles/text-edit-overlay.tsx diff --git a/js/src/api/types.ts b/js/src/api/types.ts index 5f75f72..38835a8 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -136,6 +136,7 @@ export type FileProperties = z.infer; export const TextPropertiesSchema = z.object({ content: z.string(), + edited_at: z.coerce.date().optional(), }); export type TextProperties = z.infer; diff --git a/js/src/features/compose/text-compose-step.tsx b/js/src/features/compose/text-compose-step.tsx index 1eab691..7b52299 100644 --- a/js/src/features/compose/text-compose-step.tsx +++ b/js/src/features/compose/text-compose-step.tsx @@ -1,14 +1,5 @@ -import { useEffect, useRef, useCallback, useState } from "react"; -import { Paperclip } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useAllLinkMetadata } from "@/hooks/use-link-metadata"; -import { AttachmentStrip } from "@/features/compose/attachment-strip"; +import { TextEditor } from "@/features/compose/text-editor"; import type { PendingAttachment } from "@/features/compose/attachment-strip"; -import { Button } from "@/components/ui/button"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import rehypeHighlight from "rehype-highlight"; -import "highlight.js/styles/github-dark.css"; interface TextComposeStepProps { textContent: string; @@ -27,74 +18,6 @@ interface TextComposeStepProps { }; } -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" }; -} - -const markdownComponents: React.ComponentProps["components"] = { - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - h4: ({ children }) =>

{children}

, - h5: ({ children }) =>
{children}
, - h6: ({ children }) =>
{children}
, - p: ({ children }) =>

{children}

, - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - a: ({ href, children }) => ( - - {children} - - ), - code: ({ className, children, ...props }) => { - const isBlock = className?.startsWith("language-"); - if (isBlock) { - return ( - - {children} - - ); - } - return ( - - {children} - - ); - }, - pre: ({ children }) => ( -
-      {children}
-    
- ), - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , -}; - -function MarkdownPreview({ content }: { content: string }) { - return ( -
    - - {content} - -
    - ); -} - export function TextComposeStep({ textContent, onTextChange, @@ -106,190 +29,20 @@ export function TextComposeStep({ isDragging, dropZoneProps, }: TextComposeStepProps) { - const textareaRef = useRef(null); - const [previewMode, setPreviewMode] = useState(false); - const [forceCardMode, setForceCardMode] = useState(false); - - // Debounce URL detection to avoid fetching on every keystroke - const [debouncedText, setDebouncedText] = useState(textContent); - useEffect(() => { - const t = setTimeout(() => setDebouncedText(textContent), 500); - return () => clearTimeout(t); - }, [textContent]); - const linkPreviews = useAllLinkMetadata(debouncedText); - - const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0; - const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode; - - useEffect(() => { - if (!previewMode) { - // Small timeout so the textarea is mounted before focusing - const t = setTimeout(() => { - const el = textareaRef.current; - if (el) { - el.focus(); - el.selectionStart = el.selectionEnd = el.value.length; - } - }, 0); - return () => clearTimeout(t); - } - }, [previewMode, forceCardMode, immersive]); - - useEffect(() => { - textareaRef.current?.focus(); - }, []); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - onCancel(); - } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - if (textContent.trim()) onAdvance(); - } else if (e.key === "m" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - setForceCardMode(true); - } - }, - [onCancel, onAdvance, textContent], - ); - - const strip = ( - ); - - const keyboardHints = ( -
    - - - Esc - {" "} - cancel - - - - ⌘+Enter - {" "} - next - - {immersive && ( - - - ⌘+M - {" "} - markdown - - )} - - - -
    - ); - - if (immersive) { - const style = getImmersiveTextStyle(textContent.length); - return ( -
    -
    -