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,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>
|