feat(editor): add TitleEditor component, replace <input> for issue titles

- 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 <Input> in create-issue modal and <input> 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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-31 15:38:42 +08:00
parent ac2a4c419f
commit 9f03b73809
5 changed files with 178 additions and 45 deletions

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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<TitleEditorRef, TitleEditorProps>(
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: `<p>${defaultValue}</p>`,
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 <EditorContent editor={editor} />;
},
);
export { TitleEditor, type TitleEditorProps, type TitleEditorRef };

View file

@ -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 */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<input
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onFocus={() => { titleFocusedRef.current = true; }}
onBlur={() => {
titleFocusedRef.current = false;
const trimmed = titleDraft.trim();
<TitleEditor
key={`title-${id}`}
defaultValue={issue.title}
placeholder="Issue title"
className="w-full text-2xl font-bold leading-snug tracking-tight"
onBlur={(value) => {
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"
/>
<RichTextEditor

View file

@ -24,8 +24,8 @@ import {
import { Calendar } from "@/components/ui/calendar";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
import { TitleEditor } from "@/components/common/title-editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
@ -186,19 +186,13 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<Input
<TitleEditor
autoFocus
type="text"
value={title}
onChange={(e) => 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}
/>
</div>