mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/CodeMirror
synced 2024-11-23 13:56:44 +00:00
Merge "CodeMirror 6 template folding"
This commit is contained in:
commit
985f2991e5
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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": "无障碍"
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@
|
|||
"bundlesize": [
|
||||
{
|
||||
"path": "resources/dist/main.js",
|
||||
"maxSize": "105.0kB"
|
||||
"maxSize": "110.0kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
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
|
@ -8,3 +8,9 @@
|
|||
.cm-special-char-nbsp {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cm-tooltip-fold {
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
|
|
@ -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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
258
src/codemirror.templateFolding.js
Normal file
258
src/codemirror.templateFolding.js
Normal 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 )
|
||||
];
|
|
@ -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.
|
||||
|
|
|
@ -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();
|
||||
} );
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
54
tests/selenium/specs/templateFolding-wikitext2010.js
Normal file
54
tests/selenium/specs/templateFolding-wikitext2010.js
Normal 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 } )
|
||||
);
|
||||
} );
|
||||
} );
|
Loading…
Reference in a new issue