Skip to content
840 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.