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 />
<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}
<Unsupported class="grow" />
<Unsupported />
{:else}
<Main class="grow" />
<Main />
{/if}
<Footer />
</div>
+22 -2
View File
@@ -69,10 +69,30 @@
@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 {
.multimodal {
@apply flex gap-1 items-end;
.stack {
@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 * as Popover from '$lib/components/ui/popover'
import { Label } from '$lib/components/ui/label'
</script>
<Popover.Root>
<Label>
<Popover.Trigger>
<IconInfo class="text-muted-foreground inline" />
</Popover.Trigger>
</Label>
<Popover.Trigger aria-label="More info">
<IconInfo class="text-muted-foreground text-sm" aria-hidden />
</Popover.Trigger>
<Popover.Content class="text-sm">
<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 type { ConversionConfig } from './config.svelte'
import { Quantizer } from '$lib/image/quantizer'
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'
const scaler = new Scaler(200, 200)
export interface BitmapContext {
image: ImageBitmap | null
export interface RenderedContext {
rendered: number[] | null
}
export const BitmapContextToken = Symbol('bitmap')
export const RenderedContextToken = Symbol('rendered')
export function getBitmapContext(): BitmapContext {
return getContext(BitmapContextToken)
export function getRenderedContext(): Readonly<RenderedContext> {
return getContext(RenderedContextToken)
}
export function createBitmapContext(config: ConversionConfig): BitmapContext {
const ctx: BitmapContext = $state({
image: null,
const scaler = new Scaler(200, 200)
export function createRenderedContext(
imageCtx: Readonly<ImageContext>,
config: ConversionConfig
): Readonly<RenderedContext> {
const ctx: RenderedContext = $state({
rendered: null,
})
const canvas = new OffscreenCanvas(200, 200)
const quantizer = $derived(
new Quantizer({
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(() => {
const bitmap = ctx.image
const bitmap = imageCtx.image
if (bitmap === null) {
ctx.rendered = null
return
}
const canvasCtx = canvas.getContext('2d', {
willReadFrequently: true,
})!
withTransform(canvasCtx, config.transform, () => {
const nonNullBitmap = bitmap
const bg = config.backgroundColor
canvasCtx.fillStyle = `rgb(${bg} ${bg} ${bg})`
canvasCtx.fillRect(0, 0, 200, 200)
const { dx, dy, dWidth, dHeight } = scaler[config.scaleMode](nonNullBitmap)
canvasCtx.drawImage(nonNullBitmap, dx, dy, dWidth, dHeight)
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(BitmapContextToken, ctx)
return setContext(RenderedContextToken, ctx)
}
+4
View File
@@ -72,4 +72,8 @@ export class Scaler {
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 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
@@ -32,11 +33,15 @@ export class Transform {
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
export function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T {
function withCtx<T>(ctx: Context2D, pre: () => void, action: () => T): T {
ctx.save()
try {
pre()
+7 -7
View File
@@ -1,20 +1,20 @@
<script lang="ts">
import { mode, toggleMode } from 'mode-watcher'
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="flex items-center text-sm text-muted-foreground">
<footer class="row text-sm text-muted-foreground">
<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>
<Button size="icon" variant="ghost" onclick={toggleMode}>
<Button size="icon" variant="ghost" onclick={toggleMode} aria-label="Toggle color scheme">
{#if $mode === 'dark'}
<IconDarkMode />
<IconDarkMode aria-hidden />
{:else}
<IconLightMode />
<IconLightMode aria-hidden />
{/if}
</Button>
</footer>
+11 -14
View File
@@ -1,33 +1,30 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
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 { createBitmapContext } from '$lib/contexts/bitmap.svelte'
interface Props extends HTMLAttributes<HTMLElement> {}
const { class: classNames, ...restProps }: Props = $props()
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()
createBitmapContext(config)
const filesCtx = createFilesContext()
const imageCtx = createImageContext(filesCtx)
createRenderedContext(imageCtx, config)
</script>
<main class={cn('w-full flex flex-col gap-4', classNames)} {...restProps}>
<main class="grow w-full stack gap-4">
<ConnectSection />
<Separator />
<Separator decorative />
<EditSection class="grow" />
<EditSection />
<Separator />
<Separator decorative />
<WriteSection />
</main>
+8 -15
View File
@@ -1,20 +1,10 @@
<script lang="ts">
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'
interface Props extends HTMLAttributes<HTMLElement> {}
const { class: classNames, ...restProps }: Props = $props()
</script>
<main
class={cn('flex items-center justify-center gap-2 font-medium text-xl text-muted-foreground', classNames)}
{...restProps}
>
<main class="grow row justify-center gap-2 font-medium text-xl text-muted-foreground">
<IconDisabledByDefault />
<div>Not Supported</div>
</main>
@@ -25,11 +15,14 @@
<AlertDialog.Content>
<AlertDialog.Title>Browser not supported</AlertDialog.Title>
<AlertDialog.Description>
<p class="mb-2">Write to Inkclip uses the WebHID API, which is not supported by your browser.</p>
<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 Google Chrome, Microsoft Edge, Opera,
or Arc.
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>
+2 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button'
import { getDeviceContext } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext()
@@ -14,10 +15,7 @@
],
})
if (devs == undefined || devs[0] == undefined) {
return
}
if (devs == undefined || devs[0] == undefined) return
deviceCtx.device = devs[0]
}
</script>
@@ -1,23 +1,23 @@
<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 } from '$lib/contexts/device.svelte'
const deviceCtx = getDeviceContext()
</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">
<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}
<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.
{: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.
{/if}
</div>
+4 -11
View File
@@ -1,25 +1,18 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
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'
interface Props extends HTMLAttributes<HTMLDivElement> {}
const { class: classNames, ...restProps }: Props = $props()
const mobileMediaQuery = createMediaQuery('(max-width: 1024px)')
const separatorOrientation = $derived(mobileMediaQuery.matches ? 'horizontal' : 'vertical')
</script>
<div class={cn('flex max-lg:flex-col gap-4', classNames)} {...restProps}>
<div class="grow stack-h max-lg:stack gap-4">
<PreviewSection />
<Separator orientation={separatorOrientation} />
<Separator decorative orientation={separatorOrientation} />
<ControlsSection class="grow" />
<ControlsSection />
</div>
@@ -1,25 +1,26 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
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'
const config = getConversionConfig()
let value = $derived(config.backgroundColor)
</script>
<div class="flex flex-col gap-4">
<Label id="background-color-input-label" class="multimodal">
Background Color
<span class="font-normal text-muted-foreground"> = {config.backgroundColor} </span>
<Infotip>The color used for transparent pixels.</Infotip>
</Label>
<div class="stack gap-4" role="group" aria-label="Background color">
<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>
<MoreInfo>The color used for transparent pixels.</MoreInfo>
</div>
<Slider
type="single"
value={config.backgroundColor}
onValueCommit={v => {
config.backgroundColor = v
}}
bind:value
onValueCommit={() => (config.backgroundColor = value)}
min={0}
max={255}
step={1}
@@ -1,15 +1,10 @@
<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 { 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'
@@ -17,21 +12,18 @@
import DitherControls from './conversion/dither/DitherControls.svelte'
import ContrastSlider from './conversion/ContrastSlider.svelte'
import BiasSlider from './conversion/BiasSlider.svelte'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
interface Props extends HTMLAttributes<HTMLElement> {}
const { class: className, ...restProps }: Props = $props()
const bitmapCtx = getBitmapContext()
const imageCtx = getImageContext()
const config = getConversionConfig()
const transformDisabled = $derived(bitmapCtx.image === null)
const transformDisabled = $derived(imageCtx.image === null)
function imageNonSquare() {
if (bitmapCtx.image === null) return false
return bitmapCtx.image.height !== bitmapCtx.image.width
if (imageCtx.image === null) return false
return imageCtx.image.height !== imageCtx.image.width
}
function restoreDefaultImageSettings() {
@@ -44,14 +36,14 @@
}
</script>
<section class={cn('flex flex-col gap-4', className)} {...restProps}>
<h1 class="font-semibold text-xl/6">Edit image</h1>
<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="flex gap-4">
<div class="row gap-4">
{#if imageNonSquare()}
<ScaleModeToggleGroup />
{/if}
@@ -78,7 +70,7 @@
<Separator />
<Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}>
<IconEditOff />
<IconEditOff aria-hidden />
Reset All
</Button>
</section>
@@ -1,33 +1,31 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
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'
const config = getConversionConfig()
let value = $derived(config.bias)
</script>
<div class="flex flex-col gap-4">
<Label for="bias-input" class="multimodal">
<div>
Bias
<span class="font-normal text-muted-foreground"> = {Math.floor(config.bias * 100)}% </span>
</div>
<Infotip>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</Infotip>
</Label>
<div class="stack gap-4" role="group" aria-label="Bias">
<div class="row gap-1 text-sm">
<Label id="bias-input-label">Bias</Label>
<span class="font-normal text-muted-foreground"> = {Math.floor(value * 100)}% </span>
<MoreInfo>
Whether the conversion algorithm should bias the entire image towards white (negative) or black (positive).
</MoreInfo>
</div>
<Slider
type="single"
value={config.bias}
onValueCommit={v => {
config.bias = v
}}
bind:value
onValueCommit={() => (config.bias = value)}
min={-1}
max={1}
step={0.01}
id="bias-input"
aria-labelledby="bias-input-label"
/>
</div>
@@ -1,33 +1,31 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
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'
const config = getConversionConfig()
let value = $derived(config.contrast)
</script>
<div class="flex flex-col gap-4">
<Label for="contrast-input" class="multimodal">
<div>
Contrast
<span class="font-normal text-muted-foreground"> = {Math.floor(config.contrast * 100)}% </span>
</div>
<Infotip>
<div class="stack gap-4" role="group" aria-label="Contrast">
<div class="row gap-1 text-sm">
<Label id="contrast-input-label">Contrast</Label>
<span class="font-normal text-muted-foreground"> = {Math.floor(value * 100)}% </span>
<MoreInfo>
How eager the conversion algorithm should push colors towards the two ends (black and white) of the grayscale.
</Infotip>
</Label>
</MoreInfo>
</div>
<Slider
type="single"
value={config.contrast}
onValueCommit={v => {
config.contrast = v
}}
bind:value
onValueCommit={() => (config.contrast = value)}
min={0}
max={1}
step={0.01}
id="contrast-input"
aria-labelledby="contrast-input-label"
/>
</div>
@@ -1,9 +1,11 @@
<script lang="ts">
import { DEFAULT_DITHERING_KERNEL, type DitheringKernel } from '$lib/image/quantizer'
import { getConversionConfig } from '$lib/contexts/config.svelte'
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)
@@ -14,20 +16,14 @@
})
</script>
<div class="flex gap-4">
<div class="stack-h gap-4">
<DitherSwitch
checked={config.ditheringKernel !== null}
onCheckedChange={c => {
if (c) {
config.ditheringKernel = lastDitheringKernel
} else {
config.ditheringKernel = null
}
}}
onchange={c => (config.ditheringKernel = c ? lastDitheringKernel : null)}
/>
<DitheringKernelDropdown
class={config.ditheringKernel !== null ? [] : ['invisible']}
hidden={config.ditheringKernel === null}
value={lastDitheringKernel}
onchange={v => {
config.ditheringKernel = v
@@ -1,25 +1,23 @@
<script lang="ts">
import { Switch } from '$lib/components/ui/switch'
import { Label } from '$lib/components/ui/label'
import Infotip from '$lib/components/Infotip.svelte'
import MoreInfo from '$lib/components/MoreInfo.svelte'
interface Props {
checked: boolean
onCheckedChange: (checked: boolean) => void
onchange: (checked: boolean) => void
}
let { checked, onCheckedChange }: Props = $props()
let { checked, onchange }: Props = $props()
</script>
<div class="flex flex-col gap-2">
<div>
<Label for="dither-switch" class="multimodal">
<div>Dither</div>
<Infotip>Dithering uses different dot densities to simulate shades of gray.</Infotip>
</Label>
<div class="stack gap-2" role="group" aria-label="Dither">
<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 flex items-center">
<Switch id="dither-switch" {checked} {onCheckedChange} />
<div class="grow row items-center">
<Switch {checked} onCheckedChange={onchange} aria-labelledby="dither-switch-label" />
</div>
</div>
@@ -1,19 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils'
import { type DitheringKernel } from '$lib/image/quantizer'
import { Label } from '$lib/components/ui/label'
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
onchange: (v: DitheringKernel) => void
}
let { value, onchange, class: classNames, ...restProps }: Props = $props()
const { hidden, value, onchange }: Props = $props()
const ditheringKernels: Record<DitheringKernel, string> = {
FloydSteinberg: 'Floyd-Steinberg',
@@ -28,18 +28,18 @@
}
</script>
<div class={cn('grow flex flex-col gap-2', classNames)} {...restProps}>
<Label class="multimodal">
<div>Dithering Kernel</div>
<div class={cn('grow stack gap-2', hidden ? ['invisible'] : [])} role="group" aria-label="Dithering kernel">
<div class="row gap-1">
<Label id="dithering-kernel-select-label">Dithering Kernel</Label>
<Infotip>
<MoreInfo>
Algorithm used for dithering. <br />
Switch around to see which one works best for your image.
</Infotip>
</Label>
</MoreInfo>
</div>
<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>
{#each Object.entries(ditheringKernels) as [kernel, name]}
<Select.Item value={kernel}>{name}</Select.Item>
@@ -4,7 +4,7 @@
</script>
<Alert.Root>
<IconAspectRatio />
<IconAspectRatio aria-hidden />
<Alert.Title>Image is not 1:1 ratio</Alert.Title>
<Alert.Description>You need to choose how to scale your image.</Alert.Description>
</Alert.Root>
@@ -4,6 +4,7 @@
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()
@@ -24,29 +25,32 @@
}
</script>
<div class="flex flex-col gap-2">
<Label for="scale-mode-input">Scaling method</Label>
<Tooltip.Provider delayDuration={0} disableHoverableContent>
<ToggleGroup.Root
id="scale-mode-input"
type="single"
bind:value={
() => config.scaleMode,
v => {
if (v.length !== 0) config.scaleMode = v as ScaleMode
}
<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>
<ToggleGroup.Item value={mode}>{name}</ToggleGroup.Item>
{#snippet child({ props })}
<ToggleGroup.Item {...props} value={mode}>{name}</ToggleGroup.Item>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
{description}
</Tooltip.Content>
</Tooltip.Root>
{/each}
</ToggleGroup.Root>
</Tooltip.Provider>
</Tooltip.Provider>
</ToggleGroup.Root>
</div>
@@ -1,12 +1,14 @@
<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 {
@@ -16,80 +18,53 @@
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="flex flex-col gap-2">
<Label for="transform-controls">Transform</Label>
<div class="stack gap-2">
<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.Root>
<Tooltip.Trigger>
<Button
{disabled}
size="icon"
variant="outline"
onclick={() => {
config.transform = config.transform.cw()
}}
>
<IconRotate90DegreesCw />
</Button>
</Tooltip.Trigger>
{#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-hidden />
</Button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>Rotate 90&deg; clockwise</Tooltip.Content>
</Tooltip.Root>
<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.Content>{description}</Tooltip.Content>
</Tooltip.Root>
{/each}
</Tooltip.Provider>
</div>
</div>
+8 -17
View File
@@ -1,24 +1,15 @@
<script lang="ts">
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) {
if (fileList === null || fileList.length < 1) {
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
}
interface Props {
ref?: HTMLInputElement | null
}
let { ref = $bindable(null) }: Props = $props()
const filesCtx = getFilesContext()
</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">
import PreviewCanvases from './PreviewCanvases.svelte'
import FileSelect from './FileSelect.svelte'
import PreviewCanvas1x from './PreviewCanvas1x.svelte'
import PreviewCanvas2x from './PreviewCanvas2x.svelte'
let fileInputEl: HTMLInputElement | null = $state(null)
</script>
<section class="flex flex-col gap-4">
<h1 class="font-semibold text-xl/6">Choose an image</h1>
<FileSelect />
<PreviewCanvases />
<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 bind:ref={fileInputEl} />
<div class="stack-h gap-4 max-md:stack">
<PreviewCanvas2x {fileInputEl} />
<PreviewCanvas1x />
</div>
</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">
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 { getDeviceContext } from '$lib/contexts/device.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
interface Props {
onprogress: (inPropgress: boolean) => void
@@ -13,13 +14,13 @@
let inProgress = $state(false)
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)
async function connectAndWrite() {
if (deviceCtx.device === null || bitmapCtx.rendered === null) return
if (deviceCtx.device === null || renderedCtx.rendered === null) return
if (!deviceCtx.device.opened) {
try {
@@ -36,7 +37,7 @@
let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * 200 + xStride * 8 + xStroll
cell |= bitmapCtx.rendered[index] << xStroll
cell |= renderedCtx.rendered[index] << xStroll
}
buffer[y * 25 + xStride] = cell
}
+12 -16
View File
@@ -3,37 +3,33 @@
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 { getDeviceContext } from '$lib/contexts/device.svelte'
import { getBitmapContext } from '$lib/contexts/bitmap.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
const deviceCtx = getDeviceContext()
const bitmapCtx = getBitmapContext()
const imageCtx = getImageContext()
let inProgress = $state(false)
</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">
<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}
<IconHelp class="inline" /> To start writing patterns to your device, connect it first.
{:else if bitmapCtx.image === null}
<IconPending class="inline" /> Select an image file in order to write it onto your device.
<IconHelp aria-hidden /> To start writing patterns to your device, connect it first.
{:else if imageCtx.image === null}
<IconPending aria-hidden /> Select an image file in order to write it onto your device.
{: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}
<IconWarning class="inline" /> Refresh in progress. Do not disconnect device.
<IconWarning aria-hidden /> Refresh in progress. Do not disconnect device.
{/if}
</div>
</div>
<WriteButton
onprogress={v => {
inProgress = v
}}
/>
<WriteButton onprogress={v => (inProgress = v)} />
</section>