1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-03-12 10:00:05 +02:00

Compare commits

...

1 Commits

Author SHA1 Message Date
Laurent Cozic
68d1601847 update 2026-03-11 22:31:39 +00:00
3 changed files with 125 additions and 97 deletions

View File

@@ -6,12 +6,18 @@ describe('useContextMenu', () => {
it('should return type=image when cursor is inside markdown image', () => {
const line = `![alt text](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'image' });
const result = getResourceIdFromMarkup(line, 15);
expect(result.resourceId).toBe(resourceId);
expect(result.type).toBe('image');
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
});
it('should return type=file when cursor is inside markdown link', () => {
const line = `[document.pdf](:/${resourceId})`;
expect(getResourceIdFromMarkup(line, 15)).toEqual({ resourceId, type: 'file' });
const result = getResourceIdFromMarkup(line, 15);
expect(result.resourceId).toBe(resourceId);
expect(result.type).toBe('file');
expect(line.substring(result.markupStart, result.markupEnd)).toBe(line);
});
it('should return null when cursor is outside markup', () => {
@@ -22,8 +28,13 @@ describe('useContextMenu', () => {
it('should correctly distinguish between image and file on same line', () => {
const line = `![image](:/${resourceId}) [file](:/${resourceId2})`;
expect(getResourceIdFromMarkup(line, 10)).toEqual({ resourceId, type: 'image' });
expect(getResourceIdFromMarkup(line, 48)).toEqual({ resourceId: resourceId2, type: 'file' });
const imageResult = getResourceIdFromMarkup(line, 10);
expect(imageResult.resourceId).toBe(resourceId);
expect(imageResult.type).toBe('image');
const fileResult = getResourceIdFromMarkup(line, 48);
expect(fileResult.resourceId).toBe(resourceId2);
expect(fileResult.type).toBe('file');
});
it('should return null for empty line', () => {

View File

@@ -22,6 +22,8 @@ export type ResourceMarkupType = 'image' | 'file';
export interface ResourceMarkupInfo {
resourceId: string;
type: ResourceMarkupType;
markupStart: number;
markupEnd: number;
}
// Extract resource ID from resource markup (images or file attachments) at a given cursor position within a line.
@@ -74,7 +76,7 @@ export const getResourceIdFromMarkup = (lineContent: string, cursorPosInLine: nu
}
if (markupEnd !== -1 && cursorPosInLine >= markupStart && cursorPosInLine <= markupEnd) {
return { resourceId: resourceInfo.itemId, type: markupType };
return { resourceId: resourceInfo.itemId, type: markupType, markupStart, markupEnd };
}
}
}
@@ -161,28 +163,20 @@ const useContextMenu = (props: ContextMenuProps) => {
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
};
// Get resource info from markup at click position (not cursor position)
const getResourceInfoAtClickPos = (params: ContextMenuParams): ResourceMarkupInfo | null => {
if (!editorRef.current) return null;
const editor = editorRef.current.editor;
if (!editor) return null;
const zoom = Setting.value('windowContentZoomFactor');
const x = convertFromScreenCoordinates(zoom, params.x);
const y = convertFromScreenCoordinates(zoom, params.y);
const clickPos = editor.posAtCoords({ x, y });
if (clickPos === null) return null;
const line = editor.state.doc.lineAt(clickPos);
return getResourceIdFromMarkup(line.text, clickPos - line.from);
};
const targetWindow = bridge().windowById(windowId);
const appendEditMenuItems = (menu: typeof Menu.prototype) => {
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection();
menu.append(new MenuItem({ label: _('Cut'), enabled: hasSelectedText, click: () => props.editorCutText() }));
menu.append(new MenuItem({ label: _('Copy'), enabled: hasSelectedText, click: () => props.editorCopyText() }));
menu.append(new MenuItem({ label: _('Paste'), enabled: true, click: () => props.editorPaste() }));
menu.append(new MenuItem({ label: _('Paste as Markdown'), enabled: true, click: () => CommandService.instance().execute('pasteAsMarkdown') }));
};
const showResourceContextMenu = async (resourceId: string, type: ResourceMarkupType) => {
const menu = new Menu();
// Add resource-specific options first
const baseType = type === 'image' ? ContextMenuItemType.Image : ContextMenuItemType.Resource;
const itemType = await resolveContextMenuItemType(baseType, resourceId);
const contextMenuOptions: ContextMenuOptions = {
@@ -194,18 +188,34 @@ const useContextMenu = (props: ContextMenuProps) => {
linkToOpen: null,
textToCopy: null,
htmlToCopy: null,
insertContent: () => {},
isReadOnly: true,
insertContent: () => { editorRef.current?.insertText(''); },
isReadOnly: false,
fireEditorEvent: () => {},
htmlToMd: null,
mdToHtml: null,
};
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
const resourceMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions, { excludeEditItems: true, excludePluginItems: true });
for (const item of resourceMenuItems) {
menu.append(item);
}
// Add edit items
menu.append(new MenuItem({ type: 'separator' }));
appendEditMenuItems(menu);
// Add plugin items last
const extraItems = await handleEditorContextMenuFilter({
resourceId,
itemType,
});
if (extraItems.length) {
menu.append(new MenuItem({ type: 'separator' }));
for (const item of extraItems) {
menu.append(item);
}
}
menu.popup({ window: targetWindow });
};
@@ -227,7 +237,25 @@ const useContextMenu = (props: ContextMenuProps) => {
});
};
interface ResourceContextInfo {
resourceId: string;
type: ResourceMarkupType;
}
const getResourceInfoAtPos = (docPos: number): ResourceContextInfo | null => {
const editor = editorRef.current?.editor;
if (!editor) return null;
const line = editor.state.doc.lineAt(docPos);
const info = getResourceIdFromMarkup(line.text, docPos - line.from);
if (!info) return null;
return { resourceId: info.resourceId, type: info.type };
};
const onContextMenu = async (event: Event, params: ContextMenuParams) => {
let resourceInfo: ResourceContextInfo | null = null;
// Check if right-clicking on a rendered image first (images may not be "editable")
const imageContainer = getClickedImageContainer(params);
if (imageContainer && pointerInsideEditor(params, true)) {
@@ -235,19 +263,40 @@ const useContextMenu = (props: ContextMenuProps) => {
if (imgElement) {
const resourceId = pathToId(imgElement.src);
if (resourceId) {
event.preventDefault();
moveCursorToImageLine(imageContainer);
await showResourceContextMenu(resourceId, 'image');
return;
const sourceFrom = imageContainer.dataset.sourceFrom;
if (sourceFrom !== undefined) {
const editor = editorRef.current?.editor;
if (editor) {
const pos = Math.min(Number(sourceFrom), editor.state.doc.length);
resourceInfo = getResourceInfoAtPos(pos);
}
}
// Fallback if we couldn't get markup info
if (!resourceInfo) {
resourceInfo = { resourceId, type: 'image' };
}
}
}
}
// Check if right-clicking on resource markup text (images or file attachments)
const markupResourceInfo = getResourceInfoAtClickPos(params);
if (markupResourceInfo && pointerInsideEditor(params)) {
if (!resourceInfo && pointerInsideEditor(params)) {
const editor = editorRef.current?.editor;
if (editor) {
const zoom = Setting.value('windowContentZoomFactor');
const x = convertFromScreenCoordinates(zoom, params.x);
const y = convertFromScreenCoordinates(zoom, params.y);
const clickPos = editor.posAtCoords({ x, y });
if (clickPos !== null) {
resourceInfo = getResourceInfoAtPos(clickPos);
}
}
}
if (resourceInfo) {
event.preventDefault();
await showResourceContextMenu(markupResourceInfo.resourceId, markupResourceInfo.type);
await showResourceContextMenu(resourceInfo.resourceId, resourceInfo.type);
return;
}
@@ -258,48 +307,7 @@ const useContextMenu = (props: ContextMenuProps) => {
event.preventDefault();
const menu = new Menu();
const hasSelectedText = editorRef.current && !!editorRef.current.getSelection() ;
menu.append(
new MenuItem({
label: _('Cut'),
enabled: hasSelectedText,
click: async () => {
props.editorCutText();
},
}),
);
menu.append(
new MenuItem({
label: _('Copy'),
enabled: hasSelectedText,
click: async () => {
props.editorCopyText();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste'),
enabled: true,
click: async () => {
props.editorPaste();
},
}),
);
menu.append(
new MenuItem({
label: _('Paste as Markdown'),
enabled: true,
click: async () => {
await CommandService.instance().execute('pasteAsMarkdown');
},
}),
);
appendEditMenuItems(menu);
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);

View File

@@ -197,39 +197,48 @@ export const handleEditorContextMenuFilter = async (context?: EditorContextMenuF
return output;
};
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions) => {
export interface BuildMenuItemsOptions {
excludeEditItems?: boolean;
excludePluginItems?: boolean;
}
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions, buildOptions?: BuildMenuItemsOptions) => {
const editItemKeys = ['cut', 'copy', 'paste', 'pasteAsText', 'separator4'];
const activeItems: ContextMenuItem[] = [];
for (const itemKey in items) {
if (buildOptions?.excludeEditItems && editItemKeys.includes(itemKey)) continue;
const item = items[itemKey];
if (item.isActive(options.itemType, options)) {
activeItems.push(item);
}
}
const extraItems = await handleEditorContextMenuFilter({
resourceId: options.resourceId,
itemType: options.itemType,
textToCopy: options.textToCopy,
});
if (extraItems.length) {
activeItems.push({
isActive: () => true,
label: '',
onAction: () => {},
isSeparator: true,
if (!buildOptions?.excludePluginItems) {
const extraItems = await handleEditorContextMenuFilter({
resourceId: options.resourceId,
itemType: options.itemType,
textToCopy: options.textToCopy,
});
}
for (const [, extraItem] of extraItems.entries()) {
activeItems.push({
isActive: () => true,
label: extraItem.label,
onAction: () => {
extraItem.click();
},
isSeparator: extraItem.type === 'separator',
});
if (extraItems.length) {
activeItems.push({
isActive: () => true,
label: '',
onAction: () => {},
isSeparator: true,
});
}
for (const [, extraItem] of extraItems.entries()) {
activeItems.push({
isActive: () => true,
label: extraItem.label,
onAction: () => {
extraItem.click();
},
isSeparator: extraItem.type === 'separator',
});
}
}
const filteredItems = filterSeparators(activeItems, item => item.isSeparator);