1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Resolves #9964: Beta editor plugins: Allow fixing conflicts between plugins that add autocompletions (#9965)

This commit is contained in:
Henry Heino 2024-02-22 07:36:27 -08:00 committed by GitHub
parent 4a61ff2df3
commit f1a833ef21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 363 additions and 19 deletions

View File

@ -605,10 +605,14 @@ packages/editor/CodeMirror/markdown/markdownReformatter.test.js
packages/editor/CodeMirror/markdown/markdownReformatter.js
packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testUtil/createEditorControl.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/testUtil/typeText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/util/isInSyntaxNode.js
packages/editor/CodeMirror/util/setupVim.js

4
.gitignore vendored
View File

@ -585,10 +585,14 @@ packages/editor/CodeMirror/markdown/markdownReformatter.test.js
packages/editor/CodeMirror/markdown/markdownReformatter.js
packages/editor/CodeMirror/pluginApi/PluginLoader.js
packages/editor/CodeMirror/pluginApi/codeMirrorRequire.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.js
packages/editor/CodeMirror/pluginApi/customEditorCompletion.js
packages/editor/CodeMirror/testUtil/createEditorControl.js
packages/editor/CodeMirror/testUtil/createEditorSettings.js
packages/editor/CodeMirror/testUtil/createTestEditor.js
packages/editor/CodeMirror/testUtil/forceFullParse.js
packages/editor/CodeMirror/testUtil/loadLanguages.js
packages/editor/CodeMirror/testUtil/typeText.js
packages/editor/CodeMirror/theme.js
packages/editor/CodeMirror/util/isInSyntaxNode.js
packages/editor/CodeMirror/util/setupVim.js

View File

@ -533,6 +533,40 @@ export interface MarkdownItContentScriptModule extends Omit<ContentScriptModule,
plugin: (markdownIt: any, options: any)=> any;
}
export interface CodeMirrorControl {
/** Points to a CodeMirror 6 EditorView instance. */
editor: any;
cm6: any;
/** `extension` should be a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension). */
addExtension(extension: any|any[]): void;
execCommand(name: string): any;
supportsCommand(name: string): boolean;
joplinExtensions: {
/**
* Returns a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension) that
* registers the given [CompletionSource](https://codemirror.net/docs/ref/#autocomplete.CompletionSource).
*
* Use this extension rather than the built-in CodeMirror [`autocompletion`](https://codemirror.net/docs/ref/#autocomplete.autocompletion)
* if you don't want to use [langaugeData-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override).
*
* Using `autocompletion({ override: [ ... ]})` causes errors when done by multiple plugins.
*/
completionSource(completionSource: any): any;
/**
* Creates an extension that enables or disables [`languageData`-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override).
*/
enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any };
};
}
export interface CodeMirrorContentScriptModule extends Omit<ContentScriptModule, 'plugin'> {
plugin: (codeMirrorControl: CodeMirrorControl)=> void;
}
export enum ContentScriptType {
/**
* Registers a new Markdown-It plugin, which should follow the template

View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"@codemirror/autocomplete": "6.12.0",
"@codemirror/view": "6.22.2",
"@joplin/lib": "~2.9",
"@types/node": "^18.7.13",
@ -915,10 +916,53 @@
"integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==",
"dev": true
},
"node_modules/@codemirror/autocomplete": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.12.0.tgz",
"integrity": "sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==",
"dev": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.1.tgz",
"integrity": "sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==",
"dev": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/language/node_modules/@codemirror/view": {
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.24.1.tgz",
"integrity": "sha512-sBfP4rniPBRQzNakwuQEqjEuiJDWJyF2kqLLqij4WXRoVwPPJfjx966Eq3F7+OPQxDtMt/Q9MWLoZLWjeveBlg==",
"dev": true,
"dependencies": {
"@codemirror/state": "^6.4.0",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/state": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.3.3.tgz",
"integrity": "sha512-0wufKcTw2dEwEaADajjHf6hBy1sh3M6V0e+q4JKIhLuiMSe5td5HOWpUdvKth1fT1M9VYOboajoBHpkCd7PG7A==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz",
"integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==",
"dev": true
},
"node_modules/@codemirror/view": {
@ -1517,6 +1561,30 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
"integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==",
"dev": true
},
"node_modules/@lezer/highlight": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz",
"integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==",
"dev": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.0.tgz",
"integrity": "sha512-Wst46p51km8gH0ZUmeNrtpRYmdlRHUpN1DQd3GFAyKANi8WVz8c2jHYTf1CVScFaCjQw1iO3ZZdqGDxQPRErTg==",
"dev": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",

View File

@ -26,6 +26,7 @@
"webpack": "^5.74.0",
"webpack-cli": "^4.10.0",
"@joplin/lib": "~2.9",
"@codemirror/view": "6.22.2"
"@codemirror/view": "6.22.2",
"@codemirror/autocomplete": "6.12.0"
}
}

View File

@ -5,6 +5,8 @@
// the editor to not work properly.
//
import { lineNumbers, highlightActiveLineGutter, } from '@codemirror/view';
import { completeFromList } from '@codemirror/autocomplete';
import { CodeMirrorContentScriptModule, CodeMirrorControl } from 'api/types';
//
// For the above import to work, you may also need to add @codemirror/view as a dev dependency
// to package.json. (For the type information only).
@ -13,7 +15,7 @@ import { lineNumbers, highlightActiveLineGutter, } from '@codemirror/view';
// const { lineNumbers } = joplin.require('@codemirror/view');
export default (_context: { contentScriptId: string }) => {
export default (_context: { contentScriptId: string }): CodeMirrorContentScriptModule => {
return {
// - codeMirrorWrapper: A thin wrapper around CodeMirror 6, designed to be similar to the
// CodeMirror 5 API. If running in CodeMirror 5, a CodeMirror object is provided instead.
@ -29,6 +31,20 @@ export default (_context: { contentScriptId: string }) => {
// See https://codemirror.net/ for more built-in extensions and configuration
// options.
// Joplin also exposes extensions for autocompletion.
// CodeMirror's built-in `autocompletion(...)` doesn't work if multiple plugins
// try to use its `override` option.
codeMirrorWrapper.addExtension([
codeMirrorWrapper.joplinExtensions.completionSource(
completeFromList(['# Example completion'])
),
// Joplin also exposes a Facet that allows enabling or disabling CodeMirror's
// built-in autocompletions. These apply, for example, to HTML tags.
codeMirrorWrapper.joplinExtensions.enableLanguageDataAutocomplete.of(true),
]);
},
// There are two main ways to style the CodeMirror editor:

View File

@ -1,18 +1,5 @@
import { ViewPlugin } from '@codemirror/view';
import createEditor from './createEditor';
import createEditorSettings from './testUtil/createEditorSettings';
import Setting from '@joplin/lib/models/Setting';
const createEditorControl = (initialText: string) => {
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
return createEditor(document.body, {
initialText,
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
});
};
import createEditorControl from './testUtil/createEditorControl';
describe('CodeMirrorControl', () => {
it('clearHistory should clear the undo/redo history', () => {

View File

@ -6,6 +6,8 @@ import { EditorSelection, Extension, StateEffect } from '@codemirror/state';
import { updateLink } from './markdown/markdownCommands';
import { SearchQuery, setSearchQuery } from '@codemirror/search';
import PluginLoader from './pluginApi/PluginLoader';
import customEditorCompletion, { editorCompletionSource, enableLanguageDataAutocomplete } from './pluginApi/customEditorCompletion';
import { CompletionSource } from '@codemirror/autocomplete';
interface Callbacks {
onUndoRedo(): void;
@ -26,6 +28,8 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
super(editor, _callbacks.onLogMessage);
this._pluginControl = new PluginLoader(this, _callbacks.onLogMessage);
this.addExtension(customEditorCompletion());
}
public supportsCommand(name: string) {
@ -149,6 +153,16 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
// CodeMirror-specific methods
//
public joplinExtensions = {
// Some plugins want to enable autocompletion from *just* that plugin, without also
// enabling autocompletion for text within code blocks (and other built-in completion
// sources).
// To support this, we need to provide extensions that wrap the built-in autocomplete.
// See https://discuss.codemirror.net/t/autocompletion-merging-override-in-config/7853
completionSource: (completionSource: CompletionSource) => editorCompletionSource.of(completionSource),
enableLanguageDataAutocomplete: enableLanguageDataAutocomplete,
};
public addExtension(extension: Extension) {
this.editor.dispatch({
effects: StateEffect.appendConfig.of([extension]),

View File

@ -0,0 +1,87 @@
import { EditorState, StateEffect } from '@codemirror/state';
import createEditorControl from '../testUtil/createEditorControl';
import { EditorView } from '@codemirror/view';
import { completeFromList, completionStatus, currentCompletions } from '@codemirror/autocomplete';
import typeText from '../testUtil/typeText';
const waitForShownCompletionsToContain = (editor: EditorView, completionLabels: string[]) => {
return new Promise<string[]>(resolve => {
let resolved = false;
const checkCompletions = (state: EditorState) => {
if (resolved) {
return;
}
const completions = currentCompletions(state).map(completion => completion.label);
for (const result of completionLabels) {
if (!completions.includes(result)) {
return;
}
}
resolve(completions);
resolved = true;
};
checkCompletions(editor.state);
editor.dispatch({
effects: StateEffect.appendConfig.of(EditorState.transactionExtender.of(transaction => {
checkCompletions(transaction.state);
return null;
})),
});
});
};
describe('customEditorCompletion', () => {
test('should not show completions when no completion sources have been registered', async () => {
const editorControl = createEditorControl('');
const completion = completeFromList(['test']);
editorControl.addExtension([
EditorState.languageData.of(() => [{ autocomplete: completion }]),
]);
const editor = editorControl.editor;
typeText(editor, 'tes');
// Would be 'pending' or 'active' if trying to autocomplete.
expect(completionStatus(editor.state)).toBe(null);
});
test('should show langaugeData completions when languageData-based autocomplete is enabled', async () => {
const editorControl = createEditorControl('');
const completion = completeFromList(['function']);
editorControl.addExtension([
EditorState.languageData.of(() => [{ autocomplete: completion }]),
editorControl.joplinExtensions.enableLanguageDataAutocomplete.of(true),
]);
const editor = editorControl.editor;
typeText(editor, 'fun');
await waitForShownCompletionsToContain(editor, ['function']);
});
test.each([
{ useLanguageData: false },
{ useLanguageData: true },
])('should show completions from all registered sources (%j)', async ({ useLanguageData }) => {
const editorControl = createEditorControl('');
const completion1 = completeFromList(['completion1-test', 'completion1-test2']);
const completion2 = completeFromList(['completion2-test', 'completion2-test2']);
const joplinExtensions = editorControl.joplinExtensions;
editorControl.addExtension([
joplinExtensions.completionSource(completion1),
joplinExtensions.completionSource(completion2),
joplinExtensions.enableLanguageDataAutocomplete.of(useLanguageData),
]);
const editor = editorControl.editor;
typeText(editor, 'com');
await waitForShownCompletionsToContain(editor, ['completion1-test', 'completion2-test']);
});
});

View File

@ -0,0 +1,63 @@
import { EditorState, Facet, Compartment, Extension } from '@codemirror/state';
import { CompletionSource, autocompletion } from '@codemirror/autocomplete';
// CodeMirror 6's built-in autocomplete functionality is difficult to work with
// unless you want to enable languageData-based autocompletion.
//
// This file wraps CodeMirror's built-in `autocompletion` extension and allows plugins
// to provide completions that work regardless of whether languageData-based autocomplete
// is enabled.
//
// See https://discuss.codemirror.net/t/autocompletion-merging-override-in-config/7853
export const editorCompletionSource = Facet.define<CompletionSource, CompletionSource[]>();
export const enableLanguageDataAutocomplete = Facet.define<boolean, boolean[]>();
// Provides languageData OR override autocompletions based on the value of
// the enableLanguageDataAutocomplete facet.
const customEditorCompletion = () => {
const compartment = new Compartment();
return [
compartment.of([]),
EditorState.transactionExtender.of(transaction => {
const lastCompletions = transaction.startState.facet(editorCompletionSource) ?? [];
const completions = transaction.state.facet(editorCompletionSource) ?? [];
const currentExtension = compartment.get(transaction.state) as Extension[];
const missingExtension = currentExtension.length === 0 && completions.length > 0;
const lastEnableLangDataComplete = transaction.startState.facet(enableLanguageDataAutocomplete)[0];
const enableLangDataComplete = transaction.state.facet(enableLanguageDataAutocomplete)[0];
const completionsChanged = lastCompletions.length !== completions.length || completions.some((val, idx) => lastCompletions[idx] !== val);
const useLangDataChanged = lastEnableLangDataComplete !== enableLangDataComplete;
// Update the autocompletion extension based on the editorCompletionSource
// facet.
if (missingExtension || completionsChanged || useLangDataChanged) {
if (completions.length > 0 || enableLangDataComplete) {
return {
effects: compartment.reconfigure([
completions.map(completion =>
EditorState.languageData.of(() => [{ autocomplete: completion }]),
),
autocompletion(enableLangDataComplete ? {} : {
override: [...completions],
}),
]),
};
} else if (!missingExtension) {
return {
effects: compartment.reconfigure([]),
};
}
}
return null;
}),
];
};
export default customEditorCompletion;

View File

@ -0,0 +1,16 @@
import Setting from '@joplin/lib/models/Setting';
import createEditor from '../createEditor';
import createEditorSettings from './createEditorSettings';
const createEditorControl = (initialText: string) => {
const editorSettings = createEditorSettings(Setting.THEME_LIGHT);
return createEditor(document.body, {
initialText,
settings: editorSettings,
onEvent: _event => {},
onLogMessage: _message => {},
});
};
export default createEditorControl;

View File

@ -0,0 +1,16 @@
import { EditorView } from '@codemirror/view';
const typeText = (editor: EditorView, text: string) => {
// How CodeMirror does this in their tests:
// https://github.com/codemirror/autocomplete/blob/fb1c899464df4d36528331412cdd316548134cb2/test/webtest-autocomplete.ts#L116
// The important part is the userEvent: input.type.
const selection = editor.state.selection;
editor.dispatch({
changes: [{ from: selection.main.head, insert: text }],
selection: { anchor: selection.main.head + text.length },
userEvent: 'input.type',
});
};
export default typeText;

View File

@ -533,6 +533,40 @@ export interface MarkdownItContentScriptModule extends Omit<ContentScriptModule,
plugin: (markdownIt: any, options: any)=> any;
}
export interface CodeMirrorControl {
/** Points to a CodeMirror 6 EditorView instance. */
editor: any;
cm6: any;
/** `extension` should be a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension). */
addExtension(extension: any|any[]): void;
execCommand(name: string): any;
supportsCommand(name: string): boolean;
joplinExtensions: {
/**
* Returns a [CodeMirror 6 extension](https://codemirror.net/docs/ref/#state.Extension) that
* registers the given [CompletionSource](https://codemirror.net/docs/ref/#autocomplete.CompletionSource).
*
* Use this extension rather than the built-in CodeMirror [`autocompletion`](https://codemirror.net/docs/ref/#autocomplete.autocompletion)
* if you don't want to use [langaugeData-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override).
*
* Using `autocompletion({ override: [ ... ]})` causes errors when done by multiple plugins.
*/
completionSource(completionSource: any): any;
/**
* Creates an extension that enables or disables [`languageData`-based autocompletion](https://codemirror.net/docs/ref/#autocomplete.autocompletion^config.override).
*/
enableLanguageDataAutocomplete: { of: (enabled: boolean)=> any };
};
}
export interface CodeMirrorContentScriptModule extends Omit<ContentScriptModule, 'plugin'> {
plugin: (codeMirrorControl: CodeMirrorControl)=> void;
}
export enum ContentScriptType {
/**
* Registers a new Markdown-It plugin, which should follow the template