diff --git a/extension.json b/extension.json index 0572bc20..be209a27 100644 --- a/extension.json +++ b/extension.json @@ -165,13 +165,15 @@ "legacy/modules/ve-cm/ve.ui.CodeMirrorTool.js" ], "styles": [ - "legacy/modules/ve-cm/ve.ui.CodeMirror.less" + "legacy/modules/ve-cm/ve.ui.CodeMirror.less", + "legacy/ext.CodeMirror.less" ], "messages": [ "codemirror-toggle-label" ] }, "ext.CodeMirror.v6": { + "class": "MediaWiki\\ResourceLoader\\CodexModule", "dependencies": [ "mediawiki.api", "mediawiki.user", @@ -181,6 +183,9 @@ "packageFiles": [ "codemirror.js", "codemirror.textSelection.js", + "codemirror.search.js", + "codemirror.gotoLine.js", + "codemirror.panel.js", { "name": "ext.CodeMirror.data.js", "callback": "MediaWiki\\Extension\\CodeMirror\\DataScript::makeScript" @@ -189,44 +194,57 @@ "styles": [ "codemirror.less" ], + "codexStyleOnly": true, + "codexComponents": [ + "CdxButton", + "CdxCheckbox", + "CdxLabel", + "CdxTextInput", + "CdxToggleButton", + "CdxToggleButtonGroup" + ], "messages": [ + "codemirror-all", + "codemirror-all-tooltip", + "codemirror-by-word", + "codemirror-control-character", + "codemirror-done", "codemirror-find", + "codemirror-fold-template", + "codemirror-folded-code", + "codemirror-goto-line", + "codemirror-goto-line-go", + "codemirror-match-case", "codemirror-next", "codemirror-previous", - "codemirror-all", - "codemirror-match-case", "codemirror-regexp", - "codemirror-by-word", "codemirror-replace", - "codemirror-replace-placeholder", "codemirror-replace-all", - "codemirror-control-character", - "codemirror-special-char-null", - "codemirror-special-char-bell", + "codemirror-replace-placeholder", "codemirror-special-char-backspace", - "codemirror-special-char-newline", - "codemirror-special-char-vertical-tab", + "codemirror-special-char-bell", "codemirror-special-char-carriage-return", "codemirror-special-char-escape", - "codemirror-special-char-nbsp", - "codemirror-special-char-zero-width-space", - "codemirror-special-char-zero-width-non-joiner", - "codemirror-special-char-zero-width-joiner", - "codemirror-special-char-left-to-right-mark", - "codemirror-special-char-right-to-left-mark", - "codemirror-special-char-line-separator", - "codemirror-special-char-left-to-right-override", - "codemirror-special-char-right-to-left-override", - "codemirror-special-char-narrow-nbsp", "codemirror-special-char-left-to-right-isolate", - "codemirror-special-char-right-to-left-isolate", - "codemirror-special-char-pop-directional-isolate", - "codemirror-special-char-paragraph-separator", - "codemirror-special-char-zero-width-no-break-space", + "codemirror-special-char-left-to-right-mark", + "codemirror-special-char-left-to-right-override", + "codemirror-special-char-line-separator", + "codemirror-special-char-narrow-nbsp", + "codemirror-special-char-nbsp", + "codemirror-special-char-newline", + "codemirror-special-char-null", "codemirror-special-char-object-replacement", - "codemirror-fold-template", - "codemirror-unfold", - "codemirror-folded-code" + "codemirror-special-char-paragraph-separator", + "codemirror-special-char-pop-directional-isolate", + "codemirror-special-char-right-to-left-isolate", + "codemirror-special-char-right-to-left-mark", + "codemirror-special-char-right-to-left-override", + "codemirror-special-char-vertical-tab", + "codemirror-special-char-zero-width-joiner", + "codemirror-special-char-zero-width-no-break-space", + "codemirror-special-char-zero-width-non-joiner", + "codemirror-special-char-zero-width-space", + "codemirror-unfold" ] }, "ext.CodeMirror.v6.init": { @@ -266,6 +284,9 @@ "packageFiles": [ "codemirror.wikieditor.js" ], + "styles": [ + "codemirror.wikieditor.less" + ], "messages": [ "codemirror-toggle-label", "codemirror-toggle-label-short" diff --git a/i18n/en.json b/i18n/en.json index f8331a09..66265635 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -14,15 +14,19 @@ "codemirror-prefs-colorblind": "Enable colorblind-friendly scheme for syntax highlighting when editing wikitext", "codemirror-prefs-colorblind-help": "If you use a gadget for syntax highlighting, this preference will not work.", "codemirror-find": "Find", - "codemirror-next": "next", - "codemirror-previous": "previous", - "codemirror-all": "all", - "codemirror-match-case": "match case", - "codemirror-regexp": "regexp", - "codemirror-by-word": "by word", - "codemirror-replace": "replace", + "codemirror-next": "Find next", + "codemirror-previous": "Find previous", + "codemirror-all": "All", + "codemirror-all-tooltip": "Select all matches", + "codemirror-match-case": "Match case", + "codemirror-regexp": "Regular expression", + "codemirror-by-word": "By word", + "codemirror-replace": "Replace", "codemirror-replace-placeholder": "Replace", - "codemirror-replace-all": "replace all", + "codemirror-replace-all": "Replace all", + "codemirror-done": "Done", + "codemirror-goto-line": "Go to line", + "codemirror-goto-line-go": "Go", "codemirror-control-character": "Control character $1", "codemirror-special-char-null": "Null character", "codemirror-special-char-bell": "Bell character", diff --git a/i18n/qqq.json b/i18n/qqq.json index 274f4cd3..4c06fd4c 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -6,7 +6,8 @@ "Raymond", "Shirayuki", "SkyDaisy9", - "pastakhov" + "pastakhov", + "MusikAnimal" ] }, "codemirror-desc": "{{desc|name=Code Mirror|url=https://www.mediawiki.org/wiki/Extension:CodeMirror}}\n\nAdditional info: Description of \"Syntax highlighting\" in wiki\n[[mw:Extension:SyntaxHighlight GeSHi]]", @@ -17,16 +18,20 @@ "codemirror-v6-prefs-colorblind": "Used in user preferences as label for enabling the colorblind-friendly option. This is a shorter version of {{msg-mw|codemirror-prefs-colorblind}} shown under section {{msg-mw|prefs-syntax-highlighting}} on wikis using CodeMirror 6.", "codemirror-prefs-colorblind": "Used in user preferences as label for enabling the colorblind-friendly option.", "codemirror-prefs-colorblind-help": "Used in user preferences as remark on the colorblind-friendly option.", - "codemirror-find": "Placeholder text for the input in the CodeMirror search dialog.", - "codemirror-next": "Label for the 'Next' button in the CodeMirror search dialog.", - "codemirror-previous": "Label for the 'Previous' button in the CodeMirror search dialog.", - "codemirror-all": "Label for the 'All' button in the CodeMirror search dialog.", - "codemirror-match-case": "Label for the 'match case' option in the CodeMirror search dialog.", - "codemirror-regexp": "Label for the 'regexp' button in the CodeMirror search dialog. This enables the user to search using regular expressions.", - "codemirror-by-word": "Label for the 'by word' button in the CodeMirror search dialog.", - "codemirror-replace": "Label for the 'replace' button in the CodeMirror search dialog.", - "codemirror-replace-placeholder": "Placeholder text for the 'Replace' input in the CodeMirror search dialog.", - "codemirror-replace-all": "Label for the 'replace all' button in the CodeMirror search dialog.", + "codemirror-find": "Placeholder text for the input in the CodeMirror search panel.", + "codemirror-next": "Tooltip text for the 'Find next' button in the CodeMirror search panel.", + "codemirror-previous": "Tooltip text for the 'Find previous' button in the CodeMirror search panel.", + "codemirror-all": "Label for the 'All' button in the CodeMirror search panel, which finds all the results. See also {{msg-mw|codemirror-all-tooltip}}.", + "codemirror-all-tooltip": "Tooltip shown when hovering over the 'All' button in the CodeMirror search panel.", + "codemirror-match-case": "Tooltip for the 'Match case' button in the CodeMirror search panel.", + "codemirror-regexp": "Tooltip for the 'Regular expression' button in the CodeMirror search panel.", + "codemirror-by-word": "Tooltip for the 'By word' button in the CodeMirror search panel.", + "codemirror-replace": "Label for the 'Replace' button in the CodeMirror search panel.", + "codemirror-replace-placeholder": "Placeholder text for the 'Replace' input in the CodeMirror search panel.", + "codemirror-replace-all": "Label for the 'Replace all' button in the CodeMirror search panel.", + "codemirror-done": "Label for the 'Done' button in CodeMirror panels.\n{{Identical|Done}}", + "codemirror-goto-line": "Label for the 'Go to line' input field.", + "codemirror-goto-line-go": "Label for the 'Go to line' submit button.\n{{Identical|Go}}", "codemirror-control-character": "Tooltip text shown when hovering over special characters. $1 is the Unicode value of the special character.", "codemirror-special-char-null": "Tooltip text shown when hovering over a null character. See [[wikidata:Q617945]] for possible translations.", "codemirror-special-char-bell": "Tooltip text shown when hovering over a bell character. See [[wikidata:Q815674]] for possible translations.", diff --git a/jsdoc.json b/jsdoc.json index da4cb81d..10e5d843 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -35,8 +35,12 @@ "Extension": "https://codemirror.net/docs/ref/#state.Extension", "KeyBinding": "https://codemirror.net/docs/ref/#view.KeyBinding", "LanguageSupport": "https://codemirror.net/docs/ref/#language.LanguageSupport", + "Panel": "https://codemirror.net/docs/ref/#view.Panel", "PluginSpec": "https://codemirror.net/docs/ref/#view.PluginSpec", "RangeSet": "https://codemirror.net/docs/ref/#state.RangeSet", + "SearchQuery": "https://codemirror.net/docs/ref/#search.SearchQuery", + "StateEffectType": "https://codemirror.net/docs/ref/#state.StateEffectType", + "StateField": "https://codemirror.net/docs/ref/#state.StateField", "StreamParser": "https://codemirror.net/docs/ref/#language.StreamParser", "StringStream": "https://codemirror.net/docs/ref/#language.StringStream", "SyntaxNode": "https://lezer.codemirror.net/docs/ref/#common.SyntaxNode", diff --git a/package-lock.json b/package-lock.json index ba2af060..074f7682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "devDependencies": { "@codemirror/commands": "6.2.5", "@codemirror/language": "6.9.3", - "@codemirror/search": "6.5.4", + "@codemirror/search": "6.5.6", "@codemirror/state": "6.2.1", "@codemirror/view": "6.22.2", "@lezer/highlight": "1.2.0", @@ -524,9 +524,10 @@ } }, "node_modules/@codemirror/search": { - "version": "6.5.4", + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", + "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", "dev": true, - "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", diff --git a/package.json b/package.json index 8f52cdc1..5479026e 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "devDependencies": { "@codemirror/commands": "6.2.5", "@codemirror/language": "6.9.3", - "@codemirror/search": "6.5.4", + "@codemirror/search": "6.5.6", "@codemirror/state": "6.2.1", "@codemirror/view": "6.22.2", "@lezer/highlight": "1.2.0", diff --git a/resources/.eslintrc.json b/resources/.eslintrc.json index 281480d9..81dbf253 100644 --- a/resources/.eslintrc.json +++ b/resources/.eslintrc.json @@ -17,7 +17,6 @@ "ve": "readonly" }, "rules": { - "max-len": "off", "es-x/no-array-prototype-includes": "off" }, "overrides": [ diff --git a/resources/codemirror.gotoLine.js b/resources/codemirror.gotoLine.js new file mode 100644 index 00000000..1e13db7f --- /dev/null +++ b/resources/codemirror.gotoLine.js @@ -0,0 +1,173 @@ +const { + EditorSelection, + EditorView, + Prec, + StateEffect, + StateEffectType, + StateField, + keymap, + showPanel +} = require( 'ext.CodeMirror.v6.lib' ); +const CodeMirrorPanel = require( './codemirror.panel.js' ); + +/** + * Custom goto line panel for CodeMirror using CSS-only Codex components. + * + * Using the Alt-g keybinding, this shows a panel asking the user for a line number, + * when a valid position is provided, moves the cursor to that line. + * + * This feature supports line numbers, relative line offsets prefixed with `+` or `-`, + * document percentages suffixed with `%`, and an optional column position by adding `:` + * and a second number after the line number. + * + * Based on the CodeMirror implementation (MIT). + * + * @see https://github.com/codemirror/search/blob/0d8af3e4cc/src/goto-line.ts + * @extends CodeMirrorPanel + */ +class CodeMirrorGotoLine extends CodeMirrorPanel { + constructor() { + super(); + + /** + * @type {StateEffectType} + */ + this.toggleEffect = StateEffect.define(); + + /** + * @type {StateField} + */ + this.panelStateField = StateField.define( { + create: () => true, + update: ( value, transaction ) => { + for ( const e of transaction.effects ) { + if ( e.is( this.toggleEffect ) ) { + value = e.value; + } + } + + return value; + }, + // eslint-disable-next-line arrow-body-style + provide: ( stateField ) => { + // eslint-disable-next-line arrow-body-style + return showPanel.from( stateField, ( on ) => { + return on ? () => this.panel : null; + } ); + } + } ); + + /** + * @type {HTMLInputElement} + */ + this.input = undefined; + } + + /** + * @inheritDoc + */ + get extension() { + // Use Prec.highest to ensure that this keymap is used before the default searchKeymap. + return Prec.highest( + keymap.of( { + key: 'Mod-Alt-g', + run: ( view ) => { + this.view = view; + const effects = [ this.toggleEffect.of( true ) ]; + if ( !this.view.state.field( this.panelStateField, false ) ) { + effects.push( StateEffect.appendConfig.of( [ this.panelStateField ] ) ); + } + this.view.dispatch( { effects } ); + return true; + } + } ) + ); + } + + /** + * @inheritDoc + */ + get panel() { + const container = document.createElement( 'div' ); + container.className = 'cm-mw-goto-line-panel cm-mw-panel cm-mw-panel--row'; + container.addEventListener( 'keydown', this.onKeydown.bind( this ) ); + + // Line input. + const [ inputWrapper, input ] = this.getTextInput( 'line', this.line ); + this.input = input; + container.appendChild( inputWrapper ); + + // Go button. + const button = this.getButton( 'codemirror-goto-line-go' ); + button.addEventListener( 'click', this.go.bind( this ) ); + container.appendChild( button ); + + return { + dom: container, + top: true, + mount: () => { + this.input.value = String( + this.view.state.doc.lineAt( + this.view.state.selection.main.head + ).number + ); + this.input.focus(); + this.input.select(); + } + }; + } + + /** + * Respond to keydown events. + * + * @param {KeyboardEvent} event + */ + onKeydown( event ) { + if ( event.key === 'Escape' ) { + event.preventDefault(); + this.view.dispatch( { effects: this.toggleEffect.of( false ) } ); + this.view.focus(); + } else if ( event.key === 'Enter' ) { + event.preventDefault(); + this.go(); + } + } + + /** + * Go to the specified line. + */ + go() { + const match = /^([+-])?(\d+)?(:\d+)?(%)?$/.exec( this.input.value ); + if ( !match ) { + return; + } + const { state } = this.view; + const startLine = state.doc.lineAt( state.selection.main.head ); + const [ , sign, ln, cl, percent ] = match; + const col = cl ? +cl.slice( 1 ) : 0; + let line = ln ? +ln : startLine.number; + if ( ln && percent ) { + let pc = line / 100; + if ( sign ) { + pc = pc * ( sign === '-' ? -1 : 1 ) + ( startLine.number / state.doc.lines ); + } + line = Math.round( state.doc.lines * pc ); + } else if ( ln && sign ) { + line = line * ( sign === '-' ? -1 : 1 ) + startLine.number; + } + const docLine = state.doc.line( Math.max( 1, Math.min( state.doc.lines, line ) ) ); + const selection = EditorSelection.cursor( + docLine.from + Math.max( 0, Math.min( col, docLine.length ) ) + ); + this.view.dispatch( { + effects: [ + this.toggleEffect.of( false ), + EditorView.scrollIntoView( selection.from, { y: 'center' } ) + ], + selection + } ); + this.view.focus(); + } +} + +module.exports = CodeMirrorGotoLine; diff --git a/resources/codemirror.js b/resources/codemirror.js index a526999f..56deb1e0 100644 --- a/resources/codemirror.js +++ b/resources/codemirror.js @@ -15,10 +15,11 @@ const { keymap, lineNumbers, rectangularSelection, - redo, - searchKeymap + redo } = require( 'ext.CodeMirror.v6.lib' ); const CodeMirrorTextSelection = require( './codemirror.textSelection.js' ); +const CodeMirrorSearch = require( './codemirror.search.js' ); +const CodeMirrorGotoLine = require( './codemirror.gotoLine.js' ); require( './ext.CodeMirror.data.js' ); /** @@ -125,6 +126,7 @@ class CodeMirror { this.updateExtension, this.bracketMatchingExtension, this.dirExtension, + this.searchExtension, EditorState.readOnly.of( this.readOnly ), EditorView.domEventHandlers( { blur: () => { @@ -135,10 +137,7 @@ class CodeMirror { } } ), EditorView.lineWrapping, - keymap.of( [ - ...defaultKeymap, - ...searchKeymap - ] ), + keymap.of( defaultKeymap ), EditorState.allowMultipleSelections.of( true ), drawSelection(), rectangularSelection(), @@ -171,6 +170,18 @@ class CodeMirror { return extensions; } + /** + * Extension for search and goto line functionality. + * + * @return {Extension|Extension[]} + */ + get searchExtension() { + return [ + new CodeMirrorSearch().extension, + new CodeMirrorGotoLine().extension + ]; + } + /** * This extension adds bracket matching to the CodeMirror editor. * @@ -293,16 +304,6 @@ class CodeMirror { */ get phrasesExtension() { return EditorState.phrases.of( { - Find: mw.msg( 'codemirror-find' ), - next: mw.msg( 'codemirror-next' ), - previous: mw.msg( 'codemirror-previous' ), - all: mw.msg( 'codemirror-all' ), - 'match case': mw.msg( 'codemirror-match-case' ), - regexp: mw.msg( 'codemirror-regexp' ), - 'by word': mw.msg( 'codemirror-by-word' ), - replace: mw.msg( 'codemirror-replace' ), - Replace: mw.msg( 'codemirror-replace-placeholder' ), - 'replace all': mw.msg( 'codemirror-replace-all' ), 'Control character': mw.msg( 'codemirror-control-character' ) } ); } diff --git a/resources/codemirror.less b/resources/codemirror.less index 9a0f8b2f..ec19d5dc 100644 --- a/resources/codemirror.less +++ b/resources/codemirror.less @@ -1,7 +1,39 @@ @import 'mediawiki.skin.variables.less'; .cm-editor { - border: 1px solid #c8ccd1; + border: @border-width-base @border-style-base @border-color-subtle; + + .cm-selectionBackground { + background: #d9d9d9; + + @media screen { + html.skin-theme-clientpref-night & { + background: #222; + } + } + + @media screen and ( prefers-color-scheme: dark ) { + html.skin-theme-clientpref-os & { + background: #222; + } + } + } + + &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground { + background: #d7d4f0; + + @media screen { + html.skin-theme-clientpref-night & { + background: #233; + } + } + + @media screen and ( prefers-color-scheme: dark ) { + html.skin-theme-clientpref-os & { + background: #233; + } + } + } } .cm-matchingBracket, @@ -11,16 +43,26 @@ font-weight: bold; } +.cm-editor .cm-specialChar { + color: @color-destructive--hover; +} + .cm-special-char-nbsp { - color: #888; + color: @color-placeholder; } .cm-tooltip-fold { - cursor: pointer; + cursor: @cursor-base--hover; line-height: 1.2; padding: 0 1px; } +.cm-editor .cm-foldPlaceholder { + background-color: @background-color-interactive; + border-color: @border-color-subtle; + color: @color-base; +} + .cm-bidi-isolate { /* @noflip */ direction: ltr; @@ -31,56 +73,92 @@ height: 100%; } -// Overrides for WikiEditor. - -.wikiEditor-ui-text .cm-editor { - border: inherit; +// The various .cm-editor prefixed styles are required to have higher +// specificity than CodeMirror's default styles, which are set by JS. +.cm-editor .cm-gutters { + background-color: @background-color-interactive-subtle; + border-right-color: @border-color-subtle; + color: @color-subtle; } -.cm-mw-toggle-wikieditor { - .oo-ui-icon-syntax-highlight { - background-color: @color-base; - // The SVG is just barely over 300 bytes, and is also only temporary - // until an official icon has been established in Codex/OOUI (T174145). - /* @embed */ - @url: url( codemirror.icon.svg ); - -webkit-mask-image: @url; - mask-image: @url; - -webkit-mask-size: @size-icon-medium; - mask-size: @size-icon-medium; - -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; - -webkit-mask-position: center; - mask-position: center; - } +.cm-editor .cm-cursor { + border-left-color: @color-emphasized; +} - &:hover { - background-color: @background-color-interactive; - } +.cm-editor .cm-tooltip { + background-color: @background-color-neutral-subtle; + border-color: @border-color-base; +} - &.oo-ui-toggleWidget-on { - .oo-ui-labelElement-label { - color: @color-progressive; - } +.cm-editor .cm-panels { + background-color: @background-color-neutral-subtle; + border-bottom: 0; + color: @color-base; + z-index: @z-index-above-content; - .oo-ui-icon-syntax-highlight { - background-color: @color-progressive; + .cdx-button-group { + .cdx-button, + .cdx-toggle-button { + min-width: @min-width-toggle-switch; } } - &.oo-ui-buttonElement-frameless.oo-ui-labelElement.oo-ui-iconElement:first-child { - margin-left: 0; + .cm-mw-panel { + border-bottom: @border-style-base @border-width-base @border-color-subtle; + padding: @spacing-50; + } + + .cm-mw-panel--text-input { + flex-basis: 0; + flex-grow: 1; + } + + .cm-mw-panel--row { + align-items: center; + column-gap: @spacing-50; + display: flex; + + &:not( :last-child ) { + margin-bottom: @spacing-50; + } + } + + .cm-mw-panel--button { + margin-bottom: 0; + } + + .cm-mw-panel--toggle-button.cdx-toggle-button--toggled-on { + &:enabled:active { + background-color: @color-progressive--active; + } + + .cdx-icon { + background-color: @color-inverted; + } } } -// Hide all buttons except CodeMirror on read only pages (T301615) -// This is the same hack that CodeEditor uses to customize the toolbar. -// WikiEditor should be updated to better handle read only pages (T188817). -.ext-codemirror-readonly { - .wikiEditor-section-secondary, - .group:not( .group-codemirror ), - .tabs, - .sections { - display: none; - } +.cm-mw-icon--match-case { + background-color: @color-base; + .cdx-mixin-css-icon( @cdx-icon-search-case-sensitive, @color-base, @size-icon-medium, true ); +} + +.cm-mw-icon--regexp { + background-color: @color-base; + .cdx-mixin-css-icon( @cdx-icon-search-regular-expression, @color-base, @size-icon-medium, true ); +} + +.cm-mw-icon--quotes { + background-color: @color-base; + .cdx-mixin-css-icon( @cdx-icon-quotes, @color-base, @size-icon-medium, true ); +} + +.cm-mw-icon--previous { + background-color: @color-base; + .cdx-mixin-css-icon( @cdx-icon-previous, @color-base, @size-icon-medium, true ); +} + +.cm-mw-icon--next { + background-color: @color-base; + .cdx-mixin-css-icon( @cdx-icon-next, @color-base, @size-icon-medium, true ); } diff --git a/resources/codemirror.mediawiki.less b/resources/codemirror.mediawiki.less index 555d33ff..78b6325c 100644 --- a/resources/codemirror.mediawiki.less +++ b/resources/codemirror.mediawiki.less @@ -1,16 +1,20 @@ -@comment-color: #72777d; -@error-color: #d73333; -@link-color: #000aaa; -@parser-function-color: #d73333; -@table-color: #d08; -@template-color: #80c; -@template-variable-color: #ac6600; -@wikitext-formatting-color: #0076dd; -@xml-tag-color: #14866d; +@import 'mediawiki.skin.variables.less'; +@error-color: @color-destructive; +@link-color: @color-progressive; +@parser-function-color: @color-destructive; +@table-color: #d08; +@table-color-dark: #ff5edd; +@template-color: #80c; +@template-color-dark: #af84e6; +@template-variable-color: #ac6600; +@wikitext-formatting-color: @color-progressive--focus; +@wikitext-formatting-color-dark: @color-progressive--hover; +@xml-tag-color: @color-content-added; @template-background-color: #a11; -@ext-background-color: #70a; +@ext-background-color: #eee; @link-background-color: #219; +@skip-formatting-color: #adf; .ground( @template: 0, @ext: 0, @link: 0 ) { @template-shade: fade( @template-background-color, 4% * @template ); @@ -19,44 +23,76 @@ background-color: average( average( @template-shade, @ext-shade ), @link-shade ); } -/* stylelint-disable declaration-block-single-line-max-declarations */ -/* stylelint-disable @stylistic/block-closing-brace-space-after */ -/* stylelint-disable @stylistic/block-opening-brace-newline-after */ -/* stylelint-disable @stylistic/block-opening-brace-newline-before */ -/* stylelint-disable @stylistic/declaration-block-semicolon-newline-after */ -/* stylelint-disable @stylistic/selector-list-comma-newline-after */ +.darkmode( @prop, @value ) { + @media screen { + html.skin-theme-clientpref-night & { + @{prop}: @value; + } + } -// See T365311 -.CodeMirror { color: inherit; } + @media screen and ( prefers-color-scheme: dark ) { + html.skin-theme-clientpref-os & { + @{prop}: @value; + } + } +} -.cm-mw-pagename { text-decoration: underline; } +.wikitext-formatting-color { + color: @wikitext-formatting-color; + .darkmode( color, @wikitext-formatting-color-dark ); +} -// TODO: It appears like this was never used. Remove? -.cm-mw-matching { background-color: #ffd700; } +.cm-mw-pagename { + text-decoration: underline; +} -.cm-mw-skipformatting { background-color: #adf; } +.cm-mw-skipformatting { + background-color: @skip-formatting-color; +} .cm-mw-list, -.cm-mw-indenting { color: @wikitext-formatting-color; font-weight: bold; } +.cm-mw-indenting { + .wikitext-formatting-color(); + font-weight: bold; +} // FIXME: Remove camelCase variant after CM6 upgrade is complete (also check Global Search) .cm-mw-doubleUnderscore, .cm-mw-double-underscore, -.cm-mw-signature, .cm-mw-hr { color: @wikitext-formatting-color; font-weight: bold; background-color: #eee; } +.cm-mw-signature, +.cm-mw-hr { + .wikitext-formatting-color(); + font-weight: bold; + background-color: @background-color-disabled-subtle; +} + // TODO: Deprecate .cm-mw-mnemonic in favor of -html-entity -.cm-mw-mnemonic, .cm-mw-html-entity { color: @xml-tag-color; } -.cm-mw-comment { color: @comment-color; font-weight: normal; } -.cm-mw-apostrophes-bold, .cm-mw-apostrophes-italic { color: @wikitext-formatting-color; } -.cm-mw-strong { font-weight: bold; } +.cm-mw-mnemonic, +.cm-mw-html-entity { + color: @xml-tag-color; +} +.cm-mw-comment { + color: @color-subtle; + font-weight: normal; +} +.cm-mw-apostrophes-bold, +.cm-mw-apostrophes-italic { + .wikitext-formatting-color(); +} +.cm-mw-strong { + font-weight: bold; +} // FIXME: Remove .CodeMirror-line rules after CM6 upgrade pre.CodeMirror-line.cm-mw-section-1, pre.CodeMirror-line-like.cm-mw-section-1, -.cm-mw-section-1, .cm-mw-section-1 ~ * { +.cm-mw-section-1, +.cm-mw-section-1 ~ * { font-size: 1.8em; line-height: 1.2em; } pre.CodeMirror-line.cm-mw-section-2, pre.CodeMirror-line-like.cm-mw-section-2, -.cm-mw-section-2, .cm-mw-section-2 ~ * { +.cm-mw-section-2, +.cm-mw-section-2 ~ * { font-size: 1.5em; line-height: 1.2em; } @@ -76,48 +112,92 @@ span.cm-mw-section-6 ~ * { font-weight: bold; } -.cm-mw-template { color: @template-color; font-weight: normal; } +.cm-mw-template { + color: @template-color; + font-weight: normal; + + html.skin-theme-clientpref-night & { + color: @template-color-dark; + } + + @media ( prefers-color-scheme: dark ) { + html.skin-theme-clientpref-os & { + color: @template-color-dark; + } + } +} + // TODO: deprecate/remove after CM6 upgrade -.cm-mw-template-name-mnemonic { font-weight: normal; } +.cm-mw-template-name-mnemonic { + font-weight: normal; +} + .cm-mw-template-name, .cm-mw-template-argument-name, .cm-mw-template-delimiter, -.cm-mw-template-bracket { color: @template-color; font-weight: bold; } +.cm-mw-template-bracket { + color: @template-color; + font-weight: bold; + .darkmode( color, @template-color-dark ); +} .cm-mw-templatevariable, -.cm-mw-templatevariable-bracket { color: @template-variable-color; font-weight: normal; } +.cm-mw-templatevariable-bracket { + color: @template-variable-color; + font-weight: normal; +} .cm-mw-templatevariable-name, -.cm-mw-templatevariable-delimiter { color: @template-variable-color; font-weight: bold; } +.cm-mw-templatevariable-delimiter { + color: @template-variable-color; + font-weight: bold; +} -.cm-mw-parserfunction { font-weight: normal; } +.cm-mw-parserfunction { + font-weight: normal; +} .cm-mw-parserfunction-name, .cm-mw-parserfunction-bracket, -.cm-mw-parserfunction-delimiter { color: @parser-function-color; font-weight: bold; } +.cm-mw-parserfunction-delimiter { + color: @parser-function-color; + font-weight: bold; +} pre.CodeMirror-line.cm-mw-exttag, pre.CodeMirror-line-like.cm-mw-exttag { .ground( @ext: 0.5 ); } -.cm-mw-exttag { .ground( @ext: 1 ); } +.cm-mw-exttag { + .ground( @ext: 1 ); +} .cm-mw-exttag-name, -.cm-mw-htmltag-name { color: @xml-tag-color; font-weight: bold; } +.cm-mw-htmltag-name { + color: @xml-tag-color; + font-weight: bold; +} .cm-mw-exttag-bracket, .cm-mw-exttag-attribute, .cm-mw-htmltag-bracket, -.cm-mw-htmltag-attribute { color: @xml-tag-color; font-weight: normal; } +.cm-mw-htmltag-attribute { + color: @xml-tag-color; + font-weight: normal; +} +.cm-mw-tag-pre, +.cm-mw-tag-nowiki, pre.CodeMirror-line.cm-mw-tag-pre, pre.CodeMirror-line-like.cm-mw-tag-pre, -.cm-mw-tag-pre, pre.CodeMirror-line.cm-mw-tag-nowiki, -pre.CodeMirror-line-like.cm-mw-tag-nowiki, -.cm-mw-tag-nowiki { +pre.CodeMirror-line-like.cm-mw-tag-nowiki { background-color: rgba( 0, 0, 0, 0.04 ); + .darkmode( background-color, rgba( 255, 255, 255, 0.06 ) ); } .cm-mw-link, .cm-mw-link-tosection, -.cm-mw-section-header { color: @wikitext-formatting-color; font-weight: normal; } +.cm-mw-section-header { + .wikitext-formatting-color(); + font-weight: normal; +} .cm-mw-link-pagename, .cm-mw-link-bracket, .cm-mw-link-delimiter, @@ -130,52 +210,109 @@ pre.CodeMirror-line-like.cm-mw-tag-nowiki, } .cm-mw-extlink-protocol, .cm-mw-free-extlink-protocol, -.cm-mw-extlink-bracket { color: @link-color; font-weight: bold; } - -.cm-mw-table-bracket, -.cm-mw-table-delimiter { color: @table-color; font-weight: bold; } -.cm-mw-table-definition { color: @table-color; font-weight: normal; } -.cm-mw-table-caption { font-weight: bold; } - -.cm-mw-template2-ground { .ground( @template: 1 ); } -.cm-mw-template3-ground { .ground( @template: 2 ); } -.cm-mw-ext-ground, -.cm-mw-template-ext-ground { .ground( @ext: 1 ); } -.cm-mw-ext2-ground, -.cm-mw-template-ext2-ground { .ground( @ext: 2 ); } -.cm-mw-ext3-ground, -.cm-mw-template-ext3-ground { .ground( @ext: 3 ); } -.cm-mw-link-ground, -.cm-mw-ext-link-ground, -.cm-mw-template-link-ground { .ground( @link: 1 ); } -.cm-mw-ext2-link-ground, -.cm-mw-template-ext-link-ground { .ground( @ext: 1, @link: 1 ); } -.cm-mw-ext3-link-ground, -.cm-mw-template-ext2-link-ground { .ground( @ext: 2, @link: 1 ); } -.cm-mw-template-ext3-link-ground { .ground( @ext: 3, @link: 1 ); } - -.cm-mw-template2-ext-ground { .ground( @template: 1, @ext: 1 ); } -.cm-mw-template2-ext2-ground { .ground( @template: 1, @ext: 2 ); } -.cm-mw-template2-ext3-ground { .ground( @template: 1, @ext: 3 ); } -.cm-mw-template2-link-ground { .ground( @template: 1, @link: 1 ); } -.cm-mw-template2-ext-link-ground { .ground( @template: 1, @ext: 1, @link: 1 ); } -.cm-mw-template2-ext2-link-ground { .ground( @template: 1, @ext: 2, @link: 1 ); } -.cm-mw-template2-ext3-link-ground { .ground( @template: 1, @ext: 3, @link: 1 ); } - -.cm-mw-template3-ext-ground { .ground( @template: 2, @ext: 1 ); } -.cm-mw-template3-ext2-ground { .ground( @template: 2, @ext: 2 ); } -.cm-mw-template3-ext3-ground { .ground( @template: 2, @ext: 3 ); } -.cm-mw-template3-link-ground { .ground( @template: 2, @link: 1 ); } -.cm-mw-template3-ext-link-ground { .ground( @template: 2, @ext: 1, @link: 1 ); } -.cm-mw-template3-ext2-link-ground { .ground( @template: 2, @ext: 2, @link: 1 ); } -.cm-mw-template3-ext3-link-ground { .ground( @template: 2, @ext: 3, @link: 1 ); } - -.cm-mw-error { color: @error-color; } - -.cm-mw-em { font-style: italic; } - -.cm-mw-matchingbracket { - background-color: #eee; - box-shadow: inset 0 0 1px 1px #999; +.cm-mw-extlink-bracket { + color: @link-color; font-weight: bold; } + +.cm-mw-table-bracket, +.cm-mw-table-delimiter { + color: @table-color; + font-weight: bold; + .darkmode( color, @table-color-dark ); +} +.cm-mw-table-definition { + color: @table-color; + font-weight: normal; + .darkmode( color, @table-color-dark ); +} +.cm-mw-table-caption { + font-weight: bold; +} + +.cm-mw-template2-ground { + .ground( @template: 1 ); +} +.cm-mw-template3-ground { + .ground( @template: 2 ); +} +.cm-mw-ext-ground, +.cm-mw-template-ext-ground { + .ground( @ext: 1 ); +} +.cm-mw-ext2-ground, +.cm-mw-template-ext2-ground { + .ground( @ext: 2 ); +} +.cm-mw-ext3-ground, +.cm-mw-template-ext3-ground { + .ground( @ext: 3 ); +} +.cm-mw-link-ground, +.cm-mw-ext-link-ground, +.cm-mw-template-link-ground { + .ground( @link: 1 ); +} +.cm-mw-ext2-link-ground, +.cm-mw-template-ext-link-ground { + .ground( @ext: 1, @link: 1 ); +} +.cm-mw-ext3-link-ground, +.cm-mw-template-ext2-link-ground { + .ground( @ext: 2, @link: 1 ); +} +.cm-mw-template-ext3-link-ground { + .ground( @ext: 3, @link: 1 ); +} + +.cm-mw-template2-ext-ground { + .ground( @template: 1, @ext: 1 ); +} +.cm-mw-template2-ext2-ground { + .ground( @template: 1, @ext: 2 ); +} +.cm-mw-template2-ext3-ground { + .ground( @template: 1, @ext: 3 ); +} +.cm-mw-template2-link-ground { + .ground( @template: 1, @link: 1 ); +} +.cm-mw-template2-ext-link-ground { + .ground( @template: 1, @ext: 1, @link: 1 ); +} +.cm-mw-template2-ext2-link-ground { + .ground( @template: 1, @ext: 2, @link: 1 ); +} +.cm-mw-template2-ext3-link-ground { + .ground( @template: 1, @ext: 3, @link: 1 ); +} + +.cm-mw-template3-ext-ground { + .ground( @template: 2, @ext: 1 ); +} +.cm-mw-template3-ext2-ground { + .ground( @template: 2, @ext: 2 ); +} +.cm-mw-template3-ext3-ground { + .ground( @template: 2, @ext: 3 ); +} +.cm-mw-template3-link-ground { + .ground( @template: 2, @link: 1 ); +} +.cm-mw-template3-ext-link-ground { + .ground( @template: 2, @ext: 1, @link: 1 ); +} +.cm-mw-template3-ext2-link-ground { + .ground( @template: 2, @ext: 2, @link: 1 ); +} +.cm-mw-template3-ext3-link-ground { + .ground( @template: 2, @ext: 3, @link: 1 ); +} + +.cm-mw-error { + color: @error-color; +} + +.cm-mw-em { + font-style: italic; +} diff --git a/resources/codemirror.panel.js b/resources/codemirror.panel.js new file mode 100644 index 00000000..939f60df --- /dev/null +++ b/resources/codemirror.panel.js @@ -0,0 +1,205 @@ +const { EditorView, Extension, Panel } = require( 'ext.CodeMirror.v6.lib' ); + +/** + * Abstract class for a panel that can be used with CodeMirror. + * This class provides methods to create CSS-only Codex components. + * + * @see https://codemirror.net/docs/ref/#h_panels + * @abstract + */ +class CodeMirrorPanel { + /** + * @constructor + */ + constructor() { + /** + * @type {EditorView} + */ + this.view = undefined; + } + + /** + * Get the panel and any associated keymaps as a CodeMirror Extension. + * + * @abstract + * @type {Extension} + */ + // eslint-disable-next-line getter-return + get extension() {} + + /** + * Get the Panel object. + * + * @abstract + * @type {Panel} + */ + // eslint-disable-next-line getter-return + get panel() {} + + /** + * Get a CSS-only Codex TextInput. + * + * @param {string} name + * @param {string} [value=''] + * @param {string} placeholder + * @return {Array} [HTMLDivElement, HTMLInputElement] + * @internal + */ + getTextInput( name, value = '', placeholder = '' ) { + const wrapper = document.createElement( 'div' ); + wrapper.className = 'cdx-text-input cm-mw-panel--text-input'; + const input = document.createElement( 'input' ); + input.className = 'cdx-text-input__input'; + input.type = 'text'; + input.name = name; + // The following messages may be used here: + // * codemirror-find + // * codemirror-replace-placeholder + input.placeholder = placeholder ? mw.msg( placeholder ) : ''; + input.value = value; + wrapper.appendChild( input ); + return [ wrapper, input ]; + } + + /** + * Get a CSS-only Codex Button. + * + * @param {string} label + * @param {string|null} [icon=null] + * @param {boolean} [iconOnly=false] + * @return {HTMLButtonElement} + * @internal + */ + getButton( label, icon = null, iconOnly = false ) { + const button = document.createElement( 'button' ); + button.className = 'cdx-button cm-mw-panel--button'; + button.type = 'button'; + + if ( icon ) { + const iconSpan = document.createElement( 'span' ); + // The following CSS classes may be used here: + // * cm-mw-icon--previous + // * cm-mw-icon--next + // * cm-mw-icon--all + // * cm-mw-icon--replace + // * cm-mw-icon--replace-all + // * cm-mw-icon--done + // * cm-mw-icon--goto-line-go + iconSpan.className = 'cdx-button__icon cm-mw-icon--' + icon; + + if ( !iconOnly ) { + iconSpan.setAttribute( 'aria-hidden', 'true' ); + } + + button.appendChild( iconSpan ); + } + + // The following messages may be used here: + // * codemirror-next + // * codemirror-previous + // * codemirror-all + // * codemirror-replace + // * codemirror-replace-all + const message = mw.msg( label ); + if ( iconOnly ) { + button.classList.add( 'cdx-button--icon-only' ); + button.title = message; + button.setAttribute( 'aria-label', message ); + } else { + button.append( message ); + } + + return button; + } + + /** + * Get a CSS-only Codex Checkbox. + * + * @param {string} name + * @param {string} label + * @param {boolean} [checked=false] + * @return {Array} [HTMLSpanElement, HTMLInputElement] + * @internal + */ + getCheckbox( name, label, checked = false ) { + const wrapper = document.createElement( 'span' ); + wrapper.className = 'cdx-checkbox cdx-checkbox--inline cm-mw-panel--checkbox'; + const input = document.createElement( 'input' ); + input.className = 'cdx-checkbox__input'; + input.id = `cm-mw-panel--checkbox-${ name }`; + input.type = 'checkbox'; + input.name = name; + input.checked = checked; + wrapper.appendChild( input ); + const emptyIcon = document.createElement( 'span' ); + emptyIcon.className = 'cdx-checkbox__icon'; + wrapper.appendChild( emptyIcon ); + const labelWrapper = document.createElement( 'div' ); + labelWrapper.className = 'cdx-checkbox__label cdx-label'; + const labelElement = document.createElement( 'label' ); + labelElement.className = 'cdx-label__label'; + labelElement.htmlFor = input.id; + const innerSpan = document.createElement( 'span' ); + innerSpan.className = 'cdx-label__label__text'; + // The following messages may be used here: + // * codemirror-match-case + // * codemirror-regexp + // * codemirror-by-word + innerSpan.textContent = mw.msg( label ); + labelElement.appendChild( innerSpan ); + labelWrapper.appendChild( labelElement ); + wrapper.appendChild( labelWrapper ); + return [ wrapper, input ]; + } + + /** + * Get a CSS-only Codex ToggleButton. + * + * @param {string} name + * @param {string} label + * @param {string} icon + * @param {boolean} [checked=false] + * @return {HTMLButtonElement} + * @internal + */ + getToggleButton( name, label, icon, checked = false ) { + const btn = document.createElement( 'button' ); + // The following CSS classes may be used here: + // * cdx-toggle-button--toggled-on + // * cdx-toggle-button--toggled-off + btn.className = 'cdx-toggle-button cdx-toggle-button--framed ' + + `cdx-toggle-button--toggled-${ checked ? 'on' : 'off' } cm-mw-panel--toggle-button`; + btn.dataset.checked = String( checked ); + btn.setAttribute( 'aria-pressed', checked ); + // The following messages may be used here: + // * codemirror-match-case + // * codemirror-regexp + // * codemirror-by-word + const message = mw.msg( label ); + btn.title = message; + btn.setAttribute( 'aria-label', message ); + + // Add the icon. + const iconWrapper = document.createElement( 'span' ); + // The following CSS classes may be used here: + // * cm-mw-icon--match-case + // * cm-mw-icon--regexp + // * cm-mw-icon--quotes + iconWrapper.className = 'cdx-icon cdx-icon--medium cm-mw-icon--' + icon; + btn.appendChild( iconWrapper ); + + // Add the click handler. + btn.addEventListener( 'click', ( e ) => { + e.preventDefault(); + const toggled = btn.dataset.checked === 'true'; + btn.dataset.checked = String( !toggled ); + btn.setAttribute( 'aria-pressed', String( !toggled ) ); + btn.classList.toggle( 'cdx-toggle-button--toggled-on', !toggled ); + btn.classList.toggle( 'cdx-toggle-button--toggled-off', toggled ); + } ); + + return btn; + } +} + +module.exports = CodeMirrorPanel; diff --git a/resources/codemirror.search.js b/resources/codemirror.search.js new file mode 100644 index 00000000..5f90c1b3 --- /dev/null +++ b/resources/codemirror.search.js @@ -0,0 +1,307 @@ +const { + EditorView, + SearchQuery, + closeSearchPanel, + findNext, + findPrevious, + keymap, + replaceAll, + replaceNext, + runScopeHandlers, + search, + searchKeymap, + selectMatches, + setSearchQuery +} = require( 'ext.CodeMirror.v6.lib' ); +const CodeMirrorPanel = require( './codemirror.panel.js' ); + +/** + * Custom search panel for CodeMirror using CSS-only Codex components. + * + * @extends CodeMirrorPanel + */ +class CodeMirrorSearch extends CodeMirrorPanel { + constructor() { + super(); + + /** + * @type {SearchQuery} + */ + this.searchQuery = { + search: '' + }; + /** + * @type {HTMLInputElement} + */ + this.searchInput = undefined; + /** + * @type {HTMLInputElement} + */ + this.replaceInput = undefined; + /** + * @type {HTMLButtonElement} + */ + this.matchCaseButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.regexpButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.wholeWordButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.nextButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.prevButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.allButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.replaceButton = undefined; + /** + * @type {HTMLButtonElement} + */ + this.replaceAllButton = undefined; + } + + /** + * @inheritDoc + */ + get extension() { + return [ + search( { + createPanel: ( view ) => { + this.view = view; + return this.panel; + } + } ), + keymap.of( searchKeymap ) + ]; + } + + /** + * @inheritDoc + */ + get panel() { + const container = document.createElement( 'div' ); + container.className = 'cm-mw-panel cm-mw-panel--search-panel'; + container.addEventListener( 'keydown', this.onKeydown.bind( this ) ); + + const firstRow = document.createElement( 'div' ); + firstRow.className = 'cm-mw-panel--row'; + container.appendChild( firstRow ); + + // Search input. + const [ searchInputWrapper, searchInput ] = this.getTextInput( + 'search', + this.searchQuery.search || '', + 'codemirror-find' + ); + this.searchInput = searchInput; + this.searchInput.setAttribute( 'main-field', 'true' ); + firstRow.appendChild( searchInputWrapper ); + + this.appendPrevAndNextButtons( firstRow ); + + // "All" button. + this.allButton = this.getButton( 'codemirror-all' ); + this.allButton.title = mw.msg( 'codemirror-all-tooltip' ); + this.allButton.addEventListener( 'click', ( e ) => { + e.preventDefault(); + selectMatches( this.view ); + } ); + firstRow.appendChild( this.allButton ); + + this.appendSearchOptions( firstRow ); + this.appendSecondRow( container ); + + return { + dom: container, + top: true, + mount: () => { + this.searchInput.focus(); + this.searchInput.select(); + } + }; + } + + /** + * @param {HTMLDivElement} firstRow + * @private + */ + appendPrevAndNextButtons( firstRow ) { + const buttonGroup = document.createElement( 'div' ); + buttonGroup.className = 'cdx-button-group'; + + // "Previous" button. + this.prevButton = this.getButton( 'codemirror-previous', 'previous', true ); + buttonGroup.appendChild( this.prevButton ); + this.prevButton.addEventListener( 'click', ( e ) => { + e.preventDefault(); + findPrevious( this.view ); + } ); + + // "Next" button. + this.nextButton = this.getButton( 'codemirror-next', 'next', true ); + buttonGroup.appendChild( this.nextButton ); + this.nextButton.addEventListener( 'click', ( e ) => { + e.preventDefault(); + findNext( this.view ); + } ); + + firstRow.appendChild( buttonGroup ); + } + + /** + * @param {HTMLDivElement} firstRow + * @private + */ + appendSearchOptions( firstRow ) { + const buttonGroup = document.createElement( 'div' ); + buttonGroup.className = 'cdx-toggle-button-group'; + + // "Match case" ToggleButton. + this.matchCaseButton = this.getToggleButton( + 'case', + 'codemirror-match-case', + 'match-case', + this.searchQuery.caseSensitive + ); + buttonGroup.appendChild( this.matchCaseButton ); + + // "Regexp" ToggleButton. + this.regexpButton = this.getToggleButton( + 're', + 'codemirror-regexp', + 'regexp', + this.searchQuery.regexp + ); + buttonGroup.appendChild( this.regexpButton ); + + // "Whole word" checkbox. + this.wholeWordButton = this.getToggleButton( + 'word', + 'codemirror-by-word', + 'quotes', + this.searchQuery.wholeWord + ); + buttonGroup.appendChild( this.wholeWordButton ); + + firstRow.appendChild( buttonGroup ); + } + + /** + * @param {HTMLDivElement} container + * @private + */ + appendSecondRow( container ) { + const shouldBeDisabled = this.view.state.readOnly; + const row = document.createElement( 'div' ); + row.className = 'cm-mw-panel--row'; + container.appendChild( row ); + + // Replace input. + const [ replaceInputWrapper, replaceInput ] = this.getTextInput( + 'replace', + this.searchQuery.replace || '', + 'codemirror-replace-placeholder' + ); + this.replaceInput = replaceInput; + this.replaceInput.disabled = shouldBeDisabled; + row.appendChild( replaceInputWrapper ); + + // "Replace" button. + this.replaceButton = this.getButton( 'codemirror-replace' ); + this.replaceButton.disabled = shouldBeDisabled; + row.appendChild( this.replaceButton ); + this.replaceButton.addEventListener( 'click', ( e ) => { + e.preventDefault(); + replaceNext( this.view ); + } ); + + // "Replace all" button. + this.replaceAllButton = this.getButton( 'codemirror-replace-all' ); + this.replaceAllButton.disabled = shouldBeDisabled; + row.appendChild( this.replaceAllButton ); + this.replaceAllButton.addEventListener( 'click', ( e ) => { + e.preventDefault(); + replaceAll( this.view ); + } ); + + // "Done" button. + const doneButton = this.getButton( 'codemirror-done' ); + row.appendChild( doneButton ); + doneButton.addEventListener( 'click', ( e ) => { + e.preventDefault(); + closeSearchPanel( this.view ); + this.view.focus(); + } ); + } + + /** + * Respond to keydown events. + * + * @param {KeyboardEvent} event + */ + onKeydown( event ) { + if ( runScopeHandlers( this.view, event, 'search-panel' ) ) { + event.preventDefault(); + } else if ( event.key === 'Enter' && event.target === this.searchInput ) { + event.preventDefault(); + ( event.shiftKey ? findPrevious : findNext )( this.view ); + } else if ( event.key === 'Enter' && event.target === this.replaceInput ) { + event.preventDefault(); + replaceNext( this.view ); + } + } + + /** + * Create a new {@link SearchQuery} and dispatch it to the {@link EditorView}. + */ + commit() { + const query = new SearchQuery( { + search: this.searchInput.value, + caseSensitive: this.matchCaseButton.dataset.checked === 'true', + regexp: this.regexpButton.dataset.checked === 'true', + wholeWord: this.wholeWordButton.dataset.checked === 'true', + replace: this.replaceInput.value, + // Makes i.e. "\n" match the literal string "\n" instead of a newline. + literal: true + } ); + if ( !this.searchQuery || !query.eq( this.searchQuery ) ) { + this.searchQuery = query; + this.view.dispatch( { + effects: setSearchQuery.of( query ) + } ); + } + } + + /** + * @inheritDoc + */ + getTextInput( name, value = '', placeholder = '' ) { + const [ container, input ] = super.getTextInput( name, value, placeholder ); + input.addEventListener( 'change', this.commit.bind( this ) ); + input.addEventListener( 'keyup', this.commit.bind( this ) ); + return [ container, input ]; + } + + /** + * @inheritDoc + */ + getToggleButton( name, label, icon, checked = false ) { + const button = super.getToggleButton( name, label, icon, checked ); + button.addEventListener( 'click', this.commit.bind( this ) ); + return button; + } +} + +module.exports = CodeMirrorSearch; diff --git a/resources/codemirror.wikieditor.less b/resources/codemirror.wikieditor.less new file mode 100644 index 00000000..c459fab5 --- /dev/null +++ b/resources/codemirror.wikieditor.less @@ -0,0 +1,55 @@ +@import 'mediawiki.skin.variables.less'; + +// Overrides for WikiEditor. + +.wikiEditor-ui-text .cm-editor { + border: inherit; +} + +.cm-mw-toggle-wikieditor { + .oo-ui-icon-syntax-highlight { + background-color: @color-base; + // The SVG is just barely over 300 bytes, and is also only temporary + // until an official icon has been established in Codex/OOUI (T174145). + /* @embed */ + @url: url( codemirror.icon.svg ); + -webkit-mask-image: @url; + mask-image: @url; + -webkit-mask-size: @size-icon-medium; + mask-size: @size-icon-medium; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + } + + &:hover { + background-color: @background-color-interactive; + } + + &.oo-ui-toggleWidget-on { + .oo-ui-labelElement-label { + color: @color-progressive; + } + + .oo-ui-icon-syntax-highlight { + background-color: @color-progressive; + } + } + + &.oo-ui-buttonElement-frameless.oo-ui-labelElement.oo-ui-iconElement:first-child { + margin-left: 0; + } +} + +// Hide all buttons except CodeMirror on read only pages (T301615) +// This is the same hack that CodeEditor uses to customize the toolbar. +// WikiEditor should be updated to better handle read only pages (T188817). +.ext-codemirror-readonly { + .wikiEditor-section-secondary, + .group:not( .group-codemirror ), + .tabs, + .sections { + display: none; + } +} diff --git a/resources/legacy/ext.CodeMirror.WikiEditor.js b/resources/legacy/ext.CodeMirror.WikiEditor.js index 6c6ee094..998cb77f 100644 --- a/resources/legacy/ext.CodeMirror.WikiEditor.js +++ b/resources/legacy/ext.CodeMirror.WikiEditor.js @@ -236,9 +236,6 @@ function init() { $codeMirror.addClass( 'cm-mw-colorblind-colors' ); } - // T365311: Apply color inversion until dark syntax styles are chosen - $codeMirror.addClass( 'notheme skin-invert' ); - // T305333: Reload CodeMirror to fix a cursor caret issue. codeMirror.refresh(); diff --git a/resources/legacy/ext.CodeMirror.less b/resources/legacy/ext.CodeMirror.less index fe1756a7..9b2c29d5 100644 --- a/resources/legacy/ext.CodeMirror.less +++ b/resources/legacy/ext.CodeMirror.less @@ -1,6 +1,13 @@ -@import 'mediawiki.mixins'; +@import 'mediawiki.skin.variables.less'; -/* TODO: Replace with ext.CodeMirror.v6.less following CM6 upgrade */ +@matching-bracket-border-color: #eee; +@matching-bracket-box-shadow-color: #999; + +// CM5 dark mode fixes, see T365311 +.mw-body-content .CodeMirror { + background-color: inherit; + color: inherit; +} .wikiEditor-ui .CodeMirror { line-height: 1.5em; @@ -13,3 +20,32 @@ padding: 0; } } + +.CodeMirror-gutters { + background-color: @background-color-interactive-subtle; + border-right-color: @border-color-subtle; +} + +.CodeMirror-line::selection, +.CodeMirror-line > span::selection, +.CodeMirror-line > span > span::selection { + background: #d9d9d9; + + @media screen { + html.skin-theme-clientpref-night & { + background: #233; + } + } + + @media screen and ( prefers-color-scheme: dark ) { + html.skin-theme-clientpref-os { + background: #233; + } + } +} + +.cm-mw-matchingbracket { + background-color: @matching-bracket-border-color; + box-shadow: inset 0 0 1px 1px @matching-bracket-box-shadow-color; + font-weight: bold; +} diff --git a/resources/lib/codemirror6.bundle.dist.js b/resources/lib/codemirror6.bundle.dist.js index c22877eb..6a16dcaf 100644 --- a/resources/lib/codemirror6.bundle.dist.js +++ b/resources/lib/codemirror6.bundle.dist.js @@ -21477,7 +21477,7 @@ class SearchCursor { let norm = this.normalize(str); for (let i = 0, pos = start;; i++) { let code = norm.charCodeAt(i); - let match = this.match(code, pos); + let match = this.match(code, pos, this.bufferPos + this.bufferStart); if (i == norm.length - 1) { if (match) { this.value = match; @@ -21490,13 +21490,13 @@ class SearchCursor { } } } - match(code, pos) { + match(code, pos, end) { let match = null; for (let i = 0; i < this.matches.length; i += 2) { let index = this.matches[i], keep = false; if (this.query.charCodeAt(index) == code) { if (index == this.query.length - 1) { - match = { from: this.matches[i + 1], to: pos + 1 }; + match = { from: this.matches[i + 1], to: end }; } else { this.matches[i]++; @@ -21510,7 +21510,7 @@ class SearchCursor { } if (this.query.charCodeAt(0) == code) { if (this.query.length == 1) - match = { from: pos, to: pos + 1 }; + match = { from: pos, to: end }; else this.matches.push(1, pos); } @@ -21858,12 +21858,12 @@ const matchHighlighter = /*@__PURE__*/ViewPlugin.fromClass(class { if (conf.wholeWords) { query = state.sliceDoc(range.from, range.to); // TODO: allow and include leading/trailing space? check = state.charCategorizer(range.head); - if (!(insideWordBoundaries(check, state, range.from, range.to) - && insideWord(check, state, range.from, range.to))) + if (!(insideWordBoundaries(check, state, range.from, range.to) && + insideWord(check, state, range.from, range.to))) return Decoration.none; } else { - query = state.sliceDoc(range.from, range.to).trim(); + query = state.sliceDoc(range.from, range.to); if (!query) return Decoration.none; } @@ -22126,10 +22126,10 @@ class RegExpQuery extends QueryType { this.prevMatchInRange(state, curTo, state.doc.length); } getReplacement(result) { - return this.spec.unquote(this.spec.replace.replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$" + return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$" : i == "&" ? result.match[0] : i != "0" && +i < result.match.length ? result.match[i] - : m)); + : m); } matchAll(state, limit) { let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = []; @@ -22422,7 +22422,7 @@ Default search-related key bindings. - Mod-f: [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSearchPanel) - F3, Mod-g: [`findNext`](https://codemirror.net/6/docs/ref/#search.findNext) - Shift-F3, Shift-Mod-g: [`findPrevious`](https://codemirror.net/6/docs/ref/#search.findPrevious) - - Alt-g: [`gotoLine`](https://codemirror.net/6/docs/ref/#search.gotoLine) + - Mod-Alt-g: [`gotoLine`](https://codemirror.net/6/docs/ref/#search.gotoLine) - Mod-d: [`selectNextOccurrence`](https://codemirror.net/6/docs/ref/#search.selectNextOccurrence) */ const searchKeymap = [ @@ -22431,7 +22431,7 @@ const searchKeymap = [ { key: "Mod-g", run: findNext, shift: findPrevious, scope: "editor search-panel", preventDefault: true }, { key: "Escape", run: closeSearchPanel, scope: "editor search-panel" }, { key: "Mod-Shift-l", run: selectSelectionMatches }, - { key: "Alt-g", run: gotoLine }, + { key: "Mod-Alt-g", run: gotoLine }, { key: "Mod-d", run: selectNextOccurrence, preventDefault: true }, ]; class SearchPanel { diff --git a/tests/jest/codemirror.panel.test.js b/tests/jest/codemirror.panel.test.js new file mode 100644 index 00000000..9bf4974c --- /dev/null +++ b/tests/jest/codemirror.panel.test.js @@ -0,0 +1,94 @@ +const CodeMirrorPanel = require( '../../resources/codemirror.panel.js' ); + +// CodeMirrorPanel is tagged as abstract, but being JavaScript it isn't a +// "real" abstract class, so we can instantiate it directly for testing purposes. +const cmPanel = new CodeMirrorPanel(); + +describe( 'CodeMirrorPanel', () => { + it( 'should create a Codex TextInput', () => { + const [ inputWrapper, input ] = cmPanel.getTextInput( 'foo', 'bar', 'codemirror-find' ); + expect( inputWrapper.className ).toBe( 'cdx-text-input cm-mw-panel--text-input' ); + expect( input.className ).toBe( 'cdx-text-input__input' ); + expect( input.type ).toBe( 'text' ); + expect( input.name ).toBe( 'foo' ); + // No i18n in unit tests, so we only check for the key. + expect( input.placeholder ).toBe( 'codemirror-find' ); + expect( input.value ).toBe( 'bar' ); + } ); + + it( 'should create a Codex Button with no icon', () => { + const buttonNoIcon = cmPanel.getButton( 'foo' ); + expect( buttonNoIcon.tagName ).toBe( 'BUTTON' ); + expect( buttonNoIcon.className ).toBe( 'cdx-button cm-mw-panel--button' ); + expect( buttonNoIcon.type ).toBe( 'button' ); + expect( buttonNoIcon.children.length ).toBe( 0 ); + } ); + + it( 'should create a Codex button with an icon and a label', () => { + const buttonWithIcon = cmPanel.getButton( 'foo', 'bar' ); + expect( buttonWithIcon.tagName ).toBe( 'BUTTON' ); + expect( buttonWithIcon.className ).toBe( 'cdx-button cm-mw-panel--button' ); + expect( buttonWithIcon.type ).toBe( 'button' ); + expect( buttonWithIcon.children.length ).toBe( 1 ); + const iconSpan = buttonWithIcon.children[ 0 ]; + expect( iconSpan.tagName ).toBe( 'SPAN' ); + expect( iconSpan.className ).toBe( 'cdx-button__icon cm-mw-icon--bar' ); + expect( iconSpan.getAttribute( 'aria-hidden' ) ).toBe( 'true' ); + } ); + + it( 'should create an icon-only Codex button', () => { + const buttonIconOnly = cmPanel.getButton( 'foo', 'bar', true ); + expect( buttonIconOnly.tagName ).toBe( 'BUTTON' ); + expect( buttonIconOnly.className ).toBe( + 'cdx-button cm-mw-panel--button cdx-button--icon-only' + ); + expect( buttonIconOnly.type ).toBe( 'button' ); + expect( buttonIconOnly.children.length ).toBe( 1 ); + expect( buttonIconOnly.getAttribute( 'aria-label' ) ).toBe( 'foo' ); + expect( buttonIconOnly.title ).toBe( 'foo' ); + const iconSpan = buttonIconOnly.children[ 0 ]; + expect( iconSpan.tagName ).toBe( 'SPAN' ); + expect( iconSpan.className ).toBe( 'cdx-button__icon cm-mw-icon--bar' ); + expect( iconSpan.getAttribute( 'aria-hidden' ) ).toBeNull(); + } ); + + it( 'should create a Codex Checkbox', () => { + const [ checkboxWrapper, checkbox ] = cmPanel.getCheckbox( 'foo', 'bar', true ); + expect( checkboxWrapper.className ).toBe( 'cdx-checkbox cdx-checkbox--inline cm-mw-panel--checkbox' ); + expect( checkboxWrapper.children.length ).toBe( 3 ); + const labelWrapper = checkboxWrapper.children[ 2 ]; + expect( labelWrapper.tagName ).toBe( 'DIV' ); + expect( labelWrapper.className ).toBe( 'cdx-checkbox__label cdx-label' ); + const label = labelWrapper.children[ 0 ]; + expect( label.tagName ).toBe( 'LABEL' ); + expect( label.className ).toBe( 'cdx-label__label' ); + expect( label.textContent ).toBe( 'bar' ); + expect( checkbox.className ).toBe( 'cdx-checkbox__input' ); + expect( checkbox.type ).toBe( 'checkbox' ); + expect( checkbox.name ).toBe( 'foo' ); + expect( checkbox.checked ).toBe( true ); + } ); + + it( 'should create a Codex ToggleButton', () => { + const toggleButtonOn = cmPanel.getToggleButton( 'foo', 'bar', 'baz', true ); + expect( toggleButtonOn.tagName ).toBe( 'BUTTON' ); + expect( toggleButtonOn.className ).toBe( + 'cdx-toggle-button cdx-toggle-button--framed cdx-toggle-button--toggled-on cm-mw-panel--toggle-button' + ); + expect( toggleButtonOn.dataset.checked ).toBe( 'true' ); + expect( toggleButtonOn.getAttribute( 'aria-pressed' ) ).toBe( 'true' ); + expect( toggleButtonOn.title ).toBe( 'bar' ); + expect( toggleButtonOn.getAttribute( 'aria-label' ) ).toBe( 'bar' ); + expect( toggleButtonOn.children.length ).toBe( 1 ); + const iconSpan = toggleButtonOn.children[ 0 ]; + expect( iconSpan.tagName ).toBe( 'SPAN' ); + expect( iconSpan.className ).toBe( 'cdx-icon cdx-icon--medium cm-mw-icon--baz' ); + + const toggleButtonOff = cmPanel.getToggleButton( 'foo', 'bar', 'baz', false ); + expect( toggleButtonOff.className ).toBe( + 'cdx-toggle-button cdx-toggle-button--framed cdx-toggle-button--toggled-off cm-mw-panel--toggle-button' + ); + expect( toggleButtonOff.dataset.checked ).toBe( 'false' ); + expect( toggleButtonOff.getAttribute( 'aria-pressed' ) ).toBe( 'false' ); + } ); +} ); diff --git a/tests/jest/setup.js b/tests/jest/setup.js index 795a1720..e7f1acb1 100644 --- a/tests/jest/setup.js +++ b/tests/jest/setup.js @@ -19,3 +19,4 @@ mw.track = jest.fn(); mw.Api.prototype.saveOption = jest.fn(); global.$ = require( 'jquery' ); $.fn.textSelection = () => {}; +window.matchMedia = jest.fn().mockReturnValue( { matches: false } );