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,980 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { browser } from '$app/environment';
4
+ import { base } from '$app/paths';
5
+ import MarkdownIt from 'markdown-it';
6
+ import lilyletPlugin from '@k-l-lambda/lilylet-markdown';
7
+ import { initVerovio, getToolkit } from '$lib/verovio/toolkit';
8
+ import { lilyletToMEI } from '$lib/lilylet';
9
+
10
+ let markdownInput = `# Lilylet in Markdown Demo
11
+
12
+ This demonstrates embedding **Lilylet music notation** in Markdown documents.
13
+
14
+ ## Simple Scale (Static)
15
+
16
+ \`\`\`lilylet
17
+ \\key c \\major
18
+ \\time 4/4
19
+ c4 d e f | g a b c | c1
20
+ \`\`\`
21
+
22
+ ## Playable Example
23
+
24
+ Use \`lyl.play\` or \`lilylet.play\` to make the score playable:
25
+
26
+ \`\`\`lyl.play
27
+ \\time 4/4
28
+ c4 d e f | g a b c | <c e g>2 <g, b d>2 | c1
29
+ \`\`\`
30
+
31
+ ## Another Playable
32
+
33
+ \`\`\`lilylet.play
34
+ \\staff "1" \\key g \\major \\time 3/4 \\clef "treble" \\stemDown d'4(\\p \\stemUp g,8[ a b c] \\\\
35
+ \\staff "2" \\clef "bass" \\stemDown <g b d>2( a4 | %1
36
+
37
+ \\staff "1" \\stemDown \\clef "treble" d'4) \\stemUp g, g \\\\
38
+ \\staff "2" \\clef "bass" \\stemDown b2.) | %2
39
+
40
+ \\staff "1" \\stemDown \\clef "treble" e'4( c8[ d e fs] \\\\
41
+ \\staff "2" \\clef "bass" \\stemDown c2.( | %3
42
+
43
+ \\staff "1" \\clef "treble" \\stemDown g''4) \\stemUp g, g \\\\
44
+ \\staff "2" \\clef "bass" \\stemDown b2.) | %4
45
+ \`\`\`
46
+
47
+ ## Regular Code Block
48
+
49
+ \`\`\`javascript
50
+ console.log('Hello, Lilylet!');
51
+ \`\`\`
52
+ `;
53
+
54
+ // Music widgets (loaded dynamically)
55
+ let MIDI: any;
56
+ let MusicNotation: any;
57
+ let MidiAudio: any;
58
+ let isAudioLoaded = false;
59
+
60
+ // Per-block player state
61
+ interface BlockPlayer {
62
+ id: string;
63
+ element: HTMLElement;
64
+ mei: string;
65
+ midiData: any;
66
+ midiPlayer: any;
67
+ isPlaying: boolean;
68
+ currentTime: number;
69
+ duration: number;
70
+ updateInterval: number | null;
71
+ playStartTime: number;
72
+ lastEventIndex: number;
73
+ pausedTime: number;
74
+ highlightedNotes: Set<string>;
75
+ lastHighlightUpdate: number;
76
+ }
77
+ let blockPlayers: Map<string, BlockPlayer> = new Map();
78
+ let playingBlockId: string | null = null;
79
+ const HIGHLIGHT_THROTTLE_MS = 50;
80
+
81
+ let renderedHtml = '';
82
+ let verovioReady = false;
83
+ let md: MarkdownIt;
84
+ let renderVersion = 0;
85
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
86
+ let previewContainer: HTMLElement;
87
+ let resizeObserver: ResizeObserver | null = null;
88
+ let containerWidth = 800; // Default width
89
+
90
+ function renderMarkdown() {
91
+ if (!md) return;
92
+ renderedHtml = md.render(markdownInput);
93
+
94
+ // After rendering, process lilylet placeholders with Verovio
95
+ if (browser && verovioReady) {
96
+ renderVersion++;
97
+ setTimeout(() => renderLilyletBlocks(renderVersion), 0);
98
+ }
99
+ }
100
+
101
+ function debouncedRender() {
102
+ if (debounceTimer) clearTimeout(debounceTimer);
103
+ debounceTimer = setTimeout(() => {
104
+ renderMarkdown();
105
+ }, 300);
106
+ }
107
+
108
+ // Load music-widgets UMD bundle via script tag
109
+ async function loadMusicWidgets(): Promise<any> {
110
+ if ((window as any).musicWidgetsBrowser) {
111
+ return (window as any).musicWidgetsBrowser;
112
+ }
113
+
114
+ return new Promise((resolve, reject) => {
115
+ const script = document.createElement('script');
116
+ script.src = `${import.meta.env.BASE_URL}js/musicWidgetsBrowser.umd.min.js`;
117
+ script.onload = () => {
118
+ if ((window as any).musicWidgetsBrowser) {
119
+ resolve((window as any).musicWidgetsBrowser);
120
+ } else {
121
+ reject(new Error('musicWidgetsBrowser not found on window after script load'));
122
+ }
123
+ };
124
+ script.onerror = () => reject(new Error('Failed to load music-widgets UMD bundle'));
125
+ document.head.appendChild(script);
126
+ });
127
+ }
128
+
129
+ async function initAudio() {
130
+ try {
131
+ const musicWidgets = await loadMusicWidgets();
132
+ MIDI = musicWidgets.MIDI;
133
+ MusicNotation = musicWidgets.MusicNotation;
134
+ MidiAudio = musicWidgets.MidiAudio;
135
+
136
+ await MidiAudio.loadPlugin({
137
+ soundfontUrl: `${base}/soundfont/`,
138
+ api: 'webaudio'
139
+ });
140
+ isAudioLoaded = true;
141
+ } catch (error) {
142
+ console.error('Failed to load MidiAudio:', error);
143
+ }
144
+ }
145
+
146
+ function createMiniPlayer(blockId: string): HTMLElement {
147
+ const controls = document.createElement('div');
148
+ controls.className = 'mini-player';
149
+ controls.innerHTML = `
150
+ <button class="play-btn" data-block="${blockId}" title="Play">
151
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
152
+ <path d="M3 2v12l10-6z" />
153
+ </svg>
154
+ </button>
155
+ <span class="time-display">0:00 / 0:00</span>
156
+ <div class="mini-progress">
157
+ <div class="mini-progress-fill"></div>
158
+ </div>
159
+ `;
160
+
161
+ // Add event listeners
162
+ const playBtn = controls.querySelector('.play-btn') as HTMLElement;
163
+ playBtn.addEventListener('click', () => togglePlay(blockId));
164
+
165
+ const progressBar = controls.querySelector('.mini-progress') as HTMLElement;
166
+ progressBar.addEventListener('click', (e) => handleProgressClick(blockId, e));
167
+
168
+ return controls;
169
+ }
170
+
171
+ function formatTime(ms: number): string {
172
+ const seconds = Math.floor(ms / 1000);
173
+ const minutes = Math.floor(seconds / 60);
174
+ const remainingSeconds = seconds % 60;
175
+ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
176
+ }
177
+
178
+ function updatePlayerUI(player: BlockPlayer) {
179
+ const controls = player.element.querySelector('.mini-player');
180
+ if (!controls) return;
181
+
182
+ const timeDisplay = controls.querySelector('.time-display');
183
+ const progressFill = controls.querySelector('.mini-progress-fill') as HTMLElement;
184
+ const playBtn = controls.querySelector('.play-btn');
185
+
186
+ if (timeDisplay) {
187
+ timeDisplay.textContent = `${formatTime(player.currentTime)} / ${formatTime(player.duration)}`;
188
+ }
189
+ if (progressFill && player.duration > 0) {
190
+ progressFill.style.width = `${(player.currentTime / player.duration) * 100}%`;
191
+ }
192
+ if (playBtn) {
193
+ if (player.isPlaying) {
194
+ playBtn.innerHTML = `
195
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
196
+ <path d="M4 2h3v12H4zM9 2h3v12H9z" />
197
+ </svg>
198
+ `;
199
+ playBtn.setAttribute('title', 'Pause');
200
+ } else {
201
+ playBtn.innerHTML = `
202
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
203
+ <path d="M3 2v12l10-6z" />
204
+ </svg>
205
+ `;
206
+ playBtn.setAttribute('title', 'Play');
207
+ }
208
+ }
209
+ }
210
+
211
+ function updateHighlights(player: BlockPlayer, time: number) {
212
+ const toolkit = getToolkit();
213
+ if (!toolkit) return;
214
+
215
+ try {
216
+ // Load MEI for this block to get correct element timing
217
+ toolkit.loadData(player.mei);
218
+
219
+ // Get elements at current time (time is in ms)
220
+ const result = toolkit.getElementsAtTime(time);
221
+ const newNotes = new Set<string>(result.notes || []);
222
+
223
+ // Find notes within this block's SVG
224
+ const svg = player.element.querySelector('svg');
225
+ if (!svg) return;
226
+
227
+ // Remove highlights from notes no longer playing
228
+ player.highlightedNotes.forEach(id => {
229
+ if (!newNotes.has(id)) {
230
+ const element = svg.querySelector(`#${id}`);
231
+ if (element) {
232
+ element.classList.remove('verovio-highlight');
233
+ }
234
+ }
235
+ });
236
+
237
+ // Add highlights to new notes
238
+ newNotes.forEach(id => {
239
+ if (!player.highlightedNotes.has(id)) {
240
+ const element = svg.querySelector(`#${id}`);
241
+ if (element) {
242
+ element.classList.add('verovio-highlight');
243
+ }
244
+ }
245
+ });
246
+
247
+ player.highlightedNotes = newNotes;
248
+ } catch (error) {
249
+ // Ignore errors during highlight update
250
+ }
251
+ }
252
+
253
+ function updateHighlightsThrottled(player: BlockPlayer, time: number) {
254
+ const now = performance.now();
255
+ if (now - player.lastHighlightUpdate < HIGHLIGHT_THROTTLE_MS) return;
256
+ player.lastHighlightUpdate = now;
257
+ updateHighlights(player, time);
258
+ }
259
+
260
+ function clearHighlights(player: BlockPlayer) {
261
+ const svg = player.element.querySelector('svg');
262
+ if (!svg) return;
263
+
264
+ player.highlightedNotes.forEach(id => {
265
+ const element = svg.querySelector(`#${id}`);
266
+ if (element) {
267
+ element.classList.remove('verovio-highlight');
268
+ }
269
+ });
270
+ player.highlightedNotes = new Set();
271
+ }
272
+
273
+ function togglePlay(blockId: string) {
274
+ const player = blockPlayers.get(blockId);
275
+ if (!player || !player.midiData) return;
276
+
277
+ if (player.isPlaying) {
278
+ pauseBlock(blockId);
279
+ } else {
280
+ // Stop any other playing block
281
+ if (playingBlockId && playingBlockId !== blockId) {
282
+ stopBlock(playingBlockId);
283
+ }
284
+ playBlock(blockId);
285
+ }
286
+ }
287
+
288
+ function playBlock(blockId: string) {
289
+ const player = blockPlayers.get(blockId);
290
+ if (!player || !player.midiData || player.isPlaying) return;
291
+
292
+ player.isPlaying = true;
293
+ playingBlockId = blockId;
294
+
295
+ if (player.pausedTime > 0) {
296
+ player.playStartTime = performance.now() - player.pausedTime;
297
+ player.lastEventIndex = findEventIndexAtTime(player.midiData, player.pausedTime);
298
+ } else {
299
+ player.playStartTime = performance.now();
300
+ player.lastEventIndex = 0;
301
+ }
302
+
303
+ player.updateInterval = setInterval(() => {
304
+ if (!player.isPlaying || !player.midiData) return;
305
+
306
+ const now = performance.now();
307
+ const elapsed = now - player.playStartTime;
308
+ player.currentTime = elapsed;
309
+
310
+ const events = player.midiData.events;
311
+ for (; player.lastEventIndex < events.length; player.lastEventIndex++) {
312
+ const event = events[player.lastEventIndex];
313
+ if (event.time > elapsed) break;
314
+
315
+ if (event.data.type === 'channel') {
316
+ const timestamp = player.playStartTime + event.time;
317
+ switch (event.data.subtype) {
318
+ case 'noteOn':
319
+ MidiAudio.noteOn(event.data.channel, event.data.noteNumber, event.data.velocity, timestamp);
320
+ break;
321
+ case 'noteOff':
322
+ MidiAudio.noteOff(event.data.channel, event.data.noteNumber, timestamp);
323
+ break;
324
+ case 'programChange':
325
+ MidiAudio.programChange(event.data.channel, event.data.programNumber);
326
+ break;
327
+ }
328
+ }
329
+ }
330
+
331
+ // Update note highlights
332
+ updateHighlightsThrottled(player, elapsed);
333
+
334
+ updatePlayerUI(player);
335
+
336
+ if (elapsed >= player.duration) {
337
+ stopBlock(blockId);
338
+ }
339
+ }, 30) as unknown as number;
340
+
341
+ updatePlayerUI(player);
342
+ }
343
+
344
+ function pauseBlock(blockId: string) {
345
+ const player = blockPlayers.get(blockId);
346
+ if (!player) return;
347
+
348
+ if (player.updateInterval) {
349
+ clearInterval(player.updateInterval);
350
+ player.updateInterval = null;
351
+ }
352
+ player.pausedTime = player.currentTime;
353
+ player.isPlaying = false;
354
+ playingBlockId = null;
355
+ MidiAudio?.stopAllNotes?.();
356
+ updatePlayerUI(player);
357
+ }
358
+
359
+ function stopBlock(blockId: string) {
360
+ const player = blockPlayers.get(blockId);
361
+ if (!player) return;
362
+
363
+ if (player.updateInterval) {
364
+ clearInterval(player.updateInterval);
365
+ player.updateInterval = null;
366
+ }
367
+ player.isPlaying = false;
368
+ player.currentTime = 0;
369
+ player.pausedTime = 0;
370
+ player.lastEventIndex = 0;
371
+ if (playingBlockId === blockId) {
372
+ playingBlockId = null;
373
+ }
374
+ MidiAudio?.stopAllNotes?.();
375
+ clearHighlights(player);
376
+ updatePlayerUI(player);
377
+ }
378
+
379
+ function findEventIndexAtTime(midiData: any, time: number): number {
380
+ const events = midiData.events;
381
+ let low = 0;
382
+ let high = events.length;
383
+ while (low < high) {
384
+ const mid = (low + high) >>> 1;
385
+ if (events[mid].time < time) {
386
+ low = mid + 1;
387
+ } else {
388
+ high = mid;
389
+ }
390
+ }
391
+ return low;
392
+ }
393
+
394
+ function handleProgressClick(blockId: string, e: MouseEvent) {
395
+ const player = blockPlayers.get(blockId);
396
+ if (!player || !player.midiData) return;
397
+
398
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
399
+ const x = e.clientX - rect.left;
400
+ const percent = x / rect.width;
401
+ const targetTime = player.duration * percent;
402
+
403
+ MidiAudio?.stopAllNotes?.();
404
+ player.currentTime = targetTime;
405
+ player.pausedTime = targetTime;
406
+ player.lastEventIndex = findEventIndexAtTime(player.midiData, targetTime);
407
+
408
+ if (player.isPlaying) {
409
+ player.playStartTime = performance.now() - targetTime;
410
+ }
411
+
412
+ updatePlayerUI(player);
413
+ }
414
+
415
+ async function initPlayableBlock(el: HTMLElement, mei: string, blockId: string) {
416
+ if (!isAudioLoaded) return;
417
+
418
+ const toolkit = getToolkit();
419
+ if (!toolkit) return;
420
+
421
+ try {
422
+ toolkit.loadData(mei);
423
+ const midiBase64 = toolkit.renderToMIDI();
424
+ const binaryString = atob(midiBase64);
425
+ const bytes = new Uint8Array(binaryString.length);
426
+ for (let i = 0; i < binaryString.length; i++) {
427
+ bytes[i] = binaryString.charCodeAt(i);
428
+ }
429
+
430
+ const rawMidiData = MIDI.parseMidiData(bytes.buffer);
431
+ const notation = MusicNotation.Notation.parseMidi(rawMidiData);
432
+ if (!notation.tempos || notation.tempos.length === 0) {
433
+ notation.tempos = [{ tempo: 500000, tick: 0, time: 0 }];
434
+ }
435
+
436
+ const player: BlockPlayer = {
437
+ id: blockId,
438
+ element: el,
439
+ mei: mei,
440
+ midiData: notation,
441
+ midiPlayer: null,
442
+ isPlaying: false,
443
+ currentTime: 0,
444
+ duration: notation.endTime,
445
+ updateInterval: null,
446
+ playStartTime: 0,
447
+ lastEventIndex: 0,
448
+ pausedTime: 0,
449
+ highlightedNotes: new Set(),
450
+ lastHighlightUpdate: 0
451
+ };
452
+
453
+ blockPlayers.set(blockId, player);
454
+
455
+ // Add mini-player controls
456
+ const miniPlayer = createMiniPlayer(blockId);
457
+ el.appendChild(miniPlayer);
458
+ updatePlayerUI(player);
459
+
460
+ } catch (err) {
461
+ console.error('Failed to init playable block:', err);
462
+ }
463
+ }
464
+
465
+ function handleContainerResize() {
466
+ if (!previewContainer) return;
467
+ const newWidth = previewContainer.clientWidth;
468
+ if (Math.abs(newWidth - containerWidth) > 20) { // Only re-render if significant change
469
+ containerWidth = newWidth;
470
+ if (verovioReady) {
471
+ // Re-render all blocks with new width
472
+ renderVersion++;
473
+ reRenderAllBlocks(renderVersion);
474
+ }
475
+ }
476
+ }
477
+
478
+ async function reRenderAllBlocks(version: number) {
479
+ const toolkit = getToolkit();
480
+ if (!toolkit || !previewContainer) return;
481
+
482
+ // Find all already-rendered lilylet blocks
483
+ const blocks = previewContainer.querySelectorAll('[data-lilylet]');
484
+
485
+ for (const el of blocks) {
486
+ if (version !== renderVersion) return;
487
+
488
+ const source = el.getAttribute('data-source');
489
+ if (!source) continue;
490
+
491
+ const isPlayable = el.hasAttribute('data-playable');
492
+ const blockId = el.querySelector('.mini-player')?.querySelector('.play-btn')?.getAttribute('data-block');
493
+
494
+ // Preserve player state if exists
495
+ const existingPlayer = blockId ? blockPlayers.get(blockId) : null;
496
+ const wasPlaying = existingPlayer?.isPlaying;
497
+ const currentTime = existingPlayer?.currentTime || 0;
498
+
499
+ if (wasPlaying && blockId) {
500
+ pauseBlock(blockId);
501
+ }
502
+
503
+ try {
504
+ const result = lilyletToMEI(source);
505
+ if (version !== renderVersion) return;
506
+ if (!result.success) continue;
507
+
508
+ const mei = result.mei;
509
+ const { measureCount, staffCount } = result;
510
+ const effectiveWidth = Math.max(400, containerWidth - 80);
511
+ const pageWidthUnits = Math.round(effectiveWidth * 2.5);
512
+ const basePageHeight = 2000;
513
+ const measuresPerPage = 20;
514
+ const pageHeight = Math.max(basePageHeight, Math.ceil(measureCount / measuresPerPage) * basePageHeight) * 2 * staffCount;
515
+
516
+ toolkit.setOptions({
517
+ scale: 40,
518
+ adjustPageHeight: true,
519
+ pageWidth: pageWidthUnits,
520
+ pageHeight
521
+ });
522
+
523
+ const loaded = toolkit.loadData(mei);
524
+ if (loaded) {
525
+ const svg = toolkit.renderToSVG(1);
526
+ // Preserve mini-player if it exists
527
+ const miniPlayer = el.querySelector('.mini-player');
528
+ el.innerHTML = svg;
529
+ if (miniPlayer && isPlayable) {
530
+ el.appendChild(miniPlayer);
531
+ }
532
+
533
+ // Update player element reference
534
+ if (existingPlayer) {
535
+ existingPlayer.element = el as HTMLElement;
536
+ existingPlayer.mei = mei;
537
+ }
538
+ }
539
+ } catch (err) {
540
+ console.error('Failed to re-render block:', err);
541
+ }
542
+ }
543
+ }
544
+
545
+ async function renderLilyletBlocks(version: number) {
546
+ const toolkit = getToolkit();
547
+ if (!toolkit) return;
548
+
549
+ const container = document.querySelector('.preview-content');
550
+ if (!container) return;
551
+
552
+ // Find all lilylet placeholders (pending ones that need rendering)
553
+ const placeholders = container.querySelectorAll('[data-lilylet-pending]');
554
+
555
+ let blockIndex = 0;
556
+ for (const el of placeholders) {
557
+ // Check if this render is still current
558
+ if (version !== renderVersion) return;
559
+
560
+ // Get source code from data-source attribute
561
+ const source = el.getAttribute('data-source');
562
+ if (!source) continue;
563
+
564
+ const isPlayable = el.hasAttribute('data-playable');
565
+ const blockId = `block-${version}-${blockIndex++}`;
566
+
567
+ try {
568
+ // Parse lilylet code to MEI
569
+ const result = await lilyletToMEI(source);
570
+
571
+ // Check again after async operation
572
+ if (version !== renderVersion) return;
573
+
574
+ if (!result.success) {
575
+ el.innerHTML = `<pre class="error">Failed to parse lilylet code: ${result.error}</pre>`;
576
+ continue;
577
+ }
578
+
579
+ const mei = result.mei;
580
+ const { measureCount, staffCount } = result;
581
+
582
+ // Calculate pageWidth based on container - account for padding and borders
583
+ const effectiveWidth = Math.max(400, containerWidth - 80); // 80px for padding/margins
584
+ // Verovio scale 40 means 40% of default size, pageWidth is in abstract units
585
+ // At scale 40, approximately 2.5 abstract units = 1 pixel
586
+ const pageWidthUnits = Math.round(effectiveWidth * 2.5);
587
+ const basePageHeight = 2000;
588
+ const measuresPerPage = 20;
589
+ const pageHeight = Math.max(basePageHeight, Math.ceil(measureCount / measuresPerPage) * basePageHeight) * 2 * staffCount;
590
+
591
+ toolkit.setOptions({
592
+ scale: 40,
593
+ adjustPageHeight: true,
594
+ pageWidth: pageWidthUnits,
595
+ pageHeight
596
+ });
597
+
598
+ const loaded = toolkit.loadData(mei);
599
+ if (loaded) {
600
+ const svg = toolkit.renderToSVG(1);
601
+ el.innerHTML = svg;
602
+ el.removeAttribute('data-lilylet-pending');
603
+ el.setAttribute('data-lilylet', '');
604
+
605
+ // Initialize playable block
606
+ if (isPlayable && isAudioLoaded) {
607
+ await initPlayableBlock(el as HTMLElement, mei, blockId);
608
+ }
609
+ }
610
+ } catch (err) {
611
+ console.error('Failed to render lilylet block:', err);
612
+ if (version === renderVersion) {
613
+ el.innerHTML = `<pre class="error">Error: ${err}</pre>`;
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ onMount(async () => {
620
+ if (!browser) return;
621
+
622
+ // Initialize markdown-it with lilylet plugin
623
+ md = new MarkdownIt({
624
+ html: true,
625
+ linkify: true,
626
+ typographer: true
627
+ });
628
+ md.use(lilyletPlugin);
629
+
630
+ // Initial render (without Verovio)
631
+ renderMarkdown();
632
+
633
+ // Set up ResizeObserver for responsive layout
634
+ if (previewContainer) {
635
+ containerWidth = previewContainer.clientWidth;
636
+ resizeObserver = new ResizeObserver(() => {
637
+ handleContainerResize();
638
+ });
639
+ resizeObserver.observe(previewContainer);
640
+ }
641
+
642
+ // Initialize Verovio and audio in parallel
643
+ try {
644
+ await Promise.all([
645
+ initVerovio().then(() => {
646
+ verovioReady = true;
647
+ }),
648
+ initAudio()
649
+ ]);
650
+ // Re-render with Verovio (audio may still be loading)
651
+ renderVersion++;
652
+ renderLilyletBlocks(renderVersion);
653
+ } catch (err) {
654
+ console.error('Failed to initialize:', err);
655
+ }
656
+ });
657
+
658
+ onDestroy(() => {
659
+ // Stop all players and clean up
660
+ blockPlayers.forEach((_player, blockId) => {
661
+ stopBlock(blockId);
662
+ });
663
+ blockPlayers.clear();
664
+
665
+ // Clean up ResizeObserver
666
+ if (resizeObserver) {
667
+ resizeObserver.disconnect();
668
+ resizeObserver = null;
669
+ }
670
+ });
671
+
672
+ // Reactive rendering when markdown input changes
673
+ $: if (browser && md && markdownInput !== undefined) {
674
+ debouncedRender();
675
+ }
676
+ </script>
677
+
678
+ <svelte:head>
679
+ <title>Lilylet Markdown Demo</title>
680
+ </svelte:head>
681
+
682
+ <div class="app">
683
+ <header>
684
+ <h1>Lilylet Markdown Demo</h1>
685
+ <nav>
686
+ <a href="{base}/">Live Editor</a>
687
+ <span class="current">Markdown</span>
688
+ <a href="{base}/docs/lilylet-tutorial.html" target="_blank">Tutorial</a>
689
+ </nav>
690
+ <span class="status">
691
+ {#if !verovioReady}
692
+ Loading Verovio...
693
+ {:else}
694
+ Ready
695
+ {/if}
696
+ </span>
697
+ </header>
698
+
699
+ <main>
700
+ <div class="pane editor-pane">
701
+ <div class="pane-header">Markdown Source</div>
702
+ <textarea
703
+ class="markdown-editor"
704
+ bind:value={markdownInput}
705
+ spellcheck="false"
706
+ ></textarea>
707
+ </div>
708
+ <div class="divider"></div>
709
+ <div class="pane preview-pane">
710
+ <div class="pane-header">Preview</div>
711
+ <div class="preview-content" bind:this={previewContainer}>
712
+ {@html renderedHtml}
713
+ </div>
714
+ </div>
715
+ </main>
716
+ </div>
717
+
718
+ <style>
719
+ :global(body) {
720
+ margin: 0;
721
+ }
722
+
723
+ .app {
724
+ display: flex;
725
+ flex-direction: column;
726
+ height: 100vh;
727
+ overflow: hidden;
728
+ }
729
+
730
+ header {
731
+ display: flex;
732
+ align-items: center;
733
+ justify-content: space-between;
734
+ padding: 8px 16px;
735
+ background: #333333;
736
+ border-bottom: 1px solid #454545;
737
+ }
738
+
739
+ h1 {
740
+ margin: 0;
741
+ font-size: 18px;
742
+ font-weight: 600;
743
+ color: #ffffff;
744
+ }
745
+
746
+ nav {
747
+ display: flex;
748
+ gap: 16px;
749
+ }
750
+
751
+ nav a {
752
+ color: #858585;
753
+ text-decoration: none;
754
+ font-size: 14px;
755
+ }
756
+
757
+ nav a:hover {
758
+ color: #d4d4d4;
759
+ }
760
+
761
+ nav .current {
762
+ color: #0e639c;
763
+ font-weight: 600;
764
+ font-size: 14px;
765
+ }
766
+
767
+ .status {
768
+ font-size: 12px;
769
+ color: #858585;
770
+ }
771
+
772
+ main {
773
+ display: flex;
774
+ flex: 1;
775
+ overflow: hidden;
776
+ }
777
+
778
+ .pane {
779
+ display: flex;
780
+ flex-direction: column;
781
+ overflow: hidden;
782
+ }
783
+
784
+ .pane-header {
785
+ padding: 8px 12px;
786
+ background: #252526;
787
+ border-bottom: 1px solid #454545;
788
+ font-size: 12px;
789
+ color: #858585;
790
+ text-transform: uppercase;
791
+ letter-spacing: 0.5px;
792
+ }
793
+
794
+ .editor-pane {
795
+ flex: 0 0 40%;
796
+ min-width: 300px;
797
+ }
798
+
799
+ .preview-pane {
800
+ flex: 1;
801
+ }
802
+
803
+ .markdown-editor {
804
+ flex: 1;
805
+ width: 100%;
806
+ padding: 16px;
807
+ background: #1e1e1e;
808
+ color: #d4d4d4;
809
+ border: none;
810
+ resize: none;
811
+ font-family: 'Fira Code', 'Consolas', monospace;
812
+ font-size: 14px;
813
+ line-height: 1.6;
814
+ outline: none;
815
+ }
816
+
817
+ .preview-content {
818
+ flex: 1;
819
+ padding: 24px;
820
+ overflow: auto;
821
+ background: #ffffff;
822
+ color: #333333;
823
+ }
824
+
825
+ .divider {
826
+ width: 4px;
827
+ background: #333333;
828
+ cursor: col-resize;
829
+ }
830
+
831
+ .divider:hover {
832
+ background: #0078d4;
833
+ }
834
+
835
+ /* Markdown preview styles */
836
+ .preview-content :global(h1) {
837
+ font-size: 2em;
838
+ margin: 0 0 0.5em 0;
839
+ border-bottom: 1px solid #eee;
840
+ padding-bottom: 0.3em;
841
+ }
842
+
843
+ .preview-content :global(h2) {
844
+ font-size: 1.5em;
845
+ margin: 1em 0 0.5em 0;
846
+ border-bottom: 1px solid #eee;
847
+ padding-bottom: 0.3em;
848
+ }
849
+
850
+ .preview-content :global(h3) {
851
+ font-size: 1.25em;
852
+ margin: 1em 0 0.5em 0;
853
+ }
854
+
855
+ .preview-content :global(p) {
856
+ margin: 0.5em 0;
857
+ line-height: 1.6;
858
+ }
859
+
860
+ .preview-content :global(code) {
861
+ background: #f4f4f4;
862
+ padding: 2px 6px;
863
+ border-radius: 3px;
864
+ font-family: 'Fira Code', 'Consolas', monospace;
865
+ font-size: 0.9em;
866
+ }
867
+
868
+ .preview-content :global(pre) {
869
+ background: #f4f4f4;
870
+ padding: 16px;
871
+ border-radius: 6px;
872
+ overflow-x: auto;
873
+ }
874
+
875
+ .preview-content :global(pre code) {
876
+ background: none;
877
+ padding: 0;
878
+ }
879
+
880
+ .preview-content :global(ul), .preview-content :global(ol) {
881
+ padding-left: 2em;
882
+ margin: 0.5em 0;
883
+ }
884
+
885
+ .preview-content :global(li) {
886
+ margin: 0.25em 0;
887
+ }
888
+
889
+ /* Lilylet container styles */
890
+ .preview-content :global(.lilylet-container) {
891
+ margin: 1em 0;
892
+ padding: 16px;
893
+ background: #fafafa;
894
+ border: 1px solid #e0e0e0;
895
+ border-radius: 8px;
896
+ text-align: center;
897
+ }
898
+
899
+ .preview-content :global(.lilylet-container svg) {
900
+ max-width: 100%;
901
+ height: auto;
902
+ }
903
+
904
+ .preview-content :global(.lilylet-container code) {
905
+ display: block;
906
+ text-align: left;
907
+ color: #666;
908
+ font-size: 12px;
909
+ }
910
+
911
+ .preview-content :global(.lilylet-error) {
912
+ margin: 1em 0;
913
+ padding: 16px;
914
+ background: #fff0f0;
915
+ border: 1px solid #ffcccc;
916
+ border-radius: 8px;
917
+ color: #cc0000;
918
+ }
919
+
920
+ /* Mini-player styles for playable blocks */
921
+ .preview-content :global(.mini-player) {
922
+ display: flex;
923
+ align-items: center;
924
+ gap: 10px;
925
+ margin-top: 12px;
926
+ padding: 8px 12px;
927
+ background: #f0f0f0;
928
+ border-radius: 6px;
929
+ }
930
+
931
+ .preview-content :global(.mini-player .play-btn) {
932
+ background: #0e639c;
933
+ border: none;
934
+ color: white;
935
+ width: 28px;
936
+ height: 28px;
937
+ border-radius: 50%;
938
+ cursor: pointer;
939
+ display: flex;
940
+ align-items: center;
941
+ justify-content: center;
942
+ transition: background 0.2s;
943
+ }
944
+
945
+ .preview-content :global(.mini-player .play-btn:hover) {
946
+ background: #1177bb;
947
+ }
948
+
949
+ .preview-content :global(.mini-player .time-display) {
950
+ font-size: 12px;
951
+ color: #666;
952
+ font-family: 'Consolas', 'Monaco', monospace;
953
+ min-width: 80px;
954
+ }
955
+
956
+ .preview-content :global(.mini-player .mini-progress) {
957
+ flex: 1;
958
+ height: 6px;
959
+ background: #ddd;
960
+ border-radius: 3px;
961
+ cursor: pointer;
962
+ overflow: hidden;
963
+ }
964
+
965
+ .preview-content :global(.mini-player .mini-progress-fill) {
966
+ height: 100%;
967
+ background: #0e639c;
968
+ width: 0%;
969
+ transition: width 0.1s linear;
970
+ }
971
+
972
+ /* Note highlight animation during playback */
973
+ .preview-content :global(.verovio-highlight) {
974
+ fill: #ff6b35 !important;
975
+ stroke: #ff6b35 !important;
976
+ stroke-width: 1px;
977
+ filter: drop-shadow(0 0 3px #ff6b35);
978
+ transition: fill 0.05s ease, stroke 0.05s ease;
979
+ }
980
+ </style>