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
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"jsdom": "^26.1.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.4"
"vitest": "^4.1.9"
},
"dependencies": {
"@csshub/shared": "*",
Expand Down
6 changes: 3 additions & 3 deletions apps/extension/perf-budgets.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"entries": {
"background.js": 12,
"contentScript.js": 4,
"background.js": 13,
"contentScript.js": 22,
"popup.js": 12,
"settings.js": 15
},
Expand All @@ -12,5 +12,5 @@
"schemas": 2,
"messaging": 2
},
"totalJsGzipMaxKb": 130
"totalJsGzipMaxKb": 132
}
9 changes: 9 additions & 0 deletions apps/extension/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@
"<all_urls>"
],
"content_scripts": [
{
"matches": [
"https://cssbattle.dev/play/*",
"https://www.cssbattle.dev/play/*"
],
"js": ["shortcutBridge.js"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": [
"https://cssbattle.dev/play/*",
Expand Down
32 changes: 32 additions & 0 deletions apps/extension/src/contentScript.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { detectChallengeContext } from "./contentScriptChallengeContext";
import {
CLICKABLE_SELECTOR,
addCssBattleSubmitShortcutListener,
asImageDataUrlOrNull,
extractCodeFromCmLines,
fetchTargetImagePayload,
Expand Down Expand Up @@ -31,6 +32,9 @@ import {
} from "./contentScriptStats";

const EXTENSION_CONTEXT_INVALIDATED = "Extension context invalidated";
const CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE = "CSSHUB_SUBMIT_SHORTCUT";
const CSSHUB_MESSAGE_SOURCE = "csshub-shortcut-bridge";
const KEYBOARD_SUBMISSION_DEBOUNCE_MS = 750;

const isExtensionContextInvalidated = (error: unknown): boolean =>
error instanceof Error && error.message.includes(EXTENSION_CONTEXT_INVALIDATED);
Expand Down Expand Up @@ -134,6 +138,7 @@ const capturePreviewImage = async (): Promise<string | null> => {
};

let isProcessingSubmission = false;
let lastKeyboardSubmissionAt = 0;

const notifySubmissionProcessingStarted = (): void => {
void sendBackgroundAction("submissionProcessingStarted");
Expand Down Expand Up @@ -229,6 +234,15 @@ const processSubmission = async (): Promise<void> => {
}
};

const processKeyboardSubmission = (): void => {
const now = Date.now();
if (now - lastKeyboardSubmissionAt < KEYBOARD_SUBMISSION_DEBOUNCE_MS) {
return;
}
lastKeyboardSubmissionAt = now;
void processSubmission();
};

const installSubmitListeners = (): void => {
// Main ingestion path: capture and submit are automatic on CSSBattle submit clicks.
document.addEventListener(
Expand All @@ -253,6 +267,24 @@ const installSubmitListeners = (): void => {
},
true
);
addCssBattleSubmitShortcutListener(window, processKeyboardSubmission);
window.addEventListener(
"message",
(event) => {
if (event.source !== window || event.origin !== window.location.origin) {
return;
}
const data = event.data as { source?: unknown; type?: unknown } | null;
if (
data?.source !== CSSHUB_MESSAGE_SOURCE ||
data.type !== CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE
) {
return;
}
processKeyboardSubmission();
},
true
);
};

chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
Expand Down
28 changes: 28 additions & 0 deletions apps/extension/src/contentScriptDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const isNumericChallengeId = (challengeId: string): boolean =>
export const SUBMIT_LABEL = /submit/i;
export const CLICKABLE_SELECTOR =
"button, [role='button'], input[type='submit'], a";
export const ENTER_SHORTCUT_VALUES = new Set(["Enter", "NumpadEnter"]);
export const CM_LINE_SELECTOR = ".cm-line";
export const MONACO_LINE_SELECTOR = ".monaco-editor .view-line";
export const CHALLENGE_ID_PATH_REGEX = /^\/play\/([^/]+)/;
Expand Down Expand Up @@ -84,6 +85,33 @@ export const getChallengeNameFromTitle = (

export const isSubmitControlText = (text: string): boolean => SUBMIT_LABEL.test(text);

export const isCssBattleSubmitShortcut = (
event: Pick<
KeyboardEvent,
"altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "repeat" | "shiftKey"
>
): boolean =>
[
[event.key, event.code].some((value) => ENTER_SHORTCUT_VALUES.has(value)),
[event.metaKey, event.ctrlKey].some(Boolean),
![event.altKey, event.shiftKey, event.repeat].some(Boolean),
].every(Boolean);

export const addCssBattleSubmitShortcutListener = (
target: EventTarget,
onSubmit: () => void
): void => {
const handleShortcut = (event: Event): void => {
if (!isCssBattleSubmitShortcut(event as KeyboardEvent)) {
return;
}
onSubmit();
};

target.addEventListener("keydown", handleShortcut, true);
target.addEventListener("keyup", handleShortcut, true);
};

export const dimensionsFromRect = (
rect: DOMRect,
devicePixelRatio = 1
Expand Down
28 changes: 28 additions & 0 deletions apps/extension/src/contentScriptShortcutBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE = "CSSHUB_SUBMIT_SHORTCUT";
const CSSHUB_MESSAGE_SOURCE = "csshub-shortcut-bridge";
const ENTER_SHORTCUT_VALUES = new Set(["Enter", "NumpadEnter"]);

const isCssBattleSubmitShortcut = (event: KeyboardEvent): boolean =>
[
[event.key, event.code].some((value) => ENTER_SHORTCUT_VALUES.has(value)),
[event.metaKey, event.ctrlKey].some(Boolean),
![event.altKey, event.shiftKey, event.repeat].some(Boolean),
].every(Boolean);

const postSubmitShortcut = (event: KeyboardEvent): void => {
if (!isCssBattleSubmitShortcut(event)) {
return;
}

window.postMessage(
{
source: CSSHUB_MESSAGE_SOURCE,
type: CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE,
keyEventType: event.type,
},
window.location.origin
);
};

window.addEventListener("keydown", postSubmitShortcut, true);
window.addEventListener("keyup", postSubmitShortcut, true);
3 changes: 2 additions & 1 deletion apps/extension/tests/e2e/submission-perf.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ test("cssbattleSubmission commits within SLO with mocked GitHub", async () => {
matchPct: 99.5,
code: "<div></div>",
targetImage: null,
resultImageDataUrl: null,
resultImageDataUrl:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
},
});
});
Expand Down
87 changes: 87 additions & 0 deletions apps/extension/tests/unit/contentScript.dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, it } from "vitest";
import {
addCssBattleSubmitShortcutListener,
buildCanonicalDailyTargetUrl,
CLICKABLE_SELECTOR,
asImageDataUrlOrNull,
Expand All @@ -17,6 +18,7 @@ import {
getElementDimensionsFromElement,
getTargetImageUrl,
isCssBattleHostedTargetUrl,
isCssBattleSubmitShortcut,
isFooterDecorativeImage,
isSubmitControlText,
isTargetImageElement,
Expand Down Expand Up @@ -70,6 +72,91 @@ describe("isSubmitControlText", () => {
});
});

describe("isCssBattleSubmitShortcut", () => {
it("matches CSSBattle keyboard submit shortcuts", () => {
expect(
isCssBattleSubmitShortcut(
new KeyboardEvent("keydown", { key: "Enter", metaKey: true })
)
).toBe(true);
expect(
isCssBattleSubmitShortcut(
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true })
)
).toBe(true);
expect(
isCssBattleSubmitShortcut(
new KeyboardEvent("keydown", { code: "NumpadEnter", ctrlKey: true })
)
).toBe(true);
expect(isCssBattleSubmitShortcut(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(
false
);
expect(
isCssBattleSubmitShortcut(
new KeyboardEvent("keydown", { key: "Enter", shiftKey: true })
)
).toBe(false);
expect(
isCssBattleSubmitShortcut(
new KeyboardEvent("keydown", { key: "s", metaKey: true })
)
).toBe(false);
});

it("captures shortcuts before document-level propagation can be stopped", () => {
let shortcutSubmissions = 0;
let documentListenersReached = 0;

addCssBattleSubmitShortcutListener(window, () => {
shortcutSubmissions += 1;
});
window.addEventListener(
"keydown",
(event) => {
event.stopImmediatePropagation();
},
true
);
document.addEventListener(
"keydown",
() => {
documentListenersReached += 1;
},
true
);

document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "Enter",
ctrlKey: true,
bubbles: true,
})
);

expect(shortcutSubmissions).toBe(1);
expect(documentListenersReached).toBe(0);
});

it("also captures shortcut keyup as a fallback", () => {
let shortcutSubmissions = 0;

addCssBattleSubmitShortcutListener(window, () => {
shortcutSubmissions += 1;
});

document.dispatchEvent(
new KeyboardEvent("keyup", {
key: "Enter",
ctrlKey: true,
bubbles: true,
})
);

expect(shortcutSubmissions).toBe(1);
});
});

describe("asImageDataUrlOrNull", () => {
it("keeps image data URLs and drops empty canvas placeholders", () => {
expect(asImageDataUrlOrNull("data:image/png;base64,abc")).toBe(
Expand Down
27 changes: 17 additions & 10 deletions apps/extension/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ const previewFrameCaptureInjectPlugin = (): Plugin => ({
outfile: resolve(__dirname, "dist/previewFrameCaptureInject.js"),
bundle: true,
format: "iife",
minify: true,
platform: "browser",
target: "chrome109",
logLevel: "silent",
Expand All @@ -123,16 +124,22 @@ const contentScriptBundlePlugin = (): Plugin => ({
name: "content-script-iife",
apply: "build",
async closeBundle() {
await esbuild({
entryPoints: [resolve(__dirname, "src/contentScript.ts")],
outfile: resolve(__dirname, "dist/contentScript.js"),
bundle: true,
format: "iife",
platform: "browser",
target: "chrome109",
alias: extensionSrcAlias,
logLevel: "silent",
});
for (const [entry, outfile] of [
["src/contentScript.ts", "dist/contentScript.js"],
["src/contentScriptShortcutBridge.ts", "dist/shortcutBridge.js"],
] as const) {
await esbuild({
entryPoints: [resolve(__dirname, entry)],
outfile: resolve(__dirname, outfile),
bundle: true,
format: "iife",
minify: true,
platform: "browser",
target: "chrome109",
alias: extensionSrcAlias,
logLevel: "silent",
});
}
},
});

Expand Down
Loading
Loading