2015년 6월 10일 수요일

contenteditable을 사용한 HTML WYSWYG 편집기 제작

contenteditable은 말 그대로 해당 tag의 content를 수정가능한 상태로 만드는 태그인데

https://medium.com/medium-eng/why-contenteditable-is-terrible-122d8a40e480
사실 WYSWYG이 이 글대로 쉬운 작업은 아니다.

최종적으로 Meteor의 reactive data 연동하는 것까지를 목표로 잡아보자.

일단 div[contenteditable=true]를 하나 만들고 테두리를 제거하기 위해

[contenteditable=true] {
  outline: none;
}

를 css에 잡자.
contenteditable의 테두리는 레이아웃을 깨뜨리지 않기 때문에 border를 사용하지 않고 있다는 걸 알 수 있다.

하고보니 버튼 등등이 눌렀을 때 테두리가 생기는 것도 싫어서
* {
  outline: none;
}

[contenteditable=true] {
  outline: solid 1px #cbcbcb;
  padding: 0.5em;
}

이렇게 일단 해보자.

빠르게 버튼을 만들어서 기능을 더해보는데

대부분의 기능은 https://developer.mozilla.org/en-US/docs/Web/API/document/execCommand#Commands 에서 구현할 수 있다.

Bold, 정렬 이런 건 괜찮은데 image가 제일 중요.
손을 좀 건드려야하는데
그 이유는 이미지 실제는 항상 엄청 크기가 큰게 들어올 수 있기 때문에 아무래도 불편하다.
크기를 지정해서 해주면 좋은데 그러려면 그냥 insertImage를 쓰면 안되고
해당 커맨드가 들어왔을 때 createElement를 통해서 image 객체를 만들고 현재 커서 위치에 넣도록 하는게 좋다.

  insertImage : (url)->
    console.log "#{url} insert"
    img = document.createElement 'img'
    img.setAttribute 'src', url
    img.setAttribute 'style', 'width: 50px;'
    if window.getSelection
      sel = window.getSelection()
      if sel.getRangeAt and sel.rangeCount
        range = sel.getRangeAt(0)
        range.deleteContents()
        range.insertNode img

window.getSelection이 있음 현재 위치를 range로 잡고 insertNode에 img 객체를 밀어넣는 식.

이것저것 필요한 거 넣고 link랑 image 주소도 넣어서 일단 여기까지.

#selector
@s=(q)-> document.querySelector q
@ss=(q)-> document.querySelectorAll q

@util=
  insertImage : (url)->
    console.log "#{url} insert"
    img = document.createElement 'img'
    img.setAttribute 'src', s("input.url").value
    img.setAttribute 'style', 'width: 50px;'
    @insertObject img
  insertObject : (obj)->
    #TODO : fuck IE?
    if window.getSelection
      sel = window.getSelection()
      if sel.getRangeAt and sel.rangeCount
        range = sel.getRangeAt(0)
        range.deleteContents()
        console.log 'insert'
        range.insertNode obj

# onContentLoaded
document.addEventListener "DOMContentLoaded", (event)->
  for obj in ss(".cmd")
    obj.addEventListener "click", (event)->
      param = event.target.getAttribute "data-param"
      cmd = event.target.getAttribute "data-cmd"
      if cmd is 'insertImage'
        util.insertImage(param)
      else if cmd is 'createLink'
        document.execCommand 'createLink', false, s("input.url").value
      else
        document.execCommand cmd, false, param
      # fix a style of blockquote
      if cmd is 'indent'
        obj.removeAttribute('style') for obj in ss("#editor>blockquote")

createLink 같은 경우는 target="_black"를 해야할 것 같은데 역시 range.deleteContents()를 제거하고 해야할 것 같다.

이제 중요한 건 어떻게 reactive랑 관계를 생각해봐야하는데
정확하게 보면 편집 중인 대상은 외부에서 변경하지 말아야하는데 상태를 collection에 저장하는게 나을 것 같다.

하다보니 디테일이 꽤 늘어났다. 계속 정리해야;

보다보니 대충 중요한 이슈는 이정도다.

  • Link 와 같이 특정 영역에서 커맨드를 날린 후 별도의 입력값을 받아야하는 경우 Selection(선택영역)이 사라져버리기 때문에 포커스가 이동하기전 Range를 저장하고 입력값을 받고 다시 선택영역을 복구해야한다.
  • 실제로 execCommand가 쓸모없는 경우도 많다. a tag 같은 경우엔 target을 지정해야하는 경우도 있고 image 경우도 마찬가지. 선택영역을 특정 Element로 감싸는 구현도 매우 필요함.
  • 요소를 삽입 후 커서의 위치를 삽입한 요소 다음으로 놓고자 할때 선택영역을 재조정해야한다.
Selection과 Range에 대한 설명을 잘 살펴보면서 구현을 하나하나 해보자.

먼저 선택 영역을 보존하는 것은
range = window.getSelection().getRangeAt(window.getSelection().rangeCount-1);
이런 식으로 Range 객체를 보존한다. document.get
rangeCount는 영역이 없으면 0, 있으면 보통 1이지만 스크립트에서 1이상을 줄 수도 있긴 하다. 오류를 피하기 위해 rangeCount가 있는지 미리 검사하는 걸 잊지 말자.

그 다음으로 복원인데 반드시 기존에 Range들을 모두 제거하고 위에서 저장한 영역을 선택하게 하자.
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
선택영역의 보존/복원이 아마 가장 중요한 테크닉이지 않을까 싶다.

좀 더 깊게 들어가서 Element를 감싸는 경우는 다소 섬세한 조작이 필요하다.
  1. range를 저장한다.
  2. 감쌀 Element를 document.createElement(tagname) 으로 생성한다. link일 경우 element=document.createElement('a')
  3. 생성한 Element에 range.extractContents() 로 뽑아낸 선택영역을 appendChild로 붙여넣는다. 이때 range의 종단점이 사라진다. 주의!
    element.appendChild(range.extractContents()) 와 같이 구현.
  4. insertNode로 변경한 Element를 삽입한다.
    range.insertNode(element);
  5. 3,4를 range.surroundContents(element); 로 한번에 구현할 수도 있다. 대신 IE용 polyfill 구현체는 아직 없는 것으로 보인다.
여기까지 한 뒤 이전에 언급한 removeAllRanges()와 addRange를 사용해 복원하자.
그러면 커서는 삽입한 새로운 Element바로 앞을 가르키고 있을텐데
만일 뒤로 보내고 싶다면 insertNode 직후
range.setStartAfter(window.getSelection().focusNode.nextSibling)
이렇게 현재 선택지점의 다음 요소(nextSibling)를 range의 시작점으로 삼는다.

Medium에선 되지만 다른 라이브러리들에서 잘 안되는 기능 중 하나가
중첩 link나 highlight 같은 기능이다.
즉, 링크가 걸린 지점을 포함한 부분에 또 다시 링크를 걸 경우인 예외상황인데 Medium과 같은 경우는 기본적으로 unlink기능을 사용한다.
(해보니까 medium의 경우 겹친 부분이 unlink였다가도 창전환 후 돌아오면 다시 link로 아이콘이 보인다. 그래도 작동은 unlink로 정삭동작!)

이 경우 고려해야할 사항은 이렇다.
먼저 선택한 영역에 있는 node들이 어떤 것이 있는지를 조사한다.
startContainer와 endContainer의 parentNode 를 보자
scan 순서는 startContainer에서부터 nextSibling 을 반복하여 그것이 endContainer와 같을때까지 루프하면 된다.

테스트 케이스를 작성해보면

aaa bbb ccc -> unlink 가능
aaa bbb ccc -> unlink 시 aa는 여전히 링크로 남음
aaa bbb ccc -> unlink 시 cc는 여전히 링크로 남음
aaa bbb ccc -> 복합 케이스