mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/CodeMirror
synced 2024-11-23 13:56:44 +00:00
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:
parent
894d2c33e9
commit
18a92122ef
2
resources/dist/main.js
vendored
2
resources/dist/main.js
vendored
File diff suppressed because one or more lines are too long
2
resources/dist/main.js.map.json
vendored
2
resources/dist/main.js.map.json
vendored
File diff suppressed because one or more lines are too long
|
@ -14,3 +14,9 @@
|
|||
line-height: 1.2;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.cm-bidi-isolate {
|
||||
/* @noflip */
|
||||
direction: ltr;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
109
src/codemirror.bidiIsolation.js
Normal file
109
src/codemirror.bidiIsolation.js
Normal 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 );
|
|
@ -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() );
|
||||
}
|
||||
|
|
37
tests/jest/codemirror.bidiIsolation.test.js
Normal file
37
tests/jest/codemirror.bidiIsolation.test.js
Normal 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"><</span><span class="cm-mw-htmltag-name">span </span><span class="cm-mw-htmltag-attribute">class="foobar"</span><span class="cm-mw-htmltag-bracket">></span></span>שלום<span class="cm-bidi-isolate"><span class="cm-mw-htmltag-bracket"></</span><span class="cm-mw-htmltag-name">span</span><span class="cm-mw-htmltag-bracket">></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 );
|
||||
}
|
||||
);
|
||||
} );
|
Loading…
Reference in a new issue