mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 13:06:20 +02:00
* feat(views): support the send shortcut in manual issue create (MUL-4931) Agent create has had Cmd/Ctrl+Enter all along; manual create had no submit shortcut at all, in either the title or the description. Reuse the configurable `send` action rather than hardcoding the chord, so a rebound or unbound shortcut follows the user's setting and the IME guards and Shift+Enter replay come along for free: - Description: pass `onSubmit` to ContentEditor, which already carries the extension. - Title: add an opt-in `onSubmitShortcut` to the shared TitleEditor. Plain Enter stays inert there — #5532 removed that trigger a day ago because it created from half-typed titles — and a single-line title has no newline to trade Enter against, so the chord path refuses a plain-Enter `send` binding. Hosts relying on plain-Enter submit (create-project, autopilot-dialog) keep `onSubmit` and are untouched. Also fixes a real double-create: `submitting` is state, so two chord presses in one tick both read the stale value and fired two creates. A ref flips synchronously and single-flights both create panels. Accessibility: the empty-title Create button moves from native `disabled` to `aria-disabled`, so it stays focusable and keyboard/SR users can finally reach the "Enter a title to create" tooltip. Keycaps are decorative, keeping the accessible name "Create Issue". Empty-title chord submits now focus the title instead of silently no-oping. Widen the `send` setting description across all four locales — it claimed to cover only chat, comments, replies, feedback and prompts. Tests: real-ProseMirror coverage for the title chord (every other editor test mocks Tiptap, so nothing verified extension ordering), plus chord/plain-Enter/ empty-title/upload-gate/single-flight/keycaps/aria-disabled cases. The single-flight test dispatches both presses inside one act(); it fails against the old state-based guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * test(views): cover quick-create single-flight + aria-disabled visuals (MUL-4931) Review follow-up on #5583. The quick-create ref guard shipped without a regression test, so the claim that both create panels were covered was only true of manual create. That path files a real issue, so a double-fire is a duplicate issue rather than a glitch. Add the same-tick regression: both presses dispatch inside one act(), and it fails against the old state-based guard (expected 1 call, got 2). The Button base only styles native `disabled` (disabled:opacity-50 / disabled:pointer-events-none), so the aria-disabled empty-title button stayed a fully lit, pressable-looking primary. Add local aria-disabled opacity, cursor, and press-animation styles, with no pointer-events-none — that would break the tooltip hover and the click that focuses the title. Verified against compiled Tailwind output: aria-disabled:active:translate-y-0 and the base's active:not-aria-[haspopup]:translate-y-px have equal specificity and the override emits later, so it wins without !important. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
277 lines
9.8 KiB
TypeScript
277 lines
9.8 KiB
TypeScript
"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 {
|
|
getShortcut,
|
|
isPlainShortcut,
|
|
type ShortcutChord,
|
|
} from "@multica/core/shortcuts";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useT } from "../i18n";
|
|
import { createSubmitShortcutExtension } from "./extensions/submit-shortcut";
|
|
import "./title-editor.css";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TitleEditorProps {
|
|
defaultValue?: string;
|
|
placeholder?: string;
|
|
className?: string;
|
|
autoFocus?: boolean;
|
|
onSubmit?: () => void;
|
|
/**
|
|
* Fires on the configured `send` chord, independent of `onSubmit`'s plain
|
|
* Enter path. Hosts that submit on plain Enter pass `onSubmit`; hosts that
|
|
* want an explicit chord (create-issue, MUL-4931) pass this instead.
|
|
*
|
|
* Plain Enter is deliberately never a trigger here even when `send` is
|
|
* configured as plain Enter: the keymap below already owns that key for
|
|
* "finish editing", and this single-line editor has no newline to trade it
|
|
* against. Shadowing it would silently create from a half-typed title —
|
|
* exactly what #5532 removed.
|
|
*/
|
|
onSubmitShortcut?: () => void;
|
|
onBlur?: (value: string) => void;
|
|
onChange?: (value: string) => void;
|
|
/**
|
|
* Called once when the Tiptap instance exists and its DOM is attached
|
|
* (creation is deferred past first paint by `immediatelyRender: false`).
|
|
* Same contract as ContentEditorProps.onReady.
|
|
*/
|
|
onReady?: () => void;
|
|
}
|
|
|
|
interface TitleEditorRef {
|
|
getText: () => string;
|
|
focus: () => void;
|
|
/**
|
|
* Focus and place the caret at the document position under the given
|
|
* viewport coordinates — same contract as ContentEditorRef.focusAtCoords,
|
|
* so readonly-first hosts (useLazyEditor) can treat both editors alike.
|
|
* Must be called while the editor element is laid out (not display: none).
|
|
*/
|
|
focusAtCoords: (coords: { x: number; y: number }) => void;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single-paragraph document — prevents Enter from creating new lines
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SingleLineDocument = Document.extend({
|
|
content: "paragraph",
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keyboard shortcuts: Enter → submit, Escape → blur
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Whether `onSubmitShortcut` may fire for the configured `send` chord.
|
|
*
|
|
* Plain Enter is excluded on purpose. The keymap below already owns that key
|
|
* ("finish editing"), and unlike a prose editor a single-line title has no
|
|
* newline to trade it against — so a `send` configured as plain Enter would
|
|
* turn every Enter into a create from a half-typed title. That is exactly the
|
|
* misfire #5532 removed, so the title keeps plain Enter inert and only honors
|
|
* an explicit chord. Hosts wanting plain-Enter submit still pass `onSubmit`.
|
|
*/
|
|
export function titleShortcutSubmitAllowed(
|
|
sendShortcut: ShortcutChord | null,
|
|
): boolean {
|
|
if (!sendShortcut) return false;
|
|
return !isPlainShortcut(sendShortcut, "Enter");
|
|
}
|
|
|
|
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,
|
|
onSubmitShortcut,
|
|
onBlur,
|
|
onChange,
|
|
onReady,
|
|
},
|
|
ref,
|
|
) {
|
|
const { t } = useT("editor");
|
|
const onSubmitRef = useRef(onSubmit);
|
|
const onSubmitShortcutRef = useRef(onSubmitShortcut);
|
|
const onBlurRef = useRef(onBlur);
|
|
const onChangeRef = useRef(onChange);
|
|
const onReadyRef = useRef(onReady);
|
|
|
|
onSubmitRef.current = onSubmit;
|
|
onSubmitShortcutRef.current = onSubmitShortcut;
|
|
onBlurRef.current = onBlur;
|
|
onChangeRef.current = onChange;
|
|
onReadyRef.current = onReady;
|
|
|
|
// `useEditor` reads `extensions` once at mount and no host toggles this
|
|
// prop over its lifetime, so pin the mount-time answer rather than let the
|
|
// extension list depend on whichever render happened to create the editor.
|
|
const shortcutSubmitEnabled = useRef(onSubmitShortcut !== undefined).current;
|
|
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
content: defaultValue
|
|
? { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: defaultValue }] }] }
|
|
: "",
|
|
extensions: [
|
|
SingleLineDocument,
|
|
Paragraph,
|
|
Text,
|
|
Placeholder.configure({
|
|
placeholder: placeholderText,
|
|
showOnlyCurrent: false,
|
|
}),
|
|
createTitleKeymap({ onSubmitRef }),
|
|
// Added last so its ProseMirror plugin sits ahead of the keymap above
|
|
// — same placement the ContentEditor extension list relies on. It
|
|
// brings the configured `send` chord, the IME guards, and key-repeat
|
|
// protection with it, none of which a hand-rolled keydown would have.
|
|
...(shortcutSubmitEnabled
|
|
? [
|
|
createSubmitShortcutExtension(() => {
|
|
const fn = onSubmitShortcutRef.current;
|
|
if (!fn) return false;
|
|
if (!titleShortcutSubmitAllowed(getShortcut("send"))) return false;
|
|
fn();
|
|
return true;
|
|
}),
|
|
]
|
|
: []),
|
|
],
|
|
editorProps: {
|
|
attributes: {
|
|
class: cn("title-editor outline-none", className),
|
|
role: "textbox",
|
|
"aria-multiline": "false",
|
|
"aria-label": placeholderText || t(($) => $.title_editor.title_aria_label),
|
|
},
|
|
},
|
|
onUpdate: ({ editor: ed }) => {
|
|
onChangeRef.current?.(ed.getText());
|
|
},
|
|
onBlur: ({ editor: ed }) => {
|
|
onBlurRef.current?.(ed.getText());
|
|
},
|
|
});
|
|
|
|
// Signal readonly-first hosts that the deferred editor now exists. Fired
|
|
// from a passive effect so it runs after the commit that attached the
|
|
// editor DOM — same pattern as ContentEditor.
|
|
const readyFiredRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!editor || readyFiredRef.current) return;
|
|
readyFiredRef.current = true;
|
|
onReadyRef.current?.();
|
|
}, [editor]);
|
|
|
|
// Auto-focus after mount — delay to wait for Dialog open animation
|
|
useEffect(() => {
|
|
if (autoFocus && editor) {
|
|
const timer = setTimeout(() => {
|
|
editor.commands.focus("end");
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
return undefined;
|
|
}, [autoFocus, editor]);
|
|
|
|
// Track the last `defaultValue` we've reconciled against, so we can tell
|
|
// "focused + dirty" (user typed something that diverges from external)
|
|
// apart from "focused + clean" (user just clicked in without typing).
|
|
const lastDefaultValueRef = useRef(defaultValue);
|
|
|
|
// Sync external `defaultValue` changes into the editor.
|
|
// Tiptap `useEditor` consumes `content` only at mount, so a WS-driven
|
|
// title update would otherwise leave the editor showing stale text — and
|
|
// the next blur would silently roll the external change back via onBlur's
|
|
// value-vs-issue.title compare.
|
|
useEffect(() => {
|
|
if (!editor || editor.isDestroyed) return;
|
|
const prevDefaultValue = lastDefaultValueRef.current;
|
|
lastDefaultValueRef.current = defaultValue;
|
|
|
|
// Already in sync — nothing to do.
|
|
if (editor.getText() === defaultValue) return;
|
|
|
|
// Focused + dirty: editor text diverges from the previous external
|
|
// value, meaning the user has typed in this session. Preserve input.
|
|
// Focused + clean (text still equals prev defaultValue) falls through
|
|
// so we accept the new external value instead of letting the next blur
|
|
// roll it back.
|
|
if (editor.isFocused && editor.getText() !== prevDefaultValue) return;
|
|
|
|
editor.commands.setContent(
|
|
defaultValue
|
|
? {
|
|
type: "doc",
|
|
content: [
|
|
{ type: "paragraph", content: [{ type: "text", text: defaultValue }] },
|
|
],
|
|
}
|
|
: "",
|
|
{ emitUpdate: false },
|
|
);
|
|
}, [defaultValue, editor]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
getText: () => editor?.getText() ?? "",
|
|
focus: () => {
|
|
editor?.commands.focus("end");
|
|
},
|
|
focusAtCoords: (coords: { x: number; y: number }) => {
|
|
if (!editor) return;
|
|
const pos = editor.view.posAtCoords({ left: coords.x, top: coords.y });
|
|
if (pos) editor.commands.focus(pos.pos);
|
|
else editor.commands.focus("end");
|
|
},
|
|
}));
|
|
|
|
if (!editor) return null;
|
|
|
|
return <EditorContent editor={editor} />;
|
|
},
|
|
);
|
|
|
|
export { TitleEditor, type TitleEditorProps, type TitleEditorRef };
|