├── test-support ├── data │ └── docs │ │ ├── none │ │ ├── empty.md │ │ └── nonempty.md │ │ ├── any │ │ ├── simple.md │ │ └── combo.md │ │ ├── unspaced │ │ ├── simple.md │ │ └── combo.md │ │ ├── spaced │ │ ├── simple.md │ │ └── combo.md │ │ ├── invalid │ │ ├── nested.md │ │ └── unclosed.md │ │ ├── unopened │ │ └── combo.md │ │ ├── opened │ │ └── combo.md │ │ └── mixed │ │ └── combo.md ├── matchers │ ├── JestExtendedMatchers.ts │ ├── common.ts │ ├── SetMatchers.ts │ └── MapMatchers.ts ├── ext │ ├── stdlib │ │ └── CondensedString.ts │ ├── vitest │ │ └── Vitest.ts │ └── codemirror │ │ └── cm6 │ │ ├── RangeSets.ts │ │ ├── InlineText.ts │ │ └── InlineState.ts ├── types │ └── vitest │ │ └── index.d.ts ├── fakes │ ├── dom │ │ ├── FakeClipboard.ts │ │ ├── FakeKeyboardEvent.ts │ │ └── FakeWindow.ts │ ├── joplin │ │ ├── FakeJoplinCommands.ts │ │ ├── FakeJoplinExtensions.ts │ │ ├── FakeCodeMirror6.ts │ │ └── FakeCodeMirror5.ts │ ├── codemirror │ │ └── cm6 │ │ │ ├── autocomplete │ │ │ └── FakeCompletionContext.ts │ │ │ └── view │ │ │ ├── FakeViewUpdate.ts │ │ │ └── FakeEditorView.ts │ └── stdlib │ │ └── FakeRetrier.ts ├── fixtures │ └── cm6 │ │ └── Any.ts └── TestSetup.ts ├── plugin.config.json ├── .prettierignore ├── media ├── logo128.png ├── logo16.png ├── logo32.png ├── logo48.png ├── preview.gif ├── promo.png ├── settings.png ├── minimal layout.png ├── standard layout.png └── logo.svg ├── api ├── index.ts ├── JoplinFilters.d.ts ├── Global.d.ts ├── JoplinViewsToolbarButtons.d.ts ├── JoplinViewsMenuItems.d.ts ├── JoplinViewsMenus.d.ts ├── JoplinClipboard.d.ts ├── JoplinInterop.d.ts ├── JoplinViewsNoteList.d.ts ├── JoplinWindow.d.ts ├── JoplinViews.d.ts └── JoplinPlugins.d.ts ├── .gitignore ├── src ├── cm-extension │ ├── cm5 │ │ ├── style │ │ │ ├── BuiltInClass.ts │ │ │ ├── FontAwesomeClass.ts │ │ │ └── CodeBlockClass.ts │ │ ├── marker │ │ │ ├── widgeter │ │ │ │ ├── Widget.ts │ │ │ │ └── Widgeter.ts │ │ │ ├── line-styler │ │ │ │ ├── LineStyle.ts │ │ │ │ └── LineStyler.ts │ │ │ └── Marker.ts │ │ ├── model │ │ │ ├── CodeBlocks.ts │ │ │ ├── Origin.ts │ │ │ └── Config.ts │ │ ├── handler │ │ │ ├── RequestHandler.ts │ │ │ ├── CompleteHandler.ts │ │ │ ├── RenderHandler.ts │ │ │ └── SelectHandler.ts │ │ ├── assets │ │ │ └── opt │ │ │ │ └── render-layout │ │ │ │ ├── standard.css │ │ │ │ └── minimal.css │ │ ├── formatter │ │ │ ├── Formatter.ts │ │ │ ├── BetweenSpacer.ts │ │ │ └── Opener.ts │ │ ├── RangeFinder.ts │ │ └── renderer │ │ │ ├── RenderParser.ts │ │ │ ├── Renderer.ts │ │ │ └── RenderPerformer.ts │ └── cm6 │ │ ├── theme │ │ ├── style │ │ │ ├── FontAwesomeClass.ts │ │ │ └── CodeBlockClass.ts │ │ ├── StandardMixin.ts │ │ ├── MinimalMixin.ts │ │ └── Theme.ts │ │ ├── rendering │ │ ├── ViewPluginSpec.ts │ │ ├── ConfigEditorAttributes.ts │ │ ├── ViewPluginValue.ts │ │ └── decoration │ │ │ ├── ReplaceDecorations.ts │ │ │ └── LineDecorations.ts │ │ ├── state │ │ ├── ConfigFacet.ts │ │ └── CodeBlocksStateField.ts │ │ ├── parsing │ │ └── FenceMatcher.ts │ │ ├── model │ │ ├── CodeEditorStates.ts │ │ └── CodeDocs.ts │ │ └── modification │ │ ├── formatting │ │ └── EmptyCodeBlockUpdater.ts │ │ ├── cursor │ │ ├── CodeFenceAtomicRanges.ts │ │ ├── ClosingFenceCursorFilter.ts │ │ └── OpeningFenceCursorFilter.ts │ │ └── selection │ │ └── AllCodeSelectionFilter.ts ├── ext │ ├── ts-belt │ │ └── alias.ts │ ├── codemirror │ │ ├── cm6 │ │ │ ├── autocomplete │ │ │ │ └── CompletionType.ts │ │ │ └── state │ │ │ │ ├── UserEvent.ts │ │ │ │ ├── SingularFacet.ts │ │ │ │ ├── EditorSelections.ts │ │ │ │ ├── Facets.ts │ │ │ │ ├── Transactions.ts │ │ │ │ └── CursorMovementFilter.ts │ │ └── cm5 │ │ │ ├── Events.ts │ │ │ ├── Positions.ts │ │ │ ├── Range.ts │ │ │ └── LineSegment.ts │ ├── joplin │ │ ├── JoplinPlugins.ts │ │ ├── JoplinSettings.ts │ │ └── JoplinCommands.ts │ ├── stdlib │ │ ├── Repeat.ts │ │ ├── Arrays.ts │ │ ├── existence.ts │ │ ├── Iterables.ts │ │ ├── Require.ts │ │ └── Retrier.ts │ └── lezer │ │ └── markdown │ │ └── Nodes.ts ├── joplin-plugin-ipc │ └── model │ │ ├── handler.ts │ │ ├── types.ts │ │ ├── base.ts │ │ └── messages.ts ├── cm-extension-ipc │ ├── model │ │ ├── handler.ts │ │ ├── messages.ts │ │ └── base.ts │ └── CmExtensionClient.ts ├── joplin-plugin │ ├── settings │ │ └── PluginSettings.ts │ └── handler │ │ └── RequestHandler.ts ├── manifest.json └── index.ts ├── .npmignore ├── types └── @lezer │ └── index.d.ts ├── .prettierrc.mjs ├── .vale.ini ├── test ├── cm-extension │ ├── cm6 │ │ ├── model │ │ │ ├── Config.test.ts │ │ │ ├── CodeEditorStates.test.ts │ │ │ └── CodeDocs.test.ts │ │ ├── rendering │ │ │ ├── ViewPluginSpec.test.ts │ │ │ ├── ConfigEditorAttributes.test.ts │ │ │ └── decoration │ │ │ │ ├── LineDecorations.test.ts │ │ │ │ └── ReplaceDecorations.test.ts │ │ ├── modification │ │ │ ├── cursor │ │ │ │ └── CodeFenceAtomicRanges.test.ts │ │ │ └── formatting │ │ │ │ └── EmptyCodeBlockUpdater.test.ts │ │ ├── state │ │ │ ├── ConfigFacet.test.ts │ │ │ └── CodeBlocksStateField.test.ts │ │ └── parsing │ │ │ ├── FenceMatcher.test.ts │ │ │ └── FenceCompletionParser.test.ts │ └── cm5 │ │ ├── handler │ │ ├── RequestHandler.test.ts │ │ ├── RenderHandler.test.ts │ │ └── CompleteHandler.test.ts │ │ ├── renderer │ │ ├── RenderParser.test.ts │ │ ├── RenderPerformer.test.ts │ │ └── Renderer.test.ts │ │ ├── marker │ │ ├── Marker.test.ts │ │ ├── widgeter │ │ │ └── WidgetGenerator.test.ts │ │ └── line-styler │ │ │ └── LineStyler.test.ts │ │ ├── RangeFinder.test.ts │ │ ├── formatter │ │ ├── Formatter.test.ts │ │ └── EdgeSpacer.test.ts │ │ ├── completer │ │ └── CompletionGenerator.test.ts │ │ └── model │ │ └── CodeBlocks.test.ts ├── ext │ ├── joplin │ │ ├── JoplinPlugins.test.ts │ │ ├── JoplinCommands.test.ts │ │ └── JoplinSettings.test.ts │ ├── stdlib │ │ ├── Repeat.test.ts │ │ ├── Arrays.test.ts │ │ ├── Require.test.ts │ │ ├── Iterables.test.ts │ │ └── Retrier.test.ts │ ├── codemirror │ │ ├── cm6 │ │ │ └── state │ │ │ │ └── Facets.test.ts │ │ └── cm5 │ │ │ ├── Range.test.ts │ │ │ ├── Positions.test.ts │ │ │ └── Events.test.ts │ └── lezer │ │ └── markdown │ │ └── Nodes.test.ts ├── cm-extension-ipc │ └── CmExtensionClient.test.ts └── joplin-plugin │ ├── settings │ └── PluginSettingsProvider.test.ts │ └── handler │ └── RequestHandler.test.ts ├── stylelint.config.mjs ├── .github └── workflows │ ├── publish.yml │ └── ci.yml ├── tsconfig.json ├── LICENSE ├── vitest.config.mts └── CHANGELOG.md /test-support/data/docs/none/empty.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugin.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extraScripts": ["contentScriptDefinition.ts"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore CodeMirror test docs 2 | test-support/data/docs/**/*.md 3 | -------------------------------------------------------------------------------- /media/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/logo128.png -------------------------------------------------------------------------------- /media/logo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/logo16.png -------------------------------------------------------------------------------- /media/logo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/logo32.png -------------------------------------------------------------------------------- /media/logo48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/logo48.png -------------------------------------------------------------------------------- /media/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/preview.gif -------------------------------------------------------------------------------- /media/promo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/promo.png -------------------------------------------------------------------------------- /media/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/settings.png -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import type Joplin from './Joplin'; 2 | 3 | declare const joplin: Joplin; 4 | 5 | export default joplin; 6 | -------------------------------------------------------------------------------- /media/minimal layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/minimal layout.png -------------------------------------------------------------------------------- /test-support/data/docs/any/simple.md: -------------------------------------------------------------------------------- 1 | ```typescript 2 | function main() { 3 | console.info("Hello World") 4 | } 5 | ``` -------------------------------------------------------------------------------- /media/standard layout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ckant/joplin-plugin-better-code-blocks/HEAD/media/standard layout.png -------------------------------------------------------------------------------- /test-support/data/docs/unspaced/simple.md: -------------------------------------------------------------------------------- 1 | ```typescript 2 | function main() { 3 | console.info("Hello World") 4 | } 5 | ``` -------------------------------------------------------------------------------- /test-support/data/docs/spaced/simple.md: -------------------------------------------------------------------------------- 1 | 2 | ```typescript 3 | function main() { 4 | console.info("Hello World") 5 | } 6 | ``` 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | 4 | coverage/ 5 | dist/ 6 | node_modules/ 7 | publish/ 8 | styles/ 9 | 10 | .DS_STORE 11 | -------------------------------------------------------------------------------- /test-support/data/docs/invalid/nested.md: -------------------------------------------------------------------------------- 1 | ```typescript 2 | function main() { 3 | console.info("Hello World") 4 | } 5 | ```typescript 6 | function main() { 7 | console.info("Hello World") 8 | } 9 | ``` 10 | ``` -------------------------------------------------------------------------------- /test-support/matchers/JestExtendedMatchers.ts: -------------------------------------------------------------------------------- 1 | import * as matchers from "jest-extended" 2 | 3 | /** 4 | * More jest matchers. 5 | * 6 | * @see https://github.com/jest-community/jest-extended 7 | */ 8 | export const JestExtendedMatchers = matchers 9 | -------------------------------------------------------------------------------- /test-support/data/docs/any/combo.md: -------------------------------------------------------------------------------- 1 | # Heading 1 2 | 3 | ```typescript 4 | function main() { 5 | console.info("Hello World") 6 | } 7 | ``` 8 | 9 | # Heading 2 10 | 11 | ```typescript 12 | ``` 13 | 14 | # Heading 3 15 | 16 | ``` 17 | git status 18 | ``` 19 | -------------------------------------------------------------------------------- /test-support/data/docs/invalid/unclosed.md: -------------------------------------------------------------------------------- 1 | ```typescript 2 | function main() { 3 | console.info("Hello World") 4 | } 5 | ``` 6 | 7 | ````` 8 | ````` 9 | 10 | ````java 11 | public static void main(String[] args) { 12 | System.out.println("Hello World"); 13 | } 14 | ```` 15 | 16 | ``` 17 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/style/BuiltInClass.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Built-in css classes. 3 | */ 4 | export const BuiltInClass = { 5 | // Tag for the Joplin monospace font (customizable by the user) 6 | monospace: "cm-jn-monospace", 7 | } as const 8 | export type BuiltInClass = (typeof BuiltInClass)[keyof typeof BuiltInClass] 9 | -------------------------------------------------------------------------------- /api/JoplinFilters.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @ignore 3 | * 4 | * Not sure if it's the best way to hook into the app 5 | * so for now disable filters. 6 | */ 7 | export default class JoplinFilters { 8 | on(name: string, callback: Function): Promise; 9 | off(name: string, callback: Function): Promise; 10 | } 11 | -------------------------------------------------------------------------------- /test-support/data/docs/unopened/combo.md: -------------------------------------------------------------------------------- 1 | 2 | ```typescript 3 | ``` 4 | 5 | ```typescript 6 | function main() { 7 | console.info("Hello World") 8 | } 9 | ``` 10 | 11 | ```typescript 12 | ``` 13 | 14 | ```typescript 15 | function main() { 16 | console.info("Hello World") 17 | } 18 | ``` 19 | 20 | ```typescript 21 | ``` 22 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/style/FontAwesomeClass.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Font-Awesome CSS classes. 3 | */ 4 | export const FontAwesomeClass = { 5 | solid: "fa-solid", 6 | clipboard: "fa-clipboard", 7 | clipboardCheck: "fa-clipboard-check", 8 | } as const 9 | export type FontAwesomeClass = (typeof FontAwesomeClass)[keyof typeof FontAwesomeClass] 10 | -------------------------------------------------------------------------------- /api/Global.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import Joplin from './Joplin'; 3 | /** 4 | * @ignore 5 | */ 6 | /** 7 | * @ignore 8 | */ 9 | export default class Global { 10 | private joplin_; 11 | constructor(implementation: any, plugin: Plugin, store: any); 12 | get joplin(): Joplin; 13 | get process(): any; 14 | } 15 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/theme/style/FontAwesomeClass.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Font-Awesome CSS classes. 3 | */ 4 | export const FontAwesomeClass = { 5 | solid: "fa-solid", 6 | clipboard: "fa-clipboard", 7 | clipboardCheck: "fa-clipboard-check", 8 | } as const 9 | export type FontAwesomeClass = (typeof FontAwesomeClass)[keyof typeof FontAwesomeClass] 10 | -------------------------------------------------------------------------------- /test-support/data/docs/opened/combo.md: -------------------------------------------------------------------------------- 1 | 2 | ```typescript 3 | 4 | ``` 5 | 6 | ```typescript 7 | function main() { 8 | console.info("Hello World") 9 | } 10 | ``` 11 | 12 | ```typescript 13 | 14 | ``` 15 | 16 | ```typescript 17 | function main() { 18 | console.info("Hello World") 19 | } 20 | ``` 21 | 22 | ```typescript 23 | 24 | ``` 25 | -------------------------------------------------------------------------------- /test-support/ext/stdlib/CondensedString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Trims and removes newlines from the given `string`. 3 | */ 4 | export function CondensedString(arr: TemplateStringsArray): string { 5 | const lines = arr.join("").split("\n") 6 | return lines 7 | .slice(1, lines.length - 1) 8 | .map((line) => line.trim()) 9 | .join("") 10 | } 11 | -------------------------------------------------------------------------------- /test-support/data/docs/none/nonempty.md: -------------------------------------------------------------------------------- 1 | # This is a doc that contains no code blocks 2 | 3 | ## Reasoning 4 | 5 | - Good 6 | - For 7 | - Testing 8 | 9 | `Inline Code` 10 | 11 | console.log("Indented code") 12 | ```Indented code that looks like fenced code``` 13 | ~~~Same with this one~~~ 14 | 15 | [Check `this` out!](http://localhost) 16 | -------------------------------------------------------------------------------- /src/ext/ts-belt/alias.ts: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | 3 | /** 4 | * Renames rather vague single-letter namespaces. 5 | */ 6 | export { 7 | A as Arrays, 8 | B as Bools, 9 | D as Dicts, 10 | F as Functions, 11 | G as Guards, 12 | N as Numbers, 13 | O as Options, 14 | R as Results, 15 | S as Strings, 16 | } from "@mobily/ts-belt" 17 | -------------------------------------------------------------------------------- /test-support/ext/vitest/Vitest.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test extensions for `vitest`. 3 | */ 4 | export namespace Vitest { 5 | /** 6 | * Creates a new {@link Promise} which, when awaited, settles earlier {@link Promise}s in the queue. 7 | */ 8 | export async function settlePendingPromises(): Promise { 9 | await new Promise(setImmediate) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test-support/matchers/common.ts: -------------------------------------------------------------------------------- 1 | import { MatcherState } from "@vitest/expect" 2 | 3 | /** 4 | * Shortcut for checking strict equality using {@link MatcherState}. 5 | */ 6 | export function strictEquals(matcherState: MatcherState): (a: unknown, b: unknown) => boolean { 7 | return (a, b) => matcherState.equals(a, b, [matcherState.utils.iterableEquality], true) 8 | } 9 | -------------------------------------------------------------------------------- /src/joplin-plugin-ipc/model/handler.ts: -------------------------------------------------------------------------------- 1 | import { PluginMessageKind, PluginRequest, PluginResponse } from "@joplin-plugin-ipc/model/base" 2 | 3 | /** 4 | * Handles a {@link PluginRequest} and returns the corresponding {@link PluginResponse}. 5 | */ 6 | export type PluginRequestHandler = ( 7 | request: PluginRequest, 8 | ) => PluginResponse 9 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/autocomplete/CompletionType.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Completion types defined by CodeMirror. 3 | * 4 | * @see https://codemirror.net/docs/ref/#autocomplete.Completion.type 5 | */ 6 | export const CompletionType = { 7 | // Completion of a type definition 8 | type: "type", 9 | } as const 10 | export type CompletionType = (typeof CompletionType)[keyof typeof CompletionType] 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | 4 | .github/ 5 | api/ 6 | coverage/ 7 | dist/ 8 | media/ 9 | src/ 10 | styles/ 11 | test/ 12 | test-integration/ 13 | test-support/ 14 | types/ 15 | 16 | .prettierignore 17 | .prettierrc.mjs 18 | .vale.ini 19 | CHANGELOG.md 20 | eslint.config.mjs 21 | GENERATOR_DOC.md 22 | stylelint.config.mjs 23 | tsconfig.json 24 | vitest.config.mts 25 | webpack.config.js 26 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/marker/widgeter/Widget.ts: -------------------------------------------------------------------------------- 1 | import { LineSegment } from "@ext/codemirror/cm5/LineSegment" 2 | 3 | /** 4 | * Represents an {@link element} that replaces the content of a {@link range} of text. 5 | * 6 | * @see https://codemirror.net/5/doc/manual.html#markText 7 | */ 8 | export interface Widget { 9 | readonly element: HTMLElement 10 | readonly range: LineSegment 11 | } 12 | -------------------------------------------------------------------------------- /test-support/data/docs/mixed/combo.md: -------------------------------------------------------------------------------- 1 | ```kotlin // Some other stuff 2 | ``` 3 | ~~~~~~ 4 | 5 | ~~~~~~ 6 | 7 | ```typescript 8 | function main() { 9 | console.info("Hello World") 10 | } 11 | ``` 12 | 13 | ````` 14 | ````` 15 | 16 | ````java 17 | public static void main(String[] args) { 18 | System.out.println("Hello World"); 19 | } 20 | ```` 21 | 22 | ``` 23 | 24 | ``` 25 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/state/UserEvent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * User events defined by CodeMirror. 3 | * 4 | * @see https://codemirror.net/docs/ref/#state.Transaction%5EuserEvent 5 | */ 6 | export const UserEvent = { 7 | // Backspace key press 8 | deleteBackward: "delete.backward", 9 | 10 | // Delete key press 11 | deleteForward: "delete.forward", 12 | } as const 13 | export type UserEvent = (typeof UserEvent)[keyof typeof UserEvent] 14 | -------------------------------------------------------------------------------- /src/cm-extension-ipc/model/handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CmExtensionMessageKind, 3 | CmExtensionRequest, 4 | CmExtensionResponse, 5 | } from "@cm-extension-ipc/model/base" 6 | 7 | /** 8 | * Handles a {@link CmExtensionRequest} and returns the corresponding {@link CmExtensionResponse}. 9 | */ 10 | export type CmExtensionRequestHandler = ( 11 | request: CmExtensionRequest, 12 | ) => CmExtensionResponse 13 | -------------------------------------------------------------------------------- /types/@lezer/index.d.ts: -------------------------------------------------------------------------------- 1 | import "@lezer/common" 2 | import "@lezer/markdown" 3 | 4 | import { SyntaxNodeRef } from "@lezer/common" 5 | 6 | declare module "@lezer/common" { 7 | /** 8 | * Description of a lezer syntax node (mimics the {@link SyntaxNodeRef} type definition). 9 | * 10 | * Useful to describe a smaller subset of attributes. 11 | */ 12 | export type NodeDescription = Pick 13 | } 14 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | export default { 3 | arrowParens: "always", 4 | bracketSpacing: true, 5 | endOfLine: "lf", 6 | htmlWhitespaceSensitivity: "css", 7 | insertPragma: false, 8 | printWidth: 100, 9 | proseWrap: "preserve", 10 | quoteProps: "as-needed", 11 | requirePragma: false, 12 | semi: false, 13 | singleQuote: false, 14 | tabWidth: 2, 15 | trailingComma: "all", 16 | useTabs: false, 17 | } 18 | -------------------------------------------------------------------------------- /.vale.ini: -------------------------------------------------------------------------------- 1 | StylesPath = styles 2 | 3 | MinAlertLevel = suggestion 4 | 5 | Packages = Microsoft, proselint 6 | 7 | [*] 8 | BasedOnStyles = Vale, Microsoft, proselint 9 | 10 | # Disable due to too many false positives with coding terms 11 | [*] 12 | Vale.Spelling = NO 13 | 14 | Microsoft.Adverbs = NO 15 | Microsoft.ComplexWords = NO 16 | Microsoft.Foreign = NO 17 | Microsoft.Quotes = NO 18 | Microsoft.Spacing = NO 19 | Microsoft.Units = NO 20 | Microsoft.Vocab = NO 21 | -------------------------------------------------------------------------------- /test-support/data/docs/unspaced/combo.md: -------------------------------------------------------------------------------- 1 | 2 | ```typescript 3 | function main() { 4 | console.info("Hello World") 5 | } 6 | ``` 7 | ```typescript 8 | function main() { 9 | console.info("Hello World") 10 | } 11 | ``` 12 | ```typescript 13 | function main() { 14 | console.info("Hello World") 15 | } 16 | ``` 17 | 18 | ```typescript 19 | function main() { 20 | console.info("Hello World") 21 | } 22 | ``` 23 | 24 | ```typescript 25 | function main() { 26 | console.info("Hello World") 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /test-support/data/docs/spaced/combo.md: -------------------------------------------------------------------------------- 1 | 2 | ```typescript 3 | function main() { 4 | console.info("Hello World") 5 | } 6 | ``` 7 | 8 | ```typescript 9 | function main() { 10 | console.info("Hello World") 11 | } 12 | ``` 13 | 14 | ```typescript 15 | function main() { 16 | console.info("Hello World") 17 | } 18 | ``` 19 | 20 | ```typescript 21 | function main() { 22 | console.info("Hello World") 23 | } 24 | ``` 25 | 26 | ```typescript 27 | function main() { 28 | console.info("Hello World") 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/state/SingularFacet.ts: -------------------------------------------------------------------------------- 1 | import { Facet, FacetSpec } from "@codemirror/state" 2 | 3 | /** 4 | * Config for a {@link SingularFacet}. 5 | * 6 | * Simplification of a {@link FacetSpec} that defines a `defaultValue` rather than 7 | * way to `combine` multiple values. 8 | */ 9 | export type SingularFacetSpec = { defaultValue(): T } & Omit, "combine"> 10 | 11 | /** 12 | * A {@link Facet} that has a singular input and output. 13 | */ 14 | export type SingularFacet = Facet 15 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/model/Config.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Config } from "@cm-extension/cm6/model/Config" 4 | 5 | describe("Config", () => { 6 | describe("createDefault", () => { 7 | it("creates a default config", () => { 8 | const firstConfig = Config.createDefault() 9 | const secondConfig = Config.createDefault() 10 | expect(firstConfig).toStrictEqual(secondConfig) 11 | expect(firstConfig).not.toBe(secondConfig) 12 | }) 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/marker/line-styler/LineStyle.ts: -------------------------------------------------------------------------------- 1 | import { CodeBlockClass } from "@cm-extension/cm5/style/CodeBlockClass" 2 | 3 | /** 4 | * Represents the line classes ({@link background}/{@link text}/{@link wrap}) for a {@link line}. 5 | * 6 | * @see https://codemirror.net/5/doc/manual.html#addLineClass 7 | */ 8 | export interface LineStyle { 9 | readonly line: number 10 | 11 | readonly background?: readonly CodeBlockClass[] 12 | readonly text?: readonly CodeBlockClass[] 13 | readonly wrap?: readonly CodeBlockClass[] 14 | } 15 | -------------------------------------------------------------------------------- /src/ext/joplin/JoplinPlugins.ts: -------------------------------------------------------------------------------- 1 | import { CodeMirror5, CodeMirror6 } from "api/types" 2 | 3 | /** 4 | * Extensions for `JoplinSettings`. 5 | */ 6 | export namespace JoplinPlugins { 7 | /** 8 | * Type guard that returns whether a {@link codeMirror} is {@link CodeMirror6}. 9 | * 10 | * @see https://joplinapp.org/help/api/tutorials/cm6_plugin#codemirror-5-compatibility 11 | */ 12 | export function isCodeMirror6(codeMirror: CodeMirror5 | CodeMirror6): codeMirror is CodeMirror6 { 13 | return "cm6" in codeMirror 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/model/CodeBlocks.ts: -------------------------------------------------------------------------------- 1 | import { Arrays } from "@ts-belt" 2 | 3 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 4 | 5 | /** 6 | * Operations on multiple {@link CodeBlock}s. 7 | */ 8 | export namespace CodeBlocks { 9 | /** 10 | * Returns true if {@link first} is deeply equal to {@link second}. 11 | */ 12 | export function areEqual(first: readonly CodeBlock[], second: readonly CodeBlock[]): boolean { 13 | return Arrays.eq(first, second, (firstCb, secondCb) => firstCb.equals(secondCb)) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/rendering/ViewPluginSpec.ts: -------------------------------------------------------------------------------- 1 | import { DecoratedPluginValue, DecorationSet, PluginSpec } from "@codemirror/view" 2 | 3 | /** 4 | * `ViewPlugin` specification that provides decorations stored on the {@link DecoratedPluginValue}. 5 | * 6 | * @see PluginSpec 7 | */ 8 | export const ViewPluginSpec: PluginSpec = { 9 | /** 10 | * Provides decorations stored on the {@link plugin}. 11 | */ 12 | decorations(plugin: DecoratedPluginValue): DecorationSet { 13 | return plugin.decorations 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /stylelint.config.mjs: -------------------------------------------------------------------------------- 1 | import standardConfig from "stylelint-config-standard" 2 | 3 | const [standardClassPattern, standardMessage] = standardConfig.rules["selector-class-pattern"] 4 | const builtInClassPattern = "^CodeMirror" 5 | const oneOf = (...patterns) => patterns.map((it) => `(${it})`).join("|") 6 | 7 | // noinspection JSUnusedGlobalSymbols 8 | export default { 9 | extends: ["stylelint-config-standard", "stylelint-config-recess-order"], 10 | rules: { 11 | "selector-class-pattern": [oneOf(standardClassPattern, builtInClassPattern), standardMessage], 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/state/ConfigFacet.ts: -------------------------------------------------------------------------------- 1 | import { Facets } from "@ext/codemirror/cm6/state/Facets" 2 | import { SingularFacetSpec } from "@ext/codemirror/cm6/state/SingularFacet" 3 | 4 | import { Config } from "@cm-extension/cm6/model/Config" 5 | 6 | /** 7 | * {@link SingularFacetSpec} for a {@link SingularFacet} that stores a {@link Config}. 8 | */ 9 | export const ConfigFacetSpec: SingularFacetSpec = { defaultValue: Config.createDefault } 10 | 11 | /** 12 | * {@link SingularFacet} that stores a {@link Config}. 13 | */ 14 | export const ConfigFacet = Facets.defineSingular(ConfigFacetSpec) 15 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/model/Origin.ts: -------------------------------------------------------------------------------- 1 | import { CmExtension } from "@cm-extension/cm5/CmExtension" 2 | 3 | /** 4 | * Represents specific origins for tagging the source of {@link CodeMirror} write operations. 5 | */ 6 | export const Origin = { 7 | /** 8 | * Originates from {@link RenderHandler}. 9 | */ 10 | RenderHandler: `${CmExtension.extensionName}.RenderHandler`, 11 | 12 | /** 13 | * Originates from {@link CompleteHandler}. 14 | */ 15 | CompleteHandler: `${CmExtension.extensionName}.CompleteHandler`, 16 | } as const 17 | 18 | export type Origin = (typeof Origin)[keyof typeof Origin] 19 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/rendering/ViewPluginSpec.test.ts: -------------------------------------------------------------------------------- 1 | import { RangeSet } from "@codemirror/state" 2 | import { Decoration } from "@codemirror/view" 3 | import { describe, expect, it } from "vitest" 4 | 5 | import { ViewPluginSpec } from "@cm-extension/cm6/rendering/ViewPluginSpec" 6 | 7 | describe("ViewPluginSpec", () => { 8 | describe("decorations", () => { 9 | it("returns plugin decorations", () => { 10 | const decorations = RangeSet.of(Decoration.line({}).range(0)) 11 | 12 | expect(ViewPluginSpec.decorations!({ decorations })).toStrictEqual(decorations) 13 | }) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /test-support/ext/codemirror/cm6/RangeSets.ts: -------------------------------------------------------------------------------- 1 | import { RangeSet, RangeValue } from "@codemirror/state" 2 | 3 | import { def } from "@ext/stdlib/existence" 4 | 5 | export namespace RangeSets { 6 | export function getRanges( 7 | rangeSet: RangeSet, 8 | ): readonly { from: number; to: number; value: T }[] { 9 | const cursor = rangeSet.iter() 10 | 11 | const ranges: { from: number; to: number; value: T }[] = [] 12 | do { 13 | ranges.push({ from: cursor.from, to: cursor.to, value: cursor.value! }) 14 | cursor.next() 15 | } while (def(cursor.value)) 16 | 17 | return ranges 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: [created] 5 | permissions: 6 | id-token: write 7 | contents: read 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | - name: Setup node 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '18.x' 18 | registry-url: 'https://registry.npmjs.org' 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Build dist 22 | run: npm run dist 23 | - name: Publish 24 | run: npm publish 25 | -------------------------------------------------------------------------------- /src/cm-extension-ipc/model/messages.ts: -------------------------------------------------------------------------------- 1 | import { CmExtensionRequest, CmExtensionResponse } from "@cm-extension-ipc/model/base" 2 | 3 | /** 4 | * A request to ping the CodeMirror extension to check that it's reachable. 5 | */ 6 | export type PingRequest = CmExtensionRequest<"ping"> 7 | export namespace PingRequest { 8 | export function of(): PingRequest { 9 | return { kind: "ping" } 10 | } 11 | } 12 | 13 | /** 14 | * A response for a {@link PingResponse}. 15 | */ 16 | export type PingResponse = Awaited> 17 | export namespace PingResponse { 18 | export function of(): PingResponse { 19 | return { kind: "ping" } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/JoplinViewsToolbarButtons.d.ts: -------------------------------------------------------------------------------- 1 | import { ToolbarButtonLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating and managing toolbar buttons. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 7 | */ 8 | export default class JoplinViewsToolbarButtons { 9 | private store; 10 | private plugin; 11 | constructor(plugin: Plugin, store: any); 12 | /** 13 | * Creates a new toolbar button and associate it with the given command. 14 | */ 15 | create(id: string, commandName: string, location: ToolbarButtonLocation): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/parsing/FenceMatcher.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Matches code fences with regular expressions. 3 | */ 4 | export namespace FenceMatcher { 5 | /** 6 | * Match the start of an opening code fence. 7 | * 8 | * - Start of line 9 | * - An (optional) indent of 0 to 3 spaces 10 | * - A mark of 3 or more ~ or \` 11 | * 12 | * Adapted from the regex in the CodeMirror markdown mode. 13 | * Note that this differs slightly from the CommonMark spec. 14 | * 15 | * @see https://spec.commonmark.org/0.31.2/#fenced-code-blocks 16 | */ 17 | export function matchesOpeningFence(text: string): boolean { 18 | return /^ {0,3}(?:```|~~~)/.test(text) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/ext/joplin/JoplinPlugins.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { JoplinPlugins } from "@ext/joplin/JoplinPlugins" 4 | 5 | import { FakeCodeMirror5 } from "test-support/fakes/joplin/FakeCodeMirror5" 6 | import { FakeCodeMirror6 } from "test-support/fakes/joplin/FakeCodeMirror6" 7 | 8 | describe("JoplinPlugins", () => { 9 | describe("isCodeMirror6", () => { 10 | it("returns true when code mirror 6", () => { 11 | expect(JoplinPlugins.isCodeMirror6(FakeCodeMirror6.create())).toBeTrue() 12 | }) 13 | 14 | it("returns false when code mirror 5", () => { 15 | expect(JoplinPlugins.isCodeMirror6(FakeCodeMirror5.create())).toBeFalse() 16 | }) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /test-support/ext/codemirror/cm6/InlineText.ts: -------------------------------------------------------------------------------- 1 | import { Text } from "@codemirror/state" 2 | 3 | export function InlineText(arr: TemplateStringsArray): Text { 4 | const text = arr.join("").split("\n") 5 | const lines = trimMinIndent(removeSurroundingNewlines(text)) 6 | 7 | return Text.of(lines) 8 | } 9 | 10 | function removeSurroundingNewlines(lines: readonly string[]): readonly string[] { 11 | if (lines.length <= 1) return lines 12 | 13 | return lines.slice(1, lines.length - 1) 14 | } 15 | 16 | function trimMinIndent(lines: readonly string[]): readonly string[] { 17 | const minIndent = Math.min(...lines.map((it) => it.length - it.trimStart().length)) 18 | return lines.map((it) => it.slice(minIndent)) 19 | } 20 | -------------------------------------------------------------------------------- /src/cm-extension-ipc/model/base.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Kinds of CodeMirror extension requests / responses. 3 | */ 4 | export type CmExtensionMessageKind = "ping" 5 | 6 | /** 7 | * CodeMirror extension requests / responses. 8 | */ 9 | interface CmExtensionRequestMap { 10 | readonly ping: { readonly kind: K } 11 | } 12 | interface CmExtensionResponseMap { 13 | readonly ping: Promise<{ readonly kind: "ping" }> 14 | } 15 | 16 | /** 17 | * Base type for a CodeMirror extension request / response. 18 | */ 19 | export type CmExtensionRequest = CmExtensionRequestMap[K] 20 | export type CmExtensionResponse = CmExtensionResponseMap[K] 21 | -------------------------------------------------------------------------------- /test-support/types/vitest/index.d.ts: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | 3 | import "vitest" 4 | 5 | declare module "vitest" { 6 | /** 7 | * Extend vitest matchers. 8 | * 9 | * @see https://vitest.dev/guide/extending-matchers.html 10 | */ 11 | interface Assertion { 12 | toContainExactlyEntry(key: unknown, value: unknown): T 13 | toContainExactlyItem(value: unknown): T 14 | } 15 | 16 | /** 17 | * Extend vitest matchers. 18 | * 19 | * @see https://vitest.dev/guide/extending-matchers.html 20 | */ 21 | interface AsymmetricMatchersContaining { 22 | toContainExactlyEntry(key: unknown, value: unknown): Map 23 | toContainExactlyItem(value: unknown): Set 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/joplin-plugin/settings/PluginSettings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Settings for the {@link JoplinPlugin}. 3 | * 4 | * Currently, this is a duplicate of the CodeMirror extension {@link Config}. 5 | * This duplication exists to decouple the Joplin plugin from the CodeMirror extension. 6 | * 7 | * @see Config 8 | */ 9 | export interface PluginSettings { 10 | readonly completedLanguages: readonly string[] 11 | readonly completion: "enabled" | "disabled" 12 | readonly copyFormat: "code" | "fencedCode" 13 | readonly cornerStyle: "square" | "round" 14 | readonly excludedLanguages: readonly string[] 15 | readonly rendering: "enabled" | "disabled" 16 | readonly renderLayout: "minimal" | "standard" 17 | readonly selectAllCapturing: "enabled" | "disabled" 18 | } 19 | -------------------------------------------------------------------------------- /test/ext/stdlib/Repeat.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Repeat } from "@ext/stdlib/Repeat" 4 | 5 | describe("Repeat", () => { 6 | describe("times", () => { 7 | it("maps the given number of times", () => { 8 | expect(Repeat.times(3, (i) => i)).toStrictEqual([0, 1, 2]) 9 | }) 10 | 11 | it("maps 0 times to an empty array", () => { 12 | expect(Repeat.times(0, (i) => i)).toStrictEqual([]) 13 | }) 14 | 15 | it("throws Error if mapping non-integer", () => { 16 | expect(() => Repeat.times(0.5, (i) => i)).toThrowError() 17 | }) 18 | 19 | it("throws Error if mapping negative integer", () => { 20 | expect(() => Repeat.times(-1, (i) => i)).toThrowError() 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/joplin-plugin-ipc/model/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The Joplin plugin settings. 3 | * 4 | * Duplicate of {@link PluginSettings} to decouple the Joplin plugin and the CodeMirror extension. 5 | * The CodeMirror extension can remain standalone and couple solely with `joplin-plugin-ipc`. 6 | * 7 | * @see PluginSettings 8 | */ 9 | export interface Settings { 10 | readonly completedLanguages: readonly string[] 11 | readonly completion: "enabled" | "disabled" 12 | readonly copyFormat: "code" | "fencedCode" 13 | readonly cornerStyle: "square" | "round" 14 | readonly excludedLanguages: readonly string[] 15 | readonly rendering: "enabled" | "disabled" 16 | readonly renderLayout: "minimal" | "standard" 17 | readonly selectAllCapturing: "enabled" | "disabled" 18 | } 19 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/handler/RequestHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { PingRequest, PingResponse } from "@cm-extension-ipc/model/messages" 4 | 5 | import { Any } from "test-support/fixtures/cm5/Any" 6 | 7 | describe("RequestHandler", () => { 8 | describe("handle", () => { 9 | it("handles ping", async () => { 10 | await expect(Any.requestHandler().handle(PingRequest.of())).resolves.toStrictEqual( 11 | PingResponse.of(), 12 | ) 13 | }) 14 | }) 15 | 16 | describe("ping", () => { 17 | it("returns ping response", async () => { 18 | await expect(Any.requestHandler().ping(PingRequest.of())).resolves.toStrictEqual( 19 | PingResponse.of(), 20 | ) 21 | }) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /src/ext/stdlib/Repeat.ts: -------------------------------------------------------------------------------- 1 | import { Require } from "@ext/stdlib/Require" 2 | 3 | /** 4 | * Extensions for repetition. 5 | */ 6 | export namespace Repeat { 7 | /** 8 | * Applies {@link fn} {@link n} times, passing the current {@link i} and returning the results. 9 | * 10 | * Basically, the functional equivalent of a simple for-loop that accumulates results. 11 | * Same as `_.times` in `lodash` / `underscore`. 12 | * 13 | * The {@link n} must be a non-negative integer. 14 | */ 15 | export function times(n: number, fn: (i: number) => T): readonly T[] { 16 | Require.nonNegativeInteger(n) 17 | 18 | const results = new Array(n) 19 | for (let i = 0; i < n; i++) { 20 | results[i] = fn(i) 21 | } 22 | return results 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "module": "commonjs", 5 | "target": "es2019", 6 | "jsx": "react", 7 | "allowJs": true, 8 | "baseUrl": ".", 9 | "strict": true, 10 | "allowSyntheticDefaultImports": true, 11 | "noImplicitReturns": true, 12 | "paths": { 13 | "@ts-belt": ["src/ext/ts-belt/alias"], 14 | "@ext/*": ["src/ext/*"], 15 | "@cm-extension/*": ["src/cm-extension/*"], 16 | "@cm-extension-ipc/*": ["src/cm-extension-ipc/*"], 17 | "@content-script/*": ["src/content-script/*"], 18 | "@joplin-plugin/*": ["src/joplin-plugin/*"], 19 | "@joplin-plugin-ipc/*": ["src/joplin-plugin-ipc/*"], 20 | }, 21 | "typeRoots": ["./node_modules/@types", "./types"], 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm5/Events.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlyDoc, ReadonlyEditorSelectionChange } from "codemirror" 2 | 3 | /** 4 | * Extensions for {@link CodeMirror} Events. 5 | */ 6 | export namespace Events { 7 | /** 8 | * Returns true if the {@link selectionChange} within the {@link doc} represents a `Select All`. 9 | */ 10 | export function isSelectAll( 11 | doc: ReadonlyDoc, 12 | selectionChange: ReadonlyEditorSelectionChange, 13 | ): boolean { 14 | if (selectionChange.ranges?.length !== 1) return false 15 | 16 | const [{ anchor: from, head: to }] = selectionChange.ranges 17 | if (from.line > 0 || from.ch > 0) return false 18 | 19 | const lastLine = doc.lastLine() 20 | return to.line === lastLine && to.ch === doc.getLine(lastLine).length 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /api/JoplinViewsMenuItems.d.ts: -------------------------------------------------------------------------------- 1 | import { CreateMenuItemOptions, MenuItemLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating and managing menu items. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/register_command) 7 | */ 8 | export default class JoplinViewsMenuItems { 9 | private store; 10 | private plugin; 11 | constructor(plugin: Plugin, store: any); 12 | /** 13 | * Creates a new menu item and associate it with the given command. You can specify under which menu the item should appear using the `location` parameter. 14 | */ 15 | create(id: string, commandName: string, location?: MenuItemLocation, options?: CreateMenuItemOptions): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/rendering/ConfigEditorAttributes.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | import { AttrSource } from "@codemirror/view" 3 | 4 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 5 | 6 | /** 7 | * Editor attributes that add Config values to the editor dataset. 8 | * 9 | * Used for applying conditional css rules and increasing specificity. 10 | * 11 | * @see EditorState.editorAttributes 12 | */ 13 | export function ConfigEditorAttributes(state: EditorState): AttrSource { 14 | const { cornerStyle, rendering, renderLayout } = CodeEditorStates.getConfig(state) 15 | 16 | return { 17 | "data-cb-corner-style": cornerStyle, 18 | "data-cb-rendering": rendering, 19 | "data-cb-render-layout": renderLayout, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/ext/joplin/JoplinCommands.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { JoplinCommands } from "@ext/joplin/JoplinCommands" 4 | 5 | import { FakeJoplinCommands } from "test-support/fakes/joplin/FakeJoplinCommands" 6 | 7 | describe("JoplinCommands", () => { 8 | describe("callCodeMirrorExtension", () => { 9 | it("calls execCommand with extension name and given arg", async () => { 10 | const fakeJoplinCommands = FakeJoplinCommands.create() 11 | 12 | await JoplinCommands.callCodeMirrorExtension(fakeJoplinCommands, "extensionName", "arg") 13 | 14 | expect(fakeJoplinCommands.ext.execution).toStrictEqual({ 15 | commandName: "editor.execCommand", 16 | args: [{ name: "extensionName", args: ["arg"] }], 17 | }) 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/renderer/RenderParser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { RenderParser } from "@cm-extension/cm5/renderer/RenderParser" 4 | 5 | import { Any } from "test-support/fixtures/cm5/Any" 6 | import { DocData } from "test-support/fixtures/cm5/DocData" 7 | 8 | describe("RenderParser", () => { 9 | describe("parse", () => { 10 | it("parses code blocks with exclusions", () => { 11 | const { doc, codeBlocks } = DocData.Mixed.combo() 12 | expect( 13 | RenderParser.create({ 14 | config: Any.configWith({ excludedLanguages: ["java", "kotlin"] }), 15 | parser: Any.parser(), 16 | }).parse(doc), 17 | ).toStrictEqual(codeBlocks.filter((cb) => cb.lang !== "java" && cb.lang !== "kotlin")) 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /api/JoplinViewsMenus.d.ts: -------------------------------------------------------------------------------- 1 | import { MenuItem, MenuItemLocation } from './types'; 2 | import Plugin from '../Plugin'; 3 | /** 4 | * Allows creating menus. 5 | * 6 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/menu) 7 | */ 8 | export default class JoplinViewsMenus { 9 | private store; 10 | private plugin; 11 | constructor(plugin: Plugin, store: any); 12 | private registerCommandAccelerators; 13 | /** 14 | * Creates a new menu from the provided menu items and place it at the given location. As of now, it is only possible to place the 15 | * menu as a sub-menu of the application build-in menus. 16 | */ 17 | create(id: string, label: string, menuItems: MenuItem[], location?: MenuItemLocation): Promise; 18 | } 19 | -------------------------------------------------------------------------------- /test/ext/stdlib/Arrays.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Arrays } from "@ext/stdlib/Arrays" 4 | 5 | describe("Arrays", () => { 6 | describe("compact", () => { 7 | it("removes null and undefined values", () => { 8 | expect(Arrays.compact(["", 0, false, "foo", {}, NaN, undefined])).toStrictEqual([ 9 | "", 10 | 0, 11 | false, 12 | "foo", 13 | {}, 14 | NaN, 15 | ]) 16 | }) 17 | }) 18 | 19 | describe("onlyElement", () => { 20 | it("returns only element", () => { 21 | expect(Arrays.onlyElement(["first"])).toStrictEqual("first") 22 | }) 23 | 24 | it("throws Error if more or less than one element", () => { 25 | expect(() => Arrays.onlyElement([])).toThrowError() 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm5/Positions.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "codemirror" 2 | 3 | /** 4 | * Extensions for {@link Position}s. 5 | */ 6 | export namespace Positions { 7 | /** 8 | * Returns true if {@link first} is deeply equal to {@link second}. 9 | */ 10 | export function areEqual(first: Position, second: Position): boolean { 11 | return first.line === second.line && first.ch === second.ch 12 | } 13 | 14 | /** 15 | * Returns true if {@link before} comes strictly before {@link after}. 16 | */ 17 | export function areStrictlyOrdered({ 18 | before, 19 | after, 20 | }: { 21 | before: Position 22 | after: Position 23 | }): boolean { 24 | if (before.line < after.line) return true 25 | if (before.line > after.line) return false 26 | 27 | return before.ch < after.ch 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/cm-extension-ipc/CmExtensionClient.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, when } from "strong-mock" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { CmExtensionClient } from "@cm-extension-ipc/CmExtensionClient" 5 | import { CmExtensionRequestHandler } from "@cm-extension-ipc/model/handler" 6 | import { PingRequest, PingResponse } from "@cm-extension-ipc/model/messages" 7 | 8 | describe("CmExtensionClient", () => { 9 | describe("ping", () => { 10 | it("returns successfully", async () => { 11 | const mockRequestHandler = mock() 12 | when(() => mockRequestHandler(PingRequest.of())).thenResolve(PingResponse.of()) 13 | 14 | await expect( 15 | CmExtensionClient.create({ call: mockRequestHandler }).ping(), 16 | ).resolves.toBeUndefined() 17 | }) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /test-support/matchers/SetMatchers.ts: -------------------------------------------------------------------------------- 1 | import { MatchersObject, MatcherState } from "@vitest/expect" 2 | 3 | import { strictEquals } from "test-support/matchers/common" 4 | 5 | /** 6 | * Matchers for {@link Set}s. 7 | */ 8 | export const SetMatchers: MatchersObject = { 9 | /** 10 | * Matches an {@link actual} {@link Set} that contains exactly the given {@link item}. 11 | */ 12 | toContainExactlyItem(this: MatcherState, actual: Set, item: unknown) { 13 | const equals = strictEquals(this) 14 | const expected = new Set([item]) 15 | 16 | return { 17 | pass: equals(expected, actual), 18 | message: () => `Expected Set to ${this.isNot ? "not " : ""}contain exactly the given item`, 19 | expected: this.utils.stringify(expected), 20 | actual: this.utils.stringify(actual), 21 | } 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /media/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm5/Range.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "codemirror" 2 | 3 | import { Positions } from "@ext/codemirror/cm5/Positions" 4 | 5 | export interface RangeProps { 6 | readonly from: Position 7 | readonly to: Position 8 | } 9 | 10 | /** 11 | * Represents a range [{@link from},{@link to}] within a {@link Doc}. 12 | */ 13 | export class Range { 14 | readonly from: Position 15 | readonly to: Position 16 | 17 | static of(props: RangeProps): Range { 18 | return new Range(props) 19 | } 20 | 21 | private constructor({ from, to }: RangeProps) { 22 | this.from = from 23 | this.to = to 24 | } 25 | 26 | /** 27 | * Returns true if `this` deeply equals {@link other}. 28 | */ 29 | equals(other: Range): boolean { 30 | return Positions.areEqual(this.from, other.from) && Positions.areEqual(this.to, other.to) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/marker/Marker.test.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | import { mock, when } from "strong-mock" 3 | import { describe, it } from "vitest" 4 | 5 | import { Marker } from "@cm-extension/cm5/marker/Marker" 6 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 7 | 8 | describe("Marker", () => { 9 | describe("combine", () => { 10 | it("combines markers", () => { 11 | const mockDoc = mock() 12 | const mockCodeBlocks = mock() 13 | const mockMarkerOne = mock() 14 | const mockMarkerTwo = mock() 15 | 16 | when(() => mockMarkerOne.mark(mockDoc, mockCodeBlocks)).thenReturn() 17 | when(() => mockMarkerTwo.mark(mockDoc, mockCodeBlocks)).thenReturn() 18 | 19 | Marker.combine(mockMarkerOne, mockMarkerTwo).mark(mockDoc, mockCodeBlocks) 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /test/ext/codemirror/cm6/state/Facets.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { Facets } from "@ext/codemirror/cm6/state/Facets" 5 | 6 | describe("Facets", () => { 7 | describe("defineSingular", () => { 8 | it("returns default value when no value present", () => { 9 | const facet = Facets.defineSingular({ defaultValue: () => "defaultValue" }) 10 | 11 | expect(EditorState.create().facet(facet)).toBe("defaultValue") 12 | }) 13 | 14 | it("returns first value when multiple values present", () => { 15 | const facet = Facets.defineSingular({ defaultValue: () => "defaultValue" }) 16 | 17 | expect( 18 | EditorState.create({ extensions: [facet.of("first"), facet.of("second")] }).facet(facet), 19 | ).toBe("first") 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/joplin-plugin-ipc/model/base.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "@joplin-plugin-ipc/model/types" 2 | 3 | /** 4 | * Kinds of Joplin plugin requests / responses. 5 | */ 6 | export type PluginMessageKind = "ping" | "getSettings" 7 | 8 | /** 9 | * Joplin plugin requests / responses. 10 | */ 11 | interface PluginRequestMap { 12 | readonly ping: { readonly kind: K } 13 | readonly getSettings: { readonly kind: K } 14 | } 15 | interface PluginResponseMap { 16 | readonly ping: Promise<{ readonly kind: "ping" }> 17 | readonly getSettings: Promise<{ readonly kind: "getSettings"; readonly settings: Settings }> 18 | } 19 | 20 | /** 21 | * Base type for a Joplin plugin request / response. 22 | */ 23 | export type PluginRequest = PluginRequestMap[K] 24 | export type PluginResponse = PluginResponseMap[K] 25 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/marker/Marker.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 4 | 5 | /** 6 | * Augments {@link Doc}s with marks and styles (e.g. line classes). 7 | */ 8 | export interface Marker { 9 | /** 10 | * Marks the given {@link codeBlocks} in {@link doc}. 11 | * 12 | * e.g. adding line classes to lines within {@link codeBlocks}. 13 | */ 14 | mark(doc: Doc, codeBlocks: readonly CodeBlock[]): void 15 | } 16 | export namespace Marker { 17 | /** 18 | * Adapts multiple {@link markers} into a single {@link Marker} (applied first to last). 19 | */ 20 | export function combine(...markers: readonly Marker[]): Marker { 21 | return { 22 | mark(doc: Doc, codeBlocks: readonly CodeBlock[]): void { 23 | markers.forEach((it) => it.mark(doc, codeBlocks)) 24 | }, 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ext/stdlib/Arrays.ts: -------------------------------------------------------------------------------- 1 | import { def } from "@ext/stdlib/existence" 2 | import { Require } from "@ext/stdlib/Require" 3 | 4 | /** 5 | * Extensions for `Array`s. 6 | */ 7 | export namespace Arrays { 8 | /** 9 | * Returns a copy of {@link array} with `null` and `undefined` values filtered out. 10 | * 11 | * Similar to Ruby's `Array.compact` 12 | * @see https://ruby-doc.org/3.2.2/Array.html#method-i-compact 13 | */ 14 | export function compact(array: readonly T[]): readonly NonNullable[] { 15 | return array.filter((it) => { 16 | return def(it) 17 | }) 18 | } 19 | 20 | /** 21 | * Returns the only element in an {@link array}. 22 | * 23 | * The {@link array} must contain exactly 1 element. 24 | */ 25 | export function onlyElement(array: readonly T[]): T { 26 | Require.hasSingleElement(array) 27 | 28 | return array[0] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 1, 3 | "id": "com.ckant.joplin-plugin-better-code-blocks", 4 | "app_min_version": "2.11", 5 | "version": "2.0.1", 6 | "name": "Better Code Blocks", 7 | "description": "Enhances code blocks with inline rendering, autocompletion, and more!", 8 | "author": "Chris Kant", 9 | "homepage_url": "https://github.com/ckant/joplin-plugin-better-code-blocks", 10 | "repository_url": "https://github.com/ckant/joplin-plugin-better-code-blocks", 11 | "keywords": ["autocomplete", "code", "coding", "developer", "programming", "render", "rendering"], 12 | "categories": ["developer tools", "editor", "productivity"], 13 | "screenshots": [], 14 | "icons": { 15 | "16": "media/logo16.png", 16 | "32": "media/logo32.png", 17 | "48": "media/logo48.png", 18 | "128": "media/logo128.png" 19 | }, 20 | "promo_tile": { 21 | "src": "media/promo.png" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/cm-extension-ipc/CmExtensionClient.ts: -------------------------------------------------------------------------------- 1 | import { CmExtensionRequestHandler } from "@cm-extension-ipc/model/handler" 2 | import { PingRequest } from "@cm-extension-ipc/model/messages" 3 | 4 | export interface CmExtensionClientProps { 5 | readonly call: CmExtensionRequestHandler 6 | } 7 | 8 | /** 9 | * Calls the CodeMirror extension over inter-process communication using the given {@link call}. 10 | */ 11 | export class CmExtensionClient { 12 | private readonly call: CmExtensionRequestHandler 13 | 14 | static create(props: CmExtensionClientProps): CmExtensionClient { 15 | return new CmExtensionClient(props) 16 | } 17 | 18 | private constructor(props: CmExtensionClientProps) { 19 | this.call = props.call 20 | } 21 | 22 | /** 23 | * Pings the CodeMirror extension to check that it's reachable. 24 | */ 25 | async ping(): Promise { 26 | await this.call(PingRequest.of()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/RangeFinder.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { InlineDoc } from "test-support/ext/codemirror/cm5/InlineDoc" 4 | import { Any } from "test-support/fixtures/cm5/Any" 5 | 6 | describe("RangeFinder", () => { 7 | describe("findActiveCodeRange", () => { 8 | it("finds active code range", () => { 9 | const doc = InlineDoc` 10 | ~~~typescrip^t 11 | true 12 | ~~~ 13 | ` 14 | 15 | expect(Any.rangeFinder().findActiveCodeRange(doc)).toEqual({ 16 | from: { line: 1, ch: 0 }, 17 | to: { line: 1, ch: 4 }, 18 | }) 19 | }) 20 | 21 | it("finds nothing when no active code", () => { 22 | const doc = InlineDoc` 23 | # Heading ^ 24 | ~~~typescript 25 | true 26 | ~~~ 27 | ` 28 | expect(Any.rangeFinder().findActiveCodeRange(doc)).toBeUndefined() 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /api/JoplinClipboard.d.ts: -------------------------------------------------------------------------------- 1 | export default class JoplinClipboard { 2 | private electronClipboard_; 3 | private electronNativeImage_; 4 | constructor(electronClipboard: any, electronNativeImage: any); 5 | readText(): Promise; 6 | writeText(text: string): Promise; 7 | readHtml(): Promise; 8 | writeHtml(html: string): Promise; 9 | /** 10 | * Returns the image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 11 | */ 12 | readImage(): Promise; 13 | /** 14 | * Takes an image in [data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) format. 15 | */ 16 | writeImage(dataUrl: string): Promise; 17 | /** 18 | * Returns the list available formats (mime types). 19 | * 20 | * For example [ 'text/plain', 'text/html' ] 21 | */ 22 | availableFormats(): Promise; 23 | } 24 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/state/CodeBlocksStateField.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, StateFieldSpec, Transaction } from "@codemirror/state" 2 | 3 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 4 | import { CodeBlocksParser } from "@cm-extension/cm6/parsing/CodeBlocksParser" 5 | 6 | /** 7 | * State field that stores {@link CodeBlock}s. 8 | */ 9 | export const CodeBlocksStateField: StateFieldSpec = { 10 | /** 11 | * Parses and returns {@link CodeBlock}s in the given {@link state}. 12 | */ 13 | create(state: EditorState): readonly CodeBlock[] { 14 | return CodeBlocksParser.parse(state) 15 | }, 16 | /** 17 | * Parses and returns {@link CodeBlock}s in the given {@link state} when {@link docChanged}. 18 | */ 19 | update(oldValue: readonly CodeBlock[], { docChanged, state }: Transaction): readonly CodeBlock[] { 20 | if (!docChanged) return oldValue 21 | 22 | return CodeBlocksParser.parse(state) 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /test-support/matchers/MapMatchers.ts: -------------------------------------------------------------------------------- 1 | import { MatchersObject, MatcherState } from "@vitest/expect" 2 | 3 | import { strictEquals } from "test-support/matchers/common" 4 | 5 | /** 6 | * Matchers for {@link Map}s. 7 | */ 8 | export const MapMatchers: MatchersObject = { 9 | /** 10 | * Matches an {@link actual} {@link Map} that contains exactly the given {@link key}-{@link value} pair. 11 | */ 12 | toContainExactlyEntry( 13 | this: MatcherState, 14 | actual: Map, 15 | key: unknown, 16 | value: unknown, 17 | ) { 18 | const equals = strictEquals(this) 19 | const expected = new Map([[key, value]]) 20 | 21 | return { 22 | pass: equals(expected, actual), 23 | message: () => 24 | `Expected Map to ${this.isNot ? "not " : ""}contain exactly the given (key, value) pair`, 25 | expected: this.utils.stringify(expected), 26 | actual: this.utils.stringify(actual), 27 | } 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/modification/cursor/CodeFenceAtomicRanges.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { CodeFenceAtomicRanges } from "@cm-extension/cm6/modification/cursor/CodeFenceAtomicRanges" 4 | 5 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 6 | import { RangeSets } from "test-support/ext/codemirror/cm6/RangeSets" 7 | import { FakeEditorView } from "test-support/fakes/codemirror/cm6/view/FakeEditorView" 8 | 9 | describe("CodeFenceAtomicRanges", () => { 10 | it("builds a range sets of code blocks", () => { 11 | const view = FakeEditorView.create({ 12 | state: InlineState` 13 | ~~~lang 14 | code 15 | ~~~ 16 | `, 17 | }) 18 | 19 | expect( 20 | RangeSets.getRanges(CodeFenceAtomicRanges(view)).map(({ from, to }) => ({ from, to })), 21 | ).toStrictEqual([ 22 | { from: 0, to: 7 }, 23 | { from: 13, to: 16 }, 24 | ]) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/handler/RequestHandler.ts: -------------------------------------------------------------------------------- 1 | import { CmExtensionRequestHandler } from "@cm-extension-ipc/model/handler" 2 | import { PingRequest, PingResponse } from "@cm-extension-ipc/model/messages" 3 | 4 | /** 5 | * Handles requests to the Code Mirror extension (through inter-process communication). 6 | */ 7 | export class RequestHandler { 8 | static create(): RequestHandler { 9 | return new RequestHandler() 10 | } 11 | 12 | private constructor() { 13 | // empty 14 | } 15 | 16 | /** 17 | * Handles a Code Mirror extension {@link request} and returns a response. 18 | */ 19 | handle: CmExtensionRequestHandler = (request) => { 20 | return (this[request.kind] as CmExtensionRequestHandler)(request) 21 | } 22 | 23 | /** 24 | * Handles a {@link _pingRequest} and returns a {@link PingResponse}. 25 | */ 26 | async ping(_pingRequest: PingRequest): Promise { 27 | return Promise.resolve(PingResponse.of()) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/state/ConfigFacet.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { Config } from "@cm-extension/cm6/model/Config" 5 | import { ConfigFacet, ConfigFacetSpec } from "@cm-extension/cm6/state/ConfigFacet" 6 | 7 | describe("ConfigFacetSpec", () => { 8 | describe("combine", () => { 9 | it("returns default config", () => { 10 | expect(ConfigFacetSpec.defaultValue()).toStrictEqual(Config.createDefault()) 11 | }) 12 | }) 13 | }) 14 | 15 | describe("ConfigFacet", () => { 16 | it("returns default config when no config present", () => { 17 | expect(EditorState.create().facet(ConfigFacet)).toStrictEqual(Config.createDefault()) 18 | }) 19 | 20 | it("returns config", () => { 21 | const config = Config.createDefault() 22 | expect(EditorState.create({ extensions: ConfigFacet.of(config) }).facet(ConfigFacet)).toBe( 23 | config, 24 | ) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/theme/style/CodeBlockClass.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CM extension CSS classes. 3 | */ 4 | export const CodeBlockClass = { 5 | // Opening code fence line 6 | startLine: "cb-start-line", 7 | 8 | // Code lines 9 | codeLine: "cb-code-line", 10 | 11 | // Closing code fence line 12 | endLine: "cb-end-line", 13 | 14 | // Widgets that replace opening and closing fence text 15 | startWidget: "cb-start-widget", 16 | endWidget: "cb-end-widget", 17 | 18 | // Tag added when the user clicks the copy button 19 | copied: "cb-copied", 20 | 21 | // Button that copies code 22 | copyBtn: "cb-copy-btn", 23 | 24 | // First and last code lines 25 | first: "cb-first", 26 | last: "cb-last", 27 | 28 | // Opening and closing fence text 29 | openingFence: "cb-opening-fence", 30 | closingFence: "cb-closing-fence", 31 | 32 | // Lang text 33 | lang: "cb-lang", 34 | } as const 35 | export type CodeBlockClass = (typeof CodeBlockClass)[keyof typeof CodeBlockClass] 36 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/assets/opt/render-layout/standard.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrap of start line with round top corners. 3 | */ 4 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="standard"][data-cb-corner-style="round"] .cb-start-background { 5 | border-top-left-radius: 3px; 6 | border-top-right-radius: 3px; 7 | } 8 | 9 | /** 10 | * Wrap of end line with round bottom corners. 11 | */ 12 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="standard"][data-cb-corner-style="round"] .cb-end-background { 13 | border-bottom-right-radius: 3px; 14 | border-bottom-left-radius: 3px; 15 | } 16 | 17 | /** 18 | * Button inside start widget that copies code within a code fence. 19 | */ 20 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="standard"] .cb-copy-btn { 21 | padding: 0 0.5ch; 22 | } 23 | 24 | /** 25 | * Text inside end widget that shows the code fence language. 26 | */ 27 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="standard"] .cb-lang { 28 | visibility: hidden; 29 | } 30 | -------------------------------------------------------------------------------- /api/JoplinInterop.d.ts: -------------------------------------------------------------------------------- 1 | import { ExportModule, ImportModule } from './types'; 2 | /** 3 | * Provides a way to create modules to import external data into Joplin or to export notes into any arbitrary format. 4 | * 5 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/json_export) 6 | * 7 | * To implement an import or export module, you would simply define an object with various event handlers that are called 8 | * by the application during the import/export process. 9 | * 10 | * See the documentation of the [[ExportModule]] and [[ImportModule]] for more information. 11 | * 12 | * You may also want to refer to the Joplin API documentation to see the list of properties for each item (note, notebook, etc.) - https://joplinapp.org/help/api/references/rest_api 13 | */ 14 | export default class JoplinInterop { 15 | registerExportModule(module: ExportModule): Promise; 16 | registerImportModule(module: ImportModule): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/rendering/ConfigEditorAttributes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Config } from "@cm-extension/cm6/model/Config" 4 | import { ConfigEditorAttributes } from "@cm-extension/cm6/rendering/ConfigEditorAttributes" 5 | 6 | import { Any } from "test-support/fixtures/cm6/Any" 7 | 8 | describe("ConfigEditorAttributes", () => { 9 | it("returns config editor attributes", () => { 10 | const config: Config = { 11 | completedLanguages: ["french"], 12 | completion: "disabled", 13 | copyFormat: "fencedCode", 14 | cornerStyle: "round", 15 | excludedLanguages: ["english"], 16 | renderLayout: "standard", 17 | rendering: "disabled", 18 | selectAllCapturing: "disabled", 19 | } 20 | 21 | expect(ConfigEditorAttributes(Any.stateWith({ config }))).toStrictEqual({ 22 | "data-cb-corner-style": "round", 23 | "data-cb-rendering": "disabled", 24 | "data-cb-render-layout": "standard", 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test-support/fakes/dom/FakeClipboard.ts: -------------------------------------------------------------------------------- 1 | export type FakeClipboard = Clipboard & Extensions 2 | export namespace FakeClipboard { 3 | export function create(): FakeClipboard { 4 | return new ExtendedClipboard() as unknown as FakeClipboard 5 | } 6 | } 7 | 8 | type PartialClipboard = Pick 9 | 10 | export interface Extensions { 11 | readonly ext: { 12 | /** 13 | * Text written to the clipboard (defaults to ""). 14 | */ 15 | readonly data: string 16 | 17 | /** 18 | * Clears the clipboard content (resets to ""). 19 | */ 20 | clear(): void 21 | } 22 | } 23 | 24 | // noinspection JSUnusedGlobalSymbols 25 | class ExtendedClipboard implements PartialClipboard, Extensions { 26 | // noinspection JSUnusedGlobalSymbols 27 | readonly ext = new (class { 28 | data = "" 29 | 30 | clear(): void { 31 | this.data = "" 32 | } 33 | })() 34 | 35 | async writeText(data: string): Promise { 36 | this.ext.data = data 37 | return Promise.resolve() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ext/stdlib/existence.ts: -------------------------------------------------------------------------------- 1 | import { Guards } from "@ts-belt" 2 | 3 | /** 4 | * Returns true if the given value exists (is neither `undefined` nor `null`). 5 | * 6 | * Short replacement for somewhat unclean `if (value != null)` checks which use coercion and `null` 7 | * and unsafe `if (value)` checks which use coercion and match `""`, `0`, and `NaN` as well. 8 | * 9 | * Coercion to boolean and mixing `null` and `undefined` often lead to hard-to-spot bugs. 10 | * 11 | * The opposite of {@link nil}. 12 | */ 13 | export const def = Guards.isNotNullable 14 | 15 | /** 16 | * Returns true if the given value doesn't exist (is `undefined` or `null`). 17 | * 18 | * Short replacement for somewhat unclean `if (value == null)` checks which use coercion and `null` 19 | * or unsafe `if (!value)` checks which use coercion and match `""`, `0`, and `NaN` as well. 20 | * 21 | * Coercion to boolean and mixing `null` and `undefined` often lead to hard-to-spot bugs. 22 | * 23 | * The opposite of {@link def}. 24 | */ 25 | export const nil = Guards.isNullable 26 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/model/Config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for the {@link CmExtension}. 3 | */ 4 | export interface Config { 5 | /** 6 | * Automatic completion of code blocks. 7 | */ 8 | readonly completion: "enabled" | "disabled" 9 | 10 | /** 11 | * Portion of the code block to copy when the user clicks the copy button. 12 | */ 13 | readonly copyFormat: "code" | "fencedCode" 14 | 15 | /** 16 | * Style of the borders of rendered code blocks. 17 | */ 18 | readonly cornerStyle: "square" | "round" 19 | 20 | /** 21 | * Languages of code blocks that skip rendering. 22 | */ 23 | readonly excludedLanguages: readonly string[] 24 | 25 | /** 26 | * Rendering of code blocks. 27 | */ 28 | readonly rendering: "enabled" | "disabled" 29 | 30 | /** 31 | * Layout of the rendered code blocks. 32 | */ 33 | readonly renderLayout: "minimal" | "standard" 34 | 35 | /** 36 | * Change `Select All` to instead select an entire code block (if the cursor is inside it). 37 | */ 38 | readonly selectAllCapturing: "enabled" | "disabled" 39 | } 40 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/theme/StandardMixin.ts: -------------------------------------------------------------------------------- 1 | import { ThemeSpec } from "@codemirror/view" 2 | 3 | const SquareMixin: ThemeSpec = { 4 | // Opening fence line with square top corners 5 | "&[data-cb-render-layout='standard'][data-cb-corner-style='square'] .cb-start-line": { 6 | "border-top-left-radius": 0, 7 | "border-top-right-radius": 0, 8 | }, 9 | 10 | // Closing fence line with square bottom corners 11 | "&[data-cb-render-layout='standard'][data-cb-corner-style='square'] .cb-end-line": { 12 | "border-bottom-right-radius": 0, 13 | "border-bottom-left-radius": 0, 14 | }, 15 | } 16 | 17 | /** 18 | * Mixin that adds styles specific to the standard layout. 19 | */ 20 | export const StandardMixin: ThemeSpec = { 21 | ...SquareMixin, 22 | 23 | // Button inside opening fence widget that copies the code 24 | "&[data-cb-render-layout='standard'] .cb-copy-btn": { 25 | padding: "0 0.25ch", 26 | }, 27 | 28 | // Text inside closing fence widget that shows the language 29 | "&[data-cb-render-layout='standard'] .cb-lang": { 30 | visibility: "hidden", 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /test-support/fixtures/cm6/Any.ts: -------------------------------------------------------------------------------- 1 | import { markdown } from "@codemirror/lang-markdown" 2 | import { EditorState, EditorStateConfig } from "@codemirror/state" 3 | 4 | import { Extensions } from "@cm-extension/cm6/Extensions" 5 | import { Config } from "@cm-extension/cm6/model/Config" 6 | 7 | type New = () => T 8 | 9 | /** 10 | * Test fixtures for use when any instance will suffice. 11 | * 12 | * i.e. when the assertions in the test aren't dependent on the particulars of the instance. 13 | */ 14 | export namespace Any { 15 | export const stateWith: ( 16 | stateConfig?: Omit & { config?: Config }, 17 | ) => EditorState = (stateConfig) => 18 | EditorState.create({ 19 | doc: stateConfig?.doc, 20 | selection: stateConfig?.selection, 21 | extensions: [ 22 | Extensions.codeBlocksStateField, 23 | Extensions.configFacetOf(stateConfig?.config ?? Config.createDefault()), 24 | markdown(), 25 | EditorState.allowMultipleSelections.of(true), 26 | ], 27 | }) 28 | 29 | export const state: New = () => stateWith() 30 | } 31 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/model/CodeEditorStates.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | 3 | import { Extensions } from "@cm-extension/cm6/Extensions" 4 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 5 | import { Config } from "@cm-extension/cm6/model/Config" 6 | import { ConfigFacet } from "@cm-extension/cm6/state/ConfigFacet" 7 | 8 | /** 9 | * Operations on {@link BetterCodeBlocks} within {@link EditorState}s . 10 | */ 11 | export namespace CodeEditorStates { 12 | /** 13 | * Returns the {@link Config} within the {@link state} as defined by the {@link ConfigFacet}. 14 | */ 15 | export function getConfig(state: EditorState): Config { 16 | return state.facet(ConfigFacet) 17 | } 18 | 19 | /** 20 | * Returns the {@link CodeBlock}s within the {@link state} 21 | * as defined by the {@link Extensions.codeBlocksStateField}. 22 | * 23 | * Throws an error if {@link Extensions.codeBlocksStateField} isn't registered. 24 | */ 25 | export function getCodeBlocks(state: EditorState): readonly CodeBlock[] { 26 | return state.field(Extensions.codeBlocksStateField) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chris Kant 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/modification/formatting/EmptyCodeBlockUpdater.ts: -------------------------------------------------------------------------------- 1 | import { ChangeSpec } from "@codemirror/state" 2 | import { UpdateListenerSpec, ViewUpdate } from "@codemirror/view" 3 | import { Arrays } from "@ts-belt" 4 | 5 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 6 | 7 | /** 8 | * Update listener that inserts a line break into each empty code block to create a code line. 9 | * 10 | * e.g. the following empty code block: 11 | * 12 | *
13 |  * ~~~typescript
14 |  * ~~~
15 |  * 
16 | * 17 | * is "opened", allowing the user to move the cursor inside: 18 | * 19 | *
20 |  * ~~~typescript
21 |  *
22 |  * ~~~
23 |  * 
24 | * 25 | * @see EditorView.updateListener 26 | */ 27 | export const EmptyCodeBlockUpdater: UpdateListenerSpec = ({ 28 | docChanged, 29 | state, 30 | view, 31 | }: ViewUpdate) => { 32 | if (!docChanged) return 33 | 34 | const changes: ChangeSpec[] = CodeEditorStates.getCodeBlocks(state) 35 | .filter((it) => !it.hasCode()) 36 | .map((it) => ({ from: it.openingFenceEnd, insert: state.lineBreak })) 37 | 38 | if (Arrays.isNotEmpty(changes)) view.dispatch({ changes }) 39 | } 40 | -------------------------------------------------------------------------------- /src/ext/joplin/JoplinSettings.ts: -------------------------------------------------------------------------------- 1 | import ActualJoplinSettings from "api/JoplinSettings" 2 | import { ExtendedSettingSection } from "api/types" 3 | import { Dicts } from "@ts-belt" 4 | 5 | /** 6 | * Extensions for `JoplinSettings`. 7 | */ 8 | export namespace JoplinSettings { 9 | /** 10 | * Registers a setting {@link section} named {@link name} in the {@link joplinSettings}. 11 | * 12 | * This is a slightly cleaner way to register a {@link SettingSection} 13 | * along with its associated {@link SettingItem}s. 14 | * The two combine into a single {@link ExtendedSettingSection} and register together. 15 | */ 16 | export async function register( 17 | joplinSettings: ActualJoplinSettings, 18 | name: string, 19 | section: ExtendedSettingSection, 20 | ): Promise { 21 | // Remove the merged settings 22 | await joplinSettings.registerSection(name, Dicts.deleteKey(section, "settings")) 23 | 24 | // Add back the missing `section` tag that associates each setting with its section 25 | await joplinSettings.registerSettings( 26 | Dicts.map(section.settings, (setting) => ({ ...setting, section: name })), 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /api/JoplinViewsNoteList.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'redux'; 2 | import Plugin from '../Plugin'; 3 | import { ListRenderer } from './noteListType'; 4 | /** 5 | * This API allows you to customise how each note in the note list is rendered. 6 | * The renderer you implement follows a unidirectional data flow. 7 | * 8 | * The app provides the required dependencies whenever a note is updated - you 9 | * process these dependencies, and return some props, which are then passed to 10 | * your template and rendered. See [[[ListRenderer]]] for a detailed description 11 | * of each property of the renderer. 12 | * 13 | * [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/note_list_renderer) 14 | * 15 | * The default list renderer is implemented using the same API, so it worth checking it too: 16 | * 17 | * [Default list renderer](https://github.com/laurent22/joplin/tree/dev/packages/lib/services/noteList/defaultListRenderer.ts) 18 | */ 19 | export default class JoplinViewsNoteList { 20 | private plugin_; 21 | private store_; 22 | constructor(plugin: Plugin, store: Store); 23 | registerRenderer(renderer: ListRenderer): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/style/CodeBlockClass.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CM extension CSS classes. 3 | */ 4 | export const CodeBlockClass = { 5 | // Code fence start line 6 | startBackground: "cb-start-background", 7 | startLine: "cb-start-line", 8 | startText: "cb-start-text", 9 | 10 | // Code fence code lines 11 | codeBackground: "cb-code-background", 12 | codeLine: "cb-code-line", 13 | codeText: "cb-code-text", 14 | 15 | // Code fence end line 16 | endBackground: "cb-end-background", 17 | endLine: "cb-end-line", 18 | endText: "cb-end-text", 19 | 20 | // Widgets that replace start and end line content 21 | startWidget: "cb-start-widget", 22 | endWidget: "cb-end-widget", 23 | 24 | // Tag for when the user clicks the copy button 25 | copied: "cb-copied", 26 | 27 | // Button that copies code fence 28 | copyBtn: "cb-copy-btn", 29 | 30 | // First and last code fence lines 31 | first: "cb-first", 32 | last: "cb-last", 33 | 34 | // Opening and closing fence content 35 | openingFence: "cb-opening-fence", 36 | closingFence: "cb-closing-fence", 37 | 38 | // Lang content 39 | lang: "cb-lang", 40 | } as const 41 | export type CodeBlockClass = (typeof CodeBlockClass)[keyof typeof CodeBlockClass] 42 | -------------------------------------------------------------------------------- /api/JoplinWindow.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | export interface Implementation { 3 | injectCustomStyles(elementId: string, cssFilePath: string): Promise; 4 | } 5 | export default class JoplinWindow { 6 | private plugin_; 7 | private store_; 8 | private implementation_; 9 | constructor(implementation: Implementation, plugin: Plugin, store: any); 10 | /** 11 | * Loads a chrome CSS file. It will apply to the window UI elements, except 12 | * for the note viewer. It is the same as the "Custom stylesheet for 13 | * Joplin-wide app styles" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 14 | * for an example. 15 | */ 16 | loadChromeCssFile(filePath: string): Promise; 17 | /** 18 | * Loads a note CSS file. It will apply to the note viewer, as well as any 19 | * exported or printed note. It is the same as the "Custom stylesheet for 20 | * rendered Markdown" setting. See the [Load CSS Demo](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins/load_css) 21 | * for an example. 22 | */ 23 | loadNoteCssFile(filePath: string): Promise; 24 | } 25 | -------------------------------------------------------------------------------- /test/ext/lezer/markdown/Nodes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Nodes } from "@ext/lezer/markdown/Nodes" 4 | 5 | describe("Nodes", () => { 6 | describe("isFencedCode", () => { 7 | it("returns true when fenced code", () => { 8 | expect(Nodes.isFencedCode({ name: "FencedCode", from: 0, to: 3 })).toBeTrue() 9 | }) 10 | 11 | it("returns false when not fenced code", () => { 12 | expect(Nodes.isFencedCode({ name: "NotFencedCode", from: 0, to: 3 })).toBeFalse() 13 | }) 14 | }) 15 | 16 | describe("isCodeMark", () => { 17 | it("returns true when code mark", () => { 18 | expect(Nodes.isCodeMark({ name: "CodeMark", from: 0, to: 3 })).toBeTrue() 19 | }) 20 | 21 | it("returns false when not code mark", () => { 22 | expect(Nodes.isCodeMark({ name: "NotCodeMark", from: 0, to: 3 })).toBeFalse() 23 | }) 24 | }) 25 | 26 | describe("isCodeInfo", () => { 27 | it("returns true when code info", () => { 28 | expect(Nodes.isCodeInfo({ name: "CodeInfo", from: 0, to: 3 })).toBeTrue() 29 | }) 30 | 31 | it("returns false when not code info", () => { 32 | expect(Nodes.isCodeInfo({ name: "NotCodeInfo", from: 0, to: 3 })).toBeFalse() 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /test-support/fakes/dom/FakeKeyboardEvent.ts: -------------------------------------------------------------------------------- 1 | export interface FakeKeyboardEventProps { 2 | /** 3 | * The keyboard code to set on the event (defaults to "Space"). 4 | */ 5 | readonly code?: string 6 | } 7 | 8 | export type FakeKeyboardEvent = KeyboardEvent & Extensions 9 | export namespace FakeKeyboardEvent { 10 | export function create(props?: FakeKeyboardEventProps): FakeKeyboardEvent { 11 | return new ExtendedKeyboardEvent(props) as unknown as FakeKeyboardEvent 12 | } 13 | } 14 | 15 | type PartialKeyboardEvent = Pick 16 | 17 | export interface Extensions { 18 | readonly ext: { 19 | /** 20 | * Set to true if `preventDefault` executes on the {@link KeyboardEvent}. 21 | */ 22 | readonly defaultPrevented: boolean 23 | } 24 | } 25 | 26 | // noinspection JSUnusedGlobalSymbols 27 | class ExtendedKeyboardEvent implements PartialKeyboardEvent, Extensions { 28 | readonly code: string 29 | 30 | // noinspection JSUnusedGlobalSymbols 31 | readonly ext = new (class { 32 | defaultPrevented = false 33 | })() 34 | 35 | constructor(props?: FakeKeyboardEventProps) { 36 | this.code = props?.code ?? "Space" 37 | } 38 | 39 | preventDefault(): void { 40 | this.ext.defaultPrevented = true 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test-support/fakes/joplin/FakeJoplinCommands.ts: -------------------------------------------------------------------------------- 1 | import JoplinCommands from "api/JoplinCommands" 2 | 3 | export type FakeJoplinCommands = JoplinCommands & Extensions 4 | export namespace FakeJoplinCommands { 5 | export function create(): FakeJoplinCommands { 6 | return new ExtendedJoplinCommands() as unknown as FakeJoplinCommands 7 | } 8 | } 9 | 10 | type PartialJoplinCommands = Pick 11 | 12 | export interface Extensions { 13 | readonly ext: { 14 | /** 15 | * Represents the call to {@link JoplinCommands#execute} (if performed). 16 | */ 17 | readonly execution: Execution | undefined 18 | } 19 | } 20 | 21 | /** 22 | * Represents the call to {@link JoplinCommands#execute}. 23 | */ 24 | export interface Execution { 25 | readonly commandName: string 26 | readonly args: readonly unknown[] 27 | } 28 | 29 | // noinspection JSUnusedGlobalSymbols 30 | class ExtendedJoplinCommands implements PartialJoplinCommands, Extensions { 31 | // noinspection JSUnusedGlobalSymbols 32 | readonly ext = new (class { 33 | execution: Execution | undefined 34 | })() 35 | 36 | async execute(commandName: string, ...args: readonly unknown[]): Promise { 37 | this.ext.execution = { commandName, args } 38 | return Promise.resolve() 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ext/stdlib/Iterables.ts: -------------------------------------------------------------------------------- 1 | import { Require } from "@ext/stdlib/Require" 2 | 3 | /** 4 | * Extensions of {@link Iterable}s. 5 | */ 6 | export namespace Iterables { 7 | /** 8 | * Returns an {@link Iterable} that yields the integers from 9 | * {@link start} up to (but not including) {@link endExclusive}. 10 | * 11 | * Requires that {@link start} and {@link endExclusive} must be integers and 12 | * {@link start} must be less than or equal to {@link endExclusive}. 13 | */ 14 | export function range({ 15 | start, 16 | endExclusive, 17 | }: { 18 | start: number 19 | endExclusive: number 20 | }): Iterable { 21 | Require.integer(start) 22 | Require.integer(endExclusive) 23 | Require.validRange({ from: start, to: endExclusive }) 24 | 25 | return { 26 | *[Symbol.iterator](): Generator { 27 | for (let value = start; value < endExclusive; value++) { 28 | yield value 29 | } 30 | }, 31 | } 32 | } 33 | 34 | /** 35 | * Returns an {@link Iterable} that yields nothing. 36 | * 37 | * Useful as a placeholder value. 38 | */ 39 | export function empty(): Iterable { 40 | return { 41 | *[Symbol.iterator](): Generator { 42 | // empty 43 | }, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test-support/fakes/joplin/FakeJoplinExtensions.ts: -------------------------------------------------------------------------------- 1 | import { CodeMirror6 } from "api/types" 2 | import { CompletionSource } from "@codemirror/autocomplete" 3 | import { Extension } from "@codemirror/state" 4 | 5 | export type FakeJoplinExtensions = CodeMirror6["joplinExtensions"] & Extensions 6 | export namespace FakeJoplinExtensions { 7 | export function create(): FakeJoplinExtensions { 8 | return new ExtendedJoplinExtensions() as unknown as FakeJoplinExtensions 9 | } 10 | } 11 | 12 | type PartialJoplinExtensions = Pick 13 | 14 | export interface Extensions { 15 | readonly ext: { 16 | /** 17 | * The source sent in {@link CodeMirror6#joplinExtensions.completionSource} (if called). 18 | */ 19 | readonly completionSource: CompletionSource | undefined 20 | } 21 | } 22 | 23 | // noinspection JSUnusedGlobalSymbols 24 | class ExtendedJoplinExtensions implements PartialJoplinExtensions, Extensions { 25 | // noinspection JSUnusedGlobalSymbols 26 | readonly ext = new (class { 27 | completionSource: CompletionSource | undefined 28 | })() 29 | 30 | completionSource(completionSource: CompletionSource): Extension { 31 | this.ext.completionSource = completionSource 32 | 33 | return undefined as unknown as Extension 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/formatter/Formatter.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 4 | import { Origin } from "@cm-extension/cm5/model/Origin" 5 | 6 | /** 7 | * Modifies the formatting of {@link Doc}s (e.g. spacing). 8 | */ 9 | export interface Formatter { 10 | /** 11 | * Formats the {@link codeBlocks} in {@link doc} and returns the formatted {@link CodeBlock}s. 12 | * The operation executes using the given {@link origin}. 13 | * 14 | * e.g. adding spacing to the {@link doc}, changing the position of the {@link codeBlocks}. 15 | */ 16 | format(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] 17 | } 18 | export namespace Formatter { 19 | /** 20 | * Adapts multiple {@link formatters} into a single {@link Formatter} (applied first to last). 21 | */ 22 | export function combine(...formatters: readonly Formatter[]): Formatter { 23 | return { 24 | format(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] { 25 | let formattedCodeBlocks = codeBlocks 26 | formatters.forEach((it) => { 27 | formattedCodeBlocks = it.format(doc, formattedCodeBlocks, origin) 28 | }) 29 | return formattedCodeBlocks 30 | }, 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/modification/cursor/CodeFenceAtomicRanges.ts: -------------------------------------------------------------------------------- 1 | import { RangeSetBuilder, RangeValue } from "@codemirror/state" 2 | import { AtomicRangesSpec } from "@codemirror/view" 3 | 4 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 5 | 6 | const NoRangeValue = new (class extends RangeValue {})() 7 | 8 | /** 9 | * Provides atomic ranges over the opening and closing fences of all code blocks. 10 | * 11 | * This causes the editor to treat fences as single units of text when it comes to 12 | * cursor movement, deletion, and other operations. 13 | * 14 | * Note that most examples show atomic ranges built directly from decorations. 15 | * This is intentionally separate here since the code block decorations represent 16 | * a subset of these atomic ranges that have additional filtering for rendering purposes. 17 | * 18 | * @see EditorView.atomicRanges 19 | */ 20 | export const CodeFenceAtomicRanges: AtomicRangesSpec = ({ state }) => { 21 | const builder = new RangeSetBuilder() 22 | for (const codeBlock of CodeEditorStates.getCodeBlocks(state)) { 23 | builder.add(codeBlock.openingFenceStart, codeBlock.openingFenceEnd, NoRangeValue) 24 | builder.add(codeBlock.closingFenceStart, codeBlock.closingFenceEnd, NoRangeValue) 25 | } 26 | return builder.finish() 27 | } 28 | -------------------------------------------------------------------------------- /src/ext/stdlib/Require.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extensions for function requirements / prerequisites. 3 | */ 4 | export namespace Require { 5 | /** 6 | * Asserts that {@link number} is an integer. 7 | */ 8 | export function integer(number: number): void { 9 | if (!Number.isInteger(number)) throw new Error(`${number} is not an integer`) 10 | } 11 | 12 | /** 13 | * Asserts that {@link number} is a non-negative integer. 14 | */ 15 | export function nonNegativeInteger(number: number): void { 16 | if (!Number.isInteger(number)) throw new Error(`${number} is not an integer`) 17 | if (number < 0) throw new Error(`${number} is negative`) 18 | } 19 | 20 | /** 21 | * Asserts that {@link from} is less than or equal to {@link to}. 22 | */ 23 | export function validRange({ from, to }: { from: number; to: number }): void { 24 | if (from > to) throw new Error(`[${from},${to}] is an invalid range`) 25 | } 26 | 27 | /** 28 | * Asserts that {@link array} has exactly one element. 29 | */ 30 | export function hasSingleElement(array: readonly T[]): void { 31 | if (array.length === 0) { 32 | throw new Error(`${array.toString()} is empty`) 33 | } 34 | 35 | if (array.length > 1) { 36 | throw new Error(`${array.toString()} has ${array.length} elements`) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/joplin-plugin-ipc/model/messages.ts: -------------------------------------------------------------------------------- 1 | import { PluginRequest, PluginResponse } from "@joplin-plugin-ipc/model/base" 2 | import { Settings } from "@joplin-plugin-ipc/model/types" 3 | 4 | /** 5 | * A request to get the (latest) Joplin plugin {@link Settings}. 6 | */ 7 | export type GetSettingsRequest = PluginRequest<"getSettings"> 8 | export namespace GetSettingsRequest { 9 | export function of(): GetSettingsRequest { 10 | return { kind: "getSettings" } 11 | } 12 | } 13 | 14 | /** 15 | * A response for a {@link GetSettingsRequest} with the latest {@link settings}. 16 | */ 17 | export type GetSettingsResponse = Awaited> 18 | export namespace GetSettingsResponse { 19 | export function of(settings: Settings): GetSettingsResponse { 20 | return { kind: "getSettings", settings } 21 | } 22 | } 23 | 24 | /** 25 | * A request to ping the Joplin plugin to check that it's reachable. 26 | */ 27 | export type PingRequest = PluginRequest<"ping"> 28 | export namespace PingRequest { 29 | export function of(): PingRequest { 30 | return { kind: "ping" } 31 | } 32 | } 33 | 34 | /** 35 | * A response for a {@link PingRequest}. 36 | */ 37 | export type PingResponse = Awaited> 38 | export namespace PingResponse { 39 | export function of(): PingResponse { 40 | return { kind: "ping" } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/RangeFinder.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlyDoc } from "codemirror" 2 | 3 | import { Range } from "@ext/codemirror/cm5/Range" 4 | import { nil } from "@ext/stdlib/existence" 5 | 6 | import { CodeDocs } from "@cm-extension/cm5/model/CodeDocs" 7 | import { Parser } from "@cm-extension/cm5/Parser" 8 | 9 | export interface RangeFinderProps { 10 | readonly parser: Parser 11 | } 12 | 13 | /** 14 | * Finds ranges within {@link Doc}s using the given {@link parser}. 15 | */ 16 | export class RangeFinder { 17 | private readonly parser: Parser 18 | 19 | static create(props: RangeFinderProps): RangeFinder { 20 | return new RangeFinder(props) 21 | } 22 | 23 | private constructor(props: RangeFinderProps) { 24 | this.parser = props.parser 25 | } 26 | 27 | /** 28 | * Returns the range that encloses the "active" code in {@link doc} (if one exists). 29 | * 30 | * Code is "active" if the cursor is inside its code fence. 31 | * 32 | * @see CodeDocs#getActiveCodeBlock 33 | * @see CodeDocs#getCodeRange 34 | */ 35 | findActiveCodeRange(doc: ReadonlyDoc): Range | undefined { 36 | const codeBlocks = this.parser.parse(doc) 37 | 38 | const activeCodeBlock = CodeDocs.getActiveCodeBlock(doc, codeBlocks) 39 | if (nil(activeCodeBlock)) return undefined 40 | 41 | return CodeDocs.getCodeRange(doc, activeCodeBlock) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/model/CodeEditorStates.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 5 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 6 | import { Config } from "@cm-extension/cm6/model/Config" 7 | import { ConfigFacet } from "@cm-extension/cm6/state/ConfigFacet" 8 | 9 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 10 | 11 | describe("CodeEditorStates", () => { 12 | describe("getConfig", () => { 13 | it("returns config facet", () => { 14 | const config = Config.createDefault() 15 | 16 | expect( 17 | CodeEditorStates.getConfig(EditorState.create({ extensions: ConfigFacet.of(config) })), 18 | ).toBe(config) 19 | }) 20 | }) 21 | 22 | describe("getCodeBlocks", () => { 23 | it("returns code blocks state field", () => { 24 | const state = InlineState` 25 | ~~~ 26 | ~~~ 27 | ` 28 | 29 | expect(CodeEditorStates.getCodeBlocks(state)).toStrictEqual([ 30 | CodeBlock.of({ 31 | openingFence: { from: 0, to: 3, number: 1, text: "~~~" }, 32 | closingFence: { from: 4, to: 7, number: 2, text: "~~~" }, 33 | lang: undefined, 34 | }), 35 | ]) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/rendering/ViewPluginValue.ts: -------------------------------------------------------------------------------- 1 | import { DecoratedPluginValue, DecorationSet, EditorView, ViewUpdate } from "@codemirror/view" 2 | 3 | import { DecorationSets } from "@cm-extension/cm6/rendering/decoration/DecorationSets" 4 | 5 | export interface ViewPluginValueProps { 6 | readonly view: EditorView 7 | } 8 | 9 | /** 10 | * `ViewPlugin` value that generates and stores {@link CodeBlock} decorations. 11 | */ 12 | export class ViewPluginValue implements DecoratedPluginValue { 13 | decorations: DecorationSet 14 | 15 | /** 16 | * Creates a {@link ViewPluginValue} and generates / stores {@link CodeBlock} decorations 17 | * from the given {@link ViewPluginValueProps.view}. 18 | */ 19 | static create(props: ViewPluginValueProps): ViewPluginValue { 20 | return new ViewPluginValue(props) 21 | } 22 | 23 | private constructor({ view }: ViewPluginValueProps) { 24 | this.decorations = DecorationSets.create(view) 25 | } 26 | 27 | /** 28 | * Regenerates / updates stored {@link CodeBlock} decorations from the given {@link view}. 29 | * 30 | * Skips the update unless the {@link docChanged} or the {@link viewportChanged}. 31 | */ 32 | update({ docChanged, viewportChanged, view }: ViewUpdate): void { 33 | if (!docChanged && !viewportChanged) return 34 | 35 | this.decorations = DecorationSets.create(view) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/modification/cursor/ClosingFenceCursorFilter.ts: -------------------------------------------------------------------------------- 1 | import { CursorMovementFilter } from "@ext/codemirror/cm6/state/CursorMovementFilter" 2 | import { nil } from "@ext/stdlib/existence" 3 | 4 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 5 | 6 | /** 7 | * Transaction filter that "pushes" the cursor out of the position 8 | * at the start of the closing fence. 9 | * 10 | * The cursor skips over this position during horizontal and vertical movement. 11 | * 12 | * This makes it easier to select and move the cursor around the code block while maintaining a 13 | * "border" at the end of the closing fence for adding /removing line breaks. 14 | * 15 | * @see OpeningFenceCursorFilter 16 | * @see CursorMovementFilter 17 | * @see EditorState.transactionFilter 18 | */ 19 | export const ClosingFenceCursorFilter = CursorMovementFilter((transaction) => { 20 | const { startState, pos, previousPos, isVertical } = transaction 21 | const codeBlockWithUnwantedCursor = CodeEditorStates.getCodeBlocks(startState).find( 22 | (it) => pos === it.closingFenceStart, 23 | ) 24 | 25 | if (nil(codeBlockWithUnwantedCursor)) return undefined 26 | 27 | const { codeEnd, openingFenceStart, closingFenceEnd } = codeBlockWithUnwantedCursor 28 | return isVertical || pos >= previousPos ? closingFenceEnd : (codeEnd ?? openingFenceStart) 29 | }) 30 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/modification/cursor/OpeningFenceCursorFilter.ts: -------------------------------------------------------------------------------- 1 | import { CursorMovementFilter } from "@ext/codemirror/cm6/state/CursorMovementFilter" 2 | import { nil } from "@ext/stdlib/existence" 3 | 4 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 5 | 6 | /** 7 | * Transaction filter that "pushes" the cursor out of the position 8 | * at the end of the opening fence. 9 | * 10 | * The cursor skips over this position during horizontal and vertical movement. 11 | * 12 | * This makes it easier to select and move the cursor around the code block while maintaining a 13 | * "border" at the start of the opening fence for adding /removing line breaks. 14 | * 15 | * @see ClosingFenceCursorFilter 16 | * @see CursorMovementFilter 17 | * @see EditorState.transactionFilter 18 | */ 19 | export const OpeningFenceCursorFilter = CursorMovementFilter((transaction) => { 20 | const { startState, pos, previousPos, isVertical } = transaction 21 | const codeBlockWithUnwantedCursor = CodeEditorStates.getCodeBlocks(startState).find( 22 | (it) => pos === it.openingFenceEnd, 23 | ) 24 | 25 | if (nil(codeBlockWithUnwantedCursor)) return undefined 26 | 27 | const { openingFenceStart, codeStart, closingFenceEnd } = codeBlockWithUnwantedCursor 28 | return isVertical || pos <= previousPos ? openingFenceStart : (codeStart ?? closingFenceEnd) 29 | }) 30 | -------------------------------------------------------------------------------- /test/ext/codemirror/cm5/Range.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Range } from "@ext/codemirror/cm5/Range" 4 | 5 | describe("Range", () => { 6 | describe("equals", () => { 7 | it("returns true when equal", () => { 8 | const firstRange = Range.of({ from: { line: 1, ch: 2 }, to: { line: 3, ch: 4 } }) 9 | const secondRange = Range.of({ from: { line: 1, ch: 2 }, to: { line: 3, ch: 4 } }) 10 | 11 | expect(firstRange.equals(secondRange)).toBeTrue() 12 | expect(secondRange.equals(firstRange)).toBeTrue() 13 | }) 14 | 15 | it("returns false when not equal, different froms", () => { 16 | const firstRange = Range.of({ from: { line: 1, ch: 2 }, to: { line: 3, ch: 4 } }) 17 | const secondRange = Range.of({ from: { line: 0, ch: 2 }, to: { line: 3, ch: 4 } }) 18 | 19 | expect(firstRange.equals(secondRange)).toBeFalse() 20 | expect(secondRange.equals(firstRange)).toBeFalse() 21 | }) 22 | 23 | it("returns false when not equal, different tos", () => { 24 | const firstRange = Range.of({ from: { line: 1, ch: 2 }, to: { line: 3, ch: 4 } }) 25 | const secondRange = Range.of({ from: { line: 1, ch: 2 }, to: { line: 3, ch: 5 } }) 26 | 27 | expect(firstRange.equals(secondRange)).toBeFalse() 28 | expect(secondRange.equals(firstRange)).toBeFalse() 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/ext/lezer/markdown/Nodes.ts: -------------------------------------------------------------------------------- 1 | import { NodeDescription } from "@lezer/common" 2 | 3 | /** 4 | * Functions on lezer markdown syntax nodes. 5 | */ 6 | export namespace Nodes { 7 | /** 8 | * Returns true if the {@link node} represents `FencedCode`. 9 | * 10 | * This is the parent node of a fenced code block. 11 | * 12 | * @see https://github.com/lezer-parser/markdown/blob/main/src/markdown.ts#L432 13 | */ 14 | export function isFencedCode(node: NodeDescription): boolean { 15 | return node.name === "FencedCode" 16 | } 17 | 18 | /** 19 | * Returns true if the {@link node} represents `CodeMark`. 20 | * 21 | * This is a child of `FencedCode` that represents the opening / closing fence marks (e.g. `~~~`). 22 | * 23 | * @see https://github.com/lezer-parser/markdown/blob/main/src/markdown.ts#L411-L421 24 | */ 25 | export function isCodeMark(node: NodeDescription): boolean { 26 | return node.name === "CodeMark" 27 | } 28 | 29 | /** 30 | * Returns true if the {@link node} represents `CodeInfo`. 31 | * 32 | * This is a child of `FencedCode` that represents the opening fence language (e.g. `typescript`). 33 | * 34 | * @see https://github.com/lezer-parser/markdown/blob/main/src/markdown.ts#L413 35 | */ 36 | export function isCodeInfo(node: NodeDescription): boolean { 37 | return node.name === "CodeInfo" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/renderer/RenderParser.ts: -------------------------------------------------------------------------------- 1 | import { ReadonlyDoc } from "codemirror" 2 | 3 | import { nil } from "@ext/stdlib/existence" 4 | 5 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 6 | import { Config } from "@cm-extension/cm5/model/Config" 7 | import { Parser } from "@cm-extension/cm5/Parser" 8 | 9 | export interface RenderParserProps { 10 | readonly config: Config 11 | readonly parser: Parser 12 | } 13 | 14 | /** 15 | * Parses code fences with the given {@link parser} according to the given {@link config}. 16 | */ 17 | export class RenderParser { 18 | private readonly config: Config 19 | private readonly parser: Parser 20 | 21 | static create(props: RenderParserProps): RenderParser { 22 | return new RenderParser(props) 23 | } 24 | 25 | private constructor(props: RenderParserProps) { 26 | this.config = props.config 27 | this.parser = props.parser 28 | } 29 | 30 | /** 31 | * Returns {@link CodeBlock}s in {@link doc}, excluding those with {@link config#excludedLanguages}. 32 | */ 33 | parse(doc: ReadonlyDoc): readonly CodeBlock[] { 34 | const allCodeBlocks = this.parser.parse(doc) 35 | return allCodeBlocks.filter((it) => !this.isExcluded(it)) 36 | } 37 | 38 | private isExcluded({ lang }: CodeBlock): boolean { 39 | if (nil(lang)) return false 40 | return this.config.excludedLanguages.includes(lang) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/state/EditorSelections.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection } from "@codemirror/state" 2 | 3 | /** 4 | * Operations on {@link EditorSelection}s. 5 | */ 6 | export namespace EditorSelections { 7 | /** 8 | * Returns true if the {@link selection} has a single range. 9 | */ 10 | export function isSingle(selection: EditorSelection): boolean { 11 | return selection.ranges.length === 1 12 | } 13 | 14 | /** 15 | * Returns true if the {@link selection} has more than one range. 16 | */ 17 | export function isMultiple(selection: EditorSelection): boolean { 18 | return selection.ranges.length > 1 19 | } 20 | 21 | /** 22 | * Returns true if the {@link selection} has a single, empty range (it represents a cursor). 23 | */ 24 | export function isSingleCursor(selection: EditorSelection): boolean { 25 | return isSingle(selection) && selection.main.empty 26 | } 27 | 28 | /** 29 | * Returns an {@link EditorSelection} with a single cursor. 30 | */ 31 | export function singleCursor({ 32 | pos, 33 | assoc, 34 | bidiLevel, 35 | goalColumn, 36 | }: { 37 | pos: number 38 | assoc?: number 39 | bidiLevel?: number | null 40 | goalColumn?: number 41 | }): EditorSelection { 42 | return EditorSelection.create([ 43 | EditorSelection.cursor(pos, assoc, bidiLevel as number | undefined, goalColumn), 44 | ]) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/formatter/Formatter.test.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | import { mock, when } from "strong-mock" 3 | import { describe, expect, it } from "vitest" 4 | 5 | import { Formatter } from "@cm-extension/cm5/formatter/Formatter" 6 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 7 | import { Origin } from "@cm-extension/cm5/model/Origin" 8 | 9 | describe("Formatter", () => { 10 | describe("combine", () => { 11 | it("combines formatters", () => { 12 | const mockDoc = mock() 13 | const mockFormatterOne = mock() 14 | const mockFormatterTwo = mock() 15 | const mockCodeBlocks = mock() 16 | const mockCodeBlocksFromOne = mock() 17 | const mockCodeBlocksFromTwo = mock() 18 | 19 | when(() => mockFormatterOne.format(mockDoc, mockCodeBlocks, Origin.RenderHandler)).thenReturn( 20 | mockCodeBlocksFromOne, 21 | ) 22 | 23 | when(() => 24 | mockFormatterTwo.format(mockDoc, mockCodeBlocksFromOne, Origin.RenderHandler), 25 | ).thenReturn(mockCodeBlocksFromTwo) 26 | 27 | const newCodeBlocks = Formatter.combine(mockFormatterOne, mockFormatterTwo).format( 28 | mockDoc, 29 | mockCodeBlocks, 30 | Origin.RenderHandler, 31 | ) 32 | 33 | expect(newCodeBlocks).toBe(mockCodeBlocksFromTwo) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/completer/CompletionGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { InlineDoc } from "test-support/ext/codemirror/cm5/InlineDoc" 4 | import { Any } from "test-support/fixtures/cm5/Any" 5 | 6 | describe("CompletionGenerator", () => { 7 | describe("generate", () => { 8 | it.each(noCompletions)("generates no completion when $reason", ({ doc }) => { 9 | expect(Any.completionGenerator().generate(doc)).toBeUndefined() 10 | }) 11 | 12 | it("generates completion", () => { 13 | const doc = InlineDoc`~~~^` 14 | 15 | expect(Any.completionGenerator().generate(doc)).toBe("~~~") 16 | }) 17 | }) 18 | }) 19 | 20 | const noCompletions = [ 21 | { 22 | reason: "cursor not at end of line", 23 | doc: InlineDoc` 24 | ~~^~ 25 | ~~~ 26 | `, 27 | }, 28 | { 29 | reason: "line isn't start sequence", 30 | doc: InlineDoc`~~^`, 31 | }, 32 | { 33 | reason: "codeblocks are already parseable", 34 | doc: InlineDoc` 35 | ~~~^ 36 | ~~~ 37 | `, 38 | }, 39 | { 40 | reason: "completion wouldn't end current code block", 41 | doc: InlineDoc` 42 | ~~~ 43 | ~~~^ 44 | ~~~ 45 | `, 46 | }, 47 | { 48 | reason: "completion wouldn't make all codeblocks parseable", 49 | doc: InlineDoc` 50 | ~~~^ 51 | ~~~ 52 | ~~~typescript 53 | `, 54 | }, 55 | ] 56 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/model/CodeBlocks.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { LineSegment } from "@ext/codemirror/cm5/LineSegment" 4 | 5 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 6 | import { CodeBlocks } from "@cm-extension/cm5/model/CodeBlocks" 7 | 8 | describe("CodeBlocks", () => { 9 | describe("areEqual", () => { 10 | const first = codeBlockOf({ startLine: 0, endLine: 1 }) 11 | const second = codeBlockOf({ startLine: 2, endLine: 3 }) 12 | 13 | it("equal (empty)", () => { 14 | expect(CodeBlocks.areEqual([], [])).toBe(true) 15 | }) 16 | 17 | it("equal (same elements)", () => { 18 | expect(CodeBlocks.areEqual([first, second], [first, second])).toBe(true) 19 | }) 20 | 21 | it("not equal (different elements)", () => { 22 | expect(CodeBlocks.areEqual([first], [second])).toBe(false) 23 | }) 24 | 25 | it("not equal (different order)", () => { 26 | expect(CodeBlocks.areEqual([first, second], [second, first])).toBe(false) 27 | }) 28 | }) 29 | }) 30 | 31 | function codeBlockOf({ startLine, endLine }: { startLine: number; endLine: number }): CodeBlock { 32 | return CodeBlock.of({ 33 | start: LineSegment.of({ line: startLine, from: 0, to: 0 }), 34 | end: LineSegment.of({ line: endLine, from: 0, to: 0 }), 35 | lang: "typescript", 36 | openingFence: "```typescript", 37 | closingFence: "```", 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/handler/CompleteHandler.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from "codemirror" 2 | 3 | import { Completer } from "@cm-extension/cm5/completer/Completer" 4 | import { Origin } from "@cm-extension/cm5/model/Origin" 5 | 6 | export interface CompleteHandlerProps { 7 | readonly completer: Completer 8 | } 9 | 10 | /** 11 | * Handles autocompletion of code fences using the given {@link completer}. 12 | */ 13 | export class CompleteHandler { 14 | private readonly completer: Completer 15 | 16 | static create(props: CompleteHandlerProps): CompleteHandler { 17 | return new CompleteHandler(props) 18 | } 19 | 20 | private constructor(props: CompleteHandlerProps) { 21 | this.completer = props.completer 22 | } 23 | 24 | /** 25 | * Completes an incomplete code fence if one exists at the {@link cm} {@link Doc} cursor position 26 | * and {@link keyboardEvent} is due to an `Enter` press. 27 | * 28 | * The operation executes using {@link Origin#CompleteHandler}. 29 | * 30 | * If the completion succeeds, calls {@link KeyboardEvent#preventDefault} to cut off handling 31 | * downstream. 32 | */ 33 | completeOnEnter(cm: Editor, keyboardEvent: KeyboardEvent): void { 34 | if (keyboardEvent.code !== "Enter") return 35 | 36 | const completed = cm.operation(() => 37 | this.completer.complete(cm.getDoc(), Origin.CompleteHandler), 38 | ) 39 | if (completed) keyboardEvent.preventDefault() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /api/JoplinViews.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import JoplinViewsDialogs from './JoplinViewsDialogs'; 3 | import JoplinViewsMenuItems from './JoplinViewsMenuItems'; 4 | import JoplinViewsMenus from './JoplinViewsMenus'; 5 | import JoplinViewsToolbarButtons from './JoplinViewsToolbarButtons'; 6 | import JoplinViewsPanels from './JoplinViewsPanels'; 7 | import JoplinViewsNoteList from './JoplinViewsNoteList'; 8 | /** 9 | * This namespace provides access to view-related services. 10 | * 11 | * 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. 12 | * 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. 13 | */ 14 | export default class JoplinViews { 15 | private store; 16 | private plugin; 17 | private panels_; 18 | private menuItems_; 19 | private menus_; 20 | private toolbarButtons_; 21 | private dialogs_; 22 | private noteList_; 23 | private implementation_; 24 | constructor(implementation: any, plugin: Plugin, store: any); 25 | get dialogs(): JoplinViewsDialogs; 26 | get panels(): JoplinViewsPanels; 27 | get menuItems(): JoplinViewsMenuItems; 28 | get menus(): JoplinViewsMenus; 29 | get toolbarButtons(): JoplinViewsToolbarButtons; 30 | get noteList(): JoplinViewsNoteList; 31 | } 32 | -------------------------------------------------------------------------------- /test-support/fakes/joplin/FakeCodeMirror6.ts: -------------------------------------------------------------------------------- 1 | import { CodeMirror6 } from "api/types" 2 | import { Extension } from "@codemirror/state" 3 | import { EditorView } from "@codemirror/view" 4 | 5 | import { FakeEditorView } from "test-support/fakes/codemirror/cm6/view/FakeEditorView" 6 | import { FakeJoplinExtensions } from "test-support/fakes/joplin/FakeJoplinExtensions" 7 | 8 | export type FakeCodeMirror6 = CodeMirror6 & Extensions 9 | export namespace FakeCodeMirror6 { 10 | export function create(): FakeCodeMirror6 { 11 | return new ExtendedCodeMirror6() as unknown as FakeCodeMirror6 12 | } 13 | } 14 | 15 | type PartialCodeMirror6 = Pick & { 16 | joplinExtensions: FakeJoplinExtensions 17 | } 18 | 19 | export interface Extensions { 20 | readonly ext: { 21 | /** 22 | * The extension sent in {@link CodeMirror#addExtension} (if called). 23 | */ 24 | readonly extension: Extension | undefined 25 | } 26 | } 27 | 28 | // noinspection JSUnusedGlobalSymbols 29 | class ExtendedCodeMirror6 implements PartialCodeMirror6, Extensions { 30 | // noinspection JSUnusedGlobalSymbols 31 | readonly ext = new (class { 32 | extension: Extension | undefined 33 | })() 34 | 35 | readonly joplinExtensions: FakeJoplinExtensions = FakeJoplinExtensions.create() 36 | 37 | get cm6(): EditorView { 38 | return FakeEditorView.create() 39 | } 40 | 41 | addExtension(extension: Extension): void { 42 | this.ext.extension = extension 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/renderer/RenderPerformer.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, when } from "strong-mock" 2 | import { Arrays } from "@ts-belt" 3 | import { describe, expect, it } from "vitest" 4 | 5 | import { Formatter } from "@cm-extension/cm5/formatter/Formatter" 6 | import { Marker } from "@cm-extension/cm5/marker/Marker" 7 | import { Origin } from "@cm-extension/cm5/model/Origin" 8 | import { RenderPerformer } from "@cm-extension/cm5/renderer/RenderPerformer" 9 | 10 | import { DocData } from "test-support/fixtures/cm5/DocData" 11 | 12 | describe("RenderPerformer", () => { 13 | describe("perform", () => { 14 | it("performs rendering", () => { 15 | const { doc, codeBlocks } = DocData.Any.combo() 16 | doc.setCursor({ line: 6, ch: 2 }) 17 | const formattedCodeBlocks = [Arrays.head(codeBlocks)!] 18 | const mockFormatter = mock() 19 | const mockMarker = mock() 20 | 21 | when(() => mockFormatter.format(doc, codeBlocks, Origin.RenderHandler)).thenReturn( 22 | formattedCodeBlocks, 23 | ) 24 | 25 | when(() => mockMarker.mark(doc, formattedCodeBlocks)).thenReturn() 26 | 27 | const renderPerformer = RenderPerformer.create({ 28 | formatter: mockFormatter, 29 | marker: mockMarker, 30 | }) 31 | 32 | expect(renderPerformer.perform(doc, codeBlocks, Origin.RenderHandler)).toBe( 33 | formattedCodeBlocks, 34 | ) 35 | expect(doc.getCursor()).toEqual({ line: 5, ch: 1 }) 36 | }) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /test-support/TestSetup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry-point for Vitest test setup. 3 | * 4 | * Vitest imports this before each test file (i.e. importing this file has the side effect 5 | * of setting up the tests). 6 | */ 7 | 8 | import { resetAll, setDefaults, verifyAll } from "strong-mock" 9 | import { afterEach, expect } from "vitest" 10 | 11 | import { JestExtendedMatchers } from "test-support/matchers/JestExtendedMatchers" 12 | import { MapMatchers } from "test-support/matchers/MapMatchers" 13 | import { SetMatchers } from "test-support/matchers/SetMatchers" 14 | 15 | /** 16 | * Runs for each test file. 17 | */ 18 | namespace TestSetup { 19 | export function initialize(): void { 20 | initializeMockingConfig() 21 | initializeMatchers() 22 | initializeMocking() 23 | } 24 | 25 | /** 26 | * Increases strictness of `strong-mock` mocks. 27 | */ 28 | const initializeMockingConfig = function (): void { 29 | setDefaults({ exactParams: true }) 30 | } 31 | 32 | /** 33 | * Adds additional matchers. 34 | */ 35 | const initializeMatchers = function (): void { 36 | expect.extend(MapMatchers) 37 | expect.extend(SetMatchers) 38 | expect.extend(JestExtendedMatchers) 39 | } 40 | 41 | /** 42 | * Verifies and resets all mocks after each test, allowing for mock re-use and brevity. 43 | */ 44 | const initializeMocking = function (): void { 45 | afterEach(() => { 46 | verifyAll() 47 | resetAll() 48 | }) 49 | } 50 | } 51 | 52 | TestSetup.initialize() 53 | -------------------------------------------------------------------------------- /test/joplin-plugin/settings/PluginSettingsProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { PluginSettings } from "@joplin-plugin/settings/PluginSettings" 4 | import { PluginSettingsProvider } from "@joplin-plugin/settings/PluginSettingsProvider" 5 | 6 | import { FakeJoplinSettings } from "test-support/fakes/joplin/FakeJoplinSettings" 7 | 8 | describe("PluginSettingsProvider", () => { 9 | describe("provide", () => { 10 | it("provides settings", async () => { 11 | const fakeJoplinSettings = FakeJoplinSettings.create({ 12 | values: { 13 | completedLanguages: " c , c++, c-- ", 14 | completion: "enabled", 15 | copyFormat: "fencedCode", 16 | cornerStyle: "round", 17 | excludedLanguages: " a , b , c ", 18 | rendering: "disabled", 19 | renderLayout: "minimal", 20 | selectAllCapturing: "enabled", 21 | }, 22 | }) 23 | 24 | await expect( 25 | PluginSettingsProvider.create({ 26 | joplinSettings: fakeJoplinSettings, 27 | }).provide(), 28 | ).resolves.toStrictEqual({ 29 | completedLanguages: ["c", "c++", "c--"], 30 | completion: "enabled", 31 | copyFormat: "fencedCode", 32 | cornerStyle: "round", 33 | excludedLanguages: ["a", "b", "c"], 34 | rendering: "disabled", 35 | renderLayout: "minimal", 36 | selectAllCapturing: "enabled", 37 | } satisfies PluginSettings) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /test-support/fakes/codemirror/cm6/autocomplete/FakeCompletionContext.ts: -------------------------------------------------------------------------------- 1 | import { CompletionContext } from "@codemirror/autocomplete" 2 | 3 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 4 | 5 | export interface FakeCompletionContextProps { 6 | /** 7 | * The `EditorState` to set for the context (defaults to a doc with the text `doc`) 8 | */ 9 | readonly state?: CompletionContext["state"] 10 | 11 | /** 12 | * The position of the cursor (defaults to the start of the doc). 13 | */ 14 | readonly pos?: CompletionContext["pos"] 15 | } 16 | 17 | export type FakeCompletionContext = CompletionContext & Extensions 18 | export namespace FakeCompletionContext { 19 | export function create(props?: FakeCompletionContextProps): FakeCompletionContext { 20 | return new ExtendedCompletionContext(props) as unknown as FakeCompletionContext 21 | } 22 | } 23 | 24 | type PartialCompletionContext = Pick 25 | 26 | export interface Extensions { 27 | readonly ext: object 28 | } 29 | 30 | // noinspection JSUnusedGlobalSymbols 31 | class ExtendedCompletionContext implements PartialCompletionContext, Extensions { 32 | // noinspection JSUnusedGlobalSymbols 33 | readonly ext = new (class {})() 34 | 35 | readonly state: CompletionContext["state"] 36 | readonly pos: CompletionContext["pos"] 37 | 38 | constructor(props?: FakeCompletionContextProps) { 39 | this.state = props?.state ?? InlineState`doc` 40 | this.pos = props?.pos ?? 0 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/handler/RenderHandler.ts: -------------------------------------------------------------------------------- 1 | import { Editor, EditorChange } from "codemirror" 2 | 3 | import { Origin } from "@cm-extension/cm5/model/Origin" 4 | import { Renderer } from "@cm-extension/cm5/renderer/Renderer" 5 | 6 | export interface RenderHandlerProps { 7 | readonly renderer: Renderer 8 | } 9 | 10 | /** 11 | * Handles rendering of code fences using the given {@link renderer}. 12 | */ 13 | export class RenderHandler { 14 | private readonly renderer: Renderer 15 | 16 | static create(props: RenderHandlerProps): RenderHandler { 17 | return new RenderHandler(props) 18 | } 19 | 20 | private constructor(props: RenderHandlerProps) { 21 | this.renderer = props.renderer 22 | } 23 | 24 | /** 25 | * Renders code fences in the {@link cm} {@link Doc} after an editor change. 26 | * 27 | * The operation executes using {@link Origin#RenderHandler}. 28 | * 29 | * Ignores re-rendering that's caused by {@link RenderHandler} itself. 30 | */ 31 | renderOnChange(cm: Editor, { origin }: EditorChange): void { 32 | if (origin !== Origin.RenderHandler) this.renderInternal(cm) 33 | } 34 | 35 | /** 36 | * Renders code fences in the {@link cm} {@link Doc} immediately. 37 | * 38 | * The operation executes using {@link Origin#RenderHandler}. 39 | */ 40 | renderNow(cm: Editor): void { 41 | this.renderInternal(cm) 42 | } 43 | 44 | private renderInternal(cm: Editor): void { 45 | cm.operation(() => this.renderer.render(cm.getDoc(), Origin.RenderHandler)) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/rendering/decoration/ReplaceDecorations.ts: -------------------------------------------------------------------------------- 1 | import { Decoration, ReplaceDecorationSpec } from "@codemirror/view" 2 | 3 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 4 | import { Widgets } from "@cm-extension/cm6/rendering/decoration/Widgets" 5 | 6 | /** 7 | * {@link Decoration.replace}s for {@link CodeBlock}s. 8 | */ 9 | export namespace ReplaceDecorations { 10 | /** 11 | * {@link ReplaceDecorationSpec} that replaces an opening fence with 12 | * a {@link Widgets.OpeningFence}. 13 | */ 14 | export function openingFenceSpec(codeBlock: CodeBlock): ReplaceDecorationSpec { 15 | return { widget: Widgets.OpeningFence.create({ codeBlock }) } 16 | } 17 | /** 18 | * {@link Decoration.replace} that replaces an opening fence with 19 | * a {@link Widgets.OpeningFence}. 20 | */ 21 | export function openingFence(codeBlock: CodeBlock): Decoration { 22 | return Decoration.replace(openingFenceSpec(codeBlock)) 23 | } 24 | 25 | /** 26 | * {@link ReplaceDecorationSpec} that replaces a closing fence with 27 | * a {@link Widgets.ClosingFence}. 28 | */ 29 | export function closingFenceSpec(codeBlock: CodeBlock): ReplaceDecorationSpec { 30 | return { widget: Widgets.ClosingFence.create({ codeBlock }) } 31 | } 32 | /** 33 | * {@link Decoration.replace} that replaces an closing fence with 34 | * a {@link Widgets.ClosingFence}. 35 | */ 36 | export function closingFence(codeBlock: CodeBlock): Decoration { 37 | return Decoration.replace(closingFenceSpec(codeBlock)) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/theme/MinimalMixin.ts: -------------------------------------------------------------------------------- 1 | import { ThemeSpec } from "@codemirror/view" 2 | 3 | const RoundMixin: ThemeSpec = { 4 | // First code line with round top corners 5 | "&[data-cb-render-layout='minimal'][data-cb-corner-style='round'] .cb-code-line.cb-first": { 6 | "border-top-left-radius": "3px", 7 | "border-top-right-radius": "3px", 8 | }, 9 | 10 | // Last code line with round bottom corners 11 | "&[data-cb-render-layout='minimal'][data-cb-corner-style='round'] .cb-code-line.cb-last": { 12 | "border-bottom-right-radius": "3px", 13 | "border-bottom-left-radius": "3px", 14 | }, 15 | } 16 | 17 | /** 18 | * Mixin that adds styles specific to the minimal layout. 19 | */ 20 | export const MinimalMixin: ThemeSpec = { 21 | ...RoundMixin, 22 | 23 | // Opening and closing code fences 24 | "&[data-cb-render-layout='minimal'] .cb-start-line, &[data-cb-render-layout='minimal'] .cb-end-line": 25 | { 26 | position: "relative", 27 | background: "none", 28 | }, 29 | 30 | // Opening and closing fence content 31 | "&[data-cb-render-layout='minimal'] .cb-opening-fence, &[data-cb-render-layout='minimal'] .cb-closing-fence": 32 | { 33 | visibility: "hidden", 34 | }, 35 | 36 | // Button inside start widget that copies the code 37 | "&[data-cb-render-layout='minimal'] .cb-copy-btn": { 38 | // Lower the button onto the rightmost edge of the first code line 39 | position: "absolute", 40 | top: "100%", 41 | right: 0, 42 | height: "100%", 43 | padding: "0 0.25ch", 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/state/Facets.ts: -------------------------------------------------------------------------------- 1 | import { Facet } from "@codemirror/state" 2 | import { Arrays, Dicts } from "@ts-belt" 3 | 4 | import { SingularFacet, SingularFacetSpec } from "@ext/codemirror/cm6/state/SingularFacet" 5 | 6 | /** 7 | * Operations on {@link Facet}s. 8 | */ 9 | export namespace Facets { 10 | /** 11 | * Defines a {@link Facet} that should always have exactly one value. 12 | * 13 | * Note that all Facets must specify a default value for cases where a value 14 | * for the Facet is never actually assigned, even if that shouldn't ordinarily be the case. 15 | */ 16 | export function defineSingular(spec: SingularFacetSpec): SingularFacet { 17 | return Facet.define({ 18 | ...Dicts.deleteKey(spec, "defaultValue"), 19 | 20 | /** 21 | * Returns the `defaultValue` when the Facet is first defined, otherwise 22 | * returns the first of the provided {@link values}. 23 | * 24 | * Note that singular Facets can't the default implementation of `combine`. 25 | * This is because Facets, by default, return `T[]` instead of `T`, 26 | * despite what the return type on the method would suggest. 27 | * 28 | * Leaving `combine` unimplemented here would return `[]` on create, 29 | * followed by `[T]` on assignment. 30 | * This implementation always returns `T` (no tuples/arrays). 31 | */ 32 | combine(values: readonly T[]): T { 33 | return Arrays.isEmpty(values) ? spec.defaultValue() : Arrays.head(values)! 34 | }, 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ext/stdlib/Retrier.ts: -------------------------------------------------------------------------------- 1 | import { Require } from "@ext/stdlib/Require" 2 | 3 | export interface RetrierProps { 4 | readonly window: Window 5 | } 6 | 7 | /** 8 | * Performs asynchronous retrying with exponential backoff using {@link window#setTimeout}. 9 | */ 10 | export class Retrier { 11 | private readonly window: Window 12 | 13 | static create(props: RetrierProps): Retrier { 14 | return new Retrier(props) 15 | } 16 | 17 | private constructor(props: RetrierProps) { 18 | this.window = props.window 19 | } 20 | 21 | /** 22 | * Repeatedly calls {@link fn} until the call succeeds and returns the successful response. 23 | * The delay between retries starts at {@link startDelayMillis} and doubles each time until 24 | * {@link isSuccess} returns true. 25 | * 26 | * The {@link startDelayMillis} must be a non-negative integer. 27 | */ 28 | async retry({ 29 | fn, 30 | isSuccess, 31 | startDelayMillis, 32 | }: { 33 | fn: () => Promise 34 | isSuccess: (response: T) => boolean 35 | startDelayMillis: number 36 | }): Promise { 37 | Require.nonNegativeInteger(startDelayMillis) 38 | 39 | let delayMillis = startDelayMillis 40 | let response = await fn() 41 | while (!isSuccess(response)) { 42 | await this.delay(delayMillis) 43 | delayMillis *= 2 44 | response = await fn() 45 | } 46 | 47 | return response 48 | } 49 | 50 | private async delay(millis: number): Promise { 51 | return await new Promise((it) => this.window.setTimeout(it, millis)) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/rendering/decoration/LineDecorations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { LineDecorations } from "@cm-extension/cm6/rendering/decoration/LineDecorations" 4 | 5 | describe("LineDecorations", () => { 6 | describe("codeSpec", () => { 7 | it("returns a first and last code line decoration spec", () => { 8 | expect(LineDecorations.codeSpec({ isFirst: true, isLast: true })).toStrictEqual({ 9 | attributes: { class: "cb-code-line cb-first cb-last" }, 10 | }) 11 | }) 12 | 13 | it("returns a first code line decoration spec", () => { 14 | expect(LineDecorations.codeSpec({ isFirst: true, isLast: false })).toStrictEqual({ 15 | attributes: { class: "cb-code-line cb-first" }, 16 | }) 17 | }) 18 | 19 | it("returns a last code line decoration spec", () => { 20 | expect(LineDecorations.codeSpec({ isFirst: false, isLast: true })).toStrictEqual({ 21 | attributes: { class: "cb-code-line cb-last" }, 22 | }) 23 | }) 24 | 25 | it("returns a middle code line decoration spec", () => { 26 | expect(LineDecorations.codeSpec({ isFirst: false, isLast: false })).toStrictEqual({ 27 | attributes: { class: "cb-code-line" }, 28 | }) 29 | }) 30 | }) 31 | 32 | describe("code", () => { 33 | it("returns a code line decoration", () => { 34 | expect(LineDecorations.code({ isFirst: true, isLast: true }).spec).toStrictEqual({ 35 | attributes: { class: "cb-code-line cb-first cb-last" }, 36 | }) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/model/CodeDocs.ts: -------------------------------------------------------------------------------- 1 | import { Text } from "@codemirror/state" 2 | 3 | import { Iterables } from "@ext/stdlib/Iterables" 4 | 5 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 6 | 7 | /** 8 | * Operations on {@link CodeBlock}s within docs. 9 | */ 10 | export namespace CodeDocs { 11 | /** 12 | * Returns the `code` within the given {@link codeBlock} in the {@link doc}. 13 | * This may optionally {@link includeFences} of the {@link codeBlock}. 14 | */ 15 | export function getCode( 16 | doc: Text, 17 | codeBlock: CodeBlock, 18 | options: { includeFences: boolean }, 19 | ): string { 20 | if (options.includeFences) return doc.sliceString(codeBlock.start, codeBlock.end) 21 | 22 | if (!codeBlock.hasCode()) return "" 23 | 24 | return doc.sliceString(codeBlock.codeStart, codeBlock.codeEnd) 25 | } 26 | 27 | /** 28 | * Returns an {@link Iterable} over the lines of the given {@link codeBlock} in the {@link doc}. 29 | * This may optionally {@link includeFences} of the {@link codeBlock}. 30 | * 31 | * Doesn't return the line breaks and yields empty strings for empty lines. 32 | * 33 | * @see Text.iterLines 34 | */ 35 | export function iterCodeLines( 36 | doc: Text, 37 | codeBlock: CodeBlock, 38 | options: { includeFences: boolean }, 39 | ): Iterable { 40 | if (options.includeFences) return doc.iterLines(codeBlock.firstLine, codeBlock.lastLine + 1) 41 | 42 | if (!codeBlock.hasCode()) return Iterables.empty() 43 | 44 | return doc.iterLines(codeBlock.firstCodeLine, codeBlock.lastCodeLine + 1) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/assets/opt/render-layout/minimal.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Background of the start and end (non-code) line in a code fence. 3 | * 4 | * e.g. Background of starting `~~~typescript` and ending `~~~` 5 | */ 6 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"] .cb-start-background, 7 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"] .cb-end-background { 8 | background: none; 9 | } 10 | 11 | /** 12 | * Wrap of first code line with round top corners. 13 | */ 14 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"][data-cb-corner-style="round"] .cb-code-background.cb-first { 15 | border-top-left-radius: 3px; 16 | border-top-right-radius: 3px; 17 | } 18 | 19 | /** 20 | * Wrap of last code line with round bottom corners. 21 | */ 22 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"][data-cb-corner-style="round"] .cb-code-background.cb-last { 23 | border-bottom-right-radius: 3px; 24 | border-bottom-left-radius: 3px; 25 | } 26 | 27 | /** 28 | * Opening and closing fence widgets (non-code). 29 | * 30 | * e.g. Widget that replaces `~~~typescript` and `~~~` 31 | */ 32 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"] .cb-opening-fence, 33 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"] .cb-closing-fence { 34 | visibility: hidden; 35 | } 36 | 37 | /** 38 | * Button inside start widget that copies code within a code fence. 39 | */ 40 | .CodeMirror:not(.cm-editor)[data-cb-render-layout="minimal"] .cb-copy-btn { 41 | /* Lower the button onto the rightmost edge of the first code line */ 42 | position: absolute; 43 | top: 100%; 44 | right: 0; 45 | height: 100%; 46 | padding: 0.5ch; 47 | } 48 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/handler/SelectHandler.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelectionChange, ReadonlyDoc } from "codemirror" 2 | 3 | import { Docs } from "@ext/codemirror/cm5/Docs" 4 | import { Events } from "@ext/codemirror/cm5/Events" 5 | import { nil } from "@ext/stdlib/existence" 6 | 7 | import { RangeFinder } from "@cm-extension/cm5/RangeFinder" 8 | 9 | export interface SelectHandlerProps { 10 | readonly rangeFinder: RangeFinder 11 | } 12 | 13 | /** 14 | * Handles selection of code fences using the given {@link rangeFinder}. 15 | */ 16 | export class SelectHandler { 17 | private readonly rangeFinder: RangeFinder 18 | 19 | static create(props: SelectHandlerProps): SelectHandler { 20 | return new SelectHandler(props) 21 | } 22 | 23 | private constructor(props: SelectHandlerProps) { 24 | this.rangeFinder = props.rangeFinder 25 | } 26 | 27 | /** 28 | * Selects the code within a code fence if the {@link doc} cursor is inside the code fence 29 | * and the {@link change} is due to a `Select All` action. 30 | * 31 | * This "captures" the `Select All` action to simplify code fence selection. 32 | * 33 | * If the code fence is already selected, lets the `Select All` go through unchanged. 34 | * This allows the user to perform `Select All` twice to perform a true `Select All` action. 35 | */ 36 | selectOnSelectAll(doc: ReadonlyDoc, change: EditorSelectionChange): void { 37 | if (!Events.isSelectAll(doc, change)) return 38 | 39 | const codeRange = this.rangeFinder.findActiveCodeRange(doc) 40 | if (nil(codeRange) || Docs.isSelected(doc, codeRange)) return 41 | 42 | change.update([{ anchor: codeRange.from, head: codeRange.to }]) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/state/Transactions.ts: -------------------------------------------------------------------------------- 1 | import { Transaction } from "@codemirror/state" 2 | 3 | import { EditorSelections } from "@ext/codemirror/cm6/state/EditorSelections" 4 | import { UserEvent } from "@ext/codemirror/cm6/state/UserEvent" 5 | import { nil } from "@ext/stdlib/existence" 6 | 7 | /** 8 | * Operations on {@link Transaction}s. 9 | */ 10 | export namespace Transactions { 11 | /** 12 | * Returns true if the {@link transaction} is due to the user pressing backspace. 13 | */ 14 | export function isDeleteBackward(transaction: Transaction): boolean { 15 | return transaction.isUserEvent(UserEvent.deleteBackward) 16 | } 17 | 18 | /** 19 | * Returns true if the {@link transaction} is due to the user pressing delete. 20 | */ 21 | export function isDeleteForward(transaction: Transaction): boolean { 22 | return transaction.isUserEvent(UserEvent.deleteForward) 23 | } 24 | 25 | /** 26 | * Returns true if the {@link transaction} is due to the user pressing backspace or delete. 27 | */ 28 | export function isDeleteDirection(transaction: Transaction): boolean { 29 | return isDeleteBackward(transaction) || isDeleteForward(transaction) 30 | } 31 | 32 | /** 33 | * Returns true if the {@link transaction} is due to the user performing select-all. 34 | */ 35 | export function isSelectAll(transaction: Transaction): boolean { 36 | const { selection, docChanged, startState } = transaction 37 | 38 | if (docChanged) return false 39 | if (nil(selection) || EditorSelections.isMultiple(selection)) return false 40 | 41 | const { main: range } = selection 42 | return range.from === 0 && range.to === startState.doc.length 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/modification/formatting/EmptyCodeBlockUpdater.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { EmptyCodeBlockUpdater } from "@cm-extension/cm6/modification/formatting/EmptyCodeBlockUpdater" 4 | 5 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 6 | import { FakeEditorView } from "test-support/fakes/codemirror/cm6/view/FakeEditorView" 7 | import { FakeViewUpdate } from "test-support/fakes/codemirror/cm6/view/FakeViewUpdate" 8 | 9 | describe("EmptyCodeBlockUpdater", () => { 10 | it("does nothing when doc is unchanged", () => { 11 | const viewUpdate = FakeViewUpdate.create({ docChanged: false }) 12 | 13 | EmptyCodeBlockUpdater(viewUpdate) 14 | }) 15 | 16 | it("does nothing when no code blocks are empty", () => { 17 | const viewUpdate = FakeViewUpdate.create({ 18 | docChanged: true, 19 | state: InlineState` 20 | ~~~lang 21 | code 22 | ~~~ 23 | `, 24 | }) 25 | EmptyCodeBlockUpdater(viewUpdate) 26 | }) 27 | 28 | it("dispatches line breaks for empty code blocks", () => { 29 | const view = FakeEditorView.create() 30 | const viewUpdate = FakeViewUpdate.create({ 31 | docChanged: true, 32 | state: InlineState` 33 | ~~~lang 34 | ~~~ 35 | ~~~lang 36 | 37 | ~~~ 38 | ~~~~lang 39 | ~~~~ 40 | `, 41 | view, 42 | }) 43 | 44 | EmptyCodeBlockUpdater(viewUpdate) 45 | expect(view.ext.dispatches).toStrictEqual([ 46 | { 47 | changes: [ 48 | { from: 7, insert: "\n" }, 49 | { from: 33, insert: "\n" }, 50 | ], 51 | }, 52 | ]) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /src/ext/joplin/JoplinCommands.ts: -------------------------------------------------------------------------------- 1 | import ActualJoplinCommands from "api/JoplinCommands" 2 | 3 | /** 4 | * Extensions for `JoplinCommands`. 5 | */ 6 | export namespace JoplinCommands { 7 | /** 8 | * Calls the CodeMirror `extension` {@link extensionName} passing {@link arg}. 9 | * 10 | * Takes advantage of the `editor.execCommand` Joplin command to call methods on CodeMirror. 11 | * This works on plugin-defined `extension` methods as well (i.e. methods added 12 | * by calling {@link CodeMirror#defineExtension}). 13 | * 14 | * This enables one-way communication from the Joplin Plugin to the CodeMirror Extension which 15 | * is normally impossible due to Joplin Plugins running in a separate process. 16 | * 17 | * Joplin Plugin APIs provide communication in the reverse direction, from CodeMirror Extension 18 | * to Joplin Plugin, via {@link ContentScriptContext#postMessage} and {@link JoplinContentScripts#onMessage}. 19 | * 20 | * So {@link callCodeMirrorExtension} is to {@link CodeMirror#defineExtension} 21 | * as {@link ContentScriptContext#postMessage} is to {@link JoplinContentScripts#onMessage}. 22 | * 23 | * @see https://codemirror.net/5/doc/manual.html#defineExtension 24 | * @see https://joplinapp.org/api/references/plugin_api/enums/contentscripttype.html#posting-messages-from-the-content-script-to-your-plugin 25 | */ 26 | export async function callCodeMirrorExtension( 27 | joplinCommands: ActualJoplinCommands, 28 | extensionName: string, 29 | arg: unknown, 30 | ): Promise { 31 | return (await joplinCommands.execute("editor.execCommand", { 32 | name: extensionName, 33 | args: [arg], 34 | })) as unknown 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/JoplinPlugins.d.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '../Plugin'; 2 | import { ContentScriptType, Script } from './types'; 3 | /** 4 | * This class provides access to plugin-related features. 5 | */ 6 | export default class JoplinPlugins { 7 | private plugin; 8 | constructor(plugin: Plugin); 9 | /** 10 | * Registers a new plugin. This is the entry point when creating a plugin. You should pass a simple object with an `onStart` method to it. 11 | * That `onStart` method will be executed as soon as the plugin is loaded. 12 | * 13 | * ```typescript 14 | * joplin.plugins.register({ 15 | * onStart: async function() { 16 | * // Run your plugin code here 17 | * } 18 | * }); 19 | * ``` 20 | */ 21 | register(script: Script): Promise; 22 | /** 23 | * @deprecated Use joplin.contentScripts.register() 24 | */ 25 | registerContentScript(type: ContentScriptType, id: string, scriptPath: string): Promise; 26 | /** 27 | * Gets the plugin own data directory path. Use this to store any 28 | * plugin-related data. Unlike [[installationDir]], any data stored here 29 | * will be persisted. 30 | */ 31 | dataDir(): Promise; 32 | /** 33 | * Gets the plugin installation directory. This can be used to access any 34 | * asset that was packaged with the plugin. This directory should be 35 | * considered read-only because any data you store here might be deleted or 36 | * re-created at any time. To store new persistent data, use [[dataDir]]. 37 | */ 38 | installationDir(): Promise; 39 | /** 40 | * @deprecated Use joplin.require() 41 | */ 42 | require(_path: string): any; 43 | } 44 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/marker/widgeter/WidgetGenerator.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { LineSegment } from "@ext/codemirror/cm5/LineSegment" 4 | 5 | import { WidgetGenerator } from "@cm-extension/cm5/marker/widgeter/WidgetGenerator" 6 | 7 | import { CondensedString } from "test-support/ext/stdlib/CondensedString" 8 | import { Any } from "test-support/fixtures/cm5/Any" 9 | import { DocData } from "test-support/fixtures/cm5/DocData" 10 | 11 | describe("WidgetGenerator", () => { 12 | describe("generate", () => { 13 | it("generates widgets", () => { 14 | const { doc, codeBlock } = DocData.Any.simple() 15 | 16 | const widgets = WidgetGenerator.create({ 17 | copyButtonGenerator: Any.copyButtonGenerator(), 18 | }).generate(doc, [codeBlock]) 19 | 20 | expect(widgets).toMatchObject(simpleWidgets) 21 | }) 22 | }) 23 | }) 24 | 25 | const simpleWidgets = [ 26 | { 27 | range: LineSegment.of({ line: 0, from: 0, to: 13 }), 28 | element: { 29 | outerHTML: CondensedString` 30 | 31 | \`\`\`typescript 32 | 35 | 36 | `, 37 | }, 38 | }, 39 | { 40 | range: LineSegment.of({ line: 4, from: 0, to: 3 }), 41 | element: { 42 | outerHTML: CondensedString` 43 | 44 | \`\`\` 45 | typescript 46 | 47 | `, 48 | }, 49 | }, 50 | ] 51 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig, mergeConfig, ViteUserConfig } from "vitest/config" 2 | 3 | import tsconfigPaths from "vite-tsconfig-paths" 4 | 5 | /** 6 | * Configuration for tests. 7 | */ 8 | const testConfig: ViteUserConfig = { 9 | plugins: [tsconfigPaths()], 10 | test: { 11 | setupFiles: ["./test-support/TestSetup.ts"], 12 | environment: "happy-dom", 13 | coverage: { 14 | include: ["src/**/*"], 15 | 16 | // Exclude files that contain only type definitions which show up 17 | // as having 0% coverage for some reason 18 | exclude: [ 19 | "src/cm-extension/marker/line-styler/LineStyle.ts", 20 | "src/cm-extension/marker/widgeter/Widget.ts", 21 | "src/cm-extension/model/Config.ts", 22 | "src/cm-extension-ipc/model/*", 23 | "src/joplin-plugin/settings/PluginSettings.ts", 24 | "src/joplin-plugin-ipc/model/*", 25 | ], 26 | 27 | // Include all files in coverage, including those that are untested 28 | all: true, 29 | }, 30 | }, 31 | } 32 | 33 | const configs = new Map([ 34 | // Run all tests by default (`npm run test`) 35 | ["test", testConfig], 36 | 37 | // Run only unit tests (`npm run test:unit`) 38 | ["unit-test", mergeConfig(testConfig, { test: { include: ["test/**/*"] } })], 39 | 40 | // Run only integration tests (`npm run test:integ`) 41 | ["integration-test", mergeConfig(testConfig, { test: { include: ["test-integration/**/*"] } })], 42 | ]) 43 | 44 | // noinspection JSUnusedGlobalSymbols 45 | export default defineConfig(({ mode }) => { 46 | if (!configs.has(mode)) 47 | throw new Error(`Invalid mode "${mode}" (expected one of [${[...configs.keys()].toString()}])`) 48 | 49 | return configs.get(mode)! 50 | }) 51 | -------------------------------------------------------------------------------- /src/joplin-plugin/handler/RequestHandler.ts: -------------------------------------------------------------------------------- 1 | import { PluginRequestHandler } from "@joplin-plugin-ipc/model/handler" 2 | import { 3 | GetSettingsRequest, 4 | GetSettingsResponse, 5 | PingRequest, 6 | PingResponse, 7 | } from "@joplin-plugin-ipc/model/messages" 8 | 9 | import { PluginSettingsProvider } from "@joplin-plugin/settings/PluginSettingsProvider" 10 | 11 | export interface RequestHandlerProps { 12 | readonly pluginSettingsProvider: PluginSettingsProvider 13 | } 14 | 15 | /** 16 | * Handles requests to the Joplin plugin (through inter-process communication). 17 | * 18 | * Returns settings using the {@link pluginSettingsProvider}. 19 | */ 20 | export class RequestHandler { 21 | private readonly pluginSettingsProvider: PluginSettingsProvider 22 | 23 | static create(props: RequestHandlerProps): RequestHandler { 24 | return new RequestHandler(props) 25 | } 26 | 27 | private constructor(props: RequestHandlerProps) { 28 | this.pluginSettingsProvider = props.pluginSettingsProvider 29 | } 30 | 31 | /** 32 | * Handles a Joplin plugin {@link request} and returns a response. 33 | */ 34 | handle: PluginRequestHandler = (request) => { 35 | return (this[request.kind] as PluginRequestHandler)(request) 36 | } 37 | 38 | /** 39 | * Handles a {@link _pingRequest} and returns a {@link PingResponse}. 40 | */ 41 | async ping(_pingRequest: PingRequest): Promise { 42 | return Promise.resolve(PingResponse.of()) 43 | } 44 | 45 | /** 46 | * Handles a {@link _getSettingsRequest} and returns a {@link GetSettingsResponse}. 47 | */ 48 | async getSettings(_getSettingsRequest: GetSettingsRequest): Promise { 49 | return GetSettingsResponse.of(await this.pluginSettingsProvider.provide()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/rendering/decoration/ReplaceDecorations.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 4 | import { ReplaceDecorations } from "@cm-extension/cm6/rendering/decoration/ReplaceDecorations" 5 | import { Widgets } from "@cm-extension/cm6/rendering/decoration/Widgets" 6 | 7 | describe("ReplaceDecorations", () => { 8 | describe("openingFenceSpec", () => { 9 | it("returns an opening fence replace decoration spec", () => { 10 | expect(ReplaceDecorations.openingFenceSpec(codeBlock)).toStrictEqual({ 11 | widget: Widgets.OpeningFence.create({ codeBlock }), 12 | }) 13 | }) 14 | }) 15 | 16 | describe("openingFence", () => { 17 | it("returns an opening fence replace decoration", () => { 18 | expect(ReplaceDecorations.openingFence(codeBlock).spec).toStrictEqual({ 19 | widget: Widgets.OpeningFence.create({ codeBlock }), 20 | }) 21 | }) 22 | }) 23 | 24 | describe("closingFenceSpec", () => { 25 | it("returns an closing fence replace decoration spec", () => { 26 | expect(ReplaceDecorations.closingFenceSpec(codeBlock)).toStrictEqual({ 27 | widget: Widgets.ClosingFence.create({ codeBlock }), 28 | }) 29 | }) 30 | }) 31 | 32 | describe("closingFence", () => { 33 | it("returns an closing fence replace decoration", () => { 34 | expect(ReplaceDecorations.closingFence(codeBlock).spec).toStrictEqual({ 35 | widget: Widgets.ClosingFence.create({ codeBlock }), 36 | }) 37 | }) 38 | }) 39 | }) 40 | 41 | const codeBlock = CodeBlock.of({ 42 | openingFence: { from: 8, to: 15, number: 3, text: "~~~lang" }, 43 | closingFence: { from: 21, to: 24, number: 5, text: "~~~" }, 44 | lang: "lang", 45 | }) 46 | -------------------------------------------------------------------------------- /test-support/fakes/stdlib/FakeRetrier.ts: -------------------------------------------------------------------------------- 1 | import { Retrier } from "@ext/stdlib/Retrier" 2 | 3 | export type FakeRetrier = Retrier & Extensions 4 | export namespace FakeRetrier { 5 | export function create(): FakeRetrier { 6 | return new ExtendedRetrier() as unknown as FakeRetrier 7 | } 8 | } 9 | 10 | type PartialRetrier = Pick 11 | 12 | export interface Extensions { 13 | readonly ext: { 14 | /** 15 | * Represents a summary of the retries by {@link Retrier#retry} (if called). 16 | */ 17 | readonly result: RetryResult | undefined 18 | } 19 | } 20 | 21 | /** 22 | * Represents a summary of the retries by {@link Retrier#retry}. 23 | */ 24 | export interface RetryResult { 25 | /** 26 | * The `startDelayMillis` sent in the call to {@link Retrier#retry}. 27 | */ 28 | startDelayMillis: number 29 | 30 | /** 31 | * The total retries performed before success. 32 | */ 33 | retryCount: number 34 | 35 | /** 36 | * The successful response. 37 | */ 38 | response: unknown 39 | } 40 | 41 | // noinspection JSUnusedGlobalSymbols 42 | class ExtendedRetrier implements PartialRetrier, Extensions { 43 | // noinspection JSUnusedGlobalSymbols 44 | readonly ext = new (class { 45 | result: RetryResult | undefined 46 | })() 47 | 48 | async retry({ 49 | fn, 50 | isSuccess, 51 | startDelayMillis, 52 | }: { 53 | fn: () => Promise 54 | isSuccess: (response: T) => boolean 55 | startDelayMillis: number 56 | }): Promise { 57 | let retryCount = 0 58 | let response = await fn() 59 | while (!isSuccess(response)) { 60 | response = await fn() 61 | retryCount++ 62 | } 63 | 64 | this.ext.result = { startDelayMillis, retryCount, response } 65 | return response 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - name: Setup node 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '18.x' 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Run lint 22 | run: npm run lint:ci 23 | build: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v3 28 | - name: Setup node 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version: '18.x' 32 | - name: Install dependencies 33 | run: npm ci 34 | - name: Build dist 35 | run: npm run dist 36 | test: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | - name: Setup node 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: '18.x' 45 | - name: Install dependencies 46 | run: npm ci 47 | - name: Run tests 48 | run: npm test 49 | coverage: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@v3 54 | - name: Setup node 55 | uses: actions/setup-node@v3 56 | with: 57 | node-version: '18.x' 58 | - name: Install dependencies 59 | run: npm ci 60 | - name: Run coverage 61 | run: npm run coverage 62 | - name: Upload coverage 63 | uses: codecov/codecov-action@v3 64 | env: 65 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 66 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/marker/line-styler/LineStyler.test.ts: -------------------------------------------------------------------------------- 1 | import { Doc, LineHandle } from "codemirror" 2 | import { mock, when } from "strong-mock" 3 | import { beforeEach, describe, it } from "vitest" 4 | 5 | import { LineStyle } from "@cm-extension/cm5/marker/line-styler/LineStyle" 6 | import { LineStyleGenerator } from "@cm-extension/cm5/marker/line-styler/LineStyleGenerator" 7 | import { LineStyler } from "@cm-extension/cm5/marker/line-styler/LineStyler" 8 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 9 | 10 | describe("LineStyler", () => { 11 | describe("mark", () => { 12 | const mockDoc = mock() 13 | const mockCodeBlocks = mock() 14 | const mockLineStyleGenerator = mock() 15 | const mockLineHandle = mock() 16 | 17 | const lineStyles: readonly LineStyle[] = [{ line: 0, text: ["cb-code-line"] }] 18 | 19 | beforeEach(() => { 20 | when(() => mockLineStyleGenerator.generate(mockCodeBlocks)).thenReturn(lineStyles) 21 | when(() => mockDoc.addLineClass(0, "text", "cb-code-line")).thenReturn(mockLineHandle) 22 | }) 23 | 24 | it("adds line styles", () => { 25 | LineStyler.create({ 26 | lineStyleGenerator: mockLineStyleGenerator, 27 | }).mark(mockDoc, mockCodeBlocks) 28 | }) 29 | 30 | it("removes old line styles", () => { 31 | when(() => mockLineStyleGenerator.generate([])).thenReturn([]) 32 | when(() => mockDoc.removeLineClass(mockLineHandle, "text", "cb-code-line")).thenReturn( 33 | mockLineHandle, 34 | ) 35 | 36 | const lineStyler = LineStyler.create({ 37 | lineStyleGenerator: mockLineStyleGenerator, 38 | }) 39 | 40 | lineStyler.mark(mockDoc, mockCodeBlocks) 41 | lineStyler.mark(mockDoc, []) 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/parsing/FenceMatcher.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { FenceMatcher } from "@cm-extension/cm6/parsing/FenceMatcher" 4 | 5 | describe("FenceMatcher", () => { 6 | describe("matchesOpeningFence", () => { 7 | it.each(patterns.matches)("$text should match ($type)", ({ text }) => { 8 | expect(FenceMatcher.matchesOpeningFence(text)).toBeTrue() 9 | }) 10 | 11 | it.each(patterns.mismatches)("$text shouldn't match ($type)", ({ text }) => { 12 | expect(FenceMatcher.matchesOpeningFence(text)).toBeFalse() 13 | }) 14 | }) 15 | }) 16 | 17 | const patterns = { 18 | matches: [ 19 | { 20 | type: "backticks", 21 | text: "```", 22 | }, 23 | { 24 | type: "tildes", 25 | text: "~~~", 26 | }, 27 | { 28 | type: "extra backticks", 29 | text: "````````", 30 | }, 31 | { 32 | type: "extra tildes", 33 | text: "~~~~", 34 | }, 35 | { 36 | type: "indent", 37 | text: " ```", 38 | }, 39 | ], 40 | mismatches: [ 41 | { 42 | type: "empty", 43 | text: "", 44 | }, 45 | { 46 | type: "blank", 47 | text: " ", 48 | }, 49 | { 50 | type: "mixed tags", 51 | text: "`~`", 52 | }, 53 | { 54 | type: "too few backticks", 55 | text: "``", 56 | }, 57 | { 58 | type: "too few tildes", 59 | text: "~~", 60 | }, 61 | { 62 | type: "too much indent", 63 | text: " ```", 64 | }, 65 | { 66 | type: "tabs in indent", 67 | text: " \t\t```", 68 | }, 69 | { 70 | type: "h3", 71 | text: "### typescript", 72 | }, 73 | { 74 | type: "unrelated", 75 | text: "The quick red fox jumps over the orange cat", 76 | }, 77 | ], 78 | } 79 | -------------------------------------------------------------------------------- /test/ext/codemirror/cm5/Positions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Positions } from "@ext/codemirror/cm5/Positions" 4 | 5 | describe("Positions", () => { 6 | describe("areEqual", () => { 7 | it("returns true when equal", () => { 8 | expect(Positions.areEqual({ line: 0, ch: 0 }, { line: 0, ch: 0 })).toBeTrue() 9 | }) 10 | 11 | it("returns false when not equal, different lines", () => { 12 | expect(Positions.areEqual({ line: 0, ch: 0 }, { line: 1, ch: 0 })).toBeFalse() 13 | expect(Positions.areEqual({ line: 1, ch: 0 }, { line: 0, ch: 1 })).toBeFalse() 14 | }) 15 | 16 | it("returns false when not equal, different chs", () => { 17 | expect(Positions.areEqual({ line: 0, ch: 0 }, { line: 0, ch: 1 })).toBeFalse() 18 | expect(Positions.areEqual({ line: 0, ch: 1 }, { line: 0, ch: 0 })).toBeFalse() 19 | }) 20 | }) 21 | 22 | describe("areStrictlyOrdered", () => { 23 | it("returns true when before line", () => { 24 | expect( 25 | Positions.areStrictlyOrdered({ before: { line: 0, ch: 0 }, after: { line: 1, ch: 0 } }), 26 | ).toBeTrue() 27 | }) 28 | 29 | it("returns true when same line and before ch", () => { 30 | expect( 31 | Positions.areStrictlyOrdered({ before: { line: 0, ch: 0 }, after: { line: 0, ch: 1 } }), 32 | ).toBeTrue() 33 | }) 34 | 35 | it("returns false when after line", () => { 36 | expect( 37 | Positions.areStrictlyOrdered({ before: { line: 1, ch: 0 }, after: { line: 0, ch: 0 } }), 38 | ).toBeFalse() 39 | }) 40 | 41 | it("returns false when same line and after ch", () => { 42 | expect( 43 | Positions.areStrictlyOrdered({ before: { line: 0, ch: 1 }, after: { line: 0, ch: 0 } }), 44 | ).toBeFalse() 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/handler/RenderHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorChange } from "codemirror" 2 | import { mock, when } from "strong-mock" 3 | import { describe, expect, it } from "vitest" 4 | 5 | import { RenderHandler } from "@cm-extension/cm5/handler/RenderHandler" 6 | import { Origin } from "@cm-extension/cm5/model/Origin" 7 | import { Renderer } from "@cm-extension/cm5/renderer/Renderer" 8 | 9 | import { FakeEditor } from "test-support/fakes/codemirror/cm5/FakeEditor" 10 | 11 | describe("RenderHandler", () => { 12 | describe("renderOnChange", () => { 13 | it("renders", () => { 14 | const fakeEditor = FakeEditor.create() 15 | const mockRenderer = mock() 16 | 17 | when(() => mockRenderer.render(fakeEditor.getDoc(), Origin.RenderHandler)).thenReturn() 18 | 19 | RenderHandler.create({ 20 | renderer: mockRenderer, 21 | }).renderOnChange(fakeEditor, { origin: "not renderer" } as EditorChange) 22 | 23 | expect(fakeEditor.ext.operation).toBeDefined() 24 | }) 25 | 26 | it("doesn't render when change caused by itself", () => { 27 | const fakeEditor = FakeEditor.create() 28 | const mockRenderer = mock() 29 | 30 | RenderHandler.create({ 31 | renderer: mockRenderer, 32 | }).renderOnChange(fakeEditor, { origin: Origin.RenderHandler } as EditorChange) 33 | }) 34 | }) 35 | 36 | describe("renderNow", () => { 37 | it("renders", () => { 38 | const fakeEditor = FakeEditor.create() 39 | const mockRenderer = mock() 40 | 41 | when(() => mockRenderer.render(fakeEditor.getDoc(), Origin.RenderHandler)).thenReturn() 42 | 43 | RenderHandler.create({ 44 | renderer: mockRenderer, 45 | }).renderNow(fakeEditor) 46 | 47 | expect(fakeEditor.ext.operation).toBeDefined() 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/modification/selection/AllCodeSelectionFilter.ts: -------------------------------------------------------------------------------- 1 | import { TransactionFilterSpec } from "@codemirror/state" 2 | 3 | import { EditorSelections } from "@ext/codemirror/cm6/state/EditorSelections" 4 | import { Transactions } from "@ext/codemirror/cm6/state/Transactions" 5 | import { nil } from "@ext/stdlib/existence" 6 | 7 | import { CodeBlockWithCode } from "@cm-extension/cm6/model/CodeBlock" 8 | import { CodeEditorStates } from "@cm-extension/cm6/model/CodeEditorStates" 9 | 10 | /** 11 | * Transaction filter that shrinks the selection to select the code block code if the cursor 12 | * is inside the code fence and the {@link transaction} is due to a `Select All` action. 13 | * 14 | * This "captures" the `Select All` action to simplify code block selection. 15 | * 16 | * If the code block is already selected, lets the `Select All` go through unchanged. 17 | * This allows the user to perform `Select All` twice to perform a true `Select All` action. 18 | * 19 | * @see EditorState.transactionFilter 20 | */ 21 | export const AllCodeSelectionFilter: TransactionFilterSpec = (transaction) => { 22 | if (!Transactions.isSelectAll(transaction)) return [transaction] 23 | 24 | const { startState } = transaction 25 | if (EditorSelections.isMultiple(startState.selection)) return [transaction] 26 | const startRange = startState.selection.main 27 | 28 | const activeCodeBlock = CodeEditorStates.getCodeBlocks(startState).find((codeBlock) => 29 | codeBlock.includesRange(startRange, { includeFences: false }), 30 | ) as CodeBlockWithCode | undefined 31 | 32 | if (nil(activeCodeBlock)) return [transaction] 33 | 34 | if (activeCodeBlock.isExactRange(startRange, { includeFences: false })) return [transaction] 35 | 36 | return [ 37 | transaction, 38 | { selection: { anchor: activeCodeBlock.codeStart, head: activeCodeBlock.codeEnd } }, 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/parsing/FenceCompletionParser.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { FenceCompletionParser } from "@cm-extension/cm6/parsing/FenceCompletionParser" 4 | 5 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 6 | 7 | describe("FenceCompletionParser", () => { 8 | describe("parseOpeningFenceAt", () => { 9 | it("returns undefined when position before minimum code mark size", () => { 10 | const state = InlineState` 11 | ~~~ 12 | ` 13 | 14 | expect(FenceCompletionParser.parseOpeningFenceAt(state, 2)).toBeUndefined() 15 | }) 16 | 17 | it("returns undefined when position not at code mark", () => { 18 | const state = InlineState` 19 | ~~~ 20 | code 21 | ` 22 | 23 | expect(FenceCompletionParser.parseOpeningFenceAt(state, 2)).toBeUndefined() 24 | }) 25 | 26 | it("returns undefined when position at closing fence code mark", () => { 27 | const state = InlineState` 28 | ~~~ 29 | ~~~ 30 | ` 31 | 32 | expect(FenceCompletionParser.parseOpeningFenceAt(state, 7)).toBeUndefined() 33 | }) 34 | 35 | it("returns opening fence with code info", () => { 36 | const state = InlineState` 37 | ~~~~lang 38 | # H1 39 | ` 40 | 41 | expect(FenceCompletionParser.parseOpeningFenceAt(state, 10)).toStrictEqual({ 42 | indent: " ", 43 | codeMark: "~~~~", 44 | codeInfoPrefix: "lang", 45 | }) 46 | }) 47 | 48 | it("returns opening fence without code info", () => { 49 | const state = InlineState` 50 | ~~~ 51 | ` 52 | 53 | expect(FenceCompletionParser.parseOpeningFenceAt(state, 3)).toStrictEqual({ 54 | indent: "", 55 | codeMark: "~~~", 56 | codeInfoPrefix: "", 57 | }) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/ext/stdlib/Require.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Require } from "@ext/stdlib/Require" 4 | 5 | describe("Require", () => { 6 | describe("integer", () => { 7 | it("allows integers", () => { 8 | expect(() => Require.integer(0)).not.toThrowError() 9 | }) 10 | 11 | it("throws Error if non-integer", () => { 12 | expect(() => Require.integer(0.5)).toThrowError() 13 | }) 14 | }) 15 | 16 | describe("nonNegativeInteger", () => { 17 | it("allows 0", () => { 18 | expect(() => Require.nonNegativeInteger(0)).not.toThrowError() 19 | }) 20 | 21 | it("allows positive integers", () => { 22 | expect(() => Require.nonNegativeInteger(1)).not.toThrowError() 23 | }) 24 | 25 | it("throws Error if non-integer", () => { 26 | expect(() => Require.nonNegativeInteger(0.5)).toThrowError() 27 | }) 28 | 29 | it("throws Error if negative integer", () => { 30 | expect(() => Require.nonNegativeInteger(-1)).toThrowError() 31 | }) 32 | }) 33 | 34 | describe("validRange", () => { 35 | it("allows valid range", () => { 36 | expect(() => Require.validRange({ from: 0, to: 0 })).not.toThrowError() 37 | }) 38 | 39 | it("throws Error if invalid range", () => { 40 | expect(() => Require.validRange({ from: 5, to: 0 })).toThrowError() 41 | }) 42 | }) 43 | 44 | describe("hasSingleElement", () => { 45 | it("allows array with single element", () => { 46 | expect(() => Require.hasSingleElement(["first"])).not.toThrowError() 47 | }) 48 | 49 | it("throws Error if zero elements", () => { 50 | expect(() => Require.hasSingleElement([])).toThrowError() 51 | }) 52 | 53 | it("throws Error if more than 1 element", () => { 54 | expect(() => Require.hasSingleElement(["first", "second"])).toThrowError() 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /test/cm-extension/cm6/model/CodeDocs.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 4 | import { CodeDocs } from "@cm-extension/cm6/model/CodeDocs" 5 | 6 | import { InlineText } from "test-support/ext/codemirror/cm6/InlineText" 7 | 8 | describe("CodeDocs", () => { 9 | describe("getCode", () => { 10 | it("returns code when fences are included", () => { 11 | const doc = InlineText` 12 | ~~~lang 13 | code 14 | ~~~ 15 | ` 16 | const codeBlock = CodeBlock.of({ 17 | openingFence: { from: 0, to: 7, number: 1, text: "~~~lang" }, 18 | closingFence: { from: 13, to: 16, number: 3, text: "~~~" }, 19 | lang: "lang", 20 | }) 21 | 22 | expect(CodeDocs.getCode(doc, codeBlock, { includeFences: true })).toBe("~~~lang\ncode\n~~~") 23 | }) 24 | 25 | it("returns empty string when fences aren't included and no code", () => { 26 | const doc = InlineText` 27 | ~~~lang 28 | ~~~ 29 | ` 30 | const codeBlock = CodeBlock.of({ 31 | openingFence: { from: 0, to: 7, number: 1, text: "~~~lang" }, 32 | closingFence: { from: 8, to: 11, number: 2, text: "~~~" }, 33 | lang: "lang", 34 | }) 35 | 36 | expect(CodeDocs.getCode(doc, codeBlock, { includeFences: false })).toBe("") 37 | }) 38 | 39 | it("returns code when fences aren't included", () => { 40 | const doc = InlineText` 41 | ~~~lang 42 | code 43 | ~~~ 44 | ` 45 | const codeBlock = CodeBlock.of({ 46 | openingFence: { from: 0, to: 7, number: 1, text: "~~~lang" }, 47 | closingFence: { from: 13, to: 16, number: 3, text: "~~~" }, 48 | lang: "lang", 49 | }) 50 | 51 | expect(CodeDocs.getCode(doc, codeBlock, { includeFences: false })).toBe("code") 52 | }) 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/ext/codemirror/cm5/Events.test.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelectionChange, Range, SelectionRange } from "codemirror" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { Events } from "@ext/codemirror/cm5/Events" 5 | 6 | import { DocData } from "test-support/fixtures/cm5/DocData" 7 | 8 | describe("Events", () => { 9 | describe("isSelectAll", () => { 10 | it.each(notSelectAll)("returns false when $type", ({ ranges }) => { 11 | expect(Events.isSelectAll(DocData.Any.simple().doc, changeOf(ranges))).toBeFalse() 12 | }) 13 | 14 | it("return true when range is select all", () => { 15 | expect( 16 | Events.isSelectAll( 17 | DocData.Any.simple().doc, 18 | changeOf([{ anchor: { line: 0, ch: 0 }, head: { line: 4, ch: 3 } }]), 19 | ), 20 | ).toBeTrue() 21 | }) 22 | }) 23 | }) 24 | 25 | const notSelectAll = [ 26 | { 27 | type: "no ranges", 28 | ranges: [], 29 | }, 30 | { 31 | type: "more than 1 range", 32 | ranges: [ 33 | { anchor: { line: 0, ch: 1 }, head: { line: 2, ch: 3 } }, 34 | { anchor: { line: 4, ch: 5 }, head: { line: 6, ch: 7 } }, 35 | ], 36 | }, 37 | { 38 | type: "range after first line", 39 | ranges: [{ anchor: { line: 1, ch: 0 }, head: { line: 4, ch: 3 } }], 40 | }, 41 | { 42 | type: "range after first char", 43 | ranges: [{ anchor: { line: 0, ch: 1 }, head: { line: 4, ch: 3 } }], 44 | }, 45 | { 46 | type: "range before last line", 47 | ranges: [{ anchor: { line: 0, ch: 0 }, head: { line: 3, ch: 3 } }], 48 | }, 49 | { 50 | type: "range before last char", 51 | ranges: [{ anchor: { line: 0, ch: 0 }, head: { line: 4, ch: 2 } }], 52 | }, 53 | ] 54 | 55 | function changeOf(ranges: readonly SelectionRange[]): EditorSelectionChange { 56 | return { 57 | ranges: ranges as Range[], 58 | update: () => undefined, 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/joplin-plugin/handler/RequestHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, when } from "strong-mock" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { 5 | GetSettingsRequest, 6 | GetSettingsResponse, 7 | PingRequest, 8 | PingResponse, 9 | } from "@joplin-plugin-ipc/model/messages" 10 | 11 | import { RequestHandler } from "@joplin-plugin/handler/RequestHandler" 12 | import { PluginSettingsProvider } from "@joplin-plugin/settings/PluginSettingsProvider" 13 | 14 | import { Any } from "test-support/fixtures/cm5/Any" 15 | 16 | describe("RequestHandler", () => { 17 | const mockPluginSettingsProvider = mock() 18 | const pluginService: RequestHandler = RequestHandler.create({ 19 | pluginSettingsProvider: mockPluginSettingsProvider, 20 | }) 21 | 22 | describe("handle", () => { 23 | it("handles ping", async () => { 24 | await expect(pluginService.handle(PingRequest.of())).resolves.toStrictEqual(PingResponse.of()) 25 | }) 26 | 27 | it("handles getSettings", async () => { 28 | when(() => mockPluginSettingsProvider.provide()).thenResolve(Any.pluginSettings()) 29 | await expect(pluginService.handle(GetSettingsRequest.of())).resolves.toStrictEqual( 30 | GetSettingsResponse.of(Any.pluginSettings()), 31 | ) 32 | }) 33 | }) 34 | 35 | describe("ping", () => { 36 | it("returns ping response", async () => { 37 | await expect(pluginService.ping(PingRequest.of())).resolves.toStrictEqual(PingResponse.of()) 38 | }) 39 | }) 40 | 41 | describe("getSettings", () => { 42 | it("returns settings", async () => { 43 | when(() => mockPluginSettingsProvider.provide()).thenResolve(Any.pluginSettings()) 44 | 45 | await expect(pluginService.getSettings(GetSettingsRequest.of())).resolves.toStrictEqual( 46 | GetSettingsResponse.of(Any.pluginSettings()), 47 | ) 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /test-support/fakes/joplin/FakeCodeMirror5.ts: -------------------------------------------------------------------------------- 1 | import { CodeMirror5 } from "api/types" 2 | import CodeMirror, { Editor } from "codemirror" 3 | 4 | export type FakeCodeMirror5 = CodeMirror5 & Extensions 5 | export namespace FakeCodeMirror5 { 6 | export function create(): FakeCodeMirror5 { 7 | return new ExtendedCodeMirror5() as unknown as FakeCodeMirror5 8 | } 9 | } 10 | 11 | type PartialCodeMirror5 = Pick 12 | 13 | export interface Extensions { 14 | readonly ext: { 15 | /** 16 | * The extension sent in {@link CodeMirror#defineExtension} (if called). 17 | */ 18 | readonly extension: CodeMirror5Extension | undefined 19 | 20 | /** 21 | * The option sent in {@link CodeMirror#defineOption} (if called). 22 | */ 23 | readonly option: CodeMirror5Option | undefined 24 | } 25 | } 26 | 27 | /** 28 | * Represents the parameters of call to {@link CodeMirror#defineExtension}. 29 | */ 30 | export interface CodeMirror5Extension { 31 | name: string 32 | value: unknown 33 | } 34 | 35 | /** 36 | * Represents the parameters of a call to {@link CodeMirror#defineOption}. 37 | */ 38 | export interface CodeMirror5Option { 39 | name: string 40 | default_: unknown 41 | updateFunc: (editor: Editor, val: unknown, old: unknown) => void 42 | } 43 | 44 | // noinspection JSUnusedGlobalSymbols 45 | class ExtendedCodeMirror5 implements PartialCodeMirror5, Extensions { 46 | // noinspection JSUnusedGlobalSymbols 47 | readonly ext = new (class { 48 | extension: CodeMirror5Extension | undefined 49 | option: CodeMirror5Option | undefined 50 | })() 51 | 52 | defineExtension(name: string, value: unknown): void { 53 | this.ext.extension = { name, value } 54 | } 55 | 56 | defineOption: typeof CodeMirror.defineOption = (name, default_: unknown, updateFunc) => { 57 | this.ext.option = { name, default_, updateFunc } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/ext/stdlib/Iterables.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Iterables } from "@ext/stdlib/Iterables" 4 | 5 | describe("Iterables", () => { 6 | describe("range", () => { 7 | it("throws Error if start is not an integer", () => { 8 | expect(() => Iterables.range({ start: 0.5, endExclusive: 2 })).toThrowError() 9 | }) 10 | 11 | it("throws Error if endExclusive is not an integer", () => { 12 | expect(() => Iterables.range({ start: 0, endExclusive: 2.5 })).toThrowError() 13 | }) 14 | 15 | it("throws Error if endExclusive is before start", () => { 16 | expect(() => Iterables.range({ start: 1, endExclusive: 0 })).toThrowError() 17 | }) 18 | 19 | it("returns a new Iterable", () => { 20 | const firstIterable = Iterables.range({ start: 0, endExclusive: 0 }) 21 | const secondIterable = Iterables.range({ start: 0, endExclusive: 0 }) 22 | expect(firstIterable).not.toBe(secondIterable) 23 | }) 24 | 25 | it("returns an Iterable over an empty range", () => { 26 | expect([...Iterables.range({ start: 2, endExclusive: 2 })]).toStrictEqual([]) 27 | }) 28 | 29 | it("returns an Iterable over a range with a single value", () => { 30 | expect([...Iterables.range({ start: 4, endExclusive: 5 })]).toStrictEqual([4]) 31 | }) 32 | 33 | it("returns an Iterable over a range", () => { 34 | expect([...Iterables.range({ start: -2, endExclusive: 3 })]).toStrictEqual([-2, -1, 0, 1, 2]) 35 | }) 36 | }) 37 | 38 | describe("empty", () => { 39 | it("returns a new Iterable", () => { 40 | const firstIterable = Iterables.empty() 41 | const secondIterable = Iterables.empty() 42 | expect(firstIterable).not.toBe(secondIterable) 43 | }) 44 | 45 | it("returns an Iterable over an empty range", () => { 46 | expect([...Iterables.empty()]).toStrictEqual([]) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased](https://github.com/ckant/joplin-plugin-better-code-blocks/compare/v2.0.1...HEAD) 9 | 10 | ## [2.0.1](https://github.com/ckant/joplin-plugin-better-code-blocks/compare/v2.0.0...v2.0.1) - 2025-03-21 11 | 12 | ### Fixed 13 | 14 | - Issue running with legacy editor due to missing CodeMirror 6 dependencies 15 | 16 | ## [2.0.0](https://github.com/ckant/joplin-plugin-better-code-blocks/compare/v1.1.0...v2.0.0) - 2024-11-25 17 | 18 | ### Added 19 | 20 | - Support for CodeMirror 6 21 | - Native autocomplete for given languages 22 | - Cursor placement before and after code blocks to simplify adding /removing line breaks 23 | - Some tricks to prevent accidental deletion of code blocks 24 | - Branching logic to choose between CodeMirror 5 and 6 25 | 26 | ### Fixed 27 | 28 | - CodeMirror 5 implementation leniency with 1-3 tab characters at the start of code fences 29 | 30 | ### Changed 31 | 32 | - Update dependencies 33 | 34 | ## [1.1.0](https://github.com/ckant/joplin-plugin-better-code-blocks/compare/v1.0.0...v1.1.0) - 2023-09-10 35 | 36 | ### Added 37 | 38 | - Continuous integration and publishing workflows for GitHub 39 | - This CHANGELOG.md 40 | 41 | ### Fixed 42 | 43 | - Re-render during initialization 44 | - Unintentional instance of coupling from `@ext` to `@cm-extension` 45 | 46 | ### Changed 47 | 48 | - Style injection to happen before first render 49 | - Badges, logo, and formatting in README.md 50 | - Lint script to run all linters, rather than just ESLint 51 | 52 | ## [1.0.0](https://github.com/ckant/joplin-plugin-better-code-blocks/releases/tag/v1.0.0) - 2023-09-08 53 | 54 | ### Added 55 | 56 | - Initial release -------------------------------------------------------------------------------- /test/cm-extension/cm6/state/CodeBlocksStateField.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { CodeBlock } from "@cm-extension/cm6/model/CodeBlock" 4 | import { CodeBlocksStateField } from "@cm-extension/cm6/state/CodeBlocksStateField" 5 | 6 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 7 | 8 | describe("CodeBlocksStateField", () => { 9 | describe("create", () => { 10 | it("returns code blocks in doc", () => { 11 | const state = InlineState` 12 | ~~~ 13 | ~~~ 14 | ` 15 | 16 | expect(CodeBlocksStateField.create(state)).toStrictEqual([ 17 | CodeBlock.of({ 18 | openingFence: { from: 0, to: 3, number: 1, text: "~~~" }, 19 | closingFence: { from: 4, to: 7, number: 2, text: "~~~" }, 20 | lang: undefined, 21 | }), 22 | ]) 23 | }) 24 | }) 25 | 26 | describe("update", () => { 27 | it("returns original code blocks when doc not updated", () => { 28 | const state = InlineState` 29 | ~~~ 30 | ~~~ 31 | ` 32 | 33 | const codeBlock = CodeBlock.of({ 34 | openingFence: { from: 0, to: 3, number: 1, text: "~~~" }, 35 | closingFence: { from: 4, to: 7, number: 2, text: "~~~" }, 36 | lang: undefined, 37 | }) 38 | 39 | expect(CodeBlocksStateField.update([codeBlock], state.update({}))).toStrictEqual([codeBlock]) 40 | }) 41 | 42 | it("returns code blocks in updated doc", () => { 43 | const state = InlineState`` 44 | 45 | expect( 46 | CodeBlocksStateField.update([], state.update({ changes: { from: 0, insert: "```\n```" } })), 47 | ).toStrictEqual([ 48 | CodeBlock.of({ 49 | openingFence: { from: 0, to: 3, number: 1, text: "```" }, 50 | closingFence: { from: 4, to: 7, number: 2, text: "```" }, 51 | lang: undefined, 52 | }), 53 | ]) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main entry-point for the Joplin plugin. 3 | * 4 | * Joplin imports this file to register the plugin (i.e. importing this file has the side effect 5 | * of registering the plugin). 6 | */ 7 | 8 | import joplin from "api" 9 | 10 | import { JoplinCommands } from "@ext/joplin/JoplinCommands" 11 | 12 | import { CmExtensionClient } from "@cm-extension-ipc/CmExtensionClient" 13 | import { CmExtensionRequestHandler } from "@cm-extension-ipc/model/handler" 14 | 15 | import { RequestHandler } from "@joplin-plugin/handler/RequestHandler" 16 | import { JoplinPlugin } from "@joplin-plugin/JoplinPlugin" 17 | import { PluginSettings } from "@joplin-plugin/settings/PluginSettings" 18 | import { PluginSettingSection } from "@joplin-plugin/settings/PluginSettingSection" 19 | import { PluginSettingsProvider } from "@joplin-plugin/settings/PluginSettingsProvider" 20 | 21 | /** 22 | * Calls the Code Mirror extension with inter-process communication triggered by `editor.execCommand`. 23 | * 24 | * @see callCodeMirrorExtension 25 | */ 26 | const requestHandler: CmExtensionRequestHandler = ((arg) => 27 | JoplinCommands.callCodeMirrorExtension( 28 | joplin.commands, 29 | JoplinPlugin.pluginName, 30 | arg, 31 | )) as CmExtensionRequestHandler 32 | 33 | function registerPlugin(): void { 34 | void JoplinPlugin.register({ 35 | cmExtensionClient: CmExtensionClient.create({ call: requestHandler }), 36 | contentScript: { 37 | id: JoplinPlugin.pluginName, 38 | path: "./contentScriptDefinition.js", 39 | }, 40 | joplin: { 41 | contentScripts: joplin.contentScripts, 42 | plugins: joplin.plugins, 43 | settings: joplin.settings, 44 | }, 45 | requestHandler: RequestHandler.create({ 46 | pluginSettingsProvider: PluginSettingsProvider.create({ joplinSettings: joplin.settings }), 47 | }), 48 | settingSection: PluginSettingSection, 49 | }) 50 | } 51 | 52 | registerPlugin() 53 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm5/LineSegment.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "codemirror" 2 | 3 | import { Require } from "@ext/stdlib/Require" 4 | 5 | export interface LineSegmentProps { 6 | readonly line: number 7 | readonly from: number 8 | readonly to: number 9 | } 10 | 11 | /** 12 | * Represents a contiguous part of a given {@link line} [{@link from},{@link to}]. 13 | * 14 | * The {@link line}, {@link from}, and {@link to} must be non-negative integers. 15 | * Additionally, {@link from} must be less than or equal to {@link to}. 16 | */ 17 | export class LineSegment { 18 | readonly line: number 19 | readonly from: number 20 | readonly to: number 21 | 22 | static of(props: LineSegmentProps): LineSegment { 23 | return new LineSegment(props) 24 | } 25 | 26 | private constructor({ line, from, to }: LineSegmentProps) { 27 | Require.nonNegativeInteger(line) 28 | Require.nonNegativeInteger(from) 29 | Require.nonNegativeInteger(to) 30 | Require.validRange({ from, to }) 31 | 32 | this.line = line 33 | this.from = from 34 | this.to = to 35 | } 36 | 37 | /** 38 | * Returns the start of the line segment represented as a {@link Position}. 39 | */ 40 | get fromPosition(): Position { 41 | return { line: this.line, ch: this.from } 42 | } 43 | 44 | /** 45 | * Returns the end of the line segment represented as a {@link Position}. 46 | */ 47 | get toPosition(): Position { 48 | return { line: this.line, ch: this.to } 49 | } 50 | 51 | /** 52 | * Returns a new {@link LineSegment} shifted down by the given {@link addedLines}. 53 | */ 54 | plusLines(addedLines: number): LineSegment { 55 | Require.integer(addedLines) 56 | 57 | return LineSegment.of({ line: this.line + addedLines, from: this.from, to: this.to }) 58 | } 59 | 60 | /** 61 | * Returns true if `this` deeply equals {@link other}. 62 | */ 63 | equals(other: LineSegment): boolean { 64 | return this.line === other.line && this.from === other.from && this.to === other.to 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test-support/fakes/codemirror/cm6/view/FakeViewUpdate.ts: -------------------------------------------------------------------------------- 1 | import { ViewUpdate } from "@codemirror/view" 2 | 3 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 4 | import { FakeEditorView } from "test-support/fakes/codemirror/cm6/view/FakeEditorView" 5 | 6 | export interface FakeViewUpdateProps { 7 | /** 8 | * Whether the doc changed in the {@link ViewUpdate}. 9 | */ 10 | readonly docChanged?: ViewUpdate["docChanged"] 11 | 12 | /** 13 | * Whether the viewport changed in the {@link ViewUpdate}. 14 | */ 15 | readonly viewportChanged?: ViewUpdate["viewportChanged"] 16 | 17 | /** 18 | * The `EditorView` to set in the {@link ViewUpdate}. 19 | */ 20 | readonly view?: ViewUpdate["view"] 21 | 22 | /** 23 | * The `EditorState` to set in the {@link ViewUpdate}. 24 | */ 25 | readonly state?: ViewUpdate["state"] 26 | } 27 | 28 | export type FakeViewUpdate = ViewUpdate & Extensions 29 | export namespace FakeViewUpdate { 30 | export function create(props?: FakeViewUpdateProps): FakeViewUpdate { 31 | return new ExtendedViewUpdate(props) as unknown as FakeViewUpdate 32 | } 33 | } 34 | 35 | type PartialViewUpdate = Pick 36 | 37 | export interface Extensions { 38 | readonly ext: object 39 | } 40 | 41 | // noinspection JSUnusedGlobalSymbols 42 | class ExtendedViewUpdate implements PartialViewUpdate, Extensions { 43 | // noinspection JSUnusedGlobalSymbols 44 | readonly ext = new (class {})() 45 | 46 | readonly docChanged: ViewUpdate["docChanged"] 47 | readonly state: ViewUpdate["state"] 48 | readonly view: ViewUpdate["view"] 49 | readonly viewportChanged: ViewUpdate["viewportChanged"] 50 | 51 | constructor(props?: FakeViewUpdateProps) { 52 | this.docChanged = props?.docChanged ?? false 53 | this.viewportChanged = props?.viewportChanged ?? false 54 | this.state = props?.state ?? InlineState`^doc` 55 | this.view = props?.view ?? FakeEditorView.create({ state: this.state }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/renderer/Renderer.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 4 | import { CodeBlocks } from "@cm-extension/cm5/model/CodeBlocks" 5 | import { Origin } from "@cm-extension/cm5/model/Origin" 6 | import { RenderParser } from "@cm-extension/cm5/renderer/RenderParser" 7 | import { RenderPerformer } from "@cm-extension/cm5/renderer/RenderPerformer" 8 | 9 | export interface RendererProps { 10 | readonly renderParser: RenderParser 11 | readonly renderPerformer: RenderPerformer 12 | } 13 | 14 | /** 15 | * Renders code fences found by the {@link renderParser} using the given {@link renderPerformer}. 16 | */ 17 | export class Renderer { 18 | private readonly renderParser: RenderParser 19 | private readonly renderPerformer: RenderPerformer 20 | 21 | private codeBlocks: readonly CodeBlock[] = [] 22 | 23 | static create(props: RendererProps): Renderer { 24 | return new Renderer(props) 25 | } 26 | 27 | private constructor(props: RendererProps) { 28 | this.renderParser = props.renderParser 29 | this.renderPerformer = props.renderPerformer 30 | } 31 | 32 | /** 33 | * Renders all code fences in the {@link doc}. 34 | * The operation executes using the given {@link origin}. 35 | * 36 | * As an optimization, code fences are only re-rendered if any have changed position. 37 | * 38 | * e.g. given the following code fence: 39 | * 40 | *
41 |    * ~~~typescript
42 |    * // some code
43 |    * ~~~
44 |    * 
45 | * 46 | * re-rendering is unnecessary when something gets typed below: 47 | * 48 | *
49 |    * ~~~typescript
50 |    * // some code
51 |    * ~~~
52 |    * # Something added below
53 |    * 
54 | * 55 | */ 56 | render(doc: Doc, origin: Origin): void { 57 | const codeBlocks = this.renderParser.parse(doc) 58 | if (CodeBlocks.areEqual(this.codeBlocks, codeBlocks)) return 59 | 60 | this.codeBlocks = this.renderPerformer.perform(doc, codeBlocks, origin) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test-support/fakes/dom/FakeWindow.ts: -------------------------------------------------------------------------------- 1 | import { Guards } from "@ts-belt" 2 | 3 | export interface FakeWindowProps { 4 | /** 5 | * Pauses calls to {@link window#setTimeout} to allow for manual testing (defaults to false). 6 | */ 7 | readonly pauseTimeouts?: boolean 8 | } 9 | 10 | export type FakeWindow = Window & Extensions 11 | export namespace FakeWindow { 12 | export function create(props?: FakeWindowProps): FakeWindow { 13 | return new ExtendedWindow(props) as unknown as FakeWindow 14 | } 15 | } 16 | 17 | type PartialWindow = Pick 18 | 19 | export interface Extensions { 20 | readonly ext: { 21 | /** 22 | * Represents calls made to {@link window#setTimeout}. 23 | */ 24 | readonly setTimeouts: readonly SetTimeout[] 25 | } 26 | } 27 | 28 | /** 29 | * Represents a call to {@link window#setTimeout}. 30 | */ 31 | export interface SetTimeout { 32 | /** 33 | * The timeout sent to {@link window#setTimeout} (if provided). 34 | */ 35 | readonly timeout: number | undefined 36 | 37 | /** 38 | * Runs the callback sent to {@link window#setTimeout}. 39 | */ 40 | run(): unknown 41 | } 42 | 43 | // noinspection JSUnusedGlobalSymbols 44 | class ExtendedWindow implements PartialWindow, Extensions { 45 | private readonly pauseTimeouts: boolean 46 | 47 | // noinspection JSUnusedGlobalSymbols 48 | readonly ext = new (class { 49 | readonly setTimeouts: SetTimeout[] = [] 50 | })() 51 | 52 | constructor(props?: FakeWindowProps) { 53 | this.pauseTimeouts = props?.pauseTimeouts ?? false 54 | } 55 | 56 | setTimeout(handler: TimerHandler, timeout?: number): number { 57 | if (Guards.isString(handler)) { 58 | throw new TypeError("string handler is unsupported") 59 | } 60 | 61 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- setTimeout uses Function 62 | const run: () => unknown = () => handler() 63 | this.ext.setTimeouts.push({ run, timeout }) 64 | if (!this.pauseTimeouts) run() 65 | 66 | return 0 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/handler/CompleteHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, when } from "strong-mock" 2 | import { beforeEach, describe, expect, it } from "vitest" 3 | 4 | import { Completer } from "@cm-extension/cm5/completer/Completer" 5 | import { CompleteHandler } from "@cm-extension/cm5/handler/CompleteHandler" 6 | import { Origin } from "@cm-extension/cm5/model/Origin" 7 | 8 | import { FakeEditor } from "test-support/fakes/codemirror/cm5/FakeEditor" 9 | import { FakeKeyboardEvent } from "test-support/fakes/dom/FakeKeyboardEvent" 10 | 11 | describe("CompleteHandler", () => { 12 | describe("completeOnEnter", () => { 13 | let fakeEditor: FakeEditor 14 | const mockCompleter = mock() 15 | let completeHandler: CompleteHandler 16 | 17 | beforeEach(() => { 18 | fakeEditor = FakeEditor.create() 19 | completeHandler = CompleteHandler.create({ completer: mockCompleter }) 20 | }) 21 | 22 | it("completes on enter and completion", () => { 23 | const fakeKeyboardEvent = FakeKeyboardEvent.create({ code: "Enter" }) 24 | when(() => mockCompleter.complete(fakeEditor.getDoc(), Origin.CompleteHandler)).thenReturn( 25 | true, 26 | ) 27 | 28 | completeHandler.completeOnEnter(fakeEditor, fakeKeyboardEvent) 29 | 30 | expect(fakeEditor.ext.operation).toBeDefined() 31 | expect(fakeKeyboardEvent.ext.defaultPrevented).toBeTrue() 32 | }) 33 | 34 | it("doesn't complete when not enter", () => { 35 | completeHandler.completeOnEnter(fakeEditor, FakeKeyboardEvent.create({ code: "Space" })) 36 | }) 37 | 38 | it("doesn't prevent defaults when no completion", () => { 39 | const fakeKeyboardEvent = FakeKeyboardEvent.create({ code: "Enter" }) 40 | 41 | when(() => mockCompleter.complete(fakeEditor.getDoc(), Origin.CompleteHandler)).thenReturn( 42 | false, 43 | ) 44 | 45 | completeHandler.completeOnEnter(fakeEditor, fakeKeyboardEvent) 46 | 47 | expect(fakeKeyboardEvent.ext.defaultPrevented).toBeFalse() 48 | }) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/formatter/BetweenSpacer.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { Docs } from "@ext/codemirror/cm5/Docs" 4 | import { def } from "@ext/stdlib/existence" 5 | 6 | import { Formatter } from "@cm-extension/cm5/formatter/Formatter" 7 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 8 | import { Origin } from "@cm-extension/cm5/model/Origin" 9 | 10 | /** 11 | * Inserts new lines between adjacent {@link CodeBlock}s. 12 | * 13 | * This is necessary for cursor placement between rendered {@link CodeBlock}s, as the user can't 14 | * place the cursor on the start (or end) line to insert content between "unspaced" code fences. 15 | * 16 | * e.g. the following adjacent code fences: 17 | *
18 |  * ~~~one
19 |  * ~~~
20 |  * ~~~two
21 |  * ~~~
22 |  * 
23 | * 24 | * are "spaced", allowing for the user to place the cursor between them: 25 | * 26 | *
27 |  * ~~~one
28 |  * ~~~
29 |  *
30 |  * ~~~two
31 |  * ~~~
32 |  * 
33 | */ 34 | export class BetweenSpacer implements Formatter { 35 | static create(): BetweenSpacer { 36 | return new BetweenSpacer() 37 | } 38 | 39 | private constructor() { 40 | // empty 41 | } 42 | 43 | /** 44 | * Spaces the adjacent {@link codeBlocks} within the {@link doc}. 45 | * The operation executes using the given {@link origin}. 46 | */ 47 | format(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] { 48 | return this.space(doc, codeBlocks, origin) 49 | } 50 | 51 | private space(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] { 52 | let lastEndLine: number | undefined 53 | let newLines = 0 54 | return codeBlocks.map((codeBlock) => { 55 | if (def(lastEndLine) && codeBlock.startsAt(lastEndLine + 1)) { 56 | Docs.insertNewLine(doc, { line: lastEndLine + 1 + newLines, ch: 0 }, origin) 57 | newLines++ 58 | } 59 | 60 | lastEndLine = codeBlock.endLine 61 | return codeBlock.shiftDownBy(newLines) 62 | }) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/marker/line-styler/LineStyler.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { Docs } from "@ext/codemirror/cm5/Docs" 4 | 5 | import { LineStyle } from "@cm-extension/cm5/marker/line-styler/LineStyle" 6 | import { LineStyleGenerator } from "@cm-extension/cm5/marker/line-styler/LineStyleGenerator" 7 | import { Marker } from "@cm-extension/cm5/marker/Marker" 8 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 9 | 10 | /** 11 | * A {@link LineStyle} that's later removable. 12 | */ 13 | interface RemovableLineStyle extends LineStyle { 14 | remove(): void 15 | } 16 | 17 | export interface LineStylerProps { 18 | readonly lineStyleGenerator: LineStyleGenerator 19 | } 20 | 21 | /** 22 | * Applies line classes generated by {@link lineStyleGenerator}. 23 | */ 24 | export class LineStyler implements Marker { 25 | private readonly lineStyleGenerator: LineStyleGenerator 26 | private lineStyles: readonly RemovableLineStyle[] = [] 27 | 28 | static create(props: LineStylerProps): LineStyler { 29 | return new LineStyler(props) 30 | } 31 | 32 | private constructor(props: LineStylerProps) { 33 | this.lineStyleGenerator = props.lineStyleGenerator 34 | } 35 | 36 | /** 37 | * Adds line classes for lines within the given {@link codeBlocks} in the {@link doc}. 38 | * 39 | * Removes all previous line classes before adding new ones. 40 | */ 41 | mark(doc: Doc, codeBlocks: readonly CodeBlock[]): void { 42 | this.updateLineStyles(doc, codeBlocks) 43 | } 44 | 45 | private updateLineStyles(doc: Doc, codeBlocks: readonly CodeBlock[]): void { 46 | const generatedLineStyles = this.lineStyleGenerator.generate(codeBlocks) 47 | 48 | this.lineStyles.forEach((it) => it.remove()) 49 | 50 | this.lineStyles = generatedLineStyles.map((lineStyle) => { 51 | const handle = Docs.addLineClasses(doc, lineStyle.line, lineStyle) 52 | 53 | return { 54 | ...lineStyle, 55 | remove: () => Docs.removeLineClasses(doc, handle, lineStyle), 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/formatter/Opener.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { Docs } from "@ext/codemirror/cm5/Docs" 4 | 5 | import { Formatter } from "@cm-extension/cm5/formatter/Formatter" 6 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 7 | import { Origin } from "@cm-extension/cm5/model/Origin" 8 | 9 | /** 10 | * Inserts a new line into empty {@link CodeBlock}s. 11 | * 12 | * This is necessary for cursor placement inside rendered {@link CodeBlock}s, 13 | * as the user can't move the cursor to the start (or end) line to "open" the code fence manually. 14 | * 15 | * e.g. the following "closed" code fence: 16 | * 17 | *
18 |  * ~~~typescript
19 |  * ~~~
20 |  * 
21 | * 22 | * is "opened", allowing the user to move the cursor inside: 23 | * 24 | *
25 |  * ~~~typescript
26 |  *
27 |  * ~~~
28 |  * 
29 | * 30 | */ 31 | export class Opener implements Formatter { 32 | static create(): Opener { 33 | return new Opener() 34 | } 35 | 36 | private constructor() { 37 | // empty 38 | } 39 | 40 | /** 41 | * Opens the closed {@link codeBlocks} within the {@link doc}. 42 | * The operation executes using the given {@link origin}. 43 | */ 44 | format(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] { 45 | return this.open(doc, codeBlocks, origin) 46 | } 47 | 48 | private open(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] { 49 | let newLines = 0 50 | return codeBlocks.map((codeBlock) => { 51 | if (!codeBlock.isEmpty()) return codeBlock.shiftDownBy(newLines) 52 | 53 | Docs.insertNewLine(doc, { line: codeBlock.endLine + newLines, ch: 0 }, origin) 54 | newLines++ 55 | 56 | return CodeBlock.of({ 57 | start: codeBlock.start.plusLines(newLines - 1), 58 | end: codeBlock.end.plusLines(newLines), 59 | lang: codeBlock.lang, 60 | openingFence: codeBlock.openingFence, 61 | closingFence: codeBlock.closingFence, 62 | }) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/theme/Theme.ts: -------------------------------------------------------------------------------- 1 | import { ThemeSpec } from "@codemirror/view" 2 | 3 | import { MinimalMixin } from "@cm-extension/cm6/theme/MinimalMixin" 4 | import { StandardMixin } from "@cm-extension/cm6/theme/StandardMixin" 5 | 6 | /** 7 | * Base theme that defines styles for code fences. 8 | * 9 | * @see EditorView.baseTheme 10 | */ 11 | export const Theme: ThemeSpec = { 12 | ...MinimalMixin, 13 | ...StandardMixin, 14 | 15 | // All lines of the code block 16 | "&.cm-editor .cm-codeBlock": { 17 | "border-width": 0, 18 | "padding-right": 0, 19 | }, 20 | 21 | // Opening and closing fence widgets 22 | "& .cb-start-widget, & .cb-end-widget": { 23 | display: "inline-flex", 24 | "align-items": "center", 25 | "justify-content": "space-between", 26 | width: "100%", 27 | }, 28 | 29 | // Opening and closing fence content 30 | "& .cb-opening-fence, & .cb-closing-fence": { 31 | color: "var(--joplin-color-faded)", 32 | "-webkitFontSmoothing": "antialiased", 33 | "user-select": "none", 34 | }, 35 | 36 | // Button inside start widget that copies the code 37 | "& .cb-copy-btn": { 38 | cursor: "pointer", 39 | background: "none", 40 | border: "none", 41 | opacity: 0, 42 | transition: "opacity 0.5s ease-in-out 0s", 43 | }, 44 | 45 | "& .cb-copy-btn:hover": { 46 | opacity: 1, 47 | }, 48 | 49 | // Copy button click transition state 50 | "& .cb-copy-btn.cb-copied": { 51 | opacity: 1, 52 | }, 53 | 54 | // Font Awesome icon inside the copy button 55 | "& .cb-copy-btn .fa-solid": { 56 | "font-family": '"Font Awesome 5 Free"', 57 | "font-size": "2.5ch", 58 | "font-style": "normal", 59 | "font-weight": 900, 60 | "line-height": 1, 61 | color: "var(--joplin-color-faded)", 62 | "-webkitFontSmoothing": "antialiased", 63 | }, 64 | 65 | // Text inside closing fence widget that shows the language 66 | "& .cb-lang": { 67 | color: "var(--joplin-color-faded)", 68 | "-webkitFontSmoothing": "antialiased", 69 | "user-select": "none", 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/renderer/RenderPerformer.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { Formatter } from "@cm-extension/cm5/formatter/Formatter" 4 | import { Marker } from "@cm-extension/cm5/marker/Marker" 5 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 6 | import { CodeDocs } from "@cm-extension/cm5/model/CodeDocs" 7 | import { Origin } from "@cm-extension/cm5/model/Origin" 8 | 9 | export interface RenderPerformerProps { 10 | readonly formatter: Formatter 11 | readonly marker: Marker 12 | } 13 | 14 | /** 15 | * Renders code fences, formatted by the {@link formatter} and marked by the {@link marker}. 16 | */ 17 | export class RenderPerformer { 18 | private readonly formatter: Formatter 19 | private readonly marker: Marker 20 | 21 | static create(props: RenderPerformerProps): RenderPerformer { 22 | return new RenderPerformer(props) 23 | } 24 | 25 | private constructor(props: RenderPerformerProps) { 26 | this.formatter = props.formatter 27 | this.marker = props.marker 28 | } 29 | 30 | /** 31 | * Renders {@link codeBlocks} within {@link doc} and returns the resulting {@link CodeBlock}s. 32 | * The operation executes using the given {@link origin}. 33 | * 34 | * The {@link codeBlocks} are (possibly) modified during the rendering (e.g. by adding new lines). 35 | * If the cursor lies within a code fence after formatting (a code fence is "active"), 36 | * the cursor moves inside that code fence's code for easier editing. 37 | */ 38 | perform(doc: Doc, codeBlocks: readonly CodeBlock[], origin: Origin): readonly CodeBlock[] { 39 | return this.renderCodeBlocks(doc, codeBlocks, origin) 40 | } 41 | 42 | private renderCodeBlocks( 43 | doc: Doc, 44 | codeBlocks: readonly CodeBlock[], 45 | origin: Origin, 46 | ): readonly CodeBlock[] { 47 | const formattedCodeBlocks = this.formatter.format(doc, codeBlocks, origin) 48 | CodeDocs.clampCursorOfActiveCodeBlockToCode(doc, formattedCodeBlocks, origin) 49 | this.marker.mark(doc, formattedCodeBlocks) 50 | return formattedCodeBlocks 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test-support/ext/codemirror/cm6/InlineState.ts: -------------------------------------------------------------------------------- 1 | import { EditorSelection, EditorState, Text } from "@codemirror/state" 2 | 3 | import { nil } from "@ext/stdlib/existence" 4 | 5 | import { Any } from "test-support/fixtures/cm6/Any" 6 | 7 | export function InlineState(arr: TemplateStringsArray): EditorState { 8 | const text = arr.join("").split("\n") 9 | const lines = trimMinIndent(removeSurroundingNewlines(text)) 10 | 11 | return stateWithSelection(lines) 12 | } 13 | 14 | function removeSurroundingNewlines(lines: readonly string[]): readonly string[] { 15 | if (lines.length <= 1) return lines 16 | 17 | return lines.slice(1, lines.length - 1) 18 | } 19 | 20 | function trimMinIndent(lines: readonly string[]): readonly string[] { 21 | const minIndent = Math.min(...lines.map((it) => it.length - it.trimStart().length)) 22 | return lines.map((it) => it.slice(minIndent)) 23 | } 24 | 25 | function stateWithSelection(lines: readonly string[]): EditorState { 26 | const removed = removeSelection(lines) 27 | if (nil(removed)) return Any.stateWith({ doc: Text.of(lines) }) 28 | 29 | const { replacedLines, selection } = removed 30 | 31 | return Any.stateWith({ 32 | doc: Text.of(replacedLines), 33 | selection: 34 | "pos" in selection 35 | ? EditorSelection.create([EditorSelection.cursor(selection.pos)]) 36 | : EditorSelection.create([EditorSelection.range(selection.anchor, selection.head)]), 37 | }) 38 | } 39 | 40 | function removeSelection(lines: readonly string[]): 41 | | { 42 | replacedLines: readonly string[] 43 | selection: { pos: number } | { anchor: number; head: number } 44 | } 45 | | undefined { 46 | const text = lines.join("\n") 47 | const startIndex = text.indexOf("^") 48 | 49 | if (startIndex === -1) return undefined 50 | 51 | const endIndex = text.indexOf("$") 52 | const replacedText = text.replace("^", "").replace("$", "") 53 | 54 | return { 55 | replacedLines: replacedText.split("\n"), 56 | selection: endIndex === -1 ? { pos: startIndex } : { anchor: startIndex, head: endIndex - 1 }, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/renderer/Renderer.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, when } from "strong-mock" 2 | import { describe, it } from "vitest" 3 | 4 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 5 | import { Origin } from "@cm-extension/cm5/model/Origin" 6 | import { Renderer } from "@cm-extension/cm5/renderer/Renderer" 7 | import { RenderParser } from "@cm-extension/cm5/renderer/RenderParser" 8 | import { RenderPerformer } from "@cm-extension/cm5/renderer/RenderPerformer" 9 | 10 | import { Any } from "test-support/fixtures/cm5/Any" 11 | import { DocData } from "test-support/fixtures/cm5/DocData" 12 | 13 | describe("Renderer", () => { 14 | describe("render", () => { 15 | it("renders", () => { 16 | const { doc, codeBlock } = DocData.Any.simple() 17 | const mockRenderPerformer = mock() 18 | const mockRenderedCodeBlocks = mock() 19 | when(() => mockRenderPerformer.perform(doc, [codeBlock], Origin.RenderHandler)).thenReturn( 20 | mockRenderedCodeBlocks, 21 | ) 22 | 23 | Renderer.create({ 24 | renderParser: RenderParser.create({ 25 | config: Any.configWith({ excludedLanguages: [] }), 26 | parser: Any.parser(), 27 | }), 28 | renderPerformer: mockRenderPerformer, 29 | }).render(doc, Origin.RenderHandler) 30 | }) 31 | 32 | it("skips render when code blocks are unchanged", () => { 33 | const { doc, codeBlock } = DocData.Any.simple() 34 | const mockRenderPerformer = mock() 35 | when(() => mockRenderPerformer.perform(doc, [codeBlock], Origin.RenderHandler)).thenReturn([ 36 | codeBlock, 37 | ]) 38 | 39 | const renderer = Renderer.create({ 40 | renderParser: RenderParser.create({ 41 | config: Any.configWith({ excludedLanguages: [] }), 42 | parser: Any.parser(), 43 | }), 44 | renderPerformer: mockRenderPerformer, 45 | }) 46 | 47 | renderer.render(doc, Origin.RenderHandler) 48 | renderer.render(doc, Origin.RenderHandler) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/cm-extension/cm6/rendering/decoration/LineDecorations.ts: -------------------------------------------------------------------------------- 1 | import { Decoration, LineDecorationSpec } from "@codemirror/view" 2 | 3 | import { Arrays } from "@ext/stdlib/Arrays" 4 | 5 | import { CodeBlockClass as Cb } from "@cm-extension/cm6/theme/style/CodeBlockClass" 6 | 7 | /** 8 | * {@link Decoration.line}s for {@link CodeBlock}s. 9 | */ 10 | export namespace LineDecorations { 11 | /** 12 | * {@link LineDecorationSpec} that assigns a css class to an opening fence line. 13 | */ 14 | export const openingFenceSpec: LineDecorationSpec = { attributes: { class: Cb.startLine } } 15 | /** 16 | * {@link Decoration.line} that assigns a css class to an opening fence line. 17 | */ 18 | export const openingFence = Decoration.line(openingFenceSpec) 19 | 20 | /** 21 | * {@link LineDecorationSpec} that assigns a css class to a closing fence line. 22 | */ 23 | export const closingFenceSpec: LineDecorationSpec = { attributes: { class: Cb.endLine } } as const 24 | /** 25 | * {@link Decoration.line} that assigns a css class to a closing fence line. 26 | */ 27 | export const closingFence = Decoration.line(closingFenceSpec) 28 | 29 | /** 30 | * {@link LineDecorationSpec} that assigns css classes to a code line. 31 | * Assigns one additional class to a line that {@link isFirst} and/or a line that {@link isLast}. 32 | */ 33 | export function codeSpec({ 34 | isFirst, 35 | isLast, 36 | }: { 37 | isFirst: boolean 38 | isLast: boolean 39 | }): LineDecorationSpec { 40 | const classes = Arrays.compact([ 41 | Cb.codeLine, 42 | isFirst ? Cb.first : undefined, 43 | isLast ? Cb.last : undefined, 44 | ]) 45 | return { attributes: { class: classes.join(" ") } } 46 | } 47 | 48 | /** 49 | * {@link Decoration.line} that assigns css classes to a code line. 50 | * Assigns one additional class to a line that {@link isFirst} and/or a line that {@link isLast}. 51 | */ 52 | export function code(attributes: { isFirst: boolean; isLast: boolean }): Decoration { 53 | return Decoration.line(codeSpec(attributes)) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ext/codemirror/cm6/state/CursorMovementFilter.ts: -------------------------------------------------------------------------------- 1 | import { EditorState, TransactionFilterSpec } from "@codemirror/state" 2 | 3 | import { EditorSelections } from "@ext/codemirror/cm6/state/EditorSelections" 4 | import { def, nil } from "@ext/stdlib/existence" 5 | 6 | /** 7 | * Returns a new cursor position for the change caused by the {@link transaction} if the cursor 8 | * should move, or else `undefined`. 9 | */ 10 | export type CursorMover = (transaction: { 11 | readonly startState: EditorState 12 | readonly pos: number 13 | readonly previousPos: number 14 | readonly isVertical: boolean 15 | }) => number | undefined 16 | 17 | /** 18 | * Transaction filter that (potentially) changes the cursor position inside the `transaction` 19 | * to a new position provided by the {@link CursorMover}. 20 | * 21 | * Useful for skipping the cursor past undesired selection points (e.g. around atomic ranges). 22 | * 23 | * If the `transaction` contains a single cursor and the {@link CursorMover} returns a new 24 | * cursor position, clones the cursor in the `transaction` and sets it to the new location. 25 | * 26 | * @see EditorState.transactionFilter 27 | */ 28 | export const CursorMovementFilter = (move: CursorMover): TransactionFilterSpec => { 29 | return (transaction) => { 30 | const { selection, startState, docChanged } = transaction 31 | 32 | if (nil(selection) || docChanged) return [transaction] 33 | if (!EditorSelections.isSingleCursor(selection)) return [transaction] 34 | 35 | const cursor = selection.main 36 | const newPosition = move({ 37 | startState, 38 | pos: cursor.head, 39 | previousPos: startState.selection.main.head, 40 | isVertical: def(cursor.goalColumn), 41 | }) 42 | if (nil(newPosition)) return [transaction] 43 | 44 | return [ 45 | transaction, 46 | { 47 | selection: EditorSelections.singleCursor({ 48 | pos: newPosition, 49 | assoc: cursor.assoc, 50 | bidiLevel: cursor.bidiLevel, 51 | goalColumn: cursor.goalColumn, 52 | }), 53 | }, 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /test/ext/joplin/JoplinSettings.test.ts: -------------------------------------------------------------------------------- 1 | import { ExtendedSettingSection, SettingItem, SettingItemType, SettingSection } from "api/types" 2 | import { describe, expect, it } from "vitest" 3 | 4 | import { JoplinSettings } from "@ext/joplin/JoplinSettings" 5 | 6 | import { FakeJoplinSettings } from "test-support/fakes/joplin/FakeJoplinSettings" 7 | 8 | describe("JoplinSettings", () => { 9 | describe("register", () => { 10 | it("registers section and settings", async () => { 11 | const fakeJoplinSettings = FakeJoplinSettings.create() 12 | 13 | await JoplinSettings.register(fakeJoplinSettings, "FooSection", FooSection) 14 | 15 | expect(fakeJoplinSettings.ext.settingSections).toContainExactlyEntry("FooSection", { 16 | name: FooSection.name, 17 | label: FooSection.label, 18 | description: FooSection.description, 19 | iconName: FooSection.iconName, 20 | } satisfies SettingSection) 21 | 22 | expect(fakeJoplinSettings.ext.settingItems).toStrictEqual( 23 | new Map( 24 | Object.entries({ 25 | a: { section: "FooSection", ...FooSection.settings.a }, 26 | b: { section: "FooSection", ...FooSection.settings.b }, 27 | } satisfies Record), 28 | ), 29 | ) 30 | }) 31 | }) 32 | }) 33 | 34 | interface FooSettings { 35 | readonly a: string 36 | readonly b: "1" | "2" 37 | } 38 | 39 | const FooSection = { 40 | name: "name", 41 | label: "label", 42 | description: "FooSection.description", 43 | iconName: "FooSection.iconName", 44 | settings: { 45 | a: { 46 | type: SettingItemType.String, 47 | public: true, 48 | value: "a.value", 49 | label: "a.label", 50 | description: "a.description", 51 | }, 52 | b: { 53 | type: SettingItemType.String, 54 | public: false, 55 | value: "1", 56 | isEnum: true, 57 | options: { 58 | "1": "b.options.1", 59 | "2": "b.options.2", 60 | }, 61 | label: "b.label", 62 | description: "b.description", 63 | }, 64 | }, 65 | } as const satisfies ExtendedSettingSection 66 | -------------------------------------------------------------------------------- /test-support/fakes/codemirror/cm6/view/FakeEditorView.ts: -------------------------------------------------------------------------------- 1 | import { Transaction, TransactionSpec } from "@codemirror/state" 2 | import { EditorView } from "@codemirror/view" 3 | 4 | import { InlineState } from "test-support/ext/codemirror/cm6/InlineState" 5 | 6 | export interface FakeEditorViewProps { 7 | /** 8 | * The `EditorState` to set for the view (defaults to a doc with the text `doc`). 9 | */ 10 | readonly state?: EditorView["state"] 11 | 12 | /** 13 | * The visible ranges to set (defaults to a single range spanning the entire doc). 14 | */ 15 | readonly visibleRanges?: EditorView["visibleRanges"] 16 | } 17 | 18 | export type FakeEditorView = EditorView & Extensions 19 | export namespace FakeEditorView { 20 | export function create(props?: FakeEditorViewProps): FakeEditorView { 21 | return new ExtendedEditorView(props) as unknown as FakeEditorView 22 | } 23 | } 24 | 25 | type PartialEditorView = Pick 26 | 27 | export interface Extensions { 28 | readonly ext: { 29 | /** 30 | * Dispatches made on the {@link EditorView} with {@link EditorView.dispatch}. 31 | */ 32 | readonly dispatches: readonly (Transaction | TransactionSpec)[] 33 | } 34 | } 35 | 36 | // noinspection JSUnusedGlobalSymbols 37 | class ExtendedEditorView implements PartialEditorView, Extensions { 38 | // noinspection JSUnusedGlobalSymbols 39 | readonly ext = new (class { 40 | readonly dispatches: (Transaction | TransactionSpec)[] = [] 41 | })() 42 | 43 | readonly state: EditorView["state"] 44 | readonly visibleRanges: EditorView["visibleRanges"] 45 | 46 | constructor(props?: FakeEditorViewProps) { 47 | this.state = props?.state ?? InlineState`doc` 48 | this.visibleRanges = props?.visibleRanges ?? [{ from: 0, to: this.state.doc.length }] 49 | } 50 | 51 | dispatch(tr: Transaction): void 52 | dispatch(trs: readonly Transaction[]): void 53 | dispatch(...specs: TransactionSpec[]): void 54 | dispatch( 55 | value: Transaction | readonly Transaction[] | TransactionSpec | TransactionSpec[], 56 | ): void { 57 | this.ext.dispatches.push([value].flat()[0]) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/cm-extension/cm5/formatter/EdgeSpacer.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest" 2 | 3 | import { Origin } from "@cm-extension/cm5/model/Origin" 4 | 5 | import { Any } from "test-support/fixtures/cm5/Any" 6 | import { DocData } from "test-support/fixtures/cm5/DocData" 7 | 8 | describe("EdgeSpacer", () => { 9 | describe("format", () => { 10 | it("adds newline at start and/or end of doc if doc starts and/or ends with code block", () => { 11 | const { doc, codeBlock } = DocData.Unspaced.simple() 12 | const { doc: spacedDoc, codeBlock: spacedCodeBlock } = DocData.Spaced.simple() 13 | 14 | const newCodeBlocks = Any.edgeSpacer().format(doc, [codeBlock], Origin.RenderHandler) 15 | 16 | expect(newCodeBlocks).toStrictEqual([spacedCodeBlock]) 17 | expect(doc.getValue()).toStrictEqual(spacedDoc.getValue()) 18 | }) 19 | 20 | it.each(simpleDocCursors)( 21 | "preserves cursor when adding newline at start and/or end and cursor is $cursor", 22 | ({ before, after }) => { 23 | const { doc, codeBlock } = DocData.Unspaced.simple() 24 | doc.setCursor(before) 25 | 26 | Any.edgeSpacer().format(doc, [codeBlock], Origin.RenderHandler) 27 | 28 | expect(doc.getCursor()).toMatchObject(after) 29 | }, 30 | ) 31 | 32 | it("uses the proper origin when adding newlines", () => { 33 | const { doc, codeBlock } = DocData.Unspaced.simple() 34 | 35 | const origins = new Set() 36 | doc.on("beforeChange", (_doc, change) => { 37 | origins.add(change.origin) 38 | }) 39 | Any.edgeSpacer().format(doc, [codeBlock], Origin.RenderHandler) 40 | 41 | expect(origins).toContainExactlyItem(Origin.RenderHandler) 42 | }) 43 | }) 44 | }) 45 | 46 | const simpleDocCursors = [ 47 | { 48 | cursor: "at start of code block", 49 | before: { line: 0, ch: 4 }, 50 | after: { line: 1, ch: 4 }, 51 | }, 52 | { 53 | cursor: "inside code block", 54 | before: { line: 2, ch: 10 }, 55 | after: { line: 3, ch: 10 }, 56 | }, 57 | { 58 | cursor: "at end of code block", 59 | before: { line: 4, ch: 3 }, 60 | after: { line: 5, ch: 3 }, 61 | }, 62 | ] 63 | -------------------------------------------------------------------------------- /test/ext/stdlib/Retrier.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, when } from "strong-mock" 2 | import { beforeEach, describe, expect, it } from "vitest" 3 | 4 | import { Retrier } from "@ext/stdlib/Retrier" 5 | 6 | import { FakeWindow } from "test-support/fakes/dom/FakeWindow" 7 | 8 | describe("Retrier", () => { 9 | describe("retry", () => { 10 | let fakeWindow: FakeWindow 11 | const mockFn = mock<() => Promise>() 12 | const mockIsComplete = mock<(response: string) => boolean>() 13 | let retrier: Retrier 14 | 15 | beforeEach(() => { 16 | fakeWindow = FakeWindow.create() 17 | retrier = Retrier.create({ window: fakeWindow }) 18 | }) 19 | 20 | it("retries fn exponentially", async () => { 21 | when(() => mockFn()) 22 | .thenResolve("response") 23 | .times(3) 24 | when(() => mockIsComplete("response")).thenReturn(false) 25 | when(() => mockIsComplete("response")).thenReturn(false) 26 | when(() => mockIsComplete("response")).thenReturn(true) 27 | 28 | const response = await retrier.retry({ 29 | fn: mockFn, 30 | isSuccess: mockIsComplete, 31 | startDelayMillis: 1, 32 | }) 33 | 34 | const [firstTimeout, secondTimeout] = fakeWindow.ext.setTimeouts 35 | expect(firstTimeout.timeout).toBe(1) 36 | expect(secondTimeout.timeout).toBe(2) 37 | 38 | expect(response).toBe("response") 39 | }) 40 | 41 | it("does not retry when fn completes the first time", async () => { 42 | when(() => mockFn()).thenResolve("response") 43 | when(() => mockIsComplete("response")).thenReturn(true) 44 | 45 | const response = await retrier.retry({ 46 | fn: mockFn, 47 | isSuccess: mockIsComplete, 48 | startDelayMillis: 1, 49 | }) 50 | 51 | expect(response).toBe("response") 52 | expect(fakeWindow.ext.setTimeouts).toBeEmpty() 53 | }) 54 | 55 | it("throw Error when start delay is invalid", async () => { 56 | await expect( 57 | retrier.retry({ 58 | fn: mockFn, 59 | isSuccess: mockIsComplete, 60 | startDelayMillis: -1, 61 | }), 62 | ).rejects.toThrowError() 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/cm-extension/cm5/marker/widgeter/Widgeter.ts: -------------------------------------------------------------------------------- 1 | import { Doc } from "codemirror" 2 | 3 | import { Marker } from "@cm-extension/cm5/marker/Marker" 4 | import { Widget } from "@cm-extension/cm5/marker/widgeter/Widget" 5 | import { WidgetGenerator } from "@cm-extension/cm5/marker/widgeter/WidgetGenerator" 6 | import { CodeBlock } from "@cm-extension/cm5/model/CodeBlock" 7 | 8 | /** 9 | * A {@link Widget} that's later removable. 10 | */ 11 | interface ClearableWidget extends Widget { 12 | clear(): void 13 | } 14 | 15 | export interface WidgeterProps { 16 | readonly widgetGenerator: WidgetGenerator 17 | } 18 | 19 | /** 20 | * Replaces text with widgets generated by {@link widgetGenerator}. 21 | */ 22 | export class Widgeter implements Marker { 23 | private readonly widgetGenerator: WidgetGenerator 24 | private widgets: readonly ClearableWidget[] = [] 25 | 26 | static create(props: WidgeterProps): Widgeter { 27 | return new Widgeter(props) 28 | } 29 | 30 | private constructor(props: WidgeterProps) { 31 | this.widgetGenerator = props.widgetGenerator 32 | } 33 | 34 | /** 35 | * Marks text with replacement widgets for the given {@link codeBlocks} in the {@link doc}. 36 | * 37 | * Removes all previous widgets before adding new ones. 38 | */ 39 | mark(doc: Doc, codeBlocks: readonly CodeBlock[]): void { 40 | this.updateWidgets(doc, codeBlocks) 41 | } 42 | 43 | private updateWidgets(doc: Doc, codeBlocks: readonly CodeBlock[]): void { 44 | const generatedWidgets = this.widgetGenerator.generate(doc, codeBlocks) 45 | 46 | this.widgets.forEach((it) => it.clear()) 47 | 48 | this.widgets = generatedWidgets.map((widget) => { 49 | const { range, element } = widget 50 | 51 | const marker = doc.markText( 52 | { line: range.line, ch: range.from }, 53 | { line: range.line, ch: range.to }, 54 | { 55 | atomic: true, 56 | collapsed: true, 57 | inclusiveLeft: false, 58 | inclusiveRight: false, 59 | replacedWith: element, 60 | selectLeft: false, 61 | selectRight: false, 62 | }, 63 | ) 64 | return { ...widget, clear: () => marker.clear() } 65 | }) 66 | } 67 | } 68 | --------------------------------------------------------------------------------