diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 373ec70..17dfd97 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -1528,6 +1528,9 @@ const adjustTextareaSize = () => { return; } const target = root; + const previousScrollTop = target.scrollTop; + const selectionShouldStayVisible = + document.activeElement === target || target.contains(document.activeElement); target.style.height = 'auto'; const computedStyle = window.getComputedStyle(target); const lineHeight = parseFloat(computedStyle.lineHeight || '20') || 20; @@ -1537,11 +1540,58 @@ const adjustTextareaSize = () => { const multiline = targetHeight > lineHeight * 1.4; applyLineMetrics(lines, multiline); target.style.height = `${targetHeight}px`; + keepEditorSelectionVisible(target, { + previousScrollTop, + selectionShouldStayVisible, + padding: Math.max(4, lineHeight * 0.3) + }); nextTick(() => { emitComposerHeight(); }); }; +const keepEditorSelectionVisible = ( + target: HTMLElement, + options: { previousScrollTop: number; selectionShouldStayVisible: boolean; padding: number } +) => { + const maxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight); + if (maxScrollTop <= 0) { + target.scrollTop = 0; + return; + } + + if (!options.selectionShouldStayVisible) { + target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop)); + return; + } + + requestAnimationFrame(() => { + const instance = editor.value; + if (!instance) { + target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop)); + return; + } + + try { + const caretRect = instance.view.coordsAtPos(instance.state.selection.head); + const targetRect = target.getBoundingClientRect(); + const padding = options.padding; + const currentMaxScrollTop = Math.max(0, target.scrollHeight - target.clientHeight); + let nextScrollTop = target.scrollTop; + + if (caretRect.bottom > targetRect.bottom - padding) { + nextScrollTop += caretRect.bottom - targetRect.bottom + padding; + } else if (caretRect.top < targetRect.top + padding) { + nextScrollTop -= targetRect.top + padding - caretRect.top; + } + + target.scrollTop = Math.min(currentMaxScrollTop, Math.max(0, nextScrollTop)); + } catch { + target.scrollTop = Math.min(maxScrollTop, Math.max(0, options.previousScrollTop)); + } + }); +}; + const onInput = (event: Event) => { const target = event.target as HTMLTextAreaElement; if (!props.isConnected || props.inputLocked) {