From b6c781084463faf8d139dbaed649cff75a4170a3 Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Wed, 6 Aug 2025 11:18:50 +0300 Subject: [PATCH] fix: Empty onclick breaks range parser in HTML editor (#18032) --- .../src/components/HtmlEditor/HtmlEditor.vue | 10 ++- .../src/components/HtmlEditor/utils.test.ts | 85 +++++++++++++++++++ .../src/components/HtmlEditor/utils.ts | 24 ++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/editor-ui/src/components/HtmlEditor/utils.test.ts diff --git a/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue b/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue index cc990cee9f..68b2c97ecb 100644 --- a/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue +++ b/packages/frontend/editor-ui/src/components/HtmlEditor/HtmlEditor.vue @@ -10,6 +10,7 @@ import { import { Prec, EditorState } from '@codemirror/state'; import { dropCursor, + EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, @@ -35,7 +36,7 @@ import { n8nAutocompletion } from '@/plugins/codemirror/n8nLang'; import { autoCloseTags, htmlLanguage } from 'codemirror-lang-html-n8n'; import { codeEditorTheme } from '../CodeNodeEditor/theme'; import type { Range, Section } from './types'; -import { nonTakenRanges } from './utils'; +import { nonTakenRanges, pasteHandler } from './utils'; import type { TargetNodeParameterContext } from '@/Interface'; type Props = { @@ -67,6 +68,7 @@ const extensions = computed(() => [ ]), autoCloseTags, expressionCloseBrackets(), + pasteSanitizer(), Prec.highest(keymap.of(editorKeymap)), indentOnInput(), codeEditorTheme({ @@ -231,6 +233,12 @@ async function formatHtml() { }); } +function pasteSanitizer() { + return EditorView.domEventHandlers({ + paste: pasteHandler, + }); +} + onMounted(() => { htmlEditorEventBus.on('format-html', formatHtml); }); diff --git a/packages/frontend/editor-ui/src/components/HtmlEditor/utils.test.ts b/packages/frontend/editor-ui/src/components/HtmlEditor/utils.test.ts new file mode 100644 index 0000000000..c2156fc2ce --- /dev/null +++ b/packages/frontend/editor-ui/src/components/HtmlEditor/utils.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; // Change to jest if needed +import { pasteHandler } from './utils'; +import type { EditorView } from '@codemirror/view'; + +describe('pasteHandler', () => { + it('replaces empty onclick attributes and dispatches changes', () => { + const clipboardData = { + getData: (type: string) => (type === 'text/html' ? '
' : ''), + }; + + const preventDefault = vi.fn(); + const dispatch = vi.fn(); + + const event = { clipboardData, preventDefault } as unknown as ClipboardEvent; + const view = { + state: { + selection: { + main: { from: 0, to: 0 }, + }, + }, + dispatch, + } as unknown as EditorView; + + const result = pasteHandler(event, view); + + expect(result).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + changes: { + from: 0, + to: 0, + insert: '', + }, + scrollIntoView: true, + }); + }); + + it('does nothing if there is no onclick="" in pasted content', () => { + const clipboardData = { + getData: (type: string) => (type === 'text/html' ? 'Hello world
' : ''), + }; + const preventDefault = vi.fn(); + const dispatch = vi.fn(); + + const event = { clipboardData, preventDefault } as unknown as ClipboardEvent; + const view = { + state: { + selection: { + main: { from: 0, to: 0 }, + }, + }, + dispatch, + } as unknown as EditorView; + + const result = pasteHandler(event, view); + + expect(result).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('does nothing if clipboard data is empty', () => { + const clipboardData = { + getData: () => '', + }; + const preventDefault = vi.fn(); + const dispatch = vi.fn(); + + const event = { clipboardData, preventDefault } as unknown as ClipboardEvent; + const view = { + state: { + selection: { + main: { from: 0, to: 0 }, + }, + }, + dispatch, + } as unknown as EditorView; + + const result = pasteHandler(event, view); + + expect(result).toBe(false); + expect(preventDefault).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/HtmlEditor/utils.ts b/packages/frontend/editor-ui/src/components/HtmlEditor/utils.ts index 239be2b831..0cac20dd68 100644 --- a/packages/frontend/editor-ui/src/components/HtmlEditor/utils.ts +++ b/packages/frontend/editor-ui/src/components/HtmlEditor/utils.ts @@ -1,5 +1,7 @@ import type { Range } from './types'; +import type { EditorView } from '@codemirror/view'; + /** * Return the ranges of a full range that are _not_ within the taken ranges, * assuming sorted taken ranges. e.g. `[0, 10]` and `[[2, 3], [7, 8]]` @@ -38,3 +40,25 @@ export function nonTakenRanges(fullRange: Range, takenRanges: Range[]) { return found; } + +export function pasteHandler(event: ClipboardEvent, view: EditorView) { + const htmlData = event.clipboardData?.getData('text/html') ?? ''; + const textData = event.clipboardData?.getData('text/plain') ?? ''; + + const content = htmlData || textData; + if (!content) return false; + + // Replace onclick="" with onclick=" " as empty onclick breaks range parser + if (/onclick=""/.test(content)) { + event.preventDefault(); + const sanitized = content.replace(/onclick=""/g, 'onclick=" "'); + const { from, to } = view.state.selection.main; + view.dispatch({ + changes: { from, to, insert: sanitized }, + scrollIntoView: true, + }); + return true; + } + + return false; +}