index.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. import { IEditorTracker } from '@jupyterlab/fileeditor';
  2. import { INotebookTracker } from '@jupyterlab/notebook';
  3. import { LabIcon } from '@jupyterlab/ui-components';
  4. import { ICommandPalette, InputDialog, ReactWidget } from '@jupyterlab/apputils';
  5. import { Menu } from '@lumino/widgets';
  6. import { ISettingRegistry } from '@jupyterlab/settingregistry';
  7. import { IStatusBar, TextItem } from '@jupyterlab/statusbar';
  8. import { ICodeMirror } from '@jupyterlab/codemirror';
  9. import { ITranslator, nullTranslator } from '@jupyterlab/translation';
  10. import { requestAPI } from './handler';
  11. import '../style/index.css';
  12. import spellcheckSvg from '../style/icons/ic-baseline-spellcheck.svg';
  13. export const spellcheckIcon = new LabIcon({
  14. name: 'spellcheck:spellcheck',
  15. svgstr: spellcheckSvg
  16. });
  17. // eslint-disable-next-line @typescript-eslint/no-var-requires
  18. const Typo = require('typo-js');
  19. class LanguageManager {
  20. /**
  21. * initialise the manager
  22. * mainly reading the definitions from the external extension
  23. */
  24. constructor(settingsRegistry) {
  25. const loadSettings = settingsRegistry.load(extension.id).then(settings => {
  26. this.updateSettings(settings);
  27. settings.changed.connect(() => {
  28. this.updateSettings(settings);
  29. });
  30. });
  31. this.ready = Promise.all([
  32. this.fetchServerDictionariesList(),
  33. loadSettings
  34. ]).then(() => {
  35. console.debug('LanguageManager is ready');
  36. });
  37. }
  38. updateSettings(settings) {
  39. if (settings) {
  40. this.onlineDictionaries = settings.get('onlineDictionaries').composite.map(dictionary => {
  41. return Object.assign(Object.assign({}, dictionary), { isOnline: true });
  42. });
  43. }
  44. }
  45. /**
  46. * Read the list of languages from the server extension
  47. */
  48. fetchServerDictionariesList() {
  49. return requestAPI('language_manager').then(values => {
  50. console.debug('Dictionaries fetched from server');
  51. this.serverDictionaries = values.dictionaries.map(dictionary => {
  52. return Object.assign(Object.assign({}, dictionary), { isOnline: false });
  53. });
  54. });
  55. }
  56. get dictionaries() {
  57. return [...this.serverDictionaries, ...this.onlineDictionaries];
  58. }
  59. /**
  60. * get an array of languages, put "language" in front of the list
  61. * the list is alphabetically sorted
  62. */
  63. getChoices(language) {
  64. const options = language
  65. ? [language, ...this.dictionaries.filter(l => l.id !== language.id)]
  66. : this.dictionaries;
  67. return options.sort((a, b) => a.name.localeCompare(b.name));
  68. }
  69. /**
  70. * select the language by the identifier
  71. */
  72. getLanguageByIdentifier(identifier) {
  73. const exactMatch = this.dictionaries.find(l => l.id === identifier);
  74. if (exactMatch) {
  75. return exactMatch;
  76. }
  77. // approximate matches support transition from the 0.5 version (and older)
  78. // that used incorrect codes as language identifiers
  79. const approximateMatch = this.dictionaries.find(l => l.id.toLowerCase() === identifier.replace('-', '_').toLowerCase());
  80. if (approximateMatch) {
  81. console.warn(`Language identifier ${identifier} has a non-exact match, please update it to ${approximateMatch.id}`);
  82. return approximateMatch;
  83. }
  84. }
  85. }
  86. class StatusWidget extends ReactWidget {
  87. constructor(source) {
  88. super();
  89. this.language_source = source;
  90. }
  91. render() {
  92. return TextItem({ source: this.language_source() });
  93. }
  94. }
  95. /**
  96. * SpellChecker
  97. */
  98. class SpellChecker {
  99. constructor(app, tracker, editor_tracker, setting_registry, code_mirror, translator, palette, status_bar) {
  100. this.app = app;
  101. this.tracker = tracker;
  102. this.editor_tracker = editor_tracker;
  103. this.setting_registry = setting_registry;
  104. this.code_mirror = code_mirror;
  105. this.palette = palette;
  106. this.status_bar = status_bar;
  107. // Default Options
  108. this.check_spelling = true;
  109. this.rx_word_char = /[^-[\]{}():/!;&@$£%§<>"*+=?.,~\\^|_`#±\s\t]/;
  110. this.rx_non_word_char = /[-[\]{}():/!;&@$£%§<>"*+=?.,~\\^|_`#±\s\t]/;
  111. this.ignored_tokens = new Set();
  112. this.define_mode = (original_mode_spec) => {
  113. if (original_mode_spec.indexOf('spellcheck_') === 0) {
  114. return original_mode_spec;
  115. }
  116. const new_mode_spec = 'spellcheck_' + original_mode_spec;
  117. this.code_mirror.CodeMirror.defineMode(new_mode_spec, (config) => {
  118. const spellchecker_overlay = {
  119. name: new_mode_spec,
  120. token: (stream, state) => {
  121. if (stream.eatWhile(this.rx_word_char)) {
  122. const word = stream.current().replace(/(^')|('$)/g, '');
  123. if (word !== '' &&
  124. !word.match(/^\d+$/) &&
  125. this.dictionary !== undefined &&
  126. !this.dictionary.check(word) &&
  127. !this.ignored_tokens.has(word)) {
  128. return 'spell-error';
  129. }
  130. }
  131. stream.eatWhile(this.rx_non_word_char);
  132. return null;
  133. }
  134. };
  135. return this.code_mirror.CodeMirror.overlayMode(this.code_mirror.CodeMirror.getMode(config, original_mode_spec), spellchecker_overlay, true);
  136. });
  137. return new_mode_spec;
  138. };
  139. // use the language_manager
  140. this.language_manager = new LanguageManager(setting_registry);
  141. this._trans = translator.load('jupyterlab-spellchecker');
  142. this.status_msg = this._trans.__('Dictionary not loaded');
  143. this.TEXT_SUGGESTIONS_AVAILABLE = this._trans.__('Adjust spelling to');
  144. this.TEXT_NO_SUGGESTIONS = this._trans.__('No spellcheck suggestions');
  145. this.PALETTE_CATEGORY = this._trans.__('Spell Checker');
  146. // read the settings
  147. this.setup_settings();
  148. // setup the static content of the spellchecker UI
  149. this.setup_button();
  150. this.setup_suggestions();
  151. this.setup_language_picker();
  152. this.setup_ignore_action();
  153. this.tracker.activeCellChanged.connect(() => {
  154. if (this.tracker.activeCell) {
  155. this.setup_cell_editor(this.tracker.activeCell);
  156. }
  157. });
  158. // setup newly open editors
  159. this.editor_tracker.widgetAdded.connect((sender, widget) => this.setup_file_editor(widget.content, true));
  160. // refresh already open editors when activated (because the MIME type might have changed)
  161. this.editor_tracker.currentChanged.connect((sender, widget) => {
  162. if (widget !== null) {
  163. this.setup_file_editor(widget.content, false);
  164. }
  165. });
  166. }
  167. // move the load_dictionary into the setup routine, because then
  168. // we know that the values are set correctly!
  169. setup_settings() {
  170. Promise.all([
  171. this.setting_registry.load(extension.id),
  172. this.app.restored,
  173. this.language_manager.ready
  174. ])
  175. .then(([settings]) => {
  176. this.update_settings(settings);
  177. settings.changed.connect(() => {
  178. this.update_settings(settings);
  179. });
  180. })
  181. .catch((reason) => {
  182. console.error(reason.message);
  183. });
  184. }
  185. _set_theme(name) {
  186. document.body.setAttribute('data-jp-spellchecker-theme', name);
  187. }
  188. update_settings(settings) {
  189. this.settings = settings;
  190. const tokens = settings.get('ignore').composite;
  191. this.ignored_tokens = new Set(tokens);
  192. this.accepted_types = settings.get('mimeTypes').composite;
  193. const theme = settings.get('theme').composite;
  194. this._set_theme(theme);
  195. // read the saved language setting
  196. const language_id = settings.get('language').composite;
  197. const user_language = this.language_manager.getLanguageByIdentifier(language_id);
  198. if (user_language === undefined) {
  199. console.warn('The language ' + language_id + ' is not supported!');
  200. }
  201. else {
  202. this.language = user_language;
  203. // load the dictionary
  204. this.load_dictionary().catch(console.warn);
  205. }
  206. this.refresh_state();
  207. }
  208. setup_file_editor(file_editor, setup_signal = false) {
  209. if (this.accepted_types &&
  210. this.accepted_types.indexOf(file_editor.model.mimeType) !== -1) {
  211. const editor = this.extract_editor(file_editor);
  212. this.setup_overlay(editor);
  213. }
  214. if (setup_signal) {
  215. file_editor.model.mimeTypeChanged.connect((model, args) => {
  216. // putting at the end of execution queue to allow the CodeMirror mode to be updated
  217. setTimeout(() => this.setup_file_editor(file_editor), 0);
  218. });
  219. }
  220. }
  221. setup_cell_editor(cell) {
  222. if (cell !== null && cell.model.type === 'markdown') {
  223. const editor = this.extract_editor(cell);
  224. this.setup_overlay(editor);
  225. }
  226. }
  227. extract_editor(cell_or_editor) {
  228. const editor_temp = cell_or_editor.editor;
  229. return editor_temp.editor;
  230. }
  231. setup_overlay(editor, retry = true) {
  232. const current_mode = editor.getOption('mode');
  233. if (current_mode === 'null') {
  234. if (retry) {
  235. // putting at the end of execution queue to allow the CodeMirror mode to be updated
  236. setTimeout(() => this.setup_overlay(editor, false), 0);
  237. }
  238. return;
  239. }
  240. if (this.check_spelling) {
  241. editor.setOption('mode', this.define_mode(current_mode));
  242. }
  243. else {
  244. const original_mode = current_mode.match(/^spellcheck_/)
  245. ? current_mode.substr(11)
  246. : current_mode;
  247. editor.setOption('mode', original_mode);
  248. }
  249. }
  250. toggle_spellcheck() {
  251. this.check_spelling = !this.check_spelling;
  252. console.log('Spell checking is currently: ', this.check_spelling);
  253. }
  254. setup_button() {
  255. this.app.commands.addCommand("spellchecker:toggle-check-spelling" /* toggle */, {
  256. label: this._trans.__('Toggle spellchecker'),
  257. execute: () => {
  258. this.toggle_spellcheck();
  259. }
  260. });
  261. if (this.palette) {
  262. this.palette.addItem({
  263. command: "spellchecker:toggle-check-spelling" /* toggle */,
  264. category: this.PALETTE_CATEGORY
  265. });
  266. }
  267. }
  268. get_contextmenu_context() {
  269. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  270. // @ts-ignore
  271. const event = this.app._contextMenuEvent;
  272. const target = event.target;
  273. const code_mirror_wrapper = target.closest('.CodeMirror');
  274. if (code_mirror_wrapper === null) {
  275. return null;
  276. }
  277. const code_mirror = code_mirror_wrapper.CodeMirror;
  278. const position = code_mirror.coordsChar({
  279. left: event.clientX,
  280. top: event.clientY
  281. });
  282. return {
  283. editor: code_mirror,
  284. position: position
  285. };
  286. }
  287. /**
  288. * This is different from token as implemented in CodeMirror
  289. * and needed because Markdown does not tokenize words
  290. * (each letter outside of markdown features is a separate token!)
  291. */
  292. get_current_word(context) {
  293. const { editor, position } = context;
  294. const line = editor.getDoc().getLine(position.line);
  295. let start = position.ch;
  296. while (start > 0 && line[start].match(this.rx_word_char)) {
  297. start--;
  298. }
  299. let end = position.ch;
  300. while (end < line.length && line[end].match(this.rx_word_char)) {
  301. end++;
  302. }
  303. return {
  304. line: position.line,
  305. start: start,
  306. end: end,
  307. text: line.substring(start, end)
  308. };
  309. }
  310. setup_suggestions() {
  311. this.suggestions_menu = new Menu({ commands: this.app.commands });
  312. this.suggestions_menu.title.label = this.TEXT_SUGGESTIONS_AVAILABLE;
  313. this.suggestions_menu.title.icon = spellcheckIcon.bindprops({
  314. stylesheet: 'menuItem'
  315. });
  316. // this command is not meant to be show - it is just menu trigger detection hack
  317. this.app.commands.addCommand("spellchecker:update-suggestions" /* updateSuggestions */, {
  318. execute: args => {
  319. // no-op
  320. },
  321. isVisible: args => {
  322. this.prepare_suggestions();
  323. return false;
  324. }
  325. });
  326. this.app.contextMenu.addItem({
  327. selector: '.cm-spell-error',
  328. command: "spellchecker:update-suggestions" /* updateSuggestions */
  329. });
  330. // end of the menu trigger detection hack
  331. this.app.contextMenu.addItem({
  332. selector: '.cm-spell-error',
  333. submenu: this.suggestions_menu,
  334. type: 'submenu'
  335. });
  336. this.app.commands.addCommand("spellchecker:apply-suggestion" /* applySuggestion */, {
  337. execute: args => {
  338. this.apply_suggestion(args['name']);
  339. },
  340. label: args => args['name']
  341. });
  342. }
  343. setup_ignore_action() {
  344. this.app.commands.addCommand("spellchecker:ignore" /* ignoreWord */, {
  345. execute: () => {
  346. this.ignore();
  347. },
  348. label: this._trans.__('Ignore')
  349. });
  350. this.app.contextMenu.addItem({
  351. selector: '.cm-spell-error',
  352. command: "spellchecker:ignore" /* ignoreWord */
  353. });
  354. }
  355. ignore() {
  356. const context = this.get_contextmenu_context();
  357. if (context === null) {
  358. console.log('Could not ignore the word as the context was no longer available');
  359. }
  360. else {
  361. const word = this.get_current_word(context);
  362. this.settings
  363. .set('ignore', [
  364. word.text.trim(),
  365. ...this.settings.get('ignore').composite
  366. ])
  367. .catch(console.warn);
  368. }
  369. }
  370. prepare_suggestions() {
  371. const context = this.get_contextmenu_context();
  372. let suggestions;
  373. if (context === null) {
  374. // no context (e.g. the edit was applied and the token is no longer in DOM,
  375. // so we cannot find the parent editor
  376. suggestions = [];
  377. }
  378. else {
  379. const word = this.get_current_word(context);
  380. suggestions = this.dictionary.suggest(word.text);
  381. }
  382. this.suggestions_menu.clearItems();
  383. if (suggestions.length) {
  384. for (const suggestion of suggestions) {
  385. this.suggestions_menu.addItem({
  386. command: "spellchecker:apply-suggestion" /* applySuggestion */,
  387. args: { name: suggestion }
  388. });
  389. }
  390. this.suggestions_menu.title.label = this.TEXT_SUGGESTIONS_AVAILABLE;
  391. this.suggestions_menu.title.className = '';
  392. this.suggestions_menu.setHidden(false);
  393. }
  394. else {
  395. this.suggestions_menu.title.className = 'lm-mod-disabled';
  396. this.suggestions_menu.title.label = this.TEXT_NO_SUGGESTIONS;
  397. this.suggestions_menu.setHidden(true);
  398. }
  399. }
  400. apply_suggestion(replacement) {
  401. const context = this.get_contextmenu_context();
  402. if (context === null) {
  403. console.warn('Applying suggestion failed (probably was already applied earlier)');
  404. return;
  405. }
  406. const word = this.get_current_word(context);
  407. context.editor.getDoc().replaceRange(replacement, {
  408. ch: word.start,
  409. line: word.line
  410. }, {
  411. ch: word.end,
  412. line: word.line
  413. });
  414. }
  415. load_dictionary() {
  416. const language = this.language;
  417. if (!language) {
  418. return new Promise((accept, reject) => reject('Cannot load dictionary: no language set'));
  419. }
  420. this.status_msg = this._trans.__('Loading dictionary…');
  421. this.status_widget.update();
  422. return Promise.all([
  423. fetch(language.aff).then(res => res.text()),
  424. fetch(language.dic).then(res => res.text())
  425. ]).then(values => {
  426. this.dictionary = new Typo(language.name, values[0], values[1]);
  427. console.debug('Dictionary Loaded ', language.name, language.id);
  428. this.status_msg = language.name;
  429. // update the complete UI
  430. this.status_widget.update();
  431. this.refresh_state();
  432. });
  433. }
  434. refresh_state() {
  435. // update the active cell (if any)
  436. if (this.tracker.activeCell !== null) {
  437. this.setup_cell_editor(this.tracker.activeCell);
  438. }
  439. // update the current file editor (if any)
  440. if (this.editor_tracker.currentWidget !== null) {
  441. this.setup_file_editor(this.editor_tracker.currentWidget.content);
  442. }
  443. }
  444. choose_language() {
  445. const choices = this.language_manager.getChoices(this.language);
  446. const choiceStrings = choices.map(
  447. // note: two dictionaries may exist for a language with the same name,
  448. // so we append the actual id of the dictionary in the square brackets.
  449. dictionary => dictionary.isOnline
  450. ? this._trans.__('%1 [%2] (online)', dictionary.name, dictionary.id)
  451. : this._trans.__('%1 [%2]', dictionary.name, dictionary.id));
  452. InputDialog.getItem({
  453. title: this._trans.__('Choose spellchecker language'),
  454. items: choiceStrings
  455. }).then(value => {
  456. if (value.value !== null) {
  457. const index = choiceStrings.indexOf(value.value);
  458. const lang = this.language_manager.getLanguageByIdentifier(choices[index].id);
  459. if (!lang) {
  460. console.error('Language could not be matched - please report this as an issue');
  461. return;
  462. }
  463. this.language = lang;
  464. // the setup routine will load the dictionary
  465. this.settings.set('language', this.language.id).catch(console.warn);
  466. }
  467. });
  468. }
  469. setup_language_picker() {
  470. this.status_widget = new StatusWidget(() => this.status_msg);
  471. this.status_widget.node.onclick = () => {
  472. this.choose_language();
  473. };
  474. this.app.commands.addCommand("spellchecker:choose-language" /* chooseLanguage */, {
  475. execute: args => this.choose_language(),
  476. label: this._trans.__('Choose spellchecker language')
  477. });
  478. if (this.palette) {
  479. this.palette.addItem({
  480. command: "spellchecker:choose-language" /* chooseLanguage */,
  481. category: this.PALETTE_CATEGORY
  482. });
  483. }
  484. if (this.status_bar) {
  485. this.status_bar.registerStatusItem('spellchecker:choose-language', {
  486. align: 'right',
  487. item: this.status_widget
  488. });
  489. }
  490. }
  491. }
  492. /**
  493. * Activate extension
  494. */
  495. function activate(app, tracker, editor_tracker, setting_registry, code_mirror, translator, palette, status_bar) {
  496. console.log('Attempting to load spellchecker');
  497. const sp = new SpellChecker(app, tracker, editor_tracker, setting_registry, code_mirror, translator || nullTranslator, palette, status_bar);
  498. console.log('Spellchecker Loaded ', sp);
  499. }
  500. /**
  501. * Initialization data for the jupyterlab_spellchecker extension.
  502. */
  503. const extension = {
  504. id: '@ijmbarr/jupyterlab_spellchecker:plugin',
  505. autoStart: true,
  506. requires: [INotebookTracker, IEditorTracker, ISettingRegistry, ICodeMirror],
  507. optional: [ITranslator, ICommandPalette, IStatusBar],
  508. activate: activate
  509. };
  510. export default extension;