Improve accessibility
- Add alt text for image preview - Add hidden <h1> heading
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user