Merge pull request #239 from multica-ai/feature/editor-ux-improvements

feat(ui): editor UX improvements — lowlight, upload, emoji, comment editing
This commit is contained in:
Naiyuan Qing 2026-03-31 18:39:36 +08:00 committed by GitHub
commit 4c3d9ed1a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 592 additions and 196 deletions

View file

@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
function CodeBlockView({ node }: NodeViewProps) {
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
>
{language && (
<span className="text-xs text-muted-foreground select-none">
{language}
</span>
)}
<button
type="button"
onClick={handleCopy}
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
title="Copy code"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
<pre spellCheck={false}>
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
}
export { CodeBlockView };

View file

@ -0,0 +1,62 @@
"use client";
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
interface FileUploadButtonProps {
onUpload: (file: File) => Promise<UploadResult | null>;
onInsert?: (result: UploadResult, isImage: boolean) => void;
disabled?: boolean;
className?: string;
size?: "sm" | "default";
}
function FileUploadButton({
onUpload,
onInsert,
disabled,
className,
size = "default",
}: FileUploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await onUpload(file);
if (result && onInsert) {
onInsert(result, file.type.startsWith("image/"));
}
};
const iconSize = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
const btnSize = size === "sm" ? "h-6 w-6" : "h-7 w-7";
return (
<>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
className={cn(
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
btnSize,
className,
)}
>
<Paperclip className={iconSize} />
</button>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={handleChange}
/>
</>
);
}
export { FileUploadButton, type FileUploadButtonProps };

View file

@ -0,0 +1,79 @@
"use client";
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
const EmojiPicker = lazy(() =>
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
interface QuickEmojiPickerProps {
onSelect: (emoji: string) => void;
align?: "start" | "end";
className?: string;
}
function QuickEmojiPicker({ onSelect, align = "start", className }: QuickEmojiPickerProps) {
const [open, setOpen] = useState(false);
const [showFull, setShowFull] = useState(false);
const handleOpenChange = (v: boolean) => {
setOpen(v);
if (!v) setShowFull(false);
};
const handleSelect = (emoji: string) => {
onSelect(emoji);
setOpen(false);
setShowFull(false);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<button
type="button"
className={`inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors ${className ?? ""}`}
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align={align} className="w-auto p-0">
{showFull ? (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<EmojiPicker onSelect={handleSelect} />
</Suspense>
) : (
<div className="p-2">
<div className="flex gap-1">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelect(emoji)}
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
>
{emoji}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowFull(true)}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
>
More emojis...
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}
export { QuickEmojiPicker };

View file

@ -1,17 +1,9 @@
"use client";
import { useState, lazy, Suspense } from "react";
import { SmilePlus } from "lucide-react";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { useActorName } from "@/features/workspace";
const EmojiPicker = lazy(() =>
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
);
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
interface ReactionItem {
id: string;
actor_type: string;
@ -48,22 +40,17 @@ export function ReactionBar({
currentUserId,
onToggle,
className,
hideAddButton,
}: {
reactions: ReactionItem[];
currentUserId?: string;
onToggle: (emoji: string) => void;
className?: string;
hideAddButton?: boolean;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [showFullPicker, setShowFullPicker] = useState(false);
const grouped = groupReactions(reactions, currentUserId);
const { getActorName } = useActorName();
const handlePickerOpenChange = (open: boolean) => {
setPickerOpen(open);
if (!open) setShowFullPicker(false);
};
return (
<div className={`flex flex-wrap items-center gap-1.5 ${className ?? ""}`}>
{grouped.map((g) => (
@ -73,10 +60,10 @@ export function ReactionBar({
<button
type="button"
onClick={() => onToggle(g.emoji)}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-accent ${
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-brand/15 ${
g.reacted
? "border-primary/40 bg-primary/10 text-primary"
: "border-border text-muted-foreground"
? "border-brand/30 bg-brand/8 text-brand"
: "border-brand/10 bg-brand/4 text-muted-foreground"
}`}
>
<span>{g.emoji}</span>
@ -89,56 +76,7 @@ export function ReactionBar({
</TooltipContent>
</Tooltip>
))}
<Popover open={pickerOpen} onOpenChange={handlePickerOpenChange}>
<PopoverTrigger
render={
<button
type="button"
className="inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<SmilePlus className="h-3.5 w-3.5" />
</button>
}
/>
<PopoverContent align="start" className="w-auto p-0">
{showFullPicker ? (
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
<EmojiPicker
onSelect={(emoji) => {
onToggle(emoji);
setPickerOpen(false);
setShowFullPicker(false);
}}
/>
</Suspense>
) : (
<div className="p-2">
<div className="flex gap-1">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => {
onToggle(emoji);
setPickerOpen(false);
}}
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
>
{emoji}
</button>
))}
</div>
<button
type="button"
onClick={() => setShowFullPicker(true)}
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
>
More emojis...
</button>
</div>
)}
</PopoverContent>
</Popover>
{!hideAddButton && <QuickEmojiPicker onSelect={onToggle} />}
</div>
);
}

View file

@ -109,6 +109,63 @@
line-height: 1.6;
}
/* Syntax highlighting — lowlight (hljs) */
.rich-text-editor .hljs-keyword,
.rich-text-editor .hljs-selector-tag,
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
.rich-text-editor .hljs-string,
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
.rich-text-editor .hljs-comment,
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
.rich-text-editor .hljs-number,
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
.rich-text-editor .hljs-title,
.rich-text-editor .hljs-section,
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
.rich-text-editor .hljs-attr,
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
.rich-text-editor .hljs-variable,
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
.rich-text-editor .hljs-type,
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
.dark .rich-text-editor .hljs-string,
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
.dark .rich-text-editor .hljs-number,
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
.dark .rich-text-editor .hljs-title,
.dark .rich-text-editor .hljs-section,
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
.dark .rich-text-editor .hljs-attr,
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
.dark .rich-text-editor .hljs-variable,
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
.dark .rich-text-editor .hljs-type,
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
/* Blockquotes */
.rich-text-editor blockquote {
border-left: 2px solid var(--border);
@ -156,3 +213,15 @@
text-decoration: line-through;
color: var(--muted-foreground);
}
/* Uploading image placeholder (blob: URLs = in-flight uploads) */
.rich-text-editor img[src^="blob:"] {
opacity: 0.5;
border-radius: var(--radius);
animation: rte-pulse 1.5s ease-in-out infinite;
}
@keyframes rte-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.3; }
}

View file

@ -6,8 +6,10 @@ import {
useImperativeHandle,
useRef,
} from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from "lowlight";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
@ -16,11 +18,15 @@ import Image from "@tiptap/extension-image";
import { Markdown } from "@tiptap/markdown";
import { Extension, mergeAttributes } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Slice } from "@tiptap/pm/model";
import { cn } from "@/lib/utils";
import type { UploadResult } from "@/shared/hooks/use-file-upload";
import { createMentionSuggestion } from "./mention-suggestion";
import { CodeBlockView } from "./code-block-view";
import "./rich-text-editor.css";
const lowlight = createLowlight(common);
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@ -33,6 +39,7 @@ interface RichTextEditorProps {
className?: string;
debounceMs?: number;
onSubmit?: () => void;
onBlur?: () => void;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
}
@ -130,9 +137,56 @@ function createSubmitExtension(onSubmit: () => void) {
}
// ---------------------------------------------------------------------------
// File upload extension (paste + drop)
// Markdown paste extension — parse pasted markdown text as rich text
// ---------------------------------------------------------------------------
function createMarkdownPasteExtension() {
return Extension.create({
name: "markdownPaste",
addProseMirrorPlugins() {
const { editor } = this;
return [
new Plugin({
key: new PluginKey("markdownPaste"),
props: {
clipboardTextParser(text, _context, plainText) {
if (!plainText && editor.markdown) {
const json = editor.markdown.parse(text);
const node = editor.schema.nodeFromJSON(json);
return Slice.maxOpen(node.content);
}
// Plain text fallback
const p = editor.schema.nodes.paragraph!;
const doc = editor.schema.nodes.doc!;
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
return new Slice(doc.create(null, paragraph).content, 0, 0);
},
},
}),
];
},
});
}
// ---------------------------------------------------------------------------
// File upload extension (paste + drop) with blob URL instant preview
// ---------------------------------------------------------------------------
function removeImageBySrc(editor: ReturnType<typeof useEditor>, src: string) {
if (!editor) return;
const { tr } = editor.state;
let deleted = false;
editor.state.doc.descendants((node, pos) => {
if (deleted) return false;
if (node.type.name === "image" && node.attrs.src === src) {
tr.delete(pos, pos + node.nodeSize);
deleted = true;
return false;
}
});
if (deleted) editor.view.dispatch(tr);
}
function createFileUploadExtension(
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
) {
@ -148,28 +202,67 @@ function createFileUploadExtension(
let handled = false;
for (const file of Array.from(files)) {
handled = true;
try {
const result = await handler(file);
if (!result) continue;
const isImage = file.type.startsWith("image/");
const isImage = file.type.startsWith("image/");
if (isImage) {
if (isImage) {
// Instant preview via blob URL, then replace with real URL after upload
const blobUrl = URL.createObjectURL(file);
if (pos !== undefined) {
editor
.chain()
.focus()
.setImage({ src: result.link, alt: result.filename })
.insertContentAt(pos, {
type: "image",
attrs: { src: blobUrl, alt: file.name },
})
.run();
} else {
// Insert as a markdown link
editor
.chain()
.focus()
.setImage({ src: blobUrl, alt: file.name })
.run();
}
try {
const result = await handler(file);
if (result) {
const { tr } = editor.state;
editor.state.doc.descendants((node, nodePos) => {
if (
node.type.name === "image" &&
node.attrs.src === blobUrl
) {
tr.setNodeMarkup(nodePos, undefined, {
...node.attrs,
src: result.link,
alt: result.filename,
});
}
});
editor.view.dispatch(tr);
} else {
removeImageBySrc(editor, blobUrl);
}
} catch {
removeImageBySrc(editor, blobUrl);
} finally {
URL.revokeObjectURL(blobUrl);
}
} else {
// Non-image: upload first, then insert link
try {
const result = await handler(file);
if (!result) continue;
const linkText = `[${result.filename}](${result.link})`;
if (pos !== undefined) {
editor.chain().focus().insertContentAt(pos, linkText).run();
} else {
editor.chain().focus().insertContent(linkText).run();
}
} catch {
// Upload errors handled by the hook/caller via toast
}
} catch {
// Upload errors handled by the hook/caller via toast
}
}
return handled;
@ -214,6 +307,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
className,
debounceMs = 300,
onSubmit,
onBlur,
onUploadFile,
},
ref,
@ -221,6 +315,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const onUpdateRef = useRef(onUpdate);
const onSubmitRef = useRef(onSubmit);
const onBlurRef = useRef(onBlur);
const onUploadFileRef = useRef(onUploadFile);
// Helper to get markdown from @tiptap/markdown extension.
@ -265,6 +360,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
// Keep refs in sync without recreating editor
onUpdateRef.current = onUpdate;
onSubmitRef.current = onSubmit;
onBlurRef.current = onBlur;
onUploadFileRef.current = onUploadFile;
const editor = useEditor({
@ -276,7 +372,13 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
StarterKit.configure({
heading: { levels: [1, 2, 3] },
link: false,
codeBlock: false,
}),
CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockView);
},
}).configure({ lowlight }),
Placeholder.configure({
placeholder: placeholderText,
}),
@ -289,6 +391,7 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
}),
Markdown,
createMarkdownPasteExtension(),
createSubmitExtension(() => onSubmitRef.current?.()),
createFileUploadExtension(onUploadFileRef),
],
@ -299,6 +402,9 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
onUpdateRef.current?.(ed.getMarkdown());
}, debounceMs);
},
onBlur: () => {
onBlurRef.current?.();
},
editorProps: {
handleDOMEvents: {
click(_view, event) {

View file

@ -16,11 +16,13 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
import { QuickEmojiPicker } from "@/components/common/quick-emoji-picker";
import { cn } from "@/lib/utils";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { Markdown } from "@/components/markdown";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry } from "@/shared/types";
@ -44,12 +46,14 @@ interface CommentCardProps {
// ---------------------------------------------------------------------------
function CommentRow({
issueId,
entry,
currentUserId,
onEdit,
onDelete,
onToggleReaction,
}: {
issueId: string;
entry: TimelineEntry;
currentUserId?: string;
onEdit: (commentId: string, content: string) => Promise<void>;
@ -59,24 +63,32 @@ function CommentRow({
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload();
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
@ -108,10 +120,15 @@ function CommentRow({
</Tooltip>
{!isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
@ -140,15 +157,16 @@ function CommentRow({
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{editing ? (
<div
className="mt-2 pl-8"
className="mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
@ -157,21 +175,29 @@ function CommentRow({
debounceMs={100}
/>
</div>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onUpload={(file) => uploadWithToast(file, { issueId })}
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
</div>
</div>
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
hideAddButton
className="mt-1.5 pl-8"
/>
)}
@ -196,27 +222,35 @@ function CommentCard({
onToggleReaction,
}: CommentCardProps) {
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload();
const [open, setOpen] = useState(true);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<RichTextEditorRef>(null);
const cancelledRef = useRef(false);
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed) return;
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
@ -278,10 +312,15 @@ function CommentCard({
)}
{open && !isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<Button variant="ghost" size="icon-xs" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
@ -310,6 +349,7 @@ function CommentCard({
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</div>
@ -323,7 +363,7 @@ function CommentCard({
className="pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="max-h-48 overflow-y-auto rounded-md border border-border px-3 py-2">
<div className="max-h-48 overflow-y-auto text-sm leading-relaxed">
<RichTextEditor
ref={editEditorRef}
defaultValue={entry.content ?? ""}
@ -332,15 +372,22 @@ function CommentCard({
debounceMs={100}
/>
</div>
<div className="flex gap-2 mt-1.5">
<Button size="sm" onClick={saveEdit}>Save</Button>
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onUpload={(file) => uploadWithToast(file, { issueId })}
onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>Save</Button>
</div>
</div>
</div>
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
<RichTextEditor defaultValue={entry.content ?? ""} editable={false} />
</div>
{!isTemp && (
<ReactionBar
@ -358,6 +405,7 @@ function CommentCard({
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t border-border/50 px-4">
<CommentRow
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
onEdit={onEdit}

View file

@ -1,9 +1,10 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
import { ArrowUp, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
interface CommentInputProps {
@ -13,7 +14,6 @@ interface CommentInputProps {
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [submitting, setSubmitting] = useState(false);
@ -25,16 +25,6 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
@ -51,8 +41,8 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
};
return (
<div className="relative rounded-lg bg-card ring-1 ring-border">
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2 pb-8">
<div className="relative flex max-h-56 flex-col rounded-lg bg-card pb-8 ring-1 ring-border">
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
<RichTextEditor
ref={editorRef}
placeholder="Leave a comment..."
@ -62,28 +52,25 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
debounceMs={100}
/>
</div>
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => fileInputRef.current?.click()}
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
<FileUploadButton
size="sm"
onUpload={handleUpload}
onInsert={(result, isImage) =>
editorRef.current?.insertFile(result.filename, result.link, isImage)
}
disabled={uploading}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-4 w-4" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="icon-sm"
size="icon-xs"
disabled={isEmpty || submitting}
onClick={handleSubmit}
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, memo } from "react";
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
@ -44,6 +44,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { RichTextEditor } from "@/components/common/rich-text-editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { TitleEditor } from "@/components/common/title-editor";
import {
Tooltip,
@ -242,6 +243,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
[issue, id],
);
const descEditorRef = useRef<import("@/components/common/rich-text-editor").RichTextEditorRef>(null);
const handleDescriptionUpload = useCallback(
(file: File) => uploadWithToast(file, { issueId: id }),
[uploadWithToast, id],
@ -557,6 +559,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
/>
<RichTextEditor
ref={descEditorRef}
key={id}
defaultValue={issue.description || ""}
placeholder="Add description..."
@ -566,12 +569,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
className="mt-5"
/>
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
className="mt-3"
/>
<div className="flex items-center gap-1 mt-3">
<ReactionBar
reactions={issueReactions}
currentUserId={user?.id}
onToggle={handleToggleIssueReaction}
/>
<FileUploadButton
size="sm"
onUpload={handleDescriptionUpload}
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/>
</div>
<div className="my-8 border-t" />

View file

@ -1,11 +1,12 @@
"use client";
import { useRef, useState } from "react";
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRef, useState, useEffect } from "react";
import { ArrowUp, Loader2 } from "lucide-react";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { FileUploadButton } from "@/components/common/file-upload-button";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Types
@ -33,28 +34,30 @@ function ReplyInput({
size = "default",
}: ReplyInputProps) {
const editorRef = useRef<RichTextEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const attachmentIdsRef = useRef<string[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const { uploadWithToast, uploading } = useFileUpload();
useEffect(() => {
const el = measureRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setIsExpanded(entry.contentRect.height > 32);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const handleUpload = async (file: File) => {
const result = await uploadWithToast(file, { issueId });
if (result) attachmentIdsRef.current.push(result.id);
return result;
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
const result = await handleUpload(file);
if (result) {
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
}
};
const handleSubmit = async () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
if (!content || submitting) return;
@ -73,62 +76,54 @@ function ReplyInput({
const avatarSize = size === "sm" ? 22 : 28;
return (
<div className="flex items-start gap-2.5">
<div className="group/editor flex items-start gap-2.5">
<ActorAvatar
actorType={avatarType}
actorId={avatarId}
size={avatarSize}
className="mt-0.5 shrink-0"
/>
<div className="min-w-0 flex-1">
<div
className={`overflow-y-auto text-sm ${
size === "sm" ? "max-h-32" : "max-h-48"
}`}
>
<RichTextEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
<div
className={`grid transition-all duration-150 ${
isEmpty ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100"
}`}
>
<div className="overflow-hidden">
<div className="flex items-center justify-end gap-1 pt-1">
<Button
variant="ghost"
size="icon-xs"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
tabIndex={isEmpty ? -1 : 0}
className="text-muted-foreground hover:text-foreground"
>
<Paperclip className="h-3.5 w-3.5" />
</Button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileSelect}
/>
<Button
size="icon-xs"
disabled={isEmpty || submitting}
onClick={handleSubmit}
tabIndex={isEmpty ? -1 : 0}
>
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
</Button>
</div>
<div
className={cn(
"relative min-w-0 flex-1 flex flex-col",
size === "sm" ? "max-h-40" : "max-h-56",
isExpanded && "pb-7",
)}
>
<div className="flex-1 min-h-0 overflow-y-auto pr-14">
<div ref={measureRef}>
<RichTextEditor
ref={editorRef}
placeholder={placeholder}
onUpdate={(md) => setIsEmpty(!md.trim())}
onSubmit={handleSubmit}
onUploadFile={handleUpload}
debounceMs={100}
/>
</div>
</div>
<div className="absolute bottom-0 right-0 flex items-center gap-1 text-muted-foreground transition-colors group-focus-within/editor:text-foreground">
<FileUploadButton
size="sm"
onUpload={handleUpload}
onInsert={(result, isImage) =>
editorRef.current?.insertFile(result.filename, result.link, isImage)
}
disabled={uploading}
/>
<button
type="button"
disabled={isEmpty || submitting}
onClick={handleSubmit}
className="inline-flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
{submitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
</div>
);

View file

@ -33,6 +33,8 @@ import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { api } from "@/shared/api";
import { useFileUpload } from "@/shared/hooks/use-file-upload";
import { FileUploadButton } from "@/components/common/file-upload-button";
// ---------------------------------------------------------------------------
// Pill trigger — shared rounded-full button style for toolbar
@ -90,6 +92,10 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
// Due date popover
const [dueDateOpen, setDueDateOpen] = useState(false);
// File upload
const { uploadWithToast } = useFileUpload();
const handleUpload = (file: File) => uploadWithToast(file);
const assigneeQuery = assigneeFilter.toLowerCase();
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery));
const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery));
@ -229,6 +235,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
defaultValue={draft.description}
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
onUploadFile={handleUpload}
debounceMs={500}
/>
</div>
@ -420,7 +427,11 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-3 border-t shrink-0">
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton
onUpload={handleUpload}
onInsert={(result, isImage) => descEditorRef.current?.insertFile(result.filename, result.link, isImage)}
/>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>

View file

@ -18,6 +18,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.6",
"@tiptap/extension-code-block-lowlight": "3.20.5",
"@tiptap/extension-image": "^3.20.5",
"@tiptap/extension-link": "^3.20.5",
"@tiptap/extension-mention": "^3.20.5",
@ -36,6 +37,7 @@
"emoji-mart": "^5.6.0",
"input-otp": "^1.4.2",
"linkify-it": "^5.0.0",
"lowlight": "^3.3.0",
"lucide-react": "catalog:",
"next": "^16.1.6",
"next-themes": "^0.4.6",

38
pnpm-lock.yaml generated
View file

@ -75,6 +75,9 @@ importers:
'@floating-ui/dom':
specifier: ^1.7.6
version: 1.7.6
'@tiptap/extension-code-block-lowlight':
specifier: 3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(highlight.js@11.11.1)(lowlight@3.3.0)
'@tiptap/extension-image':
specifier: ^3.20.5
version: 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))
@ -129,6 +132,9 @@ importers:
linkify-it:
specifier: ^5.0.0
version: 5.0.0
lowlight:
specifier: ^3.3.0
version: 3.3.0
lucide-react:
specifier: 'catalog:'
version: 1.0.1(react@19.2.3)
@ -1322,6 +1328,15 @@ packages:
peerDependencies:
'@tiptap/extension-list': ^3.20.5
'@tiptap/extension-code-block-lowlight@3.20.5':
resolution: {integrity: sha512-EINMkflwiUfCkBTAj1meP+nwEEUyXKmJF4yQVHzbt/iIswMtIc/7qvyld92VBgXWJkc+vo/lIPioaZGoSO7TsQ==}
peerDependencies:
'@tiptap/core': ^3.20.5
'@tiptap/extension-code-block': ^3.20.5
'@tiptap/pm': ^3.20.5
highlight.js: ^11
lowlight: ^2 || ^3
'@tiptap/extension-code-block@3.20.5':
resolution: {integrity: sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA==}
peerDependencies:
@ -2303,6 +2318,10 @@ packages:
headers-polyfill@4.0.3:
resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.12.8:
resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
engines: {node: '>=16.9.0'}
@ -2619,6 +2638,9 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
@ -4916,6 +4938,14 @@ snapshots:
dependencies:
'@tiptap/extension-list': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/extension-code-block-lowlight@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
'@tiptap/extension-code-block': 3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)
'@tiptap/pm': 3.20.5
highlight.js: 11.11.1
lowlight: 3.3.0
'@tiptap/extension-code-block@3.20.5(@tiptap/core@3.20.5(@tiptap/pm@3.20.5))(@tiptap/pm@3.20.5)':
dependencies:
'@tiptap/core': 3.20.5(@tiptap/pm@3.20.5)
@ -5927,6 +5957,8 @@ snapshots:
headers-polyfill@4.0.3: {}
highlight.js@11.11.1: {}
hono@4.12.8: {}
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
@ -6171,6 +6203,12 @@ snapshots:
longest-streak@3.1.0: {}
lowlight@3.3.0:
dependencies:
'@types/hast': 3.0.4
devlop: 1.1.0
highlight.js: 11.11.1
lru-cache@11.2.7: {}
lru-cache@5.1.1: