Skip to content
Open
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
28 changes: 28 additions & 0 deletions src/annotator/anchoring/test/pdf-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,34 @@ describe('annotator/anchoring/pdf', () => {
container.remove();
});

describe('FakePDFViewerApplication', () => {
it('uses default config when config is not provided', () => {
cleanupViewer();
viewer = new FakePDFViewerApplication({ container, content: ['page'] });
assert.ok(viewer.pdfViewer);
viewer.dispose();
});

it('notify with eventDispatch "dom" dispatches CustomEvent on container', () => {
const eventName = 'test-pdf-event';
const listener = sinon.stub();
container.addEventListener(eventName, listener);
viewer.pdfViewer.notify(eventName, { eventDispatch: 'dom' });
assert.calledOnce(listener);
assert.equal(listener.firstCall.args[0].type, eventName);
container.removeEventListener(eventName, listener);
});

it('notify with no second argument emits via eventBus', () => {
const eventName = 'test-pdf-event';
const listener = sinon.stub();
viewer.pdfViewer.eventBus.on(eventName, listener);
viewer.pdfViewer.notify(eventName);
assert.calledOnce(listener);
viewer.pdfViewer.eventBus.off(eventName, listener);
});
});

describe('describe', () => {
it('returns position and quote selectors', async () => {
viewer.pdfViewer.setCurrentPage(2);
Expand Down
103 changes: 103 additions & 0 deletions src/annotator/components/DrawToolAnnouncer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { KeyboardMode, PinnedCorner } from '../../types/annotator';
import { pinnedCornerToLabel } from '../util/pinned-corner';

/**
* Component that announces changes to pin/rectangle position and size
* to screen readers using a live region.
*/
export type DrawToolAnnouncerProps = {
/** Current mode: 'move' for moving, 'resize' for resizing, 'rect' for rectangle, null when inactive */
mode: KeyboardMode;

/** Current tool type: 'point' for pin, 'rect' for rectangle */
tool: 'point' | 'rect' | null;

/** Which corner is pinned during resize mode */
pinnedCorner?: PinnedCorner;

/** Current X coordinate (for point) or left position (for rect) */
x?: number;

/** Current Y coordinate (for point) or top position (for rect) */
y?: number;

/** Width of rectangle (only for rect tool) */
width?: number;

/** Height of rectangle (only for rect tool) */
height?: number;

/** Whether keyboard mode is active */
keyboardActive: boolean;
};

/**
* Announce current position/size of drawing tool to screen readers.
*
* This component renders a hidden live region that announces changes
* to the position or size of pin/rectangle annotations when using
* keyboard navigation.
*/
export default function DrawToolAnnouncer({
mode,
tool,
x,
y,
width,
height,
keyboardActive,
pinnedCorner,
}: DrawToolAnnouncerProps) {
if (!keyboardActive || !tool) {
return null;
}

let announcement = '';

if (tool === 'point') {
if (mode === 'move' && typeof x === 'number' && typeof y === 'number') {
announcement = `Pin position: ${Math.round(x)}, ${Math.round(y)}`;
} else if (mode === 'resize') {
// Pin doesn't support resize, but announce if mode is set incorrectly
announcement = 'Pin annotation mode. Use arrow keys to move.';
} else {
announcement =
'Pin annotation mode. Use arrow keys to move, Enter to confirm.';
}
} else if (tool === 'rect') {
if (
mode === 'move' &&
typeof x === 'number' &&
typeof y === 'number' &&
typeof width === 'number' &&
typeof height === 'number'
) {
announcement = `Rectangle position: ${Math.round(x)}, ${Math.round(y)}. Size: ${Math.round(width)} by ${Math.round(height)} pixels`;
} else if (
mode === 'resize' &&
typeof width === 'number' &&
typeof height === 'number'
) {
const cornerText = pinnedCornerToLabel(pinnedCorner, 'long');
announcement = `Rectangle size: ${Math.round(width)} by ${Math.round(height)} pixels. ${cornerText}. Press Tab to change pinned corner.`;
} else if (mode === 'rect') {
announcement =
'Rectangle annotation mode. Click the mode button to switch to Move or Resize mode.';
} else {
announcement =
'Rectangle annotation mode. Use arrow keys to move, Ctrl+Shift+J to resize, Enter to confirm.';
}
}

return (
<div
aria-live="polite"
aria-atomic="true"
role="status"
className="sr-only"
data-testid="draw-tool-announcer"
>
{announcement && <span>{announcement}</span>}
</div>
);
}
61 changes: 61 additions & 0 deletions src/annotator/components/DrawToolKeyboardIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { KeyboardMode, PinnedCorner } from '../../types/annotator';
import { pinnedCornerToLabel } from '../util/pinned-corner';

/**
* Visual indicator showing the current keyboard mode (move/resize) for drawing tools.
* This provides visual feedback to users when keyboard mode is active.
*/
export type DrawToolKeyboardIndicatorProps = {
/** Current mode: 'move' for moving, 'resize' for resizing, 'rect' for rectangle, null when inactive */
mode: KeyboardMode;

/** Whether keyboard mode is active */
keyboardActive: boolean;

/** Which corner is pinned during resize mode */
pinnedCorner?: PinnedCorner;
};

/**
* Visual indicator for keyboard drawing mode.
*
* Displays a small overlay showing the current mode (move/resize) when
* keyboard mode is active. This helps users understand the current state
* without relying solely on screen reader announcements.
*/
export default function DrawToolKeyboardIndicator({
mode,
keyboardActive,
pinnedCorner,
}: DrawToolKeyboardIndicatorProps) {
if (!keyboardActive || !mode) {
return null;
}

const modeText =
mode === 'move' ? 'Move' : mode === 'resize' ? 'Resize' : 'Rectangle';
let instructions: string;
if (mode === 'move') {
instructions =
'Use arrow keys to move, click mode button to switch modes, Enter to confirm';
} else if (mode === 'resize') {
const cornerText = pinnedCornerToLabel(pinnedCorner, 'short');
instructions = `Use arrow keys to resize (${cornerText} corner pinned), Tab to change corner, click mode button to switch modes, Enter to confirm`;
} else {
instructions =
'Rectangle mode. Click the mode button to switch to Move or Resize mode.';
}

return (
<div
className="fixed bottom-4 left-4 bg-white border border-grey-3 rounded shadow-lg p-3 z-50 pointer-events-none"
data-testid="draw-tool-keyboard-indicator"
role="status"
>
<div className="text-sm font-semibold text-grey-9">
Keyboard mode: {modeText}
</div>
<div className="text-xs text-grey-6 mt-1">{instructions}</div>
</div>
);
}
160 changes: 160 additions & 0 deletions src/annotator/components/DrawToolSurface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import type { KeyboardMode, PinnedCorner, Shape } from '../../types/annotator';
import { normalizeRect } from '../util/draw-tool-position';
import { getActiveEdges } from '../util/rect-resize';

export type DrawToolSurfaceProps = {
shape: Shape | undefined;
waitingForSecondClick: boolean;
firstClickPoint: { x: number; y: number } | undefined;
keyboardMode: KeyboardMode;
keyboardActive: boolean;
pinnedCorner: PinnedCorner;
};

const ACTIVE_EDGE_COLOR = '#374151';
const INACTIVE_EDGE_COLOR = 'grey';

/**
* Renders the current draw-tool shape (rect or point) or the two-click indicator
* into the SVG surface.
*/
export function DrawToolSurface({
shape,
waitingForSecondClick,
firstClickPoint,
keyboardMode,
keyboardActive,
pinnedCorner,
}: DrawToolSurfaceProps) {
Comment on lines 21 to 28
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool is destructured but not used anywhere in this component. If it is not needed, remove it from the destructuring (and potentially from DrawToolSurfaceProps) to avoid unused-parameter churn and simplify the API.

Copilot uses AI. Check for mistakes.
if (shape?.type === 'rect') {
if (waitingForSecondClick && firstClickPoint) {
const { x, y } = firstClickPoint;
return (
<>
<circle
stroke="grey"
stroke-width="2px"
fill="none"
cx={x}
cy={y}
r={8}
/>
<line
stroke="grey"
stroke-width="1px"
x1={x - 12}
y1={y}
x2={x + 12}
y2={y}
/>
<line
stroke="grey"
stroke-width="1px"
x1={x}
y1={y - 12}
x2={x}
y2={y + 12}
/>
</>
);
}

const rect = normalizeRect(shape);
const width = rect.right - rect.left;
const height = rect.bottom - rect.top;
const activeEdges =
keyboardMode === 'resize' && keyboardActive
? getActiveEdges(pinnedCorner)
: { top: false, right: false, bottom: false, left: false };

return (
<>
{/* Background fill - white dashed stroke so grey dashes show through from next rect */}
<rect
stroke="white"
stroke-dasharray="5"
stroke-width="1px"
fill="grey"
fill-opacity="0.5"
x={rect.left}
y={rect.top}
width={width}
height={height}
/>
{/* Base border - grey dashed, offset to alternate with white */}
<rect
stroke={INACTIVE_EDGE_COLOR}
stroke-dasharray="5"
stroke-dashoffset="5"
stroke-width="1px"
fill="none"
x={rect.left}
y={rect.top}
width={width}
height={height}
/>
{/* Active edges overlay in resize mode - dashed dark grey on active edges only */}
{keyboardMode === 'resize' && keyboardActive && (
<>
<line
x1={rect.left}
y1={rect.top}
x2={rect.right}
y2={rect.top}
stroke={activeEdges.top ? ACTIVE_EDGE_COLOR : 'transparent'}
stroke-width="3px"
stroke-dasharray="5"
stroke-dashoffset="5"
/>
<line
x1={rect.right}
y1={rect.top}
x2={rect.right}
y2={rect.bottom}
stroke={activeEdges.right ? ACTIVE_EDGE_COLOR : 'transparent'}
stroke-width="3px"
stroke-dasharray="5"
stroke-dashoffset="5"
/>
<line
x1={rect.left}
y1={rect.bottom}
x2={rect.right}
y2={rect.bottom}
stroke={activeEdges.bottom ? ACTIVE_EDGE_COLOR : 'transparent'}
stroke-width="3px"
stroke-dasharray="5"
stroke-dashoffset="5"
/>
<line
x1={rect.left}
y1={rect.top}
x2={rect.left}
y2={rect.bottom}
stroke={activeEdges.left ? ACTIVE_EDGE_COLOR : 'transparent'}
stroke-width="3px"
stroke-dasharray="5"
stroke-dashoffset="5"
/>
</>
)}
</>
);
}

if (shape?.type === 'point') {
const point = shape;
return (
<circle
stroke="black"
stroke-width="1px"
fill="yellow"
cx={point.x}
cy={point.y}
r={5}
/>
);
}

return null;
}
Loading