| 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 | } |