서식 있는 텍스트 편집기 만들기(WYSIWYG)

게시 됨: 2022-03-10
빠른 요약 ↬ 이 기사에서는 서식 있는 텍스트, 이미지, 링크 및 워드 프로세싱 앱의 미묘한 기능을 지원하는 WYSIWYG/Rich-Text Editor를 구축하는 방법을 배웁니다. SlateJS를 사용하여 편집기의 셸을 빌드한 다음 도구 모음 및 사용자 지정 구성을 추가합니다. 애플리케이션 코드는 GitHub에서 참조할 수 있습니다.

최근 몇 년 동안 디지털 플랫폼의 콘텐츠 생성 및 표현 분야는 큰 혼란을 겪었습니다. Quip, Google 문서 및 Dropbox Paper와 같은 제품의 광범위한 성공은 기업이 콘텐츠 제작자를 위해 엔터프라이즈 도메인에서 최고의 경험을 구축하기 위해 경쟁하고 콘텐츠 공유 및 소비 방식에 대한 기존 틀을 깨는 혁신적인 방법을 찾는 방법을 보여주었습니다. 소셜 미디어 플랫폼의 대규모 지원을 활용하여 Medium과 같은 플랫폼을 사용하여 콘텐츠를 만들고 청중과 공유하는 새로운 독립 콘텐츠 제작자의 물결이 있습니다.

다양한 직업과 배경을 가진 많은 사람들이 이러한 제품에 대한 콘텐츠를 만들려고 하기 때문에 이러한 제품이 콘텐츠 생성에 대한 성능과 원활한 경험을 제공하고 이 분야에서 시간이 지남에 따라 일정 수준의 도메인 전문 지식을 개발하는 디자이너 및 엔지니어 팀을 보유하는 것이 중요합니다. . 이 기사를 통해 우리는 편집기 구축의 토대를 마련할 뿐만 아니라 독자들에게 작은 기능이 결합될 때 콘텐츠 제작자를 위한 훌륭한 사용자 경험을 만들 수 있는 방법을 엿볼 수 있도록 노력합니다.

문서 구조 이해하기

편집기를 구축하기 전에 문서가 서식 있는 텍스트 편집기를 위해 구성되는 방식과 관련된 다양한 유형의 데이터 구조를 살펴보겠습니다.

문서 노드

문서 노드는 문서의 내용을 나타내는 데 사용됩니다. 서식 있는 텍스트 문서에 포함될 수 있는 일반적인 유형의 노드에는 단락, 머리글, 이미지, 비디오, 코드 블록 및 인용 부호가 있습니다. 이들 중 일부는 내부에 다른 노드를 자식으로 포함할 수 있습니다(예: 단락 노드에는 내부에 텍스트 노드가 포함됨). 노드는 또한 편집기 내에서 해당 노드를 렌더링하는 데 필요한 개체가 나타내는 특정 속성을 보유합니다. (예: 이미지 노드는 이미지 src 속성을 포함하고 코드 블록은 language 속성 등을 포함할 수 있습니다).

렌더링 방법을 나타내는 노드에는 크게 두 가지 유형이 있습니다.

  • 블록 노드 (블록 수준 요소의 HTML 개념과 유사)는 각각 새 줄에 렌더링되고 사용 가능한 너비를 차지합니다. 블록 노드는 내부에 다른 블록 노드 또는 인라인 노드를 포함할 수 있습니다. 여기서 관찰할 수 있는 점은 문서의 최상위 노드는 항상 블록 노드라는 것입니다.
  • 이전 노드와 동일한 라인에서 렌더링을 시작하는 인라인 노드 (인라인 요소의 HTML 개념과 유사). 인라인 요소가 다른 편집 라이브러리에서 표현되는 방식에는 약간의 차이가 있습니다. SlateJS를 사용하면 인라인 요소가 노드 자체가 될 수 있습니다. 또 다른 인기 있는 서식 있는 텍스트 편집 라이브러리인 DraftJS를 사용하면 엔티티 개념을 사용하여 인라인 요소를 렌더링할 수 있습니다. 링크 및 인라인 이미지는 인라인 노드의 예입니다.
  • Void Nodes — SlateJS는 또한 이 기사의 뒷부분에서 미디어를 렌더링하는 데 사용할 세 번째 범주의 노드를 허용합니다.

이러한 범주에 대해 더 알고 싶다면 노드에 대한 SlateJS 문서를 시작하는 것이 좋습니다.

점프 후 더! 아래에서 계속 읽기 ↓

속성

HTML의 속성 개념과 유사하게 서식 있는 텍스트 문서의 속성은 노드 또는 그 자식의 콘텐츠가 아닌 속성을 나타내는 데 사용됩니다. 예를 들어, 텍스트 노드는 텍스트가 볼드/이탤릭/밑줄 등인지 여부를 알려주는 문자 스타일 속성을 가질 수 있습니다. 이 기사는 표제를 노드 자체로 나타내지만, 표제를 나타내는 또 다른 방법은 노드가 속성으로 단락 스타일( paragraph & h1-h6 )을 갖는 것일 수 있습니다.

아래 이미지는 왼쪽에 있는 구조의 일부 요소를 강조 표시하는 노드와 속성을 사용하여 문서 구조(JSON)를 보다 세분화된 수준에서 설명하는 방법의 예를 제공합니다.

왼쪽에 구조 표현이 있는 편집기 내부의 예제 문서를 보여주는 이미지
예제 문서 및 그 구조적 표현. (큰 미리보기)

구조와 함께 여기에서 언급할 가치가 있는 몇 가지 사항은 다음과 같습니다.

  • 텍스트 노드는 {text: 'text content'} 로 표시됩니다.
  • 노드의 속성은 노드에 직접 저장됩니다(예: 링크의 경우 url 및 이미지의 경우 caption ).
  • SlateJS 관련 텍스트 속성 표현은 문자 스타일이 변경되는 경우 텍스트 노드를 자체 노드로 분리합니다. 따라서 ' Duis aute irure dolor '라는 텍스트는 자체 텍스트 노드로, bold: true 가 설정되어 있습니다. 이 문서의 기울임꼴, 밑줄 및 코드 스타일 텍스트도 마찬가지입니다.

위치 및 선택

서식 있는 텍스트 편집기를 구축할 때 문서의 가장 세부적인 부분(예: 문자)을 일종의 좌표로 표현하는 방법을 이해하는 것이 중요합니다. 이것은 우리가 문서 계층에서 우리가 어디에 있는지 이해하기 위해 런타임에 문서 구조를 탐색하는 데 도움이 됩니다. 가장 중요한 것은 위치 개체는 실시간으로 편집기의 사용자 경험을 조정하는 데 매우 광범위하게 사용되는 사용자 선택을 나타내는 방법을 제공한다는 것입니다. 이 기사 뒷부분에서 선택을 사용하여 도구 모음을 작성합니다. 이러한 예는 다음과 같습니다.

  • 사용자의 커서가 현재 링크 안에 있습니다. 링크를 편집/제거하는 메뉴를 표시해야 할까요?
  • 사용자가 이미지를 선택했습니까? 이미지 크기를 조정할 수 있는 메뉴를 제공할 수도 있습니다.
  • 사용자가 특정 텍스트를 선택하고 DELETE 버튼을 누르면 사용자가 선택한 텍스트가 무엇인지 확인하고 문서에서 제거합니다.

위치에 대한 SlateJS의 문서는 이러한 데이터 구조를 광범위하게 설명하지만 기사의 다른 인스턴스에서 이러한 용어를 사용하고 다음 다이어그램의 예를 보여주기 때문에 여기에서 빠르게 살펴보겠습니다.


  • 숫자 배열로 표시되는 경로는 문서의 노드에 도달하는 방법입니다. 예를 들어 경로 [2,3] 은 문서에서 두 번째 노드의 세 번째 자식 노드를 나타냅니다.
  • 가리키다
    경로 + 오프셋으로 표시되는 콘텐츠의 더 세분화된 위치입니다. 예를 들어 {path: [2,3], offset: 14} 의 점은 문서의 2번째 노드 내 3번째 자식 노드의 14번째 문자를 나타냅니다.
  • 범위
    문서 내부의 텍스트 범위를 나타내는 한 쌍의 포인트( anchorfocus 라고 함). 이 개념은 anchor 가 사용자의 선택이 시작된 곳이고 focus 가 끝나는 곳인 웹의 선택 API에서 비롯됩니다. 축소된 범위/선택 영역은 앵커 포인트와 초점 포인트가 동일한 위치를 나타냅니다(예를 들어 텍스트 입력에서 깜박이는 커서를 생각해 보십시오).

예를 들어 위의 문서 예제에서 사용자가 선택한 항목이 ipsum 이라고 가정해 보겠습니다.

편집기에서 텍스트 ` ipsum`이 선택된 이미지
사용자는 ipsum 이라는 단어를 선택합니다. (큰 미리보기)

사용자의 선택은 다음과 같이 나타낼 수 있습니다.

 { anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum' }`

편집기 설정

이 섹션에서는 애플리케이션을 설정하고 SlateJS와 함께 사용할 기본 서식 있는 텍스트 편집기를 얻을 것입니다. 상용구 애플리케이션은 SlateJS 종속성이 추가된 create-react-app 입니다. react-bootstrap 구성 요소를 사용하여 애플리케이션의 UI를 구축하고 있습니다. 시작하자!

wysiwyg-editor 라는 폴더를 만들고 디렉토리 내부에서 아래 명령어를 실행하여 반응 앱을 설정합니다. 그런 다음 로컬 웹 서버(포트 기본값은 3000)를 실행하고 React 시작 화면을 표시하는 yarn start 명령을 실행합니다.

 npx create-react-app . yarn start

그런 다음 SlateJS 종속성을 애플리케이션에 추가합니다.

 yarn add slate slate-react

slate 는 SlateJS의 핵심 패키지이고 slate-react 에는 Slate 편집기를 렌더링하는 데 사용할 React 구성 요소 집합이 포함되어 있습니다. SlateJS는 편집기에 추가하는 것을 고려할 수 있는 기능별로 구성된 패키지를 더 많이 노출합니다.

먼저 이 애플리케이션에서 생성한 모든 유틸리티 모듈이 들어 있는 utils 폴더를 생성합니다. 일부 텍스트가 포함된 단락이 포함된 기본 문서 구조를 반환하는 ExampleDocument.js 를 만드는 것으로 시작합니다. 이 모듈은 다음과 같습니다.

 const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;

이제 모든 React 구성 요소를 보관할 components 라는 폴더를 추가하고 다음을 수행합니다.

  • 첫 번째 React 구성 요소 Editor.js 를 추가합니다. 지금은 div 만 반환합니다.
  • 위의 ExampleDocument 로 초기화된 상태로 문서를 유지하도록 App.js 구성 요소를 업데이트합니다.
  • 앱 내에서 Editor를 렌더링하고 문서 상태와 onChange 핸들러를 Editor로 전달하여 사용자가 업데이트할 때 문서 상태가 업데이트되도록 합니다.
  • React 부트스트랩의 Nav 구성 요소를 사용하여 애플리케이션에도 탐색 모음을 추가합니다.

이제 App.js 구성 요소는 다음과 같습니다.

 import Editor from './components/Editor'; function App() { const [document, updateDocument] = useState(ExampleDocument); return ( <> <Navbar bg="dark" variant="dark"> <Navbar.Brand href="#"> <img alt="" src="/app-icon.png" width="30" height="30" className="d-inline-block align-top" />{" "} WYSIWYG Editor </Navbar.Brand> </Navbar> <div className="App"> <Editor document={document} onChange={updateDocument} /> </div> </> );

그런 다음 Editor 구성 요소 내에서 SlateJS 편집기를 인스턴스화하고 useMemo 내부에 유지하여 개체가 다시 렌더링되는 동안 변경되지 않도록 합니다.

 // dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);

createEditor 는 선택에 액세스하고 데이터 변환 등을 실행하기 위해 애플리케이션을 통해 광범위하게 사용하는 SlateJS editor 인스턴스를 제공합니다. withReact는 편집기 개체에 React 및 DOM 동작을 추가하는 SlateJS 플러그인입니다. SlateJS 플러그인은 editor 개체를 수신하고 여기에 일부 구성을 첨부하는 Javascript 기능입니다. 이를 통해 웹 개발자는 구성 가능한 방식으로 SlateJS 편집기 인스턴스에 구성을 추가할 수 있습니다.

이제 App.js에서 가져온 문서 소품을 사용하여 SlateJS에서 <Slate /><Editable /> 구성 요소를 가져오고 렌더링합니다. Slate 는 애플리케이션 코드에서 액세스하는 데 사용하는 많은 React 컨텍스트를 노출합니다. Editable 은 편집을 위해 문서 계층 구조를 렌더링하는 구성 요소입니다. 전반적으로 이 단계의 Editor.js 모듈은 다음과 같습니다.

 import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }

이 시점에서 필요한 React 구성 요소가 추가되었고 편집기가 예제 문서로 채워졌습니다. 이제 에디터가 설정되어 아래 스크린캐스트와 같이 실시간으로 콘텐츠를 입력하고 변경할 수 있습니다.

작동 중인 기본 편집기 설정

이제 문자 스타일과 단락 노드를 렌더링하도록 편집기를 구성하는 다음 섹션으로 이동하겠습니다.

사용자 정의 텍스트 렌더링 및 도구 모음

단락 스타일 노드

현재 우리 편집기는 문서에 추가할 수 있는 모든 새 노드 유형에 대해 SlateJS의 기본 렌더링을 사용합니다. 이 섹션에서는 제목 노드를 렌더링할 수 있기를 원합니다. 그렇게 할 수 있도록 Slate의 구성 요소에 renderElement 함수 소품을 제공합니다. 이 함수는 런타임에 Slate가 문서 트리를 탐색하고 각 노드를 렌더링하려고 할 때 호출됩니다. renderElement 함수는 세 개의 매개변수를 얻습니다.

  • attributes
    이 함수에서 반환되는 최상위 DOM 요소에 적용해야 하는 특정 SlateJS입니다.
  • element
    문서 구조에 존재하는 노드 객체 자체
  • children
    문서 구조에 정의된 이 노드의 자식입니다.

우리는 renderElement 구현을 useEditorConfig 라는 후크에 추가합니다. 여기서 우리는 계속해서 더 많은 편집기 구성을 추가할 것입니다. 그런 다음 Editor.js 내부의 편집기 인스턴스에서 후크를 사용합니다.

 import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }

이 함수는 element (노드 자체)에 대한 액세스를 제공하므로 renderElement 를 사용자 정의하여 element.type 확인 이상의 작업을 수행하는 더 사용자 정의된 렌더링을 구현할 수 있습니다. 예를 들어, 블록 이미지에 대해 인라인 이미지를 렌더링하는 데 도움이 되는 다른 DOM 구조를 반환하는 데 사용할 수 있는 isInline 속성이 있는 이미지 노드가 있을 수 있습니다.

이제 아래와 같이 이 후크를 사용하도록 Editor 구성 요소를 업데이트합니다.

 const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );

사용자 정의 렌더링을 사용하여 새 노드 유형을 포함하도록 ExampleDocument를 업데이트하고 편집기 내에서 올바르게 렌더링되는지 확인합니다.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
편집기에서 렌더링된 다양한 제목 및 단락 노드를 보여주는 이미지
편집기의 제목 및 단락 노드. (큰 미리보기)

캐릭터 스타일

renderElement 와 유사하게 renderElement 는 텍스트 노드의 렌더링을 사용자 정의하는 데 사용할 수 있는 renderLeaf라는 함수 소품을 제공합니다( Leaf 는 문서 트리의 잎/최하위 수준 노드인 텍스트 노드를 나타냄). renderElement 의 예에 따라 renderElement 에 대한 구현을 renderLeaf 합니다.

 export default function useEditorConfig(editor) { return { renderElement, renderLeaf }; } // ... function renderLeaf({ attributes, children, leaf }) { let el = <>{children}</>; if (leaf.bold) { el = <strong>{el}</strong>; } if (leaf.code) { el = <code>{el}</code>; } if (leaf.italic) { el = <em>{el}</em>; } if (leaf.underline) { el = <u>{el}</u>; } return <span {...attributes}>{el}</span>; }

위 구현의 중요한 관찰은 문자 스타일에 대한 HTML 의미를 존중할 수 있다는 것입니다. renderLeaf는 텍스트 노드 leaf 자체에 대한 액세스를 제공하므로 더 사용자 정의된 렌더링을 구현하기 위해 함수를 사용자 정의할 수 있습니다. 예를 들어 사용자가 텍스트에 대해 highlightColor 를 선택하고 여기에서 해당 리프 속성을 확인하여 각 스타일을 첨부하도록 하는 방법이 있을 수 있습니다.

이제 위의 ExampleDocument 문서를 사용하도록 편집기 구성 요소를 업데이트하여 이러한 스타일의 조합이 있는 단락에 몇 개의 텍스트 노드를 포함하고 우리가 사용한 시맨틱 태그를 사용하여 편집기에서 예상대로 렌더링되는지 확인합니다.

 # src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
 # src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], }, 
UI의 문자 스타일과 DOM 트리에서 렌더링되는 방법
UI의 문자 스타일과 DOM 트리에서 렌더링되는 방법. (큰 미리보기)

도구 모음 추가

새 구성 요소 Toolbar.js 를 추가하는 것으로 시작하여 문자 스타일에 대한 몇 개의 버튼과 단락 스타일에 대한 드롭다운을 추가하고 이 섹션의 뒷부분에서 이를 연결합니다.

 const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"]; const CHARACTER_STYLES = ["bold", "italic", "underline", "code"]; export default function Toolbar({ selection, previousSelection }) { return ( <div className="toolbar"> {/* Dropdown for paragraph styles */} <DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={false} /> ))} </div> ); } function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( <Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} > {icon} </Button> ); }

React Bootstrap Button 구성 요소를 둘러싼 래퍼인 ToolbarButton 구성 요소에 대한 버튼을 추상화합니다. 그런 다음 Editable inside Editor 구성 요소 위에 도구 모음을 렌더링하고 도구 모음이 응용 프로그램에 표시되는지 확인합니다.

편집기 위에 렌더링된 버튼이 있는 도구 모음을 보여주는 이미지
버튼이 있는 도구 모음(큰 미리보기)

다음은 지원하는 도구 모음이 필요한 세 가지 주요 기능입니다.

  1. 사용자의 커서가 문서의 특정 지점에 있고 문자 스타일 버튼 중 하나를 클릭하면 다음에 입력할 텍스트의 스타일을 토글해야 합니다.
  2. 사용자가 텍스트 범위를 선택하고 문자 스타일 버튼 중 하나를 클릭하면 해당 특정 섹션의 스타일을 토글해야 합니다.
  3. 사용자가 텍스트 범위를 선택하면 선택 항목의 단락 유형을 반영하도록 단락 스타일 드롭다운을 업데이트하려고 합니다. 선택 항목에서 다른 값을 선택하는 경우 전체 선택 항목의 단락 스타일을 선택한 항목으로 업데이트하려고 합니다.

구현을 시작하기 전에 이러한 기능이 에디터에서 어떻게 작동하는지 살펴보겠습니다.

캐릭터 스타일 전환 동작

선택 듣기

툴바가 위의 기능을 수행하기 위해 필요한 가장 중요한 것은 문서의 선택 상태입니다. 이 기사를 작성하는 시점에서 SlateJS는 문서의 최신 선택 상태를 제공할 수 있는 onSelectionChange 메소드를 노출하지 않습니다. 그러나 편집기에서 선택이 변경되면 SlateJS는 문서 내용이 변경되지 않은 경우에도 onChange 메서드를 호출합니다. 우리는 이것을 선택 변경 사항을 알리고 Editor 구성 요소의 상태에 저장하는 방법으로 사용합니다. 우리는 이것을 선택 상태의 더 최적의 업데이트를 수행할 수 있는 후크 useSelection 으로 추상화합니다. WYSIWYG Editor 인스턴스의 경우 선택이 매우 자주 변경되는 속성이므로 이는 중요합니다.

 import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }

아래와 같이 Editor 구성 요소 내부에서 이 후크를 사용하고 선택 항목을 Toolbar 구성 요소에 전달합니다.

 const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...

성능 고려 사항

훨씬 더 많은 기능을 가진 훨씬 더 큰 편집기 코드베이스가 있는 애플리케이션에서는 선택 변경 사항을 수신하는 구성 요소도 렌더링될 가능성이 높기 때문에 성능이 좋은 방식으로 선택 변경 사항을 저장하고 수신하는 것이 중요합니다(일부 상태 관리 라이브러리 사용 등). 자주. 이를 수행하는 한 가지 방법은 특정 선택 정보를 보유하는 선택 상태 위에 최적화된 선택기를 갖는 것입니다. 예를 들어, 편집자는 이미지가 선택될 때 이미지 크기 조정 메뉴를 렌더링하기를 원할 수 있습니다. 이러한 경우 편집기의 선택 상태에서 계산된 isImageSelected 선택기를 갖는 것이 도움이 될 수 있으며 이미지 메뉴는 이 선택기의 값이 변경될 때만 다시 렌더링됩니다. Redux의 Reselect는 선택기를 빌드할 수 있는 라이브러리 중 하나입니다.

나중에까지 도구 모음 내에서 selection 을 사용하지 않지만 소품으로 전달하면 편집기에서 선택 항목이 변경될 때마다 도구 모음이 다시 렌더링됩니다. 사용자가 문서 주위를 계속 클릭하여 선택 항목을 변경하지만 실제로는 문서 콘텐츠를 변경하지 않을 수 있으므로 계층 구조( App -> Editor -> Toolbar )에서 다시 렌더링을 트리거하기 위해 문서 콘텐츠 변경에만 의존할 수 없기 때문에 이 작업을 수행합니다. 그 자체.

문자 스타일 전환

이제 SlateJS에서 활성 문자 스타일을 가져오고 Editor 내에서 사용하는 것으로 이동합니다. 앞으로 SlateJS로 작업을 수행/얻기 위해 구축하는 모든 유틸리티 기능을 호스팅할 새 JS 모듈 EditorUtils 를 추가해 보겠습니다. 모듈의 첫 번째 함수는 편집기에서 활성 스타일 Set 를 제공하는 getActiveStyles 입니다. 또한 편집기 기능에서 스타일을 토글하는 기능을 추가합니다 — toggleStyle :

 # src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }

두 함수 모두 Slate 인스턴스인 editor 개체를 매개변수로 사용하며 기사의 뒷부분에서 추가할 많은 util 함수도 마찬가지입니다. Slate 용어에서 서식 지정 스타일은 Marks라고 하며 Editor 인터페이스에서 도우미 메서드를 사용하여 가져오고 추가 이 표시를 제거합니다. 도구 모음 내부에서 이러한 유틸리티 기능을 가져와서 이전에 추가한 버튼에 연결합니다.

 # src/components/Toolbar.js import { getActiveStyles, toggleStyle } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; export default function Toolbar({ selection }) { const editor = useEditor(); return <div ... {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} characterStyle={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) => { event.preventDefault(); toggleStyle(editor, style); }} /> ))} </div>

useEditor 는 렌더링 계층에서 상위에 있는 &lt;Slate> 구성 요소에 의해 연결된 컨텍스트에서 Slate 인스턴스에 대한 액세스를 제공하는 Slate 후크입니다.

여기에서 onClick 대신 onMouseDown 을 사용하는 이유가 궁금할 것입니다. 에디터가 어떤 식으로든 포커스를 잃을 때 Slate가 selectionnull 로 바꾸는 방법에 대한 공개 Github 문제가 있습니다. 따라서 툴바 버튼에 onClick 핸들러를 연결하면 selectionnull 이 되고 사용자는 좋은 경험이 아닌 스타일을 토글하려고 커서 위치를 잃게 됩니다. 대신 선택 항목이 재설정되는 것을 방지하는 onMouseDown 이벤트를 첨부하여 스타일을 토글합니다. 이를 수행하는 또 다른 방법은 선택 항목을 직접 추적하여 마지막 선택 항목이 무엇인지 알고 이를 사용하여 스타일을 전환하는 것입니다. 이 기사 뒷부분에서 previousSelection 선택의 개념을 소개하지만 다른 문제를 해결합니다.

SlateJS를 사용하면 Editor에서 이벤트 핸들러를 구성할 수 있습니다. 이를 사용하여 키보드 단축키를 연결하여 문자 스타일을 토글합니다. 그렇게 하려면 Editable 구성 요소에 연결된 onKeyDown 이벤트 핸들러를 노출하는 useEditorConfig 내부에 KeyBindings 개체를 추가합니다. is-hotkey 유틸리티를 사용하여 키 조합을 결정하고 해당 스타일을 토글합니다.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> 
키보드 단축키를 사용하여 토글되는 문자 스타일.

단락 스타일 드롭다운 작동 만들기

단락 스타일 드롭다운이 작동하도록 합시다. 단락 스타일 드롭다운이 MS Word 또는 Google 문서와 같은 널리 사용되는 워드 프로세싱 애플리케이션에서 작동하는 방식과 유사하게, 우리는 사용자가 선택한 최상위 블록의 스타일이 드롭다운에 반영되기를 원합니다. 선택 항목 전체에 일관된 단일 스타일이 있는 경우 드롭다운 값을 해당 스타일로 업데이트합니다. 여러 개 있는 경우 드롭다운 값을 '다중'으로 설정합니다. 이 동작은 축소 및 확장 선택 모두에 대해 작동해야 합니다.

이 동작을 구현하려면 사용자 선택에 걸쳐 있는 최상위 블록을 찾을 수 있어야 합니다. 이를 위해 Slate의 Editor.nodes — 다양한 옵션으로 필터링된 트리에서 노드를 검색하는 데 일반적으로 사용되는 도우미 함수를 사용합니다.

 nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>

도우미 함수는 트리를 통과할 때 트리의 노드를 필터링하는 방법인 Editor 인스턴스와 options 개체를 사용합니다. 이 함수는 NodeEntry 생성기를 반환합니다. Slate 용어로 NodeEntry 는 노드와 노드의 경로 [node, pathToNode] 튜플입니다. 여기에 있는 옵션은 대부분의 Slate 도우미 기능에서 사용할 수 있습니다. 각각이 의미하는 바를 살펴보겠습니다.

  • at
    이것은 도우미 함수가 트리 탐색의 범위를 좁히는 데 사용할 경로/점/범위일 수 있습니다. 제공되지 않으면 기본적으로 editor.selection 이 됩니다. 또한 사용자 선택 내의 노드에 관심이 있으므로 아래 사용 사례에 대해 기본값을 사용합니다.
  • match
    이것은 각 노드에서 호출되고 일치하는 경우 포함되는 제공할 수 있는 일치 기능입니다. 아래 구현에서 이 매개변수를 사용하여 요소만 차단하도록 필터링합니다.
  • mode
    도우미 함수가 지정된 위치 일치 match 함수에서 모든 최상위 또는 최하위 노드 at 관심이 있는지 알도록 합시다. 이 매개변수( highest 로 설정됨)는 최상위 노드를 찾기 위해 트리 탐색하는 것을 피하는 데 도움이 됩니다.
  • universal
    노드의 전체 또는 부분 일치 중에서 선택하는 플래그입니다. (이 플래그에 대한 제안과 관련된 GitHub 문제에는 이를 설명하는 몇 가지 예가 있습니다)
  • reverse
    노드 검색은 전달된 위치의 시작점과 끝점의 역방향이어야 합니다.
  • voids
    검색이 void 요소로만 필터링되어야 하는 경우.

SlateJS는 다양한 방법으로 노드를 쿼리하고, 트리를 탐색하고, 복잡한 방식으로 노드 또는 선택 항목을 업데이트할 수 있는 많은 도우미 기능을 제공합니다. Slate 위에 복잡한 편집 기능을 구축할 때 이러한 인터페이스 중 일부(이 기사의 끝부분에 나열됨)를 파헤쳐 볼 가치가 있습니다.

도우미 함수에 대한 배경을 가지고 아래는 getTextBlockStyle 의 구현입니다.

 # src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }

성능 고려 사항

Editor.nodes 의 현재 구현은 at 매개변수 범위 내에 있는 모든 수준에서 트리 전체의 모든 노드를 찾은 다음 여기에 일치 필터를 실행합니다( nodeEntries 및 필터링은 나중에 확인 — 소스). 이것은 작은 문서에 적합합니다. 그러나 우리 사용 사례의 경우 사용자가 3개의 제목과 2개의 단락(각 단락에는 10개의 텍스트 노드가 포함됨)을 선택하면 최소 25개의 노드(3 + 2 + 2*10)를 순환하고 필터를 실행하려고 시도합니다. 그들에. 우리는 이미 최상위 노드에만 관심이 있다는 것을 알고 있기 때문에 선택 항목에서 최상위 블록의 시작 및 끝 인덱스를 찾고 스스로 반복할 수 있습니다. 이러한 논리는 3개의 노드 항목(2개의 제목 및 1개의 단락)만 반복합니다. 이에 대한 코드는 다음과 같습니다.

 export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex <= endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType; }

WYSIWYG 편집기에 더 많은 기능을 추가하고 문서 트리를 자주 탐색해야 하므로 사용 가능한 API 또는 도우미 메서드가 항상 가장 좋은 것은 아니므로 현재 사용 사례에 대해 가장 성능이 좋은 방법에 대해 생각하는 것이 중요합니다. 효율적인 방법입니다.

일단 getTextBlockStyle 이 구현되면 블록 스타일의 토글은 비교적 간단합니다. 현재 스타일이 사용자가 드롭다운에서 선택한 것이 아닌 경우 스타일을 해당 스타일로 전환합니다. 사용자가 이미 선택한 것이라면 단락으로 전환합니다. 문서 구조에서 단락 스타일을 노드로 나타내기 때문에 단락 스타일을 토글한다는 것은 본질적으로 노드의 type 속성을 변경하는 것을 의미합니다. Slate에서 제공하는 Transforms.setNodes 를 사용하여 노드의 속성을 업데이트합니다.

우리의 toggleBlockType 의 구현은 다음과 같습니다:

 # src/utils/EditorUtils.js export function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) => Editor.isBlock(editor, n) } ); }

마지막으로 이러한 유틸리티 기능을 사용하도록 단락 스타일 드롭다운을 업데이트합니다.

 #src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... ); 
여러 블록 유형을 선택하고 드롭다운으로 유형을 변경합니다.

연결

이 섹션에서는 링크 표시, 추가, 제거 및 변경에 대한 지원을 추가할 것입니다. 우리는 또한 사용자가 입력한 텍스트를 스캔하고 거기에 링크가 있는지 확인하는 Google 문서 또는 MS Word와 매우 유사한 Link-Detector 기능을 추가할 것입니다. 있는 경우 링크 개체로 변환되므로 사용자가 직접 수행하기 위해 도구 모음 버튼을 사용할 필요가 없습니다.

렌더링 링크

편집기에서 SlateJS를 사용하여 링크를 인라인 노드로 구현할 것입니다. 링크를 SlateJS용 인라인 노드로 플래그 지정하도록 편집기 구성을 업데이트하고 렌더링할 구성 요소도 제공하므로 Slate가 링크 노드를 렌더링하는 방법을 알 수 있습니다.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
 # src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }

그런 다음 ExampleDocument 에 링크 노드를 추가하고 편집기에서 올바르게 렌더링되는지 확인합니다(링크 내 문자 스타일의 경우 포함).

 # src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... } 
편집기에서 렌더링된 링크와 편집기의 DOM 트리를 보여주는 이미지
편집기에서 렌더링된 링크(큰 미리보기)

도구 모음에 링크 버튼 추가하기

사용자가 다음을 수행할 수 있도록 하는 링크 버튼을 도구 모음에 추가해 보겠습니다.

  • 일부 텍스트를 선택하고 버튼을 클릭하면 해당 텍스트가 링크로 변환됩니다.
  • 깜박이는 커서(축소된 선택)가 있고 버튼을 클릭하면 거기에 새 링크가 삽입됩니다.
  • 사용자의 선택이 링크 안에 있는 경우 버튼을 클릭하면 링크가 토글되어야 합니다. 즉, 링크를 다시 텍스트로 변환해야 합니다.

이러한 기능을 빌드하려면 도구 모음에서 사용자의 선택 항목이 링크 노드 내부에 있는지 알 수 있는 방법이 필요합니다. SlateJS의 Editor.above helper 함수를 사용하여 링크 노드가 있는 경우 이를 찾기 위해 사용자 선택에서 위쪽 방향으로 레벨을 탐색하는 util 함수를 추가합니다.

 # src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }

이제 사용자의 선택이 링크 노드 내부에 있는 경우 활성 상태인 도구 모음에 버튼을 추가해 보겠습니다.

 # src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> ); 
선택 항목이 링크 내부에 있는 경우 도구 모음의 링크 버튼이 활성화됩니다.

편집기에서 링크를 토글하기 위해 util 함수 toggleLinkAtSelection 을 추가합니다. 일부 텍스트를 선택했을 때 토글이 어떻게 작동하는지 먼저 살펴보겠습니다. 사용자가 일부 텍스트를 선택하고 버튼을 클릭하면 선택한 텍스트만 링크가 되기를 원합니다. 이것이 본질적으로 의미하는 바는 선택한 텍스트가 포함된 텍스트 노드를 분리하고 선택한 텍스트를 새 링크 노드로 추출해야 한다는 것입니다. 이들의 전후 상태는 다음과 같을 것입니다.

링크가 삽입된 전후 노드 구조
링크가 삽입된 전후 노드 구조. (큰 미리보기)

이 작업을 직접 수행해야 하는 경우 선택 범위를 파악하고 원래 텍스트 노드를 대체하는 세 개의 새 노드(텍스트, 링크, 텍스트)를 만들어야 합니다. SlateJS에는 정확히 이 작업을 수행하는 Transforms.wrapNodes 라는 도우미 함수가 있습니다. 즉, 특정 위치의 노드를 새 컨테이너 노드로 래핑합니다. 또한 이 프로세스의 역순으로 사용할 수 있는 도우미가 있습니다. 선택한 텍스트에서 링크를 제거하고 해당 텍스트를 주변의 텍스트 노드로 다시 병합하는 데 사용하는 Transforms.unwrapNodes 입니다. 이를 통해 toggleLinkAtSelection 에는 확장된 선택 영역에 새 링크를 삽입하는 아래 구현이 있습니다.

 # src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }

선택 항목이 축소되면 문서의 지정된 위치에 노드를 삽입하는 Transform.insertNodes 와 함께 새 노드를 삽입합니다. 이 기능을 도구 모음 버튼과 연결하고 이제 링크 버튼을 사용하여 문서에서 링크를 추가/제거하는 방법이 있어야 합니다.

 # src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} /> 

링크 편집기 메뉴

So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor component and rendering it whenever the user selection is inside a link.

# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
 # src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>

LinkEditor 를 편집기 외부에서 렌더링하고 있기 때문에 LinkEditor 에게 DOM 트리에서 링크가 있는 위치를 알려주어 편집기 근처에서 자체적으로 렌더링할 수 있도록 하는 방법이 필요합니다. 이를 수행하는 방법은 Slate의 React API를 사용하여 선택에서 링크 노드에 해당하는 DOM 노드를 찾는 것입니다. 그런 다음 getBoundingClientRect() 를 사용하여 링크의 DOM 요소의 경계와 편집기 구성 요소의 경계를 찾고 링크 편집기의 topleft 을 계산합니다. EditorLinkEditor 에 대한 코드 업데이트는 다음과 같습니다.

 # src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
 # src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }

SlateJS는 내부적으로 해당 DOM 요소에 대한 노드 맵을 유지 관리합니다. 해당 맵에 액세스하고 ReactEditor.toDOMNode 를 사용하여 링크의 DOM 요소를 찾습니다.

링크 내부의 선택은 링크 편집기 팝오버를 표시합니다.

위의 동영상에서 볼 수 있듯이 링크가 삽입되고 URL이 없을 때 선택이 링크 안에 있기 때문에 링크 편집기를 열어 사용자가 새로 삽입된 링크에 대한 URL을 입력하고 URL을 입력할 수 있는 방법을 제공합니다. 따라서 사용자 경험의 루프를 닫습니다.

이제 사용자가 URL을 입력하고 링크 노드에 적용할 수 있도록 하는 입력 요소와 버튼을 LinkEditor 에 추가합니다. URL 유효성 검사를 위해 isUrl 패키지를 사용합니다.

 # src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );

양식 요소가 연결된 상태에서 링크 편집기가 예상대로 작동하는지 봅시다.

링크 편집기 내부를 클릭하면 편집기가 선택을 잃음

여기 비디오에서 볼 수 있듯이 사용자가 입력을 클릭하려고 하면 링크 편집기가 사라집니다. 이는 Editable 구성 요소 외부에서 링크 편집기를 렌더링할 때 사용자가 입력 요소를 클릭할 때 SlateJS가 편집기가 포커스를 잃은 것으로 생각하고 isLinkActiveAtSelection 이 더 이상 true 가 아니기 때문에 LinkEditor 를 제거하는 nullselection 을 재설정하기 때문입니다. 이 Slate 동작에 대해 설명하는 공개 GitHub 문제가 있습니다. 이 문제를 해결하는 한 가지 방법은 사용자의 이전 선택이 변경됨에 따라 추적하고 편집기가 포커스를 잃으면 이전 선택을 보고 이전 선택에 링크가 있는 경우 여전히 링크 편집기 메뉴를 표시할 수 있습니다. useSelection 후크를 업데이트하여 이전 선택을 기억하고 이를 Editor 구성 요소로 반환하겠습니다.

 # src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }

그런 다음 이전 선택 항목에 링크가 있는 경우에도 링크 메뉴를 표시하도록 Editor 구성 요소의 논리를 업데이트합니다.

 # src/components/Editor.js const [previousSelection, selection, setSelection] = useSelection(editor); let selectionForLink = null; if (isLinkNodeAtSelection(editor, selection)) { selectionForLink = selection; } else if (selection == null && isLinkNodeAtSelection(editor, previousSelection)) { selectionForLink = previousSelection; } return ( ... <div className="editor" ref={editorRef}> {selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );

그런 다음 LinkEditor 를 업데이트하여 selectionForLink 를 사용하여 링크 노드를 조회하고 그 아래에서 렌더링하고 URL을 업데이트합니다.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
LinkEditor 구성 요소를 사용하여 링크 편집.

텍스트에서 링크 감지

대부분의 워드 프로세싱 응용 프로그램은 텍스트 내의 링크를 식별하고 링크 개체로 변환합니다. 빌드를 시작하기 전에 편집기에서 어떻게 작동하는지 봅시다.

사용자가 입력할 때 감지되는 링크.

이 동작을 활성화하는 논리 단계는 다음과 같습니다.

  1. 사용자가 입력함에 따라 문서가 변경되면 사용자가 삽입한 마지막 문자를 찾습니다. 그 문자가 공백이라면 그 앞에 올 수도 있는 단어가 있어야 한다는 것을 압니다.
  2. 마지막 문자가 공백이면 그 앞에 오는 단어의 끝 경계로 표시합니다. 그런 다음 해당 단어가 시작된 위치를 찾기 위해 텍스트 노드 내에서 문자별로 다시 탐색합니다. 이 순회 동안 우리는 노드 시작의 가장자리를 지나 이전 노드로 가지 않도록 주의해야 합니다.
  3. 이전에 단어의 시작과 끝 경계를 찾았으면 단어의 문자열을 확인하고 그것이 URL인지 확인합니다. 그렇다면 링크 노드로 변환합니다.

우리의 논리는 EditorUtils에 있고 Editor 구성 요소의 onChange 내에서 호출되는 util 함수 identifyLinksInTextIfAny EditorUtils 에 있습니다.

 # src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );

다음은 1단계에 대한 논리가 구현된 identifyLinksInTextIfAny 링크인텍스트이프Any입니다.

 export function identifyLinksInTextIfAny(editor) { // if selection is not collapsed, we do not proceed with the link // detection if (editor.selection == null || !Range.isCollapsed(editor.selection)) { return; } const [node, _] = Editor.parent(editor, editor.selection); // if we are already inside a link, exit early. if (node.type === "link") { return; } const [currentNode, currentNodePath] = Editor.node(editor, editor.selection); // if we are not inside a text node, exit early. if (!Text.isText(currentNode)) { return; } let [start] = Range.edges(editor.selection); const cursorPoint = start; const startPointOfLastCharacter = Editor.before(editor, editor.selection, { unit: "character", }); const lastCharacter = Editor.string( editor, Editor.range(editor, startPointOfLastCharacter, cursorPoint) ); if(lastCharacter !== ' ') { return; }

여기에 일을 쉽게 해주는 두 개의 SlateJS 도우미 함수가 있습니다.

  • Editor.before — 특정 위치 이전의 지점을 제공합니다. unit 를 매개변수로 사용하므로 location 가 전달되기 전에 문자/단어/블록 등을 요청할 수 있습니다.
  • Editor.string — 범위 내의 문자열을 가져옵니다.

예를 들어, 아래 다이어그램은 사용자가 문자 'E'를 삽입하고 커서가 그 뒤에 있을 때 이러한 변수의 값이 무엇인지 설명합니다.

1단계 이후 cursorPoint 및 startPointOfLastCharacter가 가리키는 위치를 예시로 설명하는 다이어그램
예제 텍스트가 있는 1단계 이후의 cursorPointstartPointOfLastCharacter . (큰 미리보기)

텍스트 'ABCDE'가 문서의 첫 번째 단락의 첫 번째 텍스트 노드인 경우 포인트 값은 다음과 같습니다.

 cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}

마지막 문자가 공백인 경우 startPointOfLastCharacter. 다른 공백이나 텍스트 노드 자체의 시작을 찾을 때까지 문자별로 뒤로 이동하는 2단계로 이동하겠습니다.

 ... if (lastCharacter !== " ") { return; } let end = startPointOfLastCharacter; start = Editor.before(editor, end, { unit: "character", }); const startOfTextNode = Editor.point(editor, currentNodePath, { edge: "start", }); while ( Editor.string(editor, Editor.range(editor, start, end)) !== " " && !Point.isBefore(start, startOfTextNode) ) { end = start; start = Editor.before(editor, end, { unit: "character" }); } const lastWordRange = Editor.range(editor, end, startPointOfLastCharacter); const lastWord = Editor.string(editor, lastWordRange);

다음은 ABCDE 로 입력된 마지막 단어를 찾은 후 이러한 여러 점이 가리키는 곳을 보여주는 다이어그램입니다.

링크 감지 2단계 이후에 서로 다른 지점이 어디에 있는지를 예시로 설명하는 다이어그램
예를 들어 링크 감지의 2단계 이후에 서로 다른 지점이 있는 경우. (큰 미리보기)

startend 은 공백 앞뒤의 지점입니다. 마찬가지로 startPointOfLastCharactercursorPoint 는 사용자가 방금 삽입한 공간 전후의 점입니다. 따라서 [end,startPointOfLastCharacter] 는 삽입된 마지막 단어를 제공합니다.

lastWord 의 값을 콘솔에 기록하고 입력할 때 값을 확인합니다.

콘솔 로그는 2단계의 논리 이후 사용자가 입력한 마지막 단어를 확인합니다.

이제 사용자가 마지막으로 입력한 단어가 무엇인지 추론했으므로 실제로 URL인지 확인하고 해당 범위를 링크 개체로 변환합니다. 이 변환은 도구 모음 링크 버튼이 사용자가 선택한 텍스트를 링크로 변환하는 방법과 유사합니다.

 if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }

identifierLinksInTextIfAny는 Slate의 onChange 내에서 호출되므로 onChange 내에서 문서 구조를 업데이트 identifyLinksInTextIfAny 싶지 않습니다. 따라서 Promise.resolve().then(..) 호출을 사용하여 이 업데이트를 작업 대기열에 넣습니다.

논리가 함께 작용하는 것을 봅시다! 텍스트 노드의 끝, 중간 또는 시작 부분에 링크를 삽입하는지 확인합니다.

사용자가 입력할 때 링크가 감지됩니다.

그것으로 우리는 편집기의 링크에 대한 기능을 마무리하고 이미지로 넘어갑니다.

이미지 처리

이 섹션에서는 이미지 노드를 렌더링하고, 새 이미지를 추가하고, 이미지 캡션을 업데이트하기 위한 지원을 추가하는 데 중점을 둡니다. 문서 구조에서 이미지는 Void 노드로 표시됩니다. SlateJS의 Void 노드(HTML 사양의 Void 요소와 유사)는 해당 내용이 편집 가능한 텍스트가 아닙니다. 이를 통해 이미지를 공극으로 렌더링할 수 있습니다. Slate의 렌더링 유연성 덕분에 Void 요소 내에서 편집 가능한 요소를 렌더링할 수 있습니다. 이는 이미지 캡션 편집을 위한 것입니다. SlateJS에는 Void 요소 내부에 전체 서식 있는 텍스트 편집기를 포함하는 방법을 보여주는 예제가 있습니다.

이미지를 렌더링하려면 이미지를 Void 요소로 처리하고 이미지가 렌더링되어야 하는 방법에 대한 렌더링 구현을 제공하도록 편집기를 구성합니다. ExampleDocument에 이미지를 추가하고 캡션과 함께 올바르게 렌더링되는지 확인합니다.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const { isVoid } = editor; editor.isVoid = (element) => { return ["image"].includes(element.type) || isVoid(element); }; ... } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "image": return <Image {...props} />; ... `` `` # src/components/Image.js function Image({ attributes, children, element }) { return ( <div contentEditable={false} {...attributes}> <div className={classNames({ "image-container": true, })} > <img src={String(element.url)} alt={element.caption} className={"image"} /> <div className={"image-caption-read-mode"}>{element.caption}</div> </div> {children} </div> ); }

SlateJS로 void 노드를 렌더링할 때 기억해야 할 두 가지:

  • 루트 DOM 요소에는 SlateJS가 콘텐츠를 그렇게 취급하도록 contentEditable={false} 가 설정되어 있어야 합니다. 이것이 없으면 void 요소와 상호 작용할 때 SlateJS가 선택 항목 등을 계산하려고 시도하고 결과적으로 중단될 수 있습니다.
  • Void 노드에 자식 노드가 없는 경우(예: 이미지 노드), 여전히 children 을 렌더링하고 Void의 선택 지점으로 처리되는 빈 텍스트 노드를 자식으로 제공해야 합니다(아래 ExampleDocument 참조). SlateJS의 요소

이제 ExampleDocument 를 업데이트하여 이미지를 추가하고 편집기에서 캡션과 함께 표시되는지 확인합니다.

 # src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ]; 
편집기에서 렌더링된 이미지
편집기에서 렌더링된 이미지입니다. (큰 미리보기)

이제 캡션 편집에 집중해 보겠습니다. 사용자에게 원활한 경험을 제공하는 방법은 사용자가 캡션을 클릭할 때 캡션을 편집할 수 있는 텍스트 입력을 표시하는 것입니다. 입력 외부를 클릭하거나 RETURN 키를 누르면 캡션을 적용하기 위한 확인으로 처리됩니다. 그런 다음 이미지 노드의 캡션을 업데이트하고 캡션을 다시 읽기 모드로 전환합니다. 우리가 무엇을 만들고 있는지 알 수 있도록 실제로 작동하는 모습을 봅시다.

이미지 캡션 편집 중입니다.

캡션의 읽기-편집 모드에 대한 상태를 갖도록 Image 구성 요소를 업데이트하겠습니다. 사용자가 업데이트할 때 로컬 캡션 상태를 업데이트하고 사용자가 클릭( onBlur )하거나 RETURN( onKeyDown )을 누르면 캡션을 노드에 적용하고 읽기 모드로 다시 전환합니다.

 const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...

이것으로 캡션 편집 기능이 완성되었습니다. 이제 사용자가 편집기에 이미지를 업로드할 수 있는 방법을 추가하는 단계로 넘어갑니다. 사용자가 이미지를 선택하고 업로드할 수 있는 도구 모음 버튼을 추가해 보겠습니다.

 # src/components/Toolbar.js const onImageSelected = useImageUploadHandler(editor, previousSelection); return ( <div className="toolbar"> .... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> <i className={`bi ${getIconForButton("image")}`} /> <input type="file" className="image-upload-input" accept="image/png, image/jpeg" onChange={onImageSelected} /> </> } /> </div>

이미지 업로드로 작업할 때 코드가 상당히 커질 수 있으므로 이미지 업로드 처리를 파일 입력 요소에 연결된 콜백을 제공하는 후크 useImageUploadHandler 로 이동합니다. previousSelection 상태가 필요한 이유에 대해 간단히 논의할 것입니다.

useImageUploadHandler 를 구현하기 전에 이미지를 업로드할 수 있도록 서버를 설정합니다. 우리는 Express 서버를 설정하고 파일 업로드를 처리하는 corsmulter 라는 두 개의 다른 패키지를 설치합니다.

 yarn add express cors multer

그런 다음 cors 및 multer로 Express 서버를 구성하고 이미지를 업로드할 엔드포인트 /upload 를 노출하는 src/server.js 스크립트를 추가합니다.

 # src/server.js const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "./public/photos/"); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); var upload = multer({ storage: storage }).single("photo"); app.post("/upload", function (req, res) { upload(req, res, function (err) { if (err instanceof multer.MulterError) { return res.status(500).json(err); } else if (err) { return res.status(500).json(err); } return res.status(200).send(req.file); }); }); app.use(cors()); app.listen(port, () => console.log(`Listening on port ${port}`));

이제 서버 설정이 완료되었으므로 이미지 업로드 처리에 집중할 수 있습니다. 사용자가 이미지를 업로드할 때 이미지가 업로드되고 URL이 생성되기까지 몇 초가 걸릴 수 있습니다. 그러나 우리는 사용자에게 이미지 업로드가 진행 중이라는 즉각적인 피드백을 제공하여 이미지가 편집기에 삽입되고 있음을 알 수 있도록 합니다. 이 동작이 작동하도록 구현하는 단계는 다음과 같습니다.

  1. 사용자가 이미지를 선택하면 isUploading 플래그가 설정된 사용자의 커서 위치에 이미지 노드를 삽입하여 사용자에게 로딩 상태를 표시할 수 있습니다.
  2. 서버에 이미지 업로드 요청을 보냅니다.
  3. 요청이 완료되고 이미지 URL이 있으면 이미지에 설정하고 로드 상태를 제거합니다.

이미지 노드를 삽입하는 첫 번째 단계부터 시작하겠습니다. 이제 여기에서 까다로운 부분은 도구 모음의 링크 버튼과 동일한 선택 문제가 발생한다는 것입니다. 사용자가 도구 모음에서 이미지 버튼을 클릭하자마자 편집기는 포커스를 잃고 선택 항목이 null 이 됩니다. 이미지를 삽입하려고 하면 사용자의 커서가 어디에 있는지 알 수 없습니다. previousSelection 선택을 추적하면 해당 위치가 제공되고 이를 사용하여 노드를 삽입합니다.

 # src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }

새 이미지 노드를 삽입할 때 uuid 패키지를 사용하여 식별자 id 도 할당합니다. 이것이 필요한 이유는 (3) 단계의 구현에서 논의할 것입니다. 이제 isUploading 플래그를 사용하여 로드 상태를 표시하도록 이미지 구성 요소를 업데이트합니다.

 {!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}

그러면 1단계의 구현이 완료됩니다. 업로드할 이미지를 선택할 수 있는지 확인하고 문서에 삽입된 로드 표시기와 함께 이미지 노드가 삽입되는 것을 확인합니다.

로딩 상태로 이미지 노드를 생성하는 이미지 업로드.

(2) 단계로 이동하여 axois 라이브러리를 사용하여 서버에 요청을 보냅니다.

 export default function useImageUploadHandler(editor, previousSelection) { return useCallback((event) => { .... Transforms.insertNodes( … {at: previousSelection, select: true} ); axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { // update the image node. }) .catch((error) => { // Fire another Transform.setNodes to set an upload failed state on the image }); }, [...]); }

이미지 업로드가 작동하고 이미지가 앱의 public/photos 폴더에 표시되는지 확인합니다. 이제 이미지 업로드가 완료되었으므로 axios promise의 resolve() 함수에서 이미지에 대한 URL을 설정하려는 (3) 단계로 이동합니다. Transforms.setNodes 로 이미지를 업데이트할 수 있지만 문제가 있습니다. 새로 삽입된 이미지 노드에 대한 경로가 없습니다. 해당 이미지에 도달하기 위한 옵션이 무엇인지 살펴보겠습니다.

  • 선택 항목이 새로 삽입된 이미지 노드에 있어야 하므로 editor.selection 을 사용할 수 없습니까? 이미지가 업로드되는 동안 사용자가 다른 곳을 클릭하여 선택 항목이 변경되었을 수 있으므로 이를 보장할 수 없습니다.
  • 먼저 이미지 노드를 삽입할 때 사용한 previousSelection 을 사용하는 것은 어떻습니까? 같은 이유로 우리는 editor.selection 을 사용할 수 없습니다. previousSelection 선택도 변경되었을 수 있으므로 사용할 수 없습니다.
  • SlateJS에는 문서에 발생하는 모든 변경 사항을 추적하는 기록 모듈이 있습니다. 이 모듈을 사용하여 기록을 검색하고 마지막으로 삽입된 이미지 노드를 찾을 수 있습니다. 이미지를 업로드하는 데 시간이 더 오래 걸리고 사용자가 첫 번째 업로드가 완료되기 전에 문서의 다른 부분에 더 많은 이미지를 삽입한 경우에도 이것은 완전히 신뢰할 수 없습니다.
  • 현재 Transform.insertNodes 의 API는 삽입된 노드에 대한 정보를 반환하지 않습니다. 삽입된 노드에 대한 경로를 반환할 수 있다면 이를 사용하여 업데이트해야 하는 정확한 이미지 노드를 찾을 수 있습니다.

위의 접근 방식 중 어느 것도 작동하지 않기 때문에 삽입된 이미지 노드에 id 를 적용하고(단계 (1)에서) 이미지 업로드가 완료되면 동일한 id 를 다시 사용하여 찾습니다. 이를 통해 단계 (3)에 대한 코드는 다음과 같습니다.

 axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { const newImageEntry = Editor.nodes(editor, { match: (n) => n.id === id, }); if (newImageEntry == null) { return; } Transforms.setNodes( editor, { isUploading: false, url: `/photos/${fileName}` }, { at: newImageEntry[1] } ); }) .catch((error) => { // Fire another Transform.setNodes to set an upload failure state // on the image. });

세 단계의 구현이 모두 완료되면 이미지 업로드를 끝에서 끝까지 테스트할 준비가 되었습니다.

종단 간 작업 이미지 업로드

그것으로 우리는 편집기용 이미지를 마무리했습니다. 현재는 이미지와 상관없이 동일한 크기의 로딩 상태를 보여주고 있습니다. 업로드가 완료될 때 로드 상태가 훨씬 더 작거나 큰 이미지로 바뀌면 사용자에게 혼란스러운 경험이 될 수 있습니다. 업로드 경험에 대한 좋은 후속 조치는 업로드 전에 이미지 크기를 가져오고 해당 크기의 자리 표시자를 표시하여 원활하게 전환할 수 있도록 하는 것입니다. 위에서 추가한 후크는 비디오 또는 문서와 같은 다른 미디어 유형을 지원하고 이러한 유형의 노드도 렌더링하도록 확장될 수 있습니다.

결론

이 기사에서는 기본 기능 세트와 링크 감지, 내부 링크 편집 및 이미지 캡션 편집과 같은 일부 마이크로 사용자 경험을 갖춘 WYSIWYG 편집기를 구축하여 SlateJS 및 서식 있는 텍스트 편집의 개념을 더 깊이 있게 이해할 수 있었습니다. 일반. 서식 있는 텍스트 편집 또는 워드 프로세싱을 둘러싼 이 문제 공간에 관심이 있다면 다음과 같은 멋진 문제를 해결할 수 있습니다.

  • 협동
  • 텍스트 정렬, 인라인 이미지, 복사하여 붙여넣기, 글꼴 및 텍스트 색상 변경 등을 지원하는 더욱 풍부한 텍스트 편집 경험
  • Word 문서 및 Markdown과 같은 인기 있는 형식에서 가져오기.

SlateJS에 대해 더 알고 싶다면 다음 링크가 도움이 될 수 있습니다.

  • SlateJS 예제
    검색 및 강조 표시, 마크다운 미리 보기 및 멘션과 같은 편집기에서 일반적으로 발견되는 기본 및 빌드 기능을 넘어선 많은 예제.
  • API 문서
    SlateJS 객체에 대한 복잡한 쿼리/변환을 수행하려고 할 때 편리하게 유지하고 싶어할 수 있는 SlateJS에 의해 노출된 많은 도우미 함수에 대한 참조입니다.

마지막으로 SlateJS의 Slack 채널은 SlateJS를 사용하여 서식 있는 텍스트 편집 응용 프로그램을 구축하는 웹 개발자의 매우 활발한 커뮤니티이며 라이브러리에 대해 자세히 알아보고 필요한 경우 도움을 받을 수 있는 좋은 장소입니다.