Rewrite using shadcn-svelte and tailwindcss
This commit is contained in:
@@ -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"
|
||||
}
|
||||
Generated
+3428
-5
File diff suppressed because it is too large
Load Diff
+22
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
+17
-122
@@ -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>
|
||||
|
||||
@@ -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 © <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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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° 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° 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>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
@@ -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}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
+1
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -3,5 +3,12 @@
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$lib": ["./src/lib"],
|
||||
"$lib/*": ["./src/lib/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
@@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user