ProseMirror Guide (6)

정현석·2020년 12월 5일
0

ProseMirror

목록 보기
6/7

The view component

프로즈미러 에디터 view는 ui 컴포넌트이며, editor state를 유저에게 보여준다. 그리고 이를 이용해 editing action을 할 수 있다.

core view component에 의해 사용되는 editing action의 정의는 좁은 의미를 가진다. editing surface와의 직접적인 상호작용 - 타이핑, 클릭, 카피, 페이스트, 드래깅 정도만. 이는 메뉴 표시나 키 바인딩, core view component의 범위 밖에 있는 것들은 플러그인을 통해 해결해야 한다는 것이다.

Editable DOM

브라우저는 DOM의 일부를 editable하게 명시하도록 허용해준다. 이는 focus와 selection을 가능케하고, 여기에 타이핑을 할 수 있게 한다. view는 document를 DOM representation하고(스키마의 toDOM 메소드를 기본으로 사용하여), 편집가능하게한다. 편집가능한 요소가 focused되었을 때 프로즈미러는 DOM 셀렉션이 editor state의 selection과 일치하게 만든다.

이는 또한 많은 DOM 이벤트에 대해 event 핸들러를 등록하는데, 이는 이벤트를 적절한 transaction으로 번역하기도 한다. 예를 들어, pasting에 대해서는 붙여넣어진 컨텐트가 프로즈미러 document slice로 파싱되고, document에 삽입된다.

많은 이벤트는 또한 그 자체로 행해진 다음, 프로즈미러의 자료구조로 번역되기도 한다. 브라우저는 커서나 selection placement에 대해서는 매우 좋다(bidirectional text에서는 매우 어려운 문제인). 그렇기 때문에 대부분의 커서 모션관련된 keys와 mouse action은 브라우저에 의해 다뤄지고, 이후에 프로즈미러가 어떤 text selection이 현재의 DOM selection과 연관되는지를 파악한다. 만약 그 selection이 현재의 selection과 다르다면, 트랜잭션이 selection을 업데이트한다.

타이핑이 브라우저의 역할이긴 하지만, 이걸 방해하는 것은 spell-checking이나 autocapitalizing과 같은 몇몇 모바일 인터페이스를 해친다. 브라우저가 DOM을 업데이트 하면, 에디터가 알아차리고, 그 변화를 다시 document의 일부로 파싱하고, 변화를 transaction으로 번역한다.

Data flow

그렇기 때문에 에디터 view는 주어진 editor state를 표시하고, 무언가가 일어나면 transaction을 만들어 이를 broadcast한다. 이 트랜잭션은 그리고 나서 새로운 state를 만들고, 이것이 다시 updateState를 통해 view로 주어진다.

이는 스트레이트 포워드한, cyclic data flow를 만들고, 이는 클래식한 접근방법과 대치된다(JavaScript 세계에서는).

트랜잭션은 인터셉트하는 것이 가능하고, 더 큰 cycle로 보내기 위해서 이는 dispatchTransaction prop을 통해 가능하다. 만약 전체 앱이 이러한 data flow를 가지고 있다면, Redux나 비슷한 아키텍쳐와 같은, 여러분은 프로즈미러의 트랜젝션을 여러분 자신의 메인 action-dispatching cycle을 만들 수도 있다. 그리고 프로즈미러의 state를 여러분의 어플리케이션 저장소에 저장하는 식으로.

// The app's state
let appState = {
  editor: EditorState.create({schema}),
  score: 0
}

let view = new EditorView(document.body, {
  state: appState.editor,
  dispatchTransaction(transaction) {
    update({type: "EDITOR_TRANSACTION", transaction})
  }
})

// A crude app state update function, which takes an update object,
// updates the `appState`, and then refreshes the UI.
function update(event) {
  if (event.type == "EDITOR_TRANSACTION")
    appState.editor = appState.editor.apply(event.transaction)
  else if (event.type == "SCORE_POINT")
    appState.score++
  draw()
}

// An even cruder drawing function
function draw() {
  document.querySelector("#score").textContent = appState.score
  view.updateState(appState.editor)
}

Efficient updating

updateState를 구현하는 한가지 방법은, 단순히 document가 불러질 때 마다 redraw하는 것이다. 그러나 큰 document에 대해서는 이게 굉장히 느리다.

그렇기 때문에, update할 때 view가 old document와 new document 양쪽에 접근할 수 있어야 하고, 둘을 비교해 변화되지 않은 노드만을 남기고 업데이트 한다. 프로즈미러는 이런방식으로 업데이트 하기 때문에 큰 비용이 들지 않는다.

몇몇 경우에, 업데이트가 typed text같은 경우, 이는 브라우저 스스로의 편집 action에 의해 DOM에 추가되고, 이는 DOM과 state가 연관성이 깊고, 이것이 어떠한 DOM change를 요구하지 않는다(이러한 트랜잭션이 취소되거나 수정될 때, view는 DOM 변화를 취소함으로써 DOM과 state가 싱크되게 한다).

비슷하게, DOM 셀렉션은 state의 셀렉션과 out of sync일 때만 업데이트 한다. 이는 브라우저가 셀렉션과 함께 보관하는 다양한 hidden state를 방해하는것을 막는다.(방향키를 위아래로 눌러 짧은 라인을 넘어갈 때, 수직 position이 다음 글을 입력하기 위한 이전 위치로 돌아가는 경우 등?)

Props

Props는 매우 유용하다. 이게 모호하게 들릴 수 있는데, 이는 React에서 따온 것이다. Props는 UI component의 파라미터이다. 이상적으로, props의 셋은 컴포넌트의 동작을 완전히 정의한다.

let view = new EditorView({
  state: myState,
  editable() { return false }, // Enables read-only behavior
  handleDoubleClick() { console.log("Double click!") }
})

이러한 경우, 현재의 state는 하나의 prop이다. 다른 prop의 값은 시간에 따라 달라질 수 있다. 만약 컴포넌트를 제어하는 코드가 그들을 업데이트한다면, 그러나 이것이 state로 간주되진 않는다, 왜냐하면 컴포넌트 그자체는 그들을 변화시키지 않기 때문이다. updateState 메소드는 state prop을 변화시키기 위한 약칭이다.

플러그인은 또한 props를 정의할 수 있지만, state와 dispatchTransaction은 예외다. 이들은 view에 직접적으로 제공되는 유일한 것들이다.

function maxSizePlugin(max) {
  return new Plugin({
    props: {
      editable(state) { return state.doc.content.size < max }
    }
  })
}

주어진 prop이 여러번 정의된다면, 작동방식은 prop에 달려있다. 일반적으로 직접적으로 prop을 제공하는 것은 우선순위가 있고, 각자의 플러그인이 자신의 순서를 갖는다. domParser와 같은 props은 처음 발견된 값이 사용되고, 다른 값은 무시된다. 이벤트를 관리하는지에 대한 boolean을 리턴하는 handler function은 true를 리턴하는 첫번째 것이 이벤트를 관리한다. 마지막으로 몇몇 props은, attributees와 같은(이는 editable DOM 노드의 attributes를 세팅하기 위해 사용됨) 그리고 decoration은(이는 다음 섹션에서 다룬다), 모든 제공된 값을 합하여 사용한다.

Decorations

Decoration은 view가 당신의 document를 그리는 방식에 대한 약간의 control을 제공한다. 그들은 decoration prop으로부터 값을 반환하는것으로 생성되고, 다음 세가지 타입을 갖는다.

  • 노드 decoration은 한 노드의 DOM 표현에 스타일링이나, 다른 DOM 특성을 부가한다.
  • 위젯 decoration은 DOM 노드를 추가하고, 이는 실제 주어진 위치에 대한 document 구성요소가 아니다.
  • 인라인 decoration은 스타일링과 attribute를 부가한다, 노드 데코레이션처럼, 그러나 주어진 범위에 대한 모든 노드에 대해 적용된다.

효율적으로 decoration을 그리고 비교하기 위해서는, 그들이 decoration set으로 제공되어야한다(이는 실제 document의 트리 모양을 흉내낸다). 여러분은 이는 document와 decoration object 배열을 제공하고, static create 메소드를 이용함으로써 이를 만들어낼 수 있다.

let purplePlugin = new Plugin({
  props: {
    decorations(state) {
      return DecorationSet.create(state.doc, [
        Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
      ])
    }
  }
})

데코레이션이 많은 경우, 매 redraw마다 새로 set을 만드는것은 너무 비싸다. 이러한 경우 데코레이션 유지를 위해 추천되는 방식은, set을 플러그인의 state에 넣는 것이며, 이를 변화를 바로 매핑하고, 필요할 때 마다 변화시키는 것이다.

let specklePlugin = new Plugin({
  state: {
    init(_, {doc}) {
      let speckles = []
      for (let pos = 1; pos < doc.content.size; pos += 4)
        speckles.push(Decoration.inline(pos - 1, pos, {style: "background: yellow"}))
      return DecorationSet.create(doc, speckles)
    },
    apply(tr, set) { return set.map(tr.mapping, tr.doc) }
  },
  props: {
    decorations(state) { return specklePlugin.getState(state) }
  }
})

이 플러그인은 그 state를 노란 배경 인라인 데코레이션을 매 4번째 위치마다 세팅하도록 decoration set initialize한다. 이는 아주 쓸모없지 않지만, 검색 결과 하이라이팅이나 annotated regions와 비슷한 사례같다.

트랜잭션이 state에 적용될 때, 플러그인 state의 apply method는 데코레이션 셋을 바로 적용하고, 데코레이션이 제 위치에 새로운 document shape에 딱 맞도록 한다. 매핑 메서드는(일반적으로, local changes) 트리 모양의 decoration set을 이용하여 효율적으로 구현된다 - 변경되는 트리의 일부만이 새로 만들어진다.

(실제 세계의 플러그인에서, 적용 메서드는 새로운 이벤트에 입각해 데코레이션을 추가하거나 제거하는 곳에서 발생하고, 이는 트랜잭션의 변화를 관찰함으로써, 혹은 플러그인-specific 메타데이터를 트랜잭션에 붙임으로써 가능하다.)

결과적으로, 데코레이션 prop은 단순히 plugin state를 반환하고, 데코레이션이 view에 나타나도록 한다.

Node views

에디터 view가 여러분의 document를 그리는 방식에 영향력을 미치는 방식은 한가지 뿐만이 아니다. 노드 view는 특정 종류의 miniature UI 컴포넌트를 정의함으로써 document의 개별 노드를 정의할 수 있다. 이들은 그들의 DOM을 렌더하게 하고, update방식을 정의하고, 이벤트에 반응하는 custom code를 쓰도록해준다.

let view = new EditorView({
  state,
  nodeViews: {
    image(node) { return new ImageView(node) }
  }
})

class ImageView {
  constructor(node) {
    // The editor will use this as the node's DOM representation
    this.dom = document.createElement("img")
    this.dom.src = node.attrs.src
    this.dom.addEventListener("click", e => {
      console.log("You clicked me!")
      e.preventDefault()
    })
  }

  stopEvent() { return true }
}

이 예제에서 정의하는 view 오브젝트는 이미지 노드에 대해 그 자신의 커스텀 DOM 노드를 만들게 하고, 이벤트 핸들러가 부착되며, stopEvent 메서들르 통해 프로즈미러가 해당 DOM 노드로부터 발생되는 event를 무시하도록 한다.

여러분은 어쩌면 document에서 실제로 효과가 있는 어떤 노드와 상호작용해야 할지도 모른다. 그러나 노드를 바꾸는 트랜잭션을 하기 위해서는, 노드가 어디있는지 알아야한다. 이를 돕기 위해 노드 뷰는 현재 노드의 위치를 알아내는 getter function을 전달받는다. 이제 예제를 수정해서 노드를 클릭하면 alt text를 입력하도록 해보자.

let view = new EditorView({
  state,
  nodeViews: {
    image(node, view, getPos) { return new ImageView(node, view, getPos) }
  }
})

class ImageView {
  constructor(node, view, getPos) {
    this.dom = document.createElement("img")
    this.dom.src = node.attrs.src
    this.dom.alt = node.attrs.alt
    this.dom.addEventListener("click", e => {
      e.preventDefault()
      let alt = prompt("New alt text:", "")
      if (alt) view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, {
        src: node.attrs.src,
        alt
      }))
    })
  }

  stopEvent() { return true }
}

setNodeMarkup은 주어진 위치의 노드에 대해 타입과 attribute를 바꾸기 위해 사용되는 메서드이다. 이 예제에서, 우리는 getPos를 사용하여 이미지의 현재 위치를 찾아내고, 새로운 attribute 오브젝트와 함께 새로운 alt text를 붙인다.

노드가 업데이트되면, 기본 행동은 그 외의 DOM 구조는 놔두고, 자식과 새로운 자식을 비교해 업데이트하거나 교체한다. 노드 뷰는 이 커스텀 행동으로 오버라이딩될 수 있으며, 이는 paragraph의 class를 그 content에 따라 바뀌도록 하는 걸 가능케한다.

let view = new EditorView({
  state,
  nodeViews: {
    paragraph(node) { return new ParagraphView(node) }
  }
})

class ParagraphView {
  constructor(node) {
    this.dom = this.contentDOM = document.createElement("p")
    if (node.content.size == 0) this.dom.classList.add("empty")
  }

  update(node) {
    if (node.type.name != "paragraph") return false
    if (node.content.size > 0) this.dom.classList.remove("empty")
    else this.dom.classList.add("empty")
    return true
  }
}

Images never have content, so in our previous example, we didn't need to worry about how that would be rendered. But paragraphs do have content. Node views support two approaches to handling content: you can let the ProseMirror library manage it, or you can manage it entirely yourself. If you provide a contentDOM property, the library will render the node's content into that, and handle content updates. If you don't, the content becomes a black box to the editor, and how you display it and let the user interact with it is entirely up to you.

In this case, we want paragraph content to behave like regular editable text, so the contentDOM property is defined to be the same as the dom property, since the content needs to be rendered directly into the outer node.

The magic happens in the update method. Firstly, this method is responsible for deciding whether the node view can be updated to show the new node at all. This new node may be anything that the editor's update algorithm might try to draw here, so you must verify that this is a node that this node view can handle.

The update method in the example first checks whether the new node is a paragraph, and bails out if that's not the case. Then it makes sure that the "empty" class is present or absent, depending on the content of the new node, and returns true, to indicate that the update succeeded (at which point the node's content will be updated).

profile
데이터 사이언스 공부중

0개의 댓글