diff options
| author | 2025-07-31 22:48:01 +0200 | |
|---|---|---|
| committer | 2025-07-31 22:48:01 +0200 | |
| commit | e55f4c2fe8a6e1d62a0b005777b46c80e360d37e (patch) | |
| tree | 1f9ad00b914f04677ffaf8b395a4c5d4ff659756 /src | |
| download | videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.gz videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.bz2 videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.tar.lz videotool-e55f4c2fe8a6e1d62a0b005777b46c80e360d37e.zip | |
feat: initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.css | 1 | ||||
| -rw-r--r-- | src/app.d.ts | 13 | ||||
| -rw-r--r-- | src/app.html | 14 | ||||
| -rw-r--r-- | src/lib/Player/FrameSlider.svelte | 67 | ||||
| -rw-r--r-- | src/lib/Player/Keybinds.svelte | 47 | ||||
| -rw-r--r-- | src/lib/Player/Player.svelte | 161 | ||||
| -rw-r--r-- | src/lib/Player/Video.ts | 55 | ||||
| -rw-r--r-- | src/lib/Renderer/Renderer.svelte | 146 | ||||
| -rw-r--r-- | src/lib/assets/favicon.svg | 1 | ||||
| -rw-r--r-- | src/lib/index.ts | 1 | ||||
| -rw-r--r-- | src/lib/vendor/svelte-range-slider/README | 1 | ||||
| -rw-r--r-- | src/lib/vendor/svelte-range-slider/range-pips.svelte | 303 | ||||
| -rw-r--r-- | src/lib/vendor/svelte-range-slider/range-slider.svelte | 1026 | ||||
| -rw-r--r-- | src/routes/+layout.svelte | 12 | ||||
| -rw-r--r-- | src/routes/+page.svelte | 6 | ||||
| -rw-r--r-- | src/routes/ffmpeg-test/+page.svelte | 45 | ||||
| -rw-r--r-- | src/routes/render/+page.svelte | 6 | ||||
| -rw-r--r-- | src/user/Sneky Snitch.mp4 | bin | 0 -> 1620945 bytes | |||
| -rw-r--r-- | src/user/index.ts | 21 |
19 files changed, 1926 insertions, 0 deletions
diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/src/app.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..d1e03cb --- /dev/null +++ b/src/app.html @@ -0,0 +1,14 @@ +<!doctype html> +<html lang="en" class='bg-[#0a0a0a] text-white'> + +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% +</head> + +<body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> +</body> + +</html> diff --git a/src/lib/Player/FrameSlider.svelte b/src/lib/Player/FrameSlider.svelte new file mode 100644 index 0000000..a632aa9 --- /dev/null +++ b/src/lib/Player/FrameSlider.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import RangeSlider from '../vendor/svelte-range-slider/range-slider.svelte'; + let { + frame = $bindable(), + frameCount, + playing = $bindable(), + playbackStarted = $bindable() + }: { + frame: number; + frameCount: number; + playing: boolean; + playbackStarted: number; + } = $props(); +</script> + +<div class="w-full flex items-center justify-center pt-2"> + <div class="p-2 pr-1"> + <button + onclick={() => { + playing = !playing; + }} + aria-label={playing ? 'pause' : 'play'} + class="flex items-center justify-center" + ><svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d={!playing + ? 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z' + : 'M15.75 5.25v13.5m-7.5-13.5v13.5'} + /> + </svg></button + > + </div> + <div class="flex-1"> + <RangeSlider bind:value={frame} min={0} max={frameCount} /> + </div> + <div class="label p-2 pl-1"> + <input + type="number" + bind:value={frame} + class="w-16 appearance-none text-right" + style="-moz-appearance:textfield;" + max={frameCount} + onkeypress={(e) => { + e.stopPropagation(); + }} + onkeydown={(e) => { + e.stopPropagation(); + }} + /> + of {frameCount} + <!-- <input + type="number" + bind:value={frameCount} + class="w-8 appearance-none" + style="-moz-appearance:textfield;" + /> --> + </div> +</div> diff --git a/src/lib/Player/Keybinds.svelte b/src/lib/Player/Keybinds.svelte new file mode 100644 index 0000000..756a865 --- /dev/null +++ b/src/lib/Player/Keybinds.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + let { + frame = $bindable(), + frameCount, + fps, + playing = $bindable(), + playbackStarted = $bindable() + }: { + frame: number; + frameCount: number; + fps: number | undefined; + playing: boolean; + playbackStarted: number; + } = $props(); +</script> + +<svelte:window + onkeypress={(e) => { + switch (e.key) { + case ' ': + e.preventDefault(); + playing = !playing; + if (playing) playbackStarted = performance.now(); + break; + + // default: + // if (dev) console.debug('Keypress:', e.key); + // break; + } + }} + onkeydown={(e) => { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + frame = Math.max(frame - (e.ctrlKey ? (fps ?? 60) : 1), 0); + break; + case 'ArrowRight': + e.preventDefault(); + frame = Math.min(frame + (e.ctrlKey ? (fps ?? 60) : 1), frameCount); + break; + + // default: + // if (dev) console.debug('Keydown:', e.key); + // break; + } + }} +/> diff --git a/src/lib/Player/Player.svelte b/src/lib/Player/Player.svelte new file mode 100644 index 0000000..f6df121 --- /dev/null +++ b/src/lib/Player/Player.svelte @@ -0,0 +1,161 @@ +<script lang="ts"> + import FrameSlider from './FrameSlider.svelte'; + import { type Video, type VideoConstructor } from '$/lib/Player/Video'; + import Keybinds from './Keybinds.svelte'; + import { onMount } from 'svelte'; + + let frame = $state(0); + let canvas = $state(null as HTMLCanvasElement | null); + let audio = $state(null as HTMLAudioElement | null); + let lastCanvas = null as typeof canvas; + let video = $state(undefined as Video | undefined); + let frameCount = $state(0); + let playing = $state(false); + let playbackStarted = $state(0); + let playbackFrameOffset = 0; + let renderPromise: Promise<void> | void = void 0; + let renderId = 0; + let audioSource = $state(null as null | string); + let { + Video: VideoImplementation + }: { + Video: VideoConstructor; + } = $props(); + const newCanvas = async (canvas: HTMLCanvasElement, videoImplementation: VideoConstructor) => { + if (video) video.cleanup(); + lastCanvas = canvas; + video = new VideoImplementation(canvas); + const audioSourcePromise = (async () => { + if (video?.audioUrl?.[1]) { + audioSource = await fetch(video.audioUrl[1]) + .then(async (v) => { + if (v.status.toString().startsWith('2')) return URL.createObjectURL(await v.blob()); + else throw new Error('non-2xx audio'); + }) + .catch((e) => { + console.warn('Failed to get audio', e); + return video?.audioUrl?.[1] ?? null; + }); + } + })(); + video['_isInit'] = true; + renderPromise = video.init(); + await renderPromise; + video['_isInit'] = false; + frameCount = video.length; + await audioSourcePromise; + }; + const renderPreviewFrame = async (video: Video, frame: number) => { + const ourId = ++renderId; + if (renderPromise) await renderPromise; + if (renderId !== ourId) return; + renderPromise = + video.renderFrame({ + frames: frame, + milliseconds: (frame / video.fps) * 1000, + seconds: frame / video.fps + }) ?? Promise.resolve(); + return renderPromise; + }; + $effect(() => { + if (canvas && canvas !== lastCanvas) newCanvas(canvas, VideoImplementation); + }); + $effect(() => { + if (video) renderPreviewFrame(video, frame); + }); + let playbackLoopId = 0; + const startPlaybackLoop = (id = ++playbackLoopId) => { + if (video && id === playbackLoopId) { + const ms = performance.now() - playbackStarted; + let f = Math.floor((ms / 1000) * video.fps) + playbackFrameOffset; + + if (f > frameCount) { + f = frameCount; + playing = false; + } + frame = f; + renderPreviewFrame(video, frame).then(() => + requestAnimationFrame(() => (playing ? startPlaybackLoop(id) : void 0)) + ); + } + }; + $effect(() => { + if (playing) { + playbackStarted = performance.now(); + playbackFrameOffset = frame; + startPlaybackLoop(); + } + }); + let loadedFrameTimestamp = false; + onMount(() => { + const t = sessionStorage.getItem('timestamp'); + const tI = t ? parseInt(t, 36) : null; + if (tI && !isNaN(tI)) { + frame = tI; + requestAnimationFrame(() => (frame = tI)); + } + loadedFrameTimestamp = true; + }); + // TODO: implement waitin a few seconds before saving + $effect(() => { + if (loadedFrameTimestamp) + try { + sessionStorage.setItem('timestamp', frame.toString(36)); + } catch (_) {} + }); + $effect(() => { + if (audio && video && !playing) { + try { + const f = frame; + audio.currentTime = frame / video.fps; + audio.play(); + (async () => { + const targetTime = (frame + 1) / video.fps; + while (audio.currentTime <= targetTime) { + await new Promise((rs) => requestAnimationFrame(rs)); + if (playing || frame !== f) return; + } + audio.pause(); + audio.currentTime = frame / video.fps; + })(); + } catch (error) { + console.warn(error); + } + } + }); + $effect(() => { + if (audio) { + if (playing) audio.play(); + else audio.pause(); + } + }); +</script> + +<svelte:window + onresize={() => { + if (canvas && video) { + (async () => { + video['_isInit'] = true; + renderPromise = await video.init(); + video['_isInit'] = false; + renderPreviewFrame(video, frame); + })(); + } + }} +/> + +<Keybinds bind:frame {frameCount} fps={video?.fps} bind:playing bind:playbackStarted /> + +<div class="p-2 w-screen h-screen relative flex flex-col"> + <div class="flex-1 relative"> + <div class="absolute top-0 left-0 w-full h-full flex items-center justify-center"> + <canvas class="pointer-events-none bg-black" bind:this={canvas}> + Your browser doesn't support the canvas API. + </canvas> + {#if audioSource} + <audio src={audioSource} bind:this={audio}></audio> + {/if} + </div> + </div> + <FrameSlider bind:frame {frameCount} bind:playing bind:playbackStarted /> +</div> diff --git a/src/lib/Player/Video.ts b/src/lib/Player/Video.ts new file mode 100644 index 0000000..78b3b8f --- /dev/null +++ b/src/lib/Player/Video.ts @@ -0,0 +1,55 @@ +export type FrameTime = { + milliseconds: number, + seconds: number, + frames: number +} +export abstract class Video { + public constructor(public canvas: HTMLCanvasElement) { }; + public abstract renderFrame(time: FrameTime): Promise<void> | void; + /** (re-)Initializes the Video object. Also called on window resizes. */ + public abstract init(): void | Promise<void>; + private _isInit = false; + /** The frames per second to render at */ + public abstract get fps(): number; + /** Length in frames */ + public abstract get length(): number; + /** A URL (and matching filename) to an ffmpeg-compatible audio file */ + public audioUrl?: readonly [filename: string, fileUrl: string]; + /** Resizes the canvas to a predetermined render resolution - must only be called in init() - do not overwrite */ + public resize(x: number, y: number) { + if (!this._isInit) throw new Error('Must only call resize() in init.') + this.canvas.width = x; + this.canvas.height = y; + const parentW = this.canvas.parentElement!.clientWidth, + parentH = this.canvas.parentElement!.clientHeight + if (x <= parentW && y <= parentH) { + this.canvas.style.width = `${x}px`; + this.canvas.style.height = `${y}px`; + } else if (x <= parentW && y > parentH) { + this.canvas.style.width = `${x / y * parentH}px`; + this.canvas.style.height = `${parentH}px`; + } else if (y <= parentH && x > parentW) { + this.canvas.style.width = `${parentW}px`; + this.canvas.style.height = `${y / x * parentW}px`; + } else { + if ((parentW / x) * y > parentH) { + this.canvas.style.width = `${(parentH / y) * x}px` + this.canvas.style.height = `${parentH}px` + } else { + this.canvas.style.width = `${parentW}px` + this.canvas.style.height = `${(parentW / x) * y}px` + } + } + } + /** The width of the video, in pixels */ + public get w() { + return this.canvas.width; + } + /** The height of the video, in pixels */ + public get h() { + return this.canvas.height; + } + /** Use to cleanup any mess you made - do not remove the canvas, it may be reused. */ + public cleanup() { }; +} +export type VideoConstructor = new (...params: ConstructorParameters<typeof Video>) => Video diff --git a/src/lib/Renderer/Renderer.svelte b/src/lib/Renderer/Renderer.svelte new file mode 100644 index 0000000..72c270b --- /dev/null +++ b/src/lib/Renderer/Renderer.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import { onDestroy } from 'svelte'; + import type { VideoConstructor } from '../Player/Video'; + import { FFmpeg, type LogEvent } from '@ffmpeg/ffmpeg'; + import { fetchFile, toBlobURL } from '@ffmpeg/util'; + + let { + Video: VideoImplementation + }: { + Video: VideoConstructor; + } = $props(); + let canvas = $state(null as null | HTMLCanvasElement); + + let frame = $state(0); + let frameCount = $state(0); + let active = false; + + let lastAnimationFrame = 0; + let startedAt = 0; + let message = $state('Waiting'); + let videoUrl = $state(null as null | string); + + const ffmpegBaseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.6/dist/esm'; + + const start = async (format = 'mp4') => { + active = true; + startedAt = performance.now(); + + const ffmpeg = new FFmpeg(); + message = 'Loading ffmpeg-core.js'; + ffmpeg.on('log', ({ message: msg }: LogEvent) => { + message = msg; + console.log(message); + }); + await ffmpeg.load({ + coreURL: await toBlobURL(`${ffmpegBaseURL}/ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${ffmpegBaseURL}/ffmpeg-core.wasm`, 'application/wasm'), + workerURL: await toBlobURL(`${ffmpegBaseURL}/ffmpeg-core.worker.js`, 'text/javascript') + }); + + message = 'Making directory'; + await ffmpeg.createDir('frames'); + message = 'Preparing Canvas'; + const c = canvas!; + const video = new VideoImplementation(c); + video['_isInit'] = true; + await video.init(); + video['_isInit'] = false; + frameCount = video.length; + message = 'Rendering first few frames...'; + for (frame = 0; frame <= frameCount; frame++) { + if (!active) return; + await video.renderFrame({ + frames: frame, + milliseconds: (frame / video.fps) * 1000, + seconds: frame / video.fps + }); + // TODO: see if we can pipe this into an active ffmpeg instead of writing a file then running a command after + const file = await fetchFile(c.toDataURL()); + await ffmpeg.writeFile('frames/f' + frame.toString().padStart(10, '0') + '.png', file); + if (performance.now() > lastAnimationFrame + 33) { + await new Promise((rs) => requestAnimationFrame(rs)); + lastAnimationFrame = performance.now(); + message = `Rendering to pngs - ${frame}/${frameCount} frames | running for ${( + Math.floor(lastAnimationFrame - startedAt) / 1000 + ).toFixed(3)} seconds`; + } + } + + message = 'Start transcoding'; + await ffmpeg.exec([ + '-framerate', + video.fps.toString(), + '-pattern_type', + 'sequence', + '-start_number', + '0', + '-pattern_type', + 'glob', + '-i', + 'frames/f*.png', + // 'frames/f%04d.png', + 'middle.' + format + ]); + message = 'Disposing pngs'; + for (const i of await ffmpeg.listDir('frames')) + if (!i.isDir) await ffmpeg.deleteFile('frames/' + i.name); + await ffmpeg.deleteDir('frames/'); + if (video.audioUrl) { + message = 'Fetching Audio'; + await ffmpeg.writeFile('audio.' + video.audioUrl[0], await fetchFile(video.audioUrl[1])); + message = 'Merging Audio'; + await ffmpeg.exec([ + '-i', + 'middle.' + format, + '-r', + video.fps.toString(), + '-i', + 'audio.' + video.audioUrl[0], + '-c:v', + 'copy', + '-map', + '0:v:0', + '-map', + '1:a:0', + '-frames:v', + video.length.toString(), + 'output.' + format + ]); + message = 'Removing videoless file'; + await ffmpeg.deleteFile('middle.' + format); + } else await ffmpeg.rename('middle.' + format, 'output.' + format); + message = 'Reading File'; + const data = await ffmpeg.readFile('output.' + format); + console.log('done'); + videoUrl = URL.createObjectURL( + // @ts-ignore bufferlike is good enuf + new Blob([(data as Uint8Array).buffer], { type: 'video/' + format }) + ); + message = 'Disposing ffmpeg state'; + await ffmpeg.deleteFile('output.' + format); + location.href = videoUrl; + }; + $effect(() => { + if (canvas && VideoImplementation) start(); + }); + onDestroy(() => (active = false)); +</script> + +<div class="flex flex-col h-screen w-screen"> + {#if videoUrl} + <!-- svelte-ignore a11y_media_has_caption --> + <video src={videoUrl} controls class="max-w-screen max-h-screen flex-1"></video> + {:else} + <div class="flex-1 relative"> + <div class="absolute top-0 left-0 w-full h-full flex items-center justify-center"> + <canvas class="pointer-events-none bg-black" bind:this={canvas}> + Your browser doesn't support the canvas API. + </canvas> + </div> + </div> + {/if} + <p class="p-4"> + {message} + </p> +</div> diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
\ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/vendor/svelte-range-slider/README b/src/lib/vendor/svelte-range-slider/README new file mode 100644 index 0000000..b17797b --- /dev/null +++ b/src/lib/vendor/svelte-range-slider/README @@ -0,0 +1 @@ +https://github.com/roycrippen4/svelte-range-slider/tree/master diff --git a/src/lib/vendor/svelte-range-slider/range-pips.svelte b/src/lib/vendor/svelte-range-slider/range-pips.svelte new file mode 100644 index 0000000..418fc7e --- /dev/null +++ b/src/lib/vendor/svelte-range-slider/range-pips.svelte @@ -0,0 +1,303 @@ +<script lang="ts" module> + export interface PipsProps { + min?: number; + max?: number; + step?: number; + values?: number[]; + vertical?: boolean; + reversed?: boolean; + hoverable?: boolean; + disabled?: boolean; + pipstep?: number; + prefix?: string; + suffix?: string; + focus?: boolean; + range?: undefined | boolean | 'min' | 'max'; + all?: undefined | boolean | 'pip' | 'label'; + first?: boolean | 'pip' | 'label'; + last?: boolean | 'pip' | 'label'; + rest?: boolean | 'pip' | 'label'; + percentOf: (v: number) => number; + fixFloat: (v: number) => number; + orientationStart?: 'top' | 'bottom' | 'left' | 'right'; + orientationEnd?: 'top' | 'bottom' | 'left' | 'right'; + formatter?: (v: number, i: number, p: number) => string; + moveHandle: undefined | ((index: number | undefined, value: number) => number); + normalisedClient: (e: MouseEvent | TouchEvent) => { x: number; y: number }; + } +</script> + +<script lang="ts"> + let { + range = false, + min = 0, + max = 100, + step = 1, + values = [(max + min) / 2], + vertical = false, + reversed = false, + hoverable = true, + disabled = false, + pipstep, + all = true, + first, + last, + rest, + prefix = '', + suffix = '', + focus, + orientationStart, + // eslint-disable-next-line no-unused-vars + formatter = (v, i, p) => v.toString(), + percentOf, + moveHandle, + fixFloat, + normalisedClient + }: PipsProps = $props(); + + let clientStart = $state({ x: 0, y: 0 }); + let pipStep = $derived( + pipstep || + ((max - min) / step >= (vertical ? 50 : 100) ? (max - min) / (vertical ? 10 : 20) : 1) + ); + let pipCount = $derived(parseInt(((max - min) / (step * pipStep)).toString(), 10)); + let pipVal = $derived((val: number) => fixFloat(min + val * step * pipStep)); + let isSelected = $derived((val: number) => values.some((v) => fixFloat(v) === fixFloat(val))); + let inRange = $derived((val: number) => { + if (range === 'min') { + return values[0] > val; + } + if (range === 'max') { + return values[0] < val; + } + if (range) { + return values[0] < val && values[1] > val; + } + }); + + /** + * function to run when the user clicks on a label + * we store the original client position so we can check if the user has moved the mouse/finger + * @param {MouseEvent} e the event from browser + **/ + const labelDown = (e: MouseEvent) => { + clientStart = { x: e.clientX, y: e.clientY }; + }; + + /** + * function to run when the user releases the mouse/finger + * we check if the user has moved the mouse/finger, if not we "click" the label + * and move the handle it to the label position + * @param {number} val the value of the label + * @param {MouseEvent|TouchEvent} e the event from browser + */ + function labelUp(val: number, e: MouseEvent | TouchEvent) { + if (disabled) { + return; + } + + const clientPos = normalisedClient(e); + const distanceMoved = Math.sqrt( + Math.pow(clientStart.x - clientPos.x, 2) + Math.pow(clientStart.y - clientPos.y, 2) + ); + + if (clientStart && distanceMoved <= 5) { + moveHandle?.(undefined, val); + } + } +</script> + +<div + class="rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6" + class:disabled + class:hoverable + class:vertical + class:reversed + class:focus +> + {#if (all && first !== false) || first} + <span + class="pip-680f0f01-664b-43b5-9e1c-789449c63c62 first" + class:selected={isSelected(min)} + class:in-range={inRange(min)} + style="{orientationStart}: 0%;" + onpointerdown={labelDown} + onpointerup={(e) => labelUp(min, e)} + > + {#if all === 'label' || first === 'label'} + <span class="pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b"> + {prefix}{formatter(fixFloat(min), 0, 0)}{suffix} + </span> + {/if} + </span> + {/if} + + {#if (all && rest !== false) || rest} + <!-- eslint-disable-next-line no-unused-vars --> + {#each Array(pipCount + 1) as _, i} + {#if pipVal(i) !== min && pipVal(i) !== max} + <span + class="pip-680f0f01-664b-43b5-9e1c-789449c63c62" + class:selected={isSelected(pipVal(i))} + class:in-range={inRange(pipVal(i))} + style="{orientationStart}: {percentOf(pipVal(i))}%;" + onpointerdown={labelDown} + onpointerup={(e) => labelUp(pipVal(i), e)} + > + {#if all === 'label' || rest === 'label'} + <span class="pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b"> + {prefix}{formatter(pipVal(i), i, percentOf(pipVal(i)))}{suffix} + </span> + {/if} + </span> + {/if} + {/each} + {/if} + + {#if (all && last !== false) || last} + <span + class="pip last" + class:selected={isSelected(max)} + class:in-range={inRange(max)} + style="{orientationStart}: 100%;" + onpointerdown={labelDown} + onpointerup={(e) => labelUp(max, e)} + > + {#if all === 'label' || last === 'label'} + <span class="pipVal"> + {prefix}{formatter(fixFloat(max), pipCount, 100)}{suffix} + </span> + {/if} + </span> + {/if} +</div> + +<style> + :global(._rangeslider-0f6d4a99-47b0-4108-8415-b2aefa867e28) { + --pip: var(--range-pip, lightslategray); + --pip-text: var(--range-pip-text, var(--pip)); + --pip-active: var(--range-pip-active, darkslategrey); + --pip-active-text: var(--range-pip-active-text, var(--pip-active)); + --pip-hover: var(--range-pip-hover, darkslategrey); + --pip-hover-text: var(--range-pip-hover-text, var(--pip-hover)); + --pip-in-range: var(--range-pip-in-range, var(--pip-active)); + --pip-in-range-text: var(--range-pip-in-range-text, var(--pip-active-text)); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6) { + position: absolute; + height: 1em; + left: 0; + right: 0; + bottom: -1em; + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical) { + height: auto; + width: 1em; + left: 100%; + right: auto; + top: 0; + bottom: 0; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + height: 0.4em; + position: absolute; + top: 0.25em; + width: 1px; + white-space: nowrap; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical + .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + height: 1px; + width: 0.4em; + left: 0.25em; + top: auto; + bottom: auto; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + position: absolute; + top: 0.4em; + transform: translate(-50%, 25%); + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + position: absolute; + top: 0; + left: 0.4em; + transform: translate(25%, -50%); + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + transition: all 0.15s ease; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + transition: + all 0.15s ease, + font-weight 0s linear; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip-680f0f01-664b-43b5-9e1c-789449c63c62 + ) { + color: var(--pip-text, lightslategray); + background-color: var(--pip, lightslategray); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.selected) { + color: var(--pip-active-text, darkslategrey); + background-color: var(--pip-active, darkslategrey); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) .pip:hover) { + color: var(--pip-hover-text, darkslategrey); + background-color: var(--pip-hover, darkslategrey); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.in-range) { + color: var(--pip-in-range-text, darkslategrey); + background-color: var(--pip-in-range, darkslategrey); + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 .pip.selected) { + height: 0.75em; + } + :global(.rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical .pip.selected) { + height: 1px; + width: 0.75em; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6 + .pip.selected + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + font-weight: bold; + top: 0.75em; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.vertical + .pip.selected + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + top: 0; + left: 0.75em; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) + .pip:not(.selected):hover + ) { + transition: none; + } + :global( + .rangePips-f75c52e3-b799-4c81-8238-035d862cc2e6.hoverable:not(.disabled) + .pip:not(.selected):hover + .pipVal-c41e7185-de59-40a7-90f2-e3d98b1e844b + ) { + transition: none; + font-weight: bold; + } +</style> diff --git a/src/lib/vendor/svelte-range-slider/range-slider.svelte b/src/lib/vendor/svelte-range-slider/range-slider.svelte new file mode 100644 index 0000000..7f522e2 --- /dev/null +++ b/src/lib/vendor/svelte-range-slider/range-slider.svelte @@ -0,0 +1,1026 @@ +<script lang="ts" module> + export type ChangeEvent = { + activeHandle: number; + startValue: number; + previousValue: number; + value: number; + values: number[]; + }; + + export type StartEvent = { activeHandle: number; value: number; values: number[] }; + + export type StopEvent = { + activeHandle: number; + startValue: number; + value: number; + values: number[]; + }; + + export interface RangeSliderProps { + range?: boolean | 'min' | 'max'; + onchange?: (event: ChangeEvent) => void; + onstart?: (event: StartEvent) => void; |