diff --git a/src/lib/vendor/codemirror/custom-extensions.test.ts b/src/lib/vendor/codemirror/custom-extensions.test.ts new file mode 100644 index 0000000000..23d332ecaf --- /dev/null +++ b/src/lib/vendor/codemirror/custom-extensions.test.ts @@ -0,0 +1,79 @@ +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { getLineBreakExtension } from './custom-extensions'; + +describe('getLineBreakExtension', () => { + let view: EditorView; + + afterEach(() => { + view?.destroy(); + }); + + it('should not mutate document content containing \\n escape sequences', async () => { + const content = '{"key":"Hello\\nworld"}'; + + view = new EditorView({ + state: EditorState.create({ + doc: content, + extensions: [getLineBreakExtension(false)], + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(view.state.doc.toString()).toBe(content); + }); + + it('should not mutate document content with multiple \\n sequences', async () => { + const content = '{"key":"line1\\nline2\\nline3"}'; + + view = new EditorView({ + state: EditorState.create({ + doc: content, + extensions: [getLineBreakExtension(false)], + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(view.state.doc.toString()).toBe(content); + }); + + it('should not mutate document content with even backslashes before n', async () => { + const content = '{"key":"Hello\\\\nworld"}'; + + view = new EditorView({ + state: EditorState.create({ + doc: content, + extensions: [getLineBreakExtension(false)], + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(view.state.doc.toString()).toBe(content); + }); + + it('should preserve valid JSON after extension processes content', async () => { + const content = '{"message":"Hello\\nworld","count":1}'; + + view = new EditorView({ + state: EditorState.create({ + doc: content, + extensions: [getLineBreakExtension(false)], + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(() => JSON.parse(view.state.doc.toString())).not.toThrow(); + }); + + it('should return empty array when editable is true', () => { + const result = getLineBreakExtension(true); + + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/vendor/codemirror/custom-extensions.ts b/src/lib/vendor/codemirror/custom-extensions.ts index d69e0d1b42..565395de17 100644 --- a/src/lib/vendor/codemirror/custom-extensions.ts +++ b/src/lib/vendor/codemirror/custom-extensions.ts @@ -8,7 +8,14 @@ import { typescript } from '@codemirror/legacy-modes/mode/javascript'; import { python } from '@codemirror/legacy-modes/mode/python'; import { ruby } from '@codemirror/legacy-modes/mode/ruby'; import { shell } from '@codemirror/legacy-modes/mode/shell'; -import { EditorView } from '@codemirror/view'; +import type { DecorationSet, ViewUpdate } from '@codemirror/view'; +import { + Decoration, + EditorView, + MatchDecorator, + ViewPlugin, + WidgetType, +} from '@codemirror/view'; import { tags } from '@lezer/highlight'; import colors from 'tailwindcss/colors'; @@ -135,17 +142,36 @@ export const highlightStyles = HighlightStyle.define( { themeType: 'light' }, ); -export const getLineBreakExtension = (editable: boolean) => - EditorView.updateListener.of((update) => { - if (editable) return; +class LineBreakWidget extends WidgetType { + toDOM() { + return document.createElement('br'); + } +} - const newText = update.state.doc.toString().replace(/\\n/g, '\n'); - if (newText !== update.state.doc.toString()) { - update.view.dispatch({ - changes: { from: 0, to: update.state.doc.length, insert: newText }, - }); - } - }); +const lineBreakDecorator = new MatchDecorator({ + regexp: /\\n/g, + decoration: Decoration.replace({ widget: new LineBreakWidget() }), +}); + +export const getLineBreakExtension = (editable: boolean) => { + if (editable) return []; + + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = lineBreakDecorator.createDeco(view); + } + update(update: ViewUpdate) { + this.decorations = lineBreakDecorator.updateDeco( + update, + this.decorations, + ); + } + }, + { decorations: (v) => v.decorations }, + ); +}; export const getLanguageExtension = (language: EditorLanguage) => ({