diff --git a/frontend/__tests__/test/funbox/funbox-validation.spec.ts b/frontend/__tests__/test/funbox/funbox-validation.spec.ts index 00c1e0825028..e3aa0967d0c5 100644 --- a/frontend/__tests__/test/funbox/funbox-validation.spec.ts +++ b/frontend/__tests__/test/funbox/funbox-validation.spec.ts @@ -24,6 +24,7 @@ describe("funbox-validation", () => { "nospace", //nospace "plus_one", //toPush: "read_ahead_easy", //changesWordVisibility + "tunnel_vision", //changesWordVisibility "tts", //speaks "layout_mirror", //changesLayout "zipf", //changesWordsFrequency diff --git a/frontend/src/ts/test/funbox/funbox-functions.ts b/frontend/src/ts/test/funbox/funbox-functions.ts index 1c65da0b870a..1f1116854018 100644 --- a/frontend/src/ts/test/funbox/funbox-functions.ts +++ b/frontend/src/ts/test/funbox/funbox-functions.ts @@ -28,6 +28,7 @@ import { WordGenError } from "../../utils/word-gen-error"; import { FunboxName, KeymapLayout, Layout } from "@monkeytype/schemas/configs"; import { Language, LanguageObject } from "@monkeytype/schemas/languages"; import { qs } from "../../utils/dom"; +import { convertRemToPixels } from "../../utils/numbers"; export type FunboxFunctions = { getWord?: (wordset?: Wordset, wordIndex?: number) => string; @@ -166,6 +167,14 @@ export class PolyglotWordset extends Wordset { } } +let tunnelVisionAnimationFrame: number | null = null; + +function getTunnelVisionRadiusPx(): number { + const fontSizePx = convertRemToPixels(Config.fontSize); + + return Math.max(48, Math.min(220, fontSizePx * 4.5)); +} + const list: Partial> = { "58008": { getWord(): string { @@ -679,6 +688,68 @@ const list: Partial> = { return word.toUpperCase(); }, }, + tunnel_vision: { + applyGlobalCSS(): void { + const words = qs("#words"); + const wordsWrapper = qs("#wordsWrapper"); + if (!words || !wordsWrapper) return; + + const updateCaretPos = (): void => { + const caretElem = qs("#caret"); + if (caretElem !== null) { + const wordsRect = words.native.getBoundingClientRect(); + const wordsWrapperRect = wordsWrapper.native.getBoundingClientRect(); + const caretRect = caretElem.native.getBoundingClientRect(); + const caretLeft = + caretRect.left - wordsRect.left + caretRect.width / 2; + const caretTop = caretRect.top - wordsRect.top + caretRect.height / 2; + const wrapperCaretLeft = + caretRect.left - wordsWrapperRect.left + caretRect.width / 2; + const wrapperCaretTop = + caretRect.top - wordsWrapperRect.top + caretRect.height / 2; + const radius = `${getTunnelVisionRadiusPx()}px`; + + words.native.style.setProperty("--caret-left", `${caretLeft}px`); + words.native.style.setProperty("--caret-top", `${caretTop}px`); + words.native.style.setProperty("--tunnel-radius", radius); + + wordsWrapper.native.style.setProperty( + "--caret-left", + `${wrapperCaretLeft}px`, + ); + wordsWrapper.native.style.setProperty( + "--caret-top", + `${wrapperCaretTop}px`, + ); + wordsWrapper.native.style.setProperty("--tunnel-radius", radius); + } + tunnelVisionAnimationFrame = requestAnimationFrame(updateCaretPos); + }; + + if (tunnelVisionAnimationFrame !== null) { + cancelAnimationFrame(tunnelVisionAnimationFrame); + } + updateCaretPos(); + }, + clearGlobal(): void { + if (tunnelVisionAnimationFrame !== null) { + cancelAnimationFrame(tunnelVisionAnimationFrame); + tunnelVisionAnimationFrame = null; + } + const words = qs("#words"); + const wordsWrapper = qs("#wordsWrapper"); + if (words) { + words.native.style.removeProperty("--caret-left"); + words.native.style.removeProperty("--caret-top"); + words.native.style.removeProperty("--tunnel-radius"); + } + if (wordsWrapper) { + wordsWrapper.native.style.removeProperty("--caret-left"); + wordsWrapper.native.style.removeProperty("--caret-top"); + wordsWrapper.native.style.removeProperty("--tunnel-radius"); + } + }, + }, polyglot: { async withWords(_words) { const promises = Config.customPolyglot.map(async (language) => diff --git a/frontend/static/funbox/tunnel_vision.css b/frontend/static/funbox/tunnel_vision.css new file mode 100644 index 000000000000..07d6d33ca1d3 --- /dev/null +++ b/frontend/static/funbox/tunnel_vision.css @@ -0,0 +1,44 @@ +#words { + mask-image: radial-gradient( + circle var(--tunnel-radius, 150px) at var(--caret-left) var(--caret-top), + black 0%, + black 50%, + transparent 100% + ); + -webkit-mask-image: radial-gradient( + circle var(--tunnel-radius, 150px) at var(--caret-left) var(--caret-top), + black 0%, + black 50%, + transparent 100% + ); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; +} + +body.fb-tunnel-vision #wordsWrapper.tape { + isolation: isolate; +} + +body.fb-tunnel-vision #wordsWrapper.tape #words { + mask-image: none; + -webkit-mask-image: none; +} + +body.fb-tunnel-vision #wordsWrapper.tape::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: radial-gradient( + circle var(--tunnel-radius, 150px) at var(--caret-left) var(--caret-top), + transparent 0%, + transparent 50%, + var(--bg-color) 100% + ); +} + +body.fb-tunnel-vision #wordsWrapper.tape #caret, +body.fb-tunnel-vision #wordsWrapper.tape #paceCaret { + z-index: 2; +} diff --git a/packages/funbox/__test__/validation.spec.ts b/packages/funbox/__test__/validation.spec.ts index de27659acbc0..7c919ff8600f 100644 --- a/packages/funbox/__test__/validation.spec.ts +++ b/packages/funbox/__test__/validation.spec.ts @@ -115,6 +115,26 @@ describe("validation", () => { true, ); }); + + it("should reject multiple word visibility funboxes", () => { + //GIVEN + getFunboxMock.mockReturnValueOnce([ + { + name: "plus_one", + properties: ["changesWordsVisibility"], + } as FunboxMetadata, + { + name: "tunnel_vision", + properties: ["changesWordsVisibility"], + } as FunboxMetadata, + ]); + + //WHEN / THEN + expect(Validation.checkCompatibility(["plus_one", "tunnel_vision"])).toBe( + false, + ); + }); + describe("should validate two funboxes modifying the wordset", () => { const testCases = [ { diff --git a/packages/funbox/src/list.ts b/packages/funbox/src/list.ts index 19cd30abca89..06f818c9339f 100644 --- a/packages/funbox/src/list.ts +++ b/packages/funbox/src/list.ts @@ -481,6 +481,14 @@ const list: Record = { difficultyLevel: 0, name: "no_quit", }, + tunnel_vision: { + name: "tunnel_vision", + description: "Only the area around the caret is visible.", + canGetPb: true, + difficultyLevel: 2, + properties: ["hasCssFile", "changesWordsVisibility"], + frontendFunctions: ["applyGlobalCSS", "clearGlobal"], + }, }; export function getObject(): Record { diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index 77c257a54834..037400ed9162 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -321,6 +321,7 @@ export const FunboxNameSchema = z.enum([ "asl", "rot13", "no_quit", + "tunnel_vision", ]); export type FunboxName = z.infer;