diff options
32 files changed, 2033 insertions, 212 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index a96b9b3c..74d06f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 1.6.3 + * SECURITY: Update ejs + * SECURITY: xss vulnerability when reading window.location.href + * SECURITY: sanitize jsonp + * NEW: Catch SIGTERM for graceful shutdown + * NEW: Show actual applied text formatting for caret position + * NEW: Add settings to improve scrolling of viewport on line changes + # 1.6.2 * NEW: Added pad shortcut disabling feature * NEW: Create option to automatically reconnect after a few seconds diff --git a/bin/doc/package.json b/bin/doc/package.json index d87c9345..5aba79e0 100644 --- a/bin/doc/package.json +++ b/bin/doc/package.json @@ -7,7 +7,7 @@ "node": ">=0.6.10" }, "dependencies": { - "marked": "~0.1.9" + "marked": ">=0.3.6" }, "devDependencies": {}, "optionalDependencies": {}, diff --git a/settings.json.template b/settings.json.template index 0cb10d50..699880bd 100644 --- a/settings.json.template +++ b/settings.json.template @@ -150,6 +150,34 @@ /* Time (in seconds) to automatically reconnect pad when a "Force reconnect" message is shown to user. Set to 0 to disable automatic reconnection */ "automaticReconnectionTimeout" : 0, + /* + * By default, when caret is moved out of viewport, it scrolls the minimum height needed to make this + * line visible. + */ + "scrollWhenFocusLineIsOutOfViewport": { + /* + * Percentage of viewport height to be additionally scrolled. + * E.g use "percentage.editionAboveViewport": 0.5, to place caret line in the + * middle of viewport, when user edits a line above of the viewport + * Set to 0 to disable extra scrolling + */ + "percentage": { + "editionAboveViewport": 0, + "editionBelowViewport": 0 + }, + /* Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation */ + "duration": 0, + /* + * Flag to control if it should scroll when user places the caret in the last line of the viewport + */ + "scrollWhenCaretIsInTheLastLineOfViewport": false, + /* + * Percentage of viewport height to be additionally scrolled when user presses arrow up + * in the line of the top of the viewport. + * Set to 0 to let the scroll to be handled as default by the Etherpad + */ + "percentageToScrollWhenUserPressesArrowUp": 0 + }, /* Users for basic authentication. is_admin = true gives access to /admin. If you do not uncomment this, /admin will not be available! */ diff --git a/src/locales/az.json b/src/locales/az.json index 800c22e5..97270649 100644 --- a/src/locales/az.json +++ b/src/locales/az.json @@ -6,7 +6,8 @@ "Mushviq Abdulla", "Wertuose", "Mastizada", - "Archaeodontosaurus" + "Archaeodontosaurus", + "Neriman2003" ] }, "index.newPad": "Yeni lövhə", @@ -61,6 +62,8 @@ "pad.modals.connected": "Bağlandı.", "pad.modals.reconnecting": "Sizin lövhə yenidən qoşulur..", "pad.modals.forcereconnect": "Məcbur təkrarən bağlan", + "pad.modals.reconnecttimer": "Yenidən qoşulur", + "pad.modals.cancel": "Ləğv et", "pad.modals.userdup": "Başqa pəncərədə artıq açıqdır", "pad.modals.userdup.explanation": "Bu lövhə, ola bilsin ki, bu kompüterdəki brauzerin bir neçə pəncərəsində açılmışdır.", "pad.modals.userdup.advice": "Bu pəncərəni istifadə etmək üçün yenidən qoşul.", diff --git a/src/locales/diq.json b/src/locales/diq.json index c57284ac..86f44314 100644 --- a/src/locales/diq.json +++ b/src/locales/diq.json @@ -57,10 +57,11 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "Teyna duz metini yana html formati şıma şenê azete dê. Dehana vêşi xısusiyetanê azere kerdışi rê grey <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">AbiWord'i bar kerên</a>.", + "pad.importExport.abiword.innerHTML": "Şıma şenê tenya metınanê zelalan ya zi formatanê HTML-i biyarê. Seba vêşi xısusiyetanê arezekerdışi ra gırey <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">AbiWord-i bar kerên</a>.", "pad.modals.connected": "Gırediya.", "pad.modals.reconnecting": "Bloknot da şıma rê fına irtibat kewê no", "pad.modals.forcereconnect": "Mecbur anciya gırê de", + "pad.modals.reconnecttimer": "Anciya gırê beno", "pad.modals.cancel": "Bıtexelne", "pad.modals.userdup": "Zewbina pençere de bi a", "pad.modals.userdup.explanation": "Ena bloknot ena komputer de yew ra zeder penceran dı akerde asena", diff --git a/src/locales/es.json b/src/locales/es.json index cc673e09..d271b61e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -38,7 +38,7 @@ "pad.colorpicker.save": "Guardar", "pad.colorpicker.cancel": "Cancelar", "pad.loading": "Cargando...", - "pad.noCookie": "La cookie no se pudo encontrar. ¡Habilita las cookies en tu navegador!", + "pad.noCookie": "No se pudo encontrar la «cookie». Permite la utilización de «cookies» en el navegador.", "pad.passwordRequired": "Necesitas una contraseña para acceder a este pad", "pad.permissionDenied": "No tienes permiso para acceder a este pad", "pad.wrongPassword": "La contraseña era incorrecta", diff --git a/src/locales/fi.json b/src/locales/fi.json index 8f4f583e..e42847ee 100644 --- a/src/locales/fi.json +++ b/src/locales/fi.json @@ -13,7 +13,8 @@ "Macofe", "MrTapsa", "Silvonen", - "Espeox" + "Espeox", + "Pyscowicz" ] }, "index.newPad": "Uusi muistio", @@ -68,6 +69,8 @@ "pad.modals.connected": "Yhdistetty.", "pad.modals.reconnecting": "Muodostetaan yhteyttä muistioon uudelleen...", "pad.modals.forcereconnect": "Pakota yhdistämään uudelleen", + "pad.modals.reconnecttimer": "Yritetään yhdistää uudelleen", + "pad.modals.cancel": "Peruuta", "pad.modals.userdup": "Avattu toisessa ikkunassa", "pad.modals.userdup.explanation": "Tämä muistio vaikuttaa olevan avoinna useammassa eri selainikkunassa tällä koneella.", "pad.modals.userdup.advice": "Yhdistä uudelleen, jos haluat käyttää tätä ikkunaa.", diff --git a/src/locales/fy.json b/src/locales/fy.json new file mode 100644 index 00000000..540061d6 --- /dev/null +++ b/src/locales/fy.json @@ -0,0 +1,43 @@ +{ + "@metadata": { + "authors": [ + "Robin van der Vliet" + ] + }, + "pad.toolbar.bold.title": "Fet (Ctrl+B)", + "pad.toolbar.italic.title": "Kursyf (Ctrl+I)", + "pad.toolbar.underline.title": "Understreekje (Ctrl+U)", + "pad.toolbar.settings.title": "Ynstellingen", + "pad.colorpicker.save": "Bewarje", + "pad.colorpicker.cancel": "Annulearje", + "pad.settings.fontType.normal": "Normaal", + "pad.settings.fontType.monospaced": "Monospace", + "pad.settings.language": "Taal:", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.modals.connected": "Ferbûn.", + "pad.modals.deleted": "Fuortsmiten.", + "pad.share.link": "Keppeling", + "timeslider.toolbar.authors": "Auteurs:", + "timeslider.toolbar.authorsList": "Gjin auteurs", + "timeslider.toolbar.exportlink.title": "Eksportearje", + "timeslider.version": "Ferzje {{version}}", + "timeslider.dateformat": "{{day}}-{{month}}-{{year}} {{hours}}:{{minutes}}:{{seconds}}", + "timeslider.month.january": "jannewaris", + "timeslider.month.february": "febrewaris", + "timeslider.month.march": "maart", + "timeslider.month.april": "april", + "timeslider.month.may": "maaie", + "timeslider.month.june": "juny", + "timeslider.month.july": "july", + "timeslider.month.august": "augustus", + "timeslider.month.september": "septimber", + "timeslider.month.october": "oktober", + "timeslider.month.november": "novimber", + "timeslider.month.december": "desimber", + "pad.userlist.unnamed": "sûnder namme", + "pad.userlist.guest": "Gast", + "pad.userlist.deny": "Wegerje", + "pad.userlist.approve": "Goedkarre" +} diff --git a/src/locales/hi.json b/src/locales/hi.json new file mode 100644 index 00000000..c78d74d0 --- /dev/null +++ b/src/locales/hi.json @@ -0,0 +1,39 @@ +{ + "@metadata": { + "authors": [ + "Sfic" + ] + }, + "pad.toolbar.bold.title": "गहरा (Ctrl+B)", + "pad.toolbar.italic.title": "तिरछा (Ctrl+I)", + "pad.toolbar.strikethrough.title": "काटें (Ctrl+5)", + "pad.colorpicker.save": "सहेजें", + "pad.colorpicker.cancel": "रद्द करें", + "pad.loading": "लोड हो रहा है...", + "pad.settings.language": "भाषा:", + "pad.importExport.import_export": "आयात/निर्यात", + "pad.importExport.exportpdf": "पीडीएफ़", + "pad.modals.cancel": "रद्द करें", + "timeslider.toolbar.authors": "लेखक:", + "timeslider.toolbar.exportlink.title": "निर्यात", + "timeslider.version": "संस्करण {{version}}", + "timeslider.saved": "{{day}} {{month}} {{year}} सहेजा गया", + "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", + "timeslider.month.january": "जनवरी", + "timeslider.month.february": "फ़रवरी", + "timeslider.month.march": "मार्च", + "timeslider.month.april": "अप्रैल", + "timeslider.month.may": "मई", + "timeslider.month.june": "जून", + "timeslider.month.july": "जुलाई", + "timeslider.month.august": "अगस्त", + "timeslider.month.september": "सितम्बर", + "timeslider.month.october": "अक्टूबर", + "timeslider.month.november": "नवम्बर", + "timeslider.month.december": "दिसम्बर", + "pad.userlist.guest": "अतिथि", + "pad.impexp.importbutton": "अभी आयात करें", + "pad.impexp.importing": "आयात कर रहा...", + "pad.impexp.importfailed": "आयात विफल हुआ", + "pad.impexp.copypaste": "कृपया कॉपी पेस्ट करें" +} diff --git a/src/locales/ja.json b/src/locales/ja.json index aeb6ba7d..fe200fc2 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -3,7 +3,8 @@ "authors": [ "Shirayuki", "Torinky", - "Omotecho" + "Omotecho", + "Aefgh39622" ] }, "index.newPad": "新規作成", @@ -54,7 +55,7 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "プレーンテキストまたは HTML ファイルからのみインポートできます。より高度なインポート機能を使用するには、<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">abiword をインストール</a>してください。", + "pad.importExport.abiword.innerHTML": "プレーンテキストまたは HTML ファイルからのみインポートできます。より高度なインポート機能を使用するには、<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord をインストール</a>してください。", "pad.modals.connected": "接続されました。", "pad.modals.reconnecting": "パッドに再接続中...", "pad.modals.forcereconnect": "強制的に再接続", diff --git a/src/locales/krc.json b/src/locales/krc.json new file mode 100644 index 00000000..df04e942 --- /dev/null +++ b/src/locales/krc.json @@ -0,0 +1,41 @@ +{ + "@metadata": { + "authors": [ + "Ernác" + ] + }, + "pad.toolbar.settings.title": "Джарашдырыула", + "pad.colorpicker.save": "Сакъла", + "pad.loading": "Джюклениу...", + "pad.settings.fontType.normal": "Нормал", + "pad.settings.fontType.monospaced": "Монокенгликли", + "pad.settings.globalView": "Глобал кёрюнюу", + "pad.settings.language": "Тил:", + "pad.importExport.import_export": "Импорт/экспорт", + "pad.importExport.importSuccessful": "Тыйыншлы!", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportplain": "Тюз текст", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.importExport.exportopen": "ODF (OpenOffice'ни документи)", + "pad.chat": "Чат", + "timeslider.toolbar.returnbutton": "Документге", + "timeslider.toolbar.authors": "Авторла:", + "timeslider.toolbar.exportlink.title": "Эспорт эт", + "timeslider.version": "{{version}} версия", + "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}.{{minutes}}.{{seconds}}", + "timeslider.month.january": "январь", + "timeslider.month.february": "февраль", + "timeslider.month.march": "март", + "timeslider.month.april": "апрель", + "timeslider.month.may": "май", + "timeslider.month.june": "июнь", + "timeslider.month.july": "июль", + "timeslider.month.august": "август", + "timeslider.month.september": "сентябрь", + "timeslider.month.october": "октябрь", + "timeslider.month.november": "ноябрь", + "timeslider.month.december": "декабрь", + "pad.userlist.guest": "Къонакъ", + "pad.impexp.importing": "Импорт этиу…" +} diff --git a/src/locales/map-bms.json b/src/locales/map-bms.json index 1b87bac8..8ebf70c7 100644 --- a/src/locales/map-bms.json +++ b/src/locales/map-bms.json @@ -1,7 +1,8 @@ { "@metadata": { "authors": [ - "StefanusRA" + "StefanusRA", + "Empu" ] }, "index.newPad": "Pad Anyar", @@ -30,7 +31,7 @@ "pad.permissionDenied": "Rika ora duwe idin kanggo ngakses pad kiye", "pad.wrongPassword": "Tembung sandhine Rika salah", "pad.settings.padSettings": "Pangaturan Pad", - "pad.settings.myView": "Delengane Inyong", + "pad.settings.myView": "Delengané Inyong", "pad.settings.stickychat": "Dopokan mesti nang layar", "pad.settings.colorcheck": "Authorship colors", "pad.settings.linenocheck": "Nomer baris", diff --git a/src/locales/nah.json b/src/locales/nah.json new file mode 100644 index 00000000..262766b3 --- /dev/null +++ b/src/locales/nah.json @@ -0,0 +1,42 @@ +{ + "@metadata": { + "authors": [ + "Akapochtli", + "Taresi" + ] + }, + "index.newPad": "Yancuic Pad", + "index.createOpenPad": "auh xicchīhua/xictlapo cē Pad in ītōcā:", + "pad.toolbar.bold.title": "Tilāhuac (Ctrl+B)", + "pad.toolbar.italic.title": "Coltic (Ctrl+I)", + "pad.toolbar.underline.title": "Tlahuahuantli (Ctrl+U)", + "pad.toolbar.strikethrough.title": "Tlīlhuahuantli (Ctrl+5)", + "pad.toolbar.undo.title": "Xicmācuepa (Ctrl+Z)", + "pad.toolbar.redo.title": "Occeppa (Ctrl+Y)", + "pad.toolbar.settings.title": "Tlatlālīliztli", + "pad.colorpicker.save": "Xicpiya", + "pad.colorpicker.cancel": "Xiccāhua", + "pad.settings.padSettings": "Pad Ītlatlālīliz", + "pad.settings.myView": "Notlachiyaliz", + "pad.settings.language": "Tlahtōlli:", + "pad.importExport.exportetherpad": "Etherpad", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.modals.deleted": "Omopohpoloh.", + "pad.modals.deleted.explanation": "Ōmopoloh inīn Pad.", + "timeslider.version": "Inīc {{version}} Cuepaliztli", + "timeslider.month.january": "Īccēmētztli", + "timeslider.month.february": "Īcōmemētztli", + "timeslider.month.march": "Īcyēyimētztli", + "timeslider.month.april": "Īcnāhuimētztli", + "timeslider.month.may": "Īcmācuīllimētztli", + "timeslider.month.june": "Īcchicuacemmētztli", + "timeslider.month.july": "Īcchicōmemētztli", + "timeslider.month.august": "Īcchicuēyimētztli", + "timeslider.month.september": "Īcchiucnāhuimētztli", + "timeslider.month.october": "Īcmahtlactlimētztli", + "timeslider.month.november": "Īcmahtlactlioncēmētztli", + "timeslider.month.december": "Īcmahtlactliomōmemētztli" +} diff --git a/src/locales/nb.json b/src/locales/nb.json index 06293aa8..bd39b18a 100644 --- a/src/locales/nb.json +++ b/src/locales/nb.json @@ -56,7 +56,7 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "Du kan bare importere fra ren tekst eller HTML-formater. For mer avanserte importfunksjoner, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">installer abiword</a>.", + "pad.importExport.abiword.innerHTML": "Du kan bare importere fra ren tekst eller HTML-formater. For mer avanserte importfunksjoner, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer AbiWord</a>.", "pad.modals.connected": "Tilkoblet.", "pad.modals.reconnecting": "Kobler til din blokk på nytt...", "pad.modals.forcereconnect": "Tving gjenoppkobling", diff --git a/src/locales/pt.json b/src/locales/pt.json index b5cbe1d8..a65730a0 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -8,7 +8,8 @@ "Imperadeiro98", "Macofe", "Ti4goc", - "Cainamarques" + "Cainamarques", + "Athena in Wonderland" ] }, "index.newPad": "Nova Nota", @@ -33,12 +34,14 @@ "pad.colorpicker.save": "Gravar", "pad.colorpicker.cancel": "Cancelar", "pad.loading": "A carregar…", + "pad.noCookie": "O cookie não foi encontrado. Por favor, ative os cookies no seu navegador!", "pad.passwordRequired": "Precisa de uma senha para aceder a este pad", "pad.permissionDenied": "Não tem permissão para aceder a este pad.", "pad.wrongPassword": "A palavra-chave está errada", "pad.settings.padSettings": "Configurações da Nota", "pad.settings.myView": "Minha vista", "pad.settings.stickychat": "Bate-papo sempre no ecrã", + "pad.settings.chatandusers": "Mostrar a conversação e os utilizadores", "pad.settings.colorcheck": "Cores de autoria", "pad.settings.linenocheck": "Números de linha", "pad.settings.rtlcheck": "Ler o conteúdo da direita para a esquerda?", @@ -51,21 +54,34 @@ "pad.importExport.import": "Carregar qualquer ficheiro de texto ou documento", "pad.importExport.importSuccessful": "Bem sucedido!", "pad.importExport.export": "Exportar a Nota atual como:", + "pad.importExport.exportetherpad": "Etherpad", "pad.importExport.exporthtml": "HTML", "pad.importExport.exportplain": "Texto simples", "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.importExport.abiword.innerHTML": "Só é possível importar texto sem formatação ou HTML. Para obter funcionalidades de importação mais avançadas, por favor <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instale o AbiWord</a>.", "pad.modals.connected": "Ligado.", "pad.modals.reconnecting": "Reconectando-se ao seu bloco…", "pad.modals.forcereconnect": "Forçar reconexão", + "pad.modals.reconnecttimer": "A tentar religar", + "pad.modals.cancel": "Cancelar", "pad.modals.userdup": "Aberto noutra janela", "pad.modals.userdup.explanation": "Este pad parece estar aberto em mais do que uma janela do navegador neste computador.", + "pad.modals.userdup.advice": "Religar para utilizar esta janela.", "pad.modals.unauth": "Não autorizado", + "pad.modals.unauth.explanation": "As suas permissões foram alteradas enquanto revia esta página. Tente religar.", "pad.modals.looping.explanation": "Existem problemas de comunicação com o servidor de sincronização.", + "pad.modals.looping.cause": "Talvez tenha ligado por um firewall ou proxy incompatível.", "pad.modals.initsocketfail": "O servidor está inacessível.", "pad.modals.initsocketfail.explanation": "Não foi possível a conexão ao servidor de sincronização.", + "pad.modals.initsocketfail.cause": "Isto provavelmente ocorreu por um problema no seu navegador ou na sua ligação de Internet.", "pad.modals.slowcommit.explanation": "O servidor não está a responder.", + "pad.modals.slowcommit.cause": "Isto pode ser por problemas com a ligação de rede.", + "pad.modals.badChangeset.explanation": "Uma edição que fez foi classificada como ilegal pelo servidor de sincronização.", + "pad.modals.badChangeset.cause": "Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contacte o administrador, se acredita que é um erro. Tente religar para continuar a editar.", + "pad.modals.corruptPad.explanation": "A nota que está a tentar aceder está corrompida.", + "pad.modals.corruptPad.cause": "Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contacte o administrador.", "pad.modals.deleted": "Eliminado.", "pad.modals.deleted.explanation": "Este pad foi removido.", "pad.modals.disconnected": "Você foi desconectado.", @@ -74,9 +90,11 @@ "pad.share": "Compartilhar este pad", "pad.share.readonly": "Somente para leitura", "pad.share.link": "Ligação", + "pad.share.emebdcode": "Incorporar o URL", "pad.chat": "Bate-papo", "pad.chat.title": "Abrir o bate-papo para este pad.", "pad.chat.loadmessages": "Carregar mais mensagens", + "timeslider.pageTitle": "Linha do tempo de {{appTitle}}", "timeslider.toolbar.returnbutton": "Voltar ao pad", "timeslider.toolbar.authors": "Autores:", "timeslider.toolbar.authorsList": "Sem Autores", @@ -84,6 +102,9 @@ "timeslider.exportCurrent": "Exportar versão atual como:", "timeslider.version": "Versão {{version}}", "timeslider.saved": "Gravado a {{day}} de {{month}} de {{ano}}", + "timeslider.playPause": "Reproduzir / Pausar conteúdo do Pad", + "timeslider.backRevision": "Voltar a uma revisão anterior neste Pad", + "timeslider.forwardRevision": "Ir a uma revisão posterior neste Pad", "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.month.january": "Janeiro", "timeslider.month.february": "Fevereiro", @@ -97,7 +118,9 @@ "timeslider.month.october": "Outubro", "timeslider.month.november": "Novembro", "timeslider.month.december": "Dezembro", + "timeslider.unnamedauthors": "{{num}} {[plural(num) one: autor anónimo, other: autores anónimos ]}", "pad.savedrevs.marked": "Esta revisão está agora marcada como gravada", + "pad.savedrevs.timeslider": "Pode consultar as revisões gravadas visitando a linha do tempo", "pad.userlist.entername": "Insira o seu nome", "pad.userlist.unnamed": "sem nome", "pad.userlist.guest": "Convidado", @@ -107,8 +130,10 @@ "pad.impexp.importbutton": "Importar agora", "pad.impexp.importing": "Importando...", "pad.impexp.confirmimport": "A importação de um ficheiro irá substituir o texto atual do pad. Tem certeza que deseja continuar?", - "pad.impexp.padHasData": "Não fomos capazes de importar este arquivo porque este Pad já tinha alterações, por favor importe para um novo pad", + "pad.impexp.convertFailed": "Não foi possível importar este ficheiro. Utilize outro formato ou copie e insira manualmente", + "pad.impexp.padHasData": "Não fomos capazes de importar este ficheiro porque este Pad já tinha alterações, por favor importe para um novo pad", "pad.impexp.uploadFailed": "O upload falhou. Por favor, tente novamente", "pad.impexp.importfailed": "A importação falhou", - "pad.impexp.copypaste": "Por favor, copie e cole" + "pad.impexp.copypaste": "Por favor, copie e cole", + "pad.impexp.exportdisabled": "A exportação no formato {{type}} está desativada. Por favor, contacte o administrador do sistema para mais informações." } diff --git a/src/locales/sl.json b/src/locales/sl.json index 50333132..94ccfa73 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -8,7 +8,7 @@ ] }, "index.newPad": "Nov dokument", - "index.createOpenPad": "ali pa odpri dokument z imenom:", + "index.createOpenPad": "ali pa ustvari/odpri dokument z imenom:", "pad.toolbar.bold.title": "Krepko (Ctrl-B)", "pad.toolbar.italic.title": "Ležeče (Ctrl-I)", "pad.toolbar.underline.title": "Podčrtano (Ctrl-U)", @@ -20,7 +20,7 @@ "pad.toolbar.undo.title": "Razveljavi (Ctrl-Z)", "pad.toolbar.redo.title": "Ponovno uveljavi (Ctrl-Y)", "pad.toolbar.clearAuthorship.title": "Počisti barve avtorstva (Ctrl+Shift+C)", - "pad.toolbar.import_export.title": "Izvozi/Uvozi različne oblike zapisov", + "pad.toolbar.import_export.title": "Uvozi/Izvozi različne oblike zapisov", "pad.toolbar.timeslider.title": "Časovni trak", "pad.toolbar.savedRevision.title": "Shrani redakcijo", "pad.toolbar.settings.title": "Nastavitve", diff --git a/src/locales/sr-ec.json b/src/locales/sr-ec.json index de413722..59f77ef2 100644 --- a/src/locales/sr-ec.json +++ b/src/locales/sr-ec.json @@ -5,21 +5,22 @@ "Milicevic01", "Милан Јелисавчић", "Srdjan m", - "Obsuser" + "Obsuser", + "Acamicamacaraca" ] }, "index.newPad": "Нови Пад", "index.createOpenPad": "или направите/отворите пад следећег назива:", - "pad.toolbar.bold.title": "Подебљано (Ctrl-B)", - "pad.toolbar.italic.title": "Искошено (Ctrl-I)", - "pad.toolbar.underline.title": "Подвучено (Ctrl-U)", + "pad.toolbar.bold.title": "Подебљано (Ctrl+B)", + "pad.toolbar.italic.title": "Искошено (Ctrl+I)", + "pad.toolbar.underline.title": "Подвучено (Ctrl+U)", "pad.toolbar.strikethrough.title": "Прецртано (Ctrl+5)", "pad.toolbar.ol.title": "Уређен списак (Ctrl+Shift+N)", "pad.toolbar.ul.title": "Неуређен списак (Ctrl+Shift+L)", "pad.toolbar.indent.title": "Увлачење (TAB)", "pad.toolbar.unindent.title": "Извлачење (Shift+TAB)", "pad.toolbar.undo.title": "Опозови (Ctrl+Z)", - "pad.toolbar.redo.title": "Опозови (Ctrl+Z)", + "pad.toolbar.redo.title": "Понови (Ctrl+Z)", "pad.toolbar.clearAuthorship.title": "Очисти ауторске боје (Ctrl+Shift+C)", "pad.toolbar.import_export.title": "Увези/извези из/на друге датотечне формате", "pad.toolbar.timeslider.title": "Временска линија", @@ -29,9 +30,9 @@ "pad.toolbar.showusers.title": "Прикажи кориснике на овом паду", "pad.colorpicker.save": "Сачувај", "pad.colorpicker.cancel": "Откажи", - "pad.loading": "Учитавање...", + "pad.loading": "Учитавам…", "pad.noCookie": "Колачић није пронађен. Молимо да укључите колачиће у вашем прегледавачу!", - "pad.passwordRequired": "Требате лозинку како бисте приступили овом паду", + "pad.passwordRequired": "Требате имати лозинку како бисте приступили овом паду", "pad.permissionDenied": "Немате дозволу да приступите овом паду", "pad.wrongPassword": "Ваша лозинка није исправна", "pad.settings.padSettings": "Подешавања пада", @@ -48,15 +49,15 @@ "pad.settings.language": "Језик:", "pad.importExport.import_export": "Увоз/извоз", "pad.importExport.import": "Отпремите било коју текстуалну датотеку или документ", - "pad.importExport.importSuccessful": "Успело!", + "pad.importExport.importSuccessful": "Успешно!", "pad.importExport.export": "Извези тренутни пад као:", "pad.importExport.exportetherpad": "Etherpad", "pad.importExport.exporthtml": "HTML", - "pad.importExport.exportplain": "чист текст", + "pad.importExport.exportplain": "Чист текст", "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "Једино можете увести са једноставног текстуалног формата или HTML формата. За компликованије функције о увозу, молимо да <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">инсталирате AbiWord</a>.", + "pad.importExport.abiword.innerHTML": "Једино можете увести са једноставног текстуалног формата или HTML формата. За компликованије функције о увозу, молимо да <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">инсталирате AbiWord</a>.", "pad.modals.connected": "Повезано.", "pad.modals.reconnecting": "Поново се повезујем на ваш пад..", "pad.modals.forcereconnect": "Присилно се поново повежи", @@ -83,24 +84,24 @@ "pad.modals.disconnected": "Веза је прекинута.", "pad.modals.disconnected.explanation": "Изгубљена је веза са сервером", "pad.modals.disconnected.cause": "Сервер није доступан. Обавестите сервисног администратора ако се ово настави дешавати.", - "pad.share": "Дели овај пад", + "pad.share": "Пофели овај пад", "pad.share.readonly": "Само за читање", "pad.share.link": "Веза", "pad.share.emebdcode": "Угради везу", "pad.chat": "Ћаскање", "pad.chat.title": "Отворите ћаскање за овај пад.", - "pad.chat.loadmessages": "Учитајте више порука.", + "pad.chat.loadmessages": "Учитај више порука", "timeslider.pageTitle": "{{appTitle}} временска линија", "timeslider.toolbar.returnbutton": "Врати се на пад", "timeslider.toolbar.authors": "Аутори:", "timeslider.toolbar.authorsList": "Нема аутора", "timeslider.toolbar.exportlink.title": "Извези", "timeslider.exportCurrent": "Извези тренутну верзију као:", - "timeslider.version": "Верзија {{version}}", + "timeslider.version": "Издање {{version}}", "timeslider.saved": "Сачувано на {{day}}. {{month}}. {{year}}", "timeslider.playPause": "Пусти/паузирај садржај пада", "timeslider.backRevision": "Иди на претходну верзију овог пада", - "timeslider.forwardRevision": "Иди на следећу верзију овог пада", + "timeslider.forwardRevision": "Иди на следеће издање пада", "timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.month.january": "јануар", "timeslider.month.february": "фебруар", @@ -115,21 +116,21 @@ "timeslider.month.november": "новембар", "timeslider.month.december": "децембар", "timeslider.unnamedauthors": "{{num}} неименован(и) {[plural(num) one: аутор, other: аутори ]}", - "pad.savedrevs.marked": "Ова верзија је сада означена као сачувана", + "pad.savedrevs.marked": "Ова измена је сада означена као сачувана", "pad.savedrevs.timeslider": "Можете видети сачуване измене користећи се временском линијом", "pad.userlist.entername": "Упишите своје име", - "pad.userlist.unnamed": "нема имена", + "pad.userlist.unnamed": "неименован", "pad.userlist.guest": "Гост", "pad.userlist.deny": "Одбиј", - "pad.userlist.approve": "одобрено", + "pad.userlist.approve": "Одобри", "pad.editbar.clearcolors": "Очисти ауторске боје за цели документ?", "pad.impexp.importbutton": "Увези одмах", - "pad.impexp.importing": "Увожење...", + "pad.impexp.importing": "Увозим...", "pad.impexp.confirmimport": "Увоз датотеке ће преписати тренутни текст пада. Да ли сте сигурни да желите наставити?", - "pad.impexp.convertFailed": "Не можемо увести ову датотеку. Молимо да користите други формат документа или да документ копирате ручно", - "pad.impexp.padHasData": "Не можемо да увеземо ову датотеку зато што је већ било промена на овом паду, молимо да увезете нови пад", - "pad.impexp.uploadFailed": "Отпремање није успело, молимо да покушате поново", - "pad.impexp.importfailed": "Увоз неуспешан", - "pad.impexp.copypaste": "Молимо да ручно копирате", + "pad.impexp.convertFailed": "Не могу да увезем ову датотеку. Молимо да користите други формат документа или да документ копирате ручно", + "pad.impexp.padHasData": "Не могу да увезем ову датотеку зато што је већ било промена на овом паду, молимо да увезете нови пад", + "pad.impexp.uploadFailed": "Нисам успео да отпремим, молимо покушате поново", + "pad.impexp.importfailed": "Нисам успео да увезем", + "pad.impexp.copypaste": "Копирајте и залепите", "pad.impexp.exportdisabled": "Извоз у формату {{type}} није дозвољен. Контактирајте системског администратора за детаље." } diff --git a/src/locales/tcy.json b/src/locales/tcy.json new file mode 100644 index 00000000..3cd815b6 --- /dev/null +++ b/src/locales/tcy.json @@ -0,0 +1,48 @@ +{ + "@metadata": { + "authors": [ + "BHARATHESHA ALASANDEMAJALU", + "VASANTH S.N." + ] + }, + "index.newPad": "ಪೊಸ ಪ್ಯಾಡ್", + "index.createOpenPad": "ಅತಂಡ ಈ ಪುದರ್ತ ಪ್ಯಾಡನ್ನು ಉಂಡು ಮನ್ಪು/ತೋಜಾಲ:", + "pad.toolbar.bold.title": "ದಪ್ಪೊ(Ctrl+B)", + "pad.toolbar.italic.title": "ಓರೆ (Ctrl-I)", + "pad.toolbar.underline.title": "ಅಡಿಗೆರೆ(Ctrl-U)", + "pad.toolbar.indent.title": "Indent (TAB)", + "pad.toolbar.undo.title": "ಪಿರವುತ(Ctrl+Z)", + "pad.toolbar.redo.title": "ದುಂಬುತ್ತ(Ctrl+Y)", + "pad.toolbar.settings.title": "ಸಂಯೋಜನೆಲು", + "pad.toolbar.showusers.title": "ಈ ಪ್ಯಾಡ್ ಟ್ ಗಲಸುನಾಯಾನ್ ತೋಜಾಲೆ", + "pad.colorpicker.save": "ಒರಿಪಾಲೆ", + "pad.colorpicker.cancel": "ವಜಾ ಮಲ್ಪುಲೆ", + "pad.loading": "ದಿಂಜಾವೊಂದುಂಡು......", + "pad.wrongPassword": "ಇರೇನಾ ಪಾಸ್ ವರ್ಡ್ ತಪ್ಪತುಂಡ್", + "pad.settings.padSettings": "ಪ್ಯಾಡ್ ಸಂಯೋಜನೆ", + "pad.settings.language": "ಬಾಸೆ:", + "pad.importExport.exportetherpad": "Etherpad", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportpdf": "PDF", + "pad.modals.connected": "ನೆಟ್ ವರ್ಕ್ ತಿಕೊಂತುಂಡು.", + "pad.modals.cancel": "ವಜಾ ಮಲ್ಪುಲೆ", + "pad.modals.deleted": "ಮಾಜಾಯಿನ.", + "pad.share.readonly": "ಓದ್ಯರಾ ಮಾತ್ರ", + "pad.share.link": "ಕೊಂಡಿಲು", + "timeslider.month.january": "ಜನವರಿ", + "timeslider.month.february": "ಪೆಬ್ರವರಿ", + "timeslider.month.march": "ಮಾರ್ಚಿ", + "timeslider.month.april": "ಎಪ್ರಿಲ್", + "timeslider.month.may": "ಮೇ", + "timeslider.month.june": "ಜೂನ್", + "timeslider.month.july": "ಜುಲಾಯಿ", + "timeslider.month.august": "ಆಗೋಸ್ಟು", + "timeslider.month.september": "ಸಪ್ಟಂಬರೊ", + "timeslider.month.october": "ಅಕ್ಟೋಬರ", + "timeslider.month.november": "ನವಂಬರೊ", + "timeslider.month.december": "ದಸಂಬರೊ", + "pad.userlist.entername": "ಈರೆನೆ ಪುದರ್ ಬರೆಲೆ", + "pad.userlist.unnamed": "ಪುದರ್ ಇಜ್ಜಂತಿನವು", + "pad.userlist.guest": "ಬಿನ್ನೆರ್", + "pad.userlist.approve": "ಒಪ್ಪಂದ ಅಂಡ್" +} diff --git a/src/locales/th.json b/src/locales/th.json new file mode 100644 index 00000000..d5c24374 --- /dev/null +++ b/src/locales/th.json @@ -0,0 +1,129 @@ +{ + "@metadata": { + "authors": [ + "Aefgh39622" + ] + }, + "index.newPad": "สร้างแผ่นจดบันทึกใหม่", + "index.createOpenPad": "หรือสร้าง/เปิดแผ่นจดบันทึกที่มีชื่อ:", + "pad.toolbar.bold.title": "ตัวหนา (Ctrl+B)", + "pad.toolbar.italic.title": "ตัวเอียง (Ctrl+I)", + "pad.toolbar.underline.title": "ขีดเส้นใต้ (Ctrl+U)", + "pad.toolbar.strikethrough.title": "ขีดทับ (Ctrl+5)", + "pad.toolbar.ol.title": "รายการที่เรียงลำดับ (Ctrl+Shift+N)", + "pad.toolbar.ul.title": "รายการที่ไม่เรียงลำดับ (Ctrl+Shift+L)", + "pad.toolbar.indent.title": "เยื้องเข้า (TAB)", + "pad.toolbar.unindent.title": "เยื้องออก (Shift+TAB)", + "pad.toolbar.undo.title": "เลิกทำ (Ctrl+Z)", + "pad.toolbar.redo.title": "ทำซ้ำ (Ctrl+Y)", + "pad.toolbar.clearAuthorship.title": "ลบสีผู้เขียน (Ctrl+Shift+C)", + "pad.toolbar.import_export.title": "นำเข้า/ส่งออกไฟล์จาก/เป็นรูปแบบต่าง ๆ", + "pad.toolbar.timeslider.title": "ตัวเลื่อนเวลา", + "pad.toolbar.savedRevision.title": "บันทึกรุ่นแก้ไข", + "pad.toolbar.settings.title": "การตั้งค่า", + "pad.toolbar.embed.title": "แชร์และฝังแผ่นจดบันทึกนี้", + "pad.toolbar.showusers.title": "แสดงผู้ใช้บนแผ่นจดบันทึกนี้", + "pad.colorpicker.save": "บันทึก", + "pad.colorpicker.cancel": "ยกเลิก", + "pad.loading": "กำลังโหลด...", + "pad.noCookie": "ไม่พบคุกกี้ โปรดเปิดใช้งานคุกกี้ในเบราว์เซอร์ของคุณ!", + "pad.passwordRequired": "คุณต้องใช้รหัสผ่านเพื่อเข้าถึงแผ่นจดบันทึกนี้", + "pad.permissionDenied": "คุณไม่มีสิทธิ์เข้าถึงแผ่นจดบันทึกนี้", + "pad.wrongPassword": "รหัสผ่านของคุณผิด", + "pad.settings.padSettings": "การตั้งค่าแผ่นจดบันทึก", + "pad.settings.myView": "มุมมองของฉัน", + "pad.settings.stickychat": "แสดงการแชทบนหน้าจอเสมอ", + "pad.settings.chatandusers": "แสดงการแชทและผู้ใช้", + "pad.settings.colorcheck": "สีผู้เขียน", + "pad.settings.linenocheck": "เลขบรรทัด", + "pad.settings.rtlcheck": "อ่านเนื้อหาจากขวาไปซ้ายหรือไม่?", + "pad.settings.fontType": "ชนิดแบบอักษร:", + "pad.settings.globalView": "มุมมองสากล", + "pad.settings.language": "ภาษา:", + "pad.importExport.import_export": "นำเข้า/ส่งออก", + "pad.importExport.import": "อัปโหลดไฟล์ข้อความหรือเอกสารใดๆ", + "pad.importExport.importSuccessful": "สำเร็จ!", + "pad.importExport.export": "ส่งออกแผ่นจดบันทึกปัจจุบันเป็น:", + "pad.importExport.exportetherpad": "Etherpad", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportplain": "ข้อความธรรมดา", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.importExport.abiword.innerHTML": "คุณสามารถนำเข้าได้จากรูปแบบ HTML หรือข้อความธรรมดาเท่านั้น สำหรับคุณสมบัติการนำเข้าขั้นสูงเพิ่มเติม โปรด<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">ติดตั้ง AbiWord</a>", + "pad.modals.connected": "เชื่อมต่อแล้ว", + "pad.modals.reconnecting": "กำลังเชื่อมต่อกับแผ่นจดบันทึกของคุณใหม่..", + "pad.modals.forcereconnect": "บังคับเชื่อมต่อใหม่", + "pad.modals.reconnecttimer": "กำลังพยายามเชื่อมต่อใหม่ใน", + "pad.modals.cancel": "ยกเลิก", + "pad.modals.userdup": "เปิดในหน้าต่างอื่นแล้ว", + "pad.modals.userdup.explanation": "แผ่นจดบันทึกนี้ดูเหมือนว่าจะถูกเปิดในหน้าต่างเบราว์เซอร์มากกว่าหนึ่งหน้าต่างบนคอมพิวเตอร์นี้", + "pad.modals.userdup.advice": "เชื่อมต่อใหม่เพื่อใช้หน้าต่างนี้แทน", + "pad.modals.unauth": "ไม่ได้รับอนุญาต", + "pad.modals.unauth.explanation": "สิทธิของคุณถูกเปลี่ยนขณะที่คุณดูหน้านี้อยู่ พยายามเชื่อมต่อใหม่", + "pad.modals.looping.explanation": "มีปัญหาการสื่อสารกับเซิร์ฟเวอร์การซิงค์ข้อมูล", + "pad.modals.looping.cause": "บางทีอาจเป็นเพราะคุณเชื่อมต่อกับไฟร์วอลล์หรือพร็อกซีที่เข้ากันไม่ได้", + "pad.modals.initsocketfail": "เซิร์ฟเวอร์ไม่สามารถเข้าถึงได้", + "pad.modals.initsocketfail.explanation": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์การซิงค์ข้อมูล", + "pad.modals.initsocketfail.cause": "อาจเป็นเนื่องจากเบราว์เซอร์ของคุณหรือการเชื่อมต่ออินเทอร์เน็ตของคุณมีปัญหา", + "pad.modals.slowcommit.explanation": "เซิร์ฟเวอร์ไม่ตอบสนอง", + "pad.modals.slowcommit.cause": "อาจเป็นเนื่องจากปัญหาเกี่ยวกับการเชื่อมต่อเครือข่าย", + "pad.modals.badChangeset.explanation": "การแก้ไขที่คุณกระทำถูกจัดว่าไม่เหมาะสมโดยเซิร์ฟเวอร์การซิงค์ข้อมูล", + "pad.modals.badChangeset.cause": "อาจเป็นเนื่องจากการกำหนดค่าเซิร์ฟเวอร์ไม่ถูกต้องหรือมีลักษณะการทำงานอื่นๆ บางอย่างที่ไม่คาดคิด โปรดติดต่อผู้ดูแลการให้บริการ ถ้าคุณรู้สึกว่านี่คือข้อผิดพลาด โปรดทำการเชื่อมต่อใหม่อีกครั้งเพื่อทำการแก้ไขต่อไป", + "pad.modals.corruptPad.explanation": "แผ่นจดบันทึกที่คุณกำลังพยายามเข้าถึงเสียหาย", + "pad.modals.corruptPad.cause": "อาจเป็นเนื่องจากการกำหนดค่าเซิร์ฟเวอร์ไม่ถูกต้องหรือมีลักษณะการทำงานอื่นๆ บางอย่างที่ไม่คาดคิด โปรดติดต่อผู้ดูแลการให้บริการ", + "pad.modals.deleted": "ลบแล้ว", + "pad.modals.deleted.explanation": "แผ่นจดบันทึกนี้ได้ถูกลบออกแล้ว", + "pad.modals.disconnected": "คุณได้ตัดการเชื่อมต่อแล้ว", + "pad.modals.disconnected.explanation": "การเชื่อมต่อกับเซิร์ฟเวอร์ถูกตัด", + "pad.modals.disconnected.cause": "เซิร์ฟเวอร์อาจใช้ไม่ได้ชั่วคราว โปรดแจ้งให้ผู้ดูแลการให้บริการทราบถ้าปัญหานี้ยังคงเกิดขึ้น", + "pad.share": "แชร์แผ่นจดบันทึกนี้", + "pad.share.readonly": "อ่านเท่านั้น", + "pad.share.link": "ลิงก์", + "pad.share.emebdcode": "URL แบบฝังตัว", + "pad.chat": "แชท", + "pad.chat.title": "เปิดการแชทสำหรับแผ่นจดบันทึกนี้", + "pad.chat.loadmessages": "โหลดข้อความเพิ่มเติม", + "timeslider.pageTitle": "ตัวเลื่อนเวลา {{appTitle}}", + "timeslider.toolbar.returnbutton": "กลับไปแผ่นจดบันทึก", + "timeslider.toolbar.authors": "ผู้เขียน:", + "timeslider.toolbar.authorsList": "ไม่มีผู้เขียน", + "timeslider.toolbar.exportlink.title": "ส่งออก", + "timeslider.exportCurrent": "ส่งออกรุ่นปัจจุบันเป็น:", + "timeslider.version": "รุ่น {{version}}", + "timeslider.saved": "บันทึกแล้วเมื่อ {{day}} {{month}} {{year}}", + "timeslider.playPause": "เล่น / พักเนื้อหาแผ่นจดบันทึก", + "timeslider.backRevision": "กลับไปรุ่นแก้ไขเก่าของแผ่นจดบันทึกนี้", + "timeslider.forwardRevision": "ไปยังรุ่นแก้ไขใหม่ของแผ่นจดบันทึกนี้", + "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", + "timeslider.month.january": "มกราคม", + "timeslider.month.february": "กุมภาพันธ์", + "timeslider.month.march": "มีนาคม", + "timeslider.month.april": "เมษายน", + "timeslider.month.may": "พฤษภาคม", + "timeslider.month.june": "มิถุนายน", + "timeslider.month.july": "กรกฎาคม", + "timeslider.month.august": "สิงหาคม", + "timeslider.month.september": "กันยายน", + "timeslider.month.october": "ตุลาคม", + "timeslider.month.november": "พฤศจิกายน", + "timeslider.month.december": "ธันวาคม", + "timeslider.unnamedauthors": "{{num}} ผู้เขียนที่ไม่มีชื่อ", + "pad.savedrevs.marked": "รุ่นแก้ไขนี้ถูกทำเครื่องหมายเป็นรุ่นแก้ไขที่บันทึกแล้ว", + "pad.savedrevs.timeslider": "คุณสามารถดูรุ่นแก้ไขที่บันทึกแล้วโดยเยี่ยมชมตัวเลื่อนเวลา", + "pad.userlist.entername": "กรอกชื่อของคุณ", + "pad.userlist.unnamed": "ไม่มีชื่อ", + "pad.userlist.guest": "ผู้เยี่ยมชม", + "pad.userlist.deny": "ปฏิเสธ", + "pad.userlist.approve": "อนุมัติ", + "pad.editbar.clearcolors": "ล้างสีผู้เขียนบนทั้งเอกสารหรือไม่?", + "pad.impexp.importbutton": "นำเข้าเดี๋ยวนี้", + "pad.impexp.importing": "กำลังนำเข้า...", + "pad.impexp.confirmimport": "การนำเข้าไฟล์จะเป็นการเขียนทับข้อความปัจจุบันบนแผ่นจดบันทึก คุณแน่ใจหรือว่าคุณต้องการดำเนินการต่อ?", + "pad.impexp.convertFailed": "เราไม่สามารถนำเข้าไฟล์นี้ได้ โปรดใช้รูปแบบเอกสารอื่นหรือคัดลอกแล้ววางด้วยตนเอง", + "pad.impexp.padHasData": "เราไม่สามารถนำเข้าไฟล์นี้ได้เนื่องจากแผ่นจดบันทึกนี้มีการเปลี่ยนแปลงอยู่แล้ว โปรดนำเข้าไปแผ่นจดบันทึกใหม่แทน", + "pad.impexp.uploadFailed": "การอัปโหลดล้มเหลว โปรดลองอีกครั้ง", + "pad.impexp.importfailed": "การนำเข้าล้มเหลว", + "pad.impexp.copypaste": "โปรดคัดลอกแล้ววาง", + "pad.impexp.exportdisabled": "การส่งออกเป็นรูปแบบ {{type}} ถูกปิดใช้งาน โปรดติดต่อผู้ดูแลระบบของคุณสำหรับรายละเอียดเพิ่มเติม" +} diff --git a/src/locales/zh-hant.json b/src/locales/zh-hant.json index e9cac9d4..17591884 100644 --- a/src/locales/zh-hant.json +++ b/src/locales/zh-hant.json @@ -59,7 +59,7 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF(開放文件格式)", - "pad.importExport.abiword.innerHTML": "您只可以純文字或html格式檔匯入。<a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">安裝abiword</a>取得更多進階的匯入功能。", + "pad.importExport.abiword.innerHTML": "您只可以純文字或 HTML 格式檔匯入。<a href=\"ttps://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">安裝\n AbiWord </a>取得更多進階的匯入功能。", "pad.modals.connected": "已連線。", "pad.modals.reconnecting": "重新連接到您的記事本...", "pad.modals.forcereconnect": "強制重新連線", diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index b7ec7cb2..060bca7b 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -1216,6 +1216,15 @@ function handleClientReady(client, message) "parts": plugins.parts, }, "indentationOnNewLine": settings.indentationOnNewLine, + "scrollWhenFocusLineIsOutOfViewport": { + "percentage" : { + "editionAboveViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport, + "editionBelowViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport, + }, + "duration": settings.scrollWhenFocusLineIsOutOfViewport.duration, + "scrollWhenCaretIsInTheLastLineOfViewport": settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport, + "percentageToScrollWhenUserPressesArrowUp": settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp, + }, "initialChangesets": [] // FIXME: REMOVE THIS SHIT } diff --git a/src/node/hooks/express/apicalls.js b/src/node/hooks/express/apicalls.js index db0fc81f..4482fd84 100644 --- a/src/node/hooks/express/apicalls.js +++ b/src/node/hooks/express/apicalls.js @@ -3,6 +3,7 @@ var apiLogger = log4js.getLogger("API"); var clientLogger = log4js.getLogger("client"); var formidable = require('formidable'); var apiHandler = require('../../handler/APIHandler'); +var isVarName = require('is-var-name'); //This is for making an api call, collecting all post information and passing it to the apiHandler var apiCaller = function(req, res, fields) { @@ -18,7 +19,7 @@ var apiCaller = function(req, res, fields) { apiLogger.info("RESPONSE, " + req.params.func + ", " + response); //is this a jsonp call, if yes, add the function call - if(req.query.jsonp) + if(req.query.jsonp && isVarName(response)) response = req.query.jsonp + "(" + response + ")"; res._____send(response); diff --git a/src/node/hooks/express/errorhandling.js b/src/node/hooks/express/errorhandling.js index 7afe80ae..c36595bd 100644 --- a/src/node/hooks/express/errorhandling.js +++ b/src/node/hooks/express/errorhandling.js @@ -49,5 +49,8 @@ exports.expressCreateServer = function (hook_name, args, cb) { //sigint is so far not working on windows //https://github.com/joyent/node/issues/1553 process.on('SIGINT', exports.gracefulShutdown); + // when running as PID1 (e.g. in docker container) + // allow graceful shutdown on SIGTERM c.f. #3265 + process.on('SIGTERM', exports.gracefulShutdown); } } diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js index 660b7afb..cf7fea80 100644 --- a/src/node/utils/Settings.js +++ b/src/node/utils/Settings.js @@ -247,6 +247,33 @@ exports.users = {}; */ exports.showSettingsInAdminPage = true; +/* +* By default, when caret is moved out of viewport, it scrolls the minimum height needed to make this +* line visible. +*/ +exports.scrollWhenFocusLineIsOutOfViewport = { + /* + * Percentage of viewport height to be additionally scrolled. + */ + "percentage": { + "editionAboveViewport": 0, + "editionBelowViewport": 0 + }, + /* + * Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation + */ + "duration": 0, + /* + * Flag to control if it should scroll when user places the caret in the last line of the viewport + */ + /* + * Percentage of viewport height to be additionally scrolled when user presses arrow up + * in the line of the top of the viewport. + */ + "percentageToScrollWhenUserPressesArrowUp": 0, + "scrollWhenCaretIsInTheLastLineOfViewport": false +}; + //checks if abiword is avaiable exports.abiwordAvailable = function() { diff --git a/src/package.json b/src/package.json index a182fd97..a29c06e5 100644 --- a/src/package.json +++ b/src/package.json @@ -29,7 +29,7 @@ "cheerio" : "0.20.0", "async-stacktrace" : "0.0.2", "npm" : "4.0.2", - "ejs" : "2.4.1", + "ejs" : "2.5.7", "graceful-fs" : "4.1.3", "slide" : "1.1.6", "semver" : "5.1.0", @@ -43,7 +43,8 @@ "jsonminify" : "0.4.1", "measured" : "1.1.0", "mocha" : "2.4.5", - "supertest" : "1.2.0" + "supertest" : "1.2.0", + "is-var-name" : "1.0.0" }, "bin": { "etherpad-lite": "./node/server.js" }, "devDependencies": { @@ -55,6 +56,6 @@ "repository" : { "type" : "git", "url" : "http://github.com/ether/etherpad-lite.git" }, - "version" : "1.6.2", + "version" : "1.6.3", "license" : "Apache-2.0" } diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.js index 0342408c..53b233e0 100644 --- a/src/static/js/AttributeManager.js +++ b/src/static/js/AttributeManager.js @@ -400,7 +400,19 @@ AttributeManager.prototype = _(AttributeManager.prototype).extend({ this.removeAttributeOnLine(lineNum, attributeName) : this.setAttributeOnLine(lineNum, attributeName, attributeValue); - } + }, + + hasAttributeOnSelectionOrCaretPosition: function(attributeName) { + var hasSelection = ((this.rep.selStart[0] !== this.rep.selEnd[0]) || (this.rep.selEnd[1] !== this.rep.selStart[1])); + var hasAttrib; + if (hasSelection) { + hasAttrib = this.getAttributeOnSelection(attributeName); + }else { + var attributesOnCaretPosition = this.getAttributesOnCaret(); + hasAttrib = _.contains(_.flatten(attributesOnCaretPosition), attributeName); + } + return hasAttrib; + }, }); module.exports = AttributeManager; diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index 424bacf5..df9c9642 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -20,7 +20,6 @@ * limitations under the License. */ var _, $, jQuery, plugins, Ace2Common; - var browser = require('./browser'); if(browser.msie){ // Honestly fuck IE royally. @@ -61,6 +60,7 @@ function Ace2Inner(){ var SkipList = require('./skiplist'); var undoModule = require('./undomodule').undoModule; var AttributeManager = require('./AttributeManager'); + var Scroll = require('./scroll'); var DEBUG = false; //$$ build script replaces the string "var DEBUG=true;//$$" with "var DEBUG=false;" // changed to false @@ -75,6 +75,9 @@ function Ace2Inner(){ var EDIT_BODY_PADDING_TOP = 8; var EDIT_BODY_PADDING_LEFT = 8; + var FORMATTING_STYLES = ['bold', 'italic', 'underline', 'strikethrough']; + var SELECT_BUTTON_CLASS = 'selected'; + var caughtErrors = []; var thisAuthor = ''; @@ -82,6 +85,7 @@ function Ace2Inner(){ var disposed = false; var editorInfo = parent.editorInfo; + var iframe = window.frameElement; var outerWin = iframe.ace_outerWin; iframe.ace_outerWin = null; // prevent IE 6 memory leak @@ -89,6 +93,8 @@ function Ace2Inner(){ var lineMetricsDiv = sideDiv.nextSibling; initLineNumbers(); + var scroll = Scroll.init(outerWin); + var outsideKeyDown = noop; var outsideKeyPress = function(){return true;}; @@ -424,7 +430,7 @@ function Ace2Inner(){ var undoWorked = false; try { - if (evt.eventType == "setup" || evt.eventType == "importText" || evt.eventType == "setBaseText") + if (isPadLoading(evt.eventType)) { undoModule.clearHistory(); } @@ -1208,7 +1214,7 @@ function Ace2Inner(){ updateLineNumbers(); // update line numbers if any time left if (isTimeUp()) return; - var visibleRange = getVisibleCharRange(); + var visibleRange = scroll.getVisibleCharRange(rep); var docRange = [0, rep.lines.totalWidth()]; //console.log("%o %o", docRange, visibleRange); finishedImportantWork = true; @@ -1670,7 +1676,7 @@ function Ace2Inner(){ }); //p.mark("relex"); - //rep.lexer.lexCharRange(getVisibleCharRange(), function() { return false; }); + //rep.lexer.lexCharRange(scroll.getVisibleCharRange(rep), function() { return false; }); //var isTimeUp = newTimeLimit(100); // do DOM inserts p.mark("insert"); @@ -2469,17 +2475,11 @@ function Ace2Inner(){ } } - if (selectionAllHasIt) - { - documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [ - [attributeName, ''] - ]); - } - else - { - documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [ - [attributeName, 'true'] - ]); + + var attributeValue = selectionAllHasIt ? '' : 'true'; + documentAttributeManager.setAttributesOnRange(rep.selStart, rep.selEnd, [[attributeName, attributeValue]]); + if (attribIsFormattingStyle(attributeName)) { + updateStyleButtonState(attributeName, !selectionAllHasIt); // italic, bold, ... } } editorInfo.ace_toggleAttributeOnSelection = toggleAttributeOnSelection; @@ -2908,12 +2908,24 @@ function Ace2Inner(){ rep.selFocusAtStart = newSelFocusAtStart; currentCallStack.repChanged = true; + // select the formatting buttons when there is the style applied on selection + selectFormattingButtonIfLineHasStyleApplied(rep); + hooks.callAll('aceSelectionChanged', { rep: rep, callstack: currentCallStack, documentAttributeManager: documentAttributeManager, }); + // we scroll when user places the caret at the last line of the pad + // when this settings is enabled + var docTextChanged = currentCallStack.docTextChanged; + if(!docTextChanged){ + var isScrollableEvent = !isPadLoading(currentCallStack.type) && isScrollableEditEvent(currentCallStack.type); + var innerHeight = getInnerHeight(); + scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep, isScrollableEvent, innerHeight); + } + return true; //console.log("selStart: %o, selEnd: %o, focusAtStart: %s", rep.selStart, rep.selEnd, //String(!!rep.selFocusAtStart)); @@ -2922,6 +2934,27 @@ function Ace2Inner(){ //console.log("%o %o %s", rep.selStart, rep.selEnd, rep.selFocusAtStart); } + function isPadLoading(eventType) + { + return (eventType === 'setup') || (eventType === 'setBaseText') || (eventType === 'importText'); + } + + function updateStyleButtonState(attribName, hasStyleOnRepSelection) { + var $formattingButton = parent.parent.$('[data-key="' + attribName + '"]').find('a'); + $formattingButton.toggleClass(SELECT_BUTTON_CLASS, hasStyleOnRepSelection); + } + + function attribIsFormattingStyle(attributeName) { + return _.contains(FORMATTING_STYLES, attributeName); + } + + function selectFormattingButtonIfLineHasStyleApplied (rep) { + _.each(FORMATTING_STYLES, function (style) { + var hasStyleOnRepSelection = documentAttributeManager.hasAttributeOnSelectionOrCaretPosition(style); + updateStyleButtonState(style, hasStyleOnRepSelection); + }) + } + function doCreateDomLine(nonEmpty) { if (browser.msie && (!nonEmpty)) @@ -3277,50 +3310,36 @@ function Ace2Inner(){ return false; } - function getLineEntryTopBottom(entry, destObj) - { - var dom = entry.lineNode; - var top = dom.offsetTop; - var height = dom.offsetHeight; - var obj = (destObj || {}); - obj.top = top; - obj.bottom = (top + height); - return obj; - } - function getViewPortTopBottom() { - var theTop = getScrollY(); + var theTop = scroll.getScrollY(); var doc = outerWin.document; - var height = doc.documentElement.clientHeight; + var height = doc.documentElement.clientHeight; // includes padding + + // we have to get the exactly height of the viewport. So it has to subtract all the values which changes + // the viewport height (E.g. padding, position top) + var viewportExtraSpacesAndPosition = getEditorPositionTop() + getPaddingTopAddedWhenPageViewIsEnable(); return { top: theTop, - bottom: (theTop + height) + bottom: (theTop + height - viewportExtraSpacesAndPosition) }; } - function getVisibleLineRange() + + function getEditorPositionTop() { - var viewport = getViewPortTopBottom(); - //console.log("viewport top/bottom: %o", viewport); - var obj = {}; - var start = rep.lines.search(function(e) - { - return getLineEntryTopBottom(e, obj).bottom > viewport.top; - }); - var end = rep.lines.search(function(e) - { - return getLineEntryTopBottom(e, obj).top >= viewport.bottom; - }); - if (end < start) end = start; // unlikely - //console.log(start+","+end); - return [start, end]; + var editor = parent.document.getElementsByTagName('iframe'); + var editorPositionTop = editor[0].offsetTop; + return editorPositionTop; } - function getVisibleCharRange() + // ep_page_view adds padding-top, which makes the viewport smaller + function getPaddingTopAddedWhenPageViewIsEnable() { - var lineRange = getVisibleLineRange(); - return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])]; + var rootDocument = parent.parent.document; + var aceOuter = rootDocument.getElementsByName("ace_outer"); + var aceOuterPaddingTop = parseInt($(aceOuter).css("padding-top")); + return aceOuterPaddingTop; } function handleCut(evt) @@ -3966,12 +3985,12 @@ function Ace2Inner(){ doDeleteKey(); specialHandled = true; } - if((evt.which == 36 && evt.ctrlKey == true) && padShortcutEnabled.ctrlHome){ setScrollY(0); } // Control Home send to Y = 0 + if((evt.which == 36 && evt.ctrlKey == true) && padShortcutEnabled.ctrlHome){ scroll.setScrollY(0); } // Control Home send to Y = 0 if((evt.which == 33 || evt.which == 34) && type == 'keydown' && !evt.ctrlKey){ evt.preventDefault(); // This is required, browsers will try to do normal default behavior on page up / down and the default behavior SUCKS - var oldVisibleLineRange = getVisibleLineRange(); + var oldVisibleLineRange = scroll.getVisibleLineRange(rep); var topOffset = rep.selStart[0] - oldVisibleLineRange[0]; if(topOffset < 0 ){ topOffset = 0; @@ -3981,7 +4000,7 @@ function Ace2Inner(){ var isPageUp = evt.which === 33; scheduler.setTimeout(function(){ - var newVisibleLineRange = getVisibleLineRange(); // the visible lines IE 1,10 + var newVisibleLineRange = scroll.getVisibleLineRange(rep); // the visible lines IE 1,10 var linesCount = rep.lines.length(); // total count of lines in pad IE 10 var numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0]; // How many lines are in the viewport right now? @@ -4014,56 +4033,26 @@ function Ace2Inner(){ // sometimes the first selection is -1 which causes problems (Especially with ep_page_view) // so use focusNode.offsetTop value. if(caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop; - setScrollY(caretOffsetTop); // set the scrollY offset of the viewport on the document + scroll.setScrollY(caretOffsetTop); // set the scrollY offset of the viewport on the document }, 200); } - /* Attempt to apply some sanity to cursor handling in Chrome after a copy / paste event - We have to do this the way we do because rep. doesn't hold the value for keyheld events IE if the user - presses and holds the arrow key .. Sorry if this is ugly, blame Chrome's weird handling of viewports after new content is added*/ - if((evt.which == 37 || evt.which == 38 || evt.which == 39 || evt.which == 40) && browser.chrome){ - var viewport = getViewPortTopBottom(); - var myselection = document.getSelection(); // get the current caret selection, can't use rep. here because that only gives us the start position not the current - var caretOffsetTop = myselection.focusNode.parentNode.offsetTop || myselection.focusNode.offsetTop; // get the carets selection offset in px IE 214 - var lineHeight = $(myselection.focusNode.parentNode).parent("div").height(); // get the line height of the caret line - // top.console.log("offsetTop", myselection.focusNode.parentNode.parentNode.offsetTop); - try { - lineHeight = $(myselection.focusNode).height() // needed for how chrome handles line heights of null objects - // console.log("lineHeight now", lineHeight); - }catch(e){} - var caretOffsetTopBottom = caretOffsetTop + lineHeight; - var visibleLineRange = getVisibleLineRange(); // the visible lines IE 1,10 - - if(caretOffsetTop){ // sometimes caretOffsetTop bugs out and returns 0, not sure why, possible Chrome bug? Either way if it does we don't wanna mess with it - // top.console.log(caretOffsetTop, viewport.top, caretOffsetTopBottom, viewport.bottom); - var caretIsNotVisible = (caretOffsetTop < viewport.top || caretOffsetTopBottom >= viewport.bottom); // Is the Caret Visible to the user? - // Expect some weird behavior caretOffsetTopBottom is greater than viewport.bottom on a keypress down - var offsetTopSamePlace = caretOffsetTop == viewport.top; // sometimes moving key left & up leaves the caret at the same point as the viewport.top, technically the caret is visible but it's not fully visible so we should move to it - if(offsetTopSamePlace && (evt.which == 37 || evt.which == 38)){ - var newY = caretOffsetTop; - setScrollY(newY); - } - if(caretIsNotVisible){ // is the cursor no longer visible to the user? - // top.console.log("Caret is NOT visible to the user"); - // top.console.log(caretOffsetTop,viewport.top,caretOffsetTopBottom,viewport.bottom); - // Oh boy the caret is out of the visible area, I need to scroll the browser window to lineNum. - if(evt.which == 37 || evt.which == 38){ // If left or up arrow - var newY = caretOffsetTop; // That was easy! - } - if(evt.which == 39 || evt.which == 40){ // if down or right arrow - // only move the viewport if we're at the bottom of the viewport, if we hit down any other time the viewport shouldn't change - // NOTE: This behavior only fires if Chrome decides to break the page layout after a paste, it's annoying but nothing I can do - var selection = getSelection(); - // top.console.log("line #", rep.selStart[0]); // the line our caret is on - // top.console.log("firstvisible", visibleLineRange[0]); // the first visiblel ine - // top.console.log("lastVisible", visibleLineRange[1]); // the last visible line - // top.console.log(rep.selStart[0], visibleLineRange[1], rep.selStart[0], visibleLineRange[0]); - var newY = viewport.top + lineHeight; - } - if(newY){ - setScrollY(newY); // set the scrollY offset of the viewport on the document - } + // scroll to viewport when user presses arrow keys and caret is out of the viewport + if((evt.which == 37 || evt.which == 38 || evt.which == 39 || evt.which == 40)){ + // we use arrowKeyWasReleased to avoid triggering the animation when a key is continuously pressed + // this makes the scroll smooth + if(!continuouslyPressingArrowKey(type)){ + // We use getSelection() instead of rep to get the caret position. This avoids errors like when + // the caret position is not synchronized with the rep. For example, when an user presses arrow + // down to scroll the pad without releasing the key. When the key is released the rep is not + // synchronized, so we don't get the right node where caret is. + var selection = getSelection(); + + if(selection){ + var arrowUp = evt.which === 38; + var innerHeight = getInnerHeight(); + scroll.scrollWhenPressArrowKeys(arrowUp, rep, innerHeight); } } } @@ -4121,6 +4110,19 @@ function Ace2Inner(){ var thisKeyDoesntTriggerNormalize = false; + var arrowKeyWasReleased = true; + function continuouslyPressingArrowKey(type) { + var firstTimeKeyIsContinuouslyPressed = false; + + if (type == 'keyup') arrowKeyWasReleased = true; + else if (type == 'keydown' && arrowKeyWasReleased) { + firstTimeKeyIsContinuouslyPressed = true; + arrowKeyWasReleased = false; + } + + return !firstTimeKeyIsContinuouslyPressed; + } + function doUndoRedo(which) { // precond: normalized DOM @@ -4837,9 +4839,6 @@ function Ace2Inner(){ setIfNecessary(root.style, "height", ""); } } - // if near edge, scroll to edge - var scrollX = getScrollX(); - var scrollY = getScrollY(); var win = outerWin; var r = 20; @@ -4848,52 +4847,6 @@ function Ace2Inner(){ $(sideDiv).addClass('sidedivdelayed'); } - function getScrollXY() - { - var win = outerWin; - var odoc = outerWin.document; - if (typeof(win.pageYOffset) == "number") - { - return { - x: win.pageXOffset, - y: win.pageYOffset - }; - } - var docel = odoc.documentElement; - if (docel && typeof(docel.scrollTop) == "number") - { - return { - x: docel.scrollLeft, - y: docel.scrollTop - }; - } - } - - function getScrollX() - { - return getScrollXY().x; - } - - function getScrollY() - { - return getScrollXY().y; - } - - function setScrollX(x) - { - outerWin.scrollTo(x, getScrollY()); - } - - function setScrollY(y) - { - outerWin.scrollTo(getScrollX(), y); - } - - function setScrollXY(x, y) - { - outerWin.scrollTo(x, y); - } - var _teardownActions = []; function teardown() @@ -5214,26 +5167,6 @@ function Ace2Inner(){ return odoc.documentElement.clientWidth; } - function scrollNodeVerticallyIntoView(node) - { - // requires element (non-text) node; - // if node extends above top of viewport or below bottom of viewport (or top of scrollbar), - // scroll it the minimum distance needed to be completely in view. - var win = outerWin; - var odoc = outerWin.document; - var distBelowTop = node.offsetTop + iframePadTop - win.scrollY; - var distAboveBottom = win.scrollY + getInnerHeight() - (node.offsetTop + iframePadTop + node.offsetHeight); - - if (distBelowTop < 0) - { - win.scrollBy(0, distBelowTop); - } - else if (distAboveBottom < 0) - { - win.scrollBy(0, -distAboveBottom); - } - } - function scrollXHorizontallyIntoView(pixelX) { var win = outerWin; @@ -5255,8 +5188,8 @@ function Ace2Inner(){ { if (!rep.selStart) return; fixView(); - var focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]); - scrollNodeVerticallyIntoView(rep.lines.atIndex(focusLine).lineNode); + var innerHeight = getInnerHeight(); + scroll.scrollNodeVerticallyIntoView(rep, innerHeight); if (!doesWrap) { var browserSelection = getSelection(); diff --git a/src/static/js/caretPosition.js b/src/static/js/caretPosition.js new file mode 100644 index 00000000..bc3fd007 --- /dev/null +++ b/src/static/js/caretPosition.js @@ -0,0 +1,241 @@ +// One rep.line(div) can be broken in more than one line in the browser. +// This function is useful to get the caret position of the line as +// is represented by the browser +exports.getPosition = function () +{ + var rect, line; + var editor = $('#innerdocbody')[0]; + var range = getSelectionRange(); + var isSelectionInsideTheEditor = range && $(range.endContainer).closest('body')[0].id === 'innerdocbody'; + + if(isSelectionInsideTheEditor){ + // when we have the caret in an empty line, e.g. a line with only a <br>, + // getBoundingClientRect() returns all dimensions value as 0 + var selectionIsInTheBeginningOfLine = range.endOffset > 0; + if (selectionIsInTheBeginningOfLine) { + var clonedRange = createSelectionRange(range); + line = getPositionOfElementOrSelection(clonedRange); + clonedRange.detach() + } + + // when there's a <br> or any element that has no height, we can't get + // the dimension of the element where the caret is + if(!rect || rect.height === 0){ + var clonedRange = createSelectionRange(range); + + // as we can't get the element height, we create a text node to get the dimensions + // on the position + var shadowCaret = $(document.createTextNode("|")); + clonedRange.insertNode(shadowCaret[0]); + clonedRange.selectNode(shadowCaret[0]); + + line = getPositionOfElementOrSelection(clonedRange); + clonedRange.detach() + shadowCaret.remove(); + } + } + return line; +} + +var createSelectionRange = function (range) { + clonedRange = range.cloneRange(); + + // we set the selection start and end to avoid error when user selects a text bigger than + // the viewport height and uses the arrow keys to expand the selection. In this particular + // case is necessary to know where the selections ends because both edges of the selection + // is out of the viewport but we only use the end of it to calculate if it needs to scroll + clonedRange.setStart(range.endContainer, range.endOffset); + clonedRange.setEnd(range.endContainer, range.endOffset); + return clonedRange; +} + +var getPositionOfRepLineAtOffset = function (node, offset) { + // it is not a text node, so we cannot make a selection + if (node.tagName === 'BR' || node.tagName === 'EMPTY') { + return getPositionOfElementOrSelection(node); + } + + while (node.length === 0 && node.nextSibling) { + node = node.nextSibling; + } + + var newRange = new Range(); + newRange.setStart(node, offset); + newRange.setEnd(node, offset); + var linePosition = getPositionOfElementOrSelection(newRange); + newRange.detach(); // performance sake + return linePosition; +} + +function getPositionOfElementOrSelection(element) { + var rect = element.getBoundingClientRect(); + var linePosition = { + bottom: rect.bottom, + height: rect.height, + top: rect.top + } + return linePosition; +} + +// here we have two possibilities: +// [1] the line before the caret line has the same type, so both of them has the same margin, padding +// height, etc. So, we can use the caret line to make calculation necessary to know where is the top +// of the previous line +// [2] the line before is part of another rep line. It's possible this line has different margins +// height. So we have to get the exactly position of the line +exports.getPositionTopOfPreviousBrowserLine = function(caretLinePosition, rep) { + var previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1] + var isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep); + + // the caret is in the beginning of a rep line, so the previous browser line + // is the last line browser line of the a rep line + if (isCaretLineFirstBrowserLine) { //[2] + var lineBeforeCaretLine = rep.selStart[0] - 1; + var firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep); + var linePosition = getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep); + previousLineTop = linePosition.top; + } + return previousLineTop; +} + +function caretLineIsFirstBrowserLine(caretLineTop, rep) +{ + var caretRepLine = rep.selStart[0]; + var lineNode = rep.lines.atIndex(caretRepLine).lineNode; + var firstRootNode = getFirstRootChildNode(lineNode); + + // to get the position of the node we get the position of the first char + var positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1); + return positionOfFirstRootNode.top === caretLineTop; +} + +// find the first root node, usually it is a text node +function getFirstRootChildNode(node) +{ + if(!node.firstChild){ + return node; + }else{ + return getFirstRootChildNode(node.firstChild); + } + +} + +function getPreviousVisibleLine(line, rep) +{ + if (line < 0) { + return 0; + }else if (isLineVisible(line, rep)) { + return line; + }else{ + return getPreviousVisibleLine(line - 1, rep); + } +} + +function getDimensionOfLastBrowserLineOfRepLine(line, rep) +{ + var lineNode = rep.lines.atIndex(line).lineNode; + var lastRootChildNode = getLastRootChildNode(lineNode); + + // we get the position of the line in the last char of it + var lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length); + return lastRootChildNodePosition; +} + +function getLastRootChildNode(node) +{ + if(!node.lastChild){ + return { + node: node, + length: node.length + }; + }else{ + return getLastRootChildNode(node.lastChild); + } +} + +// here we have two possibilities: +// [1] The next line is part of the same rep line of the caret line, so we have the same dimensions. +// So, we can use the caret line to calculate the bottom of the line. +// [2] the next line is part of another rep line. It's possible this line has different dimensions, so we +// have to get the exactly dimension of it +exports.getBottomOfNextBrowserLine = function(caretLinePosition, rep) +{ + var nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; //[1] + var isCaretLineLastBrowserLine = caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep); + + // the caret is at the end of a rep line, so we can get the next browser line dimension + // using the position of the first char of the next rep line + if(isCaretLineLastBrowserLine){ //[2] + var nextLineAfterCaretLine = rep.selStart[0] + 1; + var firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep); + var linePosition = getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep); + nextLineBottom = linePosition.bottom; + } + return nextLineBottom; +} + +function caretLineIsLastBrowserLineOfRepLine(caretLineTop, rep) +{ + var caretRepLine = rep.selStart[0]; + var lineNode = rep.lines.atIndex(caretRepLine).lineNode; + var lastRootChildNode = getLastRootChildNode(lineNode); + + // we take a rep line and get the position of the last char of it + var lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length); + return lastRootChildNodePosition.top === caretLineTop; +} + +function getPreviousVisibleLine(line, rep) +{ + var firstLineOfPad = 0; + if (line <= firstLineOfPad) { + return firstLineOfPad; + }else if (isLineVisible(line,rep)) { + return line; + }else{ + return getPreviousVisibleLine(line - 1, rep); + } +} +exports.getPreviousVisibleLine = getPreviousVisibleLine; + +function getNextVisibleLine(line, rep) +{ + var lastLineOfThePad = rep.lines.length() - 1; + if (line >= lastLineOfThePad) { + return lastLineOfThePad; + }else if (isLineVisible(line,rep)) { + return line; + }else{ + return getNextVisibleLine(line + 1, rep); + } +} +exports.getNextVisibleLine = getNextVisibleLine; + +function isLineVisible(line, rep) +{ + return rep.lines.atIndex(line).lineNode.offsetHeight > 0; +} + +function getDimensionOfFirstBrowserLineOfRepLine(line, rep) +{ + var lineNode = rep.lines.atIndex(line).lineNode; + var firstRootChildNode = getFirstRootChildNode(lineNode); + + // we can get the position of the line, getting the position of the first char of the rep line + var firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1); + return firstRootChildNodePosition; +} + +function getSelectionRange() +{ + var selection; + if (!window.getSelection) { + return; + } + selection = window.getSelection(); + if (selection.rangeCount > 0) { + return selection.getRangeAt(0); + } else { + return null; + } +} diff --git a/src/static/js/pad_utils.js b/src/static/js/pad_utils.js index b83f21cf..9c1277a0 100644 --- a/src/static/js/pad_utils.js +++ b/src/static/js/pad_utils.js @@ -524,7 +524,7 @@ function setupGlobalExceptionHandler() { $("#editorloadingbox").css("padding", "10px"); $("#editorloadingbox").css("padding-top", "45px"); $("#editorloadingbox").html("<div style='text-align:left;color:red;font-size:16px;'><b>An error occurred</b><br>The error was reported with the following id: '" + errorId + "'<br><br><span style='color:black;font-weight:bold;font-size:16px'>Please press and hold Ctrl and press F5 to reload this page, if the problem persists please send this error message to your webmaster: </span><div style='color:black;font-size:14px'>'" - + "ErrorId: " + errorId + "<br>URL: " + window.location.href + "<br>UserAgent: " + userAgent + "<br>" + msg + " in " + url + " at line " + linenumber + "'</div></div>"); + + "ErrorId: " + errorId + "<br>URL: " + padutils.escapeHtml(window.location.href) + "<br>UserAgent: " + userAgent + "<br>" + msg + " in " + url + " at line " + linenumber + "'</div></div>"); } //send javascript errors to the server diff --git a/src/static/js/scroll.js b/src/static/js/scroll.js new file mode 100644 index 00000000..a53dc38c --- /dev/null +++ b/src/static/js/scroll.js @@ -0,0 +1,366 @@ +/* + This file handles scroll on edition or when user presses arrow keys. + In this file we have two representations of line (browser and rep line). + Rep Line = a line in the way is represented by Etherpad(rep) (each <div> is a line) + Browser Line = each vertical line. A <div> can be break into more than one + browser line. +*/ +var caretPosition = require('/caretPosition'); + +function Scroll(outerWin) { + // scroll settings + this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport; + + // DOM reference + this.outerWin = outerWin; + this.doc = this.outerWin.document; + this.rootDocument = parent.parent.document; +} + +Scroll.prototype.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary = function (rep, isScrollableEvent, innerHeight) +{ + // are we placing the caret on the line at the bottom of viewport? + // And if so, do we need to scroll the editor, as defined on the settings.json? + var shouldScrollWhenCaretIsAtBottomOfViewport = this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport; + if (shouldScrollWhenCaretIsAtBottomOfViewport) { + // avoid scrolling when selection includes multiple lines -- user can potentially be selecting more lines + // than it fits on viewport + var multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0]; + + // avoid scrolling when pad loads + if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) { + // when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0 + var pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight); + this._scrollYPage(pixelsToScroll); + } + } +} + +Scroll.prototype.scrollWhenPressArrowKeys = function(arrowUp, rep, innerHeight) +{ + // if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous + // rep line on the top of the viewport + if(this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)){ + var pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight); + + // by default, the browser scrolls to the middle of the viewport. To avoid the twist made + // when we apply a second scroll, we made it immediately (without animation) + this._scrollYPageWithoutAnimation(-pixelsToScroll); + }else{ + this.scrollNodeVerticallyIntoView(rep, innerHeight); + } +} + +// Some plugins might set a minimum height to the editor (ex: ep_page_view), so checking +// if (caretLine() === rep.lines.length() - 1) is not enough. We need to check if there are +// other lines after caretLine(), and all of them are out of viewport. +Scroll.prototype._isCaretAtTheBottomOfViewport = function(rep) +{ + // computing a line position using getBoundingClientRect() is expensive. + // (obs: getBoundingClientRect() is called on caretPosition.getPosition()) + // To avoid that, we only call this function when it is possible that the + // caret is in the bottom of viewport + var caretLine = rep.selStart[0]; + var lineAfterCaretLine = caretLine + 1; + var firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep); + var caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep); + var lineAfterCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep); + if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) { + // check if the caret is in the bottom of the viewport + var caretLinePosition = caretPosition.getPosition(); + var viewportBottom = this._getViewPortTopBottom().bottom; + var nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep); + var nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom; + return nextLineIsBelowViewportBottom; + } + return false; +} + +Scroll.prototype._isLinePartiallyVisibleOnViewport = function(lineNumber, rep) +{ + var lineNode = rep.lines.atIndex(lineNumber); + var linePosition = this._getLineEntryTopBottom(lineNode); + var lineTop = linePosition.top; + var lineBottom = linePosition.bottom; + var viewport = this._getViewPortTopBottom(); + var viewportBottom = viewport.bottom; + var viewportTop = viewport.top; + + var topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom; + var bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom; + var topOfLineIsBelowViewportTop = lineTop >= viewportTop; + var topOfLineIsAboveViewportBottom = lineTop <= viewportBottom; + var bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom; + var bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop; + + return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) || + (topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) || + (bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop); +} + +Scroll.prototype._getViewPortTopBottom = function() +{ + var theTop = this.getScrollY(); + var doc = this.doc; + var height = doc.documentElement.clientHeight; // includes padding + + // we have to get the exactly height of the viewport. So it has to subtract all the values which changes + // the viewport height (E.g. padding, position top) + var viewportExtraSpacesAndPosition = this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable(); + return { + top: theTop, + bottom: (theTop + height - viewportExtraSpacesAndPosition) + }; +} + +Scroll.prototype._getEditorPositionTop = function() +{ + var editor = parent.document.getElementsByTagName('iframe'); + var editorPositionTop = editor[0].offsetTop; + return editorPositionTop; +} + +// ep_page_view adds padding-top, which makes the viewport smaller +Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function() +{ + var aceOuter = this.rootDocument.getElementsByName("ace_outer"); + var aceOuterPaddingTop = parseInt($(aceOuter).css("padding-top")); + return aceOuterPaddingTop; +} + +Scroll.prototype._getScrollXY = function() +{ + var win = this.outerWin; + var odoc = this.doc; + if (typeof(win.pageYOffset) == "number") + { + return { + x: win.pageXOffset, + y: win.pageYOffset + }; + } + var docel = odoc.documentElement; + if (docel && typeof(docel.scrollTop) == "number") + { + return { + x: docel.scrollLeft, + y: docel.scrollTop + }; + } +} + +Scroll.prototype.getScrollX = function() +{ + return this._getScrollXY().x; +} + +Scroll.prototype.getScrollY = function() +{ + return this._getScrollXY().y; +} + +Scroll.prototype.setScrollX = function(x) +{ + this.outerWin.scrollTo(x, this.getScrollY()); +} + +Scroll.prototype.setScrollY = function(y) +{ + this.outerWin.scrollTo(this.getScrollX(), y); +} + +Scroll.prototype.setScrollXY = function(x, y) +{ + this.outerWin.scrollTo(x, y); +} + +Scroll.prototype._isCaretAtTheTopOfViewport = function(rep) +{ + var caretLine = rep.selStart[0]; + var linePrevCaretLine = caretLine - 1; + var firstLineVisibleBeforeCaretLine = caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep); + var caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep); + var lineBeforeCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep); + if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) { + var caretLinePosition = caretPosition.getPosition(); // get the position of the browser line + var viewportPosition = this._getViewPortTopBottom(); + var viewportTop = viewportPosition.top; + var viewportBottom = viewportPosition.bottom; + var caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop; + var caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom; + var caretLineIsInsideOfViewport = caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom; + if (caretLineIsInsideOfViewport) { + var prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep); + var previousLineIsAboveViewportTop = prevLineTop < viewportTop; + return previousLineIsAboveViewportTop; + } + } + return false; +} + +// By default, when user makes an edition in a line out of viewport, this line goes +// to the edge of viewport. This function gets the extra pixels necessary to get the +// caret line in a position X relative to Y% viewport. +Scroll.prototype._getPixelsRelativeToPercentageOfViewport = function(innerHeight, aboveOfViewport) +{ + var pixels = 0; + var scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport); + if(scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1){ + pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport); + } + return pixels; +} + +// we use different percentages when change selection. It depends on if it is +// either above the top or below the bottom of the page +Scroll.prototype._getPercentageToScroll = function(aboveOfViewport) +{ + var percentageToScroll = this.scrollSettings.percentage.editionBelowViewport; + if(aboveOfViewport){ + percentageToScroll = this.scrollSettings.percentage.editionAboveViewport; + } + return percentageToScroll; +} + +Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function(innerHeight) +{ + var pixels = 0; + var percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; + if(percentageToScrollUp > 0 && percentageToScrollUp <= 1){ + pixels = parseInt(innerHeight * percentageToScrollUp); + } + return pixels; +} + +Scroll.prototype._scrollYPage = function(pixelsToScroll) +{ + var durationOfAnimationToShowFocusline = this.scrollSettings.duration; + if(durationOfAnimationToShowFocusline){ + this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline); + }else{ + this._scrollYPageWithoutAnimation(pixelsToScroll); + } +} + +Scroll.prototype._scrollYPageWithoutAnimation = function(pixelsToScroll) +{ + this.outerWin.scrollBy(0, pixelsToScroll); +} + +Scroll.prototype._scrollYPageWithAnimation = function(pixelsToScroll, durationOfAnimationToShowFocusline) +{ + var outerDocBody = this.doc.getElementById("outerdocbody"); + + // it works on later versions of Chrome + var $outerDocBody = $(outerDocBody); + this._triggerScrollWithAnimation($outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline); + + // it works on Firefox and earlier versions of Chrome + var $outerDocBodyParent = $outerDocBody.parent(); + this._triggerScrollWithAnimation($outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline); +} + +// using a custom queue and clearing it, we avoid creating a queue of scroll animations. So if this function +// is called twice quickly, only the last one runs. +Scroll.prototype._triggerScrollWithAnimation = function($elem, pixelsToScroll, durationOfAnimationToShowFocusline) +{ + // clear the queue of animation + $elem.stop("scrollanimation"); + $elem.animate({ + scrollTop: '+=' + pixelsToScroll + }, { + duration: durationOfAnimationToShowFocusline, + queue: "scrollanimation" + }).dequeue("scrollanimation"); +} + +// scrollAmountWhenFocusLineIsOutOfViewport is set to 0 (default), scroll it the minimum distance +// needed to be completely in view. If the value is greater than 0 and less than or equal to 1, +// besides of scrolling the minimum needed to be visible, it scrolls additionally +// (viewport height * scrollAmountWhenFocusLineIsOutOfViewport) pixels +Scroll.prototype.scrollNodeVerticallyIntoView = function(rep, innerHeight) +{ + var viewport = this._getViewPortTopBottom(); + var isPartOfRepLineOutOfViewport = this._partOfRepLineIsOutOfViewport(viewport, rep); + + // when the selection changes outside of the viewport the browser automatically scrolls the line + // to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now + // So, when the line scrolled gets outside of the viewport we let the browser handle it. + var linePosition = caretPosition.getPosition(); + if(linePosition){ + var distanceOfTopOfViewport = linePosition.top - viewport.top; + var distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom; + var caretIsAboveOfViewport = distanceOfTopOfViewport < 0; + var caretIsBelowOfViewport = distanceOfBottomOfViewport < 0; + if(caretIsAboveOfViewport){ + var pixelsToScroll = distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true); + this._scrollYPage(pixelsToScroll); + }else if(caretIsBelowOfViewport){ + var pixelsToScroll = -distanceOfBottomOfViewport + this._getPixelsRelativeToPercentageOfViewport(innerHeight); + this._scrollYPage(pixelsToScroll); + }else{ + this.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep, true, innerHeight); + } + } +} + +Scroll.prototype._partOfRepLineIsOutOfViewport = function(viewportPosition, rep) +{ + var focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]); + var line = rep.lines.atIndex(focusLine); + var linePosition = this._getLineEntryTopBottom(line); + var lineIsAboveOfViewport = linePosition.top < viewportPosition.top; + var lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom; + + return lineIsBelowOfViewport || lineIsAboveOfViewport; +} + +Scroll.prototype._getLineEntryTopBottom = function(entry, destObj) +{ + var dom = entry.lineNode; + var top = dom.offsetTop; + var height = dom.offsetHeight; + var obj = (destObj || {}); + obj.top = top; + obj.bottom = (top + height); + return obj; +} + +Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function(arrowUp, rep) +{ + var percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; + return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep); +} + +Scroll.prototype.getVisibleLineRange = function(rep) +{ + var viewport = this._getViewPortTopBottom(); + //console.log("viewport top/bottom: %o", viewport); + var obj = {}; + var self = this; + var start = rep.lines.search(function(e) + { + return self._getLineEntryTopBottom(e, obj).bottom > viewport.top; + }); + var end = rep.lines.search(function(e) + { + // return the first line that the top position is greater or equal than + // the viewport. That is the first line that is below the viewport bottom. + // So the line that is in the bottom of the viewport is the very previous one. + return self._getLineEntryTopBottom(e, obj).top >= viewport.bottom; + }); + if (end < start) end = start; // unlikely + // top.console.log(start+","+(end -1)); + return [start, end - 1]; +} + +Scroll.prototype.getVisibleCharRange = function(rep) +{ + var lineRange = this.getVisibleLineRange(rep); + return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])]; +} + +exports.init = function(outerWin) +{ + return new Scroll(outerWin); +} diff --git a/tests/frontend/specs/scroll.js b/tests/frontend/specs/scroll.js new file mode 100644 index 00000000..096b06b6 --- /dev/null +++ b/tests/frontend/specs/scroll.js @@ -0,0 +1,649 @@ +describe('scroll when focus line is out of viewport', function () { + before(function (done) { + helper.newPad(function(){ + cleanPad(function(){ + forceUseMonospacedFont(); + scrollWhenPlaceCaretInTheLastLineOfViewport(); + createPadWithSeveralLines(function(){ + resizeEditorHeight(); + done(); + }); + }); + }); + this.timeout(20000); + }); + + context('when user presses any arrow keys on a line above the viewport', function(){ + context('and scroll percentage config is set to 0.2 on settings.json', function(){ + var lineCloseOfTopOfPad = 10; + before(function (done) { + setScrollPercentageWhenFocusLineIsOutOfViewport(0.2, true); + scrollEditorToBottomOfPad(); + + placeCaretInTheBeginningOfLine(lineCloseOfTopOfPad, function(){ // place caret in the 10th line + // warning: even pressing right arrow, the caret does not change of position + // the column where the caret is, it has not importance, only the line + pressAndReleaseRightArrow(); + done(); + }); + }); + + it('keeps the focus line scrolled 20% from the top of the viewport', function (done) { + // default behavior is to put the line in the top of viewport, but as + // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.2, we have an extra 20% of lines scrolled + // (2 lines, which are the 20% of the 10 that are visible on viewport) + var firstLineOfViewport = getFirstLineVisibileOfViewport(); + expect(lineCloseOfTopOfPad).to.be(firstLineOfViewport + 2); + done(); + }); + }); + }); + + context('when user presses any arrow keys on a line below the viewport', function(){ + context('and scroll percentage config is set to 0.7 on settings.json', function(){ + var lineCloseToBottomOfPad = 50; + before(function (done) { + setScrollPercentageWhenFocusLineIsOutOfViewport(0.7); + + // firstly, scroll to make the lineCloseToBottomOfPad visible. After that, scroll to make it out of viewport + scrollEditorToTopOfPad(); + placeCaretAtTheEndOfLine(lineCloseToBottomOfPad); // place caret in the 50th line + setTimeout(function() { + // warning: even pressing right arrow, the caret does not change of position + pressAndReleaseLeftArrow(); + done(); + }, 1000); + }); + + it('keeps the focus line scrolled 70% from the bottom of the viewport', function (done) { + // default behavior is to put the line in the top of viewport, but as + // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.7, we have an extra 70% of lines scrolled + // (7 lines, which are the 70% of the 10 that are visible on viewport) + var lastLineOfViewport = getLastLineVisibleOfViewport(); + expect(lineCloseToBottomOfPad).to.be(lastLineOfViewport - 7); + done(); + }); + }); + }); + + context('when user presses arrow up on the first line of the viewport', function(){ + context('and percentageToScrollWhenUserPressesArrowUp is set to 0.3', function () { + var lineOnTopOfViewportWhenThePadIsScrolledDown; + before(function (done) { + setPercentageToScrollWhenUserPressesArrowUp(0.3); + + // we need some room to make the scroll up + scrollEditorToBottomOfPad(); + lineOnTopOfViewportWhenThePadIsScrolledDown = 91; + placeCaretAtTheEndOfLine(lineOnTopOfViewportWhenThePadIsScrolledDown); + setTimeout(function() { + // warning: even pressing up arrow, the caret does not change of position + pressAndReleaseUpArrow(); + done(); + }, 1000); + }); + + it('keeps the focus line scrolled 30% of the top of the viewport', function (done) { + // default behavior is to put the line in the top of viewport, but as + // PercentageToScrollWhenUserPressesArrowUp is set to 0.3, we have an extra 30% of lines scrolled + // (3 lines, which are the 30% of the 10 that are visible on viewport) + var firstLineOfViewport = getFirstLineVisibileOfViewport(); + expect(firstLineOfViewport).to.be(lineOnTopOfViewportWhenThePadIsScrolledDown - 3); + done(); + }) + }); + }); + + context('when user edits the last line of viewport', function(){ + context('and scroll percentage config is set to 0 on settings.json', function(){ + var lastLineOfViewportBeforeEnter = 10; + before(function () { + // the default value + resetScrollPercentageWhenFocusLineIsOutOfViewport(); + + // make sure the last line on viewport is the 10th one + scrollEditorToTopOfPad(); + placeCaretAtTheEndOfLine(lastLineOfViewportBeforeEnter); + pressEnter(); + }); + + it('keeps the focus line on the bottom of the viewport', function (done) { + var lastLineOfViewportAfterEnter = getLastLineVisibleOfViewport(); + expect(lastLineOfViewportAfterEnter).to.be(lastLineOfViewportBeforeEnter + 1); + done(); + }); + }); + + context('and scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.3', function(){ // this value is arbitrary + var lastLineOfViewportBeforeEnter = 9; + before(function () { + setScrollPercentageWhenFocusLineIsOutOfViewport(0.3); + + // make sure the last line on viewport is the 10th one + scrollEditorToTopOfPad(); + placeCaretAtTheEndOfLine(lastLineOfViewportBeforeEnter); + pressBackspace(); + }); + + it('scrolls 30% of viewport up', function (done) { + var lastLineOfViewportAfterEnter = getLastLineVisibleOfViewport(); + // default behavior is to scroll one line at the bottom of viewport, but as + // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.3, we have an extra 30% of lines scrolled + // (3 lines, which are the 30% of the 10 that are visible on viewport) + expect(lastLineOfViewportAfterEnter).to.be(lastLineOfViewportBeforeEnter + 3); + done(); + }); + }); + + context('and it is set to a value that overflow the interval [0, 1]', function(){ + var lastLineOfViewportBeforeEnter = 10; + before(function(){ + var scrollPercentageWhenFocusLineIsOutOfViewport = 1.5; + scrollEditorToTopOfPad(); + placeCaretAtTheEndOfLine(lastLineOfViewportBeforeEnter); + setScrollPercentageWhenFocusLineIsOutOfViewport(scrollPercentageWhenFocusLineIsOutOfViewport); + pressEnter(); + }); + + it('keeps the default behavior of moving the focus line on the bottom of the viewport', function (done) { + var lastLineOfViewportAfterEnter = getLastLineVisibleOfViewport(); + expect(lastLineOfViewportAfterEnter).to.be(lastLineOfViewportBeforeEnter + 1); + done(); + }); + }); + }); + + context('when user edits a line above the viewport', function(){ + context('and scroll percentage config is set to 0 on settings.json', function(){ + var lineCloseOfTopOfPad = 10; + before(function () { + // the default value + setScrollPercentageWhenFocusLineIsOutOfViewport(0); + + // firstly, scroll to make the lineCloseOfTopOfPad visible. After that, scroll to make it out of viewport + scrollEditorToTopOfPad(); + placeCaretAtTheEndOfLine(lineCloseOfTopOfPad); // place caret in the 10th line + scrollEditorToBottomOfPad(); + pressBackspace(); // edit the line where the caret is, which is above the viewport + }); + + it('keeps the focus line on the top of the viewport', function (done) { + var firstLineOfViewportAfterEnter = getFirstLineVisibileOfViewport(); + expect(firstLineOfViewportAfterEnter).to.be(lineCloseOfTopOfPad); + done(); + }); + }); + + context('and scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.2', function(){ // this value is arbitrary + var lineCloseToBottomOfPad = 50; + before(function () { + // we force the line edited to be above the top of the viewport + setScrollPercentageWhenFocusLineIsOutOfViewport(0.2, true); // set scroll jump to 20% + scrollEditorToTopOfPad(); + placeCaretAtTheEndOfLine(lineCloseToBottomOfPad); + scrollEditorToBottomOfPad(); + pressBackspace(); // edit line + }); + + it('scrolls 20% of viewport down', function (done) { + // default behavior is to scroll one line at the top of viewport, but as + // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.2, we have an extra 20% of lines scrolled + // (2 lines, which are the 20% of the 10 that are visible on viewport) + var firstLineVisibileOfViewport = getFirstLineVisibileOfViewport(); + expect(lineCloseToBottomOfPad).to.be(firstLineVisibileOfViewport + 2); + done(); + }); + }); + }); + + context('when user places the caret at the last line visible of viewport', function(){ + var lastLineVisible; + context('and scroll percentage config is set to 0 on settings.json', function(){ + before(function (done) { + // reset to the default value + resetScrollPercentageWhenFocusLineIsOutOfViewport(); + + placeCaretInTheBeginningOfLine(0, function(){ // reset caret position + scrollEditorToTopOfPad(); + lastLineVisible = getLastLineVisibleOfViewport(); + placeCaretInTheBeginningOfLine(lastLineVisible, done); // place caret in the 9th line + }); + + }); + + it('does not scroll', function(done){ + setTimeout(function() { + var lastLineOfViewport = getLastLineVisibleOfViewport(); + var lineDoesNotScroll = lastLineOfViewport === lastLineVisible; + expect(lineDoesNotScroll).to.be(true); + done(); + }, 1000); + }); + }); + context('and scroll percentage config is set to 0.5 on settings.json', function(){ + before(function (done) { + setScrollPercentageWhenFocusLineIsOutOfViewport(0.5); + scrollEditorToTopOfPad(); + placeCaretInTheBeginningOfLine(0, function(){ // reset caret position + // this timeout inside a callback is ugly but it necessary to give time to aceSelectionChange + // realizes that the selection has been changed + setTimeout(function() { + lastLineVisible = getLastLineVisibleOfViewport(); + placeCaretInTheBeginningOfLine(lastLineVisible, done); // place caret in the 9th line + }, 1000); + }); + }); + + it('scrolls line to 50% of the viewport', function(done){ + helper.waitFor(function(){ + var lastLineOfViewport = getLastLineVisibleOfViewport(); + var lastLinesScrolledFiveLinesUp = lastLineOfViewport - 5 === lastLineVisible; + return lastLinesScrolledFiveLinesUp; + }).done(done); + }); + }); + }); + + // This is a special case. When user is selecting a text with arrow down or arrow left we have + // to keep the last line selected on focus + context('when the first line selected is out of the viewport and user presses shift arrow down', function(){ + var lastLineOfPad = 99; + before(function (done) { + scrollEditorToTopOfPad(); + + // make a selection bigger than the viewport height + var $firstLineOfSelection = getLine(0); + var $lastLineOfSelection = getLine(lastLineOfPad); + var lengthOfLastLine = $lastLineOfSelection.text().length; + helper.selectLines($firstLineOfSelection, $lastLineOfSelection, 0, lengthOfLastLine); + + // place the last line selected on the viewport + scrollEditorToBottomOfPad(); + + // press a key to make the selection goes down + // although we can't simulate the extending of selection. It's possible to send a key event + // which is captured on ace2_inner scroll function. + pressAndReleaseLeftArrow(true); + done(); + }); + + it('keeps the last line selected on focus', function (done) { + var lastLineOfSelectionIsVisible = isLineOnViewport(lastLineOfPad); + expect(lastLineOfSelectionIsVisible).to.be(true); + done(); + }); + }); + + // In this scenario we avoid the bouncing scroll. E.g Let's suppose we have a big line that is + // the size of the viewport, and its top is above the viewport. When user presses '<-', this line + // will scroll down because the top is out of the viewport. When it scrolls down, the bottom of + // line gets below the viewport so when user presses '<-' again it scrolls up to make the bottom + // of line visible. If user presses arrow keys more than one time, the editor will keep scrolling up and down + context('when the line height is bigger than the scroll amount percentage * viewport height', function(){ + var scrollOfEditorBeforePressKey; + var BIG_LINE_NUMBER = 0; + var MIDDLE_OF_BIG_LINE = 51; + before(function (done) { + createPadWithALineHigherThanViewportHeight(this, BIG_LINE_NUMBER, function(){ + setScrollPercentageWhenFocusLineIsOutOfViewport(0.5); // set any value to force scroll to outside to viewport + var $bigLine = getLine(BIG_LINE_NUMBER); + + // each line has about 5 chars, we place the caret in the middle of the line + helper.selectLines($bigLine, $bigLine, MIDDLE_OF_BIG_LINE, MIDDLE_OF_BIG_LINE); + + scrollEditorToLeaveTopAndBottomOfBigLineOutOfViewport($bigLine); + scrollOfEditorBeforePressKey = getEditorScroll(); + + // press a key to force to scroll + pressAndReleaseRightArrow(); + done(); + }); + }); + + // reset pad to the original text + after(function (done) { + this.timeout(5000); + cleanPad(function(){ + createPadWithSeveralLines(function(){ + resetEditorWidth(); + done(); + }); + }); + }); + + // as the editor.line is inside of the viewport, it should not scroll + it('should not scroll', function (done) { + var scrollOfEditorAfterPressKey = getEditorScroll(); + expect(scrollOfEditorAfterPressKey).to.be(scrollOfEditorBeforePressKey); + done(); + }); + }); + + // Some plugins, for example the ep_page_view, change the editor dimensions. This plugin, for example, + // adds padding-top to the ace_outer, which changes the viewport height + describe('integration with plugins which changes the margin of editor', function(){ + context('when editor dimensions changes', function(){ + before(function () { + // reset the size of editor. Now we show more than 10 lines as in the other tests + resetResizeOfEditorHeight(); + scrollEditorToTopOfPad(); + + // height of the editor viewport + var editorHeight = getEditorHeight(); + + // add a big padding-top, 50% of the viewport + var paddingTopOfAceOuter = editorHeight/2; + var chrome$ = helper.padChrome$; + var $outerIframe = chrome$('iframe'); + $outerIframe.css('padding-top', paddingTopOfAceOuter); + + // we set a big value to check if the scroll is made + setScrollPercentageWhenFocusLineIsOutOfViewport(1); + }); + + context('and user places the caret in the last line visible of the pad', function(){ + var lastLineVisible; + beforeEach(function (done) { + lastLineVisible = getLastLineVisibleOfViewport(); + placeCaretInTheBeginningOfLine(lastLineVisible, done); + }); + + it('scrolls the line where caret is', function(done){ + helper.waitFor(function(){ + var firstLineVisibileOfViewport = getFirstLineVisibileOfViewport(); + var linesScrolled = firstLineVisibileOfViewport !== 0; + return linesScrolled; + }).done(done); + }); + }); + }); + }); + + /* ********************* Helper functions/constants ********************* */ + var TOP_OF_PAGE = 0; + var BOTTOM_OF_PAGE = 5000; // we use a big value to force the page to be scrolled all the way down + var LINES_OF_PAD = 100; + var ENTER = 13; + var BACKSPACE = 8; + var LEFT_ARROW = 37; + var UP_ARROW = 38; + var RIGHT_ARROW = 39; + var LINES_ON_VIEWPORT = 10; + var WIDTH_OF_EDITOR_RESIZED = 100; + var LONG_TEXT_CHARS = 100; + + var cleanPad = function(callback) { + var inner$ = helper.padInner$; + var $padContent = inner$('#innerdocbody'); + $padContent.html(''); + + // wait for Etherpad to re-create first line + helper.waitFor(function(){ + var lineNumber = inner$('div').length; + return lineNumber === 1; + }, 2000).done(callback); + }; + + var createPadWithSeveralLines = function(done) { + var line = '<span>a</span><br>'; + var $firstLine = helper.padInner$('div').first(); + var lines = line.repeat(LINES_OF_PAD); //arbitrary number, we need to create lines that is over the viewport + $firstLine.html(lines); + + helper.waitFor(function(){ + var linesCreated = helper.padInner$('div').length; + return linesCreated === LINES_OF_PAD; + }, 4000).done(done); + }; + + var createPadWithALineHigherThanViewportHeight = function(test, line, done) { + var viewportHeight = 160; //10 lines * 16px (height of line) + test.timeout(5000); + cleanPad(function(){ + // make the editor smaller to make test easier + // with that width the each line has about 5 chars + resizeEditorWidth(); + + // we create a line with 100 chars, which makes about 20 lines + setLongTextOnLine(line); + helper.waitFor(function () { + var $firstLine = getLine(line); + + var heightOfLine = $firstLine.get(0).getBoundingClientRect().height; + return heightOfLine >= viewportHeight; + }, 4000).done(done); + }); + }; + + var setLongTextOnLine = function(line) { + var $line = getLine(line); + var longText = 'a'.repeat(LONG_TEXT_CHARS); + $line.html(longText); + }; + + // resize the editor to make the tests easier + var resizeEditorHeight = function() { + var chrome$ = helper.padChrome$; + chrome$('#editorcontainer').css('height', getSizeOfViewport()); + }; + + // this makes about 5 chars per line + var resizeEditorWidth = function() { + var chrome$ = helper.padChrome$; + chrome$('#editorcontainer').css('width', WIDTH_OF_EDITOR_RESIZED); + }; + + var resetResizeOfEditorHeight = function() { + var chrome$ = helper.padChrome$; + chrome$('#editorcontainer').css('height', ''); + }; + + var resetEditorWidth = function () { + var chrome$ = helper.padChrome$; + chrome$('#editorcontainer').css('width', ''); + }; + + var getEditorHeight = function() { + var chrome$ = helper.padChrome$; + var $editor = chrome$('#editorcontainer'); + var editorHeight = $editor.get(0).clientHeight; + return editorHeight; + }; + + var getSizeOfViewport = function() { + return getLinePositionOnViewport(LINES_ON_VIEWPORT) - getLinePositionOnViewport(0); + }; + + var scrollPageTo = function(value) { + var outer$ = helper.padOuter$; + var $ace_outer = outer$('#outerdocbody').parent(); + $ace_outer.parent().scrollTop(value); + }; + + var scrollEditorToTopOfPad = function() { + scrollPageTo(TOP_OF_PAGE); + }; + + var scrollEditorToBottomOfPad = function() { + scrollPageTo(BOTTOM_OF_PAGE); + }; + + var scrollEditorToLeaveTopAndBottomOfBigLineOutOfViewport = function ($bigLine) { + var lineHeight = $bigLine.get(0).getBoundingClientRect().height; + var middleOfLine = lineHeight/2; + scrollPageTo(middleOfLine); + }; + + var getLine = function(lineNum) { + var inner$ = helper.padInner$; + var $line = inner$('div').eq(lineNum); + return $line; + }; + + var placeCaretAtTheEndOfLine = function(lineNum) { + var $targetLine = getLine(lineNum); + var lineLength = $targetLine.text().length; + helper.selectLines($targetLine, $targetLine, lineLength, lineLength); + }; + + var placeCaretInTheBeginningOfLine = function(lineNum, cb) { + var $targetLine = getLine(lineNum); + helper.selectLines($targetLine, $targetLine, 0, 0); + helper.waitFor(function() { + var $lineWhereCaretIs = getLineWhereCaretIs(); + return $targetLine.get(0) === $lineWhereCaretIs.get(0); + }).done(cb); + }; + + var getLineWhereCaretIs = function() { + var inner$ = helper.padInner$; + var nodeWhereCaretIs = inner$.document.getSelection().anchorNode; + var $lineWhereCaretIs = $(nodeWhereCaretIs).closest('div'); + return $lineWhereCaretIs; + }; + + var getFirstLineVisibileOfViewport = function() { + return _.find(_.range(0, LINES_OF_PAD - 1), isLineOnViewport); + }; + + var getLastLineVisibleOfViewport = function() { + return _.find(_.range(LINES_OF_PAD - 1, 0, -1), isLineOnViewport); + }; + + var pressKey = function(keyCode, shiftIsPressed){ + var inner$ = helper.padInner$; + var evtType; + if(inner$(window)[0].bowser.firefox || inner$(window)[0].bowser.modernIE){ // if it's a mozilla or IE + evtType = 'keypress'; + }else{ + evtType = 'keydown'; + } + var e = inner$.Event(evtType); + e.shiftKey = shiftIsPressed; + e.keyCode = keyCode; + e.which = keyCode; // etherpad listens to 'which' + inner$('#innerdocbody').trigger(e); + }; + + var releaseKey = function(keyCode){ + var inner$ = helper.padInner$; + var evtType = 'keyup'; + var e = inner$.Event(evtType); + e.keyCode = keyCode; + e.which = keyCode; // etherpad listens to 'which' + inner$('#innerdocbody').trigger(e); + }; + + var pressEnter = function() { + pressKey(ENTER); + }; + + var pressBackspace = function() { + pressKey(BACKSPACE); + }; + + var pressAndReleaseUpArrow = function() { + pressKey(UP_ARROW); + releaseKey(UP_ARROW); + }; + + var pressAndReleaseRightArrow = function() { + pressKey(RIGHT_ARROW); + releaseKey(RIGHT_ARROW); + }; + + var pressAndReleaseLeftArrow = function(shiftIsPressed) { + pressKey(LEFT_ARROW, shiftIsPressed); + releaseKey(LEFT_ARROW); + }; + + var isLineOnViewport = function(lineNumber) { + // in the function scrollNodeVerticallyIntoView from ace2_inner.js, iframePadTop is used to calculate + // how much scroll is needed. Although the name refers to padding-top, this value is not set on the + // padding-top. + var iframePadTop = 8; + var $line = getLine(lineNumber); + var linePosition = $line.get(0).getBoundingClientRect(); + + // position relative to the current viewport + var linePositionTopOnViewport = linePosition.top - getEditorScroll() + iframePadTop; + var linePositionBottomOnViewport = linePosition.bottom - getEditorScroll(); + + var lineBellowTop = linePositionBottomOnViewport > 0; + var lineAboveBottom = linePositionTopOnViewport < getClientHeightVisible(); + var isVisible = lineBellowTop && lineAboveBottom; + + return isVisible; + }; + + var getEditorScroll = function () { + var outer$ = helper.padOuter$; + var scrollTopFirefox = outer$('#outerdocbody').parent().scrollTop(); // works only on firefox + var scrollTop = outer$('#outerdocbody').scrollTop() || scrollTopFirefox; + return scrollTop; + }; + + // clientHeight includes padding, so we have to subtract it and consider only the visible viewport + var getClientHeightVisible = function () { + var outer$ = helper.padOuter$; + var $ace_outer = outer$('#outerdocbody').parent(); + var ace_outerHeight = $ace_outer.get(0).clientHeight; + var ace_outerPaddingTop = getIntValueOfCSSProperty($ace_outer, 'padding-top'); + var paddingAddedWhenPageViewIsEnable = getPaddingAddedWhenPageViewIsEnable(); + var clientHeight = ace_outerHeight - ( ace_outerPaddingTop + paddingAddedWhenPageViewIsEnable); + + return clientHeight; + }; + + // ep_page_view changes the dimensions of the editor. We have to guarantee + // the viewport height is calculated right + var getPaddingAddedWhenPageViewIsEnable = function () { + var chrome$ = helper.padChrome$; + var $outerIframe = chrome$('iframe'); + var paddingAddedWhenPageViewIsEnable = parseInt($outerIframe.css('padding-top')); + return paddingAddedWhenPageViewIsEnable; + }; + + var getIntValueOfCSSProperty = function($element, property){ + var valueString = $element.css(property); + return parseInt(valueString) || 0; + }; + + var forceUseMonospacedFont = function () { + helper.padChrome$.window.clientVars.padOptions.useMonospaceFont = true; + }; + + var setScrollPercentageWhenFocusLineIsOutOfViewport = function(value, editionAboveViewport) { + var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport; + if (editionAboveViewport) { + scrollSettings.percentage.editionAboveViewport = value; + }else{ + scrollSettings.percentage.editionBelowViewport = value; + } + }; + + var resetScrollPercentageWhenFocusLineIsOutOfViewport = function() { + var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport; + scrollSettings.percentage.editionAboveViewport = 0; + scrollSettings.percentage.editionBelowViewport = 0; + }; + + var setPercentageToScrollWhenUserPressesArrowUp = function (value) { + var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport; + scrollSettings.percentageToScrollWhenUserPressesArrowUp = value; + }; + + var scrollWhenPlaceCaretInTheLastLineOfViewport = function() { + var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport; + scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport = true; + }; + + var getLinePositionOnViewport = function(lineNumber) { + var $line = getLine(lineNumber); + var linePosition = $line.get(0).getBoundingClientRect(); + + // position relative to the current viewport + return linePosition.top - getEditorScroll(); + }; +}); + diff --git a/tests/frontend/specs/select_formatting_buttons.js b/tests/frontend/specs/select_formatting_buttons.js new file mode 100644 index 00000000..5fb97600 --- /dev/null +++ b/tests/frontend/specs/select_formatting_buttons.js @@ -0,0 +1,166 @@ +describe("select formatting buttons when selection has style applied", function(){ + var STYLES = ['italic', 'bold', 'underline', 'strikethrough']; + var SHORTCUT_KEYS = ['I', 'B', 'U', '5']; // italic, bold, underline, strikethrough + var FIRST_LINE = 0; + + before(function(cb){ + helper.newPad(cb); + this.timeout(60000); + }); + + var applyStyleOnLine = function(style, line) { + var chrome$ = helper.padChrome$; + selectLine(line); + var $formattingButton = chrome$('.buttonicon-' + style); + $formattingButton.click(); + } + + var isButtonSelected = function(style) { + var chrome$ = helper.padChrome$; + var $formattingButton = chrome$('.buttonicon-' + style); + return $formattingButton.parent().hasClass('selected'); + } + + var selectLine = function(lineNumber, offsetStart, offsetEnd) { + var inner$ = helper.padInner$; + var $line = inner$("div").eq(lineNumber); + helper.selectLines($line, $line, offsetStart, offsetEnd); + } + + var placeCaretOnLine = function(lineNumber) { + var inner$ = helper.padInner$; + var $line = inner$("div").eq(lineNumber); + $line.sendkeys('{leftarrow}'); + } + + var undo = function() { + var $undoButton = helper.padChrome$(".buttonicon-undo"); + $undoButton.click(); + } + + var testIfFormattingButtonIsDeselected = function(style) { + it('deselects the ' + style + ' button', function(done) { + helper.waitFor(function(){ + return isButtonSelected(style) === false; + }).done(done) + }); + } + + var testIfFormattingButtonIsSelected = function(style) { + it('selects the ' + style + ' button', function(done) { + helper.waitFor(function(){ + return isButtonSelected(style); + }).done(done) + }); + } + + var applyStyleOnLineAndSelectIt = function(line, style, cb) { + applyStyleOnLineOnFullLineAndRemoveSelection(line, style, selectLine, cb); + } + + var applyStyleOnLineAndPlaceCaretOnit = function(line, style, cb) { + applyStyleOnLineOnFullLineAndRemoveSelection(line, style, placeCaretOnLine, cb); + } + + var applyStyleOnLineOnFullLineAndRemoveSelection = function(line, style, selectTarget, cb) { + applyStyleOnLine(style, line); + + // we have to give some time to Etherpad detects the selection changed + setTimeout(function() { + // remove selection from previous line + selectLine(line + 1); + setTimeout(function() { + // select the text or place the caret on a position that + // has the formatting text applied previously + selectTarget(line); + cb(); + }, 1000); + }, 1000); + } + + var pressFormattingShortcutOnSelection = function(key) { + var inner$ = helper.padInner$; + var chrome$ = helper.padChrome$; + + //get the first text element out of the inner iframe + var $firstTextElement = inner$("div").first(); + + //select this text element + $firstTextElement.sendkeys('{selectall}'); + + if(inner$(window)[0].bowser.firefox || inner$(window)[0].bowser.modernIE){ // if it's a mozilla or IE + var evtType = "keypress"; + }else{ + var evtType = "keydown"; + } + + var e = inner$.Event(evtType); + e.ctrlKey = true; // Control key + e.which = key.charCodeAt(0); // I, U, B, 5 + inner$("#innerdocbody").trigger(e); + } + + STYLES.forEach(function(style){ + context('when selection is in a text with ' + style + ' applied', function(){ + before(function (done) { + this.timeout(4000); + applyStyleOnLineAndSelectIt(FIRST_LINE, style, done); + }); + + after(function () { + undo(); + }); + + testIfFormattingButtonIsSelected(style); + }); + + context('when caret is in a position with ' + style + ' applied', function(){ + before(function (done) { + this.timeout(4000); + applyStyleOnLineAndPlaceCaretOnit(FIRST_LINE, style, done); + }); + + after(function () { + undo(); + }); + + testIfFormattingButtonIsSelected(style) + }); + }); + + context('when user applies a style and the selection does not change', function() { + var style = STYLES[0]; // italic + before(function () { + applyStyleOnLine(style, FIRST_LINE); + }); + + // clean the style applied + after(function () { + applyStyleOnLine(style, FIRST_LINE); + }); + + it('selects the style button', function (done) { + expect(isButtonSelected(style)).to.be(true); + done(); + }); + }); + + SHORTCUT_KEYS.forEach(function(key, index){ + var styleOfTheShortcut = STYLES[index]; // italic, bold, ... + context('when user presses CMD + ' + key, function() { + before(function () { + pressFormattingShortcutOnSelection(key); + }); + + testIfFormattingButtonIsSelected(styleOfTheShortcut); + + context('and user presses CMD + ' + key + ' again', function() { + before(function () { + pressFormattingShortcutOnSelection(key); + }); + + testIfFormattingButtonIsDeselected(styleOfTheShortcut); + }); + }); + }); +}); |