diff --git a/apps/web/components/common/code-block-view.tsx b/apps/web/components/common/code-block-view.tsx new file mode 100644 index 00000000..db274b84 --- /dev/null +++ b/apps/web/components/common/code-block-view.tsx @@ -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 ( + +
+ {language && ( + + {language} + + )} + +
+
+        {/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
+        
+      
+
+ ); +} + +export { CodeBlockView }; diff --git a/apps/web/components/common/file-upload-button.tsx b/apps/web/components/common/file-upload-button.tsx new file mode 100644 index 00000000..1c1ddc37 --- /dev/null +++ b/apps/web/components/common/file-upload-button.tsx @@ -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; + 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(null); + + const handleChange = async (e: React.ChangeEvent) => { + 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 ( + <> + + + + ); +} + +export { FileUploadButton, type FileUploadButtonProps }; diff --git a/apps/web/components/common/quick-emoji-picker.tsx b/apps/web/components/common/quick-emoji-picker.tsx new file mode 100644 index 00000000..af2e39f7 --- /dev/null +++ b/apps/web/components/common/quick-emoji-picker.tsx @@ -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 ( + + + + + } + /> + + {showFull ? ( + Loading...}> + + + ) : ( +
+
+ {QUICK_EMOJIS.map((emoji) => ( + + ))} +
+ +
+ )} +
+
+ ); +} + +export { QuickEmojiPicker }; diff --git a/apps/web/components/common/reaction-bar.tsx b/apps/web/components/common/reaction-bar.tsx index 3552a367..0786d685 100644 --- a/apps/web/components/common/reaction-bar.tsx +++ b/apps/web/components/common/reaction-bar.tsx @@ -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 (
{grouped.map((g) => ( @@ -73,10 +60,10 @@ export function ReactionBar({ - } - /> - - {showFullPicker ? ( - Loading...
}> - { - onToggle(emoji); - setPickerOpen(false); - setShowFullPicker(false); - }} - /> - - ) : ( -
-
- {QUICK_EMOJIS.map((emoji) => ( - - ))} -
- -
- )} - - + {!hideAddButton && } ); } diff --git a/apps/web/components/common/rich-text-editor.css b/apps/web/components/common/rich-text-editor.css index f387d05d..5883156d 100644 --- a/apps/web/components/common/rich-text-editor.css +++ b/apps/web/components/common/rich-text-editor.css @@ -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; } +} diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index d7d0436a..a8687af5 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -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; } @@ -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, 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) | 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( className, debounceMs = 300, onSubmit, + onBlur, onUploadFile, }, ref, @@ -221,6 +315,7 @@ const RichTextEditor = forwardRef( const debounceRef = useRef>(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( // 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( 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( HTMLAttributes: { style: "max-width: 100%; height: auto;" }, }), Markdown, + createMarkdownPasteExtension(), createSubmitExtension(() => onSubmitRef.current?.()), createFileUploadExtension(onUploadFileRef), ], @@ -299,6 +402,9 @@ const RichTextEditor = forwardRef( onUpdateRef.current?.(ed.getMarkdown()); }, debounceMs); }, + onBlur: () => { + onBlurRef.current?.(); + }, editorProps: { handleDOMEvents: { click(_view, event) { diff --git a/apps/web/features/issues/components/comment-card.tsx b/apps/web/features/issues/components/comment-card.tsx index 3544e848..b08b582a 100644 --- a/apps/web/features/issues/components/comment-card.tsx +++ b/apps/web/features/issues/components/comment-card.tsx @@ -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; @@ -59,24 +63,32 @@ function CommentRow({ const { getActorName } = useActorName(); const [editing, setEditing] = useState(false); const editEditorRef = useRef(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({ {!isTemp && ( +
+ onToggleReaction(entry.id, emoji)} + align="end" + /> + } @@ -140,15 +157,16 @@ function CommentRow({ )} +
)} {editing ? (
{ if (e.key === "Escape") cancelEdit(); }} > -
+
-
- - +
+ uploadWithToast(file, { issueId })} + onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> +
+ + +
) : ( <>
- {entry.content ?? ""} +
{!isTemp && ( 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(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 && ( +
+ onToggleReaction(entry.id, emoji)} + align="end" + /> + } @@ -310,6 +349,7 @@ function CommentCard({ )} +
)}
@@ -323,7 +363,7 @@ function CommentCard({ className="pl-10" onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }} > -
+
-
- - +
+ uploadWithToast(file, { issueId })} + onInsert={(result, isImage) => editEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> +
+ + +
) : ( <>
- {entry.content ?? ""} +
{!isTemp && ( (
(null); - const fileInputRef = useRef(null); const attachmentIdsRef = useRef([]); 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) => { - 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 ( -
-
+
+
-
- -
diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index abe41e0b..8098c1b2 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -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(null); const handleDescriptionUpload = useCallback( (file: File) => uploadWithToast(file, { issueId: id }), [uploadWithToast, id], @@ -557,6 +559,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo /> - +
+ + descEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> +
diff --git a/apps/web/features/issues/components/reply-input.tsx b/apps/web/features/issues/components/reply-input.tsx index 52792225..e3fd5da8 100644 --- a/apps/web/features/issues/components/reply-input.tsx +++ b/apps/web/features/issues/components/reply-input.tsx @@ -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(null); - const fileInputRef = useRef(null); + const measureRef = useRef(null); const attachmentIdsRef = useRef([]); 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) => { - 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 ( -
+
-
-
- setIsEmpty(!md.trim())} - onSubmit={handleSubmit} - onUploadFile={handleUpload} - debounceMs={100} - /> -
-
-
-
- - - -
+
+
+
+ setIsEmpty(!md.trim())} + onSubmit={handleSubmit} + onUploadFile={handleUpload} + debounceMs={100} + />
+
+ + editorRef.current?.insertFile(result.filename, result.link, isImage) + } + disabled={uploading} + /> + +
); diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index 099a2a43..ddefa1ba 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -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} />
@@ -420,7 +427,11 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Footer */} -
+
+ descEditorRef.current?.insertFile(result.filename, result.link, isImage)} + /> diff --git a/apps/web/package.json b/apps/web/package.json index f48cffa1..a3e75923 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2565644c..fe3b325d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: