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
16 changes: 16 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ time,
color: var(--text-bright);
}

/* Screen-reader-only utility — hides content visually while keeping it
* accessible to assistive tech. Used by Radix `Form.Label` consumers that
* carry semantic labels but rely on placeholders / surrounding context for
* the sighted reading order (e.g. the DraftCard textarea). */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

/* Universal focus ring — wins over any component-level outline:none */
:focus {
outline: none;
Expand Down
16 changes: 15 additions & 1 deletion src/components/DraftCard/DraftCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,25 @@
.actions {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
justify-content: flex-end;
padding-top: 2px;
}

/* Tighter Kbd inside the action-row buttons — without this the DS-spec
* Kbd (20 px min-width / 10.5 px font) overflows the 300 px rail and
* clips the Cancel button. Targets the rendered <kbd> element directly
* (higher specificity than Kbd.module.css's `.key`) so we don't touch the
* sibling Kbd consumers (rail add-button, command palette, etc.). */
.kbdInBtn {
gap: 2px;
}
.kbdInBtn kbd {
min-width: 14px;
padding: 1px 4px;
font-size: 9.5px;
}

.btn {
display: inline-flex;
align-items: center;
Expand Down
47 changes: 38 additions & 9 deletions src/components/DraftCard/DraftCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Form from '@radix-ui/react-form';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { forwardRef, type ReactNode, useEffect, useImperativeHandle, useRef } from 'react';
import { Kbd } from '@/components/Kbd/Kbd';
import {
BODY_CHAR_LIMIT,
Expand Down Expand Up @@ -56,12 +56,45 @@ function sendLabel(status: DraftStatus): string {
return 'Send';
}

/**
* Locale-independent thousands separator. DS 32 specifies `10 000` with a
* thin (non-breaking) space; `toLocaleString()` swaps in `,` / `.` / ` `
* per the browser locale, drifting the counter from the contract.
*/
function formatCount(n: number): string {
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}

function pinHintCopy(count: number, pinCount: PinCount): string {
if (pinCount === 'max') return '· max reached';
if (count === 0) return '· click on the mockup to add';
return '· click pin to remove';
}

/**
* AlertDialog body copy. Branches over (bodyLength, pinCount) so the
* "lose 0 pins" awkwardness is suppressed when only one side is non-empty.
* The four branches mirror the four reachable cancel states — Cancel is
* disabled on an empty draft, so (0, 0) doesn't surface in practice.
*/
function discardCopy(bodyLen: number, pinCount: number): ReactNode {
const pins = (
<strong>
{pinCount} {pinCount === 1 ? 'pin' : 'pins'}
</strong>
);
if (bodyLen > 0 && pinCount > 0) {
return <>You'll lose {pins} and the text you typed. This can't be undone.</>;
}
if (bodyLen > 0) {
return <>You'll lose the text you typed. This can't be undone.</>;
}
if (pinCount > 0) {
return <>You'll lose {pins}. This can't be undone.</>;
}
return <>You'll lose this draft. This can't be undone.</>;
}

export const DraftCard = forwardRef<HTMLTextAreaElement, DraftCardProps>(
function DraftCard(props, forwardedRef) {
const { draft, status, density = 'compact', onBodyChange, onCancel, onSave, onSend } = props;
Expand Down Expand Up @@ -99,7 +132,7 @@ export const DraftCard = forwardRef<HTMLTextAreaElement, DraftCardProps>(
<span className={styles.spacer} />
{bodyState !== 'empty' && (
<span className={styles.countChars}>
{draft.body.length.toLocaleString()} / {BODY_CHAR_LIMIT.toLocaleString()}
{formatCount(draft.body.length)} / {formatCount(BODY_CHAR_LIMIT)}
</span>
)}
</div>
Expand Down Expand Up @@ -148,11 +181,7 @@ export const DraftCard = forwardRef<HTMLTextAreaElement, DraftCardProps>(
Discard draft?
</AlertDialog.Title>
<AlertDialog.Description className={styles.alertBody}>
You'll lose{' '}
<strong>
{draft.pins.length} {draft.pins.length === 1 ? 'pin' : 'pins'}
</strong>
{draft.body.length > 0 ? ' and the text you typed' : ''}. This can't be undone.
{discardCopy(draft.body.length, draft.pins.length)}
</AlertDialog.Description>
<div className={styles.alertActions}>
<AlertDialog.Cancel asChild>
Expand Down Expand Up @@ -191,7 +220,7 @@ export const DraftCard = forwardRef<HTMLTextAreaElement, DraftCardProps>(
onClick={onSave}
>
Draft
<Kbd keys={['mod', 's']} />
<Kbd keys={['mod', 's']} className={styles.kbdInBtn} />
</button>

<Form.Submit asChild>
Expand All @@ -201,7 +230,7 @@ export const DraftCard = forwardRef<HTMLTextAreaElement, DraftCardProps>(
disabled={bodyState !== 'typing' || status === 'sending'}
>
{sendLabel(status)}
<Kbd keys={['mod', 'enter']} />
<Kbd keys={['mod', 'enter']} className={styles.kbdInBtn} />
</button>
</Form.Submit>
</div>
Expand Down
21 changes: 13 additions & 8 deletions src/components/MockupViewer/AppMainViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,9 @@ export function AppMainViewer({
}
}, [draft, onCreateAnnotation, nextColorIndex, prependCreated, clear, toast]);

useDraftKeyboard({
draft,
onOpen: openDraft,
onCancel: cancelDraft,
onSend: sendDraft,
onSave: saveDraft,
textareaRef,
});
// useDraftKeyboard is mounted further below — after `useViewerCanvas`
// gives us the iframeRef so the hook can bridge keydown events from
// inside the mockup iframe (same-origin) into the parent doc handler.

// ── Canvas click dispatcher ──────────────────────────────────────
// `useViewerCanvas` classifies every click and emits one of:
Expand Down Expand Up @@ -307,6 +302,16 @@ export function AppMainViewer({
onMiss: handleMiss,
});

useDraftKeyboard({
draft,
onOpen: openDraft,
onCancel: cancelDraft,
onSend: sendDraft,
onSave: saveDraft,
textareaRef,
iframeRef,
});

const { isFullscreen, toggle: onFullscreenToggle } = useViewerFullscreen(appMainRef);

const badges: AnnotationsRailBadge[] = useMemo(
Expand Down
48 changes: 45 additions & 3 deletions src/hooks/useDraftKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export interface UseDraftKeyboardArgs {
onSend: () => void;
onSave: () => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
/**
* Optional iframe ref. When provided, the same keydown handler is also
* attached to the iframe's `contentDocument` so the `N` shortcut works
* even when focus has moved into the mockup canvas (same-origin iframe
* served by `/m/[mockupId]/...`). Iframe events that bubble to the
* iframe's document aren't seen by the parent document by default; we
* bridge them explicitly.
*/
iframeRef?: React.RefObject<HTMLIFrameElement | null>;
}

function isInputFocused(): boolean {
Expand All @@ -21,7 +30,7 @@ function isInputFocused(): boolean {
}

export function useDraftKeyboard(args: UseDraftKeyboardArgs): void {
const { draft, onOpen, onCancel, onSend, onSave, textareaRef } = args;
const { draft, onOpen, onCancel, onSend, onSave, textareaRef, iframeRef } = args;
const isMac = useIsMac();

useEffect(() => {
Expand Down Expand Up @@ -82,6 +91,39 @@ export function useDraftKeyboard(args: UseDraftKeyboardArgs): void {
}
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [draft, isMac, onOpen, onCancel, onSend, onSave, textareaRef]);

// Bridge: also listen inside the iframe's document so shortcuts fire
// when the user's focus is on the mockup canvas (same-origin only —
// accessing contentDocument on a cross-origin frame throws). Re-attach
// on each iframe load so the listener follows version-switches.
const iframe = iframeRef?.current ?? null;
let cleanupIframe: (() => void) | null = null;
function attachIframeListener() {
if (!iframe) return;
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
// Cross-origin iframe — nothing to do; default browser behavior.
return;
}
if (!doc) return;
doc.addEventListener('keydown', onKey);
cleanupIframe = () => {
try {
doc?.removeEventListener('keydown', onKey);
} catch {
// Iframe detached — listener is gone with it.
}
};
}
attachIframeListener();
iframe?.addEventListener('load', attachIframeListener);

return () => {
document.removeEventListener('keydown', onKey);
cleanupIframe?.();
iframe?.removeEventListener('load', attachIframeListener);
};
}, [draft, isMac, onOpen, onCancel, onSend, onSave, textareaRef, iframeRef]);
}
Loading