Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

position faux caret onFocus and onChange #2800

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
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
6 changes: 6 additions & 0 deletions panda.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,18 @@ const durations = Object.entries(durationsConfig).reduce(durationsReducer, {})

const hideCaret = {
'0%': {
'--faux-caret-opacity': 0.75,
'--faux-caret-line-end-opacity': 0.75,
caretColor: 'transparent',
},
'99%': {
'--faux-caret-opacity': 0.75,
'--faux-caret-line-end-opacity': 0.75,
caretColor: 'transparent',
},
'100%': {
'--faux-caret-opacity': 0,
'--faux-caret-line-end-opacity': 0.75,
caretColor: 'auto',
},
}
Expand Down
70 changes: 68 additions & 2 deletions src/components/LayoutTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import SimplePath from '../@types/SimplePath'
import State from '../@types/State'
import Thought from '../@types/Thought'
import ThoughtId from '../@types/ThoughtId'
import { isTouch } from '../browser'
import { isSafari, isTouch } from '../browser'
import { HOME_PATH } from '../constants'
import { getBoundingClientRect, isEndOfElementNode } from '../device/selection'
import testFlags from '../e2e/testFlags'
import useSortedContext from '../hooks/useSortedContext'
import attributeEquals from '../selectors/attributeEquals'
Expand All @@ -30,6 +31,7 @@ import rootedGrandparentOf from '../selectors/rootedGrandparentOf'
import rootedParentOf from '../selectors/rootedParentOf'
import simplifyPath from '../selectors/simplifyPath'
import thoughtToPath from '../selectors/thoughtToPath'
import editingValueStore from '../stores/editingValue'
import reactMinistore from '../stores/react-ministore'
import scrollTopStore from '../stores/scrollTop'
import viewportStore from '../stores/viewport'
Expand Down Expand Up @@ -428,6 +430,10 @@ const TreeNode = ({
autofocusDepth: number
} & Pick<CSSTransitionProps, 'in'>) => {
const [y, setY] = useState(_y)
// Since the thoughts slide up & down, the faux caret needs to be a child of the TreeNode
// rather than one universal caret in the parent.
const caretRef = useRef<HTMLSpanElement | null>(null)
const editing = useSelector(state => state.editing)
const fadeThoughtRef = useRef<HTMLDivElement>(null)
const isLastActionNewThought = useSelector(state => {
const lastPatches = state.undoPatches[state.undoPatches.length - 1]
Expand All @@ -446,6 +452,50 @@ const TreeNode = ({
return lastPatches?.some(patch => deleteActions.includes(patch.actions[0]))
})

const [showLineEndFauxCaret, setShowLineEndFauxCaret] = useState(false)

// Hide the faux caret when typing occurs.
editingValueStore.subscribe(() => {
if (isTouch && isSafari() && caretRef.current) {
caretRef.current.style.display = 'none'
setShowLineEndFauxCaret(false)
}
})

// If the thought isCursor and edit mode is on, position the faux cursor at the point where the
// selection is created.
useEffect(() => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like I can get everything I need from the props. Let me know if you can think of any impact this would have on performance.

if (isTouch && isSafari() && caretRef.current) {
if (editing && isCursor) {
// The selection ranges aren't updated until the end of the frame when the thought is focused.
setTimeout(() => {
if (caretRef.current) {
const offset = fadeThoughtRef.current?.getBoundingClientRect()

if (offset) {
const rect = getBoundingClientRect()

if (rect) {
const { x, y } = rect

caretRef.current.style.display = 'inline'
caretRef.current.style.top = `${y - offset.y}px`
caretRef.current.style.left = `${x - offset.x}px`
setShowLineEndFauxCaret(false)
} else {
caretRef.current.style.display = 'none'
setShowLineEndFauxCaret(isEndOfElementNode())
}
}
}
})
} else {
caretRef.current.style.display = 'none'
setShowLineEndFauxCaret(false)
}
}
}, [editing, isCursor, path])

useLayoutEffect(() => {
if (y !== _y) {
// When y changes React re-renders the component with the new value of y. It will result in a visual change in the DOM.
Expand Down Expand Up @@ -495,6 +545,7 @@ const TreeNode = ({
className={css({
position: 'absolute',
transition,
'--faux-caret-line-end-opacity': showLineEndFauxCaret ? undefined : 0,
})}
style={{
// Cannot use transform because it creates a new stacking context, which causes later siblings' DropChild to be covered by previous siblings'.
Expand Down Expand Up @@ -551,6 +602,21 @@ const TreeNode = ({
prevWidth={treeThoughtsPositioned[index - 1]?.width}
/>
)}
<span
className={css({
color: 'blue',
display: 'none',
fontSize: '1.25em',
margin: '-6px 0 0 -2.5px',
opacity: 'var(--faux-caret-opacity)',
position: 'absolute',
pointerEvents: 'none',
WebkitTextStroke: '0.625px var(--colors-blue)',
})}
ref={caretRef}
>
|
</span>
</div>
</FadeTransition>
)
Expand Down Expand Up @@ -924,7 +990,7 @@ const LayoutTree = () => {
<div
// the hideCaret animation must run every time the indent changes on iOS Safari, which necessitates replacing the animation with an identical substitute with a different name
className={cx(
css({ marginTop: '0.501em' }),
css({ '--faux-caret-opacity': '0', marginTop: '0.501em' }),
hideCaret({
animation: getHideCaretAnimationName(indentDepth + tableDepth),
}),
Expand Down
17 changes: 16 additions & 1 deletion src/components/ThoughtAnnotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Path from '../@types/Path'
import SimplePath from '../@types/SimplePath'
import State from '../@types/State'
import { setCursorActionCreator as setCursor } from '../actions/setCursor'
import { isSafari, isTouch } from '../browser'
import { REGEX_PUNCTUATIONS, REGEX_TAGS, Settings } from '../constants'
import decodeThoughtsUrl from '../selectors/decodeThoughtsUrl'
import findDescendant from '../selectors/findDescendant'
Expand Down Expand Up @@ -231,6 +232,20 @@ const ThoughtAnnotation = React.memo(
// with real time context update we increase context length by 1 // with the default minContexts of 2, do not count the whole thought
showSuperscript ? <StaticSuperscript absolute n={numContexts} style={style} cssRaw={cssRaw} /> : null
}
<span
className={css({
bottom: '6px',
color: 'blue',
fontSize: '1.25em',
opacity: 'var(--faux-caret-line-end-opacity)',
position: 'relative',
pointerEvents: 'none',
right: '2px',
WebkitTextStroke: '0.625px var(--colors-blue)',
})}
>
|
</span>
</div>
</div>
)
Expand Down Expand Up @@ -338,7 +353,7 @@ const ThoughtAnnotationContainer = React.memo(
setCalculateContexts(true)
}, [])

return showSuperscript || url || email || styleAnnotation ? (
return showSuperscript || url || email || styleAnnotation || (isTouch && isSafari()) ? (
<ThoughtAnnotation
{...{
simplePath,
Expand Down
21 changes: 20 additions & 1 deletion src/device/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ export const isOnLastLine = (): boolean => {
/** Returns true if the browser selection is on a text node. */
export const isText = (): boolean => window.getSelection()?.focusNode?.nodeType === Node.TEXT_NODE

/** Returns true if the browser selection is on an element node and focus offset is 1.
* This represents a case where the browser will render the caret at the end of the text node's content.
*/
export const isEndOfElementNode = (): boolean => {
const selection = window.getSelection()
return !isText() && !!selection && selection.focusOffset === 1
}

/** Returns the character offset of the active selection. */
// TODO: The browser selection offset has different semantics when the selection is on a text node vs an element node. Unfortunately this function has been used indiscriminately for both cases. We should clean this up and only use the function on text nodes.
export const offset = (): number | null => window.getSelection()?.focusOffset ?? null
Expand Down Expand Up @@ -466,7 +474,18 @@ export const getBoundingClientRect = () => {
const selection = window.getSelection()

if (selection && selection.rangeCount) {
return selection.getRangeAt(0).getBoundingClientRect()
if (isText()) return selection.getRangeAt(0).getBoundingClientRect()

// If the selection is on an element node, get the bounding rect of the element minus the padding
if (selection.focusNode instanceof HTMLElement && selection.focusOffset === 0) {
const { x, y } = selection.focusNode.getBoundingClientRect()
const styles = getComputedStyle(selection.focusNode)

return new DOMRect(
x + parseFloat(styles.getPropertyValue('padding-left')),
y + parseFloat(styles.getPropertyValue('padding-top')),
)
}
}

return null
Expand Down
Loading