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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ A Manifest V3 Chrome extension (TypeScript + React 18 + Vite + Tailwind). Three
- `src/lib/annotate.ts` — `exportAnnotatedImage` rasterizes annotations onto the screenshot; `selectFeedbackRenderMode` picks footer vs. overlay so the export canvas never exceeds `MAX_EXPORT_CANVAS_HEIGHT`/`AREA` limits.
- `src/lib/feedback.ts` — `buildExternalLlmPrompt` (the structured prompt copied for the cloud-LLM fallback) and `annotationSummary`.
- `src/lib/boxResize.ts` — box drag/resize geometry.
- `src/lib/selectNavigation.ts` — `getNextIndex`/`matchTypeahead` keyboard-navigation rules for the custom `Select` listbox (so the interaction logic is testable apart from the DOM).
- `src/types/annotation.ts` — the `Annotation` discriminated union (`box` | `arrow` | `text`).

**Design system (`src/components/ui/*` + `src/styles/globals.css` + `tailwind.config.js`):** components are driven by semantic HSL **CSS-variable tokens** (`--primary`, `--secondary`, `--muted`, `--accent`, `--destructive` + `-hover`, `--border`, `--input`, `--ring`) mapped to Tailwind color utilities — use `bg-primary`/`border-input`/`ring-ring` etc., never hardcoded `emerald-*`/`slate-*` literals, in the primitives. A `.dark` token block exists (opt-in via `class="dark"`; light is the default) and is kept **outside `@layer base`** so Tailwind does not tree-shake the unreferenced selector. `Select` is a **custom WAI-ARIA listbox** (not a native `<select>`): pass `value` + `onValueChange` + `options`, not `<option>` children — the native option popup is unstylable, which is why it was replaced.

**Two outputs from the editor:** (1) *Copy Local Share Link* → `saveLocalShare` + viewer URL; (2) *Prepare for Cloud LLM* → downloads the annotated PNG and copies `buildExternalLlmPrompt` output to the clipboard. The extension makes **no network requests of its own**; data leaves the device only via that explicit manual export.

## Conventions
Expand Down
256 changes: 235 additions & 21 deletions src/components/ui/select.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,248 @@
import * as React from "react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { getNextIndex, matchTypeahead, type ListboxKey } from "@/lib/selectNavigation";

export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
export interface SelectOption {
value: string;
label: string;
}

export interface SelectProps {
value: string;
onValueChange: (value: string) => void;
options: SelectOption[];
className?: string;
disabled?: boolean;
id?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
}

const TYPEAHEAD_RESET_MS = 600;

function Select({
value,
onValueChange,
options,
className,
disabled,
id,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby
}: SelectProps): JSX.Element {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);

const rootRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const typeahead = useRef<{ query: string; timer: number | null }>({ query: "", timer: null });

const baseId = useId();
const listboxId = `${baseId}-listbox`;
const optionId = (index: number): string => `${baseId}-option-${index}`;

const selectedIndex = options.findIndex((option) => option.value === value);
const selectedLabel = selectedIndex >= 0 ? options[selectedIndex].label : "";

const openList = useCallback((): void => {
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
setOpen(true);
}, [selectedIndex]);

const closeList = useCallback((focusButton = true): void => {
setOpen(false);
setActiveIndex(-1);
if (focusButton) buttonRef.current?.focus();
}, []);

const commit = useCallback(
(index: number): void => {
const option = options[index];
if (option) onValueChange(option.value);
closeList();
},
[options, onValueChange, closeList]
);

// Close when a pointer goes down outside the component.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent): void => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
setActiveIndex(-1);
}
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);

// Keep the active option scrolled into view (by index — option ids contain
// `:` from useId and are not valid CSS selectors).
useEffect(() => {
if (!open || activeIndex < 0) return;
const el = listRef.current?.children.item(activeIndex) as HTMLElement | null;
el?.scrollIntoView({ block: "nearest" });
}, [open, activeIndex]);

// Clear any pending typeahead reset timer on unmount.
useEffect(() => {
const state = typeahead.current;
return () => {
if (state.timer) window.clearTimeout(state.timer);
};
}, []);

const runTypeahead = useCallback(
(char: string): void => {
const state = typeahead.current;
state.query += char;
if (state.timer) window.clearTimeout(state.timer);
state.timer = window.setTimeout(() => {
state.query = "";
state.timer = null;
}, TYPEAHEAD_RESET_MS);

const match = matchTypeahead(
options.map((option) => option.label),
state.query,
activeIndex >= 0 ? activeIndex : 0
);
if (match >= 0) setActiveIndex(match);
},
[options, activeIndex]
);

const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (disabled) return;
const { key } = event;

if (!open) {
if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === " ") {
event.preventDefault();
openList();
}
return;
}

switch (key) {
case "ArrowDown":
case "ArrowUp":
case "Home":
case "End":
event.preventDefault();
setActiveIndex((current) => getNextIndex(current, options.length, key as ListboxKey));
return;
case "Enter":
case " ":
event.preventDefault();
if (activeIndex >= 0) commit(activeIndex);
return;
case "Escape":
event.preventDefault();
closeList();
return;
case "Tab":
// Let focus leave naturally; just dismiss the list.
setOpen(false);
setActiveIndex(-1);
return;
default:
if (key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
runTypeahead(key);
}
}
};

const Select = React.forwardRef<HTMLSelectElement, SelectProps>(({ className, ...props }, ref) => {
return (
<div className="relative">
<select
<div ref={rootRef} className="relative">
<button
ref={buttonRef}
type="button"
id={id}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
disabled={disabled}
onClick={() => (open ? closeList() : openList())}
onKeyDown={onKeyDown}
className={cn(
"flex h-10 w-full appearance-none rounded-md border border-input bg-card px-3 py-2 pr-9 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:ring-destructive",
"flex h-10 w-full items-center justify-between gap-2 rounded-md border border-input bg-card px-3 py-2 text-left text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
>
<path d="m6 9 6 6 6-6" />
</svg>
<span className="truncate">{selectedLabel}</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-swift",
open && "rotate-180"
)}
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>

{open ? (
<ul
ref={listRef}
id={listboxId}
role="listbox"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
className="absolute left-0 right-0 top-full z-20 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card p-1 shadow-lg"
>
{options.map((option, index) => {
const selected = option.value === value;
const active = index === activeIndex;
return (
<li
key={option.value}
id={optionId(index)}
role="option"
aria-selected={selected}
onPointerEnter={() => setActiveIndex(index)}
onClick={() => commit(index)}
className={cn(
"flex cursor-pointer items-center justify-between gap-2 rounded-sm px-2.5 py-1.5 text-sm",
active ? "bg-primary text-primary-foreground" : "text-foreground"
)}
>
<span className="truncate">{option.label}</span>
{selected ? (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4 shrink-0"
>
<path d="M20 6 9 17l-5-5" />
</svg>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
});
Select.displayName = "Select";
}

export { Select };
44 changes: 27 additions & 17 deletions src/editor/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -587,32 +587,42 @@ function EditorApp(): JSX.Element {
</Button>
</CardHeader>
<CardContent className="space-y-3">
<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="space-y-1.5">
<span
id="interaction-label"
className="block text-xs font-semibold uppercase tracking-wide text-slate-500"
>
Interaction
</span>
<Select
aria-labelledby="interaction-label"
value={interactionMode}
onChange={(event) => setInteractionMode(event.target.value as "draw" | "move")}
>
<option value="draw">Draw New</option>
<option value="move">Move Existing</option>
</Select>
</label>
onValueChange={(value) => setInteractionMode(value as "draw" | "move")}
options={[
{ value: "draw", label: "Draw New" },
{ value: "move", label: "Move Existing" }
]}
/>
</div>

<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">
<div className="space-y-1.5">
<span
id="tool-label"
className="block text-xs font-semibold uppercase tracking-wide text-slate-500"
>
Tool
</span>
<Select
aria-labelledby="tool-label"
value={tool}
onChange={(event) => setTool(event.target.value as AnnotationTool)}
>
<option value="box">Box</option>
<option value="arrow">Arrow</option>
<option value="text">Text</option>
</Select>
</label>
onValueChange={(value) => setTool(value as AnnotationTool)}
options={[
{ value: "box", label: "Box" },
{ value: "arrow", label: "Arrow" },
{ value: "text", label: "Text" }
]}
/>
</div>

<label className="block space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">
Expand Down
43 changes: 43 additions & 0 deletions src/lib/selectNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Pure keyboard-navigation helpers for the custom listbox Select.
* Kept free of React/DOM so the interaction rules can be unit tested.
*/

export type ListboxKey = "ArrowDown" | "ArrowUp" | "Home" | "End";

/**
* Compute the next active option index for a listbox key press. `current` may be
* -1 (nothing active yet). Movement is clamped to the list (no wrap-around), which
* matches native <select> behaviour.
*/
export function getNextIndex(current: number, count: number, key: ListboxKey): number {
if (count <= 0) return -1;

switch (key) {
case "ArrowDown":
return current < 0 ? 0 : Math.min(current + 1, count - 1);
case "ArrowUp":
return current < 0 ? count - 1 : Math.max(current - 1, 0);
case "Home":
return 0;
case "End":
return count - 1;
}
}

/**
* Typeahead match: find the next option whose label starts with `query`
* (case-insensitive), searching circularly from index `from` (inclusive).
* Returns -1 when nothing matches or the query is empty.
*/
export function matchTypeahead(labels: string[], query: string, from: number): number {
const q = query.trim().toLowerCase();
if (!q || labels.length === 0) return -1;

const start = from < 0 ? 0 : from % labels.length;
for (let i = 0; i < labels.length; i += 1) {
const index = (start + i) % labels.length;
if (labels[index].toLowerCase().startsWith(q)) return index;
}
return -1;
}
Loading
Loading