@fc-components/monaco-editor 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ export const languageConfiguration = {
2
+ wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\#\%\^\&\*\(\)\-\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\?\s]+)/g,
3
+ comments: {
4
+ lineComment: '//',
5
+ blockComment: ['/*', '*/'] as [string, string],
6
+ },
7
+ brackets: [
8
+ ['{', '}'],
9
+ ['[', ']'],
10
+ ['(', ')'],
11
+ ] as any,
12
+ autoClosingPairs: [
13
+ { open: '{', close: '}' },
14
+ { open: '[', close: ']' },
15
+ { open: '(', close: ')' },
16
+ { open: '"', close: '"' },
17
+ { open: "'", close: "'" },
18
+ { open: '`', close: '`' },
19
+ ] as any,
20
+ surroundingPairs: [
21
+ { open: '{', close: '}' },
22
+ { open: '[', close: ']' },
23
+ { open: '(', close: ')' },
24
+ { open: '"', close: '"' },
25
+ { open: "'", close: "'" },
26
+ { open: '`', close: '`' },
27
+ ] as any,
28
+ folding: {
29
+ offSide: false,
30
+ },
31
+ };
32
+
33
+ // Expr-lang keywords
34
+ const keywords = ['let', 'true', 'false', 'nil', 'in', 'not', 'and', 'or', 'if', 'else'];
35
+
36
+ // Expr-lang built-in functions
37
+ const stringFunctions = [
38
+ 'trim',
39
+ 'trimPrefix',
40
+ 'trimSuffix',
41
+ 'upper',
42
+ 'lower',
43
+ 'split',
44
+ 'splitAfter',
45
+ 'replace',
46
+ 'repeat',
47
+ 'indexOf',
48
+ 'lastIndexOf',
49
+ 'hasPrefix',
50
+ 'hasSuffix',
51
+ 'contains',
52
+ 'startsWith',
53
+ 'endsWith',
54
+ ];
55
+
56
+ const dateFunctions = ['now', 'duration', 'date', 'timezone'];
57
+
58
+ const numberFunctions = ['max', 'min', 'abs', 'ceil', 'floor', 'round'];
59
+
60
+ const arrayFunctions = [
61
+ 'all',
62
+ 'any',
63
+ 'one',
64
+ 'none',
65
+ 'map',
66
+ 'filter',
67
+ 'find',
68
+ 'findIndex',
69
+ 'findLast',
70
+ 'findLastIndex',
71
+ 'groupBy',
72
+ 'count',
73
+ 'concat',
74
+ 'flatten',
75
+ 'uniq',
76
+ 'join',
77
+ 'reduce',
78
+ 'sum',
79
+ 'mean',
80
+ 'median',
81
+ 'first',
82
+ 'last',
83
+ 'take',
84
+ 'reverse',
85
+ 'sort',
86
+ 'sortBy',
87
+ ];
88
+
89
+ const mapFunctions = ['keys', 'values'];
90
+
91
+ const typeConversionFunctions = ['type', 'int', 'float', 'string', 'toJSON', 'fromJSON', 'toBase64', 'fromBase64', 'toPairs', 'fromPairs'];
92
+
93
+ const miscFunctions = ['len', 'get'];
94
+
95
+ const bitwiseFunctions = ['bitand', 'bitor', 'bitxor', 'bitnand', 'bitnot', 'bitshl', 'bitshr', 'bitushr'];
96
+
97
+ const builtinFunctions = [
98
+ ...stringFunctions,
99
+ ...dateFunctions,
100
+ ...numberFunctions,
101
+ ...arrayFunctions,
102
+ ...mapFunctions,
103
+ ...typeConversionFunctions,
104
+ ...miscFunctions,
105
+ ...bitwiseFunctions,
106
+ ];
107
+
108
+ export const language = {
109
+ defaultToken: '',
110
+ tokenPostfix: '.expr',
111
+
112
+ brackets: [
113
+ { open: '(', close: ')', token: 'delimiter.parenthesis' },
114
+ { open: '{', close: '}', token: 'delimiter.curly' },
115
+ { open: '[', close: ']', token: 'delimiter.square' },
116
+ ],
117
+
118
+ keywords,
119
+
120
+ builtinFunctions,
121
+
122
+ operators: ['+', '-', '*', '/', '%', '^', '**', '==', '!=', '<', '>', '<=', '>=', '!', '&&', '||', '?:', '??', '.', '?.', 'in', 'matches', '..', '|'],
123
+
124
+ escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
125
+
126
+ digits: /\d+(_+\d+)*/,
127
+
128
+ hexdigits: /[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,
129
+
130
+ octdigits: /[0-7]+(_+[0-7]+)*/,
131
+
132
+ bindigits: /[01]+(_+[01]+)*/,
133
+
134
+ tokenizer: {
135
+ root: [
136
+ { include: '@whitespace' },
137
+ { include: '@comments' },
138
+ { include: '@numbers' },
139
+ { include: '@strings' },
140
+ { include: '@bytes' },
141
+
142
+ // Function calls: identifier followed by '('
143
+ [/[a-zA-Z_]\w*(?=\s*\()/, { cases: { '@builtinFunctions': 'keyword.function', '@default': 'identifier.function' } }],
144
+
145
+ // Keywords and identifiers
146
+ [/[a-zA-Z_]\w*/, { cases: { '@keywords': 'keyword', '@default': 'identifier' } }],
147
+
148
+ // Operators
149
+ [/[?][?:]/, 'operator'], // ??, ?:
150
+ [/[?][.]/, 'operator'], // ?.
151
+ [/[.]{2}/, 'operator'], // ..
152
+ [/[*]{2}/, 'operator'], // **
153
+ [/[|]/, 'operator'], // |
154
+ [/[+\-*/%^]/, 'operator'],
155
+ [/==|!=|<=|>=|<|>/, 'operator'],
156
+ [/!|&&|\|\|/, 'operator'],
157
+ [/[=]/, 'operator'],
158
+ [/[.~]/, 'operator'],
159
+
160
+ // Delimiters
161
+ [/[,;]/, 'delimiter'],
162
+ [/[{}()\[\]]/, '@brackets'],
163
+ ],
164
+
165
+ whitespace: [[/[ \t\r\n]+/, 'white']],
166
+
167
+ comments: [
168
+ [/\/\/.*$/, 'comment'],
169
+ [/\/\*/, { token: 'comment.quote', next: '@comment' }],
170
+ ],
171
+
172
+ comment: [
173
+ [/[^*/]+/, 'comment'],
174
+ [/\*\//, { token: 'comment.quote', next: '@pop' }],
175
+ [/./, 'comment'],
176
+ ],
177
+
178
+ numbers: [
179
+ [/0[xX]@hexdigits/, 'number.hex'],
180
+ [/0[oO]@octdigits/, 'number.octal'],
181
+ [/0[bB]@bindigits/, 'number.binary'],
182
+ [/(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/, 'number'],
183
+ ],
184
+
185
+ strings: [
186
+ [/"/, { token: 'string.double', next: '@string_double' }],
187
+ [/'/, { token: 'string', next: '@string_single' }],
188
+ [/`/, { token: 'string.backtick', next: '@string_backtick' }],
189
+ ],
190
+
191
+ string_double: [
192
+ [/[^"\\]+/, 'string.double'],
193
+ [/@escapes/, 'string.escape'],
194
+ [/\\./, 'string.escape.invalid'],
195
+ [/"/, { token: 'string.double', next: '@pop' }],
196
+ ],
197
+
198
+ string_single: [
199
+ [/[^'\\]+/, 'string'],
200
+ [/@escapes/, 'string.escape'],
201
+ [/\\./, 'string.escape.invalid'],
202
+ [/'/, { token: 'string', next: '@pop' }],
203
+ ],
204
+
205
+ string_backtick: [
206
+ [/[^`]+/, 'string.backtick'],
207
+ [/`/, { token: 'string.backtick', next: '@pop' }],
208
+ ],
209
+
210
+ bytes: [
211
+ [/[bB]"/, { token: 'string.bytes', next: '@bytes_double' }],
212
+ [/[bB]'/, { token: 'string.bytes', next: '@bytes_single' }],
213
+ ],
214
+
215
+ bytes_double: [
216
+ [/[^"\\]+/, 'string.bytes'],
217
+ [/\\(?:[abfnrtv\\"]|x[0-9A-Fa-f]{2}|[0-7]{3})/, 'string.escape'],
218
+ [/\\./, 'string.escape.invalid'],
219
+ [/"/, { token: 'string.bytes', next: '@pop' }],
220
+ ],
221
+
222
+ bytes_single: [
223
+ [/[^'\\]+/, 'string.bytes'],
224
+ [/\\(?:[abfnrtv\\']|x[0-9A-Fa-f]{2}|[0-7]{3})/, 'string.escape'],
225
+ [/\\./, 'string.escape.invalid'],
226
+ [/'/, { token: 'string.bytes', next: '@pop' }],
227
+ ],
228
+ },
229
+ };
@@ -0,0 +1,322 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import MonacoEditor from 'react-monaco-editor';
3
+ import * as monaco from 'monaco-editor';
4
+ import type * as monacoTypes from 'monaco-editor/esm/vs/editor/editor.api';
5
+ import { v4 as uuidv4 } from 'uuid';
6
+ import { css } from '@emotion/css';
7
+ import { language, languageConfiguration } from './expr';
8
+ import { getExprCompletionProvider } from './completion/getCompletionProvider';
9
+ import { validateExpr } from './validation';
10
+ import type { ExprEditorProps } from './types';
11
+
12
+ const EXPR_LANG_ID = 'expr';
13
+ const SIZE_MAP: Record<
14
+ string,
15
+ {
16
+ className: string;
17
+ top: number;
18
+ bottom: number;
19
+ minHeight: number;
20
+ }
21
+ > = {
22
+ small: {
23
+ className: 'ant-input-sm',
24
+ top: 1,
25
+ bottom: 1,
26
+ minHeight: 24,
27
+ },
28
+ middle: {
29
+ className: 'ant-input-md',
30
+ top: 1,
31
+ bottom: 1,
32
+ minHeight: 32,
33
+ },
34
+ large: {
35
+ className: 'ant-input-lg',
36
+ top: 3,
37
+ bottom: 2,
38
+ minHeight: 40,
39
+ },
40
+ };
41
+
42
+ const themeMap: Record<string, string> = {
43
+ light: 'expr-light',
44
+ dark: 'expr-dark',
45
+ };
46
+
47
+ const containerDisabledClassName = css`
48
+ .monaco-editor {
49
+ user-select: none;
50
+ pointer-events: none;
51
+ }
52
+ `;
53
+
54
+ const containerReadOnlyClassName = css`
55
+ .monaco-editor .cursors-layer > .cursor {
56
+ opacity: 0 !important;
57
+ }
58
+ `;
59
+
60
+ export default function ExprEditor(props: ExprEditorProps) {
61
+ const id = uuidv4();
62
+ const {
63
+ className,
64
+ maxHeight,
65
+ fontSize,
66
+ size = 'middle',
67
+ theme = 'light',
68
+ value = '',
69
+ placeholder,
70
+ enableAutocomplete = true,
71
+ readOnly = false,
72
+ disabled = false,
73
+ onChange,
74
+ onEnter,
75
+ onBlur,
76
+ onFocus,
77
+ editorDidMount,
78
+ } = props;
79
+
80
+ const containerRef = useRef<HTMLDivElement>(null);
81
+ const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null);
82
+ const modelRef = useRef<monaco.editor.ITextModel | null>(null);
83
+ const disposablesRef = useRef<monaco.IDisposable[]>([]);
84
+
85
+ useEffect(() => {
86
+ // Register language
87
+ if (!monaco.languages.getLanguages().some((lang) => lang.id === EXPR_LANG_ID)) {
88
+ monaco.languages.register({ id: EXPR_LANG_ID });
89
+ monaco.languages.setMonarchTokensProvider(EXPR_LANG_ID, language as any);
90
+ monaco.languages.setLanguageConfiguration(EXPR_LANG_ID, languageConfiguration);
91
+ }
92
+
93
+ // Register completion provider
94
+ if (enableAutocomplete) {
95
+ const disposable = monaco.languages.registerCompletionItemProvider(EXPR_LANG_ID, getExprCompletionProvider());
96
+ disposablesRef.current.push(disposable);
97
+ }
98
+
99
+ return () => {
100
+ disposablesRef.current.forEach((disposable) => disposable.dispose());
101
+ disposablesRef.current = [];
102
+ };
103
+ }, [enableAutocomplete]);
104
+
105
+ const handleEditorMount = (editor: monacoTypes.editor.IStandaloneCodeEditor) => {
106
+ editorRef.current = editor;
107
+ modelRef.current = editor.getModel();
108
+
109
+ monaco.editor.defineTheme('expr-light', {
110
+ base: 'vs',
111
+ inherit: true,
112
+ rules: [],
113
+ colors: {
114
+ 'editor.background': '#00000000',
115
+ focusBorder: '#00000000',
116
+ },
117
+ });
118
+
119
+ monaco.editor.defineTheme('expr-dark', {
120
+ base: 'vs-dark',
121
+ inherit: true,
122
+ rules: [],
123
+ colors: {
124
+ 'editor.background': '#00000000',
125
+ focusBorder: '#00000000',
126
+ },
127
+ });
128
+
129
+ const isEditorFocused = editor.createContextKey<boolean>('isEditorFocused' + id, false);
130
+
131
+ editor.onDidBlurEditorWidget(() => {
132
+ isEditorFocused.set(false);
133
+ onBlur?.(editor.getValue());
134
+ const position = editor.getPosition();
135
+ if (position) {
136
+ const newSelection = new monaco.Selection(position.lineNumber, position.column, position.lineNumber, position.column);
137
+ editor.setSelection(newSelection);
138
+ }
139
+ });
140
+
141
+ editor.onDidFocusEditorText(() => {
142
+ isEditorFocused.set(true);
143
+ onFocus?.(editor.getValue());
144
+ });
145
+
146
+ // Auto-height
147
+ const updateElementHeight = () => {
148
+ const containerDiv = containerRef.current;
149
+ if (containerDiv !== null) {
150
+ const pixelHeight = editor.getContentHeight();
151
+ containerDiv.style.minHeight = `${pixelHeight}px`;
152
+ containerDiv.style.width = '100%';
153
+ const pixelWidth = containerDiv.clientWidth;
154
+ editor.layout({ width: pixelWidth, height: pixelHeight });
155
+ }
156
+ };
157
+
158
+ editor.onDidContentSizeChange(updateElementHeight);
159
+ updateElementHeight();
160
+
161
+ // Disable search box
162
+ monaco.editor.addKeybindingRule({
163
+ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF,
164
+ command: null,
165
+ });
166
+
167
+ // Shift+Enter for newline
168
+ editor.addCommand(
169
+ monaco.KeyMod.Shift | monaco.KeyCode.Enter,
170
+ () => {
171
+ const position = editor.getPosition();
172
+ if (position) {
173
+ editor.executeEdits('shift-enter', [
174
+ {
175
+ range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column),
176
+ text: '\n',
177
+ },
178
+ ]);
179
+ editor.setPosition({
180
+ lineNumber: position.lineNumber + 1,
181
+ column: 1,
182
+ });
183
+ }
184
+ },
185
+ 'isEditorFocused' + id,
186
+ );
187
+
188
+ // Prevent default Enter
189
+ monaco.editor.addKeybindingRule({
190
+ keybinding: monaco.KeyCode.Enter,
191
+ command: '-',
192
+ when: '!suggestWidgetVisible',
193
+ });
194
+
195
+ // Custom Enter handler
196
+ editor.addCommand(
197
+ monaco.KeyCode.Enter,
198
+ () => {
199
+ onEnter?.(editor.getValue());
200
+ },
201
+ '!suggestWidgetVisible && isEditorFocused' + id,
202
+ );
203
+
204
+ // Setup validation on content change using decorations (no marker hover clutter)
205
+ const model = editor.getModel();
206
+ let errorDecorations: string[] = [];
207
+
208
+ if (model) {
209
+ const updateDecorations = () => {
210
+ const exprValue = model.getValue();
211
+ const markers = validateExpr(exprValue);
212
+
213
+ const newDecorations: monaco.editor.IModelDeltaDecoration[] = markers.map((m) => ({
214
+ range: new monaco.Range(
215
+ m.startLineNumber,
216
+ m.startColumn,
217
+ m.endLineNumber,
218
+ m.endColumn,
219
+ ),
220
+ options: {
221
+ className: 'expr-error-squiggly',
222
+ hoverMessage: { value: m.message },
223
+ minimap: { color: '#e51400', position: 1 as monaco.editor.MinimapPosition },
224
+ overviewRuler: { color: '#e51400', position: monaco.editor.OverviewRulerLane.Right },
225
+ },
226
+ }));
227
+
228
+ errorDecorations = model.deltaDecorations(errorDecorations, newDecorations);
229
+ };
230
+
231
+ const validateDisposable = model.onDidChangeContent(updateDecorations);
232
+ disposablesRef.current.push(validateDisposable);
233
+
234
+ // Run initial validation
235
+ updateDecorations();
236
+ }
237
+
238
+ // Inject CSS for the red squiggly underline
239
+ const styleEl = document.createElement('style');
240
+ styleEl.textContent = `
241
+ .expr-error-squiggly {
242
+ background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 3' preserveAspectRatio='none'%3E%3Cpath d='M0,2.5 L1.5,1 L3,2.5 L4.5,1 L6,2.5' stroke='%23e51400' stroke-width='0.6' fill='none'/%3E%3C/svg%3E") repeat-x left bottom;
243
+ background-size: 6px 3px;
244
+ padding-bottom: 3px;
245
+ }
246
+ `;
247
+ document.head.appendChild(styleEl);
248
+ disposablesRef.current.push({ dispose: () => styleEl.remove() });
249
+
250
+ editorDidMount?.(editor);
251
+ };
252
+
253
+ const handleChange = (newValue: string, _e: monacoTypes.editor.IModelContentChangedEvent) => {
254
+ onChange?.(newValue);
255
+ };
256
+
257
+ const themeValue = themeMap[theme];
258
+
259
+ return (
260
+ <div
261
+ className={
262
+ 'ant-input' +
263
+ (size ? ` ${SIZE_MAP[size].className}` : '') +
264
+ (disabled ? ` ant-input-disabled ${containerDisabledClassName}` : '') +
265
+ (readOnly ? ` ${containerReadOnlyClassName}` : '') +
266
+ (className ? ` ${className}` : '')
267
+ }
268
+ style={{
269
+ display: 'block',
270
+ resize: 'vertical',
271
+ overflow: 'auto',
272
+ minHeight: SIZE_MAP[size].minHeight,
273
+ maxHeight,
274
+ }}
275
+ >
276
+ <div
277
+ ref={containerRef}
278
+ style={{
279
+ height: '100%',
280
+ }}
281
+ >
282
+ <MonacoEditor
283
+ language={EXPR_LANG_ID}
284
+ theme={themeValue}
285
+ value={value}
286
+ onChange={handleChange}
287
+ options={{
288
+ placeholder: placeholder,
289
+ selectOnLineNumbers: true,
290
+ fontSize: fontSize || 12,
291
+ roundedSelection: false,
292
+ scrollBeyondLastLine: false,
293
+ readOnly: readOnly || disabled,
294
+ minimap: { enabled: false },
295
+ lineNumbers: 'off',
296
+ lineNumbersMinChars: 0,
297
+ glyphMargin: false,
298
+ folding: false,
299
+ lineDecorationsWidth: 0,
300
+ overviewRulerLanes: 0,
301
+ overviewRulerBorder: false,
302
+ hideCursorInOverviewRuler: true,
303
+ hover: {
304
+ enabled: true,
305
+ delay: 200,
306
+ },
307
+ fixedOverflowWidgets: true,
308
+ renderLineHighlight: 'none',
309
+ renderValidationDecorations: 'on',
310
+ scrollbar: {
311
+ vertical: 'hidden',
312
+ horizontal: 'auto',
313
+ },
314
+ automaticLayout: true,
315
+ wordWrap: 'on',
316
+ }}
317
+ editorDidMount={handleEditorMount}
318
+ />
319
+ </div>
320
+ </div>
321
+ );
322
+ }
@@ -0,0 +1,3 @@
1
+ export { ExprParser } from './parser';
2
+ export { Lexer } from './lexer';
3
+ export * from './types';