mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
feat: Allow ctrl/cmd + click to open URLs present in codemirror editors (#5160)
* Allow ctrl/cmd + click to open URLs * fix for when user does cmd+tab, then comes back without it --------- Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
16
package-lock.json
generated
16
package-lock.json
generated
@@ -26871,6 +26871,7 @@
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"know-your-http-well": "^0.5.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-replace-link": "^1.2.0",
|
||||
@@ -28420,6 +28421,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -28468,6 +28478,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"packages/bruno-app/node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-app/node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"jsonc-parser": "^3.2.1",
|
||||
"jsonpath-plus": "^10.3.0",
|
||||
"know-your-http-well": "^0.5.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-replace-link": "^1.2.0",
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
|
||||
import { JSHINT } from 'jshint';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
import { getAllVariables } from 'utils/collections';
|
||||
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -47,7 +48,7 @@ export default class CodeEditor extends React.Component {
|
||||
componentDidMount() {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
const editor = (this.editor = makeLinkAwareCodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
lineWrapping: this.props.enableLineWrapping ?? true,
|
||||
@@ -266,6 +267,9 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
if(this.editor._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
}
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('scroll', this.onScroll);
|
||||
this.editor = null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -29,7 +30,9 @@ class MultiLineEditor extends Component {
|
||||
/** @type {import("codemirror").Editor} */
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
this.editor = makeLinkAwareCodeMirror(this.editorRef.current, {
|
||||
lineWrapping: false,
|
||||
lineNumbers: false,
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
placeholder: this.props.placeholder,
|
||||
mode: 'brunovariables',
|
||||
@@ -168,6 +171,11 @@ class MultiLineEditor extends Component {
|
||||
if (this.brunoAutoCompleteCleanup) {
|
||||
this.brunoAutoCompleteCleanup();
|
||||
}
|
||||
|
||||
if(this.editor._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
}
|
||||
|
||||
if (this.maskedEditor) {
|
||||
this.maskedEditor.destroy();
|
||||
this.maskedEditor = null;
|
||||
|
||||
@@ -17,6 +17,7 @@ import StyledWrapper from './StyledWrapper';
|
||||
import { IconWand } from '@tabler/icons';
|
||||
|
||||
import onHasCompletion from './onHasCompletion';
|
||||
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -35,7 +36,7 @@ export default class QueryEditor extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const editor = (this.editor = CodeMirror(this._node, {
|
||||
const editor = (this.editor = makeLinkAwareCodeMirror(this._node, {
|
||||
value: this.props.value || '',
|
||||
lineNumbers: true,
|
||||
tabSize: 2,
|
||||
@@ -170,6 +171,9 @@ export default class QueryEditor extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
if(this.editor._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
}
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('keyup', this._onKeyUp);
|
||||
this.editor.off('hasCompletion', this._onHasCompletion);
|
||||
|
||||
@@ -6,8 +6,7 @@ import { MaskedEditor } from 'utils/common/masked-editor';
|
||||
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
import makeLinkAwareCodeMirror from 'utils/codemirror/makeLinkAwareCodeMirror';
|
||||
|
||||
class SingleLineEditor extends Component {
|
||||
constructor(props) {
|
||||
@@ -42,7 +41,7 @@ class SingleLineEditor extends Component {
|
||||
};
|
||||
const noopHandler = () => {};
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
this.editor = makeLinkAwareCodeMirror(this.editorRef.current, {
|
||||
placeholder: this.props.placeholder ?? '',
|
||||
lineWrapping: false,
|
||||
lineNumbers: false,
|
||||
@@ -189,6 +188,9 @@ class SingleLineEditor extends Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
if(this.editor._destroyLinkAware) {
|
||||
this.editor._destroyLinkAware();
|
||||
}
|
||||
this.editor.off('change', this._onEdit);
|
||||
this.editor.off('paste', this._onPaste);
|
||||
this._clearNewlineMarkers();
|
||||
|
||||
@@ -469,6 +469,14 @@ const GlobalStyle = createGlobalStyle`
|
||||
background: #08f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.hovered-link.CodeMirror-link {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
.cmd-ctrl-pressed .hovered-link.CodeMirror-link[data-url] {
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.textLink} !important;
|
||||
}
|
||||
`;
|
||||
|
||||
export default GlobalStyle;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
const CodeMirror = require('codemirror');
|
||||
import LinkifyIt from 'linkify-it';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
export default function makeLinkAwareCodeMirror(host, options = {}) {
|
||||
const cmdCtrlClass = 'cmd-ctrl-pressed';
|
||||
const linkClass = 'CodeMirror-link';
|
||||
const linkHoverClass = 'hovered-link';
|
||||
const linkHint = isMacOS() ? 'Hold Cmd and click to open link' : 'Hold Ctrl and click to open link';
|
||||
|
||||
const isCmdOrCtrlPressed = (event) => (isMacOS() ? event.metaKey : event.ctrlKey);
|
||||
|
||||
const editor = CodeMirror(host, {
|
||||
...options,
|
||||
configureMouse: (cm, repeat, ev) => {
|
||||
if (isCmdOrCtrlPressed(ev) && ev.target?.classList.contains(linkClass)) {
|
||||
return { addNew: false }; // prevent multi-cursor on Cmd+click on links
|
||||
}
|
||||
return {};
|
||||
}
|
||||
});
|
||||
if (!editor) return editor;
|
||||
|
||||
const linkify = new LinkifyIt();
|
||||
|
||||
function debounce(fn, delay) {
|
||||
let timer;
|
||||
return function (...args) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
const debouncedMarkUrls = debounce(() => {
|
||||
requestAnimationFrame(markUrls);
|
||||
}, 150);
|
||||
|
||||
function markUrls() {
|
||||
const doc = editor.getDoc();
|
||||
const text = doc.getValue();
|
||||
|
||||
editor.getAllMarks().forEach((mark) => {
|
||||
if (mark.className === linkClass) mark.clear();
|
||||
});
|
||||
|
||||
const matches = linkify.match(text);
|
||||
matches?.forEach(({ index, lastIndex, url }) => {
|
||||
const from = editor.posFromIndex(index);
|
||||
const to = editor.posFromIndex(lastIndex);
|
||||
editor.markText(from, to, {
|
||||
className: linkClass,
|
||||
attributes: {
|
||||
'data-url': url,
|
||||
title: linkHint
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const handleMouseEnter = (e) => {
|
||||
const el = e.target;
|
||||
if (!el.classList.contains(linkClass)) return;
|
||||
updateCmdCtrlClass(e);
|
||||
|
||||
el.classList.add(linkHoverClass);
|
||||
let sibling = el.previousElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.add(linkHoverClass);
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
sibling = el.nextElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.add(linkHoverClass);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
};
|
||||
const handleMouseLeave = (e) => {
|
||||
const el = e.target;
|
||||
el.classList.remove(linkHoverClass);
|
||||
let sibling = el.previousElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.remove(linkHoverClass);
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
sibling = el.nextElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.remove(linkHoverClass);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
};
|
||||
const editorWrapper = editor.getWrapperElement();
|
||||
|
||||
function updateCmdCtrlClass(event) {
|
||||
if (isCmdOrCtrlPressed(event)) {
|
||||
editorWrapper.classList.add(cmdCtrlClass);
|
||||
} else {
|
||||
editorWrapper.classList.remove(cmdCtrlClass);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
if (!isCmdOrCtrlPressed(event)) return;
|
||||
|
||||
if (event.target.classList.contains(linkClass)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const url = event.target.getAttribute('data-url');
|
||||
if (url) {
|
||||
window?.ipcRenderer?.openExternal(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial marking and event binding
|
||||
markUrls();
|
||||
editor.on('changes', debouncedMarkUrls);
|
||||
window.addEventListener('keydown', updateCmdCtrlClass);
|
||||
window.addEventListener('keyup', updateCmdCtrlClass);
|
||||
editorWrapper.addEventListener('click', handleClick);
|
||||
// Listen for mouseover to add hover effect
|
||||
editorWrapper.addEventListener('mouseover', handleMouseEnter);
|
||||
// Listen for mouseout to reset the hover effect
|
||||
editorWrapper.addEventListener('mouseout', handleMouseLeave);
|
||||
|
||||
editor._destroyLinkAware = () => {
|
||||
editor.off('changes', debouncedMarkUrls);
|
||||
window.removeEventListener('keydown', updateCmdCtrlClass);
|
||||
window.removeEventListener('keyup', updateCmdCtrlClass);
|
||||
editorWrapper.removeEventListener('click', handleClick);
|
||||
editorWrapper.removeEventListener('mouseover', handleMouseEnter);
|
||||
editorWrapper.removeEventListener('mouseout', handleMouseLeave);
|
||||
};
|
||||
|
||||
// Return editor instance
|
||||
return editor;
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
import makeLinkAwareCodeMirror from './makeLinkAwareCodeMirror';
|
||||
import LinkifyIt from 'linkify-it';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('codemirror', () => {
|
||||
const mockEditor = {
|
||||
getDoc: jest.fn(),
|
||||
getAllMarks: jest.fn(),
|
||||
markText: jest.fn(),
|
||||
posFromIndex: jest.fn(),
|
||||
getWrapperElement: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
_destroyLinkAware: undefined
|
||||
};
|
||||
|
||||
const CodeMirror = jest.fn(() => mockEditor);
|
||||
return CodeMirror;
|
||||
});
|
||||
|
||||
// Mock linkify-it
|
||||
jest.mock('linkify-it', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
match: jest.fn()
|
||||
}));
|
||||
});
|
||||
|
||||
jest.mock('utils/common/platform', () => ({
|
||||
isMacOS: jest.fn()
|
||||
}));
|
||||
// Mock requestAnimationFrame
|
||||
global.requestAnimationFrame = jest.fn((cb) => cb());
|
||||
|
||||
// Mock window.ipcRenderer
|
||||
global.window = {
|
||||
...global.window,
|
||||
ipcRenderer: {
|
||||
openExternal: jest.fn()
|
||||
},
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
};
|
||||
|
||||
describe('makeLinkAwareCodeMirror', () => {
|
||||
let mockHost;
|
||||
let mockEditor;
|
||||
let mockDoc;
|
||||
let mockWrapperElement;
|
||||
let mockLinkify;
|
||||
let mockMark;
|
||||
let originalTimeout;
|
||||
let mockSetTimeout;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Create a Jest mock for setTimeout
|
||||
mockSetTimeout = jest.spyOn(global, 'setTimeout');
|
||||
|
||||
// Store original timeout and mock requestAnimationFrame
|
||||
originalTimeout = global.setTimeout;
|
||||
global.requestAnimationFrame = jest.fn((cb) => cb());
|
||||
|
||||
// Setup DOM mocks
|
||||
mockHost = document.createElement('div');
|
||||
mockWrapperElement = {
|
||||
classList: {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn()
|
||||
},
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
};
|
||||
|
||||
mockMark = {
|
||||
clear: jest.fn()
|
||||
};
|
||||
|
||||
mockDoc = {
|
||||
getValue: jest.fn().mockReturnValue('Check out https://example.com and http://test.org')
|
||||
};
|
||||
|
||||
mockEditor = {
|
||||
getDoc: jest.fn().mockReturnValue(mockDoc),
|
||||
getAllMarks: jest.fn().mockReturnValue([mockMark]),
|
||||
markText: jest.fn(),
|
||||
posFromIndex: jest.fn().mockImplementation((index) => ({ line: 0, ch: index })),
|
||||
getWrapperElement: jest.fn().mockReturnValue(mockWrapperElement),
|
||||
on: jest.fn(),
|
||||
off: jest.fn()
|
||||
};
|
||||
|
||||
mockLinkify = {
|
||||
match: jest.fn().mockReturnValue([
|
||||
{ index: 10, lastIndex: 28, url: 'https://example.com' },
|
||||
{ index: 33, lastIndex: 48, url: 'http://test.org' }
|
||||
])
|
||||
};
|
||||
|
||||
// Setup mocks
|
||||
CodeMirror.mockReturnValue(mockEditor);
|
||||
|
||||
LinkifyIt.mockImplementation(() => mockLinkify);
|
||||
|
||||
// Mock window and ipcRenderer
|
||||
global.window = {
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
ipcRenderer: {
|
||||
openExternal: jest.fn()
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.window;
|
||||
delete global.requestAnimationFrame;
|
||||
global.setTimeout = originalTimeout;
|
||||
mockSetTimeout.mockRestore();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('editor creation and configuration', () => {
|
||||
it('should create a CodeMirror editor with default options', () => {
|
||||
const result = makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
expect(CodeMirror).toHaveBeenCalledWith(
|
||||
mockHost,
|
||||
expect.objectContaining({
|
||||
configureMouse: expect.any(Function)
|
||||
})
|
||||
);
|
||||
expect(result).toBe(mockEditor);
|
||||
});
|
||||
|
||||
it('should merge custom options with default configuration', () => {
|
||||
const customOptions = { lineNumbers: true, theme: 'dark' };
|
||||
|
||||
makeLinkAwareCodeMirror(mockHost, customOptions);
|
||||
|
||||
expect(CodeMirror).toHaveBeenCalledWith(
|
||||
mockHost,
|
||||
expect.objectContaining({
|
||||
lineNumbers: true,
|
||||
theme: 'dark',
|
||||
configureMouse: expect.any(Function)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return early if editor creation fails', () => {
|
||||
CodeMirror.mockReturnValue(null);
|
||||
|
||||
const result = makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should add _destroyLinkAware method to editor', () => {
|
||||
const result = makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
expect(result._destroyLinkAware).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform-specific key detection', () => {
|
||||
it('should detect Cmd key on macOS', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
|
||||
const mockEvent = { metaKey: true, ctrlKey: false, target: { classList: { contains: () => true } } };
|
||||
|
||||
const result = configureMouse(null, null, mockEvent);
|
||||
|
||||
expect(result).toEqual({ addNew: false });
|
||||
});
|
||||
|
||||
it('should detect Ctrl key on non-macOS', () => {
|
||||
isMacOS.mockReturnValue(false);
|
||||
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
|
||||
const mockEvent = { metaKey: false, ctrlKey: true, target: { classList: { contains: () => true } } };
|
||||
|
||||
const result = configureMouse(null, null, mockEvent);
|
||||
|
||||
expect(result).toEqual({ addNew: false });
|
||||
});
|
||||
|
||||
it('should return empty object when modifier key is not pressed', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
|
||||
const mockEvent = { metaKey: false, ctrlKey: false, target: { classList: { contains: () => true } } };
|
||||
|
||||
const result = configureMouse(null, null, mockEvent);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when target is not a link', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
|
||||
const mockEvent = { metaKey: true, target: { classList: { contains: () => false } } };
|
||||
|
||||
const result = configureMouse(null, null, mockEvent);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS class management', () => {
|
||||
it('should add cmd-ctrl-pressed class when modifier key is pressed', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const keydownHandler = global.window.addEventListener.mock.calls.find((call) => call[0] === 'keydown')[1];
|
||||
const mockEvent = { metaKey: true };
|
||||
|
||||
keydownHandler(mockEvent);
|
||||
|
||||
expect(mockWrapperElement.classList.add).toHaveBeenCalledWith('cmd-ctrl-pressed');
|
||||
});
|
||||
|
||||
it('should remove cmd-ctrl-pressed class when modifier key is released', () => {
|
||||
isMacOS.mockReturnValue(false);
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const keyupHandler = global.window.addEventListener.mock.calls.find((call) => call[0] === 'keyup')[1];
|
||||
const mockEvent = { ctrlKey: false };
|
||||
|
||||
keyupHandler(mockEvent);
|
||||
|
||||
expect(mockWrapperElement.classList.remove).toHaveBeenCalledWith('cmd-ctrl-pressed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('click handling', () => {
|
||||
it('should open external URL when Cmd+clicking on a link', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
|
||||
const mockEvent = {
|
||||
metaKey: true,
|
||||
target: {
|
||||
classList: { contains: (className) => className === 'CodeMirror-link' },
|
||||
getAttribute: () => 'https://example.com'
|
||||
},
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn()
|
||||
};
|
||||
|
||||
clickHandler(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(global.window.ipcRenderer.openExternal).toHaveBeenCalledWith('https://example.com');
|
||||
});
|
||||
|
||||
it('should not open URL when clicking without modifier key', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
|
||||
const mockEvent = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
target: {
|
||||
classList: { contains: (className) => className === 'CodeMirror-link' },
|
||||
getAttribute: () => 'https://example.com'
|
||||
}
|
||||
};
|
||||
|
||||
clickHandler(mockEvent);
|
||||
|
||||
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not open URL when clicking on non-link element', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
|
||||
const mockEvent = {
|
||||
metaKey: true,
|
||||
target: {
|
||||
classList: { contains: () => false }
|
||||
}
|
||||
};
|
||||
|
||||
clickHandler(mockEvent);
|
||||
|
||||
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not open URL when data-url attribute is missing', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
|
||||
const mockEvent = {
|
||||
metaKey: true,
|
||||
target: {
|
||||
classList: { contains: (className) => className === 'CodeMirror-link' },
|
||||
getAttribute: () => null
|
||||
},
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn()
|
||||
};
|
||||
|
||||
clickHandler(mockEvent);
|
||||
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
expect(mockEvent.stopPropagation).toHaveBeenCalled();
|
||||
expect(global.window.ipcRenderer.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// Test debouncing behavior
|
||||
describe('debouncing', () => {
|
||||
it('should debounce URL marking on content changes', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
// Clear the calls from initial setup
|
||||
mockEditor.getAllMarks.mockClear();
|
||||
|
||||
// Simulate multiple rapid content changes
|
||||
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
|
||||
changeHandler();
|
||||
changeHandler();
|
||||
changeHandler();
|
||||
|
||||
expect(setTimeout).toHaveBeenCalledTimes(3);
|
||||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 150);
|
||||
|
||||
// Fast-forward timers
|
||||
jest.runAllTimers();
|
||||
|
||||
// Should only mark URLs once
|
||||
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.getAllMarks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply link tooltips when marking URLs', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
expect(mockEditor.markText).toHaveBeenCalledWith(
|
||||
{ line: 0, ch: 10 },
|
||||
{ line: 0, ch: 28 },
|
||||
{
|
||||
className: 'CodeMirror-link',
|
||||
attributes: {
|
||||
'data-url': 'https://example.com',
|
||||
title: 'Hold Cmd and click to open link'
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Test animation frame handling
|
||||
describe('animation frame handling', () => {
|
||||
it('should use requestAnimationFrame for URL marking', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
|
||||
changeHandler();
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(requestAnimationFrame).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hover behavior', () => {
|
||||
it('should add hover class on mouseover for link elements', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
|
||||
(call) => call[0] === 'mouseover'
|
||||
)[1];
|
||||
|
||||
const mockTarget = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn(),
|
||||
remove: jest.fn()
|
||||
},
|
||||
previousElementSibling: {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn(),
|
||||
remove: jest.fn()
|
||||
},
|
||||
previousElementSibling: null
|
||||
},
|
||||
nextElementSibling: {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn(),
|
||||
remove: jest.fn()
|
||||
},
|
||||
nextElementSibling: null
|
||||
}
|
||||
};
|
||||
|
||||
const mockEvent = { target: mockTarget };
|
||||
mouseoverHandler(mockEvent);
|
||||
|
||||
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockTarget.previousElementSibling.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockTarget.nextElementSibling.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
});
|
||||
|
||||
it('should not add hover class for non-link elements', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
|
||||
(call) => call[0] === 'mouseover'
|
||||
)[1];
|
||||
|
||||
const mockTarget = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(false),
|
||||
add: jest.fn()
|
||||
}
|
||||
};
|
||||
|
||||
const mockEvent = { target: mockTarget };
|
||||
mouseoverHandler(mockEvent);
|
||||
|
||||
expect(mockTarget.classList.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove hover class on mouseout', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const mouseoutHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseout')[1];
|
||||
|
||||
const mockTarget = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
remove: jest.fn()
|
||||
},
|
||||
previousElementSibling: {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
remove: jest.fn()
|
||||
},
|
||||
previousElementSibling: null
|
||||
},
|
||||
nextElementSibling: {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
remove: jest.fn()
|
||||
},
|
||||
nextElementSibling: null
|
||||
}
|
||||
};
|
||||
|
||||
const mockEvent = { target: mockTarget };
|
||||
mouseoutHandler(mockEvent);
|
||||
|
||||
expect(mockTarget.classList.remove).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockTarget.previousElementSibling.classList.remove).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockTarget.nextElementSibling.classList.remove).toHaveBeenCalledWith('hovered-link');
|
||||
});
|
||||
|
||||
it('should handle multi-span links correctly on hover', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
|
||||
(call) => call[0] === 'mouseover'
|
||||
)[1];
|
||||
|
||||
// Create a mock with a chain of link spans
|
||||
const mockNestedPrev = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
previousElementSibling: null
|
||||
};
|
||||
|
||||
const mockPrev = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
previousElementSibling: mockNestedPrev
|
||||
};
|
||||
|
||||
const mockNestedNext = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
nextElementSibling: null
|
||||
};
|
||||
|
||||
const mockNext = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
nextElementSibling: mockNestedNext
|
||||
};
|
||||
|
||||
const mockTarget = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
previousElementSibling: mockPrev,
|
||||
nextElementSibling: mockNext
|
||||
};
|
||||
|
||||
const mockEvent = { target: mockTarget };
|
||||
mouseoverHandler(mockEvent);
|
||||
|
||||
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockPrev.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockNestedPrev.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockNext.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockNestedNext.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
});
|
||||
});
|
||||
|
||||
// Test memory cleanup
|
||||
describe('memory cleanup', () => {
|
||||
it('should properly clean up all event listeners and marks', () => {
|
||||
const editor = makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
editor._destroyLinkAware();
|
||||
|
||||
expect(mockEditor.off).toHaveBeenCalled();
|
||||
expect(global.window.removeEventListener).toHaveBeenCalledTimes(2);
|
||||
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledTimes(3); // click, mouseover, mouseout
|
||||
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('mouseover', expect.any(Function));
|
||||
expect(mockWrapperElement.removeEventListener).toHaveBeenCalledWith('mouseout', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle missing target in mouse event', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const configureMouse = CodeMirror.mock.calls[0][1].configureMouse;
|
||||
const mockEvent = { metaKey: true, target: null };
|
||||
|
||||
const result = configureMouse(null, null, mockEvent);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle missing ipcRenderer', () => {
|
||||
delete global.window.ipcRenderer;
|
||||
isMacOS.mockReturnValue(true);
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const clickHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'click')[1];
|
||||
const mockEvent = {
|
||||
metaKey: true,
|
||||
target: {
|
||||
classList: { contains: (className) => className === 'CodeMirror-link' },
|
||||
getAttribute: () => 'https://example.com'
|
||||
},
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn()
|
||||
};
|
||||
|
||||
expect(() => clickHandler(mockEvent)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle LinkifyIt returning null matches', () => {
|
||||
mockLinkify.match.mockReturnValue(null);
|
||||
|
||||
expect(() => makeLinkAwareCodeMirror(mockHost)).not.toThrow();
|
||||
expect(mockEditor.markText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle null siblings in mouseover events', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
|
||||
(call) => call[0] === 'mouseover'
|
||||
)[1];
|
||||
|
||||
const mockTarget = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
previousElementSibling: null,
|
||||
nextElementSibling: null
|
||||
};
|
||||
|
||||
const mockEvent = { target: mockTarget };
|
||||
|
||||
expect(() => mouseoverHandler(mockEvent)).not.toThrow();
|
||||
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
});
|
||||
|
||||
it('should handle non-link siblings in mouseover events', () => {
|
||||
makeLinkAwareCodeMirror(mockHost);
|
||||
|
||||
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find(
|
||||
(call) => call[0] === 'mouseover'
|
||||
)[1];
|
||||
|
||||
const mockPrev = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(false),
|
||||
add: jest.fn()
|
||||
}
|
||||
};
|
||||
|
||||
const mockNext = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(false),
|
||||
add: jest.fn()
|
||||
}
|
||||
};
|
||||
|
||||
const mockTarget = {
|
||||
classList: {
|
||||
contains: jest.fn().mockReturnValue(true),
|
||||
add: jest.fn()
|
||||
},
|
||||
previousElementSibling: mockPrev,
|
||||
nextElementSibling: mockNext
|
||||
};
|
||||
|
||||
const mockEvent = { target: mockTarget };
|
||||
mouseoverHandler(mockEvent);
|
||||
|
||||
expect(mockTarget.classList.add).toHaveBeenCalledWith('hovered-link');
|
||||
expect(mockPrev.classList.add).not.toHaveBeenCalled();
|
||||
expect(mockNext.classList.add).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { ipcRenderer, contextBridge, webUtils } = require('electron');
|
||||
const { ipcRenderer, contextBridge, webUtils, shell } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
@@ -14,5 +14,6 @@ contextBridge.exposeInMainWorld('ipcRenderer', {
|
||||
getFilePath(file) {
|
||||
const path = webUtils.getPathForFile(file);
|
||||
return path;
|
||||
}
|
||||
},
|
||||
openExternal: (url) => shell.openExternal(url)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user