Skip to content
849 linesCodeBlameRaw
1import { Extension, markInputRule, type Editor } from "@tiptap/core";
2import { Slice } from "@tiptap/pm/model";
3import { Placeholder } from "@tiptap/extensions";
4import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
5import StarterKit from "@tiptap/starter-kit";
6import {
7 ArrowUp,
8 AtSign,
9 Bold,
10 Code,
11 Italic,
12 Keyboard,
13 Link as LinkIcon,
14 List,
15 ListOrdered,
16 Smile,
17 SquareCode,
18 Strikethrough,
19 TextQuote,
20 Type,
21 Unlink,
22} from "lucide-react";
23import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, useEffect, useId, useMemo, useRef, useState } from "react";
24
25import { AgentPill, MemberAvatar } from "./marks";
26import { Button } from "../ui/button";
27import { Hint } from "../ui/hint";
28import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
29// `:shortcode` completion and the emoji picker (components/emoji).
30import { useEmojiAutocomplete } from "../emoji/autocomplete";
31import { EmojiPickerPopover } from "../emoji/picker";
32import { type MentionQuery, type Mentionable, mentionQuery, safeHref, shownHandle } from "../../lib/chat";
33import { docToMarkdown, markdownToDoc } from "../../lib/chat-compose";
34
35/** How tall the box grows before it scrolls. */
36const MAX_HEIGHT = 240;
37
38export type ComposerProps = {
39 /** Where the draft is kept while the tab is open; none when editing a message. */
40 draftKey?: string;
41 placeholder: string;
42 people: Mentionable[];
43 onSend: (body: string) => void;
44 onTyping?: () => void;
45 disabled?: boolean;
46 autoFocus?: boolean;
47 compact?: boolean;
48 /** Editing a message: its Markdown to start from, and what leaving does. */
49 initial?: string;
50 onCancel?: () => void;
51};
52
53// ---------------------------------------------------------------------------
54// The editor's rules: Markdown typed as you go, and the shortcuts.
55
56/** `*bold*` (one star, as chat writes it) and `**bold**`; `_italic_`; `~strike~` and `~~strike~~`. */
57const ChatMarks = Extension.create({
58 name: "chatMarks",
59 // Before the marks' own rules, so `*x*` is bold here, not italic.
60 priority: 1000,
61 addInputRules() {
62 const { bold, italic, strike } = this.editor.schema.marks;
63 return [
64 markInputRule({ find: /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/, type: bold! }),
65 markInputRule({ find: /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/, type: bold! }),
66 markInputRule({ find: /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/, type: italic! }),
67 markInputRule({ find: /(?:^|\s)(~(?!\s+~)((?:[^~]+))~(?!\s+~))$/, type: strike! }),
68 ];
69 },
70});
71
72/** The shortcuts beyond the editor's own (Mod+B, Mod+I, Mod+E, Mod+Shift+7 and 8). */
73function chatKeys(openLink: RefObject<() => void>) {
74 return Extension.create({
75 name: "chatKeys",
76 addKeyboardShortcuts() {
77 return {
78 "Mod-Shift-x": () => this.editor.commands.toggleStrike(),
79 "Mod-Shift-c": () => this.editor.commands.toggleCode(),
80 "Mod-Alt-Shift-c": () => this.editor.commands.toggleCodeBlock(),
81 "Mod-Shift-9": () => this.editor.commands.toggleBlockquote(),
82 "Mod-k": () => {
83 openLink.current();
84 return true;
85 },
86 };
87 },
88 });
89}
90
91/** Whether the caret is inside a node of this type. */
92function inside(editor: Editor, type: string): boolean {
93 const { $from } = editor.state.selection;
94 for (let depth = $from.depth; depth > 0; depth--) if ($from.node(depth).type.name === type) return true;
95 return false;
96}
97
98/**
99 * The block the caret is in, as a text box: what the `@` and `:` menus
100 * read and write, as they would a textarea. Each character of the block's
101 * text is one position in the document.
102 */
103type TextBox = { value: string; selectionStart: number; selectionEnd: number; focus: () => void; setSelectionRange: (from: number, to: number) => void };
104
105function textBox(editor: Editor): TextBox {
106 return {
107 get value() {
108 const parent = editor.state.selection.$from.parent;
109 return parent.textBetween(0, parent.content.size, undefined, "");
110 },
111 get selectionStart() {
112 return editor.state.selection.$from.parentOffset;
113 },
114 get selectionEnd() {
115 const { $from, $to } = editor.state.selection;
116 return $from.sameParent($to) ? $to.parentOffset : $from.parentOffset;
117 },
118 focus: () => editor.commands.focus(),
119 setSelectionRange: (from, to) => {
120 const start = editor.state.selection.$from.start();
121 editor.commands.setTextSelection({ from: start + from, to: start + to });
122 },
123 };
124}
125
126/** The caret's block made to read `next`: only what changed is replaced, so its formatting stays. */
127function replaceBlockText(editor: Editor, next: string) {
128 const box = textBox(editor);
129 const now = box.value;
130 let head = 0;
131 while (head < now.length && head < next.length && now[head] === next[head]) head++;
132 let tail = 0;
133 while (tail < now.length - head && tail < next.length - head && now[now.length - 1 - tail] === next[next.length - 1 - tail]) tail++;
134 const start = editor.state.selection.$from.start();
135 const inserted = next.slice(head, next.length - tail);
136 const tr = editor.state.tr;
137 if (inserted) tr.insertText(inserted, start + head, start + now.length - tail);
138 else tr.delete(start + head, start + now.length - tail);
139 editor.view.dispatch(tr);
140}
141
142/** What a link field holds: an address as typed, made into one a link may go to. */
143function linkAddress(typed: string): string | null {
144 const trimmed = typed.trim();
145 if (!trimmed) return null;
146 if (/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(trimmed)) return safeHref(`mailto:${trimmed}`);
147 if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !trimmed.startsWith("/")) return safeHref(`https://${trimmed}`);
148 return safeHref(trimmed);
149}
150
151const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl";
152
153/** Every formatting shortcut, for the hints and the list. */
154const SHORTCUTS: { label: string; keys: string[] }[] = [
155 { label: "Bold", keys: [MOD, "B"] },
156 { label: "Italic", keys: [MOD, "I"] },
157 { label: "Strikethrough", keys: [MOD, "Shift", "X"] },
158 { label: "Link", keys: [MOD, "K"] },
159 { label: "Code", keys: [MOD, "Shift", "C"] },
160 { label: "Code block", keys: [MOD, "Alt", "Shift", "C"] },
161 { label: "Bulleted list", keys: [MOD, "Shift", "8"] },
162 { label: "Numbered list", keys: [MOD, "Shift", "7"] },
163 { label: "Quote", keys: [MOD, "Shift", "9"] },
164];
165
166const keysOf = (label: string) => SHORTCUTS.find((s) => s.label === label)!.keys.join("+");
167
168/** Whether the formatting bar is shown: as last chosen on this device, else on wider screens. */
169function useToolbarShown(): [boolean, (shown: boolean) => void] {
170 const [shown, setShown] = useState(true);
171 useEffect(() => {
172 try {
173 const saved = localStorage.getItem("chat-formatting");
174 if (saved) return setShown(saved === "shown");
175 } catch {
176 // No storage: the screen decides.
177 }
178 setShown(window.matchMedia("(min-width: 768px)").matches);
179 }, []);
180 return [
181 shown,
182 (next: boolean) => {
183 setShown(next);
184 try {
185 localStorage.setItem("chat-formatting", next ? "shown" : "hidden");
186 } catch {
187 // Kept only while the page is open.
188 }
189 },
190 ];
191}
192
193// ---------------------------------------------------------------------------
194// The composer.
195
196/**
197 * Where a message is written, formatted as it is typed. Enter sends and
198 * Shift+Enter starts a new line; in a list Enter adds an item (twice to
199 * leave it), in a code block a line (three times to leave it), and
200 * Ctrl+Enter sends from anywhere. Markdown typed inline becomes its
201 * formatting; the bar above the text does the same with a click. `@`
202 * offers the workspace's people and agents and `:` its emoji (Up and Down
203 * to move, Tab or Enter to take one, Escape to dismiss). What is sent is
204 * Markdown. A draft is kept per conversation while the tab is open.
205 */
206export default function ChatEditor({ draftKey, placeholder, people, onSend, onTyping, disabled, autoFocus, compact, initial, onCancel }: ComposerProps) {
207 const editing = initial != null;
208 const [match, setMatch] = useState<MentionQuery | null>(null);
209 const [active, setActive] = useState(0);
210 const [link, setLink] = useState<{ text: string; href: string; existing: boolean } | null>(null);
211 const [toolbar, setToolbar] = useToolbarShown();
212 const list = useId();
213
214 // What the editor's handlers read: the latest of each, not the first render's.
215 const keys = useRef<(event: KeyboardEvent) => boolean>(() => false);
216 const openLink = useRef<() => void>(() => {});
217 const placeholderRef = useRef(placeholder);
218 placeholderRef.current = placeholder;
219 const typing = useRef(onTyping);
220 typing.current = onTyping;
221 const loading = useRef(false);
222
223 const extensions = useMemo(
224 () => [
225 StarterKit.configure({
226 heading: false,
227 horizontalRule: false,
228 underline: false,
229 dropcursor: false,
230 gapcursor: false,
231 trailingNode: false,
232 link: {
233 openOnClick: false,
234 autolink: false,
235 linkOnPaste: true,
236 protocols: ["mailto"],
237 isAllowedUri: (url) => safeHref(url) != null,
238 HTMLAttributes: { rel: "noopener noreferrer nofollow ugc", target: null },
239 },
240 codeBlock: { HTMLAttributes: { spellcheck: "false" } },
241 }),
242 ChatMarks,
243 chatKeys(openLink),
244 Placeholder.configure({ placeholder: () => placeholderRef.current }),
245 ],
246 [],
247 );
248
249 const draftOf = (key: string | undefined): string => {
250 if (editing) return initial ?? "";
251 if (!key) return "";
252 try {
253 return sessionStorage.getItem(`chat-draft:${key}`) ?? "";
254 } catch {
255 return "";
256 }
257 };
258
259 const editor = useEditor({
260 extensions,
261 immediatelyRender: false,
262 autofocus: autoFocus ? "end" : false,
263 editable: !disabled,
264 content: markdownToDoc(draftOf(draftKey)),
265 editorProps: {
266 attributes: {
267 role: "textbox",
268 "aria-multiline": "true",
269 "aria-label": placeholder,
270 "data-1p-ignore": "",
271 "data-lpignore": "true",
272 class: "chat-editor outline-none",
273 },
274 handleKeyDown: (_view, event) => keys.current(event),
275 // Text pasted without formatting is read as Markdown, the way it would be sent.
276 clipboardTextParser: (text, $context, plain, view) => {
277 if (plain || $context.parent.type.spec.code) return null as unknown as Slice;
278 const doc = view.state.schema.nodeFromJSON(markdownToDoc(text));
279 return Slice.maxOpen(doc.content);
280 },
281 },
282 onUpdate: ({ editor: now }) => {
283 if (loading.current) return;
284 look(now);
285 if (!editing && draftKey) {
286 const markdown = docToMarkdown(now.getJSON());
287 try {
288 if (markdown) sessionStorage.setItem(`chat-draft:${draftKey}`, markdown);
289 else sessionStorage.removeItem(`chat-draft:${draftKey}`);
290 } catch {
291 // No storage: the draft lives only while the page does.
292 }
293 }
294 typing.current?.();
295 },
296 onSelectionUpdate: ({ editor: now }) => look(now),
297 onBlur: () =>
298 setTimeout(() => {
299 setMatch(null);
300 emoji.dismiss();
301 }, 150),
302 });
303
304 // The emoji menu works on the caret's block as on a text box.
305 const box = useRef<TextBox | null>(null);
306 box.current = editor ? textBox(editor) : null;
307 const emoji = useEmojiAutocomplete({
308 box: box as unknown as RefObject<HTMLTextAreaElement | null>,
309 setText: (next) => editor && replaceBlockText(editor, next),
310 });
311
312 // Another conversation's draft when the conversation changes.
313 const shownKey = useRef(draftKey);
314 useEffect(() => {
315 if (!editor || shownKey.current === draftKey) return;
316 shownKey.current = draftKey;
317 loading.current = true;
318 editor.commands.setContent(markdownToDoc(draftOf(draftKey)), { emitUpdate: false });
319 loading.current = false;
320 setMatch(null);
321 setLink(null);
322 // eslint-disable-next-line react-hooks/exhaustive-deps
323 }, [editor, draftKey]);
324
325 useEffect(() => {
326 editor?.setEditable(!disabled);
327 }, [editor, disabled]);
328
329 const state = useEditorState({
330 editor,
331 selector: ({ editor: now }) =>
332 now
333 ? {
334 bold: now.isActive("bold"),
335 italic: now.isActive("italic"),
336 strike: now.isActive("strike"),
337 code: now.isActive("code"),
338 link: now.isActive("link"),
339 codeBlock: now.isActive("codeBlock"),
340 bulletList: now.isActive("bulletList"),
341 orderedList: now.isActive("orderedList"),
342 blockquote: now.isActive("blockquote"),
343 empty: now.isEmpty || docToMarkdown(now.getJSON()).trim() === "",
344 }
345 : null,
346 });
347
348 function look(now: Editor) {
349 emoji.look();
350 const { selection } = now.state;
351 if (!selection.empty || inside(now, "codeBlock") || now.isActive("code")) {
352 setMatch(null);
353 return;
354 }
355 const text = textBox(now);
356 setMatch(mentionQuery(text.value, text.selectionStart, people));
357 setActive(0);
358 }
359
360 const complete = (person: Mentionable) => {
361 if (!editor || !match) return;
362 const start = editor.state.selection.$from.start();
363 const caret = editor.state.selection.$from.parentOffset;
364 editor
365 .chain()
366 .focus()
367 .insertContentAt({ from: start + match.start, to: start + caret }, { type: "text", text: `@${person.name} ` })
368 .run();
369 setMatch(null);
370 };
371
372 const send = () => {
373 if (!editor || disabled) return;
374 const body = docToMarkdown(editor.getJSON()).trim();
375 if (!body) return;
376 onSend(body);
377 if (editing) return;
378 editor.commands.clearContent(true);
379 setMatch(null);
380 setLink(null);
381 };
382
383 openLink.current = () => {
384 if (!editor) return;
385 // On a link already: its whole text, to change or remove.
386 if (editor.isActive("link")) editor.chain().extendMarkRange("link").run();
387 const { from, to } = editor.state.selection;
388 setLink({ text: editor.state.doc.textBetween(from, to, " "), href: editor.getAttributes("link").href ?? "", existing: editor.isActive("link") });
389 };
390
391 keys.current = (event) => {
392 if (!editor) return false;
393 if (emoji.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLElement>)) return true;
394 if (match) {
395 const count = match.options.length;
396 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
397 event.preventDefault();
398 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
399 return true;
400 }
401 if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
402 event.preventDefault();
403 complete(match.options[active] ?? match.options[0]!);
404 return true;
405 }
406 if (event.key === "Escape") {
407 event.preventDefault();
408 setMatch(null);
409 return true;
410 }
411 }
412 if (event.key === "Escape" && editing) {
413 event.preventDefault();
414 onCancel?.();
415 return true;
416 }
417 if (event.key !== "Enter" || event.isComposing) return false;
418 // Ctrl+Enter (⌘+Enter) sends from anywhere, a list or code too.
419 if (event.ctrlKey || event.metaKey) {
420 event.preventDefault();
421 send();
422 return true;
423 }
424 const inCode = inside(editor, "codeBlock");
425 const inList = inside(editor, "listItem");
426 if (inCode) {
427 // A new line in the block; three Enters in a row leave it.
428 if (event.shiftKey) {
429 editor
430 .chain()
431 .command(({ tr }) => {
432 tr.insertText("\n");
433 return true;
434 })
435 .run();
436 return true;
437 }
438 return false;
439 }
440 if (inList) {
441 // A new item, or out of the list from an empty one.
442 return event.shiftKey ? editor.commands.splitListItem("listItem") || false : false;
443 }
444 if (event.shiftKey) {
445 // A new line: the next paragraph, so lists and quotes can start on it.
446 return editor.commands.splitBlock();
447 }
448 // ``` and a language, then Enter: a code block.
449 const fence = /^```([\w+#.-]*)$/.exec(textBox(editor).value);
450 if (fence) {
451 const start = editor.state.selection.$from.start();
452 editor
453 .chain()
454 .deleteRange({ from: start, to: start + fence[0].length })
455 .setCodeBlock(fence[1] ? { language: fence[1] } : undefined)
456 .run();
457 return true;
458 }
459 event.preventDefault();
460 send();
461 return true;
462 };
463
464 // The `@` list is the text box's: say so to a screen reader.
465 useEffect(() => {
466 const dom = editor?.view.dom;
467 if (!dom) return;
468 if (match) {
469 dom.setAttribute("aria-expanded", "true");
470 dom.setAttribute("aria-controls", list);
471 dom.setAttribute("aria-activedescendant", `${list}-${active}`);
472 } else {
473 dom.removeAttribute("aria-expanded");
474 dom.removeAttribute("aria-controls");
475 dom.removeAttribute("aria-activedescendant");
476 }
477 }, [editor, match, active, list]);
478
479 const applyLink = (text: string, href: string) => {
480 if (!editor) return false;
481 const address = linkAddress(href);
482 if (!address) return false;
483 const { from, to } = editor.state.selection;
484 if (from === to) {
485 const words = text.trim() || address;
486 editor
487 .chain()
488 .focus()
489 .insertContent({ type: "text", text: words, marks: [{ type: "link", attrs: { href: address } }] })
490 .unsetMark("link")
491 .insertContent({ type: "text", text: " " })
492 .run();
493 } else {
494 editor.chain().focus().extendMarkRange("link").setLink({ href: address }).run();
495 }
496 return true;
497 };
498
499 const tool = (label: string, icon: ReactNode, on: boolean | undefined, run: () => void) => (
500 <Hint label={`${label} (${keysOf(label)})`}>
501 <button
502 type="button"
503 aria-label={label}
504 aria-pressed={!!on}
505 disabled={disabled || !editor}
506 onMouseDown={(event) => event.preventDefault()}
507 onClick={run}
508 className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors sm:size-7 ${
509 on ? "bg-accent/15 text-accent" : "text-faint hover:bg-raised hover:text-fg"
510 }`}
511 >
512 {icon}
513 </button>
514 </Hint>
515 );
516
517 const chain = () => editor!.chain().focus();
518
519 return (
520 <div className="relative">
521 {emoji.menu}
522 {match && (
523 <ul
524 id={list}
525 role="listbox"
526 aria-label="Mention someone"
527 className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm overflow-hidden rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40"
528 >
529 {match.options.map((person, index) => (
530 <li
531 key={`${person.kind}:${person.name}`}
532 id={`${list}-${index}`}
533 role="option"
534 aria-selected={index === active}
535 onMouseDown={(event) => event.preventDefault()}
536 onMouseEnter={() => setActive(index)}
537 onClick={() => complete(person)}
538 className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
539 >
540 <MemberAvatar member={person} size={22} />
541 <span className="min-w-0 grow truncate text-sm">
542 <span className="font-medium text-fg">{person.display_name}</span>
543 <span className="ml-1.5 text-faint">@{shownHandle(person)}</span>
544 </span>
545 {person.kind === "agent" && <AgentPill />}
546 </li>
547 ))}
548 </ul>
549 )}
550 {link && (
551 <LinkForm
552 initial={link}
553 onApply={(text, href) => {
554 if (!applyLink(text, href)) return false;
555 setLink(null);
556 return true;
557 }}
558 onRemove={
559 link.existing
560 ? () => {
561 editor?.chain().focus().extendMarkRange("link").unsetLink().run();
562 setLink(null);
563 }
564 : undefined
565 }
566 onClose={() => {
567 setLink(null);
568 editor?.commands.focus();
569 }}
570 />
571 )}
572 <div
573 className={`rounded-xl border border-line-strong bg-surface transition-colors focus-within:border-accent/35 focus-within:ring-4 focus-within:ring-accent/[0.06] ${disabled ? "opacity-60" : ""}`}
574 >
575 {toolbar && (
576 <div
577 role="toolbar"
578 aria-label="Formatting"
579 className="flex items-center gap-0.5 overflow-x-auto border-b border-line px-1.5 py-1 [scrollbar-width:none]"
580 >
581 {tool("Bold", <Bold size={15} strokeWidth={2.4} />, state?.bold, () => chain().toggleBold().run())}
582 {tool("Italic", <Italic size={15} />, state?.italic, () => chain().toggleItalic().run())}
583 {tool("Strikethrough", <Strikethrough size={15} />, state?.strike, () => chain().toggleStrike().run())}
584 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
585 {tool("Link", <LinkIcon size={15} />, state?.link, () => openLink.current())}
586 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
587 {tool("Bulleted list", <List size={15} />, state?.bulletList, () => chain().toggleBulletList().run())}
588 {tool("Numbered list", <ListOrdered size={15} />, state?.orderedList, () => chain().toggleOrderedList().run())}
589 {tool("Quote", <TextQuote size={15} />, state?.blockquote, () => chain().toggleBlockquote().run())}
590 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
591 {tool("Code", <Code size={15} />, state?.code, () => chain().toggleCode().run())}
592 {tool("Code block", <SquareCode size={15} />, state?.codeBlock, () => chain().toggleCodeBlock().run())}
593 <ShortcutList />
594 </div>
595 )}
596 <div
597 className={`overflow-y-auto px-3.5 text-[0.9375rem] leading-6 text-fg [scrollbar-width:thin] ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
598 style={{ maxHeight: MAX_HEIGHT }}
599 onClick={(event) => {
600 // The whole box takes focus, not only its first line.
601 if (event.target === event.currentTarget) editor?.commands.focus("end");
602 }}
603 >
604 {editor ? (
605 <EditorContent editor={editor} />
606 ) : (
607 <p aria-hidden="true" className="text-faint">
608 {placeholder}
609 </p>
610 )}
611 </div>
612 <div className="flex items-center gap-1 px-2 pb-2">
613 <Hint label={toolbar ? "Hide formatting" : "Show formatting"}>
614 <button
615 type="button"
616 aria-label={toolbar ? "Hide formatting" : "Show formatting"}
617 aria-pressed={toolbar}
618 onMouseDown={(event) => event.preventDefault()}
619 onClick={() => setToolbar(!toolbar)}
620 className={`flex size-7 items-center justify-center rounded-md transition-colors ${toolbar ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}
621 >
622 <Type size={15} />
623 </button>
624 </Hint>
625 <Hint label="Mention someone">
626 <Button
627 type="button"
628 aria-label="Mention someone"
629 disabled={disabled || !editor}
630 onMouseDown={(event) => event.preventDefault()}
631 onClick={() => {
632 if (!editor) return;
633 const text = textBox(editor);
634 const before = text.value.slice(0, text.selectionStart);
635 editor
636 .chain()
637 .focus()
638 .insertContent({ type: "text", text: before && !/\s$/.test(before) ? " @" : "@" })
639 .run();
640 look(editor);
641 }}
642 variant="ghost"
643 size="icon-xs"
644 className="text-faint"
645 >
646 <AtSign size={15} />
647 </Button>
648 </Hint>
649 <EmojiPickerPopover side="top" align="start" onPick={(picked) => editor?.chain().focus().insertContent({ type: "text", text: picked }).run()}>
650 <Hint label="Add an emoji">
651 <Button
652 type="button"
653 aria-label="Add an emoji"
654 disabled={disabled || !editor}
655 variant="ghost"
656 size="icon-xs"
657 className="text-faint"
658 >
659 <Smile size={15} />
660 </Button>
661 </Hint>
662 </EmojiPickerPopover>
663 {editing ? (
664 <>
665 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
666 <kbd className="font-sans">Esc</kbd> to cancel · <kbd className="font-sans">Enter</kbd> to save
667 </span>
668 <span className="ml-auto flex items-center gap-1.5">
669 <Button
670 type="button"
671 onClick={onCancel}
672 variant="outline"
673 size="sm"
674 className="rounded-lg px-3 text-fg-soft hover:bg-raised"
675 >
676 Cancel
677 </Button>
678 <Button
679 type="button"
680 disabled={disabled || !editor || state?.empty}
681 onClick={send}
682 variant="accent"
683 size="sm"
684 className="rounded-lg px-3 disabled:bg-line disabled:text-faint disabled:opacity-100"
685 >
686 Save
687 </Button>
688 </span>
689 </>
690 ) : (
691 <>
692 <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
693 <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
694 </span>
695 <Button
696 type="button"
697 aria-label="Send"
698 disabled={disabled || !editor || state?.empty !== false}
699 onMouseDown={(event) => event.preventDefault()}
700 onClick={send}
701 variant="accent"
702 size="icon-sm"
703 className="ml-auto rounded-lg transition-[background-color,opacity] disabled:bg-line disabled:text-faint disabled:opacity-100"
704 >
705 <ArrowUp size={16} strokeWidth={2.4} />
706 </Button>
707 </>
708 )}
709 </div>
710 </div>
711 </div>
712 );
713}
714
715/** The bar's last button: every shortcut, and what Markdown typed becomes. */
716function ShortcutList() {
717 return (
718 <Popover>
719 <Hint label="Formatting shortcuts">
720 <PopoverTrigger asChild>
721 <Button variant="ghost" size="icon-sm" aria-label="Formatting shortcuts" onMouseDown={(event) => event.preventDefault()} className="ml-auto text-faint sm:size-7">
722 <Keyboard size={15} />
723 </Button>
724 </PopoverTrigger>
725 </Hint>
726 <PopoverContent side="top" align="end" className="w-80 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
727 <p className="mb-2 text-xs font-semibold text-fg">Formatting shortcuts</p>
728 <dl className="space-y-1 text-[0.8125rem]">
729 {SHORTCUTS.map((shortcut) => (
730 <div key={shortcut.label} className="flex items-center justify-between gap-3">
731 <dt className="text-fg-soft">{shortcut.label}</dt>
732 <dd className="flex gap-1">
733 {shortcut.keys.map((key) => (
734 <kbd key={key} className="rounded border border-line-strong bg-surface px-1.5 font-sans text-[0.6875rem] text-muted">
735 {key}
736 </kbd>
737 ))}
738 </dd>
739 </div>
740 ))}
741 </dl>
742 <p className="mt-3 mb-1.5 text-xs font-semibold text-fg">Or type Markdown</p>
743 <p className="font-mono text-[0.75rem] leading-relaxed text-muted">
744 *bold* _italic_ ~strike~ `code`
745 <br />
746 - list · 1. list · &gt; quote
747 <br />
748 ``` code block
749 </p>
750 <p className="mt-3 text-[0.75rem] text-faint">
751 <kbd className="font-sans">{MOD}+Enter</kbd> sends from a list or code block.
752 </p>
753 </PopoverContent>
754 </Popover>
755 );
756}
757
758/** Adding or changing a link: its text when nothing is selected, and where it goes. */
759function LinkForm({
760 initial,
761 onApply,
762 onRemove,
763 onClose,
764}: {
765 initial: { text: string; href: string; existing: boolean };
766 onApply: (text: string, href: string) => boolean;
767 onRemove?: () => void;
768 onClose: () => void;
769}) {
770 const [text, setText] = useState(initial.text);
771 const [href, setHref] = useState(initial.href);
772 const [wrong, setWrong] = useState(false);
773 const askText = !initial.text;
774 const id = useId();
775 const keys = (event: ReactKeyboardEvent) => {
776 if (event.key === "Escape") {
777 event.preventDefault();
778 event.stopPropagation();
779 onClose();
780 }
781 };
782 return (
783 <form
784 aria-label={initial.existing ? "Edit link" : "Add a link"}
785 onSubmit={(event) => {
786 event.preventDefault();
787 setWrong(!onApply(text, href));
788 }}
789 onKeyDown={keys}
790 className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm space-y-2 rounded-lg border border-line-strong bg-raised p-3 shadow-xl shadow-black/40"
791 >
792 {askText && (
793 <div className="space-y-1">
794 <label htmlFor={`${id}-text`} className="text-xs font-medium text-muted">
795 Text
796 </label>
797 <input
798 id={`${id}-text`}
799 autoFocus
800 value={text}
801 onChange={(event) => setText(event.target.value)}
802 placeholder="What the link says"
803 autoComplete="off"
804 className="h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/40"
805 />
806 </div>
807 )}
808 <div className="space-y-1">
809 <label htmlFor={`${id}-href`} className="text-xs font-medium text-muted">
810 Link
811 </label>
812 <input
813 id={`${id}-href`}
814 autoFocus={!askText}
815 value={href}
816 onChange={(event) => {
817 setHref(event.target.value);
818 setWrong(false);
819 }}
820 placeholder="https://"
821 inputMode="url"
822 autoComplete="off"
823 aria-invalid={wrong}
824 aria-describedby={wrong ? `${id}-wrong` : undefined}
825 className="h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/40 aria-invalid:border-danger/60"
826 />
827 {wrong && (
828 <p id={`${id}-wrong`} className="text-xs text-danger">
829 A link goes to a web address (https://…), an email address or a page on g1t.
830 </p>
831 )}
832 </div>
833 <div className="flex items-center gap-1.5 pt-0.5">
834 {onRemove && (
835 <Button type="button" onClick={onRemove} variant="ghost" size="sm" className="flex px-2 hover:bg-line font-normal">
836 <Unlink size={14} />
837 Remove link
838 </Button>
839 )}
840 <Button type="button" onClick={onClose} variant="ghost" size="sm" className="ml-auto px-3 hover:bg-line font-normal">
841 Cancel
842 </Button>
843 <Button type="submit" disabled={!href.trim()} variant="accent" size="sm" className="px-3 disabled:bg-line disabled:text-faint disabled:opacity-100">
844 {initial.existing ? "Save" : "Add link"}
845 </Button>
846 </div>
847 </form>
848 );
849}