diff --git a/.eslintignore b/.eslintignore index 888c19b15..95e5bf976 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index 26385a166..ac6bc1607 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-cli/tests/support/plugins/codemirror6/api/types.ts b/packages/app-cli/tests/support/plugins/codemirror6/api/types.ts index e439b5246..52e3e2972 100644 --- a/packages/app-cli/tests/support/plugins/codemirror6/api/types.ts +++ b/packages/app-cli/tests/support/plugins/codemirror6/api/types.ts @@ -533,6 +533,40 @@ export interface MarkdownItContentScriptModule extends Omit 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 { + plugin: (codeMirrorControl: CodeMirrorControl)=> void; +} + export enum ContentScriptType { /** * Registers a new Markdown-It plugin, which should follow the template diff --git a/packages/app-cli/tests/support/plugins/codemirror6/package-lock.json b/packages/app-cli/tests/support/plugins/codemirror6/package-lock.json index 87e4e7944..caef3d189 100644 --- a/packages/app-cli/tests/support/plugins/codemirror6/package-lock.json +++ b/packages/app-cli/tests/support/plugins/codemirror6/package-lock.json @@ -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", diff --git a/packages/app-cli/tests/support/plugins/codemirror6/package.json b/packages/app-cli/tests/support/plugins/codemirror6/package.json index 8d68e399a..a918e5811 100644 --- a/packages/app-cli/tests/support/plugins/codemirror6/package.json +++ b/packages/app-cli/tests/support/plugins/codemirror6/package.json @@ -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" } } diff --git a/packages/app-cli/tests/support/plugins/codemirror6/src/contentScript.ts b/packages/app-cli/tests/support/plugins/codemirror6/src/contentScript.ts index 5e61478af..68e6a1444 100644 --- a/packages/app-cli/tests/support/plugins/codemirror6/src/contentScript.ts +++ b/packages/app-cli/tests/support/plugins/codemirror6/src/contentScript.ts @@ -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: diff --git a/packages/editor/CodeMirror/CodeMirrorControl.test.ts b/packages/editor/CodeMirror/CodeMirrorControl.test.ts index 487214423..7eeb7270b 100644 --- a/packages/editor/CodeMirror/CodeMirrorControl.test.ts +++ b/packages/editor/CodeMirror/CodeMirrorControl.test.ts @@ -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', () => { diff --git a/packages/editor/CodeMirror/CodeMirrorControl.ts b/packages/editor/CodeMirror/CodeMirrorControl.ts index 4e0d4196f..2926c3919 100644 --- a/packages/editor/CodeMirror/CodeMirrorControl.ts +++ b/packages/editor/CodeMirror/CodeMirrorControl.ts @@ -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]), diff --git a/packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.ts b/packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.ts new file mode 100644 index 000000000..1e22b9d67 --- /dev/null +++ b/packages/editor/CodeMirror/pluginApi/customEditorCompletion.test.ts @@ -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(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']); + }); +}); diff --git a/packages/editor/CodeMirror/pluginApi/customEditorCompletion.ts b/packages/editor/CodeMirror/pluginApi/customEditorCompletion.ts new file mode 100644 index 000000000..f61f9c522 --- /dev/null +++ b/packages/editor/CodeMirror/pluginApi/customEditorCompletion.ts @@ -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(); +export const enableLanguageDataAutocomplete = Facet.define(); + +// 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; diff --git a/packages/editor/CodeMirror/testUtil/createEditorControl.ts b/packages/editor/CodeMirror/testUtil/createEditorControl.ts new file mode 100644 index 000000000..4cfcc7a3a --- /dev/null +++ b/packages/editor/CodeMirror/testUtil/createEditorControl.ts @@ -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; diff --git a/packages/editor/CodeMirror/testUtil/typeText.ts b/packages/editor/CodeMirror/testUtil/typeText.ts new file mode 100644 index 000000000..41d10e903 --- /dev/null +++ b/packages/editor/CodeMirror/testUtil/typeText.ts @@ -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; diff --git a/packages/lib/services/plugins/api/types.ts b/packages/lib/services/plugins/api/types.ts index e439b5246..52e3e2972 100644 --- a/packages/lib/services/plugins/api/types.ts +++ b/packages/lib/services/plugins/api/types.ts @@ -533,6 +533,40 @@ export interface MarkdownItContentScriptModule extends Omit 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 { + plugin: (codeMirrorControl: CodeMirrorControl)=> void; +} + export enum ContentScriptType { /** * Registers a new Markdown-It plugin, which should follow the template