Change RTL detection to rely on unicode-bidi paragraph by paragraph (#14573)
This commit is contained in:
		
							parent
							
								
									76b0f84cd5
								
							
						
					
					
						commit
						812f2bdb8f
					
				
					 12 changed files with 26 additions and 106 deletions
				
			
		|  | @ -92,22 +92,6 @@ module StatusesHelper | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def rtl_status?(status) |  | ||||||
|     status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text)) |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def rtl?(text) |  | ||||||
|     text = simplified_text(text) |  | ||||||
|     rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m) |  | ||||||
| 
 |  | ||||||
|     if rtl_words.present? |  | ||||||
|       total_size = text.size.to_f |  | ||||||
|       rtl_size(rtl_words) / total_size > 0.3 |  | ||||||
|     else |  | ||||||
|       false |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def fa_visibility_icon(status) |   def fa_visibility_icon(status) | ||||||
|     case status.visibility |     case status.visibility | ||||||
|     when 'public' |     when 'public' | ||||||
|  | @ -143,10 +127,6 @@ module StatusesHelper | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def rtl_size(words) |  | ||||||
|     words.reduce(0) { |acc, elem| acc + elem.size }.to_f |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def embedded_view? |   def embedded_view? | ||||||
|     params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION |     params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; | ||||||
| import AutosuggestHashtag from './autosuggest_hashtag'; | import AutosuggestHashtag from './autosuggest_hashtag'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { isRtl } from '../rtl'; |  | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import { List as ImmutableList } from 'immutable'; | import { List as ImmutableList } from 'immutable'; | ||||||
|  | @ -189,11 +188,6 @@ export default class AutosuggestInput extends ImmutablePureComponent { | ||||||
|   render () { |   render () { | ||||||
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; |     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; | ||||||
|     const { suggestionsHidden } = this.state; |     const { suggestionsHidden } = this.state; | ||||||
|     const style = { direction: 'ltr' }; |  | ||||||
| 
 |  | ||||||
|     if (isRtl(value)) { |  | ||||||
|       style.direction = 'rtl'; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className='autosuggest-input'> |       <div className='autosuggest-input'> | ||||||
|  | @ -212,7 +206,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { | ||||||
|             onKeyUp={onKeyUp} |             onKeyUp={onKeyUp} | ||||||
|             onFocus={this.onFocus} |             onFocus={this.onFocus} | ||||||
|             onBlur={this.onBlur} |             onBlur={this.onBlur} | ||||||
|             style={style} |             dir='auto' | ||||||
|             aria-autocomplete='list' |             aria-autocomplete='list' | ||||||
|             id={id} |             id={id} | ||||||
|             className={className} |             className={className} | ||||||
|  |  | ||||||
|  | @ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; | ||||||
| import AutosuggestHashtag from './autosuggest_hashtag'; | import AutosuggestHashtag from './autosuggest_hashtag'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { isRtl } from '../rtl'; |  | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import Textarea from 'react-textarea-autosize'; | import Textarea from 'react-textarea-autosize'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
|  | @ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||||
|   render () { |   render () { | ||||||
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; |     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; | ||||||
|     const { suggestionsHidden } = this.state; |     const { suggestionsHidden } = this.state; | ||||||
|     const style = { direction: 'ltr' }; |  | ||||||
| 
 |  | ||||||
|     if (isRtl(value)) { |  | ||||||
|       style.direction = 'rtl'; |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     return [ |     return [ | ||||||
|       <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> |       <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> | ||||||
|  | @ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | ||||||
|               onFocus={this.onFocus} |               onFocus={this.onFocus} | ||||||
|               onBlur={this.onBlur} |               onBlur={this.onBlur} | ||||||
|               onPaste={this.onPaste} |               onPaste={this.onPaste} | ||||||
|               style={style} |               dir='auto' | ||||||
|               aria-autocomplete='list' |               aria-autocomplete='list' | ||||||
|             /> |             /> | ||||||
|           </label> |           </label> | ||||||
|  |  | ||||||
|  | @ -1,7 +1,6 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import { isRtl } from '../rtl'; |  | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
| import Permalink from './permalink'; | import Permalink from './permalink'; | ||||||
| import classnames from 'classnames'; | import classnames from 'classnames'; | ||||||
|  | @ -186,17 +185,12 @@ export default class StatusContent extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|     const content = { __html: status.get('contentHtml') }; |     const content = { __html: status.get('contentHtml') }; | ||||||
|     const spoilerContent = { __html: status.get('spoilerHtml') }; |     const spoilerContent = { __html: status.get('spoilerHtml') }; | ||||||
|     const directionStyle = { direction: 'ltr' }; |  | ||||||
|     const classNames = classnames('status__content', { |     const classNames = classnames('status__content', { | ||||||
|       'status__content--with-action': this.props.onClick && this.context.router, |       'status__content--with-action': this.props.onClick && this.context.router, | ||||||
|       'status__content--with-spoiler': status.get('spoiler_text').length > 0, |       'status__content--with-spoiler': status.get('spoiler_text').length > 0, | ||||||
|       'status__content--collapsed': renderReadMore, |       'status__content--collapsed': renderReadMore, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     if (isRtl(status.get('search_index'))) { |  | ||||||
|       directionStyle.direction = 'rtl'; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     const showThreadButton = ( |     const showThreadButton = ( | ||||||
|       <button className='status__content__read-more-button' onClick={this.props.onClick}> |       <button className='status__content__read-more-button' onClick={this.props.onClick}> | ||||||
|         <FormattedMessage id='status.show_thread' defaultMessage='Show thread' /> |         <FormattedMessage id='status.show_thread' defaultMessage='Show thread' /> | ||||||
|  | @ -225,7 +219,7 @@ export default class StatusContent extends React.PureComponent { | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       return ( |       return ( | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> |         <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||||
|           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> |           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | ||||||
|             <span dangerouslySetInnerHTML={spoilerContent} /> |             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||||
|             {' '} |             {' '} | ||||||
|  | @ -234,7 +228,7 @@ export default class StatusContent extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|           {mentionsPlaceholder} |           {mentionsPlaceholder} | ||||||
| 
 | 
 | ||||||
|           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> |           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|           {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />} |           {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||||
| 
 | 
 | ||||||
|  | @ -243,8 +237,8 @@ export default class StatusContent extends React.PureComponent { | ||||||
|       ); |       ); | ||||||
|     } else if (this.props.onClick) { |     } else if (this.props.onClick) { | ||||||
|       const output = [ |       const output = [ | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> |         <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> | ||||||
|           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> |           <div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} |           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||||
| 
 | 
 | ||||||
|  | @ -259,8 +253,8 @@ export default class StatusContent extends React.PureComponent { | ||||||
|       return output; |       return output; | ||||||
|     } else { |     } else { | ||||||
|       return ( |       return ( | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}> |         <div className={classNames} ref={this.setRef} tabIndex='0'> | ||||||
|           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> |           <div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} |           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,7 +6,6 @@ import IconButton from '../../../components/icon_button'; | ||||||
| import DisplayName from '../../../components/display_name'; | import DisplayName from '../../../components/display_name'; | ||||||
| import { defineMessages, injectIntl } from 'react-intl'; | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { isRtl } from '../../../rtl'; |  | ||||||
| import AttachmentList from 'mastodon/components/attachment_list'; | import AttachmentList from 'mastodon/components/attachment_list'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|  | @ -45,9 +44,6 @@ class ReplyIndicator extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const content = { __html: status.get('contentHtml') }; |     const content = { __html: status.get('contentHtml') }; | ||||||
|     const style   = { |  | ||||||
|       direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr', |  | ||||||
|     }; |  | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className='reply-indicator'> |       <div className='reply-indicator'> | ||||||
|  | @ -60,7 +56,7 @@ class ReplyIndicator extends ImmutablePureComponent { | ||||||
|           </a> |           </a> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} /> |         <div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|         {status.get('media_attachments').size > 0 && ( |         {status.get('media_attachments').size > 0 && ( | ||||||
|           <AttachmentList |           <AttachmentList | ||||||
|  |  | ||||||
|  | @ -1,32 +0,0 @@ | ||||||
| // U+0590  to U+05FF  - Hebrew
 |  | ||||||
| // U+0600  to U+06FF  - Arabic
 |  | ||||||
| // U+0700  to U+074F  - Syriac
 |  | ||||||
| // U+0750  to U+077F  - Arabic Supplement
 |  | ||||||
| // U+0780  to U+07BF  - Thaana
 |  | ||||||
| // U+07C0  to U+07FF  - N'Ko
 |  | ||||||
| // U+0800  to U+083F  - Samaritan
 |  | ||||||
| // U+08A0  to U+08FF  - Arabic Extended-A
 |  | ||||||
| // U+FB1D  to U+FB4F  - Hebrew presentation forms
 |  | ||||||
| // U+FB50  to U+FDFF  - Arabic presentation forms A
 |  | ||||||
| // U+FE70  to U+FEFF  - Arabic presentation forms B
 |  | ||||||
| 
 |  | ||||||
| const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; |  | ||||||
| 
 |  | ||||||
| export function isRtl(text) { |  | ||||||
|   if (text.length === 0) { |  | ||||||
|     return false; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   text = text.replace(/(?:^|[^\/\w])@([a-z0-9_]+(@[a-z0-9\.\-]+)?)/ig, ''); |  | ||||||
|   text = text.replace(/(?:^|[^\/\w])#([\S]+)/ig, ''); |  | ||||||
|   text = text.replace(/\s+/g, ''); |  | ||||||
|   text = text.replace(/(\w\S+\.\w{2,}\S*)/g, ''); |  | ||||||
| 
 |  | ||||||
|   const matches = text.match(rtlChars); |  | ||||||
| 
 |  | ||||||
|   if (!matches) { |  | ||||||
|     return false; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return matches.length / text.length > 0.3; |  | ||||||
| }; |  | ||||||
|  | @ -58,6 +58,16 @@ td { | ||||||
|   vertical-align: top; |   vertical-align: top; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .auto-dir { | ||||||
|  |   p { | ||||||
|  |     unicode-bidi: plaintext; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   a { | ||||||
|  |     unicode-bidi: isolate; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .email-table, | .email-table, | ||||||
| .content-section, | .content-section, | ||||||
| .column, | .column, | ||||||
|  | @ -96,7 +106,7 @@ body { | ||||||
| .col-3, | .col-3, | ||||||
| .col-4, | .col-4, | ||||||
| .col-5, | .col-5, | ||||||
| .col-6, { | .col-6 { | ||||||
|   font-size: 0; |   font-size: 0; | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|  |  | ||||||
|  | @ -831,6 +831,7 @@ | ||||||
|   p { |   p { | ||||||
|     margin-bottom: 20px; |     margin-bottom: 20px; | ||||||
|     white-space: pre-wrap; |     white-space: pre-wrap; | ||||||
|  |     unicode-bidi: plaintext; | ||||||
| 
 | 
 | ||||||
|     &:last-child { |     &:last-child { | ||||||
|       margin-bottom: 0; |       margin-bottom: 0; | ||||||
|  | @ -840,6 +841,7 @@ | ||||||
|   a { |   a { | ||||||
|     color: $secondary-text-color; |     color: $secondary-text-color; | ||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
|  |     unicode-bidi: isolate; | ||||||
| 
 | 
 | ||||||
|     &:hover { |     &:hover { | ||||||
|       text-decoration: underline; |       text-decoration: underline; | ||||||
|  |  | ||||||
|  | @ -26,11 +26,11 @@ | ||||||
|                                       = "@#{status.account.acct}" |                                       = "@#{status.account.acct}" | ||||||
| 
 | 
 | ||||||
|                               - if status.spoiler_text? |                               - if status.spoiler_text? | ||||||
|                                 %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } |                                 %div.auto-dir | ||||||
|                                   %p |                                   %p | ||||||
|                                     = Formatter.instance.format_spoiler(status) |                                     = Formatter.instance.format_spoiler(status) | ||||||
| 
 | 
 | ||||||
|                               %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } |                               %div.auto-dir | ||||||
|                                 = Formatter.instance.format(status) |                                 = Formatter.instance.format(status) | ||||||
| 
 | 
 | ||||||
|                                 - if status.media_attachments.size > 0 |                                 - if status.media_attachments.size > 0 | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ | ||||||
|       %p< |       %p< | ||||||
|         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  |         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  | ||||||
|         %button.status__content__spoiler-link= t('statuses.show_more') |         %button.status__content__spoiler-link= t('statuses.show_more') | ||||||
|     .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } |     .e-content | ||||||
|       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) |       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) | ||||||
|       - if status.preloadable_poll |       - if status.preloadable_poll | ||||||
|         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do |         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do | ||||||
|  |  | ||||||
|  | @ -29,7 +29,7 @@ | ||||||
|       %p< |       %p< | ||||||
|         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  |         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  | ||||||
|         %button.status__content__spoiler-link= t('statuses.show_more') |         %button.status__content__spoiler-link= t('statuses.show_more') | ||||||
|     .e-content{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } |     .e-content | ||||||
|       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) |       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) | ||||||
|       - if status.preloadable_poll |       - if status.preloadable_poll | ||||||
|         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do |         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do | ||||||
|  |  | ||||||
|  | @ -149,22 +149,4 @@ RSpec.describe StatusesHelper, type: :helper do | ||||||
|       expect(css_class).to eq 'h-cite' |       expect(css_class).to eq 'h-cite' | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   describe '#rtl?' do |  | ||||||
|     it 'is false if text is empty' do |  | ||||||
|       expect(helper).not_to be_rtl '' |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'is false if there are no right to left characters' do |  | ||||||
|       expect(helper).not_to be_rtl 'hello world' |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'is false if right to left characters are fewer than 1/3 of total text' do |  | ||||||
|       expect(helper).not_to be_rtl 'hello ݟ world' |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'is true if right to left characters are greater than 1/3 of total text' do |  | ||||||
|       expect(helper).to be_rtl 'aaݟaaݟ' |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue