WYSIWYG 편집기에 주석 시스템 추가하기
게시 됨: 2022-03-10최근 몇 년 동안 우리는 협업이 많은 직업에 걸쳐 많은 디지털 워크플로와 사용 사례에 침투하는 것을 보았습니다. 디자인 및 소프트웨어 엔지니어링 커뮤니티 내에서 디자이너는 Figma와 같은 도구를 사용하여 디자인 아티팩트에 대해 협업하고, Mural과 같은 도구를 사용하여 스프린트 및 프로젝트 계획을 수행하는 팀과 CoderPad를 사용하여 인터뷰를 진행하는 것을 볼 수 있습니다. 이러한 모든 도구는 이러한 워크플로를 실행하고 가능한 한 풍부하고 원활한 협업 환경을 만드는 온라인 경험과 실제 세계 경험 사이의 격차를 해소하는 것을 지속적으로 목표로 삼고 있습니다.
이와 같은 대부분의 공동 작업 도구에서 서로 의견을 공유하고 동일한 콘텐츠에 대해 토론할 수 있는 기능은 필수 요소입니다. 공동 작업자가 문서의 일부에 주석을 달고 이에 대해 대화할 수 있도록 하는 주석 시스템이 이 개념의 핵심입니다. WYSIWYG Editor에서 텍스트용을 구축하는 것과 함께 이 기사는 WYSIWYG Editor 또는 WYSIWYG Editor 또는 일반적으로 워드 프로세서.
문서 구조에서 주석 표현하기
서식 있는 텍스트 문서의 데이터 구조에서 주석을 표현하는 방법을 찾기 위해 편집기 내에서 주석을 작성할 수 있는 몇 가지 시나리오를 살펴보겠습니다.
- 스타일이 없는 텍스트 위에 작성된 주석(기본 시나리오)
- 볼드체/이탤릭체/밑줄 등이 있는 텍스트 위에 작성된 주석.
- 어떤 식으로든 서로 겹치는 주석(두 개의 주석이 몇 단어만 공유하는 경우 부분적으로 겹치거나 한 주석의 텍스트가 다른 주석의 텍스트 내에 완전히 포함된 경우 완전히 포함됨)
- 링크 안의 텍스트 위에 작성된 주석(링크가 문서 구조에서 노드 자체이기 때문에 특히 그렇습니다).
- 여러 단락에 걸쳐 있는 주석(단락은 문서 구조의 노드이고 주석은 단락의 자식인 텍스트 노드에 적용되기 때문에 특히 그렇습니다).
위의 사용 사례를 보면 서식 있는 텍스트 문서에 나타날 수 있는 방식의 주석이 문자 스타일(굵게, 기울임꼴 등)과 매우 유사합니다. 그것들은 서로 겹칠 수 있고 링크와 같은 다른 유형의 노드에서 텍스트를 살펴보고 단락과 같은 여러 부모 노드에 걸쳐 있을 수도 있습니다.
이러한 이유로 우리는 문자 스타일, 즉 "마크"(SlateJS 용어로 소위 말하는)와 같은 주석을 표현하는 데 동일한 방법을 사용합니다. 마크는 노드의 일반 속성일 뿐입니다. 마크에 대한 Slate의 API( Editor.addMark
및 Editor.removeMark
)는 여러 마크가 동일한 텍스트 범위에 적용될 때 노드 계층 구조의 변경을 처리한다는 특징이 있습니다. 이것은 겹치는 주석의 다양한 조합을 다룰 때 매우 유용합니다.
스레드를 표시로 주석 처리
사용자가 텍스트 범위를 선택하고 주석을 삽입하려고 할 때마다 기술적으로 해당 텍스트 범위에 대한 새 주석 스레드가 시작됩니다. 우리는 그들이 주석을 삽입하고 나중에 해당 주석에 응답할 수 있도록 허용하기 때문에 이 이벤트를 문서에 새 주석 스레드 삽입으로 처리합니다.
주석 스레드를 표시로 나타내는 방법은 각 주석 스레드가 commentThread_threadID
라는 이름의 표시로 표시되는 것입니다. 여기서 threadID
는 각 주석 스레드에 할당하는 고유 ID입니다. 따라서 동일한 텍스트 범위에 두 개의 주석 스레드가 있는 true
로 설정된 두 개의 속성( commentThread_thread1
및 commentThread_thread2
있습니다. 동일한 텍스트가 굵게 및 기울임꼴인 경우 두 속성이 모두 true
( bold
및 italic
)로 설정되기 때문에 주석 스레드가 문자 스타일과 매우 유사한 곳입니다.
이 구조를 실제로 설정하기 전에 주석 스레드가 적용될 때 텍스트 노드가 어떻게 변경되는지 살펴보는 것이 좋습니다. 이것이 작동하는 방식(모든 마크와 마찬가지로)은 마크 속성이 선택한 텍스트에 설정될 때 Slate의 Editor.addMark API가 필요한 경우 텍스트 노드를 분할하여 결과 구조에서 텍스트 노드가 각 텍스트 노드가 동일한 마크 값을 갖도록 설정됩니다.
이를 더 잘 이해하려면 선택한 텍스트에 주석 스레드가 삽입되면 텍스트 노드의 전후 상태를 보여주는 다음 세 가지 예를 살펴보십시오.
주석이 달린 텍스트 강조 표시
이제 문서 구조에서 주석을 표현하는 방법을 알았으므로 첫 번째 기사의 예제 문서에 몇 가지를 추가하고 실제로 강조 표시된 것으로 표시하도록 편집기를 구성하겠습니다. 이 기사에서 주석을 처리하는 유틸리티 함수가 많기 때문에 이러한 모든 유틸리티를 수용할 EditorCommentUtils
모듈을 만듭니다. 우선, 주어진 코멘트 스레드 ID에 대한 마크를 생성하는 함수를 생성합니다. 그런 다음 이를 사용하여 ExampleDocument
에 몇 개의 주석 스레드를 삽입합니다.
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
아래 이미지는 다음 코드 조각에 추가된 예제 주석 스레드로 있는 텍스트 범위에 빨간색 밑줄을 긋습니다. 'Richard McClintock' 텍스트에는 서로 겹치는 두 개의 주석 스레드가 있습니다. 특히, 이것은 하나의 주석 스레드가 다른 스레드 안에 완전히 포함된 경우입니다.
# src/utils/ExampleDocument.js import { getMarkForCommentThreadID } from "../utils/EditorCommentUtils"; import { v4 as uuid } from "uuid"; const exampleOverlappingCommentThreadID = uuid(); const ExampleDocument = [ ... { text: "Lorem ipsum", [getMarkForCommentThreadID(uuid())]: true, }, ... { text: "Richard McClintock", // note the two comment threads here. [getMarkForCommentThreadID(uuid())]: true, [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, { text: ", a Latin scholar", [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, ... ];
이 기사에서는 주석 시스템의 UI 측면에 초점을 맞추므로 npm 패키지 uuid를 사용하여 예제 문서에서 직접 ID를 할당합니다. 프로덕션 버전의 편집기에서 이러한 ID는 백엔드 서비스에서 생성될 가능성이 매우 높습니다.
이제 이러한 텍스트 노드를 강조 표시하도록 편집기를 조정하는 데 중점을 둡니다. 그렇게 하려면 텍스트 노드를 렌더링할 때 주석 스레드가 있는지 여부를 알 수 있는 방법이 필요합니다. 이를 위해 util getCommentThreadsOnTextNode
를 추가합니다. 첫 번째 기사에서 만든 StyledText
구성 요소를 기반으로 주석이 있는 텍스트 노드를 렌더링하려는 경우를 처리합니다. 나중에 주석이 달린 텍스트 노드에 추가될 기능이 몇 가지 더 있으므로 주석이 달린 텍스트를 렌더링하는 CommentedText
구성 요소를 만듭니다. StyledText
는 렌더링하려는 텍스트 노드에 주석이 있는지 확인합니다. 그렇다면 CommentedText
를 렌더링합니다. 이를 추론하기 위해 util getCommentThreadsOnTextNode
를 사용합니다.
# src/utils/EditorCommentUtils.js export function getCommentThreadsOnTextNode(textNode) { return new Set( // Because marks are just properties on nodes, // we can simply use Object.keys() here. Object.keys(textNode) .filter(isCommentThreadIDMark) .map(getCommentThreadIDFromMark) ); } export function getCommentThreadIDFromMark(mark) { if (!isCommentThreadIDMark(mark)) { throw new Error("Expected mark to be of a comment thread"); } return mark.replace(COMMENT_THREAD_PREFIX, ""); } function isCommentThreadIDMark(mayBeCommentThread) { return mayBeCommentThread.indexOf(COMMENT_THREAD_PREFIX) === 0; }
첫 번째 기사에서는 텍스트 노드(문자 스타일 처리 등)를 렌더링하는 구성 요소 StyledText
를 구축했습니다. 위의 유틸리티를 사용하도록 해당 구성 요소를 확장하고 노드에 주석이 있는 경우 CommentedText
구성 요소를 렌더링합니다.
# src/components/StyledText.js import { getCommentThreadsOnTextNode } from "../utils/EditorCommentUtils"; export default function StyledText({ attributes, children, leaf }) { ... const commentThreads = getCommentThreadsOnTextNode(leaf); if (commentThreads.size > 0) { return ( <CommentedText {...attributes} // We use commentThreads and textNode props later in the article. commentThreads={commentThreads} textNode={leaf} > {children} </CommentedText> ); } return <span {...attributes}>{children}</span>; }
아래는 텍스트 노드를 렌더링하고 강조 표시된 CSS를 첨부하는 CommentedText
의 구현입니다.
# src/components/CommentedText.js import "./CommentedText.css"; import classNames from "classnames"; export default function CommentedText(props) { const { commentThreads, ...otherProps } = props; return ( <span {...otherProps} className={classNames({ comment: true, })} > {props.children} </span> ); } # src/components/CommentedText.css .comment { background-color: #feeab5; }
위의 모든 코드가 함께 제공되면 이제 편집기에서 강조 표시된 주석 스레드가 있는 텍스트 노드가 표시됩니다.
참고 : 현재 사용자는 특정 텍스트에 중복되는 주석이 있는지 여부를 알 수 없습니다. 강조 표시된 전체 텍스트 범위는 단일 주석 스레드처럼 보입니다. 사용자가 특정 주석 스레드를 선택하고 편집기에서 해당 범위를 볼 수 있도록 하는 활성 주석 스레드의 개념을 소개하는 기사 뒷부분에서 이에 대해 설명합니다.
주석용 UI 저장소
사용자가 새 주석을 삽입할 수 있는 기능을 추가하기 전에 먼저 주석 스레드를 보유할 UI 상태를 설정합니다. 이 기사에서는 RecoilJS를 상태 관리 라이브러리로 사용하여 주석 스레드, 스레드 내부에 포함된 주석 및 생성 시간, 상태, 주석 작성자 등과 같은 기타 메타데이터를 저장합니다. 애플리케이션에 Recoil을 추가해 보겠습니다.
> yarn add recoil
우리는 이 두 데이터 구조를 저장하기 위해 Recoil atom을 사용합니다. Recoil에 익숙하지 않은 경우 원자는 응용 프로그램 상태를 유지하는 것입니다. 애플리케이션 상태의 다른 부분에 대해 일반적으로 다른 원자를 설정하기를 원할 것입니다. Atom Family는 원자의 모음입니다. 원자를 식별하는 고유 키에서 원자 자체에 대한 Map
으로 생각할 수 있습니다. 이 시점에서 Recoil의 핵심 개념을 살펴보고 익숙해지는 것이 좋습니다.
사용 사례의 경우 주석 스레드를 Atom 제품군으로 저장한 다음 애플리케이션을 RecoilRoot
구성 요소에 래핑합니다. RecoilRoot
는 원자 값이 사용될 컨텍스트를 제공하기 위해 적용됩니다. 이 기사의 뒷부분에서 더 많은 원자 정의를 추가할 때 Recoil 원자 정의를 보유하는 별도의 모듈 CommentState
를 만듭니다.
# src/utils/CommentState.js import { atom, atomFamily } from "recoil"; export const commentThreadsState = atomFamily({ key: "commentThreads", default: [], }); export const commentThreadIDsState = atom({ key: "commentThreadIDs", default: new Set([]), });
이러한 원자 정의에 대해 몇 가지를 언급할 가치가 있습니다.
- 각 원자/원자 패밀리는
key
고유하게 식별되며 기본값으로 설정할 수 있습니다. - 이 기사에서 추가로 작성함에 따라 모든 주석 스레드를 반복하는 방법이 필요합니다. 이는 기본적으로
commentThreadsState
원자 패밀리를 반복하는 방법이 필요함을 의미합니다. 이 기사를 작성하는 시점에서 Recoil을 사용하여 이를 수행하는 방법은 원자 패밀리의 모든 ID를 보유하는 또 다른 원자를 설정하는 것입니다. 위의commentThreadIDsState
를 사용하여 이를 수행합니다. 이 두 원자는 주석 스레드를 추가/삭제할 때마다 동기화된 상태로 유지되어야 합니다.
나중에 이러한 원자를 사용할 수 있도록 루트 App
구성 요소에 RecoilRoot
래퍼를 추가합니다. Recoil의 문서는 또한 있는 그대로 가져 와서 편집기에 드롭하는 유용한 디버거 구성 요소를 제공합니다. 이 구성 요소는 Recoil 원자가 실시간으로 업데이트됨에 따라 console.debug
로그를 Dev 콘솔에 남깁니다.
# src/components/App.js import { RecoilRoot } from "recoil"; export default function App() { ... return ( <RecoilRoot> > ... <Editor document={document} onChange={updateDocument} /> </RecoilRoot> ); }
# src/components/Editor.js export default function Editor({ ... }): JSX.Element { ..... return ( <> <Slate> ..... </Slate> <DebugObserver /> </> ); function DebugObserver(): React.Node { // see API link above for implementation. }
또한 문서에 이미 있는 주석 스레드(예: 이전 섹션에서 예제 문서에 추가한 스레드)로 원자를 초기화하는 코드를 추가해야 합니다. 문서의 모든 주석 스레드를 읽어야 하는 주석 사이드바를 만들 때 나중에 이 작업을 수행합니다.
이 시점에서 애플리케이션을 로드하고 Recoil 설정을 가리키는 오류가 없는지 확인하고 계속 진행합니다.
새 댓글 추가
이 섹션에서는 사용자가 선택한 텍스트 범위에 대해 주석을 추가할 수 있는 버튼을 도구 모음에 추가합니다(즉, 새 주석 스레드 생성). 사용자가 텍스트 범위를 선택하고 이 버튼을 클릭하면 다음을 수행해야 합니다.
- 삽입되는 새 댓글 스레드에 고유 ID를 할당합니다.
- 사용자가 강조 표시된 텍스트를 볼 수 있도록 ID를 사용하여 Slate 문서 구조에 새 표시를 추가합니다.
- 이전 섹션에서 만든 Recoil atom에 새 주석 스레드를 추가합니다.
EditorCommentUtils
에 1번과 2번을 수행하는 util 함수를 추가해 보겠습니다.
# src/utils/EditorCommentUtils.js import { Editor } from "slate"; import { v4 as uuidv4 } from "uuid"; export function insertCommentThread(editor, addCommentThreadToState) { const threadID = uuidv4(); const newCommentThread = { // comments as added would be appended to the thread here. comments: [], creationTime: new Date(), // Newly created comment threads are OPEN. We deal with statuses // later in the article. status: "open", }; addCommentThreadToState(threadID, newCommentThread); Editor.addMark(editor, getMarkForCommentThreadID(threadID), true); return threadID; }
마크의 개념을 사용하여 각 주석 스레드를 자체 마크로 저장하면 Editor.addMark
API를 사용하여 선택한 텍스트 범위에 새 주석 스레드를 추가할 수 있습니다. 이 호출만으로도 주석을 추가하는 모든 다양한 경우를 처리합니다. 그 중 일부는 이전 섹션에서 설명했습니다. 부분적으로 중첩되는 주석, 링크 내부/중첩되는 주석, 볼드체/이탤릭체에 대한 주석, 단락 전체에 걸친 주석 등입니다. 이 API 호출은 이러한 경우를 처리하는 데 필요한 만큼의 새 텍스트 노드를 생성하도록 노드 계층을 조정합니다.
addCommentThreadToState
는 Recoil atom에 새 주석 스레드를 추가하는 3단계를 처리하는 콜백 함수입니다. 재사용할 수 있도록 다음을 사용자 정의 콜백 후크로 구현합니다. 이 콜백은 두 개의 아톰( commentThreadsState
및 commentThreadIDsState
)에 새 주석 스레드를 추가해야 합니다. 이를 수행하기 위해 useRecoilCallback
후크를 사용합니다. 이 후크는 원자 데이터를 읽고 설정하는 데 사용할 수 있는 몇 가지를 가져오는 콜백을 구성하는 데 사용할 수 있습니다. 지금 우리가 관심 있는 것은 원자 값을 set(atom, newValueOrUpdaterFunction)
으로 업데이트하는 데 사용할 수 있는 set
함수입니다.
# src/hooks/useAddCommentThreadToState.js import { commentThreadIDsState, commentThreadsState, } from "../utils/CommentState"; import { useRecoilCallback } from "recoil"; export default function useAddCommentThreadToState() { return useRecoilCallback( ({ set }) => (id, threadData) => { set(commentThreadIDsState, (ids) => new Set([...Array.from(ids), id])); set(commentThreadsState(id), threadData); }, [] ); }
set
에 대한 첫 번째 호출은 기존 주석 스레드 ID 집합에 새 ID를 추가하고 새 Set
(이는 원자의 새 값이 됨)을 반환합니다.
두 번째 호출에서 우리는 atom family에서 ID에 대한 atom을 얻습니다. commentThreadsState
를 commentThreadsState(id)
로 지정한 다음 threadData
를 값으로 설정합니다. atomFamilyName(atomID)
은 Recoil이 고유 키를 사용하여 원자 패밀리에서 원자에 액세스할 수 있도록 하는 방법입니다. 느슨하게 말해서, commentThreadsState
가 javascript Map이라면 이 호출은 기본적으로 - commentThreadsState.set(id, threadData)
라고 말할 수 있습니다.
이제 문서 및 Recoil 원자에 대한 새 주석 스레드의 삽입을 처리하기 위한 이 모든 코드 설정이 있으므로 도구 모음에 버튼을 추가하고 이러한 기능에 대한 호출과 연결해 보겠습니다.
# src/components/Toolbar.js import { insertCommentThread } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); ... const addCommentThread = useAddCommentThreadToState(); const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); }, [editor, addCommentThread]); return ( <div className="toolbar"> ... <ToolBarButton isActive={false} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> ); }
참고 : 우리는 onClick
이 아닌 onMouseDown
을 사용합니다. 그러면 편집기가 포커스를 잃고 선택 항목이 null
이 됩니다. 우리는 첫 번째 기사의 링크 삽입 섹션에서 조금 더 자세히 논의했습니다.
아래 예에서 간단한 주석 스레드와 링크가 있는 겹치는 주석 스레드에 대한 삽입 작업을 볼 수 있습니다. 상태가 올바르게 업데이트되고 있음을 확인하는 리코일 디버거에서 업데이트를 받는 방법을 확인하십시오. 또한 스레드가 문서에 추가될 때 새 텍스트 노드가 생성되는지 확인합니다.
겹치는 댓글
주석 시스템에 더 많은 기능을 추가하기 전에 편집기에서 겹치는 주석과 다양한 조합을 처리하는 방법에 대한 몇 가지 결정을 내려야 합니다. 왜 필요한지 알아보기 위해 댓글 팝오버가 작동하는 방식을 살짝 살펴보겠습니다. 이 기능은 이 기사의 뒷부분에서 빌드할 것입니다. 사용자가 댓글 스레드가 있는 특정 텍스트를 클릭하면 댓글 스레드를 '선택'하고 사용자가 해당 스레드에 댓글을 추가할 수 있는 팝오버를 표시합니다.
위 영상에서 알 수 있듯이 '디자이너'라는 단어는 이제 세 개의 댓글 스레드에 포함됩니다. 따라서 단어에 대해 서로 겹치는 두 개의 주석 스레드가 있습니다. 그리고 이 두 주석 스레드(#1 및 #2)는 더 긴 주석 스레드 텍스트 범위(#3)에 완전히 포함되어 있습니다. 이것은 몇 가지 질문을 제기합니다.
- 사용자가 '디자이너'라는 단어를 클릭할 때 어떤 댓글 스레드를 선택하여 표시해야 하나요?
- 위의 질문을 처리하기로 결정한 방법에 따라 단어를 클릭해도 특정 댓글 스레드가 활성화되지 않고 스레드에 전혀 액세스할 수 없는 중복 사례가 있습니까?
이는 주석이 겹치는 경우 고려해야 할 가장 중요한 사항은 — 사용자가 주석 스레드를 삽입한 후 내부의 일부 텍스트를 클릭하여 나중에 해당 주석 스레드를 선택할 수 있는 방법이 있는지 여부입니다. 그것? 그렇지 않은 경우 처음부터 삽입하는 것을 허용하고 싶지 않을 것입니다. 이 원칙이 에디터에서 대부분 존중되도록 하기 위해 중복 댓글에 관한 두 가지 규칙을 도입하고 에디터에서 구현합니다.
이러한 규칙을 정의하기 전에 편집자와 워드 프로세서마다 중복되는 주석에 대한 접근 방식이 다르다는 점을 언급할 가치가 있습니다. 일을 단순하게 유지하기 위해 일부 편집자는 중복 주석을 허용하지 않습니다. 우리의 경우 너무 복잡한 중복 사례를 허용하지 않고 사용자가 더 풍부한 협업 및 검토 경험을 가질 수 있도록 중복 댓글을 허용하여 중간 지점을 찾으려고 합니다.
최단 주석 범위 규칙
이 규칙은 사용자가 여러 주석 스레드가 있는 텍스트 노드를 클릭하는 경우 선택할 주석 스레드에 대한 위의 질문 #1에 답하는 데 도움이 됩니다. 규칙은 다음과 같습니다.
"사용자가 여러 개의 댓글 스레드가 있는 텍스트를 클릭하면 가장 짧은 텍스트 범위의 댓글 스레드를 찾아 선택합니다."
직관적으로, 사용자가 항상 다른 주석 스레드 안에 완전히 포함된 가장 안쪽 주석 스레드에 접근할 수 있도록 이렇게 하는 것이 합리적입니다. 다른 조건(부분 겹침 또는 겹치지 않음)의 경우 주석 스레드가 하나만 있는 텍스트가 있어야 해당 주석 스레드를 선택하기 위해 해당 텍스트를 쉽게 사용할 수 있습니다. 스레드의 전체(또는 조밀한 ) 겹침의 경우이며 이 규칙이 필요한 이유입니다.
주석 스레드를 선택할 때 이 규칙을 사용하고 '올바른 일을 하도록' 허용하는 다소 복잡한 중복 사례를 살펴보겠습니다.
위의 예에서 사용자는 다음 주석 스레드를 순서대로 삽입합니다.
- 'B' 문자(길이 = 1)에 대한 스레드 #1을 주석 처리합니다.
- 댓글 스레드 #2 'AB'(길이 = 2).
- 'BC'(길이 = 2)에 대한 스레드 #3에 댓글을 답니다.
이러한 삽입이 끝나면 Slate가 텍스트 노드를 표시로 분할하는 방식 때문에 각 문자에 대해 하나씩 3개의 텍스트 노드를 갖게 됩니다. 이제 사용자가 'B'를 클릭하면 최단 길이 규칙에 따라 3개의 스레드 중 길이가 가장 짧은 1번 스레드를 선택합니다. 그렇게 하지 않으면 주석 스레드 #1을 선택할 수 없습니다. 길이가 1자이고 다른 두 스레드의 일부이기 때문입니다.
이 규칙을 사용하면 더 짧은 길이의 주석 스레드를 쉽게 표시할 수 있지만 더 긴 주석 스레드에 포함된 모든 문자가 다른 더 짧은 주석 스레드의 일부이기 때문에 더 긴 주석 스레드에 액세스할 수 없는 상황이 발생할 수 있습니다. 이에 대한 예를 살펴보겠습니다.
100개의 문자(예: 문자 'A'가 100번 입력됨)가 있고 사용자가 다음 순서로 주석 스레드를 삽입한다고 가정해 보겠습니다.
- 범위 20,80 중 스레드 # 1 댓글
- 범위 0,50의 스레드 # 2
- 범위 51,100 중 3번 스레드
위의 예에서 볼 수 있듯이 여기에서 설명한 규칙을 따르면 #20과 #80 사이의 문자를 클릭하면 #1보다 짧고 따라서 #1이기 때문에 항상 스레드 #2 또는 #3이 선택됩니다. 선택할 수 없습니다. 이 규칙으로 인해 어떤 주석 스레드를 선택할지 결정할 수 없는 또 다른 시나리오는 텍스트 노드에 동일한 최단 길이의 주석 스레드가 두 개 이상 있는 경우입니다.
이 규칙을 따르면 텍스트를 클릭하여 특정 댓글 스레드에 액세스할 수 없도록 하는 위치를 생각할 수 있는 겹치는 댓글 및 기타 여러 조합의 경우, 이 기사 뒷부분에서 사용자에게 모든 댓글 스레드를 볼 수 있는 댓글 사이드바를 구축합니다. 문서에 표시되어 사이드바에서 해당 스레드를 클릭하고 편집기에서 활성화하여 주석 범위를 볼 수 있습니다. 위에서 언급한 가능성이 낮은 예를 제외하고 많은 중복 시나리오를 다루어야 하므로 이 규칙을 계속 사용하고 구현하기를 원할 것입니다. 우리는 주로 편집기에서 강조 표시된 텍스트를 보고 클릭하여 주석을 추가하는 것이 사이드바의 주석 목록을 사용하는 것보다 텍스트에 대한 주석에 액세스하는 더 직관적인 방법이기 때문에 주로 이 규칙에 대해 모든 노력을 기울였습니다.
삽입 규칙
규칙은 다음과 같습니다.
"사용자가 선택하고 댓글을 작성하려는 텍스트가 이미 댓글 스레드(들)에 의해 완전히 덮여 있다면 해당 삽입을 허용하지 마십시오."
이것은 우리가 이 삽입을 허용했다면 해당 범위의 각 문자에 최소한 두 개의 주석 스레드(기존 하나와 방금 허용한 새 주석 스레드)가 생겨서 언제 선택할 것인지 결정하기 어렵기 때문입니다. 사용자가 나중에 해당 문자를 클릭합니다.
이 규칙을 보면 가장 작은 텍스트 범위를 선택할 수 있는 Shortest Comment Range Rule이 이미 있는 경우 처음부터 왜 필요한지 의아해 할 수 있습니다. 첫 번째 규칙을 사용하여 표시할 올바른 댓글 스레드를 추론할 수 있다면 모든 중복 조합을 허용하지 않는 이유는 무엇입니까? 앞에서 논의한 몇 가지 예와 같이 첫 번째 규칙은 많은 시나리오에서 작동하지만 전부는 아닙니다. 삽입 규칙을 사용하여 첫 번째 규칙이 도움이 되지 않는 시나리오의 수를 최소화하려고 노력하고 사용자가 해당 댓글 스레드에 액세스할 수 있는 유일한 방법으로 사이드바를 대체해야 합니다. 삽입 규칙은 또한 주석 스레드의 정확한 중복을 방지합니다. 이 규칙은 일반적으로 많은 인기 있는 편집자들에 의해 구현됩니다.
아래는 이 규칙이 존재하지 않는 경우 주석 스레드 #3을 허용한 다음 첫 번째 규칙의 결과로 #3의 길이가 가장 길어지기 때문에 액세스할 수 없는 예입니다.
참고 : 이 규칙이 있다고 해서 중복되는 주석을 완전히 포함하지 않았을 것이라는 의미는 아닙니다. 겹치는 주석에 대한 까다로운 점은 규칙에도 불구하고 주석이 삽입되는 순서가 여전히 우리가 원하지 않는 상태로 남아 있을 수 있다는 것입니다. '디자이너'라는 단어에 대한 주석의 예를 다시 참조 ' 이전에는 거기에 삽입된 가장 긴 주석 스레드가 마지막으로 추가되어 삽입 규칙이 허용하므로 결국 #1과 #2가 #3 안에 포함된 완전히 포함된 상황이 됩니다. Shortest Comment Range Rule이 도움이 되기 때문에 괜찮습니다.
주석 스레드 선택을 구현하는 다음 섹션에서 최단 주석 범위 규칙을 구현합니다. 이제 주석을 삽입할 수 있는 도구 모음 버튼이 있으므로 사용자가 일부 텍스트를 선택했을 때 규칙을 확인하여 삽입 규칙을 즉시 구현할 수 있습니다. 규칙이 충족되지 않으면 사용자가 선택한 텍스트에 새 댓글 스레드를 삽입할 수 없도록 댓글 버튼을 비활성화합니다. 시작하자!
# src/utils/EditorCommentUtils.js export function shouldAllowNewCommentThreadAtSelection(editor, selection) { if (selection == null || Range.isCollapsed(selection)) { return false; } const textNodeIterator = Editor.nodes(editor, { at: selection, mode: "lowest", }); let nextTextNodeEntry = textNodeIterator.next().value; const textNodeEntriesInSelection = []; while (nextTextNodeEntry != null) { textNodeEntriesInSelection.push(nextTextNodeEntry); nextTextNodeEntry = textNodeIterator.next().value; } if (textNodeEntriesInSelection.length === 0) { return false; } return textNodeEntriesInSelection.some( ([textNode]) => getCommentThreadsOnTextNode(textNode).size === 0 ); }
이 함수의 논리는 비교적 간단합니다.
- 사용자의 선택이 깜박이는 캐럿인 경우 텍스트가 선택되지 않았기 때문에 거기에 주석을 삽입할 수 없습니다.
- 사용자의 선택이 축소된 것이 아니면 선택에서 모든 텍스트 노드를 찾습니다. 모든 텍스트 노드를 선택하는 데 도움이 되는
Editor.nodes
(SlateJS의 도우미 함수) 호출에서mode: lowest
사용에 유의하세요. 텍스트 노드는 실제로 문서 트리의 잎이기 때문입니다. - 주석 스레드가 없는 텍스트 노드가 하나 이상 있는 경우 삽입을 허용할 수 있습니다. 여기서 앞서 작성한
getCommentThreadsOnTextNode
유틸리티를 사용합니다.
이제 도구 모음 내에서 이 util 함수를 사용하여 버튼의 비활성화 상태를 제어합니다.
# src/components/Toolbar.js export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); .... return ( <div className="toolbar"> .... <ToolBarButton isActive={false} disabled={!shouldAllowNewCommentThreadAtSelection( editor, selection )} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> );
위의 예를 다시 만들어 규칙의 구현을 테스트해 보겠습니다.
여기에서 언급해야 할 훌륭한 사용자 경험 세부 사항은 사용자가 여기에서 전체 텍스트 줄을 선택한 경우 도구 모음 버튼을 비활성화하지만 사용자 경험을 완료하지 못한다는 것입니다. 사용자는 버튼이 비활성화된 이유를 완전히 이해하지 못할 수 있으며 댓글 스레드를 삽입하려는 의도에 우리가 응답하지 않는다는 것을 혼동할 수 있습니다. 툴바 버튼이 비활성화된 경우에도 댓글 스레드 중 하나에 대한 팝오버가 표시되고 사용자가 여전히 댓글을 남길 수 있도록 댓글 팝오버가 빌드되었기 때문에 나중에 이 문제를 해결합니다.
주석 처리되지 않은 텍스트 노드가 있고 규칙에 따라 새 주석 스레드를 삽입할 수 있는 경우도 테스트해 보겠습니다.
댓글 스레드 선택
이 섹션에서는 사용자가 주석이 달린 텍스트 노드를 클릭하는 기능을 활성화하고 가장 짧은 주석 범위 규칙을 사용하여 선택해야 하는 주석 스레드를 결정합니다. 프로세스의 단계는 다음과 같습니다.
- 사용자가 클릭한 주석 처리된 텍스트 노드에서 가장 짧은 주석 스레드를 찾습니다.
- 해당 댓글 스레드를 활성 댓글 스레드로 설정합니다. (우리는 이에 대한 진실의 원천이 될 새로운 Recoil 원자를 생성합니다.)
- 주석이 달린 텍스트 노드는 Recoil 상태를 수신하고 활성 주석 스레드의 일부인 경우 다르게 강조 표시됩니다. 그렇게 하면 사용자가 댓글 스레드를 클릭할 때 모든 텍스트 노드가 강조 표시 색상을 업데이트하므로 전체 텍스트 범위가 두드러집니다.
1단계: 최단 주석 범위 규칙 구현
기본적으로 최단 주석 범위 규칙을 구현하는 1단계부터 시작하겠습니다. 여기서 목표는 사용자가 클릭한 텍스트 노드에서 가장 짧은 범위의 댓글 스레드를 찾는 것입니다. 가장 짧은 길이의 스레드를 찾으려면 해당 텍스트 노드에 있는 모든 주석 스레드의 길이를 계산해야 합니다. 이를 수행하는 단계는 다음과 같습니다.
- 문제의 텍스트 노드에서 모든 주석 스레드를 가져옵니다.
- 해당 텍스트 노드에서 어느 방향으로든 트래버스하고 추적 중인 스레드 길이를 계속 업데이트합니다.
- 아래 모서리 중 하나에 도달하면 한 방향으로 순회를 중지합니다.
- 주석 처리되지 않은 텍스트 노드(추적하는 모든 주석 스레드의 가장 먼 시작/끝 가장자리에 도달했음을 의미).
- 추적 중인 모든 주석 스레드가 가장자리(시작/끝)에 도달한 텍스트 노드입니다.
- 해당 방향으로 트래버스할 텍스트 노드가 더 이상 없습니다(문서의 시작 또는 끝에 도달했거나 텍스트가 아닌 노드에 도달했음을 의미함).
정방향 및 역방향 순회는 기능적으로 동일하므로 기본적으로 텍스트 노드 반복자를 사용하는 도우미 함수 updateCommentThreadLengthMap
을 작성할 것입니다. 반복자를 계속 호출하고 추적 스레드 길이를 계속 업데이트합니다. 이 함수를 두 번 호출합니다. 한 번은 앞으로, 한 번은 뒤로. 이 도우미 함수를 사용할 주 유틸리티 함수를 작성해 보겠습니다.
# src/utils/EditorCommentUtils.js export function getSmallestCommentThreadAtTextNode(editor, textNode) { const commentThreads = getCommentThreadsOnTextNode(textNode); const commentThreadsAsArray = [...commentThreads]; let shortestCommentThreadID = commentThreadsAsArray[0]; const reverseTextNodeIterator = (slateEditor, nodePath) => Editor.previous(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); const forwardTextNodeIterator = (slateEditor, nodePath) => Editor.next(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); if (commentThreads.size > 1) { // The map here tracks the lengths of the comment threads. // We initialize the lengths with length of current text node // since all the comment threads span over the current text node // at the least. const commentThreadsLengthByID = new Map( commentThreadsAsArray.map((id) => [id, textNode.text.length]) ); // traverse in the reverse direction and update the map updateCommentThreadLengthMap( editor, commentThreads, reverseTextNodeIterator, commentThreadsLengthByID ); // traverse in the forward direction and update the map updateCommentThreadLengthMap( editor, commentThreads, forwardTextNodeIterator, commentThreadsLengthByID ); let minLength = Number.POSITIVE_INFINITY; // Find the thread with the shortest length. for (let [threadID, length] of commentThreadsLengthByID) { if (length < minLength) { shortestCommentThreadID = threadID; minLength = length; } } } return shortestCommentThreadID; }
The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.
One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap
such that it can call it while it is iterating text node's path and easily get the previous/next text node. To do that, Slate's traversal utilities Editor.previous
and Editor.next
(defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator
and forwardTextNodeIterator
call these helpers with two options mode: lowest
and the match function Text.isText
so we know we're getting a text node from the traversal, if there is one.
Now we implement updateCommentThreadLengthMap
which traverses using these iterators and updates the lengths we're tracking.
# src/utils/EditorCommentUtils.js function updateCommentThreadLengthMap( editor, commentThreads, nodeIterator, map ) { let nextNodeEntry = nodeIterator(editor); while (nextNodeEntry != null) { const nextNode = nextNodeEntry[0]; const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode); const intersection = [...commentThreadsOnNextNode].filter((x) => commentThreads.has(x) ); // All comment threads we're looking for have already ended meaning // reached an uncommented text node OR a commented text node which // has none of the comment threads we care about. if (intersection.length === 0) { break; } // update thread lengths for comment threads we did find on this // text node. for (let i = 0; i < intersection.length; i++) { map.set(intersection[i], map.get(intersection[i]) + nextNode.text.length); } // call the iterator to get the next text node to consider nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]); } return map; }
One might wonder why do we wait until the intersection
becomes 0
to stop iterating in a certain direction. Why can't we just stop if we're reached the edge of at least one comment thread — that would imply we've reached the shortest length in that direction, right? The reason we can't do that is that we know that a comment thread can span over multiple text nodes and we wouldn't know which of those text nodes did the user click on and we started our traversal from. We wouldn't know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.
Check out the below example where we have two comment threads 'A' and 'B' overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.
In this example, let's assume we don't wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we'd stop at the start of text node #2 itself since that's the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.
To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.
Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It
Now that we have the logic for the rule fully implemented, let's update the editor code to use it. For that, we first create a Recoil atom that'll store the active comment thread ID for us. We then update the CommentedText
component to use our rule's implementation.
# src/utils/CommentState.js import { atom } from "recoil"; export const activeCommentThreadIDAtom = atom({ key: "activeCommentThreadID", default: null, }); # src/components/CommentedText.js import { activeCommentThreadIDAtom } from "../utils/CommentState"; import classNames from "classnames"; import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils"; import { useRecoilState } from "recoil"; export default function CommentedText(props) { .... const { commentThreads, textNode, ...otherProps } = props; const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = () => { setActiveCommentThreadID( getSmallestCommentThreadAtTextNode(editor, textNode) ); }; return ( <span {...otherProps} className={classNames({ comment: true, // a different background color treatment if this text node's // comment threads do contain the comment thread active on the // document right now. "is-active": commentThreads.has(activeCommentThreadID), })} onClick={onClick} > {props.children} ≷/span> ); }
이 구성 요소는 구성 요소가 구독하고 Recoil 원자의 값을 설정할 수 있도록 하는 useRecoilState
를 사용합니다. 구독자가 이 텍스트 노드가 활성 주석 스레드의 일부인지 알아야 다른 스타일을 지정할 수 있습니다. 중간에 댓글 스레드가 활성화된 아래 스크린샷을 확인하고 범위를 명확하게 볼 수 있습니다.
이제 주석 스레드 선택이 작동하도록 하는 모든 코드가 있으므로 실제로 실행해 보겠습니다. 순회 코드를 잘 테스트하기 위해 다음과 같은 간단한 겹침 사례와 일부 가장자리 사례를 테스트합니다.
- 편집기 시작/끝에서 주석 처리된 텍스트 노드를 클릭합니다.
- 여러 단락에 걸쳐 있는 주석 스레드가 있는 주석 처리된 텍스트 노드를 클릭합니다.
- 이미지 노드 바로 앞에 주석이 달린 텍스트 노드를 클릭합니다.
- 주석이 달린 텍스트 노드를 클릭하면 링크가 겹칩니다.
이제 활성 주석 스레드 ID를 추적하는 Recoil 원자가 있으므로 처리해야 할 작은 세부 사항은 사용자가 도구 모음 버튼을 사용하여 새 주석 스레드를 삽입할 때 새로 생성된 주석 스레드를 활성 스레드로 설정하는 것입니다. 이를 통해 다음 섹션에서 사용자가 즉시 주석 추가를 시작할 수 있도록 삽입 즉시 주석 스레드 팝오버를 표시할 수 있습니다.
# src/components/Toolbar.js import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; import { useSetRecoilState } from "recoil"; export default function Toolbar({ selection, previousSelection }) { ... const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); ..... const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); setActiveCommentThreadID(newCommentThreadID); }, [editor, addCommentThread, setActiveCommentThreadID]); return <div className='toolbar'> .... </div>; };
참고: 여기에서 useSetRecoilState
(원자에 대한 setter를 노출하지만 구성 요소를 해당 값에 등록하지 않는 Recoil 후크)를 사용하는 것이 이 경우 도구 모음에 필요한 것입니다.
댓글 스레드 팝오버 추가
이 섹션에서는 선택/활성 댓글 스레드의 개념을 사용하는 댓글 팝오버를 만들고 사용자가 해당 댓글 스레드에 댓글을 추가할 수 있는 팝오버를 보여줍니다. 빌드하기 전에 어떻게 작동하는지 간단히 살펴보겠습니다.
활성화된 댓글 스레드에 가까운 댓글 팝오버를 렌더링하려고 할 때 링크 편집기 메뉴의 첫 번째 기사에서 했던 몇 가지 문제에 부딪힙니다. 이 시점에서 링크 편집기를 빌드하는 첫 번째 기사의 섹션과 이에 대해 발생하는 선택 문제를 읽는 것이 좋습니다.
먼저 활성 주석 스레드가 무엇인지 기반으로 올바른 위치에 빈 팝오버 구성 요소를 렌더링하는 작업을 시작하겠습니다. 팝오버가 작동하는 방식은 다음과 같습니다.
- 댓글 스레드 팝오버 는 활성 댓글 스레드 ID가 있는 경우에만 렌더링됩니다. 해당 정보를 얻기 위해 이전 섹션에서 만든 Recoil 원자를 수신합니다.
- 렌더링할 때 편집기 선택에서 텍스트 노드를 찾고 그 근처에 팝오버를 렌더링합니다.
- 사용자가 팝오버 외부의 아무 곳이나 클릭하면 활성 주석 스레드를
null
로 설정하여 주석 스레드를 비활성화하고 팝오버도 사라지게 합니다.
# src/components/CommentThreadPopover.js import NodePopover from "./NodePopover"; import { getFirstTextNodeAtSelection } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; import { useSetRecoilState} from "recoil"; import {activeCommentThreadIDAtom} from "../utils/CommentState"; export default function CommentThreadPopover({ editorOffsets, selection, threadID }) { const editor = useEditor(); const textNode = getFirstTextNodeAtSelection(editor, selection); const setActiveCommentThreadID = useSetRecoilState( activeCommentThreadIDAtom ); const onClickOutside = useCallback( () => {}, [] ); return ( <NodePopover editorOffsets={editorOffsets} isBodyFullWidth={true} node={textNode} className={"comment-thread-popover"} onClickOutside={onClickOutside} > {`Comment Thread Popover for threadID:${threadID}`} </NodePopover> ); }
팝오버 구성 요소의 이 구현을 위해 호출해야 하는 몇 가지 사항:
- 그것은
editorOffsets
와 그것이 렌더링될Editor
구성 요소의selection
을 취합니다.editorOffsets
는 Editor 구성 요소의 경계이므로 팝오버의 위치를 계산할 수 있으며 사용자가 도구 모음 버튼을 사용하여selection
이null
인 경우 선택 항목이 현재 또는 이전selection
이 될 수 있습니다. 위에 링크된 첫 번째 기사의 링크 편집기 섹션에서 이에 대해 자세히 설명합니다. - 첫 번째 기사의
LinkEditor
와 여기의CommentThreadPopover
는 모두 텍스트 노드 주위에 팝오버를 렌더링하므로 해당 공통 논리를 해당 텍스트 노드에 정렬된 구성 요소의 렌더링을 처리하는 구성 요소NodePopover
로 옮겼습니다. 구현 세부 정보는 첫 번째 기사에서LinkEditor
구성 요소에 있었던 것입니다. -
NodePopover
는 사용자가 팝오버 외부의 어딘가를 클릭하면 호출되는 소품으로onClickOutside
메소드를 사용합니다. 이 아이디어에 대한 이 Smashing 기사에서 자세히 설명된 대로document
에mousedown
이벤트 리스너를 연결하여 이를 구현합니다. -
getFirstTextNodeAtSelection
은 팝오버를 렌더링하는 데 사용하는 사용자 선택 항목 내부의 첫 번째 텍스트 노드를 가져옵니다. 이 함수의 구현은 Slate의 도우미를 사용하여 텍스트 노드를 찾습니다.
# src/utils/EditorUtils.js export function getFirstTextNodeAtSelection(editor, selection) { const selectionForNode = selection ?? editor.selection; if (selectionForNode == null) { return null; } const textNodeEntry = Editor.nodes(editor, { at: selectionForNode, mode: "lowest", match: Text.isText, }).next().value; return textNodeEntry != null ? textNodeEntry[0] : null; }
활성 주석 스레드를 지워야 하는 onClickOutside
콜백을 구현해 보겠습니다. 그러나 댓글 스레드 팝오버가 열려 있고 특정 스레드가 활성화되어 있고 사용자가 우연히 다른 댓글 스레드를 클릭하는 시나리오를 고려해야 합니다. 이 경우 다른 CommentedText
구성 요소의 클릭 이벤트가 다른 주석 스레드를 활성화하도록 설정해야 하므로 onClickOutside
가 활성 주석 스레드를 재설정하는 것을 원하지 않습니다. 우리는 팝오버에서 그것을 방해하고 싶지 않습니다.
우리가 하는 방법은 클릭 이벤트가 발생한 DOM 노드에 가장 가까운 슬레이트 노드를 찾는 것입니다. 해당 슬레이트 노드가 텍스트 노드이고 주석이 있는 경우 활성 주석 스레드인 Recoil 원자 재설정을 건너뜁니다. 구현해보자!
# src/components/CommentThreadPopover.js const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); const onClickOutside = useCallback( (event) => { const slateDOMNode = event.target.hasAttribute("data-slate-node") ? event.target : event.target.closest('[data-slate-node]'); // The click event was somewhere outside the Slate hierarchy. if (slateDOMNode == null) { setActiveCommentThreadID(null); return; } const slateNode = ReactEditor.toSlateNode(editor, slateDOMNode); // Click is on another commented text node => do nothing. if ( Text.isText(slateNode) && getCommentThreadsOnTextNode(slateNode).size > 0 ) { return; } setActiveCommentThreadID(null); }, [editor, setActiveCommentThreadID] );
Slate에는 DOM 노드 또는 자체가 Slate 노드가 아닌 경우 가장 가까운 조상에 매핑되는 Slate 노드를 반환하는 도우미 메서드 toSlateNode
가 있습니다. 이 도우미의 현재 구현은 null
을 반환하는 대신 Slate 노드를 찾을 수 없는 경우 오류를 발생시킵니다. 우리는 사용자가 Slate 노드가 존재하지 않는 편집기 외부의 어딘가를 클릭하는 경우 매우 가능성이 높은 시나리오인 null
케이스를 직접 확인하여 위의 문제를 처리합니다.
이제 activeCommentThreadIDAtom
을 수신하고 주석 스레드가 활성화된 경우에만 팝오버를 렌더링하도록 Editor
구성 요소를 업데이트할 수 있습니다.
# src/components/Editor.js import { useRecoilValue } from "recoil"; import { activeCommentThreadIDAtom } from "../utils/CommentState"; export default function Editor({ document, onChange }): JSX.Element { const activeCommentThreadID = useRecoilValue(activeCommentThreadIDAtom); // This hook is described in detail in the first article const [previousSelection, selection, setSelection] = useSelection(editor); return ( <> ... <div className="editor" ref={editorRef}> ... {activeCommentThreadID != null ? ( <CommentThreadPopover editorOffsets={editorOffsets} selection={selection ?? previousSelection} threadID={activeCommentThreadID} /> ) : null} </div> ... </> ); }
팝오버가 올바른 주석 스레드의 올바른 위치에 로드되고 외부를 클릭할 때 활성 주석 스레드가 지워지는지 확인하겠습니다.
이제 사용자가 댓글 스레드에 댓글을 추가하고 팝오버에서 해당 스레드의 모든 댓글을 볼 수 있도록 합니다. 우리는 Recoil 아톰 패밀리인 commentThreadsState
를 사용할 것입니다.
주석 스레드의 comments
배열에 저장됩니다. 새 주석을 추가할 수 있도록 사용자가 새 주석을 입력할 수 있는 양식 입력을 렌더링합니다. 사용자가 주석을 입력하는 동안 우리는 이를 로컬 상태 변수인 commentText
에 유지합니다. 버튼을 클릭하면 주석 텍스트를 comments
배열에 새 주석으로 추가합니다.
# src/components/CommentThreadPopover.js import { commentThreadsState } from "../utils/CommentState"; import { useRecoilState } from "recoil"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); const [commentText, setCommentText] = useState(""); const onClick = useCallback(() => { setCommentThreadData((threadData) => ({ ...threadData, comments: [ ...threadData.comments, // append comment to the comments on the thread. { text: commentText, author: "Jane Doe", creationTime: new Date() }, ], })); // clear the input setCommentText(""); }, [commentText, setCommentThreadData]); const onCommentTextChange = useCallback( (event) => setCommentText(event.target.value), [setCommentText] ); return ( <NodePopover ... > <div className={"comment-input-wrapper"}> <Form.Control bsPrefix={"comment-input form-control"} placeholder={"Type a comment"} type="text" value={commentText} onChange={onCommentTextChange} /> <Button size="sm" variant="primary" disabled={commentText.length === 0} onClick={onClick} > Comment </Button> </div> </NodePopover> ); }
참고 : 사용자가 주석을 입력할 수 있도록 입력을 렌더링하지만 팝오버가 마운트될 때 반드시 포커스를 가져오도록 놔두지는 않습니다. 이것은 편집자마다 다를 수 있는 사용자 경험 결정입니다. 일부 편집기에서는 주석 스레드 팝오버가 열려 있는 동안 사용자가 텍스트를 편집할 수 없습니다. 우리의 경우 사용자가 주석 텍스트를 클릭할 때 편집할 수 있기를 원합니다.
원자를 commentThreadsState(threadID)
로 호출하여 Recoil 원자 제품군에서 특정 주석 스레드의 데이터에 액세스하는 방법을 호출할 가치가 있습니다. 이것은 우리에게 원자의 값과 패밀리의 해당 원자를 업데이트하기 위한 setter를 제공합니다. 주석이 서버에서 지연 로드되는 경우 Recoil은 또한 원자 데이터의 로드 상태를 알려주는 Loadable 객체를 반환하는 useRecoilStateLoadable
후크를 제공합니다. 아직 로드 중인 경우 팝오버에 로드 상태를 표시하도록 선택할 수 있습니다.
이제 threadData
에 액세스하고 주석 목록을 렌더링합니다. 각 주석은 CommentRow
구성 요소에 의해 렌더링됩니다.
# src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );
아래는 주석 텍스트 및 작성자 이름 및 생성 시간과 같은 기타 메타데이터를 렌더링하는 CommentRow
의 구현입니다. date-fns
모듈을 사용하여 형식이 지정된 생성 시간을 표시합니다.
# src/components/CommentRow.js import { format } from "date-fns"; export default function CommentRow({ comment: { author, text, creationTime }, }) { return ( <div className={"comment-row"}> <div className="comment-author-photo"> <i className="bi bi-person-circle comment-author-photo"></i> </div> <div> <span className="comment-author-name">{author}</span> <span className="comment-creation-time"> {format(creationTime, "eee MM/dd H:mm")} </span> <div className="comment-text">{text}</div> </div> </div> ); }
나중에 Comment Sidebar를 구현할 때 재사용하므로 이것을 자체 구성 요소로 추출했습니다.
이 시점에서 Comment Popover에는 새 주석을 삽입하고 이에 대한 Recoil 상태를 업데이트하는 데 필요한 모든 코드가 있습니다. 확인해보자. 브라우저 콘솔에서 이전에 추가한 Recoil Debug Observer를 사용하여 스레드에 새 주석을 추가할 때 주석 스레드에 대한 Recoil 원자가 올바르게 업데이트되고 있는지 확인할 수 있습니다.
댓글 사이드바 추가하기
기사 앞부분에서 우리가 구현한 규칙이 겹치는 조합에 따라 텍스트 노드만 클릭하여 특정 댓글 스레드에 액세스할 수 없도록 방지하는 규칙이 가끔 발생할 수 있는 이유에 대해 설명했습니다. 이러한 경우 사용자가 문서의 모든 주석 스레드에 접근할 수 있도록 하는 주석 사이드바가 필요합니다.
댓글 사이드바는 검토자가 스윕에서 모든 댓글 스레드를 차례로 탐색하고 필요할 때마다 댓글/응답을 남길 수 있는 제안 및 검토 워크플로에 결합하는 좋은 추가 기능입니다. 사이드바 구현을 시작하기 전에 아래에서 처리해야 하는 완료되지 않은 작업이 하나 있습니다.
주석 스레드의 반동 상태 초기화
문서가 편집기에 로드되면 문서를 스캔하여 모든 주석 스레드를 찾고 초기화 프로세스의 일부로 위에서 생성한 Recoil 원자에 추가해야 합니다. EditorCommentUtils
에서 텍스트 노드를 스캔하고 모든 주석 스레드를 찾아 Recoil 원자에 추가하는 유틸리티 함수를 작성해 보겠습니다.
# src/utils/EditorCommentUtils.js export async function initializeStateWithAllCommentThreads( editor, addCommentThread ) { const textNodesWithComments = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).size > 0, }); const commentThreads = new Set(); let textNodeEntry = textNodesWithComments.next().value; while (textNodeEntry != null) { [...getCommentThreadsOnTextNode(textNodeEntry[0])].forEach((threadID) => { commentThreads.add(threadID); }); textNodeEntry = textNodesWithComments.next().value; } Array.from(commentThreads).forEach((id) => addCommentThread(id, { comments: [ { author: "Jane Doe", text: "Comment Thread Loaded from Server", creationTime: new Date(), }, ], status: "open", }) ); }
백엔드 스토리지와 동기화 및 성능 고려 사항
이 기사의 컨텍스트에서는 UI 구현에만 집중하므로 초기화 코드가 작동하는지 확인할 수 있는 일부 데이터로 초기화합니다.
주석 달기 시스템의 실제 사용에서 주석 스레드는 문서 내용 자체와 별도로 저장될 가능성이 높습니다. 이러한 경우, commentThreads
의 모든 주석 스레드 ID에 대한 모든 메타데이터와 주석을 가져오는 API 호출을 수행하려면 위의 코드를 업데이트해야 합니다. 댓글 스레드가 로드되면 여러 사용자가 실시간으로 더 많은 댓글을 추가하고 상태를 변경하는 등의 작업으로 업데이트될 가능성이 높습니다. Commenting System의 프로덕션 버전은 서버와 계속 동기화할 수 있는 방식으로 Recoil 저장소를 구성해야 합니다. 상태 관리를 위해 Recoil을 사용하기로 선택한 경우 유사한 작업을 수행하는 Atom Effects API(이 기사 작성 시점에서 실험적임)에 대한 몇 가지 예가 있습니다.
문서가 정말 길고 많은 주석 스레드에서 공동 작업하는 사용자가 많은 경우 문서의 처음 몇 페이지에 대해서만 주석 스레드를 로드하도록 초기화 코드를 최적화해야 할 수 있습니다. 또는 페이로드의 더 무거운 부분일 가능성이 있는 전체 주석 목록 대신 모든 주석 스레드의 경량 메타데이터만 로드하도록 선택할 수 있습니다.
이제 Editor
구성 요소가 문서와 함께 탑재되어 Recoil 상태가 올바르게 초기화될 때 이 함수를 호출하는 방법으로 이동해 보겠습니다.
# src/components/Editor.js import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Editor({ document, onChange }): JSX.Element { ... const addCommentThread = useAddCommentThreadToState(); useEffect(() => { initializeStateWithAllCommentThreads(editor, addCommentThread); }, [editor, addCommentThread]); return ( <> ... </> ); }
새 주석 스레드를 추가하기 위해 Toolbar Comment Button 구현과 함께 사용한 동일한 사용자 정의 후크인 useAddCommentThreadToState
를 사용합니다. 팝오버가 작동하고 있으므로 문서의 기존 주석 스레드 중 하나를 클릭하고 위 스레드를 초기화하는 데 사용한 데이터가 표시되는지 확인할 수 있습니다.
이제 상태가 올바르게 초기화되었으므로 사이드바 구현을 시작할 수 있습니다. UI의 모든 주석 스레드는 Recoil 아톰 패밀리인 commentThreadsState
에 저장됩니다. 앞서 강조했듯이, Recoil 원자 패밀리의 모든 항목을 반복하는 방법은 다른 원자의 원자 키/ID를 추적하는 것입니다. 우리는 commentThreadIDsState
로 그렇게 했습니다. 이 원자의 ID 집합을 반복하고 각각에 대해 CommentThread
CommentSidebar
요소를 추가해 보겠습니다.
# src/components/CommentsSidebar.js import "./CommentSidebar.css"; import {commentThreadIDsState,} from "../utils/CommentState"; import { useRecoilValue } from "recoil"; export default function CommentsSidebar(params) { const allCommentThreadIDs = useRecoilValue(commentThreadIDsState); return ( <Card className={"comments-sidebar"}> <Card.Header>Comments</Card.Header> <Card.Body> {Array.from(allCommentThreadIDs).map((id) => ( <Row key={id}> <Col> <CommentThread id={id} /> </Col> </Row> ))} </Card.Body> </Card> ); }
이제 렌더링 중인 주석 스레드에 해당하는 패밀리의 Recoil 원자를 수신하는 CommentThread
구성 요소를 구현합니다. 이렇게 하면 사용자가 편집기의 스레드에 더 많은 주석을 추가하거나 다른 메타데이터를 변경할 때 이를 반영하도록 사이드바를 업데이트할 수 있습니다.
댓글이 많은 문서의 경우 사이드바가 정말 커질 수 있으므로 사이드바를 렌더링할 때 첫 번째 댓글을 제외한 모든 댓글을 숨깁니다. 사용자는 '댓글 표시/숨기기' 버튼을 사용하여 전체 댓글 스레드를 표시하거나 숨길 수 있습니다.
# src/components/CommentSidebar.js function CommentThread({ id }) { const { comments } = useRecoilValue(commentThreadsState(id)); const [shouldShowReplies, setShouldShowReplies] = useState(false); const onBtnClick = useCallback(() => { setShouldShowReplies(!shouldShowReplies); }, [shouldShowReplies, setShouldShowReplies]); if (comments.length === 0) { return null; } const [firstComment, ...otherComments] = comments; return ( <Card body={true} className={classNames({ "comment-thread-container": true, })} > <CommentRow comment={firstComment} showConnector={false} /> {shouldShowReplies ? otherComments.map((comment, index) => ( <CommentRow key={`comment-${index}`} comment={comment} showConnector={true} /> )) : null} {comments.length > 1 ? ( <Button className={"show-replies-btn"} size="sm" variant="outline-primary" onClick={onBtnClick} > {shouldShowReplies ? "Hide Replies" : "Show Replies"} </Button> ) : null} </Card> ); }
기본적으로 모든 주석이 사이드바의 스레드와 연결된 것처럼 보이게 하는 showConnector
prop을 사용하여 디자인 처리를 추가했지만 팝오버에서 CommentRow
구성 요소를 재사용했습니다.
이제 Editor
에서 CommentSidebar
를 렌더링하고 문서에 있는 모든 스레드가 표시되는지 확인하고 기존 스레드에 새 스레드나 새 주석을 추가할 때 올바르게 업데이트되는지 확인합니다.
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
이제 편집기에서 볼 수 있는 인기 있는 댓글 사이드바 상호 작용을 구현하는 단계로 넘어갑니다.
사이드바에서 댓글 스레드를 클릭하면 해당 댓글 스레드를 선택/활성화해야 합니다. 또한 편집기에서 활성화된 경우 사이드바에서 주석 스레드를 강조 표시하기 위해 차등 디자인 처리를 추가합니다. 그렇게 하기 위해 우리는 Recoil 원자인 activeCommentThreadIDAtom
을 사용합니다. 이를 지원하도록 CommentThread
구성 요소를 업데이트합시다.
# src/components/CommentsSidebar.js function CommentThread({ id }) { const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = useCallback(() => { setActiveCommentThreadID(id); }, [id, setActiveCommentThreadID]); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-active": activeCommentThreadID === id, })} onClick={onClick} > .... </Card> );
자세히 살펴보면 활성 주석 스레드를 사이드바와 동기화하는 구현에 버그가 있습니다. 사이드바에서 다른 댓글 스레드를 클릭하면 편집기에서 올바른 댓글 스레드가 실제로 강조 표시됩니다. 그러나 댓글 팝오버는 실제로 변경된 활성 댓글 스레드로 이동하지 않습니다. 처음 렌더링된 위치에 유지됩니다. Comment Popover의 구현을 보면 편집기 선택의 첫 번째 텍스트 노드에 대해 자체적으로 렌더링됩니다. 구현의 그 시점에서 주석 스레드를 선택하는 유일한 방법은 클릭 이벤트의 결과로 Slate에 의해 업데이트되었기 때문에 편집기의 선택에 편리하게 의존할 수 있도록 텍스트 노드를 클릭하는 것이었습니다. 위의 onClick
이벤트에서는 선택 항목을 업데이트하지 않고 Recoil 원자 값만 업데이트하여 Slate의 선택 항목이 변경되지 않은 상태로 유지되므로 Comment Popover가 이동하지 않습니다.
이 문제에 대한 해결책은 사용자가 사이드바에서 주석 스레드를 클릭할 때 Recoil 원자를 업데이트하는 것과 함께 편집기의 선택을 업데이트하는 것입니다. 이를 수행하는 단계는 다음과 같습니다.
- 새로운 활성 스레드로 설정할 이 주석 스레드가 있는 모든 텍스트 노드를 찾으십시오.
- 이 텍스트 노드를 문서에 나타나는 순서대로 정렬합니다(이를 위해 Slate의
Path.compare
API를 사용합니다). - 첫 번째 텍스트 노드의 시작에서 마지막 텍스트 노드의 끝까지에 걸친 선택 범위를 계산합니다.
- 선택 범위를 편집기의 새 선택으로 설정합니다(Slate의
Transforms.select
API 사용).
버그를 수정하고 싶다면 1단계에서 주석 스레드가 있는 첫 번째 텍스트 노드를 찾아 편집기가 선택하도록 설정할 수 있습니다. 그러나 실제로 주석 스레드를 선택하는 것처럼 전체 주석 범위를 선택하는 것이 더 깔끔한 접근 방식처럼 느껴집니다.
위의 단계를 포함하도록 onClick
콜백 구현을 업데이트하겠습니다.
const onClick = useCallback(() => { const textNodesWithThread = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).has(id), }); let textNodeEntry = textNodesWithThread.next().value; const allTextNodePaths = []; while (textNodeEntry != null) { allTextNodePaths.push(textNodeEntry[1]); textNodeEntry = textNodesWithThread.next().value; } // sort the text nodes allTextNodePaths.sort((p1, p2) => Path.compare(p1, p2)); // set the selection on the editor Transforms.select(editor, { anchor: Editor.point(editor, allTextNodePaths[0], { edge: "start" }), focus: Editor.point( editor, allTextNodePaths[allTextNodePaths.length - 1], { edge: "end" } ), }); // Update the Recoil atom value. setActiveCommentThreadID(id); }, [editor, id, setActiveCommentThreadID]);
참고 : allTextNodePaths
는 모든 텍스트 노드에 대한 경로를 포함합니다. Editor.point
API를 사용하여 해당 경로에서 시작점과 끝점을 얻습니다. 첫 번째 기사에서는 Slate의 위치 개념을 살펴봅니다. 그들은 또한 Slate의 문서에 잘 설명되어 있습니다.
이 구현이 버그를 수정하고 주석 팝오버가 활성 주석 스레드로 올바르게 이동하는지 확인합시다. 이번에는 스레드가 겹치는 경우도 테스트하여 스레드가 끊어지지 않는지 확인합니다.
버그 수정을 통해 아직 논의하지 않은 다른 사이드바 상호 작용을 활성화했습니다. 매우 긴 문서가 있고 사용자가 뷰포트 외부에 있는 사이드바의 주석 스레드를 클릭하는 경우 사용자가 편집기의 주석 스레드에 집중할 수 있도록 문서의 해당 부분으로 스크롤하려고 합니다. Slate의 API를 사용하여 위의 선택 항목을 설정하면 무료로 얻을 수 있습니다. 아래에서 실제로 살펴보겠습니다.
이것으로 사이드바 구현을 마무리합니다. 기사의 끝에서 우리는 편집기에서 주석 달기 및 검토 경험을 향상시키는 데 도움이 되는 주석 사이드바에 수행할 수 있는 몇 가지 멋진 기능 추가 및 개선 사항을 나열합니다.
댓글 해결 및 다시 열기
이 섹션에서는 사용자가 댓글 스레드를 '해결됨'으로 표시하거나 필요한 경우 토론을 위해 다시 열 수 있도록 하는 데 중점을 둡니다. 구현 세부 사항의 관점에서 이것은 사용자가 이 작업을 수행할 때 변경하는 주석 스레드의 status
메타데이터입니다. 사용자의 관점에서 이것은 문서에 대한 토론이 끝났거나 일부 업데이트/새로운 관점 등이 있기 때문에 다시 열 필요가 있음을 확인하는 방법을 제공하기 때문에 매우 유용한 기능입니다.
상태 토글을 활성화하기 위해 사용자가 두 가지 상태( open
및 resolved
) 사이를 토글할 수 있는 버튼을 CommentPopover
에 추가합니다.
# src/components/CommentThreadPopover.js export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { … const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); ... const onToggleStatus = useCallback(() => { const currentStatus = threadData.status; setCommentThreadData((threadData) => ({ ...threadData, status: currentStatus === "open" ? "resolved" : "open", })); }, [setCommentThreadData, threadData.status]); return ( <NodePopover ... header={ <Header status={threadData.status} shouldAllowStatusChange={threadData.comments.length > 0} onToggleStatus={onToggleStatus} /> } > <div className={"comment-list"}> ... </div> </NodePopover> ); } function Header({ onToggleStatus, shouldAllowStatusChange, status }) { return ( <div className={"comment-thread-popover-header"}> {shouldAllowStatusChange && status != null ? ( <Button size="sm" variant="primary" onClick={onToggleStatus}> {status === "open" ? "Resolve" : "Re-Open"} </Button> ) : null} </div> ); }
이것을 테스트하기 전에 사용자가 해결되지 않았거나 열려 있는 댓글 스레드를 쉽게 감지하고 원하는 경우 해당 스레드에 집중할 수 있도록 댓글 사이드바에 해결된 댓글에 대한 차등 설계 처리를 제공하겠습니다.
# src/components/CommentsSidebar.js function CommentThread({ id }) { ... const { comments, status } = useRecoilValue(commentThreadsState(id)); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-resolved": status === "resolved", "is-active": activeCommentThreadID === id, })} onClick={onClick} > ... </Card> ); }
결론
이 기사에서는 서식 있는 텍스트 편집기에서 주석 달기 시스템을 위한 핵심 UI 인프라를 구축했습니다. 여기에 추가한 일련의 기능은 공동 작업자가 문서의 일부에 주석을 달고 이에 대해 대화할 수 있는 편집기에서 보다 풍부한 공동 작업 환경을 구축하기 위한 기반 역할을 합니다. 댓글 사이드바를 추가하면 제품에서 더 많은 대화 또는 리뷰 기반 기능을 사용할 수 있는 공간이 제공됩니다.
이와 관련하여 다음은 이 문서에서 구축한 것 외에 서식 있는 텍스트 편집기에서 추가할 수 있는 몇 가지 기능입니다.
-
@
멘션을 지원하여 공동 작업자가 댓글에서 서로를 태그할 수 있습니다. - 댓글 스레드에 추가할 이미지 및 비디오와 같은 미디어 유형 지원
- 검토자가 변경 제안으로 표시되는 문서를 편집할 수 있는 문서 수준의 제안 모드. Google 문서의 이 기능이나 Microsoft Word의 변경 내용 추적을 예로 들 수 있습니다.
- 키워드로 대화를 검색하고, 상태 또는 댓글 작성자별로 스레드를 필터링하는 등 사이드바가 향상되었습니다.