mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/CodeMirror
synced 2024-11-23 13:56:44 +00:00
CodeMirror6: add new modules, feature flag, and URL query parameter
Add a new $wgCodeMirrorV6 temporary feature flag that when enabled, will load the 'ext.CodeMirror.v6.WikiEditor' module that is built against CodeMirror 6. You can also pass in the ?cm6enable=1 query parameter to force use of CodeMirror 6. This is currently only implemented for the 2010 editor. Due to packaging constraints with CodeMirror 6, we now use Webpack to bundle the files, which are then used by ResourceLoader. This is similar to what is done for Extension:Popups, MobileFrontend, among other extensions. A new generic class CodeMirror can be used on other areas where syntax highlighting is desirable, but not necessarily for editing (i.e. without WikiEditor). This commit merely lays the foundation for CodeMirror 6 and updates WikiEditor to use it. The actual MediaWiki syntax highlighting will come with a future commit. With the new Webpack build, the Gruntfile was removed and the tasks moved to npm commands. Bug: T317243 Change-Id: I2239d2449b2db3b638551f847eb4eff1aafa6276
This commit is contained in:
parent
1a19f48c70
commit
880c690a10
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,6 +3,7 @@
|
|||
.*.swp
|
||||
.directory
|
||||
/node_modules/
|
||||
/coverage/
|
||||
/tests/selenium/log/
|
||||
/vendor/
|
||||
/composer.lock
|
||||
|
|
|
@ -2,5 +2,8 @@
|
|||
"extends": "stylelint-config-wikimedia",
|
||||
"rules": {
|
||||
"rule-empty-line-before": null
|
||||
}
|
||||
},
|
||||
"ignoreFiles": [
|
||||
"coverage/**/*.css"
|
||||
]
|
||||
}
|
||||
|
|
32
Gruntfile.js
32
Gruntfile.js
|
@ -1,32 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = function ( grunt ) {
|
||||
const conf = grunt.file.readJSON( 'extension.json' );
|
||||
|
||||
grunt.loadNpmTasks( 'grunt-banana-checker' );
|
||||
grunt.loadNpmTasks( 'grunt-eslint' );
|
||||
grunt.loadNpmTasks( 'grunt-stylelint' );
|
||||
|
||||
grunt.initConfig( {
|
||||
eslint: {
|
||||
options: {
|
||||
cache: true,
|
||||
fix: grunt.option( 'fix' )
|
||||
},
|
||||
all: [ '.' ]
|
||||
},
|
||||
stylelint: {
|
||||
all: [
|
||||
'**/*.{css,less}',
|
||||
'!resources/lib/**',
|
||||
'!node_modules/**',
|
||||
'!vendor/**',
|
||||
'resources/lib/codemirror-fixes.less'
|
||||
]
|
||||
},
|
||||
banana: conf.MessagesDirs
|
||||
} );
|
||||
|
||||
grunt.registerTask( 'test', [ 'eslint', 'stylelint', 'banana' ] );
|
||||
grunt.registerTask( 'default', 'test' );
|
||||
};
|
37
README.md
Normal file
37
README.md
Normal file
|
@ -0,0 +1,37 @@
|
|||
# mediawiki/extensions/CodeMirror
|
||||
|
||||
Homepage: https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:CodeMirror
|
||||
|
||||
## Development
|
||||
|
||||
As part of the [upgrade to CodeMirror 6](https://phabricator.wikimedia.org/T259059),
|
||||
CodeMirror now uses an asset bundler, so during development you'll need to run a script
|
||||
to assemble the frontend assets.
|
||||
|
||||
Use of CodeMirror 6 is controlled by the `wgCodeMirrorV6` configuration setting, or by
|
||||
passing in `cm6enable=1` in the URL query string.
|
||||
|
||||
You can find the v6 frontend source files in `src/`, the compiled sources in
|
||||
`resources/dist/`, and other frontend assets managed by ResourceLoader in
|
||||
`resources/*`.
|
||||
|
||||
### Commands
|
||||
|
||||
_NOTE: Consider using [Fresh](https://gerrit.wikimedia.org/g/fresh/) to run these tasks._
|
||||
|
||||
* `npm install` to install dependencies.
|
||||
* `npm start` to run the bundler in watch mode, reassembling the files on file change.
|
||||
You'll want to keep this running in a separate terminal during development.
|
||||
* `npm run build` to compile the production assets. You *must* run this step before
|
||||
sending the patch or CI will fail (so that sources and built assets are in sync).
|
||||
* `npm test` to run the linting tools, JavaScript unit tests, and build checks.
|
||||
* `npm run test:lint` for linting of JS/LESS/CSS.
|
||||
* `npm run test:lint:js` for linting of just JavaScript.
|
||||
* `npm run test:lint:styles` for linting of just Less/CSS.
|
||||
* `npm run test:i18n` for linting of i18n messages with banana-checker.
|
||||
* `npm run test:unit` for the new Jest unit tests.
|
||||
* `npm run selenium-test` for the Selenium tests.
|
||||
* `npm run test:bundlesize` to test if the gzip'd entrypoint is of acceptable size.
|
||||
|
||||
Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These will
|
||||
eventually be moved over to `tests/qunit` and rewritten for CodeMirror 6.
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"name": "CodeMirror",
|
||||
"version": "4.0.0",
|
||||
"version": "5.0.0",
|
||||
"author": [
|
||||
"[https://www.mediawiki.org/wiki/User:Pastakhov Pavel Astakhov]",
|
||||
"[https://www.mediawiki.org/wiki/User:Florianschmidtwelzow Florian Schmidt]",
|
||||
"Marijn Haverbeke",
|
||||
"MusikAnimal",
|
||||
"[https://raw.githubusercontent.com/codemirror/CodeMirror/master/AUTHORS CodeMirror contributors]"
|
||||
],
|
||||
"url": "https://www.mediawiki.org/wiki/Extension:CodeMirror",
|
||||
|
@ -19,6 +20,10 @@
|
|||
"value": null,
|
||||
"description": "List of namespace IDs where line numbering should be enabled, or `null` to enable for all namespaces. Set to [] to disable everywhere. Defaults to `null` for all namespaces.",
|
||||
"public": true
|
||||
},
|
||||
"CodeMirrorV6": {
|
||||
"value": false,
|
||||
"description": "Temporary feature flag for the CodeMirror 6 upgrade."
|
||||
}
|
||||
},
|
||||
"MessagesDirs": {
|
||||
|
@ -149,6 +154,23 @@
|
|||
"messages": [
|
||||
"codemirror-toggle-label"
|
||||
]
|
||||
},
|
||||
"ext.CodeMirror.v6.WikiEditor": {
|
||||
"dependencies": [
|
||||
"web2017-polyfills",
|
||||
"mediawiki.api",
|
||||
"mediawiki.user",
|
||||
"user.options"
|
||||
],
|
||||
"packageFiles": [
|
||||
"dist/main.js"
|
||||
],
|
||||
"styles": [
|
||||
"ext.CodeMirror.v6.WikiEditor.less"
|
||||
],
|
||||
"messages": [
|
||||
"codemirror-toggle-label"
|
||||
]
|
||||
}
|
||||
},
|
||||
"ResourceFileModulePaths": {
|
||||
|
@ -180,7 +202,8 @@
|
|||
"main": {
|
||||
"class": "MediaWiki\\Extension\\CodeMirror\\Hooks",
|
||||
"services": [
|
||||
"UserOptionsLookup"
|
||||
"UserOptionsLookup",
|
||||
"MainConfig"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
@ -18,15 +18,21 @@ class Hooks implements
|
|||
{
|
||||
|
||||
/** @var UserOptionsLookup */
|
||||
private $userOptionsLookup;
|
||||
private UserOptionsLookup $userOptionsLookup;
|
||||
|
||||
/** @var bool */
|
||||
private bool $useV6;
|
||||
|
||||
/**
|
||||
* @param UserOptionsLookup $userOptionsLookup
|
||||
* @param Config $config
|
||||
*/
|
||||
public function __construct(
|
||||
UserOptionsLookup $userOptionsLookup
|
||||
UserOptionsLookup $userOptionsLookup,
|
||||
Config $config
|
||||
) {
|
||||
$this->userOptionsLookup = $userOptionsLookup;
|
||||
$this->useV6 = $config->get( 'CodeMirrorV6' );
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -35,7 +41,7 @@ class Hooks implements
|
|||
* @param OutputPage $out
|
||||
* @return bool
|
||||
*/
|
||||
private function isCodeMirrorOnPage( OutputPage $out ) {
|
||||
private function isCodeMirrorOnPage( OutputPage $out ): bool {
|
||||
// Disable CodeMirror when CodeEditor is active on this page
|
||||
// Depends on ext.codeEditor being added by \MediaWiki\EditPage\EditPage::showEditForm:initial
|
||||
if ( in_array( 'ext.codeEditor', $out->getModules(), true ) ) {
|
||||
|
@ -60,7 +66,14 @@ class Hooks implements
|
|||
* @return void This hook must not abort, it must return no value
|
||||
*/
|
||||
public function onBeforePageDisplay( $out, $skin ): void {
|
||||
if ( $this->isCodeMirrorOnPage( $out ) ) {
|
||||
if ( !$this->isCodeMirrorOnPage( $out ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: remove check for cm6enable flag after migration is complete
|
||||
if ( $this->useV6 || $out->getRequest()->getRawVal( 'cm6enable' ) ) {
|
||||
$out->addModules( 'ext.CodeMirror.v6.WikiEditor' );
|
||||
} else {
|
||||
$out->addModules( 'ext.CodeMirror.WikiEditor' );
|
||||
|
||||
if ( $this->userOptionsLookup->getOption( $out->getUser(), 'usecodemirror' ) ) {
|
||||
|
|
53
jest.config.js
Normal file
53
jest.config.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
// Automatically clear mock calls and instances between every test
|
||||
clearMocks: true,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
collectCoverage: true,
|
||||
|
||||
// An array of glob patterns indicating a set of files fo
|
||||
// which coverage information should be collected
|
||||
collectCoverageFrom: [
|
||||
'tests/jest/*.js'
|
||||
],
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: 'coverage',
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
coveragePathIgnorePatterns: [
|
||||
'/node_modules/'
|
||||
],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 31,
|
||||
functions: 39,
|
||||
lines: 38,
|
||||
statements: 38
|
||||
}
|
||||
},
|
||||
|
||||
// An array of file extensions your modules use
|
||||
moduleFileExtensions: [
|
||||
'js'
|
||||
],
|
||||
|
||||
// The paths to modules that run some code to configure or
|
||||
// set up the testing environment before each test
|
||||
setupFiles: [
|
||||
'./tests/jest/setup.js'
|
||||
],
|
||||
|
||||
// Simulates a real browser environment.
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// Ignore these directories when locating tests to run.
|
||||
testPathIgnorePatterns: [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/resources/'
|
||||
]
|
||||
};
|
30794
package-lock.json
generated
30794
package-lock.json
generated
File diff suppressed because it is too large
Load diff
44
package.json
44
package.json
|
@ -1,23 +1,55 @@
|
|||
{
|
||||
"name": "CodeMirror",
|
||||
"name": "codemirror",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "grunt test",
|
||||
"start": "webpack -w --mode=development",
|
||||
"build": "webpack --mode=production",
|
||||
"test": "npm run test:lint && npm run test:unit && npm run check-built-assets && bundlesize",
|
||||
"test:lint": "npm run test:lint:styles && npm run test:lint:js && npm run test:lint:i18n",
|
||||
"test:lint:js": "eslint --cache .",
|
||||
"test:lint:styles": "stylelint \"resources/**/*.less\"",
|
||||
"test:lint:i18n": "banana-checker i18n/",
|
||||
"test:unit": "jest",
|
||||
"test:bundlesize": "bundlesize",
|
||||
"check-built-assets": "{ git status src/ | grep \"nothing to commit, working tree clean\"; } && { echo 'CHECKING BUILD SOURCES ARE COMMITTED' && npm run build && git status resources/dist/ | grep \"nothing to commit, working tree clean\" || { npm run node-debug; false; }; }",
|
||||
"node-debug": "node -v && npm -v && echo 'ERROR: Please ensure that production assets have been built with `npm run build` and commited, and that you are using the correct version of Node/NPM.'",
|
||||
"selenium-test": "wdio tests/selenium/wdio.conf.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "16.19.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.22.20",
|
||||
"@babel/plugin-transform-runtime": "7.22.15",
|
||||
"@babel/preset-env": "7.2.0",
|
||||
"@codemirror/commands": "6.2.5",
|
||||
"@codemirror/search": "6.5.4",
|
||||
"@codemirror/state": "6.2.1",
|
||||
"@codemirror/view": "6.18.1",
|
||||
"@wdio/cli": "7.30.1",
|
||||
"@wdio/junit-reporter": "7.29.1",
|
||||
"@wdio/local-runner": "7.30.1",
|
||||
"@wdio/mocha-framework": "7.26.0",
|
||||
"@wdio/spec-reporter": "7.29.1",
|
||||
"@wikimedia/mw-node-qunit": "7.2.0",
|
||||
"babel-loader": "8.0.4",
|
||||
"bundlesize": "0.18.1",
|
||||
"clean-webpack-plugin": "3.0.0",
|
||||
"dotenv": "8.2.0",
|
||||
"eslint-config-wikimedia": "0.25.1",
|
||||
"grunt": "1.6.1",
|
||||
"grunt-banana-checker": "0.11.0",
|
||||
"grunt-eslint": "24.3.0",
|
||||
"grunt-stylelint": "0.19.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jquery": "3.7.1",
|
||||
"stylelint-config-wikimedia": "0.16.1",
|
||||
"wdio-mediawiki": "2.3.0"
|
||||
"wdio-mediawiki": "2.3.0",
|
||||
"webpack": "4.47.0",
|
||||
"webpack-cli": "3.3.12"
|
||||
},
|
||||
"bundlesize": [
|
||||
{
|
||||
"path": "resources/dist/main.js",
|
||||
"maxSize": "100.0kB"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -22,5 +22,6 @@
|
|||
"indent": [ "error", 2 ]
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"ignorePatterns": [ "dist/" ]
|
||||
}
|
||||
|
|
2
resources/dist/main.js
vendored
Normal file
2
resources/dist/main.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
resources/dist/main.js.map.json
vendored
Normal file
1
resources/dist/main.js.map.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -215,8 +215,8 @@ function init() {
|
|||
|
||||
// Allow textSelection() functions to work with CodeMirror editing field.
|
||||
$codeMirror.textSelection( 'register', cmTextSelection );
|
||||
// Also override textSelection() functions for the "real" hidden textarea to route to
|
||||
// CodeMirror. We unregister this when switching to normal textarea mode.
|
||||
// Also override textSelection() functions for the "real" hidden textarea to route to CodeMirror.
|
||||
// We unregister this when switching to normal textarea mode.
|
||||
$textbox1.textSelection( 'register', cmTextSelection );
|
||||
|
||||
setupSizing( $textbox1, codeMirror );
|
||||
|
|
3
resources/ext.CodeMirror.v6.WikiEditor.less
Normal file
3
resources/ext.CodeMirror.v6.WikiEditor.less
Normal file
|
@ -0,0 +1,3 @@
|
|||
.cm-editor {
|
||||
height: 100%;
|
||||
}
|
18
src/.eslintrc.json
Normal file
18
src/.eslintrc.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": [
|
||||
"wikimedia/client-es6",
|
||||
"wikimedia/jquery",
|
||||
"wikimedia/mediawiki"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"commonjs": true
|
||||
},
|
||||
"rules": {
|
||||
"es-x/no-array-prototype-includes": 0
|
||||
}
|
||||
}
|
157
src/codemirror.js
Normal file
157
src/codemirror.js
Normal file
|
@ -0,0 +1,157 @@
|
|||
import { EditorState, Extension } from '@codemirror/state';
|
||||
import { EditorView, lineNumbers } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* @class CodeMirror
|
||||
*/
|
||||
export default class CodeMirror {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {jQuery} $textarea Textarea to add syntax highlighting to.
|
||||
*/
|
||||
constructor( $textarea ) {
|
||||
this.$textarea = $textarea;
|
||||
this.view = null;
|
||||
this.state = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions here should be applicable to all theoretical uses of CodeMirror in MediaWiki.
|
||||
* Don't assume CodeMirror is used for editing (i.e. "View source" of a protected page).
|
||||
* Subclasses are safe to override this method if needed.
|
||||
*
|
||||
* @return {Extension[]}
|
||||
*/
|
||||
get defaultExtensions() {
|
||||
const extensions = [];
|
||||
const namespaces = mw.config.get( 'wgCodeMirrorLineNumberingNamespaces' );
|
||||
|
||||
// Set to [] to disable everywhere, or null to enable everywhere
|
||||
if ( !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) ) ) {
|
||||
extensions.push( lineNumbers() );
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup CodeMirror and add it to the DOM. This will hide the original textarea.
|
||||
*
|
||||
* @param {Extension[]} extensions
|
||||
*/
|
||||
initialize( extensions = this.defaultExtensions ) {
|
||||
// Set up the initial EditorState of CodeMirror with contents of the native textarea.
|
||||
this.state = EditorState.create( {
|
||||
doc: this.$textarea.textSelection( 'getContents' ),
|
||||
extensions
|
||||
} );
|
||||
|
||||
// Add CodeMirror view to the DOM.
|
||||
this.view = new EditorView( {
|
||||
state: this.state,
|
||||
parent: this.$textarea.parent()[ 0 ]
|
||||
} );
|
||||
|
||||
// Hide native textarea and sync CodeMirror contents upon submission.
|
||||
this.$textarea.hide();
|
||||
if ( this.$textarea[ 0 ].form ) {
|
||||
this.$textarea[ 0 ].form.addEventListener( 'submit', () => {
|
||||
this.$textarea.val( this.view.state.doc.toString() );
|
||||
} );
|
||||
}
|
||||
|
||||
// Register $.textSelection() on the .cm-editor element.
|
||||
$( this.view.dom ).textSelection( 'register', this.cmTextSelection );
|
||||
// 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 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Log usage of CodeMirror.
|
||||
*
|
||||
* @param {Object} data
|
||||
*/
|
||||
logUsage( data ) {
|
||||
/* eslint-disable camelcase */
|
||||
const event = Object.assign( {
|
||||
session_token: mw.user.sessionId(),
|
||||
user_id: mw.user.getId()
|
||||
}, data );
|
||||
const editCountBucket = mw.config.get( 'wgUserEditCountBucket' );
|
||||
if ( editCountBucket !== null ) {
|
||||
event.user_edit_count_bucket = editCountBucket;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
||||
mw.track( 'event.CodeMirrorUsage', event );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CodeMirror enabled preference.
|
||||
*
|
||||
* @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false.
|
||||
*/
|
||||
setCodeMirrorPreference( prefValue ) {
|
||||
if ( !mw.user.isNamed() ) { // Skip it for unnamed users
|
||||
return;
|
||||
}
|
||||
new mw.Api().saveOption( 'usecodemirror', prefValue ? 1 : 0 );
|
||||
mw.user.options.set( 'usecodemirror', prefValue ? 1 : 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* jQuery.textSelection overrides for CodeMirror.
|
||||
*
|
||||
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/jQuery.plugin.textSelection
|
||||
* @return {Object}
|
||||
*/
|
||||
get cmTextSelection() {
|
||||
const $cmDom = $( this.view.dom );
|
||||
return {
|
||||
getContents: () => this.view.state.doc.toString(),
|
||||
setContents: ( content ) => {
|
||||
this.view.dispatch( {
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.view.state.doc.length,
|
||||
insert: content
|
||||
}
|
||||
} );
|
||||
return $cmDom;
|
||||
},
|
||||
getSelection: () => {
|
||||
return this.view.state.sliceDoc(
|
||||
this.view.state.selection.main.from,
|
||||
this.view.state.selection.main.to
|
||||
);
|
||||
},
|
||||
setSelection: ( options = { start: 0, end: 0 } ) => {
|
||||
this.view.dispatch( {
|
||||
selection: { anchor: options.start, head: ( options.end || options.start ) }
|
||||
} );
|
||||
this.view.focus();
|
||||
return $cmDom;
|
||||
},
|
||||
replaceSelection: ( value ) => {
|
||||
this.view.dispatch(
|
||||
this.view.state.replaceSelection( value )
|
||||
);
|
||||
return $cmDom;
|
||||
},
|
||||
getCaretPosition: ( options ) => {
|
||||
if ( !options.startAndEnd ) {
|
||||
return this.view.state.selection.main.head;
|
||||
}
|
||||
return [
|
||||
this.view.state.selection.main.from,
|
||||
this.view.state.selection.main.to
|
||||
];
|
||||
},
|
||||
scrollToCaretPosition: () => {
|
||||
this.view.scrollIntoView( this.view.state.selection.main.head );
|
||||
return $cmDom;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
8
src/codemirror.wikieditor.init.js
Normal file
8
src/codemirror.wikieditor.init.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import CodeMirrorWikiEditor from './codemirror.wikieditor';
|
||||
|
||||
if ( mw.loader.getState( 'ext.wikiEditor' ) ) {
|
||||
mw.hook( 'wikiEditor.toolbarReady' ).add( ( $textarea ) => {
|
||||
const cmWE = new CodeMirrorWikiEditor( $textarea );
|
||||
cmWE.addCodeMirrorToWikiEditor();
|
||||
} );
|
||||
}
|
190
src/codemirror.wikieditor.js
Normal file
190
src/codemirror.wikieditor.js
Normal file
|
@ -0,0 +1,190 @@
|
|||
import CodeMirror from './codemirror';
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
|
||||
/**
|
||||
* @class CodeMirrorWikiEditor
|
||||
*/
|
||||
export default class CodeMirrorWikiEditor extends CodeMirror {
|
||||
constructor( $textarea ) {
|
||||
super( $textarea );
|
||||
this.realtimePreviewHandler = null;
|
||||
this.useCodeMirror = mw.user.options.get( 'usecodemirror' ) > 0;
|
||||
this.editRecoveryHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
setCodeMirrorPreference( prefValue ) {
|
||||
this.useCodeMirror = prefValue; // Save state for function updateToolbarButton()
|
||||
super.setCodeMirrorPreference( prefValue );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the default textarea with CodeMirror
|
||||
*/
|
||||
enableCodeMirror() {
|
||||
// If CodeMirror is already loaded or wikEd gadget is enabled, abort. See T178348.
|
||||
// FIXME: Would be good to replace the wikEd check with something more generic.
|
||||
if ( this.view || mw.user.options.get( 'gadget-wikEd' ) > 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionStart = this.$textarea.prop( 'selectionStart' ),
|
||||
selectionEnd = this.$textarea.prop( 'selectionEnd' ),
|
||||
scrollTop = this.$textarea.scrollTop(),
|
||||
hasFocus = this.$textarea.is( ':focus' );
|
||||
|
||||
/*
|
||||
* Default configuration, which we may conditionally add to later.
|
||||
* @see https://codemirror.net/docs/ref/#state.Extension
|
||||
*/
|
||||
const extensions = [
|
||||
...this.defaultExtensions,
|
||||
history(),
|
||||
// These are HTML attributes on the .cm-content element.
|
||||
EditorView.contentAttributes.of( {
|
||||
spellcheck: 'true',
|
||||
// T259347: Use accesskey of the original textbox
|
||||
accesskey: this.$textarea.attr( 'accesskey' )
|
||||
} ),
|
||||
EditorView.domEventHandlers( {
|
||||
blur: () => this.$textarea.triggerHandler( 'blur' ),
|
||||
focus: () => this.$textarea.triggerHandler( 'focus' )
|
||||
} ),
|
||||
EditorView.updateListener.of( ( update ) => {
|
||||
if ( update.docChanged && typeof this.editRecoveryHandler === 'function' ) {
|
||||
this.editRecoveryHandler();
|
||||
}
|
||||
} ),
|
||||
EditorView.lineWrapping,
|
||||
keymap.of( [
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap
|
||||
] )
|
||||
];
|
||||
|
||||
mw.hook( 'editRecovery.loadEnd' ).add( ( data ) => {
|
||||
this.editRecoveryHandler = data.fieldChangeHandler;
|
||||
} );
|
||||
|
||||
this.initialize( extensions );
|
||||
|
||||
// Sync scroll position, selections, and focus state.
|
||||
this.view.scrollDOM.scrollTop = scrollTop;
|
||||
this.view.dispatch( {
|
||||
selection: EditorSelection.create( [
|
||||
EditorSelection.range( selectionStart, selectionEnd )
|
||||
] )
|
||||
} );
|
||||
if ( hasFocus ) {
|
||||
this.view.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the CodeMirror button to WikiEditor
|
||||
*/
|
||||
addCodeMirrorToWikiEditor() {
|
||||
const context = this.$textarea.data( 'wikiEditor-context' );
|
||||
const toolbar = context && context.modules && context.modules.toolbar;
|
||||
|
||||
// Guard against something having removed WikiEditor (T271457)
|
||||
if ( !toolbar ) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$textarea.wikiEditor(
|
||||
'addToToolbar',
|
||||
{
|
||||
section: 'main',
|
||||
groups: {
|
||||
codemirror: {
|
||||
tools: {
|
||||
CodeMirror: {
|
||||
label: mw.msg( 'codemirror-toggle-label' ),
|
||||
type: 'toggle',
|
||||
oouiIcon: 'highlight',
|
||||
action: {
|
||||
type: 'callback',
|
||||
execute: () => this.switchCodeMirror()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const $codeMirrorButton = toolbar.$toolbar.find( '.tool[rel=CodeMirror]' );
|
||||
$codeMirrorButton
|
||||
.attr( 'id', 'mw-editbutton-codemirror' );
|
||||
|
||||
if ( this.useCodeMirror ) {
|
||||
this.enableCodeMirror();
|
||||
}
|
||||
this.updateToolbarButton();
|
||||
|
||||
this.logUsage( {
|
||||
editor: 'wikitext',
|
||||
enabled: this.useCodeMirror,
|
||||
toggled: false,
|
||||
// eslint-disable-next-line no-jquery/no-global-selector,camelcase
|
||||
edit_start_ts_ms: parseInt( $( 'input[name="wpStarttime"]' ).val(), 10 ) * 1000 || 0
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates CodeMirror button on the toolbar according to the current state (on/off)
|
||||
*/
|
||||
updateToolbarButton() {
|
||||
// eslint-disable-next-line no-jquery/no-global-selector
|
||||
const $button = $( '#mw-editbutton-codemirror' );
|
||||
$button.toggleClass( 'mw-editbutton-codemirror-active', this.useCodeMirror );
|
||||
|
||||
// WikiEditor2010 OOUI ToggleButtonWidget
|
||||
if ( $button.data( 'setActive' ) ) {
|
||||
$button.data( 'setActive' )( this.useCodeMirror );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables CodeMirror
|
||||
*/
|
||||
switchCodeMirror() {
|
||||
if ( this.view ) {
|
||||
this.setCodeMirrorPreference( false );
|
||||
const scrollTop = this.view.scrollDOM.scrollTop;
|
||||
const hasFocus = this.view.hasFocus;
|
||||
const { from, to } = this.view.state.selection.ranges[ 0 ];
|
||||
$( this.view.dom ).textSelection( 'unregister' );
|
||||
this.$textarea.textSelection( 'unregister' );
|
||||
this.view.destroy();
|
||||
this.view = null;
|
||||
this.$textarea.show();
|
||||
if ( hasFocus ) {
|
||||
this.$textarea.trigger( 'focus' );
|
||||
}
|
||||
this.$textarea.prop( 'selectionStart', Math.min( from, to ) )
|
||||
.prop( 'selectionEnd', Math.max( to, from ) );
|
||||
this.$textarea.scrollTop( scrollTop );
|
||||
mw.hook( 'ext.CodeMirror.switch' ).fire( false, this.$textbox1 );
|
||||
} else {
|
||||
this.enableCodeMirror();
|
||||
this.setCodeMirrorPreference( true );
|
||||
}
|
||||
this.updateToolbarButton();
|
||||
|
||||
this.logUsage( {
|
||||
editor: 'wikitext',
|
||||
enabled: this.useCodeMirror,
|
||||
toggled: true,
|
||||
// eslint-disable-next-line no-jquery/no-global-selector,camelcase
|
||||
edit_start_ts_ms: parseInt( $( 'input[name="wpStarttime"]' ).val(), 10 ) * 1000 || 0
|
||||
} );
|
||||
}
|
||||
}
|
13
tests/jest/.eslintrc.json
Normal file
13
tests/jest/.eslintrc.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": [
|
||||
"wikimedia/jquery",
|
||||
"wikimedia/mediawiki"
|
||||
],
|
||||
"parserOptions": {
|
||||
"sourceType": "module"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"jest": true
|
||||
}
|
||||
}
|
77
tests/jest/codemirror.test.js
Normal file
77
tests/jest/codemirror.test.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
const { EditorView } = require( '@codemirror/view' );
|
||||
const CodeMirror = require( '../../src/codemirror.js' ).default;
|
||||
const $textarea = $( '<textarea>' ),
|
||||
cm = new CodeMirror( $textarea );
|
||||
|
||||
describe( 'initialize', () => {
|
||||
const initializeWithForm = () => {
|
||||
const form = document.createElement( 'form' );
|
||||
form.append( cm.$textarea[ 0 ] );
|
||||
cm.$textarea[ 0 ].form.addEventListener = jest.fn();
|
||||
cm.initialize();
|
||||
};
|
||||
|
||||
it( 'should create the EditorState with the value of the textarea', () => {
|
||||
cm.$textarea.val( 'foobar' );
|
||||
cm.$textarea.textSelection = jest.fn().mockReturnValue( 'foobar' );
|
||||
cm.initialize();
|
||||
expect( cm.view.state.doc.toString() ).toStrictEqual( 'foobar' );
|
||||
} );
|
||||
|
||||
it( 'should instantiate an EditorView and add .cm-editor to the DOM', () => {
|
||||
initializeWithForm();
|
||||
expect( cm.view ).toBeInstanceOf( EditorView );
|
||||
expect( cm.view.dom ).toBeInstanceOf( HTMLDivElement );
|
||||
expect( cm.$textarea[ 0 ].nextSibling ).toStrictEqual( cm.view.dom );
|
||||
} );
|
||||
|
||||
it( 'should hide the native textarea', () => {
|
||||
cm.initialize();
|
||||
expect( cm.$textarea[ 0 ].style.display ).toStrictEqual( 'none' );
|
||||
} );
|
||||
|
||||
it( 'should add a listener for form submission', () => {
|
||||
initializeWithForm();
|
||||
expect( cm.$textarea[ 0 ].form.addEventListener ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'logUsage', () => {
|
||||
it( 'should track usage of CodeMirror with the correct data', () => {
|
||||
cm.logUsage( {
|
||||
editor: 'wikitext',
|
||||
enabled: true,
|
||||
toggled: false
|
||||
} );
|
||||
expect( mw.track ).toBeCalledWith( 'event.CodeMirrorUsage', {
|
||||
editor: 'wikitext',
|
||||
enabled: true,
|
||||
// eslint-disable-next-line camelcase
|
||||
session_token: 'abc',
|
||||
toggled: false,
|
||||
// eslint-disable-next-line camelcase
|
||||
user_edit_count_bucket: '1000+ edits',
|
||||
// eslint-disable-next-line camelcase
|
||||
user_id: 123
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'setCodeMirrorPreference', () => {
|
||||
beforeEach( () => {
|
||||
cm.initialize();
|
||||
} );
|
||||
|
||||
it( 'should save using the API with the correct value', () => {
|
||||
cm.setCodeMirrorPreference( true );
|
||||
expect( mw.Api.prototype.saveOption ).toHaveBeenCalledWith( 'usecodemirror', 1 );
|
||||
expect( mw.user.options.set ).toHaveBeenCalledWith( 'usecodemirror', 1 );
|
||||
} );
|
||||
|
||||
it( 'should not save preferences if the user is not named', () => {
|
||||
mw.user.isNamed = jest.fn().mockReturnValue( false );
|
||||
cm.setCodeMirrorPreference( true );
|
||||
expect( mw.Api.prototype.saveOption ).toHaveBeenCalledTimes( 0 );
|
||||
expect( mw.user.options.set ).toHaveBeenCalledTimes( 0 );
|
||||
} );
|
||||
} );
|
55
tests/jest/codemirror.wikieditor.test.js
Normal file
55
tests/jest/codemirror.wikieditor.test.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
mw.loader = {
|
||||
getState: jest.fn().mockReturnValue( '1' )
|
||||
};
|
||||
|
||||
const CodeMirrorWikiEditor = require( '../../src/codemirror.wikieditor.js' ).default,
|
||||
$textarea = $( '<textarea>' )
|
||||
.text( 'The Smashing Pumpkins' ),
|
||||
cmWe = new CodeMirrorWikiEditor( $textarea );
|
||||
|
||||
beforeEach( () => {
|
||||
// Simulate the button that enables/disables CodeMirror as WikiEditor doesn't exist here.
|
||||
const btn = document.createElement( 'span' );
|
||||
btn.id = 'mw-editbutton-codemirror';
|
||||
btn.classList.add( 'tool' );
|
||||
btn.setAttribute( 'rel', 'CodeMirror' );
|
||||
document.body.appendChild( btn );
|
||||
|
||||
// Add WikiEditor context to the textarea.
|
||||
cmWe.$textarea.data = jest.fn().mockReturnValue( {
|
||||
modules: {
|
||||
toolbar: {
|
||||
$toolbar: $( btn )
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
// Initialize CodeMirror.
|
||||
cmWe.initialize();
|
||||
} );
|
||||
|
||||
describe( 'addCodeMirrorToWikiEditor', () => {
|
||||
cmWe.$textarea.wikiEditor = jest.fn();
|
||||
|
||||
it( 'should add the button to the toolbar', () => {
|
||||
cmWe.addCodeMirrorToWikiEditor();
|
||||
expect( cmWe.$textarea.wikiEditor ).toHaveBeenCalledWith(
|
||||
'addToToolbar',
|
||||
expect.objectContaining( {
|
||||
groups: { codemirror: expect.any( Object ) }
|
||||
} )
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'updateToolbarButton', () => {
|
||||
it( 'should update the toolbar button based on the current CodeMirror state', () => {
|
||||
const btn = document.getElementById( 'mw-editbutton-codemirror' );
|
||||
cmWe.setCodeMirrorPreference( false );
|
||||
cmWe.updateToolbarButton();
|
||||
expect( btn.classList.contains( 'mw-editbutton-codemirror-active' ) ).toBeFalsy();
|
||||
cmWe.setCodeMirrorPreference( true );
|
||||
cmWe.updateToolbarButton();
|
||||
expect( btn.classList.contains( 'mw-editbutton-codemirror-active' ) ).toBeTruthy();
|
||||
} );
|
||||
} );
|
17
tests/jest/setup.js
Normal file
17
tests/jest/setup.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
global.mw = require( '@wikimedia/mw-node-qunit/src/mockMediaWiki.js' )();
|
||||
mw.user = Object.assign( mw.user, {
|
||||
options: {
|
||||
// Only called for 'usecodemirror' option.
|
||||
get: jest.fn().mockReturnValue( 1 ),
|
||||
set: jest.fn()
|
||||
},
|
||||
sessionId: jest.fn().mockReturnValue( 'abc' ),
|
||||
getId: jest.fn().mockReturnValue( 123 ),
|
||||
isNamed: jest.fn().mockReturnValue( true )
|
||||
} );
|
||||
mw.config.get = jest.fn().mockReturnValue( '1000+ edits' );
|
||||
mw.track = jest.fn();
|
||||
mw.Api.prototype.saveOption = jest.fn();
|
||||
|
||||
global.$ = require( 'jquery' );
|
||||
$.fn.textSelection = () => {};
|
|
@ -3,10 +3,14 @@
|
|||
namespace MediaWiki\Extension\CodeMirror\Tests;
|
||||
|
||||
use MediaWiki\Extension\CodeMirror\Hooks;
|
||||
use MediaWiki\MediaWikiServices;
|
||||
use MediaWiki\Request\WebRequest;
|
||||
use MediaWiki\Title\Title;
|
||||
use MediaWiki\User\UserOptionsLookup;
|
||||
use MediaWikiIntegrationTestCase;
|
||||
use OutputPage;
|
||||
use RequestContext;
|
||||
use Skin;
|
||||
|
||||
/**
|
||||
* @group CodeMirror
|
||||
|
@ -18,21 +22,45 @@ class HookTest extends MediaWikiIntegrationTestCase {
|
|||
/**
|
||||
* @covers ::isCodeMirrorOnPage
|
||||
* @covers ::onBeforePageDisplay
|
||||
* @param bool $useCodeMirrorV6
|
||||
* @param int $expectedAddModuleCalls
|
||||
* @param string $expectedFirstModule
|
||||
* @dataProvider provideOnBeforePageDisplay
|
||||
*/
|
||||
public function testOnBeforePageDisplay() {
|
||||
public function testOnBeforePageDisplay(
|
||||
bool $useCodeMirrorV6, int $expectedAddModuleCalls, string $expectedFirstModule
|
||||
) {
|
||||
$this->overrideConfigValues( [
|
||||
'CodeMirrorV6' => $useCodeMirrorV6,
|
||||
] );
|
||||
$userOptionsLookup = $this->createMock( UserOptionsLookup::class );
|
||||
$userOptionsLookup->method( 'getOption' )->willReturn( true );
|
||||
$this->setService( 'UserOptionsLookup', $userOptionsLookup );
|
||||
|
||||
$out = $this->createMock( \OutputPage::class );
|
||||
$out = $this->createMock( OutputPage::class );
|
||||
$out->method( 'getModules' )->willReturn( [] );
|
||||
$out->method( 'getUser' )->willReturn( $this->createMock( \User::class ) );
|
||||
$out->method( 'getActionName' )->willReturn( 'edit' );
|
||||
$out->method( 'getTitle' )->willReturn( Title::makeTitle( NS_MAIN, __METHOD__ ) );
|
||||
$out->expects( $this->exactly( 2 ) )->method( 'addModules' );
|
||||
$request = $this->createMock( WebRequest::class );
|
||||
$request->method( 'getRawVal' )->willReturn( null );
|
||||
$out->method( 'getRequest' )->willReturn( $request );
|
||||
$out->expects( $this->exactly( $expectedAddModuleCalls ) )
|
||||
->method( 'addModules' )
|
||||
->withConsecutive( [ $this->equalTo( $expectedFirstModule ) ] );
|
||||
|
||||
( new Hooks( $userOptionsLookup ) )
|
||||
->onBeforePageDisplay( $out, $this->createMock( \Skin::class ) );
|
||||
( new Hooks( $userOptionsLookup, MediaWikiServices::getInstance()->getMainConfig() ) )
|
||||
->onBeforePageDisplay( $out, $this->createMock( Skin::class ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array[]
|
||||
*/
|
||||
public function provideOnBeforePageDisplay(): array {
|
||||
return [
|
||||
[ false, 2, 'ext.CodeMirror.WikiEditor' ],
|
||||
[ true, 1, 'ext.CodeMirror.v6.WikiEditor' ]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
98
webpack.config.js
Normal file
98
webpack.config.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
'use strict';
|
||||
|
||||
/* eslint-env node */
|
||||
const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ),
|
||||
path = require( 'path' ),
|
||||
distDir = path.resolve( __dirname, 'resources/dist' ),
|
||||
srcMapExt = '.map.json',
|
||||
PUBLIC_PATH = '/w/extensions/CodeMirror';
|
||||
|
||||
module.exports = ( env, argv ) => ( {
|
||||
// Apply the rule of silence: https://wikipedia.org/wiki/Unix_philosophy.
|
||||
stats: {
|
||||
all: false,
|
||||
// Output a timestamp when a build completes. Useful when watching files.
|
||||
builtAt: true,
|
||||
errors: true,
|
||||
warnings: true
|
||||
},
|
||||
|
||||
// Fail on the first build error instead of tolerating it for prod builds. This seems to
|
||||
// correspond to optimization.noEmitOnErrors.
|
||||
bail: argv.mode === 'production',
|
||||
|
||||
// Specify that all paths are relative the Webpack configuration directory not the current
|
||||
// working directory.
|
||||
context: __dirname,
|
||||
|
||||
entry: './src/codemirror.wikieditor.init.js',
|
||||
|
||||
module: {
|
||||
rules: [ {
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
// Beware of https://github.com/babel/babel-loader/issues/690. Changes to browsers require
|
||||
// manual invalidation.
|
||||
cacheDirectory: true
|
||||
}
|
||||
}
|
||||
} ]
|
||||
},
|
||||
|
||||
optimization: {
|
||||
// Don't produce production output when a build error occurs.
|
||||
noEmitOnErrors: argv.mode === 'production',
|
||||
|
||||
// Use filenames instead of unstable numerical identifiers for file references. This
|
||||
// increases the gzipped bundle size some but makes the build products easier to debug and
|
||||
// appear deterministic. I.e., code changes will only alter the bundle they're packed in
|
||||
// instead of shifting the identifiers in other bundles.
|
||||
// https://webpack.js.org/guides/caching/#deterministic-hashes (namedModules replaces NamedModulesPlugin.)
|
||||
moduleIds: 'named'
|
||||
},
|
||||
|
||||
output: {
|
||||
// Specify the destination of all build products.
|
||||
path: distDir,
|
||||
|
||||
// Store outputs per module in files named after the modules. For the JavaScript entry
|
||||
// itself, append .js to each ResourceLoader module entry name. This value is tightly
|
||||
// coupled to sourceMapFilename.
|
||||
filename: '[name].js',
|
||||
|
||||
// Rename source map extensions. Per T173491 files with a .map extension cannot be served
|
||||
// from prod.
|
||||
sourceMapFilename: `[file]${srcMapExt}`,
|
||||
|
||||
devtoolModuleFilenameTemplate: `${PUBLIC_PATH}/[resource-path]`
|
||||
},
|
||||
|
||||
// Accurate source maps at the expense of build time. The source map is intentionally exposed
|
||||
// to users via sourceMapFilename for prod debugging. This goes against convention as source
|
||||
// code is publicly distributed.
|
||||
devtool: 'source-map',
|
||||
|
||||
plugins: [
|
||||
// Delete the output directory on each build.
|
||||
new CleanWebpackPlugin( {
|
||||
cleanOnceBeforeBuildPatterns: [ '**/*', '!.eslintrc.json' ]
|
||||
} )
|
||||
],
|
||||
|
||||
performance: {
|
||||
// Size violations for prod builds fail; development builds are unchecked.
|
||||
hints: argv.mode === 'production' ? 'error' : false,
|
||||
|
||||
// Minified uncompressed size limits for chunks / assets and entrypoints. Keep these numbers
|
||||
// up-to-date and rounded to the nearest 10th of a kibibyte so that code sizing costs are
|
||||
// well understood. Related to bundlesize minified, gzipped compressed file size tests.
|
||||
maxAssetSize: 300.0 * 1024,
|
||||
maxEntrypointSize: 300.0 * 1024,
|
||||
|
||||
// The default filter excludes map files, but we rename ours.
|
||||
assetFilter: ( filename ) => !filename.endsWith( srcMapExt )
|
||||
}
|
||||
} );
|
Loading…
Reference in a new issue