feat: extract text editor and allow editing (#150)

This commit was merged in pull request #150.
This commit is contained in:
Arjun Patel
2026-04-12 12:13:25 -07:00
committed by GitHub
parent f03868e65b
commit 7568ceb80d
6 changed files with 471 additions and 271 deletions
@@ -0,0 +1,65 @@
import { useCallback, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import type { Particle } from "@/api/types";
import {
particlePath,
parseParticlePath,
toFirestoreDocPath,
type ParticlePath,
} from "@/lib/particle-path";
import { editTextParticleContent } from "@/lib/firestore-particles";
import { TextEditor } from "@/features/compose/text-editor";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextEditOverlayProps {
particle: TextParticle;
streamPath: ParticlePath;
onClose: () => void;
}
export function TextEditOverlay({
particle,
streamPath,
onClose,
}: TextEditOverlayProps) {
const [textContent, setTextContent] = useState(particle.properties.content);
const [saving, setSaving] = useState(false);
const handleSubmit = useCallback(async () => {
if (saving) return;
const trimmed = textContent.trim();
if (!trimmed) return;
if (trimmed === particle.properties.content) {
onClose();
return;
}
setSaving(true);
try {
const { networkId, segments } = parseParticlePath(streamPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [...segments, particle.id]),
);
await editTextParticleContent(docPath, trimmed);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
setSaving(false);
}
}, [saving, textContent, particle.properties.content, particle.id, streamPath, onClose]);
return createPortal(
<div className="fixed inset-0 z-[100]">
<TextEditor
textContent={textContent}
onTextChange={setTextContent}
onSubmit={handleSubmit}
onCancel={onClose}
submitHint="save"
/>
</div>,
document.body,
);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Pencil } from "lucide-react";
import type { Particle } from "@/api/types";
import type { ParticlePath } from "@/lib/particle-path";
import { cn } from "@/lib/utils";
@@ -10,6 +11,9 @@ import {
} from "@/components/link-preview-card";
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
import { ParticleAttachments } from "@/features/particles/particle-attachments";
import { TextEditOverlay } from "@/features/particles/text-edit-overlay";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useAuthStore } from "@/stores/auth-store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
@@ -144,6 +148,10 @@ export function TextParticleView({
const { attachments } = useParticleAttachments(streamPath, particle.id);
const urls = extractUrls(content);
const userId = useAuthStore((s) => s.user?.id);
const isCreator = !!userId && userId === particle.created_by_human_id;
const [isEditing, setIsEditing] = useState(false);
const hasLinks = urls.length > 0;
const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments;
@@ -157,7 +165,7 @@ export function TextParticleView({
}, [particle.id]);
useEffect(() => {
if (paused) return;
if (paused || isEditing) return;
const interval = setInterval(() => {
elapsedRef.current += TICK_MS / 1000;
@@ -171,18 +179,55 @@ export function TextParticleView({
}, TICK_MS);
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
}, [paused, isEditing, durationS, onEnded, onProgress, particle.id]);
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
const editButton = isCreator && !isEditing && (
<button
type="button"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
title="Edit"
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
>
<Pencil className="size-3.5" />
Edit
</button>
);
const editedLabel = particle.properties.edited_at && (
<span className="text-xs text-white/40">
edited <RelativeTimestamp date={particle.properties.edited_at} />
</span>
);
const editOverlay = isEditing && (
<TextEditOverlay
particle={particle}
streamPath={streamPath}
onClose={() => setIsEditing(false)}
/>
);
// Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) {
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<LinkPreviews entries={linkPreviews} />
{editedLabel && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
{editedLabel}
</div>
)}
{editButton}
{editOverlay}
</div>
);
}
@@ -191,7 +236,7 @@ export function TextParticleView({
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<p
className={cn(
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
@@ -201,13 +246,16 @@ export function TextParticleView({
>
{content}
</p>
{editedLabel}
{editButton}
{editOverlay}
</div>
);
}
// Mode 3: card layout
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className={cn(
"flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto overscroll-contain rounded-2xl bg-white/10 p-6 backdrop-blur-md",
@@ -218,12 +266,16 @@ export function TextParticleView({
"[&::-webkit-scrollbar-thumb]:hover:bg-white/50",
)}
>
<MarkdownContent content={content} className="select-text cursor-text" />
<MarkdownContent content={content} className="select-text cursor-text pb-3" />
{hasLinks && <LinkPreviews entries={linkPreviews} />}
{hasAttachments && <ParticleAttachments attachments={attachments} />}
{editedLabel}
</div>
{editButton}
{editOverlay}
</div>
);
}