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. | 1 | import { Extension, markInputRule, type Editor } from "@tiptap/core"; |
| 2 | import { Slice } from "@tiptap/pm/model"; | |
| 3 | import { Placeholder } from "@tiptap/extensions"; | |
| 4 | import { EditorContent, useEditor, useEditorState } from "@tiptap/react"; | |
| 5 | import StarterKit from "@tiptap/starter-kit"; | |
| 6 | import { | |
| 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"; | |
| 23 | import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, useEffect, useId, useMemo, useRef, useState } from "react"; | |
| 24 | ||
| 25 | import { AgentPill, MemberAvatar } from "./marks"; | |
| 26 | import { Hint } from "../ui/hint"; | |
| 27 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; | |
| 28 | // `:shortcode` completion and the emoji picker (components/emoji). | |
| 29 | import { useEmojiAutocomplete } from "../emoji/autocomplete"; | |
| 30 | import { EmojiPickerPopover } from "../emoji/picker"; | |
| 31 | import { type MentionQuery, type Mentionable, mentionQuery, safeHref, shownHandle } from "../../lib/chat"; | |
| 32 | import { docToMarkdown, markdownToDoc } from "../../lib/chat-compose"; | |
| 33 | ||
| 34 | /** How tall the box grows before it scrolls. */ | |
| 35 | const MAX_HEIGHT = 240; | |
| 36 | ||
| 37 | export 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~~`. */ | |
| 56 | const 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). */ | |
| 72 | function 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. */ | |
| 91 | function 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 | */ | |
| 102 | type TextBox = { value: string; selectionStart: number; selectionEnd: number; focus: () => void; setSelectionRange: (from: number, to: number) => void }; | |
| 103 | ||
| 104 | function 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. */ | |
| 126 | function 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. */ | |
| 142 | function 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 | ||
| 150 | const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl"; | |
| 151 | ||
| 152 | /** Every formatting shortcut, for the hints and the list. */ | |
| 153 | const 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 | ||
| 165 | const 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. */ | |
| 168 | function 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 | */ | |
| 205 | export 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. */ | |
| 705 | function 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 · > 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. */ | |
| 750 | function 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 | } |