From 9f03b73809176153593b751ecf5ead138d3e2a94 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:38:42 +0800 Subject: [PATCH] feat(editor): add TitleEditor component, replace for issue titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New TitleEditor: minimal tiptap (Document+Paragraph+Text+Placeholder) - Single-paragraph constraint prevents Enter from creating new lines - contenteditable div enables visual word-wrap (no horizontal scroll) - Enter→submit+blur, Shift+Enter blocked, Escape→blur - Replace in create-issue modal and in issue-detail - Remove titleDraft state/titleFocusedRef/sync effect from issue-detail - Fix duplicate React key: TitleEditor key={`title-${id}`} Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/globals.css | 8 +- apps/web/components/common/title-editor.css | 18 +++ apps/web/components/common/title-editor.tsx | 141 ++++++++++++++++++ .../issues/components/issue-detail.tsx | 38 ++--- apps/web/features/modals/create-issue.tsx | 18 +-- 5 files changed, 178 insertions(+), 45 deletions(-) create mode 100644 apps/web/components/common/title-editor.css create mode 100644 apps/web/components/common/title-editor.tsx diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 66118975..ab982eeb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -96,8 +96,8 @@ --warning: oklch(0.75 0.16 85); --info: oklch(0.55 0.18 250); --priority: oklch(0.65 0.18 50); - --scrollbar-thumb: oklch(0.82 0.003 286); - --scrollbar-thumb-hover: oklch(0.705 0.015 286.067); + --scrollbar-thumb: oklch(0 0 0 / 10%); + --scrollbar-thumb-hover: oklch(0 0 0 / 18%); --scrollbar-track: transparent; } @@ -140,8 +140,8 @@ --warning: oklch(0.70 0.16 85); --info: oklch(0.65 0.18 250); --priority: oklch(0.70 0.18 50); - --scrollbar-thumb: oklch(1 0 0 / 15%); - --scrollbar-thumb-hover: oklch(1 0 0 / 30%); + --scrollbar-thumb: oklch(1 0 0 / 8%); + --scrollbar-thumb-hover: oklch(1 0 0 / 18%); --scrollbar-track: transparent; } diff --git a/apps/web/components/common/title-editor.css b/apps/web/components/common/title-editor.css new file mode 100644 index 00000000..70d63a06 --- /dev/null +++ b/apps/web/components/common/title-editor.css @@ -0,0 +1,18 @@ +/* Title editor: minimal ProseMirror for single-line titles */ + +.title-editor.ProseMirror { + outline: none; +} + +.title-editor.ProseMirror p { + margin: 0; +} + +/* Placeholder */ +.title-editor .is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--muted-foreground); + pointer-events: none; + height: 0; +} diff --git a/apps/web/components/common/title-editor.tsx b/apps/web/components/common/title-editor.tsx new file mode 100644 index 00000000..14837b27 --- /dev/null +++ b/apps/web/components/common/title-editor.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import { Extension } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import Placeholder from "@tiptap/extension-placeholder"; +import { cn } from "@/lib/utils"; +import "./title-editor.css"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TitleEditorProps { + defaultValue?: string; + placeholder?: string; + className?: string; + autoFocus?: boolean; + onSubmit?: () => void; + onBlur?: (value: string) => void; + onChange?: (value: string) => void; +} + +interface TitleEditorRef { + getText: () => string; + focus: () => void; +} + +// --------------------------------------------------------------------------- +// Single-paragraph document — prevents Enter from creating new lines +// --------------------------------------------------------------------------- + +const SingleLineDocument = Document.extend({ + content: "paragraph", +}); + +// --------------------------------------------------------------------------- +// Keyboard shortcuts: Enter → submit, Escape → blur +// --------------------------------------------------------------------------- + +function createTitleKeymap(opts: { + onSubmitRef: React.RefObject<(() => void) | undefined>; +}) { + return Extension.create({ + name: "titleKeymap", + addKeyboardShortcuts() { + return { + Enter: ({ editor }) => { + opts.onSubmitRef.current?.(); + editor.commands.blur(); + return true; + }, + "Shift-Enter": () => true, // swallow — no line breaks + Escape: ({ editor }) => { + editor.commands.blur(); + return true; + }, + }; + }, + }); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const TitleEditor = forwardRef( + function TitleEditor( + { + defaultValue = "", + placeholder: placeholderText = "", + className, + autoFocus = false, + onSubmit, + onBlur, + onChange, + }, + ref, + ) { + const onSubmitRef = useRef(onSubmit); + const onBlurRef = useRef(onBlur); + const onChangeRef = useRef(onChange); + + onSubmitRef.current = onSubmit; + onBlurRef.current = onBlur; + onChangeRef.current = onChange; + + const editor = useEditor({ + immediatelyRender: false, + content: `

${defaultValue}

`, + extensions: [ + SingleLineDocument, + Paragraph, + Text, + Placeholder.configure({ + placeholder: placeholderText, + showOnlyCurrent: false, + }), + createTitleKeymap({ onSubmitRef }), + ], + editorProps: { + attributes: { + class: cn("title-editor outline-none", className), + role: "textbox", + "aria-multiline": "false", + "aria-label": placeholderText || "Title", + }, + }, + onUpdate: ({ editor: ed }) => { + onChangeRef.current?.(ed.getText()); + }, + onBlur: ({ editor: ed }) => { + onBlurRef.current?.(ed.getText()); + }, + }); + + // Auto-focus after mount + useEffect(() => { + if (autoFocus && editor) { + // Move cursor to end + editor.commands.focus("end"); + } + }, [autoFocus, editor]); + + useImperativeHandle(ref, () => ({ + getText: () => editor?.getText() ?? "", + focus: () => { + editor?.commands.focus("end"); + }, + })); + + if (!editor) return null; + + return ; + }, +); + +export { TitleEditor, type TitleEditorProps, type TitleEditorRef }; diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 53e44bb9..c6f8db86 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, useRef, memo } from "react"; +import { useState, useEffect, useCallback, memo } from "react"; import { useDefaultLayout, usePanelRef } from "react-resizable-panels"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -43,8 +43,8 @@ import { DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable"; -import { Input } from "@/components/ui/input"; import { RichTextEditor } from "@/components/common/rich-text-editor"; +import { TitleEditor } from "@/components/common/title-editor"; import { Tooltip, TooltipTrigger, @@ -185,8 +185,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const sidebarRef = usePanelRef(); const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen); const [deleting, setDeleting] = useState(false); - const [titleDraft, setTitleDraft] = useState(""); - const titleFocusedRef = useRef(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [propertiesOpen, setPropertiesOpen] = useState(true); const [detailsOpen, setDetailsOpen] = useState(true); @@ -211,13 +209,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo .finally(() => setIssueLoading(false)); }, [id, !!issue]); - // Sync titleDraft when issue title changes (from WS or other views) - useEffect(() => { - if (issue && !titleFocusedRef.current) { - setTitleDraft(issue.title); - } - }, [issue?.title]); - // Custom hooks — encapsulate timeline, reactions, subscribers const { timeline, submitting, submitComment, submitReply, @@ -547,26 +538,15 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Content — scrollable */}
- setTitleDraft(e.target.value)} - onFocus={() => { titleFocusedRef.current = true; }} - onBlur={() => { - titleFocusedRef.current = false; - const trimmed = titleDraft.trim(); + { + const trimmed = value.trim(); if (trimmed && trimmed !== issue.title) handleUpdateField({ title: trimmed }); - else setTitleDraft(issue.title); }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - (e.target as HTMLInputElement).blur(); - } else if (e.key === "Escape") { - setTitleDraft(issue.title); - (e.target as HTMLInputElement).blur(); - } - }} - className="w-full bg-transparent text-2xl font-bold leading-snug tracking-tight outline-none placeholder:text-muted-foreground" /> void; data? {/* Title */}
- updateTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }} + defaultValue={draft.title} placeholder="Issue title" - className="border-none shadow-none px-0 text-lg font-semibold focus-visible:ring-0 dark:bg-transparent" + className="text-lg font-semibold" + onChange={(v) => updateTitle(v)} + onSubmit={handleSubmit} />