char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
1/** 2* Maps between the chat-form-input-rich's markdown source and the 3* badge/code/text token stream the DOM is built from. A badge is one 4* opaque source contribution (`[name](file://path)`); its own subtree 5* is never walked, and the caret cannot land inside it, so offsets 6* resolve to the nearest badge edge. Code spans (`<code data-code-token>`) 7* are EDITABLE, unlike badges: they carry the full source segment 8* (backtick fences included) as their text, so their textContent 9* serializes verbatim and source offsets map 1:1 to text offsets. 10* 11* The tokenizer emits a flat DOM (text nodes + badges + code spans), 12* but browsers restructure it on Enter (`<div>` line wrappers, `<br>` 13* shapes). Serialization folds those back into `\n` so the source 14* never diverges from what is on screen; both offset mappers 15* understand the same shapes. 16* 17* The newline separating a fenced block from adjacent content is a 18* SOURCE-level concept, never stored in the DOM: the block is 19* display:block, so a leading `\n` in the following text node would 20* render as a phantom empty line. Serialization synthesizes exactly 21* one `\n` at every block boundary and `buildFragment` strips it from 22* text tokens. A text node's own leading/trailing `\n` next to a 23* block is an ADDITIONAL blank line. 24*/ 25 26import { 27decodeFileLinkPath , 28fileMentionLinkRe , 29getMentionBadgeIconPaths , 30getMentionBadgeLabel 31} from './mention-badge' ; 32import { 33CODE_TOKEN_ATTR , 34MENTION_BADGE_CLASSNAME , 35MENTION_BADGE_DATA_ATTRS , 36MENTION_BADGE_ICON_CLASSNAME , 37MENTION_BADGE_SVG_ATTRIBUTES , 38SETTINGS_KEYS 39} from '$lib/constants' ; 40import { BooleanString , ChatFormInputRichTokenKind } from '$lib/enums' ; 41import { settingsStore } from '$lib/stores/settings/index.svelte' ; 42import { toolsStore } from '$lib/stores/tools.svelte' ; 43import type { ChatFormInputRichToken } from '$lib/types/chat-form-input-rich' ; 44 45// Block wrappers browsers insert for newlines; each folds back into a 46// single `\n` during serialization. 47const BLOCK_TAG_NAMES = new Set ([ 'DIV' , 'P' ]); 48// `file://` is required so plain URLs stay as text; `)` terminates only 49// when not followed by whitespace or `[` (adjacent badges keep working). 50const MENTION_BADGE_RE = fileMentionLinkRe ( 'g' ); 51 52function badgeSourceLength ( name :string , path :string ) :number { 53if ( ! name || ! path ) return 0 ; 54 55return `[ ${ name } ](file:// ${ path } )` . length ; 56} 57 58/** 59* Recognize complete code spans. Fenced blocks (triple backticks, 60* optional language, possibly multiline) take priority over inline 61* spans (single backticks, single line, non-empty). Only CLOSED 62* spans match: an unclosed fence stays plain text until the closing 63* backticks land. The match includes the fences so the token's 64* source length equals its rendered text length. 65*/ 66const CODE_SPAN_RE = / (```[\s\S]*?```)|(`[^`\n]+`) / g ; 67 68/** 69* Cheap gate check for `ChatForm`: does the buffer contain a 70* complete code span (inline or fenced)? Used to promote the plain 71* textarea to the chat-form-input-rich renderer. 72*/ 73export function containsCodeSpan ( value :string ) :boolean { 74CODE_SPAN_RE . lastIndex = 0 ; 75 76return CODE_SPAN_RE . test ( value ); 77} 78 79const CODE_FENCE_RE = / ``` / g ; 80 81/** 82* Is `offset` inside a fenced code block region? Toggle-based: an 83* odd number of ``` fences before the offset means the position 84* sits in block content. Unlike `containsCodeSpan` this also 85* counts the still-OPEN fence while the user is typing a block 86* (no closing ``` yet), so Enter can add a line instead of 87* submitting the message. 88*/ 89export function isOffsetInCodeBlock ( source :string , offset :number ) :boolean { 90let inside = false ; 91 92CODE_FENCE_RE . lastIndex = 0 ; 93 94let match :RegExpExecArray | null ; 95 96while (( match = CODE_FENCE_RE . exec ( source )) !== null ) { 97if ( match . index + match [ 0 ]. length > offset ) break ; 98 99inside = ! inside ; 100} 101 102return inside ; 103} 104 105/** 106* Tokenize a markdown source value into the segments the 107* chat-form-input-rich will render. Code spans are carved out first 108* (their content is literal - a `file://` link inside backticks 109* must NOT render as a badge), then plain text and badges 110* interleave in the remaining gaps. Any whitespace after a badge 111* stays in a plain text token so the round trip is byte-exact. 112*/ 113export function tokenizeContent ( input :string ) :ChatFormInputRichToken [] { 114const tokens :ChatFormInputRichToken [] = []; 115 116let cursor = 0 ; 117 118CODE_SPAN_RE . lastIndex = 0 ; 119 120let match :RegExpExecArray | null ; 121 122while (( match = CODE_SPAN_RE . exec ( input )) !== null ) { 123const start = match . index ; 124 125if ( start > cursor ) { 126pushTextAndBadgeTokens ( input . slice ( cursor , start ), tokens ); 127} 128 129tokens . push ( 130match [ 1 ] !== undefined 131 ?{ kind :ChatFormInputRichTokenKind . CODE_BLOCK , text :match [ 1 ] } 132 :{ kind :ChatFormInputRichTokenKind . CODE_INLINE , text :match [ 2 ] } 133); 134cursor = start + match [ 0 ]. length ; 135} 136 137if ( cursor < input . length ) { 138pushTextAndBadgeTokens ( input . slice ( cursor ), tokens ); 139} 140 141return tokens ; 142} 143 144/** 145* Tokenize a code-free segment into text and badge tokens. 146*/ 147function pushTextAndBadgeTokens ( input :string , tokens :ChatFormInputRichToken []) { 148let cursor = 0 ; 149 150MENTION_BADGE_RE . lastIndex = 0 ; 151 152let match :RegExpExecArray | null ; 153 154while (( match = MENTION_BADGE_RE . exec ( input )) !== null ) { 155const [ whole , name , path ] = match ; 156const start = match . index ; 157 158if ( start > cursor ) { 159tokens . push ({ kind :ChatFormInputRichTokenKind . TEXT , text :input . slice ( cursor , start ) }); 160} 161 162tokens . push ({ kind :ChatFormInputRichTokenKind . BADGE , name, path}); 163cursor = start + whole . length ; 164} 165 166if ( cursor < input . length ) { 167tokens . push ({ kind :ChatFormInputRichTokenKind . TEXT , text :input . slice ( cursor ) }); 168} 169} 170 171function isCodeBlockElement ( node :Node | null ) :node isHTMLElement { 172return ( 173node instanceof HTMLElement && 174node . getAttribute ( CODE_TOKEN_ATTR ) === ChatFormInputRichTokenKind . CODE_BLOCK 175); 176} 177 178/** 179* Serialize a chat-form-input-rich subtree back to source. `<br>` and block 180* wrappers the browser inserted for newlines fold back into `\n` (a 181* trailing `<br>` is the browser's caret placeholder, not a newline); 182* any other element is transparent. Code spans serialize their 183* textContent verbatim (fences included). One separator `\n` is 184* synthesized at every fenced-block boundary (the DOM never stores 185* it), and a `<br>` adjacent to a code block is an escape hatch, not 186* a newline. 187*/ 188export function serializeContent ( root :HTMLElement ) :string { 189let out = '' ; 190let pendingBlockBoundary = false ; 191 192const walk = ( parent :Node ) => { 193let first = true ; // no source-contributing sibling seen yet 194 195for ( const child of Array . from ( parent . childNodes )) { 196if ( child . nodeType === Node . TEXT_NODE ) { 197const text = child . textContent ?? '' ; 198 199if ( text . length > 0 ) { 200if ( pendingBlockBoundary ) { 201out += '\n' ; 202pendingBlockBoundary = false ; 203} 204 205out += text ; 206first = false ; 207} 208 209continue ; 210} 211 212if ( child . nodeType !== Node . ELEMENT_NODE ) continue ; 213 214const el = child as HTMLElement ; 215 216if ( el . getAttribute ( MENTION_BADGE_DATA_ATTRS . BADGE ) === BooleanString . TRUE ) { 217const name = el . getAttribute ( MENTION_BADGE_DATA_ATTRS . NAME ) ?? '' ; 218const path = el . getAttribute ( MENTION_BADGE_DATA_ATTRS . PATH ) ?? '' ; 219 220if ( name && path ) { 221if ( pendingBlockBoundary ) { 222out += '\n' ; 223pendingBlockBoundary = false ; 224} 225 226out += `[ ${ name } ](file:// ${ path } )` ; 227first = false ; 228} 229 230continue ; 231} 232 233const codeToken = el . getAttribute ( CODE_TOKEN_ATTR ); 234 235if ( codeToken !== null ) { 236const isBlock = codeToken === ChatFormInputRichTokenKind . CODE_BLOCK ; 237 238if ( isBlock && ( pendingBlockBoundary || ! first )) out += '\n' ; 239 240pendingBlockBoundary = false ; 241walk ( el ); 242first = false ; 243 244if ( isBlock ) pendingBlockBoundary = true ; 245 246continue ; 247} 248 249if ( el . tagName === 'BR' ) { 250const isHatch = 251isCodeBlockElement ( el . previousSibling ) || isCodeBlockElement ( el . nextSibling ); 252 253if ( ! isHatch && el . nextSibling ) { 254if ( pendingBlockBoundary ) { 255out += '\n' ; 256pendingBlockBoundary = false ; 257} 258 259out += '\n' ; 260first = false ; 261} 262 263continue ; 264} 265 266if ( BLOCK_TAG_NAMES . has ( el . tagName )) { 267if ( pendingBlockBoundary || ! first ) out += '\n' ; 268 269pendingBlockBoundary = false ; 270walk ( el ); 271first = false ; 272 273continue ; 274} 275 276walk ( el ); 277 278if ( pendingBlockBoundary ) first = false ; 279} 280}; 281 282walk ( root ); 283 284return out ; 285} 286 287/** 288* Compare the live DOM's non-text structure against a token stream. 289* Only element contributions are compared (badges by name/path, code 290* spans by kind and source segment): text nodes are owned by the 291* browser between rebuilds, so their split/merge state is irrelevant. 292* A mismatch means token boundaries shifted (a code span was just 293* completed or broken) and the DOM needs a rebuild to restyle. 294*/ 295export function domMatchesTokens ( root :HTMLElement , tokens :ChatFormInputRichToken []) :boolean { 296const expected = tokens . filter (( token ) => token . kind !== ChatFormInputRichTokenKind . TEXT ); 297 298let index = 0 ; 299 300const walk = ( parent :Node ) :boolean => { 301for ( const child of Array . from ( parent . childNodes )) { 302if ( child . nodeType !== Node . ELEMENT_NODE ) continue ; 303 304const el = child as HTMLElement ; 305const isBadge = el . getAttribute ( MENTION_BADGE_DATA_ATTRS . BADGE ) === BooleanString . TRUE ; 306const isCode = el . getAttribute ( CODE_TOKEN_ATTR ) !== null ; 307 308if ( ! isBadge && ! isCode ) { 309if ( ! walk ( el )) return false ; 310 311continue ; 312} 313 314const token = expected [ index ++ ]; 315 316if ( ! token ) return false ; 317 318if ( isBadge ) { 319if ( token . kind !== ChatFormInputRichTokenKind . BADGE ) return false ; 320 321if ( token . name !== ( el . getAttribute ( MENTION_BADGE_DATA_ATTRS . NAME ) ?? '' )) return false ; 322 323if ( token . path !== ( el . getAttribute ( MENTION_BADGE_DATA_ATTRS . PATH ) ?? '' )) return false ; 324 325continue ; 326} 327 328const codeKind :ChatFormInputRichTokenKind = 329el . getAttribute ( CODE_TOKEN_ATTR ) === ChatFormInputRichTokenKind . CODE_BLOCK 330 ?ChatFormInputRichTokenKind . CODE_BLOCK 331 :ChatFormInputRichTokenKind . CODE_INLINE ; 332 333if ( token . kind !== codeKind ) return false ; 334 335if ( 336token . kind === ChatFormInputRichTokenKind . CODE_INLINE || 337token . kind === ChatFormInputRichTokenKind . CODE_BLOCK 338) { 339if ( token . text !== ( el . textContent ?? '' )) return false ; 340} 341} 342 343return true ; 344}; 345 346return walk ( root ) && index === expected . length ; 347} 348 349/** 350* Plain-text offset of a `Range` in the root; null range (selection 351* lost) falls back to buffer length. Walked against the live DOM (not 352* a clone) so a `<br>` keeps its trailing/not-trailing context. Code 353* spans count their full textContent (fences included) and the caret 354* may land inside them; synthesized block boundaries count one `\n` 355* once the caret is past them. 356*/ 357export function rangeToTextOffset ( root :HTMLElement , range :Range | null ) :number { 358if ( ! range ) return serializeContent ( root ). length ; 359 360// A point is at/before the caret iff it falls inside [root start, caret]. 361const pre = range . cloneRange (); 362 363pre . selectNodeContents ( root ); 364pre . setEnd ( range . endContainer , range . endOffset ); 365const atOrBeforeCaret = ( node :Node , offset :number ) => pre . comparePoint ( node , offset ) !== 1 ; 366 367let total = 0 ; 368let done = false ; 369// DOM position of a code block's synthesized after-boundary, set 370// when walking past a block and consumed by the next contributing 371// sibling (counts one `\n` once the caret is past it). 372let pendingPoint :{ node :Node ; index :number } | null = null ; 373 374const walk = ( parent :Node ) => { 375let first = true ; 376 377for ( const child of Array . from ( parent . childNodes )) { 378if ( done ) return ; 379 380if ( child . nodeType === Node . TEXT_NODE ) { 381const text = child . textContent ?? '' ; 382 383if ( text . length === 0 ) continue ; 384 385if ( pendingPoint ) { 386const { index, node} = pendingPoint ; 387 388pendingPoint = null ; 389 390if ( ! atOrBeforeCaret ( node , index )) { 391done = true ; 392 393return ; 394} 395 396total += 1 ; 397} 398 399if ( ! atOrBeforeCaret ( child , 0 )) { 400done = true ; 401 402return ; 403} 404 405if ( range . endContainer === child ) { 406total += range . endOffset ; 407done = true ; 408 409return ; 410} 411 412total += text . length ; 413first = false ; 414 415continue ; 416} 417 418if ( child . nodeType !== Node . ELEMENT_NODE ) continue ; 419 420const el = child as HTMLElement ; 421const parentNode = el . parentNode as Node ; 422const elIndex = Array . prototype . indexOf . call ( parentNode . childNodes , el ); 423 424if ( pendingPoint ) { 425const { index, node} = pendingPoint ; 426 427pendingPoint = null ; 428 429if ( ! atOrBeforeCaret ( node , index )) { 430done = true ; 431 432return ; 433} 434 435total += 1 ; 436} 437 438if ( el . getAttribute ( MENTION_BADGE_DATA_ATTRS . BADGE ) === BooleanString . TRUE ) { 439const len = badgeSourceLength ( 440el . getAttribute ( MENTION_BADGE_DATA_ATTRS . NAME ) ?? '' , 441el . getAttribute ( MENTION_BADGE_DATA_ATTRS . PATH ) ?? '' 442); 443 444if ( len === 0 ) continue ; 445 446if ( ! atOrBeforeCaret ( parentNode , elIndex + 1 )) { 447done = true ; 448 449return ; 450} 451 452total += len ; 453first = false ; 454 455continue ; 456} 457 458const codeToken = el . getAttribute ( CODE_TOKEN_ATTR ); 459 460if ( codeToken !== null ) { 461const isBlock = codeToken === ChatFormInputRichTokenKind . CODE_BLOCK ; 462 463if ( isBlock && ! first ) { 464if ( ! atOrBeforeCaret ( el , 0 )) { 465done = true ; 466 467return ; 468} 469 470total += 1 ; 471} 472 473walk ( el ); 474first = false ; 475 476if ( isBlock ) pendingPoint = { index :elIndex + 1 , node :parentNode }; 477 478continue ; 479} 480 481if ( el . tagName === 'BR' ) { 482const isHatch = 483isCodeBlockElement ( el . previousSibling ) || isCodeBlockElement ( el . nextSibling ); 484 485if ( isHatch || ! el . nextSibling ) continue ; 486 487if ( ! atOrBeforeCaret ( parentNode , elIndex + 1 )) { 488done = true ; 489 490return ; 491} 492 493total += 1 ; 494first = false ; 495 496continue ; 497} 498 499if ( BLOCK_TAG_NAMES . has ( el . tagName )) { 500if ( ! first ) { 501if ( ! atOrBeforeCaret ( el , 0 )) { 502done = true ; 503 504return ; 505} 506 507total += 1 ; 508} 509 510walk ( el ); 511first = false ; 512 513continue ; 514} 515 516const before = total ; 517 518walk ( el ); 519 520if ( total > before ) first = false ; 521} 522}; 523 524walk ( root ); 525 526return total ; 527} 528 529/** 530* Materialize a token stream into a DOM subtree: text nodes for text 531* tokens, `<span data-mention-badge="true">` elements for badges, 532* `<code data-code-token>` elements for code spans. The badge's class 533* string + inline SVG are shared with the rehype plugin via 534* `$lib/constants`. 535*/ 536export function buildFragment ( tokens :ChatFormInputRichToken []) :DocumentFragment { 537const fragment = document . createDocumentFragment (); 538 539for ( let index = 0 ; index < tokens . length ; index ++ ) { 540const token = tokens [ index ]; 541 542if ( token . kind === ChatFormInputRichTokenKind . TEXT ) { 543let text = token . text ; 544 545// The separator \n at a fenced-block boundary is synthesized 546// at serialization time; keeping it in the DOM would render a 547// phantom empty line next to the block. 548if ( 549tokens [ index - 1 ]?. kind === ChatFormInputRichTokenKind . CODE_BLOCK && 550text . startsWith ( '\n' ) 551) { 552text = text . slice ( 1 ); 553} 554 555if ( 556tokens [ index + 1 ]?. kind === ChatFormInputRichTokenKind . CODE_BLOCK && 557text . endsWith ( '\n' ) 558) { 559text = text . slice ( 0 , - 1 ); 560} 561 562if ( text . length === 0 ) continue ; 563 564fragment . appendChild ( document . createTextNode ( text )); 565 566continue ; 567} 568 569if ( 570token . kind === ChatFormInputRichTokenKind . CODE_INLINE || 571token . kind === ChatFormInputRichTokenKind . CODE_BLOCK 572) { 573const code = document . createElement ( 'code' ); 574 575code . setAttribute ( CODE_TOKEN_ATTR , token . kind ); 576code . textContent = token . text ; 577fragment . appendChild ( code ); 578 579continue ; 580} 581 582// A leading badge gets an empty text node prepended: without a real 583// text position at the buffer start, the spot before the badge is 584// unreachable via keyboard (ArrowLeft/Home). 585if ( ! fragment . lastChild ) { 586fragment . appendChild ( document . createTextNode ( '' )); 587} 588 589const badge = document . createElement ( 'span' ); 590 591badge . setAttribute ( MENTION_BADGE_DATA_ATTRS . BADGE , BooleanString . TRUE ); 592badge . setAttribute ( MENTION_BADGE_DATA_ATTRS . NAME , token . name ); 593badge . setAttribute ( MENTION_BADGE_DATA_ATTRS . PATH , token . path ); 594badge . title = decodeFileLinkPath ( token . path ); 595badge . className = MENTION_BADGE_CLASSNAME ; 596badge . contentEditable = 'false' ; 597 598const svg = document . createElementNS ( MENTION_BADGE_SVG_ATTRIBUTES [ 'xmlns' ], 'svg' ); 599 600for ( const [ attr , value ] of Object . entries ( MENTION_BADGE_SVG_ATTRIBUTES )) { 601svg . setAttribute ( attr , value ); 602} 603for ( const cls of MENTION_BADGE_ICON_CLASSNAME . split ( / \s+ / ). filter ( Boolean )) { 604svg . classList . add ( cls ); 605} 606 607for ( const d of getMentionBadgeIconPaths ( token . path )) { 608const path = document . createElementNS ( MENTION_BADGE_SVG_ATTRIBUTES [ 'xmlns' ], 'path' ); 609 610path . setAttribute ( 'd' , d ); 611svg . appendChild ( path ); 612} 613 614const label = document . createElement ( 'span' ); 615 616label . classList . add ( 'shrink-0' , 'truncate' ); 617label . textContent = getMentionBadgeLabel ( 618token . name , 619decodeFileLinkPath ( token . path ), 620settingsStore . getConfig ( SETTINGS_KEYS . SHOW_FULL_PATH_IN_MENTIONS ), 621toolsStore . serverHome 622); 623 624badge . appendChild ( svg ); 625badge . appendChild ( label ); 626fragment . appendChild ( badge ); 627} 628 629return fragment ; 630} 631 632// A sibling provides a reachable caret line when it is an element 633// (badge, another block, an existing hatch) or a non-empty text node. 634function hasLineBeside ( node :Node | null ) :boolean { 635if ( ! node ) return false ; 636 637if ( node . nodeType === Node . ELEMENT_NODE ) return true ; 638 639return ( node . textContent ?? '' ) !== '' ; 640} 641 642/** 643* A code block at the END of the buffer needs an editable line after 644* it: without one the caret cannot leave the block with 645* ArrowDown/ArrowRight. A trailing `<br>` provides that line while 646* staying transparent to serialization (skipped as a hatch), and is 647* removed again once real content takes its place. 648* 649* No hatch is added BEFORE a leading block: the empty line above it 650* is transient and managed by the component (created when the caret 651* arrows onto it, removed when the caret leaves). A transient 652* leading hatch found here is kept; the browser's lone placeholder 653* `<br>` in an empty root is left untouched. 654*/ 655export function syncCodeBlockHatches ( root :HTMLElement ) { 656for ( const child of Array . from ( root . childNodes )) { 657if ( child . nodeName !== 'BR' ) continue ; 658 659const isPlaceholder = root . childNodes . length === 1 ; 660const isLeadingHatch = ! child . previousSibling && isCodeBlockElement ( child . nextSibling ); 661const isTrailingHatch = ! child . nextSibling && isCodeBlockElement ( child . previousSibling ); 662 663// A hatch goes stale once real content takes over its line: 664// content before a leading hatch, content after a trailing one, 665// or a text node after the block already providing the line. 666// A `<br>` with no code block around is a real newline (browser 667// Shift+Enter shape) and stays. 668let prevElement = child . previousSibling ; 669 670while ( prevElement && prevElement . nodeType !== Node . ELEMENT_NODE ) { 671prevElement = prevElement . previousSibling ; 672} 673const nearBlock = 674isCodeBlockElement ( child . nextSibling ) || 675isCodeBlockElement ( child . previousSibling ) || 676isCodeBlockElement ( prevElement ); 677 678if ( ! isPlaceholder && ! isLeadingHatch && ! isTrailingHatch && nearBlock ) { 679child . remove (); 680} 681} 682 683for ( const child of Array . from ( root . childNodes )) { 684if ( ! isCodeBlockElement ( child )) continue ; 685 686if ( ! hasLineBeside ( child . nextSibling )) { 687child . after ( document . createElement ( 'br' )); 688} 689} 690} 691 692/** 693* Strip the separator and artificial newlines from an all-newline text 694* node directly after a fenced block. Chromium's line break at the 695* buffer end inserts an extra artificial `\n` so the new line has 696* height, and the first `\n` after a block doubles as the fence's 697* separator line (synthesized at serialization time). Removing both 698* makes Shift+Enter after a block land the caret on the line directly 699* below the block, like a plain textarea would. 700* 701* Only all-newline text nodes are touched: a node with real content 702* carries intentional blank lines and is left alone. Returns true when 703* the DOM changed. 704*/ 705export function stripBlockBoundaryLineBreaks ( root :HTMLElement ) :boolean { 706let changed = false ; 707 708for ( const child of Array . from ( root . childNodes )) { 709if ( child . nodeType !== Node . TEXT_NODE ) continue ; 710 711if ( ! isCodeBlockElement ( child . previousSibling )) continue ; 712 713let text = child . textContent ?? '' ; 714 715if ( ! /^\n{2,}$ / . test ( text )) continue ; 716 717text = text . slice ( 1 ); 718 719const atBufferEnd = ! child . nextSibling || child . nextSibling . nodeName === 'BR' ; 720 721if ( atBufferEnd ) { 722text = text . slice ( 0 , - 1 ); 723} 724 725child . textContent = text ; 726changed = true ; 727} 728 729return changed ; 730} 731 732const WORD_CHAR_RE = / [\p{L}\p{N}_] / u ; 733 734/** 735* Word-jump target (Option+Arrow / Ctrl+Arrow) in source offsets, or null 736* when the jump crosses no badge and native word movement should handle 737* it. Badge spans are masked to word characters, so a badge counts as 738* exactly one word. 739*/ 740export function badgeAwareWordJump ( 741source :string , 742offset :number , 743direction :'forward' | 'backward' 744) :number | null { 745let masked = '' ; 746 747const badgeSpans :Array <[ number , number ]> = []; 748 749for ( const token of tokenizeContent ( source )) { 750const len = 751token . kind === ChatFormInputRichTokenKind . BADGE 752 ?badgeSourceLength ( token . name , token . path ) 753 :token . text . length ; 754 755if ( token . kind === ChatFormInputRichTokenKind . BADGE ) 756badgeSpans . push ([ masked . length , masked . length + len ]); 757 758masked += token . kind === ChatFormInputRichTokenKind . BADGE ?'a' . repeat ( len ) :token . text ; 759} 760 761if ( badgeSpans . length === 0 ) return null ; 762 763const isWord = ( index :number ) => WORD_CHAR_RE . test ( masked [ index ]); 764const spanStartingAt = ( index :number ) => badgeSpans . find (([ start ]) => start === index ); 765const spanEndingAt = ( index :number ) => badgeSpans . find (([, end ]) => end === index ); 766const n = masked . length ; 767 768let i = offset ; 769 770if ( direction === 'forward' ) { 771// Entering a badge completes the word phase at the badge's end edge. 772if ( ! ( i < n && isWord ( i ))) { 773while ( i < n && ! isWord ( i )) i ++ ; 774} 775 776while ( i < n && isWord ( i )) { 777const span = spanStartingAt ( i ); 778 779if ( span ) { 780i = span [ 1 ]; 781 782break ; 783} 784 785i ++ ; 786} 787} else { 788if ( ! ( i > 0 && isWord ( i - 1 ))) { 789while ( i > 0 && ! isWord ( i - 1 )) i -- ; 790} 791 792while ( i > 0 && isWord ( i - 1 )) { 793const span = spanEndingAt ( i ); 794 795if ( span ) { 796i = span [ 0 ]; 797 798break ; 799} 800 801i -- ; 802} 803} 804 805if ( i === offset ) return null ; 806 807const lo = Math . min ( offset , i ); 808const hi = Math . max ( offset , i ); 809 810return badgeSpans . some (([ start , end ]) => start < hi && end > lo ) ?i :null ; 811} 812 813/** 814* 0 when `caret` sits exactly at a leading badge's end edge, null 815* otherwise. Plain ArrowLeft there has no native previous position, so 816* the host snaps the caret to the buffer start manually. 817*/ 818export function leadingBadgeEdgeOffset ( source :string , caret :number ) :number | null { 819const [ first ] = tokenizeContent ( source ); 820 821if ( ! first || first . kind !== ChatFormInputRichTokenKind . BADGE ) return null ; 822 823return caret === badgeSourceLength ( first . name , first . path ) ?0 :null ; 824} 825 826/** 827* Translate a plain-text offset into a degenerate `Range` at that 828* position in the DOM; out-of-range offsets clamp to buffer end (before 829* a trailing escape hatch, not after it). Zero offset lands BEFORE a 830* badge or code span, and an offset exactly at a code span's end lands 831* AFTER it, so typing at a code span's edge extends the surrounding 832* text. Interior code-span offsets land in the element's text. 833* Understands the same block/`<br>` newline shapes as 834* `serializeContent`. 835*/ 836export function textOffsetToRange ( root :HTMLElement , offset :number ) :Range { 837const range = document . createRange (); 838 839let remaining = offset ; 840let landed = false ; 841let pendingBlockBoundary = false ; 842 843const land = ( node :Node , nodeOffset :number ) => { 844range . setStart ( node , nodeOffset ); 845range . setEnd ( node , nodeOffset ); 846landed = true ; 847}; 848const walk = ( parent :Node ) => { 849let first = true ; 850 851for ( const child of Array . from ( parent . childNodes )) { 852if ( landed ) return ; 853 854if ( child . nodeType === Node . TEXT_NODE ) { 855const text = child . textContent ?? '' ; 856 857if ( text . length === 0 ) continue ; 858 859if ( pendingBlockBoundary ) { 860// The synthesized separator maps to the near edge of the 861// content that follows the block. 862pendingBlockBoundary = false ; 863 864if ( remaining === 0 ) { 865land ( child , 0 ); 866 867return ; 868} 869 870remaining -= 1 ; 871} 872 873if ( remaining <= text . length ) { 874land ( child , remaining ); 875 876return ; 877} 878 879remaining -= text . length ; 880first = false ; 881 882continue ; 883} 884 885if ( child . nodeType !== Node . ELEMENT_NODE ) continue ; 886 887const el = child as HTMLElement ; 888 889if ( el . getAttribute ( MENTION_BADGE_DATA_ATTRS . BADGE ) === BooleanString . TRUE ) { 890const len = badgeSourceLength ( 891el . getAttribute ( MENTION_BADGE_DATA_ATTRS . NAME ) ?? '' , 892el . getAttribute ( MENTION_BADGE_DATA_ATTRS . PATH ) ?? '' 893); 894 895if ( len === 0 ) continue ; 896 897if ( pendingBlockBoundary ) { 898pendingBlockBoundary = false ; 899 900if ( remaining === 0 ) { 901range . setStartBefore ( el ); 902range . setEndBefore ( el ); 903landed = true ; 904 905return ; 906} 907 908remaining -= 1 ; 909} 910 911if ( remaining <= len ) { 912if ( remaining === 0 ) { 913range . setStartBefore ( el ); 914range . setEndBefore ( el ); 915} else { 916range . setStartAfter ( el ); 917range . setEndAfter ( el ); 918} 919 920landed = true ; 921 922return ; 923} 924 925remaining -= len ; 926first = false ; 927 928continue ; 929} 930 931const codeToken = el . getAttribute ( CODE_TOKEN_ATTR ); 932 933if ( codeToken !== null ) { 934const isBlock = codeToken === ChatFormInputRichTokenKind . CODE_BLOCK ; 935 936if ( isBlock && ( pendingBlockBoundary || ! first )) { 937pendingBlockBoundary = false ; 938 939if ( remaining === 0 ) { 940range . setStartBefore ( el ); 941range . setEndBefore ( el ); 942landed = true ; 943 944return ; 945} 946 947remaining -= 1 ; 948} 949 950const len = ( el . textContent ?? '' ). length ; 951 952if ( remaining === 0 ) { 953range . setStartBefore ( el ); 954range . setEndBefore ( el ); 955landed = true ; 956 957return ; 958} 959 960if ( remaining === len ) { 961range . setStartAfter ( el ); 962range . setEndAfter ( el ); 963landed = true ; 964 965return ; 966} 967 968if ( remaining < len ) { 969walk ( el ); 970 971return ; 972} 973 974remaining -= len ; 975 976if ( isBlock ) remaining -= 1 ; 977 978first = false ; 979 980continue ; 981} 982 983if ( el . tagName === 'BR' ) { 984const isHatch = 985isCodeBlockElement ( el . previousSibling ) || isCodeBlockElement ( el . nextSibling ); 986 987if ( isHatch ) { 988// Escape hatch: no source length; offset 0 lands before it 989// so text typed there takes its place. 990if ( remaining === 0 ) { 991range . setStartBefore ( el ); 992range . setEndBefore ( el ); 993landed = true ; 994} 995 996continue ; 997} 998 999if ( ! el . nextSibling ) continue ; 1000 1001if ( pendingBlockBoundary ) { 1002pendingBlockBoundary = false ; 1003 1004if ( remaining === 0 ) { 1005range . setStartBefore ( el ); 1006range . setEndBefore ( el ); 1007landed = true ; 1008 1009return ; 1010} 1011 1012remaining -= 1 ; 1013} 1014 1015if ( remaining === 0 ) { 1016range . setStartBefore ( el ); 1017range . setEndBefore ( el ); 1018landed = true ; 1019 1020return ; 1021} 1022 1023remaining -= 1 ; 1024first = false ; 1025 1026continue ; 1027} 1028 1029if ( BLOCK_TAG_NAMES . has ( el . tagName )) { 1030if ( pendingBlockBoundary || ! first ) { 1031pendingBlockBoundary = false ; 1032 1033if ( remaining === 0 ) { 1034// The boundary newline belongs to the previous line. 1035range . setStartBefore ( el ); 1036range . setEndBefore ( el ); 1037landed = true ; 1038 1039return ; 1040} 1041 1042remaining -= 1 ; 1043} 1044 1045walk ( el ); 1046first = false ; 1047 1048continue ; 1049} 1050 1051const before = remaining ; 1052 1053walk ( el ); 1054 1055if ( remaining < before ) first = false ; 1056} 1057}; 1058 1059walk ( root ); 1060 1061if ( ! landed ) { 1062const last = root . lastChild ; 1063 1064if ( last && last . nodeName === 'BR' ) { 1065range . setStartBefore ( last ); 1066range . setEndBefore ( last ); 1067} else { 1068range . selectNodeContents ( root ); 1069range . collapse ( false ); 1070} 1071} 1072 1073return range ; 1074}