diff --git a/bun.lock b/bun.lock index 36c1726aa..e995c466b 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "devDependencies": { "nodemon": "^3.1.14", - "oxlint": "^1.78.0", + "oxlint": "^1.79.0", "typescript": "^6.0.3", }, }, @@ -200,58 +200,6 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.5", "", { "os": "android", "cpu": "arm" }, "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.5", "", { "os": "android", "cpu": "arm64" }, "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.5", "", { "os": "android", "cpu": "x64" }, "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.5", "", { "os": "linux", "cpu": "arm" }, "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.5", "", { "os": "none", "cpu": "x64" }, "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.5", "", { "os": "win32", "cpu": "x64" }, "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA=="], - "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], @@ -324,8 +272,6 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -652,8 +598,6 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -698,8 +642,6 @@ "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -880,8 +822,6 @@ "es-toolkit": ["es-toolkit@1.50.0", "", {}, "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w=="], - "esbuild": ["esbuild@0.27.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.5", "@esbuild/android-arm": "0.27.5", "@esbuild/android-arm64": "0.27.5", "@esbuild/android-x64": "0.27.5", "@esbuild/darwin-arm64": "0.27.5", "@esbuild/darwin-x64": "0.27.5", "@esbuild/freebsd-arm64": "0.27.5", "@esbuild/freebsd-x64": "0.27.5", "@esbuild/linux-arm": "0.27.5", "@esbuild/linux-arm64": "0.27.5", "@esbuild/linux-ia32": "0.27.5", "@esbuild/linux-loong64": "0.27.5", "@esbuild/linux-mips64el": "0.27.5", "@esbuild/linux-ppc64": "0.27.5", "@esbuild/linux-riscv64": "0.27.5", "@esbuild/linux-s390x": "0.27.5", "@esbuild/linux-x64": "0.27.5", "@esbuild/netbsd-arm64": "0.27.5", "@esbuild/netbsd-x64": "0.27.5", "@esbuild/openbsd-arm64": "0.27.5", "@esbuild/openbsd-x64": "0.27.5", "@esbuild/openharmony-arm64": "0.27.5", "@esbuild/sunos-x64": "0.27.5", "@esbuild/win32-arm64": "0.27.5", "@esbuild/win32-ia32": "0.27.5", "@esbuild/win32-x64": "0.27.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], @@ -1484,8 +1424,6 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], "sponsorkit": ["sponsorkit@17.1.1", "", { "dependencies": { "ansis": "^4.3.0", "cac": "^7.0.0", "consola": "^3.4.2", "dotenv": "^17.4.2", "ofetch": "^1.5.1", "sharp": "^0.34.5", "unconfig": "^7.5.0" }, "bin": { "sponsorkit": "./bin/sponsorkit.mjs" } }, "sha512-qsDA8ggyJSVszmaDa9aXJFp6DXu8ucK1kA2LRhxijpKcmRA9ndDcQZd8j9RfnLto49Q3SuAsH0L+8QStaZhwzw=="], @@ -1530,8 +1468,6 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "terser": ["terser@5.46.1", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -1652,8 +1588,6 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], - "yocto-spinner": ["yocto-spinner@1.2.2", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], @@ -1770,8 +1704,6 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "vite/postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="], diff --git a/packages/koharu/components/controls/FontPicker.tsx b/packages/koharu/components/controls/FontPicker.tsx index 7d12a442b..8857a3ef6 100644 --- a/packages/koharu/components/controls/FontPicker.tsx +++ b/packages/koharu/components/controls/FontPicker.tsx @@ -3,7 +3,16 @@ import { observeElementRect, useVirtualizer } from '@tanstack/react-virtual' import type { TFunction } from 'i18next' import { ChevronDown, ListFilter, Search, X } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + forwardRef, + type KeyboardEvent, + type RefObject, +} from 'react' import { useTranslation } from 'react-i18next' import { useFontPreview } from '@/lib/queries' @@ -45,6 +54,10 @@ type Filters = { useCase: string } +interface FontListHandle { + focusRelative: (direction: -1 | 1) => void +} + const emptyFilters: Filters = { source: '', script: '', category: '', useCase: '' } export function FontPicker({ @@ -69,6 +82,8 @@ export function FontPicker({ const [query, setQuery] = useState('') const [filters, setFilters] = useState(emptyFilters) const input = useRef(null) + const list = useRef(null) + const fontList = useRef(null) const orderedFamilies = useMemo( () => [...families].sort( @@ -109,6 +124,7 @@ export function FontPicker({ [orderedFamilies, value], ) const activeFilterCount = Object.values(filters).filter(Boolean).length + const handleFocus = (family: string) => onChange(family) return ( setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + event.preventDefault() + fontList.current?.focusRelative(event.key === 'ArrowDown' ? 1 : -1) + }} /> {query && ( @@ -185,9 +206,12 @@ export function FontPicker({ {open && ( { onChange(family) setOpen(false) @@ -339,15 +363,16 @@ function FontResultSummary({ ) } -function FontList({ - families, - value, - onSelect, -}: { - families: FontFamily[] - value: string - onSelect: (family: string) => void -}) { +const FontList = forwardRef< + FontListHandle, + { + families: FontFamily[] + value: string + viewportRef: RefObject + onFocus: (family: string) => void + onSelect: (family: string) => void + } +>(({ families, value, viewportRef, onFocus, onSelect }, ref) => { const { t } = useTranslation() const list = useRef(null) const selectedIndex = families.findIndex( @@ -370,11 +395,46 @@ function FontList({ ), }) + const focusIndex = (index: number) => { + if (index < 0 || index >= families.length) return + virtualizer.scrollToIndex(index) + requestAnimationFrame(() => { + viewportRef.current + ?.querySelector(`[data-font-index="${index}"]`) + ?.focus() + }) + } + + useImperativeHandle(ref, () => ({ + focusRelative: (direction) => { + const start = selectedIndex < 0 ? (direction === 1 ? -1 : families.length) : selectedIndex + focusIndex(start + direction) + }, + })) + + const moveFocus = (event: KeyboardEvent) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + const target = event.target as HTMLElement + const currentIndex = Number(target.dataset.fontIndex) + if (!Number.isInteger(currentIndex)) return + const nextIndex = Math.min( + families.length - 1, + Math.max(0, currentIndex + (event.key === 'ArrowDown' ? 1 : -1)), + ) + if (nextIndex === currentIndex) return + event.preventDefault() + focusIndex(nextIndex) + } + return ( { + list.current = element + viewportRef.current = element + }} role='listbox' aria-label={t('fontPicker.fonts')} + onKeyDown={moveFocus} className='relative' viewportClassName='overflow-x-hidden' style={{ @@ -401,11 +461,13 @@ function FontList({ role='option' aria-label={t('fontPicker.fontLabel', { family: family.name, source })} aria-selected={selected} + data-font-index={virtualRow.index} className={cn( 'absolute inset-x-0 top-0 flex h-9.5 w-full items-center px-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none', selected && 'bg-accent text-accent-foreground', )} style={{ transform: `translateY(${virtualRow.start}px)` }} + onFocus={() => onFocus(family.name)} onClick={() => onSelect(family.name)} >
@@ -428,7 +490,7 @@ function FontList({ )} ) -} +}) function FontPreviewLabel({ family, className }: { family: FontFamily; className?: string }) { const previewQuery = useFontPreview(family) diff --git a/packages/koharu/components/editor/CanvasWorkspace.tsx b/packages/koharu/components/editor/CanvasWorkspace.tsx index 7db377ecf..0844eb1c7 100644 --- a/packages/koharu/components/editor/CanvasWorkspace.tsx +++ b/packages/koharu/components/editor/CanvasWorkspace.tsx @@ -38,8 +38,21 @@ import { useKoharuStore, type CanvasTool, } from '@/lib/store' -import { prefetchCanvasPages, workspaceColor, type CanvasColor } from '@koharu/bridge/canvas' -import { commands, type Frame, type Point, type TransformFrame } from '@koharu/bridge/protocol' +import { + prefetchCanvasPages, + showCanvasPage, + workspaceColor, + type CanvasColor, +} from '@koharu/bridge/canvas' +import { + commands, + type Frame, + type Page, + type PageSummary, + type Point, + type ProjectInfo, + type TransformFrame, +} from '@koharu/bridge/protocol' import { Button } from '@koharu/ui/components/button' const BRUSH_DIAMETER_STEP = 4 @@ -124,6 +137,33 @@ export function CanvasWorkspace() { ? page?.layers.find((layer) => layer.id === selected[0] && layer.type === 'raster') : undefined + const moveToPage = useCallback( + (nextPage: PageSummary) => { + const previousProject = queryClient.getQueryData(projectKey) + const previousPage = queryClient.getQueryData(pageKey) + const activated = showCanvasPage(nextPage.id, previousProject?.revision ?? null) + const synchronize = () => { + void call(commands.selectPage, nextPage.id) + .then((selection) => { + queryClient.setQueryData(projectKey, selection.project) + queryClient.setQueryData(pageKey, selection.page) + }) + .catch(() => { + if (queryClient.getQueryData(projectKey)?.active_page === nextPage.id) { + queryClient.setQueryData(projectKey, previousProject) + queryClient.setQueryData(pageKey, previousPage) + } + }) + } + if (activated) { + requestAnimationFrame(() => window.setTimeout(synchronize, 0)) + } else { + synchronize() + } + }, + [], + ) + const enqueue = useCallback((operation: () => Promise): Promise => { const pending = commandQueue.current.then(operation) commandQueue.current = pending.then( @@ -356,6 +396,19 @@ export function CanvasWorkspace() { requestCanvasFit() return } + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + if (!pages || pages.length === 0 || !page) return + const currentIndex = pages.findIndex((candidate) => candidate.id === page.id) + if (currentIndex === -1) return + const direction = event.key === 'ArrowRight' ? 1 : -1 + const nextIndex = Math.min(pages.length - 1, Math.max(0, currentIndex + direction)) + const nextPage = pages[nextIndex] + if (nextPage && nextPage.id !== page.id) { + event.preventDefault() + moveToPage(nextPage) + } + return + } if (event.key === 'Escape') { cancelGesture() colorSampling?.cancel() @@ -385,7 +438,7 @@ export function CanvasWorkspace() { window.removeEventListener('keyup', up) window.removeEventListener('blur', blur) } - }, [cancelGesture, colorSampling, page, requestCanvasFit, selectLayers, setTool]) + }, [cancelGesture, colorSampling, moveToPage, page, pages, requestCanvasFit, selectLayers, setTool]) const clientPagePoint = (clientX: number, clientY: number) => pagePoint( diff --git a/packages/koharu/tests/components/workspace.test.tsx b/packages/koharu/tests/components/workspace.test.tsx index 3c2ed81c9..e31653b31 100644 --- a/packages/koharu/tests/components/workspace.test.tsx +++ b/packages/koharu/tests/components/workspace.test.tsx @@ -38,12 +38,14 @@ const canvasState = vi.hoisted(() => ({ status: 'ready' as 'loading' | 'switching' | 'ready' | 'recovering' | 'error', })) const prefetchCanvasPages = vi.hoisted(() => vi.fn(async () => [])) +const showCanvasPage = vi.hoisted(() => vi.fn(() => true)) vi.mock('@/components/editor/useCanvas', () => ({ useCanvas: () => canvasState, })) vi.mock('@koharu/bridge/canvas', () => ({ prefetchCanvasPages, + showCanvasPage, workspaceColor: () => [245, 245, 245], })) @@ -90,6 +92,7 @@ beforeEach(() => { canvasState.hasFrame = true canvasState.status = 'ready' prefetchCanvasPages.mockClear() + showCanvasPage.mockClear() }) afterEach(() => vi.unstubAllGlobals()) @@ -194,6 +197,48 @@ describe('canvas interaction adapter', () => { expect(useKoharuStore.getState().camera).toEqual(camera) }) + it('moves to the next page with the right arrow key', async () => { + installProject() + showCanvasPage.mockReturnValue(false) + queryClient.setQueryData(pagesKey, [ + { + id: 'page', + label: 'Page', + size: { width: 1000, height: 1000 }, + source_asset: null, + layer_count: 1, + }, + { + id: 'next', + label: 'Next', + size: { width: 1000, height: 1000 }, + source_asset: null, + layer_count: 1, + }, + ]) + const selectPage = vi.spyOn(commands, 'selectPage').mockResolvedValue({ + project: { + name: 'Book', + revision: 1, + active_page: 'next', + can_undo: false, + can_redo: false, + }, + page: { + id: 'next', + label: 'Next', + size: { width: 1000, height: 1000 }, + layers: [layer], + regions: [], + }, + }) + + renderWorkspace() + fireEvent.keyDown(window, { key: 'ArrowRight' }) + + await waitFor(() => expect(selectPage).toHaveBeenCalledWith('next')) + }) + it('suppresses Alt browser handling while an editor input retains focus', () => { installProject() renderWorkspace()