Skip to content

Commit 7193035

Browse files
authored
fix(knowledge): scroll the whole chunk editor area instead of the textarea (#6300)
1 parent 2172558 commit 7193035

1 file changed

Lines changed: 48 additions & 21 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/chunk-editor/chunk-editor.tsx

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2638
interface 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

Comments
 (0)