diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..172dab6 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,13 @@ +{ + "languages": { + "TypeScript": { + "tab_size": 2, + }, + "TSX": { + "tab_size": 2, + }, + "JavaScript": { + "tab_size": 2, + }, + }, +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e42f8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# AGENTS.md + +Personal blog and photo site built with SolidJS + Vite. No test suite - verification is lint and typecheck only. + +## Setup + +Requires Nix. Devshell provides Node 25, TypeScript, and `cog` (conventional commits linter). + +## Commands + +| Task | Command | +|------|---------| +| Lint | `npx eslint src plugin` | +| Fix lint errors | `npx eslint src plugin --fix` | +| Typecheck | `tsc` | + +Run lint then typecheck before committing. Use + +## Commit convention + +CI enforces [Conventional Commits](https://www.conventionalcommits.org/) via `cocogitto` (`cog check`). + +## Virtual modules (codegen) + +The custom Vite plugin (`plugin/plugin.ts`) generates two virtual modules at build/dev time by scanning content directories: + +- `virtual:data` — exports `photos: ImageInfo[]` and `posts: PostInfo[]` +- `virtual:photo` — type-only helper used in `info.tsx` files +- `virtual:post` — type-only helper used in `post.tsx` files + +Type declarations live in `src/Virtual/`. + +App can read this information from `src/Data/Database.ts` + +## Content authoring + +### Photos + +Each photo lives in `src/Content/photos/-/`: +- `image.jpg` — source image +- `info.tsx` — metadata file, must call `photo({...})` from `virtual:photo` + +Directory name prefix (`NNNN-`) controls sort order (descending). The clean slug (without the numeric prefix) becomes the photo `id` at runtime. + +Example `info.tsx`: +```tsx +import photo from "virtual:photo"; + +photo({ + name: "Title", + camera: "Sony α7c", + tags: ["digital", "architecture", "moscow"], +}); +``` + +Valid tag values are defined in `src/Virtual/photo.d.ts`. + +### Blog posts + +Each post lives in `src/Content/blogs//`: +- `post.tsx` — calls `post({...})` from `virtual:post` at the top, then exports a default SolidJS component + +Posts with `status: "draft"` are visible in dev but excluded from production builds. + +## Image processing + +`sharp` handles image resizing. In dev, images are served on-the-fly via a Vite middleware at `/__images__//{full,preview}.jpg`. In production builds, Vite emits them as assets under `assets/images//`. + +SVGs imported as components are processed by `vite-plugin-solid-svg` with SVGO (config in `svgo.config.mjs`), defined in `src/Icons` and imported as components in `src/Components/Devicons.ts`. + +## Routing + +Defined in `src/index.tsx`. Routes: +- `/` — Home (no header, no footer) +- `/photo` — photo gallery +- `/photo/:id` — single photo (no header) +- `/blog` — blog list +- `/about` — about page + +## Tech stack quirks + +- **SolidJS**, not React. Reactivity model is signals-based. Do not use React patterns (no `useState`, `useEffect` only when necessary). +- JSX is configured with `jsxImportSource: "solid-js"` — no React import needed. +- CSS Modules are used throughout (`*.module.css`). TypeScript support via `typescript-plugin-css-modules`. +- `strict: true` TypeScript. `noEmit: true` — `tsc` is typecheck only, not a build step. diff --git a/eslint.config.mjs b/eslint.config.mjs index cd5b250..074e7f1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,4 +20,16 @@ export default [ }, }, eslintPluginPrettierRecommended, + { + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "CallExpression[callee.property.name='addEventListener'][arguments.0.value=/^(keydown|keyup|keypress)$/]", + message: "Do not add keyboard listeners addEventListener(). Use ", + }, + ], + }, + }, ]; diff --git a/plugin/plugin.ts b/plugin/plugin.ts index 330a62a..1835586 100644 --- a/plugin/plugin.ts +++ b/plugin/plugin.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import fs_sync from "node:fs"; import path from "node:path"; import sharp, { Metadata } from "sharp"; import { Plugin } from "vite"; @@ -28,6 +29,9 @@ function isDev(): boolean { return process.env.NODE_ENV === "development"; } +// Maps clean photo id (e.g. "electric-trinity") → source dir name (e.g. "0014-electric-trinity") +const idToDirName = new Map(); + function prepareImageForDev(id: string): ImagePrepareInfo { const imageUrl = `"${path.join( IMAGES_SERVE_PREFIX, @@ -73,37 +77,44 @@ async function prepareImageForBuild( } async function makePhotos(ctx: PluginContext) { - const photos = []; - for await (const info of fs.glob(path.join(CONTENT_DIR, "*", "info.tsx"))) { - const contents = await fs.readFile(info, { encoding: "utf-8" }); - const match = contents.match(/photo\(\{(?[\S\s]*)\}\);/); - if (match === null) { - throw new Error(`Could not parse ${info}`); - } - const infoMap = match.groups!["description"]; - - const infoDir = path.dirname(info); - const id = path.basename(infoDir); - - const src = path.join(infoDir, CONTENT_PHOTO); - const previewWidth = calcWidth(512, await sharp(src).metadata()); - - // imageOutPath and previewOutPath are - // code, strings need to be quoted - const { imageUrl, previewUrl } = isDev() - ? prepareImageForDev(id) - : await prepareImageForBuild(ctx, src, id); - - photos.push(`{ - ${infoMap} - id: "${id}", - previewWidth: ${previewWidth}, - imageUrl: ${imageUrl}, - previewUrl: ${previewUrl}, - }`); - } - - return photos; + const infoPaths = fs_sync.globSync(path.join(CONTENT_DIR, "*", "info.tsx")); + infoPaths.sort((a, b) => + path + .basename(path.dirname(b)) + .localeCompare(path.basename(path.dirname(a))), + ); + return Promise.all( + infoPaths.map(async (info) => { + const contents = await fs.readFile(info, { encoding: "utf-8" }); + const match = contents.match(/photo\(\{(?[\S\s]*)\}\);/); + if (match === null) { + throw new Error(`Could not parse ${info}`); + } + const infoMap = match.groups!["description"]; + + const infoDir = path.dirname(info); + const dirName = path.basename(infoDir); + const id = dirName.replace(/^\d+-/, ""); + idToDirName.set(id, dirName); + + const src = path.join(infoDir, CONTENT_PHOTO); + const previewWidth = calcWidth(512, await sharp(src).metadata()); + + // imageOutPath and previewOutPath are + // code, strings need to be quoted + const { imageUrl, previewUrl } = isDev() + ? prepareImageForDev(id) + : await prepareImageForBuild(ctx, src, id); + + return `{ + ${infoMap} + id: "${id}", + previewWidth: ${previewWidth}, + imageUrl: ${imageUrl}, + previewUrl: ${previewUrl}, + }`; + }), + ); } async function makePosts(): Promise { @@ -168,8 +179,9 @@ function contentPlugin(): Plugin { try { const [, , id, filename] = req.url.split("/"); + const dirName = idToDirName.get(id) ?? id; - let s = sharp(path.join(CONTENT_DIR, id, CONTENT_PHOTO)); + let s = sharp(path.join(CONTENT_DIR, dirName, CONTENT_PHOTO)); switch (filename) { case OUTPUT_PREVIEW_FILE_NAME: diff --git a/src/Components/Devicons.ts b/src/Components/Icons.ts similarity index 86% rename from src/Components/Devicons.ts rename to src/Components/Icons.ts index 46e7e57..80edadb 100644 --- a/src/Components/Devicons.ts +++ b/src/Components/Icons.ts @@ -1,9 +1,12 @@ -import { Component, JSX, lazy } from "solid-js"; +import { Component, JSX } from "solid-js"; const icons = import.meta.glob("../Icons/**/*.svg", { query: "?component-solid", + eager: true, }); +console.log("icons", icons); + type IconComponent = Component>; function load(name: string, location?: string): IconComponent { @@ -11,11 +14,9 @@ function load(name: string, location?: string): IconComponent { location = "devicon"; } - return lazy(() => { - return icons[`../Icons/${location}/${name}.svg`]() as Promise<{ - default: IconComponent; - }>; - }); + return ( + icons[`../Icons/${location}/${name}.svg`] as { default: IconComponent } + ).default; } export const Java = load("java"); diff --git a/src/Components/ZoomableImage.tsx b/src/Components/ZoomableImage.tsx index 49cd35b..0f64224 100644 --- a/src/Components/ZoomableImage.tsx +++ b/src/Components/ZoomableImage.tsx @@ -3,11 +3,51 @@ import { Component, createSignal, onCleanup, onMount } from "solid-js"; import style from "./ZoomableImage.module.css"; import { Vector, apply_on_rows } from "../Data/Vector"; import classList from "../Util/Classes"; +import keyHandlerStack from "../Util/KeyHandlerStack"; function clamp(val: number, min: number, max: number) { return Math.max(min, Math.min(max, val)); } +type State = + // Image is not active and can not be interacted with + | { enabled: false } + // Image is active but is not actively interacted with + | { enabled: true } + // Image is active and has active pointer interaction + | { + enabled: true; + movedDuringInteraction: boolean; // TODO: do we need this in pointer interaction? + lastPointerPosition: Vector; + } + // Image is active and has active single touch interaction + | { enabled: true; movedDuringInteraction: boolean; prevTouches: [Vector] } + // Image is active and has active two touch (pinch) interaction + | { + enabled: true; + movedDuringInteraction: boolean; + prevTouches: [Vector, Vector]; + }; + +function clampPosition(args: { + position: number; + scale: number; + size: number; +}): number { + const halfSize = args.size / 2; + // Beginning moved past original beginning + if (args.position - halfSize * args.scale > -halfSize) { + return halfSize * (args.scale - 1); + } + + // End moved before original end + if (args.position + halfSize * args.scale < halfSize) { + return -halfSize * (args.scale - 1); + } else { + return args.position; + } +} + const ZoomableImage: Component<{ src: string; classList?: { @@ -29,8 +69,6 @@ const ZoomableImage: Component<{ zoomLimit: number; }; - let enabled = false; - // Styles const [active, setActive] = createSignal(false); const [tinted, setTinted] = createSignal(false); @@ -42,12 +80,7 @@ const ZoomableImage: Component<{ scale: 1, }); - let prevTouches: [Vector] | [Vector, Vector] | undefined = undefined; - let lastPointerPosition: Vector | undefined = undefined; - // Track if current pointer/touch down -> pointer/touch up sequence - // contained any movement - // See handlePointerDown and handlePointerMove for more details - let movedDuringClick = false; + let state: State = { enabled: false }; /** * Call this function in {@link onMount} and on resize to calculate @@ -92,25 +125,6 @@ const ZoomableImage: Component<{ setZoomState(calcNewState(0, Vector.zero())); } - function clampPosition(args: { - position: number; - scale: number; - size: number; - }): number { - const halfSize = args.size / 2; - // Beginning moved past original beginning - if (args.position - halfSize * args.scale > -halfSize) { - return halfSize * (args.scale - 1); - } - - // End moved before original end - if (args.position + halfSize * args.scale < halfSize) { - return -halfSize * (args.scale - 1); - } else { - return args.position; - } - } - function calcNewState( delta: number, point: Vector, @@ -152,49 +166,61 @@ const ZoomableImage: Component<{ function handleTouchStart(event: TouchEvent) { event.preventDefault(); - // We want only single finger tap to decativate - // the focused state. Any other touch combination - // and we assume that some movement was made - // and do not deactivate - movedDuringClick = true; - if (event.touches.length === 1) { - // If a touch was added and now there is one in activeTouches - // then that is the touch that was added - prevTouches = [Vector.fromClient(event.changedTouches[0])]; - // Only single touch can deactivate when no movement - movedDuringClick = false; + state = { + enabled: true, + // Only single touch can deactivate when no movement + movedDuringInteraction: false, + // If a touch was added and now there is one in activeTouches + // then that is the touch that was added + prevTouches: [Vector.fromClient(event.changedTouches[0])], + }; } else if (event.touches.length === 2) { - if (event.changedTouches.length === 1) { - // One touch added. We promote from one touch dragging to two touch resizing - prevTouches = [ - prevTouches![0], // Can not be undefined, one touch already added - Vector.fromClient(event.changedTouches[0]), - ]; + // We want only single finger tap to decativate the focused state. Any + // other touch combination and we assume that some movement was made + // and set movedDuringInteraction = true + + // One touch added. We promote from one touch dragging to two touch resizing. + // We would also expect that prevTouches is already set and has one element, + // and fall back to creating two touches just in case + if (event.changedTouches.length === 1 && "prevTouches" in state) { + state = { + enabled: true, + movedDuringInteraction: true, + prevTouches: [ + state.prevTouches[0], + Vector.fromClient(event.changedTouches[0]), + ], + }; } else { // Two touches added at once - prevTouches = [ - Vector.fromClient(event.changedTouches[0]), - Vector.fromClient(event.changedTouches[1]), - ]; + state = { + enabled: true, + movedDuringInteraction: true, + prevTouches: [ + Vector.fromClient(event.changedTouches[0]), + Vector.fromClient(event.changedTouches[1]), + ], + }; } } else { // Three and more touches are ignored - prevTouches = undefined; + state = { enabled: true }; } } function handleTouchMove(event: TouchEvent) { event.preventDefault(); - if (prevTouches === undefined) { + // touchmove but no touchstart? Strange, ignore it just in case + if (!("prevTouches" in state)) { return; } - if (prevTouches.length === 1) { + if (state.prevTouches.length === 1) { const newPointerPosition = Vector.fromClient(event.touches[0]); - const delta = newPointerPosition.sub(prevTouches[0]); - prevTouches[0] = newPointerPosition; + const delta = newPointerPosition.sub(state.prevTouches[0]); + state.prevTouches[0] = newPointerPosition; // When using touch devices (or bad mouses) even fast taps // have some miniscule amount of motion which we need to dampen @@ -203,15 +229,15 @@ const ZoomableImage: Component<{ // were big not just the last before release. User can // drag image around and then hesitate for a long time before // releasing click - movedDuringClick ||= isBigMovement; + state.movedDuringInteraction ||= isBigMovement; - const state = zoomState(); + const zoomSt = zoomState(); setZoomState({ - scale: state.scale, + scale: zoomSt.scale, position: apply_on_rows( { - position: state.position.add(delta), - scale: new Vector(state.scale, state.scale), + position: zoomSt.position.add(delta), + scale: new Vector(zoomSt.scale, zoomSt.scale), size: neutralImageDimensions.size, }, clampPosition, @@ -223,15 +249,15 @@ const ZoomableImage: Component<{ Vector, ]; - const centerPrev = prevTouches[0].add(prevTouches[1]).div(2); + const centerPrev = state.prevTouches[0].add(state.prevTouches[1]).div(2); const centerCur = touches[0].add(touches[1]).div(2); const centerMove = centerCur.sub(centerPrev); const scale = touches[0].sub(touches[1]).abs() / - prevTouches[0].sub(prevTouches[1]).abs(); + state.prevTouches[0].sub(state.prevTouches[1]).abs(); - prevTouches = touches; + state.prevTouches = touches; if (Math.abs(scale - 1) > 0.00001) { setZoomState( @@ -248,26 +274,39 @@ const ZoomableImage: Component<{ event.preventDefault(); if (event.touches.length === 0) { - // All touches lifted - prevTouches = undefined; - - // In touch mode there is no click - // event, do it ourself - if (!movedDuringClick) { + if ("movedDuringInteraction" in state && !state.movedDuringInteraction) { + // One touch -> zero touches with no movement. + // We detected a click. + // (note: there are no click events in touch mode) clickDeactivate(); + } else { + // Went from 2+ touches to zero or there was movement. + // Does not count as a click. + state = { enabled: true }; } } else if (event.touches.length === 1) { // Down to one touch, use it - prevTouches = [Vector.fromClient(event.touches[0])]; + state = { + enabled: true, + // We went from 2+ touches down to. This should not be + // considered a click if no movement happens. + movedDuringInteraction: true, + prevTouches: [Vector.fromClient(event.touches[0])], + }; } else if (event.touches.length === 2) { - // Down to two touches - prevTouches = [...event.touches].map(Vector.fromClient) as [ - Vector, - Vector, - ]; + // Down to two touches, we can start tracking this interaction + state = { + enabled: true, + // Ignoring two touches. See handleTouchStart + movedDuringInteraction: true, + prevTouches: [ + Vector.fromClient(event.changedTouches[0]), + Vector.fromClient(event.changedTouches[1]), + ], + }; } else { // Still too much touches - prevTouches = undefined; + state = { enabled: true }; } } @@ -278,10 +317,14 @@ const ZoomableImage: Component<{ return; } - // At the start of sequence there is no movement - movedDuringClick = false; image.style.cursor = "grabbing"; - lastPointerPosition = Vector.fromClient(event); + + state = { + enabled: true, + // At the start of sequence there is no movement + movedDuringInteraction: false, + lastPointerPosition: Vector.fromClient(event), + }; } function handlePointerUp(event: PointerEvent) { @@ -291,51 +334,58 @@ const ZoomableImage: Component<{ return; } - lastPointerPosition = undefined; image.style.cursor = ""; + if ("movedDuringInteraction" in state && !state.movedDuringInteraction) { + clickDeactivate(); + } else { + state = { enabled: true }; + } } function handlePointerMove(event: PointerEvent) { event.preventDefault(); + // Separate touch operation from mouse handling entierly if (event.pointerType !== "mouse") { return; } - if (lastPointerPosition !== undefined) { - const newPointerPosition = Vector.fromClient(event); - const delta = newPointerPosition.sub(lastPointerPosition); - lastPointerPosition = newPointerPosition; - - // When using touch devices (or bad mouses) even fast taps - // have some miniscule amount of motion which we need to dampen - const isBigMovement = delta.abs() > 0.1; - // In movedDuringClick we calculate if any of the motions in sequence - // were big not just the last before release. User can - // drag image around and then hesitate for a long time before - // releasing click - movedDuringClick ||= isBigMovement; - - const state = zoomState(); - setZoomState({ - scale: state.scale, - position: apply_on_rows( - { - position: state.position.add(delta), - scale: new Vector(state.scale, state.scale), - size: neutralImageDimensions.size, - }, - clampPosition, - ), - }); + if (!("lastPointerPosition" in state)) { + return; } + + const newPointerPosition = Vector.fromClient(event); + const delta = newPointerPosition.sub(state.lastPointerPosition); + state.lastPointerPosition = newPointerPosition; + + // When using touch devices (or bad mouses) even fast taps + // have some miniscule amount of motion which we need to dampen + const isBigMovement = delta.abs() > 0.1; + // In movedDuringClick we calculate if any of the motions in sequence + // were big not just the last before release. User can + // drag image around and then hesitate for a long time before + // releasing click + state.movedDuringInteraction ||= isBigMovement; + + const zoomSt = zoomState(); + setZoomState({ + scale: zoomSt.scale, + position: apply_on_rows( + { + position: zoomSt.position.add(delta), + scale: new Vector(zoomSt.scale, zoomSt.scale), + size: neutralImageDimensions.size, + }, + clampPosition, + ), + }); } function ifEnabled( func: (event: T) => void, ): (event: T) => void { return (event) => { - if (enabled) { + if (state.enabled) { event.stopPropagation(); func(event); } @@ -351,6 +401,15 @@ const ZoomableImage: Component<{ container.style.left = `${-container.getBoundingClientRect().left}px`; container.style.top = `${-container.getBoundingClientRect().top}px`; image.style.borderRadius = "0"; + // During enabled state we use pointerdown pointerup pair + // and pointermove events in between to differentiate if movement was + // a click or a dragging motion. See how movedDuringInteraction is used. + // Click can not be reliably used for this because movementX/Y property + // measures from last event, not from start of click and is generally + // unreliable (see https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/movementX). + // We need to remove click listener because it still fires even if pointerup was + // handled and interferes with state handling + container.removeEventListener("click", clickActivate); container.ontransitionend = () => { container.style.width = ""; @@ -363,11 +422,13 @@ const ZoomableImage: Component<{ setActive(true); setNoTransition(true); - enabled = true; + state = { enabled: true }; + keyHandlerStack.push(keyEventHandler); }; } function clickDeactivate(): void { + keyHandlerStack.pop(keyEventHandler); setNoTransition(false); setTinted(false); setZoomState({ position: Vector.zero(), scale: 1 }); @@ -377,12 +438,13 @@ const ZoomableImage: Component<{ container.style.top = `${wrapper.getBoundingClientRect().top}px`; image.style.borderRadius = "1rem"; - enabled = false; + state = { enabled: false }; container.ontransitionend = () => { setNoTransition(true); setActive(false); setOnTop(false); + container.addEventListener("click", clickActivate); container.style.width = ""; container.style.height = ""; container.style.left = ""; @@ -399,15 +461,7 @@ const ZoomableImage: Component<{ } } - function onClick() { - if (enabled) { - if (!movedDuringClick) { - clickDeactivate(); - } - } else { - clickActivate(); - } - } + const keyEventHandler = { keydown: handleKeyDown }; // Wait not only for image component to mount, but also until // blob is properly loaded @@ -416,16 +470,15 @@ const ZoomableImage: Component<{ updateNeutralImageDimensions(); window.addEventListener("resize", updateNeutralImageDimensions); - window.addEventListener("keydown", ifEnabled(handleKeyDown)); // When image is in disabled state container is exactly the size of // image and catches propagated events. When enabled container // is whole viewport - container.addEventListener("click", onClick); + container.addEventListener("click", clickActivate); // Handling touches and mouse need to be done sepearately // Event though pointerup and pointerdown respond to touch inputs - // they behave strangely, e.g. pointerup does not fire e.t.c + // they behave strangely, e.g. pointerup does not fire sometimes e.t.c // These handle touch devices exclusively container.addEventListener("touchstart", ifEnabled(handleTouchStart)); @@ -446,7 +499,7 @@ const ZoomableImage: Component<{ onCleanup(() => { window.removeEventListener("resize", updateNeutralImageDimensions); - window.removeEventListener("keydown", handleKeyDown); + keyHandlerStack.pop(keyEventHandler); }); return ( diff --git a/src/Pages/About.tsx b/src/Pages/About.tsx index cfad65a..b7d6af6 100644 --- a/src/Pages/About.tsx +++ b/src/Pages/About.tsx @@ -26,7 +26,7 @@ import { SQLite, Tokio, Ts, -} from "../Components/Devicons"; +} from "../Components/Icons"; import Card from "../Components/Card"; const techIKnow: [Component, string][][] = [ diff --git a/src/Pages/PhotoDetailed.tsx b/src/Pages/PhotoDetailed.tsx index af3f492..9855150 100644 --- a/src/Pages/PhotoDetailed.tsx +++ b/src/Pages/PhotoDetailed.tsx @@ -6,12 +6,14 @@ import { For, createSignal, createEffect, + onCleanup, + onMount, on, } from "solid-js"; import { useLocation, useNavigate, useParams } from "@solidjs/router"; import style from "./PhotoDetailed.module.css"; -import getDB from "../Data/Database"; +import getDB, { ImageInfo } from "../Data/Database"; import ZoomableImage from "../Components/ZoomableImage"; import { Aperture, @@ -31,6 +33,7 @@ import classList from "../Util/Classes"; import Tag from "../Components/Tag"; import Arrays from "../Util/Arrays"; import swipe from "../Util/Swipe"; +import keyHandlerStack from "../Util/KeyHandlerStack"; const Toolbar: Component<{ selfId: string; @@ -107,6 +110,48 @@ const Tags: Component<{ class: string; tags?: string[] }> = (props) => { ); }; +const InfoCard: Component<{ + imageInfo: ImageInfo; + previewURL: string; +}> = (props) => { + return ( + <> + + +

{props.imageInfo.name}

+ + + +
+ + + + +

{props.imageInfo.camera}

+
+ + + +

{props.imageInfo.lens}

+
+ + + +

{props.imageInfo.film}

+
+ + + + + ); +}; + const PhotoDetailed: Component = () => { const params = useParams(); const location = useLocation(); @@ -173,11 +218,46 @@ const PhotoDetailed: Component = () => { ), ); - swipe((dir) => { - if (dir === "right" && info()?.prevURL !== undefined) + function navPrev() { + if (info()?.prevURL !== undefined) navigate(`/photo/${info()!.prevURL}${location.search}`); - else if (dir === "left" && info()?.nextURL !== undefined) + } + + function navNext() { + if (info()?.nextURL !== undefined) navigate(`/photo/${info()!.nextURL}${location.search}`); + } + + function navClose() { + navigate(`/photo${location.search}#${params.id}`); + } + + swipe((dir) => { + switch (dir) { + case "right": + return navPrev(); + case "left": + return navNext(); + case "down": + return navClose(); + } + }); + + onMount(() => { + function onKeyDown(e: KeyboardEvent) { + switch (e.key) { + case "Escape": + return navClose(); + case "ArrowLeft": + return navPrev(); + case "ArrowRight": + return navNext(); + } + } + const keyEventHandler = { keydown: onKeyDown }; + + keyHandlerStack.push(keyEventHandler); + onCleanup(() => keyHandlerStack.pop(keyEventHandler)); }); return ( @@ -217,38 +297,10 @@ const PhotoDetailed: Component = () => { when={!(info.error || info() == undefined)} fallback={

Could not load image info

} > - - -

{info()!.imageInfo.name}

- - - -
- - - - -

{info()!.imageInfo.camera}

-
- - - -

{info()!.imageInfo?.lens}

-
- - - -

{info()!.imageInfo.film}

-
- - -
diff --git a/src/Util/KeyHandlerStack.ts b/src/Util/KeyHandlerStack.ts new file mode 100644 index 0000000..76b170f --- /dev/null +++ b/src/Util/KeyHandlerStack.ts @@ -0,0 +1,81 @@ +type KeyEventType = "keydown" | "keyup"; +type KeyEventHandler = (event: KeyboardEvent) => unknown; +type KeyEventHandlers = Partial>; + +class KeyEventListeners { + private handlers: KeyEventHandlers[] = []; + private listening = false; + + private handleKeyEvent = (event: KeyboardEvent): void => { + if (event.type !== "keydown" && event.type !== "keyup") { + return; + } + + const handler = this.handlers.at(-1)?.[event.type]; + + if (handler !== undefined) { + handler(event); + } + }; + + /** + * Add a pair of key handlers to the top of the stack. + */ + push(handler: KeyEventHandlers): void { + this.handlers.push(handler); + this.ensureListening(); + } + + /** + * Remove a pair of key handlers from the stack, together with every entry + * above it. + */ + pop(handler: KeyEventHandlers): void { + const index = this.handlers.lastIndexOf(handler); + + if (index === -1) { + return; + } + + this.handlers.splice(index); + + if (this.handlers.length === 0) { + this.stopListening(); + } + } + + private ensureListening(): void { + if (this.listening) { + return; + } + + // eslint-disable-next-line no-restricted-syntax -- This is the central keyboard listener used by the stack. + document.addEventListener("keydown", this.handleKeyEvent); + // eslint-disable-next-line no-restricted-syntax -- This is the central keyboard listener used by the stack. + document.addEventListener("keyup", this.handleKeyEvent); + this.listening = true; + } + + private stopListening(): void { + if (!this.listening) { + return; + } + + document.removeEventListener("keydown", this.handleKeyEvent); + document.removeEventListener("keyup", this.handleKeyEvent); + this.listening = false; + } +} + +/** + * A stack of key event handlers for pages that need temporary keyboard + * precedence. + * + * Only the top entry in the stack receives `keydown`/`keyup` events from + * `document`, so pushing a new entry suppresses handlers below it until the + * entry is removed. + */ +const keyHandlerStack = new KeyEventListeners(); + +export type { KeyEventHandler, KeyEventHandlers, KeyEventType }; +export default keyHandlerStack; diff --git a/src/Util/Swipe.ts b/src/Util/Swipe.ts index 037f6fc..396a20d 100644 --- a/src/Util/Swipe.ts +++ b/src/Util/Swipe.ts @@ -1,13 +1,13 @@ import { onCleanup } from "solid-js"; -type SwipeDirection = "left" | "right"; +type SwipeDirection = "left" | "right" | "down"; type SwipeHandler = (direction: SwipeDirection) => void; -// number: startX of active single-finger gesture +// [number, number]: [startX, startY] of active single-finger gesture // "scrolled": gesture was invalidated by a scroll // undefined: no active gesture -type SwipeState = number | "scrolled" | undefined; +type SwipeState = [number, number] | "scrolled" | undefined; const THRESHOLD = 50; @@ -23,7 +23,7 @@ function swipe(onSwipe: SwipeHandler) { state = undefined; return; } - state = e.touches[0].clientX; + state = [e.touches[0].clientX, e.touches[0].clientY]; // detect if scroll happened during swipe window.addEventListener("scroll", onScroll, { once: true }); } @@ -31,14 +31,17 @@ function swipe(onSwipe: SwipeHandler) { function onTouchEnd(e: TouchEvent) { window.removeEventListener("scroll", onScroll); - if (typeof state !== "number") { + if (!Array.isArray(state)) { return; } - const deltaX = e.changedTouches[0].clientX - state; + const deltaX = e.changedTouches[0].clientX - state[0]; + const deltaY = e.changedTouches[0].clientY - state[1]; state = undefined; - if (deltaX > THRESHOLD) { + if (deltaY > THRESHOLD && Math.abs(deltaY) > Math.abs(deltaX)) { + onSwipe("down"); + } else if (deltaX > THRESHOLD) { onSwipe("right"); } else if (deltaX < -THRESHOLD) { onSwipe("left");