diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 02bc257b12b..c6ca0b0c90c 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -25,6 +25,7 @@ //= require ./discourse/lib/key-value-store //= require ./discourse/lib/computed //= require ./discourse/lib/formatter +//= require ./discourse/lib/text-direction //= require ./discourse/lib/eyeline //= require ./discourse/lib/show-modal //= require ./discourse/mixins/scrolling diff --git a/app/assets/javascripts/discourse/components/d-editor.js.es6 b/app/assets/javascripts/discourse/components/d-editor.js.es6 index c70f0b3bc96..010f11bd2ba 100644 --- a/app/assets/javascripts/discourse/components/d-editor.js.es6 +++ b/app/assets/javascripts/discourse/components/d-editor.js.es6 @@ -8,6 +8,7 @@ import { emojiSearch, isSkinTonableEmoji } from 'pretty-text/emoji'; import { emojiUrlFor } from 'discourse/lib/text'; import { getRegister } from 'discourse-common/lib/get-owner'; import { findRawTemplate } from 'discourse/lib/raw-templates'; +import { siteDir } from 'discourse/lib/text-direction'; import { determinePostReplaceSelection, clipboardData } from 'discourse/lib/utilities'; import toMarkdown from 'discourse/lib/to-markdown'; import deprecated from 'discourse-common/lib/deprecated'; @@ -107,6 +108,17 @@ class Toolbar { perform: e => e.applyList(i => !i ? "1. " : `${parseInt(i) + 1}. `, 'list_item') }); + if (Discourse.SiteSettings.support_mixed_text_direction) { + this.addButton({ + id: 'toggle-direction', + group: 'extras', + icon: 'exchange', + shortcut: 'Shift+6', + title: 'composer.toggle_direction', + perform: e => e.toggleDirection(), + }); + } + if (site.mobileView) { this.groups.push({group: 'mobileExtras', buttons: []}); } @@ -647,6 +659,14 @@ export default Ember.Component.extend({ return null; }, + _toggleDirection() { + const $textArea = $(".d-editor-input"); + let currentDir = $textArea.attr('dir') ? $textArea.attr('dir') : siteDir(), + newDir = currentDir === 'ltr' ? 'rtl' : 'ltr'; + + $textArea.attr('dir', newDir).focus(); + }, + paste(e) { if (!$(".d-editor-input").is(":focus")) { return; @@ -724,6 +744,7 @@ export default Ember.Component.extend({ addText: text => this._addText(selected, text), replaceText: text => this._addText({pre: '', post: ''}, text), getText: () => this.get('value'), + toggleDirection: () => this._toggleDirection(), }; if (button.sendAction) { diff --git a/app/assets/javascripts/discourse/components/text-field.js.es6 b/app/assets/javascripts/discourse/components/text-field.js.es6 index a9246efa7db..ae26f6e99f0 100644 --- a/app/assets/javascripts/discourse/components/text-field.js.es6 +++ b/app/assets/javascripts/discourse/components/text-field.js.es6 @@ -1,7 +1,33 @@ import computed from "ember-addons/ember-computed-decorators"; +import { siteDir, isRTL, isLTR } from "discourse/lib/text-direction"; export default Ember.TextField.extend({ - attributeBindings: ['autocorrect', 'autocapitalize', 'autofocus', 'maxLength'], + attributeBindings: ['autocorrect', 'autocapitalize', 'autofocus', 'maxLength', 'dir'], + + @computed + dir() { + if (this.siteSettings.support_mixed_text_direction) { + let val = this.value; + if (val) { + return isRTL(val) ? 'rtl' : 'ltr'; + } else { + return siteDir(); + } + } + }, + + keyUp() { + if (this.siteSettings.support_mixed_text_direction) { + let val = this.value; + if (isRTL(val)) { + this.set('dir', 'rtl'); + } else if (isLTR(val)) { + this.set('dir', 'ltr'); + } else { + this.set('dir', siteDir()); + } + } + }, @computed("placeholderKey") placeholder(placeholderKey) { diff --git a/app/assets/javascripts/discourse/helpers/category-link.js.es6 b/app/assets/javascripts/discourse/helpers/category-link.js.es6 index 336e25314ee..de7f73f5793 100644 --- a/app/assets/javascripts/discourse/helpers/category-link.js.es6 +++ b/app/assets/javascripts/discourse/helpers/category-link.js.es6 @@ -1,4 +1,5 @@ import { registerUnbound } from 'discourse-common/lib/helpers'; +import { isRTL } from "discourse/lib/text-direction"; import { iconHTML } from 'discourse-common/lib/icon-library'; var get = Em.get, @@ -38,6 +39,7 @@ export function categoryBadgeHTML(category, opts) { let color = get(category, 'color'); let html = ""; let parentCat = null; + let categoryDir = ""; if (!opts.hideParent) { parentCat = Discourse.Category.findById(get(category, 'parent_category_id')); @@ -66,10 +68,14 @@ export function categoryBadgeHTML(category, opts) { let categoryName = escapeExpression(get(category, 'name')); + if (Discourse.SiteSettings.support_mixed_text_direction) { + categoryDir = isRTL(categoryName) ? 'dir="rtl"' : 'dir="ltr"'; + } + if (restricted) { - html += `${iconHTML('lock')}${categoryName}`; + html += `${iconHTML('lock')}${categoryName}`; } else { - html += `${categoryName}`; + html += `${categoryName}`; } html += ""; diff --git a/app/assets/javascripts/discourse/helpers/dir-span.js.es6 b/app/assets/javascripts/discourse/helpers/dir-span.js.es6 new file mode 100644 index 00000000000..6ce29e8f598 --- /dev/null +++ b/app/assets/javascripts/discourse/helpers/dir-span.js.es6 @@ -0,0 +1,14 @@ +import { registerUnbound } from "discourse-common/lib/helpers"; +import { isRTL } from 'discourse/lib/text-direction'; + +function setDir(text) { + if (Discourse.SiteSettings.support_mixed_text_direction) { + let textDir = isRTL(text) ? 'rtl' : 'ltr'; + return `${text}`; + } + return text; +} + +export default registerUnbound('dir-span', function(str) { + return new Handlebars.SafeString(setDir(str)); +}); diff --git a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 index d17b2087acd..0cab9b554de 100644 --- a/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 +++ b/app/assets/javascripts/discourse/initializers/post-decorations.js.es6 @@ -1,13 +1,18 @@ import highlightSyntax from 'discourse/lib/highlight-syntax'; import lightbox from 'discourse/lib/lightbox'; +import { setTextDirections } from "discourse/lib/text-direction"; import { withPluginApi } from 'discourse/lib/plugin-api'; export default { name: "post-decorations", - initialize() { + initialize(container) { withPluginApi('0.1', api => { + const siteSettings = container.lookup('site-settings:main'); api.decorateCooked(highlightSyntax); api.decorateCooked(lightbox); + if (siteSettings.support_mixed_text_direction) { + api.decorateCooked(setTextDirections); + } api.decorateCooked($elem => { const players = $('audio', $elem); diff --git a/app/assets/javascripts/discourse/lib/text-direction.js.es6 b/app/assets/javascripts/discourse/lib/text-direction.js.es6 new file mode 100644 index 00000000000..928b147ed79 --- /dev/null +++ b/app/assets/javascripts/discourse/lib/text-direction.js.es6 @@ -0,0 +1,30 @@ +const ltrChars = 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF'; +const rtlChars = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'; +const rtlDirCheck = new RegExp('^[^'+ltrChars+']*['+rtlChars+']'); +const ltrDirCheck = new RegExp('^[^'+rtlChars+']*['+ltrChars+']'); +let _siteDir; + +export function isRTL(text) { + return rtlDirCheck.test(text); +} + +export function isLTR(text) { + return ltrDirCheck.test(text); +} + +export function setTextDirections($elem) { + $elem.find('*').each((i, e) => { + let $e = $(e), + textContent = $e.text(); + if (textContent) { + isRTL(textContent) ? $e.attr('dir', 'rtl') : $e.attr('dir', 'ltr'); + } + }); +} + +export function siteDir() { + if (!_siteDir) { + _siteDir = $('html').hasClass('rtl') ? 'rtl' : 'ltr'; + } + return _siteDir; +} diff --git a/app/assets/javascripts/discourse/models/topic.js.es6 b/app/assets/javascripts/discourse/models/topic.js.es6 index 69a4f9c27f2..b8e6f897259 100644 --- a/app/assets/javascripts/discourse/models/topic.js.es6 +++ b/app/assets/javascripts/discourse/models/topic.js.es6 @@ -3,6 +3,7 @@ import { flushMap } from 'discourse/models/store'; import RestModel from 'discourse/models/rest'; import { propertyEqual } from 'discourse/lib/computed'; import { longDate } from 'discourse/lib/formatter'; +import { isRTL } from 'discourse/lib/text-direction'; import computed from 'ember-addons/ember-computed-decorators'; import ActionSummary from 'discourse/models/action-summary'; import { popupAjaxError } from 'discourse/lib/ajax-error'; @@ -58,7 +59,13 @@ const Topic = RestModel.extend({ @computed('fancy_title') fancyTitle(title) { - return censor(emojiUnescape(title || ""), Discourse.Site.currentProp('censored_words')); + let fancyTitle = censor(emojiUnescape(title || ""), Discourse.Site.currentProp('censored_words')); + + if (Discourse.SiteSettings.support_mixed_text_direction) { + let titleDir = isRTL(title) ? 'rtl' : 'ltr'; + return `${fancyTitle}`; + } + return fancyTitle; }, // returns createdAt if there's no bumped date diff --git a/app/assets/javascripts/discourse/templates/components/categories-only.hbs b/app/assets/javascripts/discourse/templates/components/categories-only.hbs index f10712bb61e..2fa528e3a92 100644 --- a/app/assets/javascripts/discourse/templates/components/categories-only.hbs +++ b/app/assets/javascripts/discourse/templates/components/categories-only.hbs @@ -16,7 +16,7 @@
{{category-title-link category=c}}
- {{{c.description_excerpt}}} + {{{dir-span c.description_excerpt}}}
diff --git a/app/assets/javascripts/discourse/templates/components/category-title-link.hbs b/app/assets/javascripts/discourse/templates/components/category-title-link.hbs index fcb0bdeb071..3403e70d494 100644 --- a/app/assets/javascripts/discourse/templates/components/category-title-link.hbs +++ b/app/assets/javascripts/discourse/templates/components/category-title-link.hbs @@ -3,7 +3,7 @@ {{d-icon 'lock'}} {{/if}} - {{category.name}} + {{dir-span category.name}} {{#if category.uploaded_logo.url}}
{{cdn-img src=category.uploaded_logo.url class="category-logo"}}
diff --git a/app/assets/javascripts/discourse/templates/list/topic-excerpt.raw.hbs b/app/assets/javascripts/discourse/templates/list/topic-excerpt.raw.hbs index 0a42a6a0c56..1c7b5eead0e 100644 --- a/app/assets/javascripts/discourse/templates/list/topic-excerpt.raw.hbs +++ b/app/assets/javascripts/discourse/templates/list/topic-excerpt.raw.hbs @@ -1,6 +1,6 @@ {{#if topic.hasExcerpt}}
- {{{topic.escapedExcerpt}}} + {{{dir-span topic.escapedExcerpt}}} {{#if topic.excerptTruncated}} {{i18n 'read_more'}} {{/if}} diff --git a/app/assets/javascripts/discourse/templates/navigation/category.hbs b/app/assets/javascripts/discourse/templates/navigation/category.hbs index d8dcac7040c..55c7e7ef849 100644 --- a/app/assets/javascripts/discourse/templates/navigation/category.hbs +++ b/app/assets/javascripts/discourse/templates/navigation/category.hbs @@ -5,7 +5,7 @@ {{#if category.uploaded_logo.url}} {{cdn-img src=category.uploaded_logo.url class="category-logo"}} {{#if category.description}} -

{{{category.description}}}

+

{{{dir-span category.description}}}

{{/if}} {{/if}} diff --git a/app/assets/javascripts/select-kit/templates/components/category-row.hbs b/app/assets/javascripts/select-kit/templates/components/category-row.hbs index 985fe9cd74a..85cdfd8f156 100644 --- a/app/assets/javascripts/select-kit/templates/components/category-row.hbs +++ b/app/assets/javascripts/select-kit/templates/components/category-row.hbs @@ -15,7 +15,7 @@ {{/if}} {{#if shouldDisplayDescription}} -
{{{description}}}
+
{{{dir-span description}}}
{{/if}} {{else}} {{{label}}} diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0f716141286..2cfdb0e1d14 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1278,6 +1278,7 @@ en: olist_title: "Numbered List" ulist_title: "Bulleted List" list_item: "List item" + toggle_direction: "Toggle Direction" help: "Markdown Editing Help" collapse: "minimize the composer panel" abandon: "close composer and discard draft" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index c32b730f3fc..e1b45e517ca 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -968,6 +968,7 @@ en: default_locale: "The default language of this Discourse instance" allow_user_locale: "Allow users to choose their own language interface preference" set_locale_from_accept_language_header: "set interface language for anonymous users from their web browser's language headers. (EXPERIMENTAL, does not work with anonymous cache)" + support_mixed_text_direction: "Support mixed left-to-right and right-to-left text directions." min_post_length: "Minimum allowed post length in characters" min_first_post_length: "Minimum allowed first post (topic body) length in characters" min_personal_message_post_length: "Minimum allowed post length in characters for messages" diff --git a/config/site_settings.yml b/config/site_settings.yml index 51e324a3346..743560fb290 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -79,6 +79,9 @@ basic: set_locale_from_accept_language_header: default: false validator: "AllowUserLocaleEnabledValidator" + support_mixed_text_direction: + client: true + default: false categories_topics: default: 20 min: 5 diff --git a/test/javascripts/components/d-editor-test.js.es6 b/test/javascripts/components/d-editor-test.js.es6 index faf4f30b3b8..e0409e4957d 100644 --- a/test/javascripts/components/d-editor-test.js.es6 +++ b/test/javascripts/components/d-editor-test.js.es6 @@ -715,6 +715,26 @@ testCase(`list button with line sequence`, function(assert, textarea) { }); }); +componentTest('clicking the toggle-direction button toggles the direction', { + template: '{{d-editor value=value}}', + beforeEach() { + this.siteSettings.support_mixed_text_direction = true; + this.siteSettings.default_locale = "en"; + }, + + test(assert) { + const textarea = this.$('textarea.d-editor-input'); + click('button.toggle-direction'); + andThen(() => { + assert.equal(textarea.attr('dir'), 'rtl'); + }); + click('button.toggle-direction'); + andThen(() => { + assert.equal(textarea.attr('dir'), 'ltr'); + }); + } +}); + testCase(`doesn't jump to bottom with long text`, function(assert, textarea) { let longText = 'hello world.'; diff --git a/test/javascripts/components/text-field-test.js.es6 b/test/javascripts/components/text-field-test.js.es6 index e18662c2ab9..7a2de69a5a2 100644 --- a/test/javascripts/components/text-field-test.js.es6 +++ b/test/javascripts/components/text-field-test.js.es6 @@ -22,3 +22,27 @@ componentTest("support a placeholder", { assert.equal(this.$('input').prop('placeholder'), 'placeholder.i18n.key'); } }); + +componentTest("sets the dir attribute to ltr for Hebrew text", { + template: `{{text-field value='זהו שם עברי עם מקום עברי'}}`, + beforeEach() { + this.siteSettings.support_mixed_text_direction = true; + }, + + test(assert) { + assert.equal(this.$('input').attr('dir'), 'rtl'); + } +}); + +componentTest("sets the dir attribute to ltr for English text", { + template: `{{text-field value='This is a ltr title'}}`, + beforeEach() { + this.siteSettings.support_mixed_text_direction = true; + }, + + test(assert) { + assert.equal(this.$('input').attr('dir'), 'ltr'); + } +}); + + diff --git a/test/javascripts/lib/category-badge-test.js.es6 b/test/javascripts/lib/category-badge-test.js.es6 index 17322d46191..5fdc807dec4 100644 --- a/test/javascripts/lib/category-badge-test.js.es6 +++ b/test/javascripts/lib/category-badge-test.js.es6 @@ -45,3 +45,28 @@ QUnit.test("allowUncategorized", assert => { assert.blank(categoryBadgeHTML(uncategorized), "it doesn't return HTML for uncategorized by default"); assert.present(categoryBadgeHTML(uncategorized, {allowUncategorized: true}), "it returns HTML"); }); + +QUnit.test("category names are wrapped in dir-spans", assert => { + Discourse.SiteSettings.support_mixed_text_direction = true; + const store = createStore(); + const rtlCategory = store.createRecord('category', { + name: 'תכנות עם Ruby', + id: 123, + description_text: 'cool description', + color: 'ff0', + text_color: 'f00' + }); + + const ltrCategory = store.createRecord('category', { + name: 'Programming in Ruby', + id: 234 + }); + + let tag = parseHTML(categoryBadgeHTML(rtlCategory))[0]; + let dirSpan = tag.children[1].children[0]; + assert.equal(dirSpan.attributes.dir, 'rtl'); + + tag = parseHTML(categoryBadgeHTML(ltrCategory))[0]; + dirSpan = tag.children[1].children[0]; + assert.equal(dirSpan.attributes.dir, 'ltr'); +}); diff --git a/test/javascripts/lib/text-direction-test.js.es6 b/test/javascripts/lib/text-direction-test.js.es6 new file mode 100644 index 00000000000..c8ddee262b8 --- /dev/null +++ b/test/javascripts/lib/text-direction-test.js.es6 @@ -0,0 +1,23 @@ +import { isRTL, isLTR } from 'discourse/lib/text-direction'; + +QUnit.module('lib:text-direction'); + +QUnit.test("isRTL", assert => { + // Hebrew + assert.equal(isRTL('זה מבחן'), true); + + // Arabic + assert.equal(isRTL('هذا اختبار'), true); + + // Persian + assert.equal(isRTL('این یک امتحان است'), true); + + assert.equal(isRTL('This is a test'), false); + assert.equal(isRTL(''), false); +}); + +QUnit.test("isLTR", assert => { + assert.equal(isLTR('This is a test'), true); + assert.equal(isLTR('זה מבחן'), false); +}); + diff --git a/test/javascripts/models/topic-test.js.es6 b/test/javascripts/models/topic-test.js.es6 index 2e9ce444365..7c33bceff03 100644 --- a/test/javascripts/models/topic-test.js.es6 +++ b/test/javascripts/models/topic-test.js.es6 @@ -98,6 +98,15 @@ QUnit.test('fancyTitle', assert => { "supports emojis"); }); +QUnit.test('fancyTitle direction', assert => { + const rtlTopic = Topic.create({ fancy_title: "هذا اختبار" }); + const ltrTopic = Topic.create({ fancy_title: "This is a test"}); + + Discourse.SiteSettings.support_mixed_text_direction = true; + assert.equal(rtlTopic.get('fancyTitle'), `هذا اختبار`, "sets the dir-span to rtl"); + assert.equal(ltrTopic.get('fancyTitle'), `This is a test`, "sets the dir-span to ltr"); +}); + QUnit.test('excerpt', assert => { const topic = Topic.create({ excerpt: "This is a test topic :smile:", pinned: true });