feat: extract text editor and allow editing (#150)
This commit was merged in pull request #150.
This commit is contained in:
@@ -136,6 +136,7 @@ export type FileProperties = z.infer<typeof FilePropertiesSchema>;
|
|||||||
|
|
||||||
export const TextPropertiesSchema = z.object({
|
export const TextPropertiesSchema = z.object({
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
|
edited_at: z.coerce.date().optional(),
|
||||||
});
|
});
|
||||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import { useEffect, useRef, useCallback, useState } from "react";
|
import { TextEditor } from "@/features/compose/text-editor";
|
||||||
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 type { PendingAttachment } from "@/features/compose/attachment-strip";
|
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 {
|
interface TextComposeStepProps {
|
||||||
textContent: string;
|
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<typeof ReactMarkdown>["components"] = {
|
|
||||||
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
|
|
||||||
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
|
|
||||||
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
|
|
||||||
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
|
|
||||||
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
|
|
||||||
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
|
|
||||||
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
|
|
||||||
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
|
||||||
em: ({ children }) => <em className="italic text-white">{children}</em>,
|
|
||||||
a: ({ href, children }) => (
|
|
||||||
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
code: ({ className, children, ...props }) => {
|
|
||||||
const isBlock = className?.startsWith("language-");
|
|
||||||
if (isBlock) {
|
|
||||||
return (
|
|
||||||
<code className={cn(className, "text-sm")} {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
pre: ({ children }) => (
|
|
||||||
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
|
|
||||||
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
|
|
||||||
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
|
|
||||||
blockquote: ({ children }) => (
|
|
||||||
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
|
|
||||||
{children}
|
|
||||||
</blockquote>
|
|
||||||
),
|
|
||||||
hr: () => <hr className="my-4 border-white/10" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
function MarkdownPreview({ content }: { content: string }) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
rehypePlugins={[rehypeHighlight]}
|
|
||||||
components={markdownComponents}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TextComposeStep({
|
export function TextComposeStep({
|
||||||
textContent,
|
textContent,
|
||||||
onTextChange,
|
onTextChange,
|
||||||
@@ -106,190 +29,20 @@ export function TextComposeStep({
|
|||||||
isDragging,
|
isDragging,
|
||||||
dropZoneProps,
|
dropZoneProps,
|
||||||
}: TextComposeStepProps) {
|
}: TextComposeStepProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
return (
|
||||||
const [previewMode, setPreviewMode] = useState(false);
|
<TextEditor
|
||||||
const [forceCardMode, setForceCardMode] = useState(false);
|
textContent={textContent}
|
||||||
|
onTextChange={onTextChange}
|
||||||
// Debounce URL detection to avoid fetching on every keystroke
|
onSubmit={onAdvance}
|
||||||
const [debouncedText, setDebouncedText] = useState(textContent);
|
onCancel={onCancel}
|
||||||
useEffect(() => {
|
submitHint="next"
|
||||||
const t = setTimeout(() => setDebouncedText(textContent), 500);
|
attachmentProps={{
|
||||||
return () => clearTimeout(t);
|
attachments,
|
||||||
}, [textContent]);
|
onRemoveAttachment,
|
||||||
const linkPreviews = useAllLinkMetadata(debouncedText);
|
onAddFiles,
|
||||||
|
isDragging,
|
||||||
const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0;
|
dropZoneProps,
|
||||||
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 = (
|
|
||||||
<AttachmentStrip
|
|
||||||
attachments={attachments}
|
|
||||||
onRemove={onRemoveAttachment}
|
|
||||||
onAddClick={onAddFiles}
|
|
||||||
linkPreviews={linkPreviews}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyboardHints = (
|
|
||||||
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
|
||||||
<span>
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
Esc
|
|
||||||
</kbd>{" "}
|
|
||||||
cancel
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
⌘+Enter
|
|
||||||
</kbd>{" "}
|
|
||||||
next
|
|
||||||
</span>
|
|
||||||
{immersive && (
|
|
||||||
<span>
|
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
||||||
⌘+M
|
|
||||||
</kbd>{" "}
|
|
||||||
markdown
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAddFiles();
|
|
||||||
}}
|
|
||||||
title="Attach files"
|
|
||||||
>
|
|
||||||
<Paperclip className="size-4" />
|
|
||||||
attach
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (immersive) {
|
|
||||||
const style = getImmersiveTextStyle(textContent.length);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
|
||||||
isDragging && "ring-2 ring-inset ring-white/30",
|
|
||||||
)}
|
|
||||||
{...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(
|
|
||||||
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
|
||||||
style.size,
|
|
||||||
style.weight,
|
|
||||||
)}
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{keyboardHints}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
|
||||||
isDragging && "ring-2 ring-inset ring-white/30",
|
|
||||||
)}
|
|
||||||
{...dropZoneProps}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
|
|
||||||
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPreviewMode(false)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
|
||||||
!previewMode
|
|
||||||
? "bg-white/15 text-white"
|
|
||||||
: "text-white/40 hover:text-white/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Write
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPreviewMode(true)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
|
||||||
previewMode
|
|
||||||
? "bg-white/15 text-white"
|
|
||||||
: "text-white/40 hover:text-white/60",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewMode ? (
|
|
||||||
<MarkdownPreview content={textContent} />
|
|
||||||
) : (
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={textContent}
|
|
||||||
onChange={(e) => onTextChange(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Type a message... (markdown supported)"
|
|
||||||
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasEnrichments && (
|
|
||||||
<div className="shrink-0 border-t border-white/10 pt-3">
|
|
||||||
{strip}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{keyboardHints}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
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 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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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<typeof ReactMarkdown>["components"] = {
|
||||||
|
h1: ({ children }) => <h1 className="mb-3 text-3xl font-bold text-white">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="mb-2 text-2xl font-semibold text-white">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="mb-2 text-xl font-semibold text-white">{children}</h3>,
|
||||||
|
h4: ({ children }) => <h4 className="mb-1 text-lg font-medium text-white">{children}</h4>,
|
||||||
|
h5: ({ children }) => <h5 className="mb-1 text-base font-medium text-white">{children}</h5>,
|
||||||
|
h6: ({ children }) => <h6 className="mb-1 text-sm font-medium text-white">{children}</h6>,
|
||||||
|
p: ({ children }) => <p className="mb-3 leading-relaxed text-white last:mb-0">{children}</p>,
|
||||||
|
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
|
||||||
|
em: ({ children }) => <em className="italic text-white">{children}</em>,
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} className="text-blue-400 underline" target="_blank" rel="noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
code: ({ className, children, ...props }) => {
|
||||||
|
const isBlock = className?.startsWith("language-");
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<code className={cn(className, "text-sm")} {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-sm text-white" {...props}>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="mb-3 overflow-x-auto rounded-lg bg-black/40 p-4 text-sm last:mb-0">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
ul: ({ children }) => <ul className="mb-3 list-disc pl-5 text-white last:mb-0">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="mb-3 list-decimal pl-5 text-white last:mb-0">{children}</ol>,
|
||||||
|
li: ({ children }) => <li className="mb-1 leading-relaxed">{children}</li>,
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="mb-3 border-l-2 border-white/30 pl-4 italic text-white/70 last:mb-0">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
hr: () => <hr className="my-4 border-white/10" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function MarkdownPreview({ content }: { content: string }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden break-words">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[rehypeHighlight]}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextEditor({
|
||||||
|
textContent,
|
||||||
|
onTextChange,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitHint = "next",
|
||||||
|
attachmentProps,
|
||||||
|
}: TextEditorProps) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [previewMode, setPreviewMode] = useState(false);
|
||||||
|
const [forceCardMode, setForceCardMode] = 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 immersive =
|
||||||
|
textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !forceCardMode;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewMode) {
|
||||||
|
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()) onSubmit();
|
||||||
|
} else if (e.key === "m" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setForceCardMode(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCancel, onSubmit, textContent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const strip = attachmentProps && (
|
||||||
|
<AttachmentStrip
|
||||||
|
attachments={attachmentProps.attachments}
|
||||||
|
onRemove={attachmentProps.onRemoveAttachment}
|
||||||
|
onAddClick={attachmentProps.onAddFiles}
|
||||||
|
linkPreviews={linkPreviews}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyboardHints = (
|
||||||
|
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
Esc
|
||||||
|
</kbd>{" "}
|
||||||
|
cancel
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
⌘+Enter
|
||||||
|
</kbd>{" "}
|
||||||
|
{submitHint}
|
||||||
|
</span>
|
||||||
|
{immersive && (
|
||||||
|
<span>
|
||||||
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
|
⌘+M
|
||||||
|
</kbd>{" "}
|
||||||
|
markdown
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{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(
|
||||||
|
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||||
|
isDragging && "ring-2 ring-inset ring-white/30",
|
||||||
|
)}
|
||||||
|
{...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(
|
||||||
|
"w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none",
|
||||||
|
style.size,
|
||||||
|
style.weight,
|
||||||
|
)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{keyboardHints}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 z-50 flex items-center justify-center bg-black/90",
|
||||||
|
isDragging && "ring-2 ring-inset ring-white/30",
|
||||||
|
)}
|
||||||
|
{...dropZoneProps}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-xl">
|
||||||
|
<div className="mb-3 flex shrink-0 items-center justify-end gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreviewMode(false)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
!previewMode
|
||||||
|
? "bg-white/15 text-white"
|
||||||
|
: "text-white/40 hover:text-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Write
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreviewMode(true)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
previewMode
|
||||||
|
? "bg-white/15 text-white"
|
||||||
|
: "text-white/40 hover:text-white/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewMode ? (
|
||||||
|
<MarkdownPreview content={textContent} />
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={textContent}
|
||||||
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type a message... (markdown supported)"
|
||||||
|
className="min-h-0 flex-1 resize-none border-none bg-transparent font-mono text-sm leading-relaxed text-white placeholder-white/40 outline-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasEnrichments && strip && (
|
||||||
|
<div className="shrink-0 border-t border-white/10 pt-3">
|
||||||
|
{strip}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{keyboardHints}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { Particle } from "@/api/types";
|
||||||
import type { ParticlePath } from "@/lib/particle-path";
|
import type { ParticlePath } from "@/lib/particle-path";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -10,6 +11,9 @@ import {
|
|||||||
} from "@/components/link-preview-card";
|
} from "@/components/link-preview-card";
|
||||||
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
|
||||||
import { ParticleAttachments } from "@/features/particles/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 ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import rehypeHighlight from "rehype-highlight";
|
import rehypeHighlight from "rehype-highlight";
|
||||||
@@ -144,6 +148,10 @@ export function TextParticleView({
|
|||||||
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
const { attachments } = useParticleAttachments(streamPath, particle.id);
|
||||||
const urls = extractUrls(content);
|
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 hasLinks = urls.length > 0;
|
||||||
const hasAttachments = attachments.length > 0;
|
const hasAttachments = attachments.length > 0;
|
||||||
const hasEnrichments = hasLinks || hasAttachments;
|
const hasEnrichments = hasLinks || hasAttachments;
|
||||||
@@ -157,7 +165,7 @@ export function TextParticleView({
|
|||||||
}, [particle.id]);
|
}, [particle.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused) return;
|
if (paused || isEditing) return;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
elapsedRef.current += TICK_MS / 1000;
|
elapsedRef.current += TICK_MS / 1000;
|
||||||
@@ -171,18 +179,55 @@ export function TextParticleView({
|
|||||||
}, TICK_MS);
|
}, TICK_MS);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
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
|
// Content is just bare URLs with no surrounding text
|
||||||
const contentTrimmed = content.trim();
|
const contentTrimmed = content.trim();
|
||||||
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
|
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
|
||||||
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
|
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
|
// Mode 1: bare URLs only — show link cards centered
|
||||||
if (linksOnly && !hasAttachments) {
|
if (linksOnly && !hasAttachments) {
|
||||||
return (
|
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} />
|
<LinkPreviews entries={linkPreviews} />
|
||||||
|
{editedLabel && (
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
|
||||||
|
{editedLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editButton}
|
||||||
|
{editOverlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -191,7 +236,7 @@ export function TextParticleView({
|
|||||||
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
|
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments && !hasMarkdownFormatting(content)) {
|
||||||
const style = getImmersiveTextStyle(content.length);
|
const style = getImmersiveTextStyle(content.length);
|
||||||
return (
|
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
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
|
"max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text",
|
||||||
@@ -201,13 +246,16 @@ export function TextParticleView({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</p>
|
</p>
|
||||||
|
{editedLabel}
|
||||||
|
{editButton}
|
||||||
|
{editOverlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mode 3: card layout
|
// Mode 3: card layout
|
||||||
return (
|
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
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
"[&::-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} />}
|
{hasLinks && <LinkPreviews entries={linkPreviews} />}
|
||||||
|
|
||||||
{hasAttachments && <ParticleAttachments attachments={attachments} />}
|
{hasAttachments && <ParticleAttachments attachments={attachments} />}
|
||||||
|
|
||||||
|
{editedLabel}
|
||||||
</div>
|
</div>
|
||||||
|
{editButton}
|
||||||
|
{editOverlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,16 +81,26 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
case "file":
|
case "file":
|
||||||
case "text":
|
case "text":
|
||||||
case "quest":
|
case "quest":
|
||||||
case "paper":
|
case "paper": {
|
||||||
|
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||||
|
// particles carry `properties.edited_at`, so coerce it if present.
|
||||||
|
const properties =
|
||||||
|
type === "text" && raw.properties?.edited_at
|
||||||
|
? {
|
||||||
|
...raw.properties,
|
||||||
|
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
|
||||||
|
}
|
||||||
|
: raw.properties;
|
||||||
return ParticleSchema.parse({
|
return ParticleSchema.parse({
|
||||||
id: snap.id,
|
id: snap.id,
|
||||||
type: raw.type,
|
type: raw.type,
|
||||||
properties: raw.properties,
|
properties,
|
||||||
created_at: (raw.created_at as Timestamp).toDate(),
|
created_at: (raw.created_at as Timestamp).toDate(),
|
||||||
created_by_human_id: raw.created_by_human_id,
|
created_by_human_id: raw.created_by_human_id,
|
||||||
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
|
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
|
||||||
reactions: raw.reactions ?? undefined,
|
reactions: raw.reactions ?? undefined,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown particle type: ${type}`);
|
throw new Error(`Unknown particle type: ${type}`);
|
||||||
}
|
}
|
||||||
@@ -296,6 +306,21 @@ export async function updateParticleProperties<T extends ParticleType>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edits the body of a text particle and stamps `properties.edited_at` so
|
||||||
|
// readers can see that the message was edited (distinct from `updated_at`,
|
||||||
|
// which is bumped by any write — visibility, reactions, etc.).
|
||||||
|
export async function editTextParticleContent(
|
||||||
|
docPath: string,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const particleRef = typedDoc(docPath);
|
||||||
|
await updateDoc(particleRef, {
|
||||||
|
"properties.content": content,
|
||||||
|
"properties.edited_at": serverTimestamp(),
|
||||||
|
updated_at: serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateParticleVisibleTo(
|
export async function updateParticleVisibleTo(
|
||||||
docPath: string,
|
docPath: string,
|
||||||
visibleTo: string[],
|
visibleTo: string[],
|
||||||
|
|||||||
Reference in New Issue
Block a user