@@ -23,6 +23,18 @@ const TOKEN_BG_COLORS = [
2323 'rgba(217, 70, 239, 0.55)' ,
2424] as const
2525
26+ /**
27+ * Collapsing to `auto` is what lets the box shrink, but it also drops the scroll range to zero
28+ * across the `scrollHeight` read, clamping the scroller to the top — so the offset is captured
29+ * and restored in the same frame.
30+ */
31+ function syncTextareaHeight ( textarea : HTMLTextAreaElement , scroller : HTMLElement ) {
32+ const { scrollTop } = scroller
33+ textarea . style . height = 'auto'
34+ textarea . style . height = `${ textarea . scrollHeight } px`
35+ scroller . scrollTop = scrollTop
36+ }
37+
2638interface ChunkEditorProps {
2739 mode ?: 'edit' | 'create'
2840 chunk ?: ChunkData
@@ -49,7 +61,7 @@ export function ChunkEditor({
4961 onCreated,
5062} : ChunkEditorProps ) {
5163 const textareaRef = useRef < HTMLTextAreaElement > ( null )
52- const tokenizedScrollRef = useRef < HTMLDivElement > ( null )
64+ const scrollRef = useRef < HTMLDivElement > ( null )
5365 const preservedScrollTopRef = useRef ( 0 )
5466 const { mutateAsync : updateChunk } = useUpdateChunk ( )
5567 const { mutateAsync : createChunk } = useCreateChunk ( )
@@ -182,22 +194,39 @@ export function ChunkEditor({
182194 [ saveRef ]
183195 )
184196
185- const hasToggledTokenizerRef = useRef ( false )
197+ const handleTokenizerChange = useCallback ( ( value : boolean ) => {
198+ preservedScrollTopRef . current = scrollRef . current ?. scrollTop ?? 0
199+ setTokenizerOn ( value )
200+ } , [ ] )
186201
187- const handleTokenizerChange = useCallback (
188- ( value : boolean ) => {
189- const source = tokenizerOn ? tokenizedScrollRef . current : textareaRef . current
190- preservedScrollTopRef . current = source ?. scrollTop ?? 0
191- hasToggledTokenizerRef . current = true
192- setTokenizerOn ( value )
193- } ,
194- [ tokenizerOn ]
195- )
202+ /**
203+ * The textarea's height is synced to its content so the surrounding container owns the only
204+ * scrollbar, as in the rich markdown editor.
205+ */
206+ useLayoutEffect ( ( ) => {
207+ const textarea = textareaRef . current
208+ const scroller = scrollRef . current
209+ if ( ! textarea || ! scroller ) return
210+ syncTextareaHeight ( textarea , scroller )
211+ } , [ editedContent , tokenizerOn ] )
212+
213+ /**
214+ * The box is `overflow-hidden`, so a width change that re-wraps lines without touching the
215+ * content would clip the tail with no scrollbar to reach it. The scroller is observed rather
216+ * than the textarea because it supplies the width without being the element the callback resizes.
217+ */
218+ useLayoutEffect ( ( ) => {
219+ const textarea = textareaRef . current
220+ const scroller = scrollRef . current
221+ if ( ! textarea || ! scroller ) return
222+ const observer = new ResizeObserver ( ( ) => syncTextareaHeight ( textarea , scroller ) )
223+ observer . observe ( scroller )
224+ return ( ) => observer . disconnect ( )
225+ } , [ tokenizerOn ] )
196226
227+ /** Must run after the measure above, which establishes the scroll range this offset needs. */
197228 useLayoutEffect ( ( ) => {
198- if ( ! hasToggledTokenizerRef . current ) return
199- const target = tokenizerOn ? tokenizedScrollRef . current : textareaRef . current
200- if ( target ) target . scrollTop = preservedScrollTopRef . current
229+ if ( scrollRef . current ) scrollRef . current . scrollTop = preservedScrollTopRef . current
201230 } , [ tokenizerOn ] )
202231
203232 const tokenStrings = useMemo ( ( ) => {
@@ -214,9 +243,10 @@ export function ChunkEditor({
214243 return (
215244 < div className = 'flex flex-1 flex-col overflow-hidden' >
216245 < div
246+ ref = { scrollRef }
217247 role = 'group'
218248 aria-label = 'Chunk content editor'
219- className = 'flex min-h-0 flex-1 cursor-text flex-col overflow-hidden '
249+ className = 'min-h-0 flex-1 cursor-text overflow-y-auto [scrollbar-gutter:stable_both-edges] '
220250 onClick = { ( e ) => {
221251 if ( e . target === e . currentTarget ) textareaRef . current ?. focus ( )
222252 } }
@@ -226,10 +256,7 @@ export function ChunkEditor({
226256 } }
227257 >
228258 { tokenizerOn ? (
229- < div
230- ref = { tokenizedScrollRef }
231- className = 'mx-auto h-full w-full max-w-[48rem] cursor-default overflow-y-auto whitespace-pre-wrap break-words px-8 py-6 font-sans text-[var(--text-body)] text-sm'
232- >
259+ < div className = 'mx-auto min-h-full w-full max-w-[48rem] cursor-default whitespace-pre-wrap break-words px-8 py-6 font-sans text-[var(--text-body)] text-sm' >
233260 { tokenStrings . map ( ( token , index ) => (
234261 < span
235262 key = { index }
@@ -255,14 +282,14 @@ export function ChunkEditor({
255282 ? 'This chunk is synced from a connector and cannot be edited'
256283 : 'Read-only view'
257284 }
258- className = 'mx-auto min-h-0 w-full max-w-[48rem] flex-1 resize-none border-0 bg-transparent px-8 py-6 font-sans text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-subtle)]'
285+ className = 'mx-auto block min-h-full w-full max-w-[48rem] resize-none overflow-hidden border-0 bg-transparent px-8 py-6 font-sans text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-subtle)]'
259286 disabled = { ! canEdit }
260287 readOnly = { ! canEdit }
261288 spellCheck = { false }
262289 />
263290 ) }
264291 </ div >
265- < div className = 'flex items-center justify-between border-[var(--border)] border-t px-6 py-2.5' >
292+ < div className = 'flex shrink-0 items-center justify-between border-[var(--border)] border-t px-6 py-2.5' >
266293 < TokenizerToggle
267294 checked = { tokenizerOn }
268295 onCheckedChange = { handleTokenizerChange }
0 commit comments