1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-02-10 08:14:27 +02:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Laurent Cozic
de7770c79a update 2026-02-09 16:15:11 +00:00
Laurent Cozic
8c214d419f update 2026-02-09 16:08:11 +00:00
36 changed files with 4364 additions and 2689 deletions

View File

@@ -248,7 +248,6 @@ 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
.gitignore vendored
View File

@@ -221,7 +221,6 @@ 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

View File

@@ -1,22 +1,27 @@
# Plugin development
# generator-joplin
This documentation describes how to create a plugin, and how to work with the plugin builder framework and API.
Scaffolds out a new Joplin plugin
## Installation
First, install [Yeoman](http://yeoman.io) and generator-joplin using [npm](https://www.npmjs.com/) (we assume you have pre-installed [node.js](https://nodejs.org/)).
```bash
npm install -g yo@4.3.1
npm install -g yo
npm install -g generator-joplin
```
Then generate your new project:
```bash
yo --node-package-manager npm joplin
yo joplin
```
## Development
To test the generator for development purposes, follow the instructions there: https://yeoman.io/authoring/#running-the-generator
This is a template to create a new Joplin plugin.
## Structure
The main two files you will want to look at are:
@@ -34,10 +39,6 @@ To build the plugin, simply run `npm run dist`.
The project is setup to use TypeScript, although you can change the configuration to use plain JavaScript.
## Updating the manifest version number
You can run `npm run updateVersion` to bump the patch part of the version number, so for example 1.0.3 will become 1.0.4. This script will update both the package.json and manifest.json version numbers so as to keep them in sync.
## Publishing the plugin
To publish the plugin, add it to npmjs.com by running `npm publish`. Later on, a script will pick up your plugin and add it automatically to the Joplin plugin repository as long as the package satisfies these conditions:
@@ -66,13 +67,6 @@ By default, the compiler (webpack) is going to compile `src/index.ts` only (as w
To get such an external script file to compile, you need to add it to the `extraScripts` array in `plugin.config.json`. The path you add should be relative to /src. For example, if you have a file in "/src/webviews/index.ts", the path should be set to "webviews/index.ts". Once compiled, the file will always be named with a .js extension. So you will get "webviews/index.js" in the plugin package, and that's the path you should use to reference the file.
## More information
- [Joplin Plugin API](https://joplinapp.org/api/references/plugin_api/classes/joplin.html)
- [Joplin Data API](https://joplinapp.org/help/api/references/rest_api)
- [Joplin Plugin Manifest](https://joplinapp.org/api/references/plugin_manifest/)
- Ask for help on the [forum](https://discourse.joplinapp.org/) or our [Discord channel](https://discord.gg/VSj7AFHvpq)
## License
MIT © Laurent Cozic

View File

@@ -73,8 +73,4 @@ export default class Joplin {
*/
require(_path: string): any;
versionInfo(): Promise<import("./types").VersionInfo>;
/**
* Tells whether the current theme is a dark one or not.
*/
shouldUseDarkColors(): Promise<boolean>;
}

View File

@@ -1,4 +1,3 @@
import { ClipboardContent } from './types';
export default class JoplinClipboard {
private electronClipboard_;
private electronNativeImage_;
@@ -27,19 +26,4 @@ export default class JoplinClipboard {
* For example [ 'text/plain', 'text/html' ]
*/
availableFormats(): Promise<string[]>;
/**
* Writes multiple formats to the clipboard simultaneously.
* This allows setting both text/plain and text/html at the same time.
*
* <span class="platform-desktop">desktop</span>
*
* @example
* ```typescript
* await joplin.clipboard.write({
* text: 'Plain text version',
* html: '<strong>HTML version</strong>'
* });
* ```
*/
write(content: ClipboardContent): Promise<void>;
}

View File

@@ -14,7 +14,7 @@ import Plugin from '../Plugin';
* now, are not well documented. You can find the list directly on GitHub
* though at the following locations:
*
* * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/WindowCommandsAndDialogs/commands)
* * [Main screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/MainScreen/commands)
* * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/commands)
* * [Editor commands](https://github.com/laurent22/joplin/tree/dev/packages/app-desktop/gui/NoteEditor/editorCommandDeclarations.ts)
*
@@ -25,13 +25,8 @@ import Plugin from '../Plugin';
* commands can be found in these places:
*
* * [Global commands](https://github.com/laurent22/joplin/tree/dev/packages/app-mobile/commands)
* * [Note screen commands](https://github.com/laurent22/joplin/tree/dev/packages/app-mobile/components/screens/Note/commands)
* * [Editor commands](https://github.com/laurent22/joplin/blob/dev/packages/app-mobile/components/NoteEditor/commandDeclarations.ts)
*
* Additionally, certain global commands have the same implementation on both platforms:
*
* * [Shared global commands](https://github.com/laurent22/joplin/tree/dev/packages/lib/commands)
*
* ## Executing editor commands
*
* There might be a situation where you want to invoke editor commands

View File

@@ -42,11 +42,9 @@ export default class JoplinSettings {
*/
values(keys: string[] | string): Promise<Record<string, unknown>>;
/**
* Gets a setting value (only applies to setting you registered from your plugin).
* @deprecated Use joplin.settings.values()
*
* Note: If you want to retrieve all your plugin settings, for example when the plugin starts,
* it is recommended to use the `values()` function instead - it will be much faster than
* calling `value()` multiple times.
* Gets a setting value (only applies to setting you registered from your plugin)
*/
value(key: string): Promise<any>;
/**
@@ -54,15 +52,11 @@ export default class JoplinSettings {
*/
setValue(key: string, value: any): Promise<void>;
/**
* Gets global setting values, including app-specific settings and those set by other plugins.
* Gets a global setting value, including app-specific settings and those set by other plugins.
*
* The list of available settings is not documented yet, but can be found by looking at the source code:
*
* https://github.com/laurent22/joplin/blob/dev/packages/lib/models/settings/builtInMetadata.ts
*/
globalValues(keys: string[]): Promise<any[]>;
/**
* @deprecated Use joplin.settings.globalValues()
* https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142
*/
globalValue(key: string): Promise<any>;
/**

View File

@@ -9,17 +9,8 @@ import JoplinViewsEditors from './JoplinViewsEditor';
/**
* This namespace provides access to view-related services.
*
* ## Creating a view
*
* All view services provide a `create()` method which you would use to create the view object,
* whether it's a dialog, a toolbar button or a menu item. In some cases, the `create()` method will
* return a [[ViewHandle]], which you would use to act on the view, for example to set certain
* properties or call some methods.
*
* ## The `webviewApi` object
*
* Within a view, you can use the global object `webviewApi` for various utility functions, such as
* sending messages or displaying context menu. Refer to [[WebviewApi]] for the full documentation.
* All view services provide a `create()` method which you would use to create the view object, whether it's a dialog, a toolbar button or a menu item.
* In some cases, the `create()` method will return a [[ViewHandle]], which you would use to act on the view, for example to set certain properties or call some methods.
*/
export default class JoplinViews {
private store;

View File

@@ -1,5 +1,5 @@
import Plugin from '../Plugin';
import { ButtonSpec, ViewHandle, DialogResult, Toast } from './types';
import { ButtonSpec, ViewHandle, DialogResult } from './types';
/**
* Allows creating and managing dialogs. A dialog is modal window that
* contains a webview and a row of buttons. You can update the
@@ -43,10 +43,6 @@ export default class JoplinViewsDialogs {
* Displays a message box with OK/Cancel buttons. Returns the button index that was clicked - "0" for OK and "1" for "Cancel"
*/
showMessageBox(message: string): Promise<number>;
/**
* Displays a Toast notification in the corner of the application screen.
*/
showToast(toast: Toast): Promise<void>;
/**
* Displays a dialog to select a file or a directory. Same options and
* output as

View File

@@ -1,18 +1,5 @@
import Plugin from '../Plugin';
import { ActivationCheckCallback, ViewHandle, UpdateCallback, EditorPluginCallbacks } from './types';
interface SaveNoteOptions {
/**
* The ID of the note to save. This should match either:
* - The ID of the note currently being edited
* - The ID of a note that was very recently open in the editor.
*
* This property is present to ensure that the note editor doesn't write
* to the wrong note just after switching notes.
*/
noteId: string;
/** The note's new content. */
body: string;
}
import { ActivationCheckCallback, ViewHandle, UpdateCallback } from './types';
/**
* Allows creating alternative note editors. You can create a view to handle loading and saving the
* note, and do your own rendering.
@@ -54,18 +41,10 @@ export default class JoplinViewsEditors {
private store;
private plugin;
private activationCheckHandlers_;
private unhandledActivationCheck_;
constructor(plugin: Plugin, store: any);
private controller;
/**
* Registers a new editor plugin. Joplin will call the provided callback to create new editor views
* associated with the plugin as necessary (e.g. when a new editor is created in a new window).
*/
register(viewId: string, callbacks: EditorPluginCallbacks): Promise<void>;
/**
* Creates a new editor view
*
* @deprecated
*/
create(id: string): Promise<ViewHandle>;
/**
@@ -81,21 +60,14 @@ export default class JoplinViewsEditors {
*/
onMessage(handle: ViewHandle, callback: Function): Promise<void>;
/**
* Saves the content of the editor, without calling `onUpdate` for editors in the same window.
*/
saveNote(handle: ViewHandle, props: SaveNoteOptions): Promise<void>;
/**
* Emitted when the editor can potentially be activated - this is for example when the current
* note is changed, or when the application is opened. At that point you should check the
* current note and decide whether your editor should be activated or not. If it should, return
* `true`, otherwise return `false`.
*
* @deprecated - `onActivationCheck` should be provided when the editor is first created with
* `editor.register`.
* Emitted when the editor can potentially be activated - this for example when the current note
* is changed, or when the application is opened. At that point should can check the current
* note and decide whether your editor should be activated or not. If it should return `true`,
* otherwise return `false`.
*/
onActivationCheck(handle: ViewHandle, callback: ActivationCheckCallback): Promise<void>;
/**
* Emitted when your editor content should be updated. This is for example when the currently
* Emitted when the editor content should be updated. This for example when the currently
* selected note changes, or when the user makes the editor visible.
*/
onUpdate(handle: ViewHandle, callback: UpdateCallback): Promise<void>;
@@ -114,4 +86,3 @@ export default class JoplinViewsEditors {
*/
isVisible(handle: ViewHandle): Promise<boolean>;
}
export {};

View File

@@ -80,9 +80,5 @@ export default class JoplinViewsPanels {
* Tells whether the panel is visible or not
*/
visible(handle: ViewHandle): Promise<boolean>;
/**
* Assuming that the current panel is an editor plugin view, returns
* whether the editor plugin view supports editing the current note.
*/
isActive(handle: ViewHandle): Promise<boolean>;
}

View File

@@ -80,8 +80,6 @@ export default class JoplinWorkspace {
filterEditorContextMenu(handler: FilterHandler<EditContextMenuFilterObject>): void;
/**
* Gets the currently selected note. Will be `null` if no note is selected.
*
* On desktop, this returns the selected note in the focused window.
*/
selectedNote(): Promise<any>;
/**
@@ -95,12 +93,5 @@ export default class JoplinWorkspace {
* Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes.
*/
selectedNoteIds(): Promise<string[]>;
/**
* Gets the last hash (note section ID) from cross-note link targeting specific section.
* New hash is available after `onNoteSelectionChange()` is triggered.
* Example of cross-note link where `hello-world` is a hash: [Other Note Title](:/9bc9a5cb83f04554bf3fd3e41b4bb415#hello-world).
* Method returns empty value when a note was navigated with method other than cross-note link containing valid hash.
*/
selectedNoteHash(): Promise<string>;
}
export {};

View File

@@ -372,19 +372,6 @@ export interface DialogResult {
formData?: any;
}
export enum ToastType {
Info = 'info',
Success = 'success',
Error = 'error',
}
export interface Toast {
message: string;
type?: ToastType;
duration?: number;
timestamp?: number;
}
export interface Size {
width?: number;
height?: number;
@@ -397,40 +384,9 @@ export interface Rectangle {
height?: number;
}
export interface EditorUpdateEvent {
newBody: string;
noteId: string;
}
export type UpdateCallback = (event: EditorUpdateEvent)=> Promise<void>;
export type ActivationCheckCallback = ()=> Promise<boolean>;
export interface ActivationCheckEvent {
handle: ViewHandle;
noteId: string;
}
export type ActivationCheckCallback = (event: ActivationCheckEvent)=> Promise<boolean>;
/**
* Required callbacks for creating an editor plugin.
*/
export interface EditorPluginCallbacks {
/**
* Emitted when the editor can potentially be activated - this is for example when the current
* note is changed, or when the application is opened. At that point you should check the
* current note and decide whether your editor should be activated or not. If it should, return
* `true`, otherwise return `false`.
*/
onActivationCheck: ActivationCheckCallback;
/**
* Emitted when an editor view is created. This happens, for example, when a new window containing
* a new editor is created.
*
* This callback should set the editor plugin's HTML using `editors.setHtml`, add scripts to the editor
* with `editors.addScript`, and optionally listen for external changes using `editors.onUpdate`.
*/
onSetup: (handle: ViewHandle)=> Promise<void>;
}
export type UpdateCallback = ()=> Promise<void>;
export type VisibleHandler = ()=> Promise<void>;
@@ -439,8 +395,6 @@ export interface EditContextMenuFilterObject {
}
export interface EditorActivationCheckFilterObject {
effectiveNoteId: string;
windowId: string;
activatedEditors: {
pluginId: string;
viewId: string;
@@ -450,20 +404,6 @@ export interface EditorActivationCheckFilterObject {
export type FilterHandler<T> = (object: T)=> Promise<T>;
export type CommandArgument = string|number|object|boolean|null;
export interface MenuTemplateItem {
label?: string;
command?: string;
commandArgs?: CommandArgument[];
}
export interface WebviewApi {
postMessage: (message: object)=> unknown;
onMessage: (message: object)=> void;
menuPopupFromTemplate: (template: MenuTemplateItem[])=> void;
}
// =================================================================
// Settings types
// =================================================================
@@ -588,30 +528,6 @@ export interface SettingSection {
*/
export type Path = string[];
// =================================================================
// Clipboard API types
// =================================================================
/**
* Represents content that can be written to the clipboard in multiple formats.
*/
export interface ClipboardContent {
/**
* Plain text representation of the content
*/
text?: string;
/**
* HTML representation of the content
*/
html?: string;
/**
* Image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format
*/
image?: string;
}
// =================================================================
// Content Script types
// =================================================================
@@ -693,27 +609,6 @@ export interface CodeMirrorControl {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any };
/**
* A CodeMirror [facet](https://codemirror.net/docs/ref/#state.EditorState.facet) that contains
* the ID of the note currently open in the editor.
*
* Access the value of this facet using
* ```ts
* const noteIdFacet = editorControl.joplinExtensions.noteIdFacet;
* const editorState = editorControl.editor.state;
* const noteId = editorState.facet(noteIdFacet);
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No better type available
noteIdFacet: any;
/**
* A CodeMirror [StateEffect](https://codemirror.net/docs/ref/#state.StateEffect) that is
* included in a [Transaction](https://codemirror.net/docs/ref/#state.Transaction) when the
* note ID changes.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- No better type available
setNoteIdEffect: any;
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,26 +3,25 @@
"version": "1.0.0",
"description": "",
"scripts": {
"dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && webpack --env joplin-plugin-config=createArchive",
"dist": "webpack --joplin-plugin-config buildMain && webpack --joplin-plugin-config buildExtraScripts && webpack --joplin-plugin-config createArchive",
"prepare": "npm run dist",
"update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force",
"updateVersion": "webpack --env joplin-plugin-config=updateVersion"
"update": "npm install -g generator-joplin && yo joplin --update"
},
"keywords": [
"joplin-plugin"
],
"license": "MIT",
"devDependencies": {
"@types/node": "^18.7.13",
"copy-webpack-plugin": "^11.0.0",
"fs-extra": "^10.1.0",
"glob": "^8.0.3",
"@types/node": "^14.0.14",
"copy-webpack-plugin": "^6.1.0",
"fs-extra": "^9.0.1",
"glob": "^7.1.6",
"on-build-webpack": "^0.1.0",
"tar": "^6.1.11",
"ts-loader": "^9.3.1",
"typescript": "^4.8.2",
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"tar": "^6.0.5",
"ts-loader": "^7.0.5",
"typescript": "^3.9.3",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"chalk": "^4.1.0",
"yargs": "^16.2.0"
},

View File

@@ -1,5 +1,4 @@
import joplin from 'api';
import { MenuItem } from 'api/types';
joplin.plugins.register({
onStart: async function() {
@@ -22,29 +21,5 @@ joplin.plugins.register({
],
},
]);
await joplin.workspace.filterEditorContextMenu(async (object: any) => {
const newItems: MenuItem[] = [];
newItems.push({
label: 'filterEditorContextMenu test 1',
commandName: 'newNote',
commandArgs: ['Created from context menu 1'],
});
newItems.push({
type: 'separator',
});
newItems.push({
label: 'filterEditorContextMenu test 2',
commandName: 'newNote',
commandArgs: ['Created from context menu 2'],
});
object.items = object.items.concat(newItems);
return object;
});
},
});

View File

@@ -5,6 +5,9 @@
"target": "es2015",
"jsx": "react",
"allowJs": true,
"baseUrl": "."
"baseUrl": ".",
"typeRoots": [
"./node_modules/@types"
]
}
}

View File

@@ -6,21 +6,16 @@
// update, you can easily restore the functionality you've added.
// -----------------------------------------------------------------------------
/* eslint-disable no-console */
const path = require('path');
const crypto = require('crypto');
const fs = require('fs-extra');
const chalk = require('chalk');
const CopyPlugin = require('copy-webpack-plugin');
const WebpackOnBuildPlugin = require('on-build-webpack');
const tar = require('tar');
const glob = require('glob');
const execSync = require('child_process').execSync;
// AUTO-GENERATED by updateCategories
const allPossibleCategories = [{ 'name': 'appearance' }, { 'name': 'developer tools' }, { 'name': 'productivity' }, { 'name': 'themes' }, { 'name': 'integrations' }, { 'name': 'viewer' }, { 'name': 'search' }, { 'name': 'tags' }, { 'name': 'editor' }, { 'name': 'files' }, { 'name': 'personal knowledge management' }];
// AUTO-GENERATED by updateCategories
const rootDir = path.resolve(__dirname);
const userConfigFilename = './plugin.config.json';
const userConfigPath = path.resolve(rootDir, userConfigFilename);
@@ -28,34 +23,19 @@ const distDir = path.resolve(rootDir, 'dist');
const srcDir = path.resolve(rootDir, 'src');
const publishDir = path.resolve(rootDir, 'publish');
const userConfig = {
const userConfig = Object.assign({}, {
extraScripts: [],
...(fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}),
};
}, fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {});
const manifestPath = `${srcDir}/manifest.json`;
const packageJsonPath = `${rootDir}/package.json`;
const allPossibleScreenshotsType = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
const allPossibleCategories = ['appearance', 'developer tools', 'productivity', 'themes', 'integrations', 'viewer', 'search', 'tags', 'editor', 'files', 'personal knowledge management'];
const manifest = readManifest(manifestPath);
const pluginArchiveFilePath = path.resolve(publishDir, `${manifest.id}.jpl`);
const pluginInfoFilePath = path.resolve(publishDir, `${manifest.id}.json`);
const { builtinModules } = require('node:module');
// Webpack5 doesn't polyfill by default and displays a warning when attempting to require() built-in
// node modules. Set these to false to prevent Webpack from warning about not polyfilling these modules.
// We don't need to polyfill because the plugins run in Electron's Node environment.
const moduleFallback = {};
for (const moduleName of builtinModules) {
moduleFallback[moduleName] = false;
}
const getPackageJson = () => {
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
};
function validatePackageJson() {
const content = getPackageJson();
const content = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!content.name || content.name.indexOf('joplin-plugin-') !== 0) {
console.warn(chalk.yellow(`WARNING: To publish the plugin, the package name should start with "joplin-plugin-" (found "${content.name}") in ${packageJsonPath}`));
}
@@ -91,45 +71,21 @@ function currentGitInfo() {
function validateCategories(categories) {
if (!categories) return null;
if ((categories.length !== new Set(categories).size)) throw new Error('Repeated categories are not allowed');
// eslint-disable-next-line github/array-foreach -- Old code before rule was applied
categories.forEach(category => {
if (!allPossibleCategories.map(category => { return category.name; }).includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid categories are: \n${allPossibleCategories.map(category => { return category.name; })}\n`);
if (!allPossibleCategories.includes(category)) throw new Error(`${category} is not a valid category. Please make sure that the category name is lowercase. Valid Categories are: \n${allPossibleCategories}\n`);
});
}
function validateScreenshots(screenshots) {
if (!screenshots) return null;
for (const screenshot of screenshots) {
if (!screenshot.src) throw new Error('You must specify a src for each screenshot');
// Avoid attempting to download and verify URL screenshots.
if (screenshot.src.startsWith('https://') || screenshot.src.startsWith('http://')) {
continue;
}
const screenshotType = screenshot.src.split('.').pop();
if (!allPossibleScreenshotsType.includes(screenshotType)) throw new Error(`${screenshotType} is not a valid screenshot type. Valid types are: \n${allPossibleScreenshotsType}\n`);
const screenshotPath = path.resolve(rootDir, screenshot.src);
// Max file size is 1MB
const fileMaxSize = 1024;
const fileSize = fs.statSync(screenshotPath).size / 1024;
if (fileSize > fileMaxSize) throw new Error(`Max screenshot file size is ${fileMaxSize}KB. ${screenshotPath} is ${fileSize}KB`);
}
}
function readManifest(manifestPath) {
const content = fs.readFileSync(manifestPath, 'utf8');
const output = JSON.parse(content);
if (!output.id) throw new Error(`Manifest plugin ID is not set in ${manifestPath}`);
validateCategories(output.categories);
validateScreenshots(output.screenshots);
return output;
}
function createPluginArchive(sourceDir, destPath) {
const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true, windowsPathsNoEscape: true })
const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true })
.map(f => f.substr(sourceDir.length + 1));
if (!distFiles.length) throw new Error('Plugin archive was not created because the "dist" directory is empty');
@@ -143,22 +99,18 @@ function createPluginArchive(sourceDir, destPath) {
cwd: sourceDir,
sync: true,
},
distFiles,
distFiles
);
console.info(chalk.cyan(`Plugin archive has been created in ${destPath}`));
}
const writeManifest = (manifestPath, content) => {
fs.writeFileSync(manifestPath, JSON.stringify(content, null, '\t'), 'utf8');
};
function createPluginInfo(manifestPath, destPath, jplFilePath) {
const contentText = fs.readFileSync(manifestPath, 'utf8');
const content = JSON.parse(contentText);
content._publish_hash = `sha256:${fileSha256(jplFilePath)}`;
content._publish_commit = currentGitInfo();
writeManifest(destPath, content);
fs.writeFileSync(destPath, JSON.stringify(content, null, '\t'), 'utf8');
}
function onBuildCompleted() {
@@ -185,15 +137,14 @@ const baseConfig = {
},
],
},
...userConfig.webpackOverrides,
};
const pluginConfig = { ...baseConfig, entry: './src/index.ts',
const pluginConfig = Object.assign({}, baseConfig, {
entry: './src/index.ts',
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
fallback: moduleFallback,
// JSON files can also be required from scripts so we include this.
// https://github.com/joplin/plugin-bibtex/pull/2
extensions: ['.js', '.tsx', '.ts', '.json'],
@@ -220,63 +171,26 @@ const pluginConfig = { ...baseConfig, entry: './src/index.ts',
},
],
}),
] };
],
});
// These libraries can be included with require(...) or
// joplin.require(...) from content scripts.
const externalContentScriptLibraries = [
'@codemirror/view',
'@codemirror/state',
'@codemirror/search',
'@codemirror/language',
'@codemirror/autocomplete',
'@codemirror/commands',
'@codemirror/highlight',
'@codemirror/lint',
'@codemirror/lang-html',
'@codemirror/lang-markdown',
'@codemirror/language-data',
'@lezer/common',
'@lezer/markdown',
'@lezer/highlight',
];
const extraScriptExternals = {};
for (const library of externalContentScriptLibraries) {
extraScriptExternals[library] = { commonjs: library };
}
const extraScriptConfig = {
...baseConfig,
const extraScriptConfig = Object.assign({}, baseConfig, {
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
fallback: moduleFallback,
extensions: ['.js', '.tsx', '.ts', '.json'],
},
// We support requiring @codemirror/... libraries through require('@codemirror/...')
externalsType: 'commonjs',
externals: extraScriptExternals,
};
});
const createArchiveConfig = {
stats: 'errors-only',
entry: './dist/index.js',
resolve: {
fallback: moduleFallback,
},
output: {
filename: 'index.js',
path: publishDir,
},
plugins: [{
apply(compiler) {
compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted);
},
}],
plugins: [new WebpackOnBuildPlugin(onBuildCompleted)],
};
function resolveExtraScriptPath(name) {
@@ -308,43 +222,20 @@ function buildExtraScriptConfigs(userConfig) {
for (const scriptName of userConfig.extraScripts) {
const scriptPaths = resolveExtraScriptPath(scriptName);
output.push({ ...extraScriptConfig, entry: scriptPaths.entry,
output: scriptPaths.output });
output.push(Object.assign({}, extraScriptConfig, {
entry: scriptPaths.entry,
output: scriptPaths.output,
}));
}
return output;
}
const increaseVersion = version => {
try {
const s = version.split('.');
const d = Number(s[s.length - 1]) + 1;
s[s.length - 1] = `${d}`;
return s.join('.');
} catch (error) {
error.message = `Could not parse version number: ${version}: ${error.message}`;
throw error;
}
};
function main(processArgv) {
const yargs = require('yargs/yargs');
const argv = yargs(processArgv).argv;
const updateVersion = () => {
const packageJson = getPackageJson();
packageJson.version = increaseVersion(packageJson.version);
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8');
const manifest = readManifest(manifestPath);
manifest.version = increaseVersion(manifest.version);
writeManifest(manifestPath, manifest);
if (packageJson.version !== manifest.version) {
console.warn(chalk.yellow(`Version numbers have been updated but they do not match: package.json (${packageJson.version}), manifest.json (${manifest.version}). Set them to the required values to get them in sync.`));
} else {
console.info(packageJson.version);
}
};
function main(environ) {
const configName = environ['joplin-plugin-config'];
const configName = argv['joplin-plugin-config'];
if (!configName) throw new Error('A config file must be specified via the --joplin-plugin-config flag');
// Webpack configurations run in parallel, while we need them to run in
@@ -379,30 +270,22 @@ function main(environ) {
fs.mkdirpSync(publishDir);
}
if (configName === 'updateVersion') {
updateVersion();
return [];
}
return configs[configName];
}
let exportedConfigs = [];
module.exports = (env) => {
let exportedConfigs = [];
try {
exportedConfigs = main(process.argv);
} catch (error) {
console.error(chalk.red(error.message));
process.exit(1);
}
try {
exportedConfigs = main(env);
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}
if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}
return exportedConfigs;
};
module.exports = exportedConfigs;

View File

@@ -441,11 +441,11 @@ export class Bridge {
}
public get Menu() {
return Menu;
return require('electron').Menu;
}
public get MenuItem() {
return MenuItem;
return require('electron').MenuItem;
}
public async openExternal(url: string) {

View File

@@ -1,65 +0,0 @@
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);
});
});

View File

@@ -1,73 +1,25 @@
import { ContextMenuParams, Event } from 'electron';
import { useEffect, RefObject, useContext } from 'react';
import { Dispatch } from 'redux';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import { EditContextMenuFilterObject, MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import type CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import eventManager from '@joplin/lib/eventManager';
import bridge from '../../../../../services/bridge';
import Setting from '@joplin/lib/models/Setting';
import Resource from '@joplin/lib/models/Resource';
import { ContextMenuItemType, ContextMenuOptions, buildMenuItems, handleEditorContextMenuFilter } from '../../../utils/contextMenuUtils';
import { menuItems } from '../../../utils/contextMenu';
import isItemId from '@joplin/lib/models/utils/isItemId';
import { extractResourceUrls } from '@joplin/lib/urlUtils';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
// 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());
const imageClassName = 'cm-md-image';
// Shared helper to extract resource ID from a path/URL
const pathToId = (path: string) => {
const id = Resource.pathToId(path);
return isItemId(id) ? id : '';
};
interface ContextMenuProps {
plugins: PluginStates;
dispatch: Dispatch;
editorCutText: ()=> void;
editorCopyText: ()=> void;
editorPaste: ()=> void;
@@ -99,7 +51,7 @@ const useContextMenu = (props: ContextMenuProps) => {
return screenXY / zoomFraction;
};
const pointerInsideEditor = (params: ContextMenuParams, allowNonEditable = false) => {
function pointerInsideEditor(params: ContextMenuParams) {
const x = params.x, y = params.y, isEditable = params.isEditable;
const containerDoc = props.containerRef.current?.ownerDocument;
const elements = containerDoc?.getElementsByClassName(props.editorClassName);
@@ -107,7 +59,7 @@ const useContextMenu = (props: ContextMenuProps) => {
// Note: We can't check inputFieldType here. When spellcheck is enabled,
// params.inputFieldType is "none". When spellcheck is disabled,
// params.inputFieldType is "plainText". Thus, such a check would be inconsistent.
if (!elements?.length || (!isEditable && !allowNonEditable)) return false;
if (!elements?.length || !isEditable) return false;
// Checks whether the element the pointer clicked on is inside the editor.
// This logic will need to be changed if the editor is eventually wrapped
@@ -118,107 +70,9 @@ const useContextMenu = (props: ContextMenuProps) => {
const yScreen = convertFromScreenCoordinates(zoom, y);
const intersectingElement = containerDoc.elementFromPoint(xScreen, yScreen);
return intersectingElement && isAncestorOfCodeMirrorEditor(intersectingElement);
};
}
const getClickedImageContainer = (params: ContextMenuParams) => {
const containerDoc = props.containerRef.current?.ownerDocument;
if (!containerDoc) return null;
const zoom = Setting.value('windowContentZoomFactor');
const xScreen = convertFromScreenCoordinates(zoom, params.x);
const yScreen = convertFromScreenCoordinates(zoom, params.y);
const clickedElement = containerDoc.elementFromPoint(xScreen, yScreen);
return clickedElement?.closest(`.${imageClassName}`) as HTMLElement | null;
};
// Get resource ID from image markup at click position (not cursor position)
const getResourceIdAtClickPos = (params: ContextMenuParams): string | 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 showImageContextMenu = async (resourceId: string) => {
const menu = new Menu();
const contextMenuOptions: ContextMenuOptions = {
itemType: ContextMenuItemType.Image,
resourceId,
filename: null,
mime: null,
linkToCopy: null,
linkToOpen: null,
textToCopy: null,
htmlToCopy: null,
insertContent: () => {},
isReadOnly: true,
fireEditorEvent: () => {},
htmlToMd: null,
mdToHtml: null,
};
const imageMenuItems = await buildMenuItems(menuItems(props.dispatch), contextMenuOptions);
for (const item of imageMenuItems) {
menu.append(item);
}
menu.popup({ window: bridge().activeWindow() });
};
// Move the cursor to the line containing the image markup for a rendered image.
// This ensures plugins that inspect cursor position (e.g. rich markdown, image resize)
// show the correct context menu options.
const moveCursorToImageLine = (imageContainer: HTMLElement) => {
const editor = editorRef.current?.editor;
if (!editor) return;
// The image widget stores its source document position as a data attribute.
const sourceFrom = imageContainer.dataset.sourceFrom;
if (sourceFrom === undefined) return;
const pos = Math.min(Number(sourceFrom), editor.state.doc.length);
const line = editor.state.doc.lineAt(pos);
editor.dispatch({
selection: { anchor: line.from },
});
};
const onContextMenu = async (event: Event, params: ContextMenuParams) => {
// Check if right-clicking on a rendered image first (images may not be "editable")
const imageContainer = getClickedImageContainer(params);
if (imageContainer && pointerInsideEditor(params, true)) {
const imgElement = imageContainer.querySelector('img');
if (imgElement) {
const resourceId = pathToId(imgElement.src);
if (resourceId) {
event.preventDefault();
moveCursorToImageLine(imageContainer);
await showImageContextMenu(resourceId);
return;
}
}
}
// Check if right-clicking on image markup text
const markupResourceId = getResourceIdAtClickPos(params);
if (markupResourceId && pointerInsideEditor(params)) {
event.preventDefault();
await showImageContextMenu(markupResourceId);
return;
}
// For text context menu, require editable
async function onContextMenu(event: Event, params: ContextMenuParams) {
if (!pointerInsideEditor(params)) return;
// Don't show the default menu.
@@ -277,25 +131,30 @@ const useContextMenu = (props: ContextMenuProps) => {
(editorRef.current as any).alignSelection(params);
}
const extraItems = await handleEditorContextMenuFilter();
let filterObject: EditContextMenuFilterObject = {
items: [],
};
if (extraItems.length) {
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
for (const item of filterObject.items) {
menu.append(new MenuItem({
type: 'separator',
label: item.label,
click: async () => {
const args = item.commandArgs || [];
void CommandService.instance().execute(item.commandName, ...args);
},
type: item.type,
}));
}
for (const extraItem of extraItems) {
menu.append(extraItem);
}
// eslint-disable-next-line github/array-foreach, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
menuUtils.pluginContextMenuItems(props.plugins, MenuItemLocation.EditorContextMenu).forEach((item: any) => {
menu.append(new MenuItem(item));
});
menu.popup({ window: bridge().activeWindow() });
};
}
// Prepend the event listener so that it gets called before
// the listener that shows the default menu.
@@ -308,7 +167,7 @@ const useContextMenu = (props: ContextMenuProps) => {
}
};
}, [
props.plugins, props.dispatch, props.editorClassName, editorRef, props.containerRef,
props.plugins, props.editorClassName, editorRef, props.containerRef,
props.editorCutText, props.editorCopyText, props.editorPaste,
windowId,
]);

View File

@@ -722,7 +722,6 @@ function CodeMirror(props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useContextMenu({
plugins: props.plugins,
dispatch: props.dispatch,
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'codeMirrorEditor',

View File

@@ -303,7 +303,6 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
useContextMenu({
plugins: props.plugins,
dispatch: props.dispatch,
editorCutText, editorCopyText, editorPaste,
editorRef,
editorClassName: 'cm-editor',

View File

@@ -3,7 +3,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import { useContext, useEffect } from 'react';
import bridge from '../../../../../services/bridge';
import { ContextMenuOptions, ContextMenuItemType, buildMenuItems } from '../../../utils/contextMenuUtils';
import { ContextMenuOptions, ContextMenuItemType } from '../../../utils/contextMenuUtils';
import { menuItems } from '../../../utils/contextMenu';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
@@ -38,7 +38,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
const contextMenuItems = menuItems(dispatch);
const targetWindow = bridge().windowById(windowId);
const makeMainMenuItems = async (element: Element) => {
const makeMainMenuItems = (element: Element) => {
let itemType: ContextMenuItemType = ContextMenuItemType.None;
let resourceId = '';
let linkUrl = null;
@@ -79,7 +79,20 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
mdToHtml,
};
return buildMenuItems(contextMenuItems, contextMenuActionOptions.current);
const result = [];
for (const itemName in contextMenuItems) {
const item = contextMenuItems[itemName];
if (!item.isActive(itemType, contextMenuActionOptions.current)) continue;
result.push(new MenuItem({
label: item.label,
click: () => {
item.onAction(contextMenuActionOptions.current);
},
}));
}
return result;
};
const makeEditableMenuItems = (element: Element) => {
@@ -98,7 +111,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
return [];
};
const showContextMenu = async (element: HTMLElement, misspelledWord: string|null, dictionarySuggestions: string[]) => {
const showContextMenu = (element: HTMLElement, misspelledWord: string|null, dictionarySuggestions: string[]) => {
const menu = new Menu();
const menuItems: MenuItemType[] = [];
const toMenuItems = (specs: MenuItemConstructorOptions[]) => {
@@ -106,7 +119,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
};
menuItems.push(...makeEditableMenuItems(element));
menuItems.push(...(await makeMainMenuItems(element)));
menuItems.push(...makeMainMenuItems(element));
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(misspelledWord, dictionarySuggestions);
menuItems.push(
...toMenuItems(spellCheckerMenuItems),
@@ -122,16 +135,16 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
};
let lastTarget: EventTarget|null = null;
const onElectronContextMenu = async (event: ElectronEvent, params: ContextMenuParams) => {
const onElectronContextMenu = (event: ElectronEvent, params: ContextMenuParams) => {
if (!lastTarget) return;
const element = lastTarget as HTMLElement;
lastTarget = null;
event.preventDefault();
await showContextMenu(element, params.misspelledWord, params.dictionarySuggestions);
showContextMenu(element, params.misspelledWord, params.dictionarySuggestions);
};
const onBrowserContextMenu = async (event: PointerEvent) => {
const onBrowserContextMenu = (event: PointerEvent) => {
const isKeyboard = event.buttons === 0;
if (isKeyboard) {
// Context menu events from the keyboard seem to always use <body> as the
@@ -150,7 +163,7 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatc
const isFromPlugin = !event.isTrusted;
if (isFromPlugin) {
event.preventDefault();
await showContextMenu(lastTarget as HTMLElement, null, []);
showContextMenu(lastTarget as HTMLElement, null, []);
lastTarget = null;
}
};

View File

@@ -2,8 +2,9 @@ import ResourceEditWatcher from '@joplin/lib/services/ResourceEditWatcher/index'
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';
import { ContextMenuItemType, ContextMenuOptions, ContextMenuItems, resourceInfo, textToDataUri, svgUriToPng, svgDimensions } from './contextMenuUtils';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
import Resource, { resourceOcrStatusToString } from '@joplin/lib/models/Resource';
import BaseItem from '@joplin/lib/models/BaseItem';
import BaseModel, { ModelType } from '@joplin/lib/BaseModel';
@@ -81,15 +82,6 @@ export async function openItemById(itemId: string, dispatch: Function, hash = ''
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export function menuItems(dispatch: Function): ContextMenuItems {
const makeSeparator = (): ContextMenuItem => {
return {
isActive: () => { return true; },
label: '',
onAction: () => {},
isSeparator: true,
};
};
return {
open: {
label: _('Open...'),
@@ -146,16 +138,6 @@ export function menuItems(dispatch: Function): ContextMenuItems {
},
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) => {
@@ -190,6 +172,14 @@ export function menuItems(dispatch: Function): ContextMenuItems {
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
},
},
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,
},
copyOcrText: {
label: _('View OCR text'),
onAction: async (options: ContextMenuOptions) => {
@@ -207,7 +197,6 @@ export function menuItems(dispatch: Function): ContextMenuItems {
return itemType === ContextMenuItemType.Resource || (itemType === ContextMenuItemType.Image && options.resourceId);
},
},
separator3: makeSeparator(),
copyPathToClipboard: {
label: _('Copy path to clipboard'),
onAction: async (options: ContextMenuOptions) => {
@@ -232,14 +221,6 @@ export function menuItems(dispatch: Function): ContextMenuItems {
},
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) => {
@@ -269,6 +250,13 @@ export function menuItems(dispatch: Function): ContextMenuItems {
},
isActive: (_itemType: ContextMenuItemType, options: ContextMenuOptions) => !options.isReadOnly && (!!clipboard.readText() || !!clipboard.readHTML()),
},
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,
},
};
}
@@ -276,12 +264,20 @@ export function menuItems(dispatch: Function): ContextMenuItems {
export default async function contextMenu(options: ContextMenuOptions, dispatch: Function) {
const menu = new Menu();
const items = menuItems(dispatch);
if (!('readyOnly' in options)) options.isReadOnly = true;
for (const itemKey in items) {
const item = items[itemKey];
const items = await buildMenuItems(menuItems(dispatch), options);
if (!item.isActive(options.itemType, options)) continue;
for (const item of items) {
menu.append(item);
menu.append(new MenuItem({
label: item.label,
click: () => {
item.onAction(options);
},
}));
}
return menu;

View File

@@ -1,13 +1,7 @@
import Resource from '@joplin/lib/models/Resource';
import Logger from '@joplin/utils/Logger';
import bridge from '../../../services/bridge';
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from './types';
import { EditContextMenuFilterObject } from '@joplin/lib/services/plugins/api/types';
import eventManager from '@joplin/lib/eventManager';
import CommandService from '@joplin/lib/services/CommandService';
import { type MenuItem as MenuItemType } from 'electron';
const MenuItem = bridge().MenuItem;
const logger = Logger.create('contextMenuUtils');
export enum ContextMenuItemType {
@@ -42,7 +36,6 @@ export interface ContextMenuItem {
onAction: Function;
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
isActive: Function;
isSeparator?: boolean;
}
export interface ContextMenuItems {
@@ -134,90 +127,3 @@ export const svgUriToPng = (document: Document, svg: string, width: number, heig
img.src = svg;
});
};
// Filter out leading, trailing, and consecutive separators from a list
const filterSeparators = <T>(items: T[], isSeparator: (item: T)=> boolean): T[] => {
const filtered: T[] = [];
let lastWasSeparator = true;
for (const item of items) {
if (isSeparator(item)) {
if (lastWasSeparator) continue;
lastWasSeparator = true;
} else {
lastWasSeparator = false;
}
filtered.push(item);
}
while (filtered.length > 0 && isSeparator(filtered[filtered.length - 1])) {
filtered.pop();
}
return filtered;
};
export const handleEditorContextMenuFilter = async () => {
let filterObject: EditContextMenuFilterObject = {
items: [],
};
filterObject = await eventManager.filterEmit('editorContextMenu', filterObject);
const filteredItems = filterSeparators(filterObject.items, item => item.type === 'separator');
const output: MenuItemType[] = [];
for (const item of filteredItems) {
output.push(new MenuItem({
label: item.label,
click: async () => {
const args = item.commandArgs || [];
void CommandService.instance().execute(item.commandName, ...args);
},
type: item.type,
}));
}
return output;
};
export const buildMenuItems = async (items: ContextMenuItems, options: ContextMenuOptions) => {
const activeItems: ContextMenuItem[] = [];
for (const itemKey in items) {
const item = items[itemKey];
if (item.isActive(options.itemType, options)) {
activeItems.push(item);
}
}
const extraItems = await handleEditorContextMenuFilter();
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);
return filteredItems.map(item => new MenuItem({
label: item.label,
click: () => {
item.onAction(options);
},
type: item.isSeparator ? 'separator' : 'normal',
}));
};

View File

@@ -2,6 +2,7 @@ import { _ } from '@joplin/lib/locale';
import { ColumnName } from '@joplin/lib/services/plugins/api/noteListType';
const titles: Record<ColumnName, ()=> string> = {
'note.checkboxes': () => _('Checkbox completion'),
'note.folder.title': () => _('Notebook: %s', _('Title')),
'note.is_todo': () => _('To-do'),
'note.latitude': () => _('Latitude'),
@@ -16,6 +17,7 @@ const titles: Record<ColumnName, ()=> string> = {
};
const titlesForHeader: Partial<Record<ColumnName, ()=> string>> = {
'note.checkboxes': () => '◐',
'note.is_todo': () => '✓',
};

View File

@@ -2,8 +2,37 @@ import { ListRendererDependency } from '@joplin/lib/services/plugins/api/noteLis
import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note';
import Setting from '@joplin/lib/models/Setting';
import { _ } from '@joplin/lib/locale';
interface CheckboxStats {
total: number;
checked: number;
percent: number;
isComplete: boolean;
}
const countCheckboxes = (body: string): CheckboxStats | null => {
if (!body) return null;
// Match unchecked: - [ ] and checked: - [x] or - [X]
const uncheckedMatches = body.match(/- \[ \]/g);
const checkedMatches = body.match(/- \[[xX]\]/g);
const unchecked = uncheckedMatches ? uncheckedMatches.length : 0;
const checked = checkedMatches ? checkedMatches.length : 0;
const total = unchecked + checked;
if (total === 0) return null;
return {
total,
checked,
percent: Math.round((checked / total) * 100),
isComplete: checked === total,
};
};
const prepareViewProps = async (
dependencies: ListRendererDependency[],
note: NoteEntity,
@@ -40,6 +69,14 @@ const prepareViewProps = async (
taskStatus = note.todo_completed ? _('Complete to-do') : _('Incomplete to-do');
}
output.note[propName] = taskStatus;
} else if (dep === 'note.checkboxes') {
// Only load the note body and compute checkbox stats if the setting is enabled
if (Setting.value('notes.showCheckboxCompletionChart')) {
if (!('body' in note)) note = await Note.load(note.id);
output.note[propName] = countCheckboxes(note.body);
} else {
output.note[propName] = null;
}
} else {
// The notes in the state only contain the properties defined in
// Note.previewFields(). It means that if a view request a

View File

@@ -6,7 +6,7 @@
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
TEMP_PATH=~/src/plugin-tests
NEED_COMPILING=1
PLUGIN_PATH=~/src/joplin/packages/app-cli/tests/support/plugins/menu
PLUGIN_PATH=~/src/plugin-yesyoucan
if [[ $NEED_COMPILING == 1 ]]; then
mkdir -p "$TEMP_PATH"

View File

@@ -79,10 +79,10 @@
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.1",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.14.0",
"react-native-svg": "15.12.1",
"react-native-url-polyfill": "2.0.0",
"react-native-version-info": "1.1.1",
"react-native-webview": "13.16.0",
"react-native-webview": "13.15.0",
"react-native-zip-archive": "7.0.2",
"react-redux": "8.1.3",
"redux": "4.2.1",

View File

@@ -42,20 +42,18 @@ class ImageWidget extends WidgetType {
private readonly alt_: string,
private readonly reloadCounter_ = 0,
private readonly width_: string | null = null,
private readonly sourceFrom_ = 0,
) {
super();
}
public eq(other: ImageWidget) {
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_ && this.width_ === other.width_ && this.sourceFrom_ === other.sourceFrom_;
return this.src_ === other.src_ && this.alt_ === other.alt_ && this.reloadCounter_ === other.reloadCounter_ && this.width_ === other.width_;
}
public updateDOM(dom: HTMLElement): boolean {
const image = dom.querySelector<HTMLImageElement>('img.image');
if (!image) return false;
dom.dataset.sourceFrom = String(this.sourceFrom_);
image.ariaLabel = this.alt_;
image.role = 'image';
@@ -104,7 +102,6 @@ class ImageWidget extends WidgetType {
public toDOM(_view: EditorView) {
const container = document.createElement('div');
container.classList.add(imageClassName);
container.dataset.sourceFrom = String(this.sourceFrom_);
const image = document.createElement('img');
image.classList.add('image');
@@ -235,7 +232,7 @@ const renderBlockImages = (context: RenderedContentContext) => [
if (src) {
const isLastLine = lineTo.number === state.doc.lines;
return Decoration.widget({
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0, width, lineFrom.from),
widget: new ImageWidget(context, src, alt, imageToRefreshCounters.get(src) ?? 0, width),
// "side: -1": In general, when the cursor is at the widget's location, it should be at
// the start of the next line (and so "side" should be -1).
//

View File

@@ -1004,6 +1004,17 @@ const builtInMetadata = (Setting: typeof SettingType) => {
isGlobal: false,
},
'notes.showCheckboxCompletionChart': {
value: true,
type: SettingItemType.Bool,
storage: SettingStorage.File,
section: 'appearance',
public: true,
appTypes: [AppType.Desktop],
label: () => _('Show checkbox completion chart in note list'),
isGlobal: true,
},
'plugins.states': {
value: {} as PluginSettings,
type: SettingItemType.Object,

View File

@@ -2,12 +2,20 @@ import { _ } from '../../locale';
import CommandService from '../CommandService';
import { ItemFlow, ListRenderer, OnClickEvent } from '../plugins/api/noteListType';
interface CheckboxStats {
total: number;
checked: number;
percent: number;
isComplete: boolean;
}
interface Props {
note: {
id: string;
title: string;
is_todo: number;
todo_completed: number;
checkboxes: CheckboxStats | null;
};
item: {
// index: number;
@@ -34,6 +42,7 @@ const renderer: ListRenderer = {
// 'item.index',
'item.selected',
'item.size.height',
'note.checkboxes',
'note.id',
'note.is_shared',
'note.is_todo',
@@ -96,6 +105,34 @@ const renderer: ListRenderer = {
color: var(--joplin-color);
}
}
> .checkbox-pie {
display: flex;
align-items: center;
padding-right: 12px;
padding-left: 8px;
> .pie {
width: 16px;
height: 16px;
border-radius: 50%;
background: conic-gradient(
var(--joplin-color4) calc(var(--percent) * 1%),
var(--joplin-background-color) calc(var(--percent) * 1%)
);
border: 1px solid var(--joplin-color-faded);
box-sizing: border-box;
}
> .pie.-complete {
background: var(--joplin-background-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--joplin-color4);
}
}
}
> .content.-shared {
@@ -147,11 +184,24 @@ const renderer: ListRenderer = {
<i class="watchedicon fa fa-share-square"></i>
<span>{{note.title}}</span>
</div>
{{#checkboxStats}}
<div class="checkbox-pie" title="{{checked}}/{{total}} completed">
{{#isComplete}}
<div class="pie -complete">✓</div>
{{/isComplete}}
{{^isComplete}}
<div class="pie" style="--percent: {{percent}};"></div>
{{/isComplete}}
</div>
{{/checkboxStats}}
</div>
`,
onRenderNote: async (props: Props) => {
return props;
return {
...props,
checkboxStats: props.note.checkboxes,
};
},
};

View File

@@ -54,10 +54,32 @@ const renderer: ListRenderer = {
}
> .item[data-name="note.is_todo"],
> .item[data-name="note.title"] {
> .item[data-name="note.title"],
> .item[data-name="note.checkboxes"] {
opacity: 1;
}
> .item[data-name="note.checkboxes"] > .content > .checkbox-pie > .pie {
width: 16px;
height: 16px;
border-radius: 50%;
background: conic-gradient(
var(--joplin-color4) calc(var(--percent) * 1%),
var(--joplin-background-color) calc(var(--percent) * 1%)
);
border: 1px solid var(--joplin-color-faded);
box-sizing: border-box;
}
> .item[data-name="note.checkboxes"] > .content > .checkbox-pie > .pie.-complete {
background: var(--joplin-background-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--joplin-color4);
}
> .item > .content > .watchedicon {
display: none;
margin-right: 8px;
@@ -117,6 +139,19 @@ const renderer: ListRenderer = {
</div>
{{/note.is_todo}}
`,
'note.checkboxes': // html
`
{{#note.checkboxes}}
<div class="checkbox-pie" title="{{note.checkboxes.checked}}/{{note.checkboxes.total}} completed">
{{#note.checkboxes.isComplete}}
<div class="pie -complete">✓</div>
{{/note.checkboxes.isComplete}}
{{^note.checkboxes.isComplete}}
<div class="pie" style="--percent: {{note.checkboxes.percent}};"></div>
{{/note.checkboxes.isComplete}}
</div>
{{/note.checkboxes}}
`,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied

View File

@@ -50,6 +50,7 @@ export type ListRendererDependency =
'item.selected' |
'item.size.height' |
'item.size.width' |
'note.checkboxes' |
'note.folder.title' |
'note.isWatched' |
'note.tags' |
@@ -59,6 +60,7 @@ export type ListRendererDependency =
export type ListRendererItemValueTemplates = Record<string, string>;
export const columnNames = [
'note.checkboxes',
'note.folder.title',
'note.is_todo',
'note.latitude',

View File

@@ -10684,11 +10684,11 @@ __metadata:
react-native-securerandom: "npm:1.0.1"
react-native-share: "npm:12.2.1"
react-native-sqlite-storage: "npm:6.0.1"
react-native-svg: "npm:15.14.0"
react-native-svg: "npm:15.12.1"
react-native-url-polyfill: "npm:2.0.0"
react-native-version-info: "npm:1.1.1"
react-native-web: "npm:0.21.2"
react-native-webview: "npm:13.16.0"
react-native-webview: "npm:13.15.0"
react-native-zip-archive: "npm:7.0.2"
react-redux: "npm:8.1.3"
react-refresh: "npm:0.18.0"
@@ -44253,9 +44253,9 @@ __metadata:
languageName: node
linkType: hard
"react-native-svg@npm:15.14.0":
version: 15.14.0
resolution: "react-native-svg@npm:15.14.0"
"react-native-svg@npm:15.12.1":
version: 15.12.1
resolution: "react-native-svg@npm:15.12.1"
dependencies:
css-select: "npm:^5.1.0"
css-tree: "npm:^1.1.3"
@@ -44263,7 +44263,7 @@ __metadata:
peerDependencies:
react: "*"
react-native: "*"
checksum: 10/8f067e265cd2749a7f0a3a09eccd6a297f1e99d0306e2c630d354e7093c3cb8e47cd054988f832b05ed80509ebdda7422cb407d8dcf851f1d4e8706f14f38c21
checksum: 10/aa7b3a54cf2eb52aeb1322ba4a3f63176d4da0725ea4b9ec0051a59df0a511dc189fd101abf7993e94676c1cdea736e4abb207a0aea6f3083b5982c48e4648ac
languageName: node
linkType: hard
@@ -44306,16 +44306,16 @@ __metadata:
languageName: node
linkType: hard
"react-native-webview@npm:13.16.0":
version: 13.16.0
resolution: "react-native-webview@npm:13.16.0"
"react-native-webview@npm:13.15.0":
version: 13.15.0
resolution: "react-native-webview@npm:13.15.0"
dependencies:
escape-string-regexp: "npm:^4.0.0"
invariant: "npm:2.2.4"
peerDependencies:
react: "*"
react-native: "*"
checksum: 10/dc3d84c4785676bac3f316ec2119d3239f64fd8b18400dcc1cf8fa4f05b9bf677cf167aa4e7da72c9f60760eb6303c99da22324e15ff5fb380a3b1b31f531132
checksum: 10/7d4b23cb0536199ab2339644d8b6ce720e794ac9a8200f23746032a768a61af8727d2401fc000be09b4fb478dce6f795d7d400945a17ab013a3bbc92a5077e4e
languageName: node
linkType: hard