solarite 0.1.0

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.
Files changed (63) hide show
  1. package/build/build.bat +3 -0
  2. package/build/build.js +139 -0
  3. package/build/lib/rollup.min.js +11 -0
  4. package/build/lib/source-map.min.js +1 -0
  5. package/build/lib/terser.min.js +1 -0
  6. package/dist/Solarite-debug.js +4143 -0
  7. package/dist/Solarite.js +3740 -0
  8. package/dist/Solarite.min.js +4 -0
  9. package/docs/index.md +423 -0
  10. package/docs/js/Playground.js +184 -0
  11. package/docs/js/codemirror/codemirror6.js +32036 -0
  12. package/docs/js/codemirror/themeSolarIce.js +312 -0
  13. package/docs/js/documentation.js +32 -0
  14. package/docs/js/ui/CodeEditor.js +840 -0
  15. package/docs/js/ui/DarkToggle.js +52 -0
  16. package/docs/js/ui/FlexResizer.js +142 -0
  17. package/docs/js/util/Draggable2.js +151 -0
  18. package/docs/js/util/Errors.js +9 -0
  19. package/docs/js/util/Html.js +147 -0
  20. package/docs/js/util/Icons.js +623 -0
  21. package/docs/js/util/Input.js +253 -0
  22. package/docs/js/util/Util.js +88 -0
  23. package/docs/js/util/delve.js +43 -0
  24. package/docs/media/FiraCode400.woff2 +0 -0
  25. package/docs/media/cabin-latin-700.woff2 +0 -0
  26. package/docs/media/documentation.css +93 -0
  27. package/docs/media/eternium.css +1123 -0
  28. package/docs/media/solarite-machine.webp +0 -0
  29. package/index.html +325 -0
  30. package/package.json +33 -0
  31. package/readme.md +3 -0
  32. package/src/solarite/ExprPath.js +554 -0
  33. package/src/solarite/MultiValueMap.js +65 -0
  34. package/src/solarite/NodeGroup.js +706 -0
  35. package/src/solarite/NodeGroupManager.js +582 -0
  36. package/src/solarite/Shell.js +307 -0
  37. package/src/solarite/Solarite.js +19 -0
  38. package/src/solarite/Template.js +85 -0
  39. package/src/solarite/Util.js +264 -0
  40. package/src/solarite/createSolarite.js +267 -0
  41. package/src/solarite/getArg.js +99 -0
  42. package/src/solarite/hash.js +101 -0
  43. package/src/solarite/r.js +143 -0
  44. package/src/solarite/udomdiff.js +233 -0
  45. package/src/solarite/watch.js +302 -0
  46. package/src/solarite/watch2.js +439 -0
  47. package/src/unused/FastLookupArray.js +54 -0
  48. package/src/unused/Hashes.js +339 -0
  49. package/src/unused/InUse.test.js +92 -0
  50. package/src/unused/InUseMap.js +98 -0
  51. package/src/unused/LinkedList.js +117 -0
  52. package/src/unused/LinkedList.test.js +115 -0
  53. package/src/unused/Perf.js +47 -0
  54. package/src/unused/Template.js +108 -0
  55. package/src/util/Errors.js +9 -0
  56. package/src/util/Util.js +88 -0
  57. package/src/util/delve.js +43 -0
  58. package/tests/Benchmark.test.js +319 -0
  59. package/tests/NodeGroup.test.js +115 -0
  60. package/tests/Shell.test.js +75 -0
  61. package/tests/Solarite.test.js +2896 -0
  62. package/tests/Testimony.js +602 -0
  63. package/tests/index.html +75 -0
@@ -0,0 +1,840 @@
1
+ import {
2
+ autocompletion,
3
+ bracketMatching,
4
+ closeBrackets,
5
+ closeBracketsKeymap,
6
+ closeSearchPanel,
7
+ Compartment,
8
+ completionKeymap,
9
+ crosshairCursor,
10
+ css,
11
+ cssParser,
12
+ defaultHighlightStyle,
13
+ defaultKeymap,
14
+ drawSelection,
15
+ EditorState,
16
+ EditorView,
17
+ foldKeymap,
18
+ highlightActiveLine,
19
+ highlightActiveLineGutter,
20
+ highlightSelectionMatches,
21
+ highlightSpecialChars,
22
+ history,
23
+ historyKeymap,
24
+ html,
25
+ htmlParser,
26
+ indentOnInput,
27
+ indentUnit,
28
+ indentWithTab,
29
+ javascript,
30
+ json,
31
+ jsParser,
32
+ keymap,
33
+ lineNumbers,
34
+ LRLanguage,
35
+ markdown,
36
+ openSearchPanel,
37
+ parseMixed,
38
+ php,
39
+ phpParser,
40
+ python,
41
+ rectangularSelection,
42
+ redo,
43
+ redoDepth,
44
+ searchKeymap,
45
+ searchPanelOpen,
46
+ sql,
47
+ StateEffect,
48
+ StateField,
49
+ syntaxHighlighting,
50
+ undo,
51
+ undoDepth
52
+ } from '../codemirror/codemirror6.js';
53
+ import themeSolarIce from "../codemirror/themeSolarIce.js";
54
+ import {r, Solarite, getArg, ArgType} from "../../../src/solarite/Solarite.js";
55
+ import Icons from "../util/Icons.js";
56
+ import Util from "../util/Util.js";
57
+
58
+
59
+
60
+ /**
61
+ * Wraps CodeMirror into a web component and provides some useful functions and default settings.
62
+ * TODO: Emmet support for html: https://discuss.codemirror.net/t/is-it-able-to-use-tab-to-generate-code-snippets-for-html-tag/5502
63
+ *
64
+ * @example
65
+ * new CodeEditor('sql', 'SELECT * FROM users', {}, 'undo redo | run'); */
66
+ export default class CodeEditor extends Solarite {
67
+
68
+ /** @type {EditorView} */
69
+ view;
70
+
71
+ extensions = [];
72
+
73
+ /**
74
+ * @type {Callbacks|function}
75
+ *
76
+ * @example
77
+ * codeEditor.onChange.push(update => {
78
+ * console.log(update.state.doc.toString();
79
+ * });
80
+ * */
81
+ onChange = Util.callback();
82
+
83
+ /**
84
+ * Called every time CodeMirror selection or content changes.
85
+ * @type {Callbacks|function} */
86
+ onInternalChange = Util.callback();
87
+
88
+ /** @type {Callbacks|function(range:{start: {line:int, column:int}, end: {line:int, column:int}})} */
89
+ onSelectionChange = Util.callback();
90
+
91
+ /** @type {Callbacks|function} */
92
+ onOptionChange = Util.callback();
93
+
94
+ allowChanges = true;
95
+
96
+ /**
97
+ * Serialized selection range. Line numbers start at 0.
98
+ * @type {string} */
99
+ lastSelection = '0-0';
100
+
101
+ /** @type {HTMLElement} */
102
+ editor;
103
+
104
+ /** @type {?CodeEditorToolbar} */
105
+ toolbar;
106
+
107
+ /** @type {string} */
108
+ language;
109
+
110
+ /**
111
+ * Compartments wrap extensions and allow changing thier settings after init. */
112
+ compartments = {
113
+ lineWrapping: new Compartment(),
114
+ language: new Compartment(),
115
+ tabSize: new Compartment(),
116
+ theme: new Compartment()
117
+ };
118
+
119
+ wordWrap = false;
120
+
121
+ /**
122
+ * Example languageConfig for SQL:
123
+ * https://github.com/codemirror/lang-sql#----interface----sqlconfig
124
+ * {
125
+ * upperCaseKeywords: true,
126
+ * schema: { // Define tables and their columns for auto-complete.
127
+ * users: ['email', 'passwordHash'],
128
+ * stats: ['id', 'created']
129
+ * }
130
+ * }
131
+ *
132
+ * @param value {string}
133
+ * @param language {?string} Can be the name of the language or one of the file extensions.
134
+ * Supported languages: php, php-plain, javascript, html, markdown, sql, css
135
+ * @param languageconfig {?Object} Options for CodeMirror.
136
+ * @param toolbar {string|Object<html:Template, update:function>[]} Names of buttons from CodeEditiorToolbar.buttonTemplates or an array of HTMLElements to use as buttons.
137
+ * @param options {object}
138
+ * @param options.wordWrap {boolean}
139
+ * @param options.tabSize {int}
140
+ * @param options.fontSize {int} */
141
+ constructor({value='', language=null, languageconfig=null, toolbar='', options={}}={}) {
142
+ super();
143
+
144
+ value = getArg(this, 'value', value, ArgType.String);
145
+ this.language = (language||'php').toLowerCase();
146
+ this.languageConfig = languageconfig || {};
147
+ this.wordWrap = options.wordWrap || false;
148
+ this.tabSize = options.tabSize || 4;
149
+
150
+
151
+
152
+ // https://github.com/codemirror/lang-php
153
+ if (this.language === 'php-plain')
154
+ this.languageConfig.plain = true;
155
+
156
+ // TODO: Use this to specify markdown code block languages:
157
+ // And add keybindings for ctrl+1 headers, etc.
158
+ // https://github.com/codemirror/lang-markdown
159
+
160
+ this.render();
161
+
162
+ let state = EditorState.create({
163
+ doc: value,
164
+ extensions: this.getExtensions(options.wordWrap)
165
+ })
166
+ this.view = new EditorView({
167
+ state,
168
+ parent: this.editor
169
+ });
170
+
171
+ // Create the toolbar
172
+ if (toolbar) {
173
+ this.toolbar = new CodeEditorToolbar({ed: this, buttons: toolbar});
174
+ this.insertBefore(this.toolbar, this.editor);
175
+ }
176
+
177
+
178
+ // Watch for dark attribute change on <html> element.
179
+ // So we can update our own theme in response.
180
+ this.onConnect.push(() => {
181
+ if (this.ownerDocument.documentElement) {
182
+ const observer = new MutationObserver((mutations) => {
183
+ mutations.forEach((mutation) => {
184
+ if (mutation.type === 'attributes' && mutation.attributeName === 'dark') {
185
+ let state = mutation.target.hasAttribute('dark');
186
+ this.setTheme(themeSolarIce(this.language, state));
187
+ }
188
+ });
189
+ });
190
+
191
+ observer.observe(this.ownerDocument.documentElement, {attributes: true});
192
+ }
193
+ });
194
+
195
+ this.onInternalChange.push((update, codeEditor) => {
196
+ let sel = update.state.selection.main;
197
+ let lineObj = update.state.doc.lineAt(sel.head);
198
+
199
+ let newSelection = lineObj.number + '-' + (sel.head - lineObj.from);
200
+
201
+ if (newSelection !== codeEditor.lastSelection) {
202
+ if (codeEditor.allowChanges && codeEditor.onSelectionChange.length) {
203
+ let range = {
204
+ start: {
205
+ line: lineObj.number - 1,
206
+ column: sel.head - lineObj.from
207
+ },
208
+ end: {}
209
+ };
210
+
211
+ lineObj = sel.head === sel.anchor ? lineObj : update.state.doc.lineAt(sel.anchor);
212
+ range.end.line = lineObj.number - 1;
213
+ range.end.column = sel.anchor - lineObj.from
214
+
215
+ codeEditor.onSelectionChange(range, update);
216
+ }
217
+ this.lastSelection = newSelection;
218
+ if (this.toolbar)
219
+ this.toolbar.update(update);
220
+ }
221
+ // console.log(startLine, startColumn, endLine, endColumn, update)
222
+ if (this.allowChanges && update.changedRanges.length && this.onChange.length) {
223
+ let codeChange = {
224
+ beforeStart: update.changedRanges[0].fromA,
225
+ beforeEnd: update.changedRanges[0].toA,
226
+ afterStart: update.changedRanges[0].fromB,
227
+ afterEnd: update.changedRanges[0].toB,
228
+ text: update.state.doc.sliceString(update.changedRanges[0].fromB, update.changedRanges[0].toB)
229
+ };
230
+ this.onChange(codeChange, update);
231
+ }
232
+ });
233
+
234
+
235
+ Object.defineProperty(this, 'value', {
236
+ get() {
237
+ return this.getValue();
238
+ },
239
+ set(val) {
240
+ this.setValue(val, false);
241
+ }
242
+ });
243
+ }
244
+
245
+ getExtensions(wordWrap=false, isDark=undefined) {
246
+ let doc = this.ownerDocument || window.top.document;
247
+ if (isDark === undefined)
248
+ isDark = doc.documentElement.hasAttribute('dark');
249
+
250
+ let languageExtensions = this.getLanguageExtension(this.language, this.languageConfig)
251
+ return [
252
+ //basicSetup,
253
+
254
+ lineNumbers(),
255
+ highlightActiveLineGutter(),
256
+ highlightSpecialChars(),
257
+ history(),
258
+ //foldGutter(),
259
+ drawSelection(),
260
+ indentUnit.of("\t"),
261
+ this.compartments.tabSize.of(EditorState.tabSize.of(this.tabSize)),
262
+ this.compartments.lineWrapping.of(wordWrap ? EditorView.lineWrapping : []),
263
+ EditorState.allowMultipleSelections.of(true),
264
+ indentOnInput(),
265
+ bracketMatching(),
266
+ closeBrackets(),
267
+ autocompletion(),
268
+ rectangularSelection(),
269
+ crosshairCursor(),
270
+ highlightActiveLine(),
271
+ highlightSelectionMatches(),
272
+ keymap.of([
273
+ indentWithTab,
274
+ ...closeBracketsKeymap,
275
+ {
276
+ key: 'Tab', // TODO: What does this do?
277
+ //run: target => acceptCompletion(target),
278
+ },
279
+ ...defaultKeymap,
280
+ ...historyKeymap,
281
+ ...foldKeymap,
282
+ ...completionKeymap,
283
+ ...searchKeymap
284
+ ]),
285
+
286
+ Extensions.changeDetection(this),
287
+ Extensions.asyncField,
288
+
289
+ this.compartments.theme.of(themeSolarIce(this.language, isDark)),
290
+
291
+ syntaxHighlighting(defaultHighlightStyle, {fallback: true}),
292
+
293
+ this.compartments.language.of(languageExtensions)
294
+ ];
295
+ }
296
+
297
+ focus() {
298
+ this.view.focus();
299
+ }
300
+
301
+ /**
302
+ * Set the colors, fonts, and sizes.
303
+ * @param theme
304
+ * @returns {Promise<void>} */
305
+ async setTheme(theme) {
306
+ this.view.dispatch({
307
+ effects: this.compartments.theme.reconfigure(theme)
308
+ })
309
+ }
310
+
311
+ getValue() {
312
+ return this.view.viewState.state.doc.toString()
313
+ }
314
+
315
+ undo() {
316
+ undo(this.view);
317
+ }
318
+
319
+ redo() {
320
+ redo(this.view);
321
+ }
322
+
323
+
324
+ toggleWordWrap(status=undefined) {
325
+ if (status===undefined)
326
+ status = !this.isWordWrapped();
327
+
328
+ this.view.dispatch({ // Update codemirror.
329
+ effects: this.compartments.lineWrapping.reconfigure(status ? EditorView.lineWrapping : [])
330
+ });
331
+ this.wordWrap = status;
332
+
333
+ if (this.toolbar)
334
+ this.toolbar.update();
335
+
336
+ this.onOptionChange();
337
+ }
338
+
339
+ isWordWrapped() {
340
+ return this.wordWrap;
341
+ }
342
+
343
+ toggleSearchPanel(status=undefined) {
344
+ if (status===undefined)
345
+ status = !searchPanelOpen(this.view.state);
346
+
347
+ if (status) {
348
+ openSearchPanel(this.view)
349
+
350
+ // Make search panel styles a little closer to SiteCrafter styles.
351
+ for (let btn of this.querySelectorAll('.cm-search .cm-button'))
352
+ btn.classList.replace('cm-button', 'button')
353
+ }
354
+ else
355
+ closeSearchPanel(this.view)
356
+ if (this.toolbar)
357
+ this.toolbar.update();
358
+ }
359
+
360
+ isSearchPanelOpen() {
361
+ return this.view && searchPanelOpen(this.view.state);
362
+ }
363
+
364
+ /**
365
+ * Used internally. Use setValue() to change the language. */
366
+ getLanguageExtension(language, languageConfig) {
367
+ this.language = language;
368
+
369
+ // TODO: It may be possible to provide additional style overrides, e.g. for css tag selectors:
370
+ // https://discuss.codemirror.net/t/highlighting-markdown-mark-only/3964/3
371
+
372
+ let langExt;
373
+ if (['css', 'scss', 'less'].includes(language))
374
+ langExt = css;
375
+ else if (['htm', 'html'].includes(language))
376
+ langExt = ParseUtil.htmlWithJavaScript;
377
+ else if (['js', 'ts', 'jsx', 'tsx', 'jscript', 'javascript'].includes(language))
378
+ langExt = ParseUtil.javascriptWithHtml; // TODO: Support it inside html and php
379
+ else if (language === 'json')
380
+ langExt = json;
381
+ // else if (language === 'ts') // TODO: jsx, tsx, python
382
+ // langExt = typescript();
383
+ else if (['md', 'markdown'].includes(language))
384
+ langExt = markdown;
385
+ else if (language === 'php')
386
+ langExt = ParseUtil.phpWithHtmlWithJavaScript;
387
+ else if (language === 'php-plain')
388
+ langExt = php; // Use this to default into php mode without an opening <? tag.
389
+ else if (language === 'sql')
390
+ langExt = sql;
391
+ else if (['py', 'python'].includes(language))
392
+ langExt = python;
393
+ if (!langExt)
394
+ return []; // plain text.
395
+
396
+ return [
397
+ langExt(languageConfig)
398
+ ];
399
+ }
400
+
401
+ /**
402
+ *
403
+ * @param range {CodeRange}*/
404
+ async selectRange(range) {
405
+ range = range.clone();
406
+ if (range.start.line === null) {
407
+ await asyncDispatch(this.view, {
408
+ selection: { // Deselect all.
409
+ head: 0,
410
+ anchor: 0
411
+ }
412
+ });
413
+ return;
414
+ }
415
+
416
+
417
+ let doc = this.view.state.doc;
418
+
419
+ // Keep within bounds.
420
+ if (doc.lines < range.start.line)
421
+ range.start.line = doc.lines-1;
422
+ if (doc.lines < range.end.line)
423
+ range.end.line = doc.lines-1;
424
+ if (range.end.line === null)
425
+ range.end.line = range.start.line;
426
+
427
+ // Sometimes these can be out of bounds if the line is pair of comments for a php print.
428
+ // In that case we just select the whole line for now.
429
+ if (doc.line(range.start.line+1).length < range.start.column)
430
+ range.start.column = 0; //doc.text[range.start.line].length
431
+ if (doc.line(range.end.line+1).length < range.end.column)
432
+ range.end.column = doc.line(range.end.line+1).length;
433
+
434
+ const startLineObj = doc.line(range.start.line+1);
435
+ const endLineObj = doc.line(range.end.line+1);
436
+
437
+ this.allowChanges = false; // Don't call onSelectionChange()
438
+
439
+ await asyncDispatch(this.view, {
440
+ // Set selection to that entire line.
441
+ selection: {
442
+ head: startLineObj.from + range.start.column,
443
+ anchor: range.end.column === null
444
+ ? endLineObj.to // If no end column, select until end of line.
445
+ : endLineObj.from + range.end.column
446
+ },
447
+ // Ensure the selection is shown in viewport
448
+ scrollIntoView: true
449
+ });
450
+
451
+ this.allowChanges = true;
452
+ }
453
+
454
+ setLanguage(language=null, languageConfig=null) {
455
+ languageConfig = languageConfig || {};
456
+ this.view.dispatch({ // Update codemirror.
457
+ effects: this.compartments.language.reconfigure(this.getLanguageExtension(language, languageConfig))
458
+ });
459
+ }
460
+
461
+ setTabSize(tabSize) {
462
+ this.tabSize = tabSize;
463
+ this.view.dispatch({ // Update codemirror.
464
+ effects: this.compartments.tabSize.reconfigure(EditorState.tabSize.of(tabSize))
465
+ });
466
+ }
467
+
468
+ /**
469
+ * TODO: Can't change language if createHistory=false */
470
+ async setValue(value, createHistory=true) {
471
+ let scroller = this.querySelector('.cm-editor > .cm-scroller');
472
+ let scroll = {x: scroller?.scrollLeft||0, y: scroller?.scrollTop||0};
473
+
474
+ // Don't trigger the onChange function
475
+ this.allowChanges = false;
476
+
477
+ // https://codemirror.net/docs/migration/
478
+ if (createHistory)
479
+ await asyncDispatch(this.view, { // Also triggers the change event.
480
+ changes: {from: 0, to: this.view.state.doc.length, insert: value}
481
+ })
482
+
483
+ // https://discuss.codemirror.net/t/codemirror-6-cm-clearhistory-equivalent/2851/2
484
+ else {
485
+ this.view.setState(EditorState.create({
486
+ doc: value,
487
+ extensions: this.getExtensions(this.wordWrap)
488
+ }));
489
+ }
490
+
491
+ this.allowChanges = true;
492
+
493
+ // if (!triggerChange)
494
+ // this.allowChanges = true;
495
+
496
+ // Not sure why both of these are necessary, but in my testing they were.
497
+ if (scroller) {
498
+ scroller.scrollTop = scroll.y;
499
+ scroller.scrollLeft = scroll.x;
500
+ requestAnimationFrame(() => {
501
+ scroller.scrollTop = scroll.y;
502
+ scroller.scrollLeft = scroll.x;
503
+ });
504
+ }
505
+ }
506
+
507
+ getUndoDepth() {
508
+ if (this.view)
509
+ return undoDepth(this.view.state);
510
+ }
511
+
512
+ getRedoDepth() {
513
+ if (this.view)
514
+ return redoDepth(this.view.state);
515
+ }
516
+
517
+
518
+ render() {
519
+ this.html = r`
520
+ <code-editor>
521
+ <style>
522
+ :host { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0 }
523
+ :host [data-id=editor] { height: 100%; width: 100%; min-height: 0 }
524
+ :host [data-id=editor] .cm-editor { height: 100%; width: 100%; min-height: 0 }
525
+ :host .cm-scroller { overflow: auto; min-height: 10px; height: 100%; width: 100%; font: 13px Hack, monospace !important }
526
+
527
+ /* Remove outline added by CodeMirror on focus. */
528
+ :host [data-id=editor] .cm-editor.cm-focused { outline: none }
529
+ :host .cm-activeLineGutter { background: transparent} /* Only highlight on focus, set in the theme.js file */
530
+
531
+ /* Search Panel styles */
532
+ :host .cm-panels.cm-panels-bottom { border-top: var(--border, 1px solid #cbcfd7) }
533
+ :host .cm-panels { background-color: var(--background, white) !important; color: var(--text, #333) !important }
534
+ :host .cm-panels label { display: inline-flex; align-items: center; user-select: none }
535
+ :host .cm-textfield { font-size: inherit }
536
+ </style>
537
+ <div data-id="editor" class="col"></div>
538
+ </code-editor>`
539
+ }
540
+ }
541
+ CodeEditor.define();
542
+
543
+
544
+
545
+
546
+
547
+ // Define a unique effect to listen for
548
+ const asyncEffect = StateEffect.define();
549
+
550
+
551
+
552
+ /**
553
+ * Use this function to allow awaiting a CodeMirror state.dispatch() call.
554
+ * @param view
555
+ * @param options
556
+ * @returns {Promise<unknown>}
557
+ *
558
+ * @example
559
+ * await asyncDispatch(this.view, dispatchArgs); */
560
+ async function asyncDispatch(view, options) {
561
+ //view.dispatch(options); // non-async version
562
+ return new Promise(resolve => {
563
+ options.effects = asyncEffect.of(resolve);
564
+ view.dispatch(options);
565
+ });
566
+ }
567
+
568
+
569
+ var Extensions = {
570
+
571
+ // Create a state field that listens for your effect
572
+ asyncField: StateField.define({
573
+ create() {
574
+ return {}; // Initial state (can be anything relevant)
575
+ },
576
+ update(value, tr, a) {
577
+ if (tr.effects.some(e => e.is(asyncEffect))) {
578
+ // Supposedly called after dispatched event is done, but doesn't seem to be?
579
+ let resolve = tr.effects[0].value; // arg sent to asncEffect.of() in asyncDispatch()
580
+ resolve();
581
+ }
582
+ return value; // Return the updated state
583
+ }
584
+ }),
585
+
586
+ // Call onChange() when the document changes.
587
+ // https://discuss.codemirror.net/t/how-to-listen-to-changes-for-react-controlled-component/4506/4
588
+ changeDetection(codeEditor) {
589
+ return EditorView.updateListener.of(update => {
590
+ if (update.docChanged || update.selectionSet)
591
+ codeEditor.onInternalChange(update, codeEditor);
592
+ })
593
+ }
594
+ }
595
+
596
+ var ParseUtil = {
597
+
598
+ /**
599
+ * A langauge extension for CodeMirror to support highlighting nested javascript/html inside javascript templates.
600
+ * https://codemirror.net/examples/mixed-language/
601
+ * @return {LRLanguage} */
602
+ javascriptWithHtml: () => LRLanguage.define({parser: ParseUtil.jsParser}),
603
+ htmlWithJavaScript: () => LRLanguage.define({parser: ParseUtil.htmlParser}),
604
+ phpWithHtmlWithJavaScript: () => LRLanguage.define({parser: ParseUtil.phpParser}),
605
+
606
+ /**
607
+ * Used by javascriptWithHtml() */
608
+ htmlParser: htmlParser.configure({
609
+ wrap: parseMixed((node, docInput) => {
610
+ if (node.name == "StyleText")
611
+ return {parser: cssParser}
612
+ if (node.name == "ScriptText")
613
+ return {parser: ParseUtil.jsParser}
614
+
615
+ // Use javascript parser for template expressions
616
+ if (node.type.name === 'Document' && docInput.doc) {
617
+ let code = docInput.doc.sliceString(node.from, node.to);
618
+ let overlay = [];
619
+ code.replace(/\${/g, (a, startIndex, c) => {
620
+ let endIndex = ParseUtil.findMatchingBrace(code, startIndex+2);
621
+ if (endIndex !== -1)
622
+ overlay.push({from: node.from + startIndex, to: node.from + endIndex + 1});
623
+ })
624
+ return {
625
+ parser: ParseUtil.jsParser,
626
+ overlay
627
+ }
628
+ }
629
+ })
630
+ }),
631
+
632
+ /**
633
+ * Used by javascriptWithHtml() */
634
+ jsParser: jsParser.configure({
635
+ wrap: parseMixed((node, docInput) => {
636
+ if (node.name == "TemplateString") {
637
+ let code = docInput.doc.sliceString(node.from, node.to);
638
+
639
+ // Only if node text starts and ends with tags, allowing for spaces.
640
+ if (code.match(/^`\s*</) && code.match(/>\s*`$/))
641
+ return {parser: ParseUtil.htmlParser}
642
+ }
643
+ return null;
644
+ })
645
+ }),
646
+
647
+ /**
648
+ * Replaces CodeMirror's php() function with one that uses the htmlParser above.
649
+ * This lets us syntax highlight html in javascript template tags.
650
+ * Search codemirror6.js for "PHP language support"
651
+ * to find the original function this is copied from. */
652
+ phpParser: phpParser.configure({
653
+ wrap: parseMixed(node => {
654
+ if (!node.type.isTop)
655
+ return null;
656
+ return {
657
+ parser: ParseUtil.htmlParser,
658
+ overlay: node => node.name == "Text"
659
+ };
660
+ }),
661
+ top: "Template"
662
+
663
+ }),
664
+
665
+
666
+ /**
667
+ * Used by javascriptWithHtml()
668
+ * @param str {string}
669
+ * @param startIndex {int}
670
+ * @returns {int} -1 if no match. */
671
+ findMatchingBrace(str, startIndex) {
672
+ let stack = 0;
673
+ let inString = null;
674
+ let inComment = null;
675
+
676
+ for (let i=startIndex; i<str.length; i++) {
677
+ let char = str[i];
678
+ if (inString && char === '\\') { // Skip escaped characters in strings.
679
+ i++;
680
+ continue;
681
+ }
682
+ let nextChar = i + 1 < str.length ? str[i+1] : '';
683
+
684
+ // String start/end
685
+ if (char === '`' && !inComment)
686
+ inString = inString ? null : '`';
687
+ else if ((char === '"' || char === "'") && !inComment)
688
+ inString = inString === char ? null : char;
689
+
690
+ // Comment start/end
691
+ if (!inString && char === '/' && (nextChar === '*' || nextChar === '/')) {
692
+ inComment = nextChar === '*' ? 'block' : 'line';
693
+ i++; // Skip the next character as it's part of the comment syntax
694
+ }
695
+ else if (inComment === 'block' && char === '*' && nextChar === '/') {
696
+ inComment = null;
697
+ i++; // Skip the '/' character
698
+ }
699
+ else if (inComment === 'line' && char === '\n')
700
+ inComment = null;
701
+
702
+ // Template literal boundaries
703
+ if (!inString && !inComment) {
704
+ if (char === '$' && nextChar === '{') {
705
+ stack++;
706
+ i++; // Skip the '{' character
707
+ } else if (char === '}') {
708
+ if (stack === 0)
709
+ return i; // Matching closing brace found
710
+ stack--;
711
+ }
712
+ }
713
+ }
714
+
715
+ return -1;
716
+ }
717
+ };
718
+
719
+
720
+ /**
721
+ * @typedef ToolbarButton
722
+ * @property {string} html
723
+ * @property {function(el:Node|HTMLElement)} update */
724
+ export class CodeEditorToolbar extends Solarite {
725
+
726
+ /** @type {object} Should have functions for everything the buttons array uses. */
727
+ ed;
728
+
729
+ /**
730
+ * Internal representation of buttons.
731
+ * @type {{html:string, update:function}[]} */
732
+ buttons = [];
733
+
734
+ buttonTemplates
735
+
736
+ /** @type {HTMLElement} */
737
+ buttonEls;
738
+
739
+ /**
740
+ * @param ed {CodeEditor}
741
+ * @param buttons {(string|ToolbarButton)[]} Names of built-in buttons to use, or objects to define new buttons */
742
+ constructor({ed, buttons=[]}={}) {
743
+ super();
744
+ this.ed = ed;
745
+
746
+ this.buttonTemplates = {
747
+ '|': {
748
+ html: r`<span>|</span>`
749
+ },
750
+ undo: {
751
+ html: r`<button onclick="${e => this.ed.undo()}" class="flat" title="Undo">${r(Icons.undo)}</button>`,
752
+ update: (el, sel) => el.toggleAttribute('disabled', this.ed.getUndoDepth() === 0)
753
+ },
754
+ redo: {
755
+ html: r`<button onclick="${e => this.ed.redo()}" class="flat" title="Redo">${r(Icons.redo)}</button>`,
756
+ update: (el, sel) => el.toggleAttribute('disabled', this.ed.getRedoDepth() === 0)
757
+ },
758
+ wordWrap: {
759
+ html: r`<button onclick="${e => this.ed.toggleWordWrap()}" class="flat ${ed.isWordWrapped() && 'selected'}" title="Word Wrap">${r(Icons.wordWrap)}</button>`,
760
+ update: (el, sel) => el.classList.toggle('selected', this.ed.isWordWrapped())
761
+ },
762
+ run: {
763
+ html: r`<button onclick="${e => this.ed.run()}" data-id="btnRun" title="Ctrl+Enter" class="flat">${r(Icons.triangleRight)}Run</button>`
764
+ },
765
+ findReplace: {
766
+ html: r`<button onclick="${e => this.ed.toggleSearchPanel()}" class="flat" title="Find and replace (ctrl+f)">${r(Icons.findReplace)}</button>`,
767
+ update: (el, sel) => el.classList.toggle('selected', this.ed.isSearchPanelOpen())
768
+ }
769
+ };
770
+
771
+ if (typeof buttons === 'string')
772
+ buttons = buttons.split(/[,\s]+/g);
773
+
774
+ for (let button of buttons||[]) {
775
+ let item = typeof button === 'string'
776
+ ? this.buttonTemplates[button]
777
+ : button;
778
+ if (!item)
779
+ throw new Error('Invalid button ' + button);
780
+ this.buttons.push(item);
781
+ }
782
+
783
+ this.render();
784
+
785
+ Util.on(this, 'mousedown', '[data-id=buttonEls] .icon', e => {
786
+ let btn = e.target.closest('.icon');
787
+
788
+ if (btn.hasAttribute('disabled')) {
789
+ e.stopPropagation();
790
+ e.stopImmediatePropagation();
791
+ e.preventDefault();
792
+ return false;
793
+ }
794
+ });
795
+
796
+ this.update();
797
+ }
798
+
799
+ command(e, func) {
800
+ e.preventDefault(); // Don't lose focus
801
+ this.ed[func]();
802
+ }
803
+
804
+ /**
805
+ * Update toolbar buttons depending on the editor state*/
806
+ update() {
807
+ for (let i in this.buttons) {
808
+ let button = this.buttons[i];
809
+ if (button.update)
810
+ button.update(this.buttonEls.children[i], this);
811
+ }
812
+ }
813
+
814
+ render() {
815
+ this.html = r`
816
+ <code-editor-toolbar>
817
+ <style>
818
+ :host { display: block }
819
+ :host [data-id='buttonEls'] span { user-select: none }
820
+ :host .blockSelect [data-id='dropdown'] > div span { opacity: .5 } /* Hotkey text */
821
+ </style>
822
+
823
+ <div data-id="buttonEls" class="row wrap center-v toolbar">
824
+ ${this.buttons.map(button => button.html || button)}
825
+ </div>
826
+
827
+ <!--
828
+ Run
829
+ Word wrap
830
+ format
831
+ find/replace
832
+ undo/redo
833
+ show line numbers
834
+ -->
835
+ </code-editor-toolbar>`
836
+ }
837
+
838
+ }
839
+ CodeEditorToolbar.define();
840
+