Move the webapp into a subdirectory
This commit is contained in:
@@ -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?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"arrowParens": "avoid",
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Write to Inkclip
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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 |
@@ -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>
|
||||
Generated
+3358
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -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 |
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Disallow: /cdn-cgi/
|
||||
Disallow: /assets/
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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()} ©
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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', '⁇')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+50
@@ -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[]
|
||||
}
|
||||
Vendored
+918
@@ -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
|
||||
@@ -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
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="unplugin-icons/types/svelte" />
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user