104 changes: 104 additions & 0 deletions src/contentScript/codeMirror6Plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@

// To avoid importing @codemirror packages directly, we import CodeMirror types, then
// later, require dynamically.
//
// This allows us to continue supporting older versions of Joplin that don't depend
// on @codemirror/ packages.
import type * as CodeMirrorAutocompleteType from '@codemirror/autocomplete';
import type * as CodeMirrorStateType from '@codemirror/state';

import type { CompletionContext, CompletionResult, Completion } from '@codemirror/autocomplete';
import type { EditorView } from '@codemirror/view';

import { PluginContext } from './types';


export default function codeMirror6Plugin(pluginContext: PluginContext, CodeMirror: any) {
const { autocompletion, insertCompletionText } = require('@codemirror/autocomplete') as typeof CodeMirrorAutocompleteType;
const { EditorSelection } = require('@codemirror/state') as typeof CodeMirrorStateType;

const completeMarkdown = async (completionContext: CompletionContext): Promise<CompletionResult> => {
const prefix = completionContext.matchBefore(/[@][@]\w+/);
if (!prefix || (prefix.from === prefix.to && !completionContext.explicit)) {
return null;
}

const response = await pluginContext.postMessage({
command: 'getNotes',
prefix: prefix.text,
});

const createApplyCompletionFn = (noteTitle: string, noteId: string) => {
return (view: EditorView, _completion: Completion, from: number, to: number) => {
const markdownLink = `[${noteTitle}](:/${noteId})`;

view.dispatch(
insertCompletionText(
view.state,
markdownLink,
from,
to,
),
);

if (response.selectText) {
const selStart = from + 1;
const selEnd = selStart + noteTitle.length;
view.dispatch({
selection: EditorSelection.range(selStart, selEnd),
});
}
};
};


const notes = response.notes;
const completions: Completion[] = [];
for (const note of notes) {
completions.push({
apply: createApplyCompletionFn(note.title, note.id),
label: note.title,
detail: response.showFolders ? `In ${note.folder ?? 'unknown'}` : undefined,
});
}

const addNewNoteCompletion = (todo: boolean) => {
const title = prefix.text.substring(2);
const description = todo ? 'New Task' : 'New Note';
completions.push({
label: description,
detail: `"${title}"`,
apply: async (view, completion, from, to) => {
const response = await pluginContext.postMessage({
command: 'createNote',
title,
todo,
});

const applyCompletion = createApplyCompletionFn(title, response.newNote.id);
applyCompletion(view, completion, from, to);
},
});
};

if (response.allowNewNotes) {
addNewNoteCompletion(true);
addNewNoteCompletion(false);
}

return {
from: prefix.from,
options: completions,
filter: false,
};
};

CodeMirror.addExtension([
autocompletion({
activateOnTyping: true,
override: [ completeMarkdown ],
tooltipClass: () => 'quick-links-completions',
}),
Comment on lines +97 to +101
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possible issue

Using override seems to mean that this won't work well with other CM6 autocompletion plugins (like the CodeMirror 6 snippets plugin). For example, if another plugin enables code block autocompletion, this will disable it.

Alternative 1

Here's an alternative:

Suggested change
autocompletion({
activateOnTyping: true,
override: [ completeMarkdown ],
tooltipClass: () => 'quick-links-completions',
}),
autocompletion(),
EditorState.languageData.of(() => ([{ autocomplete: completeMarkdown }])),

By default, autocompletion gets completions from the languageData defined at the cursor. For example, if the cursor is in a JavaScript code block, the user will get JavaScript completions.

The above adds global language data that provides autocompletions from completeMarkdown.

The issue with this is that autocompletion() enables completion from languageData, so users will also get completions in code blocks (which might not be desirable).

Example with the above change:

Screencast.from.2024-01-19.14-40-54.webm

The user may not want autocompletion within code blocks, however.

Alternative 2

Add a setting ("show suggestions from other sources") to switch between the current implementation and alternative 1.

The code might then look like this:

Suggested change
autocompletion({
activateOnTyping: true,
override: [ completeMarkdown ],
tooltipClass: () => 'quick-links-completions',
}),
useGlobalAutocompleteSetting ? [
autocompletion(),
EditorState.languageData.of(() => ([{ autocomplete: completeMarkdown }])),
] : [
autocompletion({
override: [ completeMarkdown ],
tooltipClass: () => 'quick-links-completions',
}),
]

]);
}

28 changes: 28 additions & 0 deletions src/contentScript/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PluginContext } from './types';
import codeMirror5Plugin from './codeMirror5Plugin';
import codeMirror6Plugin from './codeMirror6Plugin';

module.exports = {
default: function(context: PluginContext) {
return {
plugin: (CodeMirror: any) => {
if (CodeMirror.cm6) {
return codeMirror6Plugin(context, CodeMirror);
} else {
return codeMirror5Plugin(context, CodeMirror);
}
},
codeMirrorResources: [
'addon/hint/show-hint',
],
codeMirrorOptions: {
'quickLinks': true,
},
assets: function() {
return [
{ name: './show-hint.css'},
]
}
};
},
};
14 changes: 10 additions & 4 deletions src/show-hint.css → src/contentScript/show-hint.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.CodeMirror-hints {
.CodeMirror-hints, .quick-links-completions.cm-tooltip {
position: absolute;
z-index: 10;
overflow: hidden;
Expand All @@ -13,15 +13,19 @@
border-radius: 3px;
border: 1px solid silver;

background: white;
background-color: white;
font-size: 90%;
font-family: monospace;

max-height: 20em;
overflow-y: auto;
}

.CodeMirror-hint {
.quick-links-completions.cm-tooltip li {
font-family: sans-serif;
}

.CodeMirror-hint, .quick-links-completions li {
margin: 0;
padding: 0 4px;
border-radius: 2px;
Expand All @@ -30,7 +34,9 @@
cursor: pointer;
}

li.CodeMirror-hint-active {
li.CodeMirror-hint-active, .quick-links-completions li[aria-selected] {
background: #08f;
color: white;
}


4 changes: 4 additions & 0 deletions src/contentScript/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

export interface PluginContext {
postMessage(message: any): Promise<any>;
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ joplin.plugins.register({
await joplin.contentScripts.register(
ContentScriptType.CodeMirrorPlugin,
'quickLinks',
'./QuickLinksPlugin.js'
'./contentScript/index.js'
);

await joplin.contentScripts.onMessage('quickLinks', async (message: any) => {
Expand Down
193 changes: 155 additions & 38 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
// update, you can easily restore the functionality you've added.
// -----------------------------------------------------------------------------

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes to webpack.config.js are copied from the upstream joplin-generator package.

These changes are required to prevent Webpack from duplicating CodeMirror 6 packages (which would cause the extension not to work because globals from one copy are not equal to globals from copy 2).

/* 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;
const allPossibleCategories = require('@joplin/lib/pluginCategories.json');

const rootDir = path.resolve(__dirname);
const userConfigFilename = './plugin.config.json';
Expand All @@ -23,18 +25,31 @@ const distDir = path.resolve(rootDir, 'dist');
const srcDir = path.resolve(rootDir, 'src');
const publishDir = path.resolve(rootDir, 'publish');

const userConfig = Object.assign({}, {
extraScripts: [],
}, fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {});
const userConfig = { extraScripts: [], ...(fs.pathExistsSync(userConfigPath) ? require(userConfigFilename) : {}) };

const manifestPath = `${srcDir}/manifest.json`;
const packageJsonPath = `${rootDir}/package.json`;
const allPossibleScreenshotsType = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
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 = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const content = getPackageJson();
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}`));
}
Expand Down Expand Up @@ -67,15 +82,48 @@ 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`);
});
}

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 })
const distFiles = glob.sync(`${sourceDir}/**/*`, { nodir: true, windowsPathsNoEscape: 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');
Expand All @@ -89,18 +137,22 @@ 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();
fs.writeFileSync(destPath, JSON.stringify(content, null, '\t'), 'utf8');
writeManifest(destPath, content);
}

function onBuildCompleted() {
Expand Down Expand Up @@ -129,15 +181,15 @@ const baseConfig = {
},
};

const pluginConfig = Object.assign({}, baseConfig, {
entry: './src/index.ts',
const pluginConfig = { ...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: ['.tsx', '.ts', '.js', '.json'],
extensions: ['.js', '.tsx', '.ts', '.json'],
},
output: {
filename: 'index.js',
Expand All @@ -161,26 +213,62 @@ const pluginConfig = Object.assign({}, baseConfig, {
},
],
}),
],
});
] };


// These libraries can be included with require(...) or
// joplin.require(...) from content scripts.
const externalContentScriptLibraries = [
'@codemirror/view',
'@codemirror/state',
'@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 = Object.assign({}, baseConfig, {
const extraScriptConfig = {
...baseConfig,
resolve: {
alias: {
api: path.resolve(__dirname, 'api'),
},
extensions: ['.tsx', '.ts', '.js', '.json'],
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: [new WebpackOnBuildPlugin(onBuildCompleted)],
plugins: [{
apply(compiler) {
compiler.hooks.done.tap('archiveOnBuildListener', onBuildCompleted);
},
}],
};

function resolveExtraScriptPath(name) {
Expand Down Expand Up @@ -212,20 +300,41 @@ function buildExtraScriptConfigs(userConfig) {

for (const scriptName of userConfig.extraScripts) {
const scriptPaths = resolveExtraScriptPath(scriptName);
output.push(Object.assign({}, extraScriptConfig, {
entry: scriptPaths.entry,
output: scriptPaths.output,
}));
output.push({ ...extraScriptConfig, entry: scriptPaths.entry,
output: scriptPaths.output });
}

return output;
}

function main(processArgv) {
const yargs = require('yargs/yargs');
const argv = yargs(processArgv).argv;
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;
}
};

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.`));
}
};

const configName = argv['joplin-plugin-config'];
function main(environ) {
const configName = environ['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
Expand Down Expand Up @@ -260,22 +369,30 @@ function main(processArgv) {
fs.mkdirpSync(publishDir);
}

if (configName === 'updateVersion') {
updateVersion();
return [];
}

return configs[configName];
}

let exportedConfigs = [];

try {
exportedConfigs = main(process.argv);
} catch (error) {
console.error(chalk.red(error.message));
process.exit(1);
}
module.exports = (env) => {
let exportedConfigs = [];

if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}
try {
exportedConfigs = main(env);
} catch (error) {
console.error(error.message);
process.exit(1);
}

module.exports = exportedConfigs;
if (!exportedConfigs.length) {
// Nothing to do - for example where there are no external scripts to
// compile.
process.exit(0);
}

return exportedConfigs;
};