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 | ||||
| 
 | ||||
|   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) | ||||
|     case status.visibility | ||||
|     when 'public' | ||||
|  | @ -143,10 +127,6 @@ module StatusesHelper | |||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def rtl_size(words) | ||||
|     words.reduce(0) { |acc, elem| acc + elem.size }.to_f | ||||
|   end | ||||
| 
 | ||||
|   def embedded_view? | ||||
|     params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION | ||||
|   end | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; | |||
| import AutosuggestHashtag from './autosuggest_hashtag'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { isRtl } from '../rtl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import classNames from 'classnames'; | ||||
| import { List as ImmutableList } from 'immutable'; | ||||
|  | @ -189,11 +188,6 @@ export default class AutosuggestInput extends ImmutablePureComponent { | |||
|   render () { | ||||
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; | ||||
|     const { suggestionsHidden } = this.state; | ||||
|     const style = { direction: 'ltr' }; | ||||
| 
 | ||||
|     if (isRtl(value)) { | ||||
|       style.direction = 'rtl'; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='autosuggest-input'> | ||||
|  | @ -212,7 +206,7 @@ export default class AutosuggestInput extends ImmutablePureComponent { | |||
|             onKeyUp={onKeyUp} | ||||
|             onFocus={this.onFocus} | ||||
|             onBlur={this.onBlur} | ||||
|             style={style} | ||||
|             dir='auto' | ||||
|             aria-autocomplete='list' | ||||
|             id={id} | ||||
|             className={className} | ||||
|  |  | |||
|  | @ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji'; | |||
| import AutosuggestHashtag from './autosuggest_hashtag'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { isRtl } from '../rtl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import Textarea from 'react-textarea-autosize'; | ||||
| import classNames from 'classnames'; | ||||
|  | @ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||
|   render () { | ||||
|     const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; | ||||
|     const { suggestionsHidden } = this.state; | ||||
|     const style = { direction: 'ltr' }; | ||||
| 
 | ||||
|     if (isRtl(value)) { | ||||
|       style.direction = 'rtl'; | ||||
|     } | ||||
| 
 | ||||
|     return [ | ||||
|       <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> | ||||
|  | @ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { | |||
|               onFocus={this.onFocus} | ||||
|               onBlur={this.onBlur} | ||||
|               onPaste={this.onPaste} | ||||
|               style={style} | ||||
|               dir='auto' | ||||
|               aria-autocomplete='list' | ||||
|             /> | ||||
|           </label> | ||||
|  |  | |||
|  | @ -1,7 +1,6 @@ | |||
| import React from 'react'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { isRtl } from '../rtl'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import Permalink from './permalink'; | ||||
| import classnames from 'classnames'; | ||||
|  | @ -186,17 +185,12 @@ export default class StatusContent extends React.PureComponent { | |||
| 
 | ||||
|     const content = { __html: status.get('contentHtml') }; | ||||
|     const spoilerContent = { __html: status.get('spoilerHtml') }; | ||||
|     const directionStyle = { direction: 'ltr' }; | ||||
|     const classNames = classnames('status__content', { | ||||
|       'status__content--with-action': this.props.onClick && this.context.router, | ||||
|       'status__content--with-spoiler': status.get('spoiler_text').length > 0, | ||||
|       'status__content--collapsed': renderReadMore, | ||||
|     }); | ||||
| 
 | ||||
|     if (isRtl(status.get('search_index'))) { | ||||
|       directionStyle.direction = 'rtl'; | ||||
|     } | ||||
| 
 | ||||
|     const showThreadButton = ( | ||||
|       <button className='status__content__read-more-button' onClick={this.props.onClick}> | ||||
|         <FormattedMessage id='status.show_thread' defaultMessage='Show thread' /> | ||||
|  | @ -225,7 +219,7 @@ export default class StatusContent extends React.PureComponent { | |||
|       } | ||||
| 
 | ||||
|       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 }}> | ||||
|             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||
|             {' '} | ||||
|  | @ -234,7 +228,7 @@ export default class StatusContent extends React.PureComponent { | |||
| 
 | ||||
|           {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')} />} | ||||
| 
 | ||||
|  | @ -243,8 +237,8 @@ export default class StatusContent extends React.PureComponent { | |||
|       ); | ||||
|     } else if (this.props.onClick) { | ||||
|       const output = [ | ||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> | ||||
|           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={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' dangerouslySetInnerHTML={content} /> | ||||
| 
 | ||||
|           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||
| 
 | ||||
|  | @ -259,8 +253,8 @@ export default class StatusContent extends React.PureComponent { | |||
|       return output; | ||||
|     } else { | ||||
|       return ( | ||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}> | ||||
|           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> | ||||
|         <div className={classNames} ref={this.setRef} tabIndex='0'> | ||||
|           <div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} /> | ||||
| 
 | ||||
|           {!!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 { defineMessages, injectIntl } from 'react-intl'; | ||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||
| import { isRtl } from '../../../rtl'; | ||||
| import AttachmentList from 'mastodon/components/attachment_list'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|  | @ -45,9 +44,6 @@ class ReplyIndicator extends ImmutablePureComponent { | |||
|     } | ||||
| 
 | ||||
|     const content = { __html: status.get('contentHtml') }; | ||||
|     const style   = { | ||||
|       direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr', | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|       <div className='reply-indicator'> | ||||
|  | @ -60,7 +56,7 @@ class ReplyIndicator extends ImmutablePureComponent { | |||
|           </a> | ||||
|         </div> | ||||
| 
 | ||||
|         <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} /> | ||||
|         <div className='reply-indicator__content' dangerouslySetInnerHTML={content} /> | ||||
| 
 | ||||
|         {status.get('media_attachments').size > 0 && ( | ||||
|           <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; | ||||
| } | ||||
| 
 | ||||
| .auto-dir { | ||||
|   p { | ||||
|     unicode-bidi: plaintext; | ||||
|   } | ||||
| 
 | ||||
|   a { | ||||
|     unicode-bidi: isolate; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .email-table, | ||||
| .content-section, | ||||
| .column, | ||||
|  | @ -96,7 +106,7 @@ body { | |||
| .col-3, | ||||
| .col-4, | ||||
| .col-5, | ||||
| .col-6, { | ||||
| .col-6 { | ||||
|   font-size: 0; | ||||
|   display: inline-block; | ||||
|   width: 100%; | ||||
|  |  | |||
|  | @ -831,6 +831,7 @@ | |||
|   p { | ||||
|     margin-bottom: 20px; | ||||
|     white-space: pre-wrap; | ||||
|     unicode-bidi: plaintext; | ||||
| 
 | ||||
|     &:last-child { | ||||
|       margin-bottom: 0; | ||||
|  | @ -840,6 +841,7 @@ | |||
|   a { | ||||
|     color: $secondary-text-color; | ||||
|     text-decoration: none; | ||||
|     unicode-bidi: isolate; | ||||
| 
 | ||||
|     &:hover { | ||||
|       text-decoration: underline; | ||||
|  |  | |||
|  | @ -26,11 +26,11 @@ | |||
|                                       = "@#{status.account.acct}" | ||||
| 
 | ||||
|                               - if status.spoiler_text? | ||||
|                                 %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } | ||||
|                                 %div.auto-dir | ||||
|                                   %p | ||||
|                                     = Formatter.instance.format_spoiler(status) | ||||
| 
 | ||||
|                               %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } | ||||
|                               %div.auto-dir | ||||
|                                 = Formatter.instance.format(status) | ||||
| 
 | ||||
|                                 - if status.media_attachments.size > 0 | ||||
|  |  | |||
|  | @ -20,7 +20,7 @@ | |||
|       %p< | ||||
|         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  | ||||
|         %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) | ||||
|       - 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 | ||||
|  |  | |||
|  | @ -29,7 +29,7 @@ | |||
|       %p< | ||||
|         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  | ||||
|         %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) | ||||
|       - 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 | ||||
|  |  | |||
|  | @ -149,22 +149,4 @@ RSpec.describe StatusesHelper, type: :helper do | |||
|       expect(css_class).to eq 'h-cite' | ||||
|     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 | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue