| 1 | import { |
| 2 | chipField, |
| 3 | createMarkdownEditor, |
| 4 | enclosingCodeBlock, |
| 5 | insertNewline, |
| 6 | livePreview, |
| 7 | type MarkdownEditor, |
| 8 | } from "@clo/markdown-editor"; |
| 9 | import { isolateHistory, indentLess, indentMore } from "@codemirror/commands"; |
| 10 | import { |
| 11 | defaultHighlightStyle, |
| 12 | HighlightStyle, |
| 13 | syntaxHighlighting, |
| 14 | syntaxTree, |
| 15 | } from "@codemirror/language"; |
| 16 | import { |
| 17 | Compartment, |
| 18 | EditorState, |
| 19 | Prec, |
| 20 | StateEffect, |
| 21 | StateField, |
| 22 | Transaction, |
| 23 | } from "@codemirror/state"; |
| 24 | import type { SyntaxNode } from "@lezer/common"; |
| 25 | import { Decoration, EditorView, WidgetType } from "@codemirror/view"; |
| 26 | |
| 27 | import { cn } from "~/lib/utils"; |
| 28 | import { collectComposerPromptInlineTokens } from "~/composer-editor-mentions"; |
| 29 | import { collapseExpandedComposerCursor, expandCollapsedComposerCursor } from "~/composer-logic"; |
| 30 | import { getTimelinePageScrollKey } from "./chat/pageScrollController"; |
| 31 | import { importPastedComposerText } from "./composerInlineTokenPaste"; |
| 32 | import { composerCodeBlocks, composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; |
| 33 | import type { ComposerPromptEditorProps } from "./ComposerPromptEditor"; |
| 34 | |
| 35 | export type ComposerToken = ReturnType<typeof collectComposerPromptInlineTokens>[number]; |
| 36 | export type ComposerChip = { dom: HTMLElement } & ( |
| 37 | | { kind: "chip"; token: ComposerToken } |
| 38 | | { kind: "fence"; block: ReturnType<typeof composerCodeBlocks>[number] } |
| 39 | ); |
| 40 | |
| 41 | // Live-preview decorations own prose colors; fenced code uses the Shiki extension. |
| 42 | const composerSyntaxHighlight = syntaxHighlighting( |
| 43 | HighlightStyle.define( |
| 44 | defaultHighlightStyle.specs.map((style) => ({ ...style, color: "inherit" })), |
| 45 | ), |
| 46 | ); |
| 47 | |
| 48 | export function composerTokens(state: EditorState, literalText: boolean): ComposerToken[] { |
| 49 | if (literalText) return []; |
| 50 | const code: Array<{ from: number; to: number }> = []; |
| 51 | syntaxTree(state).iterate({ |
| 52 | enter(node) { |
| 53 | if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) { |
| 54 | code.push({ from: node.from, to: node.to }); |
| 55 | return false; |
| 56 | } |
| 57 | return undefined; |
| 58 | }, |
| 59 | }); |
| 60 | return collectComposerPromptInlineTokens(state.doc.toString()).filter( |
| 61 | (token) => !code.some((range) => token.start < range.to && token.end > range.from), |
| 62 | ); |
| 63 | } |
| 64 | |
| 65 | export function composerSuggestionsSuppressed(view: EditorView, literalText: boolean): boolean { |
| 66 | const range = view.state.selection.main; |
| 67 | if (literalText || view.compositionStarted || !range.empty) return true; |
| 68 | for ( |
| 69 | let node: SyntaxNode | null = syntaxTree(view.state).resolveInner(range.head, -1); |
| 70 | node; |
| 71 | node = node.parent |
| 72 | ) { |
| 73 | if (["InlineCode", "FencedCode", "CodeBlock"].includes(node.name)) return true; |
| 74 | } |
| 75 | return composerTokens(view.state, literalText).some( |
| 76 | (token) => token.start <= range.head && token.end >= range.head, |
| 77 | ); |
| 78 | } |
| 79 | |
| 80 | class ComposerChipWidget extends WidgetType { |
| 81 | constructor( |
| 82 | readonly token: ComposerToken, |
| 83 | readonly mount: (chip: ComposerChip) => void, |
| 84 | readonly unmount: (dom: HTMLElement) => void, |
| 85 | ) { |
| 86 | super(); |
| 87 | } |
| 88 | override eq(other: ComposerChipWidget) { |
| 89 | return ( |
| 90 | this.token.start === other.token.start && |
| 91 | this.token.end === other.token.end && |
| 92 | this.token.source === other.token.source |
| 93 | ); |
| 94 | } |
| 95 | override toDOM() { |
| 96 | const dom = document.createElement("span"); |
| 97 | dom.contentEditable = "false"; |
| 98 | dom.className = "relative inline-flex select-none items-center align-middle leading-none"; |
| 99 | dom.dataset.composerChip = "true"; |
| 100 | if (this.token.type === "citation") dom.dataset.citationStart = String(this.token.start); |
| 101 | dom.setAttribute( |
| 102 | "aria-label", |
| 103 | this.token.type === "mention" |
| 104 | ? this.token.value |
| 105 | : this.token.type === "skill" |
| 106 | ? this.token.value |
| 107 | : this.token.type === "context-reference" |
| 108 | ? this.token.label |
| 109 | : "Assistant citation", |
| 110 | ); |
| 111 | this.mount({ dom, kind: "chip", token: this.token }); |
| 112 | return dom; |
| 113 | } |
| 114 | override destroy(dom: HTMLElement) { |
| 115 | this.unmount(dom); |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | class ComposerFenceWidget extends WidgetType { |
| 120 | constructor( |
| 121 | readonly block: ReturnType<typeof composerCodeBlocks>[number], |
| 122 | readonly mount: (chip: ComposerChip) => void, |
| 123 | readonly unmount: (dom: HTMLElement) => void, |
| 124 | ) { |
| 125 | super(); |
| 126 | } |
| 127 | override eq(other: ComposerFenceWidget) { |
| 128 | return ( |
| 129 | this.block.from === other.block.from && |
| 130 | this.block.info === other.block.info && |
| 131 | this.block.codeTo === other.block.codeTo |
| 132 | ); |
| 133 | } |
| 134 | override toDOM() { |
| 135 | const dom = document.createElement("span"); |
| 136 | dom.contentEditable = "false"; |
| 137 | dom.className = "ml-3 inline-flex items-center align-middle font-sans"; |
| 138 | dom.dataset.composerFence = "true"; |
| 139 | this.mount({ dom, kind: "fence", block: this.block }); |
| 140 | return dom; |
| 141 | } |
| 142 | override destroy(dom: HTMLElement) { |
| 143 | this.unmount(dom); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | export function createComposerMarkdownEditor(options: { |
| 148 | props: () => ComposerPromptEditorProps; |
| 149 | mountChip: (chip: ComposerChip) => void; |
| 150 | unmountChip: (dom: HTMLElement) => void; |
| 151 | }): MarkdownEditor & { |
| 152 | configure: () => void; |
| 153 | snapshot: () => { value: string; cursor: number; expandedCursor: number; contextIds: string[] }; |
| 154 | } { |
| 155 | const presentation = new Compartment(); |
| 156 | const attributes = new Compartment(); |
| 157 | const editing = new Compartment(); |
| 158 | const configuredLiteral = StateEffect.define<boolean>(); |
| 159 | const literal = StateField.define<boolean>({ |
| 160 | create: () => options.props().literalText ?? false, |
| 161 | update(value, transaction) { |
| 162 | for (const effect of transaction.effects) |
| 163 | if (effect.is(configuredLiteral)) return effect.value; |
| 164 | return value; |
| 165 | }, |
| 166 | }); |
| 167 | const snapshot = () => { |
| 168 | const view = editor.view; |
| 169 | const value = view.state.doc.toString(); |
| 170 | const expandedCursor = view.state.selection.main.head; |
| 171 | const literalText = view.state.field(literal); |
| 172 | return { |
| 173 | value, |
| 174 | cursor: collapseExpandedComposerCursor(value, expandedCursor, literalText), |
| 175 | expandedCursor, |
| 176 | contextIds: composerTokens(view.state, literalText).flatMap((token) => |
| 177 | token.type === "context-reference" ? [token.contextId] : [], |
| 178 | ), |
| 179 | }; |
| 180 | }; |
| 181 | const report = () => { |
| 182 | const props = options.props(); |
| 183 | const current = snapshot(); |
| 184 | props.onChange( |
| 185 | current.value, |
| 186 | current.cursor, |
| 187 | current.expandedCursor, |
| 188 | composerSuggestionsSuppressed(editor.view, props.literalText ?? false), |
| 189 | current.contextIds, |
| 190 | ); |
| 191 | }; |
| 192 | const editor = createMarkdownEditor({ |
| 193 | preset: "composer", |
| 194 | mentions: false, |
| 195 | preview: false, |
| 196 | doc: options.props().value, |
| 197 | extensions: [ |
| 198 | literal, |
| 199 | presentation.of([]), |
| 200 | attributes.of([]), |
| 201 | editing.of([]), |
| 202 | chipField({ |
| 203 | atomic: true, |
| 204 | onSelection: true, |
| 205 | rebuild: (transaction) => |
| 206 | syntaxTree(transaction.startState) !== syntaxTree(transaction.state), |
| 207 | chips: (state) => |
| 208 | composerTokens(state, state.field(literal)).map((token) => |
| 209 | Decoration.replace({ |
| 210 | widget: new ComposerChipWidget(token, options.mountChip, options.unmountChip), |
| 211 | }).range(token.start, token.end), |
| 212 | ), |
| 213 | }), |
| 214 | chipField({ |
| 215 | onSelection: true, |
| 216 | rebuild: (transaction) => |
| 217 | syntaxTree(transaction.startState) !== syntaxTree(transaction.state), |
| 218 | chips: (state) => |
| 219 | options.props().richTextEnabled && !state.field(literal) |
| 220 | ? composerCodeBlocks(state).map((block) => |
| 221 | Decoration.widget({ |
| 222 | widget: new ComposerFenceWidget(block, options.mountChip, options.unmountChip), |
| 223 | side: 1, |
| 224 | }).range(state.doc.lineAt(block.from).to), |
| 225 | ) |
| 226 | : [], |
| 227 | }), |
| 228 | Prec.highest( |
| 229 | EditorView.domEventHandlers({ |
| 230 | keydown(event, view) { |
| 231 | const props = options.props(); |
| 232 | if (props.disabled) return false; |
| 233 | const pageKey = getTimelinePageScrollKey({ |
| 234 | altKey: event.altKey, |
| 235 | ctrlKey: event.ctrlKey, |
| 236 | metaKey: event.metaKey, |
| 237 | shiftKey: event.shiftKey, |
| 238 | defaultPrevented: event.defaultPrevented, |
| 239 | isComposing: event.isComposing || view.compositionStarted, |
| 240 | keyCode: event.keyCode, |
| 241 | key: event.key, |
| 242 | clientHeight: view.scrollDOM.clientHeight, |
| 243 | scrollHeight: view.scrollDOM.scrollHeight, |
| 244 | scrollTop: view.scrollDOM.scrollTop, |
| 245 | }); |
| 246 | if (pageKey && props.onPageScrollKeyDown) { |
| 247 | props.onPageScrollKeyDown(pageKey); |
| 248 | event.preventDefault(); |
| 249 | event.stopPropagation(); |
| 250 | return true; |
| 251 | } |
| 252 | if (event.isComposing || event.keyCode === 229 || view.compositionStarted) { |
| 253 | if (event.key === "Enter") { |
| 254 | event.preventDefault(); |
| 255 | return true; |
| 256 | } |
| 257 | return false; |
| 258 | } |
| 259 | const range = view.state.selection.main; |
| 260 | if ( |
| 261 | event.key === "Tab" && |
| 262 | event.shiftKey && |
| 263 | !event.altKey && |
| 264 | !event.metaKey && |
| 265 | !event.ctrlKey && |
| 266 | range.empty && |
| 267 | !props.literalText |
| 268 | ) { |
| 269 | const citation = composerTokens(view.state, false).find( |
| 270 | (token) => token.type === "citation" && token.end === range.head, |
| 271 | ); |
| 272 | const button = citation |
| 273 | ? view.dom.querySelector<HTMLElement>( |
| 274 | `[data-citation-start="${citation.start}"] [data-citation-comment-trigger]`, |
| 275 | ) |
| 276 | : null; |
| 277 | if (button) { |
| 278 | event.preventDefault(); |
| 279 | event.stopPropagation(); |
| 280 | button.focus(); |
| 281 | return true; |
| 282 | } |
| 283 | } |
| 284 | const block = enclosingCodeBlock(view.state, range.head); |
| 285 | if (block && !props.literalText && !event.metaKey && !event.ctrlKey) { |
| 286 | if (event.key === "Enter") return false; |
| 287 | if (event.key === "Tab") { |
| 288 | const handled = (event.shiftKey ? indentLess : indentMore)(view); |
| 289 | if (handled) { |
| 290 | event.preventDefault(); |
| 291 | event.stopPropagation(); |
| 292 | } |
| 293 | return handled; |
| 294 | } |
| 295 | } |
| 296 | const handled = props.onCommandKeyDown?.(event.key, event) ?? false; |
| 297 | if (handled) { |
| 298 | event.preventDefault(); |
| 299 | event.stopPropagation(); |
| 300 | return true; |
| 301 | } |
| 302 | if (props.literalText) { |
| 303 | if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.altKey) { |
| 304 | insertNewline(view); |
| 305 | event.preventDefault(); |
| 306 | return true; |
| 307 | } |
| 308 | if ( |
| 309 | (event.metaKey || event.ctrlKey) && |
| 310 | ["b", "i"].includes(event.key.toLowerCase()) |
| 311 | ) { |
| 312 | event.preventDefault(); |
| 313 | return true; |
| 314 | } |
| 315 | if (["Tab", "*", "`"].includes(event.key)) return true; |
| 316 | } |
| 317 | return false; |
| 318 | }, |
| 319 | compositionstart() { |
| 320 | const props = options.props(); |
| 321 | const current = snapshot(); |
| 322 | props.onChange( |
| 323 | current.value, |
| 324 | current.cursor, |
| 325 | current.expandedCursor, |
| 326 | true, |
| 327 | current.contextIds, |
| 328 | ); |
| 329 | return false; |
| 330 | }, |
| 331 | compositionend(_event, view) { |
| 332 | queueMicrotask(() => { |
| 333 | if (view.dom.isConnected) report(); |
| 334 | }); |
| 335 | return false; |
| 336 | }, |
| 337 | paste(event, view) { |
| 338 | const props = options.props(); |
| 339 | if (props.disabled) return true; |
| 340 | const data = event.clipboardData; |
| 341 | if (!data || event.defaultPrevented) return false; |
| 342 | const text = props.literalText |
| 343 | ? data.getData("text/plain") |
| 344 | : importPastedComposerText(data, props.importContextFragment); |
| 345 | if (!text) return false; |
| 346 | event.preventDefault(); |
| 347 | view.dispatch({ |
| 348 | ...view.state.replaceSelection(text), |
| 349 | annotations: isolateHistory.of("full"), |
| 350 | userEvent: "input.paste", |
| 351 | scrollIntoView: true, |
| 352 | }); |
| 353 | return true; |
| 354 | }, |
| 355 | }), |
| 356 | ), |
| 357 | EditorView.updateListener.of((update) => { |
| 358 | if (update.docChanged || update.selectionSet) { |
| 359 | if ( |
| 360 | update.transactions.some( |
| 361 | (transaction) => transaction.annotation(Transaction.userEvent) === "t3.controlled", |
| 362 | ) |
| 363 | ) |
| 364 | return; |
| 365 | if (!update.state.selection.main.empty) options.props().onVisibleSelectionChange?.(); |
| 366 | report(); |
| 367 | } |
| 368 | }), |
| 369 | Prec.highest( |
| 370 | EditorView.theme({ |
| 371 | "&": { |
| 372 | color: "var(--contrast-foreground)", |
| 373 | fontSize: "inherit", |
| 374 | "--markdown-text": "var(--contrast-foreground)", |
| 375 | "--markdown-muted": "var(--contrast-muted-foreground)", |
| 376 | "--markdown-panel": "var(--muted)", |
| 377 | "--markdown-accent": "var(--info-foreground)", |
| 378 | }, |
| 379 | ".cm-content": { |
| 380 | padding: "0", |
| 381 | caretColor: "var(--contrast-foreground)", |
| 382 | fontFamily: "inherit", |
| 383 | minHeight: "inherit", |
| 384 | }, |
| 385 | ".cm-scroller": { |
| 386 | lineHeight: "inherit", |
| 387 | maxHeight: "inherit", |
| 388 | minHeight: "inherit", |
| 389 | overflowY: "auto", |
| 390 | overflowX: "hidden", |
| 391 | fontFamily: "inherit", |
| 392 | }, |
| 393 | "&.cm-focused": { outline: "none" }, |
| 394 | ".cm-lp-code, .cm-lp-codeblock": { |
| 395 | fontFamily: "var(--font-mono)", |
| 396 | fontSize: "var(--font-size-code, 13px)", |
| 397 | }, |
| 398 | }), |
| 399 | ), |
| 400 | ], |
| 401 | }); |
| 402 | const configure = () => { |
| 403 | const props = options.props(); |
| 404 | const literalText = props.literalText ?? false; |
| 405 | const configuredPreview = presentation.get(editor.view.state); |
| 406 | const previewEnabled = Array.isArray(configuredPreview) && configuredPreview.length > 0; |
| 407 | const showPreview = Boolean(props.richTextEnabled && !literalText); |
| 408 | editor.view.dispatch({ |
| 409 | selection: editor.view.state.selection, |
| 410 | annotations: Transaction.userEvent.of("t3.controlled"), |
| 411 | effects: [ |
| 412 | configuredLiteral.of(literalText), |
| 413 | editing.reconfigure([ |
| 414 | EditorView.editable.of(!props.disabled), |
| 415 | EditorState.readOnly.of(props.disabled), |
| 416 | EditorState.transactionFilter.of((transaction) => |
| 417 | props.disabled && |
| 418 | transaction.docChanged && |
| 419 | transaction.annotation(Transaction.userEvent) !== "t3.controlled" |
| 420 | ? [] |
| 421 | : transaction, |
| 422 | ), |
| 423 | ]), |
| 424 | attributes.reconfigure([ |
| 425 | EditorView.editorAttributes.of({ |
| 426 | class: cn( |
| 427 | "-m-1 block max-h-52 min-h-19.5 p-1 bg-transparent leading-relaxed text-foreground", |
| 428 | props.className, |
| 429 | ), |
| 430 | }), |
| 431 | EditorView.contentAttributes.of({ |
| 432 | "data-testid": "composer-editor", |
| 433 | "data-composer-rich-text": props.richTextEnabled && !literalText ? "true" : "false", |
| 434 | role: "textbox", |
| 435 | "aria-multiline": "true", |
| 436 | "aria-label": props.ariaLabel ?? "Message", |
| 437 | "aria-readonly": String(props.disabled), |
| 438 | "aria-placeholder": props.placeholder, |
| 439 | ...(props.suggestionListId && !props.disabled |
| 440 | ? { "aria-autocomplete": "list", "aria-haspopup": "listbox" } |
| 441 | : {}), |
| 442 | ...(props.activeSuggestionId && props.suggestionListId |
| 443 | ? { |
| 444 | "aria-controls": props.suggestionListId, |
| 445 | "aria-activedescendant": props.activeSuggestionId, |
| 446 | } |
| 447 | : {}), |
| 448 | }), |
| 449 | ]), |
| 450 | ...(showPreview !== previewEnabled |
| 451 | ? [ |
| 452 | presentation.reconfigure( |
| 453 | showPreview |
| 454 | ? [ |
| 455 | composerSyntaxHighlight, |
| 456 | composerCodeBlockHighlight, |
| 457 | livePreview({ |
| 458 | blockWidgets: false, |
| 459 | html: false, |
| 460 | exclude: (state) => |
| 461 | composerTokens(state, false).map((token) => ({ |
| 462 | from: token.start, |
| 463 | to: token.end, |
| 464 | })), |
| 465 | }), |
| 466 | ] |
| 467 | : [], |
| 468 | ), |
| 469 | ] |
| 470 | : []), |
| 471 | ], |
| 472 | }); |
| 473 | }; |
| 474 | configure(); |
| 475 | editor.view.dispatch({ |
| 476 | selection: { |
| 477 | anchor: expandCollapsedComposerCursor( |
| 478 | options.props().value, |
| 479 | options.props().cursor, |
| 480 | options.props().literalText, |
| 481 | ), |
| 482 | }, |
| 483 | annotations: Transaction.userEvent.of("t3.controlled"), |
| 484 | }); |
| 485 | return { ...editor, configure, snapshot }; |
| 486 | } |