From ef21667d9f8983d14ab1e3a49c074c8506728488 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:01:47 +0100 Subject: [PATCH] Fix thread disappearing bug, improve article editor UX Move selectedNote guard after hooks in ThreadView to fix React rules of hooks violation that caused thread content to blank out. Article editor gets inline image previews, readable toolbar labels, and drag-and-drop image support. Thread reply textareas now auto-expand. --- src/components/article/ArticleEditor.tsx | 58 +++++++++++++++++++++- src/components/article/MarkdownToolbar.tsx | 22 ++++---- src/components/thread/ThreadNode.tsx | 4 +- src/components/thread/ThreadView.tsx | 7 +-- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/components/article/ArticleEditor.tsx b/src/components/article/ArticleEditor.tsx index 227ae15..e0680cd 100644 --- a/src/components/article/ArticleEditor.tsx +++ b/src/components/article/ArticleEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { renderMarkdown } from "../../lib/markdown"; import { publishArticle } from "../../lib/nostr"; import { useUIStore } from "../../stores/ui"; @@ -9,6 +9,17 @@ import { readFile } from "@tauri-apps/plugin-fs"; import { uploadBytes, uploadImage } from "../../lib/upload"; import { getCurrentWindow } from "@tauri-apps/api/window"; +/** Extract image URLs from markdown ![alt](url) patterns */ +function extractImages(md: string): { alt: string; url: string }[] { + const re = /!\[([^\]]*)\]\(([^)]+)\)/g; + const images: { alt: string; url: string }[] = []; + let m; + while ((m = re.exec(md))) { + images.push({ alt: m[1], url: m[2] }); + } + return images; +} + export function ArticleEditor() { const { goBack } = useUIStore(); const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore(); @@ -103,6 +114,7 @@ export function ArticleEditor() { const renderedHtml = renderMarkdown(content || "*Nothing to preview yet.*"); const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; const canPublish = title.trim().length > 0 && content.trim().length > 0; + const inlineImages = useMemo(() => extractImages(content), [content]); const handlePublish = async () => { if (!canPublish || publishing) return; @@ -398,6 +410,23 @@ export function ArticleEditor() { /> )} + {/* Inline image previews (write mode only) */} + {mode === "write" && inlineImages.length > 0 && ( +
+ {inlineImages.length} {inlineImages.length === 1 ? "image" : "images"} + {inlineImages.map((img, i) => ( +
+ {img.alt} { (e.target as HTMLImageElement).style.display = "none"; }} + /> +
+ ))} +
+ )} + {/* Content area */}
{mode === "write" ? ( @@ -407,7 +436,32 @@ export function ArticleEditor() { onChange={(e) => setContent(e.target.value)} onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)} onPaste={handleArticlePaste} - placeholder="Write your article in Markdown…" + onDrop={async (e) => { + const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/")); + if (!file) return; + e.preventDefault(); + e.stopPropagation(); + setUploading(true); + setError(null); + try { + const url = await uploadImage(file); + const ta = textareaRef.current; + if (ta) { + const start = ta.selectionStart ?? content.length; + const end = ta.selectionEnd ?? content.length; + const md = `![image](${url})`; + setContent(content.slice(0, start) + md + content.slice(end)); + } + } catch (err) { + setError(`Image upload failed: ${err}`); + } finally { + setUploading(false); + } + }} + onDragOver={(e) => { + if (Array.from(e.dataTransfer.types).includes("Files")) e.preventDefault(); + }} + placeholder="Write your article in Markdown… (paste or drop images)" className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono" /> ) : ( diff --git a/src/components/article/MarkdownToolbar.tsx b/src/components/article/MarkdownToolbar.tsx index 25057ec..af0722f 100644 --- a/src/components/article/MarkdownToolbar.tsx +++ b/src/components/article/MarkdownToolbar.tsx @@ -116,15 +116,15 @@ function applyMarkdown( }); } -const TOOLS: { action: MarkdownAction; label: string; title: string }[] = [ - { action: "bold", label: "B", title: "Bold (Ctrl+B)" }, - { action: "italic", label: "I", title: "Italic (Ctrl+I)" }, +const TOOLS: { action: MarkdownAction; label: string; title: string; bold?: boolean; italic?: boolean }[] = [ + { action: "bold", label: "B", title: "Bold (Ctrl+B)", bold: true }, + { action: "italic", label: "I", title: "Italic (Ctrl+I)", italic: true }, { action: "heading", label: "H", title: "Heading" }, - { action: "link", label: "🔗", title: "Link (Ctrl+K)" }, - { action: "image", label: "🖼", title: "Image" }, - { action: "quote", label: "❝", title: "Quote" }, - { action: "code", label: "", title: "Code" }, - { action: "list", label: "☰", title: "List" }, + { action: "link", label: "Link", title: "Insert link (Ctrl+K)" }, + { action: "image", label: "Image", title: "Upload image" }, + { action: "quote", label: "Quote", title: "Block quote" }, + { action: "code", label: "Code", title: "Code block" }, + { action: "list", label: "List", title: "Bullet list" }, ]; export function MarkdownToolbar({ textareaRef, content, setContent, setUploading, setError }: ToolbarProps) { @@ -175,13 +175,13 @@ export function MarkdownToolbar({ textareaRef, content, setContent, setUploading return (
- {TOOLS.map(({ action, label, title }) => ( + {TOOLS.map(({ action, label, title, bold, italic }) => ( diff --git a/src/components/thread/ThreadNode.tsx b/src/components/thread/ThreadNode.tsx index 1a3f814..778cc2c 100644 --- a/src/components/thread/ThreadNode.tsx +++ b/src/components/thread/ThreadNode.tsx @@ -6,6 +6,7 @@ import { publishReply } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { shortenPubkey } from "../../lib/utils"; import { EmojiPicker } from "../shared/EmojiPicker"; +import { useAutoResize } from "../../hooks/useAutoResize"; interface ThreadNodeProps { node: ThreadNodeType; @@ -31,6 +32,7 @@ function InlineThreadReply({ replyTo, rootEvent, onPublished }: { const [sent, setSent] = useState(false); const [showEmoji, setShowEmoji] = useState(false); const ref = useRef(null); + const autoResize = useAutoResize(2, 8); const handleSubmit = async () => { if (!text.trim() || replying) return; @@ -60,7 +62,7 @@ function InlineThreadReply({ replyTo, rootEvent, onPublished }: {