Move the webapp into a subdirectory

This commit is contained in:
daylily
2025-04-07 19:43:09 -04:00
parent 267c8fcad8
commit 5d0e9b6ce7
103 changed files with 0 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": false,
"arrowParens": "avoid",
"singleQuote": true,
"printWidth": 120
}
+13
View File
@@ -0,0 +1,13 @@
# Write to Inkclip
![A screenshot of the Write to Inkclip web app.](docs/screenshot.png)
Write to Inkclip is a web application for writing patterns onto the Inkclip device. It handles the conversion from any raster image format supported by the user's web browser into a format that is understood by the Inkclip firmware, and then sends this data to the connected Inkclip via WebHID.
This application also supports some primitive image editing functions, such as scaling and cropping, rotation and mirroring, the adjustment of contrast and brightness, and a range of dithering algorithms available to use for converting images to black-and-white.
## Technical matters
Write to Inkclip depends on the [WebHID API](https://developer.mozilla.org/en-US/docs/Web/API/WebHID_API) to send data to devices. As a consequence, it is only supported on Chromium-based browsers, which are the only browsers that support WebHID at this time.
Write to Inkclip is a fully client-side application. It is built with [Svelte](https://svelte.dev), [shadcn-svelte](https://next.shadcn-svelte.com), [Vite](https://vite.dev), and [Tailwind CSS](https://tailwindcss.com). An instance of Write to Inkclip is hosted at https://inkclip.dayli.ly.
+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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="author" content="daylily" />
<meta name="generator" content="Vite & Svelte 5" />
<meta name="description" content="Utility for writing images onto the “Inkclip” e-paper accessory." />
<meta property="og:title" content="Write to Inkclip" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://inkclip.dayli.ly/" />
<meta property="og:description" content="Utility for writing images onto the “Inkclip” e-paper accessory." />
<meta property="og:site_name" content="dayli.ly" />
<title>Write to Inkclip</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3358
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "inkclip-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@fontsource-variable/ibm-plex-sans": "^5.2.5",
"@iconify/json": "^2.2.323",
"@lucide/svelte": "^0.487.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"@types/node": "^22.14.0",
"@types/w3c-web-hid": "^1.0.6",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.3.16",
"clsx": "^2.1.1",
"mode-watcher": "^0.5.1",
"svelte": "^5.25.7",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^3.1.0",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3",
"unplugin-icons": "^22.1.0",
"vite": "^6.2.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+4
View File
@@ -0,0 +1,4 @@
<svg version="1.1" width="256" height="256" xmlns="http://www.w3.org/2000/svg">
<polygon points="0,0 256,0 0,256" fill="#afbabc" />
<polygon points="256,256 256,0 0,256" fill="#182435" />
</svg>

After

Width:  |  Height:  |  Size: 199 B

+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Disallow: /cdn-cgi/
Disallow: /assets/
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { ModeWatcher } from 'mode-watcher'
import { Toaster } from 'svelte-sonner'
import Footer from '$lib/layouts/Footer.svelte'
import Main from '$lib/layouts/Main.svelte'
import Unsupported from '$lib/layouts/Unsupported.svelte'
const unsupported = navigator.hid === undefined
</script>
<ModeWatcher />
<Toaster position="bottom-center" duration={2000} />
<div class="max-w-screen-xl min-h-dvh m-auto p-8 stack gap-4">
{#if unsupported}
<Unsupported />
{:else}
<Main />
{/if}
<Footer />
</div>
+98
View File
@@ -0,0 +1,98 @@
/* 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;
}
@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%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: IBM Plex Sans Variable, IBM Plex Sans, Fira Sans, sans-serif;
}
::selection {
@apply text-muted bg-muted-foreground;
}
a {
@apply underline;
}
}
@layer utilities {
.stack {
@apply flex flex-col;
}
.stack-h {
@apply flex;
}
.row {
@apply flex items-center;
}
.col {
@apply flex flex-col items-center;
}
}
@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, showNumber } from '$lib/utils'
import type { HTMLAttributes } from 'svelte/elements'
interface Props extends Omit<HTMLAttributes<HTMLInputElement>, 'type' | 'inputmode' | 'onchange' | 'value'> {
min: number
max: number
value: number
onchange: (value: number) => void
}
const { min, max, value, onchange, class: classNames, ...restProps }: Props = $props()
const size = $derived(Math.max(String(min).length, String(max).length))
</script>
<input
type="text"
inputmode="numeric"
class={cn(
'bg-muted rounded-md focus-visible:outline-none focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-2 px-1 mx-0.5',
classNames,
)}
{size}
maxlength={size}
style:field-sizing="content"
value={showNumber(value)}
onchange={e => {
let n = Number(e.currentTarget.value)
if (isNaN(n)) {
e.currentTarget.value = showNumber(value)
return
}
if (n < min) n = min
if (n > max) n = max
e.currentTarget.value = showNumber(n)
onchange(n)
}}
{...restProps}
/>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type { PopoverTriggerProps } from 'bits-ui'
import type { Snippet } from 'svelte'
import IconInfo from '~icons/material-symbols/info'
import * as Popover from '$lib/components/ui/popover'
interface Props extends PopoverTriggerProps {
icon?: Snippet
}
const { icon, children, ...restProps }: Props = $props()
</script>
<Popover.Root>
<Popover.Trigger aria-label="More info" {...restProps}>
{#if !icon}
<IconInfo class="text-muted-foreground text-sm" aria-hidden />
{:else}
{@render icon()}
{/if}
</Popover.Trigger>
<Popover.Content class="text-sm">
{#if children}
{@render children()}
{/if}
</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,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
@@ -0,0 +1,45 @@
<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-secondary text-secondary-foreground 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 hover:cursor-pointer file:hover:cursor-pointer hover:bg-secondary/80 transition-colors',
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,46 @@
<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,
'aria-labelledby': ariaLabelledby,
...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 hover:cursor-pointer"
>
<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 hover:cursor-pointer"
aria-labelledby={ariaLabelledby}
/>
{/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}
/>
+13
View File
@@ -0,0 +1,13 @@
export const DEVICE_VID = 0x1209
export const DEVICE_PID = 0xc9c9
export const DEVICE_WIDTH = 200 // px
export const DEVICE_HEIGHT = 200 // px
export const DEVICE_ASPECT_RATIO = DEVICE_WIDTH / DEVICE_HEIGHT
export const BYTES_IN_A_ROW = DEVICE_WIDTH / 8
export const WRITE_TIME = 2000 // ms
export const WRITE_PATTERN_REPORT_ID = 0x01
export const SERIAL_NUMBER_REPORT_ID = 0x02
+32
View File
@@ -0,0 +1,32 @@
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
import type { ScaleMode } from '$lib/image/scaler'
import { Transform } from '$lib/image/transform'
import { getContext, setContext } from 'svelte'
export interface ConversionConfig {
scaleMode: ScaleMode
transform: Transform
backgroundColor: number
ditheringKernel: DitheringKernel | null
contrast: number
brightness: number
}
const ConversionConfigToken = Symbol('conversion-config')
export function getConversionConfig(): ConversionConfig {
return getContext(ConversionConfigToken)
}
export function createConversionConfig() {
const config = $state<ConversionConfig>({
scaleMode: 'fit',
transform: new Transform(),
backgroundColor: 255,
ditheringKernel: DEFAULT_DITHERING_KERNEL,
contrast: 0,
brightness: 0,
})
return setContext(ConversionConfigToken, config)
}
+62
View File
@@ -0,0 +1,62 @@
import { DEVICE_PID, DEVICE_VID } from '$lib/constants'
import { getContext, onDestroy, setContext } from 'svelte'
import { toast } from 'svelte-sonner'
function isInkclip(dev: HIDDevice) {
return dev.vendorId == DEVICE_VID && dev.productId == DEVICE_PID
}
export interface DeviceContext {
device: HIDDevice | null
}
const DeviceContextToken = Symbol('device')
export function getDeviceContext(): DeviceContext {
return getContext(DeviceContextToken)
}
export function createDeviceContext(): DeviceContext {
const ctx: DeviceContext = $state({ device: null })
navigator.hid.getDevices().then(devices => {
const dev = devices.find(isInkclip)
if (dev !== undefined) ctx.device = dev
})
function connectIfIdle(e: HIDConnectionEvent) {
if (!isInkclip(e.device) || ctx.device !== null) return
toast.info('Device connected')
ctx.device = e.device
}
function disconnectIfSame(e: HIDConnectionEvent) {
if (ctx.device !== e.device) return
toast.info('Device disconnected')
ctx.device = null
}
navigator.hid.addEventListener('connect', connectIfIdle)
navigator.hid.addEventListener('disconnect', disconnectIfSame)
onDestroy(() => {
navigator.hid.removeEventListener('connect', connectIfIdle)
navigator.hid.removeEventListener('disconnect', disconnectIfSame)
})
return setContext(DeviceContextToken, ctx)
}
export async function tryOpenDevice(device: HIDDevice): Promise<boolean> {
if (device.opened) return true
try {
await device.open()
return true
} catch (e) {
toast.error(`Unable to open device: ${e}`)
return false
}
}
+19
View File
@@ -0,0 +1,19 @@
import { getContext, setContext } from 'svelte'
export interface FilesContext {
files: FileList
}
export const FilesContextToken = Symbol('files')
export function getFilesContext(): FilesContext {
return getContext(FilesContextToken)
}
export function createFilesContext(): FilesContext {
const ctx: FilesContext = $state({
files: new DataTransfer().files,
})
return setContext(FilesContextToken, ctx)
}
+47
View File
@@ -0,0 +1,47 @@
import { getContext, setContext } from 'svelte'
import type { FilesContext } from './files.svelte'
import { toast } from 'svelte-sonner'
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
export interface ImageContext {
image: ImageBitmap | null
}
export const ImageContextToken = Symbol('image')
export function getImageContext(): Readonly<ImageContext> {
return getContext(ImageContextToken)
}
export function createImageContext(filesCtx: FilesContext): Readonly<ImageContext> {
const ctx: ImageContext = $state({
image: null,
})
async function updateImageBitmap() {
const files = filesCtx.files
if (files === null || files.length < 1) {
ctx.image = null
return
}
try {
ctx.image = await createImageBitmap(files[0])
} catch (e) {
toast.error(`Error loading image file: ${e}`)
ctx.image = null
}
}
$effect(() => {
updateImageBitmap()
})
return setContext(ImageContextToken, ctx)
}
export function imageIsCorrectRatio(image: ImageBitmap | null): boolean {
if (image === null) return true
return image.height * DEVICE_WIDTH === image.width * DEVICE_HEIGHT
}
@@ -0,0 +1,63 @@
import { getContext, setContext } from 'svelte'
import type { ImageContext } from './image.svelte'
import { withTransform } from '$lib/image/transform'
import type { ConversionConfig } from './config.svelte'
import { Scaler } from '$lib/image/scaler'
import { Quantizer } from '$lib/image/quantizer'
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
export interface RenderedContext {
rendered: number[] | null
}
export const RenderedContextToken = Symbol('rendered')
export function getRenderedContext(): Readonly<RenderedContext> {
return getContext(RenderedContextToken)
}
const scaler = new Scaler(DEVICE_WIDTH, DEVICE_HEIGHT)
export function createRenderedContext(
imageCtx: Readonly<ImageContext>,
config: ConversionConfig
): Readonly<RenderedContext> {
const ctx: RenderedContext = $state({
rendered: null,
})
const quantizer = $derived(
new Quantizer({
ditheringKernel: config.ditheringKernel,
contrast: config.contrast,
brightness: config.brightness,
})
)
const canvas = new OffscreenCanvas(DEVICE_WIDTH, DEVICE_HEIGHT)
const canvasCtx = canvas.getContext('2d', {
willReadFrequently: true,
})!
$effect(() => {
const bitmap = imageCtx.image
if (bitmap === null) {
ctx.rendered = null
return
}
withTransform(canvasCtx, config.transform, () => {
const bg = config.backgroundColor
canvasCtx.fillStyle = `rgb(${bg} ${bg} ${bg})`
canvasCtx.fillRect(0, 0, DEVICE_WIDTH, DEVICE_HEIGHT)
const { dx, dy, dWidth, dHeight } = scaler.scale(config.scaleMode, bitmap)
canvasCtx.drawImage(bitmap, dx, dy, dWidth, dHeight)
})
const quantizedData = quantizer.reduce(canvasCtx)
ctx.rendered = quantizedData
})
return setContext(RenderedContextToken, ctx)
}
+39
View File
@@ -0,0 +1,39 @@
import RgbQuant from '$lib/vendor/rgbquant'
import type { DitheringKernel, RgbQuantImage } from '$lib/vendor/rgbquant'
export type { DitheringKernel, RgbQuantImage } from '$lib/vendor/rgbquant'
export const DEFAULT_DITHERING_KERNEL: DitheringKernel = 'Atkinson'
export type QuantizerOptions = {
ditheringKernel: DitheringKernel | null
contrast: number
brightness: number
}
export class Quantizer {
public readonly black: number
public readonly white: number
private readonly rgbquant: RgbQuant
constructor({ ditheringKernel, contrast, brightness }: QuantizerOptions) {
const saturationAdjustment = ditheringKernel === null ? 127 : 127 * contrast
this.white = 255 - (1 + brightness) * saturationAdjustment
this.black = (1 - brightness) * saturationAdjustment
this.rgbquant = new RgbQuant({
colors: 2,
dithKern: ditheringKernel,
palette: [
[this.white, this.white, this.white],
[this.black, this.black, this.black],
],
useCache: false,
})
}
reduce(image: RgbQuantImage): number[] {
return this.rgbquant.reduce(image, 2)
}
}
+79
View File
@@ -0,0 +1,79 @@
export type DrawParameters = {
dx: number
dy: number
dWidth: number
dHeight: number
}
export type ScaleMode = 'fit' | 'crop' | 'distort'
export class Scaler {
private readonly canvasAspectRatio: number
constructor(private readonly canvasWidth: number, private readonly canvasHeight: number) {
this.canvasAspectRatio = canvasWidth / canvasHeight
}
fit(image: ImageBitmap): DrawParameters {
const { width, height } = image
const aspectRatio = width / height
let dx = 0
let dy = 0
let dWidth = this.canvasWidth
let dHeight = this.canvasHeight
if (aspectRatio > this.canvasAspectRatio) {
dHeight = dWidth / aspectRatio
dy = (this.canvasHeight - dHeight) / 2
} else if (aspectRatio < this.canvasAspectRatio) {
dWidth = dHeight * aspectRatio
dx = (this.canvasWidth - dWidth) / 2
}
return {
dx,
dy,
dWidth,
dHeight,
}
}
crop(image: ImageBitmap): DrawParameters {
const { width, height } = image
const aspectRatio = width / height
let dx = 0
let dy = 0
let dWidth = this.canvasWidth
let dHeight = this.canvasHeight
if (aspectRatio > this.canvasAspectRatio) {
dWidth = dHeight * aspectRatio
dx = (this.canvasWidth - dWidth) / 2
} else if (aspectRatio < this.canvasAspectRatio) {
dHeight = dWidth / aspectRatio
dy = (this.canvasHeight - dHeight) / 2
}
return {
dx,
dy,
dWidth,
dHeight,
}
}
distort(): DrawParameters {
return {
dx: 0,
dy: 0,
dWidth: this.canvasWidth,
dHeight: this.canvasHeight,
}
}
scale(mode: ScaleMode, image: ImageBitmap): DrawParameters {
return this[mode](image)
}
}
+72
View File
@@ -0,0 +1,72 @@
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
export type Rotation = 0 | 90 | 180 | 270
export type Side = 'obverse' | 'reverse'
export type Operation = 'cw' | 'ccw' | 'h' | 'v'
/**
* A transform of a square image is represented by first rotating it by a multiple of 90 degrees, and then optionally
* flipping it horizontally.
*
* Algebraically, this is the dihedral group of order 8 (D₄).
*/
export class Transform {
constructor(public readonly side: Side = 'obverse', public readonly rotation: Rotation = 0) {}
cw(): Transform {
const rotationAngle = this.side === 'obverse' ? 90 : 270
const newAngle = (this.rotation + rotationAngle) % 360
return new Transform(this.side, newAngle as Rotation)
}
ccw(): Transform {
const rotationAngle = this.side === 'obverse' ? 270 : 90
const newAngle = (this.rotation + rotationAngle) % 360
return new Transform(this.side, newAngle as Rotation)
}
h(): Transform {
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
return new Transform(newSide, this.rotation)
}
v(): Transform {
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
const newAngle = (this.rotation + 180) % 360
return new Transform(newSide, newAngle as Rotation)
}
op(op: Operation): Transform {
return this[op]()
}
}
export type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T {
ctx.save()
try {
pre()
return action()
} finally {
ctx.restore()
}
}
export function withTransform<T>(ctx: Context2D, transform: Transform, action: () => T): T {
const centerX = DEVICE_WIDTH / 2
const centerY = DEVICE_HEIGHT / 2
return withCtx(
ctx,
() => {
ctx.translate(centerX, centerY)
if (transform.side === 'reverse') {
ctx.scale(-1, 1)
}
ctx.rotate((transform.rotation / 180) * Math.PI)
ctx.translate(-centerX, -centerY)
},
action
)
}
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import IconLightMode from '~icons/material-symbols/light-mode'
import IconDarkMode from '~icons/material-symbols/dark-mode'
import { mode, toggleMode } from 'mode-watcher'
</script>
<footer class="row text-sm text-muted-foreground">
<div class="grow">
{new Date().getFullYear()} &copy;
<a class="no-underline hover:underline" href="https://dayli.ly">daylily</a>
</div>
<Button size="icon" variant="ghost" onclick={toggleMode} aria-label="Toggle color scheme">
{#if $mode === 'dark'}
<IconDarkMode aria-hidden />
{:else}
<IconLightMode aria-hidden />
{/if}
</Button>
</footer>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator'
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
import EditSection from '$lib/layouts/edit/EditSection.svelte'
import WriteSection from '$lib/layouts/write/WriteSection.svelte'
import { createDeviceContext } from '$lib/contexts/device.svelte'
import { createConversionConfig } from '$lib/contexts/config.svelte'
import { createFilesContext } from '$lib/contexts/files.svelte'
import { createImageContext } from '$lib/contexts/image.svelte'
import { createRenderedContext } from '$lib/contexts/rendered.svelte'
createDeviceContext()
const config = createConversionConfig()
const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config)
</script>
<main class="grow w-full stack gap-4">
<h1 class="sr-only">Write to Inkclip</h1>
<ConnectSection />
<Separator decorative />
<EditSection />
<Separator decorative />
<WriteSection />
</main>
+30
View File
@@ -0,0 +1,30 @@
<script lang="ts">
import IconDisabledByDefault from '~icons/material-symbols/disabled-by-default'
import * as AlertDialog from '$lib/components/ui/alert-dialog'
</script>
<main class="grow row justify-center gap-2 font-medium text-xl text-muted-foreground">
<IconDisabledByDefault />
<div>Not Supported</div>
</main>
<AlertDialog.Root open>
<AlertDialog.Portal>
<AlertDialog.Overlay />
<AlertDialog.Content>
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
<AlertDialog.Description class="stack gap-2">
<p>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
<a href="https://www.google.com/chrome/">Google Chrome</a>,
<a href="https://www.microsoft.com/edge">Microsoft Edge</a>,
<a href="https://www.opera.com/">Opera</a>, or
<a href="https://arc.net/">Arc</a>.
</p>
</AlertDialog.Description>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { DEVICE_PID, DEVICE_VID } from '$lib/constants'
import { getDeviceContext } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext()
async function requestDevice() {
const devs = await navigator.hid.requestDevice({
filters: [
{
vendorId: DEVICE_VID,
productId: DEVICE_PID,
},
],
})
if (devs == undefined || devs[0] == undefined) return
deviceCtx.device = devs[0]
}
</script>
<Button variant={deviceCtx.device === null ? 'default' : 'secondary'} onclick={requestDevice}>
{#if deviceCtx.device === null}
Select device
{:else}
Connect to another device
{/if}
</Button>
@@ -0,0 +1,62 @@
<script lang="ts">
import IconPending from '~icons/material-symbols/pending'
import IconCheckCircle from '~icons/material-symbols/check-circle'
import ConnectButton from './ConnectButton.svelte'
import { getDeviceContext, tryOpenDevice } from '$lib/contexts/device.svelte'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import { SERIAL_NUMBER_REPORT_ID } from '$lib/constants'
const deviceCtx = getDeviceContext()
let serial = $state('[Retrieving...]')
async function updateSerial() {
if (deviceCtx.device === null) {
serial = '[Retrieving...]'
return
}
if (!(await tryOpenDevice(deviceCtx.device))) {
serial = '[Error]'
return
}
let updated = false
function setSerial(e: HIDInputReportEvent) {
if (updated || e.reportId !== SERIAL_NUMBER_REPORT_ID) return
serial = String.fromCharCode(...Array.from(new Uint8Array(e.data.buffer)))
updated = true
}
deviceCtx.device.addEventListener('inputreport', setSerial)
await deviceCtx.device.sendReport(SERIAL_NUMBER_REPORT_ID, new Uint8Array(1))
}
$effect(() => {
updateSerial()
})
</script>
<section class="row gap-2 max-lg:stack max-lg:items-stretch" aria-labelledby="connect-section-label">
<div class="grow">
<h2 class="font-semibold text-xl/8" id="connect-section-label">Connect to a device</h2>
<div class="row gap-1 text-sm" aria-live="polite">
{#if deviceCtx.device === null}
<IconPending aria-hidden /> Not connected to any device yet. Plug in your device, and click on the button to select
it.
{:else}
<MoreInfo>
{#snippet icon()}
<IconCheckCircle aria-hidden />
{/snippet}
The serial ID of this device is <code>{serial}</code>.
</MoreInfo>
Successfully conected to device. If you want to, you can connect to another device instead.
{/if}
</div>
</div>
<ConnectButton />
</section>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator'
import PreviewSection from './preview/PreviewSection.svelte'
import ControlsSection from './controls/ControlsSection.svelte'
import { createMediaQuery } from '$lib/utils/media.svelte'
const mobileMediaQuery = createMediaQuery('(max-width: 1024px)')
const separatorOrientation = $derived(mobileMediaQuery.matches ? 'horizontal' : 'vertical')
</script>
<div class="grow stack-h max-lg:stack gap-4">
<PreviewSection />
<Separator decorative orientation={separatorOrientation} />
<ControlsSection />
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import ImplicitNumericInput from '$lib/components/ImplicitNumericInput.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig()
let value = $derived(config.backgroundColor)
</script>
<div class="stack gap-4" role="group" aria-labelledby="background-color-input-label">
<div class="row gap-1 text-sm">
<Label id="background-color-input-label">Background Color</Label>
<span class="font-normal text-muted-foreground">
=<ImplicitNumericInput
min={0}
max={0xff}
{value}
onchange={v => (config.brightness = value = v)}
aria-labelledby="background-color-input-label"
/>
</span>
<MoreInfo>The color used for transparent pixels.</MoreInfo>
</div>
<Slider
type="single"
bind:value
onValueCommit={() => (config.backgroundColor = value)}
min={0}
max={0xff}
step={1}
aria-labelledby="background-color-input-label"
/>
</div>
@@ -0,0 +1,72 @@
<script lang="ts">
import { Transform } from '$lib/image/transform'
import { Button } from '$lib/components/ui/button'
import Separator from '$lib/components/ui/separator/separator.svelte'
import IconEditOff from '~icons/material-symbols/edit-off'
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 BrightnessSlider from './conversion/BrightnessSlider.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext, imageIsCorrectRatio } from '$lib/contexts/image.svelte'
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
const imageCtx = getImageContext()
const config = getConversionConfig()
const transformDisabled = $derived(imageCtx.image === null)
const imageNonSquare = $derived(!imageIsCorrectRatio(imageCtx.image))
function restoreDefaultImageSettings() {
config.scaleMode = 'fit'
config.transform = new Transform()
config.backgroundColor = 0xff
config.ditheringKernel = DEFAULT_DITHERING_KERNEL
config.contrast = 0
config.brightness = 0
}
</script>
<section class="grow stack gap-4" aria-labelledby="controls-section-label">
<h2 class="font-semibold text-xl/6" id="controls-section-label">Edit image</h2>
{#if imageNonSquare}
<AspectRatioAlert />
{/if}
<div class="row gap-4">
{#if imageNonSquare}
<ScaleModeToggleGroup />
{/if}
<TransformControls disabled={transformDisabled} />
</div>
<Separator decorative />
<BackgroundColorSlider />
<Separator decorative />
<DitherControls />
{#if config.ditheringKernel !== null}
<ContrastSlider />
{/if}
{#if config.ditheringKernel === null || config.contrast !== 0}
<BrightnessSlider />
{/if}
<Separator decorative />
<Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}>
<IconEditOff aria-hidden />
Reset All
</Button>
</section>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import ImplicitNumericInput from '$lib/components/ImplicitNumericInput.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig()
let value = $derived(Math.round(config.brightness * 100))
</script>
<div class="stack gap-4" role="group" aria-labelledby="brightness-input-label">
<div class="row gap-1 text-sm">
<Label id="brightness-input-label">Brightness</Label>
<span class="font-normal text-muted-foreground">
=<ImplicitNumericInput
min={-100}
max={100}
{value}
onchange={v => (config.brightness = (value = v) / 100)}
aria-labelledby="brightness-input-label"
/>%
</span>
<MoreInfo>
Whether the conversion algorithm should bias the entire image towards white (positive) or black (negative).
</MoreInfo>
</div>
<Slider
type="single"
bind:value
onValueCommit={() => (config.brightness = value / 100)}
min={-100}
max={100}
step={1}
aria-labelledby="brightness-input-label"
/>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import ImplicitNumericInput from '$lib/components/ImplicitNumericInput.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig()
let value = $derived(Math.round(config.contrast * 100))
</script>
<div class="stack gap-4" role="group" aria-labelledby="contrast-input-label">
<div class="row gap-1 text-sm">
<Label id="contrast-input-label">Contrast</Label>
<span class="font-normal text-muted-foreground">
=<ImplicitNumericInput
min={0}
max={100}
{value}
onchange={v => (config.contrast = (value = v) / 100)}
aria-labelledby="contrast-input-label"
/>%
</span>
<MoreInfo>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</MoreInfo>
</div>
<Slider
type="single"
bind:value
onValueCommit={() => (config.contrast = value / 100)}
min={0}
max={100}
step={1}
aria-labelledby="contrast-input-label"
/>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
import DitheringKernelDropdown from './DitheringKernelDropdown.svelte'
import DitherSwitch from './DitherSwitch.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig()
let lastDitheringKernel: DitheringKernel = $state(DEFAULT_DITHERING_KERNEL)
$effect(() => {
if (config.ditheringKernel === null) return
lastDitheringKernel = config.ditheringKernel
})
</script>
<div class="stack-h gap-4">
<DitherSwitch
checked={config.ditheringKernel !== null}
onchange={c => (config.ditheringKernel = c ? lastDitheringKernel : null)}
/>
<DitheringKernelDropdown
hidden={config.ditheringKernel === null}
value={lastDitheringKernel}
onchange={v => {
config.ditheringKernel = v
}}
/>
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import MoreInfo from '$lib/components/MoreInfo.svelte'
interface Props {
checked: boolean
onchange: (checked: boolean) => void
}
let { checked, onchange }: Props = $props()
</script>
<div class="stack gap-2" role="group" aria-labelledby="dither-switch-label">
<div class="row gap-1">
<Label id="dither-switch-label">Dither</Label>
<MoreInfo>Dithering uses different dot densities to simulate shades of gray.</MoreInfo>
</div>
<div class="grow row items-center">
<Switch {checked} onCheckedChange={onchange} aria-labelledby="dither-switch-label" />
</div>
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import { type DitheringKernel } from '$lib/image/quantizer'
import { Label } from '$lib/components/ui/label'
import * as Select from '$lib/components/ui/select'
import MoreInfo from '$lib/components/MoreInfo.svelte'
import { cn } from '$lib/utils'
interface Props {
hidden: boolean
value: DitheringKernel
onchange: (v: DitheringKernel) => void
}
const { hidden, value, onchange }: Props = $props()
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',
}
</script>
<div
class={cn('grow stack gap-2', hidden ? ['invisible'] : [])}
role="group"
aria-labelledby="dithering-kernel-select-label"
>
<div class="row gap-1">
<Label id="dithering-kernel-select-label">Dithering Kernel</Label>
<MoreInfo>
Algorithm used for dithering. <br />
Switch around to see which one works best for your image.
</MoreInfo>
</div>
<Select.Root type="single" {value} onValueChange={v => onchange(v as DitheringKernel)}>
<Select.Trigger aria-labelledby="dithering-kernel-select-label">{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,12 @@
<script lang="ts">
import * as Alert from '$lib/components/ui/alert'
import IconAspectRatio from '~icons/material-symbols/aspect-ratio-outline'
import { DEVICE_ASPECT_RATIO } from '$lib/constants'
</script>
<Alert.Root>
<IconAspectRatio aria-hidden />
<Alert.Title>Image is not {String(Math.round(DEVICE_ASPECT_RATIO * 100) / 100)}: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'
import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig()
const scaleModes: Record<ScaleMode, { name: string; description: string }> = {
fit: {
name: 'Fit',
description: 'Fit the entire image onto the display. May introduce letterboxing.',
},
crop: {
name: 'Crop',
description: 'Fill the display and crop out-of-frame parts of the image.',
},
distort: {
name: 'Distort',
description: 'Stretch the image to fill the display. Distorts the aspect ratio.',
},
}
</script>
<div class="stack gap-2">
<Label id="scale-mode-input-label">Scaling method</Label>
<ToggleGroup.Root
type="single"
bind:value={
() => config.scaleMode,
v => {
if (v.length !== 0) config.scaleMode = v as ScaleMode
}
}
aria-labelledby="scale-mode-input-label"
>
<Tooltip.Provider delayDuration={0} disableHoverableContent>
{#each Object.entries(scaleModes) as [mode, { name, description }]}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<ToggleGroup.Item {...props} value={mode}>{name}</ToggleGroup.Item>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
{description}
</Tooltip.Content>
</Tooltip.Root>
{/each}
</Tooltip.Provider>
</ToggleGroup.Root>
</div>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { Operation } from '$lib/image/transform'
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 { Button } from '$lib/components/ui/button'
import { Label } from '$lib/components/ui/label'
import * as Tooltip from '$lib/components/ui/tooltip'
import { getConversionConfig } from '$lib/contexts/config.svelte'
interface Props {
disabled?: boolean
}
const { disabled = false }: Props = $props()
const config = getConversionConfig()
const operations = {
cw: {
icon: IconRotate90DegreesCw,
description: 'Rotate 90° clockwise',
},
ccw: {
icon: IconRotate90DegreesCcw,
description: 'Rotate 90° counter-clockwise',
},
h: {
icon: IconFlipHorizontal,
description: 'Flip horizontally',
},
v: {
icon: IconFlipVertical,
description: 'Flip vertically',
},
}
</script>
<div class="stack gap-2">
<Label id="transform-controls-label">Transform</Label>
<div role="group" aria-labelledby="transform-controls-label">
<Tooltip.Provider delayDuration={0} disableHoverableContent>
{#each Object.entries(operations) as [op, { icon: Icon, description }]}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Button
{...props}
{disabled}
size="icon"
variant="outline"
onclick={() => {
config.transform = config.transform.op(op as Operation)
}}
>
<Icon aria-label={description} />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>{description}</Tooltip.Content>
</Tooltip.Root>
{/each}
</Tooltip.Provider>
</div>
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input'
import { getFilesContext } from '$lib/contexts/files.svelte'
interface Props {
ref?: HTMLInputElement | null
}
let { ref = $bindable(null) }: Props = $props()
const filesCtx = getFilesContext()
</script>
<Input bind:ref type="file" accept="image/*" bind:files={filesCtx.files} aria-labelledby="preview-section-label" />
@@ -0,0 +1,51 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import IconHideImage from '~icons/material-symbols/hide-image'
import { cn } from '$lib/utils'
import { drawQuantizedData, freshContext, makeAltText } from './common.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
import { getFilesContext } from '$lib/contexts/files.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
const filesCtx = getFilesContext()
const imageCtx = getImageContext()
const config = getConversionConfig()
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
let canvasEl: HTMLCanvasElement = $state(undefined!)
const ctx = $derived(freshContext(canvasEl))
$effect(() => {
if (renderedCtx.rendered === null) return
drawQuantizedData(ctx, renderedCtx.rendered)
})
</script>
<div>
<div class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit relative" role="group" aria-labelledby="preview-1x-label">
<div class="p-[3px] shadow-sm shadow-[inset#888]" role="img" aria-label={makeAltText(filesCtx, imageCtx, config)}>
{#if hasRendered}
<canvas
class={cn(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{DEVICE_WIDTH}px"
style:height="{DEVICE_HEIGHT}px"
bind:this={canvasEl}
height={DEVICE_HEIGHT}
width={DEVICE_WIDTH}
></canvas>
{:else}
<div class="col justify-center text-[#333]" style:width="{DEVICE_WIDTH}px" style:height="{DEVICE_HEIGHT}px">
<IconHideImage class="text-3xl" aria-label="No image" />
</div>
{/if}
</div>
</div>
<Label id="preview-1x-label">1x Preview</Label>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import IconHideImage from '~icons/material-symbols/hide-image'
import { cn } from '$lib/utils'
import { drawQuantizedData, freshContext, makeAltText } from './common.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
import { getFilesContext } from '$lib/contexts/files.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
const filesCtx = getFilesContext()
const imageCtx = getImageContext()
const config = getConversionConfig()
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
let canvasEl: HTMLCanvasElement = $state(undefined!)
const ctx = $derived(freshContext(canvasEl))
$effect(() => {
if (renderedCtx.rendered === null) return
drawQuantizedData(ctx, renderedCtx.rendered)
})
</script>
<div>
<div class="bg-[#ccc] shadow-lg rounded-2xl p-4 w-fit relative" role="group" aria-labelledby="preview-2x-label">
<div class="p-[6px] shadow shadow-[inset#888]" role="img" aria-label={makeAltText(filesCtx, imageCtx, config)}>
{#if hasRendered}
<canvas
class={cn(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{DEVICE_WIDTH * 2}px"
style:height="{DEVICE_HEIGHT * 2}px"
bind:this={canvasEl}
height={DEVICE_HEIGHT}
width={DEVICE_WIDTH}
></canvas>
{:else}
<div
class="col justify-center text-[#333]"
style:width="{DEVICE_WIDTH * 2}px"
style:height="{DEVICE_HEIGHT * 2}px"
>
<IconHideImage class="text-6xl" aria-label="No image" />
</div>
{/if}
</div>
</div>
<Label id="preview-2x-label">2x Preview</Label>
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import FileSelect from './FileSelect.svelte'
import PreviewCanvas1x from './PreviewCanvas1x.svelte'
import PreviewCanvas2x from './PreviewCanvas2x.svelte'
</script>
<section class="stack gap-4" aria-labelledby="preview-section-label">
<h2 class="font-semibold text-xl/6" id="preview-section-label">Choose an image</h2>
<FileSelect />
<div class="stack-h gap-4 max-md:stack">
<PreviewCanvas2x />
<PreviewCanvas1x />
</div>
</section>
@@ -0,0 +1,70 @@
import { DEVICE_HEIGHT, DEVICE_WIDTH } from '$lib/constants'
import type { ConversionConfig } from '$lib/contexts/config.svelte'
import type { FilesContext } from '$lib/contexts/files.svelte'
import { imageIsCorrectRatio, type ImageContext } from '$lib/contexts/image.svelte'
export function freshContext(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')!
ctx.imageSmoothingEnabled = false
return ctx
}
export function makeAltText(filesCtx: FilesContext, imageCtx: ImageContext, config: ConversionConfig): string {
if (filesCtx.files.length < 1) return 'No image selected for e-paper preview'
const file = filesCtx.files[0]
const output = [`${DEVICE_WIDTH}-by-${DEVICE_HEIGHT}-pixels e-paper preview of "${file.name}"`]
if (!imageIsCorrectRatio(imageCtx.image)) {
if (config.scaleMode === 'fit') output.push('letterboxed')
else if (config.scaleMode === 'crop') output.push('cropped to fit')
else if (config.scaleMode === 'distort') output.push('stretched to fit')
}
if (config.transform.side === 'reverse') output.push('flipped horizontally')
if (config.transform.rotation !== 0) output.push(`rotated ${config.transform.rotation} degrees`)
if (config.ditheringKernel === null) output.push('no dithering')
else output.push('with dithering')
if (config.ditheringKernel !== null) {
if (config.contrast >= 0.75) output.push('with very high contrast')
else if (config.contrast >= 0.25) output.push('with high contrast')
}
if (config.contrast > 0 || config.ditheringKernel === null) {
const brightnessScaleFactor = config.ditheringKernel === null ? 1 : config.contrast
const scaledBrightness = config.brightness * brightnessScaleFactor
if (scaledBrightness >= 0.5) output.push('extremely brightened')
else if (scaledBrightness >= 0.25) output.push('brightened')
else if (scaledBrightness >= 0.1) output.push('slightly brightened')
if (scaledBrightness <= -0.5) output.push('extremely darkened')
else if (scaledBrightness <= -0.25) output.push('darkened')
else if (scaledBrightness <= -0.1) output.push('slightly darkened')
}
return output.join(', ')
}
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
const imageData = ctx.createImageData(DEVICE_WIDTH, DEVICE_HEIGHT)
for (let y = 0; y < DEVICE_HEIGHT; y++) {
for (let x = 0; x < DEVICE_WIDTH; x++) {
const dataIx = y * DEVICE_WIDTH + x
const bitmapIx = dataIx * 4
const colorIx = data.at(dataIx)
const color = colorIx === 0 ? 0xcc : 0x11
imageData.data[bitmapIx] = color
imageData.data[bitmapIx + 1] = color
imageData.data[bitmapIx + 2] = color
imageData.data[bitmapIx + 3] = 0xff
}
}
ctx.putImageData(imageData, 0, 0)
}
@@ -0,0 +1,69 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { toast } from 'svelte-sonner'
import { getDeviceContext, tryOpenDevice } from '$lib/contexts/device.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import {
BYTES_IN_A_ROW,
DEVICE_HEIGHT,
DEVICE_WIDTH,
SERIAL_NUMBER_REPORT_ID,
WRITE_PATTERN_REPORT_ID,
WRITE_TIME,
} from '$lib/constants'
interface Props {
onprogress: (inPropgress: boolean) => void
}
const { onprogress }: Props = $props()
let inProgress = $state(false)
const deviceCtx = getDeviceContext()
const renderedCtx = getRenderedContext()
let disabled = $derived(deviceCtx.device === null || renderedCtx.rendered === null || inProgress)
let secondary = $derived(deviceCtx.device === null)
async function connectAndWrite() {
if (deviceCtx.device === null || renderedCtx.rendered === null) return
if (!(await tryOpenDevice(deviceCtx.device))) return
const buffer = new Uint8Array(DEVICE_HEIGHT * BYTES_IN_A_ROW)
for (let y = 0; y < DEVICE_HEIGHT; y++) {
for (let xStride = 0; xStride < BYTES_IN_A_ROW; xStride++) {
let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * DEVICE_WIDTH + xStride * 8 + xStroll
cell |= renderedCtx.rendered[index] << xStroll
}
buffer[y * BYTES_IN_A_ROW + xStride] = cell
}
}
inProgress = true
try {
await deviceCtx.device.sendReport(WRITE_PATTERN_REPORT_ID, buffer)
} catch (e) {
toast.error(`Error writing to device: ${e}`)
return
} finally {
setTimeout(() => {
inProgress = false
}, WRITE_TIME)
}
toast.success('Wrote pattern to device')
}
$effect(() => {
onprogress(inProgress)
})
</script>
<Button onclick={connectAndWrite} variant={secondary ? 'secondary' : 'default'} {disabled}>
Write pattern to device
</Button>
@@ -0,0 +1,30 @@
<script lang="ts">
import IconPending from '~icons/material-symbols/pending'
import IconArrowUploadProgress from '~icons/material-symbols/arrow-upload-progress'
import IconWarning from '~icons/material-symbols/warning'
import WriteButton from './WriteButton.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
const imageCtx = getImageContext()
let inProgress = $state(false)
</script>
<section class="row gap-2 max-lg:stack max-lg:items-stretch" aria-labelledby="write-section-label">
<div class="grow">
<h2 class="font-semibold text-xl/8" id="write-section-label">Write pattern to device</h2>
<div class="row gap-1 text-sm" aria-live="polite">
{#if imageCtx.image === null}
<IconPending aria-hidden /> Select an image file in order to write it onto your device.
{:else if !inProgress}
<IconArrowUploadProgress aria-hidden /> Write the pattern onto your device if you have finished editing the image.
{:else}
<IconWarning aria-hidden /> Refresh in progress. Do not disconnect device.
{/if}
</div>
</div>
<WriteButton onprogress={v => (inProgress = v)} />
</section>
+10
View File
@@ -0,0 +1,10 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function showNumber(n: number): string {
return String(n).replace('-', '').replace('Infinity', '∞').replace('NaN', '⁇')
}
+18
View File
@@ -0,0 +1,18 @@
import { onDestroy } from 'svelte'
export interface ReactiveMediaQuery {
readonly matches: boolean
}
export function createMediaQuery(query: string): ReactiveMediaQuery {
const queryList = matchMedia(query)
const queryState = $state({ matches: queryList.matches })
const updateQueryState = (e: MediaQueryListEvent) => (queryState.matches = e.matches)
queryList.addEventListener('change', updateQueryState)
onDestroy(() => queryList.removeEventListener('change', updateQueryState))
return queryState
}
+50
View File
@@ -0,0 +1,50 @@
export type DitheringKernel =
| 'FloydSteinberg'
| 'FalseFloydSteinberg'
| 'Stucki'
| 'Atkinson'
| 'Jarvis'
| 'Burkes'
| 'Sierra'
| 'TwoSierra'
| 'SierraLite'
export type Rgb = [number, number, number]
export type RgbQuantOptions = {
colors?: number
method?: 1 | 2
boxSize?: [number, number]
boxPxls?: number
initColors?: number
minHueCols?: number
dithKern?: DitheringKernel | null
dithDelta?: number
dithSerp?: boolean
palette?: Rgb[]
reIndex?: boolean
useCache?: boolean
cacheFreq?: number
colorDist?: 'euclidean' | 'manhattan'
}
export type RgbQuantImage =
| HTMLImageElement
| HTMLCanvasElement
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| ImageData
| Uint8Array
| Uint8ClampedArray
| Uint32Array
| number[]
export default class RgbQuant {
constructor(opts?: RgbQuantOptions)
sample(image: RgbQuantImage, width?: number): void
palette(tuples: true, noSort?: boolean = false): Rgb[]
palette(tuples: false, noSort?: boolean = false): Uint8Array
palette(): Uint8Array
reduce(image: RgbQuantImage, retType: 1, dithKern?: DitheringKernel | null, dithSerp?: boolean): Uint8Array
reduce(image: RgbQuantImage, retType: 2, dithKern?: DitheringKernel | null, dithSerp?: boolean): number[]
}
+918
View File
@@ -0,0 +1,918 @@
/*
* Copyright (c) 2015, Leon Sorokin
* All rights reserved. (MIT Licensed)
*
* RgbQuant.js - an image quantization lib
*/
function RgbQuant(opts) {
opts = opts || {}
// 1 = by global population, 2 = subregion population threshold
this.method = opts.method || 2
// desired final palette size
this.colors = opts.colors || 256
// # of highest-frequency colors to start with for palette reduction
this.initColors = opts.initColors || 4096
// color-distance threshold for initial reduction pass
this.initDist = opts.initDist || 0.01
// subsequent passes threshold
this.distIncr = opts.distIncr || 0.005
// palette grouping
this.hueGroups = opts.hueGroups || 10
this.satGroups = opts.satGroups || 10
this.lumGroups = opts.lumGroups || 10
// if > 0, enables hues stats and min-color retention per group
this.minHueCols = opts.minHueCols || 0
// HueStats instance
this.hueStats = this.minHueCols ? new HueStats(this.hueGroups, this.minHueCols) : null
// subregion partitioning box size
this.boxSize = opts.boxSize || [64, 64]
// number of same pixels required within box for histogram inclusion
this.boxPxls = opts.boxPxls || 2
// palette locked indicator
this.palLocked = false
// palette sort order
// this.sortPal = ['hue-','lum-','sat-'];
// dithering/error diffusion kernel name
this.dithKern = opts.dithKern || null
// dither serpentine pattern
this.dithSerp = opts.dithSerp || false
// minimum color difference (0-1) needed to dither
this.dithDelta = opts.dithDelta || 0
// accumulated histogram
this.histogram = {}
// palette - rgb triplets
this.idxrgb = opts.palette ? opts.palette.slice(0) : []
// palette - int32 vals
this.idxi32 = []
// reverse lookup {i32:idx}
this.i32idx = {}
// {i32:rgb}
this.i32rgb = {}
// enable color caching (also incurs overhead of cache misses and cache building)
this.useCache = opts.useCache !== false
// min color occurance count needed to qualify for caching
this.cacheFreq = opts.cacheFreq || 10
// allows pre-defined palettes to be re-indexed (enabling palette compacting and sorting)
this.reIndex = opts.reIndex || this.idxrgb.length == 0
// selection of color-distance equation
this.colorDist = opts.colorDist == 'manhattan' ? distManhattan : distEuclidean
// if pre-defined palette, build lookups
if (this.idxrgb.length > 0) {
var self = this
this.idxrgb.forEach(function (rgb, i) {
var i32 =
((255 << 24) | // alpha
(rgb[2] << 16) | // blue
(rgb[1] << 8) | // green
rgb[0]) >>> // red
0
self.idxi32[i] = i32
self.i32idx[i32] = i
self.i32rgb[i32] = rgb
})
}
}
// gathers histogram info
RgbQuant.prototype.sample = function sample(img, width) {
if (this.palLocked) throw 'Cannot sample additional images, palette already assembled.'
var data = getImageData(img, width)
switch (this.method) {
case 1:
this.colorStats1D(data.buf32)
break
case 2:
this.colorStats2D(data.buf32, data.width)
break
}
}
// image quantizer
// todo: memoize colors here also
// @retType: 1 - Uint8Array (default), 2 - Indexed array, 3 - Match @img type (unimplemented, todo)
RgbQuant.prototype.reduce = function reduce(img, retType, dithKern, dithSerp) {
if (!this.palLocked) this.buildPal()
dithKern = dithKern || this.dithKern
dithSerp = typeof dithSerp != 'undefined' ? dithSerp : this.dithSerp
retType = retType || 1
// reduce w/dither
if (dithKern) var out32 = this.dither(img, dithKern, dithSerp)
else {
var data = getImageData(img),
buf32 = data.buf32,
len = buf32.length,
out32 = new Uint32Array(len)
for (var i = 0; i < len; i++) {
var i32 = buf32[i]
out32[i] = this.nearestColor(i32)
}
}
if (retType == 1) return new Uint8Array(out32.buffer)
if (retType == 2) {
var out = [],
len = out32.length
for (var i = 0; i < len; i++) {
var i32 = out32[i]
out[i] = this.i32idx[i32]
}
return out
}
}
// adapted from http://jsbin.com/iXofIji/2/edit by PAEz
RgbQuant.prototype.dither = function (img, kernel, serpentine) {
// http://www.tannerhelland.com/4660/dithering-eleven-algorithms-source-code/
var kernels = {
FloydSteinberg: [
[7 / 16, 1, 0],
[3 / 16, -1, 1],
[5 / 16, 0, 1],
[1 / 16, 1, 1],
],
FalseFloydSteinberg: [
[3 / 8, 1, 0],
[3 / 8, 0, 1],
[2 / 8, 1, 1],
],
Stucki: [
[8 / 42, 1, 0],
[4 / 42, 2, 0],
[2 / 42, -2, 1],
[4 / 42, -1, 1],
[8 / 42, 0, 1],
[4 / 42, 1, 1],
[2 / 42, 2, 1],
[1 / 42, -2, 2],
[2 / 42, -1, 2],
[4 / 42, 0, 2],
[2 / 42, 1, 2],
[1 / 42, 2, 2],
],
Atkinson: [
[1 / 8, 1, 0],
[1 / 8, 2, 0],
[1 / 8, -1, 1],
[1 / 8, 0, 1],
[1 / 8, 1, 1],
[1 / 8, 0, 2],
],
Jarvis: [
// Jarvis, Judice, and Ninke / JJN?
[7 / 48, 1, 0],
[5 / 48, 2, 0],
[3 / 48, -2, 1],
[5 / 48, -1, 1],
[7 / 48, 0, 1],
[5 / 48, 1, 1],
[3 / 48, 2, 1],
[1 / 48, -2, 2],
[3 / 48, -1, 2],
[5 / 48, 0, 2],
[3 / 48, 1, 2],
[1 / 48, 2, 2],
],
Burkes: [
[8 / 32, 1, 0],
[4 / 32, 2, 0],
[2 / 32, -2, 1],
[4 / 32, -1, 1],
[8 / 32, 0, 1],
[4 / 32, 1, 1],
[2 / 32, 2, 1],
],
Sierra: [
[5 / 32, 1, 0],
[3 / 32, 2, 0],
[2 / 32, -2, 1],
[4 / 32, -1, 1],
[5 / 32, 0, 1],
[4 / 32, 1, 1],
[2 / 32, 2, 1],
[2 / 32, -1, 2],
[3 / 32, 0, 2],
[2 / 32, 1, 2],
],
TwoSierra: [
[4 / 16, 1, 0],
[3 / 16, 2, 0],
[1 / 16, -2, 1],
[2 / 16, -1, 1],
[3 / 16, 0, 1],
[2 / 16, 1, 1],
[1 / 16, 2, 1],
],
SierraLite: [
[2 / 4, 1, 0],
[1 / 4, -1, 1],
[1 / 4, 0, 1],
],
}
if (!kernel || !kernels[kernel]) {
throw 'Unknown dithering kernel: ' + kernel
}
var ds = kernels[kernel]
var data = getImageData(img),
// buf8 = data.buf8,
buf32 = data.buf32,
width = data.width,
height = data.height,
len = buf32.length
var dir = serpentine ? -1 : 1
for (var y = 0; y < height; y++) {
if (serpentine) dir = dir * -1
var lni = y * width
for (var x = dir == 1 ? 0 : width - 1, xend = dir == 1 ? width : 0; x !== xend; x += dir) {
// Image pixel
var idx = lni + x,
i32 = buf32[idx],
r1 = i32 & 0xff,
g1 = (i32 & 0xff00) >> 8,
b1 = (i32 & 0xff0000) >> 16
// Reduced pixel
var i32x = this.nearestColor(i32),
r2 = i32x & 0xff,
g2 = (i32x & 0xff00) >> 8,
b2 = (i32x & 0xff0000) >> 16
buf32[idx] =
(255 << 24) | // alpha
(b2 << 16) | // blue
(g2 << 8) | // green
r2
// dithering strength
if (this.dithDelta) {
var dist = this.colorDist([r1, g1, b1], [r2, g2, b2])
if (dist < this.dithDelta) continue
}
// Component distance
var er = r1 - r2,
eg = g1 - g2,
eb = b1 - b2
for (var i = dir == 1 ? 0 : ds.length - 1, end = dir == 1 ? ds.length : 0; i !== end; i += dir) {
var x1 = ds[i][1] * dir,
y1 = ds[i][2]
var lni2 = y1 * width
if (x1 + x >= 0 && x1 + x < width && y1 + y >= 0 && y1 + y < height) {
var d = ds[i][0]
var idx2 = idx + (lni2 + x1)
var r3 = buf32[idx2] & 0xff,
g3 = (buf32[idx2] & 0xff00) >> 8,
b3 = (buf32[idx2] & 0xff0000) >> 16
var r4 = Math.max(0, Math.min(255, r3 + er * d)),
g4 = Math.max(0, Math.min(255, g3 + eg * d)),
b4 = Math.max(0, Math.min(255, b3 + eb * d))
buf32[idx2] =
(255 << 24) | // alpha
(b4 << 16) | // blue
(g4 << 8) | // green
r4 // red
}
}
}
}
return buf32
}
// reduces histogram to palette, remaps & memoizes reduced colors
RgbQuant.prototype.buildPal = function buildPal(noSort) {
if (this.palLocked || (this.idxrgb.length > 0 && this.idxrgb.length <= this.colors)) return
var histG = this.histogram,
sorted = sortedHashKeys(histG, true)
if (sorted.length == 0) throw 'Nothing has been sampled, palette cannot be built.'
switch (this.method) {
case 1:
var cols = this.initColors,
last = sorted[cols - 1],
freq = histG[last]
var idxi32 = sorted.slice(0, cols)
// add any cut off colors with same freq as last
var pos = cols,
len = sorted.length
while (pos < len && histG[sorted[pos]] == freq) idxi32.push(sorted[pos++])
// inject min huegroup colors
if (this.hueStats) this.hueStats.inject(idxi32)
break
case 2:
var idxi32 = sorted
break
}
// int32-ify values
idxi32 = idxi32.map(function (v) {
return +v
})
this.reducePal(idxi32)
if (!noSort && this.reIndex) this.sortPal()
// build cache of top histogram colors
if (this.useCache) this.cacheHistogram(idxi32)
this.palLocked = true
}
RgbQuant.prototype.palette = function palette(tuples, noSort) {
this.buildPal(noSort)
return tuples ? this.idxrgb : new Uint8Array(new Uint32Array(this.idxi32).buffer)
}
RgbQuant.prototype.prunePal = function prunePal(keep) {
var i32
for (var j = 0; j < this.idxrgb.length; j++) {
if (!keep[j]) {
i32 = this.idxi32[j]
this.idxrgb[j] = null
this.idxi32[j] = null
delete this.i32idx[i32]
}
}
// compact
if (this.reIndex) {
var idxrgb = [],
idxi32 = [],
i32idx = {}
for (var j = 0, i = 0; j < this.idxrgb.length; j++) {
if (this.idxrgb[j]) {
i32 = this.idxi32[j]
idxrgb[i] = this.idxrgb[j]
i32idx[i32] = i
idxi32[i] = i32
i++
}
}
this.idxrgb = idxrgb
this.idxi32 = idxi32
this.i32idx = i32idx
}
}
// reduces similar colors from an importance-sorted Uint32 rgba array
RgbQuant.prototype.reducePal = function reducePal(idxi32) {
// if pre-defined palette's length exceeds target
if (this.idxrgb.length > this.colors) {
// quantize histogram to existing palette
var len = idxi32.length,
keep = {},
uniques = 0,
idx,
pruned = false
for (var i = 0; i < len; i++) {
// palette length reached, unset all remaining colors (sparse palette)
if (uniques == this.colors && !pruned) {
this.prunePal(keep)
pruned = true
}
idx = this.nearestIndex(idxi32[i])
if (uniques < this.colors && !keep[idx]) {
keep[idx] = true
uniques++
}
}
if (!pruned) {
this.prunePal(keep)
pruned = true
}
}
// reduce histogram to create initial palette
else {
// build full rgb palette
var idxrgb = idxi32.map(function (i32) {
return [i32 & 0xff, (i32 & 0xff00) >> 8, (i32 & 0xff0000) >> 16]
})
var len = idxrgb.length,
palLen = len,
thold = this.initDist
// palette already at or below desired length
if (palLen > this.colors) {
while (palLen > this.colors) {
var memDist = []
// iterate palette
for (var i = 0; i < len; i++) {
var pxi = idxrgb[i],
i32i = idxi32[i]
if (!pxi) continue
for (var j = i + 1; j < len; j++) {
var pxj = idxrgb[j],
i32j = idxi32[j]
if (!pxj) continue
var dist = this.colorDist(pxi, pxj)
if (dist < thold) {
// store index,rgb,dist
memDist.push([j, pxj, i32j, dist])
// kill squashed value
delete idxrgb[j]
palLen--
}
}
}
// palette reduction pass
// console.log("palette length: " + palLen);
// if palette is still much larger than target, increment by larger initDist
thold += palLen > this.colors * 3 ? this.initDist : this.distIncr
}
// if palette is over-reduced, re-add removed colors with largest distances from last round
if (palLen < this.colors) {
// sort descending
sort.call(memDist, function (a, b) {
return b[3] - a[3]
})
var k = 0
while (palLen < this.colors) {
// re-inject rgb into final palette
idxrgb[memDist[k][0]] = memDist[k][1]
palLen++
k++
}
}
}
var len = idxrgb.length
for (var i = 0; i < len; i++) {
if (!idxrgb[i]) continue
this.idxrgb.push(idxrgb[i])
this.idxi32.push(idxi32[i])
this.i32idx[idxi32[i]] = this.idxi32.length - 1
this.i32rgb[idxi32[i]] = idxrgb[i]
}
}
}
// global top-population
RgbQuant.prototype.colorStats1D = function colorStats1D(buf32) {
var histG = this.histogram,
num = 0,
col,
len = buf32.length
for (var i = 0; i < len; i++) {
col = buf32[i]
// skip transparent
if ((col & 0xff000000) >> 24 == 0) continue
// collect hue stats
if (this.hueStats) this.hueStats.check(col)
if (col in histG) histG[col]++
else histG[col] = 1
}
}
// population threshold within subregions
// FIXME: this can over-reduce (few/no colors same?), need a way to keep
// important colors that dont ever reach local thresholds (gradients?)
RgbQuant.prototype.colorStats2D = function colorStats2D(buf32, width) {
var boxW = this.boxSize[0],
boxH = this.boxSize[1],
area = boxW * boxH,
boxes = makeBoxes(width, buf32.length / width, boxW, boxH),
histG = this.histogram,
self = this
boxes.forEach(function (box) {
var effc = Math.max(Math.round((box.w * box.h) / area) * self.boxPxls, 2),
histL = {},
col
iterBox(box, width, function (i) {
col = buf32[i]
// skip transparent
if ((col & 0xff000000) >> 24 == 0) return
// collect hue stats
if (self.hueStats) self.hueStats.check(col)
if (col in histG) histG[col]++
else if (col in histL) {
if (++histL[col] >= effc) histG[col] = histL[col]
} else histL[col] = 1
})
})
if (this.hueStats) this.hueStats.inject(histG)
}
// TODO: group very low lum and very high lum colors
// TODO: pass custom sort order
RgbQuant.prototype.sortPal = function sortPal() {
var self = this
this.idxi32.sort(function (a, b) {
var idxA = self.i32idx[a],
idxB = self.i32idx[b],
rgbA = self.idxrgb[idxA],
rgbB = self.idxrgb[idxB]
var hslA = rgb2hsl(rgbA[0], rgbA[1], rgbA[2]),
hslB = rgb2hsl(rgbB[0], rgbB[1], rgbB[2])
// sort all grays + whites together
var hueA = rgbA[0] == rgbA[1] && rgbA[1] == rgbA[2] ? -1 : hueGroup(hslA.h, self.hueGroups)
var hueB = rgbB[0] == rgbB[1] && rgbB[1] == rgbB[2] ? -1 : hueGroup(hslB.h, self.hueGroups)
var hueDiff = hueB - hueA
if (hueDiff) return -hueDiff
var lumDiff = lumGroup(+hslB.l.toFixed(2)) - lumGroup(+hslA.l.toFixed(2))
if (lumDiff) return -lumDiff
var satDiff = satGroup(+hslB.s.toFixed(2)) - satGroup(+hslA.s.toFixed(2))
if (satDiff) return -satDiff
})
// sync idxrgb & i32idx
this.idxi32.forEach(function (i32, i) {
self.idxrgb[i] = self.i32rgb[i32]
self.i32idx[i32] = i
})
}
// TOTRY: use HUSL - http://boronine.com/husl/
RgbQuant.prototype.nearestColor = function nearestColor(i32) {
var idx = this.nearestIndex(i32)
return idx === null ? 0 : this.idxi32[idx]
}
// TOTRY: use HUSL - http://boronine.com/husl/
RgbQuant.prototype.nearestIndex = function nearestIndex(i32) {
// alpha 0 returns null index
if ((i32 & 0xff000000) >> 24 == 0) return null
if (this.useCache && '' + i32 in this.i32idx) return this.i32idx[i32]
var min = 1000,
idx,
rgb = [i32 & 0xff, (i32 & 0xff00) >> 8, (i32 & 0xff0000) >> 16],
len = this.idxrgb.length
for (var i = 0; i < len; i++) {
if (!this.idxrgb[i]) continue // sparse palettes
var dist = this.colorDist(rgb, this.idxrgb[i])
if (dist < min) {
min = dist
idx = i
}
}
return idx
}
RgbQuant.prototype.cacheHistogram = function cacheHistogram(idxi32) {
for (var i = 0, i32 = idxi32[i]; i < idxi32.length && this.histogram[i32] >= this.cacheFreq; i32 = idxi32[i++])
this.i32idx[i32] = this.nearestIndex(i32)
}
function HueStats(numGroups, minCols) {
this.numGroups = numGroups
this.minCols = minCols
this.stats = {}
for (var i = -1; i < numGroups; i++) this.stats[i] = { num: 0, cols: [] }
this.groupsFull = 0
}
HueStats.prototype.check = function checkHue(i32) {
if (this.groupsFull == this.numGroups + 1)
this.check = function () {
return
}
var r = i32 & 0xff,
g = (i32 & 0xff00) >> 8,
b = (i32 & 0xff0000) >> 16,
hg = r == g && g == b ? -1 : hueGroup(rgb2hsl(r, g, b).h, this.numGroups),
gr = this.stats[hg],
min = this.minCols
gr.num++
if (gr.num > min) return
if (gr.num == min) this.groupsFull++
if (gr.num <= min) this.stats[hg].cols.push(i32)
}
HueStats.prototype.inject = function injectHues(histG) {
for (var i = -1; i < this.numGroups; i++) {
if (this.stats[i].num <= this.minCols) {
switch (typeOf(histG)) {
case 'Array':
this.stats[i].cols.forEach(function (col) {
if (histG.indexOf(col) == -1) histG.push(col)
})
break
case 'Object':
this.stats[i].cols.forEach(function (col) {
if (!histG[col]) histG[col] = 1
else histG[col]++
})
break
}
}
}
}
// Rec. 709 (sRGB) luma coef
var Pr = 0.2126,
Pg = 0.7152,
Pb = 0.0722
// http://alienryderflex.com/hsp.html
function rgb2lum(r, g, b) {
return Math.sqrt(Pr * r * r + Pg * g * g + Pb * b * b)
}
var rd = 255,
gd = 255,
bd = 255
var euclMax = Math.sqrt(Pr * rd * rd + Pg * gd * gd + Pb * bd * bd)
// perceptual Euclidean color distance
function distEuclidean(rgb0, rgb1) {
var rd = rgb1[0] - rgb0[0],
gd = rgb1[1] - rgb0[1],
bd = rgb1[2] - rgb0[2]
return Math.sqrt(Pr * rd * rd + Pg * gd * gd + Pb * bd * bd) / euclMax
}
var manhMax = Pr * rd + Pg * gd + Pb * bd
// perceptual Manhattan color distance
function distManhattan(rgb0, rgb1) {
var rd = Math.abs(rgb1[0] - rgb0[0]),
gd = Math.abs(rgb1[1] - rgb0[1]),
bd = Math.abs(rgb1[2] - rgb0[2])
return (Pr * rd + Pg * gd + Pb * bd) / manhMax
}
// http://rgb2hsl.nichabi.com/javascript-function.php
function rgb2hsl(r, g, b) {
var max, min, h, s, l, d
r /= 255
g /= 255
b /= 255
max = Math.max(r, g, b)
min = Math.min(r, g, b)
l = (max + min) / 2
if (max == min) {
h = s = 0
} else {
d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0)
break
case g:
h = (b - r) / d + 2
break
case b:
h = (r - g) / d + 4
break
}
h /= 6
}
// h = Math.floor(h * 360)
// s = Math.floor(s * 100)
// l = Math.floor(l * 100)
return {
h: h,
s: s,
l: rgb2lum(r, g, b),
}
}
function hueGroup(hue, segs) {
var seg = 1 / segs,
haf = seg / 2
if (hue >= 1 - haf || hue <= haf) return 0
for (var i = 1; i < segs; i++) {
var mid = i * seg
if (hue >= mid - haf && hue <= mid + haf) return i
}
}
function satGroup(sat) {
return sat
}
function lumGroup(lum) {
return lum
}
function typeOf(val) {
return Object.prototype.toString.call(val).slice(8, -1)
}
var sort = isArrSortStable() ? Array.prototype.sort : stableSort
// must be used via stableSort.call(arr, fn)
function stableSort(fn) {
var type = typeOf(this[0])
if (type == 'Number' || type == 'String') {
var ord = {},
len = this.length,
val
for (var i = 0; i < len; i++) {
val = this[i]
if (ord[val] || ord[val] === 0) continue
ord[val] = i
}
return this.sort(function (a, b) {
return fn(a, b) || ord[a] - ord[b]
})
} else {
var ord = this.map(function (v) {
return v
})
return this.sort(function (a, b) {
return fn(a, b) || ord.indexOf(a) - ord.indexOf(b)
})
}
}
// test if js engine's Array#sort implementation is stable
function isArrSortStable() {
var str = 'abcdefghijklmnopqrstuvwxyz'
return (
'xyzvwtursopqmnklhijfgdeabc' ==
str
.split('')
.sort(function (a, b) {
return ~~(str.indexOf(b) / 2.3) - ~~(str.indexOf(a) / 2.3)
})
.join('')
)
}
// returns uniform pixel data from various img
// TODO?: if array is passed, createimagedata, createlement canvas? take a pxlen?
function getImageData(img, width) {
var can, ctx, imgd, buf8, buf32, height
switch (typeOf(img)) {
case 'HTMLImageElement':
can = document.createElement('canvas')
can.width = img.naturalWidth
can.height = img.naturalHeight
ctx = can.getContext('2d')
ctx.drawImage(img, 0, 0)
case 'Canvas':
case 'HTMLCanvasElement':
can = can || img
ctx = ctx || can.getContext('2d')
case 'CanvasRenderingContext2D':
case 'OffscreenCanvasRenderingContext2D':
ctx = ctx || img
can = can || ctx.canvas
imgd = ctx.getImageData(0, 0, can.width, can.height)
case 'ImageData':
imgd = imgd || img
width = imgd.width
if (typeOf(imgd.data) == 'CanvasPixelArray') buf8 = new Uint8Array(imgd.data)
else buf8 = imgd.data
case 'Array':
case 'CanvasPixelArray':
buf8 = buf8 || new Uint8Array(img)
case 'Uint8Array':
case 'Uint8ClampedArray':
buf8 = buf8 || img
buf32 = new Uint32Array(buf8.buffer)
case 'Uint32Array':
buf32 = buf32 || img
buf8 = buf8 || new Uint8Array(buf32.buffer)
width = width || buf32.length
height = buf32.length / width
}
return {
can: can,
ctx: ctx,
imgd: imgd,
buf8: buf8,
buf32: buf32,
width: width,
height: height,
}
}
// partitions a rect of wid x hgt into
// array of bboxes of w0 x h0 (or less)
function makeBoxes(wid, hgt, w0, h0) {
var wnum = ~~(wid / w0),
wrem = wid % w0,
hnum = ~~(hgt / h0),
hrem = hgt % h0,
xend = wid - wrem,
yend = hgt - hrem
var bxs = []
for (var y = 0; y < hgt; y += h0)
for (var x = 0; x < wid; x += w0) bxs.push({ x: x, y: y, w: x == xend ? wrem : w0, h: y == yend ? hrem : h0 })
return bxs
}
// iterates @bbox within a parent rect of width @wid; calls @fn, passing index within parent
function iterBox(bbox, wid, fn) {
var b = bbox,
i0 = b.y * wid + b.x,
i1 = (b.y + b.h - 1) * wid + (b.x + b.w - 1),
cnt = 0,
incr = wid - b.w + 1,
i = i0
do {
fn.call(this, i)
i += ++cnt % b.w == 0 ? incr : 1
} while (i <= i1)
}
// returns array of hash keys sorted by their values
function sortedHashKeys(obj, desc) {
var keys = []
for (var key in obj) keys.push(key)
return sort.call(keys, function (a, b) {
return desc ? obj[b] - obj[a] : obj[a] - obj[b]
})
}
export default RgbQuant
+9
View File
@@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
/// <reference types="unplugin-icons/types/svelte" />
+7
View File
@@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}
+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
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
"composite": true,
"baseUrl": ".",
"paths": {
"$lib": ["./src/lib"],
"$lib/*": ["./src/lib/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
}

Some files were not shown because too many files have changed in this diff Show More