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:
parent
ac2a4c419f
commit
9f03b73809
5 changed files with 178 additions and 45 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
18
apps/web/components/common/title-editor.css
Normal file
18
apps/web/components/common/title-editor.css
Normal 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;
|
||||
}
|
||||
141
apps/web/components/common/title-editor.tsx
Normal file
141
apps/web/components/common/title-editor.tsx
Normal 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 };
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue