Rewrite using shadcn-svelte and tailwindcss

This commit is contained in:
daylily
2025-03-31 03:09:37 -04:00
parent 6be8e7a185
commit 1094ca0b19
84 changed files with 5681 additions and 483 deletions
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}
+3428 -5
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -10,13 +10,34 @@
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@fontsource/ibm-plex-sans": "^5.2.5",
"@iconify-json/material-symbols": "^1.2.17",
"@iconify/json": "^2.2.321",
"@lucide/svelte": "^0.485.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.17",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^22.13.14",
"@types/w3c-web-hid": "^1.0.6",
"@unocss/preset-icons": "^66.1.0-beta.7",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.3.15",
"clsx": "^2.1.1",
"mode-watcher": "^0.5.1",
"rgbquant": "^1.1.2",
"svelte": "^5.25.2",
"svelte-check": "^4.1.4",
"svelte-check": "^4.1.5",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^3.0.2",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.7.2",
"unocss": "^66.1.0-beta.7",
"unplugin-icons": "^22.1.0",
"vite": "^6.2.0"
},
"dependencies": {
"@fontsource-variable/ibm-plex-sans": "^5.2.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+17 -122
View File
@@ -1,128 +1,23 @@
<script lang="ts">
import Canvas from './lib/Canvas.svelte'
import ConnectButton from './lib/ConnectButton.svelte'
import WriteButton from './lib/WriteButton.svelte'
import { ModeWatcher } from 'mode-watcher'
import { Toaster } from 'svelte-sonner'
const hid = navigator.hid
import Footer from './Footer.svelte'
import Main from './Main.svelte'
import Unsupported from './Unsupported.svelte'
let inProgress = $state(false)
let device: HIDDevice | null = $state(null)
let bitmapData: number[] | null = $state(null)
function isInkclip(dev: HIDDevice) {
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
}
async function tryGetPairedDevice() {
const dev = (await hid.getDevices()).find(isInkclip)
if (dev !== undefined) device = dev
}
hid.addEventListener('connect', e => {
if (isInkclip(e.device) && device === null) device = e.device
})
hid.addEventListener('disconnect', e => {
if (device === e.device) device = null
})
$effect(() => {
tryGetPairedDevice()
})
const unsupported = navigator.hid === undefined
</script>
<main>
<section class="section--connect">
<div class="section-text">
<h1 class="section-title">Connect to a device</h1>
<ModeWatcher />
<Toaster position="bottom-center" duration={2000} />
{#if device !== null}
Successfully conected to device. If you want to, you can connect to another device instead.
{:else}
Not connected to any device yet. Plug in your device, and click the button to select it.
{/if}
</div>
<ConnectButton
onconnect={dev => {
device = dev
}}
{device}
/>
</section>
<section class="section--edit">
<h1 class="section-title">Choose an image</h1>
<Canvas
onchange={v => {
bitmapData = v
}}
/>
</section>
<section class="section--write">
<div class="section-text">
<h1 class="section-title">Write pattern to device</h1>
{#if device === null}
Connect your device to start writing patterns onto it.
{:else if bitmapData === null}
Select an image file in order to write it onto your device.
{:else if !inProgress}
Write the pattern onto your device if you have finished editing the image.
{:else}
Writing in progress. Do not disconnect device.
{/if}
</div>
<WriteButton
{device}
data={bitmapData}
onprogress={v => {
inProgress = v
}}
/>
</section>
</main>
<style>
main {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1em;
min-height: 100vh;
}
section {
background-color: #fff1;
padding: 1em;
border-radius: 5px;
flex-grow: 0;
}
.section-text {
flex-grow: 1;
}
.section-title {
margin: 0;
line-height: 1.5em;
}
.section--connect,
.section--write {
display: flex;
}
.section--edit {
flex-grow: 1;
}
@media (prefers-color-scheme: light) {
section {
background-color: #0001;
}
}
</style>
<div class="max-w-screen-xl min-h-screen m-auto p-8 flex flex-col gap-4">
{#if unsupported}
<Unsupported />
<div class="grow"></div>
{:else}
<Main class="grow" />
{/if}
<Footer />
</div>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { mode, toggleMode } from 'mode-watcher'
import { Button } from '$lib/components/ui/button'
import IconLightMode from '~icons/material-symbols/light-mode'
import IconDarkMode from '~icons/material-symbols/dark-mode'
</script>
<footer class="flex items-center text-sm text-muted-foreground">
<div class="grow">
2025 &copy; <a class="hover:underline" href="https://dayli.ly">daylily</a>
</div>
<Button size="icon" variant="ghost" onclick={toggleMode}>
{#if $mode === 'dark'}
<IconDarkMode />
{:else}
<IconLightMode />
{/if}
</Button>
</footer>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { Separator } from '$lib/components/ui/separator'
import ConnectSection from './connect/ConnectSection.svelte'
import EditSection from './edit/EditSection.svelte'
import WriteSection from './write/WriteSection.svelte'
interface Props extends HTMLAttributes<HTMLElement> {}
const { class: classNames, ...restProps }: Props = $props()
let device: HIDDevice | null = $state(null)
let bitmap: number[] | null = $state(null)
</script>
<main class={cn('w-full flex flex-col gap-4', classNames)} {...restProps}>
<ConnectSection
{device}
onchange={v => {
device = v
}}
/>
<Separator />
<EditSection
class="grow"
onchange={v => {
bitmap = v
}}
/>
<Separator />
<WriteSection {device} {bitmap} />
</main>
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog'
</script>
<AlertDialog.Root open>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
<AlertDialog.Description>
<p class="mb-4">Write to Inkclip uses the WebHID API, which is not supported by your browser.</p>
<p>
We recommend using a Chromium-based browser, such as a recent version of Google Chrome, Microsoft Edge, Opera,
or Arc.
</p>
</AlertDialog.Description>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
+74 -58
View File
@@ -1,66 +1,82 @@
* {
box-sizing: border-box;
/* ibm-plex-sans-latin-wght-normal */
@font-face {
font-family: 'IBM Plex Sans Variable';
font-style: normal;
font-display: swap;
font-weight: 100 700;
src: url(@fontsource-variable/ibm-plex-sans/files/ibm-plex-sans-latin-wght-normal.woff2) format('woff2-variations');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
body {
font-family: Fira Sans, sans-serif;
font-size: 16px;
line-height: 1.5em;
color-scheme: dark light;
background-color: #222;
color: #eee;
margin: 0;
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 72.22% 50.59%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.5rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
button {
font-family: Fira Sans, sans-serif;
font-size: 16px;
font-weight: bold;
@layer base {
* {
@apply border-border;
}
background-color: #fff1;
padding: 10px 15px;
border: none;
border-radius: 5px;
transition: 0.1s ease;
}
button:hover {
background-color: #fff2;
cursor: pointer;
}
button:active {
background-color: #fff3;
cursor: pointer;
}
button:disabled {
background-color: #0003;
color: #888;
cursor: default;
}
@media (prefers-color-scheme: light) {
body {
background-color: #ddd;
color: #111;
}
button {
background-color: #0001;
}
button:hover {
background-color: #0002;
}
button:active {
background-color: #0003;
}
button:disabled {
background-color: #fff3;
@apply bg-background text-foreground;
font-family: IBM Plex Sans Variable, IBM Plex Sans, Fira Sans, sans-serif;
}
}
@layer utilities {
.multimodal {
@apply flex gap-1 items-end;
}
.section-title {
@apply font-semibold text-xl;
}
}
@@ -1,4 +1,8 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import IconPlugConnect from '~icons/material-symbols/plug-connect'
const hid = navigator.hid
interface Props {
@@ -26,10 +30,11 @@
}
</script>
<button onclick={requestDevice}>
{#if device == null}
Choose device
<Button variant={device === null ? 'default' : 'secondary'} onclick={requestDevice}>
<IconPlugConnect />
{#if device === null}
Select device
{:else}
Connect to another device
{/if}
</button>
</Button>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import IconPending from '~icons/material-symbols/pending'
import IconCheckCircle from '~icons/material-symbols/check-circle'
import { onMount } from 'svelte'
import { toast } from 'svelte-sonner'
import ConnectButton from './ConnectButton.svelte'
interface Props {
device: HIDDevice | null
onchange: (device: HIDDevice | null) => void
}
const { device, onchange }: Props = $props()
const hid = navigator.hid
function isInkclip(dev: HIDDevice) {
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
}
async function tryGetPairedDevice() {
const dev = (await hid.getDevices()).find(isInkclip)
if (dev !== undefined) onchange(dev)
}
hid.addEventListener('connect', e => {
if (isInkclip(e.device) && device === null) {
toast.info('Device connected')
onchange(e.device)
}
})
hid.addEventListener('disconnect', e => {
toast.info('Device disconnected')
if (device === e.device) onchange(null)
})
onMount(() => {
tryGetPairedDevice()
})
</script>
<section class="flex items-center gap-2 max-lg:flex-col max-lg:items-stretch">
<div class="grow">
<h1 class="font-semibold text-xl/8">Connect to a device</h1>
{#if device !== null}
<IconCheckCircle class="inline" /> Successfully conected to device. If you want to, you can connect to another device
instead.
{:else}
<IconPending class="inline" /> Not connected to any device yet. Plug in your device, and click the button to select
it.
{/if}
</div>
<ConnectButton onconnect={onchange} {device} />
</section>
+104
View File
@@ -0,0 +1,104 @@
<script lang="ts">
import { Transform, withTransform } from '$lib/image/transform'
import { DEFAULT_DITHERING_KERNEL, Quantizer, type DitheringKernel } from '$lib/image/quantizer'
import { Scaler, type ScaleMode } from '$lib/image/scaler'
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { Separator } from '$lib/components/ui/separator'
import PreviewSection from './preview/PreviewSection.svelte'
import ControlsSection from './controls/ControlsSection.svelte'
const scaler = new Scaler(200, 200)
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onchange'> {
onchange: (bitmap: number[] | null) => void
}
const { onchange, class: classNames, ...restProps }: Props = $props()
const mobileMediaQuery = matchMedia('(max-width: 1024px)')
let mobile = $state(mobileMediaQuery.matches)
const separatorOrientation = $derived(mobile ? 'horizontal' : 'vertical')
$effect(() => {
mobileMediaQuery.addEventListener('change', self => {
mobile = self.matches
})
})
let imageCanvasEl: HTMLCanvasElement
let imageBitmap: ImageBitmap | null = $state(null)
let scaleMode: ScaleMode = $state('fit')
let transform: Transform = $state(new Transform())
let backgroundColor: number = $state(255)
let ditheringKernel: DitheringKernel | null = $state(DEFAULT_DITHERING_KERNEL)
let contrast = $state(0)
let bias = $state(0)
let renderedBitmap: number[] | null = $state(null)
const quantizer = $derived(
new Quantizer({
ditheringKernel,
contrast,
bias,
}),
)
async function renderPattern() {
if (imageBitmap === null) {
onchange(null)
return
}
const ctx = imageCanvasEl.getContext('2d', {
willReadFrequently: true,
})!
withTransform(ctx, transform, () => {
const nonNullBitmap = imageBitmap!
ctx.fillStyle = `rgb(${backgroundColor} ${backgroundColor} ${backgroundColor})`
ctx.fillRect(0, 0, 200, 200)
const { dx, dy, dWidth, dHeight } = scaler.scale(nonNullBitmap, scaleMode)
ctx.drawImage(nonNullBitmap, dx, dy, dWidth, dHeight)
})
const quantizedData = quantizer.reduce(ctx)
renderedBitmap = quantizedData
onchange(quantizedData)
}
$effect(() => {
renderPattern()
})
</script>
<div class={cn('flex max-lg:flex-col gap-4', classNames)} {...restProps}>
<canvas class="hidden" width={200} height={200} bind:this={imageCanvasEl}></canvas>
<PreviewSection
bitmap={renderedBitmap}
onchange={v => {
imageBitmap = v
}}
/>
<Separator orientation={separatorOrientation} />
<ControlsSection
class="grow"
{imageBitmap}
bind:scaleMode
bind:transform
bind:backgroundColor
bind:ditheringKernel
bind:contrast
bind:bias
/>
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider'
import Infotip from '$lib/Infotip.svelte'
interface Props {
backgroundColor: number
}
let { backgroundColor = $bindable() }: Props = $props()
</script>
<div class="flex flex-col gap-4">
<Label for="background-color-input" class="multimodal">
<div>
Background Color
<span class="font-normal text-muted-foreground"> = {backgroundColor} </span>
</div>
<Infotip>The color used for transparent pixels.</Infotip>
</Label>
<Slider
type="single"
value={backgroundColor}
onValueCommit={v => {
backgroundColor = v
}}
min={0}
max={255}
step={1}
id="background-color-input"
/>
</div>
+98
View File
@@ -0,0 +1,98 @@
<script lang="ts">
import IconEditOff from '~icons/material-symbols/edit-off'
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
import type { ScaleMode } from '$lib/image/scaler'
import { Transform } from '$lib/image/transform'
import { Button } from '$lib/components/ui/button'
import Separator from '$lib/components/ui/separator/separator.svelte'
import AspectRatioAlert from './dimensions/AspectRatioAlert.svelte'
import ScaleModeToggleGroup from './dimensions/ScaleModeToggleGroup.svelte'
import TransformControls from './dimensions/TransformControls.svelte'
import BackgroundColorSlider from './BackgroundColorSlider.svelte'
import DitherControls from './conversion/dither/DitherControls.svelte'
import ContrastSlider from './conversion/ContrastSlider.svelte'
import BiasSlider from './conversion/BiasSlider.svelte'
interface Props extends HTMLAttributes<HTMLElement> {
imageBitmap: ImageBitmap | null
scaleMode: ScaleMode
transform: Transform
backgroundColor: number
ditheringKernel: DitheringKernel | null
contrast: number
bias: number
}
let {
imageBitmap,
scaleMode = $bindable(),
transform = $bindable(),
backgroundColor = $bindable(),
ditheringKernel = $bindable(),
contrast = $bindable(),
bias = $bindable(),
class: className,
...restProps
}: Props = $props()
const transformDisabled = $derived(imageBitmap === null)
function imageNonSquare() {
if (imageBitmap === null) return false
return imageBitmap.height !== imageBitmap.width
}
function restoreDefaultImageSettings() {
scaleMode = 'fit'
transform = new Transform()
backgroundColor = 255
ditheringKernel = DEFAULT_DITHERING_KERNEL
contrast = 0
bias = 0
}
</script>
<section class={cn('flex flex-col gap-4', className)} {...restProps}>
<h1 class="font-semibold text-xl/6">Edit image</h1>
{#if imageNonSquare()}
<AspectRatioAlert />
{/if}
<div class="flex gap-4">
{#if imageNonSquare()}
<ScaleModeToggleGroup bind:scaleMode />
{/if}
<TransformControls disabled={transformDisabled} bind:transform />
</div>
<Separator />
<BackgroundColorSlider bind:backgroundColor />
<Separator />
<DitherControls bind:ditheringKernel />
{#if ditheringKernel !== null}
<ContrastSlider bind:contrast />
{/if}
{#if ditheringKernel === null || contrast !== 0}
<BiasSlider bind:bias />
{/if}
<Separator />
<Button variant="secondary" class="w-full justify-start" onclick={restoreDefaultImageSettings}>
<IconEditOff />
Reset All
</Button>
</section>
@@ -0,0 +1,36 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider'
import Infotip from '$lib/Infotip.svelte'
interface Props {
bias: number
}
let { bias = $bindable() }: Props = $props()
</script>
<div class="flex flex-col gap-4">
<Label for="bias-input" class="multimodal">
<div>
Bias
<span class="font-normal text-muted-foreground"> = {Math.floor(bias * 100)}% </span>
</div>
<Infotip>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</Infotip>
</Label>
<Slider
type="single"
value={bias}
onValueCommit={v => {
bias = v
}}
min={-1}
max={1}
step={0.01}
id="bias-input"
/>
</div>
@@ -0,0 +1,36 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider'
import Infotip from '$lib/Infotip.svelte'
interface Props {
contrast: number
}
let { contrast = $bindable() }: Props = $props()
</script>
<div class="flex flex-col gap-4">
<Label for="contrast-input" class="multimodal">
<div>
Contrast
<span class="font-normal text-muted-foreground"> = {Math.floor(contrast * 100)}% </span>
</div>
<Infotip>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</Infotip>
</Label>
<Slider
type="single"
value={contrast}
onValueCommit={v => {
contrast = v
}}
min={0}
max={1}
step={0.01}
id="contrast-input"
/>
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
import DitheringKernelDropdown from './DitheringKernelDropdown.svelte'
import DitherSwitch from './DitherSwitch.svelte'
interface Props {
ditheringKernel: DitheringKernel | null
}
let { ditheringKernel = $bindable() }: Props = $props()
let lastDitheringKernel: DitheringKernel = $state(ditheringKernel ?? DEFAULT_DITHERING_KERNEL)
</script>
<div class="flex gap-4">
<DitherSwitch
checked={ditheringKernel !== null}
onCheckedChange={c => {
if (c) {
ditheringKernel = lastDitheringKernel
} else {
ditheringKernel = null
}
}}
/>
<DitheringKernelDropdown
class={ditheringKernel !== null ? [] : ['invisible']}
value={ditheringKernel ?? DEFAULT_DITHERING_KERNEL}
onchange={v => {
ditheringKernel = lastDitheringKernel = v
}}
/>
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import Infotip from '$lib/Infotip.svelte'
interface Props {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
let { checked, onCheckedChange }: Props = $props()
</script>
<div class="flex flex-col gap-2">
<div>
<Label for="dither-switch" class="multimodal">
<div>Dither</div>
<Infotip>Dithering uses different dot densities to simulate shades of gray.</Infotip>
</Label>
</div>
<div class="grow flex items-center">
<Switch id="dither-switch" {checked} {onCheckedChange} />
</div>
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { ditheringKernels, type DitheringKernel } from '$lib/image/quantizer'
import { Label } from '$lib/components/ui/label'
import * as Select from '$lib/components/ui/select'
import Infotip from '$lib/Infotip.svelte'
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onchange'> {
value: DitheringKernel
onchange: (v: DitheringKernel) => void
}
let { value, onchange, class: classNames, ...restProps }: Props = $props()
</script>
<div class={cn('grow flex flex-col gap-2', classNames)} {...restProps}>
<Label class="multimodal">
<div>Dithering Kernel</div>
<Infotip>
Algorithm used for dithering. <br />
Switch around to see which one works best for your image.
</Infotip>
</Label>
<Select.Root type="single" {value} onValueChange={v => onchange(v as DitheringKernel)}>
<Select.Trigger>{ditheringKernels[value]}</Select.Trigger>
<Select.Content>
{#each Object.entries(ditheringKernels) as [kernel, name]}
<Select.Item value={kernel}>{name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
@@ -0,0 +1,10 @@
<script lang="ts">
import * as Alert from '$lib/components/ui/alert'
import IconAspectRatio from '~icons/material-symbols/aspect-ratio-outline'
</script>
<Alert.Root>
<IconAspectRatio />
<Alert.Title>Image is not 1:1 ratio</Alert.Title>
<Alert.Description>You need to choose how to scale your image.</Alert.Description>
</Alert.Root>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { ScaleMode } from '$lib/image/scaler'
import { Label } from '$lib/components/ui/label'
import * as ToggleGroup from '$lib/components/ui/toggle-group'
import * as Tooltip from '$lib/components/ui/tooltip'
interface Props {
scaleMode: ScaleMode
}
let { scaleMode = $bindable() }: Props = $props()
</script>
<div class="flex flex-col gap-2">
<Label for="scale-mode-input">Scaling method</Label>
<Tooltip.Provider delayDuration={0} disableHoverableContent>
<ToggleGroup.Root
id="scale-mode-input"
type="single"
bind:value={
() => scaleMode,
v => {
if (v.length !== 0) scaleMode = v as ScaleMode
}
}
>
<Tooltip.Root>
<Tooltip.Trigger>
<ToggleGroup.Item value="fit">Fit</ToggleGroup.Item>
</Tooltip.Trigger>
<Tooltip.Content>
Fit the entire image onto the display. <br />
May introduce letterboxing.
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<ToggleGroup.Item value="crop">Crop</ToggleGroup.Item>
</Tooltip.Trigger>
<Tooltip.Content>Fill the display and crop out-of-frame parts of the image.</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<ToggleGroup.Item value="distort">Distort</ToggleGroup.Item>
</Tooltip.Trigger>
<Tooltip.Content>
Stretch the image to fill the display. <br />
Distorts the aspect ratio.
</Tooltip.Content>
</Tooltip.Root>
</ToggleGroup.Root>
</Tooltip.Provider>
</div>
@@ -0,0 +1,95 @@
<script lang="ts">
import IconRotate90DegreesCw from '~icons/material-symbols/rotate-90-degrees-cw'
import IconRotate90DegreesCcw from '~icons/material-symbols/rotate-90-degrees-ccw'
import IconFlipHorizontal from '~icons/mdi/flip-horizontal'
import IconFlipVertical from '~icons/mdi/flip-Vertical'
import { Transform } from '$lib/image/transform'
import { Button } from '$lib/components/ui/button'
import { Label } from '$lib/components/ui/label'
import * as Tooltip from '$lib/components/ui/tooltip'
interface Props {
transform: Transform
disabled?: boolean
}
let { transform = $bindable(), disabled = false }: Props = $props()
</script>
<div class="flex flex-col gap-2">
<Label for="transform-controls">Transform</Label>
<div id="transform-controls">
<Tooltip.Provider delayDuration={0} disableHoverableContent>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
transform = transform.cw()
}}
>
<IconRotate90DegreesCw />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Rotate 90&deg; clockwise</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
transform = transform.ccw()
}}
>
<IconRotate90DegreesCcw />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Rotate 90&deg; counter-clockwise</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
transform = transform.h()
}}
>
<IconFlipHorizontal />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Flip horizontally</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
transform = transform.v()
}}
>
<IconFlipVertical />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Flip vertically</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input'
import { toast } from 'svelte-sonner'
interface Props {
onchange: (imageBitmap: ImageBitmap | null) => void
}
const { onchange }: Props = $props()
let fileList: FileList | undefined = $state()
async function updateImageBitmap() {
if (fileList === undefined || fileList.length < 1) {
onchange(null)
return
}
const imageFile = fileList[0]
try {
onchange(await createImageBitmap(imageFile))
} catch (e) {
toast.error(`Error loading image file: ${e}`)
onchange(null)
}
}
$effect(() => {
updateImageBitmap()
})
</script>
<Input type="file" id="image-file" accept="image/*" bind:files={fileList} />
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { withCtx } from '$lib/image/transform'
interface Props {
bitmap: number[] | null
}
const { bitmap }: Props = $props()
let canvas2xEl: HTMLCanvasElement
let canvas1xEl: HTMLCanvasElement
function freshContext(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')!
ctx.clearRect(0, 0, 200, 200)
return ctx
}
function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
withCtx(
ctx,
() => {
ctx.imageSmoothingEnabled = false
},
() => {
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const color = data.at(y * 200 + x)
ctx.fillStyle = color === 0 ? '#ccc' : '#111'
ctx.fillRect(x, y, 1, 1)
}
}
},
)
}
$effect(() => {
if (bitmap === null) return
drawQuantizedData(freshContext(canvas2xEl), bitmap)
drawQuantizedData(freshContext(canvas1xEl), bitmap)
})
</script>
<div class="flex gap-4 max-md:flex-col">
<div>
<div id="canvas-2x" class="bg-[#ccc] shadow-md rounded-2xl p-4 w-fit">
<canvas
class="border-[1px] border-[#888] w-[402px] h-[402px]"
style="image-rendering: pixelated"
bind:this={canvas2xEl}
height={200}
width={200}
></canvas>
</div>
<Label for="canvas-2x">2x Preview</Label>
</div>
<div>
<div id="preview-1x" class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit">
<canvas
class="border-[1px] border-[#888] w-[202px] h-[202px]"
style="image-rendering: pixelated"
bind:this={canvas1xEl}
height={200}
width={200}
></canvas>
</div>
<Label for="canvas-1x">1x Preview</Label>
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import PreviewCanvases from './PreviewCanvases.svelte'
import FileSelect from './FileSelect.svelte'
interface Props {
bitmap: number[] | null
onchange: (imageBitmap: ImageBitmap | null) => void
}
const { bitmap, onchange }: Props = $props()
</script>
<section class="flex flex-col gap-4">
<h1 class="font-semibold text-xl/6">Choose an image</h1>
<FileSelect {onchange} />
<PreviewCanvases {bitmap} />
</section>
-272
View File
@@ -1,272 +0,0 @@
<script lang="ts">
import { Transform, withCtx, withTransform } from './image/transform'
import { Quantizer, type DitheringKernel } from './image/quantizer'
import { Scaler, type ScaleMode } from './image/scaler'
interface Props {
onchange: (bitmap: number[] | null) => void
}
const scaler = new Scaler(200, 200)
const { onchange }: Props = $props()
let scaledCanvasEl: HTMLCanvasElement
let unscaledCanvasEl: HTMLCanvasElement
let fileList: FileList | null = $state(null)
let imageBitmap: ImageBitmap | null = $state(null)
let backgroundColor: number = $state(255)
let imageState: Transform = $state(new Transform())
let scalingMethod: ScaleMode = $state('fit')
let dither = $state(true)
let ditheringKernel: DitheringKernel = $state('FloydSteinberg')
let saturation = $state(0)
let bias = $state(0)
const quantizer = $derived(
new Quantizer({
ditheringKernel: dither ? ditheringKernel : null,
saturation,
bias,
}),
)
function imageNonSquare() {
if (imageBitmap === null) return false
return imageBitmap.height !== imageBitmap.width
}
function restoreDefaultImageSettings() {
backgroundColor = 255
imageState = new Transform()
scalingMethod = 'fit'
dither = true
ditheringKernel = 'FloydSteinberg'
saturation = 0
bias = 0
}
function freshContext() {
const ctx = scaledCanvasEl.getContext('2d', {
willReadFrequently: true,
})!
ctx.clearRect(0, 0, 200, 200)
return ctx
}
function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
withCtx(
ctx,
() => {
ctx.imageSmoothingEnabled = false
},
() => {
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const color = data.at(y * 200 + x)
ctx.fillStyle = color === 0 ? '#fff' : '#000'
ctx.fillRect(x, y, 1, 1)
}
}
},
)
}
async function updateImageBitmap() {
if (fileList === null || fileList.length < 1) {
imageBitmap = null
return
}
const imageFile = fileList[0]
try {
imageBitmap = await createImageBitmap(imageFile)
} catch (e) {
console.error(e)
imageBitmap = null
}
imageState = new Transform()
}
async function drawToCanvas() {
const ctx = freshContext()
if (imageBitmap === null) {
onchange(null)
return
}
withTransform(ctx, imageState, () => {
if (imageBitmap === null) return
ctx.fillStyle = `rgb(${backgroundColor} ${backgroundColor} ${backgroundColor})`
ctx.fillRect(0, 0, 200, 200)
const { dx, dy, dWidth, dHeight } = scaler.scale(imageBitmap, scalingMethod)
ctx.drawImage(imageBitmap, dx, dy, dWidth, dHeight)
})
const quantizedData = quantizer.reduce(ctx)
drawQuantizedData(ctx, quantizedData)
drawQuantizedData(unscaledCanvasEl.getContext('2d')!, quantizedData)
onchange(quantizedData)
}
$effect(() => {
updateImageBitmap()
})
$effect(() => {
drawToCanvas()
})
</script>
<div class="canvas-container">
<div>
<input type="file" id="image-file" name="image_file" accept="image/*" bind:files={fileList} />
<canvas class="scaled" bind:this={scaledCanvasEl} height={200} width={200}></canvas>
<canvas bind:this={unscaledCanvasEl} height={200} width={200}></canvas>
</div>
<div>
{#if imageNonSquare()}
<div>
<h2>Scaling</h2>
You need to choose how to scale your image since it is not square.
<br />
<select name="scaling_method" id="scaling-method-select" bind:value={scalingMethod}>
<option value="fit">Fit</option>
<option value="crop">Crop</option>
<option value="distort">Distort</option>
</select>
</div>
{/if}
<div class="transforms">
<h2>Transform</h2>
<button
onclick={() => {
imageState = imageState.cw()
}}
name="Rotate clockwise"
>
</button>
<button
onclick={() => {
imageState = imageState.ccw()
}}
name="Rotate counter-clockwise"
>
</button>
<button
onclick={() => {
imageState = imageState.h()
}}
name="Flip horizontally"
>
</button>
<button
onclick={() => {
imageState = imageState.v()
}}
name="Flip vertically"
>
</button>
</div>
<div>
<h2>Rendering</h2>
Background Color:<input
type="range"
name="background_color"
id="background-color-input"
bind:value={backgroundColor}
min={0}
max={255}
step={1}
/>
{backgroundColor}
<br />
<input type="checkbox" name="dither" id="dither-checkbox" bind:checked={dither} /> Dither
{#if dither}
<br />
Dithering Kernel:
<select name="dithering_kernel" id="dithering-kernel-select" bind:value={ditheringKernel}>
<option value="FloydSteinberg">Floyd-Steinberg</option>
<option value="FalseFloydSteinberg">False Floyd-Steinberg</option>
<option value="Stucki">Stucki</option>
<option value="Atkinson">Atkinson</option>
<option value="Jarvis">Jarvis</option>
<option value="Burkes">Burkes</option>
<option value="Sierra">Sierra</option>
<option value="TwoSierra">2-Sierra</option>
<option value="SierraLite">Sierra Lite</option>
</select>
<br />
Saturation:
<input
type="range"
name="saturation"
id="saturation-input"
bind:value={saturation}
min={0}
max={1}
step={0.01}
/>
{Math.floor(saturation * 100)}%
{/if}
{#if !dither || saturation !== 0}
<br />
Bias:
<input type="range" name="bias" id="bias-input" bind:value={bias} min={-1} max={1} step={0.01} />
{Math.floor(bias * 100)}%
{/if}
<br />
(white = {Math.floor(quantizer.white)}, black = {Math.floor(quantizer.black)})
</div>
<div>
<h2>Reset</h2>
<button onclick={restoreDefaultImageSettings}>Reset All</button>
</div>
</div>
</div>
<style>
canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.scaled {
width: 400px;
height: 400px;
}
.canvas-container {
display: flex;
gap: 20px;
}
.transforms button {
font-family: sans-serif;
font-size: 32px;
}
h2 {
font-size: 1.3em;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import IconInfo from '~icons/material-symbols/info'
import * as Popover from '$lib/components/ui/popover'
</script>
<Popover.Root>
<Popover.Trigger>
<IconInfo class="text-muted-foreground text-sm" />
</Popover.Trigger>
<Popover.Content class="text-sm">
<slot />
</Popover.Content>
</Popover.Root>
@@ -0,0 +1,13 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.ActionProps = $props();
</script>
<AlertDialogPrimitive.Action bind:ref class={cn(buttonVariants(), className)} {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.CancelProps = $props();
</script>
<AlertDialogPrimitive.Cancel
bind:ref
class={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...restProps}
/>
@@ -0,0 +1,26 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive, type WithoutChild } from "bits-ui";
import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
portalProps?: AlertDialogPrimitive.PortalProps;
} = $props();
</script>
<AlertDialogPrimitive.Portal {...portalProps}>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
bind:ref
class={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...restProps}
/>
</AlertDialogPrimitive.Portal>
@@ -0,0 +1,16 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.DescriptionProps = $props();
</script>
<AlertDialogPrimitive.Description
bind:ref
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AlertDialogPrimitive.OverlayProps = $props();
</script>
<AlertDialogPrimitive.Overlay
bind:ref
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...restProps}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
...restProps
}: AlertDialogPrimitive.TitleProps = $props();
</script>
<AlertDialogPrimitive.Title
bind:ref
class={cn("text-lg font-semibold", className)}
{level}
{...restProps}
/>
@@ -0,0 +1,39 @@
import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
import Title from "./alert-dialog-title.svelte";
import Action from "./alert-dialog-action.svelte";
import Cancel from "./alert-dialog-cancel.svelte";
import Footer from "./alert-dialog-footer.svelte";
import Header from "./alert-dialog-header.svelte";
import Overlay from "./alert-dialog-overlay.svelte";
import Content from "./alert-dialog-content.svelte";
import Description from "./alert-dialog-description.svelte";
const Root = AlertDialogPrimitive.Root;
const Trigger = AlertDialogPrimitive.Trigger;
const Portal = AlertDialogPrimitive.Portal;
export {
Root,
Title,
Action,
Cancel,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
//
Root as AlertDialog,
Title as AlertDialogTitle,
Action as AlertDialogAction,
Cancel as AlertDialogCancel,
Portal as AlertDialogPortal,
Footer as AlertDialogFooter,
Header as AlertDialogHeader,
Trigger as AlertDialogTrigger,
Overlay as AlertDialogOverlay,
Content as AlertDialogContent,
Description as AlertDialogDescription,
};
@@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn("text-sm [&_p]:leading-relaxed", className)} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 5,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
level?: 1 | 2 | 3 | 4 | 5 | 6;
} = $props();
</script>
<div
role="heading"
aria-level={level}
bind:this={ref}
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div bind:this={ref} class={cn(alertVariants({ variant }), className)} {...restProps} role="alert">
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};
@@ -0,0 +1,74 @@
<script lang="ts" module>
import type { WithElementRef } from "bits-ui";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
import { cn } from "$lib/utils.js";
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{href}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,35 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import Check from "@lucide/svelte/icons/check";
import Minus from "@lucide/svelte/icons/minus";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
class={cn(
"border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer box-content size-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div class="flex size-4 items-center justify-center text-current">
{#if indeterminate}
<Minus class="size-3.5" />
{:else}
<Check class={cn("size-3.5", !checked && "text-transparent")} />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
+6
View File
@@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
let { ref = $bindable(null), class: className, ...restProps }: LabelPrimitive.RootProps = $props()
</script>
<LabelPrimitive.Root
bind:ref
class={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...restProps}
/>
+17
View File
@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};
@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
+34
View File
@@ -0,0 +1,34 @@
import { Select as SelectPrimitive } from "bits-ui";
import GroupHeading from "./select-group-heading.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group;
export {
Root,
Group,
GroupHeading,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
//
Root as Select,
Group as SelectGroup,
GroupHeading as SelectGroupHeading,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
};
@@ -0,0 +1,39 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-[var(--bits-select-anchor-height)] w-full min-w-[var(--bits-select-anchor-width)] p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupHeadingProps = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...restProps}
/>
@@ -0,0 +1,37 @@
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if selected}
<Check class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,19 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDown class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,19 @@
<script lang="ts">
import ChevronUp from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUp class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator bind:ref class={cn("bg-muted -mx-1 my-1 h-px", className)} {...restProps} />
@@ -0,0 +1,24 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
class={cn(
"border-input bg-background ring-offset-background data-[placeholder]:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDown class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};
@@ -0,0 +1,22 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
orientation = "horizontal",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
class={cn(
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]",
className
)}
{orientation}
{...restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./slider.svelte";
export {
Root,
//
Root as Slider,
};
@@ -0,0 +1,44 @@
<script lang="ts">
import { Slider as SliderPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
orientation = "horizontal",
class: className,
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<SliderPrimitive.Root
bind:ref
bind:value={value as never}
{orientation}
class={cn(
"relative flex touch-none select-none items-center data-[orientation='vertical']:h-full data-[orientation='vertical']:min-h-44 data-[orientation='horizontal']:w-full data-[orientation='vertical']:w-auto data-[orientation='vertical']:flex-col",
className
)}
{...restProps}
>
{#snippet children({ thumbs })}
<span
data-orientation={orientation}
class="bg-secondary relative grow overflow-hidden rounded-full data-[orientation='horizontal']:h-2 data-[orientation='vertical']:h-full data-[orientation='horizontal']:w-full data-[orientation='vertical']:w-2"
>
<SliderPrimitive.Range
class="bg-primary absolute data-[orientation='horizontal']:h-full data-[orientation='vertical']:w-full"
/>
</span>
{#each thumbs as thumb (thumb)}
<SliderPrimitive.Thumb
index={thumb}
class="border-primary bg-background ring-offset-background focus-visible:ring-ring block size-5 rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>
+1
View File
@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";
@@ -0,0 +1,20 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};
@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
"bg-background pointer-events-none block size-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
@@ -0,0 +1,10 @@
import Root from "./toggle-group.svelte";
import Item from "./toggle-group-item.svelte";
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem,
};
@@ -0,0 +1,30 @@
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { getToggleGroupCtx } from "./toggle-group.svelte";
import { cn } from "$lib/utils.js";
import { type ToggleVariants, toggleVariants } from "$lib/components/ui/toggle/index.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size,
variant,
...restProps
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
class={cn(
toggleVariants({
variant: ctx.variant || variant,
size: ctx.size || size,
}),
className
)}
{value}
{...restProps}
/>
@@ -0,0 +1,41 @@
<script lang="ts" module>
import { getContext, setContext } from "svelte";
import type { ToggleVariants } from "$lib/components/ui/toggle/index.js";
export function setToggleGroupCtx(props: ToggleVariants) {
setContext("toggleGroup", props);
}
export function getToggleGroupCtx() {
return getContext<ToggleVariants>("toggleGroup");
}
</script>
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = "default",
variant = "default",
...restProps
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
variant,
size,
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:value={value as never}
bind:ref
class={cn("flex items-center justify-center gap-1", className)}
{...restProps}
/>
+13
View File
@@ -0,0 +1,13 @@
import Root from "./toggle.svelte";
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants,
} from "./toggle.svelte";
export {
Root,
//
Root as Toggle,
};
@@ -0,0 +1,51 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const toggleVariants = tv({
base: "ring-offset-background hover:bg-muted hover:text-muted-foreground focus-visible:ring-ring data-[state=on]:bg-accent data-[state=on]:text-accent-foreground inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-transparent",
outline:
"border-input hover:bg-accent hover:text-accent-foreground border bg-transparent",
},
size: {
default: "h-10 min-w-10 px-3",
sm: "h-9 min-w-9 px-2.5",
lg: "h-11 min-w-11 px-5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ToggleVariant = VariantProps<typeof toggleVariants>["variant"];
export type ToggleSize = VariantProps<typeof toggleVariants>["size"];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { Toggle as TogglePrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
pressed = $bindable(false),
class: className,
size = "default",
variant = "default",
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:ref
bind:pressed
class={cn(toggleVariants({ variant, size }), className)}
{...restProps}
/>
+18
View File
@@ -0,0 +1,18 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Content from "./tooltip-content.svelte";
const Root = TooltipPrimitive.Root;
const Trigger = TooltipPrimitive.Trigger;
const Provider = TooltipPrimitive.Provider;
export {
Root,
Trigger,
Content,
Provider,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
};
@@ -0,0 +1,21 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
...restProps
}: TooltipPrimitive.ContentProps = $props();
</script>
<TooltipPrimitive.Content
bind:ref
{sideOffset}
class={cn(
"bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
className
)}
{...restProps}
/>
+17 -3
View File
@@ -3,9 +3,23 @@ import type { DitheringKernel, RgbQuantImage } from 'rgbquant'
export type { DitheringKernel, RgbQuantImage } from 'rgbquant'
export const DEFAULT_DITHERING_KERNEL: DitheringKernel = 'FloydSteinberg'
export const ditheringKernels: Record<DitheringKernel, string> = {
FloydSteinberg: 'Floyd-Steinberg',
FalseFloydSteinberg: 'False Floyd-Steinberg',
Stucki: 'Stucki',
Atkinson: 'Atkinson',
Jarvis: 'Jarvis',
Burkes: 'Burkes',
Sierra: 'Sierra',
TwoSierra: '2-Row Sierra',
SierraLite: 'Sierra Lite',
}
export type QuantizerOptions = {
ditheringKernel: DitheringKernel | null
saturation: number
contrast: number
bias: number
}
@@ -15,8 +29,8 @@ export class Quantizer {
private readonly rgbquant: RgbQuant
constructor({ ditheringKernel, saturation, bias }: QuantizerOptions) {
const saturationAdjustment = ditheringKernel === null ? 127 : 127 * saturation
constructor({ ditheringKernel, contrast, bias }: QuantizerOptions) {
const saturationAdjustment = ditheringKernel === null ? 127 : 127 * contrast
this.white = 255 + (bias - 1) * saturationAdjustment
this.black = (1 + bias) * saturationAdjustment
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6 -6
View File
@@ -1,9 +1,9 @@
import { mount } from "svelte";
import "./app.css";
import App from "./App.svelte";
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById("app")!,
});
target: document.getElementById('app')!,
})
export default app;
export default app
+1
View File
@@ -1,2 +1,3 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
/// <reference types="unplugin-icons/types/svelte" />
@@ -1,4 +1,9 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { toast } from 'svelte-sonner'
import IconUpload from '~icons/material-symbols/upload'
interface Props {
device: HIDDevice | null
data: number[] | null
@@ -8,6 +13,7 @@
const { device, data, onprogress }: Props = $props()
let inProgress = $state(false)
let disabled = $derived(device === null || data === null || inProgress)
let secondary = $derived(device === null)
async function connectAndWrite() {
if (device === null || data === null) return
@@ -16,8 +22,7 @@
try {
await device.open()
} catch (e) {
if (!(e instanceof Error)) throw e
alert('Unable to open device: ' + e.toString())
toast.error(`Unable to open device: ${e}`)
}
}
@@ -34,11 +39,19 @@
}
inProgress = true
setTimeout(() => {
inProgress = false
}, 2000)
await device.sendReport(0, buffer)
try {
await device.sendReport(0, buffer)
} catch (e) {
toast.error(`Error writing to device: ${e}`)
return
} finally {
setTimeout(() => {
inProgress = false
}, 2000)
}
toast.success('Wrote pattern to device')
}
$effect(() => {
@@ -46,4 +59,6 @@
})
</script>
<button onclick={connectAndWrite} {disabled}>Write pattern to device</button>
<Button onclick={connectAndWrite} variant={secondary ? 'secondary' : 'default'} {disabled}>
<IconUpload /> Write pattern to device
</Button>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts">
import IconPending from '~icons/material-symbols/pending'
import IconWarning from '~icons/material-symbols/warning'
import IconArrowUploadProgress from '~icons/material-symbols/arrow-upload-progress'
import WriteButton from './WriteButton.svelte'
interface Props {
device: HIDDevice | null
bitmap: number[] | null
}
const { device, bitmap }: Props = $props()
let inProgress = $state(false)
</script>
<section class="flex items-center gap-2 max-lg:flex-col max-lg:items-stretch">
<div class="grow">
<h1 class="font-semibold text-xl/8">Write pattern to device</h1>
{#if device === null}
<IconPending class="inline" /> Connect your device to start writing patterns onto it.
{:else if bitmap === null}
<IconPending class="inline" /> Select an image file in order to write it onto your device.
{:else if !inProgress}
<IconArrowUploadProgress class="inline" /> Write the pattern onto your device if you have finished editing the image.
{:else}
<IconWarning class="inline" /> Update in progress. Do not disconnect device.
{/if}
</div>
<WriteButton
{device}
data={bitmap}
onprogress={v => {
inProgress = v
}}
/>
</section>
+96
View File
@@ -0,0 +1,96 @@
import { fontFamily } from 'tailwindcss/defaultTheme'
import type { Config } from 'tailwindcss'
import tailwindcssAnimate from 'tailwindcss-animate'
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ['dark'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',
ring: 'hsl(var(--ring) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)',
},
destructive: {
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)',
},
accent: {
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)',
},
popover: {
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)',
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)',
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
xl: 'calc(var(--radius) + 4px)',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
fontFamily: {
sans: [...fontFamily.sans],
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--bits-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--bits-accordion-content-height)' },
to: { height: '0' },
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite',
},
},
},
plugins: [tailwindcssAnimate],
}
export default config
+8 -1
View File
@@ -15,6 +15,13 @@
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"composite": true,
"baseUrl": ".",
"paths": {
"$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
}
+8 -1
View File
@@ -3,5 +3,12 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"]
}
}
}
+3 -2
View File
@@ -11,7 +11,8 @@
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"emitDeclarationOnly": true,
"composite": true,
/* Linting */
"strict": true,
@@ -20,5 +21,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts"],
}
+14 -1
View File
@@ -1,7 +1,20 @@
/// <reference types="node" />
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import { resolve } from 'path'
import icons from 'unplugin-icons/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
plugins: [
svelte(),
icons({
compiler: 'svelte',
}),
],
resolve: {
alias: {
$lib: resolve('./src/lib'),
},
},
})