Merge "CodeMirror 6 template folding"

This commit is contained in:
jenkins-bot 2024-03-08 02:38:43 +00:00 committed by Gerrit Code Review
commit 985f2991e5
14 changed files with 350 additions and 10 deletions

View file

@ -221,7 +221,10 @@
"codemirror-special-char-pop-directional-isolate",
"codemirror-special-char-paragraph-separator",
"codemirror-special-char-zero-width-no-break-space",
"codemirror-special-char-object-replacement"
"codemirror-special-char-object-replacement",
"codemirror-fold-template",
"codemirror-unfold",
"codemirror-folded-code"
]
}
},

View file

@ -43,5 +43,8 @@
"codemirror-special-char-paragraph-separator": "Paragraph separator",
"codemirror-special-char-zero-width-no-break-space": "Word joiner",
"codemirror-special-char-object-replacement": "Object replacement character",
"codemirror-fold-template": "Fold template parameters",
"codemirror-unfold": "unfold",
"codemirror-folded-code": "folded code",
"prefs-accessibility": "Accessibility"
}

View file

@ -46,5 +46,8 @@
"codemirror-special-char-paragraph-separator": "Tooltip text shown when hovering over a paragraph separator character. See [[wikidata:Q87523339]] for possible translations.",
"codemirror-special-char-zero-width-no-break-space": "Tooltip text shown when hovering over a zero-width word joiner character. See [[wikidata:Q8069466]] for possible translations.",
"codemirror-special-char-object-replacement": "Tooltip text shown when hovering over a object replacement character. See [[wikidata:Q9398047]] for possible translations.",
"codemirror-fold-template": "Tooltip text shown when hovering over a foldable template.",
"codemirror-unfold": "Tooltip text shown when hovering over a placeholder for folded code.",
"codemirror-folded-code": "Aria label for a placeholder for folded code.",
"prefs-accessibility": "Section heading in the user preferences for accessibility topics."
}

View file

@ -12,5 +12,8 @@
"codemirror-toggle-label": "语法高亮",
"codemirror-prefs-colorblind": "编辑wikitext时启用对色盲用户友好的语法高亮配色方案",
"codemirror-prefs-colorblind-help": "如果您的语法高亮功能由小工具提供,本设置将不起作用。",
"codemirror-fold-template": "折叠模板参数",
"codemirror-unfold": "展开",
"codemirror-folded-code": "被折叠的代码",
"prefs-accessibility": "无障碍"
}

View file

@ -52,7 +52,7 @@
"bundlesize": [
{
"path": "resources/dist/main.js",
"maxSize": "105.0kB"
"maxSize": "110.0kB"
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,3 +8,9 @@
.cm-special-char-nbsp {
color: #888;
}
.cm-tooltip-fold {
cursor: pointer;
line-height: 1.2;
padding: 0 1px;
}

View file

@ -217,8 +217,6 @@ export default class CodeMirror {
// Also override textSelection() functions for the "real" hidden textarea to route to
// CodeMirror. We unregister this when switching to normal textarea mode.
this.$textarea.textSelection( 'register', this.cmTextSelection );
mw.hook( 'ext.CodeMirror.switch' ).fire( true, $( this.view.dom ) );
}
/**

View file

@ -0,0 +1,258 @@
import { showTooltip, keymap, Tooltip, KeyBinding } from '@codemirror/view';
import { StateField, Extension, EditorState } from '@codemirror/state';
import { foldEffect, ensureSyntaxTree, foldedRanges, unfoldAll, unfoldEffect, codeFolding } from '@codemirror/language';
import { SyntaxNode, Tree } from '@lezer/common';
import { mwModeConfig as modeConfig } from './codemirror.mode.mediawiki.config';
/**
* Check if a SyntaxNode is a template bracket (`{{` or `}}`)
* @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean}
*/
const isBracket = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateBracket ),
/**
* Check if a SyntaxNode is a template delimiter (`|`)
* @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean}
*/
isDelimiter = ( node ) => node.name.split( '_' ).includes( modeConfig.tags.templateDelimiter ),
/**
* Check if a SyntaxNode is part of a template, except for the brackets
* @param {SyntaxNode} node The SyntaxNode to check
* @return {boolean}
*/
isTemplate = ( node ) => /-template[a-z\d-]+ground/u.test( node.name ) && !isBracket( node ),
/**
* Update the stack of opening (+) or closing (-) brackets
* @param {EditorState} state EditorState instance
* @param {SyntaxNode} node The SyntaxNode of the bracket
* @return {1|-1}
*/
stackUpdate = ( state, node ) => state.sliceDoc( node.from, node.from + 1 ) === '{' ? 1 : -1;
/**
* If the node is a template, find the range of the template parameters
* @param {EditorState} state EditorState instance
* @param {number|SyntaxNode} posOrNode Position or node
* @param {Tree|null} [tree] Syntax tree
* @return {{from: number, to: number}|null}
*/
const foldable = ( state, posOrNode, tree ) => {
if ( typeof posOrNode === 'number' ) {
tree = ensureSyntaxTree( state, posOrNode );
}
if ( !tree ) {
return null;
}
/** @type {SyntaxNode} */
let node;
if ( typeof posOrNode === 'number' ) {
// Find the initial template node on both sides of the position
node = tree.resolve( posOrNode, -1 );
if ( !isTemplate( node ) ) {
node = tree.resolve( posOrNode, 1 );
}
} else {
node = posOrNode;
}
if ( !isTemplate( node ) ) {
// Not a template
return null;
}
let { prevSibling, nextSibling } = node,
/** The stack of opening (+) or closing (-) brackets */
stack = 1,
/** The first delimiter */
delimiter = isDelimiter( node ) ? node : null;
while ( nextSibling ) {
if ( isBracket( nextSibling ) ) {
stack += stackUpdate( state, nextSibling );
if ( stack === 0 ) {
// The closing bracket of the current template
break;
}
} else if ( !delimiter && stack === 1 && isDelimiter( nextSibling ) ) {
// The first delimiter of the current template so far
delimiter = nextSibling;
}
( { nextSibling } = nextSibling );
}
if ( !nextSibling ) {
// The closing bracket of the current template is missing
return null;
}
stack = -1;
while ( prevSibling ) {
if ( isBracket( prevSibling ) ) {
stack += stackUpdate( state, prevSibling );
if ( stack === 0 ) {
// The opening bracket of the current template
break;
}
} else if ( stack === -1 && isDelimiter( prevSibling ) ) {
// The first delimiter of the current template so far
delimiter = prevSibling;
}
( { prevSibling } = prevSibling );
}
/** The end of the first delimiter */
const from = delimiter && delimiter.to,
/** The start of the closing bracket */
to = nextSibling.from;
if ( from && from < to ) {
return { from, to };
}
return null;
};
/**
* Create a tooltip for folding a template
* @param {EditorState} state EditorState instance
* @return {Tooltip|null}
*/
const create = ( state ) => {
const { selection: { main: { head } } } = state,
range = foldable( state, head );
if ( range ) {
const { from, to } = range;
let folded = false;
// Check if the range is already folded
foldedRanges( state ).between( from, to, ( i, j ) => {
if ( i === from && j === to ) {
folded = true;
}
} );
return folded ?
null :
{
pos: head,
above: true,
create( view ) {
const dom = document.createElement( 'div' );
dom.className = 'cm-tooltip-fold';
dom.textContent = '\uff0d';
dom.title = mw.msg( 'codemirror-fold-template' );
dom.onclick = () => {
view.dispatch( {
effects: foldEffect.of( { from, to } ),
selection: { anchor: to }
} );
dom.remove();
};
return { dom };
}
};
}
return null;
};
/** @type {KeyBinding[]} */
const foldKeymap = [
{
// Fold the template at the selection/cursor
key: 'Ctrl-Shift-[',
mac: 'Cmd-Alt-[',
run( view ) {
const { state } = view,
tree = ensureSyntaxTree( state, view.viewport.to );
if ( !tree ) {
return false;
}
const effects = [],
{ selection: { ranges } } = state;
/** The rightmost position of all selections, to be updated with folding */
let anchor = Math.max( ...ranges.map( ( { to } ) => to ) );
for ( const { from, to } of ranges ) {
let node;
if ( from === to ) {
// No selection, try both sides of the cursor position
node = tree.resolve( from, -1 );
}
if ( !node || !isTemplate( node ) ) {
node = tree.resolve( from, 1 );
}
while ( node && node.from <= to ) {
const range = foldable( state, node, tree );
if ( range ) {
effects.push( foldEffect.of( range ) );
node = tree.resolve( range.to, 1 );
// Update the anchor with the end of the last folded range
anchor = Math.max( anchor, range.to );
continue;
}
node = node.nextSibling;
}
}
if ( effects.length > 0 ) {
const dom = view.dom.querySelector( '.cm-tooltip-fold' );
if ( dom ) {
dom.remove();
}
// Fold the template(s) and update the cursor position
view.dispatch( { effects, selection: { anchor } } );
return true;
}
return false;
}
},
{
// Unfold the template at the selection/cursor
key: 'Ctrl-Shift-]',
mac: 'Cmd-Alt-]',
run( view ) {
const { state } = view,
{ selection } = state,
effects = [],
folded = foldedRanges( state );
for ( const { from, to } of selection.ranges ) {
// Unfold any folded range at the selection
folded.between( from, to, ( i, j ) => {
effects.push( unfoldEffect.of( { from: i, to: j } ) );
} );
}
if ( effects.length > 0 ) {
// Unfold the template(s) and redraw the selections
view.dispatch( { effects, selection } );
return true;
}
return false;
}
},
{ key: 'Ctrl-Alt-]', run: unfoldAll }
];
/** @type {Extension} */
export const templateFoldingExtension = [
codeFolding( {
placeholderDOM( view ) {
const element = document.createElement( 'span' );
element.textContent = '…';
element.setAttribute( 'aria-label', mw.msg( 'codemirror-folded-code' ) );
element.title = mw.msg( 'codemirror-unfold' );
element.className = 'cm-foldPlaceholder';
element.onclick = ( { target } ) => {
const pos = view.posAtDOM( target ),
{ state } = view,
{ selection } = state;
foldedRanges( state ).between( pos, pos, ( from, to ) => {
if ( from === pos ) {
// Unfold the template and redraw the selections
view.dispatch( { effects: unfoldEffect.of( { from, to } ), selection } );
}
} );
};
return element;
}
} ),
/** @see https://codemirror.net/examples/tooltip/ */
StateField.define( {
create,
update( tooltip, { state, docChanged, selection } ) {
return docChanged || selection ? create( state ) : tooltip;
},
provide( f ) {
return showTooltip.from( f );
}
} ),
keymap.of( foldKeymap )
];

View file

@ -14,9 +14,9 @@ __non_webpack_require__( '../ext.CodeMirror.data.js' );
* @class CodeMirrorWikiEditor
*/
export default class CodeMirrorWikiEditor extends CodeMirror {
constructor( $textarea, langExtension ) {
constructor( $textarea, langExtensions ) {
super( $textarea );
this.langExtension = langExtension;
this.langExtensions = langExtensions;
this.editRecoveryHandler = null;
this.useCodeMirror = mw.user.options.get( 'usecodemirror' ) > 0;
}
@ -49,7 +49,7 @@ export default class CodeMirrorWikiEditor extends CodeMirror {
*/
const extensions = [
...this.defaultExtensions,
this.langExtension,
...this.langExtensions,
bracketMatching(),
history(),
// See also the default attributes at contentAttributesExtension() in the parent class.

View file

@ -1,9 +1,13 @@
import CodeMirrorWikiEditor from './codemirror.wikieditor';
import { mediaWikiLang } from './codemirror.mode.mediawiki';
import { templateFoldingExtension } from './codemirror.templateFolding';
if ( mw.loader.getState( 'ext.wikiEditor' ) ) {
mw.hook( 'wikiEditor.toolbarReady' ).add( ( $textarea ) => {
const cmWE = new CodeMirrorWikiEditor( $textarea, mediaWikiLang() );
const cmWE = new CodeMirrorWikiEditor( $textarea, [
mediaWikiLang(),
templateFoldingExtension
] );
cmWE.addCodeMirrorToWikiEditor();
} );
}

View file

@ -47,6 +47,14 @@ class EditPage extends Page {
return $( '.ve-ui-surface-source' );
}
get codeMirrorTemplateFoldingButton() {
return $( '.cm-tooltip-fold' );
}
get codeMirrorTemplateFoldingPlaceholder() {
return $( '.cm-foldPlaceholder' );
}
async cursorToPosition( index ) {
await this.clickText();

View file

@ -0,0 +1,54 @@
'use strict';
const assert = require( 'assert' ),
EditPage = require( '../pageobjects/edit.page' ),
FixtureContent = require( '../fixturecontent' ),
LoginPage = require( 'wdio-mediawiki/LoginPage' ),
UserPreferences = require( '../userpreferences' ),
Util = require( 'wdio-mediawiki/Util' );
describe( 'CodeMirror template folding for the wikitext 2010 editor', () => {
let title, parserFunctionNode;
before( async () => {
title = Util.getTestString( 'CodeMirror-fixture1-' );
await LoginPage.loginAdmin();
await FixtureContent.createFixturePage( title );
await UserPreferences.enableWikitext2010EditorWithCodeMirror();
await EditPage.openForEditing( title, true );
await EditPage.wikiEditorToolbar.waitForDisplayed();
await browser.execute( () => {
$( '.cm-editor' ).textSelection( 'setContents', '{{foo|1={{bar|{{baz|{{PAGENAME}}}}}}}}' );
} );
parserFunctionNode = $( '.cm-mw-parserfunction-name' );
} );
it( 'folds the template parameters via the button', async () => {
// First make sure the parser function node is visible.
assert( await parserFunctionNode.waitForDisplayed() );
// Insert the cursor.
await browser.execute( () => {
// Just after the '{{' in '{{PAGENAME}}'
$( '.cm-editor' ).textSelection( 'setSelection', { start: 22, end: 22 } );
} );
await EditPage.codeMirrorTemplateFoldingButton.waitForDisplayed();
// Fold the template, which should hide the parser function node.
await EditPage.codeMirrorTemplateFoldingButton.click();
// The parser function node should be hidden, while the placeholder should be visible.
assert( await parserFunctionNode.waitForDisplayed( { reverse: true } ) );
assert( await EditPage.codeMirrorTemplateFoldingPlaceholder.isDisplayedInViewport() );
} );
it( 'expands the template parameters via the button', async () => {
// Parser function node should be hidden.
assert( await parserFunctionNode.waitForDisplayed( { reverse: true } ) );
// Expand the template.
await EditPage.codeMirrorTemplateFoldingPlaceholder.click();
// Parser function node should be visible, while the placeholder should be hidden.
assert( await parserFunctionNode.waitForDisplayed() );
assert(
await EditPage.codeMirrorTemplateFoldingPlaceholder
.waitForDisplayed( { reverse: true } )
);
} );
} );