Skip to content

Commit ad7569e

Browse files
committed
fix(canvas): show prompt composer on hover
1 parent 70028f8 commit ad7569e

8 files changed

Lines changed: 253 additions & 93 deletions

‎src/components/beatcanvas/beatcanvas-composer-shell.tsx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export function BeatCanvasComposerShell({
3232
isPromptComposing,
3333
labels,
3434
onActiveComposerCardIdChange,
35+
onComposerFocusChange,
36+
onComposerPointerChange,
3537
onPromptChange,
3638
onPromptCommit,
3739
onGenerateDraft,
@@ -54,6 +56,8 @@ export function BeatCanvasComposerShell({
5456
isPromptComposing: boolean;
5557
labels: CanvasLabels;
5658
onActiveComposerCardIdChange: (cardId: string | null) => void;
59+
onComposerFocusChange?: (isFocusWithinComposer: boolean) => void;
60+
onComposerPointerChange?: (isPointerWithinComposer: boolean) => void;
5761
onPromptChange: (nextPrompt: string) => void;
5862
onPromptCommit: (nextPrompt: string) => void;
5963
onGenerateDraft: (draftId: string) => void;
@@ -121,14 +125,27 @@ export function BeatCanvasComposerShell({
121125
<section
122126
ref={composerRef}
123127
className={cn(
124-
'beat-composer pointer-events-auto absolute z-[60] isolate',
128+
'beat-composer beat-pop-in pointer-events-auto absolute z-[60] isolate',
125129
composerCardClassName,
126130
'w-[min(560px,calc(100vw-32px))]',
127131
position ? '' : 'bottom-[98px] left-1/2 -translate-x-1/2'
128132
)}
129133
style={position ? { left: position.left, top: position.top } : undefined}
130134
onPointerDown={stopComposerEvent}
131135
onPointerDownCapture={stopComposerEvent}
136+
onPointerEnter={() => onComposerPointerChange?.(true)}
137+
onPointerLeave={() => onComposerPointerChange?.(false)}
138+
onFocusCapture={() => onComposerFocusChange?.(true)}
139+
onBlurCapture={(event) => {
140+
const nextFocusedElement = event.relatedTarget;
141+
if (
142+
nextFocusedElement instanceof Node &&
143+
event.currentTarget.contains(nextFocusedElement)
144+
) {
145+
return;
146+
}
147+
onComposerFocusChange?.(false);
148+
}}
132149
onKeyDownCapture={stopComposerKeyboardEvent}
133150
onKeyUpCapture={stopComposerKeyboardEvent}
134151
>

‎src/components/beatcanvas/beatcanvas-composer-utils.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ test('keeps the anchored composer inside the canvas horizontally', () => {
2626
);
2727
});
2828

29+
test('places the hover composer above a card when it would overflow below', () => {
30+
assert.deepEqual(
31+
resolveAnchoredComposerPosition({
32+
frame: { left: 300, top: 520, width: 240, height: 220 },
33+
viewportWidth: 1200,
34+
viewportHeight: 800,
35+
composerHeight: 240,
36+
}),
37+
{ left: 140, top: 266 }
38+
);
39+
});
40+
2941
test('reduces zoom only when the frame and composer do not fit vertically', () => {
3042
assert.deepEqual(
3143
resolveComposerFocusLayout({

‎src/components/beatcanvas/beatcanvas-composer-utils.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,16 @@ export const stopComposerKeyboardEvent = <
121121
export const resolveAnchoredComposerPosition = ({
122122
frame,
123123
viewportWidth,
124+
viewportHeight,
125+
composerHeight,
124126
preferredWidth = 560,
125127
edgeInset = 16,
126128
gap = 14,
127129
}: {
128130
frame: { left: number; top: number; width: number; height: number };
129131
viewportWidth: number;
132+
viewportHeight?: number;
133+
composerHeight?: number;
130134
preferredWidth?: number;
131135
edgeInset?: number;
132136
gap?: number;
@@ -138,10 +142,31 @@ export const resolveAnchoredComposerPosition = ({
138142
edgeInset,
139143
viewportWidth - edgeInset - composerWidth
140144
);
145+
const preferredBelowTop = frame.top + frame.height + gap;
146+
let top = preferredBelowTop;
147+
148+
if (
149+
typeof viewportHeight === 'number' &&
150+
typeof composerHeight === 'number'
151+
) {
152+
const maximumTop = Math.max(
153+
edgeInset,
154+
viewportHeight - edgeInset - composerHeight
155+
);
156+
const preferredAboveTop = frame.top - gap - composerHeight;
157+
158+
if (preferredBelowTop + composerHeight <= viewportHeight - edgeInset) {
159+
top = preferredBelowTop;
160+
} else if (preferredAboveTop >= edgeInset) {
161+
top = preferredAboveTop;
162+
} else {
163+
top = Math.min(Math.max(preferredBelowTop, edgeInset), maximumTop);
164+
}
165+
}
141166

142167
return {
143168
left: Math.min(Math.max(preferredLeft, edgeInset), maximumLeft),
144-
top: frame.top + frame.height + gap,
169+
top,
145170
};
146171
};
147172

‎src/components/beatcanvas/beatcanvas-front-layer-toolbar.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,13 @@ test('canvas toolbar stays at the bottom and hides while the composer is open',
1717
/activeComposerCardId \? 'top-4' : 'bottom-4'/
1818
);
1919
});
20+
21+
test('canvas composer opens from generation-card hover intent instead of selection', () => {
22+
assert.match(source, /registerGenerationCardHoverCallback/);
23+
assert.match(source, /COMPOSER_HOVER_OPEN_DELAY_MS = 140/);
24+
assert.match(source, /COMPOSER_HOVER_CLOSE_DELAY_MS = 220/);
25+
assert.match(source, /hoveredComposerCardId/);
26+
assert.match(source, /onComposerPointerChange=\{handleComposerPointerChange\}/);
27+
assert.match(source, /onComposerFocusChange=\{handleComposerFocusChange\}/);
28+
assert.doesNotMatch(source, /resolveComposerFocusLayout/);
29+
});

0 commit comments

Comments
 (0)