[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

Manual virtual scroll (#12)

authored by

Kasper and committed by
GitHub
(Jun 4, 2025, 10:19 AM +0200) c6ae1d09 c3ce3c6a

+560 -296
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## Next 4 + - Faster scroll performance 5 + 3 6 ## 0.19.3 - 2024 Nov 26 4 7 - Fixes rare .ogg metadata corruption (https://github.com/Serial-ATA/lofty-rs/issues/469) 5 8
-111
src/components/Cover.svelte
··· 1 - <script lang="ts"> 2 - import { paths } from '@/lib/data' 3 - import type { Track } from '../../ferrum-addon' 4 - import { ipc_renderer, join_paths } from '@/lib/window' 5 - 6 - export let track: Track 7 - $: src = 8 - 'app://trackimg?path=' + 9 - encodeURIComponent(join_paths(paths.tracksDir, track.file)) + 10 - '&cache_db_path=' + 11 - encodeURIComponent(paths.cacheDb) + 12 - '&date_modified=' + 13 - encodeURIComponent(track.dateModified) 14 - 15 - let error: { src: string; message: 404 | string | null } | false | null = null 16 - </script> 17 - 18 - {#if error} 19 - {#if error.message === null || error.message === 404} 20 - <svg 21 - class="cover" 22 - xmlns="http://www.w3.org/2000/svg" 23 - width="24" 24 - height="24" 25 - viewBox="0 0 24 24" 26 - > 27 - <path 28 - d="M23 0l-15.996 3.585v13.04c-2.979-.589-6.004 1.671-6.004 4.154 0 2.137 1.671 3.221 3.485 3.221 2.155 0 4.512-1.528 4.515-4.638v-10.9l12-2.459v8.624c-2.975-.587-6 1.664-6 4.141 0 2.143 1.715 3.232 3.521 3.232 2.14 0 4.476-1.526 4.479-4.636v-17.364z" 29 - /> 30 - </svg> 31 - {:else} 32 - {@const error_msg = error.message} 33 - <!-- svelte-ignore a11y-click-events-have-key-events --> 34 - <!-- svelte-ignore a11y-no-static-element-interactions --> 35 - <div 36 - class="h-full cursor-pointer" 37 - title={error.message} 38 - on:mousedown|stopPropagation 39 - on:click={() => { 40 - ipc_renderer.invoke('showMessageBox', false, { 41 - type: 'error', 42 - message: 'Failed to load cover', 43 - detail: error_msg, 44 - }) 45 - }} 46 - > 47 - <svg 48 - class="cover error" 49 - xmlns="http://www.w3.org/2000/svg" 50 - height="24px" 51 - width="24px" 52 - viewBox="0 0 24 24" 53 - fill="#e8eaed" 54 - ><path d="M0 0h24v24H0z" fill="none" /><path 55 - d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" 56 - /></svg 57 - > 58 - </div> 59 - {/if} 60 - {:else} 61 - <img 62 - class="cover" 63 - class:invisible={error === null} 64 - {src} 65 - alt="" 66 - on:load={() => { 67 - error = false 68 - }} 69 - on:error={async () => { 70 - // Yes this is dumb, but there's no way to get an error code from <img src="" /> 71 - error = { message: null, src } 72 - try { 73 - const response = await fetch(src) 74 - if (src !== error.src) { 75 - return 76 - } 77 - if (response.status === 404) { 78 - error.message = 404 79 - } else { 80 - error.message = await response.text() 81 - console.log(`Failed to load cover (${response.status}): ${error.message}`) 82 - } 83 - } catch (_) { 84 - error.message = 'network' 85 - } 86 - }} 87 - /> 88 - {/if} 89 - 90 - <style lang="sass"> 91 - .cover 92 - width: 18px 93 - min-width: 18px 94 - height: 18px 95 - min-height: 18px 96 - margin: 3px 0px 97 - pointer-events: none 98 - img 99 - object-fit: contain 100 - svg 101 - padding: 5px 102 - box-sizing: border-box 103 - background: var(--empty-cover-bg-color) 104 - fill: var(--empty-cover-color) 105 - border-radius: 2px 106 - .error 107 - fill: #ef4444 108 - background: transparent 109 - padding: 3px 110 - display: inline-block 111 - </style>
+4 -3
src/components/DragGhost.svelte
··· 1 1 <script lang="ts" context="module"> 2 2 export let drag_el: HTMLElement 3 - let text = '' 3 + export let div: HTMLElement 4 4 export function set_inner_text(new_text: string) { 5 - text = new_text 5 + // eslint-disable-next-line svelte/no-dom-manipulating 6 + div.innerText = new_text 6 7 } 7 8 </script> 8 9 9 10 <div class="drag-ghost" bind:this={drag_el}> 10 - <div>{text}</div> 11 + <div bind:this={div}></div> 11 12 </div> 12 13 13 14 <style lang="sass">
+263 -182
src/components/TrackList.svelte
··· 31 31 save_view_options, 32 32 get_track_by_item_id, 33 33 view_options, 34 + paths, 34 35 } from '@/lib/data' 35 36 import { new_playback_instance, playing_id } from '../lib/player' 36 37 import { get_duration, format_date, check_mouse_shortcut, check_shortcut } from '../lib/helpers' 37 38 import { tracklist_actions } from '../lib/page' 38 - import { ipc_listen, ipc_renderer } from '../lib/window' 39 + import { ipc_listen, ipc_renderer, join_paths } from '../lib/window' 39 40 import { onDestroy, onMount } from 'svelte' 40 41 import { dragged } from '../lib/drag-drop' 41 - import * as dragGhost from './DragGhost.svelte' 42 - import VirtualListBlock, { scroll_container_keydown } from './VirtualListBlock.svelte' 42 + import * as DragGhost from './DragGhost.svelte' 43 43 import type { ItemId, Track, TracksPage } from 'ferrum-addon/addon' 44 - import Cover from './Cover.svelte' 45 44 import Header from './Header.svelte' 46 45 import { writable } from 'svelte/store' 47 46 import { SvelteSelection } from '@/lib/selection' 48 47 import { get_flattened_tracklists, handle_selected_tracks_action } from '@/lib/menus' 49 48 import type { SelectedTracksAction } from '@/electron/typed_ipc' 49 + import { RefreshLevel, VirtualGrid, type Column } from '@/lib/virtual-grid' 50 50 51 51 let tracklist_element: HTMLDivElement 52 52 ··· 183 183 e.dataTransfer.effectAllowed = 'move' 184 184 if (drag_item_ids.length === 1) { 185 185 const { track } = get_track_by_item_id(drag_item_ids[0]) 186 - dragGhost.set_inner_text(track.artist + ' - ' + track.name) 186 + DragGhost.set_inner_text(track.artist + ' - ' + track.name) 187 187 } else { 188 - dragGhost.set_inner_text(drag_item_ids.length + ' items') 188 + DragGhost.set_inner_text(drag_item_ids.length + ' items') 189 189 } 190 190 dragged.tracks = { 191 191 ids: get_track_ids(drag_item_ids), 192 192 playlist_indexes: drag_item_ids, 193 193 } 194 - e.dataTransfer.setDragImage(dragGhost.drag_el, 0, 0) 194 + e.dataTransfer.setDragImage(DragGhost.drag_el, 0, 0) 195 195 e.dataTransfer.setData('ferrum.tracks', '') 196 196 } 197 197 } 198 198 let drag_to_index: null | number = null 199 - function on_drag_over(e: DragEvent, index: number) { 199 + function on_drag_over(e: DragEvent, element: Element, index: number) { 200 200 if ( 201 201 !$sort_desc || 202 202 $sort_key !== 'index' || ··· 212 212 e.dataTransfer?.types[0] === 'ferrum.tracks' 213 213 ) { 214 214 e.preventDefault() 215 - const rect = e.currentTarget.getBoundingClientRect() 216 - const container_rect = scroll_container.getBoundingClientRect() 215 + const rect = element.getBoundingClientRect() 216 + const container_rect = viewport.getBoundingClientRect() 217 217 if (e.pageY < rect.bottom - rect.height / 2) { 218 - const top = rect.top - container_rect.top + scroll_container.scrollTop - 1 218 + const top = rect.top - container_rect.top + viewport.scrollTop - 1 219 219 drag_line.style.top = Math.max(0, top) + 'px' 220 220 drag_to_index = index 221 221 } else { 222 - const top = rect.bottom - container_rect.top + scroll_container.scrollTop - 1 223 - const max_top = scroll_container.scrollHeight - 2 222 + const top = rect.bottom - container_rect.top + viewport.scrollTop - 1 223 + const max_top = viewport.scrollHeight - 2 224 224 drag_line.style.top = Math.min(max_top, top) + 'px' 225 225 drag_to_index = index + 1 226 226 } 227 227 } 228 228 } 229 - async function drop_handler() { 229 + function drop_handler() { 230 230 if (drag_to_index !== null) { 231 231 move_tracks(params.playlist_id, drag_item_ids, drag_to_index) 232 232 drag_to_index = null 233 233 } 234 - } 235 - function drag_end_handler() { 236 - drag_to_index = null 237 234 } 238 235 239 236 function get_item(item_id: ItemId) { ··· 244 241 } 245 242 } 246 243 247 - let virtual_list: VirtualListBlock<ItemId> 248 - 249 - $: if (virtual_list) { 250 - virtual_list.refresh() 251 - } 252 - 253 - let scroll_container: HTMLElement 244 + let viewport: HTMLElement 254 245 onMount(() => { 255 - tracklist_actions.scroll_to_index = virtual_list.scroll_to_index 246 + // tracklist_actions.scroll_to_index = virtual_list.scroll_to_index 256 247 }) 257 248 258 - type Column = { 249 + type TrackListColumn = Column & { 259 250 name: string 260 251 key: 'index' | 'image' | keyof Track 261 - width?: string 262 252 } 263 - const all_columns: Column[] = [ 253 + const all_columns: TrackListColumn[] = [ 264 254 // sorted alphabetically 265 - { name: '#', key: 'index' }, 255 + { name: '#', key: 'index', width: 46 }, 266 256 // { name: 'Size', key: 'size' }, 267 - { name: 'Album', key: 'albumName', width: '90%' }, 268 - { name: 'Album Artist', key: 'albumArtist', width: '90%' }, 269 - { name: 'Artist', key: 'artist', width: '120%' }, 257 + { name: 'Album', key: 'albumName', width: 0.9, is_pct: true }, 258 + { name: 'Album Artist', key: 'albumArtist', width: 0.9, is_pct: true }, 259 + { name: 'Artist', key: 'artist', width: 1.2, is_pct: true }, 270 260 // { name: 'Bitrate', key: 'bitrate' }, 271 - { name: 'BPM', key: 'bpm' }, 272 - { name: 'Comments', key: 'comments', width: '65%' }, 261 + { name: 'BPM', key: 'bpm', width: 43 }, 262 + { name: 'Comments', key: 'comments', width: 0.65, is_pct: true }, 273 263 // { name: 'Compilation', key: 'compilation' }, 274 - { name: 'Composer', key: 'composer', width: '65%' }, 275 - { name: 'Date Added', key: 'dateAdded' }, 264 + { name: 'Composer', key: 'composer', width: 0.65, is_pct: true }, 265 + { name: 'Date Added', key: 'dateAdded', width: 140 }, 276 266 // { name: 'DateImported', key: 'dateImported' }, 277 267 // { name: 'DateModified', key: 'dateModified' }, 278 268 // { name: 'Disabled', key: 'disabled' }, 279 269 // { name: 'DiscCount', key: 'discCount' }, 280 270 // { name: 'DiscNum', key: 'discNum' }, 281 271 // { name: 'Disliked', key: 'disliked' }, 282 - { name: 'Time', key: 'duration' }, 283 - { name: 'Genre', key: 'genre', width: '65%' }, 284 - { name: 'Grouping', key: 'grouping', width: '65%' }, 285 - { name: 'Image', key: 'image' }, 272 + { name: 'Time', key: 'duration', width: 50 }, 273 + { name: 'Genre', key: 'genre', width: 0.65, is_pct: true }, 274 + { name: 'Grouping', key: 'grouping', width: 0.65, is_pct: true }, 275 + { 276 + name: 'Image', 277 + key: 'image', 278 + width: 28, 279 + cell_render(cell, value) { 280 + if (typeof value !== 'string') { 281 + throw new Error('Non-string image value') 282 + } 283 + if (cell.firstChild instanceof HTMLImageElement && cell.firstChild.src === value) { 284 + return 285 + } 286 + let img: HTMLImageElement 287 + if (cell.firstChild instanceof HTMLImageElement) { 288 + img = cell.firstChild 289 + } else { 290 + img = document.createElement('img') 291 + } 292 + img.classList.add('invisible') 293 + img.src = value 294 + img.onload = (e) => { 295 + const img = e.currentTarget as HTMLImageElement 296 + if (img.src !== value) return 297 + img.classList.remove('invisible', 'error', 'missing') 298 + img.removeAttribute('title') 299 + } 300 + img.onerror = async (e) => { 301 + // Yes this is dumb, but there's no way to get an error code from <img src="" /> 302 + const img = (e as Event).currentTarget as HTMLImageElement 303 + if (img.src !== value) return 304 + img.classList.remove('invisible') 305 + img.removeAttribute('title') 306 + // 404 is an common expected result, so we start with that 307 + img.classList.add('error', 'missing') 308 + const new_error = await fetch(value) 309 + .then(async (response) => { 310 + if (response.status === 404) { 311 + return '404' 312 + } 313 + const response_text = await response.text() 314 + console.log(`Failed to load cover (${response.status}): ${response_text}`) 315 + return response_text 316 + }) 317 + .catch(() => { 318 + return 'network' 319 + }) 320 + if (img.src !== value) return 321 + img.classList.toggle('missing', new_error === '404') 322 + img.title = new_error 323 + } 324 + img.onmousedown = (e) => { 325 + const img = e.currentTarget as HTMLImageElement 326 + if (img.classList.contains('error')) { 327 + e.stopPropagation() 328 + } 329 + } 330 + img.onclick = (e) => { 331 + const img = e.currentTarget as HTMLImageElement 332 + const error = (e.currentTarget as HTMLImageElement).title 333 + if (img.classList.contains('error')) { 334 + ipc_renderer.invoke('showMessageBox', false, { 335 + type: 'error', 336 + message: 'Failed to load cover', 337 + detail: error, 338 + }) 339 + } 340 + } 341 + cell.replaceChildren(img) 342 + }, 343 + }, 286 344 // { name: 'ImportedFrom', key: 'importedFrom' }, 287 345 // { name: 'Liked', key: 'liked' }, 288 - { name: 'Name', key: 'name', width: '170%' }, 289 - { name: 'Plays', key: 'playCount' }, 346 + { name: 'Name', key: 'name', width: 1.7, is_pct: true }, 347 + { name: 'Plays', key: 'playCount', width: 52 }, 290 348 // { name: 'Rating', key: 'rating' }, 291 349 // { name: 'SampleRate', key: 'sampleRate' }, 292 - { name: 'Skips', key: 'skipCount' }, 293 - // { name: 'Sort Album', key: 'sortAlbumName', width: '65%' }, 294 - // { name: 'Sort Album Artist', key: 'sortAlbumArtist', width: '65%' }, 295 - // { name: 'Sort Artist', key: 'sortArtist', width: '65%' }, 296 - // { name: 'Sort Composer', key: 'sortComposer', width: '65%' }, 297 - // { name: 'Sort Name', key: 'sortName', width: '65%' }, 350 + { name: 'Skips', key: 'skipCount', width: 52 }, 351 + // { name: 'Sort Album', key: 'sortAlbumName', width: 0.65, is_pct: true }, 352 + // { name: 'Sort Album Artist', key: 'sortAlbumArtist', width: 0.65, is_pct: true }, 353 + // { name: 'Sort Artist', key: 'sortArtist', width: 0.65, is_pct: true }, 354 + // { name: 'Sort Composer', key: 'sortComposer', width: 0.65, is_pct: true }, 355 + // { name: 'Sort Name', key: 'sortName', width: 0.65, is_pct: true }, 298 356 // { name: 'TrackCount', key: 'trackCount' }, 299 357 // { name: 'TrackNum', key: 'trackNum' }, 300 358 // { name: 'Volume', key: 'volume' }, 301 - { name: 'Year', key: 'year' }, 359 + { name: 'Year', key: 'year', width: 47 }, 302 360 ] 303 361 const default_columns: Column['key'][] = [ 304 362 'index', ··· 314 372 'year', 315 373 ] 316 374 let columns: Column[] = load_columns() 375 + onMount(() => { 376 + columns = load_columns() 377 + }) 317 378 function load_columns(): Column[] { 318 379 let loaded_columns = view_options.columns 319 380 if (loaded_columns.length === 0) { 320 381 loaded_columns = [...default_columns] 321 382 } 322 - return loaded_columns 383 + const columns = loaded_columns 323 384 .map((key) => all_columns.find((col) => col.key === key)) 324 385 .filter((col) => col !== undefined) 386 + return columns 325 387 } 326 388 function save_columns() { 327 389 view_options.columns = columns.map((col) => col.key) ··· 397 459 function col_drag_end_handler() { 398 460 col_drag_to_index = null 399 461 } 462 + 463 + function get_row(e: Event) { 464 + if (!(e.target instanceof Element)) { 465 + return null 466 + } 467 + const row = e.target?.closest('[aria-rowindex]') 468 + const row_number = parseInt(row?.getAttribute('aria-rowindex') ?? '') 469 + if (!row || !Number.isInteger(row_number)) { 470 + return null 471 + } 472 + return { 473 + index: row_number - 1, 474 + element: row, 475 + } 476 + } 477 + 478 + const virtual_grid = VirtualGrid.create(tracks_page.itemIds, { 479 + buffer: 20, 480 + row_prepare(item_id, i) { 481 + const { track, id } = get_item(item_id) 482 + if (track === null) { 483 + throw new Error(`Track with item_id ${item_id} not found`) 484 + } 485 + return { 486 + ...track, 487 + item_id, 488 + track_id: id, 489 + duration: track.duration ? get_duration(track.duration) : '', 490 + dateAdded: format_date(track.dateAdded), 491 + index: i + 1, 492 + image: 493 + 'app://trackimg/?path=' + 494 + encodeURIComponent(join_paths(paths.tracksDir, track.file)) + 495 + '&cache_db_path=' + 496 + encodeURIComponent(paths.cacheDb) + 497 + '&date_modified=' + 498 + encodeURIComponent(track.dateModified), 499 + } 500 + }, 501 + row_render(row, item, i) { 502 + row.classList.toggle('odd', i % 2 === 0) 503 + row.classList.toggle('selected', $selection.has(item.item_id)) 504 + }, 505 + }) 506 + $: grid_columns = virtual_grid.set_columns(columns) 507 + $: virtual_grid.set_source_items(tracks_page.itemIds) 508 + $: $selection, virtual_grid.refresh(RefreshLevel.AllRows) 400 509 </script> 401 510 402 511 <Header ··· 412 521 > 413 522 <!-- svelte-ignore a11y-interactive-supports-focus --> 414 523 <div 415 - class="row table-header border-b border-b-slate-500/30" 524 + class="row table-header shrink-0 border-b border-b-slate-500/30" 416 525 class:desc={$sort_desc} 417 526 role="row" 418 527 on:contextmenu={on_column_context_menu} 419 528 on:dragleave={() => (col_drag_to_index = null)} 420 529 bind:this={col_container} 421 530 > 422 - {#each columns as column, i} 531 + {#each grid_columns as column, i} 423 532 <!-- svelte-ignore a11y-interactive-supports-focus --> 424 533 <!-- svelte-ignore a11y-click-events-have-key-events --> 425 534 <div 426 - class="c {column.key}" 535 + class="cell {column.key}" 427 536 class:sort={$sort_key === column.key} 428 - style:width={column.width} 537 + style:width="{column.width}px" 538 + style:translate="{column.offset}px 0" 429 539 role="button" 430 540 on:click={() => { 431 541 if (tracks_page.playlistKind === 'special' && column.key === 'index') { ··· 458 568 <!-- svelte-ignore a11y-no-noninteractive-tabindex --> 459 569 <!-- svelte-ignore a11y-no-static-element-interactions --> 460 570 <div 461 - bind:this={scroll_container} 571 + bind:this={viewport} 462 572 class="main-focus-element relative h-full overflow-y-auto outline-none" 463 573 tabindex="0" 464 574 on:mousedown|self={() => selection.clear()} 465 - on:keydown={scroll_container_keydown} 466 575 on:keydown={keydown} 576 + on:mousedown={(e: MouseEvent) => { 577 + const row = get_row(e) 578 + if (row) { 579 + selection.handle_mousedown(e, row.index) 580 + } 581 + }} 582 + on:click={(e: MouseEvent) => { 583 + const row = get_row(e) 584 + if (row) { 585 + selection.handle_click(e, row.index) 586 + } 587 + }} 588 + on:dblclick={(e: MouseEvent) => { 589 + const row = get_row(e) 590 + if (row) { 591 + double_click(e, row.index) 592 + } 593 + }} 594 + on:contextmenu={(e: MouseEvent) => { 595 + const row = get_row(e) 596 + if (row) { 597 + selection.handle_contextmenu(e, row.index) 598 + } 599 + }} 600 + on:dragstart={on_drag_start} 601 + on:dragover={(e: DragEvent) => { 602 + const row = get_row(e) 603 + if (row) { 604 + on_drag_over(e, row.element, row.index) 605 + } 606 + }} 607 + on:drop={drop_handler} 608 + on:dragend={() => (drag_to_index = null)} 609 + on:dragleave={() => (drag_to_index = null)} 467 610 > 468 - <VirtualListBlock 469 - bind:this={virtual_list} 470 - items={tracks_page.itemIds} 471 - get_key={(item) => item} 472 - item_height={24} 473 - {scroll_container} 474 - let:item={item_id} 475 - let:i 476 - buffer={5} 477 - > 478 - {@const { id: track_id, track } = get_item(item_id)} 479 - {#if track !== null} 480 - <!-- svelte-ignore a11y-click-events-have-key-events --> 481 - <!-- svelte-ignore a11y-interactive-supports-focus --> 482 - <div 483 - class="row" 484 - role="row" 485 - on:dblclick={(e) => double_click(e, i)} 486 - on:mousedown={(e) => selection.handle_mousedown(e, i)} 487 - on:contextmenu={(e) => selection.handle_contextmenu(e, i)} 488 - on:click={(e) => selection.handle_click(e, i)} 489 - draggable="true" 490 - on:dragstart={on_drag_start} 491 - on:dragover={(e) => on_drag_over(e, i)} 492 - on:drop={drop_handler} 493 - on:dragend={drag_end_handler} 494 - class:odd={i % 2 === 0} 495 - class:selected={$selection.has(item_id)} 496 - class:playing={track_id === $playing_id} 497 - > 498 - {#each columns as column} 499 - <div class="c {column.key}" style:width={column.width}> 500 - {#if column.key === 'index'} 501 - {#if track_id === $playing_id} 502 - <svg 503 - class="playing-icon inline" 504 - xmlns="http://www.w3.org/2000/svg" 505 - height="24" 506 - viewBox="0 0 24 24" 507 - width="24" 508 - > 509 - <path d="M0 0h24v24H0z" fill="none" /> 510 - <path 511 - d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" 512 - /> 513 - </svg> 514 - {:else} 515 - {i + 1} 516 - {/if} 517 - {:else if column.key === 'duration'} 518 - {track.duration ? get_duration(track.duration) : ''} 519 - {:else if column.key === 'dateAdded'} 520 - {format_date(track.dateAdded)} 521 - {:else if column.key === 'image'} 522 - <Cover {track} /> 523 - {:else} 524 - {track[column.key] || ''} 525 - {/if} 526 - </div> 527 - {/each} 528 - </div> 529 - {/if} 530 - </VirtualListBlock> 531 - <div class="drag-line" class:hidden={drag_to_index === null} bind:this={drag_line}></div> 611 + <div {@attach virtual_grid.attach()}> 612 + <div class="drag-line" class:hidden={drag_to_index === null} bind:this={drag_line}></div> 613 + </div> 532 614 </div> 533 615 </div> 534 616 535 617 <style lang="sass"> 536 - .odd 537 - background-color: hsla(0, 0%, 90%, 0.06) 538 - .selected 539 - background-color: hsla(var(--hue), 20%, 42%, 0.8) 540 - :global(:focus) 618 + .tracklist :global 619 + .odd 620 + background-color: hsla(0, 0%, 90%, 0.06) 621 + .selected 622 + background-color: hsla(var(--hue), 20%, 42%, 0.8) 623 + :global(:focus) .tracklist :global, .main-focus-element:focus :global 541 624 .selected 542 625 background-color: hsla(var(--hue), 70%, 46%, 1) 543 - .tracklist 626 + .tracklist :global 544 627 display: flex 545 628 flex-direction: column 546 629 min-width: 0px 547 630 width: 100% 548 631 background-color: rgba(0, 0, 0, 0.01) 549 632 overflow: hidden 550 - .table-header 633 + .row.table-header 634 + position: relative 551 635 .c 552 636 overflow: visible 553 637 * ··· 561 645 display: inline-block 562 646 &.desc .c.sort span::after 563 647 content: '▼' 564 - .row 565 - display: flex 566 - max-width: 100% 567 648 $row-height: 24px 568 - height: $row-height 569 - font-size: 12px 570 - line-height: $row-height 571 - box-sizing: border-box 572 - position: relative 573 - &.playing.selected 574 - color: #ffffff 575 - &.playing 576 - color: #00ffff 577 - .c 578 - display: inline-block 579 - vertical-align: top 580 - width: 100% 581 - white-space: nowrap 582 - overflow: hidden 583 - text-overflow: ellipsis 584 - padding-left: 5px 585 - padding-right: 5px 586 - &:first-child 587 - padding-left: 10px 588 - &:last-child 589 - padding-right: 0px 590 - &.index, &.playCount, &.skipCount, &.duration 591 - padding-left: 0px 592 - padding-right: 10px 593 - text-align: right 594 - flex-shrink: 0 595 - .selected .index svg.playing-icon 596 - fill: var(--icon-color) 597 - .index 598 - width: 46px 599 - svg.playing-icon 600 - fill: #00ffff 601 - width: 16px 649 + .row 650 + width: 100% 651 + height: $row-height 652 + font-size: 12px 653 + line-height: $row-height 654 + box-sizing: border-box 655 + position: absolute 656 + &.playing.selected 657 + color: #ffffff 658 + &.playing 659 + color: #00ffff 660 + .cell 661 + display: block 602 662 height: 100% 603 - .image 604 - width: 18px 605 - flex-shrink: 0 606 - box-sizing: content-box 607 - .playCount, .skipCount 608 - width: 52px 609 - .duration 610 - width: 50px 611 - .dateAdded 612 - width: 140px 613 - flex-shrink: 0 614 - font-variant-numeric: tabular-nums 615 - .year 616 - width: 0px 617 - min-width: 47px 618 - .bpm 619 - width: 0px 620 - min-width: 43px 663 + position: absolute 664 + vertical-align: top 665 + white-space: nowrap 666 + overflow: hidden 667 + text-overflow: ellipsis 668 + padding-left: 5px 669 + padding-right: 5px 670 + &:first-child 671 + padding-left: 10px 672 + &:last-child 673 + padding-right: 0px 674 + &.index, &.playCount, &.skipCount, &.duration 675 + padding-left: 0px 676 + padding-right: 10px 677 + text-align: right 678 + &.image:first-child 679 + padding-left: 7px 680 + padding-right: 3px 681 + .image 682 + display: flex 683 + align-items: center 684 + img 685 + display: block 686 + object-fit: contain 687 + height: 24px 688 + width: 18px 689 + padding: 3px 0 690 + .error::before 691 + content: '' 692 + cursor: pointer 693 + display: block 694 + width: 100% 695 + height: 100% 696 + background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" fill="%23ef4444"><path d="M9,4c-2.76,0 -5,2.24 -5,5c0,2.76 2.24,5 5,5c2.76,0 5,-2.24 5,-5c0,-2.76 -2.24,-5 -5,-5Zm0.5,7.5l-1,0l0,-1l1,0l0,1Zm0,-2l-1,0l0,-3l1,0l0,3Z"/></svg>') 697 + .error.missing::before 698 + cursor: default 699 + background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><rect x="0" y="0" width="18" height="18" fill="%232b2c31" rx="2" ry="2"/><path fill="%2345464a" d="M12.667,5l-5.332,1.195l-0,4.347c-0.993,-0.197 -2.002,0.557 -2.002,1.384c0,0.713 0.557,1.074 1.162,1.074c0.718,-0 1.504,-0.509 1.505,-1.546l0,-3.633l4,-0.82l0,2.875c-0.992,-0.196 -2,0.554 -2,1.38c0,0.714 0.572,1.077 1.174,1.077c0.713,0 1.492,-0.508 1.493,-1.545l-0,-5.788Z"/></svg>') 700 + .dateAdded 701 + font-variant-numeric: tabular-nums 621 702 .drag-line 622 703 position: absolute 623 704 width: 100%
+290
src/lib/virtual-grid.ts
··· 1 + export type Column = { 2 + name: string 3 + key: string 4 + width: number 5 + is_pct?: true 6 + offset?: number 7 + /** Handles both creation and updating of rows */ 8 + cell_render?: (element: HTMLElement, value: unknown) => void 9 + } 10 + 11 + export const RefreshLevel = { 12 + Nothing: 0, 13 + // Render rows that go in/out of the viewport 14 + NewRows: 1, 15 + // Render all rows in the viewport 16 + AllRows: 2, 17 + } as const 18 + export type RefreshLevel = (typeof RefreshLevel)[keyof typeof RefreshLevel] 19 + 20 + type Row = { 21 + /* null means it will render */ 22 + element: HTMLElement | null 23 + /* null means it will be removed */ 24 + index: number | null 25 + rendered: boolean 26 + } 27 + 28 + /** 29 + * Note that parentElement is not reactive. 30 + * Do not add other elements into the row elements. Things would break because cells are referenced by indexing into the row's children. 31 + */ 32 + export class VirtualGrid<I, R extends Record<string, unknown>> { 33 + main_element?: HTMLElement 34 + viewport?: HTMLElement 35 + size_observer?: ResizeObserver 36 + refreshing: RefreshLevel = RefreshLevel.Nothing 37 + 38 + private constructor( 39 + public source_items: I[], 40 + public options: { 41 + buffer?: number 42 + row_prepare: (source_items: I, index: number) => R 43 + row_render: (element: HTMLElement, item: R, index: number) => void 44 + }, 45 + ) {} 46 + 47 + static create<I, R extends Record<string, unknown>>( 48 + source_items: I[], 49 + options: { 50 + buffer?: number 51 + row_prepare: (source_items: I, index: number) => R 52 + row_render: (element: HTMLElement, item: R, index: number) => void 53 + }, 54 + ) { 55 + return new VirtualGrid<I, R>(source_items, options) 56 + } 57 + 58 + row_height = 24 59 + 60 + set_source_items(source_items: I[]) { 61 + this.source_items = source_items 62 + this.#update_viewport_size() 63 + this.refresh(RefreshLevel.AllRows) 64 + } 65 + 66 + viewport_row_count = 0 67 + 68 + #update_viewport_size() { 69 + const viewport_height = this.viewport?.clientHeight ?? 0 70 + this.viewport_row_count = Math.ceil(viewport_height / this.row_height) 71 + 72 + const height = this.source_items.length * this.row_height 73 + if (this.main_element) { 74 + this.main_element.style.height = height + 'px' 75 + } 76 + } 77 + 78 + rows: Row[] = [] 79 + 80 + #recalculate_visible_indexes() { 81 + if (!this.viewport) { 82 + return 83 + } 84 + const buffer = this.options.buffer ?? 5 85 + const rendered_count = this.viewport_row_count + buffer * 2 86 + 87 + let start_index = Math.max(0, Math.floor(this.viewport.scrollTop / this.row_height - buffer)) 88 + const end_index = Math.min(this.source_items.length - 1, start_index - 1 + rendered_count) 89 + if (end_index - start_index + 1 < rendered_count) { 90 + // fill backwards when scrolled to the end 91 + start_index = Math.max(0, end_index + 1 - rendered_count) 92 + } 93 + 94 + // figure out which indexes should now be added 95 + const new_visible_indexes: number[] = [] 96 + for (let i = start_index; i <= end_index; i++) { 97 + const exists = this.rows.find((row) => row.index === i) 98 + if (!exists) { 99 + new_visible_indexes.push(i) 100 + } 101 + } 102 + 103 + // update the visible indexes 104 + for (let i = 0; i < this.rows.length; i++) { 105 + const row = this.rows[i] 106 + const still_visible = row.index !== null && row.index >= start_index && row.index <= end_index 107 + if (!still_visible) { 108 + const new_index = new_visible_indexes.pop() 109 + if (new_index !== undefined) { 110 + // update it to a new visible index 111 + row.index = new_index 112 + row.rendered = false 113 + } else { 114 + // if there are no new visible indexes left, remove it 115 + row.index = null 116 + row.rendered = false 117 + } 118 + } 119 + } 120 + if (new_visible_indexes.length > 0) { 121 + // add new visible indexes 122 + for (const index of new_visible_indexes) { 123 + this.rows.push({ 124 + element: null, 125 + index, 126 + rendered: false, 127 + }) 128 + } 129 + } 130 + } 131 + 132 + refresh(level: RefreshLevel = RefreshLevel.AllRows) { 133 + if (this.refreshing) { 134 + if (level > this.refreshing) { 135 + this.refreshing = level 136 + } 137 + return 138 + } 139 + this.refreshing = level 140 + 141 + requestAnimationFrame(() => { 142 + this.#recalculate_visible_indexes() 143 + if (this.refreshing === RefreshLevel.AllRows) { 144 + for (const row of this.rows) { 145 + row.rendered = false 146 + } 147 + } 148 + this.#render() 149 + this.refreshing = 0 150 + }) 151 + } 152 + 153 + columns: Column[] = [] 154 + set_columns(columns: Column[]) { 155 + const total_fixed_width = columns.reduce((sum, col) => sum + (col.is_pct ? 0 : col.width), 0) 156 + const total_percent_pct = columns.reduce((sum, col) => sum + (col.is_pct ? col.width : 0), 0) 157 + const container_width = this.viewport?.clientWidth ?? total_fixed_width 158 + const total_percent_width = container_width - total_fixed_width 159 + let offset = 0 160 + this.columns = columns.map((col) => { 161 + col = { ...col } 162 + if (col.is_pct) { 163 + const pct = col.width / total_percent_pct 164 + col.width = pct * total_percent_width 165 + } 166 + col.offset = offset 167 + offset += col.width 168 + return col 169 + }) 170 + 171 + // make all rows fully rerender 172 + for (const row of this.rows) { 173 + row.element?.remove() 174 + } 175 + this.rows = [] 176 + 177 + this.refresh(RefreshLevel.NewRows) 178 + return this.columns 179 + } 180 + 181 + #render() { 182 + // prepare rows, before any DOM updates 183 + const items: R[] = [] 184 + for (const row of this.rows) { 185 + if (row.index === null || row.rendered) { 186 + continue 187 + } 188 + const row_item = this.options.row_prepare(this.source_items[row.index], row.index) 189 + items[row.index] = row_item 190 + } 191 + 192 + // create new row elements 193 + for (const row of this.rows) { 194 + if (row.element || row.index === null) { 195 + continue 196 + } 197 + const row_element = document.createElement('div') 198 + row_element.className = 'row' 199 + row_element.setAttribute('role', 'row') 200 + row_element.setAttribute('draggable', 'true') 201 + row.element = row_element 202 + this.main_element?.appendChild(row_element) 203 + 204 + for (const column of this.columns) { 205 + const cell = document.createElement('div') 206 + cell.className = `cell ${column.key}` 207 + cell.style.width = `${column.width}px` 208 + cell.style.translate = `${column.offset}px 0` 209 + row_element.appendChild(cell) 210 + } 211 + } 212 + 213 + // render rows 214 + for (const row of this.rows) { 215 + if (row.rendered || row.index === null) { 216 + continue 217 + } 218 + if (!row.element) { 219 + throw new Error('Unexpected missing row element') 220 + } 221 + row.element.style.translate = `0 ${row.index * this.row_height}px` 222 + row.element.setAttribute('aria-rowindex', String(row.index + 1)) 223 + const row_item = items[row.index] 224 + for (let ci = 0; ci < this.columns.length; ci++) { 225 + const column = this.columns[ci] 226 + const cell = row.element.children[ci] as HTMLElement 227 + let cell_value = row_item[column.key] 228 + if (cell_value === undefined || cell_value === null) { 229 + cell_value = '' 230 + } 231 + if (column.cell_render) { 232 + column.cell_render(cell, cell_value) 233 + } else { 234 + cell.textContent = String(cell_value) 235 + } 236 + } 237 + this.options.row_render(row.element, row_item, row.index) 238 + row.rendered = true 239 + } 240 + 241 + // delete rows that are no longer visible 242 + for (let i = this.rows.length - 1; i >= 0; i--) { 243 + const row = this.rows[i] 244 + if (row.index === null) { 245 + row.element?.remove() 246 + this.rows.splice(i, 1) 247 + } 248 + } 249 + } 250 + 251 + setup(node: HTMLElement) { 252 + this.main_element = node 253 + 254 + const viewport_result = this.main_element.parentElement 255 + if (!viewport_result) { 256 + throw new Error('No viewport') 257 + } 258 + const viewport = viewport_result 259 + this.viewport = viewport 260 + 261 + this.#update_viewport_size() 262 + 263 + this.size_observer = new ResizeObserver(() => { 264 + this.#update_viewport_size() 265 + this.refresh(RefreshLevel.NewRows) 266 + }) 267 + this.size_observer.observe(this.viewport) 268 + 269 + const on_scroll = () => this.refresh(RefreshLevel.NewRows) 270 + const on_keydown = (e: KeyboardEvent) => { 271 + let prevent = true 272 + if (e.key === 'Home') viewport.scrollTop = 0 273 + else if (e.key === 'End') viewport.scrollTop = viewport.scrollHeight 274 + else if (e.key === 'PageUp') viewport.scrollTop -= viewport.clientHeight 275 + else if (e.key === 'PageDown') viewport.scrollTop += viewport.clientHeight 276 + else prevent = false 277 + if (prevent) e.preventDefault() 278 + } 279 + viewport.addEventListener('scroll', on_scroll) 280 + viewport.addEventListener('keydown', on_keydown) 281 + return () => { 282 + viewport.removeEventListener('scroll', on_scroll) 283 + viewport.removeEventListener('keydown', on_keydown) 284 + this.size_observer?.disconnect() 285 + } 286 + } 287 + attach() { 288 + return (node: HTMLElement) => this.setup(node) 289 + } 290 + }