From be7c2a6d6273c3b221083ba620635bfdf8127cf8 Mon Sep 17 00:00:00 2001 From: Mathias Vanden Auweele Date: Wed, 2 Sep 2026 09:26:00 +0200 Subject: [PATCH 1/8] feat(yasqe): migrate editor to CodeMirror 6 Replace the CodeMirror 5 based editor with a CodeMirror 6 EditorView wrapped in a CM5-compatible facade (EditorFacade/DocFacade). The SPARQL tokenizer is exposed as a StreamLanguage, autocompletion is bridged to @codemirror/autocomplete, and gutters, folding, key bindings and themes are re-implemented as CM6 extensions while keeping the existing CSS class names. BREAKING CHANGE: Yasqe no longer extends the CodeMirror class; CodeMirror 5 addons, modes and theme stylesheets are no longer available. --- package-lock.json | 218 +++++++- packages/yasqe/grammar/tokenizer.ts | 70 ++- packages/yasqe/package.json | 9 +- packages/yasqe/src/CodeMirror.ts | 54 -- packages/yasqe/src/autocompleters/index.ts | 77 +-- .../yasqe/src/autocompleters/show-hint.scss | 26 +- .../yasqe/src/autocompleters/variables.ts | 44 +- packages/yasqe/src/defaults.ts | 8 +- packages/yasqe/src/editor/autocompletion.ts | 147 +++++ packages/yasqe/src/editor/doc.ts | 151 +++++ packages/yasqe/src/editor/facade.ts | 521 ++++++++++++++++++ packages/yasqe/src/editor/folding.ts | 216 ++++++++ packages/yasqe/src/editor/gutters.ts | 114 ++++ packages/yasqe/src/editor/keymap.ts | 133 +++++ packages/yasqe/src/editor/language.ts | 47 ++ packages/yasqe/src/editor/tokenizerRunner.ts | 198 +++++++ packages/yasqe/src/index.ts | 285 ++++++---- packages/yasqe/src/prefixFold.ts | 94 ---- packages/yasqe/src/scss/buttons.scss | 6 +- packages/yasqe/src/scss/codemirrorMods.scss | 86 ++- packages/yasqe/src/scss/yasqe.scss | 6 +- packages/yasr/package.json | 10 +- 22 files changed, 2108 insertions(+), 412 deletions(-) delete mode 100644 packages/yasqe/src/CodeMirror.ts create mode 100644 packages/yasqe/src/editor/autocompletion.ts create mode 100644 packages/yasqe/src/editor/doc.ts create mode 100644 packages/yasqe/src/editor/facade.ts create mode 100644 packages/yasqe/src/editor/folding.ts create mode 100644 packages/yasqe/src/editor/gutters.ts create mode 100644 packages/yasqe/src/editor/keymap.ts create mode 100644 packages/yasqe/src/editor/language.ts create mode 100644 packages/yasqe/src/editor/tokenizerRunner.ts delete mode 100644 packages/yasqe/src/prefixFold.ts diff --git a/package-lock.json b/package-lock.json index 0f485bd2..138c1146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -576,6 +576,109 @@ "node": ">= 4.0.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", + "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.2.tgz", + "integrity": "sha512-U3RiPX62Wl/Gx4ftQ7UxLlloSfsFTQqKa+7vFBYteZGCzkp6oBqcsD7iSwniWRGobCAmDcwZSY+Six3+3ztdfg==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.10", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.10.tgz", + "integrity": "sha512-vVWLvd4fKWYN/pMcwUrg6dWeublxmSz+V+52ZjW3h64IVN9cRUYLk5Km4JDT9vifxAFfDtNOIFxKYENthcolJQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -1316,6 +1419,52 @@ "@streamparser/json": "^0.0.20" } }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -1428,6 +1577,12 @@ "node": ">= 4.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@matdata/yasgui": { "resolved": "packages/yasgui", "link": true @@ -2335,16 +2490,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/codemirror": { - "version": "0.0.100", - "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.100.tgz", - "integrity": "sha512-4jGmu1T8vpQrJCe8cbe3KveiJmK2UAt3rZO2qE2sPoMhGLuwW0cMzFYJLyXebbRJg5G3RbuUXLip1IHPUESkFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/tern": "*" - } - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2522,16 +2667,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tern": { - "version": "0.23.9", - "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", - "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3594,12 +3729,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/codemirror": { - "version": "5.65.20", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.20.tgz", - "integrity": "sha512-i5dLDDxwkFCbhjvL2pNjShsojoL3XHyDwsGv1jqETUoW+lzpBKKqNTUWgQwVAOa0tUm4BwekT455ujafi8payA==", - "license": "MIT" - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3712,6 +3841,12 @@ } } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -7809,6 +7944,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -8692,6 +8833,12 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/webdriver-bidi-protocol": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz", @@ -9108,15 +9255,20 @@ "version": "4.6.1", "license": "MIT", "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", + "@codemirror/language": "^6.12.4", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.2", + "@codemirror/view": "^6.43.10", + "@lezer/highlight": "^1.2.3", "@matdata/yasgui-utils": "^4.6.1", - "codemirror": "^5.51.0", "leaflet": "^1.9.4", "lodash-es": "^4.17.15", "query-string": "^6.10.1", "sparql-formatter": "^1.0.2" }, "devDependencies": { - "@types/codemirror": "0.0.100", "@types/leaflet": "^1.9.21", "@types/lodash-es": "^4.17.3", "@types/node": "^22.5.4" @@ -9145,11 +9297,18 @@ "version": "4.6.1", "license": "MIT", "dependencies": { + "@codemirror/commands": "^6.11.0", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.12.4", + "@codemirror/legacy-modes": "^6.5.3", + "@codemirror/state": "^6.7.2", + "@codemirror/view": "^6.43.10", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@json2csv/plainjs": "^7.0.4", + "@lezer/highlight": "^1.2.3", "@matdata/yasgui-utils": "^4.6.1", "@matdata/yasqe": "^4.6.1", - "codemirror": "^5.51.0", "colors": "^1.4.0", "column-resizer": "^1.4.0", "datatables.net": "^2.0.5", @@ -9161,7 +9320,6 @@ "papaparse": "^5.3.1" }, "devDependencies": { - "@types/codemirror": "0.0.100", "@types/jquery": "^3.5.32", "@types/lodash-es": "^4.17.3", "@types/n3": "^1.1.5", diff --git a/packages/yasqe/grammar/tokenizer.ts b/packages/yasqe/grammar/tokenizer.ts index 5aca0c41..71861b39 100644 --- a/packages/yasqe/grammar/tokenizer.ts +++ b/packages/yasqe/grammar/tokenizer.ts @@ -1,6 +1,6 @@ -import CodeMirror from "codemirror"; +import type { StringStream, StreamParser, IndentContext } from "@codemirror/language"; export interface State { - tokenize: (stream: CodeMirror.StringStream, state: State) => string; + tokenize: (stream: StringStream, state: State) => string; inLiteral: "SINGLE" | "DOUBLE" | undefined; errorStartPos: number | undefined; errorEndPos: number | undefined; @@ -52,7 +52,54 @@ export interface Token { start: number; } import * as grammar from "./_tokenizer-table.js"; -export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mode { + +export interface TokenizerConfig { + indentUnit?: number; +} + +/** + * All token style names that the tokenizer can emit (the space-separated + * parts of the `style` strings). Exported so that the CodeMirror 6 language + * definition can build a token table and a highlight style for them. + */ +export const TOKEN_STYLES = [ + "ws", + "comment", + "variable-3", + "atom", + "meta", + "number", + "string", + "string-2", + "punc", + "keyword", + "error", + "bracket", + "bracket-level-0", + "bracket-level-1", + "bracket-level-2", + "bracket-level-3", + "bracket-paren", + "bracket-mismatch", +] as const; +export type TokenStyle = (typeof TOKEN_STYLES)[number]; + +export function copyState(s: State): State { + return { + ...s, + possibleCurrent: s.possibleCurrent.slice(), + possibleNext: s.possibleNext.slice(), + stack: s.stack.slice(), + variables: { ...s.variables }, + prefixes: { ...s.prefixes }, + constructVariables: { ...s.constructVariables }, + whereVariables: { ...s.whereVariables }, + pendingToken: s.pendingToken ? { ...s.pendingToken } : undefined, + bracketStack: s.bracketStack.map((b) => ({ ...b })), + }; +} + +export default function (config: TokenizerConfig = {}): StreamParser { const ll1_table = grammar.table; const IRI_REF = '<[^<>"`|{}^\\\x00-\x20]*>'; @@ -324,7 +371,7 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod return possibles; } - function tokenBase(stream: CodeMirror.StringStream, state: State) { + function tokenBase(stream: StringStream, state: State): string { // If we have a pending token from a previous split, consume it from stream and return style if (state.pendingToken) { const token = state.pendingToken; @@ -764,7 +811,7 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod // "*[;,?[or([verbPath,verbSimple]),objectList]]": 1, }; - function indent(state: State, textAfter: string) { + function indent(state: State, textAfter: string, indentUnit: number) { //just avoid we don't indent multi-line literals if (state.inLiteral) return 0; if ( @@ -800,12 +847,21 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod n += dn; } } - return n * (config.indentUnit ?? 2); + return n * indentUnit; } } return { + name: "sparql11", token: tokenBase, + copyState, + indent: (state: State, textAfter: string, context: IndentContext) => + indent(state, textAfter, context.unit ?? config.indentUnit ?? 2), + languageData: { + commentTokens: { line: "#" }, + indentOnInput: /^\s*[\}\]\)]$/, + closeBrackets: { brackets: ["(", "[", "{", "'", '"'] }, + }, startState: function (): State { return { tokenize: tokenBase, @@ -839,7 +895,5 @@ export default function (config: CodeMirror.EditorConfiguration): CodeMirror.Mod bracketLevel: 0, }; }, - indent: indent, - electricChars: "}])", }; } diff --git a/packages/yasqe/package.json b/packages/yasqe/package.json index 0106398b..55a9d956 100644 --- a/packages/yasqe/package.json +++ b/packages/yasqe/package.json @@ -24,15 +24,20 @@ "directory": "packages/yasqe" }, "dependencies": { + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.11.0", + "@codemirror/language": "^6.12.4", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.2", + "@codemirror/view": "^6.43.10", + "@lezer/highlight": "^1.2.3", "@matdata/yasgui-utils": "^4.6.1", - "codemirror": "^5.51.0", "leaflet": "^1.9.4", "lodash-es": "^4.17.15", "query-string": "^6.10.1", "sparql-formatter": "^1.0.2" }, "devDependencies": { - "@types/codemirror": "0.0.100", "@types/leaflet": "^1.9.21", "@types/lodash-es": "^4.17.3", "@types/node": "^22.5.4" diff --git a/packages/yasqe/src/CodeMirror.ts b/packages/yasqe/src/CodeMirror.ts deleted file mode 100644 index 3aa8068a..00000000 --- a/packages/yasqe/src/CodeMirror.ts +++ /dev/null @@ -1,54 +0,0 @@ -//Do not want to import this using typescript. Somehow, we get a tangled mess when transpiling to es6 with ts, -//and applying babel. -import { - Editor as CmEditor, - Doc as CmDoc, - Token as CmToken, - Position as CmPosition, - EditorConfiguration as CmEditorConfiguration, -} from "codemirror"; - -import _CodeMirror from "codemirror"; - -import * as sparql11Mode from "../grammar/tokenizer"; -import { default as prefixFold } from "./prefixFold"; -import { TokenizerState } from "./index"; - -import "codemirror/addon/fold/foldcode.js"; -import "codemirror/addon/fold/foldgutter.js"; -import "codemirror/addon/fold/xml-fold.js"; -import "codemirror/addon/fold/brace-fold.js"; -import "codemirror/addon/hint/show-hint.js"; -import "codemirror/addon/search/searchcursor.js"; -import "codemirror/addon/search/match-highlighter.js"; -import "codemirror/addon/edit/matchbrackets.js"; -import "codemirror/addon/runmode/runmode.js"; -import "codemirror/lib/codemirror.css"; -import "codemirror/addon/fold/foldgutter.css"; -import "./scss/codemirrorMods.scss"; - -_CodeMirror.registerHelper("fold", "prefix", prefixFold); -_CodeMirror.defineMode("sparql11", sparql11Mode.default); - -namespace CodeMirror { - export type Doc = CmDoc; - export type Position = CmPosition; - export type EditorConfiguration = CmEditorConfiguration; - export interface Token extends CmToken { - state: sparql11Mode.State; - } -} -interface CodeMirror extends Omit { - /** - * Added some more specific typings for `getOption` - * For some functions (called from keyboard combinations like ctrl-enter) we cannot use member props of our object - * as these are lost when we are receiving the native CM object as argument - * Only way to persistently store these options is by using getOption and setOption - */ - getOption(opt: "queryType"): TokenizerState["queryType"]; - setOption(opt: "queryType", val: TokenizerState["queryType"]): void; - - foldCode(firstPrefixLine: number, prefix: string, collapse: "fold" | "unfold"): void; -} -const CodeMirror: any = _CodeMirror; -export default CodeMirror; diff --git a/packages/yasqe/src/autocompleters/index.ts b/packages/yasqe/src/autocompleters/index.ts index bdf4cb3c..deea1e3a 100644 --- a/packages/yasqe/src/autocompleters/index.ts +++ b/packages/yasqe/src/autocompleters/index.ts @@ -1,8 +1,7 @@ -import { default as Yasqe, Token, Hint, Position, Config, HintFn, HintConfig } from "../"; +import { default as Yasqe, Token, Hint, Position, Config, HintFn, HintList } from "../"; import Trie from "../trie"; import { EventEmitter } from "events"; import { take } from "lodash-es"; -import CodeMirror from "codemirror"; import "./show-hint.scss"; export interface CompleterConfig { onInitialize?: (this: CompleterConfig, yasqe: Yasqe) => void; //allows for e.g. registering event listeners in yasqe, like the prefix autocompleter does @@ -173,39 +172,39 @@ export class Completer extends EventEmitter { }); return Promise.resolve([]); } - public autocomplete(fromAutoShow: boolean) { + /** + * Returns whether the cursor is currently at a position this completer can complete. + * Also takes care of showing/hiding the "press ctrl-space" notification. + */ + public isValidCompletionPosition(): boolean { + return this.isValidPosition(); + } + + /** + * Returns a hint function for the current cursor position, or `false` when this completer should not + * (or cannot) show completions right now. The caller (Yasqe) is responsible for opening the completion popup. + */ + public autocomplete(fromAutoShow: boolean): HintFn | false { //this part goes before the autoshow check, as we _would_ like notification showing to indicate a user can press ctrl-space if (!this.isValidPosition()) return false; - const previousCompletionItem = this.yasqe.state.completionActive; - - // Showhint by defaults takes the autocomplete start position (the location of the cursor at the time of starting the autocompletion). - const cursor = this.yasqe.getDoc().getCursor(); - if ( - // When the cursor goes before current completionItem (e.g. using arrow keys), it would close the autocompletions. - // We want the autocompletion to be active at whatever point we are in the token, so let's modify this start pos with the start pos of the token - previousCompletionItem && - cursor.sticky && // Is undefined at the end of the token, otherwise it is set as either "before" or "after" (The movement of the cursor) - cursor.ch !== previousCompletionItem.startPos.ch - ) { - this.yasqe.state.completionActive.startPos = cursor; - } else if (previousCompletionItem && !cursor.sticky && cursor.ch < previousCompletionItem.startPos.ch) { - // A similar thing happens when pressing backspace, CodeMirror will close this autocomplete when 'startLen' changes downward - cursor.sticky = previousCompletionItem.startPos.sticky; - this.yasqe.state.completionActive.startPos.ch = cursor.ch; - this.yasqe.state.completionActive.startLen--; - } if ( fromAutoShow && // from autoShow, i.e. this gets called each time the editor content changes - (!this.config.autoShow || this.yasqe.state.completionActive) // Don't show and don't create a new instance when its already active + (!this.config.autoShow || this.yasqe.isAutocompletionActive()) // Don't show and don't create a new instance when its already active ) { return false; } + return this.getHintFn(); + } + /** + * Hint function that computes the hints for the token at the current cursor position + */ + public getHintFn(): HintFn { const getHints: HintFn = () => { - return this.getHints(this.yasqe.getCompleteToken()).then((list) => { + return this.getHints(this.yasqe.getCompleteToken()).then((list): HintList => { const cur = this.yasqe.getDoc().getCursor(); const token: AutocompletionToken = this.yasqe.getCompleteToken(); - const hintResult = { + return { list: list, from: { line: cur.line, @@ -216,37 +215,9 @@ export class Completer extends EventEmitter { ch: token.end, }, }; - CodeMirror.on(hintResult, "shown", () => { - this.yasqe.emit("autocompletionShown", (this.yasqe as any).state.completionActive.widget); - }); - CodeMirror.on(hintResult, "close", () => { - this.yasqe.emit("autocompletionClose"); - }); - return hintResult; }); }; - - getHints.async = false; //in their code, async means using a callback - //we always return a promise, which should be properly handled regardless of this val - var hintConfig: HintConfig = { - closeCharacters: /[\s>"]/, - completeSingle: false, - hint: getHints, - container: this.yasqe.rootEl, - // Override these actions back to use their default function - // Otherwise these would navigate to the start/end of the suggestion list, while this can also be accomplished with PgUp and PgDn - extraKeys: { - Home: (yasqe, event) => { - yasqe.getDoc().setCursor({ ch: 0, line: event.data.from.line }); - }, - End: (yasqe, event) => { - yasqe.getDoc().setCursor({ ch: yasqe.getLine(event.data.to.line).length, line: event.data.to.line }); - }, - }, - ...this.yasqe.config.hintConfig, - }; - this.yasqe.showHint(hintConfig); - return true; + return getHints; } } diff --git a/packages/yasqe/src/autocompleters/show-hint.scss b/packages/yasqe/src/autocompleters/show-hint.scss index 338762dc..a224a29e 100644 --- a/packages/yasqe/src/autocompleters/show-hint.scss +++ b/packages/yasqe/src/autocompleters/show-hint.scss @@ -1,13 +1,11 @@ -//This is a copy of the codemirror show-hint css -//Including this ourselves, as we don't want this css to be auto-scoped to yasqe +// Styling for the autocompletion tooltip (CodeMirror 6 `@codemirror/autocomplete`). +// The tooltip gets the legacy `CodeMirror-hints` class (see editor/autocompletion.ts) so that +// existing consumers / tests keep working. +// Including this ourselves, as we don't want this css to be auto-scoped to yasqe +.cm-tooltip.CodeMirror-hints, .CodeMirror-hints { - position: absolute; z-index: 10; overflow: hidden; - list-style: none; - - margin: 0; - padding: 2px; -webkit-box-shadow: 2px 3px 5px rgba(0, 0, 0, 0.2); -moz-box-shadow: 2px 3px 5px rgba(0, 0, 0, 0.2); @@ -18,10 +16,18 @@ background: white; font-size: 90%; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; - max-height: 20em; - overflow-y: auto; + + & > ul { + list-style: none; + margin: 0; + padding: 2px; + max-height: 20em; + overflow-y: auto; + font-family: inherit; + } } +.CodeMirror-hints > ul > li, .CodeMirror-hint { margin: 0; padding: 0 4px; @@ -29,8 +35,10 @@ white-space: pre; color: black; cursor: pointer; + line-height: 1.4; } +.CodeMirror-hints > ul > li[aria-selected], li.CodeMirror-hint-active { background: #08f; color: white; diff --git a/packages/yasqe/src/autocompleters/variables.ts b/packages/yasqe/src/autocompleters/variables.ts index f66630f3..0b59c986 100644 --- a/packages/yasqe/src/autocompleters/variables.ts +++ b/packages/yasqe/src/autocompleters/variables.ts @@ -1,4 +1,5 @@ import * as autocompleter from "./"; +import { runMode } from "../editor/tokenizerRunner"; var conf: autocompleter.CompleterConfig = { name: "variables", @@ -16,33 +17,28 @@ var conf: autocompleter.CompleterConfig = { if (!token || token.string.length == 0) return []; //nothing to autocomplete const distinctVars: { [varname: string]: any } = {}; const vars: string[] = []; - //do this outside of codemirror. I expect jquery to be faster here (just finding dom elements with classnames) - //and: this'll still work when the query is incorrect (i.e., when simply typing '?') - const atoms = yasqe.getWrapperElement().querySelectorAll(".cm-atom"); - for (let i = 0; i < atoms.length; i++) { - const atom = atoms[i]; - var variable = atom.innerHTML; - if (variable[0] === "?" || variable[0] === "$") { - //ok, lets check if the next element in the div is an atom as well. In that case, they belong together (may happen sometimes when query is not syntactically valid) - // var nextElClass = nextEl.attr("class"); - const nextEl: HTMLElement = atom.nextSibling; - if (nextEl && nextEl.className && nextEl.className.indexOf("cm-atom") >= 0) { - variable += nextEl.innerText; - } - if (distinctVars[variable]) continue; //already in list - //skip single questionmarks - if (variable.length <= 1) continue; + // Tokenize the whole query (independent of what is rendered in the viewport), collecting all + // variable tokens. This still works when the query is syntactically incorrect (e.g. when simply typing '?'). + const atoms: string[] = []; + runMode(yasqe.getValue(), (stringVal: string, style: string | null) => { + if (style === "atom" && (stringVal[0] === "?" || stringVal[0] === "$")) { + atoms.push(stringVal); + } + }); + for (const variable of atoms) { + if (distinctVars[variable]) continue; //already in list + //skip single questionmarks + if (variable.length <= 1) continue; - //it should match our token ofcourse - if (variable.indexOf(token.string) !== 0) continue; + //it should match our token ofcourse + if (variable.indexOf(token.string) !== 0) continue; - //skip exact matches - if (variable === token.string) continue; + //skip exact matches + if (variable === token.string) continue; - //store in map so we have a unique list - distinctVars[variable] = true; - vars.push(variable); - } + //store in map so we have a unique list + distinctVars[variable] = true; + vars.push(variable); } return vars.sort(); }, diff --git a/packages/yasqe/src/defaults.ts b/packages/yasqe/src/defaults.ts index 8713d366..dc172dc0 100644 --- a/packages/yasqe/src/defaults.ts +++ b/packages/yasqe/src/defaults.ts @@ -6,8 +6,6 @@ */ import { default as Yasqe, Config, PlainRequestConfig } from "./"; import * as queryString from "query-string"; -import CodeMirror from "codemirror"; -//need to pass Yasqe object as argument, as the imported version might not have inherited all (e.g. `fold`) props of Codemirror yet export default function get() { const prefixCcApi = null; // null = use bundled prefix data (packages/yasqe/src/prefixes.json, refreshed each build) const config: Omit = { @@ -23,11 +21,9 @@ SELECT * WHERE { tabMode: "indent", lineNumbers: true, lineWrapping: true, - foldGutter: { - rangeFinder: new (CodeMirror).fold.combine((CodeMirror).fold.brace, (CodeMirror).fold.prefix), - }, + foldGutter: true, // Folding of braces and prefix blocks is handled by the built-in SPARQL fold service collapsePrefixesOnLoad: false, - gutters: ["gutterErrorBar", "gutterConstructWarning", "CodeMirror-linenumbers", "CodeMirror-foldgutter"], + gutters: ["gutterErrorBar", "gutterConstructWarning"], matchBrackets: true, fixedGutter: true, syntaxErrorCheck: true, diff --git a/packages/yasqe/src/editor/autocompletion.ts b/packages/yasqe/src/editor/autocompletion.ts new file mode 100644 index 00000000..66f334b1 --- /dev/null +++ b/packages/yasqe/src/editor/autocompletion.ts @@ -0,0 +1,147 @@ +/** + * CodeMirror 6 autocompletion bridge. + * + * YASQE's autocompleters (see ../autocompleters) were written against the CodeMirror 5 + * `show-hint` addon: a "hint function" returns a list of `Hint`s together with the range + * that should be replaced. This module adapts that model onto `@codemirror/autocomplete`: + * + * - A single `CompletionSource` is registered. Whenever it is queried it asks the host + * (the `Yasqe` facade) for the currently active hint function and converts its result + * into a `CompletionResult`. + * - `Hint.from` / `Hint.to` are honoured per completion by using a custom `apply`. + * - Open/close transitions are observed through `completionStatus` and reported to the host + * so that the legacy `autocompletionShown` / `autocompletionClose` events keep working. + */ +import { EditorState, Extension, Text } from "@codemirror/state"; +import { EditorView, ViewUpdate } from "@codemirror/view"; +import { + autocompletion, + Completion, + CompletionContext, + CompletionResult, + completionStatus, + completionKeymap, + startCompletion, + closeCompletion, +} from "@codemirror/autocomplete"; +import { keymap } from "@codemirror/view"; +import { Prec } from "@codemirror/state"; +import type { Position } from "./tokenizerRunner"; +import { posToOffset, offsetToPos } from "./doc"; + +export interface Hint { + text: string; + displayText?: string; + className?: string; + /** Kept for API compatibility; custom rendering is not supported by the CM6 bridge. */ + render?: (el: HTMLElement, self: Hint, data: any) => void; + from?: Position; + to?: Position; +} +export interface HintList { + list: Hint[]; + from: Position; + to: Position; +} +export type HintFn = { async?: boolean } & (() => Promise | HintList | undefined); + +export interface AutocompletionHost { + /** Returns the hint function that should be used for the current completion session (if any) */ + getHintFn: () => HintFn | undefined; + onShown?: (tooltipDom: HTMLElement | null) => void; + onClose?: () => void; +} + +export { posToOffset, offsetToPos }; + +function hintToCompletion(hint: Hint): Completion { + return { + label: hint.displayText || hint.text, + type: hint.className, + apply: (view: EditorView, _completion: Completion, from: number, to: number) => { + const doc = view.state.doc; + const replaceFrom = hint.from ? posToOffset(doc, hint.from) : from; + const replaceTo = hint.to ? posToOffset(doc, hint.to) : to; + const start = Math.min(replaceFrom, replaceTo); + const end = Math.max(replaceFrom, replaceTo); + view.dispatch({ + changes: { from: start, to: end, insert: hint.text }, + selection: { anchor: start + hint.text.length }, + userEvent: "input.complete", + }); + }, + }; +} + +export function hintListToCompletionResult(hintList: HintList, doc: Text): CompletionResult | null { + if (!hintList || !hintList.list || hintList.list.length === 0) return null; + const from = posToOffset(doc, hintList.from); + const to = posToOffset(doc, hintList.to); + return { + from: Math.min(from, to), + to: Math.max(from, to), + options: hintList.list.map(hintToCompletion), + // Completers already filtered and ordered their suggestions + filter: false, + }; +} + +function createCompletionSource(host: AutocompletionHost) { + return async (context: CompletionContext): Promise => { + const hintFn = host.getHintFn(); + if (!hintFn) return null; + const result = await hintFn(); + if (!result || context.aborted) return null; + return hintListToCompletionResult(result, context.state.doc); + }; +} + +/** + * Autocompletion extension. `host.getHintFn()` is consulted every time CodeMirror queries for completions. + */ +export function sparqlAutocompletion(host: AutocompletionHost): Extension { + const statusListener = EditorView.updateListener.of((update: ViewUpdate) => { + const before = completionStatus(update.startState); + const after = completionStatus(update.state); + if (before === null && after !== null) { + host.onShown?.(update.view.dom.querySelector(".cm-tooltip-autocomplete")); + } else if (before !== null && after === null) { + host.onClose?.(); + } + }); + return [ + autocompletion({ + override: [createCompletionSource(host)], + // YASQE decides itself when to open the popup (see Yasqe.autocomplete) + activateOnTyping: false, + closeOnBlur: true, + // We register the navigation keys ourselves (below) so that `Ctrl-Space` is exclusively handled by + // YASQE's `extraKeys` (which builds the hint function before opening the popup). + defaultKeymap: false, + icons: false, + // CM5 rendered the complete hint list; CM6 pages by 100 items by default. Render everything so that + // consumers that inspect the popup (and the existing tests) see the full list. + maxRenderedOptions: 100000, + // CM5 accepted a completion immediately after the popup opened; CM6 ignores Enter/arrows during the first + // 75ms (and lets the key fall through, inserting a newline). Keep the CM5 behaviour. + interactionDelay: 0, + // Keep the CM5 class names around so existing styling / tests keep working + tooltipClass: () => "CodeMirror-hints", + optionClass: (completion) => (completion.type ? completion.type : ""), + }), + Prec.highest(keymap.of(completionKeymap.filter((binding) => binding.key !== "Ctrl-Space"))), + statusListener, + ]; +} + +export function isCompletionActive(state: EditorState): boolean { + return completionStatus(state) !== null; +} + +export function openCompletion(view: EditorView): boolean { + return startCompletion(view); +} + +export function hideCompletion(view: EditorView): boolean { + return closeCompletion(view); +} diff --git a/packages/yasqe/src/editor/doc.ts b/packages/yasqe/src/editor/doc.ts new file mode 100644 index 00000000..bbbc3be0 --- /dev/null +++ b/packages/yasqe/src/editor/doc.ts @@ -0,0 +1,151 @@ +/** + * A small CodeMirror 5 `Doc`-compatible facade on top of a CodeMirror 6 `EditorView`. + * Positions are `{line, ch}` and 0-based, like in CodeMirror 5. + */ +import { EditorSelection, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { Position } from "./tokenizerRunner"; + +/** + * Mirrors CodeMirror 5's `clipPos`: a line before the document clips to the document start, a line past the + * end of the document clips to the end of the last line, and `ch` is clamped to the line length. + */ +export function posToOffset(doc: Text, pos: Position): number { + if (pos.line < 0) return 0; + if (pos.line >= doc.lines) return doc.length; + const line = doc.line(pos.line + 1); + return line.from + Math.max(0, Math.min(pos.ch, line.length)); +} + +export function offsetToPos(doc: Text, offset: number): Position { + const clamped = Math.max(0, Math.min(offset, doc.length)); + const line = doc.lineAt(clamped); + return { line: line.number - 1, ch: clamped - line.from }; +} + +export interface Selection { + anchor: Position; + head: Position; +} + +export class DocFacade { + constructor(private view: EditorView) {} + + private get doc(): Text { + return this.view.state.doc; + } + + public getValue(separator = "\n"): string { + return separator === "\n" ? this.doc.toString() : this.doc.toJSON().join(separator); + } + + public setValue(value: string) { + this.view.dispatch({ + changes: { from: 0, to: this.doc.length, insert: value }, + }); + } + + public getLine(n: number): string { + if (n < 0 || n >= this.doc.lines) return ""; + return this.doc.line(n + 1).text; + } + + public lineCount(): number { + return this.doc.lines; + } + + public firstLine(): number { + return 0; + } + + public lastLine(): number { + return this.doc.lines - 1; + } + + public indexFromPos(pos: Position): number { + return posToOffset(this.doc, pos); + } + + public posFromIndex(index: number): Position { + return offsetToPos(this.doc, index); + } + + public getCursor(start?: "from" | "to" | "head" | "anchor" | "start" | "end"): Position { + const range = this.view.state.selection.main; + let offset: number; + switch (start) { + case "from": + case "start": + offset = range.from; + break; + case "to": + case "end": + offset = range.to; + break; + case "anchor": + offset = range.anchor; + break; + case "head": + default: + offset = range.head; + } + return offsetToPos(this.doc, offset); + } + + public setCursor(pos: Position | number, ch?: number) { + const position: Position = typeof pos === "number" ? { line: pos, ch: ch ?? 0 } : pos; + const offset = posToOffset(this.doc, position); + this.view.dispatch({ selection: { anchor: offset }, scrollIntoView: true }); + } + + public setSelection(anchor: Position, head?: Position) { + const a = posToOffset(this.doc, anchor); + const h = head ? posToOffset(this.doc, head) : a; + this.view.dispatch({ selection: { anchor: a, head: h }, scrollIntoView: true }); + } + + public listSelections(): Selection[] { + return this.view.state.selection.ranges.map((r) => ({ + anchor: offsetToPos(this.doc, r.anchor), + head: offsetToPos(this.doc, r.head), + })); + } + + public somethingSelected(): boolean { + return this.view.state.selection.ranges.some((r) => !r.empty); + } + + public getSelection(lineSep = "\n"): string { + return this.view.state.selection.ranges + .filter((r) => !r.empty) + .map((r) => this.doc.sliceString(r.from, r.to)) + .join(lineSep); + } + + public replaceSelection(text: string, collapse: "around" | "start" | "end" = "end") { + this.view.dispatch( + this.view.state.changeByRange((range) => { + const end = range.from + text.length; + let sel: ReturnType; + if (collapse === "around") sel = EditorSelection.range(range.from, end); + else if (collapse === "start") sel = EditorSelection.cursor(range.from); + else sel = EditorSelection.cursor(end); + return { changes: { from: range.from, to: range.to, insert: text }, range: sel }; + }), + ); + } + + public getRange(from: Position, to: Position): string { + return this.doc.sliceString(posToOffset(this.doc, from), posToOffset(this.doc, to)); + } + + public replaceRange(text: string, from: Position, to?: Position) { + const f = posToOffset(this.doc, from); + const t = to ? posToOffset(this.doc, to) : f; + this.view.dispatch({ changes: { from: Math.min(f, t), to: Math.max(f, t), insert: text } }); + } + + public selectAll() { + this.view.dispatch({ selection: { anchor: 0, head: this.doc.length } }); + } +} diff --git a/packages/yasqe/src/editor/facade.ts b/packages/yasqe/src/editor/facade.ts new file mode 100644 index 00000000..15b29eb2 --- /dev/null +++ b/packages/yasqe/src/editor/facade.ts @@ -0,0 +1,521 @@ +/** + * `EditorFacade` wraps a CodeMirror 6 `EditorView` and exposes a CodeMirror 5 flavoured API + * (getValue/setValue, getDoc(), getTokenAt(), setOption(), gutter markers, events, ...). + * + * YASQE (and YASGUI) were written against CodeMirror 5. Keeping this compatibility surface + * means the rest of the code base — autocompleters, prefix utilities, tab management — + * keeps working without invasive rewrites, while the actual editor is CodeMirror 6. + */ +import { Compartment, EditorState, Extension, StateEffect } from "@codemirror/state"; +import { + EditorView, + ViewUpdate, + lineNumbers as lineNumbersExt, + drawSelection, + highlightActiveLine, + highlightActiveLineGutter, + rectangularSelection, + crosshairCursor, + dropCursor, +} from "@codemirror/view"; +import { history, indentMore, indentLess, selectAll, deleteLine, undo, redo } from "@codemirror/commands"; +import { bracketMatching, indentUnit as indentUnitFacet, getIndentation, indentString } from "@codemirror/language"; +import { closeBrackets } from "@codemirror/autocomplete"; +import { highlightSelectionMatches } from "@codemirror/search"; +import { sparql } from "./language"; +import { sparqlFolding, foldAt, toggleFoldAt } from "./folding"; +import { markerGutters, setGutterMarker, clearGutter, GutterName } from "./gutters"; +import { TokenizerRunner, Token, Position } from "./tokenizerRunner"; +import { extraKeysExtension, tabKeymap, defaultKeymaps, ExtraKeys } from "./keymap"; +import { sparqlAutocompletion, AutocompletionHost, HintFn } from "./autocompletion"; +import { DocFacade, posToOffset, offsetToPos } from "./doc"; + +/** + * Editor related configuration. Mirrors the subset of CodeMirror 5's `EditorConfiguration` + * that YASQE historically supported. + */ +export interface EditorOptions { + mode?: string; + value?: string; + lineNumbers?: boolean; + lineWrapping?: boolean; + readOnly?: boolean; + tabSize?: number; + indentUnit?: number; + indentWithTabs?: boolean; + theme?: string; + extraKeys?: ExtraKeys; + tabMode?: string; + foldGutter?: boolean | object; + gutters?: string[]; + matchBrackets?: boolean; + highlightSelectionMatches?: boolean | object; + autoCloseBrackets?: boolean; + fixedGutter?: boolean; + placeholder?: string; + /** Kept for backwards compatibility; ignored */ + autofocus?: boolean; + /** Kept for backwards compatibility; ignored */ + viewportMargin?: number; +} + +export type EventHandler = (...args: any[]) => void; + +export interface CursorCoords { + left: number; + top: number; + bottom: number; +} + +const THEME_CLASS_PREFIX = "cm-s-"; + +/** + * Classes applied to the editor root. CM6 owns `view.dom.className` and rewrites it whenever its computed + * attributes change (e.g. focus), so these must go through `EditorView.editorAttributes` rather than classList. + * `CodeMirror` is kept as a legacy hook for existing styling / tests. + */ +function themeClassExtension(theme: string | undefined): Extension { + const classes = ["CodeMirror"]; + if (theme) { + for (const t of theme.split(/\s+/)) { + if (t) classes.push(THEME_CLASS_PREFIX + t); + } + } + return EditorView.editorAttributes.of({ class: classes.join(" ") }); +} + +export abstract class EditorFacade { + public readonly view: EditorView; + public readonly runner: TokenizerRunner; + protected readonly editorOptions: EditorOptions; + private readonly doc: DocFacade; + private readonly eventHandlers: { [event: string]: EventHandler[] } = {}; + private readonly customOptions: { [key: string]: any } = {}; + + private readonly compartments = { + theme: new Compartment(), + readOnly: new Compartment(), + lineNumbers: new Compartment(), + lineWrapping: new Compartment(), + tabSize: new Compartment(), + indentUnit: new Compartment(), + extraKeys: new Compartment(), + tabMode: new Compartment(), + matchBrackets: new Compartment(), + highlightSelectionMatches: new Compartment(), + autoCloseBrackets: new Compartment(), + }; + + /** + * @param parent element the editor is appended to + * @param options editor options + */ + constructor(parent: HTMLElement, options: EditorOptions) { + this.editorOptions = { ...options }; + this.runner = new TokenizerRunner({ tabSize: options.tabSize, indentUnit: options.indentUnit }); + + const host: AutocompletionHost = { + getHintFn: () => this.getHintFn(), + onShown: (dom) => this.emit("autocompletionShown", dom), + onClose: () => this.emit("autocompletionClose"), + }; + + const state = EditorState.create({ + doc: options.value || "", + extensions: [ + sparql(), + sparqlFolding(this.runner), + markerGutters(), + this.compartments.lineNumbers.of( + options.lineNumbers === false ? [] : [lineNumbersExt(), highlightActiveLineGutter()], + ), + this.compartments.lineWrapping.of(options.lineWrapping === false ? [] : EditorView.lineWrapping), + this.compartments.readOnly.of([ + EditorState.readOnly.of(!!options.readOnly), + EditorView.editable.of(!options.readOnly), + ]), + this.compartments.tabSize.of(EditorState.tabSize.of(options.tabSize || 4)), + this.compartments.indentUnit.of(indentUnitFacet.of(indentUnitString(options))), + this.compartments.theme.of(themeClassExtension(options.theme)), + this.compartments.matchBrackets.of(options.matchBrackets === false ? [] : bracketMatching()), + this.compartments.highlightSelectionMatches.of( + options.highlightSelectionMatches ? highlightSelectionMatches() : [], + ), + this.compartments.autoCloseBrackets.of(options.autoCloseBrackets ? closeBrackets() : []), + history(), + drawSelection(), + dropCursor(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + EditorState.allowMultipleSelections.of(true), + sparqlAutocompletion(host), + this.compartments.extraKeys.of(extraKeysExtension(options.extraKeys, () => this)), + this.compartments.tabMode.of(tabKeymap(options.tabMode)), + defaultKeymaps(), + EditorView.updateListener.of((update) => this.handleUpdate(update)), + EditorView.domEventHandlers({ + blur: () => { + this.emit("blur"); + }, + focus: () => { + this.emit("focus"); + }, + }), + ], + }); + this.view = new EditorView({ state, parent }); + // Legacy back-reference (CM5 exposed `wrapper.CodeMirror`); used by Yasgui theme management + (this.view.dom as any).CodeMirror = this; + this.doc = new DocFacade(this.view); + this.customOptions.theme = options.theme; + if (options.mode) this.customOptions.mode = options.mode; + } + + /** Returns the hint function for the current completion session. Implemented by Yasqe. */ + protected abstract getHintFn(): HintFn | undefined; + + private handleUpdate(update: ViewUpdate) { + if (update.docChanged) { + let fromLine = Number.MAX_SAFE_INTEGER; + update.changes.iterChangedRanges((fromA) => { + const line = update.startState.doc.lineAt(fromA).number - 1; + if (line < fromLine) fromLine = line; + }); + this.runner.invalidate(fromLine === Number.MAX_SAFE_INTEGER ? 0 : fromLine); + this.emit("change", { origin: "input" }); + this.emit("changes"); + } + if (update.selectionSet || update.docChanged) { + this.emit("cursorActivity"); + } + } + + /* ------------------------------------------------------------------ */ + /* Events */ + /* ------------------------------------------------------------------ */ + + public on(eventName: string, handler: EventHandler): void { + if (!this.eventHandlers[eventName]) this.eventHandlers[eventName] = []; + this.eventHandlers[eventName].push(handler); + } + + public off(eventName: string, handler: EventHandler): void { + const handlers = this.eventHandlers[eventName]; + if (!handlers) return; + this.eventHandlers[eventName] = handlers.filter((h) => h !== handler); + } + + /** Emits an event. Handlers are invoked with `(this, ...data)` like CodeMirror 5 did. */ + public emit(eventName: string, ...data: any[]): void { + const handlers = this.eventHandlers[eventName]; + if (!handlers || !handlers.length) return; + for (const handler of handlers.slice()) { + handler(this, ...data); + } + } + + /* ------------------------------------------------------------------ */ + /* Document */ + /* ------------------------------------------------------------------ */ + + public getDoc(): DocFacade { + return this.doc; + } + + public getValue(): string { + return this.doc.getValue(); + } + + public setValue(value: string) { + this.doc.setValue(value); + } + + public getLine(line: number) { + return this.doc.getLine(line); + } + + public lineCount() { + return this.doc.lineCount(); + } + + public lastLine() { + return this.doc.lastLine(); + } + + public getCursor(start?: "from" | "to" | "head" | "anchor" | "start" | "end") { + return this.doc.getCursor(start); + } + + public setCursor(pos: Position | number, ch?: number) { + this.doc.setCursor(pos, ch); + } + + public setSelection(anchor: Position, head?: Position) { + this.doc.setSelection(anchor, head); + } + + public somethingSelected() { + return this.doc.somethingSelected(); + } + + public getSelection() { + return this.doc.getSelection(); + } + + public replaceSelection(text: string, collapse?: "around" | "start" | "end") { + this.doc.replaceSelection(text, collapse); + } + + public getRange(from: Position, to: Position) { + return this.doc.getRange(from, to); + } + + public replaceRange(text: string, from: Position, to?: Position) { + this.doc.replaceRange(text, from, to); + } + + public indexFromPos(pos: Position) { + return this.doc.indexFromPos(pos); + } + + public posFromIndex(index: number) { + return this.doc.posFromIndex(index); + } + + /** + * CM5 compatibility: batch operations. CodeMirror 6 already batches DOM updates, + * so the callback is simply executed. + */ + public operation(fn: () => T): T { + return fn(); + } + + /* ------------------------------------------------------------------ */ + /* Tokens */ + /* ------------------------------------------------------------------ */ + + public getTokenAt(pos: Position, precise = false): Token { + return this.runner.getTokenAt(this.view.state.doc, pos, precise); + } + + public getTokenTypeAt(pos: Position): string | null { + return this.runner.getTokenTypeAt(this.view.state.doc, pos); + } + + /* ------------------------------------------------------------------ */ + /* Options */ + /* ------------------------------------------------------------------ */ + + public getOption(name: string): any { + if (name in this.customOptions) return this.customOptions[name]; + return (this.editorOptions as any)[name]; + } + + public setOption(name: string, value: any) { + (this.editorOptions as any)[name] = value; + switch (name) { + case "theme": + this.setTheme(value); + break; + case "readOnly": { + const readOnly = !!value && value !== "nocursor" ? true : !!value; + this.view.dispatch({ + effects: this.compartments.readOnly.reconfigure([ + EditorState.readOnly.of(readOnly), + EditorView.editable.of(!readOnly), + ]), + }); + break; + } + case "lineNumbers": + this.view.dispatch({ + effects: this.compartments.lineNumbers.reconfigure( + value ? [lineNumbersExt(), highlightActiveLineGutter()] : [], + ), + }); + break; + case "lineWrapping": + this.view.dispatch({ + effects: this.compartments.lineWrapping.reconfigure(value ? EditorView.lineWrapping : []), + }); + break; + case "tabSize": + this.view.dispatch({ effects: this.compartments.tabSize.reconfigure(EditorState.tabSize.of(value || 4)) }); + break; + case "indentUnit": + case "indentWithTabs": + this.view.dispatch({ + effects: this.compartments.indentUnit.reconfigure(indentUnitFacet.of(indentUnitString(this.editorOptions))), + }); + break; + case "extraKeys": + this.view.dispatch({ + effects: this.compartments.extraKeys.reconfigure(extraKeysExtension(value, () => this)), + }); + break; + case "tabMode": + this.view.dispatch({ effects: this.compartments.tabMode.reconfigure(tabKeymap(value)) }); + break; + case "matchBrackets": + this.view.dispatch({ effects: this.compartments.matchBrackets.reconfigure(value ? bracketMatching() : []) }); + break; + case "highlightSelectionMatches": + this.view.dispatch({ + effects: this.compartments.highlightSelectionMatches.reconfigure(value ? highlightSelectionMatches() : []), + }); + break; + case "autoCloseBrackets": + this.view.dispatch({ effects: this.compartments.autoCloseBrackets.reconfigure(value ? closeBrackets() : []) }); + break; + default: + this.customOptions[name] = value; + } + } + + private setTheme(theme: string | undefined) { + this.view.dispatch({ effects: this.compartments.theme.reconfigure(themeClassExtension(theme)) }); + this.customOptions.theme = theme; + } + + /* ------------------------------------------------------------------ */ + /* Gutters */ + /* ------------------------------------------------------------------ */ + + public setGutterMarker(line: number, gutterName: GutterName, element: HTMLElement | null) { + setGutterMarker(this.view, line, gutterName, element); + } + + public clearGutter(gutterName: GutterName) { + clearGutter(this.view, gutterName); + } + + /* ------------------------------------------------------------------ */ + /* Commands / editing helpers */ + /* ------------------------------------------------------------------ */ + + public execCommand(command: string): void { + switch (command) { + case "selectAll": + selectAll(this.view); + break; + case "indentMore": + indentMore(this.view); + break; + case "indentLess": + indentLess(this.view); + break; + case "deleteLine": + deleteLine(this.view); + break; + case "undo": + undo(this.view); + break; + case "redo": + redo(this.view); + break; + default: + // eslint-disable-next-line no-console + console.warn(`Unknown editor command: ${command}`); + } + } + + /** + * Re-indents the given (0-based) line using the language's indentation rules + * (CM5 `indentLine(line, "smart")`). + */ + public indentLine(lineNumber: number) { + const line = this.view.state.doc.line(lineNumber + 1); + const indentation = getIndentation(this.view.state, line.from); + if (indentation === null) return; + const currentWs = /^\s*/.exec(line.text)?.[0] || ""; + const newWs = indentString(this.view.state, indentation); + if (currentWs === newWs) return; + this.view.dispatch({ changes: { from: line.from, to: line.from + currentWs.length, insert: newWs } }); + } + + /** Fold the block starting on the given line. */ + public foldCode(line: number | Position) { + const pos = typeof line === "number" ? { line, ch: 0 } : line; + foldAt(this.view, posToOffset(this.view.state.doc, pos)); + } + + /** Toggle folding of the block starting on the given line. */ + public toggleFold(line: number | Position) { + const pos = typeof line === "number" ? { line, ch: 0 } : line; + toggleFoldAt(this.view, posToOffset(this.view.state.doc, pos)); + } + + /* ------------------------------------------------------------------ */ + /* DOM / layout */ + /* ------------------------------------------------------------------ */ + + public getWrapperElement(): HTMLElement { + return this.view.dom; + } + + public getInputField(): HTMLElement { + return this.view.contentDOM; + } + + public getScrollerElement(): HTMLElement { + return this.view.scrollDOM; + } + + public focus() { + this.view.focus(); + } + + public hasFocus() { + return this.view.hasFocus; + } + + public refresh() { + this.view.requestMeasure(); + } + + public setSize(width: number | string | null, height: number | string | null) { + const toCss = (v: number | string) => (typeof v === "number" ? `${v}px` : v); + if (width !== null && width !== undefined) this.view.dom.style.width = toCss(width); + if (height !== null && height !== undefined) this.view.dom.style.height = toCss(height); + this.view.requestMeasure(); + } + + /** Coordinates of a position, relative to the page (CM5 `cursorCoords(where, "page")`). */ + public cursorCoords(where?: Position | boolean): CursorCoords { + const pos = + where && typeof where === "object" + ? posToOffset(this.view.state.doc, where) + : where === false + ? this.view.state.selection.main.anchor + : this.view.state.selection.main.head; + const rect = this.view.coordsAtPos(pos); + if (!rect) { + const domRect = this.view.dom.getBoundingClientRect(); + return { left: domRect.left, top: domRect.top, bottom: domRect.top }; + } + return { left: rect.left, top: rect.top, bottom: rect.bottom }; + } + + /** Position for page coordinates (CM5 `coordsChar`). */ + public coordsChar(coords: { left: number; top: number }): Position { + const offset = this.view.posAtCoords({ x: coords.left, y: coords.top }); + return offsetToPos(this.view.state.doc, offset === null ? this.view.state.doc.length : offset); + } + + /** Adds an extension at runtime (e.g. by plugins). */ + public addExtension(extension: Extension) { + this.view.dispatch({ effects: StateEffect.appendConfig.of(extension) }); + } + + /** Destroys the editor view. */ + public destroyEditor() { + this.view.destroy(); + } +} + +/** Compute the indent unit string from the options (CM5 `indentUnit` + `indentWithTabs`). */ +function indentUnitString(options: EditorOptions) { + if (options.indentWithTabs) return "\t"; + return " ".repeat(options.indentUnit || 2); +} diff --git a/packages/yasqe/src/editor/folding.ts b/packages/yasqe/src/editor/folding.ts new file mode 100644 index 00000000..6d2d2a32 --- /dev/null +++ b/packages/yasqe/src/editor/folding.ts @@ -0,0 +1,216 @@ +/** + * Code folding for SPARQL: folds bracket blocks (`{ ... }`) and the block of PREFIX declarations. + * + * Replaces the CodeMirror 5 combination of `fold.brace` and the custom `fold.prefix` helper. + * Because the SPARQL grammar is a stream parser (no syntax tree with nesting), folding is + * implemented as a `foldService` working on the tokenizer output. + */ +import { EditorState, Extension, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { foldService, foldGutter, foldEffect, unfoldEffect, foldedRanges, foldable } from "@codemirror/language"; +import { TokenizerRunner, Position } from "./tokenizerRunner"; + +export interface FoldRange { + from: number; + to: number; +} + +const PREFIX_KEYWORD = "PREFIX "; + +function tokenTypeAt(runner: TokenizerRunner, doc: Text, pos: Position) { + return runner.getTokenAt(doc, pos).type; +} + +/** + * Finds the fold range for a block starting with a bracket on the given line. Mirrors the + * CodeMirror 5 `brace` range finder: the *last* opening bracket on the line is used. + */ +export function braceFoldRange(runner: TokenizerRunner, doc: Text, line: number): FoldRange | undefined { + const lineText = doc.line(line + 1).text; + const pairs: { [open: string]: string } = { "{": "}", "[": "]", "(": ")" }; + let startCh = -1; + let open = ""; + for (let ch = lineText.length - 1; ch >= 0; ch--) { + const c = lineText[ch]; + if (pairs[c] && tokenTypeAt(runner, doc, { line, ch: ch + 1 }) === "punc") { + startCh = ch; + open = c; + break; + } + } + if (startCh < 0) return; + const close = pairs[open]; + + // Scan forward for the matching bracket, taking nesting into account and ignoring brackets inside strings/comments + let depth = 1; + const lastLine = doc.lines - 1; + for (let l = line; l <= lastLine; l++) { + const text = doc.line(l + 1).text; + let ch = l === line ? startCh + 1 : 0; + for (; ch < text.length; ch++) { + const c = text[ch]; + if (c !== open && c !== close) continue; + if (tokenTypeAt(runner, doc, { line: l, ch: ch + 1 }) !== "punc") continue; + if (c === open) depth++; + else if (--depth === 0) { + if (l === line) return; // opening and closing bracket on the same line: nothing to fold + return { from: doc.line(line + 1).from + startCh + 1, to: doc.line(l + 1).from + ch }; + } + } + } + return; +} + +/** Returns the char index of the first PREFIX keyword on the line (or undefined) */ +export function findFirstPrefix( + runner: TokenizerRunner, + doc: Text, + line: number, + startFromCharIndex = 0, +): number | undefined { + const lineText = doc.line(line + 1).text; + if (!lineText) return undefined; + const charIndex = lineText.toUpperCase().indexOf(PREFIX_KEYWORD, startFromCharIndex); + if (charIndex >= 0 && tokenTypeAt(runner, doc, { line, ch: charIndex + 1 }) === "keyword") { + return charIndex; + } + return undefined; +} + +/** Returns the (0-based) line of the first PREFIX declaration in the document */ +export function findFirstPrefixLine(runner: TokenizerRunner, doc: Text): number | undefined { + for (let i = 0; i < doc.lines; i++) { + const firstPrefix = findFirstPrefix(runner, doc, i); + if (firstPrefix !== undefined && firstPrefix >= 0) return i; + } + return undefined; +} + +/** + * Fold range for the block of PREFIX declarations. Only the first PREFIX declaration of the query + * starts a foldable block. Ported from the CodeMirror 5 `fold.prefix` helper. + */ +export function prefixFoldRange(runner: TokenizerRunner, doc: Text, line: number): FoldRange | undefined { + // Only the opening prefix declaration is foldable + for (let i = line - 1; i >= 0; i--) { + if ( + doc + .line(i + 1) + .text.toUpperCase() + .indexOf(PREFIX_KEYWORD) >= 0 + ) + return; + } + const prefixStart = findFirstPrefix(runner, doc, line); + if (prefixStart === undefined) return; + + const getNextNonWsToken = (l: number, ch: number) => { + let token = runner.getTokenAt(doc, { line: l, ch }); + if (token.end < ch) return undefined; + while (token.type === "ws") { + const next = runner.getTokenAt(doc, { line: l, ch: token.end + 1 }); + if (next.end < token.end + 1) return undefined; + token = next; + } + return token; + }; + const getLastPrefixPos = (l: number, ch: number): number => { + const keyword = runner.getTokenAt(doc, { line: l, ch: ch + 1 }); + if (keyword.type !== "keyword") return -1; + const shortname = getNextNonWsToken(l, keyword.end + 1); + if (!shortname || shortname.type !== "string-2") return -1; + const uri = getNextNonWsToken(l, shortname.end + 1); + if (!uri || uri.type !== "variable-3") return -1; + return uri.end; + }; + + let prefixEndChar = getLastPrefixPos(line, prefixStart); + let prefixEndLine = line; + let stopAtNextLine = false; + for (let i = line; i < doc.lines; i++) { + if (stopAtNextLine) break; + const text = doc.line(i + 1).text; + let pos = i === line ? prefixStart + 1 : 0; + for (;;) { + if (!stopAtNextLine && text.indexOf("{") >= 0) stopAtNextLine = true; + const nextPrefixDeclaration = text.toUpperCase().indexOf(PREFIX_KEYWORD, pos); + if (nextPrefixDeclaration < 0) break; + const endCh = getLastPrefixPos(i, nextPrefixDeclaration); + if (endCh > 0) { + prefixEndChar = endCh; + prefixEndLine = i; + pos = prefixEndChar; + } + pos++; + } + } + if (prefixEndChar < 0) return; + const from = doc.line(line + 1).from + prefixStart + PREFIX_KEYWORD.length; + const to = doc.line(prefixEndLine + 1).from + prefixEndChar; + if (to <= from) return; + return { from, to }; +} + +/** Combined range finder (brace first, then prefix block) for a given 0-based line */ +export function foldRangeForLine(runner: TokenizerRunner, doc: Text, line: number): FoldRange | undefined { + return braceFoldRange(runner, doc, line) || prefixFoldRange(runner, doc, line); +} + +/** Folding extensions: fold service + fold gutter */ +export function sparqlFolding(runner: TokenizerRunner): Extension { + return [ + foldService.of((state: EditorState, lineStart: number) => { + const line = state.doc.lineAt(lineStart).number - 1; + return foldRangeForLine(runner, state.doc, line) || null; + }), + foldGutter({ + markerDOM(open) { + const el = document.createElement("span"); + el.className = `cm-foldMarker CodeMirror-foldgutter-${open ? "open" : "folded"}`; + el.title = open ? "Fold" : "Unfold"; + return el; + }, + }), + ]; +} + +/** Fold (or unfold, when already folded) the block starting at the given position. */ +export function toggleFoldAt(view: EditorView, pos: number) { + const line = view.state.doc.lineAt(pos); + // Unfold when the line is (partly) hidden by a fold + let unfolded = false; + foldedRanges(view.state).between(line.from, line.to, (from, to) => { + if (from >= line.from && from <= line.to) { + view.dispatch({ effects: unfoldEffect.of({ from, to }) }); + unfolded = true; + return false; + } + return; + }); + if (unfolded) return; + const range = foldable(view.state, line.from, line.to); + if (range) view.dispatch({ effects: foldEffect.of(range) }); +} + +/** Fold the block starting at the given position (no-op if nothing foldable or already folded) */ +export function foldAt(view: EditorView, pos: number) { + const line = view.state.doc.lineAt(pos); + let alreadyFolded = false; + foldedRanges(view.state).between(line.from, line.to, () => { + alreadyFolded = true; + return false; + }); + if (alreadyFolded) return; + const range = foldable(view.state, line.from, line.to); + if (range) view.dispatch({ effects: foldEffect.of(range) }); +} + +/** Unfold any fold that starts on the line of the given position (no-op if not folded) */ +export function unfoldAt(view: EditorView, pos: number) { + const line = view.state.doc.lineAt(pos); + const effects: ReturnType[] = []; + foldedRanges(view.state).between(line.from, line.to, (from, to) => { + if (from >= line.from && from <= line.to) effects.push(unfoldEffect.of({ from, to })); + }); + if (effects.length) view.dispatch({ effects }); +} diff --git a/packages/yasqe/src/editor/gutters.ts b/packages/yasqe/src/editor/gutters.ts new file mode 100644 index 00000000..6b209af5 --- /dev/null +++ b/packages/yasqe/src/editor/gutters.ts @@ -0,0 +1,114 @@ +/** + * Marker gutters replacing the CodeMirror 5 `setGutterMarker` / `clearGutter` API. + * + * Two named gutters are supported: `gutterErrorBar` (syntax errors) and `gutterConstructWarning` + * (undefined variables in CONSTRUCT templates). Markers are arbitrary DOM elements, exactly like + * in CodeMirror 5, so the existing tooltip logic keeps working. + */ +import { EditorState, StateEffect, StateField, RangeSet, Extension } from "@codemirror/state"; +import { EditorView, gutter, GutterMarker } from "@codemirror/view"; + +export type GutterName = "gutterErrorBar" | "gutterConstructWarning"; +export const GUTTER_NAMES: GutterName[] = ["gutterErrorBar", "gutterConstructWarning"]; + +class ElementMarker extends GutterMarker { + constructor(readonly element: HTMLElement) { + super(); + } + eq(other: ElementMarker) { + return other.element === this.element; + } + toDOM() { + return this.element; + } +} + +interface SetMarkerSpec { + gutter: GutterName; + /** 0-based line number */ + line: number; + element: HTMLElement | null; +} + +const setMarkerEffect = StateEffect.define(); +const clearGutterEffect = StateEffect.define(); + +function createGutter(name: GutterName): { field: StateField>; extension: Extension } { + const field = StateField.define>({ + create() { + return RangeSet.empty; + }, + update(markers, tr) { + markers = markers.map(tr.changes); + for (const effect of tr.effects) { + if (effect.is(clearGutterEffect) && effect.value === name) { + markers = RangeSet.empty; + } else if (effect.is(setMarkerEffect) && effect.value.gutter === name) { + const { line, element } = effect.value; + if (line < 0 || line >= tr.state.doc.lines) continue; + const pos = tr.state.doc.line(line + 1).from; + markers = markers.update({ filter: (from) => from !== pos }); + if (element) { + markers = markers.update({ add: [new ElementMarker(element).range(pos)] }); + } + } + } + return markers; + }, + }); + const extension = [ + field, + gutter({ + class: `cm-gutter-${name} CodeMirror-${name}`, + markers: (view) => view.state.field(field), + initialSpacer: () => new ElementMarker(spacerElement()), + }), + ]; + return { field, extension }; +} + +function spacerElement() { + const el = document.createElement("span"); + el.className = "cm-gutter-spacer"; + el.textContent = "\u00a0"; + return el; +} + +// Fields are defined once so that they are shared between all editor instances +const gutterFields = new Map>>(); +const gutterExtensions: Extension[] = []; +for (const name of GUTTER_NAMES) { + const { field, extension } = createGutter(name); + gutterFields.set(name, field); + gutterExtensions.push(extension); +} + +/** Extension enabling the yasqe marker gutters */ +export function markerGutters(): Extension { + return gutterExtensions; +} + +/** Set (or remove when `element` is null) the marker of `line` (0-based) in the given gutter */ +export function setGutterMarker(view: EditorView, line: number, name: GutterName, element: HTMLElement | null) { + view.dispatch({ effects: setMarkerEffect.of({ gutter: name, line, element }) }); +} + +/** Remove all markers from the given gutter */ +export function clearGutter(view: EditorView, name: GutterName) { + view.dispatch({ effects: clearGutterEffect.of(name) }); +} + +/** Returns the marker elements of the gutter, keyed by 0-based line number */ +export function getGutterMarkers(state: EditorState, name: GutterName): Map { + const result = new Map(); + const field = gutterFields.get(name); + if (!field) return result; + const set = state.field(field, false); + if (!set) return result; + const iter = set.iter(); + while (iter.value) { + result.set(state.doc.lineAt(iter.from).number - 1, iter.value.element); + iter.next(); + } + return result; +} diff --git a/packages/yasqe/src/editor/keymap.ts b/packages/yasqe/src/editor/keymap.ts new file mode 100644 index 00000000..9f8d2eca --- /dev/null +++ b/packages/yasqe/src/editor/keymap.ts @@ -0,0 +1,133 @@ +/** + * Keymap helpers for the CodeMirror 6 based editor. + * + * Yasqe historically exposed key bindings using CodeMirror 5 key names + * (e.g. "Shift-Ctrl-K", "Cmd-Enter", "Esc"). To stay backwards compatible we + * accept those names in the `extraKeys` config option and convert them to the + * CodeMirror 6 keymap format. + */ +import { Extension, Prec } from "@codemirror/state"; +import { EditorView, KeyBinding, keymap } from "@codemirror/view"; +import { defaultKeymap, historyKeymap, indentWithTab, indentLess, indentMore, insertTab } from "@codemirror/commands"; +import { searchKeymap } from "@codemirror/search"; +import { foldKeymap } from "@codemirror/language"; +import { closeBracketsKeymap } from "@codemirror/autocomplete"; + +/** + * A key handler in the (CM5-compatible) `extraKeys` config. It receives the + * Yasqe instance. Returning `false` lets the event propagate to the next + * binding; anything else is considered "handled". + */ +export type ExtraKeyHandler = (yasqe: T) => any; +export type ExtraKeys = { [key: string]: ExtraKeyHandler | string }; + +const MODIFIERS = new Set(["shift", "ctrl", "cmd", "alt", "mod", "meta"]); + +const KEY_NAME_MAP: { [cm5: string]: string } = { + esc: "Escape", + space: "Space", + enter: "Enter", + tab: "Tab", + backspace: "Backspace", + delete: "Delete", + del: "Delete", + insert: "Insert", + home: "Home", + end: "End", + pageup: "PageUp", + pagedown: "PageDown", + left: "ArrowLeft", + right: "ArrowRight", + up: "ArrowUp", + down: "ArrowDown", +}; + +/** + * Convert a CodeMirror 5 style key name (e.g. "Shift-Ctrl-K") to a + * CodeMirror 6 key name ("Shift-Ctrl-k"). + * + * CM6 uses `event.key` values, so single characters must be lower case + * (unless shift is pressed), and special keys use their DOM names. + */ +export function convertKeyName(cm5Name: string): string { + const parts = cm5Name.split(/-(?!$)/); + const modifiers: string[] = []; + let key = parts[parts.length - 1]; + for (let i = 0; i < parts.length - 1; i++) { + const mod = parts[i].toLowerCase(); + if (mod === "meta") modifiers.push("Cmd"); + else if (MODIFIERS.has(mod)) modifiers.push(parts[i][0].toUpperCase() + mod.slice(1)); + } + const lowerKey = key.toLowerCase(); + if (KEY_NAME_MAP[lowerKey]) { + key = KEY_NAME_MAP[lowerKey]; + } else if (/^f\d{1,2}$/i.test(key)) { + key = key.toUpperCase(); + } else if (key.length === 1) { + // Letters are lower case in CM6 (they match event.key). When shift is part of + // the binding CM6 also accepts the lower case form and derives the shifted variant. + key = key.toLowerCase(); + } + return [...modifiers, key].join("-"); +} + +/** + * Build a keymap extension from a CM5-style `extraKeys` object. + * + * @param extraKeys CM5-style key -> handler map + * @param getYasqe returns the Yasqe instance (passed to the handler) + * @param commands named commands that string handlers may refer to + */ +export function extraKeysExtension( + extraKeys: ExtraKeys | undefined, + getYasqe: () => T, + commands: { [name: string]: (yasqe: T) => any } = {}, +): Extension { + if (!extraKeys) return []; + const bindings: KeyBinding[] = []; + for (const cm5Key of Object.keys(extraKeys)) { + const handler = extraKeys[cm5Key]; + const fn: ExtraKeyHandler | undefined = typeof handler === "string" ? commands[handler] : handler; + if (!fn) continue; + bindings.push({ + key: convertKeyName(cm5Key), + run: () => { + const result = fn(getYasqe()); + return result !== false; + }, + preventDefault: true, + }); + } + // Highest precedence so user defined keys win over the built-in ones. + return Prec.highest(keymap.of(bindings)); +} + +/** + * Tab handling that mimics CM5's `tabMode: "indent"` behaviour: Tab indents, + * Shift-Tab dedents. When `tabMode` is anything else, tab inserts a tab + * character (or spaces when `indentWithTabs` is false). + */ +export function tabKeymap(tabMode: string | undefined): Extension { + if (tabMode === "indent") { + return keymap.of([ + { + key: "Tab", + run: (view: EditorView) => { + // Indent selection when something is selected, otherwise behave like indentMore. + return indentMore(view); + }, + shift: indentLess, + }, + ]); + } + if (tabMode === "default") return keymap.of([indentWithTab]); + return keymap.of([{ key: "Tab", run: insertTab, shift: indentLess }]); +} + +/** + * The default set of key bindings: standard editing, history, search, folding, + * bracket closing and autocompletion. + */ +export function defaultKeymaps(): Extension { + return keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap]); +} diff --git a/packages/yasqe/src/editor/language.ts b/packages/yasqe/src/editor/language.ts new file mode 100644 index 00000000..5e0a4031 --- /dev/null +++ b/packages/yasqe/src/editor/language.ts @@ -0,0 +1,47 @@ +/** + * CodeMirror 6 language definition for SPARQL 1.1, built on top of the + * legacy (stream based) LL(1) tokenizer in ../../grammar/tokenizer.ts. + * + * Every token style emitted by the tokenizer gets its own lezer Tag and a + * matching `cm-