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.

The chat composer formats as you write: a bar for bold, italic, strikethrough, links, lists, quotes, code and code blocks that can be shown or hidden, shortcuts listed under its keyboard button (Ctrl or Cmd with B, I, Shift+X, K, Shift+C, Alt+Shift+C, Shift+7, 8 and 9), and Markdown typed inline (*bold*, _italic_, ~strike~, `code`, ``` blocks, - and 1. lists, > quotes) turning into its formatting. Enter sends, Shift+Enter starts a line, Enter adds a list item or a code line where it should, Ctrl+Enter sends from anywhere, pasting a link over words links them, and plain text pasted in is read as Markdown. Mentions, emoji completion, drafts and typing work as before, messages are still sent and kept as Markdown, the editor loads only with the composer, and your own messages are edited in place with the same composer on a computer (Edit message in the message's menu) and in the sheet on a phone.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 { 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}