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; }