diff --git a/vue3-src/app/components/ResourceEditor.vue b/vue3-src/app/components/ResourceEditor.vue index 503d273e..b07b581f 100644 --- a/vue3-src/app/components/ResourceEditor.vue +++ b/vue3-src/app/components/ResourceEditor.vue @@ -59,41 +59,51 @@ -
- +
+
+ + mdi-undo + + + mdi-redo + + + + mdi-arrow-up + + + mdi-arrow-down + + + mdi-arrow-left + + + mdi-arrow-right + + + mdi-keyboard-tab + + + + mdi-magnify + + + mdi-format-indent-increase + +
+ diff --git a/vue3-src/app/pages/fhirpath.vue b/vue3-src/app/pages/fhirpath.vue index 36f979c7..7b5ee807 100644 --- a/vue3-src/app/pages/fhirpath.vue +++ b/vue3-src/app/pages/fhirpath.vue @@ -589,6 +589,10 @@ const tabDetails = computed(() => [ tabSubName: "(test input)", show: true, enabled: true, + // Keep the Monaco-backed ResourceEditor mounted across single-pane tab + // switches; tearing it down and re-creating it on every switch is very + // expensive (workers + ResizeObserver + DOM) and can crash/hang the UI. + keepMounted: true, }, { iconName: "mdi-chart-box-multiple-outline", @@ -623,6 +627,10 @@ const tabDetails = computed(() => [ tabName: "DEBUG", show: true, enabled: true, + // The DEBUG tab hosts a Monaco-backed ResourceEditor; keep it mounted + // across single-pane tab switches so Monaco isn't disposed/re-created + // on every switch (which can crash/hang the UI). + keepMounted: true, }, ]) diff --git a/vue3-src/app/pages/fml.vue b/vue3-src/app/pages/fml.vue index 03c6a841..f4d045e3 100644 --- a/vue3-src/app/pages/fml.vue +++ b/vue3-src/app/pages/fml.vue @@ -224,6 +224,10 @@ const tabDetails = computed(() => [ tabName: "Map", show: true, enabled: true, + // Keep Monaco-backed ResourceEditor tabs mounted across single-pane tab + // switches; tearing down and recreating Monaco on every switch is very + // expensive (workers + ResizeObserver + DOM) and can crash/hang the UI. + keepMounted: true, }, { iconName: "mdi-clipboard-text-outline", @@ -232,6 +236,7 @@ const tabDetails = computed(() => [ tabHeaderName: "Test Input Resource", show: true, enabled: true, + keepMounted: true, }, { iconName: "mdi-tree-outline", @@ -240,6 +245,7 @@ const tabDetails = computed(() => [ title: "Logical Models Required\n(Structure Definitions)", show: true, enabled: true, + keepMounted: true, }, { iconName: "mdi-format-list-bulleted", @@ -254,6 +260,7 @@ const tabDetails = computed(() => [ title: "Map Output", show: true, enabled: true, + keepMounted: true, }, { iconName: "mdi-alert-circle-outline", @@ -275,6 +282,7 @@ const tabDetails = computed(() => [ tabName: "Debug", show: showAdvancedSettings.value, enabled: true, + keepMounted: true, }, ]) diff --git a/vue3-src/app/utils/monaco-fhirpath.ts b/vue3-src/app/utils/monaco-fhirpath.ts new file mode 100644 index 00000000..9dbf190f --- /dev/null +++ b/vue3-src/app/utils/monaco-fhirpath.ts @@ -0,0 +1,190 @@ +/** + * Monaco Monarch language definition for FHIRPath / FHIR Mapping Language (FML). + * + * Ported from `helpers/fhirpath_highlighter.ts` (Ace highlight rules) so that the + * vue3-src Monaco-based ResourceEditor highlights FML maps and FHIRPath expressions + * with the same token semantics as the legacy Ace editor. + * + * Language id: `fhirpath` + * Theme rules below are merged into the default Monaco "vs" theme via + * `monaco.editor.defineTheme` so the FHIRPath tokens render with sensible colours + * even though they are not part of Monaco's built-in token palette. + */ +import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api' + +let registered = false + +export function setupFhirPathLanguage(monaco: typeof monacoType): void { + if (registered) return + registered = true + + monaco.languages.register({ id: 'fhirpath' }) + + monaco.languages.setLanguageConfiguration('fhirpath', { + comments: { lineComment: '//', blockComment: ['/*', '*/'] }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'] + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: "'", close: "'", notIn: ['string'] }, + { open: '`', close: '`', notIn: ['string'] } + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: "'", close: "'" }, + { open: '`', close: '`' } + ] + }) + + // Monarch tokenizer ported from helpers/fhirpath_highlighter.ts + monaco.languages.setMonarchTokensProvider('fhirpath', { + defaultToken: '', + tokenPostfix: '.fhirpath', + + existence: ['empty', 'exists', 'all', 'allTrue', 'anyTrue', 'allFalse', 'anyFalse', 'subsetOf', 'supersetOf', 'count', 'distinct', 'isDistinct'], + filtering: ['where', 'select', 'repeat', 'ofType'], + subsetting: ['single', 'first', 'last', 'tail', 'skip', 'take', 'intersect', 'exclude'], + combining: ['union', 'combine'], + conversion: ['iif', 'toBoolean', 'convertsToBoolean', 'toInteger', 'convertsToInteger', 'toDate', 'convertsToDate', 'toDateTime', 'convertsToDateTime', 'toDecimal', 'convertsToDecimal', 'toQuantity', 'convertsToQuantity', 'toString', 'convertsToString', 'toTime', 'convertsToTime'], + strings: ['indexOf', 'substring', 'startsWith', 'endsWith', 'contains', 'upper', 'lower', 'replace', 'matches', 'replaceMatches', 'length', 'toChars', 'split', 'join', 'encode', 'decode'], + math: ['abs', 'ceiling', 'exp', 'floor', 'ln', 'log', 'power', 'round', 'sqrt', 'truncate'], + treenav: ['children', 'descendants'], + utility: ['trace', 'now', 'timeOfDay', 'today', 'not'], + types: ['as', 'is'], + keywords: ['as', 'is', 'in', 'contains', 'and', 'or', 'xor', 'implies'], + mathKeywords: ['div', 'mod'], + units: ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond'], + + tokenizer: { + root: [ + // Strings (single-quoted) + [/'/, { token: 'string.quote', next: '@fhirString' }], + // Backtick identifiers + [/`/, { token: 'identifier.quote', next: '@bstring' }], + + // Numbers / quantities / temporals (order matters: most specific first) + [/0[xX][0-9a-fA-F]+\b/, 'number.hex'], + [/\b\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b\s+'/, { token: 'number.quantity', next: '@fhirQString' }], + [/\b\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b\s+(?:years|months|weeks|days|hours|minutes|seconds|milliseconds|year|month|week|day|hour|minute|second|millisecond)\b/, 'number.quantity'], + [/\b\d+L\b/, 'number'], + [/\b\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/, 'number.float'], + // Use a character class for the leading `@` so Monaco's Monarch tokenizer + // does not treat `@T`/`@[`... as an attribute reference (which produces + // "language definition does not contain attribute 'T'" errors). + [/[@]T[0-9][0-9]:[0-9][0-9](?::[0-9][0-9])?(?:\.[0-9]+)?/, 'string.time'], + [/[@][0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(?::[0-9][0-9])?(?:\.[0-9]+)?(?:Z|[+\-][0-9]{2}:[0-9]{2})/, 'string.datetime'], + [/[@][0-9]{4}(?:-[0-9]{2}(?:-[0-9]{2})?)?/, 'string.date'], + + // Boolean literals + [/\b(?:true|false)\b/, 'constant.language'], + + // Comments + [/\/\/.*$/, 'comment'], + [/\/\*/, { token: 'comment', next: '@blockComment' }], + + // Function names followed by `(` + [/\b(empty|exists|all|allTrue|anyTrue|allFalse|anyFalse|subsetOf|supersetOf|count|distinct|isDistinct)\b(?=\s*\()/, 'support.function.existence'], + [/\b(where|select|repeat|ofType)\b(?=\s*\()/, 'support.function.filtering'], + [/\b(single|first|last|tail|skip|take|intersect|exclude)\b(?=\s*\()/, 'support.function.subsetting'], + [/\b(union|combine)\b(?=\s*\()/, 'support.function.combining'], + [/\b(iif|toBoolean|convertsToBoolean|toInteger|convertsToInteger|toDate|convertsToDate|toDateTime|convertsToDateTime|toDecimal|convertsToDecimal|toQuantity|convertsToQuantity|toString|convertsToString|toTime|convertsToTime)\b(?=\s*\()/, 'support.function.conversion'], + [/\b(indexOf|substring|startsWith|endsWith|contains|upper|lower|replace|matches|replaceMatches|length|toChars|split|join|encode|decode)\b(?=\s*\()/, 'support.function.strings'], + [/\b(abs|ceiling|exp|floor|ln|log|power|round|sqrt|truncate)\b(?=\s*\()/, 'support.function.math'], + [/\b(children|descendants)\b(?=\s*\()/, 'support.function.treenav'], + [/\b(trace|now|timeOfDay|today|not)\b(?=\s*\()/, 'support.function.utility'], + [/\b(as|is)\b(?=\s*\()/, 'support.function.types'], + [/\baggregate\b/, 'support.function.aggregates'], + + // Keywords + [/\b(?:as|is|in|contains|and|or|xor|implies)\b/, 'keyword'], + [/\$this\b/, 'variable.language.this'], + [/\$index\b/, 'variable.language.index'], + [/\$total\b/, 'variable.language.total'], + [/\b(?:div|mod)\b/, 'keyword.operator.math'], + + // External constants (%foo or %`escaped name`) + [/%`/, { token: 'variable.parameter', next: '@fhirVariable' }], + [/%\w+/, 'variable.parameter'], + + // Operators / punctuation + [/[!=<>~]+|[+\-*|]/, 'operator'], + [/[,]/, 'delimiter'], + [/\./, 'delimiter.dot'], + [/[(){}[\]]/, '@brackets'], + + // Identifiers + [/[A-Za-z_][A-Za-z0-9_]*/, 'identifier'], + + // Whitespace + [/\s+/, 'white'] + ], + + fhirString: [ + [/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\/fnrt'])/, 'string.escape'], + [/'/, { token: 'string.quote', next: '@pop' }], + [/./, 'string'] + ], + + fhirQString: [ + [/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\/fnrt'])/, 'string.escape'], + [/'/, { token: 'number.quantity', next: '@pop' }], + [/./, 'number.quantity'] + ], + + bstring: [ + [/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\/fnrt`])/, 'string.escape'], + [/`/, { token: 'identifier.quote', next: '@pop' }], + [/./, 'identifier'] + ], + + fhirVariable: [ + [/\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\/fnrt`])/, 'string.escape'], + [/`/, { token: 'variable.parameter', next: '@pop' }], + [/./, 'variable.parameter'] + ], + + blockComment: [ + [/\*\//, { token: 'comment', next: '@pop' }], + [/./, 'comment'] + ] + } + } as monacoType.languages.IMonarchLanguage) + + // Provide colours for the custom token classes (Monaco "vs" theme has no defaults + // for `support.function.*`, `number.quantity`, etc.). + monaco.editor.defineTheme('fhirpath-lab', { + base: 'vs', + inherit: true, + rules: [ + { token: 'support.function.existence', foreground: '795E26', fontStyle: 'bold' }, + { token: 'support.function.filtering', foreground: '795E26', fontStyle: 'bold' }, + { token: 'support.function.subsetting', foreground: '795E26' }, + { token: 'support.function.combining', foreground: '795E26' }, + { token: 'support.function.conversion', foreground: '267F99' }, + { token: 'support.function.strings', foreground: '795E26' }, + { token: 'support.function.math', foreground: '795E26' }, + { token: 'support.function.treenav', foreground: '795E26' }, + { token: 'support.function.utility', foreground: '795E26' }, + { token: 'support.function.types', foreground: 'AF00DB' }, + { token: 'support.function.aggregates', foreground: '795E26' }, + { token: 'variable.language.this', foreground: '0070C1', fontStyle: 'italic' }, + { token: 'variable.language.index', foreground: '0070C1', fontStyle: 'italic' }, + { token: 'variable.language.total', foreground: '0070C1', fontStyle: 'italic' }, + { token: 'variable.parameter', foreground: '001080' }, + { token: 'keyword.operator.math', foreground: 'AF00DB' }, + { token: 'number.quantity', foreground: '098658' }, + { token: 'string.time', foreground: '098658' }, + { token: 'string.date', foreground: '098658' }, + { token: 'string.datetime', foreground: '098658' }, + { token: 'delimiter.dot', foreground: '000000' } + ], + colors: {} + }) +} diff --git a/vue3-src/package-lock.json b/vue3-src/package-lock.json index 1763b875..f5bd3be6 100644 --- a/vue3-src/package-lock.json +++ b/vue3-src/package-lock.json @@ -12,7 +12,6 @@ "@nuxt/fonts": "^0.14.0", "@types/fhir": "^0.0.41", "@types/lz-string": "^1.5.0", - "ace-builds": "^1.43.6", "antlr4": "^4.13.2", "axios": "^1.16.0", "fhir-extension-helpers": "^0.3.0", @@ -21,10 +20,10 @@ "luxon": "^3.7.2", "lz-string": "^1.5.0", "marked": "^14.1.4", + "monaco-editor": "^0.52.2", "nuxt": "^4.4.2", "vue": "^3.5.32", "vue-router": "^5.0.4", - "vue3-ace-editor": "^2.2.4", "vuetify": "^4.0.5", "vuetify-nuxt-module": "^0.19.5", "xml-formatter": "^3.7.0" @@ -4077,12 +4076,6 @@ "node": ">=6.5" } }, - "node_modules/ace-builds": { - "version": "1.43.6", - "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.6.tgz", - "integrity": "sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg==", - "license": "BSD-3-Clause" - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7995,6 +7988,12 @@ "integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==", "license": "MIT" }, + "node_modules/monaco-editor": { + "version": "0.52.2", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", + "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -9339,12 +9338,6 @@ "regexp-tree": "bin/regexp-tree" } }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -11245,19 +11238,6 @@ } } }, - "node_modules/vue3-ace-editor": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/vue3-ace-editor/-/vue3-ace-editor-2.2.4.tgz", - "integrity": "sha512-FZkEyfpbH068BwjhMyNROxfEI8135Sc+x8ouxkMdCNkuj/Tuw83VP/gStFQqZHqljyX9/VfMTCdTqtOnJZGN8g==", - "license": "MIT", - "dependencies": { - "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "ace-builds": "*", - "vue": "^3" - } - }, "node_modules/vuetify": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.0.5.tgz", diff --git a/vue3-src/package.json b/vue3-src/package.json index 039459e3..9fed661b 100644 --- a/vue3-src/package.json +++ b/vue3-src/package.json @@ -15,7 +15,6 @@ "@nuxt/fonts": "^0.14.0", "@types/fhir": "^0.0.41", "@types/lz-string": "^1.5.0", - "ace-builds": "^1.43.6", "antlr4": "^4.13.2", "axios": "^1.16.0", "fhir-extension-helpers": "^0.3.0", @@ -24,10 +23,10 @@ "luxon": "^3.7.2", "lz-string": "^1.5.0", "marked": "^14.1.4", + "monaco-editor": "^0.52.2", "nuxt": "^4.4.2", "vue": "^3.5.32", "vue-router": "^5.0.4", - "vue3-ace-editor": "^2.2.4", "vuetify": "^4.0.5", "vuetify-nuxt-module": "^0.19.5", "xml-formatter": "^3.7.0"