Merge pull request #5540 from discourse/mixed-text-direction-support

FEATURE: Mixed text direction support
This commit is contained in:
Robin Ward 2018-02-01 07:29:15 -08:00 committed by GitHub
commit 96710754d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 226 additions and 10 deletions

View File

@ -25,6 +25,7 @@
//= require ./discourse/lib/key-value-store //= require ./discourse/lib/key-value-store
//= require ./discourse/lib/computed //= require ./discourse/lib/computed
//= require ./discourse/lib/formatter //= require ./discourse/lib/formatter
//= require ./discourse/lib/text-direction
//= require ./discourse/lib/eyeline //= require ./discourse/lib/eyeline
//= require ./discourse/lib/show-modal //= require ./discourse/lib/show-modal
//= require ./discourse/mixins/scrolling //= require ./discourse/mixins/scrolling

View File

@ -8,6 +8,7 @@ import { emojiSearch, isSkinTonableEmoji } from 'pretty-text/emoji';
import { emojiUrlFor } from 'discourse/lib/text'; import { emojiUrlFor } from 'discourse/lib/text';
import { getRegister } from 'discourse-common/lib/get-owner'; import { getRegister } from 'discourse-common/lib/get-owner';
import { findRawTemplate } from 'discourse/lib/raw-templates'; import { findRawTemplate } from 'discourse/lib/raw-templates';
import { siteDir } from 'discourse/lib/text-direction';
import { determinePostReplaceSelection, clipboardData } from 'discourse/lib/utilities'; import { determinePostReplaceSelection, clipboardData } from 'discourse/lib/utilities';
import toMarkdown from 'discourse/lib/to-markdown'; import toMarkdown from 'discourse/lib/to-markdown';
import deprecated from 'discourse-common/lib/deprecated'; 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') 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) { if (site.mobileView) {
this.groups.push({group: 'mobileExtras', buttons: []}); this.groups.push({group: 'mobileExtras', buttons: []});
} }
@ -647,6 +659,14 @@ export default Ember.Component.extend({
return null; 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) { paste(e) {
if (!$(".d-editor-input").is(":focus")) { if (!$(".d-editor-input").is(":focus")) {
return; return;
@ -724,6 +744,7 @@ export default Ember.Component.extend({
addText: text => this._addText(selected, text), addText: text => this._addText(selected, text),
replaceText: text => this._addText({pre: '', post: ''}, text), replaceText: text => this._addText({pre: '', post: ''}, text),
getText: () => this.get('value'), getText: () => this.get('value'),
toggleDirection: () => this._toggleDirection(),
}; };
if (button.sendAction) { if (button.sendAction) {

View File

@ -1,7 +1,33 @@
import computed from "ember-addons/ember-computed-decorators"; import computed from "ember-addons/ember-computed-decorators";
import { siteDir, isRTL, isLTR } from "discourse/lib/text-direction";
export default Ember.TextField.extend({ 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") @computed("placeholderKey")
placeholder(placeholderKey) { placeholder(placeholderKey) {

View File

@ -1,4 +1,5 @@
import { registerUnbound } from 'discourse-common/lib/helpers'; import { registerUnbound } from 'discourse-common/lib/helpers';
import { isRTL } from "discourse/lib/text-direction";
import { iconHTML } from 'discourse-common/lib/icon-library'; import { iconHTML } from 'discourse-common/lib/icon-library';
var get = Em.get, var get = Em.get,
@ -38,6 +39,7 @@ export function categoryBadgeHTML(category, opts) {
let color = get(category, 'color'); let color = get(category, 'color');
let html = ""; let html = "";
let parentCat = null; let parentCat = null;
let categoryDir = "";
if (!opts.hideParent) { if (!opts.hideParent) {
parentCat = Discourse.Category.findById(get(category, 'parent_category_id')); parentCat = Discourse.Category.findById(get(category, 'parent_category_id'));
@ -66,10 +68,14 @@ export function categoryBadgeHTML(category, opts) {
let categoryName = escapeExpression(get(category, 'name')); let categoryName = escapeExpression(get(category, 'name'));
if (Discourse.SiteSettings.support_mixed_text_direction) {
categoryDir = isRTL(categoryName) ? 'dir="rtl"' : 'dir="ltr"';
}
if (restricted) { if (restricted) {
html += `${iconHTML('lock')}<span>${categoryName}</span>`; html += `${iconHTML('lock')}<span ${categoryDir}>${categoryName}</span>`;
} else { } else {
html += `<span>${categoryName}</span>`; html += `<span ${categoryDir}>${categoryName}</span>`;
} }
html += "</span>"; html += "</span>";

View File

@ -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 `<span dir="${textDir}">${text}</span>`;
}
return text;
}
export default registerUnbound('dir-span', function(str) {
return new Handlebars.SafeString(setDir(str));
});

View File

@ -1,13 +1,18 @@
import highlightSyntax from 'discourse/lib/highlight-syntax'; import highlightSyntax from 'discourse/lib/highlight-syntax';
import lightbox from 'discourse/lib/lightbox'; import lightbox from 'discourse/lib/lightbox';
import { setTextDirections } from "discourse/lib/text-direction";
import { withPluginApi } from 'discourse/lib/plugin-api'; import { withPluginApi } from 'discourse/lib/plugin-api';
export default { export default {
name: "post-decorations", name: "post-decorations",
initialize() { initialize(container) {
withPluginApi('0.1', api => { withPluginApi('0.1', api => {
const siteSettings = container.lookup('site-settings:main');
api.decorateCooked(highlightSyntax); api.decorateCooked(highlightSyntax);
api.decorateCooked(lightbox); api.decorateCooked(lightbox);
if (siteSettings.support_mixed_text_direction) {
api.decorateCooked(setTextDirections);
}
api.decorateCooked($elem => { api.decorateCooked($elem => {
const players = $('audio', $elem); const players = $('audio', $elem);

View File

@ -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;
}

View File

@ -3,6 +3,7 @@ import { flushMap } from 'discourse/models/store';
import RestModel from 'discourse/models/rest'; import RestModel from 'discourse/models/rest';
import { propertyEqual } from 'discourse/lib/computed'; import { propertyEqual } from 'discourse/lib/computed';
import { longDate } from 'discourse/lib/formatter'; import { longDate } from 'discourse/lib/formatter';
import { isRTL } from 'discourse/lib/text-direction';
import computed from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators';
import ActionSummary from 'discourse/models/action-summary'; import ActionSummary from 'discourse/models/action-summary';
import { popupAjaxError } from 'discourse/lib/ajax-error'; import { popupAjaxError } from 'discourse/lib/ajax-error';
@ -58,7 +59,13 @@ const Topic = RestModel.extend({
@computed('fancy_title') @computed('fancy_title')
fancyTitle(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 `<span dir="${titleDir}">${fancyTitle}</span>`;
}
return fancyTitle;
}, },
// returns createdAt if there's no bumped date // returns createdAt if there's no bumped date

View File

@ -16,7 +16,7 @@
<div> <div>
{{category-title-link category=c}} {{category-title-link category=c}}
<div class="category-description"> <div class="category-description">
{{{c.description_excerpt}}} {{{dir-span c.description_excerpt}}}
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>

View File

@ -3,7 +3,7 @@
{{d-icon 'lock'}} {{d-icon 'lock'}}
{{/if}} {{/if}}
<span class="category-name">{{category.name}}</span> <span class="category-name">{{dir-span category.name}}</span>
{{#if category.uploaded_logo.url}} {{#if category.uploaded_logo.url}}
<div>{{cdn-img src=category.uploaded_logo.url class="category-logo"}}</div> <div>{{cdn-img src=category.uploaded_logo.url class="category-logo"}}</div>

View File

@ -1,6 +1,6 @@
{{#if topic.hasExcerpt}} {{#if topic.hasExcerpt}}
<div class="topic-excerpt"> <div class="topic-excerpt">
{{{topic.escapedExcerpt}}} {{{dir-span topic.escapedExcerpt}}}
{{#if topic.excerptTruncated}} {{#if topic.excerptTruncated}}
<a href="{{topic.url}}">{{i18n 'read_more'}}</a> <a href="{{topic.url}}">{{i18n 'read_more'}}</a>
{{/if}} {{/if}}

View File

@ -5,7 +5,7 @@
{{#if category.uploaded_logo.url}} {{#if category.uploaded_logo.url}}
{{cdn-img src=category.uploaded_logo.url class="category-logo"}} {{cdn-img src=category.uploaded_logo.url class="category-logo"}}
{{#if category.description}} {{#if category.description}}
<p>{{{category.description}}}</p> <p>{{{dir-span category.description}}}</p>
{{/if}} {{/if}}
{{/if}} {{/if}}
</section> </section>

View File

@ -15,7 +15,7 @@
{{/if}} {{/if}}
{{#if shouldDisplayDescription}} {{#if shouldDisplayDescription}}
<div class="category-desc">{{{description}}}</div> <div class="category-desc">{{{dir-span description}}}</div>
{{/if}} {{/if}}
{{else}} {{else}}
{{{label}}} {{{label}}}

View File

@ -1278,6 +1278,7 @@ en:
olist_title: "Numbered List" olist_title: "Numbered List"
ulist_title: "Bulleted List" ulist_title: "Bulleted List"
list_item: "List item" list_item: "List item"
toggle_direction: "Toggle Direction"
help: "Markdown Editing Help" help: "Markdown Editing Help"
collapse: "minimize the composer panel" collapse: "minimize the composer panel"
abandon: "close composer and discard draft" abandon: "close composer and discard draft"

View File

@ -968,6 +968,7 @@ en:
default_locale: "The default language of this Discourse instance" default_locale: "The default language of this Discourse instance"
allow_user_locale: "Allow users to choose their own language interface preference" 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)" 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_post_length: "Minimum allowed post length in characters"
min_first_post_length: "Minimum allowed first post (topic body) 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" min_personal_message_post_length: "Minimum allowed post length in characters for messages"

View File

@ -79,6 +79,9 @@ basic:
set_locale_from_accept_language_header: set_locale_from_accept_language_header:
default: false default: false
validator: "AllowUserLocaleEnabledValidator" validator: "AllowUserLocaleEnabledValidator"
support_mixed_text_direction:
client: true
default: false
categories_topics: categories_topics:
default: 20 default: 20
min: 5 min: 5

View File

@ -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) { testCase(`doesn't jump to bottom with long text`, function(assert, textarea) {
let longText = 'hello world.'; let longText = 'hello world.';

View File

@ -22,3 +22,27 @@ componentTest("support a placeholder", {
assert.equal(this.$('input').prop('placeholder'), 'placeholder.i18n.key'); 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');
}
});

View File

@ -45,3 +45,28 @@ QUnit.test("allowUncategorized", assert => {
assert.blank(categoryBadgeHTML(uncategorized), "it doesn't return HTML for uncategorized by default"); assert.blank(categoryBadgeHTML(uncategorized), "it doesn't return HTML for uncategorized by default");
assert.present(categoryBadgeHTML(uncategorized, {allowUncategorized: true}), "it returns HTML"); 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');
});

View File

@ -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);
});

View File

@ -98,6 +98,15 @@ QUnit.test('fancyTitle', assert => {
"supports emojis"); "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'), `<span dir="rtl">هذا اختبار</span>`, "sets the dir-span to rtl");
assert.equal(ltrTopic.get('fancyTitle'), `<span dir="ltr">This is a test</span>`, "sets the dir-span to ltr");
});
QUnit.test('excerpt', assert => { QUnit.test('excerpt', assert => {
const topic = Topic.create({ excerpt: "This is a test topic :smile:", pinned: true }); const topic = Topic.create({ excerpt: "This is a test topic :smile:", pinned: true });