123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- import { IEditorTracker } from '@jupyterlab/fileeditor';
- import { INotebookTracker } from '@jupyterlab/notebook';
- import { LabIcon } from '@jupyterlab/ui-components';
- import { ICommandPalette, InputDialog, ReactWidget } from '@jupyterlab/apputils';
- import { Menu } from '@lumino/widgets';
- import { ISettingRegistry } from '@jupyterlab/settingregistry';
- import { IStatusBar, TextItem } from '@jupyterlab/statusbar';
- import { ICodeMirror } from '@jupyterlab/codemirror';
- import { ITranslator, nullTranslator } from '@jupyterlab/translation';
- import { requestAPI } from './handler';
- import '../style/index.css';
- import spellcheckSvg from '../style/icons/ic-baseline-spellcheck.svg';
- export const spellcheckIcon = new LabIcon({
- name: 'spellcheck:spellcheck',
- svgstr: spellcheckSvg
- });
- // eslint-disable-next-line @typescript-eslint/no-var-requires
- const Typo = require('typo-js');
- class LanguageManager {
- /**
- * initialise the manager
- * mainly reading the definitions from the external extension
- */
- constructor(settingsRegistry) {
- const loadSettings = settingsRegistry.load(extension.id).then(settings => {
- this.updateSettings(settings);
- settings.changed.connect(() => {
- this.updateSettings(settings);
- });
- });
- this.ready = Promise.all([
- this.fetchServerDictionariesList(),
- loadSettings
- ]).then(() => {
- console.debug('LanguageManager is ready');
- });
- }
- updateSettings(settings) {
- if (settings) {
- this.onlineDictionaries = settings.get('onlineDictionaries').composite.map(dictionary => {
- return Object.assign(Object.assign({}, dictionary), { isOnline: true });
- });
- }
- }
- /**
- * Read the list of languages from the server extension
- */
- fetchServerDictionariesList() {
- return requestAPI('language_manager').then(values => {
- console.debug('Dictionaries fetched from server');
- this.serverDictionaries = values.dictionaries.map(dictionary => {
- return Object.assign(Object.assign({}, dictionary), { isOnline: false });
- });
- });
- }
- get dictionaries() {
- return [...this.serverDictionaries, ...this.onlineDictionaries];
- }
- /**
- * get an array of languages, put "language" in front of the list
- * the list is alphabetically sorted
- */
- getChoices(language) {
- const options = language
- ? [language, ...this.dictionaries.filter(l => l.id !== language.id)]
- : this.dictionaries;
- return options.sort((a, b) => a.name.localeCompare(b.name));
- }
- /**
- * select the language by the identifier
- */
- getLanguageByIdentifier(identifier) {
- const exactMatch = this.dictionaries.find(l => l.id === identifier);
- if (exactMatch) {
- return exactMatch;
- }
- // approximate matches support transition from the 0.5 version (and older)
- // that used incorrect codes as language identifiers
- const approximateMatch = this.dictionaries.find(l => l.id.toLowerCase() === identifier.replace('-', '_').toLowerCase());
- if (approximateMatch) {
- console.warn(`Language identifier ${identifier} has a non-exact match, please update it to ${approximateMatch.id}`);
- return approximateMatch;
- }
- }
- }
- class StatusWidget extends ReactWidget {
- constructor(source) {
- super();
- this.language_source = source;
- }
- render() {
- return TextItem({ source: this.language_source() });
- }
- }
- /**
- * SpellChecker
- */
- class SpellChecker {
- constructor(app, tracker, editor_tracker, setting_registry, code_mirror, translator, palette, status_bar) {
- this.app = app;
- this.tracker = tracker;
- this.editor_tracker = editor_tracker;
- this.setting_registry = setting_registry;
- this.code_mirror = code_mirror;
- this.palette = palette;
- this.status_bar = status_bar;
- // Default Options
- this.check_spelling = true;
- this.rx_word_char = /[^-[\]{}():/!;&@$£%§<>"*+=?.,~\\^|_`#±\s\t]/;
- this.rx_non_word_char = /[-[\]{}():/!;&@$£%§<>"*+=?.,~\\^|_`#±\s\t]/;
- this.ignored_tokens = new Set();
- this.define_mode = (original_mode_spec) => {
- if (original_mode_spec.indexOf('spellcheck_') === 0) {
- return original_mode_spec;
- }
- const new_mode_spec = 'spellcheck_' + original_mode_spec;
- this.code_mirror.CodeMirror.defineMode(new_mode_spec, (config) => {
- const spellchecker_overlay = {
- name: new_mode_spec,
- token: (stream, state) => {
- if (stream.eatWhile(this.rx_word_char)) {
- const word = stream.current().replace(/(^')|('$)/g, '');
- if (word !== '' &&
- !word.match(/^\d+$/) &&
- this.dictionary !== undefined &&
- !this.dictionary.check(word) &&
- !this.ignored_tokens.has(word)) {
- return 'spell-error';
- }
- }
- stream.eatWhile(this.rx_non_word_char);
- return null;
- }
- };
- return this.code_mirror.CodeMirror.overlayMode(this.code_mirror.CodeMirror.getMode(config, original_mode_spec), spellchecker_overlay, true);
- });
- return new_mode_spec;
- };
- // use the language_manager
- this.language_manager = new LanguageManager(setting_registry);
- this._trans = translator.load('jupyterlab-spellchecker');
- this.status_msg = this._trans.__('Dictionary not loaded');
- this.TEXT_SUGGESTIONS_AVAILABLE = this._trans.__('Adjust spelling to');
- this.TEXT_NO_SUGGESTIONS = this._trans.__('No spellcheck suggestions');
- this.PALETTE_CATEGORY = this._trans.__('Spell Checker');
- // read the settings
- this.setup_settings();
- // setup the static content of the spellchecker UI
- this.setup_button();
- this.setup_suggestions();
- this.setup_language_picker();
- this.setup_ignore_action();
- this.tracker.activeCellChanged.connect(() => {
- if (this.tracker.activeCell) {
- this.setup_cell_editor(this.tracker.activeCell);
- }
- });
- // setup newly open editors
- this.editor_tracker.widgetAdded.connect((sender, widget) => this.setup_file_editor(widget.content, true));
- // refresh already open editors when activated (because the MIME type might have changed)
- this.editor_tracker.currentChanged.connect((sender, widget) => {
- if (widget !== null) {
- this.setup_file_editor(widget.content, false);
- }
- });
- }
- // move the load_dictionary into the setup routine, because then
- // we know that the values are set correctly!
- setup_settings() {
- Promise.all([
- this.setting_registry.load(extension.id),
- this.app.restored,
- this.language_manager.ready
- ])
- .then(([settings]) => {
- this.update_settings(settings);
- settings.changed.connect(() => {
- this.update_settings(settings);
- });
- })
- .catch((reason) => {
- console.error(reason.message);
- });
- }
- _set_theme(name) {
- document.body.setAttribute('data-jp-spellchecker-theme', name);
- }
- update_settings(settings) {
- this.settings = settings;
- const tokens = settings.get('ignore').composite;
- this.ignored_tokens = new Set(tokens);
- this.accepted_types = settings.get('mimeTypes').composite;
- const theme = settings.get('theme').composite;
- this._set_theme(theme);
- // read the saved language setting
- const language_id = settings.get('language').composite;
- const user_language = this.language_manager.getLanguageByIdentifier(language_id);
- if (user_language === undefined) {
- console.warn('The language ' + language_id + ' is not supported!');
- }
- else {
- this.language = user_language;
- // load the dictionary
- this.load_dictionary().catch(console.warn);
- }
- this.refresh_state();
- }
- setup_file_editor(file_editor, setup_signal = false) {
- if (this.accepted_types &&
- this.accepted_types.indexOf(file_editor.model.mimeType) !== -1) {
- const editor = this.extract_editor(file_editor);
- this.setup_overlay(editor);
- }
- if (setup_signal) {
- file_editor.model.mimeTypeChanged.connect((model, args) => {
- // putting at the end of execution queue to allow the CodeMirror mode to be updated
- setTimeout(() => this.setup_file_editor(file_editor), 0);
- });
- }
- }
- setup_cell_editor(cell) {
- if (cell !== null && cell.model.type === 'markdown') {
- const editor = this.extract_editor(cell);
- this.setup_overlay(editor);
- }
- }
- extract_editor(cell_or_editor) {
- const editor_temp = cell_or_editor.editor;
- return editor_temp.editor;
- }
- setup_overlay(editor, retry = true) {
- const current_mode = editor.getOption('mode');
- if (current_mode === 'null') {
- if (retry) {
- // putting at the end of execution queue to allow the CodeMirror mode to be updated
- setTimeout(() => this.setup_overlay(editor, false), 0);
- }
- return;
- }
- if (this.check_spelling) {
- editor.setOption('mode', this.define_mode(current_mode));
- }
- else {
- const original_mode = current_mode.match(/^spellcheck_/)
- ? current_mode.substr(11)
- : current_mode;
- editor.setOption('mode', original_mode);
- }
- }
- toggle_spellcheck() {
- this.check_spelling = !this.check_spelling;
- console.log('Spell checking is currently: ', this.check_spelling);
- }
- setup_button() {
- this.app.commands.addCommand("spellchecker:toggle-check-spelling" /* toggle */, {
- label: this._trans.__('Toggle spellchecker'),
- execute: () => {
- this.toggle_spellcheck();
- }
- });
- if (this.palette) {
- this.palette.addItem({
- command: "spellchecker:toggle-check-spelling" /* toggle */,
- category: this.PALETTE_CATEGORY
- });
- }
- }
- get_contextmenu_context() {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- const event = this.app._contextMenuEvent;
- const target = event.target;
- const code_mirror_wrapper = target.closest('.CodeMirror');
- if (code_mirror_wrapper === null) {
- return null;
- }
- const code_mirror = code_mirror_wrapper.CodeMirror;
- const position = code_mirror.coordsChar({
- left: event.clientX,
- top: event.clientY
- });
- return {
- editor: code_mirror,
- position: position
- };
- }
- /**
- * This is different from token as implemented in CodeMirror
- * and needed because Markdown does not tokenize words
- * (each letter outside of markdown features is a separate token!)
- */
- get_current_word(context) {
- const { editor, position } = context;
- const line = editor.getDoc().getLine(position.line);
- let start = position.ch;
- while (start > 0 && line[start].match(this.rx_word_char)) {
- start--;
- }
- let end = position.ch;
- while (end < line.length && line[end].match(this.rx_word_char)) {
- end++;
- }
- return {
- line: position.line,
- start: start,
- end: end,
- text: line.substring(start, end)
- };
- }
- setup_suggestions() {
- this.suggestions_menu = new Menu({ commands: this.app.commands });
- this.suggestions_menu.title.label = this.TEXT_SUGGESTIONS_AVAILABLE;
- this.suggestions_menu.title.icon = spellcheckIcon.bindprops({
- stylesheet: 'menuItem'
- });
- // this command is not meant to be show - it is just menu trigger detection hack
- this.app.commands.addCommand("spellchecker:update-suggestions" /* updateSuggestions */, {
- execute: args => {
- // no-op
- },
- isVisible: args => {
- this.prepare_suggestions();
- return false;
- }
- });
- this.app.contextMenu.addItem({
- selector: '.cm-spell-error',
- command: "spellchecker:update-suggestions" /* updateSuggestions */
- });
- // end of the menu trigger detection hack
- this.app.contextMenu.addItem({
- selector: '.cm-spell-error',
- submenu: this.suggestions_menu,
- type: 'submenu'
- });
- this.app.commands.addCommand("spellchecker:apply-suggestion" /* applySuggestion */, {
- execute: args => {
- this.apply_suggestion(args['name']);
- },
- label: args => args['name']
- });
- }
- setup_ignore_action() {
- this.app.commands.addCommand("spellchecker:ignore" /* ignoreWord */, {
- execute: () => {
- this.ignore();
- },
- label: this._trans.__('Ignore')
- });
- this.app.contextMenu.addItem({
- selector: '.cm-spell-error',
- command: "spellchecker:ignore" /* ignoreWord */
- });
- }
- ignore() {
- const context = this.get_contextmenu_context();
- if (context === null) {
- console.log('Could not ignore the word as the context was no longer available');
- }
- else {
- const word = this.get_current_word(context);
- this.settings
- .set('ignore', [
- word.text.trim(),
- ...this.settings.get('ignore').composite
- ])
- .catch(console.warn);
- }
- }
- prepare_suggestions() {
- const context = this.get_contextmenu_context();
- let suggestions;
- if (context === null) {
- // no context (e.g. the edit was applied and the token is no longer in DOM,
- // so we cannot find the parent editor
- suggestions = [];
- }
- else {
- const word = this.get_current_word(context);
- suggestions = this.dictionary.suggest(word.text);
- }
- this.suggestions_menu.clearItems();
- if (suggestions.length) {
- for (const suggestion of suggestions) {
- this.suggestions_menu.addItem({
- command: "spellchecker:apply-suggestion" /* applySuggestion */,
- args: { name: suggestion }
- });
- }
- this.suggestions_menu.title.label = this.TEXT_SUGGESTIONS_AVAILABLE;
- this.suggestions_menu.title.className = '';
- this.suggestions_menu.setHidden(false);
- }
- else {
- this.suggestions_menu.title.className = 'lm-mod-disabled';
- this.suggestions_menu.title.label = this.TEXT_NO_SUGGESTIONS;
- this.suggestions_menu.setHidden(true);
- }
- }
- apply_suggestion(replacement) {
- const context = this.get_contextmenu_context();
- if (context === null) {
- console.warn('Applying suggestion failed (probably was already applied earlier)');
- return;
- }
- const word = this.get_current_word(context);
- context.editor.getDoc().replaceRange(replacement, {
- ch: word.start,
- line: word.line
- }, {
- ch: word.end,
- line: word.line
- });
- }
- load_dictionary() {
- const language = this.language;
- if (!language) {
- return new Promise((accept, reject) => reject('Cannot load dictionary: no language set'));
- }
- this.status_msg = this._trans.__('Loading dictionary…');
- this.status_widget.update();
- return Promise.all([
- fetch(language.aff).then(res => res.text()),
- fetch(language.dic).then(res => res.text())
- ]).then(values => {
- this.dictionary = new Typo(language.name, values[0], values[1]);
- console.debug('Dictionary Loaded ', language.name, language.id);
- this.status_msg = language.name;
- // update the complete UI
- this.status_widget.update();
- this.refresh_state();
- });
- }
- refresh_state() {
- // update the active cell (if any)
- if (this.tracker.activeCell !== null) {
- this.setup_cell_editor(this.tracker.activeCell);
- }
- // update the current file editor (if any)
- if (this.editor_tracker.currentWidget !== null) {
- this.setup_file_editor(this.editor_tracker.currentWidget.content);
- }
- }
- choose_language() {
- const choices = this.language_manager.getChoices(this.language);
- const choiceStrings = choices.map(
- // note: two dictionaries may exist for a language with the same name,
- // so we append the actual id of the dictionary in the square brackets.
- dictionary => dictionary.isOnline
- ? this._trans.__('%1 [%2] (online)', dictionary.name, dictionary.id)
- : this._trans.__('%1 [%2]', dictionary.name, dictionary.id));
- InputDialog.getItem({
- title: this._trans.__('Choose spellchecker language'),
- items: choiceStrings
- }).then(value => {
- if (value.value !== null) {
- const index = choiceStrings.indexOf(value.value);
- const lang = this.language_manager.getLanguageByIdentifier(choices[index].id);
- if (!lang) {
- console.error('Language could not be matched - please report this as an issue');
- return;
- }
- this.language = lang;
- // the setup routine will load the dictionary
- this.settings.set('language', this.language.id).catch(console.warn);
- }
- });
- }
- setup_language_picker() {
- this.status_widget = new StatusWidget(() => this.status_msg);
- this.status_widget.node.onclick = () => {
- this.choose_language();
- };
- this.app.commands.addCommand("spellchecker:choose-language" /* chooseLanguage */, {
- execute: args => this.choose_language(),
- label: this._trans.__('Choose spellchecker language')
- });
- if (this.palette) {
- this.palette.addItem({
- command: "spellchecker:choose-language" /* chooseLanguage */,
- category: this.PALETTE_CATEGORY
- });
- }
- if (this.status_bar) {
- this.status_bar.registerStatusItem('spellchecker:choose-language', {
- align: 'right',
- item: this.status_widget
- });
- }
- }
- }
- /**
- * Activate extension
- */
- function activate(app, tracker, editor_tracker, setting_registry, code_mirror, translator, palette, status_bar) {
- console.log('Attempting to load spellchecker');
- const sp = new SpellChecker(app, tracker, editor_tracker, setting_registry, code_mirror, translator || nullTranslator, palette, status_bar);
- console.log('Spellchecker Loaded ', sp);
- }
- /**
- * Initialization data for the jupyterlab_spellchecker extension.
- */
- const extension = {
- id: '@ijmbarr/jupyterlab_spellchecker:plugin',
- autoStart: true,
- requires: [INotebookTracker, IEditorTracker, ISettingRegistry, ICodeMirror],
- optional: [ITranslator, ICommandPalette, IStatusBar],
- activate: activate
- };
- export default extension;
|