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.
@@ -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>
@@ -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 };