fix: Empty onclick breaks range parser in HTML editor (#18032)

This commit is contained in:
Michael Kret
2025-08-06 11:18:50 +03:00
committed by GitHub
parent 5fc356f6e7
commit b6c7810844
3 changed files with 118 additions and 1 deletions

View File

@@ -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);
});

View File

@@ -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' ? '<div onclick=""></div>' : ''),
};
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: '<div onclick=" "></div>',
},
scrollIntoView: true,
});
});
it('does nothing if there is no onclick="" in pasted content', () => {
const clipboardData = {
getData: (type: string) => (type === 'text/html' ? '<p>Hello world</p>' : ''),
};
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();
});
});

View File

@@ -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;
}