Refactor codebase

- Support file drag-and-drop
- Fix wrong usage of tooltips
- Add tailwind shorthands for layouting
- Complete ARIA annotating
This commit is contained in:
daylily
2025-04-03 02:36:01 -04:00
parent 932ba0b259
commit cd0d2a9572
32 changed files with 479 additions and 392 deletions
+3 -3
View File
@@ -12,11 +12,11 @@
<ModeWatcher /> <ModeWatcher />
<Toaster position="bottom-center" duration={2000} /> <Toaster position="bottom-center" duration={2000} />
<div class="max-w-screen-xl min-h-screen m-auto p-8 flex flex-col gap-4"> <div class="max-w-screen-xl min-h-screen m-auto p-8 stack gap-4">
{#if unsupported} {#if unsupported}
<Unsupported class="grow" /> <Unsupported />
{:else} {:else}
<Main class="grow" /> <Main />
{/if} {/if}
<Footer /> <Footer />
</div> </div>
+22 -2
View File
@@ -69,10 +69,30 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: IBM Plex Sans Variable, IBM Plex Sans, Fira Sans, sans-serif; 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 { @layer utilities {
.multimodal { .stack {
@apply flex gap-1 items-end; @apply flex flex-col;
}
.stack-h {
@apply flex;
}
.row {
@apply flex items-center;
}
.col {
@apply flex flex-col items-center;
} }
} }
@@ -2,15 +2,12 @@
import IconInfo from '~icons/material-symbols/info' import IconInfo from '~icons/material-symbols/info'
import * as Popover from '$lib/components/ui/popover' import * as Popover from '$lib/components/ui/popover'
import { Label } from '$lib/components/ui/label'
</script> </script>
<Popover.Root> <Popover.Root>
<Label> <Popover.Trigger aria-label="More info">
<Popover.Trigger> <IconInfo class="text-muted-foreground text-sm" aria-hidden />
<IconInfo class="text-muted-foreground inline" /> </Popover.Trigger>
</Popover.Trigger>
</Label>
<Popover.Content class="text-sm"> <Popover.Content class="text-sm">
<slot /> <slot />
+19
View File
@@ -0,0 +1,19 @@
import { getContext, setContext } from 'svelte'
export interface FilesContext {
files: FileList
}
export const FilesContextToken = Symbol('files')
export function getFilesContext(): FilesContext {
return getContext(FilesContextToken)
}
export function createFilesContext(): FilesContext {
const ctx: FilesContext = $state({
files: new DataTransfer().files,
})
return setContext(FilesContextToken, ctx)
}
+41
View File
@@ -0,0 +1,41 @@
import { getContext, setContext } from 'svelte'
import type { FilesContext } from './files.svelte'
import { toast } from 'svelte-sonner'
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)
}
@@ -1,30 +1,30 @@
import { getContext, setContext } from 'svelte' import { getContext, setContext } from 'svelte'
import type { ConversionConfig } from './config.svelte' import type { ImageContext } from './image.svelte'
import { Quantizer } from '$lib/image/quantizer'
import { withTransform } from '$lib/image/transform' import { withTransform } from '$lib/image/transform'
import type { ConversionConfig } from './config.svelte'
import { Scaler } from '$lib/image/scaler' import { Scaler } from '$lib/image/scaler'
import { Quantizer } from '$lib/image/quantizer'
const scaler = new Scaler(200, 200) export interface RenderedContext {
export interface BitmapContext {
image: ImageBitmap | null
rendered: number[] | null rendered: number[] | null
} }
export const BitmapContextToken = Symbol('bitmap') export const RenderedContextToken = Symbol('rendered')
export function getBitmapContext(): BitmapContext { export function getRenderedContext(): Readonly<RenderedContext> {
return getContext(BitmapContextToken) return getContext(RenderedContextToken)
} }
export function createBitmapContext(config: ConversionConfig): BitmapContext { const scaler = new Scaler(200, 200)
const ctx: BitmapContext = $state({
image: null, export function createRenderedContext(
imageCtx: Readonly<ImageContext>,
config: ConversionConfig
): Readonly<RenderedContext> {
const ctx: RenderedContext = $state({
rendered: null, rendered: null,
}) })
const canvas = new OffscreenCanvas(200, 200)
const quantizer = $derived( const quantizer = $derived(
new Quantizer({ new Quantizer({
ditheringKernel: config.ditheringKernel, ditheringKernel: config.ditheringKernel,
@@ -33,31 +33,30 @@ export function createBitmapContext(config: ConversionConfig): BitmapContext {
}) })
) )
const canvas = new OffscreenCanvas(200, 200)
const canvasCtx = canvas.getContext('2d', {
willReadFrequently: true,
})!
$effect(() => { $effect(() => {
const bitmap = ctx.image const bitmap = imageCtx.image
if (bitmap === null) { if (bitmap === null) {
ctx.rendered = null ctx.rendered = null
return return
} }
const canvasCtx = canvas.getContext('2d', {
willReadFrequently: true,
})!
withTransform(canvasCtx, config.transform, () => { withTransform(canvasCtx, config.transform, () => {
const nonNullBitmap = bitmap
const bg = config.backgroundColor const bg = config.backgroundColor
canvasCtx.fillStyle = `rgb(${bg} ${bg} ${bg})` canvasCtx.fillStyle = `rgb(${bg} ${bg} ${bg})`
canvasCtx.fillRect(0, 0, 200, 200) canvasCtx.fillRect(0, 0, 200, 200)
const { dx, dy, dWidth, dHeight } = scaler[config.scaleMode](nonNullBitmap) const { dx, dy, dWidth, dHeight } = scaler.scale(config.scaleMode, bitmap)
canvasCtx.drawImage(nonNullBitmap, dx, dy, dWidth, dHeight) canvasCtx.drawImage(bitmap, dx, dy, dWidth, dHeight)
}) })
const quantizedData = quantizer.reduce(canvasCtx) const quantizedData = quantizer.reduce(canvasCtx)
ctx.rendered = quantizedData ctx.rendered = quantizedData
}) })
return setContext(BitmapContextToken, ctx) return setContext(RenderedContextToken, ctx)
} }
+4
View File
@@ -72,4 +72,8 @@ export class Scaler {
dHeight: this.canvasHeight, dHeight: this.canvasHeight,
} }
} }
scale(mode: ScaleMode, image: ImageBitmap): DrawParameters {
return this[mode](image)
}
} }
+6 -1
View File
@@ -1,5 +1,6 @@
export type Rotation = 0 | 90 | 180 | 270 export type Rotation = 0 | 90 | 180 | 270
export type Side = 'obverse' | 'reverse' 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 * A transform of a square image is represented by first rotating it by a multiple of 90 degrees, and then optionally
@@ -32,11 +33,15 @@ export class Transform {
const newAngle = (this.rotation + 180) % 360 const newAngle = (this.rotation + 180) % 360
return new Transform(newSide, newAngle as Rotation) return new Transform(newSide, newAngle as Rotation)
} }
op(op: Operation): Transform {
return this[op]()
}
} }
export type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D export type Context2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
export function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T { function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T {
ctx.save() ctx.save()
try { try {
pre() pre()
+7 -7
View File
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { mode, toggleMode } from 'mode-watcher'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import IconLightMode from '~icons/material-symbols/light-mode' import IconLightMode from '~icons/material-symbols/light-mode'
import IconDarkMode from '~icons/material-symbols/dark-mode' import IconDarkMode from '~icons/material-symbols/dark-mode'
import { mode, toggleMode } from 'mode-watcher'
</script> </script>
<footer class="flex items-center text-sm text-muted-foreground"> <footer class="row text-sm text-muted-foreground">
<div class="grow"> <div class="grow">
2025 &copy; <a class="hover:underline" href="https://dayli.ly">daylily</a> 2025 &copy; <a class="no-underline hover:underline" href="https://dayli.ly">daylily</a>
</div> </div>
<Button size="icon" variant="ghost" onclick={toggleMode}> <Button size="icon" variant="ghost" onclick={toggleMode} aria-label="Toggle color scheme">
{#if $mode === 'dark'} {#if $mode === 'dark'}
<IconDarkMode /> <IconDarkMode aria-hidden />
{:else} {:else}
<IconLightMode /> <IconLightMode aria-hidden />
{/if} {/if}
</Button> </Button>
</footer> </footer>
+11 -14
View File
@@ -1,33 +1,30 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { Separator } from '$lib/components/ui/separator' import { Separator } from '$lib/components/ui/separator'
import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte' import ConnectSection from '$lib/layouts/connect/ConnectSection.svelte'
import EditSection from '$lib/layouts/edit/EditSection.svelte' import EditSection from '$lib/layouts/edit/EditSection.svelte'
import WriteSection from '$lib/layouts/write/WriteSection.svelte' import WriteSection from '$lib/layouts/write/WriteSection.svelte'
import { createDeviceContext } from '$lib/contexts/device.svelte' import { createDeviceContext } from '$lib/contexts/device.svelte'
import { createConversionConfig } from '$lib/contexts/config.svelte' import { createConversionConfig } from '$lib/contexts/config.svelte'
import { createBitmapContext } from '$lib/contexts/bitmap.svelte' import { createFilesContext } from '$lib/contexts/files.svelte'
import { createImageContext } from '$lib/contexts/image.svelte'
interface Props extends HTMLAttributes<HTMLElement> {} import { createRenderedContext } from '$lib/contexts/rendered.svelte'
const { class: classNames, ...restProps }: Props = $props()
createDeviceContext() createDeviceContext()
const config = createConversionConfig() const config = createConversionConfig()
createBitmapContext(config) const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config)
</script> </script>
<main class={cn('w-full flex flex-col gap-4', classNames)} {...restProps}> <main class="grow w-full stack gap-4">
<ConnectSection /> <ConnectSection />
<Separator /> <Separator decorative />
<EditSection class="grow" /> <EditSection />
<Separator /> <Separator decorative />
<WriteSection /> <WriteSection />
</main> </main>
+8 -15
View File
@@ -1,20 +1,10 @@
<script lang="ts"> <script lang="ts">
import IconDisabledByDefault from '~icons/material-symbols/disabled-by-default' import IconDisabledByDefault from '~icons/material-symbols/disabled-by-default'
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import * as AlertDialog from '$lib/components/ui/alert-dialog' import * as AlertDialog from '$lib/components/ui/alert-dialog'
interface Props extends HTMLAttributes<HTMLElement> {}
const { class: classNames, ...restProps }: Props = $props()
</script> </script>
<main <main class="grow row justify-center gap-2 font-medium text-xl text-muted-foreground">
class={cn('flex items-center justify-center gap-2 font-medium text-xl text-muted-foreground', classNames)}
{...restProps}
>
<IconDisabledByDefault /> <IconDisabledByDefault />
<div>Not Supported</div> <div>Not Supported</div>
</main> </main>
@@ -25,11 +15,14 @@
<AlertDialog.Content> <AlertDialog.Content>
<AlertDialog.Title>Browser not supported</AlertDialog.Title> <AlertDialog.Title>Browser not supported</AlertDialog.Title>
<AlertDialog.Description> <AlertDialog.Description class="stack gap-2">
<p class="mb-2">Write to Inkclip uses the WebHID API, which is not supported by your browser.</p> <p>Write to Inkclip uses the WebHID API, which is not supported by your browser.</p>
<p> <p>
We recommend using a Chromium-based browser, such as a recent version of Google Chrome, Microsoft Edge, Opera, We recommend using a Chromium-based browser, such as a recent version of
or Arc. <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> </p>
</AlertDialog.Description> </AlertDialog.Description>
</AlertDialog.Content> </AlertDialog.Content>
+2 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { getDeviceContext } from '$lib/contexts/device.svelte' import { getDeviceContext } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext() const deviceCtx = getDeviceContext()
@@ -14,10 +15,7 @@
], ],
}) })
if (devs == undefined || devs[0] == undefined) { if (devs == undefined || devs[0] == undefined) return
return
}
deviceCtx.device = devs[0] deviceCtx.device = devs[0]
} }
</script> </script>
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import IconPending from '~icons/material-symbols/pending' import IconPending from '~icons/material-symbols/pending'
import IconCheckCircle from '~icons/material-symbols/check-circle' import IconCheckCircle from '~icons/material-symbols/check-circle'
import ConnectButton from './ConnectButton.svelte' import ConnectButton from './ConnectButton.svelte'
import { getDeviceContext } from '$lib/contexts/device.svelte' import { getDeviceContext } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext() const deviceCtx = getDeviceContext()
</script> </script>
<section class="flex items-center gap-2 max-lg:flex-col max-lg:items-stretch"> <section class="row gap-2 max-lg:stack max-lg:items-stretch" aria-labelledby="connect-section-label">
<div class="grow"> <div class="grow">
<h1 class="font-semibold text-xl/8">Connect to a device</h1> <h2 class="font-semibold text-xl/8" id="connect-section-label">Connect to a device</h2>
<div class="text-sm"> <div class="row gap-1 text-sm">
{#if deviceCtx.device === null} {#if deviceCtx.device === null}
<IconPending class="inline" /> Not connected to any device yet. Plug in your device, and click on the button to select <IconPending aria-hidden /> Not connected to any device yet. Plug in your device, and click on the button to select
it. it.
{:else} {:else}
<IconCheckCircle class="inline" /> Successfully conected to device. If you want to, you can connect to another device <IconCheckCircle aria-hidden /> Successfully conected to device. If you want to, you can connect to another device
instead. instead.
{/if} {/if}
</div> </div>
+4 -11
View File
@@ -1,25 +1,18 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { Separator } from '$lib/components/ui/separator' import { Separator } from '$lib/components/ui/separator'
import PreviewSection from './preview/PreviewSection.svelte' import PreviewSection from './preview/PreviewSection.svelte'
import ControlsSection from './controls/ControlsSection.svelte' import ControlsSection from './controls/ControlsSection.svelte'
import { createMediaQuery } from '$lib/utils/media.svelte' import { createMediaQuery } from '$lib/utils/media.svelte'
interface Props extends HTMLAttributes<HTMLDivElement> {}
const { class: classNames, ...restProps }: Props = $props()
const mobileMediaQuery = createMediaQuery('(max-width: 1024px)') const mobileMediaQuery = createMediaQuery('(max-width: 1024px)')
const separatorOrientation = $derived(mobileMediaQuery.matches ? 'horizontal' : 'vertical') const separatorOrientation = $derived(mobileMediaQuery.matches ? 'horizontal' : 'vertical')
</script> </script>
<div class={cn('flex max-lg:flex-col gap-4', classNames)} {...restProps}> <div class="grow stack-h max-lg:stack gap-4">
<PreviewSection /> <PreviewSection />
<Separator orientation={separatorOrientation} /> <Separator decorative orientation={separatorOrientation} />
<ControlsSection class="grow" /> <ControlsSection />
</div> </div>
@@ -1,25 +1,26 @@
<script lang="ts"> <script lang="ts">
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider' import { Slider } from '$lib/components/ui/slider'
import Infotip from '$lib/components/Infotip.svelte' import MoreInfo from '$lib/components/MoreInfo.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte' import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig() const config = getConversionConfig()
let value = $derived(config.backgroundColor)
</script> </script>
<div class="flex flex-col gap-4"> <div class="stack gap-4" role="group" aria-label="Background color">
<Label id="background-color-input-label" class="multimodal"> <div class="row gap-1 text-sm">
Background Color <Label id="background-color-input-label">Background Color</Label>
<span class="font-normal text-muted-foreground"> = {config.backgroundColor} </span> <span class="font-normal text-muted-foreground"> = {value} </span>
<Infotip>The color used for transparent pixels.</Infotip> <MoreInfo>The color used for transparent pixels.</MoreInfo>
</Label> </div>
<Slider <Slider
type="single" type="single"
value={config.backgroundColor} bind:value
onValueCommit={v => { onValueCommit={() => (config.backgroundColor = value)}
config.backgroundColor = v
}}
min={0} min={0}
max={255} max={255}
step={1} step={1}
@@ -1,15 +1,10 @@
<script lang="ts"> <script lang="ts">
import IconEditOff from '~icons/material-symbols/edit-off'
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer' import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
import { Transform } from '$lib/image/transform' import { Transform } from '$lib/image/transform'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import Separator from '$lib/components/ui/separator/separator.svelte' import Separator from '$lib/components/ui/separator/separator.svelte'
import IconEditOff from '~icons/material-symbols/edit-off'
import AspectRatioAlert from './dimensions/AspectRatioAlert.svelte' import AspectRatioAlert from './dimensions/AspectRatioAlert.svelte'
import ScaleModeToggleGroup from './dimensions/ScaleModeToggleGroup.svelte' import ScaleModeToggleGroup from './dimensions/ScaleModeToggleGroup.svelte'
import TransformControls from './dimensions/TransformControls.svelte' import TransformControls from './dimensions/TransformControls.svelte'
@@ -17,21 +12,18 @@
import DitherControls from './conversion/dither/DitherControls.svelte' import DitherControls from './conversion/dither/DitherControls.svelte'
import ContrastSlider from './conversion/ContrastSlider.svelte' import ContrastSlider from './conversion/ContrastSlider.svelte'
import BiasSlider from './conversion/BiasSlider.svelte' import BiasSlider from './conversion/BiasSlider.svelte'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte' import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
interface Props extends HTMLAttributes<HTMLElement> {} const imageCtx = getImageContext()
const { class: className, ...restProps }: Props = $props()
const bitmapCtx = getBitmapContext()
const config = getConversionConfig() const config = getConversionConfig()
const transformDisabled = $derived(bitmapCtx.image === null) const transformDisabled = $derived(imageCtx.image === null)
function imageNonSquare() { function imageNonSquare() {
if (bitmapCtx.image === null) return false if (imageCtx.image === null) return false
return bitmapCtx.image.height !== bitmapCtx.image.width return imageCtx.image.height !== imageCtx.image.width
} }
function restoreDefaultImageSettings() { function restoreDefaultImageSettings() {
@@ -44,14 +36,14 @@
} }
</script> </script>
<section class={cn('flex flex-col gap-4', className)} {...restProps}> <section class="grow stack gap-4" aria-labelledby="controls-section-label">
<h1 class="font-semibold text-xl/6">Edit image</h1> <h2 class="font-semibold text-xl/6" id="controls-section-label">Edit image</h2>
{#if imageNonSquare()} {#if imageNonSquare()}
<AspectRatioAlert /> <AspectRatioAlert />
{/if} {/if}
<div class="flex gap-4"> <div class="row gap-4">
{#if imageNonSquare()} {#if imageNonSquare()}
<ScaleModeToggleGroup /> <ScaleModeToggleGroup />
{/if} {/if}
@@ -78,7 +70,7 @@
<Separator /> <Separator />
<Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}> <Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}>
<IconEditOff /> <IconEditOff aria-hidden />
Reset All Reset All
</Button> </Button>
</section> </section>
@@ -1,33 +1,31 @@
<script lang="ts"> <script lang="ts">
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider' import { Slider } from '$lib/components/ui/slider'
import Infotip from '$lib/components/Infotip.svelte' import MoreInfo from '$lib/components/MoreInfo.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte' import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig() const config = getConversionConfig()
let value = $derived(config.bias)
</script> </script>
<div class="flex flex-col gap-4"> <div class="stack gap-4" role="group" aria-label="Bias">
<Label for="bias-input" class="multimodal"> <div class="row gap-1 text-sm">
<div> <Label id="bias-input-label">Bias</Label>
Bias <span class="font-normal text-muted-foreground"> = {Math.floor(value * 100)}% </span>
<span class="font-normal text-muted-foreground"> = {Math.floor(config.bias * 100)}% </span> <MoreInfo>
</div> Whether the conversion algorithm should bias the entire image towards white (negative) or black (positive).
</MoreInfo>
<Infotip> </div>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</Infotip>
</Label>
<Slider <Slider
type="single" type="single"
value={config.bias} bind:value
onValueCommit={v => { onValueCommit={() => (config.bias = value)}
config.bias = v
}}
min={-1} min={-1}
max={1} max={1}
step={0.01} step={0.01}
id="bias-input" aria-labelledby="bias-input-label"
/> />
</div> </div>
@@ -1,33 +1,31 @@
<script lang="ts"> <script lang="ts">
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import { Slider } from '$lib/components/ui/slider' import { Slider } from '$lib/components/ui/slider'
import Infotip from '$lib/components/Infotip.svelte' import MoreInfo from '$lib/components/MoreInfo.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte' import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig() const config = getConversionConfig()
let value = $derived(config.contrast)
</script> </script>
<div class="flex flex-col gap-4"> <div class="stack gap-4" role="group" aria-label="Contrast">
<Label for="contrast-input" class="multimodal"> <div class="row gap-1 text-sm">
<div> <Label id="contrast-input-label">Contrast</Label>
Contrast <span class="font-normal text-muted-foreground"> = {Math.floor(value * 100)}% </span>
<span class="font-normal text-muted-foreground"> = {Math.floor(config.contrast * 100)}% </span> <MoreInfo>
</div>
<Infotip>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale. How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</Infotip> </MoreInfo>
</Label> </div>
<Slider <Slider
type="single" type="single"
value={config.contrast} bind:value
onValueCommit={v => { onValueCommit={() => (config.contrast = value)}
config.contrast = v
}}
min={0} min={0}
max={1} max={1}
step={0.01} step={0.01}
id="contrast-input" aria-labelledby="contrast-input-label"
/> />
</div> </div>
@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer' import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import DitheringKernelDropdown from './DitheringKernelDropdown.svelte' import DitheringKernelDropdown from './DitheringKernelDropdown.svelte'
import DitherSwitch from './DitherSwitch.svelte' import DitherSwitch from './DitherSwitch.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig() const config = getConversionConfig()
let lastDitheringKernel: DitheringKernel = $state(DEFAULT_DITHERING_KERNEL) let lastDitheringKernel: DitheringKernel = $state(DEFAULT_DITHERING_KERNEL)
@@ -14,20 +16,14 @@
}) })
</script> </script>
<div class="flex gap-4"> <div class="stack-h gap-4">
<DitherSwitch <DitherSwitch
checked={config.ditheringKernel !== null} checked={config.ditheringKernel !== null}
onCheckedChange={c => { onchange={c => (config.ditheringKernel = c ? lastDitheringKernel : null)}
if (c) {
config.ditheringKernel = lastDitheringKernel
} else {
config.ditheringKernel = null
}
}}
/> />
<DitheringKernelDropdown <DitheringKernelDropdown
class={config.ditheringKernel !== null ? [] : ['invisible']} hidden={config.ditheringKernel === null}
value={lastDitheringKernel} value={lastDitheringKernel}
onchange={v => { onchange={v => {
config.ditheringKernel = v config.ditheringKernel = v
@@ -1,25 +1,23 @@
<script lang="ts"> <script lang="ts">
import { Switch } from '$lib/components/ui/switch' import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import Infotip from '$lib/components/Infotip.svelte' import MoreInfo from '$lib/components/MoreInfo.svelte'
interface Props { interface Props {
checked: boolean checked: boolean
onCheckedChange: (checked: boolean) => void onchange: (checked: boolean) => void
} }
let { checked, onCheckedChange }: Props = $props() let { checked, onchange }: Props = $props()
</script> </script>
<div class="flex flex-col gap-2"> <div class="stack gap-2" role="group" aria-label="Dither">
<div> <div class="row gap-1">
<Label for="dither-switch" class="multimodal"> <Label id="dither-switch-label">Dither</Label>
<div>Dither</div> <MoreInfo>Dithering uses different dot densities to simulate shades of gray.</MoreInfo>
<Infotip>Dithering uses different dot densities to simulate shades of gray.</Infotip>
</Label>
</div> </div>
<div class="grow flex items-center"> <div class="grow row items-center">
<Switch id="dither-switch" {checked} {onCheckedChange} /> <Switch {checked} onCheckedChange={onchange} aria-labelledby="dither-switch-label" />
</div> </div>
</div> </div>
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { type DitheringKernel } from '$lib/image/quantizer' import { type DitheringKernel } from '$lib/image/quantizer'
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import * as Select from '$lib/components/ui/select' import * as Select from '$lib/components/ui/select'
import Infotip from '$lib/components/Infotip.svelte' import MoreInfo from '$lib/components/MoreInfo.svelte'
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'onchange'> { import { cn } from '$lib/utils'
interface Props {
hidden: boolean
value: DitheringKernel value: DitheringKernel
onchange: (v: DitheringKernel) => void onchange: (v: DitheringKernel) => void
} }
let { value, onchange, class: classNames, ...restProps }: Props = $props() const { hidden, value, onchange }: Props = $props()
const ditheringKernels: Record<DitheringKernel, string> = { const ditheringKernels: Record<DitheringKernel, string> = {
FloydSteinberg: 'Floyd-Steinberg', FloydSteinberg: 'Floyd-Steinberg',
@@ -28,18 +28,18 @@
} }
</script> </script>
<div class={cn('grow flex flex-col gap-2', classNames)} {...restProps}> <div class={cn('grow stack gap-2', hidden ? ['invisible'] : [])} role="group" aria-label="Dithering kernel">
<Label class="multimodal"> <div class="row gap-1">
<div>Dithering Kernel</div> <Label id="dithering-kernel-select-label">Dithering Kernel</Label>
<Infotip> <MoreInfo>
Algorithm used for dithering. <br /> Algorithm used for dithering. <br />
Switch around to see which one works best for your image. Switch around to see which one works best for your image.
</Infotip> </MoreInfo>
</Label> </div>
<Select.Root type="single" {value} onValueChange={v => onchange(v as DitheringKernel)}> <Select.Root type="single" {value} onValueChange={v => onchange(v as DitheringKernel)}>
<Select.Trigger>{ditheringKernels[value]}</Select.Trigger> <Select.Trigger aria-labelledby="dithering-kernel-select-label">{ditheringKernels[value]}</Select.Trigger>
<Select.Content> <Select.Content>
{#each Object.entries(ditheringKernels) as [kernel, name]} {#each Object.entries(ditheringKernels) as [kernel, name]}
<Select.Item value={kernel}>{name}</Select.Item> <Select.Item value={kernel}>{name}</Select.Item>
@@ -4,7 +4,7 @@
</script> </script>
<Alert.Root> <Alert.Root>
<IconAspectRatio /> <IconAspectRatio aria-hidden />
<Alert.Title>Image is not 1:1 ratio</Alert.Title> <Alert.Title>Image is not 1:1 ratio</Alert.Title>
<Alert.Description>You need to choose how to scale your image.</Alert.Description> <Alert.Description>You need to choose how to scale your image.</Alert.Description>
</Alert.Root> </Alert.Root>
@@ -4,6 +4,7 @@
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import * as ToggleGroup from '$lib/components/ui/toggle-group' import * as ToggleGroup from '$lib/components/ui/toggle-group'
import * as Tooltip from '$lib/components/ui/tooltip' import * as Tooltip from '$lib/components/ui/tooltip'
import { getConversionConfig } from '$lib/contexts/config.svelte' import { getConversionConfig } from '$lib/contexts/config.svelte'
const config = getConversionConfig() const config = getConversionConfig()
@@ -24,29 +25,32 @@
} }
</script> </script>
<div class="flex flex-col gap-2"> <div class="stack gap-2">
<Label for="scale-mode-input">Scaling method</Label> <Label id="scale-mode-input-label">Scaling method</Label>
<Tooltip.Provider delayDuration={0} disableHoverableContent>
<ToggleGroup.Root <ToggleGroup.Root
id="scale-mode-input" type="single"
type="single" bind:value={
bind:value={ () => config.scaleMode,
() => config.scaleMode, v => {
v => { if (v.length !== 0) config.scaleMode = v as ScaleMode
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 }]} {#each Object.entries(scaleModes) as [mode, { name, description }]}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<ToggleGroup.Item value={mode}>{name}</ToggleGroup.Item> {#snippet child({ props })}
<ToggleGroup.Item {...props} value={mode}>{name}</ToggleGroup.Item>
{/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content> <Tooltip.Content>
{description} {description}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{/each} {/each}
</ToggleGroup.Root> </Tooltip.Provider>
</Tooltip.Provider> </ToggleGroup.Root>
</div> </div>
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { Operation } from '$lib/image/transform'
import IconRotate90DegreesCw from '~icons/material-symbols/rotate-90-degrees-cw' import IconRotate90DegreesCw from '~icons/material-symbols/rotate-90-degrees-cw'
import IconRotate90DegreesCcw from '~icons/material-symbols/rotate-90-degrees-ccw' import IconRotate90DegreesCcw from '~icons/material-symbols/rotate-90-degrees-ccw'
import IconFlipHorizontal from '~icons/mdi/flip-horizontal' import IconFlipHorizontal from '~icons/mdi/flip-horizontal'
import IconFlipVertical from '~icons/mdi/flip-Vertical' import IconFlipVertical from '~icons/mdi/flip-Vertical'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { Label } from '$lib/components/ui/label' import { Label } from '$lib/components/ui/label'
import * as Tooltip from '$lib/components/ui/tooltip' import * as Tooltip from '$lib/components/ui/tooltip'
import { getConversionConfig } from '$lib/contexts/config.svelte' import { getConversionConfig } from '$lib/contexts/config.svelte'
interface Props { interface Props {
@@ -16,80 +18,53 @@
const { disabled = false }: Props = $props() const { disabled = false }: Props = $props()
const config = getConversionConfig() 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> </script>
<div class="flex flex-col gap-2"> <div class="stack gap-2">
<Label for="transform-controls">Transform</Label> <Label id="transform-controls-label">Transform</Label>
<div id="transform-controls"> <div role="group" aria-labelledby="transform-controls-label">
<Tooltip.Provider delayDuration={0} disableHoverableContent> <Tooltip.Provider delayDuration={0} disableHoverableContent>
<Tooltip.Root> {#each Object.entries(operations) as [op, { icon: Icon, description }]}
<Tooltip.Trigger> <Tooltip.Root>
<Button <Tooltip.Trigger>
{disabled} {#snippet child({ props })}
size="icon" <Button
variant="outline" {...props}
onclick={() => { {disabled}
config.transform = config.transform.cw() size="icon"
}} variant="outline"
> onclick={() => {
<IconRotate90DegreesCw /> config.transform = config.transform.op(op as Operation)
</Button> }}
</Tooltip.Trigger> >
<Icon aria-hidden />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Rotate 90&deg; clockwise</Tooltip.Content> <Tooltip.Content>{description}</Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{/each}
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
config.transform = config.transform.ccw()
}}
>
<IconRotate90DegreesCcw />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Rotate 90&deg; counter-clockwise</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
config.transform = config.transform.h()
}}
>
<IconFlipHorizontal />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Flip horizontally</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
config.transform = config.transform.v()
}}
>
<IconFlipVertical />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Flip vertically</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider> </Tooltip.Provider>
</div> </div>
</div> </div>
+8 -17
View File
@@ -1,24 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Input } from '$lib/components/ui/input' import { Input } from '$lib/components/ui/input'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
import { toast } from 'svelte-sonner'
const bitmapCtx = getBitmapContext() import { getFilesContext } from '$lib/contexts/files.svelte'
async function updateImageBitmap(fileList: FileList | null) { interface Props {
if (fileList === null || fileList.length < 1) { ref?: HTMLInputElement | null
bitmapCtx.image = null
return
}
const imageFile = fileList[0]
try {
bitmapCtx.image = await createImageBitmap(imageFile)
} catch (e) {
toast.error(`Error loading image file: ${e}`)
bitmapCtx.image = null
}
} }
let { ref = $bindable(null) }: Props = $props()
const filesCtx = getFilesContext()
</script> </script>
<Input type="file" id="image-file" accept="image/*" onchange={e => updateImageBitmap(e.currentTarget.files)} /> <Input bind:ref type="file" accept="image/*" bind:files={filesCtx.files} />
@@ -0,0 +1,42 @@
<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 } from './common.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
let canvasEl!: HTMLCanvasElement
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="img" aria-labelledby="preview-1x-label">
<div class="p-[3px] border-[1px] border-[#888]">
<canvas
class={cn('w-[200px] h-[200px]', hasRendered || 'hidden')}
style:image-rendering="pixelated"
bind:this={canvasEl}
height={200}
width={200}
></canvas>
{#if !hasRendered}
<div class="col justify-center w-[200px] h-[200px] text-muted">
<IconHideImage class="text-3xl" aria-label="No image" />
</div>
{/if}
</div>
</div>
<Label id="preview-1x-label">1x Preview</Label>
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import IconUploadFile from '~icons/material-symbols/upload-file'
import { cn } from '$lib/utils'
import { drawQuantizedData, freshContext } from './common.svelte'
import { getFilesContext } from '$lib/contexts/files.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
interface Props {
fileInputEl: HTMLInputElement | null
}
const { fileInputEl }: Props = $props()
const filesCtx = getFilesContext()
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
let canvasEl!: HTMLCanvasElement
const ctx = $derived(freshContext(canvasEl))
let fileOverDragZone = $state(false)
$effect(() => {
if (renderedCtx.rendered === null) return
drawQuantizedData(ctx, renderedCtx.rendered)
})
</script>
<div>
<div
ondragover={e => e.preventDefault()}
ondrop={e => {
e.preventDefault()
const files = e.dataTransfer?.files
if (files) filesCtx.files = files
}}
class="bg-[#ccc] shadow-md rounded-2xl p-4 w-fit relative"
role="img"
aria-labelledby="preview-2x-label"
>
<div class="p-[6px] border-[1px] border-[#888]">
<canvas
class={cn('w-[400px] h-[400px]', hasRendered || 'hidden')}
style:image-rendering="pixelated"
bind:this={canvasEl}
height={200}
width={200}
></canvas>
{#if !hasRendered}
<button
onclick={() => fileInputEl?.showPicker()}
ondragleave={() => (fileOverDragZone = false)}
ondragover={() => (fileOverDragZone = true)}
class={cn(
'col justify-center w-[400px] h-[400px] rounded-xl hover:cursor-pointer text-muted',
fileOverDragZone && 'outline-dashed',
)}
tabindex={-1}
aria-label="Choose file"
>
<IconUploadFile class="text-3xl" aria-hidden />
<div class="font-medium">Drop file</div>
</button>
{/if}
</div>
</div>
<Label id="preview-2x-label">2x Preview</Label>
</div>
@@ -1,72 +0,0 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import { withCtx } from '$lib/image/transform'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
let canvas2xEl: HTMLCanvasElement
let canvas1xEl: HTMLCanvasElement
const bitmapCtx = getBitmapContext()
function freshContext(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')!
ctx.clearRect(0, 0, 200, 200)
return ctx
}
function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
withCtx(
ctx,
() => {
ctx.imageSmoothingEnabled = false
},
() => {
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const color = data.at(y * 200 + x)
ctx.fillStyle = color === 0 ? '#ccc' : '#111'
ctx.fillRect(x, y, 1, 1)
}
}
},
)
}
$effect(() => {
const context2x = freshContext(canvas2xEl)
const context1x = freshContext(canvas1xEl)
if (bitmapCtx.rendered === null) return
drawQuantizedData(context2x, bitmapCtx.rendered)
drawQuantizedData(context1x, bitmapCtx.rendered)
})
</script>
<div class="flex gap-4 max-md:flex-col">
<div>
<div id="canvas-2x" class="bg-[#ccc] shadow-md rounded-2xl p-4 w-fit">
<canvas
class="border-[1px] p-[6px] border-[#888] w-[414px] h-[414px]"
style="image-rendering: pixelated"
bind:this={canvas2xEl}
height={200}
width={200}
></canvas>
</div>
<Label for="canvas-2x">2x Preview</Label>
</div>
<div>
<div id="preview-1x" class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit">
<canvas
class="border-[1px] p-[3px] border-[#888] w-[208px] h-[208px]"
style="image-rendering: pixelated"
bind:this={canvas1xEl}
height={200}
width={200}
></canvas>
</div>
<Label for="canvas-1x">1x Preview</Label>
</div>
</div>
@@ -1,10 +1,18 @@
<script lang="ts"> <script lang="ts">
import PreviewCanvases from './PreviewCanvases.svelte'
import FileSelect from './FileSelect.svelte' import FileSelect from './FileSelect.svelte'
import PreviewCanvas1x from './PreviewCanvas1x.svelte'
import PreviewCanvas2x from './PreviewCanvas2x.svelte'
let fileInputEl: HTMLInputElement | null = $state(null)
</script> </script>
<section class="flex flex-col gap-4"> <section class="stack gap-4" aria-labelledby="preview-section-label">
<h1 class="font-semibold text-xl/6">Choose an image</h1> <h2 class="font-semibold text-xl/6" id="preview-section-label">Choose an image</h2>
<FileSelect />
<PreviewCanvases /> <FileSelect bind:ref={fileInputEl} />
<div class="stack-h gap-4 max-md:stack">
<PreviewCanvas2x {fileInputEl} />
<PreviewCanvas1x />
</div>
</section> </section>
@@ -0,0 +1,22 @@
export function freshContext(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')!
ctx.imageSmoothingEnabled = false
return ctx
}
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
const imageData = ctx.createImageData(200, 200)
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const colorIx = data.at(y * 200 + x)
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
}
}
ctx.putImageData(imageData, 0, 0)
}
+7 -6
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
import { getDeviceContext } from '$lib/contexts/device.svelte'
import { toast } from 'svelte-sonner' import { toast } from 'svelte-sonner'
import { getDeviceContext } from '$lib/contexts/device.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
interface Props { interface Props {
onprogress: (inPropgress: boolean) => void onprogress: (inPropgress: boolean) => void
@@ -13,13 +14,13 @@
let inProgress = $state(false) let inProgress = $state(false)
const deviceCtx = getDeviceContext() const deviceCtx = getDeviceContext()
const bitmapCtx = getBitmapContext() const renderedCtx = getRenderedContext()
let disabled = $derived(deviceCtx.device === null || bitmapCtx.rendered === null || inProgress) let disabled = $derived(deviceCtx.device === null || renderedCtx.rendered === null || inProgress)
let secondary = $derived(deviceCtx.device === null) let secondary = $derived(deviceCtx.device === null)
async function connectAndWrite() { async function connectAndWrite() {
if (deviceCtx.device === null || bitmapCtx.rendered === null) return if (deviceCtx.device === null || renderedCtx.rendered === null) return
if (!deviceCtx.device.opened) { if (!deviceCtx.device.opened) {
try { try {
@@ -36,7 +37,7 @@
let cell = 0x0 let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) { for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * 200 + xStride * 8 + xStroll const index = y * 200 + xStride * 8 + xStroll
cell |= bitmapCtx.rendered[index] << xStroll cell |= renderedCtx.rendered[index] << xStroll
} }
buffer[y * 25 + xStride] = cell buffer[y * 25 + xStride] = cell
} }
+12 -16
View File
@@ -3,37 +3,33 @@
import IconPending from '~icons/material-symbols/pending' import IconPending from '~icons/material-symbols/pending'
import IconArrowUploadProgress from '~icons/material-symbols/arrow-upload-progress' import IconArrowUploadProgress from '~icons/material-symbols/arrow-upload-progress'
import IconWarning from '~icons/material-symbols/warning' import IconWarning from '~icons/material-symbols/warning'
import WriteButton from './WriteButton.svelte' import WriteButton from './WriteButton.svelte'
import { getDeviceContext } from '$lib/contexts/device.svelte' import { getDeviceContext } from '$lib/contexts/device.svelte'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte' import { getImageContext } from '$lib/contexts/image.svelte'
const deviceCtx = getDeviceContext() const deviceCtx = getDeviceContext()
const bitmapCtx = getBitmapContext() const imageCtx = getImageContext()
let inProgress = $state(false) let inProgress = $state(false)
</script> </script>
<section class="flex items-center gap-2 max-lg:flex-col max-lg:items-stretch"> <section class="row gap-2 max-lg:stack max-lg:items-stretch" aria-labelledby="write-section-label">
<div class="grow"> <div class="grow">
<h1 class="font-semibold text-xl/8">Write pattern to device</h1> <h2 class="font-semibold text-xl/8" id="write-section-label">Write pattern to device</h2>
<div class="text-sm"> <div class="row gap-1 text-sm">
{#if deviceCtx.device === null} {#if deviceCtx.device === null}
<IconHelp class="inline" /> To start writing patterns to your device, connect it first. <IconHelp aria-hidden /> To start writing patterns to your device, connect it first.
{:else if bitmapCtx.image === null} {:else if imageCtx.image === null}
<IconPending class="inline" /> Select an image file in order to write it onto your device. <IconPending aria-hidden /> Select an image file in order to write it onto your device.
{:else if !inProgress} {:else if !inProgress}
<IconArrowUploadProgress class="inline" /> Write the pattern onto your device if you have finished editing the image. <IconArrowUploadProgress aria-hidden /> Write the pattern onto your device if you have finished editing the image.
{:else} {:else}
<IconWarning class="inline" /> Refresh in progress. Do not disconnect device. <IconWarning aria-hidden /> Refresh in progress. Do not disconnect device.
{/if} {/if}
</div> </div>
</div> </div>
<WriteButton <WriteButton onprogress={v => (inProgress = v)} />
onprogress={v => {
inProgress = v
}}
/>
</section> </section>