一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
302 行
12 KiB
TypeScript
302 行
12 KiB
TypeScript
import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index';
|
|
import { Dispatch } from 'redux';
|
|
import { _ } from '@joplin/lib/locale';
|
|
import { copyHtmlToClipboard } from './clipboardUtils';
|
|
import bridge from '../../../services/bridge';
|
|
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng, svgDimensions, buildMenuItems, ContextMenuItem } from './contextMenuUtils';
|
|
const Menu = bridge().Menu;
|
|
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
|
|
import BaseItem from '@joplin/lib/models/BaseItem';
|
|
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
|
|
import { NoteEntity, ResourceEntity, ResourceOcrDriverId, ResourceOcrStatus } from '@joplin/lib/services/database/types';
|
|
import { TinyMceEditorEvents } from '../NoteBody/TinyMCE/utils/types';
|
|
import { itemIsReadOnlySync, ItemSlice } from '@joplin/lib/models/utils/readOnly';
|
|
import Setting from '@joplin/lib/models/Setting';
|
|
import ItemChange from '@joplin/lib/models/ItemChange';
|
|
import shim, { MessageBoxType } from '@joplin/lib/shim';
|
|
import { openFileWithExternalEditor } from '@joplin/lib/services/ExternalEditWatcher/utils';
|
|
import CommandService from '@joplin/lib/services/CommandService';
|
|
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
|
import * as fs from 'fs-extra';
|
|
import { clipboard } from 'electron';
|
|
import { toSystemSlashes } from '@joplin/lib/path-utils';
|
|
|
|
function handleCopyToClipboard(options: ContextMenuOptions) {
|
|
if (options.textToCopy) {
|
|
clipboard.writeText(options.textToCopy);
|
|
} else if (options.htmlToCopy) {
|
|
copyHtmlToClipboard(options.htmlToCopy);
|
|
}
|
|
}
|
|
|
|
async function saveFileData(data: string | NodeJS.ArrayBufferView, filename: string) {
|
|
const newFilePath = await bridge().showSaveDialog({ defaultPath: filename });
|
|
if (!newFilePath) return;
|
|
await fs.writeFile(newFilePath, data);
|
|
}
|
|
|
|
export async function openItemById(itemId: string, dispatch: Dispatch, hash = '') {
|
|
|
|
const item = await BaseItem.loadItemById(itemId);
|
|
|
|
if (!item) throw new Error(`No item with ID ${itemId}`);
|
|
|
|
if (item.type_ === BaseModel.TYPE_RESOURCE) {
|
|
const resource = item as ResourceEntity;
|
|
const localState = await Resource.localState(resource);
|
|
if (localState.fetch_status !== Resource.FETCH_STATUS_DONE || !!resource.encryption_blob_encrypted) {
|
|
if (localState.fetch_status === Resource.FETCH_STATUS_ERROR) {
|
|
bridge().showErrorMessageBox(`${_('There was an error downloading this attachment:')}\n\n${localState.fetch_error}`);
|
|
} else {
|
|
bridge().showErrorMessageBox(_('This attachment is not downloaded or not decrypted yet'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (itemIsReadOnlySync(ModelType.Resource, ItemChange.SOURCE_UNSPECIFIED, resource as ItemSlice, Setting.value('sync.userId'), BaseItem.syncShareCache)) {
|
|
await ResourceEditWatcher.instance().openAsReadOnly(resource.id);
|
|
} else {
|
|
await ResourceEditWatcher.instance().openAndWatch(resource.id);
|
|
}
|
|
} catch (error) {
|
|
console.error(error);
|
|
bridge().showErrorMessageBox(error.message);
|
|
}
|
|
} else if (item.type_ === BaseModel.TYPE_NOTE) {
|
|
const note = item as NoteEntity;
|
|
|
|
dispatch({
|
|
type: 'FOLDER_AND_NOTE_SELECT',
|
|
folderId: note.parent_id,
|
|
noteId: note.id,
|
|
hash,
|
|
});
|
|
} else {
|
|
throw new Error(`Unsupported item type: ${item.type_}`);
|
|
}
|
|
}
|
|
|
|
export function menuItems(dispatch: Dispatch): ContextMenuItems {
|
|
const makeSeparator = (): ContextMenuItem => {
|
|
return {
|
|
isActive: () => { return true; },
|
|
label: '',
|
|
onAction: () => {},
|
|
isSeparator: true,
|
|
};
|
|
};
|
|
|
|
return {
|
|
open: {
|
|
label: _('Open...'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
if (options.resourceId) {
|
|
await openItemById(options.resourceId, dispatch);
|
|
} else if (options.linkToOpen) {
|
|
await CommandService.instance().execute('openItem', options.linkToOpen);
|
|
} else {
|
|
await shim.showErrorDialog('No link found');
|
|
}
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => (
|
|
(!options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource || itemType === ContextMenuItemType.NoteLink))
|
|
|| (!!options.linkToOpen && itemType === ContextMenuItemType.Link)
|
|
),
|
|
},
|
|
openNoteInNewWindow: {
|
|
label: _('Open in new window'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
await CommandService.instance().execute('openNoteInNewWindow', options.resourceId);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.NoteLink,
|
|
},
|
|
saveAs: {
|
|
label: _('Save as...'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
const { resourcePath, resource } = await resourceInfo(options);
|
|
const filePath = await bridge().showSaveDialog({
|
|
defaultPath: resource.filename ? resource.filename : resource.title,
|
|
});
|
|
if (!filePath) return;
|
|
await fs.copy(resourcePath, filePath);
|
|
},
|
|
// We handle images received as text separately as it can be saved in multiple formats
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource),
|
|
},
|
|
saveAsSvg: {
|
|
label: _('Save as %s', 'SVG'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
await saveFileData(options.textToCopy, options.filename);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
|
|
},
|
|
saveAsPng: {
|
|
label: _('Save as %s', 'PNG'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
// First convert it to png then save
|
|
if (options.mime !== 'image/svg+xml') {
|
|
throw new Error(`Unsupported image type: ${options.mime}`);
|
|
}
|
|
if (!options.filename) {
|
|
throw new Error('Filename is needed to save as png');
|
|
}
|
|
// double dimensions to make sure it's always big enough even on hdpi screens
|
|
const [width, height] = svgDimensions(document, options.textToCopy).map((x: number) => x * 2 || undefined);
|
|
const dataUri = textToDataUri(options.textToCopy, options.mime);
|
|
const png = await svgUriToPng(document, dataUri, width, height);
|
|
const filename = options.filename.replace('.svg', '.png');
|
|
await saveFileData(png, filename);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !!options.textToCopy && itemType === ContextMenuItemType.Image && options.mime?.startsWith('image/svg'),
|
|
},
|
|
separator1: makeSeparator(),
|
|
revealInFolder: {
|
|
label: _('Reveal file in folder'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
const { resourcePath } = await resourceInfo(options);
|
|
bridge().showItemInFolder(resourcePath);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && (itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource),
|
|
},
|
|
separator2: makeSeparator(),
|
|
recognizeHandwrittenImage: {
|
|
label: _('Recognize handwritten image'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
const syncTargetId = Setting.value('sync.target');
|
|
if (!SyncTargetRegistry.isJoplinServerOrCloud(syncTargetId)) {
|
|
await shim.showMessageBox(_('This feature is only available on Joplin Cloud and Joplin Server.'), { type: MessageBoxType.Error });
|
|
return;
|
|
}
|
|
|
|
if (!Setting.value('ocr.handwrittenTextDriverEnabled')) {
|
|
await shim.showMessageBox(_('This feature is disabled by default, you need to manually enable it by turning on the option to \'Enable handwritten transcription\'.'), { type: MessageBoxType.Error });
|
|
return;
|
|
}
|
|
|
|
const { resource } = await resourceInfo(options);
|
|
|
|
if (!['image/png', 'image/jpg', 'image/jpeg', 'image/bmp'].includes(resource.mime)) {
|
|
await shim.showMessageBox(_('This image type is not supported by the recognition system.'), { type: MessageBoxType.Error });
|
|
return;
|
|
}
|
|
|
|
await Resource.save({
|
|
id: resource.id,
|
|
ocr_status: ResourceOcrStatus.Todo,
|
|
ocr_driver_id: ResourceOcrDriverId.HandwrittenText,
|
|
ocr_details: '',
|
|
ocr_error: '',
|
|
ocr_text: '',
|
|
});
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
|
|
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && !!options.resourceId);
|
|
},
|
|
},
|
|
copyOcrText: {
|
|
label: _('View OCR text'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
const { resource } = await resourceInfo(options);
|
|
|
|
if (resource.ocr_status === ResourceOcrStatus.Done) {
|
|
const tempFilePath = `${Setting.value('tempDir')}/${resource.id}_ocr.txt`;
|
|
await shim.fsDriver().writeFile(tempFilePath, resource.ocr_text, 'utf8');
|
|
await openFileWithExternalEditor(tempFilePath, bridge());
|
|
} else {
|
|
bridge().showInfoMessageBox(_('This attachment does not have OCR data (Status: %s)', resourceOcrStatusToString(resource.ocr_status)));
|
|
}
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
|
|
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && !!options.resourceId);
|
|
},
|
|
},
|
|
createAccessibleDocument: {
|
|
label: _('Create accessible document'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
const { resource } = await resourceInfo(options);
|
|
await CommandService.instance().execute('createAccessibleDocument', resource.id);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => {
|
|
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && !!options.resourceId);
|
|
},
|
|
},
|
|
separator3: makeSeparator(),
|
|
copyPathToClipboard: {
|
|
label: _('Copy path to clipboard'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
let path = '';
|
|
if (options.textToCopy && options.mime) {
|
|
path = textToDataUri(options.textToCopy, options.mime);
|
|
} else {
|
|
const { resourcePath } = await resourceInfo(options);
|
|
if (resourcePath) path = toSystemSlashes(resourcePath);
|
|
}
|
|
if (!path) return;
|
|
clipboard.writeText(path);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType) => itemType === ContextMenuItemType.Image || itemType === ContextMenuItemType.Resource,
|
|
},
|
|
copyImage: {
|
|
label: _('Copy image'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
const { resourcePath } = await resourceInfo(options);
|
|
const image = bridge().createImageFromPath(resourcePath);
|
|
clipboard.writeImage(image);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.textToCopy && itemType === ContextMenuItemType.Image,
|
|
},
|
|
copyLinkUrl: {
|
|
label: _('Copy Link Address'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
clipboard.writeText(options.linkToCopy !== null ? options.linkToCopy : options.textToCopy);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType === ContextMenuItemType.Link || !!options.linkToCopy,
|
|
},
|
|
separator4: makeSeparator(),
|
|
cut: {
|
|
label: _('Cut'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
handleCopyToClipboard(options);
|
|
options.insertContent('');
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType !== ContextMenuItemType.Image && (!options.isReadOnly && (!!options.textToCopy || !!options.htmlToCopy)),
|
|
},
|
|
copy: {
|
|
label: _('Copy'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
handleCopyToClipboard(options);
|
|
},
|
|
isActive: (itemType: ContextMenuItemType, options: ContextMenuOptions) => itemType !== ContextMenuItemType.Image && (!!options.textToCopy || !!options.htmlToCopy),
|
|
},
|
|
paste: {
|
|
label: _('Paste'),
|
|
onAction: async (_options: ContextMenuOptions) => {
|
|
bridge().activeWindow().webContents.paste();
|
|
},
|
|
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && clipboard.availableFormats().length > 0,
|
|
},
|
|
pasteAsText: {
|
|
label: _('Paste as text'),
|
|
onAction: async (options: ContextMenuOptions) => {
|
|
options.fireEditorEvent(TinyMceEditorEvents.PasteAsText);
|
|
},
|
|
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && (!!clipboard.readText() || !!clipboard.readHTML()),
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function contextMenu(options: ContextMenuOptions, dispatch: Dispatch) {
|
|
const menu = new Menu();
|
|
|
|
if (!('readyOnly' in options)) options.isReadOnly = true;
|
|
|
|
const items = await buildMenuItems(menuItems(dispatch), options);
|
|
|
|
for (const item of items) {
|
|
menu.append(item);
|
|
}
|
|
|
|
return menu;
|
|
}
|