lilylet-live-editor 0.0.4
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.
- package/.github/workflows/deploy.yml +81 -0
- package/README.md +78 -0
- package/package.json +50 -0
- package/scripts/build-docs.js +523 -0
- package/src/app.d.ts +13 -0
- package/src/app.html +11 -0
- package/src/lib/components/Editor.svelte +164 -0
- package/src/lib/components/Player.svelte +529 -0
- package/src/lib/components/Preview.svelte +496 -0
- package/src/lib/index.ts +8 -0
- package/src/lib/lilylet/highlight.ts +179 -0
- package/src/lib/lilylet/index.ts +93 -0
- package/src/lib/stores/editor.ts +119 -0
- package/src/lib/utils/share.ts +68 -0
- package/src/lib/verovio/toolkit.ts +83 -0
- package/src/modules.d.ts +41 -0
- package/src/routes/+layout.svelte +8 -0
- package/src/routes/+layout.ts +3 -0
- package/src/routes/+page.svelte +596 -0
- package/src/routes/markdown/+page.svelte +980 -0
- package/static/docs/lilylet-tutorial.md +932 -0
- package/static/js/.gitkeep +7 -0
- package/static/js/musicWidgetsBrowser.umd.min.js +2 -0
- package/static/soundfont/acoustic_grand_piano-mp3.js +93 -0
- package/static/soundfont/acoustic_grand_piano-ogg.js +93 -0
- package/svelte.config.js +22 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +15 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { editorStore } from '$lib/stores/editor';
|
|
4
|
+
import Player from './Player.svelte';
|
|
5
|
+
import { tick } from 'svelte';
|
|
6
|
+
|
|
7
|
+
let svgContainer: HTMLDivElement;
|
|
8
|
+
let previewContainer: HTMLDivElement;
|
|
9
|
+
let cursorElement: HTMLDivElement;
|
|
10
|
+
let cursorStyle = '';
|
|
11
|
+
let lastRenderedSvg = '';
|
|
12
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
13
|
+
let exportMenuOpen = false;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Safely inject SVG content using DOMParser.
|
|
17
|
+
* This extracts only the SVG element from the content, avoiding potential XSS vectors.
|
|
18
|
+
*/
|
|
19
|
+
function safelyInjectSvg(container: HTMLDivElement, svgString: string) {
|
|
20
|
+
// Parse the SVG string
|
|
21
|
+
const parser = new DOMParser();
|
|
22
|
+
const doc = parser.parseFromString(svgString, 'image/svg+xml');
|
|
23
|
+
|
|
24
|
+
// Check for parse errors
|
|
25
|
+
const parseError = doc.querySelector('parsererror');
|
|
26
|
+
if (parseError) {
|
|
27
|
+
console.error('SVG parse error:', parseError.textContent);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Get the SVG element
|
|
32
|
+
const svgElement = doc.querySelector('svg');
|
|
33
|
+
if (!svgElement) {
|
|
34
|
+
console.error('No SVG element found in content');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Clear container and append the sanitized SVG
|
|
39
|
+
container.innerHTML = '';
|
|
40
|
+
container.appendChild(document.importNode(svgElement, true));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
$: if (svgContainer && $editorStore.svg && $editorStore.svg !== lastRenderedSvg) {
|
|
44
|
+
safelyInjectSvg(svgContainer, $editorStore.svg);
|
|
45
|
+
lastRenderedSvg = $editorStore.svg;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Update cursor position when cursorElementId changes
|
|
49
|
+
$: if ($editorStore.cursorElementId && svgContainer) {
|
|
50
|
+
updateCursorPosition($editorStore.cursorElementId);
|
|
51
|
+
} else {
|
|
52
|
+
cursorStyle = 'display: none;';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function updateCursorPosition(elementId: string) {
|
|
56
|
+
await tick(); // Ensure DOM is updated
|
|
57
|
+
|
|
58
|
+
// Use scoped query within svgContainer for safety
|
|
59
|
+
const noteElement = svgContainer?.querySelector(`#${CSS.escape(elementId)}`);
|
|
60
|
+
if (!noteElement || !svgContainer) {
|
|
61
|
+
cursorStyle = 'display: none;';
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Find the parent system element to get proper height bounds
|
|
66
|
+
let systemElement = noteElement.closest('.system');
|
|
67
|
+
if (!systemElement) {
|
|
68
|
+
// Fallback: try to find staff
|
|
69
|
+
systemElement = noteElement.closest('.staff');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const svgRect = svgContainer.getBoundingClientRect();
|
|
73
|
+
const noteRect = noteElement.getBoundingClientRect();
|
|
74
|
+
|
|
75
|
+
// Calculate x position relative to SVG container
|
|
76
|
+
const x = noteRect.left - svgRect.left;
|
|
77
|
+
|
|
78
|
+
// Calculate y and height based on system/staff bounds
|
|
79
|
+
let top = 0;
|
|
80
|
+
let height = svgRect.height;
|
|
81
|
+
|
|
82
|
+
if (systemElement) {
|
|
83
|
+
const systemRect = systemElement.getBoundingClientRect();
|
|
84
|
+
top = systemRect.top - svgRect.top;
|
|
85
|
+
height = systemRect.height;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
cursorStyle = `left: ${x}px; top: ${top}px; height: ${height}px; display: block;`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function downloadFile(content: string, filename: string, mimeType: string) {
|
|
92
|
+
const blob = new Blob([content], { type: mimeType });
|
|
93
|
+
const url = URL.createObjectURL(blob);
|
|
94
|
+
const a = document.createElement('a');
|
|
95
|
+
a.href = url;
|
|
96
|
+
a.download = filename;
|
|
97
|
+
document.body.appendChild(a);
|
|
98
|
+
a.click();
|
|
99
|
+
document.body.removeChild(a);
|
|
100
|
+
URL.revokeObjectURL(url);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function exportSVG() {
|
|
104
|
+
if ($editorStore.svg) {
|
|
105
|
+
downloadFile($editorStore.svg, 'lilylet-score.svg', 'image/svg+xml');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function exportMEI() {
|
|
110
|
+
if ($editorStore.mei) {
|
|
111
|
+
downloadFile($editorStore.mei, 'lilylet-score.mei', 'application/xml');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function exportLilyPond() {
|
|
116
|
+
// TODO:
|
|
117
|
+
//if ($editorStore.code) {
|
|
118
|
+
//}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toggleExportMenu() {
|
|
122
|
+
exportMenuOpen = !exportMenuOpen;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function closeExportMenu() {
|
|
126
|
+
exportMenuOpen = false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function handleExport(type: string) {
|
|
130
|
+
switch (type) {
|
|
131
|
+
case 'svg':
|
|
132
|
+
exportSVG();
|
|
133
|
+
break;
|
|
134
|
+
case 'lilypond':
|
|
135
|
+
exportLilyPond();
|
|
136
|
+
break;
|
|
137
|
+
case 'mei':
|
|
138
|
+
exportMEI();
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
closeExportMenu();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleContainerResize() {
|
|
145
|
+
if (!previewContainer) return;
|
|
146
|
+
const newWidth = previewContainer.clientWidth;
|
|
147
|
+
// Only update if significant change (more than 20px)
|
|
148
|
+
if (Math.abs(newWidth - $editorStore.previewWidth) > 20) {
|
|
149
|
+
editorStore.setPreviewWidth(newWidth);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
onMount(() => {
|
|
154
|
+
if (previewContainer) {
|
|
155
|
+
// Set initial width
|
|
156
|
+
editorStore.setPreviewWidth(previewContainer.clientWidth);
|
|
157
|
+
|
|
158
|
+
// Set up ResizeObserver
|
|
159
|
+
resizeObserver = new ResizeObserver(() => {
|
|
160
|
+
handleContainerResize();
|
|
161
|
+
});
|
|
162
|
+
resizeObserver.observe(previewContainer);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
onDestroy(() => {
|
|
167
|
+
if (resizeObserver) {
|
|
168
|
+
resizeObserver.disconnect();
|
|
169
|
+
resizeObserver = null;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<div class="preview-wrapper">
|
|
175
|
+
<div class="preview-header">
|
|
176
|
+
<span class="title">Preview</span>
|
|
177
|
+
{#if $editorStore.isRendering}
|
|
178
|
+
<span class="rendering">Rendering...</span>
|
|
179
|
+
{/if}
|
|
180
|
+
<div class="spacer"></div>
|
|
181
|
+
<div class="export-dropdown">
|
|
182
|
+
<button class="export-toggle" on:click={toggleExportMenu}>
|
|
183
|
+
Export ▾
|
|
184
|
+
</button>
|
|
185
|
+
{#if exportMenuOpen}
|
|
186
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
187
|
+
<div class="export-backdrop" on:click={closeExportMenu}></div>
|
|
188
|
+
<div class="export-menu">
|
|
189
|
+
<button
|
|
190
|
+
class="export-item"
|
|
191
|
+
on:click={() => handleExport('svg')}
|
|
192
|
+
>
|
|
193
|
+
SVG
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
class="export-item"
|
|
197
|
+
on:click={() => handleExport('lilypond')}
|
|
198
|
+
disabled
|
|
199
|
+
title="Not implemented yet"
|
|
200
|
+
>
|
|
201
|
+
LilyPond (.ly)
|
|
202
|
+
</button>
|
|
203
|
+
<button
|
|
204
|
+
class="export-item"
|
|
205
|
+
disabled
|
|
206
|
+
title="Not implemented yet"
|
|
207
|
+
>
|
|
208
|
+
MusicXML
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
class="export-item"
|
|
212
|
+
on:click={() => handleExport('mei')}
|
|
213
|
+
disabled={!$editorStore.mei}
|
|
214
|
+
>
|
|
215
|
+
MEI
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
{/if}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<div class="preview-container" bind:this={previewContainer}>
|
|
223
|
+
{#if !$editorStore.verovioReady}
|
|
224
|
+
<div class="loading-container">
|
|
225
|
+
<div class="loading-spinner">
|
|
226
|
+
<div class="spinner-ring"></div>
|
|
227
|
+
<div class="spinner-ring"></div>
|
|
228
|
+
<div class="spinner-ring"></div>
|
|
229
|
+
</div>
|
|
230
|
+
<p class="loading-text">Loading Verovio...</p>
|
|
231
|
+
</div>
|
|
232
|
+
{:else if $editorStore.error}
|
|
233
|
+
<div class="error-message">
|
|
234
|
+
<div class="error-title">Error</div>
|
|
235
|
+
<pre>{$editorStore.error}</pre>
|
|
236
|
+
</div>
|
|
237
|
+
{:else if $editorStore.svg}
|
|
238
|
+
<div class="svg-wrapper">
|
|
239
|
+
<div class="svg-container" bind:this={svgContainer}></div>
|
|
240
|
+
<div class="playback-cursor" bind:this={cursorElement} style={cursorStyle}></div>
|
|
241
|
+
</div>
|
|
242
|
+
{:else}
|
|
243
|
+
<div class="placeholder">
|
|
244
|
+
<p>Enter Lilylet code to see the rendered score</p>
|
|
245
|
+
</div>
|
|
246
|
+
{/if}
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<!-- Player controls at bottom -->
|
|
250
|
+
{#if $editorStore.svg && !$editorStore.error}
|
|
251
|
+
<Player />
|
|
252
|
+
{/if}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<style>
|
|
256
|
+
.preview-wrapper {
|
|
257
|
+
display: flex;
|
|
258
|
+
flex-direction: column;
|
|
259
|
+
height: 100%;
|
|
260
|
+
background: #1e1e1e;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.preview-header {
|
|
264
|
+
padding: 8px 16px;
|
|
265
|
+
background: #252526;
|
|
266
|
+
border-bottom: 1px solid #1e1e1e;
|
|
267
|
+
display: flex;
|
|
268
|
+
align-items: center;
|
|
269
|
+
gap: 16px;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.title {
|
|
273
|
+
color: #cccccc;
|
|
274
|
+
font-size: 14px;
|
|
275
|
+
font-weight: 500;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.rendering {
|
|
279
|
+
color: #569cd6;
|
|
280
|
+
font-size: 12px;
|
|
281
|
+
animation: pulse 1s infinite;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@keyframes pulse {
|
|
285
|
+
0%,
|
|
286
|
+
100% {
|
|
287
|
+
opacity: 1;
|
|
288
|
+
}
|
|
289
|
+
50% {
|
|
290
|
+
opacity: 0.5;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.preview-container {
|
|
295
|
+
flex: 1;
|
|
296
|
+
overflow: auto;
|
|
297
|
+
padding: 16px;
|
|
298
|
+
display: flex;
|
|
299
|
+
justify-content: center;
|
|
300
|
+
align-items: flex-start;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.svg-wrapper {
|
|
304
|
+
position: relative;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.svg-container {
|
|
308
|
+
background: white;
|
|
309
|
+
padding: 20px;
|
|
310
|
+
border-radius: 4px;
|
|
311
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.svg-container :global(svg) {
|
|
315
|
+
max-width: 100%;
|
|
316
|
+
height: auto;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.playback-cursor {
|
|
320
|
+
position: absolute;
|
|
321
|
+
width: 2px;
|
|
322
|
+
background: rgba(0, 122, 204, 0.8);
|
|
323
|
+
pointer-events: none;
|
|
324
|
+
z-index: 10;
|
|
325
|
+
display: none;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.error-message {
|
|
329
|
+
background: #5a1d1d;
|
|
330
|
+
border: 1px solid #be1100;
|
|
331
|
+
border-radius: 4px;
|
|
332
|
+
padding: 16px;
|
|
333
|
+
max-width: 600px;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.error-title {
|
|
337
|
+
color: #f48771;
|
|
338
|
+
font-weight: 600;
|
|
339
|
+
margin-bottom: 8px;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
.error-message pre {
|
|
343
|
+
color: #d4d4d4;
|
|
344
|
+
font-size: 12px;
|
|
345
|
+
white-space: pre-wrap;
|
|
346
|
+
word-break: break-all;
|
|
347
|
+
margin: 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.placeholder {
|
|
351
|
+
color: #858585;
|
|
352
|
+
text-align: center;
|
|
353
|
+
padding: 40px;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.loading-container {
|
|
357
|
+
display: flex;
|
|
358
|
+
flex-direction: column;
|
|
359
|
+
align-items: center;
|
|
360
|
+
justify-content: center;
|
|
361
|
+
padding: 60px;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.loading-spinner {
|
|
365
|
+
position: relative;
|
|
366
|
+
width: 60px;
|
|
367
|
+
height: 60px;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.spinner-ring {
|
|
371
|
+
position: absolute;
|
|
372
|
+
width: 100%;
|
|
373
|
+
height: 100%;
|
|
374
|
+
border-radius: 50%;
|
|
375
|
+
border: 3px solid transparent;
|
|
376
|
+
animation: spin 1.5s linear infinite;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.spinner-ring:nth-child(1) {
|
|
380
|
+
border-top-color: #569cd6;
|
|
381
|
+
animation-delay: 0s;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.spinner-ring:nth-child(2) {
|
|
385
|
+
width: 80%;
|
|
386
|
+
height: 80%;
|
|
387
|
+
top: 10%;
|
|
388
|
+
left: 10%;
|
|
389
|
+
border-right-color: #4ec9b0;
|
|
390
|
+
animation-delay: 0.15s;
|
|
391
|
+
animation-direction: reverse;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.spinner-ring:nth-child(3) {
|
|
395
|
+
width: 60%;
|
|
396
|
+
height: 60%;
|
|
397
|
+
top: 20%;
|
|
398
|
+
left: 20%;
|
|
399
|
+
border-bottom-color: #ce9178;
|
|
400
|
+
animation-delay: 0.3s;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@keyframes spin {
|
|
404
|
+
0% {
|
|
405
|
+
transform: rotate(0deg);
|
|
406
|
+
}
|
|
407
|
+
100% {
|
|
408
|
+
transform: rotate(360deg);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.loading-text {
|
|
413
|
+
margin-top: 20px;
|
|
414
|
+
color: #858585;
|
|
415
|
+
font-size: 14px;
|
|
416
|
+
animation: pulse 1.5s ease-in-out infinite;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.spacer {
|
|
420
|
+
flex: 1;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.export-dropdown {
|
|
424
|
+
position: relative;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.export-toggle {
|
|
428
|
+
background: #0e639c;
|
|
429
|
+
color: #ffffff;
|
|
430
|
+
border: none;
|
|
431
|
+
padding: 4px 12px;
|
|
432
|
+
border-radius: 3px;
|
|
433
|
+
font-size: 12px;
|
|
434
|
+
cursor: pointer;
|
|
435
|
+
transition: background 0.2s;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.export-toggle:hover {
|
|
439
|
+
background: #1177bb;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.export-backdrop {
|
|
443
|
+
position: fixed;
|
|
444
|
+
top: 0;
|
|
445
|
+
left: 0;
|
|
446
|
+
right: 0;
|
|
447
|
+
bottom: 0;
|
|
448
|
+
z-index: 99;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.export-menu {
|
|
452
|
+
position: absolute;
|
|
453
|
+
top: 100%;
|
|
454
|
+
right: 0;
|
|
455
|
+
margin-top: 4px;
|
|
456
|
+
background: #252526;
|
|
457
|
+
border: 1px solid #3c3c3c;
|
|
458
|
+
border-radius: 4px;
|
|
459
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
460
|
+
min-width: 160px;
|
|
461
|
+
z-index: 100;
|
|
462
|
+
overflow: hidden;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.export-item {
|
|
466
|
+
display: block;
|
|
467
|
+
width: 100%;
|
|
468
|
+
padding: 8px 16px;
|
|
469
|
+
background: transparent;
|
|
470
|
+
border: none;
|
|
471
|
+
color: #cccccc;
|
|
472
|
+
font-size: 13px;
|
|
473
|
+
text-align: left;
|
|
474
|
+
cursor: pointer;
|
|
475
|
+
transition: background 0.15s;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.export-item:hover:not(:disabled) {
|
|
479
|
+
background: #094771;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.export-item:disabled {
|
|
483
|
+
color: #5a5a5a;
|
|
484
|
+
cursor: not-allowed;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.export-item:not(:last-child) {
|
|
488
|
+
border-bottom: 1px solid #3c3c3c;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.not-impl {
|
|
492
|
+
font-size: 11px;
|
|
493
|
+
color: #858585;
|
|
494
|
+
margin-left: 4px;
|
|
495
|
+
}
|
|
496
|
+
</style>
|
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Reexport components and utilities
|
|
2
|
+
export { default as Editor } from './components/Editor.svelte';
|
|
3
|
+
export { default as Preview } from './components/Preview.svelte';
|
|
4
|
+
export { default as Player } from './components/Player.svelte';
|
|
5
|
+
export * from './stores/editor';
|
|
6
|
+
export * from './lilylet';
|
|
7
|
+
export * from './utils/share';
|
|
8
|
+
export * from './verovio/toolkit';
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Lilylet syntax highlighting for CodeMirror 6
|
|
2
|
+
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
3
|
+
import { tags as t } from '@lezer/highlight';
|
|
4
|
+
import { StreamLanguage } from '@codemirror/language';
|
|
5
|
+
|
|
6
|
+
// Define the Lilylet language
|
|
7
|
+
const lilyletLanguage = StreamLanguage.define({
|
|
8
|
+
token(stream) {
|
|
9
|
+
// Skip whitespace
|
|
10
|
+
if (stream.eatSpace()) return null;
|
|
11
|
+
|
|
12
|
+
// Comments: % to end of line
|
|
13
|
+
if (stream.match('%')) {
|
|
14
|
+
stream.skipToEnd();
|
|
15
|
+
return 'comment';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Bar line: |
|
|
19
|
+
if (stream.match('|')) {
|
|
20
|
+
return 'separator';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Commands starting with backslash
|
|
24
|
+
if (stream.match(/^\\[a-zA-Z]+/)) {
|
|
25
|
+
const cmd = stream.current();
|
|
26
|
+
// Context commands
|
|
27
|
+
if (/^\\(key|time|clef|tempo|ottava|staff)$/.test(cmd)) {
|
|
28
|
+
return 'keyword';
|
|
29
|
+
}
|
|
30
|
+
// Mode commands
|
|
31
|
+
if (/^\\(major|minor)$/.test(cmd)) {
|
|
32
|
+
return 'typeName';
|
|
33
|
+
}
|
|
34
|
+
// Articulation and expressive commands
|
|
35
|
+
if (/^\\(trill|turn|mordent|prall|fermata|shortfermata|arpeggio)$/.test(cmd)) {
|
|
36
|
+
return 'labelName';
|
|
37
|
+
}
|
|
38
|
+
// Dynamic commands
|
|
39
|
+
if (/^\\(ppp|pp|p|mp|mf|f|ff|fff|sfz|rfz)$/.test(cmd)) {
|
|
40
|
+
return 'labelName';
|
|
41
|
+
}
|
|
42
|
+
// Hairpin commands
|
|
43
|
+
if (/^\\(<|>|!)$/.test(cmd)) {
|
|
44
|
+
return 'labelName';
|
|
45
|
+
}
|
|
46
|
+
// Stem direction
|
|
47
|
+
if (/^\\(stemUp|stemDown|stemNeutral)$/.test(cmd)) {
|
|
48
|
+
return 'operator';
|
|
49
|
+
}
|
|
50
|
+
// Tuplet
|
|
51
|
+
if (/^\\times$/.test(cmd)) {
|
|
52
|
+
return 'keyword';
|
|
53
|
+
}
|
|
54
|
+
// Repeat/tremolo
|
|
55
|
+
if (/^\\repeat$/.test(cmd)) {
|
|
56
|
+
return 'keyword';
|
|
57
|
+
}
|
|
58
|
+
// Grace note
|
|
59
|
+
if (/^\\grace$/.test(cmd)) {
|
|
60
|
+
return 'special';
|
|
61
|
+
}
|
|
62
|
+
// Rest marker
|
|
63
|
+
if (/^\\rest$/.test(cmd)) {
|
|
64
|
+
return 'comment';
|
|
65
|
+
}
|
|
66
|
+
// Pedal
|
|
67
|
+
if (/^\\(sustainOn|sustainOff|sostenutoOn|sostenutoOff|unaCorda|treCorde)$/.test(cmd)) {
|
|
68
|
+
return 'labelName';
|
|
69
|
+
}
|
|
70
|
+
// Other commands
|
|
71
|
+
return 'meta';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Chord: < ... >
|
|
75
|
+
if (stream.match('<')) {
|
|
76
|
+
return 'bracket';
|
|
77
|
+
}
|
|
78
|
+
if (stream.match('>')) {
|
|
79
|
+
return 'bracket';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Tuplet braces
|
|
83
|
+
if (stream.match('{')) {
|
|
84
|
+
return 'brace';
|
|
85
|
+
}
|
|
86
|
+
if (stream.match('}')) {
|
|
87
|
+
return 'brace';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Quoted strings (for clef names, titles, etc.)
|
|
91
|
+
if (stream.match(/"[^"]*"/)) {
|
|
92
|
+
return 'string';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Numbers (for time signature, duration, etc.)
|
|
96
|
+
if (stream.match(/^\d+/)) {
|
|
97
|
+
return 'number';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Tie ~
|
|
101
|
+
if (stream.match('~')) {
|
|
102
|
+
return 'operator';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Slur ( )
|
|
106
|
+
if (stream.match('(') || stream.match(')')) {
|
|
107
|
+
return 'paren';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Beam [ ]
|
|
111
|
+
if (stream.match('[') || stream.match(']')) {
|
|
112
|
+
return 'squareBracket';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Articulation marks: -. -! -_ -^ ->
|
|
116
|
+
if (stream.match(/^[-_^][.!_^>]/)) {
|
|
117
|
+
return 'labelName';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Pitch names with optional accidentals and octave marks
|
|
121
|
+
// Pitch: c, d, e, f, g, a, b followed by optional s/f/ss/ff/! and '/,
|
|
122
|
+
if (stream.match(/^[a-g](s|f|ss|ff|!)?[',]*/)) {
|
|
123
|
+
return 'variableName';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Rest types: r, s, R (full measure)
|
|
127
|
+
if (stream.match(/^[rsR]/)) {
|
|
128
|
+
return 'comment';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Dots after duration
|
|
132
|
+
if (stream.match('.')) {
|
|
133
|
+
return 'punctuation';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Tremolo marker :
|
|
137
|
+
if (stream.match(':')) {
|
|
138
|
+
return 'operator';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fraction for tuplet ratio
|
|
142
|
+
if (stream.match('/')) {
|
|
143
|
+
return 'operator';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Skip any other character
|
|
147
|
+
stream.next();
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Custom highlight style for Lilylet
|
|
153
|
+
const lilyletHighlightStyle = HighlightStyle.define([
|
|
154
|
+
{ tag: t.keyword, color: '#c678dd', fontWeight: 'bold' }, // \key, \time, \clef
|
|
155
|
+
{ tag: t.typeName, color: '#56b6c2' }, // \major, \minor
|
|
156
|
+
{ tag: t.separator, color: '#e06c75', fontWeight: 'bold' }, // |
|
|
157
|
+
{ tag: t.comment, color: '#5c6370', fontStyle: 'italic' }, // %, r, s, R
|
|
158
|
+
{ tag: t.string, color: '#98c379' }, // "..."
|
|
159
|
+
{ tag: t.number, color: '#d19a66' }, // 4, 8, 16
|
|
160
|
+
{ tag: t.variableName, color: '#e06c75', fontWeight: 'bold' }, // c, d, e, f, g, a, b
|
|
161
|
+
{ tag: t.bracket, color: '#61afef' }, // < >
|
|
162
|
+
{ tag: t.brace, color: '#c678dd' }, // { }
|
|
163
|
+
{ tag: t.paren, color: '#98c379' }, // ( )
|
|
164
|
+
{ tag: t.squareBracket, color: '#61afef' }, // [ ]
|
|
165
|
+
{ tag: t.operator, color: '#56b6c2' }, // ~, :, /
|
|
166
|
+
{ tag: t.labelName, color: '#98c379' }, // articulations, dynamics
|
|
167
|
+
{ tag: t.special(t.keyword), color: '#c678dd', fontStyle: 'italic' }, // \grace
|
|
168
|
+
{ tag: t.meta, color: '#abb2bf' }, // other commands
|
|
169
|
+
{ tag: t.punctuation, color: '#abb2bf' }, // .
|
|
170
|
+
{ tag: t.invalid, color: '#e06c75', textDecoration: 'underline' } // Unknown
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
// Export the language and highlighting
|
|
174
|
+
export const lilylet = () => [
|
|
175
|
+
lilyletLanguage,
|
|
176
|
+
syntaxHighlighting(lilyletHighlightStyle)
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
export { lilyletLanguage, lilyletHighlightStyle };
|