Skip to content
Open
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
1 change: 0 additions & 1 deletion panda.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,6 @@ export default defineConfig({
modalPadding: { value: '8%' },
safeAreaTop: { value: 'env(safe-area-inset-top)' },
safeAreaBottom: { value: 'env(safe-area-inset-bottom)' },
editableClipBottom: { value: '0.1em' },
},
/* z-index schedule
Keep these in one place to make it easier to determine interactions and prevent conflicts. */
Expand Down
60 changes: 58 additions & 2 deletions src/components/Editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import addEmojiSpace from '../util/addEmojiSpace'
import containsURL from '../util/containsURL'
import ellipsize from '../util/ellipsize'
import equalPath from '../util/equalPath'
import getNodeOffsetForVoidArea from '../util/getNodeOffsetForVoidArea'
import haptics from '../util/haptics'
import head from '../util/head'
import isDivider from '../util/isDivider'
Expand Down Expand Up @@ -525,6 +526,20 @@ const Editable = ({
[value, setCursorOnThought],
)

/** Sets the caret position for a void area tap. */
const setVoidAreaCaret = useCallback(
(nodeOffset: number) => {
// Update Redux cursor state
dispatch(
setCursor({
path,
offset: nodeOffset,
}),
)
},
[dispatch, path],
)

const onMouseDown = useCallback(
(e: React.MouseEvent) => {
// If CMD/CTRL is pressed, don't focus the editable.
Expand All @@ -545,7 +560,19 @@ const Editable = ({
bottomMargin: fontSize * 2,
})

allowDefaultSelection()
// Handle void area detection
const nodeOffset = getNodeOffsetForVoidArea(contentRef.current, {
clientX: e.clientX,
clientY: e.clientY,
})
// nodeOffset is null if the tap is on a valid character
// in that case, allow the default selection
if (nodeOffset) {
e.preventDefault()
setVoidAreaCaret(nodeOffset)
} else {
allowDefaultSelection()
}
}
// There are areas on the outside edge of the thought that will fail to trigger onTouchEnd.
// In those cases, it is best to prevent onFocus or onClick, otherwise keyboard is open will be incorrectly activated.
Expand All @@ -555,9 +582,38 @@ const Editable = ({
e.preventDefault()
}
},
[contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor],
[contentRef, editingOrOnCursor, fontSize, allowDefaultSelection, hasMulticursor, setVoidAreaCaret],
)

// Manually attach touchstart listener with { passive: false } to allow preventDefault
useEffect(() => {
const editable = contentRef.current
if (!editable) return
/** Handle touch events to prevent initial cursor jump. */
const handleTouchStart = (e: TouchEvent) => {
if (editingOrOnCursor && !hasMulticursor && e.touches.length > 0) {
const nodeOffset = getNodeOffsetForVoidArea(editable, {
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY,
})
// nodeOffset is null if the tap is on a valid character
// in that case, allow the default selection
if (nodeOffset) {
e.preventDefault()
setVoidAreaCaret(nodeOffset)
} else {
allowDefaultSelection()
}
}
}

editable.addEventListener('touchstart', handleTouchStart, { passive: false })

return () => {
editable.removeEventListener('touchstart', handleTouchStart)
}
}, [contentRef, editingOrOnCursor, hasMulticursor, allowDefaultSelection, setVoidAreaCaret])

/** Sets the cursor on the thought on touchend or click. Handles hidden elements, drags, and editing mode. */
const onTap = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
Expand Down
11 changes: 2 additions & 9 deletions src/e2e/puppeteer/__tests__/editable-gap.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
// import { ElementHandle } from 'puppeteer'
import { token } from '../../../../styled-system/tokens'
import { DEFAULT_FONT_SIZE } from '../../../constants'
import clickThought from '../helpers/clickThought'
import getEditable from '../helpers/getEditable'
import getEditingText from '../helpers/getEditingText'
import getSelection from '../helpers/getSelection'
import paste from '../helpers/paste'
import { page } from '../setup'

// Calculate the clip height from the PandaCSS token to ensure we stay in sync with editable.ts and convert it to pixels
const CLIP_HEIGHT = parseFloat(token('spacing.editableClipBottom')) * DEFAULT_FONT_SIZE

vi.setConfig({ testTimeout: 20000, hookTimeout: 20000 })

/** Helper function to check if caret is positioned in the middle of a thought (not at beginning or end). */
Expand Down Expand Up @@ -49,8 +43,7 @@ async function testClickBetweenThoughts(thought1: string, thought2: string) {
}

// Calculate overlap (expected to be negative due to intentional overlap)
// Account for clipHeight which clips from the bottom of the first thought
const firstThoughtBottom = rect1.y + rect1.height - CLIP_HEIGHT
const firstThoughtBottom = rect1.y + rect1.height
const secondThoughtTop = rect2.y

// Note: negative = overlap
Expand All @@ -60,7 +53,7 @@ async function testClickBetweenThoughts(thought1: string, thought2: string) {
const clickX = rect1.x + rect1.width / 2

// Test 1: Click in top edge of the overlap zone (should be in first thought)
await page.mouse.click(clickX, firstThoughtBottom + overlapHeight)
await page.mouse.click(clickX, firstThoughtBottom + overlapHeight - 1)

expect(await isSelectionLost()).toBe(false)
expect(await getEditingText()).toBe(thought1)
Expand Down
2 changes: 0 additions & 2 deletions src/hooks/useSizeTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ const useSizeTracking = () => {
key: string
}) => {
if (height !== null) {
// To create the correct selection behavior, thoughts must be clipped on the top and bottom. Otherwise the top and bottom 1px cause the caret to move to the beginning or end of the editable. But clipPath creates a gap between thoughts. To eliminate this gap, thoughts are rendered with a slight overlap by subtracting a small amount from each thought's measured height, thus affecting their y positions as they are rendered.
// See: clipPath in recipes/editable.ts
const lineHeightOverlap = fontSize / 8
const heightClipped = height - lineHeightOverlap

Expand Down
2 changes: 0 additions & 2 deletions src/recipes/editable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ const editableRecipe = defineRecipe({
margin: '-0.5px calc(18px - 1em) 0 calc(1em - 18px)',
/* create stacking order to position above thought-annotation so that function background color does not mask thought */
position: 'relative',
/* Prevent the selection from being incorrectly set to the beginning of the thought when the top edge is clicked, and the end of the thought when the bottom edge is clicked. Instead, we want the usual behavior of the selection being placed near the click. */
clipPath: 'inset(0.001px 0 token(spacing.editableClipBottom) 0)',
wordBreak: 'break-word' /* breaks urls; backwards-compatible with regular text unlike break-all */,
marginTop: {
/* TODO: Safari only? */
Expand Down
8 changes: 4 additions & 4 deletions src/serviceWorkerRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@

const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
)

type Config = {
Expand Down
Loading