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 { getContext, setContext } from 'svelte'
|
||||||
import type { FilesContext } from './files.svelte'
|
import type { FilesContext } from './files.svelte'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
|
||||||
|
|
||||||
export interface ImageContext {
|
export interface ImageContext {
|
||||||
image: ImageBitmap | null
|
image: ImageBitmap | null
|
||||||
@@ -39,3 +40,8 @@ export function createImageContext(filesCtx: FilesContext): Readonly<ImageContex
|
|||||||
|
|
||||||
return setContext(ImageContextToken, ctx)
|
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)
|
createRenderedContext(imageCtx, config)
|
||||||
</script>
|
</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 />
|
<ConnectSection />
|
||||||
|
|
||||||
<Separator decorative />
|
<Separator decorative />
|
||||||
|
|||||||
@@ -13,19 +13,14 @@
|
|||||||
import BiasSlider from './conversion/BiasSlider.svelte'
|
import BiasSlider from './conversion/BiasSlider.svelte'
|
||||||
|
|
||||||
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
import { getImageContext } from '$lib/contexts/image.svelte'
|
import { getImageContext, imageIsCorrectRatio } from '$lib/contexts/image.svelte'
|
||||||
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
|
|
||||||
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
|
import { DEFAULT_DITHERING_KERNEL } from '$lib/image/quantizer'
|
||||||
|
|
||||||
const imageCtx = getImageContext()
|
const imageCtx = getImageContext()
|
||||||
const config = getConversionConfig()
|
const config = getConversionConfig()
|
||||||
|
|
||||||
const transformDisabled = $derived(imageCtx.image === null)
|
const transformDisabled = $derived(imageCtx.image === null)
|
||||||
|
const imageNonSquare = $derived(!imageIsCorrectRatio(imageCtx))
|
||||||
function imageNonSquare() {
|
|
||||||
if (imageCtx.image === null) return false
|
|
||||||
return imageCtx.image.height * INKCLIP_WIDTH !== imageCtx.image.width * INKCLIP_HEIGHT
|
|
||||||
}
|
|
||||||
|
|
||||||
function restoreDefaultImageSettings() {
|
function restoreDefaultImageSettings() {
|
||||||
config.scaleMode = 'fit'
|
config.scaleMode = 'fit'
|
||||||
@@ -40,23 +35,23 @@
|
|||||||
<section class="grow stack gap-4" aria-labelledby="controls-section-label">
|
<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>
|
<h2 class="font-semibold text-xl/6" id="controls-section-label">Edit image</h2>
|
||||||
|
|
||||||
{#if imageNonSquare()}
|
{#if imageNonSquare}
|
||||||
<AspectRatioAlert />
|
<AspectRatioAlert />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="row gap-4">
|
<div class="row gap-4">
|
||||||
{#if imageNonSquare()}
|
{#if imageNonSquare}
|
||||||
<ScaleModeToggleGroup />
|
<ScaleModeToggleGroup />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TransformControls disabled={transformDisabled} />
|
<TransformControls disabled={transformDisabled} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator decorative />
|
||||||
|
|
||||||
<BackgroundColorSlider />
|
<BackgroundColorSlider />
|
||||||
|
|
||||||
<Separator />
|
<Separator decorative />
|
||||||
|
|
||||||
<DitherControls />
|
<DitherControls />
|
||||||
|
|
||||||
@@ -68,7 +63,7 @@
|
|||||||
<BiasSlider />
|
<BiasSlider />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Separator />
|
<Separator decorative />
|
||||||
|
|
||||||
<Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}>
|
<Button variant="secondary" class="w-full" onclick={restoreDefaultImageSettings}>
|
||||||
<IconEditOff aria-hidden />
|
<IconEditOff aria-hidden />
|
||||||
|
|||||||
@@ -3,14 +3,20 @@
|
|||||||
import IconHideImage from '~icons/material-symbols/hide-image'
|
import IconHideImage from '~icons/material-symbols/hide-image'
|
||||||
|
|
||||||
import { cn } from '$lib/utils'
|
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 { getRenderedContext } from '$lib/contexts/rendered.svelte'
|
||||||
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
|
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 renderedCtx = getRenderedContext()
|
||||||
const hasRendered = $derived(renderedCtx.rendered !== null)
|
const hasRendered = $derived(renderedCtx.rendered !== null)
|
||||||
|
|
||||||
let canvasEl!: HTMLCanvasElement
|
let canvasEl: HTMLCanvasElement = $state(undefined!)
|
||||||
|
|
||||||
const ctx = $derived(freshContext(canvasEl))
|
const ctx = $derived(freshContext(canvasEl))
|
||||||
|
|
||||||
@@ -21,20 +27,20 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit relative" role="img" aria-labelledby="preview-1x-label">
|
<div class="bg-[#ccc] shadow-md rounded-lg p-2 w-fit relative" role="group" aria-labelledby="preview-1x-label">
|
||||||
<div class="p-[3px] border-[1px] border-[#888]">
|
<div class="p-[3px] shadow-sm shadow-[inset#888]" role="img" aria-label={makeAltText(filesCtx, imageCtx, config)}>
|
||||||
<canvas
|
{#if hasRendered}
|
||||||
class={cn(hasRendered || 'hidden')}
|
<canvas
|
||||||
style:image-rendering="pixelated"
|
class={cn(hasRendered || 'hidden')}
|
||||||
style:width="{INKCLIP_WIDTH}px"
|
style:image-rendering="pixelated"
|
||||||
style:height="{INKCLIP_HEIGHT}px"
|
style:width="{INKCLIP_WIDTH}px"
|
||||||
bind:this={canvasEl}
|
style:height="{INKCLIP_HEIGHT}px"
|
||||||
height={INKCLIP_HEIGHT}
|
bind:this={canvasEl}
|
||||||
width={INKCLIP_WIDTH}
|
height={INKCLIP_HEIGHT}
|
||||||
></canvas>
|
width={INKCLIP_WIDTH}
|
||||||
|
></canvas>
|
||||||
{#if !hasRendered}
|
{:else}
|
||||||
<div class="col justify-center text-muted" style:width="{INKCLIP_WIDTH}px" style:height="{INKCLIP_HEIGHT}px">
|
<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" />
|
<IconHideImage class="text-3xl" aria-label="No image" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label'
|
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 { cn } from '$lib/utils'
|
||||||
import { drawQuantizedData, freshContext } from './common.svelte'
|
import { drawQuantizedData, freshContext, makeAltText } from './common.svelte'
|
||||||
import { getFilesContext } from '$lib/contexts/files.svelte'
|
|
||||||
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
|
import { getRenderedContext } from '$lib/contexts/rendered.svelte'
|
||||||
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
|
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
|
||||||
|
import { getFilesContext } from '$lib/contexts/files.svelte'
|
||||||
interface Props {
|
import { getConversionConfig } from '$lib/contexts/config.svelte'
|
||||||
fileInputEl: HTMLInputElement | null
|
import { getImageContext } from '$lib/contexts/image.svelte'
|
||||||
}
|
|
||||||
|
|
||||||
const { fileInputEl }: Props = $props()
|
|
||||||
|
|
||||||
const filesCtx = getFilesContext()
|
const filesCtx = getFilesContext()
|
||||||
|
const imageCtx = getImageContext()
|
||||||
|
const config = getConversionConfig()
|
||||||
const renderedCtx = getRenderedContext()
|
const renderedCtx = getRenderedContext()
|
||||||
const hasRendered = $derived(renderedCtx.rendered !== null)
|
const hasRendered = $derived(renderedCtx.rendered !== null)
|
||||||
|
|
||||||
let canvasEl!: HTMLCanvasElement
|
let canvasEl: HTMLCanvasElement = $state(undefined!)
|
||||||
|
|
||||||
const ctx = $derived(freshContext(canvasEl))
|
const ctx = $derived(freshContext(canvasEl))
|
||||||
|
|
||||||
let fileOverDragZone = $state(false)
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (renderedCtx.rendered === null) return
|
if (renderedCtx.rendered === null) return
|
||||||
drawQuantizedData(ctx, renderedCtx.rendered)
|
drawQuantizedData(ctx, renderedCtx.rendered)
|
||||||
@@ -31,44 +27,26 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div class="bg-[#ccc] shadow-lg rounded-2xl p-4 w-fit relative" role="group" aria-labelledby="preview-2x-label">
|
||||||
ondragover={e => e.preventDefault()}
|
<div class="p-[6px] shadow shadow-[inset#888]" role="img" aria-label={makeAltText(filesCtx, imageCtx, config)}>
|
||||||
ondrop={e => {
|
{#if hasRendered}
|
||||||
e.preventDefault()
|
<canvas
|
||||||
const files = e.dataTransfer?.files
|
class={cn(hasRendered || 'hidden')}
|
||||||
if (files) filesCtx.files = files
|
style:image-rendering="pixelated"
|
||||||
}}
|
style:width="{INKCLIP_WIDTH * 2}px"
|
||||||
class="bg-[#ccc] shadow-md rounded-2xl p-4 w-fit relative"
|
style:height="{INKCLIP_HEIGHT * 2}px"
|
||||||
role="img"
|
bind:this={canvasEl}
|
||||||
aria-labelledby="preview-2x-label"
|
height={INKCLIP_HEIGHT}
|
||||||
>
|
width={INKCLIP_WIDTH}
|
||||||
<div class="p-[6px] border-[1px] border-[#888]">
|
></canvas>
|
||||||
<canvas
|
{:else}
|
||||||
class={cn(hasRendered || 'hidden')}
|
<div
|
||||||
style:image-rendering="pixelated"
|
class="col justify-center text-[#333]"
|
||||||
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',
|
|
||||||
)}
|
|
||||||
style:width="{INKCLIP_WIDTH * 2}px"
|
style:width="{INKCLIP_WIDTH * 2}px"
|
||||||
style:height="{INKCLIP_HEIGHT * 2}px"
|
style:height="{INKCLIP_HEIGHT * 2}px"
|
||||||
tabindex={-1}
|
|
||||||
aria-label="Choose file"
|
|
||||||
>
|
>
|
||||||
<IconUploadFile class="text-3xl" aria-hidden />
|
<IconHideImage class="text-6xl" aria-label="No image" />
|
||||||
<div class="font-medium">Drop file</div>
|
</div>
|
||||||
</button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { INKCLIP_HEIGHT, INKCLIP_WIDTH } from '$lib/constants'
|
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) {
|
export function freshContext(el: HTMLCanvasElement) {
|
||||||
const ctx = el.getContext('2d')!
|
const ctx = el.getContext('2d')!
|
||||||
@@ -6,6 +9,47 @@ export function freshContext(el: HTMLCanvasElement) {
|
|||||||
return ctx
|
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[]) {
|
export function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
|
||||||
const imageData = ctx.createImageData(INKCLIP_WIDTH, INKCLIP_HEIGHT)
|
const imageData = ctx.createImageData(INKCLIP_WIDTH, INKCLIP_HEIGHT)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user