This commit is contained in:
Laurent Cozic
2026-02-03 10:28:56 +00:00
parent b37bc4e960
commit b85bf12a22
4 changed files with 106 additions and 43 deletions
+1
View File
@@ -247,6 +247,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.js
+1
View File
@@ -221,6 +221,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/index.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/normalizeAccelerator.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useContextMenu.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchExtension.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useEditorSearchHandler.js
@@ -0,0 +1,65 @@
import { getResourceIdFromMarkup } from './useContextMenu';
describe('useContextMenu', () => {
const resourceId = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4';
const resourceId2 = 'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5';
it('should return resource ID when cursor is inside markdown image', () => {
const line = `![alt text](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, 15)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, line.length - 1)).toBe(resourceId);
});
it('should return null when cursor is outside markdown image', () => {
const line = `Some text ![alt](:/${resourceId}) more text`;
expect(getResourceIdFromMarkup(line, 5)).toBeNull();
expect(getResourceIdFromMarkup(line, line.length - 5)).toBeNull();
});
it('should handle markdown image without alt text', () => {
const line = `![](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 5)).toBe(resourceId);
});
it('should return resource ID when cursor is inside HTML img tag', () => {
const line = `<img src=":/${resourceId}" />`;
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
});
it('should handle HTML img tag with additional attributes', () => {
const line = `<img alt="test" src=":/${resourceId}" width="100" />`;
expect(getResourceIdFromMarkup(line, 25)).toBe(resourceId);
});
it('should return null when cursor is outside HTML img tag', () => {
const line = `text <img src=":/${resourceId}" /> more`;
expect(getResourceIdFromMarkup(line, 2)).toBeNull();
expect(getResourceIdFromMarkup(line, line.length - 2)).toBeNull();
});
it('should return correct resource ID when multiple images on same line', () => {
const line = `![first](:/${resourceId}) ![second](:/${resourceId2})`;
expect(getResourceIdFromMarkup(line, 10)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, 50)).toBe(resourceId2);
});
it('should return null for empty line', () => {
expect(getResourceIdFromMarkup('', 0)).toBeNull();
});
it('should return null for line without images', () => {
expect(getResourceIdFromMarkup('Just some regular text', 10)).toBeNull();
});
it('should return null for non-resource links', () => {
const line = '![alt](https://example.com/image.png)';
expect(getResourceIdFromMarkup(line, 10)).toBeNull();
});
it('should handle cursor at exact boundaries of image markup', () => {
const line = `![a](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 0)).toBe(resourceId);
expect(getResourceIdFromMarkup(line, line.length)).toBe(resourceId);
});
});
@@ -16,6 +16,42 @@ import { menuItems } from '../../../utils/contextMenu';
import isItemId from '@joplin/lib/models/utils/isItemId';
import { extractResourceUrls } from '@joplin/lib/urlUtils';
// Extract resource ID from image markup at a given cursor position within a line.
// Returns the resource ID if the cursor is within an image markup, null otherwise.
export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: number): string | null => {
const resourceUrls = extractResourceUrls(lineContent);
if (!resourceUrls.length) return null;
for (const resourceInfo of resourceUrls) {
const resourcePattern = new RegExp(`[:](/?${resourceInfo.itemId})`, 'g');
let match;
while ((match = resourcePattern.exec(lineContent)) !== null) {
// Look backwards for ![ or <img
let markupStart = lineContent.lastIndexOf('![', match.index);
const imgTagStart = lineContent.lastIndexOf('<img', match.index);
if (imgTagStart > markupStart) markupStart = imgTagStart;
if (markupStart === -1) continue;
// Find the end of the markup
let markupEnd: number;
if (lineContent[markupStart] === '!') {
markupEnd = lineContent.indexOf(')', match.index);
if (markupEnd !== -1) markupEnd += 1;
} else {
markupEnd = lineContent.indexOf('>', match.index);
if (markupEnd !== -1) markupEnd += 1;
}
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
return resourceInfo.itemId;
}
}
}
return null;
};
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
const menuUtils = new MenuUtils(CommandService.instance());
@@ -94,8 +130,7 @@ const useContextMenu = (props: ContextMenuProps) => {
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
};
// Extract resource ID from image markup at cursor position
const getResourceIdFromMarkup = (): string | null => {
const getResourceIdAtCursor = (): string | null => {
if (!editorRef.current) return null;
const editor = editorRef.current.editor;
@@ -104,46 +139,7 @@ const useContextMenu = (props: ContextMenuProps) => {
const state = editor.state;
const cursorPos = state.selection.main.head;
const line = state.doc.lineAt(cursorPos);
const lineContent = line.text;
const cursorPosInLine = cursorPos - line.from;
// Get all resource URLs from the line
const resourceUrls = extractResourceUrls(lineContent);
if (!resourceUrls.length) return null;
// Find which resource (if any) the cursor is within
for (const resourceInfo of resourceUrls) {
// Find the position of this resource ID in the line
const resourcePattern = new RegExp(`[:](/?${resourceInfo.itemId})`, 'g');
let match;
while ((match = resourcePattern.exec(lineContent)) !== null) {
// Expand to find the full image markup containing this resource
// Look backwards for ![ or <img
let markupStart = lineContent.lastIndexOf('![', match.index);
const imgTagStart = lineContent.lastIndexOf('<img', match.index);
if (imgTagStart > markupStart) markupStart = imgTagStart;
if (markupStart === -1) continue;
// Find the end of the markup
let markupEnd: number;
if (lineContent[markupStart] === '!') {
// Markdown image: find closing )
markupEnd = lineContent.indexOf(')', match.index);
if (markupEnd !== -1) markupEnd += 1;
} else {
// HTML img: find closing >
markupEnd = lineContent.indexOf('>', match.index);
if (markupEnd !== -1) markupEnd += 1;
}
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
return resourceInfo.itemId;
}
}
}
return null;
return getResourceIdFromMarkup(line.text, cursorPos - line.from);
};
const showImageContextMenu = async (resourceId: string) => {
@@ -207,7 +203,7 @@ const useContextMenu = (props: ContextMenuProps) => {
}
// Check if right-clicking on image markup text
const markupResourceId = getResourceIdFromMarkup();
const markupResourceId = getResourceIdAtCursor();
if (markupResourceId && pointerInsideEditor(params)) {
event.preventDefault();
await showImageContextMenu(markupResourceId);