Multiple refactors

- Use aria-labelledby instead of aria-label where possible
- Replace magic numbers with explicitly defined constants
- Add input textboxes for values previously only changed with sliders
This commit is contained in:
daylily
2025-04-03 18:06:00 -04:00
parent 09d5022b1d
commit ccdb91a243
22 changed files with 168 additions and 49 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
<ModeWatcher />
<Toaster position="bottom-center" duration={2000} />
<div class="max-w-screen-xl min-h-screen m-auto p-8 stack gap-4">
<div class="max-w-screen-xl min-h-dvh m-auto p-8 stack gap-4">
{#if unsupported}
<Unsupported />
{:else}
@@ -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}
/>
+10 -3
View File
@@ -1,15 +1,22 @@
<script lang="ts">
import IconInfo from '~icons/material-symbols/info'
import type { PopoverTriggerProps } from 'bits-ui'
import IconInfo from '~icons/material-symbols/info'
import * as Popover from '$lib/components/ui/popover'
interface Props extends PopoverTriggerProps {}
const { children, ...restProps }: Props = $props()
</script>
<Popover.Root>
<Popover.Trigger aria-label="More info">
<Popover.Trigger aria-label="More info" {...restProps}>
<IconInfo class="text-muted-foreground text-sm" aria-hidden />
</Popover.Trigger>
<Popover.Content class="text-sm">
<slot />
{#if children}
{@render children()}
{/if}
</Popover.Content>
</Popover.Root>
+10
View File
@@ -0,0 +1,10 @@
export const INKCLIP_VID = 0x1209
export const INKCLIP_PID = 0xc9c9
export const INKCLIP_WIDTH = 200 // px
export const INKCLIP_HEIGHT = 200 // px
export const INKCLIP_ASPECT_RATIO = INKCLIP_WIDTH / INKCLIP_HEIGHT
export const BYTES_IN_A_ROW = INKCLIP_WIDTH / 8
export const WRITE_TIME = 2000 // ms
+2 -1
View File
@@ -1,8 +1,9 @@
import { INKCLIP_PID, INKCLIP_VID } from '$lib/constants'
import { getContext, setContext } from 'svelte'
import { toast } from 'svelte-sonner'
function isInkclip(dev: HIDDevice) {
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
return dev.vendorId == INKCLIP_VID && dev.productId == INKCLIP_PID
}
export interface DeviceContext {
+4 -3
View File
@@ -4,6 +4,7 @@ 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 { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
export interface RenderedContext {
rendered: number[] | null
@@ -15,7 +16,7 @@ export function getRenderedContext(): Readonly<RenderedContext> {
return getContext(RenderedContextToken)
}
const scaler = new Scaler(200, 200)
const scaler = new Scaler(INKCLIP_WIDTH, INKCLIP_HEIGHT)
export function createRenderedContext(
imageCtx: Readonly<ImageContext>,
@@ -33,7 +34,7 @@ export function createRenderedContext(
})
)
const canvas = new OffscreenCanvas(200, 200)
const canvas = new OffscreenCanvas(INKCLIP_WIDTH, INKCLIP_HEIGHT)
const canvasCtx = canvas.getContext('2d', {
willReadFrequently: true,
})!
@@ -49,7 +50,7 @@ export function createRenderedContext(
withTransform(canvasCtx, config.transform, () => {
const bg = config.backgroundColor
canvasCtx.fillStyle = `rgb(${bg} ${bg} ${bg})`
canvasCtx.fillRect(0, 0, 200, 200)
canvasCtx.fillRect(0, 0, INKCLIP_WIDTH, INKCLIP_HEIGHT)
const { dx, dy, dWidth, dHeight } = scaler.scale(config.scaleMode, bitmap)
canvasCtx.drawImage(bitmap, dx, dy, dWidth, dHeight)
})
+7 -2
View File
@@ -1,3 +1,5 @@
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
export type Rotation = 0 | 90 | 180 | 270
export type Side = 'obverse' | 'reverse'
export type Operation = 'cw' | 'ccw' | 'h' | 'v'
@@ -52,15 +54,18 @@ function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T {
}
export function withTransform<T>(ctx: Context2D, transform: Transform, action: () => T): T {
const centerX = INKCLIP_WIDTH / 2
const centerY = INKCLIP_HEIGHT / 2
return withCtx(
ctx,
() => {
ctx.translate(100, 100)
ctx.translate(centerX, centerY)
if (transform.side === 'reverse') {
ctx.scale(-1, 1)
}
ctx.rotate((transform.rotation / 180) * Math.PI)
ctx.translate(-100, -100)
ctx.translate(-centerX, -centerY)
},
action
)
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { INKCLIP_PID, INKCLIP_VID } from '$lib/constants'
import { getDeviceContext } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext()
@@ -9,8 +10,8 @@
const devs = await navigator.hid.requestDevice({
filters: [
{
vendorId: 0xc0de,
productId: 0xcafe,
vendorId: INKCLIP_VID,
productId: INKCLIP_PID,
},
],
})
@@ -2,6 +2,7 @@
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'
@@ -10,10 +11,18 @@
let value = $derived(config.backgroundColor)
</script>
<div class="stack gap-4" role="group" aria-label="Background color">
<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"> = {value} </span>
<span class="font-normal text-muted-foreground">
=<ImplicitNumericInput
min={0}
max={0xff}
{value}
onchange={v => (config.bias = value = v)}
aria-labelledby="background-color-input-label"
/>
</span>
<MoreInfo>The color used for transparent pixels.</MoreInfo>
</div>
@@ -22,7 +31,7 @@
bind:value
onValueCommit={() => (config.backgroundColor = value)}
min={0}
max={255}
max={0xff}
step={1}
aria-labelledby="background-color-input-label"
/>
@@ -1,5 +1,4 @@
<script lang="ts">
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
import { Transform } from '$lib/image/transform'
import { Button } from '$lib/components/ui/button'
@@ -15,6 +14,8 @@
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
const imageCtx = getImageContext()
const config = getConversionConfig()
@@ -23,13 +24,13 @@
function imageNonSquare() {
if (imageCtx.image === null) return false
return imageCtx.image.height !== imageCtx.image.width
return imageCtx.image.height * INKCLIP_WIDTH !== imageCtx.image.width * INKCLIP_HEIGHT
}
function restoreDefaultImageSettings() {
config.scaleMode = 'fit'
config.transform = new Transform()
config.backgroundColor = 255
config.backgroundColor = 0xff
config.ditheringKernel = DEFAULT_DITHERING_KERNEL
config.contrast = 0
config.bias = 0
@@ -2,6 +2,7 @@
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'
@@ -10,10 +11,18 @@
let value = $derived(Math.round(config.bias * 100))
</script>
<div class="stack gap-4" role="group" aria-label="Bias">
<div class="stack gap-4" role="group" aria-labelledby="bias-input-label">
<div class="row gap-1 text-sm">
<Label id="bias-input-label">Bias</Label>
<span class="font-normal text-muted-foreground"> = {value}% </span>
<span class="font-normal text-muted-foreground">
=<ImplicitNumericInput
min={-100}
max={100}
{value}
onchange={v => (config.bias = (value = v) / 100)}
aria-labelledby="bias-input-label"
/>%
</span>
<MoreInfo>
Whether the conversion algorithm should bias the entire image towards white (negative) or black (positive).
</MoreInfo>
@@ -2,6 +2,7 @@
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'
@@ -10,10 +11,18 @@
let value = $derived(Math.round(config.contrast * 100))
</script>
<div class="stack gap-4" role="group" aria-label="Contrast">
<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"> = {value}% </span>
<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>
@@ -11,7 +11,7 @@
let { checked, onchange }: Props = $props()
</script>
<div class="stack gap-2" role="group" aria-label="Dither">
<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>
@@ -28,7 +28,11 @@
}
</script>
<div class={cn('grow stack gap-2', hidden ? ['invisible'] : [])} role="group" aria-label="Dithering kernel">
<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>
@@ -1,10 +1,12 @@
<script lang="ts">
import * as Alert from '$lib/components/ui/alert'
import IconAspectRatio from '~icons/material-symbols/aspect-ratio-outline'
import { INKCLIP_ASPECT_RATIO } from '$lib/constants'
</script>
<Alert.Root>
<IconAspectRatio aria-hidden />
<Alert.Title>Image is not 1:1 ratio</Alert.Title>
<Alert.Title>Image is not {String(Math.round(INKCLIP_ASPECT_RATIO * 100) / 100)}:1 ratio</Alert.Title>
<Alert.Description>You need to choose how to scale your image.</Alert.Description>
</Alert.Root>
@@ -57,7 +57,7 @@
config.transform = config.transform.op(op as Operation)
}}
>
<Icon aria-hidden />
<Icon aria-label={description} />
</Button>
{/snippet}
</Tooltip.Trigger>
@@ -12,4 +12,4 @@
const filesCtx = getFilesContext()
</script>
<Input bind:ref type="file" accept="image/*" bind:files={filesCtx.files} />
<Input bind:ref type="file" accept="image/*" bind:files={filesCtx.files} aria-labelledby="preview-section-label" />
@@ -5,6 +5,7 @@
import { cn } from '$lib/utils'
import { drawQuantizedData, freshContext } from './common.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
@@ -23,15 +24,17 @@
<div class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit relative" role="img" aria-labelledby="preview-1x-label">
<div class="p-[3px] border-[1px] border-[#888]">
<canvas
class={cn('w-[200px] h-[200px]', hasRendered || 'hidden')}
class={cn(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{INKCLIP_WIDTH}px"
style:height="{INKCLIP_HEIGHT}px"
bind:this={canvasEl}
height={200}
width={200}
height={INKCLIP_HEIGHT}
width={INKCLIP_WIDTH}
></canvas>
{#if !hasRendered}
<div class="col justify-center w-[200px] h-[200px] text-muted">
<div class="col justify-center text-muted" style:width="{INKCLIP_WIDTH}px" style:height="{INKCLIP_HEIGHT}px">
<IconHideImage class="text-3xl" aria-label="No image" />
</div>
{/if}
@@ -6,6 +6,7 @@
import { drawQuantizedData, freshContext } from './common.svelte'
import { getFilesContext } from '$lib/contexts/files.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
interface Props {
fileInputEl: HTMLInputElement | null
@@ -43,11 +44,13 @@
>
<div class="p-[6px] border-[1px] border-[#888]">
<canvas
class={cn('w-[400px] h-[400px]', hasRendered || 'hidden')}
class={cn(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{INKCLIP_WIDTH * 2}px"
style:height="{INKCLIP_HEIGHT * 2}px"
bind:this={canvasEl}
height={200}
width={200}
height={INKCLIP_HEIGHT}
width={INKCLIP_WIDTH}
></canvas>
{#if !hasRendered}
<button
@@ -55,9 +58,11 @@
ondragleave={() => (fileOverDragZone = false)}
ondragover={() => (fileOverDragZone = true)}
class={cn(
'col justify-center w-[400px] h-[400px] rounded-xl hover:cursor-pointer text-muted',
`col justify-center rounded-xl hover:cursor-pointer text-muted`,
fileOverDragZone && 'outline-dashed',
)}
style:width="{INKCLIP_WIDTH * 2}px"
style:height="{INKCLIP_HEIGHT * 2}px"
tabindex={-1}
aria-label="Choose file"
>
+12 -8
View File
@@ -1,3 +1,5 @@
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
export function freshContext(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')!
ctx.imageSmoothingEnabled = false
@@ -5,17 +7,19 @@ export function freshContext(el: HTMLCanvasElement) {
}
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
const imageData = ctx.createImageData(200, 200)
const imageData = ctx.createImageData(INKCLIP_WIDTH, INKCLIP_HEIGHT)
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const colorIx = data.at(y * 200 + x)
for (let y = 0; y < INKCLIP_HEIGHT; y++) {
for (let x = 0; x < INKCLIP_WIDTH; x++) {
const dataIx = y * INKCLIP_WIDTH + x
const bitmapIx = dataIx * 4
const colorIx = data.at(dataIx)
const color = colorIx === 0 ? 0xcc : 0x11
imageData.data[y * 800 + x * 4] = color
imageData.data[y * 800 + x * 4 + 1] = color
imageData.data[y * 800 + x * 4 + 2] = color
imageData.data[y * 800 + x * 4 + 3] = 0xff
imageData.data[bitmapIx] = color
imageData.data[bitmapIx + 1] = color
imageData.data[bitmapIx + 2] = color
imageData.data[bitmapIx + 3] = 0xff
}
}
ctx.putImageData(imageData, 0, 0)
+7 -6
View File
@@ -4,6 +4,7 @@
import { toast } from 'svelte-sonner'
import { getDeviceContext } from '$lib/contexts/device.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { BYTES_IN_A_ROW, INKCLIP_HEIGHT, INKCLIP_WIDTH, WRITE_TIME } from '$lib/constants'
interface Props {
onprogress: (inPropgress: boolean) => void
@@ -31,15 +32,15 @@
}
}
const buffer = new Uint8Array(5000)
for (let y = 0; y < 200; y++) {
for (let xStride = 0; xStride < 25; xStride++) {
const buffer = new Uint8Array(INKCLIP_HEIGHT * BYTES_IN_A_ROW)
for (let y = 0; y < INKCLIP_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 * 200 + xStride * 8 + xStroll
const index = y * INKCLIP_WIDTH + xStride * 8 + xStroll
cell |= renderedCtx.rendered[index] << xStroll
}
buffer[y * 25 + xStride] = cell
buffer[y * BYTES_IN_A_ROW + xStride] = cell
}
}
@@ -53,7 +54,7 @@
} finally {
setTimeout(() => {
inProgress = false
}, 2000)
}, WRITE_TIME)
}
toast.success('Wrote pattern to device')
+4
View File
@@ -4,3 +4,7 @@ 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', '⁇')
}