merge: resolve conflicts with main
- Take main's router.go, rich-text-editor.tsx, comment-card.tsx - Remove deleted daemon_pairing.go - Keep issue mention card feature
This commit is contained in:
commit
b8c784dda3
68 changed files with 2359 additions and 1139 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
|
@ -8,8 +9,10 @@ interface ActorAvatarProps {
|
|||
actorType: string;
|
||||
actorId: string;
|
||||
size?: number;
|
||||
avatarUrl?: string | null;
|
||||
getName?: (type: string, id: string) => string;
|
||||
getInitials?: (type: string, id: string) => string;
|
||||
getAvatarUrl?: (type: string, id: string) => string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -17,29 +20,47 @@ function ActorAvatar({
|
|||
actorType,
|
||||
actorId,
|
||||
size = 20,
|
||||
avatarUrl,
|
||||
getName,
|
||||
getInitials,
|
||||
getAvatarUrl,
|
||||
className,
|
||||
}: ActorAvatarProps) {
|
||||
const actorNameHook = useActorName();
|
||||
const resolveName = getName ?? actorNameHook.getActorName;
|
||||
const resolveInitials = getInitials ?? actorNameHook.getActorInitials;
|
||||
const resolveAvatarUrl = getAvatarUrl ?? actorNameHook.getActorAvatarUrl;
|
||||
|
||||
const name = resolveName(actorType, actorId);
|
||||
const initials = resolveInitials(actorType, actorId);
|
||||
const isAgent = actorType === "agent";
|
||||
const resolvedUrl = avatarUrl !== undefined ? avatarUrl : resolveAvatarUrl(actorType, actorId);
|
||||
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Reset error state when URL changes (e.g. user uploads new avatar)
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
}, [resolvedUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full font-medium",
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
|
||||
"bg-muted text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||
title={name}
|
||||
>
|
||||
{isAgent ? (
|
||||
{resolvedUrl && !imgError ? (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt={name}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : isAgent ? (
|
||||
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : (
|
||||
initials
|
||||
|
|
|
|||
70
apps/web/components/common/mention-hover-card.tsx
Normal file
70
apps/web/components/common/mention-hover-card.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
interface MentionHoverCardProps {
|
||||
type: string;
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
if (type === "member") {
|
||||
const member = members.find((m) => m.user_id === id);
|
||||
if (!member) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar actorType="member" actorId={id} size={32} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "agent") {
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
if (!agent) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{agent.name}</p>
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export { MentionHoverCard };
|
||||
|
|
@ -126,7 +126,11 @@
|
|||
|
||||
/* Links */
|
||||
.rich-text-editor a {
|
||||
color: var(--primary);
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-editor a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
|
@ -134,11 +138,9 @@
|
|||
/* Mentions */
|
||||
.rich-text-editor .mention {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
padding: 0 0.2em;
|
||||
border-radius: calc(var(--radius) * 0.5);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ import Placeholder from "@tiptap/extension-placeholder";
|
|||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Extension, mergeAttributes } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import "./rich-text-editor.css";
|
||||
|
||||
|
|
@ -30,56 +33,22 @@ interface RichTextEditorProps {
|
|||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit shortcut extension (Mod+Enter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mention extension configured for markdown serialization
|
||||
// Stores as: [@Label](mention://type/id)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link extension — always serialize as [text](url), never <url> autolinks;
|
||||
// support Cmd+Click / Ctrl+Click to open in new tab.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LinkExtension = Link.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary hover:underline cursor-pointer",
|
||||
},
|
||||
}).extend({
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize: {
|
||||
open() {
|
||||
return "[";
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
close(_state: any, mark: any) {
|
||||
const href = (mark.attrs.href as string).replace(/[\(\)"]/g, "\\$&");
|
||||
const title = mark.attrs.title
|
||||
? ` "${(mark.attrs.title as string).replace(/"/g, '\\"')}"`
|
||||
: "";
|
||||
return `](${href}${title})`;
|
||||
},
|
||||
mixable: true,
|
||||
},
|
||||
parse: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const MentionExtension = Mention.configure({
|
||||
|
|
@ -87,17 +56,18 @@ const MentionExtension = Mention.configure({
|
|||
suggestion: createMentionSuggestion(),
|
||||
}).extend({
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const type = node.attrs.type ?? "member";
|
||||
const label = node.attrs.label ?? node.attrs.id;
|
||||
return [
|
||||
"a",
|
||||
{
|
||||
...HTMLAttributes,
|
||||
href: `mention://${type}/${node.attrs.id}`,
|
||||
"data-mention-type": type,
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
type === "issue" ? label : `@${label}`,
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`@${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
|
|
@ -105,21 +75,39 @@ const MentionExtension = Mention.configure({
|
|||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-type") ?? "member",
|
||||
},
|
||||
description: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-mention-description"),
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
// @tiptap/markdown 3.x uses renderMarkdown as a top-level extension field
|
||||
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2], id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderMarkdown(node: any) {
|
||||
const type = node.attrs?.type ?? "member";
|
||||
const label = node.attrs?.label ?? node.attrs?.id;
|
||||
const display = type === "issue" ? label : `@${label}`;
|
||||
return `[${display}](mention://${type}/${node.attrs?.id})`;
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
return `[@${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -141,6 +129,77 @@ function createSubmitExtension(onSubmit: () => void) {
|
|||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File upload extension (paste + drop)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList, pos?: number) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
|
||||
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/");
|
||||
if (isImage) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: result.link, alt: result.filename })
|
||||
.run();
|
||||
} else {
|
||||
// Insert as a markdown link
|
||||
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
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -155,26 +214,25 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
|||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
|
||||
// Helper to get markdown from @tiptap/markdown extension
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getEditorMarkdown = (ed: any): string =>
|
||||
ed?.getMarkdown?.() ?? "";
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue,
|
||||
content: defaultValue || "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
|
|
@ -186,14 +244,20 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
|||
LinkExtension,
|
||||
Typography,
|
||||
MentionExtension,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
}),
|
||||
Markdown,
|
||||
createSubmitExtension(() => onSubmitRef.current?.()),
|
||||
createFileUploadExtension(onUploadFileRef),
|
||||
],
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(getEditorMarkdown(ed));
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
}, debounceMs);
|
||||
},
|
||||
editorProps: {
|
||||
|
|
@ -225,13 +289,21 @@ const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
|||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => getEditorMarkdown(editor),
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => {
|
||||
if (!editor) return;
|
||||
if (isImage) {
|
||||
editor.chain().focus().setImage({ src: url, alt: filename }).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) return null;
|
||||
|
|
|
|||
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue