Web & PWA

TEXTAREA Autosizing Scroll Jumping 버그 해결

제임스는거위 2021. 8. 17. 19:51

본인은 앵귤러로 개발중임.
현재 작업중인 프로젝트는 텍스트 편집기에서 여러 개의 <textarea>가 동적으로 추가되면서 트위터와 같은 스레드를 생성하는 것을 목표로 함.

이전에는 Material 의 TextareaAutosize를 활용해서 글을 작성함에 따라 textarea 의 height가 동적으로 변경되게끔 하고 있었음. (하단 링크 참고)
https://material.angular.io/cdk/text-field/overview

 

Angular Material

UI component infrastructure and Material Design components for Angular web applications.

material.angular.io

 

버그 상황


그러던 중 정말 골치 아픈 버그를 만났으니...
1) 만약 textarea 에 Autosizing 기능이 달려 있고
2) textarea의 height가 100vh를 초과할 경우에
3) text입력 중 spacebar 를 누르면 스크롤이 이리저리(특히 textarea의 제일 윗 부분으로) Jumping 하는
현상이었다.
참고로 iOS에서는 이런 버그가 없었고, Android와 Chrome Desktop에서는 재현 가능했다. Safari나 Firefox 등 다른 환경에서는 테스트해보지 않았다. 다만 구글링 도중 Firefox에서도 해당 문제가 발생함을 확인할 수 있었다.
(증상은 아래 링크 타고 들어가면 확인해 볼 수 있음. 프레임워크가 다르긴 한데 증상은 같더라)https://github.com/vuetifyjs/vuetify/issues/5314

 

[Bug Report] Textarea with auto-grow scrolls to top when pressing space bar · Issue #5314 · vuetifyjs/vuetify

Versions and Environment Vuetify: 1.2.9 Vue: 2.5.17 Browsers: Android Chrome 69.0.3497.100 OS: Android 8.1 Steps to reproduce Open the codepen. Enter Text until the text area is bigger than the scr...

github.com

깃허브 Bug Report 갈무리 화면

원인


일반적인 textarea의 autosizing기능은 다음과 같이 구현된다.

1) textarea에 input이 발생할 때마다
2) textarea의 height을 1px로 초기화
3) textarea의 scrollHeight을 불러옴
4) textarea의 height를 scrollHeight로 설정

문제는 height를 1px로 초기화하는  지점에서 발생한다.
글이 입력할 때마다 (우리가 인지하지 못하는 짧은 시간동안) element의 height가 1px이 되었다가 scrollheight만큼 다시 커지는게 반복되는 셈.
안드로이드와 크롬에서는 그 과정의 연산이 "충분히 빠르게"이루어지지 않아서 현재 편집중인 (focus가 되어있는) 위치에 스크롤이 고정되지 못하고 height가 원래 높이로 바뀌기 전 초기화된 위치로 옮겨지는 것이었다.

해결방안


1) 제일 간단한 해결법(완벽하지 않음)

역시 문제가 발생하는 지점을 그냥 지워버리면 제일 편하다.
즉, textarea의 height를 1px로 초기화하지 않고 scrollHeight를 곧바로 height에 적용하는 것.
그러나 이 경우에는 문제가 발생하는데, 높이가 동적으로 커지기는 하는데 반대로 줄어들지는 않는 것이다.
예를 들어 글을 작성하다가 줄바꿈(\r\n)을 입력했다가 다시 이를 지우면 확장된 높이만큼 (실제로 텍스트가 입력되어있지 않음에도) 남아있게 된다.
하지만 사용자가 글을 썼다 지웠다를 반복할 애플리케이션이 아니라면 (혹은 그정도는 용일할만한 수준의 자잘한 input을 받는 애플리케이션이라면) 가장 편한 방법이다.

+)
보통의 경우 height style을 직접 입력하지만, scrollheight를 line height로 나눠 textarea태그의 rows property에 바인딩할 수도 있다.
Angular는 ElementRef와 Renderer2를 활용해 height style을 입력해줘야 한다. 나의 경우에는 프로젝트 특성 상 textarea에 값이 미리 입력되어 있는 경우가 많아, 이 방식을 활용하려면 처음 뷰가 켜질 때 angular lifecycle hook 인 NgAfterViewInit(또는 ngAfterViewChecked)에 해당 작업을 수행하는 함수를 넣어줘야 한다. 또 각 textarea의 (ngmodelchanges)에 같은 함수를 바인딩해줘야 한다.
다만 이 경우에 해당 함수가 invoke되는 시점을 정확하기 예측하기 어렵다는 문제가 있었다. 예컨대 ngAfterViewInit의 경우는 SPA 특성상 다른 페이지를 보다가 라우팅으로 돌아오면 invoke되지 않는 경우가 많고 ngAfterViewChecked는 너무 자주 invoke되어서 성능에 악영향을 줄 거라 판단했다.
따라서 특정 textarea를 편집할 때 해당 textarea의 property 값만 변경되게끔 하고자 rows를 계산하는 함수를 만들어 rows property에 바인딩했다.


2) 조금 과하지만 완벽한 해결법

아래 링크를 참고했다.
https://stackoverflow.com/questions/28905965/textarea-how-to-count-wrapped-lines-rows

 

textarea - how to count wrapped lines/rows

I need some function That will count rows ( i know that on stackoverflow there are more than hundreds of these questions) but in my case i need to count them even when there is no end of line (mean...

stackoverflow.com



textarea의 높이가 초기화되었다가 다시 커지는 것이 문제라면, 사용자의 눈에 보이지 않는(height=0) mirror element를 만들어서 해당 element의 scrollheight 를 변경하려는 textarea의 height에 적용하면 된다는 개념이다.

즉,
1) 사용자의 눈에 보이지 않는 mirror element를 하나 더 만들어서
2) 해당 mirror element의 style을 autosizing해야하는 textarea와 똑같이 설정하고
3) textarea의 값이 변경될 때마다 mirror에 같은 내용을 innerHTML로 입력해서
4) mirror element의 scrollheight 값을 textarea의 사이즈로 설정
하는 방법이다.

이 때 mirror element 의 height를 제외한 각종 속성들(이를테면 padding 따위)은 모두 사용자가 텍스트를 입력할 textarea 와 동일해야 한다. (사이즈가 달라지면 측정값에 오차가 생기므로)

나의 경우엔 rows property를 변경해야 했으므로 scrollHeight를 line height로 나눠 반환하는 계산을 추가했다.
또, 뷰에 미리 mirror textarea를 만들고 스타일도 미리 다 설정해서, 불필요하게 appendChild하고 setStyle 하는 과정을 없앴다.
따라서 뷰에 바인딩 된 함수에는 사실상 textarea에 입력된 값을 innerHTML로 mirror textarea에 입력하고, 높이 계산해 반환하게끔 해둔 셈.

 

참고를 위해 Angular 에서 작성한 코드의 일부를 같이 남겨둔다. 

아마 일반적인 웹 개발을 할 때에는 위에 첨부한 링크에 나온 방식대로 해야하지 않을까 싶다.

 

View ↓

<div class="list" #listContainer>
 <div class="mirror-container">
 <!--mirror textarea-->
  <textarea class="mirror-textarea" #mirrorContainer>
  </textarea>
 </div>
 <div class="block" *ngFor="let block of blocksList">
  <!--사용자가 실제로 텍스트를 작성하는 textarea-->
  <textarea wrap="hard" [rows]="getNumberOfLines(block.content)"
  class="blockForm"></textarea>
 </div>
</div>

 

 

Component ↓

@ViewChild('mirrorContainer',{static:false}) private mirrorContainer: ElementRef;

getNumberOfLines(text){
  //binding 된 text 값 입력
  this.renderer.setProperty(this.mirrorContainer.nativeElement,'innerHTML',text);
  
  //scrollHeight 를 lineHeight 로 나눈 값 반환
  return this.mirrorContainer.nativeElement.scrollHeight/25;
}

 

결론


빌어먹을 프론트엔드. 이건 코드의 문제가 아니라 애플리케이션이 동작하는 환경 자체의 문제라 애를 많이 먹었다.
현재로서는 이쁜 방법을 찾기 힘든데,
1) mirror element 없이 css의 height style를 변경하는 것은 height값을 줄이는 기능을 포기하지 않는 이상 필연적으로 버그를 수반하고
2) rows property를 직접 계산하는 것은 textarea css의 white space wrap 으로 줄바꿈이 일어나는 것을 잡아낼 방법이 없기 때문이다.
(만약 엔터 입력에 따라서만 rows가 추가되게 하고 싶다면 이것도 방법이다. 하지만 사용자 입력이 textarea의 가로 길이를 넘지 않으리라는 장담을 할 수 있나? css의 white-space property에 의해 생성된 line break들은 \r\n 따위의 개행문자가 없어 정규식으로 줄 수를 세는 것이 불가능하다.)