Use `update.state.sliceDoc()` rather than `update.state.doc.toJSON()` `update.state.sliceDoc()` seems to work with EditorState.lineSeparator. Which is better then using `update.state.doc.toJSON()` and hacking it to apply our set line editor. In a future change we should add a button to be able to switch between \r\n and \n and vice versa. Also fix the ability to run tests and fix tests. Bug: Issue 393149327 Change-Id: I8ccfbe714c347694f80dede48462bb3a0e2655fa
diff --git a/.gitignore b/.gitignore index bc39d2c..9fef988 100644 --- a/.gitignore +++ b/.gitignore
@@ -1,3 +1,5 @@ /bazel-* .tmp .vscode +.DS_Store +*.swp \ No newline at end of file
diff --git a/web/BUILD b/web/BUILD index 1879dae..1195c5d 100644 --- a/web/BUILD +++ b/web/BUILD
@@ -1,6 +1,7 @@ load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") load("@com_googlesource_gerrit_bazlets//js:defs.bzl", "gerrit_js_bundle") load("//tools/js:eslint.bzl", "plugin_eslint") +load("//tools/bzl:js.bzl", "web_test_runner") package_group( name = "visibility", @@ -54,6 +55,18 @@ ], ) +ts_project( + name = "codemirror-editor-ts-tests", + srcs = glob(["**/*.ts"]), + incremental = True, + out_dir = "_bazel_ts_out_tests", + tsconfig = ":tsconfig", + deps = [ + "//plugins:node_modules", + "//polygerrit-ui:node_modules", + ], +) + gerrit_js_bundle( name = "codemirror_element", srcs = [":codemirror-element-ts"], @@ -74,3 +87,15 @@ # Run the lint tests with `bazel test plugins/codemirror-editor/web:lint_test`. plugin_eslint() + +# Run the lint tests with `bazel test plugins/codemirror-editor/web:web_test_runner`. +web_test_runner( + name = "web_test_runner", + srcs = ["web_test_runner.sh"], + data = [ + ":codemirror-editor-ts-tests", + ":tsconfig", + "//plugins:node_modules", + "//polygerrit-ui:node_modules", + ], +) \ No newline at end of file
diff --git a/web/element/codemirror-element.ts b/web/element/codemirror-element.ts index eb014ac..ea59615 100644 --- a/web/element/codemirror-element.ts +++ b/web/element/codemirror-element.ts
@@ -60,7 +60,8 @@ @query('#result') result!: HTMLElement; - private lineTerminator?: string; + // Expose the editor instance so tests can dispatch editor changes. + editor?: EditorView; private initialized = false; @@ -124,8 +125,6 @@ if (this.initialized) return; this.initialized = true; - this.lineTerminator = this.fileContent?.includes('\r\n') ? '\r\n' : '\n'; - const editor = new EditorView({ state: EditorState.create({ doc: this.fileContent ?? '', @@ -135,7 +134,7 @@ this.prefs, this.fileType, this.fileContent ?? '', - this.darkMode + this.darkMode, ), EditorView.updateListener.of(update => { // Set ruler width to 72 for commit messages, @@ -148,17 +147,17 @@ updateRulerWidth( this.prefs.line_length, update.view.defaultCharacterWidth, - true + true, ); } if (update.docChanged) { this.dispatchEvent( new CustomEvent('content-change', { - detail: {value: update.state.doc.toJSON().join(this.lineTerminator)}, + detail: {value: update.state.sliceDoc()}, bubbles: true, composed: true, - }) + }), ); } }), @@ -189,6 +188,7 @@ parent: this.wrapper as Element, }); + this.editor = editor; editor.focus(); if (this.lineNum) { @@ -209,6 +209,8 @@ window.removeEventListener('resize', this.onResize); this.onResize = null; } + // Clear the editor reference when disconnected. + this.editor = undefined; } setCursorToLine(view: EditorView, lineNum: number) { @@ -217,14 +219,16 @@ // codemirror will error out. Instead lets just log. // Line 1 will be selected automatically. if (lineNum < 1 || lineNum > totalLines) { - console.warn(`Line number ${lineNum} is out of bounds (valid range: 1 - ${totalLines}).`); + console.warn( + `Line number ${lineNum} is out of bounds (valid range: 1 - ${totalLines}).`, + ); return; } const line = view.state.doc.line(lineNum); view.dispatch({ - selection: { anchor: line.from }, - scrollIntoView: true + selection: {anchor: line.from}, + scrollIntoView: true, }); view.focus(); } @@ -233,7 +237,9 @@ const cursor = view.state.selection.main.head; const line = view.state.doc.lineAt(cursor); if (this.result) { - this.result.textContent = `Line: ${line.number}, Column: ${cursor - line.from + 1}`; + this.result.textContent = `Line: ${line.number}, Column: ${ + cursor - line.from + 1 + }`; } }
diff --git a/web/element/codemirror-element_test.ts b/web/element/codemirror-element_test.ts index 86f3f4d..4aa1fd7 100644 --- a/web/element/codemirror-element_test.ts +++ b/web/element/codemirror-element_test.ts
@@ -3,16 +3,17 @@ * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import '../test/test-setup'; import './codemirror-element'; import {assert, fixture, html} from '@open-wc/testing'; import {CodeMirrorElement} from './codemirror-element'; -suite('gr-editor tests', () => { +suite('codemirror-element tests', () => { let element: CodeMirrorElement; setup(async () => { element = await fixture<CodeMirrorElement>( - html`<codemirror-element></codemirror-element>` + html`<codemirror-element></codemirror-element>`, ); element.lineNum = 123; element.prefs = { @@ -21,72 +22,193 @@ await element.updateComplete; }); - teardown(() => {}); - test('render', () => { assert.shadowDom.equal( element, /* HTML */ ` <div id="wrapper"> - <div class="CodeMirror cm-s-default" translate="no"> - <div> - <textarea - autocapitalize="off" - autocorrect="off" - spellcheck="false" - tabindex="0" + <div class="cm-editor ͼ1 ͼ1f ͼ1g ͼ1h ͼ1i ͼ2 ͼ4"> + <div aria-live="polite" class="cm-announced"></div> + <div class="cm-scroller" tabindex="-1"> + <div + aria-hidden="true" + class="cm-gutters cm-gutters-before" + style="min-height: 14px; position: sticky;" > - </textarea> - </div> - <div - class="CodeMirror-vscrollbar" - cm-not-content="true" - tabindex="-1" - > - <div></div> - </div> - <div - class="CodeMirror-hscrollbar" - cm-not-content="true" - tabindex="-1" - > - <div></div> - </div> - <div - class="CodeMirror-scrollbar-filler" - cm-not-content="true" - ></div> - <div class="CodeMirror-gutter-filler" cm-not-content="true"></div> - <div class="CodeMirror-scroll" tabindex="-1"> - <div class="CodeMirror-sizer"> - <div> - <div class="CodeMirror-lines" role="presentation"> - <div role="presentation"> - <div class="CodeMirror-measure"> - <pre class="CodeMirror-line-like"></pre> - </div> - <div class="CodeMirror-measure"></div> - <div></div> - <div class="CodeMirror-cursors"> - <div class="CodeMirror-cursor"></div> - </div> - <div class="CodeMirror-code" role="presentation"> - <pre class="CodeMirror-line" role="presentation"></pre> - </div> - </div> + <div class="cm-gutter cm-lineNumbers"> + <div + class="cm-gutterElement" + style="height: 0px; visibility: hidden; pointer-events: none;" + > + 9 + </div> + <div + class="cm-activeLineGutter cm-gutterElement" + style="height: 14px;" + > + 1 </div> </div> + <div class="cm-foldGutter cm-gutter"> + <div + class="cm-gutterElement" + style="height: 0px; visibility: hidden; pointer-events: none;" + > + <span title="Unfold line"> › </span> + </div> + <div + class="cm-activeLineGutter cm-gutterElement" + style="height: 14px;" + ></div> + </div> </div> - <div></div> - <div class="CodeMirror-gutters"></div> + <div + aria-multiline="true" + autocapitalize="off" + autocorrect="off" + class="cm-content" + contenteditable="true" + role="textbox" + spellcheck="false" + style="tab-size: 0;" + translate="no" + writingsuggestions="false" + > + <div class="cm-line"> + <br /> + </div> + </div> + <div + aria-hidden="true" + class="cm-cursorLayer cm-layer cm-layer-above" + style="z-index: 150; animation-duration: 1200ms;" + ></div> + <div + aria-hidden="true" + class="cm-layer cm-selectionLayer" + style="z-index: -2;" + ></div> </div> </div> </div> + <div class="statusLine"> + <div class="cursorPosition"> + <span id="result"> Line: 1, Column: 1 </span> + </div> + </div> `, - { - ignoreTags: ['span'], - ignoreAttributes: ['style'], - } ); }); + + async function waitForEditorInstance( + el: CodeMirrorElement, + attempts = 20, + delayMs = 50, + ) { + for (let i = 0; i < attempts && !el.editor; i++) { + await new Promise<void>(resolve => { + setTimeout((): void => resolve(), delayMs); + }); + } + return el.editor; + } + + function waitForContentChangeEvent(el: CodeMirrorElement) { + return new Promise<CustomEvent<{value: string}>>(resolve => { + el.addEventListener( + 'content-change', + (e: Event): void => { + resolve(e as CustomEvent<{value: string}>); + }, + {once: true}, + ); + }); + } + + test('dispatches content-change event when editor content changes', async () => { + const initialContent = 'first line\nsecond line\n'; + + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element .fileContent=${initialContent}></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + const eventPromise = waitForContentChangeEvent(editorElement); + + void editorView.dispatch({ + changes: { + from: 0, + to: 0, + insert: 'X', + }, + }); + + const event = await eventPromise; + + assert.isString(event.detail.value); + assert.isTrue(event.detail.value.length > initialContent.length); + }); + + test('content-change preserves LF for LF-only inputs', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element .fileContent=${'a\nb\n'}></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + const eventPromise = waitForContentChangeEvent(editorElement); + + void editorView.dispatch({ + changes: { + from: 0, + to: 0, + insert: 'x', + }, + }); + + const event = await eventPromise; + + assert.isFalse(/\r\n/.test(event.detail.value)); + assert.isTrue(event.detail.value.endsWith('\n')); + }); + + test('content-change preserves CRLF for CRLF inputs', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + const eventPromise = waitForContentChangeEvent(editorElement); + + void editorView.dispatch({ + changes: { + from: 0, + to: 0, + insert: 'x', + }, + }); + + const event = await eventPromise; + + assert.isString(event.detail.value); + assert.isTrue(/\r\n/.test(event.detail.value)); + assert.isTrue(event.detail.value.endsWith('\r\n')); + }); });
diff --git a/web/element/extensions.ts b/web/element/extensions.ts index 6c2be75..08f9c30 100644 --- a/web/element/extensions.ts +++ b/web/element/extensions.ts
@@ -48,10 +48,10 @@ 'background-color': 'var(--background-color-secondary)', }, '&.cm-editor.cm-focused': { - outline: 'none' + outline: 'none', }, }, - {dark} + {dark}, ); const trailingspace = () => @@ -144,7 +144,7 @@ prefs?: EditPreferencesInfo, fileType?: string, fileContent?: string, - darkMode?: boolean + darkMode?: boolean, ) => { const codeExtensions: Array<Extension> = [ lineNumbers(), @@ -172,6 +172,10 @@ hideTabsAndSpaces(), ]; + if (fileContent?.includes('\r\n')) { + codeExtensions.push(EditorState.lineSeparator.of('\r\n')); + } + if (!prefs) return codeExtensions; if (prefs.line_length) { @@ -192,7 +196,7 @@ if (prefs.syntax_highlighting && language(fileType)) { codeExtensions.push( language(fileType) as Extension, - syntaxHighlighting(gerritTheme, {fallback: true}) + syntaxHighlighting(gerritTheme, {fallback: true}), ); } @@ -210,9 +214,5 @@ codeExtensions.push(highlightTrailingWhitespace()); } - if (fileContent?.includes('\r\n')) { - codeExtensions.push(EditorState.lineSeparator.of('\r\n')); - } - return codeExtensions; };
diff --git a/web/element/ruler.ts b/web/element/ruler.ts index 3d5bf76..1112891 100644 --- a/web/element/ruler.ts +++ b/web/element/ruler.ts
@@ -15,7 +15,7 @@ constructor(view: EditorView) { this.containerElement = view.dom.appendChild( - document.createElement('div') + document.createElement('div'), ); this.containerElement.style.cssText = ` position: absolute; @@ -27,7 +27,7 @@ overflow: hidden; `; rulerElement = this.containerElement.appendChild( - document.createElement('div') + document.createElement('div'), ); rulerElement.style.cssText = ` position: absolute; @@ -48,7 +48,7 @@ function updateRulerWidth( newWidth: number, defaultWidth: number, - force = false + force = false, ) { if ((newWidth !== width || force) && rulerElement) { width = newWidth;
diff --git a/web/eslint.config.js b/web/eslint.config.js index 75fcd1e..ca271be 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js
@@ -9,7 +9,7 @@ // eslint-disable-next-line no-undef __plugindir = 'codemirror-editor/web'; -const gerritEslint = require('../../eslint.config.js'); +const gerritEslint = require('../../eslint-plugin.config.js'); module.exports = defineConfig([ {
diff --git a/web/gr-editor_test.ts b/web/gr-editor_test.ts index 613daa4..330b92e 100644 --- a/web/gr-editor_test.ts +++ b/web/gr-editor_test.ts
@@ -3,6 +3,7 @@ * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import './test/test-setup'; import './gr-editor'; import './element/codemirror-element'; import {assert, fixture, html} from '@open-wc/testing'; @@ -40,7 +41,7 @@ test('presence of codemirror-element', () => { const cmElement = queryAndAssert<HTMLElement>( element, - 'codemirror-element' + 'codemirror-element', ); assert.equal(cmElement.tagName, 'CODEMIRROR-ELEMENT'); }); @@ -48,7 +49,7 @@ test('properties of codemirror-element', () => { const cmElement = queryAndAssert<CodeMirrorElement>( element, - 'codemirror-element' + 'codemirror-element', ); assert.equal(cmElement.lineNum, 123); assert.isTrue(cmElement.prefs?.match_brackets);
diff --git a/web/plugin.ts b/web/plugin.ts index c63b157..d1e3a8a 100644 --- a/web/plugin.ts +++ b/web/plugin.ts
@@ -6,6 +6,6 @@ import '@gerritcodereview/typescript-api/gerrit'; import './gr-editor'; -window.Gerrit.install(plugin => { +window.Gerrit?.install(plugin => { plugin.registerCustomComponent('editor', 'gr-editor', {replace: true}); });
diff --git a/web/test/test-setup.ts b/web/test/test-setup.ts new file mode 100644 index 0000000..3e47525 --- /dev/null +++ b/web/test/test-setup.ts
@@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@gerritcodereview/typescript-api/gerrit'; +import {css} from 'lit'; +window.Gerrit = { + install: () => {}, + styles: { + font: css``, + form: css``, + material: css``, + menuPage: css``, + spinner: css``, + subPage: css``, + table: css``, + modal: css``, + }, +};
diff --git a/web/test/test-util.ts b/web/test/test-util.ts index ae43483..fcdf565 100644 --- a/web/test/test-util.ts +++ b/web/test/test-util.ts
@@ -5,7 +5,7 @@ */ export function queryAll<E extends Element = Element>( el: Element, - selector: string + selector: string, ): NodeListOf<E> { if (!el) throw new Error('element not defined'); const root = el.shadowRoot ?? el; @@ -14,7 +14,7 @@ export function query<E extends Element = Element>( el: Element | undefined, - selector: string + selector: string, ): E | undefined { if (!el) return undefined; const root = el.shadowRoot ?? el; @@ -23,7 +23,7 @@ export function queryAndAssert<E extends Element = Element>( el: Element | undefined, - selector: string + selector: string, ): E { const found = query<E>(el, selector); if (!found) throw new Error(`selector '${selector}' did not match anything'`);
diff --git a/web/web_test_runner.sh b/web/web_test_runner.sh new file mode 100755 index 0000000..4c2a81b --- /dev/null +++ b/web/web_test_runner.sh
@@ -0,0 +1,8 @@ +#!/bin/bash + +set -euo pipefail +./"$1" --config "$2" \ + --dir 'plugins/codemirror-editor/web/_bazel_ts_out_tests' \ + --test-files 'plugins/codemirror-editor/web/_bazel_ts_out_tests/*_test.js' \ + --ts-config='plugins/codemirror-editor/web/tsconfig.json' +