Phase 6: design polish — accept confetti + distraction-free mode
- Accept confetti: CSS-only 4-dot burst spawned at the accepted card, trajectory via inline --dx/--dy, auto-cleared after 720ms. - Distraction-free mode: editor focus collapses the doc-list sidebar (width→0 slide), canvas re-centers full-width; Escape or a click outside the canvas (header/gutter/status bar) restores it. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -13,7 +13,7 @@ interface Props {
|
||||
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
|
||||
return (
|
||||
<aside
|
||||
className="flex w-[260px] shrink-0 flex-col gap-2 p-3"
|
||||
className="flex h-full w-[260px] flex-col gap-2 p-3"
|
||||
style={{ borderRight: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -30,6 +30,31 @@ interface Props {
|
||||
// it runs (drives the toolbar button's loading state).
|
||||
onVoiceCheck: () => void
|
||||
voicing: boolean
|
||||
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||
onFocusMode?: () => void
|
||||
}
|
||||
|
||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||
// spray up-and-out from a point; each reads its direction from --dx/--dy.
|
||||
const CONFETTI_DOTS = [
|
||||
{ dx: -20, dy: -26, color: 'var(--color-accent)' },
|
||||
{ dx: 18, dy: -30, color: 'var(--color-mint)' },
|
||||
{ dx: -6, dy: -34, color: 'var(--color-peach)' },
|
||||
{ dx: 24, dy: -18, color: 'var(--color-lavender)' },
|
||||
]
|
||||
|
||||
function Confetti({ top, left }: { top: number; left: number }) {
|
||||
return (
|
||||
<div className="petal-confetti pointer-events-none absolute z-30" style={{ top, left }} aria-hidden>
|
||||
{CONFETTI_DOTS.map((d, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="petal-confetti-dot"
|
||||
style={{ '--dx': `${d.dx}px`, '--dy': `${d.dy}px`, background: d.color } as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
|
||||
@@ -63,9 +88,13 @@ export function EditorCore({
|
||||
onDismiss,
|
||||
onVoiceCheck,
|
||||
voicing,
|
||||
onFocusMode,
|
||||
}: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<HoverState | null>(null)
|
||||
// Transient confetti burst played at the last accept location.
|
||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
// Delays card close so the pointer can travel from highlight to card.
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
// While the Ask Petal panel is expanded the card is pinned: the hover-close
|
||||
@@ -85,6 +114,7 @@ export function EditorCore({
|
||||
editorProps: {
|
||||
attributes: { class: 'petal-prose focus:outline-none' },
|
||||
},
|
||||
onFocus: () => onFocusMode?.(),
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
@@ -170,7 +200,8 @@ export function EditorCore({
|
||||
|
||||
const keepOpen = useCallback(() => clearTimeout(closeTimer.current), [])
|
||||
|
||||
// Accept applies the replacement to the document, then notifies the parent.
|
||||
// Accept applies the replacement to the document, plays a little confetti
|
||||
// burst where the card sat, then notifies the parent.
|
||||
const handleAccept = useCallback(
|
||||
(s: Suggestion) => {
|
||||
if (editor && s.replacement.trim() !== '') {
|
||||
@@ -179,6 +210,14 @@ export function EditorCore({
|
||||
editor.chain().focus().insertContentAt(range, s.replacement).run()
|
||||
}
|
||||
}
|
||||
setHover((h) => {
|
||||
if (h) {
|
||||
setConfetti({ top: h.top, left: h.left + 16 })
|
||||
clearTimeout(confettiTimer.current)
|
||||
confettiTimer.current = setTimeout(() => setConfetti(null), 720)
|
||||
}
|
||||
return h
|
||||
})
|
||||
closeCard()
|
||||
onAccept(s)
|
||||
},
|
||||
@@ -193,7 +232,10 @@ export function EditorCore({
|
||||
[onDismiss, closeCard],
|
||||
)
|
||||
|
||||
useEffect(() => () => clearTimeout(closeTimer.current), [])
|
||||
useEffect(() => () => {
|
||||
clearTimeout(closeTimer.current)
|
||||
clearTimeout(confettiTimer.current)
|
||||
}, [])
|
||||
|
||||
// While pinned (Ask Petal open), a pointer-down outside the card closes it —
|
||||
// the only way to dismiss a pinned card without accept/dismiss.
|
||||
@@ -216,6 +258,7 @@ export function EditorCore({
|
||||
onMouseOut={handleMouseOut}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||
{hover && (
|
||||
<SuggestionCard
|
||||
suggestion={hover.suggestion}
|
||||
|
||||
Reference in New Issue
Block a user