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,596 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { browser } from '$app/environment';
4
+ import { base } from '$app/paths';
5
+ import Editor from '$lib/components/Editor.svelte';
6
+ import Preview from '$lib/components/Preview.svelte';
7
+ import { editorStore } from '$lib/stores/editor';
8
+ import { lilyletToMEI, musicXmlToLilylet, lilypondToLilylet } from '$lib/lilylet';
9
+ import { getStateFromUrl, copyShareUrl } from '$lib/utils/share';
10
+ import { initVerovio, getToolkit } from '$lib/verovio/toolkit';
11
+
12
+ let shareStatus: 'idle' | 'copied' | 'error' = 'idle';
13
+ let lastRenderedCode = '';
14
+ let lastRenderedWidth = 0;
15
+ let resizeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
16
+
17
+ // Divider drag state
18
+ let isDragging = false;
19
+ let editorWidth = 40; // percentage
20
+ let mainElement: HTMLElement;
21
+
22
+ // Drag and drop state
23
+ let isDragOver = false;
24
+
25
+ // Render cancellation token to prevent out-of-order updates
26
+ let currentRenderId = 0;
27
+
28
+ // Watch for code changes and re-render (only when code actually changes)
29
+ $: if (browser && $editorStore.verovioReady && $editorStore.code && $editorStore.code !== lastRenderedCode) {
30
+ lastRenderedCode = $editorStore.code;
31
+ lastRenderedWidth = $editorStore.previewWidth;
32
+ renderScore($editorStore.code);
33
+ }
34
+
35
+ // Watch for width changes and re-render with debounce
36
+ $: if (browser && $editorStore.verovioReady && $editorStore.svg && $editorStore.previewWidth !== lastRenderedWidth) {
37
+ if (resizeDebounceTimer) clearTimeout(resizeDebounceTimer);
38
+ resizeDebounceTimer = setTimeout(() => {
39
+ lastRenderedWidth = $editorStore.previewWidth;
40
+ renderScore($editorStore.code);
41
+ }, 150);
42
+ }
43
+
44
+ async function handleShare() {
45
+ const success = await copyShareUrl({ code: $editorStore.code });
46
+ shareStatus = success ? 'copied' : 'error';
47
+ setTimeout(() => {
48
+ shareStatus = 'idle';
49
+ }, 2000);
50
+ }
51
+
52
+ async function setupVerovio() {
53
+ try {
54
+ await initVerovio();
55
+ editorStore.setVerovioReady(true);
56
+ } catch (err) {
57
+ console.error('Failed to initialize Verovio:', err);
58
+ editorStore.setError('Failed to initialize Verovio: ' + String(err));
59
+ }
60
+ }
61
+
62
+ async function renderScore(code: string) {
63
+ const toolkit = getToolkit();
64
+ if (!toolkit) return;
65
+
66
+ // Increment render ID and capture for this render
67
+ const renderId = ++currentRenderId;
68
+
69
+ editorStore.setRendering(true);
70
+
71
+ try {
72
+ // Convert Lilylet to MEI
73
+ const result = await lilyletToMEI(code);
74
+ if (!result.success) {
75
+ // Check if this render is still current
76
+ if (renderId !== currentRenderId) return;
77
+ editorStore.setError('Failed to parse Lilylet code');
78
+ editorStore.addLog('error', `Lilylet to MEI conversion failed: ${result.error}`);
79
+ return;
80
+ }
81
+
82
+ const { mei, measureCount, staffCount } = result;
83
+
84
+ // Check if this render is still current before updating store
85
+ if (renderId !== currentRenderId) return;
86
+ editorStore.setMEI(mei);
87
+
88
+ // Calculate pageWidth based on container - account for padding
89
+ const effectiveWidth = Math.max(400, $editorStore.previewWidth - 80);
90
+ // Verovio scale 40 means 40% of default size, pageWidth is in abstract units
91
+ // At scale 40, approximately 2.5 abstract units = 1 pixel
92
+ const pageWidthUnits = Math.round(effectiveWidth * 2.5);
93
+
94
+ // Calculate pageHeight based on measure count and staff count
95
+ // ~20 measures fit in one standard page (height ~2000 units at scale 40)
96
+ const basePageHeight = 2000;
97
+ const measuresPerPage = 20;
98
+ const pageHeight = Math.max(basePageHeight, Math.ceil(measureCount / measuresPerPage) * basePageHeight) * 2 * staffCount;
99
+
100
+ toolkit.setOptions({
101
+ scale: 40,
102
+ adjustPageHeight: true,
103
+ pageHeight,
104
+ pageWidth: pageWidthUnits
105
+ });
106
+
107
+ // Render with Verovio
108
+ const success = toolkit.loadData(mei);
109
+ if (!success) {
110
+ if (renderId !== currentRenderId) return;
111
+ editorStore.setError('Verovio failed to load MEI data');
112
+ editorStore.addLog('error', 'Verovio failed to load MEI data');
113
+ return;
114
+ }
115
+
116
+ const pageCount = toolkit.getPageCount();
117
+ const svg = toolkit.renderToSVG(1);
118
+
119
+ // Final check before committing results
120
+ if (renderId !== currentRenderId) return;
121
+ editorStore.setSVG(svg, pageCount);
122
+ } catch (err) {
123
+ // Only set error if this render is still current
124
+ if (renderId === currentRenderId) {
125
+ console.error('Render error:', err);
126
+ editorStore.setError(String(err));
127
+ editorStore.addLog('error', `Render error: ${String(err)}`);
128
+ }
129
+ } finally {
130
+ // Only clear rendering flag if this is the current render
131
+ if (renderId === currentRenderId) {
132
+ editorStore.setRendering(false);
133
+ }
134
+ }
135
+ }
136
+
137
+ // Drag and drop handlers
138
+ function handleDragOver(e: DragEvent) {
139
+ e.preventDefault();
140
+ isDragOver = true;
141
+ }
142
+
143
+ function handleDragLeave(e: DragEvent) {
144
+ e.preventDefault();
145
+ isDragOver = false;
146
+ }
147
+
148
+ async function handleFileDrop(file: File) {
149
+ const fileName = file.name.toLowerCase();
150
+
151
+ // Check file type by extension
152
+ const isLilypond = fileName.endsWith('.ly') || fileName.endsWith('.ily');
153
+ const isMusicXml = fileName.endsWith('.musicxml') || fileName.endsWith('.mxl') ||
154
+ (fileName.endsWith('.xml') && !fileName.endsWith('.mei.xml'));
155
+
156
+ if (!isLilypond && !isMusicXml) {
157
+ editorStore.addLog('warning', `Unsupported file type: ${file.name}. Supported formats: .ly, .musicxml, .xml`);
158
+ return;
159
+ }
160
+
161
+ try {
162
+ const content = await file.text();
163
+ editorStore.addLog('info', `Converting ${file.name}...`);
164
+
165
+ if (isLilypond) {
166
+ const result = lilypondToLilylet(content);
167
+ if (result.success) {
168
+ editorStore.setCode(result.data);
169
+ editorStore.addLog('info', `Successfully converted ${file.name} to Lilylet`);
170
+ } else {
171
+ editorStore.addLog('error', result.error);
172
+ }
173
+ } else if (isMusicXml) {
174
+ const result = musicXmlToLilylet(content);
175
+ if (result.success) {
176
+ editorStore.setCode(result.data);
177
+ editorStore.addLog('info', `Successfully converted ${file.name} to Lilylet`);
178
+ } else {
179
+ editorStore.addLog('error', result.error);
180
+ }
181
+ }
182
+ } catch (error) {
183
+ const errorMessage = error instanceof Error ? error.message : String(error);
184
+ editorStore.addLog('error', `Failed to read file: ${errorMessage}`);
185
+ }
186
+ }
187
+
188
+ async function handleDrop(e: DragEvent) {
189
+ e.preventDefault();
190
+ isDragOver = false;
191
+
192
+ const files = e.dataTransfer?.files;
193
+ if (!files || files.length === 0) return;
194
+
195
+ await handleFileDrop(files[0]);
196
+ }
197
+
198
+ function handleDividerMouseDown(e: MouseEvent) {
199
+ isDragging = true;
200
+ e.preventDefault();
201
+ }
202
+
203
+ function handleMouseMove(e: MouseEvent) {
204
+ if (!isDragging || !mainElement) return;
205
+ const rect = mainElement.getBoundingClientRect();
206
+ const newWidth = ((e.clientX - rect.left) / rect.width) * 100;
207
+ // Clamp between 20% and 80%
208
+ editorWidth = Math.max(20, Math.min(80, newWidth));
209
+ }
210
+
211
+ function handleMouseUp() {
212
+ isDragging = false;
213
+ }
214
+
215
+ onMount(() => {
216
+ if (browser) {
217
+ // Load state from URL if present
218
+ const urlState = getStateFromUrl();
219
+ if (urlState?.code) {
220
+ editorStore.setCode(urlState.code);
221
+ }
222
+
223
+ setupVerovio();
224
+
225
+ // Add global mouse event listeners for dragging
226
+ window.addEventListener('mousemove', handleMouseMove);
227
+ window.addEventListener('mouseup', handleMouseUp);
228
+
229
+ return () => {
230
+ window.removeEventListener('mousemove', handleMouseMove);
231
+ window.removeEventListener('mouseup', handleMouseUp);
232
+ };
233
+ }
234
+ });
235
+ </script>
236
+
237
+ <svelte:head>
238
+ <title>Lilylet Live Editor</title>
239
+ </svelte:head>
240
+
241
+ <div class="app">
242
+ <header>
243
+ <h1>Lilylet Live Editor</h1>
244
+ <nav>
245
+ <span class="current">Editor</span>
246
+ <a href="{base}/markdown">Markdown Demo</a>
247
+ <a href="{base}/docs/lilylet-tutorial.html" target="_blank">Tutorial</a>
248
+ </nav>
249
+ <div class="header-actions">
250
+ <button class="share-btn" on:click={handleShare}>
251
+ {#if shareStatus === 'copied'}
252
+ Copied!
253
+ {:else if shareStatus === 'error'}
254
+ Error
255
+ {:else}
256
+ Share
257
+ {/if}
258
+ </button>
259
+ <span class="status">
260
+ {#if !$editorStore.verovioReady}
261
+ Loading Verovio...
262
+ {:else}
263
+ Ready
264
+ {/if}
265
+ </span>
266
+ </div>
267
+ </header>
268
+
269
+ <main
270
+ bind:this={mainElement}
271
+ class:dragging={isDragging}
272
+ class:drag-over={isDragOver}
273
+ on:dragover={handleDragOver}
274
+ on:dragleave={handleDragLeave}
275
+ on:drop={handleDrop}
276
+ >
277
+ <div class="pane editor-pane" style="flex: 0 0 {editorWidth}%">
278
+ <Editor on:filedrop={(e) => { isDragOver = false; handleFileDrop(e.detail); }} />
279
+ </div>
280
+ <div class="divider" on:mousedown={handleDividerMouseDown}></div>
281
+ <div class="pane preview-pane">
282
+ <Preview />
283
+ </div>
284
+ {#if isDragOver}
285
+ <div class="drop-overlay">
286
+ <div class="drop-message">
287
+ Drop LilyPond (.ly) or MusicXML (.musicxml, .xml) file to convert
288
+ </div>
289
+ </div>
290
+ {/if}
291
+ </main>
292
+
293
+ <!-- Collapsible Log Area -->
294
+ <div class="log-area" class:expanded={$editorStore.logsExpanded}>
295
+ <button class="log-toggle" on:click={() => editorStore.setLogsExpanded(!$editorStore.logsExpanded)}>
296
+ <span class="toggle-icon">{$editorStore.logsExpanded ? '▼' : '▶'}</span>
297
+ Logs ({$editorStore.logs.length})
298
+ {#if $editorStore.logs.filter(l => l.level === 'error').length > 0}
299
+ <span class="error-badge">{$editorStore.logs.filter(l => l.level === 'error').length} errors</span>
300
+ {/if}
301
+ </button>
302
+ {#if $editorStore.logsExpanded}
303
+ <div class="log-content">
304
+ <div class="log-actions">
305
+ <button class="clear-btn" on:click={() => editorStore.clearLogs()}>Clear</button>
306
+ </div>
307
+ <div class="log-entries">
308
+ {#each $editorStore.logs as log}
309
+ <div class="log-entry log-{log.level}">
310
+ <span class="log-time">{log.timestamp.toLocaleTimeString()}</span>
311
+ <span class="log-level">[{log.level.toUpperCase()}]</span>
312
+ <span class="log-message">{log.message}</span>
313
+ </div>
314
+ {/each}
315
+ {#if $editorStore.logs.length === 0}
316
+ <div class="log-empty">No logs yet</div>
317
+ {/if}
318
+ </div>
319
+ </div>
320
+ {/if}
321
+ </div>
322
+ </div>
323
+
324
+ <style>
325
+ :global(body) {
326
+ margin: 0;
327
+ padding: 0;
328
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
329
+ background: #1e1e1e;
330
+ color: #d4d4d4;
331
+ }
332
+
333
+ :global(*) {
334
+ box-sizing: border-box;
335
+ }
336
+
337
+ .app {
338
+ display: flex;
339
+ flex-direction: column;
340
+ height: 100vh;
341
+ overflow: hidden;
342
+ }
343
+
344
+ header {
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: space-between;
348
+ padding: 8px 16px;
349
+ background: #333333;
350
+ border-bottom: 1px solid #454545;
351
+ }
352
+
353
+ h1 {
354
+ margin: 0;
355
+ font-size: 18px;
356
+ font-weight: 600;
357
+ color: #ffffff;
358
+ }
359
+
360
+ nav {
361
+ display: flex;
362
+ gap: 16px;
363
+ }
364
+
365
+ nav a {
366
+ color: #858585;
367
+ text-decoration: none;
368
+ font-size: 14px;
369
+ }
370
+
371
+ nav a:hover {
372
+ color: #d4d4d4;
373
+ }
374
+
375
+ nav .current {
376
+ color: #0e639c;
377
+ font-weight: 600;
378
+ font-size: 14px;
379
+ }
380
+
381
+ .header-actions {
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 16px;
385
+ }
386
+
387
+ .share-btn {
388
+ background: #0e639c;
389
+ color: #ffffff;
390
+ border: none;
391
+ padding: 6px 16px;
392
+ border-radius: 3px;
393
+ font-size: 13px;
394
+ cursor: pointer;
395
+ transition: background 0.2s;
396
+ min-width: 80px;
397
+ }
398
+
399
+ .share-btn:hover {
400
+ background: #1177bb;
401
+ }
402
+
403
+ .share-btn:active {
404
+ background: #094771;
405
+ }
406
+
407
+ .status {
408
+ font-size: 12px;
409
+ color: #858585;
410
+ }
411
+
412
+ main {
413
+ display: flex;
414
+ flex: 1;
415
+ overflow: hidden;
416
+ }
417
+
418
+ .pane {
419
+ flex: 1;
420
+ overflow: hidden;
421
+ }
422
+
423
+ .editor-pane {
424
+ min-width: 200px;
425
+ }
426
+
427
+ .preview-pane {
428
+ flex: 1;
429
+ }
430
+
431
+ .divider {
432
+ width: 4px;
433
+ background: #333333;
434
+ cursor: col-resize;
435
+ }
436
+
437
+ .divider:hover {
438
+ background: #0078d4;
439
+ }
440
+
441
+ main.dragging {
442
+ cursor: col-resize;
443
+ user-select: none;
444
+ }
445
+
446
+ main.dragging .divider {
447
+ background: #0078d4;
448
+ }
449
+
450
+ /* Drag and drop styles */
451
+ main.drag-over {
452
+ position: relative;
453
+ }
454
+
455
+ .drop-overlay {
456
+ position: absolute;
457
+ top: 0;
458
+ left: 0;
459
+ right: 0;
460
+ bottom: 0;
461
+ background: rgba(14, 99, 156, 0.9);
462
+ display: flex;
463
+ align-items: center;
464
+ justify-content: center;
465
+ z-index: 100;
466
+ pointer-events: none;
467
+ }
468
+
469
+ .drop-message {
470
+ color: #ffffff;
471
+ font-size: 18px;
472
+ font-weight: 600;
473
+ padding: 24px 48px;
474
+ border: 3px dashed #ffffff;
475
+ border-radius: 8px;
476
+ text-align: center;
477
+ }
478
+
479
+ /* Log area styles */
480
+ .log-area {
481
+ border-top: 1px solid #454545;
482
+ background: #252526;
483
+ }
484
+
485
+ .log-toggle {
486
+ width: 100%;
487
+ display: flex;
488
+ align-items: center;
489
+ gap: 8px;
490
+ padding: 6px 12px;
491
+ background: #333333;
492
+ border: none;
493
+ color: #d4d4d4;
494
+ font-size: 12px;
495
+ cursor: pointer;
496
+ text-align: left;
497
+ }
498
+
499
+ .log-toggle:hover {
500
+ background: #3c3c3c;
501
+ }
502
+
503
+ .toggle-icon {
504
+ font-size: 10px;
505
+ color: #858585;
506
+ }
507
+
508
+ .error-badge {
509
+ background: #f14c4c;
510
+ color: #ffffff;
511
+ padding: 1px 6px;
512
+ border-radius: 10px;
513
+ font-size: 11px;
514
+ margin-left: auto;
515
+ }
516
+
517
+ .log-content {
518
+ max-height: 200px;
519
+ overflow: hidden;
520
+ display: flex;
521
+ flex-direction: column;
522
+ }
523
+
524
+ .log-actions {
525
+ padding: 4px 12px;
526
+ border-bottom: 1px solid #454545;
527
+ display: flex;
528
+ justify-content: flex-end;
529
+ }
530
+
531
+ .clear-btn {
532
+ background: transparent;
533
+ border: 1px solid #454545;
534
+ color: #858585;
535
+ padding: 2px 8px;
536
+ border-radius: 3px;
537
+ font-size: 11px;
538
+ cursor: pointer;
539
+ }
540
+
541
+ .clear-btn:hover {
542
+ background: #3c3c3c;
543
+ color: #d4d4d4;
544
+ }
545
+
546
+ .log-entries {
547
+ flex: 1;
548
+ overflow-y: auto;
549
+ padding: 8px 12px;
550
+ font-family: 'Consolas', 'Monaco', monospace;
551
+ font-size: 12px;
552
+ }
553
+
554
+ .log-entry {
555
+ padding: 2px 0;
556
+ display: flex;
557
+ gap: 8px;
558
+ }
559
+
560
+ .log-time {
561
+ color: #6a9955;
562
+ flex-shrink: 0;
563
+ }
564
+
565
+ .log-level {
566
+ flex-shrink: 0;
567
+ min-width: 60px;
568
+ }
569
+
570
+ .log-message {
571
+ word-break: break-word;
572
+ }
573
+
574
+ .log-info .log-level {
575
+ color: #4fc1ff;
576
+ }
577
+
578
+ .log-warning .log-level {
579
+ color: #cca700;
580
+ }
581
+
582
+ .log-error .log-level {
583
+ color: #f14c4c;
584
+ }
585
+
586
+ .log-error .log-message {
587
+ color: #f14c4c;
588
+ }
589
+
590
+ .log-empty {
591
+ color: #858585;
592
+ font-style: italic;
593
+ text-align: center;
594
+ padding: 16px;
595
+ }
596
+ </style>