diff --git a/e2e/extension.spec.ts b/e2e/extension.spec.ts index 0888763..3ee058b 100644 --- a/e2e/extension.spec.ts +++ b/e2e/extension.spec.ts @@ -286,6 +286,68 @@ test.describe("Slash Commands", () => { await context.close(); }); + + test("hint tooltip appears on focus and hides on blur", async () => { + const context = await chromium.launch({ headless: false }); + const page = await context.newPage(); + + await page.goto(`http://localhost:${testServer.port}/`); + await page.waitForLoadState("domcontentloaded"); + await injectContentScript(page); + await page.waitForTimeout(500); + + const textarea = page.locator("#test-textarea"); + await expect(textarea).toBeVisible(); + + // Focus the textarea - tooltip should appear + await textarea.click(); + await page.waitForTimeout(200); + + const tooltip = page.locator("#slashPaletteHint"); + await expect(tooltip).toBeVisible({ timeout: 2000 }); + + // Tooltip should contain the "//" hint text + const tooltipText = await tooltip.textContent(); + expect(tooltipText).toContain("//"); + + // Blur the textarea - tooltip should hide + await page.keyboard.press("Tab"); + await page.waitForTimeout(200); + await expect(tooltip).not.toBeVisible(); + + await context.close(); + }); + + test("hint tooltip hides when picker opens", async () => { + const context = await chromium.launch({ headless: false }); + const page = await context.newPage(); + + await page.goto(`http://localhost:${testServer.port}/`); + await page.waitForLoadState("domcontentloaded"); + await injectContentScript(page); + await page.waitForTimeout(500); + + const textarea = page.locator("#test-textarea"); + await textarea.click(); + await page.waitForTimeout(200); + + // Tooltip should be visible after focus + const tooltip = page.locator("#slashPaletteHint"); + await expect(tooltip).toBeVisible({ timeout: 2000 }); + + // Type // to open picker + await textarea.fill("//"); + await page.waitForTimeout(500); + + // Picker should be visible + const picker = page.locator("#slashPalettePicker"); + await expect(picker).toBeVisible({ timeout: 3000 }); + + // Tooltip should be hidden + await expect(tooltip).not.toBeVisible(); + + await context.close(); + }); }); test.describe("Options Page - Giphy Image Settings", () => { diff --git a/src/content/hint-tooltip.ts b/src/content/hint-tooltip.ts new file mode 100644 index 0000000..3ec2b42 --- /dev/null +++ b/src/content/hint-tooltip.ts @@ -0,0 +1,73 @@ +/** + * Hint tooltip shown near qualifying markdown textareas. + * Displays a small indicator hinting to type "//" to open the command list. + */ + +import { isDarkMode, fontSystemUi, fontSansSerif } from "../utils/theme.ts" +import { COMMAND_PREFIX } from "../utils/command-prefix.ts" + +let tooltipEl: HTMLElement | null = null + +function applyTooltipStyles(el: HTMLElement): void { + const dark = isDarkMode() + el.style.position = "fixed" + el.style.zIndex = "999998" + el.style.fontSize = "12px" + el.style.padding = "3px 7px" + el.style.borderRadius = "4px" + el.style.pointerEvents = "none" + el.style.whiteSpace = "nowrap" + el.style.fontFamily = fontSystemUi() + ", " + fontSansSerif() + el.style.color = dark ? "#8d96a0" : "#656d76" + el.style.border = `1px solid ${dark ? "#3d444d" : "#d0d7de"}` + el.style.backgroundColor = dark ? "#161b22" : "#ffffff" + el.style.boxShadow = dark ? "0 2px 8px rgba(1,4,9,0.4)" : "0 2px 8px rgba(140,149,159,0.15)" +} + +function ensureTooltip(): HTMLElement { + if (!tooltipEl || !document.body.contains(tooltipEl)) { + const el = document.createElement("div") + el.id = "slashPaletteHint" + el.textContent = `Type ${COMMAND_PREFIX} for commands` + el.style.display = "none" + applyTooltipStyles(el) + document.body.appendChild(el) + tooltipEl = el + } + return tooltipEl +} + +function positionTooltip(field: HTMLTextAreaElement): void { + const el = tooltipEl + if (!el) return + const rect = field.getBoundingClientRect() + const elWidth = el.offsetWidth || 160 + const left = Math.max(0, rect.right - elWidth) + const top = rect.bottom + 4 + el.style.left = `${left}px` + el.style.top = `${top}px` +} + +export function showHintTooltip(field: HTMLTextAreaElement): void { + const el = ensureTooltip() + applyTooltipStyles(el) + el.style.display = "block" + positionTooltip(field) +} + +export function hideHintTooltip(): void { + if (tooltipEl) { + tooltipEl.style.display = "none" + } +} + +export function repositionHintTooltip(field: HTMLTextAreaElement): void { + if (!tooltipEl || tooltipEl.style.display === "none") return + positionTooltip(field) +} + +export function refreshHintTooltipTheme(): void { + if (tooltipEl && tooltipEl.style.display !== "none") { + applyTooltipStyles(tooltipEl) + } +} diff --git a/src/content/index.ts b/src/content/index.ts index 17b74db..06046aa 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -23,6 +23,12 @@ import { moveSelectionGrid, applyPickerStyles, } from "./picker/index.ts" +import { + showHintTooltip, + hideHintTooltip, + repositionHintTooltip, + refreshHintTooltipTheme, +} from "./hint-tooltip.ts" // Import commands to register them import "./commands/giphy/index.ts" @@ -119,6 +125,7 @@ async function handleCommandInput( state.activeCommand = cmdName setHeader("Slash Palette", COMMAND_PREFIX + cmdName + (query ? " " + query : "")) + hideHintTooltip() showPicker(field) positionPickerAtCaret(field) @@ -303,6 +310,9 @@ function attachToField(field: HTMLTextAreaElement): void { ;(field as unknown as { __slashPaletteBound: boolean }).__slashPaletteBound = true field.addEventListener("input", () => handleFieldInput(field)) + field.addEventListener("focus", () => { + if (!isPickerVisible()) showHintTooltip(field) + }) field.addEventListener("keyup", (ev) => { // Skip action keys that are handled elsewhere if (ev.key === "Escape") return @@ -336,10 +346,12 @@ function attachToField(field: HTMLTextAreaElement): void { }) field.addEventListener("scroll", () => { if (isPickerVisible()) positionPickerAtCaret(field) + repositionHintTooltip(field) }) field.addEventListener("keydown", (ev) => onFieldKeyDown(ev, field)) field.addEventListener("blur", () => { + hideHintTooltip() setTimeout(() => { // Never hide while settings panel is open - user is interacting with controls if (state.showingSettings) return @@ -420,6 +432,7 @@ function boot(): void { if (isPickerVisible() && state.activeField) { positionPickerAtCaret(state.activeField) } + if (state.activeField) repositionHintTooltip(state.activeField) }, { passive: true } ) @@ -428,13 +441,16 @@ function boot(): void { if (isPickerVisible() && state.activeField) { positionPickerAtCaret(state.activeField) } + if (state.activeField) repositionHintTooltip(state.activeField) }) // Handle theme changes onThemeChange(() => { - if (!isPickerVisible()) return - if (state.pickerEl) applyPickerStyles(state.pickerEl) - refreshSelectionStyles() + if (isPickerVisible()) { + if (state.pickerEl) applyPickerStyles(state.pickerEl) + refreshSelectionStyles() + } + refreshHintTooltipTheme() }) }