Repository navigation
Expand file tree
/
Copy pathCommandCenter.tsx
More file actions
324 lines (306 loc) · 13.1 KB
/
Copy pathCommandCenter.tsx
File metadata and controls
324 lines (306 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
import _ from 'lodash'
import { motion, useMotionTemplate, useTransform } from 'motion/react'
import pluralize from 'pluralize'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { Sheet, SheetRef } from 'react-modal-sheet'
import { useDispatch, useSelector } from 'react-redux'
import { css, cx } from '../../../styled-system/css'
import { token } from '../../../styled-system/tokens'
import { clearMulticursorsActionCreator as clearMulticursors } from '../../actions/clearMulticursors'
import { toggleDropdownActionCreator as toggleDropdown } from '../../actions/toggleDropdown'
import { isTouch } from '../../browser'
import categorize from '../../commands/categorize'
import copyCursorCommand from '../../commands/copyCursor'
import deleteCommand from '../../commands/delete'
import favorite from '../../commands/favorite'
import indent from '../../commands/indent'
import note from '../../commands/note'
import outdent from '../../commands/outdent'
import swapParent from '../../commands/swapParent'
import uncategorize from '../../commands/uncategorize'
import isTutorial from '../../selectors/isTutorial'
import backgroundGlowStore from '../../stores/backgroundGlowStore'
import durations from '../../util/durations'
import fastClick from '../../util/fastClick'
import backgroundGlowImageClassName from '../backgroundGlowImageClassName'
import PanelCommand from './PanelCommand'
import PanelCommandGroup from './PanelCommandGroup'
/** Close to MUI easeOut, but with a 20% head start to catch up with the finger. Otherwise it feels sluggish. The animation can't start until touchend, so we can't afford to smoothly animate from 0. */
const easeOpen = [0.1, 0.2, 0.2, 1] as const
/** MUI sharp — matches the close curve used by SwipeableDrawer. Slow first frame avoids a large initial gap. */
const easeClose = [0.4, 0, 0.6, 1] as const
/**
* A custom hook that returns the last non-zero number of multicursors.
* This is used to avoid showing the MultiselectMessage changing as the Command Center is closed.
*/
const useNonzeroNumMulticursors = () => {
const numMulticursors = useSelector(state => Object.keys(state.multicursors).length)
const lastNumMulticursorsRef = useRef(numMulticursors)
// update ref if numMulticursors is not zero
if (numMulticursors !== 0) {
lastNumMulticursorsRef.current = numMulticursors
}
return lastNumMulticursorsRef.current
}
/** Shows a message with the number of thoughts selected, and a cancel button to deselect all. */
const MultiselectMessage: FC = () => {
const displayNumMulticursors = useNonzeroNumMulticursors()
return (
<div>
<span
className={css({
color: 'fg',
fontWeight: 700,
letterSpacing: '-0.011em',
opacity: 0.6,
fontSize: '1.3em',
})}
>
{displayNumMulticursors} {pluralize('thought', displayNumMulticursors, false)} selected
</span>
</div>
)
}
/**
* Custom hook that returns reactive transforms for a draggable sheet.
*/
const useSheetTransforms = (ref: React.RefObject<SheetRef | null>) => {
/*
* Force a re-render once the Sheet ref is attached so that the motion transforms below re-run
* their compute functions while ref.current is set, allowing them to subscribe to the sheet's
* motion values (e.g. yInverted). On the first render after the Command Center (re)mounts,
* ref.current is still null, so the compute functions read no motion values and the overlay
* opacity stays stuck at 0 (transparent). This is most visible when the Command Center remounts
* after a modal (Export/Share, Devices, Settings) is closed while it is still open.
*/
const [, setSheetReady] = useState(false)
useEffect(() => {
if (ref.current) setSheetReady(true)
}, [ref])
const height = useTransform(() => {
return ref.current?.yInverted.get() ?? 0
})
const sheetProgress = useTransform(() => {
const y = ref.current?.yInverted.get() ?? 0
const height = ref.current?.height ?? 0
if (height === 0) return 0
return Math.min(Math.max(y / height, 0), 1)
})
const blurHeight = useTransform(height, height => {
// Start at 0, then smoothly grow with progress
return height + 110 * sheetProgress.get()
})
return { height, opacity: sheetProgress, blurHeight }
}
/**
* A panel that displays the Command Center.
*/
const CommandCenter = () => {
const dispatch = useDispatch()
const showCommandCenter = useSelector(state => state.showCommandCenter)
const showSidebar = useSelector(state => state.showSidebar)
const isTutorialOn = useSelector(isTutorial)
const sheetRef = useRef<SheetRef>(null)
const { height, opacity, blurHeight } = useSheetTransforms(sheetRef)
const backgroundGlow = backgroundGlowStore.useState()
// Reveals the glow-mode falloff only within the sheet area, with the same soft 2.5rem top edge as the plain falloff gradient. Anchored to the bottom of the viewport since the sheet is bottom-anchored.
const backgroundGlowMask = useMotionTemplate`linear-gradient(to top, black calc(${height}px - 2.5rem), transparent ${height}px)`
/** Prevent native page scroll when dragging the sheet. The page body is scrollable, and without this the browser scrolls the body on touchmove, stealing touch from the sheet's drag handler. React touch handlers are passive so we need a non-passive listener via addEventListener. */
const preventTouchMoveRef = useCallback((el: HTMLDivElement | null) => {
if (!el) return
/** Prevent native page scroll on touchmove. */
const handler: EventListenerOrEventListenerObject = e => e.preventDefault()
el.addEventListener('touchmove', handler, { passive: false })
}, [])
const onClose = useCallback(() => {
dispatch([toggleDropdown({ dropDownType: 'commandCenter', value: false }), clearMulticursors()])
}, [dispatch])
useEffect(() => {
if (isTouch && showCommandCenter && showSidebar) onClose()
}, [onClose, showCommandCenter, showSidebar])
const isOpen = showCommandCenter && !showSidebar
if (isTouch && !isTutorialOn) {
return (
<>
{isOpen && (
<motion.div
/*
* Progressive blur effect. Must be placed outside the Sheet to avoid separation
* from the background content due to the fixed position of the parent.
*/
className={css({
position: 'fixed',
pointerEvents: 'none',
backdropFilter: 'blur(2px)',
mask: 'linear-gradient(180deg, {colors.bgTransparent} 0%, black 110px, black 100%)',
bottom: 0,
width: '100%',
zIndex: 'commandCenterBlur',
})}
style={{
height: blurHeight,
}}
/>
)}
<Sheet
data-testid='command-center-panel'
ref={sheetRef}
isOpen={isOpen}
onClose={onClose}
detent='content'
unstyled
tweenConfig={{
duration: durations.get('commandCenter') / 1000,
ease: isOpen ? easeOpen : easeClose,
}}
style={{
/** Override default Sheet zIndex. */
zIndex: token('zIndex.commandCenter'),
}}
/** Fixes sheet shifting up on ios when it opens. */
disableScrollLocking
/** Lifts the sheet above the keyboard only while it is open. On Chromium the lift tracks the live keyboard inset, so a keyboard that rises while the sheet is closing (e.g. after Note) would grow the sheet back up into view mid-tween. */
avoidKeyboard={isOpen}
>
{backgroundGlow.image ? (
<motion.div
/** Falloff when a BackgroundGlow image is active. An exact copy of the app background (glow image at its opacity over the opaque background color), masked to the sheet area with a soft top edge. Because the layer is identical to the background behind the content, the masked blend is a pure crossfade: the thoughts fade out over the mask ramp while the glow brightness stays constant, with no dark seam. The fixed position and identical background sizing keep the image pixel-aligned with the BackgroundGlow layer behind the content. */
className={css({
position: 'fixed',
inset: 0,
pointerEvents: 'none',
backgroundColor: 'bg',
})}
style={{
maskImage: backgroundGlowMask,
WebkitMaskImage: backgroundGlowMask,
}}
>
<div
className={cx(
backgroundGlowImageClassName[backgroundGlow.image],
css({
position: 'absolute',
inset: 0,
backgroundSize: 'cover',
backgroundPosition: 'bottom center',
backgroundRepeat: 'no-repeat',
}),
)}
style={{ opacity: backgroundGlow.opacity }}
/>
</motion.div>
) : (
<motion.div
/** Falloff. Softly cuts off the content behind the sheet by fading to the opaque background color. */
className={css({
pointerEvents: 'none',
position: 'absolute',
// eslint-disable-next-line @pandacss/prefer-token -- every color in the gradient is a token reference
background: 'linear-gradient(180deg, {colors.bgTransparent} 0%, {colors.bg} 2.5rem)',
paddingTop: '0.711rem',
bottom: 0,
width: '100%',
height: '100%',
})}
style={{ height }}
/>
)}
<motion.div
className={css({
position: 'fixed',
pointerEvents: 'none',
backgroundImage: 'url(/img/command-center/overlay.webp)',
backgroundSize: 'cover',
backgroundPosition: 'center bottom',
height: '100vh',
width: '100%',
bottom: 0,
})}
style={{ opacity }}
/>
<Sheet.Container
ref={preventTouchMoveRef}
data-testid='command-menu-panel'
className={css({
backgroundColor: 'transparent',
overflow: 'visible',
boxShadow: 'none',
})}
style={{
// override default Sheet.Container styles
maxHeight: '70%',
zIndex: 'auto',
}}
>
<Sheet.Content
className={css({
overflow: 'visible',
})}
>
<div
className={css({
display: 'flex',
flexDirection: 'column',
margin: '0 1.333rem calc(1.333rem + env(safe-area-inset-bottom)) 1.333rem',
gap: '0.889rem',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'space-between',
})}
>
<MultiselectMessage />
<button
{...fastClick(onClose)}
data-testid='command-center-done'
className={css({
all: 'unset',
fontSize: '0.85em',
fontWeight: 500,
letterSpacing: '-0.011em',
color: 'fg',
opacity: 0.5,
borderRadius: 46,
cursor: 'pointer',
padding: '8px 16px',
background: 'commandCenterDoneButton',
})}
>
Done
</button>
</div>
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gridTemplateRows: 'auto',
gridAutoFlow: 'row',
gap: '0.622rem',
gridRowGap: '0.889rem',
})}
>
<PanelCommand command={{ ...copyCursorCommand, label: 'Copy' }} size='small' />
<PanelCommand command={note} size='small' />
<PanelCommand command={{ ...favorite, label: 'Favorite' }} size='small' />
<PanelCommand command={deleteCommand} size='small' />
<PanelCommandGroup commandSize='small' commandCount={2}>
<PanelCommand command={{ ...outdent, label: '' }} size='small' />
<PanelCommand command={{ ...indent, label: '' }} size='small' />
</PanelCommandGroup>
<PanelCommand command={swapParent} size='medium' />
<PanelCommand command={categorize} size='medium' />
<PanelCommand command={uncategorize} size='medium' />
</div>
</div>
</Sheet.Content>
</Sheet.Container>
</Sheet>
</>
)
}
}
export default CommandCenter