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