├── .github └── workflows │ └── dispatch.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── src ├── README.md ├── closebrackets.ts ├── completion.ts ├── config.ts ├── filter.ts ├── index.ts ├── snippet.ts ├── state.ts ├── theme.ts ├── tooltip.ts ├── view.ts └── word.ts └── test └── webtest-autocomplete.ts /.github/workflows/dispatch.yml: -------------------------------------------------------------------------------- 1 | name: Trigger CI 2 | on: push 3 | 4 | jobs: 5 | build: 6 | name: Dispatch to main repo 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Emit repository_dispatch 10 | uses: mvasigh/dispatch-action@main 11 | with: 12 | # You should create a personal access token and store it in your repository 13 | token: ${{ secrets.DISPATCH_AUTH }} 14 | repo: dev 15 | owner: codemirror 16 | event_type: push 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | package-lock.json 3 | /dist 4 | /test/*.js 5 | /test/*.d.ts 6 | /test/*.d.ts.map 7 | .tern-* 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /test 3 | /node_modules 4 | .tern-* 5 | rollup.config.js 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 6.18.6 (2025-02-12) 2 | 3 | ### Bug fixes 4 | 5 | Fix an issue where the closing character for double-angle quotation marks and full-width brackets was computed incorrectly. 6 | 7 | ## 6.18.5 (2025-02-11) 8 | 9 | ### Bug fixes 10 | 11 | Fix an issue where clicking on the scrollbar for the completion list could move focus out of the editor. 12 | 13 | ## 6.18.4 (2024-12-17) 14 | 15 | ### Bug fixes 16 | 17 | Align the behavior of snippet completions with text completions in that they overwrite the selected text. 18 | 19 | ## 6.18.3 (2024-11-13) 20 | 21 | ### Bug fixes 22 | 23 | Backspacing to the start of the completed range will no longer close the completion tooltip when it was triggered implicitly by typing the character before that range. 24 | 25 | ## 6.18.2 (2024-10-30) 26 | 27 | ### Bug fixes 28 | 29 | Don't immediately show synchronously updated completions when there are some sources that still need to return. 30 | 31 | ## 6.18.1 (2024-09-14) 32 | 33 | ### Bug fixes 34 | 35 | Fix an issue where `insertCompletionText` would get confused about the length of the inserted text when it contained CRLF line breaks, and create an invalid selection. 36 | 37 | Add Alt-Backtick as additional binding on macOS, where IME can take over Ctrl-Space. 38 | 39 | ## 6.18.0 (2024-08-05) 40 | 41 | ### Bug fixes 42 | 43 | Style the info element so that newlines are preserved, to make it easier to display multi-line info from a string source. 44 | 45 | ### New features 46 | 47 | When registering an `abort` handler for a completion query, you can now use the `onDocChange` option to indicate that your query should be aborted as soon as the document changes while it is running. 48 | 49 | ## 6.17.0 (2024-07-03) 50 | 51 | ### Bug fixes 52 | 53 | Fix an issue where completions weren't properly reset when starting a new completion through `activateOnCompletion`. 54 | 55 | ### New features 56 | 57 | `CompletionContext` objects now have a `view` property that holds the editor view when the query context has a view available. 58 | 59 | ## 6.16.3 (2024-06-19) 60 | 61 | ### Bug fixes 62 | 63 | Avoid adding an `aria-autocomplete` attribute to the editor when there are no active sources active. 64 | 65 | ## 6.16.2 (2024-05-31) 66 | 67 | ### Bug fixes 68 | 69 | Allow backslash-escaped closing braces inside snippet field names/content. 70 | 71 | ## 6.16.1 (2024-05-29) 72 | 73 | ### Bug fixes 74 | 75 | Fix a bug where multiple backslashes before a brace in a snippet were all removed. 76 | 77 | ## 6.16.0 (2024-04-12) 78 | 79 | ### New features 80 | 81 | The new `activateOnCompletion` option allows autocompletion to be configured to chain completion activation for some types of completions. 82 | 83 | ## 6.15.0 (2024-03-13) 84 | 85 | ### New features 86 | 87 | The new `filterStrict` option can be used to turn off fuzzy matching of completions. 88 | 89 | ## 6.14.0 (2024-03-10) 90 | 91 | ### New features 92 | 93 | Completion results can now define a `map` method that can be used to adjust position-dependent information for document changes. 94 | 95 | ## 6.13.0 (2024-02-29) 96 | 97 | ### New features 98 | 99 | Completions may now provide 'commit characters' that, when typed, commit the completion before inserting the character. 100 | 101 | ## 6.12.0 (2024-01-12) 102 | 103 | ### Bug fixes 104 | 105 | Make sure snippet completions also set `userEvent` to `input.complete`. 106 | 107 | Fix a crash when the editor lost focus during an update and autocompletion was active. 108 | 109 | Fix a crash when using a snippet that has only one field, but multiple instances of that field. 110 | 111 | ### New features 112 | 113 | The new `activateOnTypingDelay` option allows control over the debounce time before the completions are queried when the user types. 114 | 115 | ## 6.11.1 (2023-11-27) 116 | 117 | ### Bug fixes 118 | 119 | Fix a bug that caused typing over closed brackets after pressing enter to still not work in many situations. 120 | 121 | ## 6.11.0 (2023-11-09) 122 | 123 | ### Bug fixes 124 | 125 | Fix an issue that would prevent typing over closed brackets after starting a new line with enter. 126 | 127 | ### New features 128 | 129 | Additional elements rendered in completion options with `addToOptions` are now given access to the editor view. 130 | 131 | ## 6.10.2 (2023-10-13) 132 | 133 | ### Bug fixes 134 | 135 | Fix a bug that caused `updateSyncTime` to always delay the initial population of the tooltip. 136 | 137 | ## 6.10.1 (2023-10-11) 138 | 139 | ### Bug fixes 140 | 141 | Fix a bug where picking a selection with the mouse could use the wrong completion if the completion list was updated after being opened. 142 | 143 | ## 6.10.0 (2023-10-11) 144 | 145 | ### New features 146 | 147 | The new autocompletion configuration option `updateSyncTime` allows control over how long fast sources are held back waiting for slower completion sources. 148 | 149 | ## 6.9.2 (2023-10-06) 150 | 151 | ### Bug fixes 152 | 153 | Fix a bug in `completeAnyWord` that could cause it to generate invalid regular expressions and crash. 154 | 155 | ## 6.9.1 (2023-09-14) 156 | 157 | ### Bug fixes 158 | 159 | Make sure the cursor is scrolled into view after inserting completion text. 160 | 161 | Make sure scrolling completions into view doesn't get confused when the tooltip is scaled. 162 | 163 | ## 6.9.0 (2023-07-18) 164 | 165 | ### New features 166 | 167 | Completions may now provide a `displayLabel` property that overrides the way they are displayed in the completion list. 168 | 169 | ## 6.8.1 (2023-06-23) 170 | 171 | ### Bug fixes 172 | 173 | `acceptCompletion` now returns false (allowing other handlers to take effect) when the completion popup is open but disabled. 174 | 175 | ## 6.8.0 (2023-06-12) 176 | 177 | ### New features 178 | 179 | The result of `Completion.info` may now include a `destroy` method that will be called when the tooltip is removed. 180 | 181 | ## 6.7.1 (2023-05-13) 182 | 183 | ### Bug fixes 184 | 185 | Fix a bug that cause incorrect ordering of completions when some results covered input text and others didn't. 186 | 187 | ## 6.7.0 (2023-05-11) 188 | 189 | ### New features 190 | 191 | The new `hasNextSnippetField` and `hasPrevSnippetField` functions can be used to figure out if the snippet-field-motion commands apply to a given state. 192 | 193 | ## 6.6.1 (2023-05-03) 194 | 195 | ### Bug fixes 196 | 197 | Fix a bug that made the editor use the completion's original position, rather than its current position, when changes happened in the document while a result was active. 198 | 199 | ## 6.6.0 (2023-04-27) 200 | 201 | ### Bug fixes 202 | 203 | Fix a bug in `insertCompletionText` that caused it to replace the wrong range when a result set's `to` fell after the cursor. 204 | 205 | ### New features 206 | 207 | Functions returned by `snippet` can now be called without a completion object. 208 | 209 | ## 6.5.1 (2023-04-13) 210 | 211 | ### Bug fixes 212 | 213 | Keep completions open when interaction with an info tooltip moves focus out of the editor. 214 | 215 | ## 6.5.0 (2023-04-13) 216 | 217 | ### Bug fixes 218 | 219 | When `closeBrackets` skips a bracket, it now generates a change that overwrites the bracket. 220 | 221 | Replace the entire selected range when picking a completion with a non-cursor selection active. 222 | 223 | ### New features 224 | 225 | Completions can now provide a `section` field that is used to group them into sections. 226 | 227 | The new `positionInfo` option can be used to provide custom logic for positioning the info tooltips. 228 | 229 | ## 6.4.2 (2023-02-17) 230 | 231 | ### Bug fixes 232 | 233 | Fix a bug where the apply method created by `snippet` didn't add a `pickedCompletion` annotation to the transactions it created. 234 | 235 | ## 6.4.1 (2023-02-14) 236 | 237 | ### Bug fixes 238 | 239 | Don't consider node names in trees that aren't the same language as the one at the completion position in `ifIn` and `ifNotIn`. 240 | 241 | Make sure completions that exactly match the input get a higher score than those that don't (so that even if the latter has a score boost, it ends up lower in the list). 242 | 243 | ## 6.4.0 (2022-12-14) 244 | 245 | ### Bug fixes 246 | 247 | Fix an issue where the extension would sometimes try to draw a disabled dialog at an outdated position, leading to plugin crashes. 248 | 249 | ### New features 250 | 251 | A `tooltipClass` option to autocompletion can now be used to add additional CSS classes to the completion tooltip. 252 | 253 | ## 6.3.4 (2022-11-24) 254 | 255 | ### Bug fixes 256 | 257 | Fix an issue where completion lists could end up being higher than the tooltip they were in. 258 | 259 | ## 6.3.3 (2022-11-18) 260 | 261 | ### Bug fixes 262 | 263 | Set an explicit `box-sizing` style on completion icons so CSS resets don't mess them up. 264 | 265 | Allow closing braces in templates to be escaped with a backslash. 266 | 267 | ## 6.3.2 (2022-11-15) 268 | 269 | ### Bug fixes 270 | 271 | Fix a regression that could cause the completion dialog to stick around when it should be hidden. 272 | 273 | ## 6.3.1 (2022-11-14) 274 | 275 | ### Bug fixes 276 | 277 | Fix a regression where transactions for picking a completion (without custom `apply` method) no longer had the `pickedCompletion` annotation. 278 | 279 | Reduce flickering for completion sources without `validFor` info by temporarily showing a disabled tooltip while the completion updates. 280 | 281 | Make sure completion info tooltips are kept within the space provided by the `tooltipSpace` option. 282 | 283 | ## 6.3.0 (2022-09-22) 284 | 285 | ### New features 286 | 287 | Close bracket configuration now supports a `stringPrefixes` property that can be used to allow autoclosing of prefixed strings. 288 | 289 | ## 6.2.0 (2022-09-13) 290 | 291 | ### New features 292 | 293 | Autocompletion now takes an `interactionDelay` option that can be used to control the delay between the time where completion opens and the time where commands like `acceptCompletion` affect it. 294 | 295 | ## 6.1.1 (2022-09-08) 296 | 297 | ### Bug fixes 298 | 299 | Fix a bug that prevented transactions produced by `deleteBracketPair` from being marked as deletion user events. 300 | 301 | Improve positioning of completion info tooltips so they are less likely to stick out of the screen on small displays. 302 | 303 | ## 6.1.0 (2022-07-19) 304 | 305 | ### New features 306 | 307 | You can now provide a `compareCompletions` option to autocompletion to influence the way completions with the same match score are sorted. 308 | 309 | The `selectOnOpen` option to autocompletion can be used to require explicitly selecting a completion option before `acceptCompletion` does anything. 310 | 311 | ## 6.0.4 (2022-07-07) 312 | 313 | ### Bug fixes 314 | 315 | Remove a leftover `console.log` in bracket closing code. 316 | 317 | ## 6.0.3 (2022-07-04) 318 | 319 | ### Bug fixes 320 | 321 | Fix a bug that caused `closeBrackets` to not close quotes when at the end of a syntactic construct that starts with a similar quote. 322 | 323 | ## 6.0.2 (2022-06-15) 324 | 325 | ### Bug fixes 326 | 327 | Declare package dependencies as peer dependencies as an attempt to avoid duplicated package issues. 328 | 329 | ## 6.0.1 (2022-06-09) 330 | 331 | ### Bug fixes 332 | 333 | Support escaping `${` or `#{` in snippets. 334 | 335 | ## 6.0.0 (2022-06-08) 336 | 337 | ### Bug fixes 338 | 339 | Scroll the cursor into view when inserting a snippet. 340 | 341 | ## 0.20.3 (2022-05-30) 342 | 343 | ### Bug fixes 344 | 345 | Add an aria-label to the completion listbox. 346 | 347 | Fix a regression that caused transactions generated for completion to not have a `userEvent` annotation. 348 | 349 | ## 0.20.2 (2022-05-24) 350 | 351 | ### New features 352 | 353 | The package now exports an `insertCompletionText` helper that implements the default behavior for applying a completion. 354 | 355 | ## 0.20.1 (2022-05-16) 356 | 357 | ### New features 358 | 359 | The new `closeOnBlur` option determines whether the completion tooltip is closed when the editor loses focus. 360 | 361 | `CompletionResult` objects with `filter: false` may now have a `getMatch` property that determines the matched range in the options. 362 | 363 | ## 0.20.0 (2022-04-20) 364 | 365 | ### Breaking changes 366 | 367 | `CompletionResult.span` has been renamed to `validFor`, and may now hold a function as well as a regular expression. 368 | 369 | ### Bug fixes 370 | 371 | Remove code that dropped any options beyond the 300th one when matching and sorting option lists. 372 | 373 | Completion will now apply to all cursors when there are multiple cursors. 374 | 375 | ### New features 376 | 377 | `CompletionResult.update` can now be used to implement quick autocompletion updates in a synchronous way. 378 | 379 | The @codemirror/closebrackets package was merged into this one. 380 | 381 | ## 0.19.15 (2022-03-23) 382 | 383 | ### New features 384 | 385 | The `selectedCompletionIndex` function tells you the position of the currently selected completion. 386 | 387 | The new `setSelectionCompletion` function creates a state effect that moves the selected completion to a given index. 388 | 389 | A completion's `info` method may now return null to indicate that no further info is available. 390 | 391 | ## 0.19.14 (2022-03-10) 392 | 393 | ### Bug fixes 394 | 395 | Make the ARIA attributes added to the editor during autocompletion spec-compliant. 396 | 397 | ## 0.19.13 (2022-02-18) 398 | 399 | ### Bug fixes 400 | 401 | Fix an issue where the completion tooltip stayed open if it was explicitly opened and the user backspaced past its start. 402 | 403 | Stop snippet filling when a change happens across one of the snippet fields' boundaries. 404 | 405 | ## 0.19.12 (2022-01-11) 406 | 407 | ### Bug fixes 408 | 409 | Fix completion navigation with PageUp/Down when the completion tooltip isn't part of the view DOM. 410 | 411 | ## 0.19.11 (2022-01-11) 412 | 413 | ### Bug fixes 414 | 415 | Fix a bug that caused page up/down to only move the selection by two options in the completion tooltip. 416 | 417 | ## 0.19.10 (2022-01-05) 418 | 419 | ### Bug fixes 420 | 421 | Make sure the info tooltip is hidden when the selected option is scrolled out of view. 422 | 423 | Fix a bug in the completion ranking that would sometimes give options that match the input by word start chars higher scores than appropriate. 424 | 425 | Options are now sorted (ascending) by length when their match score is otherwise identical. 426 | 427 | ## 0.19.9 (2021-11-26) 428 | 429 | ### Bug fixes 430 | 431 | Fix an issue where info tooltips would be visible in an inappropriate position when there was no room to place them properly. 432 | 433 | ## 0.19.8 (2021-11-17) 434 | 435 | ### Bug fixes 436 | 437 | Give the completion tooltip a minimal width, and show ellipsis when completions overflow the tooltip width. 438 | 439 | ### New features 440 | 441 | `autocompletion` now accepts an `aboveCursor` option to make the completion tooltip show up above the cursor. 442 | 443 | ## 0.19.7 (2021-11-16) 444 | 445 | ### Bug fixes 446 | 447 | Make option deduplication less aggressive, so that options with different `type` or `apply` fields don't get merged. 448 | 449 | ## 0.19.6 (2021-11-12) 450 | 451 | ### Bug fixes 452 | 453 | Fix an issue where parsing a snippet with a field that was labeled only by a number crashed. 454 | 455 | ## 0.19.5 (2021-11-09) 456 | 457 | ### Bug fixes 458 | 459 | Make sure info tooltips don't stick out of the bottom of the page. 460 | 461 | ### New features 462 | 463 | The package exports a new function `selectedCompletion`, which can be used to find out which completion is currently selected. 464 | 465 | Transactions created by picking a completion now have an annotation (`pickedCompletion`) holding the original completion. 466 | 467 | ## 0.19.4 (2021-10-24) 468 | 469 | ### Bug fixes 470 | 471 | Don't rely on the platform's highlight colors for the active completion, since those are inconsistent and may not be appropriate for the theme. 472 | 473 | Fix incorrect match underline for some kinds of matched completions. 474 | 475 | ## 0.19.3 (2021-08-31) 476 | 477 | ### Bug fixes 478 | 479 | Improve the sorting of completions by using `localeCompare`. 480 | 481 | Fix reading of autocompletions in NVDA screen reader. 482 | 483 | ### New features 484 | 485 | The new `icons` option can be used to turn off icons in the completion list. 486 | 487 | The `optionClass` option can now be used to add CSS classes to the options in the completion list. 488 | 489 | It is now possible to inject additional content into rendered completion options with the `addToOptions` configuration option. 490 | 491 | ## 0.19.2 (2021-08-25) 492 | 493 | ### Bug fixes 494 | 495 | Fix an issue where `completeAnyWord` would return results when there was no query and `explicit` was false. 496 | 497 | ## 0.19.1 (2021-08-11) 498 | 499 | ### Bug fixes 500 | 501 | Fix incorrect versions for @lezer dependencies. 502 | 503 | ## 0.19.0 (2021-08-11) 504 | 505 | ### Breaking changes 506 | 507 | Update dependencies to 0.19.0 508 | 509 | ## 0.18.8 (2021-06-30) 510 | 511 | ### New features 512 | 513 | Add an `ifIn` helper function that constrains a completion source to only fire when in a given syntax node. Add support for unfiltered completions 514 | 515 | A completion result can now set a `filter: false` property to disable filtering and sorting of completions, when it already did so itself. 516 | 517 | ## 0.18.7 (2021-06-14) 518 | 519 | ### Bug fixes 520 | 521 | Don't treat continued completions when typing after an explicit completion as explicit. 522 | 523 | ## 0.18.6 (2021-06-03) 524 | 525 | ### Bug fixes 526 | 527 | Adding or reconfiguring completion sources will now cause them to be activated right away if a completion was active. 528 | 529 | ### New features 530 | 531 | You can now specify multiple types in `Completion.type` by separating them by spaces. Small doc comment tweak for Completion.type 532 | 533 | ## 0.18.5 (2021-04-23) 534 | 535 | ### Bug fixes 536 | 537 | Fix a regression where snippet field selection didn't work with @codemirror/state 0.18.6. 538 | 539 | Fix a bug where snippet fields with different position numbers were inappropriately merged. 540 | 541 | ## 0.18.4 (2021-04-20) 542 | 543 | ### Bug fixes 544 | 545 | Fix a crash in Safari when moving the selection during composition. 546 | 547 | ## 0.18.3 (2021-03-15) 548 | 549 | ### Bug fixes 550 | 551 | Adjust to updated @codemirror/tooltip interface. 552 | 553 | ## 0.18.2 (2021-03-14) 554 | 555 | ### Bug fixes 556 | 557 | Fix unintended ES2020 output (the package contains ES6 code again). 558 | 559 | ## 0.18.1 (2021-03-11) 560 | 561 | ### Bug fixes 562 | 563 | Stop active completion when all sources resolve without producing any matches. 564 | 565 | ### New features 566 | 567 | `Completion.info` may now return a promise. 568 | 569 | ## 0.18.0 (2021-03-03) 570 | 571 | ### Bug fixes 572 | 573 | Only preserve selected option across updates when it isn't the first option. 574 | 575 | ## 0.17.4 (2021-01-18) 576 | 577 | ### Bug fixes 578 | 579 | Fix a styling issue where the selection had become invisible inside snippet fields (when using `drawSelection`). 580 | 581 | ### New features 582 | 583 | Snippet fields can now be selected with the pointing device (so that they are usable on touch devices). 584 | 585 | ## 0.17.3 (2021-01-18) 586 | 587 | ### Bug fixes 588 | 589 | Fix a bug where uppercase completions would be incorrectly matched against the typed input. 590 | 591 | ## 0.17.2 (2021-01-12) 592 | 593 | ### Bug fixes 594 | 595 | Don't bind Cmd-Space on macOS, since that already has a system default binding. Use Ctrl-Space for autocompletion. 596 | 597 | ## 0.17.1 (2021-01-06) 598 | 599 | ### New features 600 | 601 | The package now also exports a CommonJS module. 602 | 603 | ## 0.17.0 (2020-12-29) 604 | 605 | ### Breaking changes 606 | 607 | First numbered release. 608 | 609 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2018-2021 by Marijn Haverbeke and others 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @codemirror/autocomplete [![NPM version](https://img.shields.io/npm/v/@codemirror/autocomplete.svg)](https://www.npmjs.org/package/@codemirror/autocomplete) 2 | 3 | [ [**WEBSITE**](https://codemirror.net/) | [**DOCS**](https://codemirror.net/docs/ref/#autocomplete) | [**ISSUES**](https://github.com/codemirror/dev/issues) | [**FORUM**](https://discuss.codemirror.net/c/next/) | [**CHANGELOG**](https://github.com/codemirror/autocomplete/blob/main/CHANGELOG.md) ] 4 | 5 | This package implements autocompletion for the 6 | [CodeMirror](https://codemirror.net/) code editor. 7 | 8 | The [project page](https://codemirror.net/) has more information, a 9 | number of [examples](https://codemirror.net/examples/) and the 10 | [documentation](https://codemirror.net/docs/). 11 | 12 | This code is released under an 13 | [MIT license](https://github.com/codemirror/autocomplete/tree/main/LICENSE). 14 | 15 | We aim to be an inclusive, welcoming community. To make that explicit, 16 | we have a [code of 17 | conduct](http://contributor-covenant.org/version/1/1/0/) that applies 18 | to communication around the project. 19 | 20 | ## Usage 21 | 22 | ```javascript 23 | import {EditorView} from "@codemirror/view" 24 | import {autocompletion} from "@codemirror/autocomplete" 25 | import {jsonLanguage} from "@codemirror/lang-json" 26 | 27 | const view = new EditorView({ 28 | parent: document.body, 29 | extensions: [ 30 | jsonLanguage, 31 | autocompletion(), 32 | jsonLanguage.data.of({ 33 | autocomplete: ["id", "name", "address"] 34 | }) 35 | ] 36 | }) 37 | ``` 38 | 39 | This configuration will just complete the given words anywhere in JSON 40 | context. Most language modules come with more refined autocompletion 41 | built-in, but you can also write your own custom autocompletion 42 | [sources](https://codemirror.net/docs/ref/#autocomplete.CompletionSource) 43 | and associate them with your language this way. 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codemirror/autocomplete", 3 | "version": "6.18.6", 4 | "description": "Autocompletion for the CodeMirror code editor", 5 | "scripts": { 6 | "test": "cm-runtests", 7 | "prepare": "cm-buildhelper src/index.ts" 8 | }, 9 | "keywords": [ 10 | "editor", 11 | "code" 12 | ], 13 | "author": { 14 | "name": "Marijn Haverbeke", 15 | "email": "marijn@haverbeke.berlin", 16 | "url": "http://marijnhaverbeke.nl" 17 | }, 18 | "type": "module", 19 | "main": "dist/index.cjs", 20 | "exports": { 21 | "import": "./dist/index.js", 22 | "require": "./dist/index.cjs" 23 | }, 24 | "types": "dist/index.d.ts", 25 | "module": "dist/index.js", 26 | "sideEffects": false, 27 | "license": "MIT", 28 | "dependencies": { 29 | "@codemirror/language": "^6.0.0", 30 | "@codemirror/state": "^6.0.0", 31 | "@codemirror/view": "^6.17.0", 32 | "@lezer/common": "^1.0.0" 33 | }, 34 | "devDependencies": { 35 | "@codemirror/buildhelper": "^1.0.0" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/codemirror/autocomplete.git" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | @Completion 2 | 3 | @CompletionInfo 4 | 5 | @CompletionSection 6 | 7 | @autocompletion 8 | 9 | @completionStatus 10 | 11 | @currentCompletions 12 | 13 | @selectedCompletion 14 | 15 | @selectedCompletionIndex 16 | 17 | @setSelectedCompletion 18 | 19 | @pickedCompletion 20 | 21 | ### Sources 22 | 23 | @CompletionContext 24 | 25 | @CompletionResult 26 | 27 | @CompletionSource 28 | 29 | @completeFromList 30 | 31 | @ifIn 32 | 33 | @ifNotIn 34 | 35 | @completeAnyWord 36 | 37 | @insertCompletionText 38 | 39 | ### Commands 40 | 41 | @startCompletion 42 | 43 | @closeCompletion 44 | 45 | @acceptCompletion 46 | 47 | @moveCompletionSelection 48 | 49 | @completionKeymap 50 | 51 | ### Snippets 52 | 53 | @snippet 54 | 55 | @snippetCompletion 56 | 57 | @nextSnippetField 58 | 59 | @hasNextSnippetField 60 | 61 | @prevSnippetField 62 | 63 | @hasPrevSnippetField 64 | 65 | @clearSnippet 66 | 67 | @snippetKeymap 68 | 69 | ### Automatic Bracket Closing 70 | 71 | @CloseBracketConfig 72 | 73 | @closeBrackets 74 | 75 | @closeBracketsKeymap 76 | 77 | @deleteBracketPair 78 | 79 | @insertBracket 80 | -------------------------------------------------------------------------------- /src/closebrackets.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, KeyBinding} from "@codemirror/view" 2 | import {EditorState, EditorSelection, Transaction, Extension, 3 | StateCommand, StateField, StateEffect, MapMode, CharCategory, 4 | Text, codePointAt, fromCodePoint, codePointSize, 5 | RangeSet, RangeValue} from "@codemirror/state" 6 | import {syntaxTree} from "@codemirror/language" 7 | 8 | /// Configures bracket closing behavior for a syntax (via 9 | /// [language data](#state.EditorState.languageDataAt)) using the `"closeBrackets"` 10 | /// identifier. 11 | export interface CloseBracketConfig { 12 | /// The opening brackets to close. Defaults to `["(", "[", "{", "'", 13 | /// '"']`. Brackets may be single characters or a triple of quotes 14 | /// (as in `"'''"`). 15 | brackets?: string[] 16 | /// Characters in front of which newly opened brackets are 17 | /// automatically closed. Closing always happens in front of 18 | /// whitespace. Defaults to `")]}:;>"`. 19 | before?: string 20 | /// When determining whether a given node may be a string, recognize 21 | /// these prefixes before the opening quote. 22 | stringPrefixes?: string[] 23 | } 24 | 25 | const defaults: Required = { 26 | brackets: ["(", "[", "{", "'", '"'], 27 | before: ")]}:;>", 28 | stringPrefixes: [] 29 | } 30 | 31 | const closeBracketEffect = StateEffect.define({ 32 | map(value, mapping) { 33 | let mapped = mapping.mapPos(value, -1, MapMode.TrackAfter) 34 | return mapped == null ? undefined : mapped 35 | } 36 | }) 37 | 38 | const closedBracket = new class extends RangeValue {} 39 | closedBracket.startSide = 1; closedBracket.endSide = -1 40 | 41 | const bracketState = StateField.define>({ 42 | create() { return RangeSet.empty }, 43 | update(value, tr) { 44 | value = value.map(tr.changes) 45 | if (tr.selection) { 46 | let line = tr.state.doc.lineAt(tr.selection.main.head) 47 | value = value.update({filter: from => from >= line.from && from <= line.to}) 48 | } 49 | for (let effect of tr.effects) if (effect.is(closeBracketEffect)) 50 | value = value.update({add: [closedBracket.range(effect.value, effect.value + 1)]}) 51 | return value 52 | } 53 | }) 54 | 55 | /// Extension to enable bracket-closing behavior. When a closeable 56 | /// bracket is typed, its closing bracket is immediately inserted 57 | /// after the cursor. When closing a bracket directly in front of a 58 | /// closing bracket inserted by the extension, the cursor moves over 59 | /// that bracket. 60 | export function closeBrackets(): Extension { 61 | return [inputHandler, bracketState] 62 | } 63 | 64 | const definedClosing = "()[]{}<>«»»«[]{}" 65 | 66 | function closing(ch: number) { 67 | for (let i = 0; i < definedClosing.length; i += 2) 68 | if (definedClosing.charCodeAt(i) == ch) return definedClosing.charAt(i + 1) 69 | return fromCodePoint(ch < 128 ? ch : ch + 1) 70 | } 71 | 72 | function config(state: EditorState, pos: number) { 73 | return state.languageDataAt("closeBrackets", pos)[0] || defaults 74 | } 75 | 76 | const android = typeof navigator == "object" && /Android\b/.test(navigator.userAgent) 77 | 78 | const inputHandler = EditorView.inputHandler.of((view, from, to, insert) => { 79 | if ((android ? view.composing : view.compositionStarted) || view.state.readOnly) return false 80 | let sel = view.state.selection.main 81 | if (insert.length > 2 || insert.length == 2 && codePointSize(codePointAt(insert, 0)) == 1 || 82 | from != sel.from || to != sel.to) return false 83 | let tr = insertBracket(view.state, insert) 84 | if (!tr) return false 85 | view.dispatch(tr) 86 | return true 87 | }) 88 | 89 | /// Command that implements deleting a pair of matching brackets when 90 | /// the cursor is between them. 91 | export const deleteBracketPair: StateCommand = ({state, dispatch}) => { 92 | if (state.readOnly) return false 93 | let conf = config(state, state.selection.main.head) 94 | let tokens = conf.brackets || defaults.brackets 95 | let dont = null, changes = state.changeByRange(range => { 96 | if (range.empty) { 97 | let before = prevChar(state.doc, range.head) 98 | for (let token of tokens) { 99 | if (token == before && nextChar(state.doc, range.head) == closing(codePointAt(token, 0))) 100 | return {changes: {from: range.head - token.length, to: range.head + token.length}, 101 | range: EditorSelection.cursor(range.head - token.length)} 102 | } 103 | } 104 | return {range: dont = range} 105 | }) 106 | if (!dont) dispatch(state.update(changes, {scrollIntoView: true, userEvent: "delete.backward"})) 107 | return !dont 108 | } 109 | 110 | /// Close-brackets related key bindings. Binds Backspace to 111 | /// [`deleteBracketPair`](#autocomplete.deleteBracketPair). 112 | export const closeBracketsKeymap: readonly KeyBinding[] = [ 113 | {key: "Backspace", run: deleteBracketPair} 114 | ] 115 | 116 | /// Implements the extension's behavior on text insertion. If the 117 | /// given string counts as a bracket in the language around the 118 | /// selection, and replacing the selection with it requires custom 119 | /// behavior (inserting a closing version or skipping past a 120 | /// previously-closed bracket), this function returns a transaction 121 | /// representing that custom behavior. (You only need this if you want 122 | /// to programmatically insert brackets—the 123 | /// [`closeBrackets`](#autocomplete.closeBrackets) extension will 124 | /// take care of running this for user input.) 125 | export function insertBracket(state: EditorState, bracket: string): Transaction | null { 126 | let conf = config(state, state.selection.main.head) 127 | let tokens = conf.brackets || defaults.brackets 128 | for (let tok of tokens) { 129 | let closed = closing(codePointAt(tok, 0)) 130 | if (bracket == tok) 131 | return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok + tok) > -1, conf) 132 | : handleOpen(state, tok, closed, conf.before || defaults.before) 133 | if (bracket == closed && closedBracketAt(state, state.selection.main.from)) 134 | return handleClose(state, tok, closed) 135 | } 136 | return null 137 | } 138 | 139 | function closedBracketAt(state: EditorState, pos: number) { 140 | let found = false 141 | state.field(bracketState).between(0, state.doc.length, from => { 142 | if (from == pos) found = true 143 | }) 144 | return found 145 | } 146 | 147 | function nextChar(doc: Text, pos: number) { 148 | let next = doc.sliceString(pos, pos + 2) 149 | return next.slice(0, codePointSize(codePointAt(next, 0))) 150 | } 151 | 152 | function prevChar(doc: Text, pos: number) { 153 | let prev = doc.sliceString(pos - 2, pos) 154 | return codePointSize(codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1) 155 | } 156 | 157 | function handleOpen(state: EditorState, open: string, close: string, closeBefore: string) { 158 | let dont = null, changes = state.changeByRange(range => { 159 | if (!range.empty) 160 | return {changes: [{insert: open, from: range.from}, {insert: close, from: range.to}], 161 | effects: closeBracketEffect.of(range.to + open.length), 162 | range: EditorSelection.range(range.anchor + open.length, range.head + open.length)} 163 | let next = nextChar(state.doc, range.head) 164 | if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) 165 | return {changes: {insert: open + close, from: range.head}, 166 | effects: closeBracketEffect.of(range.head + open.length), 167 | range: EditorSelection.cursor(range.head + open.length)} 168 | return {range: dont = range} 169 | }) 170 | return dont ? null : state.update(changes, { 171 | scrollIntoView: true, 172 | userEvent: "input.type" 173 | }) 174 | } 175 | 176 | function handleClose(state: EditorState, _open: string, close: string) { 177 | let dont = null, changes = state.changeByRange(range => { 178 | if (range.empty && nextChar(state.doc, range.head) == close) 179 | return {changes: {from: range.head, to: range.head + close.length, insert: close}, 180 | range: EditorSelection.cursor(range.head + close.length)} 181 | return dont = {range} 182 | }) 183 | return dont ? null : state.update(changes, { 184 | scrollIntoView: true, 185 | userEvent: "input.type" 186 | }) 187 | } 188 | 189 | // Handles cases where the open and close token are the same, and 190 | // possibly triple quotes (as in `"""abc"""`-style quoting). 191 | function handleSame(state: EditorState, token: string, allowTriple: boolean, config: CloseBracketConfig) { 192 | let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes 193 | let dont = null, changes = state.changeByRange(range => { 194 | if (!range.empty) 195 | return {changes: [{insert: token, from: range.from}, {insert: token, from: range.to}], 196 | effects: closeBracketEffect.of(range.to + token.length), 197 | range: EditorSelection.range(range.anchor + token.length, range.head + token.length)} 198 | let pos = range.head, next = nextChar(state.doc, pos), start 199 | if (next == token) { 200 | if (nodeStart(state, pos)) { 201 | return {changes: {insert: token + token, from: pos}, 202 | effects: closeBracketEffect.of(pos + token.length), 203 | range: EditorSelection.cursor(pos + token.length)} 204 | } else if (closedBracketAt(state, pos)) { 205 | let isTriple = allowTriple && state.sliceDoc(pos, pos + token.length * 3) == token + token + token 206 | let content = isTriple ? token + token + token : token 207 | return {changes: {from: pos, to: pos + content.length, insert: content}, 208 | range: EditorSelection.cursor(pos + content.length)} 209 | } 210 | } else if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token && 211 | (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 && 212 | nodeStart(state, start)) { 213 | return {changes: {insert: token + token + token + token, from: pos}, 214 | effects: closeBracketEffect.of(pos + token.length), 215 | range: EditorSelection.cursor(pos + token.length)} 216 | } else if (state.charCategorizer(pos)(next) != CharCategory.Word) { 217 | if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes)) 218 | return {changes: {insert: token + token, from: pos}, 219 | effects: closeBracketEffect.of(pos + token.length), 220 | range: EditorSelection.cursor(pos + token.length)} 221 | } 222 | return {range: dont = range} 223 | }) 224 | return dont ? null : state.update(changes, { 225 | scrollIntoView: true, 226 | userEvent: "input.type" 227 | }) 228 | } 229 | 230 | function nodeStart(state: EditorState, pos: number) { 231 | let tree = syntaxTree(state).resolveInner(pos + 1) 232 | return tree.parent && tree.from == pos 233 | } 234 | 235 | function probablyInString(state: EditorState, pos: number, quoteToken: string, prefixes: readonly string[]) { 236 | let node = syntaxTree(state).resolveInner(pos, -1) 237 | let maxPrefix = prefixes.reduce((m, p) => Math.max(m, p.length), 0) 238 | for (let i = 0; i < 5; i++) { 239 | let start = state.sliceDoc(node.from, Math.min(node.to, node.from + quoteToken.length + maxPrefix)) 240 | let quotePos = start.indexOf(quoteToken) 241 | if (!quotePos || quotePos > -1 && prefixes.indexOf(start.slice(0, quotePos)) > -1) { 242 | let first = node.firstChild 243 | while (first && first.from == node.from && first.to - first.from > quoteToken.length + quotePos) { 244 | if (state.sliceDoc(first.to - quoteToken.length, first.to) == quoteToken) return false 245 | first = first.firstChild 246 | } 247 | return true 248 | } 249 | let parent = node.to == pos && node.parent 250 | if (!parent) break 251 | node = parent 252 | } 253 | return false 254 | } 255 | 256 | function canStartStringAt(state: EditorState, pos: number, prefixes: readonly string[]) { 257 | let charCat = state.charCategorizer(pos) 258 | if (charCat(state.sliceDoc(pos - 1, pos)) != CharCategory.Word) return pos 259 | for (let prefix of prefixes) { 260 | let start = pos - prefix.length 261 | if (state.sliceDoc(start, pos) == prefix && charCat(state.sliceDoc(start - 1, start)) != CharCategory.Word) 262 | return start 263 | } 264 | return -1 265 | } 266 | -------------------------------------------------------------------------------- /src/completion.ts: -------------------------------------------------------------------------------- 1 | import {EditorView} from "@codemirror/view" 2 | import {EditorState, StateEffect, Annotation, EditorSelection, TransactionSpec, ChangeDesc} from "@codemirror/state" 3 | import {syntaxTree} from "@codemirror/language" 4 | import {SyntaxNode} from "@lezer/common" 5 | 6 | /// Objects type used to represent individual completions. 7 | export interface Completion { 8 | /// The label to show in the completion picker. This is what input 9 | /// is matched against to determine whether a completion matches (and 10 | /// how well it matches). 11 | label: string 12 | /// An optional override for the completion's visible label. When 13 | /// using this, matched characters will only be highlighted if you 14 | /// provide a [`getMatch`](#autocomplete.CompletionResult.getMatch) 15 | /// function. 16 | displayLabel?: string 17 | /// An optional short piece of information to show (with a different 18 | /// style) after the label. 19 | detail?: string 20 | /// Additional info to show when the completion is selected. Can be 21 | /// a plain string or a function that'll render the DOM structure to 22 | /// show when invoked. 23 | info?: string | ((completion: Completion) => CompletionInfo | Promise) 24 | /// How to apply the completion. The default is to replace it with 25 | /// its [label](#autocomplete.Completion.label). When this holds a 26 | /// string, the completion range is replaced by that string. When it 27 | /// is a function, that function is called to perform the 28 | /// completion. If it fires a transaction, it is responsible for 29 | /// adding the [`pickedCompletion`](#autocomplete.pickedCompletion) 30 | /// annotation to it. 31 | apply?: string | ((view: EditorView, completion: Completion, from: number, to: number) => void) 32 | /// The type of the completion. This is used to pick an icon to show 33 | /// for the completion. Icons are styled with a CSS class created by 34 | /// appending the type name to `"cm-completionIcon-"`. You can 35 | /// define or restyle icons by defining these selectors. The base 36 | /// library defines simple icons for `class`, `constant`, `enum`, 37 | /// `function`, `interface`, `keyword`, `method`, `namespace`, 38 | /// `property`, `text`, `type`, and `variable`. 39 | /// 40 | /// Multiple types can be provided by separating them with spaces. 41 | type?: string 42 | /// When this option is selected, and one of these characters is 43 | /// typed, insert the completion before typing the character. 44 | commitCharacters?: readonly string[], 45 | /// When given, should be a number from -99 to 99 that adjusts how 46 | /// this completion is ranked compared to other completions that 47 | /// match the input as well as this one. A negative number moves it 48 | /// down the list, a positive number moves it up. 49 | boost?: number 50 | /// Can be used to divide the completion list into sections. 51 | /// Completions in a given section (matched by name) will be grouped 52 | /// together, with a heading above them. Options without section 53 | /// will appear above all sections. A string value is equivalent to 54 | /// a `{name}` object. 55 | section?: string | CompletionSection 56 | } 57 | 58 | /// The type returned from 59 | /// [`Completion.info`](#autocomplete.Completion.info). May be a DOM 60 | /// node, null to indicate there is no info, or an object with an 61 | /// optional `destroy` method that cleans up the node. 62 | export type CompletionInfo = Node | null | {dom: Node, destroy?(): void} 63 | 64 | /// Object used to describe a completion 65 | /// [section](#autocomplete.Completion.section). It is recommended to 66 | /// create a shared object used by all the completions in a given 67 | /// section. 68 | export interface CompletionSection { 69 | /// The name of the section. If no `render` method is present, this 70 | /// will be displayed above the options. 71 | name: string 72 | /// An optional function that renders the section header. Since the 73 | /// headers are shown inside a list, you should make sure the 74 | /// resulting element has a `display: list-item` style. 75 | header?: (section: CompletionSection) => HTMLElement 76 | /// By default, sections are ordered alphabetically by name. To 77 | /// specify an explicit order, `rank` can be used. Sections with a 78 | /// lower rank will be shown above sections with a higher rank. 79 | rank?: number 80 | } 81 | 82 | /// An instance of this is passed to completion source functions. 83 | export class CompletionContext { 84 | /// @internal 85 | abortListeners: (() => void)[] | null = [] 86 | /// @internal 87 | abortOnDocChange = false 88 | 89 | /// Create a new completion context. (Mostly useful for testing 90 | /// completion sources—in the editor, the extension will create 91 | /// these for you.) 92 | constructor( 93 | /// The editor state that the completion happens in. 94 | readonly state: EditorState, 95 | /// The position at which the completion is happening. 96 | readonly pos: number, 97 | /// Indicates whether completion was activated explicitly, or 98 | /// implicitly by typing. The usual way to respond to this is to 99 | /// only return completions when either there is part of a 100 | /// completable entity before the cursor, or `explicit` is true. 101 | readonly explicit: boolean, 102 | /// The editor view. May be undefined if the context was created 103 | /// in a situation where there is no such view available, such as 104 | /// in synchronous updates via 105 | /// [`CompletionResult.update`](#autocomplete.CompletionResult.update) 106 | /// or when called by test code. 107 | readonly view?: EditorView 108 | ) {} 109 | 110 | /// Get the extent, content, and (if there is a token) type of the 111 | /// token before `this.pos`. 112 | tokenBefore(types: readonly string[]) { 113 | let token: SyntaxNode | null = syntaxTree(this.state).resolveInner(this.pos, -1) 114 | while (token && types.indexOf(token.name) < 0) token = token.parent 115 | return token ? {from: token.from, to: this.pos, 116 | text: this.state.sliceDoc(token.from, this.pos), 117 | type: token.type} : null 118 | } 119 | 120 | /// Get the match of the given expression directly before the 121 | /// cursor. 122 | matchBefore(expr: RegExp) { 123 | let line = this.state.doc.lineAt(this.pos) 124 | let start = Math.max(line.from, this.pos - 250) 125 | let str = line.text.slice(start - line.from, this.pos - line.from) 126 | let found = str.search(ensureAnchor(expr, false)) 127 | return found < 0 ? null : {from: start + found, to: this.pos, text: str.slice(found)} 128 | } 129 | 130 | /// Yields true when the query has been aborted. Can be useful in 131 | /// asynchronous queries to avoid doing work that will be ignored. 132 | get aborted() { return this.abortListeners == null } 133 | 134 | /// Allows you to register abort handlers, which will be called when 135 | /// the query is 136 | /// [aborted](#autocomplete.CompletionContext.aborted). 137 | /// 138 | /// By default, running queries will not be aborted for regular 139 | /// typing or backspacing, on the assumption that they are likely to 140 | /// return a result with a 141 | /// [`validFor`](#autocomplete.CompletionResult.validFor) field that 142 | /// allows the result to be used after all. Passing `onDocChange: 143 | /// true` will cause this query to be aborted for any document 144 | /// change. 145 | addEventListener(type: "abort", listener: () => void, options?: {onDocChange: boolean}) { 146 | if (type == "abort" && this.abortListeners) { 147 | this.abortListeners.push(listener) 148 | if (options && options.onDocChange) this.abortOnDocChange = true 149 | } 150 | } 151 | } 152 | 153 | function toSet(chars: {[ch: string]: true}) { 154 | let flat = Object.keys(chars).join("") 155 | let words = /\w/.test(flat) 156 | if (words) flat = flat.replace(/\w/g, "") 157 | return `[${words ? "\\w" : ""}${flat.replace(/[^\w\s]/g, "\\$&")}]` 158 | } 159 | 160 | function prefixMatch(options: readonly Completion[]) { 161 | let first = Object.create(null), rest = Object.create(null) 162 | for (let {label} of options) { 163 | first[label[0]] = true 164 | for (let i = 1; i < label.length; i++) rest[label[i]] = true 165 | } 166 | let source = toSet(first) + toSet(rest) + "*$" 167 | return [new RegExp("^" + source), new RegExp(source)] 168 | } 169 | 170 | /// Given a a fixed array of options, return an autocompleter that 171 | /// completes them. 172 | export function completeFromList(list: readonly (string | Completion)[]): CompletionSource { 173 | let options = list.map(o => typeof o == "string" ? {label: o} : o) as Completion[] 174 | let [validFor, match] = options.every(o => /^\w+$/.test(o.label)) ? [/\w*$/, /\w+$/] : prefixMatch(options) 175 | return (context: CompletionContext) => { 176 | let token = context.matchBefore(match) 177 | return token || context.explicit ? {from: token ? token.from : context.pos, options, validFor} : null 178 | } 179 | } 180 | 181 | /// Wrap the given completion source so that it will only fire when the 182 | /// cursor is in a syntax node with one of the given names. 183 | export function ifIn(nodes: readonly string[], source: CompletionSource): CompletionSource { 184 | return (context: CompletionContext) => { 185 | for (let pos: SyntaxNode | null = syntaxTree(context.state).resolveInner(context.pos, -1); pos; pos = pos.parent) { 186 | if (nodes.indexOf(pos.name) > -1) return source(context) 187 | if (pos.type.isTop) break 188 | } 189 | return null 190 | } 191 | } 192 | 193 | /// Wrap the given completion source so that it will not fire when the 194 | /// cursor is in a syntax node with one of the given names. 195 | export function ifNotIn(nodes: readonly string[], source: CompletionSource): CompletionSource { 196 | return (context: CompletionContext) => { 197 | for (let pos: SyntaxNode | null = syntaxTree(context.state).resolveInner(context.pos, -1); pos; pos = pos.parent) { 198 | if (nodes.indexOf(pos.name) > -1) return null 199 | if (pos.type.isTop) break 200 | } 201 | return source(context) 202 | } 203 | } 204 | 205 | /// The function signature for a completion source. Such a function 206 | /// may return its [result](#autocomplete.CompletionResult) 207 | /// synchronously or as a promise. Returning null indicates no 208 | /// completions are available. 209 | export type CompletionSource = 210 | (context: CompletionContext) => CompletionResult | null | Promise 211 | 212 | /// Interface for objects returned by completion sources. 213 | export interface CompletionResult { 214 | /// The start of the range that is being completed. 215 | from: number 216 | /// The end of the range that is being completed. Defaults to the 217 | /// main cursor position. 218 | to?: number 219 | /// The completions returned. These don't have to be compared with 220 | /// the input by the source—the autocompletion system will do its 221 | /// own matching (against the text between `from` and `to`) and 222 | /// sorting. 223 | options: readonly Completion[] 224 | /// When given, further typing or deletion that causes the part of 225 | /// the document between ([mapped](#state.ChangeDesc.mapPos)) `from` 226 | /// and `to` to match this regular expression or predicate function 227 | /// will not query the completion source again, but continue with 228 | /// this list of options. This can help a lot with responsiveness, 229 | /// since it allows the completion list to be updated synchronously. 230 | validFor?: RegExp | ((text: string, from: number, to: number, state: EditorState) => boolean) 231 | /// By default, the library filters and scores completions. Set 232 | /// `filter` to `false` to disable this, and cause your completions 233 | /// to all be included, in the order they were given. When there are 234 | /// other sources, unfiltered completions appear at the top of the 235 | /// list of completions. `validFor` must not be given when `filter` 236 | /// is `false`, because it only works when filtering. 237 | filter?: boolean 238 | /// When [`filter`](#autocomplete.CompletionResult.filter) is set to 239 | /// `false` or a completion has a 240 | /// [`displayLabel`](#autocomplete.Completion.displayLabel), this 241 | /// may be provided to compute the ranges on the label that match 242 | /// the input. Should return an array of numbers where each pair of 243 | /// adjacent numbers provide the start and end of a range. The 244 | /// second argument, the match found by the library, is only passed 245 | /// when `filter` isn't `false`. 246 | getMatch?: (completion: Completion, matched?: readonly number[]) => readonly number[] 247 | /// Synchronously update the completion result after typing or 248 | /// deletion. If given, this should not do any expensive work, since 249 | /// it will be called during editor state updates. The function 250 | /// should make sure (similar to 251 | /// [`validFor`](#autocomplete.CompletionResult.validFor)) that the 252 | /// completion still applies in the new state. 253 | update?: (current: CompletionResult, from: number, to: number, context: CompletionContext) => CompletionResult | null 254 | /// When results contain position-dependent information in, for 255 | /// example, `apply` methods, you can provide this method to update 256 | /// the result for transactions that happen after the query. It is 257 | /// not necessary to update `from` and `to`—those are tracked 258 | /// automatically. 259 | map?: (current: CompletionResult, changes: ChangeDesc) => CompletionResult | null 260 | /// Set a default set of [commit 261 | /// characters](#autocomplete.Completion.commitCharacters) for all 262 | /// options in this result. 263 | commitCharacters?: readonly string[] 264 | } 265 | 266 | export class Option { 267 | constructor(readonly completion: Completion, 268 | readonly source: CompletionSource, 269 | readonly match: readonly number[], 270 | public score: number) {} 271 | } 272 | 273 | export function cur(state: EditorState) { return state.selection.main.from } 274 | 275 | // Make sure the given regexp has a $ at its end and, if `start` is 276 | // true, a ^ at its start. 277 | export function ensureAnchor(expr: RegExp, start: boolean) { 278 | let {source} = expr 279 | let addStart = start && source[0] != "^", addEnd = source[source.length - 1] != "$" 280 | if (!addStart && !addEnd) return expr 281 | return new RegExp(`${addStart ? "^" : ""}(?:${source})${addEnd ? "$" : ""}`, 282 | expr.flags ?? (expr.ignoreCase ? "i" : "")) 283 | } 284 | 285 | /// This annotation is added to transactions that are produced by 286 | /// picking a completion. 287 | export const pickedCompletion = Annotation.define() 288 | 289 | /// Helper function that returns a transaction spec which inserts a 290 | /// completion's text in the main selection range, and any other 291 | /// selection range that has the same text in front of it. 292 | export function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec { 293 | let {main} = state.selection, fromOff = from - main.from, toOff = to - main.from 294 | return { 295 | ...state.changeByRange(range => { 296 | if (range != main && from != to && 297 | state.sliceDoc(range.from + fromOff, range.from + toOff) != state.sliceDoc(from, to)) 298 | return {range} 299 | let lines = state.toText(text) 300 | return { 301 | changes: {from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines}, 302 | range: EditorSelection.cursor(range.from + fromOff + lines.length) 303 | } 304 | }), 305 | scrollIntoView: true, 306 | userEvent: "input.complete" 307 | } 308 | } 309 | 310 | const SourceCache = new WeakMap() 311 | 312 | export function asSource(source: CompletionSource | readonly (string | Completion)[]): CompletionSource { 313 | if (!Array.isArray(source)) return source as CompletionSource 314 | let known = SourceCache.get(source) 315 | if (!known) SourceCache.set(source, known = completeFromList(source)) 316 | return known 317 | } 318 | 319 | export const startCompletionEffect = StateEffect.define() 320 | export const closeCompletionEffect = StateEffect.define() 321 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import {Completion, CompletionSource} from "./completion" 2 | import {Info} from "./theme" 3 | import {Facet, combineConfig, EditorState} from "@codemirror/state" 4 | import {EditorView, Rect, Direction} from "@codemirror/view" 5 | 6 | export interface CompletionConfig { 7 | /// When enabled (defaults to true), autocompletion will start 8 | /// whenever the user types something that can be completed. 9 | activateOnTyping?: boolean 10 | /// When given, if a completion that matches the predicate is 11 | /// picked, reactivate completion again as if it was typed normally. 12 | activateOnCompletion?: (completion: Completion) => boolean 13 | /// The amount of time to wait for further typing before querying 14 | /// completion sources via 15 | /// [`activateOnTyping`](#autocomplete.autocompletion^config.activateOnTyping). 16 | /// Defaults to 100, which should be fine unless your completion 17 | /// source is very slow and/or doesn't use `validFor`. 18 | activateOnTypingDelay?: number 19 | /// By default, when completion opens, the first option is selected 20 | /// and can be confirmed with 21 | /// [`acceptCompletion`](#autocomplete.acceptCompletion). When this 22 | /// is set to false, the completion widget starts with no completion 23 | /// selected, and the user has to explicitly move to a completion 24 | /// before you can confirm one. 25 | selectOnOpen?: boolean 26 | /// Override the completion sources used. By default, they will be 27 | /// taken from the `"autocomplete"` [language 28 | /// data](#state.EditorState.languageDataAt) (which should hold 29 | /// [completion sources](#autocomplete.CompletionSource) or arrays 30 | /// of [completions](#autocomplete.Completion)). 31 | override?: readonly CompletionSource[] | null, 32 | /// Determines whether the completion tooltip is closed when the 33 | /// editor loses focus. Defaults to true. 34 | closeOnBlur?: boolean, 35 | /// The maximum number of options to render to the DOM. 36 | maxRenderedOptions?: number, 37 | /// Set this to false to disable the [default completion 38 | /// keymap](#autocomplete.completionKeymap). (This requires you to 39 | /// add bindings to control completion yourself. The bindings should 40 | /// probably have a higher precedence than other bindings for the 41 | /// same keys.) 42 | defaultKeymap?: boolean, 43 | /// By default, completions are shown below the cursor when there is 44 | /// space. Setting this to true will make the extension put the 45 | /// completions above the cursor when possible. 46 | aboveCursor?: boolean, 47 | /// When given, this may return an additional CSS class to add to 48 | /// the completion dialog element. 49 | tooltipClass?: (state: EditorState) => string, 50 | /// This can be used to add additional CSS classes to completion 51 | /// options. 52 | optionClass?: (completion: Completion) => string, 53 | /// By default, the library will render icons based on the 54 | /// completion's [type](#autocomplete.Completion.type) in front of 55 | /// each option. Set this to false to turn that off. 56 | icons?: boolean, 57 | /// This option can be used to inject additional content into 58 | /// options. The `render` function will be called for each visible 59 | /// completion, and should produce a DOM node to show. `position` 60 | /// determines where in the DOM the result appears, relative to 61 | /// other added widgets and the standard content. The default icons 62 | /// have position 20, the label position 50, and the detail position 63 | /// 80. 64 | addToOptions?: {render: (completion: Completion, state: EditorState, view: EditorView) => Node | null, 65 | position: number}[] 66 | /// By default, [info](#autocomplete.Completion.info) tooltips are 67 | /// placed to the side of the selected completion. This option can 68 | /// be used to override that. It will be given rectangles for the 69 | /// list of completions, the selected option, the info element, and 70 | /// the availble [tooltip 71 | /// space](#view.tooltips^config.tooltipSpace), and should return 72 | /// style and/or class strings for the info element. 73 | positionInfo?: (view: EditorView, list: Rect, option: Rect, info: Rect, space: Rect) => {style?: string, class?: string} 74 | /// The comparison function to use when sorting completions with the same 75 | /// match score. Defaults to using 76 | /// [`localeCompare`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare). 77 | compareCompletions?: (a: Completion, b: Completion) => number 78 | /// When set to true (the default is false), turn off fuzzy matching 79 | /// of completions and only show those that start with the text the 80 | /// user typed. Only takes effect for results where 81 | /// [`filter`](#autocomplete.CompletionResult.filter) isn't false. 82 | filterStrict?: boolean 83 | /// By default, commands relating to an open completion only take 84 | /// effect 75 milliseconds after the completion opened, so that key 85 | /// presses made before the user is aware of the tooltip don't go to 86 | /// the tooltip. This option can be used to configure that delay. 87 | interactionDelay?: number 88 | /// When there are multiple asynchronous completion sources, this 89 | /// controls how long the extension waits for a slow source before 90 | /// displaying results from faster sources. Defaults to 100 91 | /// milliseconds. 92 | updateSyncTime?: number 93 | } 94 | 95 | export const completionConfig = Facet.define>({ 96 | combine(configs) { 97 | return combineConfig>(configs, { 98 | activateOnTyping: true, 99 | activateOnCompletion: () => false, 100 | activateOnTypingDelay: 100, 101 | selectOnOpen: true, 102 | override: null, 103 | closeOnBlur: true, 104 | maxRenderedOptions: 100, 105 | defaultKeymap: true, 106 | tooltipClass: () => "", 107 | optionClass: () => "", 108 | aboveCursor: false, 109 | icons: true, 110 | addToOptions: [], 111 | positionInfo: defaultPositionInfo as any, 112 | filterStrict: false, 113 | compareCompletions: (a, b) => a.label.localeCompare(b.label), 114 | interactionDelay: 75, 115 | updateSyncTime: 100 116 | }, { 117 | defaultKeymap: (a, b) => a && b, 118 | closeOnBlur: (a, b) => a && b, 119 | icons: (a, b) => a && b, 120 | tooltipClass: (a, b) => c => joinClass(a(c), b(c)), 121 | optionClass: (a, b) => c => joinClass(a(c), b(c)), 122 | addToOptions: (a, b) => a.concat(b), 123 | filterStrict: (a, b) => a || b, 124 | }) 125 | } 126 | }) 127 | 128 | function joinClass(a: string, b: string) { 129 | return a ? b ? a + " " + b : a : b 130 | } 131 | 132 | function defaultPositionInfo(view: EditorView, list: Rect, option: Rect, info: Rect, space: Rect, tooltip: HTMLElement) { 133 | let rtl = view.textDirection == Direction.RTL, left = rtl, narrow = false 134 | let side = "top", offset, maxWidth 135 | let spaceLeft = list.left - space.left, spaceRight = space.right - list.right 136 | let infoWidth = info.right - info.left, infoHeight = info.bottom - info.top 137 | if (left && spaceLeft < Math.min(infoWidth, spaceRight)) left = false 138 | else if (!left && spaceRight < Math.min(infoWidth, spaceLeft)) left = true 139 | if (infoWidth <= (left ? spaceLeft : spaceRight)) { 140 | offset = Math.max(space.top, Math.min(option.top, space.bottom - infoHeight)) - list.top 141 | maxWidth = Math.min(Info.Width, left ? spaceLeft : spaceRight) 142 | } else { 143 | narrow = true 144 | maxWidth = Math.min(Info.Width, (rtl ? list.right : space.right - list.left) - Info.Margin) 145 | let spaceBelow = space.bottom - list.bottom 146 | if (spaceBelow >= infoHeight || spaceBelow > list.top) { // Below the completion 147 | offset = option.bottom - list.top 148 | } else { // Above it 149 | side = "bottom" 150 | offset = list.bottom - option.top 151 | } 152 | } 153 | let scaleY = (list.bottom - list.top) / tooltip.offsetHeight 154 | let scaleX = (list.right - list.left) / tooltip.offsetWidth 155 | return { 156 | style: `${side}: ${offset / scaleY}px; max-width: ${maxWidth / scaleX}px`, 157 | class: "cm-completionInfo-" + (narrow ? (rtl ? "left-narrow" : "right-narrow") : left ? "left" : "right") 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/filter.ts: -------------------------------------------------------------------------------- 1 | import {codePointAt, codePointSize, fromCodePoint} from "@codemirror/state" 2 | 3 | // Scores are counted from 0 (great match) down to negative numbers, 4 | // assigning specific penalty values for specific shortcomings. 5 | const enum Penalty { 6 | Gap = -1100, // Added for each gap in the match (not counted for by-word matches) 7 | NotStart = -700, // The match doesn't start at the start of the word 8 | CaseFold = -200, // At least one character needed to be case-folded to match 9 | ByWord = -100, // The match is by-word, meaning each char in the pattern matches the start of a word in the string 10 | NotFull = -100, // Used to push down matches that don't match the pattern fully relative to those that do 11 | } 12 | 13 | const enum Tp { NonWord, Upper, Lower } 14 | 15 | // A pattern matcher for fuzzy completion matching. Create an instance 16 | // once for a pattern, and then use that to match any number of 17 | // completions. 18 | export class FuzzyMatcher { 19 | chars: number[] = [] 20 | folded: number[] = [] 21 | astral: boolean 22 | 23 | // Buffers reused by calls to `match` to track matched character 24 | // positions. 25 | any: number[] = [] 26 | precise: number[] = [] 27 | byWord: number[] = [] 28 | 29 | score = 0 30 | matched: readonly number[] = [] 31 | 32 | constructor(readonly pattern: string) { 33 | for (let p = 0; p < pattern.length;) { 34 | let char = codePointAt(pattern, p), size = codePointSize(char) 35 | this.chars.push(char) 36 | let part = pattern.slice(p, p + size), upper = part.toUpperCase() 37 | this.folded.push(codePointAt(upper == part ? part.toLowerCase() : upper, 0)) 38 | p += size 39 | } 40 | this.astral = pattern.length != this.chars.length 41 | } 42 | 43 | ret(score: number, matched: readonly number[]) { 44 | this.score = score 45 | this.matched = matched 46 | return this 47 | } 48 | 49 | // Matches a given word (completion) against the pattern (input). 50 | // Will return a boolean indicating whether there was a match and, 51 | // on success, set `this.score` to the score, `this.matched` to an 52 | // array of `from, to` pairs indicating the matched parts of `word`. 53 | // 54 | // The score is a number that is more negative the worse the match 55 | // is. See `Penalty` above. 56 | match(word: string): {score: number, matched: readonly number[]} | null { 57 | if (this.pattern.length == 0) return this.ret(Penalty.NotFull, []) 58 | if (word.length < this.pattern.length) return null 59 | let {chars, folded, any, precise, byWord} = this 60 | // For single-character queries, only match when they occur right 61 | // at the start 62 | if (chars.length == 1) { 63 | let first = codePointAt(word, 0), firstSize = codePointSize(first) 64 | let score = firstSize == word.length ? 0 : Penalty.NotFull 65 | if (first == chars[0]) {} 66 | else if (first == folded[0]) score += Penalty.CaseFold 67 | else return null 68 | return this.ret(score, [0, firstSize]) 69 | } 70 | let direct = word.indexOf(this.pattern) 71 | if (direct == 0) return this.ret(word.length == this.pattern.length ? 0 : Penalty.NotFull, [0, this.pattern.length]) 72 | 73 | let len = chars.length, anyTo = 0 74 | if (direct < 0) { 75 | for (let i = 0, e = Math.min(word.length, 200); i < e && anyTo < len;) { 76 | let next = codePointAt(word, i) 77 | if (next == chars[anyTo] || next == folded[anyTo]) any[anyTo++] = i 78 | i += codePointSize(next) 79 | } 80 | // No match, exit immediately 81 | if (anyTo < len) return null 82 | } 83 | 84 | // This tracks the extent of the precise (non-folded, not 85 | // necessarily adjacent) match 86 | let preciseTo = 0 87 | // Tracks whether there is a match that hits only characters that 88 | // appear to be starting words. `byWordFolded` is set to true when 89 | // a case folded character is encountered in such a match 90 | let byWordTo = 0, byWordFolded = false 91 | // If we've found a partial adjacent match, these track its state 92 | let adjacentTo = 0, adjacentStart = -1, adjacentEnd = -1 93 | let hasLower = /[a-z]/.test(word), wordAdjacent = true 94 | // Go over the option's text, scanning for the various kinds of matches 95 | for (let i = 0, e = Math.min(word.length, 200), prevType = Tp.NonWord; i < e && byWordTo < len;) { 96 | let next = codePointAt(word, i) 97 | if (direct < 0) { 98 | if (preciseTo < len && next == chars[preciseTo]) 99 | precise[preciseTo++] = i 100 | if (adjacentTo < len) { 101 | if (next == chars[adjacentTo] || next == folded[adjacentTo]) { 102 | if (adjacentTo == 0) adjacentStart = i 103 | adjacentEnd = i + 1 104 | adjacentTo++ 105 | } else { 106 | adjacentTo = 0 107 | } 108 | } 109 | } 110 | let ch, type = next < 0xff 111 | ? (next >= 48 && next <= 57 || next >= 97 && next <= 122 ? Tp.Lower : next >= 65 && next <= 90 ? Tp.Upper : Tp.NonWord) 112 | : ((ch = fromCodePoint(next)) != ch.toLowerCase() ? Tp.Upper : ch != ch.toUpperCase() ? Tp.Lower : Tp.NonWord) 113 | if (!i || type == Tp.Upper && hasLower || prevType == Tp.NonWord && type != Tp.NonWord) { 114 | if (chars[byWordTo] == next || (folded[byWordTo] == next && (byWordFolded = true))) byWord[byWordTo++] = i 115 | else if (byWord.length) wordAdjacent = false 116 | } 117 | prevType = type 118 | i += codePointSize(next) 119 | } 120 | 121 | if (byWordTo == len && byWord[0] == 0 && wordAdjacent) 122 | return this.result(Penalty.ByWord + (byWordFolded ? Penalty.CaseFold : 0), byWord, word) 123 | if (adjacentTo == len && adjacentStart == 0) 124 | return this.ret(Penalty.CaseFold - word.length + (adjacentEnd == word.length ? 0 : Penalty.NotFull), [0, adjacentEnd]) 125 | if (direct > -1) 126 | return this.ret(Penalty.NotStart - word.length, [direct, direct + this.pattern.length]) 127 | if (adjacentTo == len) 128 | return this.ret(Penalty.CaseFold + Penalty.NotStart - word.length, [adjacentStart, adjacentEnd]) 129 | if (byWordTo == len) 130 | return this.result(Penalty.ByWord + (byWordFolded ? Penalty.CaseFold : 0) + Penalty.NotStart + 131 | (wordAdjacent ? 0 : Penalty.Gap), byWord, word) 132 | return chars.length == 2 ? null 133 | : this.result((any[0] ? Penalty.NotStart : 0) + Penalty.CaseFold + Penalty.Gap, any, word) 134 | } 135 | 136 | result(score: number, positions: number[], word: string) { 137 | let result: number[] = [], i = 0 138 | for (let pos of positions) { 139 | let to = pos + (this.astral ? codePointSize(codePointAt(word, pos)) : 1) 140 | if (i && result[i - 1] == pos) result[i - 1] = to 141 | else { result[i++] = pos; result[i++] = to } 142 | } 143 | return this.ret(score - word.length, result) 144 | } 145 | } 146 | 147 | 148 | export class StrictMatcher { 149 | matched: readonly number[] = [] 150 | score: number = 0 151 | folded: string 152 | 153 | constructor(readonly pattern: string) { 154 | this.folded = pattern.toLowerCase() 155 | } 156 | 157 | match(word: string): {score: number, matched: readonly number[]} | null { 158 | if (word.length < this.pattern.length) return null 159 | let start = word.slice(0, this.pattern.length) 160 | let match = start == this.pattern ? 0 : start.toLowerCase() == this.folded ? Penalty.CaseFold : null 161 | if (match == null) return null 162 | this.matched = [0, start.length] 163 | this.score = match + (word.length == this.pattern.length ? 0 : Penalty.NotFull) 164 | return this 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Prec, Extension, EditorState, StateEffect} from "@codemirror/state" 2 | import {keymap, KeyBinding} from "@codemirror/view" 3 | import {Completion, Option} from "./completion" 4 | import {completionState, State, setSelectedEffect} from "./state" 5 | import {CompletionConfig, completionConfig} from "./config" 6 | import {completionPlugin, moveCompletionSelection, acceptCompletion, 7 | startCompletion, closeCompletion, commitCharacters} from "./view" 8 | import {baseTheme} from "./theme" 9 | 10 | export {snippet, snippetCompletion, nextSnippetField, prevSnippetField, 11 | hasNextSnippetField, hasPrevSnippetField, clearSnippet, snippetKeymap} from "./snippet" 12 | export {Completion, CompletionInfo, CompletionSection, CompletionContext, CompletionSource, CompletionResult, 13 | pickedCompletion, completeFromList, ifIn, ifNotIn, insertCompletionText} from "./completion" 14 | export {startCompletion, closeCompletion, acceptCompletion, moveCompletionSelection} from "./view" 15 | export {completeAnyWord} from "./word" 16 | export {CloseBracketConfig, closeBrackets, closeBracketsKeymap, deleteBracketPair, insertBracket} from "./closebrackets" 17 | 18 | /// Returns an extension that enables autocompletion. 19 | export function autocompletion(config: CompletionConfig = {}): Extension { 20 | return [ 21 | commitCharacters, 22 | completionState, 23 | completionConfig.of(config), 24 | completionPlugin, 25 | completionKeymapExt, 26 | baseTheme 27 | ] 28 | } 29 | 30 | /// Basic keybindings for autocompletion. 31 | /// 32 | /// - Ctrl-Space (and Alt-\` or Alt-i on macOS): [`startCompletion`](#autocomplete.startCompletion) 33 | /// - Escape: [`closeCompletion`](#autocomplete.closeCompletion) 34 | /// - ArrowDown: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(true)` 35 | /// - ArrowUp: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(false)` 36 | /// - PageDown: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(true, "page")` 37 | /// - PageUp: [`moveCompletionSelection`](#autocomplete.moveCompletionSelection)`(false, "page")` 38 | /// - Enter: [`acceptCompletion`](#autocomplete.acceptCompletion) 39 | export const completionKeymap: readonly KeyBinding[] = [ 40 | {key: "Ctrl-Space", run: startCompletion}, 41 | {mac: "Alt-`", run: startCompletion}, 42 | {mac: "Alt-i", run: startCompletion}, 43 | {key: "Escape", run: closeCompletion}, 44 | {key: "ArrowDown", run: moveCompletionSelection(true)}, 45 | {key: "ArrowUp", run: moveCompletionSelection(false)}, 46 | {key: "PageDown", run: moveCompletionSelection(true, "page")}, 47 | {key: "PageUp", run: moveCompletionSelection(false, "page")}, 48 | {key: "Enter", run: acceptCompletion} 49 | ] 50 | 51 | const completionKeymapExt = Prec.highest(keymap.computeN([completionConfig], state => 52 | state.facet(completionConfig).defaultKeymap ? [completionKeymap] : [])) 53 | 54 | /// Get the current completion status. When completions are available, 55 | /// this will return `"active"`. When completions are pending (in the 56 | /// process of being queried), this returns `"pending"`. Otherwise, it 57 | /// returns `null`. 58 | export function completionStatus(state: EditorState): null | "active" | "pending" { 59 | let cState = state.field(completionState, false) 60 | return cState && cState.active.some(a => a.isPending) ? "pending" 61 | : cState && cState.active.some(a => a.state != State.Inactive) ? "active" : null 62 | } 63 | 64 | const completionArrayCache: WeakMap = new WeakMap 65 | 66 | /// Returns the available completions as an array. 67 | export function currentCompletions(state: EditorState): readonly Completion[] { 68 | let open = state.field(completionState, false)?.open 69 | if (!open || open.disabled) return [] 70 | let completions = completionArrayCache.get(open.options) 71 | if (!completions) 72 | completionArrayCache.set(open.options, completions = open.options.map(o => o.completion)) 73 | return completions 74 | } 75 | 76 | /// Return the currently selected completion, if any. 77 | export function selectedCompletion(state: EditorState): Completion | null { 78 | let open = state.field(completionState, false)?.open 79 | return open && !open.disabled && open.selected >= 0 ? open.options[open.selected].completion : null 80 | } 81 | 82 | /// Returns the currently selected position in the active completion 83 | /// list, or null if no completions are active. 84 | export function selectedCompletionIndex(state: EditorState): number | null { 85 | let open = state.field(completionState, false)?.open 86 | return open && !open.disabled && open.selected >= 0 ? open.selected : null 87 | } 88 | 89 | /// Create an effect that can be attached to a transaction to change 90 | /// the currently selected completion. 91 | export function setSelectedCompletion(index: number): StateEffect { 92 | return setSelectedEffect.of(index) 93 | } 94 | -------------------------------------------------------------------------------- /src/snippet.ts: -------------------------------------------------------------------------------- 1 | import {Decoration, DecorationSet, WidgetType, EditorView, keymap, KeyBinding} from "@codemirror/view" 2 | import {StateField, StateEffect, ChangeDesc, EditorState, EditorSelection, 3 | Transaction, TransactionSpec, Text, StateCommand, Prec, Facet, MapMode} from "@codemirror/state" 4 | import {indentUnit} from "@codemirror/language" 5 | import {baseTheme} from "./theme" 6 | import {Completion, pickedCompletion} from "./completion" 7 | 8 | class FieldPos { 9 | constructor(public field: number, 10 | readonly line: number, 11 | public from: number, 12 | public to: number) {} 13 | } 14 | 15 | class FieldRange { 16 | constructor(readonly field: number, readonly from: number, readonly to: number) {} 17 | 18 | map(changes: ChangeDesc) { 19 | let from = changes.mapPos(this.from, -1, MapMode.TrackDel) 20 | let to = changes.mapPos(this.to, 1, MapMode.TrackDel) 21 | return from == null || to == null ? null : new FieldRange(this.field, from, to) 22 | } 23 | } 24 | 25 | class Snippet { 26 | constructor(readonly lines: readonly string[], 27 | readonly fieldPositions: readonly FieldPos[]) {} 28 | 29 | instantiate(state: EditorState, pos: number) { 30 | let text = [], lineStart = [pos] 31 | let lineObj = state.doc.lineAt(pos), baseIndent = /^\s*/.exec(lineObj.text)![0] 32 | for (let line of this.lines) { 33 | if (text.length) { 34 | let indent = baseIndent, tabs = /^\t*/.exec(line)![0].length 35 | for (let i = 0; i < tabs; i++) indent += state.facet(indentUnit) 36 | lineStart.push(pos + indent.length - tabs) 37 | line = indent + line.slice(tabs) 38 | } 39 | text.push(line) 40 | pos += line.length + 1 41 | } 42 | let ranges = this.fieldPositions.map( 43 | pos => new FieldRange(pos.field, lineStart[pos.line] + pos.from, lineStart[pos.line] + pos.to)) 44 | return {text, ranges} 45 | } 46 | 47 | static parse(template: string) { 48 | let fields: {seq: number | null, name: string}[] = [] 49 | let lines = [], positions: FieldPos[] = [], m 50 | for (let line of template.split(/\r\n?|\n/)) { 51 | while (m = /[#$]\{(?:(\d+)(?::([^}]*))?|((?:\\[{}]|[^}])*))\}/.exec(line)) { 52 | let seq = m[1] ? +m[1] : null, rawName = m[2] || m[3] || "", found = -1 53 | let name = rawName.replace(/\\[{}]/g, m => m[1]) 54 | for (let i = 0; i < fields.length; i++) { 55 | if (seq != null ? fields[i].seq == seq : name ? fields[i].name == name : false) found = i 56 | } 57 | if (found < 0) { 58 | let i = 0 59 | while (i < fields.length && (seq == null || (fields[i].seq != null && fields[i].seq! < seq))) i++ 60 | fields.splice(i, 0, {seq, name}) 61 | found = i 62 | for (let pos of positions) if (pos.field >= found) pos.field++ 63 | } 64 | positions.push(new FieldPos(found, lines.length, m.index, m.index + name.length)) 65 | line = line.slice(0, m.index) + rawName + line.slice(m.index + m[0].length) 66 | } 67 | line = line.replace(/\\([{}])/g, (_, brace, index) => { 68 | for (let pos of positions) if (pos.line == lines.length && pos.from > index) { 69 | pos.from-- 70 | pos.to-- 71 | } 72 | return brace 73 | }) 74 | lines.push(line) 75 | } 76 | return new Snippet(lines, positions) 77 | } 78 | } 79 | 80 | let fieldMarker = Decoration.widget({widget: new class extends WidgetType { 81 | toDOM() { 82 | let span = document.createElement("span") 83 | span.className = "cm-snippetFieldPosition" 84 | return span 85 | } 86 | ignoreEvent() { return false } 87 | }}) 88 | let fieldRange = Decoration.mark({class: "cm-snippetField"}) 89 | 90 | class ActiveSnippet { 91 | deco: DecorationSet 92 | 93 | constructor(readonly ranges: readonly FieldRange[], 94 | readonly active: number) { 95 | this.deco = Decoration.set(ranges.map(r => (r.from == r.to ? fieldMarker : fieldRange).range(r.from, r.to))) 96 | } 97 | 98 | map(changes: ChangeDesc) { 99 | let ranges = [] 100 | for (let r of this.ranges) { 101 | let mapped = r.map(changes) 102 | if (!mapped) return null 103 | ranges.push(mapped) 104 | } 105 | return new ActiveSnippet(ranges, this.active) 106 | } 107 | 108 | selectionInsideField(sel: EditorSelection) { 109 | return sel.ranges.every( 110 | range => this.ranges.some(r => r.field == this.active && r.from <= range.from && r.to >= range.to)) 111 | } 112 | } 113 | 114 | const setActive = StateEffect.define({ 115 | map(value, changes) { return value && value.map(changes) } 116 | }) 117 | 118 | const moveToField = StateEffect.define() 119 | 120 | const snippetState = StateField.define({ 121 | create() { return null }, 122 | 123 | update(value, tr) { 124 | for (let effect of tr.effects) { 125 | if (effect.is(setActive)) return effect.value 126 | if (effect.is(moveToField) && value) return new ActiveSnippet(value.ranges, effect.value) 127 | } 128 | if (value && tr.docChanged) value = value.map(tr.changes) 129 | if (value && tr.selection && !value.selectionInsideField(tr.selection)) value = null 130 | return value 131 | }, 132 | 133 | provide: f => EditorView.decorations.from(f, val => val ? val.deco : Decoration.none) 134 | }) 135 | 136 | function fieldSelection(ranges: readonly FieldRange[], field: number) { 137 | return EditorSelection.create(ranges.filter(r => r.field == field).map(r => EditorSelection.range(r.from, r.to))) 138 | } 139 | 140 | /// Convert a snippet template to a function that can 141 | /// [apply](#autocomplete.Completion.apply) it. Snippets are written 142 | /// using syntax like this: 143 | /// 144 | /// "for (let ${index} = 0; ${index} < ${end}; ${index}++) {\n\t${}\n}" 145 | /// 146 | /// Each `${}` placeholder (you may also use `#{}`) indicates a field 147 | /// that the user can fill in. Its name, if any, will be the default 148 | /// content for the field. 149 | /// 150 | /// When the snippet is activated by calling the returned function, 151 | /// the code is inserted at the given position. Newlines in the 152 | /// template are indented by the indentation of the start line, plus 153 | /// one [indent unit](#language.indentUnit) per tab character after 154 | /// the newline. 155 | /// 156 | /// On activation, (all instances of) the first field are selected. 157 | /// The user can move between fields with Tab and Shift-Tab as long as 158 | /// the fields are active. Moving to the last field or moving the 159 | /// cursor out of the current field deactivates the fields. 160 | /// 161 | /// The order of fields defaults to textual order, but you can add 162 | /// numbers to placeholders (`${1}` or `${1:defaultText}`) to provide 163 | /// a custom order. 164 | /// 165 | /// To include a literal `{` or `}` in your template, put a backslash 166 | /// in front of it. This will be removed and the brace will not be 167 | /// interpreted as indicating a placeholder. 168 | export function snippet(template: string) { 169 | let snippet = Snippet.parse(template) 170 | return (editor: {state: EditorState, dispatch: (tr: Transaction) => void}, completion: Completion | null, from: number, to: number) => { 171 | let {text, ranges} = snippet.instantiate(editor.state, from) 172 | let {main} = editor.state.selection 173 | let spec: TransactionSpec = { 174 | changes: {from, to: to == main.from ? main.to : to, insert: Text.of(text)}, 175 | scrollIntoView: true, 176 | annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined 177 | } 178 | if (ranges.length) spec.selection = fieldSelection(ranges, 0) 179 | if (ranges.some(r => r.field > 0)) { 180 | let active = new ActiveSnippet(ranges, 0) 181 | let effects: StateEffect[] = spec.effects = [setActive.of(active)] 182 | if (editor.state.field(snippetState, false) === undefined) 183 | effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme])) 184 | } 185 | editor.dispatch(editor.state.update(spec)) 186 | } 187 | } 188 | 189 | function moveField(dir: 1 | -1): StateCommand { 190 | return ({state, dispatch}) => { 191 | let active = state.field(snippetState, false) 192 | if (!active || dir < 0 && active.active == 0) return false 193 | let next = active.active + dir, last = dir > 0 && !active.ranges.some(r => r.field == next + dir) 194 | dispatch(state.update({ 195 | selection: fieldSelection(active.ranges, next), 196 | effects: setActive.of(last ? null : new ActiveSnippet(active.ranges, next)), 197 | scrollIntoView: true 198 | })) 199 | return true 200 | } 201 | } 202 | 203 | /// A command that clears the active snippet, if any. 204 | export const clearSnippet: StateCommand = ({state, dispatch}) => { 205 | let active = state.field(snippetState, false) 206 | if (!active) return false 207 | dispatch(state.update({effects: setActive.of(null)})) 208 | return true 209 | } 210 | 211 | /// Move to the next snippet field, if available. 212 | export const nextSnippetField = moveField(1) 213 | 214 | /// Move to the previous snippet field, if available. 215 | export const prevSnippetField = moveField(-1) 216 | 217 | /// Check if there is an active snippet with a next field for 218 | /// `nextSnippetField` to move to. 219 | export function hasNextSnippetField(state: EditorState) { 220 | let active = state.field(snippetState, false) 221 | return !!(active && active.ranges.some(r => r.field == active!.active + 1)) 222 | } 223 | 224 | /// Returns true if there is an active snippet and a previous field 225 | /// for `prevSnippetField` to move to. 226 | export function hasPrevSnippetField(state: EditorState) { 227 | let active = state.field(snippetState, false) 228 | return !!(active && active.active > 0) 229 | } 230 | 231 | const defaultSnippetKeymap = [ 232 | {key: "Tab", run: nextSnippetField, shift: prevSnippetField}, 233 | {key: "Escape", run: clearSnippet} 234 | ] 235 | 236 | /// A facet that can be used to configure the key bindings used by 237 | /// snippets. The default binds Tab to 238 | /// [`nextSnippetField`](#autocomplete.nextSnippetField), Shift-Tab to 239 | /// [`prevSnippetField`](#autocomplete.prevSnippetField), and Escape 240 | /// to [`clearSnippet`](#autocomplete.clearSnippet). 241 | export const snippetKeymap = Facet.define({ 242 | combine(maps) { return maps.length ? maps[0] : defaultSnippetKeymap } 243 | }) 244 | 245 | const addSnippetKeymap = Prec.highest(keymap.compute([snippetKeymap], state => state.facet(snippetKeymap))) 246 | 247 | /// Create a completion from a snippet. Returns an object with the 248 | /// properties from `completion`, plus an `apply` function that 249 | /// applies the snippet. 250 | export function snippetCompletion(template: string, completion: Completion): Completion { 251 | return {...completion, apply: snippet(template)} 252 | } 253 | 254 | const snippetPointerHandler = EditorView.domEventHandlers({ 255 | mousedown(event, view) { 256 | let active = view.state.field(snippetState, false), pos: number | null 257 | if (!active || (pos = view.posAtCoords({x: event.clientX, y: event.clientY})) == null) return false 258 | let match = active.ranges.find(r => r.from <= pos! && r.to >= pos!) 259 | if (!match || match.field == active.active) return false 260 | view.dispatch({ 261 | selection: fieldSelection(active.ranges, match.field), 262 | effects: setActive.of(active.ranges.some(r => r.field > match!.field) 263 | ? new ActiveSnippet(active.ranges, match.field) : null), 264 | scrollIntoView: true 265 | }) 266 | return true 267 | } 268 | }) 269 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, Tooltip, showTooltip} from "@codemirror/view" 2 | import {Transaction, StateField, StateEffect, EditorState, ChangeDesc} from "@codemirror/state" 3 | import {Option, CompletionSource, CompletionResult, cur, asSource, 4 | Completion, ensureAnchor, CompletionContext, CompletionSection, 5 | startCompletionEffect, closeCompletionEffect, 6 | insertCompletionText, pickedCompletion} from "./completion" 7 | import {FuzzyMatcher, StrictMatcher} from "./filter" 8 | import {completionTooltip} from "./tooltip" 9 | import {CompletionConfig, completionConfig} from "./config" 10 | 11 | // Used to pick a preferred option when two options with the same 12 | // label occur in the result. 13 | function score(option: Completion) { 14 | return (option.boost || 0) * 100 + (option.apply ? 10 : 0) + (option.info ? 5 : 0) + 15 | (option.type ? 1 : 0) 16 | } 17 | 18 | function sortOptions(active: readonly ActiveSource[], state: EditorState) { 19 | let options: Option[] = [] 20 | let sections: null | CompletionSection[] = null 21 | let addOption = (option: Option) => { 22 | options.push(option) 23 | let {section} = option.completion 24 | if (section) { 25 | if (!sections) sections = [] 26 | let name = typeof section == "string" ? section : section.name 27 | if (!sections.some(s => s.name == name)) sections.push(typeof section == "string" ? {name} : section) 28 | } 29 | } 30 | 31 | let conf = state.facet(completionConfig) 32 | for (let a of active) if (a.hasResult()) { 33 | let getMatch = a.result.getMatch 34 | if (a.result.filter === false) { 35 | for (let option of a.result.options) { 36 | addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length)) 37 | } 38 | } else { 39 | let pattern = state.sliceDoc(a.from, a.to), match 40 | let matcher = conf.filterStrict ? new StrictMatcher(pattern) : new FuzzyMatcher(pattern) 41 | for (let option of a.result.options) if (match = matcher.match(option.label)) { 42 | let matched = !option.displayLabel ? match.matched : getMatch ? getMatch(option, match.matched) : [] 43 | addOption(new Option(option, a.source, matched, match.score + (option.boost || 0))) 44 | } 45 | } 46 | } 47 | 48 | if (sections) { 49 | let sectionOrder: {[name: string]: number} = Object.create(null), pos = 0 50 | let cmp = (a: CompletionSection, b: CompletionSection) => (a.rank ?? 1e9) - (b.rank ?? 1e9) || (a.name < b.name ? -1 : 1) 51 | for (let s of (sections as CompletionSection[]).sort(cmp)) { 52 | pos -= 1e5 53 | sectionOrder[s.name] = pos 54 | } 55 | for (let option of options) { 56 | let {section} = option.completion 57 | if (section) option.score += sectionOrder[typeof section == "string" ? section : section.name] 58 | } 59 | } 60 | 61 | let result = [], prev = null 62 | let compare = conf.compareCompletions 63 | for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) { 64 | let cur = opt.completion 65 | if (!prev || prev.label != cur.label || prev.detail != cur.detail || 66 | (prev.type != null && cur.type != null && prev.type != cur.type) || 67 | prev.apply != cur.apply || prev.boost != cur.boost) result.push(opt) 68 | else if (score(opt.completion) > score(prev)) result[result.length - 1] = opt 69 | prev = opt.completion 70 | } 71 | return result 72 | } 73 | 74 | class CompletionDialog { 75 | constructor(readonly options: readonly Option[], 76 | readonly attrs: {[name: string]: string}, 77 | readonly tooltip: Tooltip, 78 | readonly timestamp: number, 79 | readonly selected: number, 80 | readonly disabled: boolean) {} 81 | 82 | setSelected(selected: number, id: string) { 83 | return selected == this.selected || selected >= this.options.length ? this 84 | : new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled) 85 | } 86 | 87 | static build( 88 | active: readonly ActiveSource[], 89 | state: EditorState, 90 | id: string, 91 | prev: CompletionDialog | null, 92 | conf: Required, 93 | didSetActive: boolean 94 | ): CompletionDialog | null { 95 | if (prev && !didSetActive && active.some(s => s.isPending)) 96 | return prev.setDisabled() 97 | let options = sortOptions(active, state) 98 | if (!options.length) 99 | return prev && active.some(a => a.isPending) ? prev.setDisabled() : null 100 | let selected = state.facet(completionConfig).selectOnOpen ? 0 : -1 101 | if (prev && prev.selected != selected && prev.selected != -1) { 102 | let selectedValue = prev.options[prev.selected].completion 103 | for (let i = 0; i < options.length; i++) if (options[i].completion == selectedValue) { 104 | selected = i 105 | break 106 | } 107 | } 108 | return new CompletionDialog(options, makeAttrs(id, selected), { 109 | pos: active.reduce((a, b) => b.hasResult() ? Math.min(a, b.from) : a, 1e8), 110 | create: createTooltip, 111 | above: conf.aboveCursor, 112 | }, prev ? prev.timestamp : Date.now(), selected, false) 113 | } 114 | 115 | map(changes: ChangeDesc) { 116 | return new CompletionDialog(this.options, this.attrs, {...this.tooltip, pos: changes.mapPos(this.tooltip.pos)}, 117 | this.timestamp, this.selected, this.disabled) 118 | } 119 | 120 | setDisabled() { 121 | return new CompletionDialog(this.options, this.attrs, this.tooltip, this.timestamp, this.selected, true) 122 | } 123 | } 124 | 125 | export class CompletionState { 126 | constructor(readonly active: readonly ActiveSource[], 127 | readonly id: string, 128 | readonly open: CompletionDialog | null) {} 129 | 130 | static start() { 131 | return new CompletionState(none, "cm-ac-" + Math.floor(Math.random() * 2e6).toString(36), null) 132 | } 133 | 134 | update(tr: Transaction) { 135 | let {state} = tr, conf = state.facet(completionConfig) 136 | let sources = conf.override || 137 | state.languageDataAt("autocomplete", cur(state)).map(asSource) 138 | let active: readonly ActiveSource[] = sources.map(source => { 139 | let value = this.active.find(s => s.source == source) || 140 | new ActiveSource(source, this.active.some(a => a.state != State.Inactive) ? State.Pending : State.Inactive) 141 | return value.update(tr, conf) 142 | }) 143 | if (active.length == this.active.length && active.every((a, i) => a == this.active[i])) active = this.active 144 | 145 | let open = this.open, didSet = tr.effects.some(e => e.is(setActiveEffect)) 146 | if (open && tr.docChanged) open = open.map(tr.changes) 147 | if (tr.selection || active.some(a => a.hasResult() && tr.changes.touchesRange(a.from, a.to)) || 148 | !sameResults(active, this.active) || didSet) 149 | open = CompletionDialog.build(active, state, this.id, open, conf, didSet) 150 | else if (open && open.disabled && !active.some(a => a.isPending)) 151 | open = null 152 | 153 | if (!open && active.every(a => !a.isPending) && active.some(a => a.hasResult())) 154 | active = active.map(a => a.hasResult() ? new ActiveSource(a.source, State.Inactive) : a) 155 | for (let effect of tr.effects) if (effect.is(setSelectedEffect)) open = open && open.setSelected(effect.value, this.id) 156 | 157 | return active == this.active && open == this.open ? this : new CompletionState(active, this.id, open) 158 | } 159 | 160 | get tooltip(): Tooltip | null { return this.open ? this.open.tooltip : null } 161 | 162 | get attrs() { return this.open ? this.open.attrs : this.active.length ? baseAttrs : noAttrs } 163 | } 164 | 165 | function sameResults(a: readonly ActiveSource[], b: readonly ActiveSource[]) { 166 | if (a == b) return true 167 | for (let iA = 0, iB = 0;;) { 168 | while (iA < a.length && !a[iA].hasResult()) iA++ 169 | while (iB < b.length && !b[iB].hasResult()) iB++ 170 | let endA = iA == a.length, endB = iB == b.length 171 | if (endA || endB) return endA == endB 172 | if ((a[iA++] as ActiveResult).result != (b[iB++] as ActiveResult).result) return false 173 | } 174 | } 175 | 176 | const baseAttrs = { 177 | "aria-autocomplete": "list" 178 | } 179 | 180 | const noAttrs = {} 181 | 182 | function makeAttrs(id: string, selected: number) { 183 | let result: {[name: string]: string} = { 184 | "aria-autocomplete": "list", 185 | "aria-haspopup": "listbox", 186 | "aria-controls": id 187 | } 188 | if (selected > -1) result["aria-activedescendant"] = id + "-" + selected 189 | return result 190 | } 191 | 192 | const none: readonly any[] = [] 193 | 194 | export const enum State { Inactive = 0, Pending = 1, Result = 3 } 195 | 196 | export const enum UpdateType { 197 | None = 0, 198 | Typing = 1, 199 | Backspacing = 2, 200 | SimpleInteraction = Typing | Backspacing, 201 | Activate = 4, 202 | Reset = 8, 203 | ResetIfTouching = 16 204 | } 205 | 206 | export function getUpdateType(tr: Transaction, conf: Required): UpdateType { 207 | if (tr.isUserEvent("input.complete")) { 208 | let completion = tr.annotation(pickedCompletion) 209 | if (completion && conf.activateOnCompletion(completion)) return UpdateType.Activate | UpdateType.Reset 210 | } 211 | let typing = tr.isUserEvent("input.type") 212 | return typing && conf.activateOnTyping ? UpdateType.Activate | UpdateType.Typing 213 | : typing ? UpdateType.Typing 214 | : tr.isUserEvent("delete.backward") ? UpdateType.Backspacing 215 | : tr.selection ? UpdateType.Reset 216 | : tr.docChanged ? UpdateType.ResetIfTouching : UpdateType.None 217 | } 218 | 219 | export class ActiveSource { 220 | constructor(readonly source: CompletionSource, 221 | readonly state: State, 222 | readonly explicit: boolean = false) {} 223 | 224 | hasResult(): this is ActiveResult { return false } 225 | 226 | get isPending() { return this.state == State.Pending } 227 | 228 | update(tr: Transaction, conf: Required): ActiveSource { 229 | let type = getUpdateType(tr, conf), value: ActiveSource = this 230 | if ((type & UpdateType.Reset) || (type & UpdateType.ResetIfTouching) && this.touches(tr)) 231 | value = new ActiveSource(value.source, State.Inactive) 232 | if ((type & UpdateType.Activate) && value.state == State.Inactive) 233 | value = new ActiveSource(this.source, State.Pending) 234 | value = value.updateFor(tr, type) 235 | 236 | for (let effect of tr.effects) { 237 | if (effect.is(startCompletionEffect)) 238 | value = new ActiveSource(value.source, State.Pending, effect.value) 239 | else if (effect.is(closeCompletionEffect)) 240 | value = new ActiveSource(value.source, State.Inactive) 241 | else if (effect.is(setActiveEffect)) 242 | for (let active of effect.value) if (active.source == value.source) value = active 243 | } 244 | return value 245 | } 246 | 247 | updateFor(tr: Transaction, type: UpdateType): ActiveSource { return this.map(tr.changes) } 248 | 249 | map(changes: ChangeDesc): ActiveSource { return this } 250 | 251 | touches(tr: Transaction) { 252 | return tr.changes.touchesRange(cur(tr.state)) 253 | } 254 | } 255 | 256 | export class ActiveResult extends ActiveSource { 257 | constructor(source: CompletionSource, 258 | explicit: boolean, 259 | readonly limit: number, 260 | readonly result: CompletionResult, 261 | readonly from: number, 262 | readonly to: number) { 263 | super(source, State.Result, explicit) 264 | } 265 | 266 | hasResult(): this is ActiveResult { return true } 267 | 268 | updateFor(tr: Transaction, type: UpdateType) { 269 | if (!(type & UpdateType.SimpleInteraction)) return this.map(tr.changes) 270 | let result = this.result as CompletionResult | null 271 | if (result!.map && !tr.changes.empty) result = result!.map(result!, tr.changes) 272 | let from = tr.changes.mapPos(this.from), to = tr.changes.mapPos(this.to, 1) 273 | let pos = cur(tr.state) 274 | if (pos > to || !result || 275 | (type & UpdateType.Backspacing) && (cur(tr.startState) == this.from || pos < this.limit)) 276 | return new ActiveSource(this.source, type & UpdateType.Activate ? State.Pending : State.Inactive) 277 | let limit = tr.changes.mapPos(this.limit) 278 | if (checkValid(result.validFor, tr.state, from, to)) 279 | return new ActiveResult(this.source, this.explicit, limit, result, from, to) 280 | if (result.update && 281 | (result = result.update(result, from, to, new CompletionContext(tr.state, pos, false)))) 282 | return new ActiveResult(this.source, this.explicit, limit, result, result.from, result.to ?? cur(tr.state)) 283 | return new ActiveSource(this.source, State.Pending, this.explicit) 284 | } 285 | 286 | map(mapping: ChangeDesc) { 287 | if (mapping.empty) return this 288 | let result = this.result.map ? this.result.map(this.result, mapping) : this.result 289 | if (!result) return new ActiveSource(this.source, State.Inactive) 290 | return new ActiveResult(this.source, this.explicit, mapping.mapPos(this.limit), this.result, 291 | mapping.mapPos(this.from), mapping.mapPos(this.to, 1)) 292 | } 293 | 294 | touches(tr: Transaction) { 295 | return tr.changes.touchesRange(this.from, this.to) 296 | } 297 | } 298 | 299 | function checkValid(validFor: undefined | RegExp | ((text: string, from: number, to: number, state: EditorState) => boolean), 300 | state: EditorState, from: number, to: number) { 301 | if (!validFor) return false 302 | let text = state.sliceDoc(from, to) 303 | return typeof validFor == "function" ? validFor(text, from, to, state) : ensureAnchor(validFor, true).test(text) 304 | } 305 | 306 | export const setActiveEffect = StateEffect.define({ 307 | map(sources, mapping) { return sources.map(s => s.map(mapping)) } 308 | }) 309 | export const setSelectedEffect = StateEffect.define() 310 | 311 | export const completionState = StateField.define({ 312 | create() { return CompletionState.start() }, 313 | 314 | update(value, tr) { return value.update(tr) }, 315 | 316 | provide: f => [ 317 | showTooltip.from(f, val => val.tooltip), 318 | EditorView.contentAttributes.from(f, state => state.attrs) 319 | ] 320 | }) 321 | 322 | export function applyCompletion(view: EditorView, option: Option) { 323 | const apply = option.completion.apply || option.completion.label 324 | let result = view.state.field(completionState).active.find(a => a.source == option.source) 325 | if (!(result instanceof ActiveResult)) return false 326 | 327 | if (typeof apply == "string") 328 | view.dispatch({ 329 | ...insertCompletionText(view.state, apply, result.from, result.to), 330 | annotations: pickedCompletion.of(option.completion) 331 | }) 332 | else 333 | apply(view, option.completion, result.from, result.to) 334 | return true 335 | } 336 | 337 | const createTooltip = completionTooltip(completionState, applyCompletion) 338 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import {EditorView} from "@codemirror/view" 2 | 3 | export const enum Info { Margin = 30, Width = 400 } 4 | 5 | export const baseTheme = EditorView.baseTheme({ 6 | ".cm-tooltip.cm-tooltip-autocomplete": { 7 | "& > ul": { 8 | fontFamily: "monospace", 9 | whiteSpace: "nowrap", 10 | overflow: "hidden auto", 11 | maxWidth_fallback: "700px", 12 | maxWidth: "min(700px, 95vw)", 13 | minWidth: "250px", 14 | maxHeight: "10em", 15 | height: "100%", 16 | listStyle: "none", 17 | margin: 0, 18 | padding: 0, 19 | 20 | "& > li, & > completion-section": { 21 | padding: "1px 3px", 22 | lineHeight: 1.2 23 | }, 24 | "& > li": { 25 | overflowX: "hidden", 26 | textOverflow: "ellipsis", 27 | cursor: "pointer" 28 | }, 29 | "& > completion-section": { 30 | display: "list-item", 31 | borderBottom: "1px solid silver", 32 | paddingLeft: "0.5em", 33 | opacity: 0.7 34 | } 35 | } 36 | }, 37 | 38 | "&light .cm-tooltip-autocomplete ul li[aria-selected]": { 39 | background: "#17c", 40 | color: "white", 41 | }, 42 | 43 | "&light .cm-tooltip-autocomplete-disabled ul li[aria-selected]": { 44 | background: "#777", 45 | }, 46 | 47 | "&dark .cm-tooltip-autocomplete ul li[aria-selected]": { 48 | background: "#347", 49 | color: "white", 50 | }, 51 | 52 | "&dark .cm-tooltip-autocomplete-disabled ul li[aria-selected]": { 53 | background: "#444", 54 | }, 55 | 56 | ".cm-completionListIncompleteTop:before, .cm-completionListIncompleteBottom:after": { 57 | content: '"···"', 58 | opacity: 0.5, 59 | display: "block", 60 | textAlign: "center" 61 | }, 62 | 63 | ".cm-tooltip.cm-completionInfo": { 64 | position: "absolute", 65 | padding: "3px 9px", 66 | width: "max-content", 67 | maxWidth: `${Info.Width}px`, 68 | boxSizing: "border-box", 69 | whiteSpace: "pre-line" 70 | }, 71 | 72 | ".cm-completionInfo.cm-completionInfo-left": { right: "100%" }, 73 | ".cm-completionInfo.cm-completionInfo-right": { left: "100%" }, 74 | ".cm-completionInfo.cm-completionInfo-left-narrow": { right: `${Info.Margin}px` }, 75 | ".cm-completionInfo.cm-completionInfo-right-narrow": { left: `${Info.Margin}px` }, 76 | 77 | "&light .cm-snippetField": {backgroundColor: "#00000022"}, 78 | "&dark .cm-snippetField": {backgroundColor: "#ffffff22"}, 79 | ".cm-snippetFieldPosition": { 80 | verticalAlign: "text-top", 81 | width: 0, 82 | height: "1.15em", 83 | display: "inline-block", 84 | margin: "0 -0.7px -.7em", 85 | borderLeft: "1.4px dotted #888" 86 | }, 87 | 88 | ".cm-completionMatchedText": { 89 | textDecoration: "underline" 90 | }, 91 | 92 | ".cm-completionDetail": { 93 | marginLeft: "0.5em", 94 | fontStyle: "italic" 95 | }, 96 | 97 | ".cm-completionIcon": { 98 | fontSize: "90%", 99 | width: ".8em", 100 | display: "inline-block", 101 | textAlign: "center", 102 | paddingRight: ".6em", 103 | opacity: "0.6", 104 | boxSizing: "content-box" 105 | }, 106 | 107 | ".cm-completionIcon-function, .cm-completionIcon-method": { 108 | "&:after": { content: "'ƒ'" } 109 | }, 110 | ".cm-completionIcon-class": { 111 | "&:after": { content: "'○'" } 112 | }, 113 | ".cm-completionIcon-interface": { 114 | "&:after": { content: "'◌'" } 115 | }, 116 | ".cm-completionIcon-variable": { 117 | "&:after": { content: "'𝑥'" } 118 | }, 119 | ".cm-completionIcon-constant": { 120 | "&:after": { content: "'𝐶'" } 121 | }, 122 | ".cm-completionIcon-type": { 123 | "&:after": { content: "'𝑡'" } 124 | }, 125 | ".cm-completionIcon-enum": { 126 | "&:after": { content: "'∪'" } 127 | }, 128 | ".cm-completionIcon-property": { 129 | "&:after": { content: "'□'" } 130 | }, 131 | ".cm-completionIcon-keyword": { 132 | "&:after": { content: "'🔑\uFE0E'" } // Disable emoji rendering 133 | }, 134 | ".cm-completionIcon-namespace": { 135 | "&:after": { content: "'▢'" } 136 | }, 137 | ".cm-completionIcon-text": { 138 | "&:after": { content: "'abc'", fontSize: "50%", verticalAlign: "middle" } 139 | } 140 | }) 141 | -------------------------------------------------------------------------------- /src/tooltip.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, ViewUpdate, logException, TooltipView, Rect} from "@codemirror/view" 2 | import {StateField, EditorState} from "@codemirror/state" 3 | import {CompletionState} from "./state" 4 | import {completionConfig, CompletionConfig} from "./config" 5 | import {Option, Completion, CompletionInfo, closeCompletionEffect} from "./completion" 6 | 7 | type OptionContentSource = 8 | (completion: Completion, state: EditorState, view: EditorView, match: readonly number[]) => Node | null 9 | 10 | function optionContent(config: Required): OptionContentSource[] { 11 | let content = config.addToOptions.slice() as {render: OptionContentSource, position: number}[] 12 | if (config.icons) content.push({ 13 | render(completion: Completion) { 14 | let icon = document.createElement("div") 15 | icon.classList.add("cm-completionIcon") 16 | if (completion.type) 17 | icon.classList.add(...completion.type.split(/\s+/g).map(cls => "cm-completionIcon-" + cls)) 18 | icon.setAttribute("aria-hidden", "true") 19 | return icon 20 | }, 21 | position: 20 22 | }) 23 | content.push({ 24 | render(completion: Completion, _s: EditorState, _v: EditorView, match: readonly number[]) { 25 | let labelElt = document.createElement("span") 26 | labelElt.className = "cm-completionLabel" 27 | let label = completion.displayLabel || completion.label, off = 0 28 | for (let j = 0; j < match.length;) { 29 | let from = match[j++], to = match[j++] 30 | if (from > off) labelElt.appendChild(document.createTextNode(label.slice(off, from))) 31 | let span = labelElt.appendChild(document.createElement("span")) 32 | span.appendChild(document.createTextNode(label.slice(from, to))) 33 | span.className = "cm-completionMatchedText" 34 | off = to 35 | } 36 | if (off < label.length) labelElt.appendChild(document.createTextNode(label.slice(off))) 37 | return labelElt 38 | }, 39 | position: 50 40 | }, { 41 | render(completion: Completion) { 42 | if (!completion.detail) return null 43 | let detailElt = document.createElement("span") 44 | detailElt.className = "cm-completionDetail" 45 | detailElt.textContent = completion.detail 46 | return detailElt 47 | }, 48 | position: 80 49 | }) 50 | return content.sort((a, b) => a.position - b.position).map(a => a.render) 51 | } 52 | 53 | function rangeAroundSelected(total: number, selected: number, max: number) { 54 | if (total <= max) return {from: 0, to: total} 55 | if (selected < 0) selected = 0 56 | if (selected <= (total >> 1)) { 57 | let off = Math.floor(selected / max) 58 | return {from: off * max, to: (off + 1) * max} 59 | } 60 | let off = Math.floor((total - selected) / max) 61 | return {from: total - (off + 1) * max, to: total - off * max} 62 | } 63 | 64 | class CompletionTooltip { 65 | dom: HTMLElement 66 | info: HTMLElement | null = null 67 | infoDestroy: (() => void) | null = null 68 | declare list: HTMLElement 69 | placeInfoReq = { 70 | read: () => this.measureInfo(), 71 | write: (pos: {style?: string, class?: string} | null) => this.placeInfo(pos), 72 | key: this 73 | } 74 | range: {from: number, to: number} 75 | space: Rect | null = null 76 | optionContent: OptionContentSource[] 77 | tooltipClass: (state: EditorState) => string 78 | currentClass = "" 79 | optionClass: (option: Completion) => string 80 | 81 | constructor(readonly view: EditorView, 82 | readonly stateField: StateField, 83 | readonly applyCompletion: (view: EditorView, option: Option) => void) { 84 | let cState = view.state.field(stateField) 85 | let {options, selected} = cState.open! 86 | let config = view.state.facet(completionConfig) 87 | this.optionContent = optionContent(config) 88 | this.optionClass = config.optionClass 89 | this.tooltipClass = config.tooltipClass 90 | 91 | this.range = rangeAroundSelected(options.length, selected, config.maxRenderedOptions) 92 | 93 | this.dom = document.createElement("div") 94 | this.dom.className = "cm-tooltip-autocomplete" 95 | this.updateTooltipClass(view.state) 96 | this.dom.addEventListener("mousedown", (e: MouseEvent) => { 97 | let {options} = view.state.field(stateField).open! 98 | for (let dom = e.target as HTMLElement | null, match; dom && dom != this.dom; dom = dom.parentNode as HTMLElement) { 99 | if (dom.nodeName == "LI" && (match = /-(\d+)$/.exec(dom.id)) && +match[1] < options.length) { 100 | this.applyCompletion(view, options[+match[1]]) 101 | e.preventDefault() 102 | return 103 | } 104 | } 105 | }) 106 | this.dom.addEventListener("focusout", (e: FocusEvent) => { 107 | let state = view.state.field(this.stateField, false) 108 | if (state && state.tooltip && view.state.facet(completionConfig).closeOnBlur && 109 | e.relatedTarget != view.contentDOM) 110 | view.dispatch({effects: closeCompletionEffect.of(null)}) 111 | }) 112 | this.showOptions(options, cState.id) 113 | } 114 | 115 | mount() { this.updateSel() } 116 | 117 | showOptions(options: readonly Option[], id: string) { 118 | if (this.list) this.list.remove() 119 | this.list = this.dom.appendChild(this.createListBox(options, id, this.range)) 120 | this.list.addEventListener("scroll", () => { 121 | if (this.info) this.view.requestMeasure(this.placeInfoReq) 122 | }) 123 | } 124 | 125 | update(update: ViewUpdate) { 126 | let cState = update.state.field(this.stateField) 127 | let prevState = update.startState.field(this.stateField) 128 | this.updateTooltipClass(update.state) 129 | if (cState != prevState) { 130 | let {options, selected, disabled} = cState.open! 131 | if (!prevState.open || prevState.open.options != options) { 132 | this.range = rangeAroundSelected(options.length, selected, update.state.facet(completionConfig).maxRenderedOptions) 133 | this.showOptions(options, cState.id) 134 | } 135 | this.updateSel() 136 | if (disabled != prevState.open?.disabled) 137 | this.dom.classList.toggle("cm-tooltip-autocomplete-disabled", !!disabled) 138 | } 139 | } 140 | 141 | updateTooltipClass(state: EditorState) { 142 | let cls = this.tooltipClass(state) 143 | if (cls != this.currentClass) { 144 | for (let c of this.currentClass.split(" ")) if (c) this.dom.classList.remove(c) 145 | for (let c of cls.split(" ")) if (c) this.dom.classList.add(c) 146 | this.currentClass = cls 147 | } 148 | } 149 | 150 | positioned(space: Rect) { 151 | this.space = space 152 | if (this.info) this.view.requestMeasure(this.placeInfoReq) 153 | } 154 | 155 | updateSel() { 156 | let cState = this.view.state.field(this.stateField), open = cState.open! 157 | if (open.selected > -1 && open.selected < this.range.from || open.selected >= this.range.to) { 158 | this.range = rangeAroundSelected(open.options.length, open.selected, 159 | this.view.state.facet(completionConfig).maxRenderedOptions) 160 | this.showOptions(open.options, cState.id) 161 | } 162 | if (this.updateSelectedOption(open.selected)) { 163 | this.destroyInfo() 164 | let {completion} = open.options[open.selected] 165 | let {info} = completion 166 | if (!info) return 167 | let infoResult = typeof info === "string" ? document.createTextNode(info) : info(completion) 168 | if (!infoResult) return 169 | if ("then" in infoResult) { 170 | infoResult.then(obj => { 171 | if (obj && this.view.state.field(this.stateField, false) == cState) 172 | this.addInfoPane(obj, completion) 173 | }).catch(e => logException(this.view.state, e, "completion info")) 174 | } else { 175 | this.addInfoPane(infoResult, completion) 176 | } 177 | } 178 | } 179 | 180 | addInfoPane(content: NonNullable, completion: Completion) { 181 | this.destroyInfo() 182 | let wrap = this.info = document.createElement("div") 183 | wrap.className = "cm-tooltip cm-completionInfo" 184 | if ((content as Node).nodeType != null) { 185 | wrap.appendChild(content as Node) 186 | this.infoDestroy = null 187 | } else { 188 | let {dom, destroy} = content as {dom: Node, destroy?(): void} 189 | wrap.appendChild(dom) 190 | this.infoDestroy = destroy || null 191 | } 192 | this.dom.appendChild(wrap) 193 | this.view.requestMeasure(this.placeInfoReq) 194 | } 195 | 196 | updateSelectedOption(selected: number) { 197 | let set: null | HTMLElement = null 198 | for (let opt = this.list.firstChild as (HTMLElement | null), i = this.range.from; opt; 199 | opt = opt.nextSibling as (HTMLElement | null), i++) { 200 | if (opt.nodeName != "LI" || !opt.id) { 201 | i-- // A section header 202 | } else if (i == selected) { 203 | if (!opt.hasAttribute("aria-selected")) { 204 | opt.setAttribute("aria-selected", "true") 205 | set = opt 206 | } 207 | } else { 208 | if (opt.hasAttribute("aria-selected")) opt.removeAttribute("aria-selected") 209 | } 210 | } 211 | if (set) scrollIntoView(this.list, set) 212 | return set 213 | } 214 | 215 | measureInfo() { 216 | let sel = this.dom.querySelector("[aria-selected]") as HTMLElement | null 217 | if (!sel || !this.info) return null 218 | let listRect = this.dom.getBoundingClientRect() 219 | let infoRect = this.info!.getBoundingClientRect() 220 | let selRect = sel.getBoundingClientRect() 221 | let space = this.space 222 | if (!space) { 223 | let docElt = this.dom.ownerDocument.documentElement 224 | space = {left: 0, top: 0, right: docElt.clientWidth, bottom: docElt.clientHeight} 225 | } 226 | if (selRect.top > Math.min(space.bottom, listRect.bottom) - 10 || 227 | selRect.bottom < Math.max(space.top, listRect.top) + 10) 228 | return null 229 | return (this.view.state.facet(completionConfig).positionInfo as any)( 230 | this.view, listRect, selRect, infoRect, space, this.dom) 231 | } 232 | 233 | placeInfo(pos: {style?: string, class?: string} | null) { 234 | if (this.info) { 235 | if (pos) { 236 | if (pos.style) this.info.style.cssText = pos.style 237 | this.info.className = "cm-tooltip cm-completionInfo " + (pos.class || "") 238 | } else { 239 | this.info.style.cssText = "top: -1e6px" 240 | } 241 | } 242 | } 243 | 244 | createListBox(options: readonly Option[], id: string, range: {from: number, to: number}) { 245 | const ul = document.createElement("ul") 246 | ul.id = id 247 | ul.setAttribute("role", "listbox") 248 | ul.setAttribute("aria-expanded", "true") 249 | ul.setAttribute("aria-label", this.view.state.phrase("Completions")) 250 | ul.addEventListener("mousedown", e => { 251 | // Prevent focus change when clicking the scrollbar 252 | if (e.target == ul) e.preventDefault() 253 | }) 254 | let curSection: string | null = null 255 | for (let i = range.from; i < range.to; i++) { 256 | let {completion, match} = options[i], {section} = completion 257 | if (section) { 258 | let name = typeof section == "string" ? section : section.name 259 | if (name != curSection && (i > range.from || range.from == 0)) { 260 | curSection = name 261 | if (typeof section != "string" && section.header) { 262 | ul.appendChild(section.header(section)) 263 | } else { 264 | let header = ul.appendChild(document.createElement("completion-section")) 265 | header.textContent = name 266 | } 267 | } 268 | } 269 | const li = ul.appendChild(document.createElement("li")) 270 | li.id = id + "-" + i 271 | li.setAttribute("role", "option") 272 | let cls = this.optionClass(completion) 273 | if (cls) li.className = cls 274 | for (let source of this.optionContent) { 275 | let node = source(completion, this.view.state, this.view, match) 276 | if (node) li.appendChild(node) 277 | } 278 | } 279 | if (range.from) ul.classList.add("cm-completionListIncompleteTop") 280 | if (range.to < options.length) ul.classList.add("cm-completionListIncompleteBottom") 281 | return ul 282 | } 283 | 284 | destroyInfo() { 285 | if (this.info) { 286 | if (this.infoDestroy) this.infoDestroy() 287 | this.info.remove() 288 | this.info = null 289 | } 290 | } 291 | 292 | destroy() { 293 | this.destroyInfo() 294 | } 295 | } 296 | 297 | export function completionTooltip(stateField: StateField, 298 | applyCompletion: (view: EditorView, option: Option) => void) { 299 | return (view: EditorView): TooltipView => new CompletionTooltip(view, stateField, applyCompletion) 300 | } 301 | 302 | function scrollIntoView(container: HTMLElement, element: HTMLElement) { 303 | let parent = container.getBoundingClientRect() 304 | let self = element.getBoundingClientRect() 305 | let scaleY = parent.height / container.offsetHeight 306 | if (self.top < parent.top) container.scrollTop -= (parent.top - self.top) / scaleY 307 | else if (self.bottom > parent.bottom) container.scrollTop += (self.bottom - parent.bottom) / scaleY 308 | } 309 | -------------------------------------------------------------------------------- /src/view.ts: -------------------------------------------------------------------------------- 1 | import {EditorView, Command, ViewPlugin, PluginValue, ViewUpdate, logException, 2 | getTooltip, TooltipView} from "@codemirror/view" 3 | import {Transaction, Prec} from "@codemirror/state" 4 | import {completionState, setSelectedEffect, setActiveEffect, State, 5 | ActiveSource, ActiveResult, getUpdateType, UpdateType, applyCompletion} from "./state" 6 | import {completionConfig} from "./config" 7 | import {cur, CompletionResult, CompletionContext, startCompletionEffect, closeCompletionEffect} from "./completion" 8 | 9 | /// Returns a command that moves the completion selection forward or 10 | /// backward by the given amount. 11 | export function moveCompletionSelection(forward: boolean, by: "option" | "page" = "option"): Command { 12 | return (view: EditorView) => { 13 | let cState = view.state.field(completionState, false) 14 | if (!cState || !cState.open || cState.open.disabled || 15 | Date.now() - cState.open.timestamp < view.state.facet(completionConfig).interactionDelay) 16 | return false 17 | let step = 1, tooltip: TooltipView | null 18 | if (by == "page" && (tooltip = getTooltip(view, cState.open.tooltip))) 19 | step = Math.max(2, Math.floor(tooltip.dom.offsetHeight / 20 | (tooltip.dom.querySelector("li") as HTMLElement).offsetHeight) - 1) 21 | let {length} = cState.open.options 22 | let selected = cState.open.selected > -1 ? cState.open.selected + step * (forward ? 1 : -1) : forward ? 0 : length - 1 23 | if (selected < 0) selected = by == "page" ? 0 : length - 1 24 | else if (selected >= length) selected = by == "page" ? length - 1 : 0 25 | view.dispatch({effects: setSelectedEffect.of(selected)}) 26 | return true 27 | } 28 | } 29 | 30 | /// Accept the current completion. 31 | export const acceptCompletion: Command = (view: EditorView) => { 32 | let cState = view.state.field(completionState, false) 33 | if (view.state.readOnly || !cState || !cState.open || cState.open.selected < 0 || cState.open.disabled || 34 | Date.now() - cState.open.timestamp < view.state.facet(completionConfig).interactionDelay) 35 | return false 36 | return applyCompletion(view, cState.open.options[cState.open.selected]) 37 | } 38 | 39 | /// Explicitly start autocompletion. 40 | export const startCompletion: Command = (view: EditorView) => { 41 | let cState = view.state.field(completionState, false) 42 | if (!cState) return false 43 | view.dispatch({effects: startCompletionEffect.of(true)}) 44 | return true 45 | } 46 | 47 | /// Close the currently active completion. 48 | export const closeCompletion: Command = (view: EditorView) => { 49 | let cState = view.state.field(completionState, false) 50 | if (!cState || !cState.active.some(a => a.state != State.Inactive)) return false 51 | view.dispatch({effects: closeCompletionEffect.of(null)}) 52 | return true 53 | } 54 | 55 | class RunningQuery { 56 | time = Date.now() 57 | updates: Transaction[] = [] 58 | // Note that 'undefined' means 'not done yet', whereas 'null' means 59 | // 'query returned null'. 60 | done: undefined | CompletionResult | null = undefined 61 | 62 | constructor(readonly active: ActiveSource, 63 | readonly context: CompletionContext) {} 64 | } 65 | 66 | const MaxUpdateCount = 50, MinAbortTime = 1000 67 | 68 | const enum CompositionState { None, Started, Changed, ChangedAndMoved } 69 | 70 | export const completionPlugin = ViewPlugin.fromClass(class implements PluginValue { 71 | debounceUpdate = -1 72 | running: RunningQuery[] = [] 73 | debounceAccept = -1 74 | pendingStart = false 75 | composing = CompositionState.None 76 | 77 | constructor(readonly view: EditorView) { 78 | for (let active of view.state.field(completionState).active) 79 | if (active.isPending) this.startQuery(active) 80 | } 81 | 82 | update(update: ViewUpdate) { 83 | let cState = update.state.field(completionState) 84 | let conf = update.state.facet(completionConfig) 85 | if (!update.selectionSet && !update.docChanged && update.startState.field(completionState) == cState) return 86 | 87 | let doesReset = update.transactions.some(tr => { 88 | let type = getUpdateType(tr, conf) 89 | return (type & UpdateType.Reset) || (tr.selection || tr.docChanged) && !(type & UpdateType.SimpleInteraction) 90 | }) 91 | for (let i = 0; i < this.running.length; i++) { 92 | let query = this.running[i] 93 | if (doesReset || 94 | query.context.abortOnDocChange && update.docChanged || 95 | query.updates.length + update.transactions.length > MaxUpdateCount && Date.now() - query.time > MinAbortTime) { 96 | for (let handler of query.context.abortListeners!) { 97 | try { handler() } 98 | catch(e) { logException(this.view.state, e) } 99 | } 100 | query.context.abortListeners = null 101 | this.running.splice(i--, 1) 102 | } else { 103 | query.updates.push(...update.transactions) 104 | } 105 | } 106 | 107 | if (this.debounceUpdate > -1) clearTimeout(this.debounceUpdate) 108 | if (update.transactions.some(tr => tr.effects.some(e => e.is(startCompletionEffect)))) this.pendingStart = true 109 | let delay = this.pendingStart ? 50 : conf.activateOnTypingDelay 110 | this.debounceUpdate = cState.active.some(a => a.isPending && !this.running.some(q => q.active.source == a.source)) 111 | ? setTimeout(() => this.startUpdate(), delay) : -1 112 | 113 | if (this.composing != CompositionState.None) for (let tr of update.transactions) { 114 | if (tr.isUserEvent("input.type")) 115 | this.composing = CompositionState.Changed 116 | else if (this.composing == CompositionState.Changed && tr.selection) 117 | this.composing = CompositionState.ChangedAndMoved 118 | } 119 | } 120 | 121 | startUpdate() { 122 | this.debounceUpdate = -1 123 | this.pendingStart = false 124 | let {state} = this.view, cState = state.field(completionState) 125 | for (let active of cState.active) { 126 | if (active.isPending && !this.running.some(r => r.active.source == active.source)) 127 | this.startQuery(active) 128 | } 129 | if (this.running.length && cState.open && cState.open.disabled) 130 | this.debounceAccept = setTimeout(() => this.accept(), 131 | this.view.state.facet(completionConfig).updateSyncTime) 132 | } 133 | 134 | startQuery(active: ActiveSource) { 135 | let {state} = this.view, pos = cur(state) 136 | let context = new CompletionContext(state, pos, active.explicit, this.view) 137 | let pending = new RunningQuery(active, context) 138 | this.running.push(pending) 139 | Promise.resolve(active.source(context)).then(result => { 140 | if (!pending.context.aborted) { 141 | pending.done = result || null 142 | this.scheduleAccept() 143 | } 144 | }, err => { 145 | this.view.dispatch({effects: closeCompletionEffect.of(null)}) 146 | logException(this.view.state, err) 147 | }) 148 | } 149 | 150 | scheduleAccept() { 151 | if (this.running.every(q => q.done !== undefined)) 152 | this.accept() 153 | else if (this.debounceAccept < 0) 154 | this.debounceAccept = setTimeout(() => this.accept(), 155 | this.view.state.facet(completionConfig).updateSyncTime) 156 | } 157 | 158 | // For each finished query in this.running, try to create a result 159 | // or, if appropriate, restart the query. 160 | accept() { 161 | if (this.debounceAccept > -1) clearTimeout(this.debounceAccept) 162 | this.debounceAccept = -1 163 | 164 | let updated: ActiveSource[] = [] 165 | let conf = this.view.state.facet(completionConfig), cState = this.view.state.field(completionState) 166 | for (let i = 0; i < this.running.length; i++) { 167 | let query = this.running[i] 168 | if (query.done === undefined) continue 169 | this.running.splice(i--, 1) 170 | 171 | if (query.done) { 172 | let pos = cur(query.updates.length ? query.updates[0].startState : this.view.state) 173 | let limit = Math.min(pos, query.done.from + (query.active.explicit ? 0 : 1)) 174 | let active: ActiveSource = new ActiveResult( 175 | query.active.source, query.active.explicit, limit, query.done, query.done.from, 176 | query.done.to ?? pos) 177 | // Replay the transactions that happened since the start of 178 | // the request and see if that preserves the result 179 | for (let tr of query.updates) active = active.update(tr, conf) 180 | if (active.hasResult()) { 181 | updated.push(active) 182 | continue 183 | } 184 | } 185 | 186 | let current = cState.active.find(a => a.source == query.active.source) 187 | if (current && current.isPending) { 188 | if (query.done == null) { 189 | // Explicitly failed. Should clear the pending status if it 190 | // hasn't been re-set in the meantime. 191 | let active = new ActiveSource(query.active.source, State.Inactive) 192 | for (let tr of query.updates) active = active.update(tr, conf) 193 | if (!active.isPending) updated.push(active) 194 | } else { 195 | // Cleared by subsequent transactions. Restart. 196 | this.startQuery(current) 197 | } 198 | } 199 | } 200 | 201 | if (updated.length || cState.open && cState.open.disabled) 202 | this.view.dispatch({effects: setActiveEffect.of(updated)}) 203 | } 204 | }, { 205 | eventHandlers: { 206 | blur(event) { 207 | let state = this.view.state.field(completionState, false) 208 | if (state && state.tooltip && this.view.state.facet(completionConfig).closeOnBlur) { 209 | let dialog = state.open && getTooltip(this.view, state.open.tooltip) 210 | if (!dialog || !dialog.dom.contains(event.relatedTarget as HTMLElement)) 211 | setTimeout(() => this.view.dispatch({effects: closeCompletionEffect.of(null)}), 10) 212 | } 213 | }, 214 | compositionstart() { 215 | this.composing = CompositionState.Started 216 | }, 217 | compositionend() { 218 | if (this.composing == CompositionState.ChangedAndMoved) { 219 | // Safari fires compositionend events synchronously, possibly 220 | // from inside an update, so dispatch asynchronously to avoid reentrancy 221 | setTimeout(() => this.view.dispatch({effects: startCompletionEffect.of(false)}), 20) 222 | } 223 | this.composing = CompositionState.None 224 | } 225 | } 226 | }) 227 | 228 | const windows = typeof navigator == "object" && /Win/.test(navigator.platform) 229 | 230 | export const commitCharacters = Prec.highest(EditorView.domEventHandlers({ 231 | keydown(event, view) { 232 | let field = view.state.field(completionState, false) 233 | if (!field || !field.open || field.open.disabled || field.open.selected < 0 || 234 | event.key.length > 1 || event.ctrlKey && !(windows && event.altKey) || event.metaKey) 235 | return false 236 | let option = field.open.options[field.open.selected] 237 | let result = field.active.find(a => a.source == option.source) as ActiveResult 238 | let commitChars = option.completion.commitCharacters || result.result.commitCharacters 239 | if (commitChars && commitChars.indexOf(event.key) > -1) 240 | applyCompletion(view, option) 241 | return false 242 | } 243 | })) 244 | -------------------------------------------------------------------------------- /src/word.ts: -------------------------------------------------------------------------------- 1 | import {Text} from "@codemirror/state" 2 | import {Completion, CompletionSource} from "./completion" 3 | 4 | const enum C { Range = 50000, MinCacheLen = 1000, MaxList = 2000 } 5 | 6 | function wordRE(wordChars: string) { 7 | let escaped = wordChars.replace(/[\]\-\\]/g, "\\$&") 8 | try { 9 | return new RegExp(`[\\p{Alphabetic}\\p{Number}_${escaped}]+`, "ug") 10 | } catch { 11 | return new RegExp(`[\w${escaped}]`, "g") 12 | } 13 | } 14 | 15 | function mapRE(re: RegExp, f: (source: string) => string) { 16 | return new RegExp(f(re.source), re.unicode ? "u" : "") 17 | } 18 | 19 | const wordCaches: {[wordChars: string]: WeakMap} = Object.create(null) 20 | 21 | function wordCache(wordChars: string) { 22 | return wordCaches[wordChars] || (wordCaches[wordChars] = new WeakMap) 23 | } 24 | 25 | function storeWords(doc: Text, wordRE: RegExp, result: Completion[], seen: {[word: string]: boolean}, ignoreAt: number) { 26 | for (let lines = doc.iterLines(), pos = 0; !lines.next().done;) { 27 | let {value} = lines, m 28 | wordRE.lastIndex = 0 29 | while (m = wordRE.exec(value)) { 30 | if (!seen[m[0]] && pos + m.index != ignoreAt) { 31 | result.push({type: "text", label: m[0]}) 32 | seen[m[0]] = true 33 | if (result.length >= C.MaxList) return 34 | } 35 | } 36 | pos += value.length + 1 37 | } 38 | } 39 | 40 | function collectWords(doc: Text, cache: WeakMap, wordRE: RegExp, 41 | to: number, ignoreAt: number) { 42 | let big = doc.length >= C.MinCacheLen 43 | let cached = big && cache.get(doc) 44 | if (cached) return cached 45 | let result: Completion[] = [], seen: {[word: string]: boolean} = Object.create(null) 46 | if (doc.children) { 47 | let pos = 0 48 | for (let ch of doc.children) { 49 | if (ch.length >= C.MinCacheLen) { 50 | for (let c of collectWords(ch, cache, wordRE, to - pos, ignoreAt - pos)) { 51 | if (!seen[c.label]) { 52 | seen[c.label] = true 53 | result.push(c) 54 | } 55 | } 56 | } else { 57 | storeWords(ch, wordRE, result, seen, ignoreAt - pos) 58 | } 59 | pos += ch.length + 1 60 | } 61 | } else { 62 | storeWords(doc, wordRE, result, seen, ignoreAt) 63 | } 64 | if (big && result.length < C.MaxList) cache.set(doc, result) 65 | return result 66 | } 67 | 68 | /// A completion source that will scan the document for words (using a 69 | /// [character categorizer](#state.EditorState.charCategorizer)), and 70 | /// return those as completions. 71 | export const completeAnyWord: CompletionSource = context => { 72 | let wordChars = context.state.languageDataAt("wordChars", context.pos).join("") 73 | let re = wordRE(wordChars) 74 | let token = context.matchBefore(mapRE(re, s => s + "$")) 75 | if (!token && !context.explicit) return null 76 | let from = token ? token.from : context.pos 77 | let options = collectWords(context.state.doc, wordCache(wordChars), re, C.Range, from) 78 | return {from, options, validFor: mapRE(re, s => "^" + s)} 79 | } 80 | -------------------------------------------------------------------------------- /test/webtest-autocomplete.ts: -------------------------------------------------------------------------------- 1 | import {EditorView} from "@codemirror/view" 2 | import {EditorState, EditorSelection} from "@codemirror/state" 3 | import {CompletionSource, autocompletion, CompletionContext, startCompletion, 4 | currentCompletions, completionStatus, completeFromList, acceptCompletion} from "@codemirror/autocomplete" 5 | import ist from "ist" 6 | 7 | const Timeout = 1000, Chunk = 15 8 | 9 | type Sync = (get: (state: EditorState) => T, value: T) => Promise 10 | 11 | type TestSpec = { 12 | doc?: string, 13 | selection?: number | EditorSelection, 14 | sources: readonly CompletionSource[] 15 | } 16 | 17 | class Runner { 18 | tests: {name: string, spec: TestSpec, f: (view: EditorView, sync: Sync) => Promise}[] = [] 19 | 20 | test(name: string, spec: TestSpec, f: (view: EditorView, sync: Sync) => Promise) { 21 | this.tests.push({name, spec, f}) 22 | } 23 | 24 | options(name: string, doc: string, sources: readonly CompletionSource[], list: string) { 25 | this.test(name, {doc, sources}, (view, sync) => { 26 | startCompletion(view) 27 | return sync(options, list) 28 | }) 29 | } 30 | 31 | runTest(name: string, spec: TestSpec, f: (view: EditorView, sync: Sync) => Promise) { 32 | let syncing: {get: (state: EditorState) => any, value: any, resolve: () => void} | null = null 33 | let selection = spec.selection == null ? EditorSelection.single((spec.doc || "").length) 34 | : typeof spec.selection == "number" ? EditorSelection.single(spec.selection) 35 | : spec.selection 36 | let view = new EditorView({ 37 | state: EditorState.create({ 38 | doc: spec.doc, 39 | selection, 40 | extensions: [ 41 | autocompletion({override: spec.sources, interactionDelay: 0, updateSyncTime: 40, activateOnTypingDelay: 10}), 42 | EditorState.allowMultipleSelections.of(true) 43 | ] 44 | }), 45 | parent: document.querySelector("#workspace")! as HTMLElement, 46 | dispatchTransactions: trs => { 47 | if (syncing && syncing.get(trs[trs.length - 1].state) === syncing.value) { 48 | syncing.resolve() 49 | syncing = null 50 | } 51 | view.update(trs) 52 | } 53 | }) 54 | let sync = (get: (state: EditorState) => any, value: any) => new Promise((resolve, reject) => { 55 | if (syncing) throw new Error("Overlapping syncs") 56 | if (get(view.state) === value) return resolve() 57 | let mine = syncing = {get, value, resolve} 58 | setTimeout(() => { 59 | if (syncing == mine) reject(new Error(`${name}: Failed to sync: ${get(view.state)} !== ${value}\n`)) 60 | }, Timeout) 61 | }) 62 | return {view, promise: f(view, sync)} 63 | } 64 | 65 | async finish(filter?: string) { 66 | let tests = this.tests 67 | if (filter) tests = tests.filter(t => t.name.indexOf(filter) > -1) 68 | for (let from = 0; from < tests.length; from += Chunk) { 69 | let active = tests.slice(from, Math.min(tests.length, from + Chunk)).map(t => this.runTest(t.name, t.spec, t.f)) 70 | let cleanup = () => { 71 | for (let {view} of active) view.destroy() 72 | } 73 | await Promise.all(active.map(t => t.promise)).then(cleanup, err => { cleanup(); throw err }) 74 | } 75 | } 76 | } 77 | 78 | function from(list: string): CompletionSource { 79 | return cx => { 80 | let word = cx.matchBefore(/\w+$/) 81 | if (!word && !cx.explicit) return null 82 | return {from: word ? word.from : cx.pos, options: list.split(" ").map(w => ({label: w})), validFor: /^\w*/} 83 | } 84 | } 85 | 86 | function tagged(validFor: boolean): CompletionSource { 87 | return cx => { 88 | let word = cx.matchBefore(/\w+$/) 89 | return {from: word ? word.from : cx.pos, options: [{label: "tag" + cx.pos}], validFor: validFor ? /^\w*/ : undefined} 90 | } 91 | } 92 | 93 | function sleep(delay: number) { 94 | return new Promise(resolve => setTimeout(() => resolve(undefined), delay)) 95 | } 96 | 97 | function slow(c: CompletionSource, delay: number): CompletionSource { 98 | return (cx: CompletionContext) => new Promise(resolve => setTimeout(() => resolve(c(cx)), delay)) 99 | } 100 | 101 | function once(c: CompletionSource): CompletionSource { 102 | let done = false 103 | return (cx: CompletionContext) => { 104 | if (done) throw new Error("Used 'once' completer multiple times") 105 | done = true 106 | return c(cx) 107 | } 108 | } 109 | 110 | function options(s: EditorState) { 111 | return currentCompletions(s).map(c => / /.test(c.label) ? JSON.stringify(c.label) : c.label).join(" ") 112 | } 113 | 114 | function type(view: EditorView, text: string) { 115 | let cur = view.state.selection.main.head 116 | view.dispatch({changes: {from: cur, insert: text}, 117 | selection: {anchor: cur + text.length}, 118 | userEvent: "input.type"}) 119 | } 120 | 121 | function del(view: EditorView) { 122 | let cur = view.state.selection.main.head 123 | view.dispatch({changes: {from: cur - 1, to: cur}, 124 | userEvent: "delete.backward"}) 125 | } 126 | 127 | const words = "one onetwothree OneTwoThree two three" 128 | 129 | describe("autocomplete", () => { 130 | // Putting all tests together in a single `it` to allow them to run 131 | // concurrently. 132 | it("works", function() { 133 | this.timeout(5000) 134 | 135 | let run = new Runner 136 | 137 | run.options("prefers by-word matches", "ott", [from(words)], "OneTwoThree onetwothree") 138 | 139 | run.options("can merge multiple sources", "one", [from(words), from("onet bonae")], "one onet onetwothree OneTwoThree bonae") 140 | 141 | run.options("only shows prefix matches for single-letter queries", "t", [from(words)], "three two") 142 | 143 | run.options("doesn't allow split matches for two-letter queries", "wr", [from(words)], "") 144 | 145 | run.options("prefers case-matched completions", "eTw", [from(words)], "OneTwoThree onetwothree") 146 | 147 | run.options("allows everything for empty patterns", "", [from("a b foo")], "a b foo") 148 | 149 | run.options("sorts alphabetically when score is equal", "a", [from("ac ab acc")], "ab ac acc") 150 | 151 | run.options("removes duplicate options", "t", [from("two"), from("two three")], "three two") 152 | 153 | run.options("handles all-uppercase words", "sel", [from("SCOPE_CATALOG SELECT SELECTIVE")], "SELECT SELECTIVE SCOPE_CATALOG") 154 | 155 | run.options("penalizes by-word matches with gaps", "abc", [from("xabc aVeryBigCar")], "xabc aVeryBigCar") 156 | 157 | run.options("prefers shorter options", "hair", [ 158 | completeFromList(["aVerySmallChair", "Hairstyle", "chair", "BigChair"]) 159 | ], "Hairstyle chair BigChair aVerySmallChair") 160 | 161 | run.test("will eagerly populate the result list when a source is slow", { 162 | doc: "on", 163 | sources: [from("one two"), slow(from("ono"), 100)] 164 | }, async (view, sync) => { 165 | startCompletion(view) 166 | await sync(options, "one") 167 | await sync(options, "one ono") 168 | }) 169 | 170 | run.test("starts completion on input", {sources: [from("one two")]}, async (view, sync) => { 171 | type(view, "o") 172 | await sync(options, "one") 173 | }) 174 | 175 | run.test("further narrows completions on input", {sources: [once(from("one okay ono"))]}, async (view, sync) => { 176 | type(view, "o") 177 | await sync(options, "okay one ono") 178 | type(view, "n") 179 | await sync(options, "one ono") 180 | type(view, "e") 181 | await sync(options, "one") 182 | type(view, "k") 183 | await sync(options, "") 184 | }) 185 | 186 | run.test("doesn't abort on backspace", {sources: [once(from("one okay")), once(from("ohai"))]}, async (view, sync) => { 187 | type(view, "on") 188 | await sync(options, "one") 189 | del(view) 190 | await sync(options, "ohai okay one") 191 | del(view) 192 | await sync(options, "") 193 | }) 194 | 195 | run.test("can backspace out entire word when explicit", {sources: [from("one two")]}, async (view, sync) => { 196 | startCompletion(view) 197 | await sync(options, "one two") 198 | type(view, "o") 199 | await sync(options, "one") 200 | del(view) 201 | await sync(options, "one two") 202 | }) 203 | 204 | run.test("stops explicit completion on non-spanning input", {sources: [from("one two")]}, async (view, sync) => { 205 | startCompletion(view) 206 | await sync(options, "one two") 207 | type(view, "o") 208 | await sync(options, "one") 209 | type(view, " ") 210 | await sync(options, "") 211 | del(view) 212 | await sync(options, "") 213 | }) 214 | 215 | run.test("stops explicit completion when backspacing past start", { 216 | doc: "foo.o", 217 | sources: [from("one two")] 218 | }, async (view, sync) => { 219 | startCompletion(view) 220 | await sync(options, "one") 221 | del(view) 222 | await sync(options, "one two") 223 | del(view) 224 | await sync(options, "") 225 | }) 226 | 227 | run.test("stops explicit completions for non-matching input", {sources: [from("one")]}, async (view, sync) => { 228 | startCompletion(view) 229 | await sync(options, "one") 230 | type(view, "x") 231 | await sync(options, "") 232 | del(view) 233 | await sync(options, "") 234 | }) 235 | 236 | run.test("resets selection after refinement", { 237 | sources: [once(from("primitive-classnames print proxy"))] 238 | }, async (view, sync) => { 239 | type(view, "p") 240 | await sync(options, "primitive-classnames print proxy") 241 | type(view, "rin") 242 | await sync(options, "print primitive-classnames") 243 | ist(view.dom.querySelector("[aria-selected]")?.textContent, "print") 244 | }) 245 | 246 | run.test("calls sources again when necessary", {sources: [tagged(true)]}, async (view, sync) => { 247 | type(view, "t") 248 | await sync(options, "tag1") 249 | type(view, " t") 250 | await sync(options, "tag3") 251 | }) 252 | 253 | run.test("always calls span-less sources", {sources: [tagged(false)]}, async (view, sync) => { 254 | startCompletion(view) 255 | await sync(options, "tag0") 256 | type(view, "ta") 257 | await sync(options, "tag2") 258 | del(view) 259 | await sync(options, "tag1") 260 | del(view) 261 | await sync(options, "tag0") 262 | }) 263 | 264 | run.test("adjust completions when changes happen during query", { 265 | sources: [slow(once(from("one ok")), 100)] 266 | }, async (view, sync) => { 267 | type(view, "o") 268 | await sleep(80) 269 | type(view, "n") 270 | await sync(options, "one") 271 | }) 272 | 273 | run.test("doesn't cancel completions when deleting before they finish", { 274 | sources: [slow(tagged(false), 80)] 275 | }, async (view, sync) => { 276 | type(view, "ta") 277 | await sleep(80) 278 | del(view) 279 | await sync(options, "tag1") 280 | }) 281 | 282 | run.test("preserves the dialog on irrelevant changes", { 283 | sources: [from("one two")], 284 | doc: "woo o" 285 | }, async (view, sync) => { 286 | startCompletion(view) 287 | await sync(options, "one") 288 | let dialog = view.dom.querySelector(".cm-tooltip") 289 | ist(dialog) 290 | view.dispatch({changes: {from: 0, insert: "!"}}) 291 | ist(view.dom.querySelector(".cm-tooltip"), dialog) 292 | }) 293 | 294 | run.test("replaces entire selected ranges", { 295 | sources: [from("one hey")], 296 | doc: "hello world", 297 | selection: EditorSelection.single(1, 5) 298 | }, async (view, sync) => { 299 | startCompletion(view) 300 | await sync(options, "hey") 301 | acceptCompletion(view) 302 | ist(view.state.doc.toString(), "hey world") 303 | }) 304 | 305 | run.test("replaces inverted ranges", { 306 | sources: [from("one hey")], 307 | doc: "hello world", 308 | selection: EditorSelection.single(5, 1) 309 | }, async (view, sync) => { 310 | startCompletion(view) 311 | await sync(options, "hey") 312 | acceptCompletion(view) 313 | ist(view.state.doc.toString(), "hey world") 314 | }) 315 | 316 | run.test("can cover range beyond cursor", { 317 | sources: [cx => ({from: 0, to: 4, options: [{label: "brrrr"}]})], 318 | doc: "brrr" 319 | }, async (view, sync) => { 320 | startCompletion(view) 321 | await sync(options, "brrrr") 322 | acceptCompletion(view) 323 | ist(view.state.doc.toString(), "brrrr") 324 | }) 325 | 326 | run.test("complete from list", {sources: [once(completeFromList(["one", "two", "three"]))], doc: "t"}, async (view, sync) => { 327 | startCompletion(view) 328 | await sync(options, "three two") 329 | type(view, "h") 330 | await sync(options, "three") 331 | del(view) 332 | await sync(options, "three two") 333 | del(view) 334 | await sync(options, "one three two") 335 | }) 336 | 337 | run.test("complete from nonalphabetic list", { 338 | sources: [completeFromList(["$foo.bar", "$baz.boop", "$foo.quux"])] 339 | }, async (view, sync) => { 340 | type(view, "x") 341 | await sync(v => completionStatus(v), null) 342 | type(view, "$") 343 | await sync(options, "$baz.boop $foo.bar $foo.quux") 344 | type(view, "foo.b") 345 | await sync(options, "$foo.bar") 346 | }) 347 | 348 | let events: string[] = [] 349 | run.test("calls abort handlers", { 350 | sources: [async cx => { 351 | events.push("start " + cx.aborted) 352 | cx.addEventListener("abort", () => events.push("aborted")) 353 | await sleep(50) 354 | events.push("fin " + cx.aborted) 355 | return from("one two")(cx) 356 | }], 357 | doc: "one two\nthree four " 358 | }, async (view) => { 359 | startCompletion(view) 360 | await sleep(80) 361 | view.dispatch({selection: {anchor: 1}}) 362 | await sleep(80) 363 | ist(events.join(", "), "start false, aborted, fin true") 364 | }) 365 | 366 | run.test("supports unfitered completions", { 367 | sources: [completeFromList(["one", "two"]), cx => ({from: cx.pos, options: [{label: "ok"}, {label: "hah"}], filter: false})], 368 | doc: "o" 369 | }, async (view, sync) => { 370 | startCompletion(view) 371 | await sync(options, "ok hah one") 372 | }) 373 | 374 | run.test("will complete for multiple cursors", { 375 | sources: [from("okay")], 376 | doc: "o\no", 377 | selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(3)]) 378 | }, async (view, sync) => { 379 | startCompletion(view) 380 | await sync(options, "okay") 381 | await sleep(80) 382 | acceptCompletion(view) 383 | ist(view.state.doc.toString(), "okay\nokay") 384 | }) 385 | 386 | run.test("will not complete for multiple cursors if prefix doesn't match", { 387 | sources: [from("okay allo")], 388 | doc: "o\na", 389 | selection: EditorSelection.create([EditorSelection.cursor(1), EditorSelection.cursor(3)]) 390 | }, async (view, sync) => { 391 | startCompletion(view) 392 | await sync(options, "okay") 393 | acceptCompletion(view) 394 | ist(view.state.doc.toString(), "okay\na") 395 | }) 396 | 397 | run.test("can synchronously update results", {sources: [cx => ({ 398 | options: [{label: "a"}, {label: "aha"}], 399 | from: 0, 400 | update: (r, from, to) => ({options: r.options.filter(o => o.label.length > 1), from: r.from}) 401 | })]}, async (view, sync) => { 402 | type(view, "a") 403 | await sync(options, "a aha") 404 | type(view, "h") 405 | ist(options(view.state), "aha") 406 | }) 407 | 408 | run.test("preserves completion position when changes happen", { 409 | sources: [from("pow")], 410 | doc: "\n\n", 411 | selection: 2 412 | }, async (view, sync) => { 413 | startCompletion(view) 414 | await sync(options, "pow") 415 | view.dispatch({changes: {from: 0, insert: "woooooo"}}) 416 | acceptCompletion(view) 417 | ist(view.state.doc.toString(), "woooooo\n\npow") 418 | }) 419 | return run.finish() 420 | }) 421 | }) 422 | --------------------------------------------------------------------------------