CodeMirror 6: Add bidi isolation to HTML tags

HTML tags and similar markup may appear jumbled on RTL pages due to the
standard algorithm used for character placement. With this patch, we
detect all tags (HTML or MediaWiki-supplied) and wrap them with
<span class=cm-bidi-isolate>. This CSS class forces the content to be
LTR, making the tags easier to work with on RTL pages.

Bug: T358804
Change-Id: I1338afeefa16102d5cc8fd6c8a236c144e5cf81f
This commit is contained in:
MusikAnimal 2024-03-11 17:34:36 -04:00
parent 894d2c33e9
commit 18a92122ef
7 changed files with 163 additions and 4 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -14,3 +14,9 @@
line-height: 1.2;
padding: 0 1px;
}
.cm-bidi-isolate {
/* @noflip */
direction: ltr;
unicode-bidi: isolate;
}

View file

@ -14,7 +14,8 @@
"commonjs": true
},
"globals": {
"__non_webpack_require__": "readonly"
"__non_webpack_require__": "readonly",
"Tree": "readonly"
},
"rules": {
"es-x/no-array-prototype-includes": 0,

View file

@ -0,0 +1,109 @@
import {
Decoration,
DecorationSet,
Direction,
EditorView,
PluginSpec,
ViewPlugin,
ViewUpdate
} from '@codemirror/view';
import { Prec, RangeSet, RangeSetBuilder } from '@codemirror/state';
import { syntaxTree } from '@codemirror/language';
import { mwModeConfig } from './codemirror.mode.mediawiki.config';
/**
* @type {Decoration}
*/
const isolate = Decoration.mark( {
class: 'cm-bidi-isolate',
bidiIsolate: Direction.LTR
} );
/**
* @param {EditorView} view
* @return {RangeSet}
*/
function computeIsolates( view ) {
const set = new RangeSetBuilder();
for ( const { from, to } of view.visibleRanges ) {
let startPos;
syntaxTree( view.state ).iterate( {
from,
to,
enter( node ) {
// Determine if this is a bracket node (start or end of a tag).
const isBracket = node.name.split( '_' )
.some( ( tag ) => [
mwModeConfig.tags.htmlTagBracket,
mwModeConfig.tags.extTagBracket
].includes( tag ) );
if ( !startPos && isBracket ) {
// If we find a bracket node, we keep track of the start position.
startPos = node.from;
} else if ( isBracket ) {
// When we find the closing bracket, add the isolate.
set.add( startPos, node.to, isolate );
startPos = null;
}
}
} );
}
return set.finish();
}
/**
* @class
* @property {DecorationSet} isolates
* @property {Tree} tree
*/
class CodeMirrorBidiIsolation {
/**
* @constructor
* @param {EditorView} view
*/
constructor( view ) {
this.isolates = computeIsolates( view );
this.tree = syntaxTree( view.state );
}
/**
* @param {ViewUpdate} update
*/
update( update ) {
if ( update.docChanged || update.viewportChanged ||
syntaxTree( update.state ) !== this.tree
) {
this.isolates = computeIsolates( update.view );
this.tree = syntaxTree( update.state );
}
}
}
/**
* @type {PluginSpec}
*/
const bidiIsolationSpec = {
provide: ( plugin ) => {
/**
* @param {EditorView} view
* @return {DecorationSet}
*/
const access = ( view ) => {
return view.plugin( plugin ) ?
( view.plugin( plugin ).isolates || Decoration.none ) :
Decoration.none;
};
// Use the lowest precedence to ensure that other decorations
// don't break up the isolating decorations.
return Prec.lowest( [
EditorView.decorations.of( access ),
EditorView.bidiIsolatedRanges.of( access )
] );
}
};
export default ViewPlugin.fromClass( CodeMirrorBidiIsolation, bidiIsolationSpec );

View file

@ -1,5 +1,6 @@
import { EditorSelection, EditorState, Extension } from '@codemirror/state';
import { EditorView, lineNumbers, highlightSpecialChars } from '@codemirror/view';
import bidiIsolationExtension from './codemirror.bidiIsolation';
/**
* @class CodeMirror
@ -33,9 +34,14 @@ export default class CodeMirror {
this.specialCharsExtension,
this.heightExtension
];
const namespaces = mw.config.get( 'wgCodeMirrorLineNumberingNamespaces' );
// Add bidi isolation to tags on RTL pages (T358804).
if ( this.$textarea.attr( 'dir' ) === 'rtl' ) {
extensions.push( bidiIsolationExtension );
}
// Set to [] to disable everywhere, or null to enable everywhere
const namespaces = mw.config.get( 'wgCodeMirrorLineNumberingNamespaces' );
if ( !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) ) ) {
extensions.push( lineNumbers() );
}

View file

@ -0,0 +1,37 @@
import CodeMirror from '../../src/codemirror.js';
import { mediaWikiLang } from '../../src/codemirror.mode.mediawiki.js';
const testCases = [
{
title: 'wraps HTML tags with span.cm-bidi-isolate',
input: 'שלום<span class="foobar">שלום</span>שלום',
output: '<div class="cm-line">שלום<span class="cm-bidi-isolate"><span class="cm-mw-htmltag-bracket">&lt;</span><span class="cm-mw-htmltag-name">span </span><span class="cm-mw-htmltag-attribute">class="foobar"</span><span class="cm-mw-htmltag-bracket">&gt;</span></span>שלום<span class="cm-bidi-isolate"><span class="cm-mw-htmltag-bracket">&lt;/</span><span class="cm-mw-htmltag-name">span</span><span class="cm-mw-htmltag-bracket">&gt;</span></span>שלום</div>'
}
];
// Setup CodeMirror instance.
const textarea = document.createElement( 'textarea' );
textarea.dir = 'rtl';
document.body.appendChild( textarea );
const cm = new CodeMirror( textarea );
const mwLang = mediaWikiLang( {
tags: {}
} );
cm.initialize( [ ...cm.defaultExtensions, mwLang ] );
describe( 'CodeMirrorBidiIsolation', () => {
it.each( testCases )(
'bidi isolation ($title)',
( { input, output } ) => {
cm.view.dispatch( {
changes: {
from: 0,
to: cm.view.state.doc.length,
insert: input
}
} );
cm.$textarea.textSelection = jest.fn().mockReturnValue( input );
expect( cm.view.dom.querySelector( '.cm-content' ).innerHTML ).toStrictEqual( output );
}
);
} );