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:
+1
-1
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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,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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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', '⁇')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user