기본 콘텐츠로 건너뛰기

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 -> 복합 케이스


이 블로그의 인기 게시물

ESP32 DevBoard 개봉기

오늘 드디어 손에 넣었다. ESP32 DevBoard!
Adafruit 에서 15개 한정 재입고 트윗을 보고 광속 결제.
그리고 1주일의 기다림. 사랑해요 USPS <3
알리를 이용하다보니 1주일 정도는 광속 배송임.
물론 배송비도 무자비함 -_ㅜ
15개 한정판 adafruit 발 dev board
그놈이 틀림없으렸다.
오오 강려크한 포스
ESP32_Core_board_V2라고 적혀있군요.
ESP32 맞구요. 네네. ESP32-D0WDQ6 라고 써있는데 D → Dual-core 0 → No internal flash W → Wi-Fi D → Dual-mode Bluetooth Q → Quad Flat No-leads (QFN) package 6 → 6 mm × 6 mm package body size 라고 함.
길이는 이정도
모듈크기는 이정도
코어는 6mm밖에 안해! 여기에 전기만 넣으면 BLE+WIFI!
밑에 크고 발 8개 달린 놈은 FM25Q32라고 32Mbit 플래시메모리
ESP8266 DevBoard 동생이랑 비교 크고 아름다운 레귤레이터랑 CP2102 USB Driver가 붙어있음.
ESP8266 DevBoard엔 CH340G 인데 확 작아졌네.
머리를 맞대어 보았음.
모듈크기는 아주 약간 ESP32가 더 큰데 워낙에 핀이 많고 촘촘함. ESP8266인 ESP12는 핀 간격이 2.00mm인데 비해
ESP32는 1.27mm 밖에 안함.
딱봐도 비교가 될 정도.
https://www.sparkfun.com/news/2017 크고 아름다운 Pinouts

ESP8266 보드랑 별로 안달라보인다.
http://www.silabs.com/products/mcu/pages/usbtouartbridgevcpdrivers.aspx#mac
에서 CP2102 드라이버를 설치하고
screen 으로 연결해보자.
내 경우엔 tty.SLAB_USBtoUART 로 잡혔다.
어디서 기본 속도가 115200bps 라고 들은 적이 있어서
screen /dev/tty.SLAB_USBtoUART …

즐거운 Online Prototyping Tool 들

jsbin, codepen, jsfiddle 이런 것들은 일단 생략. 너무 유명한 것들이라.

https://launchpad.graphql.com - node.js 기반 graphQL 연습장. 이것만으로도 충분히 훌륭한 백엔드
https://codesandbox.io/ npm 사용이 가능한 클라이언트 사이드 연습장. webpackbin이 너무 문제가 많아서 찾아본 것.

https://scrimba.com 이건 codesandbox+ asciinema(https://asciinema.org/) 같은 느낌인데 키 녹화와 음성 녹화 기능이 추가되었다. 다 좋은데 화살표 키로 빨리감기 뒤로감기 기능이 안되고 익스포트(youtube등)으로 지원이 없는게 아쉽다.

이 둘이 만나면? https://codesandbox.io/s/jvlrl98xw3?from-embed
뭐야 이거 무서워 하지마 ㄷㄷ;  graphql+react-native-web(부왘ㅋㅋ)

https://repl.it/languages 전통을 자랑하는 REPL 도구. 지원 언어 종류가 -_-;;;;;

https://tio.run/# repl.it? 장난함? 얘는 지원 언어가 무려 386종류. J랑 아희도 있다.

https://play.golang.org/ 즐거운 go playground. 소스 포멧팅 넘 좋아.

http://decaffeinate-project.org/repl/ 최고의 coffeescript REPL. 원래 용도는 coffee를 ecma6코드로 바꾸는 것이지만...

https://scaphold.io
https://www.graph.cool/ graphql backend service. scaphold.io는 설치도 필요없는 클라우드. graphcool은 호스팅+클라우드 다있음. 둘 다 막상막하. 푸쉬서버도 되고 뭐 미친득.

https://glitch.com/ gomix에서 결국 glitch로 안착.  node.js

https://www.shadertoy.com 잘하고 싶다! 쉐이다! 오디오도 된다!

http:/…

graphql 연습 /w launchpad

https://launchpad.graphql.com/mw9wkzv99
단순 전체쿼리+조건쿼리+추가

http://graphql.org/graphql-js/passing-arguments/
참고. random ID는 crypto 1.0.1 사용
  type Query {
    Members: [member]
    getMember(id: ID!): member
  }
  type member {
    id: ID!
    text: String
  }
  input memberInput {
    text: String
  }
  type Mutation {
    addMember(member: memberInput): member
  } SQL 정의. facebook 쪽은 스트링에 지지는 거 진짜 좋아하네. *.gql 파일이 있다고 하니 이해해주자.
resolver는 var buffer = [];
const resolvers = {
  Query: {
    Members: (root, args, context) => {
      return buffer;
    },
    getMember: (id)=> {
      return buffer.find(o=>o.id)
    }
  },
  Mutation: {
    addMember(_, {member}) {
      const mm = { ...member, id:randomBytes(10).toString('hex') };
      buffer.push(mm);
      return mm;
    }
  }
}; 평범 평범.
https://dev-blog.apollodata.com/tutorial-graphql-subscriptions-server-side-e51c32dc2951 다음으로 pub/sub 연습.
https://launchpad.graphql.com/xvn94n3ql   type Subscription {
    memberAdded: member
  } member가 added되는 순간을 감시. imp…