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:
+3
-3
@@ -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
@@ -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 />
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -72,4 +72,8 @@ export class Scaler {
|
||||
dHeight: this.canvasHeight,
|
||||
}
|
||||
}
|
||||
|
||||
scale(mode: ScaleMode, image: ImageBitmap): DrawParameters {
|
||||
return this[mode](image)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 © <a class="hover:underline" href="https://dayli.ly">daylily</a>
|
||||
2025 © <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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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° 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° 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user