diff --git a/Makefile b/Makefile index 8da32fdfd..8299d01c7 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,8 @@ JSFILES= \ assets/js/translator.js \ assets/js/analyzer.js \ assets/js/generator.js \ - assets/js/sandbox.js + assets/js/sandbox.js \ + assets/js/spellchecker.js build/js/config.js: $(CONFIG) tools/read-conf.py build/js/.d ./tools/read-conf.py -c $< js > $@ diff --git a/assets/css/translation.css b/assets/css/translation.css index d4c6fbea9..7dc8fd2c0 100644 --- a/assets/css/translation.css +++ b/assets/css/translation.css @@ -87,6 +87,37 @@ html[dir='rtl'] #srcLanguages { clear: both; } +/* Spell Checker */ + +#spellCheckerInput { + display: block; + height: 131px; + overflow: auto; + resize: both; +} + +.spellCheckerVisible .spellError { + border-bottom: 1px solid #f00; +} + +.popover-content { /* sass-lint:disable-line class-name-format */ + max-height: 190px; + overflow: auto; + padding: 0; + width: 110%; +} + +.spellCheckerList { + margin-bottom: 0; +} + +.spellCheckerListItem { + border: 0; + cursor: pointer; + display: block; + padding: 4px 10px; +} + #translatedWebpage { border: 1px solid #ccc; border-radius: 4px; diff --git a/assets/js/localization.js b/assets/js/localization.js index 5476403eb..24f5fc837 100644 --- a/assets/js/localization.js +++ b/assets/js/localization.js @@ -13,7 +13,8 @@ var localizedLanguageCodes /*: {[string]: string} */ = {}, localizedLanguageName /* global config, getPairs, getGenerators, getAnalyzers, persistChoices, getURLParam, cache, ajaxSend, ajaxComplete, sendEvent, srcLangs, dstLangs, generators, analyzers, readCache, modeEnabled, populateTranslationList, populateGeneratorList, - populateAnalyzerList, analyzerData, generatorData, curSrcLang, curDstLang, restoreChoices, refreshLangList, onlyUnique */ + populateAnalyzerList, populatePrimarySpellCheckerList, analyzerData, generatorData, spellerData, curSrcLang, curDstLang, + restoreChoices, refreshLangList, onlyUnique, getSpellers */ var dynamicLocalizations /*: {[lang: string]: {[string]: string}} */ = { 'fallback': { @@ -52,7 +53,12 @@ $(document).ready(function () { iso639CodesInverse[language] = code; }); - var possibleItems = {'translation': getPairs, 'generation': getGenerators, 'analyzation': getAnalyzers}; + var possibleItems = { + 'translation': getPairs, + 'generation': getGenerators, + 'analyzation': getAnalyzers, + 'spellchecking': getSpellers + }; var deferredItems = [getLocale(), getLocales()]; if(config.ENABLED_MODES) { $.each(config.ENABLED_MODES, function () { @@ -304,6 +310,9 @@ function localizeLanguageNames(localizedNamesFromJSON) { if(modeEnabled('analyzation')) { populateAnalyzerList(analyzerData); } + if(modeEnabled('spellchecking')) { + populatePrimarySpellCheckerList(spellerData); + } } } @@ -379,7 +388,8 @@ function localizeInterface() { '#originalText': curSrcLang, '#translatedText': curDstLang, '#morphAnalyzerInput': $('#primaryAnalyzerMode').val(), - '#morphGeneratorInput': $('#primaryGeneratorMode').val() + '#morphGeneratorInput': $('#primaryGeneratorMode').val(), + '#spellCheckerInput': $('#primarySpellCheckerMode').val() }; $.each(elements, function (selector, lang /*: string */) { @@ -427,3 +437,4 @@ function setLocale(newLocale /*: string */) { /*:: import {generatorData, generators, getGenerators, populateGeneratorList} from "./generator.js" */ /*:: import {analyzerData, analyzers, getAnalyzers, populateAnalyzerList} from "./analyzer.js" */ /*:: import {cache, persistChoices, readCache, restoreChoices} from "./persistence.js" */ +/*:: import {getSpellers, populatePrimarySpellCheckerList, spellerData} from "./spellchecker.js" */ diff --git a/assets/js/persistence.js b/assets/js/persistence.js index 9144d4e89..397f64714 100644 --- a/assets/js/persistence.js +++ b/assets/js/persistence.js @@ -14,7 +14,8 @@ var URL_PARAM_Q_LIMIT = 1300, '#webpageTranslation': 'qP', '#analyzation': 'qA', '#generation': 'qG', - '#sandbox': 'qS' + '#sandbox': 'qSand', + '#spellchecking': 'qS' }; var store = new Store(config.HTML_URL); @@ -74,6 +75,13 @@ function persistChoices(mode /*: string */, updatePermalink /*: ?boolean */) { 'generatorInput': $('#morphGeneratorInput').val() }; } + else if(mode === 'spellchecker') { + objects = { + 'primarySpellCheckerChoice': $('#primarySpellCheckerMode').val(), + 'spellCheckerInput': $('#spellCheckerInput').text(), + 'instantChecking': $('#instantChecking').val() + }; + } else if(mode === 'localization') { objects = { 'locale': $('.localeSelect').val() @@ -267,6 +275,23 @@ function restoreChoices(mode /*: string */) { $('#morphGeneratorInput').val(decodeURIComponent(getURLParam('qG'))); } } + else if(mode === 'spellchecker') { + if(store.able()) { + var primarySpellCheckerChoice = store.get('primarySpellCheckerChoice', ''); + if(store.has('primarySpellCheckerChoice')) { + $('#primarySpellCheckerMode option[value="' + primarySpellCheckerChoice + '"]').prop('selected', true); + } + if(store.has('spellCheckerInput')) { + $('#spellCheckerInput').text(String(store.get('spellCheckerInput'))); + $('#instantChecking').prop('checked', store.get('instantChecking', true)); + } + } + + if(getURLParam('choice')) { + choice = getURLParam('choice'); + $('#primarySpellCheckerMode option[value="' + choice + '"]').prop('selected', true); + } + } else if(mode === 'localization') { if(store.able()) { setLocale(store.get('locale', '')); diff --git a/assets/js/spellchecker.js b/assets/js/spellchecker.js new file mode 100644 index 000000000..02e0401b6 --- /dev/null +++ b/assets/js/spellchecker.js @@ -0,0 +1,245 @@ +// @flow + +var spellers = {}, spellerData = {}; +var currentSpellCheckerRequest; + +/* exported getSpellers, spellerData, populatePrimarySpellCheckerList */ +/* global config, modeEnabled, persistChoices, readCache, ajaxSend, ajaxComplete, filterLangPairList, allowedLang, cache, + localizeInterface, getLangByCode, restoreChoices, callApy */ +/* global ENTER_KEY_CODE */ + +function getSpellers() /*: JQueryPromise */ { + var deferred = $.Deferred(); + + if(config.SPELLERS) { + spellerData = config.SPELLERS; + populatePrimarySpellCheckerList(spellerData); + deferred.resolve(); + } + else { + var spellers = readCache('spellers', 'LIST_REQUEST'); + if(spellers) { + spellerData = spellers; + populatePrimarySpellCheckerList(spellerData); + deferred.resolve(); + } + else { + console.warn('Spellers cache ' + (spellers === null ? 'stale' : 'miss') + ', retrieving from server'); + $.jsonp({ + url: config.APY_URL + '/list?q=spellers', + beforeSend: ajaxSend, + success: function (data) { + spellerData = data; + populatePrimarySpellCheckerList(spellerData); + cache('spellers', data); + populatePrimarySpellCheckerList(data); + }, + error: function () { + console.error('Failed to get available spellers'); + }, + complete: function () { + ajaxComplete(); + deferred.resolve(); + } + }); + } + } + + return deferred.promise(); +} + +if(modeEnabled('spellchecking')) { + $(document).ready(function () { + restoreChoices('spellchecker'); + var timer, timeout = 2000; + $('#spellCheckerForm').submit(function () { + clearTimeout(timer); + check(); + }); + + $('#spellCheckerInput').on('input propertychange', function () { + if(timer && $('#instantChecking').prop('checked')) { + clearTimeout(timer); + } + timer = setTimeout(function () { + if($('#instantChecking').prop('checked')) { + check(); + } + }, timeout); + }); + + $('#primarySpellCheckerMode').change(function () { + localizeInterface(); + persistChoices('spellchecker'); + }); + + $('#spellCheckerInput').keydown(function (e /*: JQueryKeyEventObject */) { + if(e.keyCode === ENTER_KEY_CODE && !e.shiftKey) { + e.preventDefault(); + check(); + } + }); + + $('#instantChecking').change(function () { + persistChoices('spellchecker'); + }); + + $('#spellCheckerInput').on('input propertychange', function () { + $('#spellCheckerInput').removeClass('spellCheckerVisible'); + $('.spellError').each(function () { + $(this).popover('hide'); + }); + persistChoices('spellchecker'); + }); + + $('#spellCheckerInput').submit(function () { + clearTimeout(timer); + check(); + }); + + $(document).on('mouseover', '.spellCheckerVisible .spellError', function () { + $('.spellError').each(function () { + $(this).popover('hide'); + }); + $(this).popover('show'); + }); + + $(document).on('mouseleave', '.spellError', function () { + var hidePopoverDuration = 400; + var hidePopoverTimer = setTimeout(function () { + $(this).popover('hide'); + }, hidePopoverDuration); + $(this).on('mouseover', function () { + clearTimeout(hidePopoverTimer); + }); + $(document).on('mouseover', '.popover', function () { + clearTimeout(hidePopoverTimer); + }); + $(document).on('mouseleave', '.popover', function () { + $(this).popover('hide'); + }); + }); + + $(document).on('click', '.spellCheckerListItem', function () { + var e = $(this).parents('.popover').prev(); + e.text($(this).text()); + e.removeClass('spellError'); + e.popover('hide'); + check(); + }); + }); +} + +function populatePrimarySpellCheckerList(data /*: {} */) { + $('.spellCheckerMode').empty(); + + spellers = ({} /*: {[string]: string[]} */); + for(var lang in data) { + var spellerLang = lang.indexOf('-') !== -1 ? lang.split('-')[0] : lang; + var group = spellers[spellerLang]; + if(group) { + group.push(lang); + } + else { + spellers[spellerLang] = [lang]; + } + } + + var spellerArray /*: [string, string][] */ = []; + $.each(spellers, function (spellerLang /*: string */, lang /*: string */) { + spellerArray.push([spellerLang, lang]); + }); + spellerArray = filterLangPairList(spellerArray, function (speller /*: [string, string] */) { + return allowedLang(speller[0]); + }); + spellerArray.sort(function (a, b) { + return getLangByCode(a[0]).localeCompare(getLangByCode(b[0])); + }); + + for(var i = 0; i < spellerArray.length; i++) { + lang = spellerArray[i][0]; + $('#primarySpellCheckerMode').append($('').val(lang).text(getLangByCode(lang))); + } + + restoreChoices('spellerchecker'); +} + +function check() { + if(currentSpellCheckerRequest) { + currentSpellCheckerRequest.abort(); + } + $('#spellCheckerInput').addClass('spellCheckerVisible'); + $('#spellCheckerInput').html($('#spellCheckerInput').html().replace(/br/g, '\n') + .replace(/ /g, ' ')); + var words = $.trim($('#spellCheckerInput').text()); + var splitWords = words.split(' '); + var content = {}; + $('#spellCheckerInput').html(''); + currentSpellCheckerRequest = callApy({ + data: { + 'q': words, + 'lang': $('#primarySpellCheckerMode').val() + }, + success: function (data) { + var originalWordsIndex = 0; + for(var tokenIndex = 0; tokenIndex < data.length; tokenIndex++) { + if(data[tokenIndex].known === true) { + $('#spellCheckerInput').html($('#spellCheckerInput').html() + ' ' + splitWords[originalWordsIndex]); + originalWordsIndex++; + continue; + } + $('#spellCheckerInput').html($('#spellCheckerInput').html() + ' ' + splitWords[originalWordsIndex] + ''); + content[splitWords[originalWordsIndex]] = '
'; + for(var sugg = 0; sugg < data[tokenIndex].sugg.length; sugg++) { + content[splitWords[originalWordsIndex]] += '' + + data[tokenIndex].sugg[sugg][0] + ''; + } + content[splitWords[originalWordsIndex]] += '
'; + originalWordsIndex++; + } + $('.spellError').each(function () { + var currentTokenId = this.id; + if(content[currentTokenId].indexOf('spellCheckerListItem') !== -1) { + $(this).popover({ + animation: false, + placement: 'bottom', + trigger: 'manual', + html: true, + content: content[currentTokenId] + }); + } + else { + $(this).popover({ + animation: false, + placement: 'bottom', + trigger: 'manual', + html: true, + content: '
No spelling suggestions
' + }); + } + }); + }, + error: handleSpellCheckerErrorResponse, + complete: function () { + ajaxComplete(); + currentSpellCheckerRequest = undefined; + } + }, '/speller', true); +} + +function handleSpellCheckerErrorResponse(jqXHR) { + spellCheckerNotAvailable(jqXHR.responseJSON); +} + +function spellCheckerNotAvailable(data) { + $('#spellCheckerInput').append($('
').text(data.message)); + $('#spellCheckerInput').append($('
').text(data.explanation)); +} + +/*:: export {getSpellers, spellerData, populatePrimarySpellCheckerList} */ + +/*:: import {modeEnabled, ajaxSend, ajaxComplete, allowedLang, filterLangPairList, callApy, ENTER_KEY_CODE} from "./util.js" */ +/*:: import {persistChoices, restoreChoices} from "./persistence.js" */ +/*:: import {localizeInterface, getLangByCode} from "./localization.js" */ +/*:: import {readCache, cache} from "./persistence.js" */ diff --git a/flow-typed/config.js.flow b/flow-typed/config.js.flow index 30604d614..4124d3308 100644 --- a/flow-typed/config.js.flow +++ b/flow-typed/config.js.flow @@ -27,6 +27,7 @@ declare var config: { PIWIK_SITEID: ?number, REPLACEMENTS: {[string]: string}, SHOW_NAVBAR: boolean, + SPELLERS: ?{[string]: string}, SUBTITLE: ?string, SUBTITLE_COLOR: ?string, TAGGERS: ?{[string]: string}, diff --git a/flow-typed/jquery.js.flow b/flow-typed/jquery.js.flow index 60ab15dca..727620936 100644 --- a/flow-typed/jquery.js.flow +++ b/flow-typed/jquery.js.flow @@ -1242,6 +1242,18 @@ declare class JQuery { */ load(url: string, data?: string | Object, complete?: (responseText: string, textStatus: string, XMLHttpRequest: XMLHttpRequest) => any): JQuery; + /** + * Bootstrap popover + */ + popover(options?: any): JQuery; + + /** + * Bootstrap popover + * + * @param command A string that tells what the popover to do. + */ + popover(command: string): JQuery; + /** * Encode a set of form elements as a string for submission. */ diff --git a/index.html.in b/index.html.in index a7df70ce4..852892450 100644 --- a/index.html.in +++ b/index.html.in @@ -100,7 +100,7 @@
  • Translation
  • Morphological analysis
  • Morphological generation
  • - +
  • Spell checking
  • APy sandbox
  • @@ -329,6 +329,36 @@
    +
    +

    Spell Checking

    +
    +
    + +
    + +
    + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +

    APy sandbox