widget.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. IIterator, IterableOrArrayLike, toArray
  5. } from '@phosphor/algorithm';
  6. import {
  7. JSONObject
  8. } from '@phosphor/coreutils';
  9. import {
  10. IDisposable
  11. } from '@phosphor/disposable';
  12. import {
  13. Message
  14. } from '@phosphor/messaging';
  15. import {
  16. ISignal, Signal
  17. } from '@phosphor/signaling';
  18. import {
  19. ElementExt
  20. } from '@phosphor/domutils';
  21. import {
  22. Widget
  23. } from '@phosphor/widgets';
  24. import {
  25. CodeEditor
  26. } from '@jupyterlab/codeeditor';
  27. import {
  28. HoverBox
  29. } from '@jupyterlab/apputils';
  30. /**
  31. * The class name added to completer menu widgets.
  32. */
  33. const COMPLETER_CLASS = 'jp-Completer';
  34. /**
  35. * The class name added to completer menu items.
  36. */
  37. const ITEM_CLASS = 'jp-Completer-item';
  38. /**
  39. * The class name added to an active completer menu item.
  40. */
  41. const ACTIVE_CLASS = 'jp-mod-active';
  42. /**
  43. * The minimum height of a completer widget.
  44. */
  45. const MIN_HEIGHT = 20;
  46. /**
  47. * The maximum height of a completer widget.
  48. */
  49. const MAX_HEIGHT = 200;
  50. /**
  51. * A flag to indicate that event handlers are caught in the capture phase.
  52. */
  53. const USE_CAPTURE = true;
  54. /**
  55. * A widget that enables text completion.
  56. */
  57. export
  58. class Completer extends Widget {
  59. /**
  60. * Construct a text completer menu widget.
  61. */
  62. constructor(options: Completer.IOptions) {
  63. super({ node: document.createElement('ul') });
  64. this._renderer = options.renderer || Completer.defaultRenderer;
  65. this.model = options.model || null;
  66. this.editor = options.editor || null;
  67. this.addClass(COMPLETER_CLASS);
  68. }
  69. /**
  70. * The editor used by the completion widget.
  71. */
  72. get editor(): CodeEditor.IEditor | null {
  73. return this._editor;
  74. }
  75. set editor(newValue: CodeEditor.IEditor | null) {
  76. this._editor = newValue;
  77. }
  78. /**
  79. * A signal emitted when a selection is made from the completer menu.
  80. */
  81. get selected(): ISignal<this, string> {
  82. return this._selected;
  83. }
  84. /**
  85. * A signal emitted when the completer widget's visibility changes.
  86. *
  87. * #### Notes
  88. * This signal is useful when there are multiple floating widgets that may
  89. * contend with the same space and ought to be mutually exclusive.
  90. */
  91. get visibilityChanged(): ISignal<this, void> {
  92. return this._visibilityChanged;
  93. }
  94. /**
  95. * The model used by the completer widget.
  96. */
  97. get model(): Completer.IModel | null {
  98. return this._model;
  99. }
  100. set model(model: Completer.IModel | null) {
  101. if (!model && !this._model || model === this._model) {
  102. return;
  103. }
  104. if (this._model) {
  105. this._model.stateChanged.disconnect(this.onModelStateChanged, this);
  106. }
  107. this._model = model;
  108. if (this._model) {
  109. this._model.stateChanged.connect(this.onModelStateChanged, this);
  110. }
  111. }
  112. /**
  113. * Dispose of the resources held by the completer widget.
  114. */
  115. dispose() {
  116. this._model = null;
  117. super.dispose();
  118. }
  119. /**
  120. * Handle the DOM events for the widget.
  121. *
  122. * @param event - The DOM event sent to the widget.
  123. *
  124. * #### Notes
  125. * This method implements the DOM `EventListener` interface and is
  126. * called in response to events on the dock panel's node. It should
  127. * not be called directly by user code.
  128. */
  129. handleEvent(event: Event): void {
  130. if (this.isHidden || !this._editor) {
  131. return;
  132. }
  133. switch (event.type) {
  134. case 'keydown':
  135. this._evtKeydown(event as KeyboardEvent);
  136. break;
  137. case 'mousedown':
  138. this._evtMousedown(event as MouseEvent);
  139. break;
  140. case 'scroll':
  141. this._evtScroll(event as MouseEvent);
  142. break;
  143. default:
  144. break;
  145. }
  146. }
  147. /**
  148. * Reset the widget.
  149. */
  150. reset(): void {
  151. this._activeIndex = 0;
  152. if (this._model) {
  153. this._model.reset(true);
  154. }
  155. }
  156. /**
  157. * Emit the selected signal for the current active item and reset.
  158. */
  159. selectActive(): void {
  160. let active = this.node.querySelector(`.${ACTIVE_CLASS}`) as HTMLElement;
  161. if (!active) {
  162. this.reset();
  163. return;
  164. }
  165. this._selected.emit(active.getAttribute('data-value') as string);
  166. this.reset();
  167. }
  168. /**
  169. * Handle `after-attach` messages for the widget.
  170. */
  171. protected onAfterAttach(msg: Message): void {
  172. document.addEventListener('keydown', this, USE_CAPTURE);
  173. document.addEventListener('mousedown', this, USE_CAPTURE);
  174. document.addEventListener('scroll', this, USE_CAPTURE);
  175. }
  176. /**
  177. * Handle `before-detach` messages for the widget.
  178. */
  179. protected onBeforeDetach(msg: Message): void {
  180. document.removeEventListener('keydown', this, USE_CAPTURE);
  181. document.removeEventListener('mousedown', this, USE_CAPTURE);
  182. document.removeEventListener('scroll', this, USE_CAPTURE);
  183. }
  184. /**
  185. * Handle model state changes.
  186. */
  187. protected onModelStateChanged(): void {
  188. if (this.isAttached) {
  189. this._activeIndex = 0;
  190. this.update();
  191. }
  192. }
  193. /**
  194. * Handle `update-request` messages.
  195. */
  196. protected onUpdateRequest(msg: Message): void {
  197. const model = this._model;
  198. if (!model) {
  199. return;
  200. }
  201. if (this._resetFlag) {
  202. this._resetFlag = false;
  203. if (!this.isHidden) {
  204. this.hide();
  205. this._visibilityChanged.emit(void 0);
  206. }
  207. return;
  208. }
  209. let items = toArray(model.items());
  210. // If there are no items, reset and bail.
  211. if (!items || !items.length) {
  212. this._resetFlag = true;
  213. this.reset();
  214. if (!this.isHidden) {
  215. this.hide();
  216. this._visibilityChanged.emit(void 0);
  217. }
  218. return;
  219. }
  220. // If there is only one item, signal and bail.
  221. if (items.length === 1) {
  222. this._selected.emit(items[0].raw);
  223. this.reset();
  224. return;
  225. }
  226. // Clear the node.
  227. let node = this.node;
  228. node.textContent = '';
  229. // Populate the completer items.
  230. for (let item of items) {
  231. let li = this._renderer.createItemNode(item!);
  232. // Set the raw, un-marked up value as a data attribute.
  233. li.setAttribute('data-value', item.raw);
  234. node.appendChild(li);
  235. }
  236. let active = node.querySelectorAll(`.${ITEM_CLASS}`)[this._activeIndex];
  237. active.classList.add(ACTIVE_CLASS);
  238. // If this is the first time the current completer session has loaded,
  239. // populate any initial subset match.
  240. if (this._model && this._model.subsetMatch) {
  241. let populated = this._populateSubset();
  242. this.model.subsetMatch = false;
  243. if (populated) {
  244. this.update();
  245. return;
  246. }
  247. }
  248. if (this.isHidden) {
  249. this.show();
  250. this._setGeometry();
  251. this._visibilityChanged.emit(void 0);
  252. } else {
  253. this._setGeometry();
  254. }
  255. }
  256. /**
  257. * Cycle through the available completer items.
  258. *
  259. * #### Notes
  260. * When the user cycles all the way `down` to the last index, subsequent
  261. * `down` cycles will remain on the last index. When the user cycles `up` to
  262. * the first item, subsequent `up` cycles will remain on the first cycle.
  263. */
  264. private _cycle(direction: 'up' | 'down'): void {
  265. let items = this.node.querySelectorAll(`.${ITEM_CLASS}`);
  266. let index = this._activeIndex;
  267. let active = this.node.querySelector(`.${ACTIVE_CLASS}`) as HTMLElement;
  268. active.classList.remove(ACTIVE_CLASS);
  269. if (direction === 'up') {
  270. this._activeIndex = index === 0 ? index : index - 1;
  271. } else {
  272. this._activeIndex = index < items.length - 1 ? index + 1 : index;
  273. }
  274. active = items[this._activeIndex] as HTMLElement;
  275. active.classList.add(ACTIVE_CLASS);
  276. ElementExt.scrollIntoViewIfNeeded(this.node, active);
  277. }
  278. /**
  279. * Handle keydown events for the widget.
  280. */
  281. private _evtKeydown(event: KeyboardEvent) {
  282. if (this.isHidden || !this._editor) {
  283. return;
  284. }
  285. if (!this._editor.host.contains(event.target as HTMLElement)) {
  286. this.reset();
  287. return;
  288. }
  289. switch (event.keyCode) {
  290. case 9: // Tab key
  291. event.preventDefault();
  292. event.stopPropagation();
  293. event.stopImmediatePropagation();
  294. let model = this._model;
  295. if (!model) {
  296. return;
  297. }
  298. model.subsetMatch = true;
  299. let populated = this._populateSubset();
  300. model.subsetMatch = false;
  301. if (populated) {
  302. return;
  303. }
  304. this.selectActive();
  305. return;
  306. case 27: // Esc key
  307. event.preventDefault();
  308. event.stopPropagation();
  309. event.stopImmediatePropagation();
  310. this.reset();
  311. return;
  312. case 38: // Up arrow key
  313. case 40: // Down arrow key
  314. event.preventDefault();
  315. event.stopPropagation();
  316. event.stopImmediatePropagation();
  317. this._cycle(event.keyCode === 38 ? 'up' : 'down');
  318. return;
  319. default:
  320. return;
  321. }
  322. }
  323. /**
  324. * Handle mousedown events for the widget.
  325. */
  326. private _evtMousedown(event: MouseEvent) {
  327. if (this.isHidden || !this._editor) {
  328. return;
  329. }
  330. if (Private.nonstandardClick(event)) {
  331. this.reset();
  332. return;
  333. }
  334. let target = event.target as HTMLElement;
  335. while (target !== document.documentElement) {
  336. // If the user has made a selection, emit its value and reset the widget.
  337. if (target.classList.contains(ITEM_CLASS)) {
  338. event.preventDefault();
  339. event.stopPropagation();
  340. event.stopImmediatePropagation();
  341. this._selected.emit(target.getAttribute('data-value') as string);
  342. this.reset();
  343. return;
  344. }
  345. // If the mouse event happened anywhere else in the widget, bail.
  346. if (target === this.node) {
  347. event.preventDefault();
  348. event.stopPropagation();
  349. event.stopImmediatePropagation();
  350. return;
  351. }
  352. target = target.parentElement as HTMLElement;
  353. }
  354. this.reset();
  355. }
  356. /**
  357. * Handle scroll events for the widget
  358. */
  359. private _evtScroll(event: MouseEvent) {
  360. if (this.isHidden || !this._editor) {
  361. return;
  362. }
  363. // All scrolls except scrolls in the actual hover box node may cause the
  364. // referent editor that anchors the node to move, so the only scroll events
  365. // that can safely be ignored are ones that happen inside the hovering node.
  366. if (this.node.contains(event.target as HTMLElement)) {
  367. return;
  368. }
  369. this._setGeometry();
  370. }
  371. /**
  372. * Populate the completer up to the longest initial subset of items.
  373. *
  374. * @returns `true` if a subset match was found and populated.
  375. */
  376. private _populateSubset(): boolean {
  377. let items = this.node.querySelectorAll(`.${ITEM_CLASS}`);
  378. let subset = Private.commonSubset(Private.itemValues(items));
  379. if (!this.model) {
  380. return false;
  381. }
  382. let query = this.model.query;
  383. if (subset && subset !== query && subset.indexOf(query) === 0) {
  384. this.model.query = subset;
  385. this._selected.emit(subset);
  386. return true;
  387. }
  388. return false;
  389. }
  390. /**
  391. * Set the visible dimensions of the widget.
  392. */
  393. private _setGeometry(): void {
  394. const model = this._model;
  395. const editor = this._editor;
  396. if (!editor) {
  397. return;
  398. }
  399. // This is an overly defensive test: `cursor` will always exist if
  400. // `original` exists, except in contrived tests. But since it is possible
  401. // to generate a runtime error, the check occurs here.
  402. if (!model || !model.original || !model.cursor) {
  403. return;
  404. }
  405. const position = editor.getPositionAt(model.cursor.start) as CodeEditor.IPosition;
  406. const anchor = editor.getCoordinateForPosition(position) as ClientRect;
  407. const style = window.getComputedStyle(this.node);
  408. const borderLeft = parseInt(style.borderLeftWidth!, 10) || 0;
  409. const paddingLeft = parseInt(style.paddingLeft!, 10) || 0;
  410. // Calculate the geometry of the completer.
  411. HoverBox.setGeometry({
  412. anchor,
  413. host: editor.host,
  414. maxHeight: MAX_HEIGHT,
  415. minHeight: MIN_HEIGHT,
  416. node: this.node,
  417. offset: { horizontal: borderLeft + paddingLeft },
  418. privilege: 'below'
  419. });
  420. }
  421. private _activeIndex = 0;
  422. private _editor: CodeEditor.IEditor | null = null;
  423. private _model: Completer.IModel | null = null;
  424. private _renderer: Completer.IRenderer | null = null;
  425. private _resetFlag = false;
  426. private _selected = new Signal<this, string>(this);
  427. private _visibilityChanged = new Signal<this, void>(this);
  428. }
  429. export
  430. namespace Completer {
  431. /**
  432. * The initialization options for a completer widget.
  433. */
  434. export
  435. interface IOptions {
  436. /**
  437. * The semantic parent of the completer widget, its referent editor.
  438. */
  439. editor?: CodeEditor.IEditor | null;
  440. /**
  441. * The model for the completer widget.
  442. */
  443. model?: IModel;
  444. /**
  445. * The renderer for the completer widget nodes.
  446. */
  447. renderer?: IRenderer;
  448. }
  449. /**
  450. * An interface for a completion request reflecting the state of the editor.
  451. */
  452. export
  453. interface ITextState extends JSONObject {
  454. /**
  455. * The current value of the editor.
  456. */
  457. readonly text: string;
  458. /**
  459. * The height of a character in the editor.
  460. */
  461. readonly lineHeight: number;
  462. /**
  463. * The width of a character in the editor.
  464. */
  465. readonly charWidth: number;
  466. /**
  467. * The line number of the editor cursor.
  468. */
  469. readonly line: number;
  470. /**
  471. * The character number of the editor cursor within a line.
  472. */
  473. readonly column: number;
  474. }
  475. /**
  476. * The data model backing a code completer widget.
  477. */
  478. export
  479. interface IModel extends IDisposable {
  480. /**
  481. * A signal emitted when state of the completer menu changes.
  482. */
  483. readonly stateChanged: ISignal<IModel, void>;
  484. /**
  485. * The current text state details.
  486. */
  487. current: ITextState | null;
  488. /**
  489. * The cursor details that the API has used to return matching options.
  490. */
  491. cursor: ICursorSpan | null;
  492. /**
  493. * A flag that is true when the model value was modified by a subset match.
  494. */
  495. subsetMatch: boolean;
  496. /**
  497. * The original completer request details.
  498. */
  499. original: ITextState | null;
  500. /**
  501. * The query against which items are filtered.
  502. */
  503. query: string;
  504. /**
  505. * Get the of visible items in the completer menu.
  506. */
  507. items(): IIterator<IItem>;
  508. /**
  509. * Get the unfiltered options in a completer menu.
  510. */
  511. options(): IIterator<string>;
  512. /**
  513. * Set the avilable options in the completer menu.
  514. */
  515. setOptions(options: IterableOrArrayLike<string>): void;
  516. /**
  517. * Handle a cursor change.
  518. */
  519. handleCursorChange(change: Completer.ITextState): void;
  520. /**
  521. * Handle a completion request.
  522. */
  523. handleTextChange(change: Completer.ITextState): void;
  524. /**
  525. * Create a resolved patch between the original state and a patch string.
  526. */
  527. createPatch(patch: string): IPatch | undefined;
  528. /**
  529. * Reset the state of the model and emit a state change signal.
  530. *
  531. * @param hard - Reset even if a subset match is in progress.
  532. */
  533. reset(hard?: boolean): void;
  534. }
  535. /**
  536. * An object describing a completion option injection into text.
  537. */
  538. export
  539. interface IPatch {
  540. /**
  541. * The patched text.
  542. */
  543. text: string;
  544. /**
  545. * The offset of the cursor.
  546. */
  547. offset: number;
  548. }
  549. /**
  550. * A completer menu item.
  551. */
  552. export
  553. interface IItem {
  554. /**
  555. * The highlighted, marked up text of a visible completer item.
  556. */
  557. text: string;
  558. /**
  559. * The raw text of a visible completer item.
  560. */
  561. raw: string;
  562. }
  563. /**
  564. * A cursor span.
  565. */
  566. export
  567. interface ICursorSpan extends JSONObject {
  568. /**
  569. * The start position of the cursor.
  570. */
  571. start: number;
  572. /**
  573. * The end position of the cursor.
  574. */
  575. end: number;
  576. }
  577. /**
  578. * A renderer for completer widget nodes.
  579. */
  580. export
  581. interface IRenderer {
  582. /**
  583. * Create an item node (an `li` element) for a text completer menu.
  584. */
  585. createItemNode(item: IItem): HTMLLIElement;
  586. }
  587. /**
  588. * The default implementation of an `IRenderer`.
  589. */
  590. export
  591. class Renderer implements IRenderer {
  592. /**
  593. * Create an item node for a text completer menu.
  594. */
  595. createItemNode(item: IItem): HTMLLIElement {
  596. let li = document.createElement('li');
  597. let code = document.createElement('code');
  598. // Use innerHTML because search results include <mark> tags.
  599. code.innerHTML = item.text;
  600. li.className = ITEM_CLASS;
  601. li.appendChild(code);
  602. return li;
  603. }
  604. }
  605. /**
  606. * The default `IRenderer` instance.
  607. */
  608. export
  609. const defaultRenderer = new Renderer();
  610. }
  611. /**
  612. * A namespace for completer widget private data.
  613. */
  614. namespace Private {
  615. /**
  616. * Returns the common subset string that a list of strings shares.
  617. */
  618. export
  619. function commonSubset(values: string[]): string {
  620. let len = values.length;
  621. let subset = '';
  622. if (len < 2) {
  623. return subset;
  624. }
  625. let strlen = values[0].length;
  626. for (let i = 0; i < strlen; i++) {
  627. let ch = values[0][i];
  628. for (let j = 1; j < len; j++) {
  629. if (values[j][i] !== ch) {
  630. return subset;
  631. }
  632. }
  633. subset += ch;
  634. }
  635. return subset;
  636. }
  637. /**
  638. * Returns the list of raw item values currently in the DOM.
  639. */
  640. export
  641. function itemValues(items: NodeList): string[] {
  642. let values: string[] = [];
  643. for (let i = 0, len = items.length; i < len; i++) {
  644. values.push((items[i] as HTMLElement).getAttribute('data-value') as string);
  645. }
  646. return values;
  647. }
  648. /**
  649. * Returns true for any modified click event (i.e., not a left-click).
  650. */
  651. export
  652. function nonstandardClick(event: MouseEvent): boolean {
  653. return event.button !== 0 ||
  654. event.altKey ||
  655. event.ctrlKey ||
  656. event.shiftKey ||
  657. event.metaKey;
  658. }
  659. }