You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-03-12 10:00:05 +02:00
Compare commits
1 Commits
dev
...
fix_contex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d1601847 |
@@ -6,12 +6,18 @@ describe('useContextMenu', () => {
|
||||
|
||||
it('should return type=image when cursor is inside markdown image', () => {
|
||||
const line = ``;
|
||||
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 = ` [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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user