Repository navigation
Expand file tree
/
Copy pathBulletPositioner.tsx
More file actions
299 lines (278 loc) · 11.6 KB
/
Copy pathBulletPositioner.tsx
File metadata and controls
299 lines (278 loc) · 11.6 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
import { PropsWithChildren, forwardRef, useCallback } from 'react'
import { ConnectDragSource } from 'react-dnd'
import { useDispatch, useSelector } from 'react-redux'
import { css, cva, cx } from '../../styled-system/css'
import { bulletRecipe } from '../../styled-system/recipes'
import { token } from '../../styled-system/tokens'
import Path from '../@types/Path'
import SimplePath from '../@types/SimplePath'
import { deleteAttributeActionCreator as deleteAttribute } from '../actions/deleteAttribute'
import { setCursorActionCreator as setCursor } from '../actions/setCursor'
import { setDescendantActionCreator as setDescendant } from '../actions/setDescendant'
import { toggleMulticursorActionCreator as toggleMulticursor } from '../actions/toggleMulticursor'
import { isTouch } from '../browser'
import { LongPressState } from '../constants'
import { LongPressProps } from '../hooks/useLongPress'
import findDescendant from '../selectors/findDescendant'
import getChildren from '../selectors/getChildren'
import getThoughtById from '../selectors/getThoughtById'
import hasMulticursor from '../selectors/hasMulticursor'
import isContextViewActive from '../selectors/isContextViewActive'
import isMulticursorPath from '../selectors/isMulticursorPath'
import isPinned from '../selectors/isPinned'
import dndRef from '../util/dndRef'
import fastClick from '../util/fastClick'
import getBulletWidth from '../util/getBulletWidth'
import hashPath from '../util/hashPath'
import head from '../util/head'
import isCommandKey from '../util/isCommandKey'
import isDivider from '../util/isDivider'
import parentOf from '../util/parentOf'
const glyph = cva({
base: {
fill: 'bullet',
position: 'relative',
},
variants: {
leaf: { true: {} },
showContexts: {
true: {
_mobile: {
fontSize: '80%',
left: '-0.08em',
top: '0.05em',
},
},
},
isBulletExpanded: { true: {} },
// childrenNew currently unused as NewThought is not importing Bullet
childrenNew: {
true: {
content: "'+'",
left: '-0.15em',
top: '-0.05em',
marginRight: '-0.3em',
_mobile: {
left: '0.05em',
top: '-0.1em',
marginRight: '-0.1em',
},
},
},
},
compoundVariants: [
{
leaf: true,
showContexts: true,
css: {
fontSize: '90%',
top: '-0.05em',
_mobile: {
top: '0',
left: '-0.3em',
},
},
},
],
defaultVariants: {
leaf: false,
showContexts: false,
},
})
type BulletPositionerProps = {
dragSource?: ConnectDragSource
longPressProps?: LongPressProps
path: Path
simplePath: SimplePath
isEditing: boolean
isInContextView?: boolean
isTableCol1?: boolean
isCursorParent?: boolean
isCursorGrandparent?: boolean
leaf: boolean | undefined
cursorOverlay?: boolean
isDragging?: boolean
}
/**
* A component that positions the Bullet and BulletCursorOverlay.
*/
const BulletPositioner = forwardRef<SVGSVGElement, PropsWithChildren<BulletPositionerProps>>(
(
{
children,
dragSource,
longPressProps,
path,
isEditing,
isInContextView,
isTableCol1,
simplePath,
isCursorParent,
isCursorGrandparent,
leaf,
isDragging,
cursorOverlay,
},
ref,
) => {
const thoughtId = head(path)
const dispatch = useDispatch()
const showContexts = useSelector(state => isContextViewActive(state, path))
const invalid = useSelector(state => isEditing && state.invalidState)
const bulletIsDivider = useSelector(state => isDivider(getThoughtById(state, head(path))?.value))
const fontSize = useSelector(state => state.fontSize)
const dragHold = useSelector(state => state.longPress === LongPressState.DragHold)
const isMulticursor = useSelector(state => isMulticursorPath(state, path))
const isHighlighted = useSelector(state => {
const isHolding = state.draggedSimplePath && head(state.draggedSimplePath) === head(simplePath)
return isHolding || isDragging || isMulticursor
})
// expand or collapse on click
// has some additional logic to make it work intuitively with pin true/false
const clickHandler = useCallback(
(e: React.MouseEvent) => {
// short circuit if dragHold
// useLongPress stop is activated in onMouseUp but is delayed to ensure that dragHold is still true here
// stopping propagation from useLongPress was not working either due to bubbling order or mismatched event type
if (dragHold) return
// short circuit if toggling multiselect
// Shift + Click selects the thoughts in between, and Cmd/Ctrl + Click toggles the clicked thought (see Thought)
if (!isTouch && (e.shiftKey || isCommandKey(e))) {
return
}
dispatch((dispatch, getState) => {
const state = getState()
// While a multiselect is active, tapping the bullet toggles the thought's selection, the same as
// tapping the thought itself. Expansion is determined by the selected thoughts during a multiselect,
// so the =pin handling below is skipped.
if (hasMulticursor(state)) {
// A bullet is not an editable, so Content treats the tap as a click on empty space and closes all
// dropdowns, which clears the multiselect along with the Command Center (see toggleDropdown).
e.stopPropagation()
dispatch(toggleMulticursor({ path }))
return
}
const isExpanded = state.expanded[hashPath(path)]
const children = getChildren(state, head(path))
const shouldCollapse = isExpanded && children.length > 0
const pathParent = path.length > 1 ? parentOf(path) : null
const parentChildren = pathParent ? getChildren(state, head(pathParent)) : null
// if thought is not expanded, set the cursor on the thought
// if thought is expanded, collapse it by moving the cursor to its parent
dispatch([
// set pin false on expanded only child
...(isExpanded &&
(!pathParent ||
parentChildren?.length === 1 ||
findDescendant(state, pathParent && head(pathParent), ['=children', '=pin', 'true']) ||
// =descendants/=pin propagates from any ancestor; the nearest ancestor that sets it wins
parentOf(path)
.slice()
.reverse()
.reduce<boolean | null>(
(accum, id) => accum ?? isPinned(state, findDescendant(state, id, '=descendants')),
null,
))
? [setDescendant({ path: simplePath, values: ['=pin', 'false'] })]
: [deleteAttribute({ path: simplePath, value: '=pin' })]),
// move cursor
setCursor({ path: shouldCollapse ? pathParent : path, preserveMulticursor: true }),
])
})
},
[dispatch, dragHold, path, simplePath],
)
// check if the thought is pinned
const isThoughtPinned = useSelector(state => !!isPinned(state, thoughtId))
const isExpanded = useSelector(state => !!state.expanded[hashPath(path)])
// A selected thought stays collapsed even when it is the cursor, so state.expanded alone determines
// whether its bullet points down.
// https://github.com/cybersemics/em/issues/4738
const isBulletExpanded = isExpanded || (!isMulticursor && (isCursorParent || isCursorGrandparent || isEditing))
// offset margin with padding by equal amounts proportional to the font size to extend the click area
const extendClickWidth = fontSize * 1.2
const extendClickHeight = fontSize / 3
const lineHeight = fontSize * 1.25
const width = getBulletWidth(fontSize) + (!isInContextView && isTableCol1 ? fontSize / 4 : 0)
const marginLeft = -width
return (
<span
data-testid={cursorOverlay ? undefined : 'bullet-' + hashPath(path)}
aria-label={cursorOverlay ? undefined : 'bullet'}
data-highlighted={cursorOverlay ? undefined : isHighlighted}
ref={dragSource ? dndRef(ref => dragSource(ref)) : undefined}
className={cx(
bulletRecipe({ invalid }),
css({
_mobile: {
marginRight: showContexts ? '-1.5px' : undefined,
},
display: bulletIsDivider ? 'none' : undefined,
position: 'absolute',
verticalAlign: 'top',
cursor: 'pointer',
}),
)}
style={{
// extend the click area upward, and mirror the editable's -0.5px top margin (see editableRecipe) so that the
// bullet shares the text's baseline. Then raise the bullet 1px above the lowercase optical center, since most
// thoughts begin with an uppercase letter, whose cap height draws the eye upward next to the bullet.
top: -extendClickHeight - 0.5 - 1,
left: -extendClickWidth + marginLeft,
paddingTop: `calc(${token('spacing.editablePaddingTop')} + ${extendClickHeight}px)`,
paddingLeft: extendClickWidth,
paddingBottom: `calc(${token('spacing.editablePaddingBottom')} + ${extendClickHeight + 2}px)`,
width,
/* Ensuring the cursor overlay will always show behind the original bullet component.
We want to avoid a situation where the active thought can’t be dragged and dropped */
zIndex: cursorOverlay ? 0 : 1,
}}
{...(!cursorOverlay && fastClick(clickHandler, { enableHaptics: false }))}
{...longPressProps}
>
<svg
className={cx(
glyph({ isBulletExpanded, showContexts, leaf }),
css({
// Safari has a known issue with subpixel calculations, especially during animations and with SVGs.
// This caused the bullet slide animation to end with a jerky movement.
// By setting "will-change: transform;", we hint to the browser that the transform property will change in the future,
// allowing the browser to optimize the animation.
willChange: 'transform',
// run grow animation on pin activation
animation: isThoughtPinned ? 'bulletGrow {durations.fast} ease-out' : undefined,
...(isHighlighted
? {
fillOpacity: 1,
fill: 'highlight',
stroke: 'highlight',
}
: null),
}),
)}
viewBox='0 0 600 600'
style={{
height: lineHeight,
width: lineHeight,
marginLeft: -lineHeight,
// required to make the distance between bullet and thought scale properly at all font sizes.
left: lineHeight * 0.317,
// Center the bullet on the midpoint of the text's x-height band, the optical center of the thought text.
// vertical-align resolves that midpoint from the font the platform actually uses, so the bullet stays
// centered on the text whichever font is substituted for Helvetica.
verticalAlign: 'middle',
// Allow multi-digit ordered-list numbers (=bullet/Ordered) to extend left into the indent gap
// instead of being clipped to the bullet's square viewport. Circle/triangle glyphs stay within
// the viewBox, so this does not affect normal bullets.
overflow: 'visible',
}}
ref={ref}
>
{children}
</svg>
</span>
)
},
)
BulletPositioner.displayName = 'BulletWrapper'
export default BulletPositioner