@miliastry/quasar 1.1.0 → 1.2.0

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.
@@ -34,6 +34,9 @@ export interface BoxDrawerOptions {
34
34
  const DEFAULT_DURATION_MS = 400
35
35
  /** Floor so an interruption near the end still reads as motion, not a snap. */
36
36
  const MIN_DURATION_MS = 120
37
+ /** Clase de estado abierto del spoilerbox de osu, puesta en el contenedor. */
38
+ const OSU_OPEN_CLASS = 'js-spoilerbox--open'
39
+
37
40
  /** easeOutCubic — fast to start, settles softly, no overshoot. */
38
41
  const DEFAULT_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)'
39
42
 
@@ -50,7 +53,7 @@ interface DrawerState {
50
53
  }
51
54
 
52
55
  /** In-flight state per element; a WeakMap so morphed-away nodes are freed. */
53
- const running = new WeakMap<HTMLDetailsElement, DrawerState>()
56
+ const running = new WeakMap<Element, DrawerState>()
54
57
 
55
58
  function prefersReducedMotion(): boolean {
56
59
  return typeof window !== 'undefined'
@@ -65,12 +68,14 @@ function prefersReducedMotion(): boolean {
65
68
  * Exported so hosts that toggle boxes programmatically (a keyboard command, an
66
69
  * "expand all" action) go through the same animation as a click.
67
70
  */
68
- export function toggleBoxWithDrawer(
69
- details: HTMLDetailsElement,
71
+ function animateDrawer(
72
+ details: HTMLElement,
73
+ isOpen: () => boolean,
74
+ setOpen: (v: boolean) => void,
70
75
  options: BoxDrawerOptions = {}
71
76
  ): void {
72
77
  if (typeof details.animate !== 'function' || prefersReducedMotion()) {
73
- details.open = !details.open
78
+ setOpen(!isOpen())
74
79
  return
75
80
  }
76
81
 
@@ -85,7 +90,7 @@ export function toggleBoxWithDrawer(
85
90
  // `details.open`: a closing box is kept open so its content stays visible, so
86
91
  // reading the attribute would report "open" and close it a second time,
87
92
  // making a mid-close click impossible to reverse.
88
- const opening = previous ? !previous.opening : !details.open
93
+ const opening = previous ? !previous.opening : !isOpen()
89
94
 
90
95
  previous?.animation.cancel()
91
96
 
@@ -94,14 +99,14 @@ export function toggleBoxWithDrawer(
94
99
  // while the box grows or shrinks over it.
95
100
  let collapsed: number
96
101
  let expanded: number
97
- if (details.open) {
102
+ if (isOpen()) {
98
103
  expanded = details.getBoundingClientRect().height
99
- details.open = false
104
+ setOpen(false)
100
105
  collapsed = details.getBoundingClientRect().height
101
- details.open = true
106
+ setOpen(true)
102
107
  } else {
103
108
  collapsed = details.getBoundingClientRect().height
104
- details.open = true
109
+ setOpen(true)
105
110
  expanded = details.getBoundingClientRect().height
106
111
  }
107
112
 
@@ -133,7 +138,7 @@ export function toggleBoxWithDrawer(
133
138
  const settle = (finished: boolean) => {
134
139
  if (running.get(details)?.animation !== animation) return
135
140
  running.delete(details)
136
- if (finished) details.open = opening
141
+ if (finished) setOpen(opening)
137
142
  details.style.overflow = restoreOverflow
138
143
  }
139
144
 
@@ -141,6 +146,36 @@ export function toggleBoxWithDrawer(
141
146
  animation.addEventListener('cancel', () => settle(false))
142
147
  }
143
148
 
149
+ /**
150
+ * Animate one `<details>` to its opposite state. Exported so hosts that toggle
151
+ * boxes programmatically go through the same animation as a click.
152
+ */
153
+ export function toggleBoxWithDrawer(
154
+ details: HTMLDetailsElement,
155
+ options: BoxDrawerOptions = {}
156
+ ): void {
157
+ animateDrawer(details, () => details.open, (v) => { details.open = v }, options)
158
+ }
159
+
160
+ /**
161
+ * Lo mismo para la caja del dialecto osu, que no es un `<details>`: es
162
+ * `div.js-spoilerbox > a.js-spoilerbox__link + div.js-spoilerbox__body`, y su
163
+ * estado abierto es la clase `js-spoilerbox--open` en el contenedor. Marcado
164
+ * distinto, misma animación — y la misma razón para vivir aquí: Quasar es
165
+ * quien emite el marcado, así que también define cómo se abre.
166
+ */
167
+ export function toggleSpoilerboxWithDrawer(
168
+ box: HTMLElement,
169
+ options: BoxDrawerOptions = {}
170
+ ): void {
171
+ animateDrawer(
172
+ box,
173
+ () => box.classList.contains(OSU_OPEN_CLASS),
174
+ (v) => box.classList.toggle(OSU_OPEN_CLASS, v),
175
+ options
176
+ )
177
+ }
178
+
144
179
  /**
145
180
  * Intercept the native toggle of every box inside `root`.
146
181
  *
@@ -157,6 +192,17 @@ export function bindBoxDrawer(
157
192
  ): () => void {
158
193
  const handleClick = (e: MouseEvent) => {
159
194
  const target = e.target as HTMLElement | null
195
+
196
+ // Dialecto osu: el marcado es div > a + div, sin <details> que interceptar.
197
+ const osuLink = target?.closest('.js-spoilerbox__link')
198
+ if (osuLink) {
199
+ const box = osuLink.closest('.js-spoilerbox')
200
+ if (!(box instanceof HTMLElement) || !root.contains(box)) return
201
+ e.preventDefault() // el <a href="#"> saltaria al principio del documento
202
+ toggleSpoilerboxWithDrawer(box, options)
203
+ return
204
+ }
205
+
160
206
  const summary = target?.closest('summary')
161
207
  if (!summary) return
162
208
 
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Quasar Document Engine — Visuals / LyneAudio.ts
3
+ *
4
+ * Interactive runtime for Quasar's Lyne / SYNE custom audio player (.lx-audio).
5
+ * Supports both scoped root binding (bindLyneAudio) and global document delegation.
6
+ */
7
+
8
+ export function bindLyneAudio(
9
+ root: HTMLElement = (typeof document !== 'undefined' ? document.documentElement : (undefined as any))
10
+ ): () => void {
11
+ if (!root || typeof root.addEventListener !== 'function') {
12
+ return () => {}
13
+ }
14
+
15
+ const formatTime = (seconds: number): string => {
16
+ if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '–:––'
17
+ const m = Math.floor(seconds / 60)
18
+ const s = Math.floor(seconds % 60)
19
+ return `${m}:${s < 10 ? '0' : ''}${s}`
20
+ }
21
+
22
+ const PLAY_SVG = '<svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg>'
23
+ const PAUSE_SVG = '<svg viewBox="0 0 16 16"><path d="M3 2h3.6v12H3zM9.4 2H13v12H9.4z"/></svg>'
24
+ const RETRY_SVG = '<svg viewBox="0 0 16 16"><path d="M8 2a6 6 0 1 0 6 6h-2a4 4 0 1 1-4-4v3l5-4-5-4z"/></svg>'
25
+ const VOL_LOW_SVG = '<svg viewBox="0 0 16 16"><path d="M10.707 11.182A4.5 4.5 0 0 0 12.025 8a4.5 4.5 0 0 0-1.318-3.182L10 5.525A3.5 3.5 0 0 1 11.025 8 3.5 3.5 0 0 1 10 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg>'
26
+ const VOL_HIGH_SVG = '<svg viewBox="0 0 16 16"><path d="M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z"/><path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z"/><path d="M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg>'
27
+ const VOL_MUTE_SVG = '<svg viewBox="0 0 16 16"><path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06m7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0"/></svg>'
28
+
29
+ const DEFAULT_VOLUME = 0.2
30
+
31
+ const applyDefaultVolume = (audio: HTMLAudioElement) => {
32
+ if (audio && audio.dataset.volSet !== 'true') {
33
+ audio.volume = DEFAULT_VOLUME
34
+ audio.dataset.volSet = 'true'
35
+ }
36
+ }
37
+
38
+ const updateVolUI = (player: HTMLElement, vol: number) => {
39
+ const btn = player.querySelector<HTMLButtonElement>('.lx-vol-btn')
40
+ const slider = player.querySelector<HTMLInputElement>('.lx-vol-slider')
41
+ if (slider) {
42
+ if (Math.abs(parseFloat(slider.value) - vol) > 0.005) {
43
+ slider.value = String(vol)
44
+ }
45
+ const pct = (vol * 100).toFixed(1)
46
+ slider.style.background = `linear-gradient(to right, var(--color-accent, #2EE6E2) ${pct}%, var(--color-inset-well, #080D20) ${pct}%)`
47
+ }
48
+ if (btn) {
49
+ if (vol <= 0.001) {
50
+ btn.innerHTML = VOL_MUTE_SVG
51
+ btn.setAttribute('aria-label', 'Unmute')
52
+ } else if (vol > 0.5) {
53
+ btn.innerHTML = VOL_HIGH_SVG
54
+ btn.setAttribute('aria-label', 'Mute')
55
+ } else {
56
+ btn.innerHTML = VOL_LOW_SVG
57
+ btn.setAttribute('aria-label', 'Mute')
58
+ }
59
+ }
60
+ }
61
+
62
+ const onClick = (e: MouseEvent) => {
63
+ const target = e.target as HTMLElement | null
64
+ if (!target) return
65
+
66
+ // 1. Play / Pause button
67
+ const btn = target.closest<HTMLButtonElement>('.lx-btn')
68
+ if (btn) {
69
+ const player = btn.closest<HTMLElement>('.lx-audio')
70
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
71
+ if (audio && player) {
72
+ e.preventDefault()
73
+ e.stopPropagation()
74
+
75
+ if (player.classList.contains('is-error')) {
76
+ player.classList.remove('is-error')
77
+ player.classList.add('is-loading')
78
+ audio.load()
79
+ const p = audio.play()
80
+ if (p !== undefined) {
81
+ p.catch((err) => {
82
+ console.warn('[LyneAudio] retry playback failed:', err)
83
+ player.classList.remove('is-loading')
84
+ player.classList.add('is-error')
85
+ })
86
+ }
87
+ } else if (audio.paused) {
88
+ // Pause any other playing audio on the page
89
+ const doc = root.ownerDocument || document
90
+ doc.querySelectorAll<HTMLElement>('.lx-audio.is-playing').forEach((other) => {
91
+ if (other !== player) {
92
+ const otherAudio = other.querySelector<HTMLAudioElement>('audio')
93
+ if (otherAudio && !otherAudio.paused) {
94
+ otherAudio.pause()
95
+ }
96
+ }
97
+ })
98
+
99
+ player.classList.add('is-loading')
100
+ const p = audio.play()
101
+ if (p !== undefined) {
102
+ p.then(() => {
103
+ player.classList.remove('is-loading')
104
+ }).catch((err) => {
105
+ console.warn('[LyneAudio] play failed:', err)
106
+ player.classList.remove('is-loading')
107
+ player.classList.add('is-error')
108
+ })
109
+ }
110
+ } else {
111
+ audio.pause()
112
+ }
113
+ }
114
+ return
115
+ }
116
+
117
+ // 2. Track seeking
118
+ const track = target.closest<HTMLElement>('.lx-track')
119
+ if (track) {
120
+ const player = track.closest<HTMLElement>('.lx-audio')
121
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
122
+ if (audio) {
123
+ e.preventDefault()
124
+ e.stopPropagation()
125
+ const rect = track.getBoundingClientRect()
126
+ const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1)
127
+ if (audio.duration && isFinite(audio.duration)) {
128
+ audio.currentTime = pct * audio.duration
129
+ }
130
+ const fill = track.querySelector<HTMLElement>('.fill')
131
+ if (fill) fill.style.width = `${pct * 100}%`
132
+ }
133
+ return
134
+ }
135
+
136
+ // 3. Playback speed cycling
137
+ const speed = target.closest<HTMLButtonElement>('.lx-speed')
138
+ if (speed) {
139
+ const player = speed.closest<HTMLElement>('.lx-audio')
140
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
141
+ if (audio) {
142
+ e.preventDefault()
143
+ e.stopPropagation()
144
+ const rates = [1.0, 1.25, 1.5, 0.75]
145
+ const curRate = audio.playbackRate || 1.0
146
+ const curIdx = rates.indexOf(curRate)
147
+ const nextRate = rates[(curIdx + 1) % rates.length]
148
+ audio.playbackRate = nextRate
149
+ speed.textContent = `${nextRate}×`
150
+ speed.classList.toggle('active', nextRate !== 1.0)
151
+ }
152
+ return
153
+ }
154
+
155
+ // 4. Volume mute toggle button
156
+ const volBtn = target.closest<HTMLButtonElement>('.lx-vol-btn')
157
+ if (volBtn) {
158
+ const player = volBtn.closest<HTMLElement>('.lx-audio')
159
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
160
+ if (audio && player) {
161
+ e.preventDefault()
162
+ e.stopPropagation()
163
+ audio.dataset.volSet = 'true'
164
+ if (audio.muted || audio.volume <= 0.001) {
165
+ const prev = parseFloat(audio.dataset.prevVol || String(DEFAULT_VOLUME)) || DEFAULT_VOLUME
166
+ audio.muted = false
167
+ audio.volume = prev
168
+ updateVolUI(player, prev)
169
+ } else {
170
+ audio.dataset.prevVol = String(audio.volume)
171
+ audio.muted = true
172
+ updateVolUI(player, 0)
173
+ }
174
+ }
175
+ return
176
+ }
177
+ }
178
+
179
+ const onInput = (e: Event) => {
180
+ const target = e.target as HTMLElement | null
181
+ if (!target) return
182
+ const slider = target.closest<HTMLInputElement>('.lx-vol-slider')
183
+ if (slider) {
184
+ const player = slider.closest<HTMLElement>('.lx-audio')
185
+ const audio = player?.querySelector<HTMLAudioElement>('audio')
186
+ if (audio) {
187
+ const val = parseFloat(slider.value)
188
+ const clamped = isNaN(val) ? DEFAULT_VOLUME : Math.max(0, Math.min(1, val))
189
+ audio.dataset.volSet = 'true'
190
+ audio.muted = (clamped === 0)
191
+ audio.volume = clamped
192
+ if (player) {
193
+ updateVolUI(player, clamped)
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ // Media events (capture phase)
200
+ const onPlay = (e: Event) => {
201
+ const audio = e.target as HTMLAudioElement
202
+ if (audio?.tagName !== 'AUDIO') return
203
+ applyDefaultVolume(audio)
204
+ const player = audio.closest<HTMLElement>('.lx-audio')
205
+ if (!player) return
206
+ player.classList.remove('is-loading')
207
+ player.classList.add('is-playing')
208
+ const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
209
+ if (btn) {
210
+ btn.innerHTML = PAUSE_SVG
211
+ btn.setAttribute('aria-label', 'Pause')
212
+ }
213
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
214
+ if (label) label.textContent = 'playing'
215
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
216
+ }
217
+
218
+ const onPause = (e: Event) => {
219
+ const audio = e.target as HTMLAudioElement
220
+ if (audio?.tagName !== 'AUDIO') return
221
+ const player = audio.closest<HTMLElement>('.lx-audio')
222
+ if (!player) return
223
+ player.classList.remove('is-playing', 'is-loading')
224
+ const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
225
+ if (btn) {
226
+ btn.innerHTML = PLAY_SVG
227
+ btn.setAttribute('aria-label', 'Play')
228
+ }
229
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
230
+ if (label) label.textContent = 'audio'
231
+ }
232
+
233
+ const onTimeUpdate = (e: Event) => {
234
+ const audio = e.target as HTMLAudioElement
235
+ if (audio?.tagName !== 'AUDIO') return
236
+ const player = audio.closest<HTMLElement>('.lx-audio')
237
+ if (!player) return
238
+ const cur = audio.currentTime || 0
239
+ const dur = audio.duration || 0
240
+ const fill = player.querySelector<HTMLElement>('.lx-track .fill')
241
+ const curTime = player.querySelector<HTMLElement>('.lx-time .cur')
242
+ if (fill && dur > 0 && isFinite(dur)) {
243
+ fill.style.width = `${(cur / dur) * 100}%`
244
+ }
245
+ if (curTime) {
246
+ curTime.textContent = formatTime(cur)
247
+ }
248
+ }
249
+
250
+ const onLoadedMetadata = (e: Event) => {
251
+ const audio = e.target as HTMLAudioElement
252
+ if (audio?.tagName !== 'AUDIO') return
253
+ applyDefaultVolume(audio)
254
+ const player = audio.closest<HTMLElement>('.lx-audio')
255
+ if (!player) return
256
+ const dur = audio.duration || 0
257
+ const totalTime = player.querySelector<HTMLElement>('.lx-time .total')
258
+ if (totalTime && dur > 0 && isFinite(dur)) {
259
+ totalTime.textContent = formatTime(dur)
260
+ }
261
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
262
+ }
263
+
264
+ const onWaiting = (e: Event) => {
265
+ const audio = e.target as HTMLAudioElement
266
+ if (audio?.tagName !== 'AUDIO') return
267
+ const player = audio.closest<HTMLElement>('.lx-audio')
268
+ if (!player) return
269
+ player.classList.add('is-loading')
270
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
271
+ if (label) label.textContent = 'buffering…'
272
+ }
273
+
274
+ const onError = (e: Event) => {
275
+ const audio = e.target as HTMLAudioElement
276
+ if (audio?.tagName !== 'AUDIO') return
277
+ const player = audio.closest<HTMLElement>('.lx-audio')
278
+ if (!player) return
279
+ player.classList.remove('is-playing', 'is-loading')
280
+ player.classList.add('is-error')
281
+ const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
282
+ if (label) label.textContent = 'unavailable — file error'
283
+ const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
284
+ if (btn) {
285
+ btn.innerHTML = RETRY_SVG
286
+ btn.setAttribute('aria-label', 'Retry')
287
+ btn.style.background = 'var(--color-danger, #FF3B5C)'
288
+ }
289
+ const curTime = player.querySelector<HTMLElement>('.lx-time .cur')
290
+ const totalTime = player.querySelector<HTMLElement>('.lx-time .total')
291
+ if (curTime) curTime.textContent = '–:––'
292
+ if (totalTime) totalTime.textContent = '–:––'
293
+ }
294
+
295
+ const onVolumeChange = (e: Event) => {
296
+ const audio = e.target as HTMLAudioElement
297
+ if (audio?.tagName !== 'AUDIO') return
298
+ const player = audio.closest<HTMLElement>('.lx-audio')
299
+ if (!player) return
300
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
301
+ }
302
+
303
+ // Apply 20% default volume to all audio elements within root
304
+ try {
305
+ root.querySelectorAll<HTMLAudioElement>('audio').forEach((audio) => {
306
+ applyDefaultVolume(audio)
307
+ const player = audio.closest<HTMLElement>('.lx-audio')
308
+ if (player) {
309
+ updateVolUI(player, audio.muted ? 0 : audio.volume)
310
+ }
311
+ })
312
+ } catch {}
313
+
314
+ // Use capture phase so parent block selection cannot swallow clicks
315
+ root.addEventListener('click', onClick, true)
316
+ root.addEventListener('input', onInput, true)
317
+ root.addEventListener('change', onInput, true)
318
+ root.addEventListener('play', onPlay, true)
319
+ root.addEventListener('pause', onPause, true)
320
+ root.addEventListener('timeupdate', onTimeUpdate, true)
321
+ root.addEventListener('loadedmetadata', onLoadedMetadata, true)
322
+ root.addEventListener('durationchange', onLoadedMetadata, true)
323
+ root.addEventListener('waiting', onWaiting, true)
324
+ root.addEventListener('error', onError, true)
325
+ root.addEventListener('volumechange', onVolumeChange, true)
326
+
327
+ return () => {
328
+ root.removeEventListener('click', onClick, true)
329
+ root.removeEventListener('input', onInput, true)
330
+ root.removeEventListener('change', onInput, true)
331
+ root.removeEventListener('play', onPlay, true)
332
+ root.removeEventListener('pause', onPause, true)
333
+ root.removeEventListener('timeupdate', onTimeUpdate, true)
334
+ root.removeEventListener('loadedmetadata', onLoadedMetadata, true)
335
+ root.removeEventListener('durationchange', onLoadedMetadata, true)
336
+ root.removeEventListener('waiting', onWaiting, true)
337
+ root.removeEventListener('error', onError, true)
338
+ root.removeEventListener('volumechange', onVolumeChange, true)
339
+ }
340
+ }
341
+
342
+ export const setupLyneAudioRuntime = bindLyneAudio
343
+
344
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
345
+ bindLyneAudio(document.documentElement)
346
+ }
@@ -25,6 +25,7 @@
25
25
 
26
26
  export { bindBoxDrawer, toggleBoxWithDrawer } from './BoxDrawer'
27
27
  export type { BoxDrawerOptions } from './BoxDrawer'
28
+ export { setupLyneAudioRuntime, bindLyneAudio } from './LyneAudio'
28
29
 
29
30
  export const visualThemes = [
30
31
  {