Improve accessibility

- Add alt text for image preview
- Add hidden <h1> heading
This commit is contained in:
daylily
2025-04-03 21:29:07 -04:00
parent 034130dd60
commit 9fa73963dc
6 changed files with 107 additions and 76 deletions
+6
View File
@@ -1,6 +1,7 @@
import { getContext, setContext } from 'svelte'
import type { FilesContext } from './files.svelte'
import { toast } from 'svelte-sonner'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
export interface ImageContext {
image: ImageBitmap | null
@@ -39,3 +40,8 @@ export function createImageContext(filesCtx: FilesContext): Readonly<ImageContex
return setContext(ImageContextToken, ctx)
}
export function imageIsCorrectRatio(imageCtx: ImageContext): boolean {
if (imageCtx.image === null) return true
return imageCtx.image.height * INKCLIP_WIDTH === imageCtx.image.width * INKCLIP_HEIGHT
}
+3 -1
View File
@@ -17,7 +17,9 @@
createRenderedContext(imageCtx, config)
</script>
<main class="grow w-full stack gap-4">
<main class="grow w-full stack gap-4" aria-labelledby="main-label">
<h1 class="sr-only" id="main-label">Write to Inkclip</h1>
<ConnectSection />
<Separator decorative />
@@ -13,19 +13,14 @@
import BiasSlider from './conversion/BiasSlider.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
import { getImageContext, imageIsCorrectRatio } from '$lib/contexts/image.svelte'
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
const imageCtx = getImageContext()
const config = getConversionConfig()
const transformDisabled = $derived(imageCtx.image === null)
function imageNonSquare() {
if (imageCtx.image === null) return false
return imageCtx.image.height * INKCLIP_WIDTH !== imageCtx.image.width * INKCLIP_HEIGHT
}
const imageNonSquare = $derived(!imageIsCorrectRatio(imageCtx))
function restoreDefaultImageSettings() {
config.scaleMode = 'fit'
@@ -40,23 +35,23 @@
<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()}
{#if imageNonSquare}
<AspectRatioAlert />
{/if}
<div class="row gap-4">
{#if imageNonSquare()}
{#if imageNonSquare}
<ScaleModeToggleGroup />
{/if}
<TransformControls disabled={transformDisabled} />
</div>
<Separator />
<Separator decorative />
<BackgroundColorSlider />
<Separator />
<Separator decorative />
<DitherControls />
@@ -68,7 +63,7 @@
<BiasSlider />
{/if}
<Separator />
<Separator decorative />
<Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}>
<IconEditOff aria-hidden />
@@ -3,14 +3,20 @@
import IconHideImage from '~icons/material-symbols/hide-image'
import { cn } from '$lib/utils'
import { drawQuantizedData, freshContext } from './common.svelte'
import { drawQuantizedData, freshContext, makeAltText } from './common.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
import { getFilesContext } from '$lib/contexts/files.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
const filesCtx = getFilesContext()
const imageCtx = getImageContext()
const config = getConversionConfig()
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
let canvasEl!: HTMLCanvasElement
let canvasEl: HTMLCanvasElement = $state(undefined!)
const ctx = $derived(freshContext(canvasEl))
@@ -21,20 +27,20 @@
</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(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{INKCLIP_WIDTH}px"
style:height="{INKCLIP_HEIGHT}px"
bind:this={canvasEl}
height={INKCLIP_HEIGHT}
width={INKCLIP_WIDTH}
></canvas>
{#if !hasRendered}
<div class="col justify-center text-muted" style:width="{INKCLIP_WIDTH}px" style:height="{INKCLIP_HEIGHT}px">
<div class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit relative" role="group" aria-labelledby="preview-1x-label">
<div class="p-[3px] shadow-sm shadow-[inset#888]" role="img" aria-label={makeAltText(filesCtx, imageCtx, config)}>
{#if hasRendered}
<canvas
class={cn(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{INKCLIP_WIDTH}px"
style:height="{INKCLIP_HEIGHT}px"
bind:this={canvasEl}
height={INKCLIP_HEIGHT}
width={INKCLIP_WIDTH}
></canvas>
{:else}
<div class="col justify-center text-[#333]" style:width="{INKCLIP_WIDTH}px" style:height="{INKCLIP_HEIGHT}px">
<IconHideImage class="text-3xl" aria-label="No image" />
</div>
{/if}
@@ -1,29 +1,25 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label'
import IconUploadFile from '~icons/material-symbols/upload-file'
import IconHideImage from '~icons/material-symbols/hide-image'
import { cn } from '$lib/utils'
import { drawQuantizedData, freshContext } from './common.svelte'
import { getFilesContext } from '$lib/contexts/files.svelte'
import { drawQuantizedData, freshContext, makeAltText } from './common.svelte'
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
interface Props {
fileInputEl: HTMLInputElement | null
}
const { fileInputEl }: Props = $props()
import { getFilesContext } from '$lib/contexts/files.svelte'
import { getConversionConfig } from '$lib/contexts/config.svelte'
import { getImageContext } from '$lib/contexts/image.svelte'
const filesCtx = getFilesContext()
const imageCtx = getImageContext()
const config = getConversionConfig()
const renderedCtx = getRenderedContext()
const hasRendered = $derived(renderedCtx.rendered !== null)
let canvasEl!: HTMLCanvasElement
let canvasEl: HTMLCanvasElement = $state(undefined!)
const ctx = $derived(freshContext(canvasEl))
let fileOverDragZone = $state(false)
$effect(() => {
if (renderedCtx.rendered === null) return
drawQuantizedData(ctx, renderedCtx.rendered)
@@ -31,44 +27,26 @@
</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(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{INKCLIP_WIDTH * 2}px"
style:height="{INKCLIP_HEIGHT * 2}px"
bind:this={canvasEl}
height={INKCLIP_HEIGHT}
width={INKCLIP_WIDTH}
></canvas>
{#if !hasRendered}
<button
onclick={() => fileInputEl?.showPicker()}
ondragleave={() => (fileOverDragZone = false)}
ondragover={() => (fileOverDragZone = true)}
class={cn(
`col justify-center rounded-xl hover:cursor-pointer text-muted`,
fileOverDragZone && 'outline-dashed',
)}
<div class="bg-[#ccc] shadow-lg rounded-2xl p-4 w-fit relative" role="group" aria-labelledby="preview-2x-label">
<div class="p-[6px] shadow shadow-[inset#888]" role="img" aria-label={makeAltText(filesCtx, imageCtx, config)}>
{#if hasRendered}
<canvas
class={cn(hasRendered || 'hidden')}
style:image-rendering="pixelated"
style:width="{INKCLIP_WIDTH * 2}px"
style:height="{INKCLIP_HEIGHT * 2}px"
bind:this={canvasEl}
height={INKCLIP_HEIGHT}
width={INKCLIP_WIDTH}
></canvas>
{:else}
<div
class="col justify-center text-[#333]"
style:width="{INKCLIP_WIDTH * 2}px"
style:height="{INKCLIP_HEIGHT * 2}px"
tabindex={-1}
aria-label="Choose file"
>
<IconUploadFile class="text-3xl" aria-hidden />
<div class="font-medium">Drop file</div>
</button>
<IconHideImage class="text-6xl" aria-label="No image" />
</div>
{/if}
</div>
</div>
@@ -1,4 +1,7 @@
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
import type { ConversionConfig } from '$lib/contexts/config.svelte'
import type { FilesContext } from '$lib/contexts/files.svelte'
import { imageIsCorrectRatio, type ImageContext } from '$lib/contexts/image.svelte'
export function freshContext(el: HTMLCanvasElement) {
const ctx = el.getContext('2d')!
@@ -6,6 +9,47 @@ export function freshContext(el: HTMLCanvasElement) {
return ctx
}
export function makeAltText(filesCtx: FilesContext, imageCtx: ImageContext, config: ConversionConfig): string {
if (filesCtx.files.length < 1) return 'No image selected for e-paper preview'
const file = filesCtx.files[0]
const output = [`${INKCLIP_WIDTH}-by-${INKCLIP_HEIGHT}-pixels e-paper preview of "${file.name}"`]
if (!imageIsCorrectRatio(imageCtx)) {
if (config.scaleMode === 'fit') output.push('letterboxed')
else if (config.scaleMode === 'crop') output.push('cropped to fit')
else if (config.scaleMode === 'distort') output.push('stretched to fit')
}
if (config.transform.side === 'reverse') output.push('flipped horizontally')
if (config.transform.rotation !== 0) output.push(`rotated ${config.transform.rotation} degrees`)
if (config.ditheringKernel === null) output.push('no dithering')
else output.push('with dithering')
if (config.ditheringKernel !== null) {
if (config.contrast >= 0.75) output.push('with very high contrast')
else if (config.contrast >= 0.25) output.push('with high contrast')
}
if (config.contrast > 0 || config.ditheringKernel === null) {
const biasScaleFactor = config.ditheringKernel === null ? 1 : config.contrast
const scaledBias = config.bias * biasScaleFactor
if (scaledBias >= 0.5) output.push('extremely biased towards black')
else if (scaledBias >= 0.25) output.push('biased towards black')
else if (scaledBias >= 0.1) output.push('slightly biased towards black')
if (scaledBias <= -0.5) output.push('extremely biased towards white')
else if (scaledBias <= -0.25) output.push('biased towards white')
else if (scaledBias <= -0.1) output.push('slightly biased towards white')
}
return output.join(', ')
}
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
const imageData = ctx.createImageData(INKCLIP_WIDTH, INKCLIP_HEIGHT)