Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ short-demo.md
# Yarn (node-modules linker)
.yarn/*
!.yarn/patches

# private launch working file
launch-plan.md
114 changes: 114 additions & 0 deletions src/components/upload/FileDropZone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useRef, useState } from "react"
import type { DragEvent, ReactNode } from "react"

import { cn } from "@/lib/utils"

type Props = {
/** An `accept` value with a wildcard subtype, e.g. `image/*`. */
accept: `${string}/*`
onPick: (file: File) => void
/**
* Whether nothing is loaded yet. Clicking only opens the picker while empty:
* once there's a source, the surface belongs to the tool (dithering uses a
* press on the canvas to compare), and a stray click that reopened a file
* dialog would be worse than no shortcut at all. Dropping still works either
* way, which is the gesture people reach for to *replace* something.
*/
empty: boolean
/** Empty-state line, e.g. "Drop an image, or click to upload". */
hint: string
className?: string
children: ReactNode
}

/**
* The preview surface, doubling as the upload target.
*
* The empty state used to be a `pointer-events-none` label that said "click
* upload" while pointing at a button somewhere else on the screen — it read as
* an affordance and behaved like a caption. Here the whole area is a real
* `<button>`, so it takes a click, a tab stop and Enter/Space for free.
*/
export function FileDropZone({
accept,
onPick,
empty,
hint,
className,
children,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)

// "image/*" → "image/". Anything else dropped here is ignored rather than
// handed to a decoder that will fail on it.
const prefix = `${accept.slice(0, accept.indexOf("/"))}/`

const takeFile = (file: File | null | undefined) => {
if (file?.type.startsWith(prefix)) onPick(file)
}

const onDrop = (e: DragEvent) => {
e.preventDefault()
setDragging(false)
takeFile(e.dataTransfer.files.item(0))
}

return (
<div
className={cn("relative", className)}
onDragOver={(e) => e.preventDefault()}
onDragEnter={() => setDragging(true)}
onDragLeave={(e) => {
// Fires for children too; ignore anything still inside the zone.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDragging(false)
}
}}
onDrop={onDrop}
>
{children}

{empty && (
<button
type="button"
onClick={() => inputRef.current?.click()}
aria-label={hint}
className="group absolute inset-0 grid cursor-pointer place-items-center outline-none focus-visible:ring-2 focus-visible:ring-white/60"
>
<span
className={cn(
"border border-dashed px-6 py-4 text-xs tracking-widest uppercase transition-colors",
dragging
? "border-white/60 text-white"
: "border-white/25 text-white/50 group-hover:border-white/40"
)}
>
{hint}
</span>
</button>
)}

{/* Ring the whole surface while a file is over it, loaded or not — the
only feedback that a drop will actually land. */}
{dragging && !empty && (
<span
aria-hidden
className="pointer-events-none absolute inset-0 border-2 border-white/60"
/>
)}

<input
ref={inputRef}
type="file"
accept={accept}
hidden
onChange={(e) => {
takeFile(e.target.files?.[0])
// Let the same file be chosen twice in a row.
e.target.value = ""
}}
/>
</div>
)
}
2 changes: 1 addition & 1 deletion src/features/canvas-stuff/anti-aliasing/AACanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function AACanvas({
[]
)

const endHold = () => comparing && onHoldEnd()
const endHold = () => onHoldEnd()
const startHold = (e: ReactPointerEvent) => {
e.preventDefault()
onHoldStart()
Expand Down
13 changes: 8 additions & 5 deletions src/features/canvas-stuff/anti-aliasing/AntiAlias.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function AntiAlias() {
const {
settings,
comparing,
compareLatched,
collapsed,
animating,
region,
Expand All @@ -20,7 +21,9 @@ export function AntiAlias() {
displayHeight,
onChange,
exportPng,
setComparing,
setCompareLatched,
startPeek,
endPeek,
setCollapsed,
setAnimating,
setRegion,
Expand All @@ -42,8 +45,8 @@ export function AntiAlias() {
comparing={comparing}
displayWidth={displayWidth}
displayHeight={displayHeight}
onHoldStart={() => setComparing(true)}
onHoldEnd={() => setComparing(false)}
onHoldStart={startPeek}
onHoldEnd={endPeek}
>
<ZoomSelection
region={region}
Expand All @@ -64,8 +67,8 @@ export function AntiAlias() {
animating={animating}
onToggleAnimate={setAnimating}
onExport={exportPng}
onCompareStart={() => setComparing(true)}
onCompareEnd={() => setComparing(false)}
comparing={compareLatched}
onComparingChange={setCompareLatched}
onCollapse={() => setCollapsed(true)}
/>
<ZoomBox
Expand Down
29 changes: 13 additions & 16 deletions src/features/canvas-stuff/anti-aliasing/Controls.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Download, Eye } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Toggle } from "@/components/ui/toggle"
import {
Select,
SelectContent,
Expand All @@ -19,8 +20,8 @@ type Props = {
animating: boolean
onToggleAnimate: (value: boolean) => void
onExport: () => void
onCompareStart: () => void
onCompareEnd: () => void
comparing: boolean
onComparingChange: (comparing: boolean) => void
onCollapse: () => void
}

Expand All @@ -30,30 +31,26 @@ export function Controls({
animating,
onToggleAnimate,
onExport,
onCompareStart,
onCompareEnd,
comparing,
onComparingChange,
onCollapse,
}: Props) {
const sceneLabel = SCENES.find((s) => s.value === settings.scene)?.label

return (
<Panel title="Controls" onCollapse={onCollapse}>
<div className="flex gap-2">
<Button
{/* A latch, not a hold — holding still works on the canvas itself.
This is the one you want while dragging Samples. */}
<Toggle
variant="outline"
className="flex-1 touch-none select-none"
aria-label="Hold to compare with the aliased version"
onPointerDown={(e) => {
e.preventDefault()
onCompareStart()
}}
onPointerUp={onCompareEnd}
onPointerLeave={onCompareEnd}
onPointerCancel={onCompareEnd}
className="flex-1"
pressed={comparing}
onPressedChange={onComparingChange}
>
<Eye />
Hold to compare
</Button>
{comparing ? "Showing aliased" : "Compare"}
</Toggle>
<Button
variant="outline"
size="icon"
Expand Down
55 changes: 55 additions & 0 deletions src/features/canvas-stuff/anti-aliasing/raster.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest"

import { SCENES, findEdge, render } from "./raster"

const W = 60
const H = 40
const BASE = {
scene: "pentagon" as const,
samples: 4,
angle: 18,
size: 0.6,
resolution: 200,
}

describe("findEdge", () => {
it("finds an edge for every scene, so the loupe is never parked on flat colour", () => {
for (const { value } of SCENES) {
const data = render(W, H, { ...BASE, scene: value })
const edge = findEdge(data, W, H)
expect(edge, `scene ${value}`).not.toBeNull()

const px = Math.floor(edge!.x * W)
const py = Math.floor(edge!.y * H)
const r = data[(py * W + px) * 4]
// Neither pure background nor pure foreground: a partially covered pixel.
expect(r, `scene ${value}`).toBeGreaterThan(18)
expect(r, `scene ${value}`).toBeLessThan(236)
}
})

it("returns null when nothing is partially covered", () => {
// One sample per pixel is a yes/no answer, so no pixel is ever partial.
const aliased = render(W, H, { ...BASE, samples: 1 })
expect(findEdge(aliased, W, H)).toBeNull()
})

it("prefers the edge nearest the middle of the frame", () => {
const data = render(W, H, BASE)
const edge = findEdge(data, W, H)!

// Every other partial pixel must be at least as far from the centre.
const cx = W / 2
const cy = H / 2
const dist = (x: number, y: number) => (x - cx) ** 2 + (y - cy) ** 2
const chosen = dist(edge.x * W, edge.y * H)

for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const r = data[(py * W + px) * 4]
if (r <= 18 || r >= 236) continue
expect(dist(px + 0.5, py + 0.5)).toBeGreaterThanOrEqual(chosen - 1e-9)
}
}
})
})
45 changes: 45 additions & 0 deletions src/features/canvas-stuff/anti-aliasing/raster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,48 @@ export function render(w: number, h: number, s: AASettings): Uint8ClampedArray {
}
return out
}

/**
* The edge pixel nearest the middle of the frame, in normalised (0..1) coords.
*
* The loupe used to open at dead centre, which for every scene here is deep
* *inside* the shape — a flat white square. That made the one instrument
* capable of settling "is this actually anti-aliased?" show nothing at all
* until you dragged it somewhere useful.
*
* An edge is found by coverage, not geometry, so this works for any scene
* without a per-scene table: a pixel that is neither fully background nor
* fully foreground is by definition one the shape's boundary passed through.
* Ties break towards the centre, so the result is deterministic.
*
* Returns `null` for a frame with no partial pixels at all (a 1-sample render,
* or an empty scene), leaving the caller's current centre alone.
*/
export function findEdge(
data: Uint8ClampedArray,
w: number,
h: number
): { x: number; y: number } | null {
const lo = Math.min(BG[0], FG[0])
const hi = Math.max(BG[0], FG[0])
const cx = w / 2
const cy = h / 2

let best: { x: number; y: number } | null = null
let bestDist = Infinity

for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const r = data[(py * w + px) * 4]
if (r <= lo || r >= hi) continue
const dx = px + 0.5 - cx
const dy = py + 0.5 - cy
const dist = dx * dx + dy * dy
if (dist < bestDist) {
bestDist = dist
best = { x: (px + 0.5) / w, y: (py + 0.5) / h }
}
}
}
return best
}
Loading
Loading