mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-16 04:11:29 +00:00
feat: Allow ctrl/cmd + click to open URLs present in codemirror (#5930)
* 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> * Feature/cmd click on links (#5927) fix: clean up whitespace and formatting in linkAware functions fix rediff Feature/cmd click on links (#6132) * Allow ctrl/cmd + click to open URLs * fix for when user does cmd+tab, then comes back without it * refactored the community contribution to match Autocomplete's implementation * updated the code to resolve issues caused during merge conflict resolution with the use of makeLinkAware * fix: updated the code to use lodash's debounce and removed redundant undefined checks * fix: correct debouncing test expectation in linkAware.spec.js The test was incorrectly expecting 3 setTimeout calls when debouncing should only result in one active timeout. Updated the test to verify debouncing behavior correctly by checking that setTimeout is called with the correct delay, and that only one execution happens after the debounce delay. * fix: fixed merge issues in linkAware.js * fix: fixed CodeMirror assignment to this.editor * fix: formatting fixes * fix: formatting fix --------- Co-authored-by: abansal21 <37215457+abansal21@users.noreply.github.com> Co-authored-by: Chirag Chandrashekhar <chirag@usebruno.com> --------- Co-authored-by: Arun Bansal <37215457+abansal21@users.noreply.github.com> Co-authored-by: Chirag Chandrashekhar <chirag@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 { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import CodeMirrorSearch from 'components/CodeMirrorSearch';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -204,6 +205,8 @@ export default class CodeEditor extends React.Component {
|
||||
editor,
|
||||
autoCompleteOptions
|
||||
);
|
||||
|
||||
setupLinkAware(editor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +269,7 @@ export default class CodeEditor extends React.Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.editor) {
|
||||
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 { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
import { IconEye, IconEyeOff } from '@tabler/icons';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
@@ -30,6 +31,8 @@ class MultiLineEditor extends Component {
|
||||
const variables = getAllVariables(this.props.collection, this.props.item);
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
lineWrapping: false,
|
||||
lineNumbers: false,
|
||||
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
|
||||
placeholder: this.props.placeholder,
|
||||
mode: 'brunovariables',
|
||||
@@ -84,6 +87,8 @@ class MultiLineEditor extends Component {
|
||||
this.editor,
|
||||
autoCompleteOptions
|
||||
);
|
||||
|
||||
setupLinkAware(this.editor);
|
||||
|
||||
this.editor.setValue(String(this.props.value) || '');
|
||||
this.editor.on('change', this._onEdit);
|
||||
@@ -168,6 +173,9 @@ 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 { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -138,6 +139,8 @@ export default class QueryEditor extends React.Component {
|
||||
editor.on('beforeChange', this._onBeforeChange);
|
||||
}
|
||||
this.addOverlay();
|
||||
|
||||
setupLinkAware(editor);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -170,6 +173,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,6 +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';
|
||||
import { setupLinkAware } from 'utils/codemirror/linkAware';
|
||||
|
||||
const CodeMirror = require('codemirror');
|
||||
|
||||
@@ -40,7 +41,7 @@ class SingleLineEditor extends Component {
|
||||
this.props.onSave();
|
||||
}
|
||||
};
|
||||
const noopHandler = () => {};
|
||||
const noopHandler = () => { };
|
||||
|
||||
this.editor = CodeMirror(this.editorRef.current, {
|
||||
placeholder: this.props.placeholder ?? '',
|
||||
@@ -94,7 +95,7 @@ class SingleLineEditor extends Component {
|
||||
this.editor,
|
||||
autoCompleteOptions
|
||||
);
|
||||
|
||||
|
||||
this.editor.setValue(String(this.props.value ?? ''));
|
||||
this.editor.on('change', this._onEdit);
|
||||
this.editor.on('paste', this._onPaste);
|
||||
@@ -106,6 +107,7 @@ class SingleLineEditor extends Component {
|
||||
if (this.props.showNewlineArrow) {
|
||||
this._updateNewlineMarkers();
|
||||
}
|
||||
setupLinkAware(this.editor);
|
||||
}
|
||||
|
||||
/** Enable or disable masking the rendered content of the editor */
|
||||
@@ -189,6 +191,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;
|
||||
|
||||
183
packages/bruno-app/src/utils/codemirror/linkAware.js
Normal file
183
packages/bruno-app/src/utils/codemirror/linkAware.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import LinkifyIt from 'linkify-it';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
import { debounce } from 'lodash';
|
||||
/**
|
||||
* Marks URLs in the CodeMirror editor with clickable link styling
|
||||
* @param {Object} editor - The CodeMirror editor instance
|
||||
* @param {Object} linkify - The LinkifyIt instance for URL detection
|
||||
* @param {string} linkClass - CSS class name for links
|
||||
* @param {string} linkHint - Tooltip text for links
|
||||
*/
|
||||
function markUrls(editor, linkify, linkClass, linkHint) {
|
||||
const doc = editor.getDoc();
|
||||
const text = doc.getValue();
|
||||
|
||||
// Clear existing link marks
|
||||
editor.getAllMarks().forEach((mark) => {
|
||||
if (mark.className === linkClass) mark.clear();
|
||||
});
|
||||
|
||||
// Find and mark new URLs
|
||||
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
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse enter events on links to show hover effects
|
||||
* @param {Event} event - The mouse enter event
|
||||
* @param {string} linkClass - CSS class name for links
|
||||
* @param {string} linkHoverClass - CSS class name for hovered links
|
||||
* @param {Function} updateCmdCtrlClass - Function to update Cmd/Ctrl state
|
||||
*/
|
||||
function handleMouseEnter(event, linkClass, linkHoverClass, updateCmdCtrlClass) {
|
||||
const el = event.target;
|
||||
if (!el.classList.contains(linkClass)) return;
|
||||
|
||||
updateCmdCtrlClass(event);
|
||||
|
||||
el.classList.add(linkHoverClass);
|
||||
|
||||
// Add hover effect to previous siblings that are also links
|
||||
let sibling = el.previousElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.add(linkHoverClass);
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
|
||||
// Add hover effect to next siblings that are also links
|
||||
sibling = el.nextElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.add(linkHoverClass);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles mouse leave events on links to remove hover effects
|
||||
* @param {Event} event - The mouse leave event
|
||||
* @param {string} linkClass - CSS class name for links
|
||||
* @param {string} linkHoverClass - CSS class name for hovered links
|
||||
*/
|
||||
function handleMouseLeave(event, linkClass, linkHoverClass) {
|
||||
const el = event.target;
|
||||
el.classList.remove(linkHoverClass);
|
||||
|
||||
// Remove hover effect from previous siblings that are also links
|
||||
let sibling = el.previousElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.remove(linkHoverClass);
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
|
||||
// Remove hover effect from next siblings that are also links
|
||||
sibling = el.nextElementSibling;
|
||||
while (sibling && sibling.classList.contains(linkClass)) {
|
||||
sibling.classList.remove(linkHoverClass);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the CSS class on the editor wrapper based on Cmd/Ctrl key state
|
||||
* @param {Event} event - The keyboard event
|
||||
* @param {HTMLElement} editorWrapper - The editor wrapper element
|
||||
* @param {string} cmdCtrlClass - CSS class name for Cmd/Ctrl pressed state
|
||||
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
|
||||
*/
|
||||
function updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed) {
|
||||
if (isCmdOrCtrlPressed(event)) {
|
||||
editorWrapper.classList.add(cmdCtrlClass);
|
||||
} else {
|
||||
editorWrapper.classList.remove(cmdCtrlClass);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click events on links to open them externally
|
||||
* @param {Event} event - The click event
|
||||
* @param {string} linkClass - CSS class name for links
|
||||
* @param {Function} isCmdOrCtrlPressed - Function to check if Cmd/Ctrl is pressed
|
||||
*/
|
||||
function handleClick(event, linkClass, isCmdOrCtrlPressed) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up link awareness for a CodeMirror editor instance.
|
||||
* This enables automatic URL detection, styling, and click-to-open functionality.
|
||||
* @param {Object} editor - The CodeMirror editor instance
|
||||
* @param {Object} options - Configuration options (currently unused but reserved for future use)
|
||||
* @returns {void}
|
||||
*/
|
||||
function setupLinkAware(editor, options = {}) {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// CSS class names and configuration
|
||||
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';
|
||||
|
||||
// Helper function to check if Cmd/Ctrl is pressed
|
||||
const isCmdOrCtrlPressed = (event) => (isMacOS() ? event.metaKey : event.ctrlKey);
|
||||
|
||||
// Initialize LinkifyIt for URL detection
|
||||
const linkify = new LinkifyIt();
|
||||
const editorWrapper = editor.getWrapperElement();
|
||||
|
||||
// Create bound versions of event handlers with proper parameters
|
||||
const boundMarkUrls = () => markUrls(editor, linkify, linkClass, linkHint);
|
||||
const boundUpdateCmdCtrlClass = (event) => updateCmdCtrlClass(event, editorWrapper, cmdCtrlClass, isCmdOrCtrlPressed);
|
||||
const boundHandleClick = (event) => handleClick(event, linkClass, isCmdOrCtrlPressed);
|
||||
const boundHandleMouseEnter = (event) => handleMouseEnter(event, linkClass, linkHoverClass, boundUpdateCmdCtrlClass);
|
||||
const boundHandleMouseLeave = (event) => handleMouseLeave(event, linkClass, linkHoverClass);
|
||||
|
||||
// Create debounced version of markUrls
|
||||
const debouncedMarkUrls = debounce(() => {
|
||||
requestAnimationFrame(boundMarkUrls);
|
||||
}, 150);
|
||||
|
||||
// Initial URL marking
|
||||
boundMarkUrls();
|
||||
|
||||
// Set up event listeners
|
||||
editor.on('changes', debouncedMarkUrls);
|
||||
window.addEventListener('keydown', boundUpdateCmdCtrlClass);
|
||||
window.addEventListener('keyup', boundUpdateCmdCtrlClass);
|
||||
editorWrapper.addEventListener('click', boundHandleClick);
|
||||
editorWrapper.addEventListener('mouseover', boundHandleMouseEnter);
|
||||
editorWrapper.addEventListener('mouseout', boundHandleMouseLeave);
|
||||
|
||||
// Cleanup function to remove all event listeners
|
||||
editor._destroyLinkAware = () => {
|
||||
editor.off('changes', debouncedMarkUrls);
|
||||
window.removeEventListener('keydown', boundUpdateCmdCtrlClass);
|
||||
window.removeEventListener('keyup', boundUpdateCmdCtrlClass);
|
||||
editorWrapper.removeEventListener('click', boundHandleClick);
|
||||
editorWrapper.removeEventListener('mouseover', boundHandleMouseEnter);
|
||||
editorWrapper.removeEventListener('mouseout', boundHandleMouseLeave);
|
||||
};
|
||||
}
|
||||
|
||||
export { setupLinkAware };
|
||||
591
packages/bruno-app/src/utils/codemirror/linkAware.spec.js
Normal file
591
packages/bruno-app/src/utils/codemirror/linkAware.spec.js
Normal file
@@ -0,0 +1,591 @@
|
||||
import { setupLinkAware } from './linkAware';
|
||||
import LinkifyIt from 'linkify-it';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
// No need to mock CodeMirror since setupLinkAware works with an existing editor
|
||||
|
||||
// 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('setupLinkAware', () => {
|
||||
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
|
||||
mockWrapperElement = {
|
||||
classList: {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn()
|
||||
},
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
};
|
||||
|
||||
mockMark = {
|
||||
clear: jest.fn(),
|
||||
className: 'CodeMirror-link'
|
||||
};
|
||||
|
||||
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(),
|
||||
_destroyLinkAware: undefined
|
||||
};
|
||||
|
||||
mockLinkify = {
|
||||
match: jest.fn().mockReturnValue([
|
||||
{ index: 10, lastIndex: 28, url: 'https://example.com' },
|
||||
{ index: 33, lastIndex: 48, url: 'http://test.org' }
|
||||
])
|
||||
};
|
||||
|
||||
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 setup and configuration', () => {
|
||||
it('should set up link awareness on an existing editor', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
expect(mockEditor.getWrapperElement).toHaveBeenCalled();
|
||||
expect(mockEditor.on).toHaveBeenCalledWith('changes', expect.any(Function));
|
||||
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('click', expect.any(Function));
|
||||
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('mouseover', expect.any(Function));
|
||||
expect(mockWrapperElement.addEventListener).toHaveBeenCalledWith('mouseout', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should accept options parameter', () => {
|
||||
const options = { someOption: true };
|
||||
|
||||
setupLinkAware(mockEditor, options);
|
||||
|
||||
expect(mockEditor.getWrapperElement).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if editor is null', () => {
|
||||
const result = setupLinkAware(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockEditor.getWrapperElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add _destroyLinkAware method to editor', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
expect(mockEditor._destroyLinkAware).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('platform-specific behavior', () => {
|
||||
it('should use Cmd key hint on macOS', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
// Verify that markUrls was called which sets the hint
|
||||
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
attributes: expect.objectContaining({
|
||||
title: 'Hold Cmd and click to open link'
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
it('should use Ctrl key hint on non-macOS', () => {
|
||||
isMacOS.mockReturnValue(false);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
// Verify that markUrls was called which sets the hint
|
||||
expect(mockEditor.markText).toHaveBeenCalledWith(expect.anything(),
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
attributes: expect.objectContaining({
|
||||
title: 'Hold Ctrl and click to open link'
|
||||
})
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS class management', () => {
|
||||
it('should add cmd-ctrl-pressed class when modifier key is pressed', () => {
|
||||
isMacOS.mockReturnValue(true);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
// Clear the calls from initial setup
|
||||
mockEditor.getAllMarks.mockClear();
|
||||
requestAnimationFrame.mockClear();
|
||||
|
||||
// Simulate multiple rapid content changes
|
||||
const changeHandler = mockEditor.on.mock.calls.find((call) => call[0] === 'changes')[1];
|
||||
changeHandler();
|
||||
changeHandler();
|
||||
changeHandler();
|
||||
|
||||
// With debouncing, setTimeout should be called (lodash debounce uses it internally)
|
||||
// The exact number may vary, but we should see at least one call
|
||||
expect(setTimeout).toHaveBeenCalled();
|
||||
expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 150);
|
||||
|
||||
// Fast-forward timers
|
||||
jest.runAllTimers();
|
||||
|
||||
// Should only mark URLs once despite multiple rapid changes
|
||||
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
|
||||
expect(mockEditor.getAllMarks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should apply link tooltips when marking URLs', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
mockEditor._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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
const mouseoverHandler = mockWrapperElement.addEventListener.mock.calls.find((call) => call[0] === 'mouseover')[1];
|
||||
const mockEvent = { target: null };
|
||||
|
||||
// Note: This will throw as the implementation accesses target.classList without null check
|
||||
expect(() => mouseoverHandler(mockEvent)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle missing ipcRenderer', () => {
|
||||
delete global.window.ipcRenderer;
|
||||
isMacOS.mockReturnValue(true);
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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(() => setupLinkAware(mockEditor)).not.toThrow();
|
||||
// markText may still be called to clear existing marks
|
||||
});
|
||||
|
||||
it('should handle null siblings in mouseover events', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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', () => {
|
||||
setupLinkAware(mockEditor);
|
||||
|
||||
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