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.
9 files+1585−2890/9 viewed
| 793 | 793 | line-height: 1.65; | |
| 794 | 794 | } | |
| 795 | 795 | ||
| 796 | + | /* The chat composer (components/chat/editor.tsx): formatting as messages show it. */ | |
| 797 | + | .chat-editor { | |
| 798 | + | white-space: pre-wrap; | |
| 799 | + | word-break: break-word; | |
| 800 | + | min-height: 1.5rem; | |
| 801 | + | } | |
| 802 | + | .chat-editor p.is-editor-empty:first-child::before { | |
| 803 | + | content: attr(data-placeholder); | |
| 804 | + | float: left; | |
| 805 | + | height: 0; | |
| 806 | + | pointer-events: none; | |
| 807 | + | color: var(--color-faint); | |
| 808 | + | } | |
| 809 | + | .chat-editor strong { | |
| 810 | + | @apply font-semibold text-fg; | |
| 811 | + | } | |
| 812 | + | .chat-editor s { | |
| 813 | + | @apply text-muted; | |
| 814 | + | } | |
| 815 | + | .chat-editor a { | |
| 816 | + | @apply text-accent underline underline-offset-2; | |
| 817 | + | } | |
| 818 | + | .chat-editor code { | |
| 819 | + | @apply rounded-[4px] border border-line bg-raised px-1 py-px font-mono text-[0.84em] text-fg-soft; | |
| 820 | + | } | |
| 821 | + | .chat-editor pre { | |
| 822 | + | @apply my-1 rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft; | |
| 823 | + | } | |
| 824 | + | .chat-editor pre code { | |
| 825 | + | @apply border-0 bg-transparent p-0 text-[1em]; | |
| 826 | + | } | |
| 827 | + | .chat-editor ul { | |
| 828 | + | @apply list-disc pl-5 marker:text-faint; | |
| 829 | + | } | |
| 830 | + | .chat-editor ol { | |
| 831 | + | @apply list-decimal pl-5 marker:text-faint; | |
| 832 | + | } | |
| 833 | + | .chat-editor li > p { | |
| 834 | + | @apply pl-0.5; | |
| 835 | + | } | |
| 836 | + | .chat-editor blockquote { | |
| 837 | + | @apply my-0.5 border-l-2 border-line-strong pl-3 text-muted; | |
| 838 | + | } | |
| 839 | + |
| 167 | 167 | const [actions, setActions] = useState<ShownMessage | null>(null); | |
| 168 | 168 | // Someone's profile, opened from their card, beside the conversation. | |
| 169 | 169 | const [profile, setProfile] = useState<string | null>(null); | |
| 170 | − | const [editing, setEditing] = useState<{ id: string; body: string } | null>(null); | |
| 170 | + | // A message being edited: in its place on a computer (`inline`), in a sheet on a phone. | |
| 171 | + | const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null); | |
| 171 | 172 | const scroller = useRef<HTMLDivElement>(null); | |
| 172 | 173 | const stick = useRef(true); | |
| 173 | 174 | ||
| ⋯ | |||
| 555 | 556 | }; | |
| 556 | 557 | ||
| 557 | 558 | const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]); | |
| 559 | + | const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at; | |
| 560 | + | // Editing uses the composer, started from the message's Markdown. | |
| 561 | + | const editor = (target: { id: string; body: string }) => ( | |
| 562 | + | <Composer | |
| 563 | + | initial={target.body} | |
| 564 | + | placeholder="Edit message" | |
| 565 | + | people={people} | |
| 566 | + | autoFocus | |
| 567 | + | compact | |
| 568 | + | onCancel={() => setEditing(null)} | |
| 569 | + | onSend={async (body) => { | |
| 570 | + | setEditing(null); | |
| 571 | + | if (body === target.body) return; | |
| 572 | + | // Shown edited at once; put back if the service refuses. | |
| 573 | + | const before = messages.find((m) => m.id === target.id); | |
| 574 | + | setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m))); | |
| 575 | + | const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body }); | |
| 576 | + | if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value])); | |
| 577 | + | else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m))); | |
| 578 | + | }} | |
| 579 | + | /> | |
| 580 | + | ); | |
| 558 | 581 | const typers = [...typing.values()].map((t) => t.member); | |
| 559 | 582 | const name = channel.name ?? ""; | |
| 560 | 583 | const archived = channel.kind === "channel" && !!channel.archived_at; | |
| ⋯ | |||
| 637 | 660 | onRetry={() => retry(row.message)} | |
| 638 | 661 | onLongPress={() => setActions(row.message)} | |
| 639 | 662 | onCopyLink={() => copyThreadLink(row.message.id)} | |
| 663 | + | onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined} | |
| 664 | + | editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined} | |
| 640 | 665 | onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined} | |
| 641 | 666 | threadOpen={row.message.id === threadId} | |
| 642 | 667 | /> | |
| ⋯ | |||
| 750 | 775 | link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""} | |
| 751 | 776 | onSend={askForWriteUp} | |
| 752 | 777 | /> | |
| 753 | − | <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message"> | |
| 754 | − | {editing && ( | |
| 755 | − | <form | |
| 756 | − | className="space-y-3 px-1 pt-1" | |
| 757 | − | onSubmit={async (event) => { | |
| 758 | − | event.preventDefault(); | |
| 759 | − | const body = editing.body.trim(); | |
| 760 | − | if (!body) return; | |
| 761 | − | // Shown edited at once; put back if the service refuses. | |
| 762 | − | const before = messages.find((m) => m.id === editing.id); | |
| 763 | − | setMessages((now) => now.map((m) => (m.id === editing.id ? { ...m, body, edited_at: new Date().toISOString() } : m))); | |
| 764 | − | setEditing(null); | |
| 765 | − | const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: editing.id, body }); | |
| 766 | − | if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value])); | |
| 767 | − | else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m))); | |
| 768 | − | }} | |
| 769 | − | > | |
| 778 | + | <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message"> | |
| 779 | + | {editing && !editing.inline && ( | |
| 780 | + | <div className="space-y-3 px-1 pt-1"> | |
| 770 | 781 | <h2 className="px-2 text-base font-semibold">Edit message</h2> | |
| 771 | − | <textarea | |
| 772 | − | autoFocus | |
| 773 | − | value={editing.body} | |
| 774 | − | onChange={(event) => setEditing({ ...editing, body: event.target.value })} | |
| 775 | − | rows={4} | |
| 776 | − | className="block w-full resize-none rounded-xl border border-line-strong bg-bg px-3.5 py-3 text-base text-fg outline-none focus:border-accent/40" | |
| 777 | − | /> | |
| 778 | − | <div className="flex gap-2"> | |
| 779 | − | <button type="button" onClick={() => setEditing(null)} className="h-11 grow rounded-xl border border-line text-[0.9375rem] font-medium active:bg-raised"> | |
| 780 | − | Cancel | |
| 781 | − | </button> | |
| 782 | − | <button type="submit" className="h-11 grow rounded-xl bg-accent text-[0.9375rem] font-medium text-bg active:bg-accent-hover"> | |
| 783 | − | Save | |
| 784 | − | </button> | |
| 785 | − | </div> | |
| 786 | − | </form> | |
| 782 | + | {editor(editing)} | |
| 783 | + | </div> | |
| 787 | 784 | )} | |
| 788 | 785 | </BottomSheet> | |
| 789 | 786 | </div> | |
| ⋯ | |||
| 1219 | 1216 | onWriteUp, | |
| 1220 | 1217 | threadOpen, | |
| 1221 | 1218 | inThread, | |
| 1219 | + | onEdit, | |
| 1220 | + | editor, | |
| 1222 | 1221 | }: { | |
| 1222 | + | /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */ | |
| 1223 | + | onEdit?: () => void; | |
| 1224 | + | editor?: ReactNode; | |
| 1223 | 1225 | /** A phone: a long press opens the message's actions. */ | |
| 1224 | 1226 | onLongPress?: () => void; | |
| 1225 | 1227 | /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */ | |
| ⋯ | |||
| 1276 | 1278 | </div> | |
| 1277 | 1279 | )} | |
| 1278 | 1280 | <div className={message.pending && !message.failed ? "opacity-60" : undefined}> | |
| 1279 | − | {message.body && <MessageText body={message.body} context={context} />} | |
| 1281 | + | {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />} | |
| 1280 | 1282 | {message.card && ( | |
| 1281 | 1283 | <CardBox | |
| 1282 | 1284 | card={message.card} | |
| ⋯ | |||
| 1327 | 1329 | <MessageSquareText size={15} /> | |
| 1328 | 1330 | </button> | |
| 1329 | 1331 | </Hint> | |
| 1330 | − | {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />} | |
| 1332 | + | {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />} | |
| 1331 | 1333 | </div> | |
| 1332 | 1334 | )} | |
| 1333 | 1335 | </article> | |
| ⋯ | |||
| 1335 | 1337 | } | |
| 1336 | 1338 | ||
| 1337 | 1339 | /** A message's "⋯" in its hover toolbar. */ | |
| 1338 | − | function MoreActions({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) { | |
| 1340 | + | function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) { | |
| 1339 | 1341 | return ( | |
| 1340 | 1342 | <DropdownMenu> | |
| 1341 | 1343 | <Hint label="More actions"> | |
| ⋯ | |||
| 1347 | 1349 | </DropdownMenuTrigger> | |
| 1348 | 1350 | </Hint> | |
| 1349 | 1351 | <DropdownMenuContent align="end"> | |
| 1352 | + | {onEdit && ( | |
| 1353 | + | <DropdownMenuItem onSelect={onEdit}> | |
| 1354 | + | <Pencil /> | |
| 1355 | + | Edit message | |
| 1356 | + | </DropdownMenuItem> | |
| 1357 | + | )} | |
| 1350 | 1358 | <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} /> | |
| 1351 | 1359 | </DropdownMenuContent> | |
| 1352 | 1360 | </DropdownMenu> | |
| 1 | − | import { ArrowUp, AtSign, Smile } from "lucide-react"; | |
| 2 | − | import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; | |
| 1 | + | import { ArrowUp } from "lucide-react"; | |
| 2 | + | import { Suspense, lazy } from "react"; | |
| 3 | 3 | ||
| 4 | − | import { AgentPill, MemberAvatar } from "./marks"; | |
| 5 | − | import { Hint } from "../ui/hint"; | |
| 6 | − | // `:shortcode` completion and the emoji picker (components/emoji). | |
| 7 | − | import { useEmojiAutocomplete } from "../emoji/autocomplete"; | |
| 8 | − | import { EmojiPickerPopover } from "../emoji/picker"; | |
| 9 | − | import { type MentionQuery, type Mentionable, mentionQuery, shownHandle } from "../../lib/chat"; | |
| 4 | + | import type { ComposerProps } from "./editor"; | |
| 10 | 5 | ||
| 11 | − | /** How tall the box grows before it scrolls. */ | |
| 12 | − | const MAX_HEIGHT = 240; | |
| 6 | + | // The editor (components/chat/editor.tsx) is loaded with the first composer, | |
| 7 | + | // so pages without one never fetch it. | |
| 8 | + | const ChatEditor = lazy(() => import("./editor")); | |
| 13 | 9 | ||
| 14 | 10 | /** | |
| 15 | − | * Where a message is written. Enter sends, Shift+Enter starts a new line; | |
| 16 | − | * `@` offers the workspace's people and agents (Up and Down to move, Tab or | |
| 17 | − | * Enter to take one, Escape to dismiss). What is typed is kept per | |
| 18 | − | * conversation while the tab is open, so moving away loses nothing. | |
| 11 | + | * Where a message is written: the formatting editor, with what it looks | |
| 12 | + | * like for the moment it takes to load. See components/chat/editor.tsx. | |
| 19 | 13 | */ | |
| 20 | − | export function Composer({ | |
| 21 | − | draftKey, | |
| 22 | − | placeholder, | |
| 23 | − | people, | |
| 24 | − | onSend, | |
| 25 | − | onTyping, | |
| 26 | − | disabled, | |
| 27 | − | autoFocus, | |
| 28 | − | compact, | |
| 29 | − | }: { | |
| 30 | − | draftKey: string; | |
| 31 | − | placeholder: string; | |
| 32 | − | people: Mentionable[]; | |
| 33 | − | onSend: (body: string) => void; | |
| 34 | − | onTyping?: () => void; | |
| 35 | − | disabled?: boolean; | |
| 36 | − | autoFocus?: boolean; | |
| 37 | − | compact?: boolean; | |
| 38 | − | }) { | |
| 39 | − | const box = useRef<HTMLTextAreaElement>(null); | |
| 40 | − | const [text, setText] = useState(""); | |
| 41 | − | const [match, setMatch] = useState<MentionQuery | null>(null); | |
| 42 | − | const [active, setActive] = useState(0); | |
| 43 | − | const list = useId(); | |
| 44 | − | const emoji = useEmojiAutocomplete({ box, setText }); | |
| 45 | − | ||
| 46 | − | // The draft for this conversation, if one was left. | |
| 47 | − | useEffect(() => { | |
| 48 | − | try { | |
| 49 | − | setText(sessionStorage.getItem(`chat-draft:${draftKey}`) ?? ""); | |
| 50 | − | } catch { | |
| 51 | − | setText(""); | |
| 52 | − | } | |
| 53 | − | }, [draftKey]); | |
| 54 | − | useEffect(() => { | |
| 55 | − | try { | |
| 56 | − | if (text) sessionStorage.setItem(`chat-draft:${draftKey}`, text); | |
| 57 | − | else sessionStorage.removeItem(`chat-draft:${draftKey}`); | |
| 58 | − | } catch { | |
| 59 | − | // No storage: the draft lives only while the page does. | |
| 60 | − | } | |
| 61 | − | }, [draftKey, text]); | |
| 62 | − | ||
| 63 | − | // Grows with what is written, up to a point. | |
| 64 | − | useLayoutEffect(() => { | |
| 65 | − | const element = box.current; | |
| 66 | − | if (!element) return; | |
| 67 | − | element.style.height = "auto"; | |
| 68 | − | element.style.height = `${Math.min(MAX_HEIGHT, element.scrollHeight)}px`; | |
| 69 | − | }, [text]); | |
| 14 | + | export function Composer(props: ComposerProps) { | |
| 15 | + | return ( | |
| 16 | + | <Suspense fallback={<Loading placeholder={props.placeholder} compact={props.compact} />}> | |
| 17 | + | <ChatEditor {...props} /> | |
| 18 | + | </Suspense> | |
| 19 | + | ); | |
| 20 | + | } | |
| 70 | 21 | ||
| 71 | − | const look = () => { | |
| 72 | − | const element = box.current; | |
| 73 | − | if (!element) return; | |
| 74 | − | emoji.look(); | |
| 75 | − | setMatch(element.selectionStart === element.selectionEnd ? mentionQuery(element.value, element.selectionStart, people) : null); | |
| 76 | − | setActive(0); | |
| 77 | − | }; | |
| 78 | − | ||
| 79 | − | const complete = (person: Mentionable) => { | |
| 80 | − | const element = box.current; | |
| 81 | − | if (!element || !match) return; | |
| 82 | − | const caret = element.selectionStart; | |
| 83 | − | const next = `${element.value.slice(0, match.start)}@${person.name} ${element.value.slice(caret)}`; | |
| 84 | − | setText(next); | |
| 85 | − | setMatch(null); | |
| 86 | − | requestAnimationFrame(() => { | |
| 87 | − | const at = match.start + person.name.length + 2; | |
| 88 | − | element.focus(); | |
| 89 | − | element.setSelectionRange(at, at); | |
| 90 | − | }); | |
| 91 | − | }; | |
| 92 | − | ||
| 93 | − | const send = () => { | |
| 94 | − | const body = text.trim(); | |
| 95 | − | if (!body || disabled) return; | |
| 96 | − | onSend(body); | |
| 97 | − | setText(""); | |
| 98 | − | setMatch(null); | |
| 99 | − | }; | |
| 100 | − | ||
| 101 | − | const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => { | |
| 102 | − | if (emoji.onKeyDown(event)) return; | |
| 103 | − | if (match) { | |
| 104 | − | const count = match.options.length; | |
| 105 | − | if (event.key === "ArrowDown" || event.key === "ArrowUp") { | |
| 106 | − | event.preventDefault(); | |
| 107 | − | setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count); | |
| 108 | − | return; | |
| 109 | − | } | |
| 110 | − | if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) { | |
| 111 | − | event.preventDefault(); | |
| 112 | − | complete(match.options[active] ?? match.options[0]!); | |
| 113 | − | return; | |
| 114 | − | } | |
| 115 | − | if (event.key === "Escape") { | |
| 116 | − | event.preventDefault(); | |
| 117 | − | setMatch(null); | |
| 118 | − | return; | |
| 119 | − | } | |
| 120 | − | } | |
| 121 | − | // Enter sends; Shift+Enter, and Enter while composing an IME word, does not. | |
| 122 | − | if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { | |
| 123 | − | event.preventDefault(); | |
| 124 | − | send(); | |
| 125 | − | } | |
| 126 | − | }; | |
| 127 | − | ||
| 128 | − | const open = match != null; | |
| 22 | + | /** The composer's frame, still, while the editor loads. */ | |
| 23 | + | function Loading({ placeholder, compact }: { placeholder: string; compact?: boolean }) { | |
| 129 | 24 | return ( | |
| 130 | − | <div className="relative"> | |
| 131 | − | {emoji.menu} | |
| 132 | − | {match && ( | |
| 133 | − | <ul | |
| 134 | − | id={list} | |
| 135 | − | role="listbox" | |
| 136 | − | aria-label="Mention someone" | |
| 137 | − | 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" | |
| 138 | − | > | |
| 139 | − | {match.options.map((person, index) => ( | |
| 140 | − | <li | |
| 141 | − | key={`${person.kind}:${person.name}`} | |
| 142 | − | id={`${list}-${index}`} | |
| 143 | − | role="option" | |
| 144 | − | aria-selected={index === active} | |
| 145 | − | onMouseDown={(event) => event.preventDefault()} | |
| 146 | − | onMouseEnter={() => setActive(index)} | |
| 147 | − | onClick={() => complete(person)} | |
| 148 | − | className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`} | |
| 149 | − | > | |
| 150 | − | <MemberAvatar member={person} size={22} /> | |
| 151 | − | <span className="min-w-0 grow truncate text-sm"> | |
| 152 | − | <span className="font-medium text-fg">{person.display_name}</span> | |
| 153 | − | <span className="ml-1.5 text-faint">@{shownHandle(person)}</span> | |
| 154 | − | </span> | |
| 155 | − | {person.kind === "agent" && <AgentPill />} | |
| 156 | − | </li> | |
| 157 | − | ))} | |
| 158 | − | </ul> | |
| 159 | − | )} | |
| 160 | − | <div | |
| 161 | − | 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" : ""}`} | |
| 162 | − | > | |
| 163 | − | <textarea | |
| 164 | − | ref={box} | |
| 165 | − | value={text} | |
| 166 | − | rows={1} | |
| 167 | − | disabled={disabled} | |
| 168 | − | autoFocus={autoFocus} | |
| 169 | − | placeholder={placeholder} | |
| 170 | − | aria-label={placeholder} | |
| 171 | − | role={open ? "combobox" : undefined} | |
| 172 | − | aria-autocomplete="list" | |
| 173 | − | aria-expanded={open} | |
| 174 | − | aria-controls={open ? list : undefined} | |
| 175 | − | aria-activedescendant={open ? `${list}-${active}` : undefined} | |
| 176 | − | autoComplete="off" | |
| 177 | − | data-1p-ignore | |
| 178 | − | data-lpignore="true" | |
| 179 | − | onChange={(event) => { | |
| 180 | − | setText(event.target.value); | |
| 181 | − | onTyping?.(); | |
| 182 | − | }} | |
| 183 | − | onInput={look} | |
| 184 | − | onClick={look} | |
| 185 | − | onKeyDown={onKeyDown} | |
| 186 | − | onBlur={() => | |
| 187 | − | setTimeout(() => { | |
| 188 | − | setMatch(null); | |
| 189 | − | emoji.dismiss(); | |
| 190 | − | }, 150) | |
| 191 | − | } | |
| 192 | − | className={`block w-full resize-none bg-transparent px-3.5 text-[0.9375rem] leading-6 text-fg outline-none placeholder:text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`} | |
| 193 | − | /> | |
| 194 | − | <div className="flex items-center gap-1 px-2 pb-2"> | |
| 195 | − | <Hint label="Mention someone"> | |
| 196 | − | <button | |
| 197 | − | type="button" | |
| 198 | − | aria-label="Mention someone" | |
| 199 | − | disabled={disabled} | |
| 200 | − | onClick={() => { | |
| 201 | − | const element = box.current; | |
| 202 | − | if (!element) return; | |
| 203 | − | const at = element.selectionStart; | |
| 204 | − | const before = element.value.slice(0, at); | |
| 205 | − | const insert = before && !/\s$/.test(before) ? " @" : "@"; | |
| 206 | − | const next = `${before}${insert}${element.value.slice(at)}`; | |
| 207 | − | setText(next); | |
| 208 | − | requestAnimationFrame(() => { | |
| 209 | − | element.focus(); | |
| 210 | − | element.setSelectionRange(at + insert.length, at + insert.length); | |
| 211 | − | setMatch(mentionQuery(next, at + insert.length, people)); | |
| 212 | − | }); | |
| 213 | − | }} | |
| 214 | − | className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 215 | − | > | |
| 216 | − | <AtSign size={15} /> | |
| 217 | − | </button> | |
| 218 | − | </Hint> | |
| 219 | − | <EmojiPickerPopover | |
| 220 | − | side="top" | |
| 221 | − | align="start" | |
| 222 | − | onPick={(picked) => { | |
| 223 | − | const element = box.current; | |
| 224 | − | const at = element?.selectionStart ?? text.length; | |
| 225 | − | const end = element?.selectionEnd ?? at; | |
| 226 | − | const next = `${text.slice(0, at)}${picked}${text.slice(end)}`; | |
| 227 | − | setText(next); | |
| 228 | − | requestAnimationFrame(() => { | |
| 229 | − | element?.focus(); | |
| 230 | − | element?.setSelectionRange(at + picked.length, at + picked.length); | |
| 231 | − | }); | |
| 232 | − | }} | |
| 233 | − | > | |
| 234 | − | <Hint label="Add an emoji"> | |
| 235 | − | <button | |
| 236 | − | type="button" | |
| 237 | − | aria-label="Add an emoji" | |
| 238 | − | disabled={disabled} | |
| 239 | − | className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 240 | − | > | |
| 241 | − | <Smile size={15} /> | |
| 242 | − | </button> | |
| 243 | − | </Hint> | |
| 244 | − | </EmojiPickerPopover> | |
| 245 | − | <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline"> | |
| 246 | − | <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line | |
| 247 | − | </span> | |
| 248 | − | <button | |
| 249 | − | type="button" | |
| 250 | − | aria-label="Send" | |
| 251 | − | disabled={disabled || !text.trim()} | |
| 252 | − | onClick={send} | |
| 253 | − | 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" | |
| 254 | − | > | |
| 255 | − | <ArrowUp size={16} strokeWidth={2.4} /> | |
| 256 | − | </button> | |
| 257 | − | </div> | |
| 25 | + | <div aria-busy="true" className="rounded-xl border border-line-strong bg-surface"> | |
| 26 | + | <p className={`px-3.5 text-[0.9375rem] leading-6 text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}>{placeholder}</p> | |
| 27 | + | <div className="flex items-center gap-1 px-2 pb-2"> | |
| 28 | + | <span className="h-7" /> | |
| 29 | + | <span aria-hidden="true" className="ml-auto flex size-8 items-center justify-center rounded-lg bg-line text-faint"> | |
| 30 | + | <ArrowUp size={16} strokeWidth={2.4} /> | |
| 31 | + | </span> | |
| 258 | 32 | </div> | |
| 259 | 33 | </div> | |
| 260 | 34 | ); |
| 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 | + | } |
| 198 | 198 | }, [language, code]); | |
| 199 | 199 | return ( | |
| 200 | 200 | <div className="group/code relative my-1"> | |
| 201 | − | <pre className="overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]"> | |
| 201 | + | <pre className="overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 pointer-coarse:pr-10 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]"> | |
| 202 | 202 | <code> | |
| 203 | 203 | {rows | |
| 204 | 204 | ? rows.map((row, index) => ( | |
| ⋯ | |||
| 214 | 214 | </code> | |
| 215 | 215 | </pre> | |
| 216 | 216 | <div className="absolute top-1.5 right-1.5 flex items-center gap-2 opacity-0 transition-opacity group-hover/code:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100"> | |
| 217 | − | {language && <span className="font-mono text-[0.6875rem] text-faint">{language}</span>} | |
| 217 | + | {language && <span className="font-mono text-[0.6875rem] text-faint pointer-coarse:hidden">{language}</span>} | |
| 218 | 218 | <Hint label={copied ? "Copied" : "Copy code"}> | |
| 219 | 219 | <button | |
| 220 | 220 | type="button" | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { type DocNode, docToMarkdown, escapeText, markdownToDoc } from "./chat-compose.ts"; | |
| 5 | + | ||
| 6 | + | const text = (value: string, ...marks: (string | { type: string; attrs?: Record<string, unknown> })[]): DocNode => ({ | |
| 7 | + | type: "text", | |
| 8 | + | text: value, | |
| 9 | + | ...(marks.length ? { marks: marks.map((mark) => (typeof mark === "string" ? { type: mark } : mark)) } : {}), | |
| 10 | + | }); | |
| 11 | + | const p = (...content: DocNode[]): DocNode => (content.length ? { type: "paragraph", content } : { type: "paragraph" }); | |
| 12 | + | const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content }); | |
| 13 | + | ||
| 14 | + | test("formatting is written as Markdown", () => { | |
| 15 | + | assert.equal( | |
| 16 | + | docToMarkdown(doc(p(text("bold", "bold"), text(" "), text("it", "italic"), text(" "), text("gone", "strike"), text(" "), text("a*b", "code")))), | |
| 17 | + | "**bold** _it_ ~~gone~~ `a*b`", | |
| 18 | + | ); | |
| 19 | + | // Marks that overlap open and close once. | |
| 20 | + | assert.equal(docToMarkdown(doc(p(text("a", "bold"), text("b", "bold", "italic"), text("c", "bold")))), "**a_b_c**"); | |
| 21 | + | // Spaces at a mark's edges sit outside it. | |
| 22 | + | assert.equal(docToMarkdown(doc(p(text("say "), text(" hi ", "bold"), text("there")))), "say **hi** there"); | |
| 23 | + | assert.equal(docToMarkdown(doc(p(text("tick ` in", "code")))), "`` tick ` in ``"); | |
| 24 | + | }); | |
| 25 | + | ||
| 26 | + | test("links: with their text, or bare when the text is the address", () => { | |
| 27 | + | const link = (href: string) => ({ type: "link", attrs: { href } }); | |
| 28 | + | assert.equal(docToMarkdown(doc(p(text("the docs", link("https://g1t.sh/docs"))))), "[the docs](https://g1t.sh/docs)"); | |
| 29 | + | assert.equal(docToMarkdown(doc(p(text("https://g1t.sh", link("https://g1t.sh"))))), "https://g1t.sh"); | |
| 30 | + | assert.equal(docToMarkdown(doc(p(text("x", link("https://a.example/a b"))))), "[x](<https://a.example/a%20b>)"); | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | test("each paragraph is a line; an empty one is a blank line", () => { | |
| 34 | + | assert.equal(docToMarkdown(doc(p(text("one")), p(text("two")), p(), p(text("three")))), "one\ntwo\n\nthree"); | |
| 35 | + | assert.equal(docToMarkdown(doc(p(), p(text("x")), p())), "x"); | |
| 36 | + | }); | |
| 37 | + | ||
| 38 | + | test("text that only looks like Markdown is escaped, and mentions stay mentions", () => { | |
| 39 | + | assert.equal(escapeText("2*3 and snake_case and _edge_ and [x] and `t` and ~5"), "2\\*3 and snake_case and \\_edge\\_ and \\[x\\] and \\`t\\` and \\~5"); | |
| 40 | + | assert.equal(docToMarkdown(doc(p(text("- not a list")), p(text("1. not either")), p(text("# nor this")), p(text("> or this")))), "\\- not a list\n1\\. not either\n\\# nor this\n\\> or this"); | |
| 41 | + | assert.equal(docToMarkdown(doc(p(text("ask @ana in #web about #12")))), "ask @ana in #web about #12"); | |
| 42 | + | }); | |
| 43 | + | ||
| 44 | + | test("lists, quotes and code blocks", () => { | |
| 45 | + | const item = (...content: DocNode[]): DocNode => ({ type: "listItem", content }); | |
| 46 | + | assert.equal( | |
| 47 | + | docToMarkdown( | |
| 48 | + | doc( | |
| 49 | + | p(text("Plan:")), | |
| 50 | + | { type: "bulletList", content: [item(p(text("one"))), item(p(text("two")), { type: "orderedList", attrs: { start: 1 }, content: [item(p(text("deep")))] })] }, | |
| 51 | + | p(text("after")), | |
| 52 | + | ), | |
| 53 | + | ), | |
| 54 | + | "Plan:\n\n- one\n- two\n 1. deep\n\nafter", | |
| 55 | + | ); | |
| 56 | + | assert.equal(docToMarkdown(doc({ type: "orderedList", attrs: { start: 3 }, content: [item(p(text("c"))), item(p(text("d")))] })), "3. c\n4. d"); | |
| 57 | + | assert.equal(docToMarkdown(doc({ type: "blockquote", content: [p(text("quoted")), p(), p(text("more"))] })), "> quoted\n>\n> more"); | |
| 58 | + | assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;\n\n\nconst b = `x`;" }] })), "```ts\nconst a = 1;\n\n\nconst b = `x`;\n```"); | |
| 59 | + | assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "```\nfence\n```" }] })), "````\n```\nfence\n```\n````"); | |
| 60 | + | }); | |
| 61 | + | ||
| 62 | + | test("Markdown comes back into the composer as it was written", () => { | |
| 63 | + | const samples = [ | |
| 64 | + | "**bold** _it_ ~~gone~~ `code`", | |
| 65 | + | "one\ntwo\n\nthree", | |
| 66 | + | "Plan:\n\n- one\n- two\n 1. deep\n\nafter", | |
| 67 | + | "3. c\n4. d", | |
| 68 | + | "> quoted\n>\n> more", | |
| 69 | + | "```ts\nconst a = 1;\n\nconst b = 2;\n```", | |
| 70 | + | "[the docs](https://g1t.sh/docs) and https://g1t.sh", | |
| 71 | + | "ask @ana in #web about g1t#12", | |
| 72 | + | "\\- not a list and 2\\*3 and snake_case", | |
| 73 | + | "**a _b_ c**", | |
| 74 | + | ]; | |
| 75 | + | for (const sample of samples) assert.equal(docToMarkdown(markdownToDoc(sample)), sample, sample); | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test("Markdown the composer cannot write still comes in", () => { | |
| 79 | + | // A heading is a bold line; a rule is left out; an image is a link. | |
| 80 | + | assert.equal(docToMarkdown(markdownToDoc("## Notes\n---\ntext")), "**Notes**\ntext"); | |
| 81 | + | assert.equal(docToMarkdown(markdownToDoc("*star em* and __strong__ and ~one~")), "_star em_ and **strong** and ~~one~~"); | |
| 82 | + | assert.deepEqual(markdownToDoc(""), { type: "doc", content: [{ type: "paragraph" }] }); | |
| 83 | + | }); |
| 1 | + | /** | |
| 2 | + | * The chat composer's document and the Markdown a message is stored as, | |
| 3 | + | * each turned into the other. The composer (components/chat/editor.tsx) | |
| 4 | + | * edits a small rich-text document; what it sends, keeps as a draft and | |
| 5 | + | * edits again is Markdown, the same text the API, agents and notifications | |
| 6 | + | * read. Pure, on the editor's JSON, so it is tested without a browser | |
| 7 | + | * (chat-compose.test.ts). | |
| 8 | + | * | |
| 9 | + | * In the document each paragraph is one line of the message: Shift+Enter | |
| 10 | + | * starts a new one, an empty one is a blank line. | |
| 11 | + | */ | |
| 12 | + | import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown"; | |
| 13 | + | ||
| 14 | + | /** The editor's JSON, as much of it as is read here. */ | |
| 15 | + | export type DocNode = { | |
| 16 | + | type: string; | |
| 17 | + | attrs?: Record<string, unknown>; | |
| 18 | + | content?: DocNode[]; | |
| 19 | + | text?: string; | |
| 20 | + | marks?: { type: string; attrs?: Record<string, unknown> }[]; | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | type Mark = { type: string; attrs?: Record<string, unknown> }; | |
| 24 | + | ||
| 25 | + | // --------------------------------------------------------------------------- | |
| 26 | + | // Markdown to the document. | |
| 27 | + | ||
| 28 | + | function textNode(text: string, marks: Mark[]): DocNode | null { | |
| 29 | + | if (!text) return null; | |
| 30 | + | return marks.length ? { type: "text", text, marks } : { type: "text", text }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | function spanNodes(list: Span[], marks: Mark[] = []): DocNode[] { | |
| 34 | + | const out: DocNode[] = []; | |
| 35 | + | const add = (node: DocNode | null) => node && out.push(node); | |
| 36 | + | for (const span of list) { | |
| 37 | + | switch (span.t) { | |
| 38 | + | case "text": | |
| 39 | + | add(textNode(span.v, marks)); | |
| 40 | + | break; | |
| 41 | + | case "code": | |
| 42 | + | add(textNode(span.v, [...marks, { type: "code" }])); | |
| 43 | + | break; | |
| 44 | + | case "strong": | |
| 45 | + | out.push(...spanNodes(span.c, [...marks, { type: "bold" }])); | |
| 46 | + | break; | |
| 47 | + | case "em": | |
| 48 | + | out.push(...spanNodes(span.c, [...marks, { type: "italic" }])); | |
| 49 | + | break; | |
| 50 | + | case "del": | |
| 51 | + | out.push(...spanNodes(span.c, [...marks, { type: "strike" }])); | |
| 52 | + | break; | |
| 53 | + | case "link": | |
| 54 | + | out.push(...spanNodes(span.c, [{ type: "link", attrs: { href: span.href } }, ...marks])); | |
| 55 | + | break; | |
| 56 | + | case "mention": | |
| 57 | + | add(textNode(`@${span.name}`, marks)); | |
| 58 | + | break; | |
| 59 | + | case "channel": | |
| 60 | + | add(textNode(`#${span.name}`, marks)); | |
| 61 | + | break; | |
| 62 | + | case "ref": | |
| 63 | + | add(textNode(`${span.repo ?? ""}#${span.number}`, marks)); | |
| 64 | + | break; | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | return out; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | function paragraph(spans: Span[]): DocNode { | |
| 71 | + | const content = spanNodes(spans); | |
| 72 | + | return content.length ? { type: "paragraph", content } : { type: "paragraph" }; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | function blockNodes(list: Block[]): DocNode[] { | |
| 76 | + | const out: DocNode[] = []; | |
| 77 | + | list.forEach((block, index) => { | |
| 78 | + | // Paragraphs one after another were apart by a blank line: an empty line between them. | |
| 79 | + | if (block.t === "p" && index > 0 && list[index - 1]!.t === "p") out.push({ type: "paragraph" }); | |
| 80 | + | switch (block.t) { | |
| 81 | + | case "p": | |
| 82 | + | for (const line of block.lines) out.push(paragraph(line)); | |
| 83 | + | break; | |
| 84 | + | case "heading": | |
| 85 | + | // Chat has no headings to write; one comes back as a bold line. | |
| 86 | + | out.push(paragraph([{ t: "strong", c: block.c }])); | |
| 87 | + | break; | |
| 88 | + | case "hr": | |
| 89 | + | break; | |
| 90 | + | case "code": | |
| 91 | + | out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) }); | |
| 92 | + | break; | |
| 93 | + | case "quote": { | |
| 94 | + | const content = blockNodes(block.c); | |
| 95 | + | out.push({ type: "blockquote", content: content.length ? content : [{ type: "paragraph" }] }); | |
| 96 | + | break; | |
| 97 | + | } | |
| 98 | + | case "list": | |
| 99 | + | out.push({ | |
| 100 | + | type: block.ordered ? "orderedList" : "bulletList", | |
| 101 | + | ...(block.ordered ? { attrs: { start: block.start } } : {}), | |
| 102 | + | content: block.items.map((item) => { | |
| 103 | + | const content = blockNodes(item); | |
| 104 | + | // An item starts with a line of text. | |
| 105 | + | if (content[0]?.type !== "paragraph") content.unshift({ type: "paragraph" }); | |
| 106 | + | return { type: "listItem", content }; | |
| 107 | + | }), | |
| 108 | + | }); | |
| 109 | + | break; | |
| 110 | + | } | |
| 111 | + | }); | |
| 112 | + | return out; | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /** A message's Markdown as the composer's document. */ | |
| 116 | + | export function markdownToDoc(markdown: string): DocNode { | |
| 117 | + | const content = blockNodes(blocks(markdown)); | |
| 118 | + | return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] }; | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // --------------------------------------------------------------------------- | |
| 122 | + | // The document to Markdown. | |
| 123 | + | ||
| 124 | + | /** Marks from the outside in: a link holds bold, bold holds code. */ | |
| 125 | + | const ORDER = ["link", "bold", "italic", "strike", "code"]; | |
| 126 | + | const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" }; | |
| 127 | + | ||
| 128 | + | function sameMark(a: Mark, b: Mark): boolean { | |
| 129 | + | return a.type === b.type && (a.type !== "link" || a.attrs?.href === b.attrs?.href); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /** Text as Markdown shows it as typed: its marks escaped. */ | |
| 133 | + | export function escapeText(text: string): string { | |
| 134 | + | return text | |
| 135 | + | .replace(/\\(?=[\\`*_{}[\]()#+\-.!~>|<=:@])/g, "\\\\") | |
| 136 | + | .replace(/[`*~[\]]/g, (char) => `\\${char}`) | |
| 137 | + | .replace(/_/g, (char, at: number, all: string) => { | |
| 138 | + | // `snake_case` stays as typed: only an `_` at a word's edge could start or end italics. | |
| 139 | + | const before = all[at - 1] ?? ""; | |
| 140 | + | const after = all[at + 1] ?? ""; | |
| 141 | + | return /\w/.test(before) && /\w/.test(after) ? char : `\\${char}`; | |
| 142 | + | }); | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /** A line that would read as a block's start (`# `, `- `, `1. `, `> `, `---`) when it is only text. */ | |
| 146 | + | function escapeLineStart(line: string): string { | |
| 147 | + | return line | |
| 148 | + | .replace(/^(\s*)(#{1,6}(?:\s|$))/, "$1\\$2") | |
| 149 | + | .replace(/^(\s*)([-+](?:\s|$))/, "$1\\$2") | |
| 150 | + | .replace(/^(\s*)(\d{1,9})([.)])(\s|$)/, "$1$2\\$3$4") | |
| 151 | + | .replace(/^(\s*)(>)/, "$1\\$2") | |
| 152 | + | .replace(/^(\s*)([-_=]{3,}\s*)$/, "$1\\$2") | |
| 153 | + | .replace(/^(\s*)•/, "$1\\•"); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | function codeSpan(text: string): string { | |
| 157 | + | if (!text.includes("`")) return `\`${text}\``; | |
| 158 | + | return `\`\` ${text} \`\``; | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | function linkTarget(href: string): string { | |
| 162 | + | return /[\s()<>]/.test(href) ? `<${href.replace(/[<>\s]/g, encodeURIComponent)}>` : href; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /** A line of inline content (text with marks) as Markdown. */ | |
| 166 | + | function inlineMarkdown(nodes: DocNode[]): string { | |
| 167 | + | let out = ""; | |
| 168 | + | const open: { mark: Mark; text: string }[] = []; | |
| 169 | + | // A link's text is gathered so a bare URL can be written bare. | |
| 170 | + | const close = (count: number) => { | |
| 171 | + | for (let n = 0; n < count; n++) { | |
| 172 | + | const top = open.pop()!; | |
| 173 | + | // Spaces at a mark's end go after its closing mark: `**a **` would not be bold. | |
| 174 | + | const body = top.text; | |
| 175 | + | const trailing = /\s*$/.exec(body)![0]; | |
| 176 | + | const inner = body.slice(0, body.length - trailing.length); | |
| 177 | + | let written: string; | |
| 178 | + | if (top.mark.type === "link") { | |
| 179 | + | const href = String(top.mark.attrs?.href ?? ""); | |
| 180 | + | written = inner === escapeText(href) && /^https?:\/\//i.test(href) && !/[\s()<>]/.test(href) ? href : `[${inner}](${linkTarget(href)})`; | |
| 181 | + | } else if (!inner) { | |
| 182 | + | written = ""; | |
| 183 | + | } else { | |
| 184 | + | const delimiter = OPEN[top.mark.type] ?? ""; | |
| 185 | + | written = `${delimiter}${inner}${delimiter}`; | |
| 186 | + | } | |
| 187 | + | append(written + trailing); | |
| 188 | + | } | |
| 189 | + | }; | |
| 190 | + | const append = (text: string) => { | |
| 191 | + | if (open.length) open[open.length - 1]!.text += text; | |
| 192 | + | else out += text; | |
| 193 | + | }; | |
| 194 | + | for (const node of nodes) { | |
| 195 | + | if (node.type === "hardBreak") { | |
| 196 | + | close(open.length); | |
| 197 | + | out += "\n"; | |
| 198 | + | continue; | |
| 199 | + | } | |
| 200 | + | if (node.type !== "text" || !node.text) continue; | |
| 201 | + | const marks = [...(node.marks ?? [])] | |
| 202 | + | .filter((mark) => ORDER.includes(mark.type)) | |
| 203 | + | .sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type)); | |
| 204 | + | // Close what this text no longer has, and everything opened after it. | |
| 205 | + | let keep = 0; | |
| 206 | + | while (keep < open.length && keep < marks.length && sameMark(open[keep]!.mark, marks[keep]!)) keep++; | |
| 207 | + | close(open.length - keep); | |
| 208 | + | const code = marks.some((mark) => mark.type === "code"); | |
| 209 | + | let text = node.text; | |
| 210 | + | // Spaces before a mark's start go before it: `** a**` would not be bold. | |
| 211 | + | const opening = marks.slice(keep).filter((mark) => mark.type !== "code"); | |
| 212 | + | if (opening.length) { | |
| 213 | + | const leading = /^\s*/.exec(text)![0]; | |
| 214 | + | append(leading); | |
| 215 | + | text = text.slice(leading.length); | |
| 216 | + | } | |
| 217 | + | for (const mark of marks.slice(keep)) if (mark.type !== "code") open.push({ mark, text: "" }); | |
| 218 | + | append(code ? codeSpan(text) : escapeText(text)); | |
| 219 | + | } | |
| 220 | + | close(open.length); | |
| 221 | + | return out; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | function prefixLines(text: string, first: string, rest: string): string { | |
| 225 | + | return text | |
| 226 | + | .split("\n") | |
| 227 | + | .map((line, index) => (index === 0 ? first : line ? rest : rest.trimEnd()) + line) | |
| 228 | + | .join("\n"); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | function blockMarkdown(node: DocNode): string | null { | |
| 232 | + | switch (node.type) { | |
| 233 | + | case "paragraph": { | |
| 234 | + | const line = inlineMarkdown(node.content ?? []); | |
| 235 | + | return line | |
| 236 | + | .split("\n") | |
| 237 | + | .map((part) => escapeLineStart(part)) | |
| 238 | + | .join("\n"); | |
| 239 | + | } | |
| 240 | + | case "codeBlock": { | |
| 241 | + | const text = (node.content ?? []).map((child) => child.text ?? "").join(""); | |
| 242 | + | const longest = Math.max(2, ...(text.match(/`{3,}/g) ?? []).map((run) => run.length)); | |
| 243 | + | const fence = "`".repeat(longest + 1); | |
| 244 | + | const language = typeof node.attrs?.language === "string" ? node.attrs.language : ""; | |
| 245 | + | return `${fence}${language}\n${text}\n${fence}`; | |
| 246 | + | } | |
| 247 | + | case "blockquote": { | |
| 248 | + | const inner = blocksMarkdown(node.content ?? []); | |
| 249 | + | return inner | |
| 250 | + | .split("\n") | |
| 251 | + | .map((line) => (line ? `> ${line}` : ">")) | |
| 252 | + | .join("\n"); | |
| 253 | + | } | |
| 254 | + | case "bulletList": | |
| 255 | + | case "orderedList": { | |
| 256 | + | const ordered = node.type === "orderedList"; | |
| 257 | + | const start = ordered ? Number(node.attrs?.start ?? 1) || 1 : 1; | |
| 258 | + | return (node.content ?? []) | |
| 259 | + | .map((item, index) => { | |
| 260 | + | const marker = ordered ? `${start + index}. ` : "- "; | |
| 261 | + | const inner = blocksMarkdown(item.content ?? [], true); | |
| 262 | + | return prefixLines(inner, marker, " ".repeat(marker.length)); | |
| 263 | + | }) | |
| 264 | + | .join("\n"); | |
| 265 | + | } | |
| 266 | + | default: | |
| 267 | + | return null; | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | function blocksMarkdown(nodes: DocNode[], tight = false): string { | |
| 272 | + | let out = ""; | |
| 273 | + | let previous: string | null = null; | |
| 274 | + | for (const node of nodes) { | |
| 275 | + | const text = blockMarkdown(node); | |
| 276 | + | if (text == null) continue; | |
| 277 | + | if (previous != null) { | |
| 278 | + | // A line follows a line, and in a list item everything does; anything else stands apart by a blank line. | |
| 279 | + | out += tight || (previous === "paragraph" && node.type === "paragraph") ? "\n" : "\n\n"; | |
| 280 | + | } | |
| 281 | + | out += text; | |
| 282 | + | previous = node.type; | |
| 283 | + | } | |
| 284 | + | return out; | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | /** The composer's document as the Markdown a message is sent as. */ | |
| 288 | + | export function docToMarkdown(doc: DocNode): string { | |
| 289 | + | return blocksMarkdown(doc.content ?? []) | |
| 290 | + | .replace(/^(?:[ \t]*\n)+/, "") | |
| 291 | + | .replace(/(?:\n[ \t]*)+$/, ""); | |
| 292 | + | } |
| 20 | 20 | "@g1t/contracts": "*", | |
| 21 | 21 | "@g1t/theme": "*", | |
| 22 | 22 | "@heycatch/sdk": "0.8.3", | |
| 23 | + | "@tiptap/core": "^3.31.4", | |
| 24 | + | "@tiptap/extensions": "^3.31.4", | |
| 25 | + | "@tiptap/pm": "^3.31.4", | |
| 26 | + | "@tiptap/react": "^3.31.4", | |
| 27 | + | "@tiptap/starter-kit": "^3.31.4", | |
| 23 | 28 | "class-variance-authority": "^0.7.1", | |
| 24 | 29 | "clsx": "^2.1.1", | |
| 25 | 30 | "cmdk": "^1.1.1", |
| 80 | 80 | "@g1t/contracts": "*", | |
| 81 | 81 | "@g1t/theme": "*", | |
| 82 | 82 | "@heycatch/sdk": "0.8.3", | |
| 83 | + | "@tiptap/core": "^3.31.4", | |
| 84 | + | "@tiptap/extensions": "^3.31.4", | |
| 85 | + | "@tiptap/pm": "^3.31.4", | |
| 86 | + | "@tiptap/react": "^3.31.4", | |
| 87 | + | "@tiptap/starter-kit": "^3.31.4", | |
| 83 | 88 | "class-variance-authority": "^0.7.1", | |
| 84 | 89 | "clsx": "^2.1.1", | |
| 85 | 90 | "cmdk": "^1.1.1", | |
| ⋯ | |||
| 5232 | 5237 | "@tiptap/pm": "3.31.4" | |
| 5233 | 5238 | } | |
| 5234 | 5239 | }, | |
| 5240 | + | "node_modules/@tiptap/extension-blockquote": { | |
| 5241 | + | "version": "3.31.4", | |
| 5242 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz", | |
| 5243 | + | "integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==", | |
| 5244 | + | "license": "MIT", | |
| 5245 | + | "funding": { | |
| 5246 | + | "type": "github", | |
| 5247 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5248 | + | }, | |
| 5249 | + | "peerDependencies": { | |
| 5250 | + | "@tiptap/core": "3.31.4", | |
| 5251 | + | "@tiptap/pm": "3.31.4" | |
| 5252 | + | } | |
| 5253 | + | }, | |
| 5235 | 5254 | "node_modules/@tiptap/extension-bold": { | |
| 5236 | 5255 | "version": "3.31.4", | |
| 5237 | 5256 | "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz", | |
| ⋯ | |||
| 5263 | 5282 | "@tiptap/pm": "3.31.4" | |
| 5264 | 5283 | } | |
| 5265 | 5284 | }, | |
| 5285 | + | "node_modules/@tiptap/extension-bullet-list": { | |
| 5286 | + | "version": "3.31.4", | |
| 5287 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz", | |
| 5288 | + | "integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==", | |
| 5289 | + | "license": "MIT", | |
| 5290 | + | "funding": { | |
| 5291 | + | "type": "github", | |
| 5292 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5293 | + | }, | |
| 5294 | + | "peerDependencies": { | |
| 5295 | + | "@tiptap/extension-list": "3.31.4" | |
| 5296 | + | } | |
| 5297 | + | }, | |
| 5266 | 5298 | "node_modules/@tiptap/extension-code": { | |
| 5267 | 5299 | "version": "3.31.4", | |
| 5268 | 5300 | "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz", | |
| ⋯ | |||
| 5276 | 5308 | "@tiptap/core": "3.31.4" | |
| 5277 | 5309 | } | |
| 5278 | 5310 | }, | |
| 5311 | + | "node_modules/@tiptap/extension-code-block": { | |
| 5312 | + | "version": "3.31.4", | |
| 5313 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz", | |
| 5314 | + | "integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==", | |
| 5315 | + | "license": "MIT", | |
| 5316 | + | "funding": { | |
| 5317 | + | "type": "github", | |
| 5318 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5319 | + | }, | |
| 5320 | + | "peerDependencies": { | |
| 5321 | + | "@tiptap/core": "3.31.4", | |
| 5322 | + | "@tiptap/pm": "3.31.4" | |
| 5323 | + | } | |
| 5324 | + | }, | |
| 5325 | + | "node_modules/@tiptap/extension-document": { | |
| 5326 | + | "version": "3.31.4", | |
| 5327 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz", | |
| 5328 | + | "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==", | |
| 5329 | + | "license": "MIT", | |
| 5330 | + | "funding": { | |
| 5331 | + | "type": "github", | |
| 5332 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5333 | + | }, | |
| 5334 | + | "peerDependencies": { | |
| 5335 | + | "@tiptap/core": "3.31.4" | |
| 5336 | + | } | |
| 5337 | + | }, | |
| 5338 | + | "node_modules/@tiptap/extension-dropcursor": { | |
| 5339 | + | "version": "3.31.4", | |
| 5340 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz", | |
| 5341 | + | "integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==", | |
| 5342 | + | "license": "MIT", | |
| 5343 | + | "funding": { | |
| 5344 | + | "type": "github", | |
| 5345 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5346 | + | }, | |
| 5347 | + | "peerDependencies": { | |
| 5348 | + | "@tiptap/extensions": "3.31.4" | |
| 5349 | + | } | |
| 5350 | + | }, | |
| 5279 | 5351 | "node_modules/@tiptap/extension-floating-menu": { | |
| 5280 | 5352 | "version": "3.31.4", | |
| 5281 | 5353 | "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz", | |
| ⋯ | |||
| 5292 | 5364 | "@tiptap/pm": "3.31.4" | |
| 5293 | 5365 | } | |
| 5294 | 5366 | }, | |
| 5367 | + | "node_modules/@tiptap/extension-gapcursor": { | |
| 5368 | + | "version": "3.31.4", | |
| 5369 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz", | |
| 5370 | + | "integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==", | |
| 5371 | + | "license": "MIT", | |
| 5372 | + | "funding": { | |
| 5373 | + | "type": "github", | |
| 5374 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5375 | + | }, | |
| 5376 | + | "peerDependencies": { | |
| 5377 | + | "@tiptap/extensions": "3.31.4" | |
| 5378 | + | } | |
| 5379 | + | }, | |
| 5380 | + | "node_modules/@tiptap/extension-hard-break": { | |
| 5381 | + | "version": "3.31.4", | |
| 5382 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz", | |
| 5383 | + | "integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==", | |
| 5384 | + | "license": "MIT", | |
| 5385 | + | "funding": { | |
| 5386 | + | "type": "github", | |
| 5387 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5388 | + | }, | |
| 5389 | + | "peerDependencies": { | |
| 5390 | + | "@tiptap/core": "3.31.4" | |
| 5391 | + | } | |
| 5392 | + | }, | |
| 5393 | + | "node_modules/@tiptap/extension-heading": { | |
| 5394 | + | "version": "3.31.4", | |
| 5395 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz", | |
| 5396 | + | "integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==", | |
| 5397 | + | "license": "MIT", | |
| 5398 | + | "funding": { | |
| 5399 | + | "type": "github", | |
| 5400 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5401 | + | }, | |
| 5402 | + | "peerDependencies": { | |
| 5403 | + | "@tiptap/core": "3.31.4" | |
| 5404 | + | } | |
| 5405 | + | }, | |
| 5406 | + | "node_modules/@tiptap/extension-horizontal-rule": { | |
| 5407 | + | "version": "3.31.4", | |
| 5408 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz", | |
| 5409 | + | "integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==", | |
| 5410 | + | "license": "MIT", | |
| 5411 | + | "funding": { | |
| 5412 | + | "type": "github", | |
| 5413 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5414 | + | }, | |
| 5415 | + | "peerDependencies": { | |
| 5416 | + | "@tiptap/core": "3.31.4", | |
| 5417 | + | "@tiptap/pm": "3.31.4" | |
| 5418 | + | } | |
| 5419 | + | }, | |
| 5295 | 5420 | "node_modules/@tiptap/extension-italic": { | |
| 5296 | 5421 | "version": "3.31.4", | |
| 5297 | 5422 | "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz", | |
| ⋯ | |||
| 5305 | 5430 | "@tiptap/core": "3.31.4" | |
| 5306 | 5431 | } | |
| 5307 | 5432 | }, | |
| 5433 | + | "node_modules/@tiptap/extension-link": { | |
| 5434 | + | "version": "3.31.4", | |
| 5435 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz", | |
| 5436 | + | "integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==", | |
| 5437 | + | "license": "MIT", | |
| 5438 | + | "dependencies": { | |
| 5439 | + | "linkifyjs": "^4.3.3" | |
| 5440 | + | }, | |
| 5441 | + | "funding": { | |
| 5442 | + | "type": "github", | |
| 5443 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5444 | + | }, | |
| 5445 | + | "peerDependencies": { | |
| 5446 | + | "@tiptap/core": "3.31.4", | |
| 5447 | + | "@tiptap/pm": "3.31.4" | |
| 5448 | + | } | |
| 5449 | + | }, | |
| 5450 | + | "node_modules/@tiptap/extension-list": { | |
| 5451 | + | "version": "3.31.4", | |
| 5452 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz", | |
| 5453 | + | "integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==", | |
| 5454 | + | "license": "MIT", | |
| 5455 | + | "funding": { | |
| 5456 | + | "type": "github", | |
| 5457 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5458 | + | }, | |
| 5459 | + | "peerDependencies": { | |
| 5460 | + | "@tiptap/core": "3.31.4", | |
| 5461 | + | "@tiptap/pm": "3.31.4" | |
| 5462 | + | } | |
| 5463 | + | }, | |
| 5464 | + | "node_modules/@tiptap/extension-list-item": { | |
| 5465 | + | "version": "3.31.4", | |
| 5466 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz", | |
| 5467 | + | "integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==", | |
| 5468 | + | "license": "MIT", | |
| 5469 | + | "funding": { | |
| 5470 | + | "type": "github", | |
| 5471 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5472 | + | }, | |
| 5473 | + | "peerDependencies": { | |
| 5474 | + | "@tiptap/extension-list": "3.31.4" | |
| 5475 | + | } | |
| 5476 | + | }, | |
| 5477 | + | "node_modules/@tiptap/extension-list-keymap": { | |
| 5478 | + | "version": "3.31.4", | |
| 5479 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz", | |
| 5480 | + | "integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==", | |
| 5481 | + | "license": "MIT", | |
| 5482 | + | "funding": { | |
| 5483 | + | "type": "github", | |
| 5484 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5485 | + | }, | |
| 5486 | + | "peerDependencies": { | |
| 5487 | + | "@tiptap/extension-list": "3.31.4" | |
| 5488 | + | } | |
| 5489 | + | }, | |
| 5490 | + | "node_modules/@tiptap/extension-ordered-list": { | |
| 5491 | + | "version": "3.31.4", | |
| 5492 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz", | |
| 5493 | + | "integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==", | |
| 5494 | + | "license": "MIT", | |
| 5495 | + | "funding": { | |
| 5496 | + | "type": "github", | |
| 5497 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5498 | + | }, | |
| 5499 | + | "peerDependencies": { | |
| 5500 | + | "@tiptap/extension-list": "3.31.4" | |
| 5501 | + | } | |
| 5502 | + | }, | |
| 5503 | + | "node_modules/@tiptap/extension-paragraph": { | |
| 5504 | + | "version": "3.31.4", | |
| 5505 | + | "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz", | |
| 5506 | + | "integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==", | |
| 5507 | + | "license": "MIT", | |
| 5508 | + | "funding": { | |
| 5509 | + | "type": "github", | |
| 5510 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5511 | + | }, | |
| 5512 | + | "peerDependencies": { | |
| 5513 | + | "@tiptap/core": "3.31.4" | |
| 5514 | + | } | |
| 5515 | + | }, | |
| 5308 | 5516 | "node_modules/@tiptap/extension-strike": { | |
| 5309 | 5517 | "version": "3.31.4", | |
| 5310 | 5518 | "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz", | |
| ⋯ | |||
| 5416 | 5624 | "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", | |
| 5417 | 5625 | "license": "MIT" | |
| 5418 | 5626 | }, | |
| 5627 | + | "node_modules/@tiptap/starter-kit": { | |
| 5628 | + | "version": "3.31.4", | |
| 5629 | + | "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz", | |
| 5630 | + | "integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==", | |
| 5631 | + | "license": "MIT", | |
| 5632 | + | "dependencies": { | |
| 5633 | + | "@tiptap/core": "3.31.4", | |
| 5634 | + | "@tiptap/extension-blockquote": "3.31.4", | |
| 5635 | + | "@tiptap/extension-bold": "3.31.4", | |
| 5636 | + | "@tiptap/extension-bullet-list": "3.31.4", | |
| 5637 | + | "@tiptap/extension-code": "3.31.4", | |
| 5638 | + | "@tiptap/extension-code-block": "3.31.4", | |
| 5639 | + | "@tiptap/extension-document": "3.31.4", | |
| 5640 | + | "@tiptap/extension-dropcursor": "3.31.4", | |
| 5641 | + | "@tiptap/extension-gapcursor": "3.31.4", | |
| 5642 | + | "@tiptap/extension-hard-break": "3.31.4", | |
| 5643 | + | "@tiptap/extension-heading": "3.31.4", | |
| 5644 | + | "@tiptap/extension-horizontal-rule": "3.31.4", | |
| 5645 | + | "@tiptap/extension-italic": "3.31.4", | |
| 5646 | + | "@tiptap/extension-link": "3.31.4", | |
| 5647 | + | "@tiptap/extension-list": "3.31.4", | |
| 5648 | + | "@tiptap/extension-list-item": "3.31.4", | |
| 5649 | + | "@tiptap/extension-list-keymap": "3.31.4", | |
| 5650 | + | "@tiptap/extension-ordered-list": "3.31.4", | |
| 5651 | + | "@tiptap/extension-paragraph": "3.31.4", | |
| 5652 | + | "@tiptap/extension-strike": "3.31.4", | |
| 5653 | + | "@tiptap/extension-text": "3.31.4", | |
| 5654 | + | "@tiptap/extension-underline": "3.31.4", | |
| 5655 | + | "@tiptap/extensions": "3.31.4", | |
| 5656 | + | "@tiptap/pm": "3.31.4" | |
| 5657 | + | }, | |
| 5658 | + | "funding": { | |
| 5659 | + | "type": "github", | |
| 5660 | + | "url": "https://github.com/sponsors/ueberdosis" | |
| 5661 | + | } | |
| 5662 | + | }, | |
| 5419 | 5663 | "node_modules/@tootallnate/quickjs-emscripten": { | |
| 5420 | 5664 | "version": "0.23.0", | |
| 5421 | 5665 | "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", | |
| ⋯ | |||
| 9113 | 9357 | "unicode-trie": "^2.0.0" | |
| 9114 | 9358 | } | |
| 9115 | 9359 | }, | |
| 9360 | + | "node_modules/linkifyjs": { | |
| 9361 | + | "version": "4.3.3", | |
| 9362 | + | "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", | |
| 9363 | + | "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", | |
| 9364 | + | "license": "MIT" | |
| 9365 | + | }, | |
| 9116 | 9366 | "node_modules/lodash": { | |
| 9117 | 9367 | "version": "4.18.1", | |
| 9118 | 9368 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", | |