Skip to content

Commit a2c9776

Browse files
grubmanItayclaude
andcommitted
feat: add mobile compatibility
- Add responsive hamburger menu with all header actions (MobileMenu) - Annotation panel renders as full-screen overlay on mobile with backdrop and close button - Panel starts closed on mobile (<768px) - Touch support for resize handles, pinpoint annotations, and toolstrip buttons - Mobile text selection creates annotations via highlighter.fromRange() bridge - Card action buttons always visible on touch devices (hover:none media query) - Settings modal uses horizontal tab bar on mobile - CommentPopover width capped to viewport on small screens - Replace mousedown with pointerdown for touch-compatible click-outside handling - Add useIsMobile reactive hook for breakpoint detection - Desktop layout (>=768px) unchanged Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bd8268d commit a2c9776

13 files changed

Lines changed: 539 additions & 145 deletions

packages/editor/App.tsx

Lines changed: 135 additions & 104 deletions
Large diffs are not rendered by default.

packages/ui/components/AnnotationPanel.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Annotation, AnnotationType, Block, type EditorAnnotation } from '../typ
33
import { isCurrentUser } from '../utils/identity';
44
import { ImageThumbnail } from './ImageThumbnail';
55
import { EditorAnnotationCard } from './EditorAnnotationCard';
6+
import { useIsMobile } from '../hooks/useIsMobile';
67

78
interface PanelProps {
89
isOpen: boolean;
@@ -17,6 +18,7 @@ interface PanelProps {
1718
width?: number;
1819
editorAnnotations?: EditorAnnotation[];
1920
onDeleteEditorAnnotation?: (id: string) => void;
21+
onClose?: () => void;
2022
}
2123

2224
export const AnnotationPanel: React.FC<PanelProps> = ({
@@ -32,7 +34,9 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
3234
width,
3335
editorAnnotations,
3436
onDeleteEditorAnnotation,
37+
onClose,
3538
}) => {
39+
const isMobile = useIsMobile();
3640
const [copied, setCopied] = useState(false);
3741
const listRef = useRef<HTMLDivElement>(null);
3842
const sortedAnnotations = [...annotations].sort((a, b) => a.createdA - b.createdA);
@@ -60,17 +64,35 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
6064

6165
if (!isOpen) return null;
6266

63-
return (
64-
<aside className="border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col flex-shrink-0" style={{ width: width ?? 288 }}>
67+
const panel = (
68+
<aside
69+
className={`border-l border-border/50 bg-card/30 backdrop-blur-sm flex flex-col flex-shrink-0 ${
70+
isMobile ? 'fixed inset-y-12 right-0 z-[60] w-full max-w-sm shadow-2xl bg-card' : ''
71+
}`}
72+
style={isMobile ? undefined : { width: width ?? 288 }}
73+
>
6574
{/* Header */}
6675
<div className="p-3 border-b border-border/50">
6776
<div className="flex items-center justify-between">
6877
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
6978
Annotations
7079
</h2>
71-
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
72-
{totalCount}
73-
</span>
80+
<div className="flex items-center gap-2">
81+
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
82+
{totalCount}
83+
</span>
84+
{isMobile && onClose && (
85+
<button
86+
onClick={onClose}
87+
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
88+
title="Close panel"
89+
>
90+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
91+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
92+
</svg>
93+
</button>
94+
)}
95+
</div>
7496
</div>
7597
</div>
7698

@@ -148,6 +170,20 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
148170
)}
149171
</aside>
150172
);
173+
174+
if (isMobile) {
175+
return (
176+
<>
177+
<div
178+
className="fixed inset-0 z-[59] bg-background/60 backdrop-blur-sm"
179+
onClick={onClose}
180+
/>
181+
{panel}
182+
</>
183+
);
184+
}
185+
186+
return panel;
151187
};
152188

153189
function formatTimestamp(ts: number): string {
@@ -321,7 +357,7 @@ const AnnotationCard: React.FC<{
321357
{formatTimestamp(annotation.createdA)}
322358
</span>
323359
</div>
324-
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
360+
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition-all">
325361
{onEdit && annotation.type !== AnnotationType.DELETION && !isEditing && (
326362
<button
327363
onClick={handleStartEdit}

packages/ui/components/AnnotationToolstrip.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
2828

2929
return (
3030
<>
31-
<div className="flex items-center gap-1.5">
31+
<div className="flex items-center gap-1.5 flex-wrap">
3232
{/* Input method group */}
3333
<div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
3434
<ToolstripButton
@@ -109,7 +109,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
109109
{/* Help */}
110110
<button
111111
onClick={() => setShowHelp(true)}
112-
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
112+
className="ml-2 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors hidden sm:block"
113113
>
114114
how does this work?
115115
</button>
@@ -229,6 +229,7 @@ const ToolstripButton: React.FC<{
229229
const [labelWidth, setLabelWidth] = useState(0);
230230
const measureRef = useRef<HTMLSpanElement>(null);
231231
const styles = colorStyles[color];
232+
const [isTouchDevice] = useState(() => 'ontouchstart' in window || navigator.maxTouchPoints > 0);
232233

233234
// Measure label text width synchronously before first paint
234235
useLayoutEffect(() => {
@@ -237,7 +238,7 @@ const ToolstripButton: React.FC<{
237238
}
238239
}, [label]);
239240

240-
const expanded = active || hovered;
241+
const expanded = active || hovered || isTouchDevice;
241242
const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD;
242243
const currentWidth = expanded ? expandedWidth : ICON_SIZE;
243244

packages/ui/components/CommentPopover.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,22 @@ interface CommentPopoverProps {
1818
onClose: () => void;
1919
}
2020

21-
const POPOVER_WIDTH = 384;
21+
const MAX_POPOVER_WIDTH = 384;
2222
const GAP = 8;
2323

24-
function computePosition(anchorRect: DOMRect): { top: number; left: number; flipAbove: boolean } {
24+
function computePosition(anchorRect: DOMRect): { top: number; left: number; flipAbove: boolean; width: number } {
2525
const spaceBelow = window.innerHeight - anchorRect.bottom;
2626
const flipAbove = spaceBelow < 280;
27+
const width = Math.min(MAX_POPOVER_WIDTH, window.innerWidth - 32);
2728

2829
const top = flipAbove
2930
? anchorRect.top - GAP
3031
: anchorRect.bottom + GAP;
3132

32-
let left = anchorRect.left + anchorRect.width / 2 - POPOVER_WIDTH / 2;
33-
left = Math.max(16, Math.min(left, window.innerWidth - POPOVER_WIDTH - 16));
33+
let left = anchorRect.left + anchorRect.width / 2 - width / 2;
34+
left = Math.max(16, Math.min(left, window.innerWidth - width - 16));
3435

35-
return { top, left, flipAbove };
36+
return { top, left, flipAbove, width };
3637
}
3738

3839
export const CommentPopover: React.FC<CommentPopoverProps> = ({
@@ -142,7 +143,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
142143
style={{
143144
animation: 'comment-dialog-in 0.15s ease-out',
144145
}}
145-
onMouseDown={(e) => e.stopPropagation()}
146+
onPointerDown={(e) => e.stopPropagation()}
146147
>
147148
<style>{`
148149
@keyframes comment-dialog-in {
@@ -220,16 +221,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
220221
return createPortal(
221222
<div
222223
ref={popoverRef}
223-
className="fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl w-96 flex flex-col"
224+
className="fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
224225
style={{
225226
top: position.top,
226227
left: position.left,
228+
width: position.width,
227229
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
228230
animation: position.flipAbove
229231
? 'comment-popover-in-above 0.15s ease-out'
230232
: 'comment-popover-in 0.15s ease-out',
231233
}}
232-
onMouseDown={(e) => e.stopPropagation()}
234+
onPointerDown={(e) => e.stopPropagation()}
233235
>
234236
<style>{`
235237
@keyframes comment-popover-in {

packages/ui/components/EditorAnnotationCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
2727
</div>
2828
<button
2929
onClick={(e) => { e.stopPropagation(); onDelete(); }}
30-
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all opacity-0 group-hover:opacity-100"
30+
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
3131
title="Delete annotation"
3232
>
3333
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>

0 commit comments

Comments
 (0)