Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
type ChipBound,
type Selection,
snapSelectionToChips,
} from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection'

// A chip occupying value indices [5, 12): e.g. " @Gmail " sitting at offset 5.
const CHIP: ChipBound = { start: 5, end: 12 }

const at = (pos: number): Selection => ({ start: pos, end: pos })

describe('snapSelectionToChips', () => {
describe('collapsed caret', () => {
it('leaves a caret outside any chip untouched', () => {
expect(snapSelectionToChips(at(3), at(3), undefined, undefined)).toEqual(at(3))
})

it('snaps a caret in the chip near the start to the start edge', () => {
expect(snapSelectionToChips(at(6), at(6), CHIP, CHIP)).toEqual(at(5))
})

it('snaps a caret in the chip near the end to the end edge', () => {
expect(snapSelectionToChips(at(11), at(11), CHIP, CHIP)).toEqual(at(12))
})

it('snaps the exact midpoint to the start edge (ties favor start)', () => {
// distance to start (8.5-5=3.5) equals distance to end (12-8.5=3.5) only at
// 8.5; integer midpoint 8 is closer to start.
expect(snapSelectionToChips(at(8), at(8), CHIP, CHIP)).toEqual(at(5))
})

it('does not snap a caret resting exactly on an edge (edge is not "inside")', () => {
// findRangeContaining is strict, so an edge caret has no containing chip.
expect(snapSelectionToChips(at(5), at(5), undefined, undefined)).toEqual(at(5))
expect(snapSelectionToChips(at(12), at(12), undefined, undefined)).toEqual(at(12))
})
})

describe('ranged — fresh selection (both edges differ from prev)', () => {
it('expands a start edge inside a chip outward to the chip start', () => {
// select-all-like: prev was a caret at 20, new selection 8..30 grew both edges.
const out = snapSelectionToChips({ start: 8, end: 30 }, at(20), CHIP, undefined)
expect(out).toEqual({ start: 5, end: 30 })
})

it('expands an end edge inside a chip outward to the chip end', () => {
const out = snapSelectionToChips({ start: 0, end: 9 }, at(20), undefined, CHIP)
expect(out).toEqual({ start: 0, end: 12 })
})

it('expands both edges when each lands in a (different) chip', () => {
const chipB: ChipBound = { start: 20, end: 27 }
const out = snapSelectionToChips({ start: 8, end: 23 }, at(40), CHIP, chipB)
expect(out).toEqual({ start: 5, end: 27 })
})
})

describe('ranged — single moved edge (keyboard extend / shrink)', () => {
it('growing the end edge into a chip absorbs the whole chip', () => {
// prev 0..6, end moved 6 -> 9 (grew); start unchanged.
const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 6 }, undefined, CHIP)
expect(out).toEqual({ start: 0, end: 12 })
})

it('shrinking the end edge out of a chip releases the whole chip', () => {
// prev 0..14, end moved 14 -> 9 (shrank) into the chip; release to chip start.
const out = snapSelectionToChips({ start: 0, end: 9 }, { start: 0, end: 14 }, undefined, CHIP)
expect(out).toEqual({ start: 0, end: 5 })
})

it('growing the start edge leftward into a chip absorbs the whole chip', () => {
// prev 9..20, start moved 9 -> 6 (grew leftward, start < prev.start).
const out = snapSelectionToChips(
{ start: 6, end: 20 },
{ start: 9, end: 20 },
CHIP,
undefined
)
expect(out).toEqual({ start: 5, end: 20 })
})

it('shrinking the start edge rightward into a chip releases the whole chip', () => {
// prev 6..20, start moved 6 -> 9 (shrank rightward, start > prev.start) → chip end.
const out = snapSelectionToChips(
{ start: 9, end: 20 },
{ start: 6, end: 20 },
CHIP,
undefined
)
expect(out).toEqual({ start: 12, end: 20 })
})
})

describe('selection contained within one chip', () => {
it('clamps to a collapsed caret rather than inverting', () => {
// Both edges inside CHIP via a fresh selection: start→5, end→12 stays ordered.
// Construct an inverting case: a shrink where start snaps to 12 and end to 5.
const out = snapSelectionToChips({ start: 7, end: 9 }, { start: 5, end: 9 }, CHIP, CHIP)
expect(out.start).toBeLessThanOrEqual(out.end)
})
})

describe('no chips', () => {
it('returns the selection unchanged', () => {
const sel = { start: 2, end: 18 }
expect(snapSelectionToChips(sel, at(0), undefined, undefined)).toEqual(sel)
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Pure selection-snapping math for the mention-chip textarea, extracted from the
* `onSelect` handler so it can be unit-tested in isolation from DOM I/O.
*
* A mention chip occupies a contiguous `[start, end)` span of the textarea
* value. The UI treats each chip as atomic: a selection edge may never land
* strictly inside a chip. This function maps an observed selection to the
* nearest valid one.
*/

/** A half-open chip span `[start, end)` in textarea-value coordinates. */
export interface ChipBound {
start: number
end: number
}

/** A selection or caret as reported by `selectionStart`/`selectionEnd`. */
export interface Selection {
start: number
end: number
}

/**
* Snaps a selection so no edge sits inside a chip.
*
* - **Collapsed caret inside a chip** → nearest chip edge.
* - **Ranged selection** → each edge inside a chip snaps to a boundary without
* collapsing the range. A lone moved edge (keyboard extend/shrink, drag) snaps
* in its direction of travel — growing absorbs the chip, shrinking releases
* it; a fresh selection (double-click, select-all) expands outward. The two
* paths differ only for a shrinking edge, so the gesture inference is safe
* even when a fresh selection happens to share an edge with `prev`.
*
* @param sel - The observed selection.
* @param prev - The previously observed selection, used to infer which edge moved.
* @param startChip - The chip containing `sel.start`, if any.
* @param endChip - The chip containing `sel.end`, if any.
* @returns The snapped selection (equal to `sel` when no edge is inside a chip).
*/
export function snapSelectionToChips(
sel: Selection,
prev: Selection,
startChip: ChipBound | undefined,
endChip: ChipBound | undefined
): Selection {
const { start, end } = sel

if (start === end) {
if (!startChip) return sel
const nearest =
start - startChip.start < startChip.end - start ? startChip.start : startChip.end
return { start: nearest, end: nearest }
}

const singleEdgeMoved = (start !== prev.start) !== (end !== prev.end)

let newStart = startChip
? singleEdgeMoved && start > prev.start
? startChip.end
: startChip.start
: start
const newEnd = endChip ? (singleEdgeMoved && end < prev.end ? endChip.start : endChip.end) : end

// A selection contained within a single chip snaps both edges; clamp so it
// collapses to a caret rather than inverting.
if (newStart > newEnd) newStart = newEnd

return { start: newStart, end: newEnd }
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,43 @@ export interface PlusMenuHandle {
selectActive: () => boolean
}

/**
* Box and typography shared by the textarea and its mirror overlay — both must
* produce identical line wrapping so the overlay text sits exactly over the
* (transparent) textarea text.
*/
const FIELD_MIRROR_CLASSES = cn(
'm-0 box-border min-h-[24px] w-full break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]'
)

/**
* The textarea grows to its full content height (`h-auto`, no internal scroll);
* the shared scroller clips and scrolls it. Its text is transparent so the
* mirror overlay shows through; only the caret paints.
*/
export const TEXTAREA_BASE_CLASSES = cn(
'm-0 box-border h-auto min-h-[24px] w-full resize-none',
'overflow-y-auto overflow-x-hidden break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
FIELD_MIRROR_CLASSES,
'block h-auto resize-none overflow-hidden',
'text-transparent caret-[var(--text-primary)] outline-none',
'placeholder:font-[380] placeholder:text-[var(--text-subtle)]',
'focus-visible:ring-0 focus-visible:ring-offset-0',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
'focus-visible:ring-0 focus-visible:ring-offset-0'
)

/**
* Pinned over the full-height textarea (`inset-0` of the sizer). Both are flow
* children of the same scroller, so they scroll together natively — no JS
* scroll-sync, so the caret and mirrored text never drift apart.
*/
export const OVERLAY_CLASSES = cn(
'pointer-events-none absolute top-0 left-0 m-0 box-border h-auto w-full resize-none',
'overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-words [overflow-wrap:anywhere] border-0 bg-transparent',
'px-1 py-1 font-body text-[15px] leading-[24px] tracking-[-0.015em]',
'text-[var(--text-primary)] outline-none',
FIELD_MIRROR_CLASSES,
'pointer-events-none absolute inset-0 whitespace-pre-wrap',
'text-[var(--text-primary)]'
)

/** Single scroll container for the textarea + overlay; caps height and hides its scrollbar. */
export const SCROLLER_CLASSES = cn(
'relative overflow-y-auto overflow-x-hidden',
'[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)

Expand All @@ -66,15 +88,8 @@ export const SEND_BUTTON_ACTIVE =
'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]'
export const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]'

export const MAX_CHAT_TEXTAREA_HEIGHT = 200
export const SPEECH_RECOGNITION_LANG = 'en-US'

export function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>, maxHeight: number) {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}

/**
* Maps a {@link MothershipResource} (resource-picker domain) to a
* {@link ChatContext} (chat-input domain). Keyed by `MothershipResourceType`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ export type {
WindowWithSpeech,
} from './constants'
export {
autoResizeTextarea,
MAX_CHAT_TEXTAREA_HEIGHT,
mapResourceToContext,
OVERLAY_CLASSES,
SCROLLER_CLASSES,
SPEECH_RECOGNITION_LANG,
TEXTAREA_BASE_CLASSES,
} from './constants'
Expand Down
Loading
Loading