การเพิ่มระบบแสดงความคิดเห็นให้กับตัวแก้ไขแบบ WYSIWYG

เผยแพร่แล้ว: 2022-03-10
สรุปโดยย่อ ↬ ในบทความนี้ เราจะนำ WYSIWYG Editor พื้นฐานกลับมาใช้ใหม่ซึ่งสร้างขึ้นในบทความแรกเพื่อสร้างระบบแสดงความคิดเห็นสำหรับ WYSIWYG Editor ที่ให้ผู้ใช้เลือกข้อความภายในเอกสารและแชร์ความคิดเห็นได้ เราจะนำ RecoilJS มาใช้สำหรับการจัดการสถานะในแอปพลิเคชัน UI (รหัสสำหรับระบบที่เราสร้างที่นี่มีอยู่ในที่เก็บ Github สำหรับการอ้างอิง)

ในช่วงไม่กี่ปีที่ผ่านมา เราพบว่า Collaboration เจาะเวิร์กโฟลว์ดิจิทัลและกรณีการใช้งานในหลายสาขาอาชีพ ภายในชุมชน Design and Software Engineering เราเห็นนักออกแบบทำงานร่วมกันในการออกแบบสิ่งประดิษฐ์โดยใช้เครื่องมือเช่น Figma ทีมที่ทำ Sprint และการวางแผนโครงการโดยใช้เครื่องมือต่างๆ เช่น Mural และการสัมภาษณ์ที่ดำเนินการโดยใช้ CoderPad เครื่องมือทั้งหมดเหล่านี้มุ่งหวังที่จะเชื่อมช่องว่างระหว่างประสบการณ์ออนไลน์และโลกทางกายภาพของการดำเนินการเวิร์กโฟลว์เหล่านี้ และทำให้ประสบการณ์การทำงานร่วมกันเป็นไปอย่างสมบูรณ์และราบรื่นที่สุด

สำหรับเครื่องมือการทำงานร่วมกันส่วนใหญ่ในลักษณะนี้ ความสามารถในการแบ่งปันความคิดเห็นระหว่างกันและอภิปรายเกี่ยวกับเนื้อหาเดียวกันนั้นเป็นสิ่งที่ต้องมี ระบบแสดงความคิดเห็นที่ช่วยให้ผู้ทำงานร่วมกันสามารถใส่คำอธิบายประกอบส่วนต่างๆ ของเอกสารและสนทนาเกี่ยวกับส่วนเหล่านั้นได้ คือหัวใจของแนวคิดนี้ นอกเหนือจากการสร้างหนึ่งสำหรับข้อความใน WYSIWYG Editor แล้ว บทความนี้พยายามที่จะดึงดูดผู้อ่านให้เข้ามามีส่วนร่วมในวิธีที่เราพยายามชั่งน้ำหนักข้อดีและข้อเสีย และพยายามหาสมดุลระหว่างความซับซ้อนของแอปพลิเคชันและประสบการณ์ของผู้ใช้ เมื่อพูดถึงการสร้างคุณลักษณะสำหรับ WYSIWYG Editors หรือ โปรแกรมประมวลผลคำโดยทั่วไป

การแสดงความเห็นในโครงสร้างเอกสาร

ในการหาวิธีแสดงความคิดเห็นในโครงสร้างข้อมูลของเอกสาร Rich Text มาดูสถานการณ์สองสามสถานการณ์ที่ความคิดเห็นสามารถสร้างขึ้นได้ในตัวแก้ไข

  • ความคิดเห็นที่สร้างขึ้นเหนือข้อความที่ไม่มีสไตล์ (สถานการณ์พื้นฐาน);
  • ความคิดเห็นที่สร้างขึ้นบนข้อความที่อาจเป็นตัวหนา/ตัวเอียง/ขีดเส้นใต้ และอื่นๆ
  • ความคิดเห็นที่ทับซ้อนกันในทางใดทางหนึ่ง (ทับซ้อนบางส่วนโดยที่ความคิดเห็นสองความคิดเห็นใช้คำเพียงไม่กี่คำหรือมีเนื้อหาครบถ้วนโดยที่ข้อความของความคิดเห็นหนึ่งมีเนื้อหาครบถ้วนในข้อความของความคิดเห็นอื่น)
  • ความคิดเห็นที่สร้างขึ้นเหนือข้อความภายในลิงก์ (พิเศษเพราะลิงก์เป็นโหนดในโครงสร้างเอกสารของเรา)
  • ความคิดเห็นที่ครอบคลุมหลายย่อหน้า (พิเศษเนื่องจากย่อหน้าเป็นโหนดในโครงสร้างเอกสารของเรา และความคิดเห็นจะนำไปใช้กับโหนดข้อความซึ่งเป็นโหนดย่อยของย่อหน้า)

เมื่อพิจารณาจากกรณีการใช้งานข้างต้นแล้ว ดูเหมือนว่าความคิดเห็นที่ปรากฏในเอกสาร Rich Text จะคล้ายกับลักษณะอักขระมาก (ตัวหนา ตัวเอียง ฯลฯ) พวกเขาสามารถซ้อนทับกัน ข้ามข้อความในโหนดประเภทอื่นๆ เช่น ลิงก์ และแม้กระทั่งขยายโหนดหลักหลายโหนด เช่น ย่อหน้า

ด้วยเหตุผลนี้ เราจึงใช้วิธีการเดียวกันเพื่อแสดงความคิดเห็นเช่นเดียวกับรูปแบบอักขระ เช่น “เครื่องหมาย” (ดังที่เรียกกันในคำศัพท์ SlateJS) เครื่องหมายเป็นเพียงคุณสมบัติปกติบนโหนด — ลักษณะพิเศษคือ API ของ Slate รอบๆ เครื่องหมาย ( Editor.addMark และ Editor.removeMark ) จัดการการเปลี่ยนแปลงลำดับชั้นของโหนด เนื่องจากมีการใช้เครื่องหมายหลายรายการกับช่วงข้อความเดียวกัน สิ่งนี้มีประโยชน์อย่างยิ่งต่อเราเมื่อเราจัดการกับความคิดเห็นที่ทับซ้อนกันจำนวนมาก

ตั้งกระทู้เป็นเครื่องหมาย

เมื่อใดก็ตามที่ผู้ใช้เลือกช่วงของข้อความและพยายามแทรกความคิดเห็น ในทางเทคนิคแล้ว พวกเขาจะเริ่มต้นเธรดความคิดเห็นใหม่สำหรับช่วงข้อความนั้น เนื่องจากเราจะอนุญาตให้พวกเขาแทรกความคิดเห็นและตอบกลับความคิดเห็นนั้นในภายหลัง เราถือว่ากิจกรรมนี้เป็นการแทรกเธรดความคิดเห็นใหม่ในเอกสาร

วิธีที่เราแสดงชุดความคิดเห็นเป็นเครื่องหมายคือแต่ละชุดความคิดเห็นจะแสดงด้วยเครื่องหมายที่ชื่อ commentThread_threadID โดยที่ threadID เป็น ID เฉพาะที่เรากำหนดให้กับแต่ละชุดความคิดเห็น ดังนั้น หากช่วงของข้อความเดียวกันมีเธรดความคิดเห็นสองชุด ข้อความนั้นจะมีการตั้งค่าคุณสมบัติสองรายการ truecommentThread_thread1 และ commentThread_thread2 นี่คือที่ที่เธรดความคิดเห็นมีความคล้ายคลึงกับลักษณะอักขระมาก เนื่องจากหากข้อความเดียวกันเป็นตัวหนาและตัวเอียง ก็จะมีทั้งคุณสมบัติที่ตั้งค่า truebold และ italic

ก่อนที่เราจะลงลึกในการตั้งค่าโครงสร้างนี้ ควรพิจารณาว่าโหนดข้อความเปลี่ยนไปอย่างไรเมื่อเธรดความคิดเห็นถูกนำไปใช้กับโหนดเหล่านี้ วิธีการทำงาน (เช่นเดียวกับเครื่องหมายใดๆ) คือเมื่อมีการตั้งค่าคุณสมบัติการทำเครื่องหมายบนข้อความที่เลือก Editor.addMark API ของ Slate จะแยกโหนดข้อความหากจำเป็น ซึ่งในโครงสร้างผลลัพธ์ โหนดข้อความ ถูกตั้งค่าในลักษณะที่โหนดข้อความแต่ละโหนดมีค่าของเครื่องหมายเหมือนกันทุกประการ

เพื่อให้เข้าใจได้ดีขึ้น ให้ดูตัวอย่างสามตัวอย่างต่อไปนี้ที่แสดงสถานะก่อนและหลังของโหนดข้อความเมื่อแทรกเธรดความคิดเห็นในข้อความที่เลือก:

ภาพประกอบแสดงวิธีแยกโหนดข้อความด้วยการแทรกเธรดความคิดเห็นพื้นฐาน
โหนดข้อความที่แยกออกเป็นสามส่วนเมื่อมีการแทรกเครื่องหมายเธรดความคิดเห็นไว้ตรงกลางข้อความ (ตัวอย่างขนาดใหญ่)
ภาพประกอบแสดงวิธีแยกโหนดข้อความในกรณีที่เธรดความคิดเห็นทับซ้อนกันบางส่วน
การเพิ่มเธรดความคิดเห็นเหนือ 'ข้อความมี' จะสร้างโหนดข้อความใหม่สองโหนด (ตัวอย่างขนาดใหญ่)
ภาพประกอบแสดงวิธีแยกโหนดข้อความในกรณีที่เธรดความคิดเห็นที่มีลิงก์ทับซ้อนกันบางส่วน
การเพิ่มเธรดความคิดเห็นเหนือ 'มีลิงก์' จะแยกโหนดข้อความภายในลิงก์ด้วย (ตัวอย่างขนาดใหญ่)
เพิ่มเติมหลังกระโดด! อ่านต่อด้านล่าง↓

เน้นข้อความแสดงความคิดเห็น

ตอนนี้เรารู้แล้วว่าเราจะแสดงความคิดเห็นในโครงสร้างเอกสารอย่างไร มาเพิ่มตัวอย่างเอกสารจากบทความแรกและกำหนดค่าให้แสดงเป็นไฮไลต์จริง ๆ เนื่องจากเราจะมีฟังก์ชันยูทิลิตี้มากมายเพื่อจัดการกับความคิดเห็นในบทความนี้ เราจึงสร้างโมดูล 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 ของสิ่งต่างๆ ของระบบการแสดงความคิดเห็นในบทความนี้ ดังนั้นเราจึงกำหนด ID ให้กับพวกเขาในเอกสารตัวอย่างโดยตรงโดยใช้แพ็คเกจ npm uuid เป็นไปได้มากว่าในเวอร์ชันที่ใช้งานจริงของตัวแก้ไข 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 ที่แสดงโหนดข้อความ (การจัดการลักษณะอักขระและอื่นๆ) เราขยายองค์ประกอบนั้นเพื่อใช้ util ด้านบนและแสดงองค์ประกอบ 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>; }

ด้านล่างนี้คือการใช้งาน CommentedText ที่แสดงโหนดข้อความและแนบ CSS ที่แสดงเป็นไฮไลต์

 # 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

เราใช้อะตอมหดตัวเพื่อเก็บโครงสร้างข้อมูลทั้งสองนี้ หากคุณไม่คุ้นเคยกับการหดตัว อะตอมคือสิ่งที่ถือสถานะแอปพลิเคชัน สำหรับสถานะการใช้งานที่แตกต่างกัน คุณมักจะต้องการตั้งค่าอะตอมที่แตกต่างกัน Atom Family คือกลุ่มของอะตอม ซึ่งถือได้ว่าเป็น Map จากคีย์เฉพาะที่ระบุอะตอมไปยังอะตอมเอง มันคุ้มค่าที่จะอ่านแนวคิดหลักของการหดตัว ณ จุดนี้และทำความคุ้นเคยกับพวกเขา

สำหรับกรณีการใช้งานของเรา เราจัดเก็บเธรดความคิดเห็นเป็นตระกูล Atom แล้วรวมแอปพลิเคชันของเราไว้ในองค์ประกอบ RecoilRoot RecoilRoot ใช้เพื่อจัดเตรียมบริบทที่จะใช้ค่าอะตอม เราสร้างโมดูล 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 ด้านบน อะตอมทั้งสองนี้จะต้องซิงค์กันทุกครั้งที่เราเพิ่ม/ลบเธรดความคิดเห็น

เราเพิ่มตัวห่อหุ้ม RecoilRoot ในส่วนประกอบรูท App ของเรา เพื่อให้เราสามารถใช้อะตอมเหล่านี้ได้ในภายหลัง เอกสารประกอบของ Recoil ยังมีองค์ประกอบ Debugger ที่เป็นประโยชน์ซึ่งเราใช้ตามที่เป็นอยู่และวางลงในเครื่องมือแก้ไขของเรา คอมโพเนนต์นี้จะออกจากบันทึก console.debug ไปที่คอนโซล Dev ของเรา เนื่องจากอะตอมของ Recoil จะได้รับการอัปเดตแบบเรียลไทม์

 # 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. }

นอกจากนี้เรายังต้องเพิ่มโค้ดที่เริ่มต้นอะตอมของเราด้วยเธรดความคิดเห็นที่มีอยู่แล้วในเอกสาร (เช่นที่เราเพิ่มลงในเอกสารตัวอย่างของเราในส่วนก่อนหน้า เป็นต้น) เราทำสิ่งนั้นในภายหลังเมื่อเราสร้างแถบด้านข้างความคิดเห็นซึ่งจำเป็นต้องอ่านเธรดความคิดเห็นทั้งหมดในเอกสาร

ณ จุดนี้ เราโหลดแอปพลิเคชันของเรา ตรวจสอบให้แน่ใจว่าไม่มีข้อผิดพลาดที่ชี้ไปที่การตั้งค่าการหดตัวของเราและดำเนินการต่อไป

เพิ่มความคิดเห็นใหม่

ในส่วนนี้ เราเพิ่มปุ่มในแถบเครื่องมือที่อนุญาตให้ผู้ใช้เพิ่มความคิดเห็น (เช่น สร้างเธรดความคิดเห็นใหม่) สำหรับช่วงข้อความที่เลือก เมื่อผู้ใช้เลือกช่วงข้อความและคลิกที่ปุ่มนี้ เราต้องทำดังต่อไปนี้:

  1. กำหนด ID เฉพาะให้กับเธรดความคิดเห็นใหม่ที่จะถูกแทรก
  2. เพิ่มเครื่องหมายใหม่ให้กับโครงสร้างเอกสาร Slate ด้วย ID เพื่อให้ผู้ใช้เห็นว่าข้อความนั้นถูกเน้น
  3. เพิ่มเธรดความคิดเห็นใหม่ให้กับ Recoil atoms ที่เราสร้างไว้ในส่วนก่อนหน้า

มาเพิ่มฟังก์ชัน util ให้กับ EditorCommentUtils ที่ทำ #1 และ #2 กันเถอะ

 # 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 เป็นฟังก์ชันเรียกกลับที่จัดการขั้นตอน #3 — เพิ่มเธรดความคิดเห็นใหม่ให้กับ Recoil atom เรานำสิ่งนั้นมาใช้เป็น callback hook แบบกำหนดเองเพื่อให้กลับมาใช้ใหม่ได้ การเรียกกลับนี้จำเป็นต้องเพิ่มเธรดความคิดเห็นใหม่ให้กับทั้งอะตอม — commentThreadsState และ commentThreadIDsState เพื่อให้สามารถทำได้ เราใช้ useRecoilCallback hook เบ็ดนี้สามารถใช้เพื่อสร้างการเรียกกลับซึ่งรับบางสิ่งที่สามารถใช้ในการอ่าน/ตั้งค่าข้อมูลอะตอม สิ่งที่เราสนใจในตอนนี้คือ set function ซึ่งสามารถใช้เพื่ออัปเดตค่า atom เป็น set(atom, newValueOrUpdaterFunction)

 # 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)

ในการโทรครั้งที่สอง เราได้รับอะตอมสำหรับ ID จากตระกูล atom — commentThreadsState เป็น commentThreadsState(id) จากนั้นตั้งค่า threadData เป็นค่าของมัน atomFamilyName(atomID) คือวิธีที่การหดตัวทำให้เราเข้าถึงอะตอมจากตระกูลอะตอมโดยใช้คีย์เฉพาะ พูดอย่างหลวมๆ เราสามารถพูดได้ว่าถ้า commentThreadsState เป็นแผนที่จาวาสคริปต์ การโทรนี้โดยพื้นฐานแล้ว — commentThreadsState.set(id, threadData)

ตอนนี้ เรามีการตั้งค่าโค้ดทั้งหมดนี้เพื่อจัดการการแทรกเธรดความคิดเห็นใหม่ลงในเอกสารและอะตอมของหดตัว ให้เพิ่มปุ่มลงในแถบเครื่องมือของเราและเชื่อมต่อกับฟังก์ชันเหล่านี้

 # 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> ); }

หมายเหตุ : เราใช้ onMouseDown ไม่ใช่ onClick ซึ่งจะทำให้เอดิเตอร์สูญเสียโฟกัสและการเลือกกลายเป็น null เราได้กล่าวถึงรายละเอียดเพิ่มเติมเล็กน้อยในส่วนการแทรกลิงก์ของบทความแรก

ในตัวอย่างด้านล่าง เราจะเห็นการทำงานของการแทรกสำหรับเธรดความคิดเห็นธรรมดาและเธรดความคิดเห็นที่ทับซ้อนกันพร้อมลิงก์ สังเกตว่าเราได้รับการอัปเดตจาก Recoil Debugger อย่างไรเพื่อยืนยันว่าสถานะของเราได้รับการอัปเดตอย่างถูกต้อง นอกจากนี้เรายังตรวจสอบว่ามีการสร้างโหนดข้อความใหม่เมื่อมีการเพิ่มเธรดลงในเอกสาร

การแทรกเธรดความคิดเห็นจะแยกโหนดข้อความทำให้ข้อความแสดงความคิดเห็นเป็นโหนดของตัวเอง
มีการสร้างโหนดข้อความมากขึ้นเมื่อเราเพิ่มความคิดเห็นที่ทับซ้อนกัน

ความคิดเห็นที่ทับซ้อนกัน

ก่อนที่เราจะดำเนินการเพิ่มคุณลักษณะเพิ่มเติมในระบบการแสดงความคิดเห็นของเรา เราต้องตัดสินใจบางอย่างเกี่ยวกับวิธีที่เราจะจัดการกับความคิดเห็นที่ทับซ้อนกันและการผสมผสานที่แตกต่างกันในตัวแก้ไข เพื่อดูว่าทำไมเราถึงต้องการสิ่งนั้น ลองมาดูวิธีการทำงานของ Popover ความคิดเห็น — ฟังก์ชันการทำงานที่เราจะสร้างขึ้นในบทความต่อไป เมื่อผู้ใช้คลิกที่ข้อความที่มีชุดความคิดเห็น เราจะ 'เลือก' ชุดความคิดเห็นและแสดงป๊อปโอเวอร์ที่ผู้ใช้สามารถเพิ่มความคิดเห็นไปยังชุดข้อความนั้นได้

เมื่อผู้ใช้คลิกที่โหนดข้อความที่มีความคิดเห็นทับซ้อนกัน ผู้แก้ไขจำเป็นต้องตัดสินใจว่าจะเลือกเธรดความคิดเห็นใด

ดังที่คุณทราบได้จากวิดีโอด้านบน คำว่า 'นักออกแบบ' เป็นส่วนหนึ่งของชุดความคิดเห็นสามชุด ดังนั้นเราจึงมีเธรดความคิดเห็นสองชุดที่ซ้อนทับกันในคำหนึ่งๆ และเธรดความคิดเห็นทั้งสองนี้ (#1 และ #2) ล้วนอยู่ภายในช่วงข้อความของเธรดความคิดเห็นที่ยาวขึ้น (#3) สิ่งนี้ทำให้เกิดคำถามสองสามข้อ:

  1. เราควรเลือกและแสดงเธรดความคิดเห็นใดเมื่อผู้ใช้คลิกที่คำว่า 'นักออกแบบ'
  2. จากวิธีที่เราตัดสินใจจัดการกับคำถามข้างต้น เราจะมีกรณีของการทับซ้อนกันที่การคลิกที่คำใด ๆ จะไม่เปิดใช้งานชุดความคิดเห็นบางข้อความและไม่สามารถเข้าถึงชุดข้อความได้เลยหรือไม่

นี่หมายความว่าในกรณีที่ความคิดเห็นทับซ้อนกัน สิ่งสำคัญที่สุดที่ต้องพิจารณาคือ — เมื่อผู้ใช้แทรกชุดความคิดเห็นแล้ว จะมีวิธีใดที่พวกเขาจะสามารถเลือกชุดความคิดเห็นนั้นได้ในอนาคตโดยคลิกที่ข้อความด้านใน มัน? หากไม่เป็นเช่นนั้น เราอาจไม่ต้องการให้พวกเขาแทรกไว้ตั้งแต่แรก เพื่อให้แน่ใจว่าหลักการนี้ได้รับการเคารพ เกือบ ตลอดเวลาในบรรณาธิการของเรา เราจึงแนะนำกฎสองข้อเกี่ยวกับความคิดเห็นที่ทับซ้อนกันและนำไปใช้ในบรรณาธิการของเรา

ก่อนที่เราจะกำหนดกฎเหล่านั้น เราควรบอกว่าผู้แก้ไขและโปรแกรมประมวลผลคำต่างกันมีแนวทางที่แตกต่างกันเมื่อพูดถึงความคิดเห็นที่ทับซ้อนกัน เพื่อให้ง่ายขึ้น บรรณาธิการบางคนไม่อนุญาตให้มีความคิดเห็นที่ทับซ้อนกัน ในกรณีของเรา เราพยายามหาจุดกึ่งกลางโดยไม่อนุญาตให้มีการซ้อนทับกันที่ซับซ้อนเกินไป แต่ยังคงอนุญาตให้มีความคิดเห็นที่ทับซ้อนกัน เพื่อให้ผู้ใช้ได้รับประสบการณ์การทำงานร่วมกันและการตรวจสอบที่สมบูรณ์ยิ่งขึ้น

กฎช่วงความคิดเห็นที่สั้นที่สุด

กฎนี้ช่วยให้เราตอบคำถาม #1 จากด้านบนว่าจะเลือกเธรดความคิดเห็นใดหากผู้ใช้คลิกโหนดข้อความที่มีเธรดความคิดเห็นหลายเธรด กฎคือ:

“หากผู้ใช้คลิกที่ข้อความที่มีชุดความคิดเห็นหลายชุด เราจะพบชุดข้อความแสดงความคิดเห็นที่มีช่วงข้อความที่สั้นที่สุดแล้วเลือกรายการนั้น”

ตามสัญชาตญาณ การทำเช่นนี้ทำให้ผู้ใช้มีทางที่จะไปยังเธรดความคิดเห็นที่อยู่ด้านในสุดซึ่งมีอยู่อย่างครบถ้วนภายในเธรดความคิดเห็นอื่นเสมอ สำหรับเงื่อนไขอื่นๆ (บางส่วนทับซ้อนกันหรือไม่ทับซ้อนกัน) ควรมีข้อความบางส่วนที่มีเธรดความคิดเห็นเพียงชุดเดียว ดังนั้นควรใช้ข้อความนั้นในการเลือกชุดความคิดเห็นนั้นได้ง่าย เป็นกรณีของการทับซ้อนของเธรดแบบเต็ม (หรือ หนาแน่น ) และเหตุใดเราจึงต้องการกฎนี้

ลองดูกรณีการทับซ้อนกันที่ค่อนข้างซับซ้อนซึ่งทำให้เราใช้กฎนี้และ 'ทำสิ่งที่ถูกต้อง' เมื่อเลือกชุดความคิดเห็น

ตัวอย่างที่แสดงชุดความคิดเห็นสามชุดซ้อนทับกันในลักษณะที่วิธีเดียวในการเลือกชุดความคิดเห็นคือการใช้กฎความยาวที่สั้นที่สุด
ตามกฎของเธรดความคิดเห็นที่สั้นที่สุด การคลิกที่ 'B' จะเลือกเธรดความคิดเห็น #1 (ตัวอย่างขนาดใหญ่)

ในตัวอย่างข้างต้น ผู้ใช้แทรกเธรดความคิดเห็นต่อไปนี้ในลำดับนั้น:

  1. ความคิดเห็น หัวข้อ #1 เหนือตัวอักษร 'B' (ยาว = 1)
  2. ความคิดเห็นที่ #2 เหนือ 'AB' (ยาว = 2)
  3. ความคิดเห็น เธรด #3 เหนือ 'BC' (ความยาว = 2)

ในตอนท้ายของการแทรกเหล่านี้ เนื่องจากวิธีที่ Slate แยกโหนดข้อความด้วยเครื่องหมาย เราจึงมีโหนดข้อความสามโหนด — หนึ่งโหนดสำหรับแต่ละอักขระ ตอนนี้ หากผู้ใช้คลิกที่ 'B' โดยใช้กฎความยาวที่สั้นที่สุด เราจะเลือกเธรด #1 เนื่องจากเป็นเธรดที่สั้นที่สุดในสามความยาว หากเราไม่ทำเช่นนั้น เราจะไม่มีทางเลือก Comment Thread #1 ได้เลย เพราะมันมีความยาวเพียงหนึ่งอักขระและเป็นส่วนหนึ่งของอีกสองเธรด

แม้ว่ากฎนี้จะทำให้ง่ายต่อการแสดงชุดความคิดเห็นที่มีความยาวน้อยกว่า แต่เราอาจพบสถานการณ์ที่ไม่สามารถเข้าถึงชุดความคิดเห็นที่ยาวขึ้นได้ เนื่องจากอักขระทั้งหมดที่อยู่ในชุดข้อความเป็นส่วนหนึ่งของชุดความคิดเห็นที่สั้นกว่า ลองดูตัวอย่างสำหรับสิ่งนั้น

สมมติว่าเรามีอักขระ 100 ตัว (เช่น อักขระ 'A' พิมพ์ 100 ครั้ง นั่นคือ) และผู้ใช้แทรกเธรดความคิดเห็นตามลำดับต่อไปนี้:

  1. ความคิดเห็น กระทู้ # 1 จากช่วง 20,80
  2. ความคิดเห็น กระทู้ # 2 ของช่วง 0.50
  3. ความคิดเห็น กระทู้ # 3 จากช่วง 51,100
ตัวอย่างที่แสดงกฎที่มีความยาวสั้นที่สุดที่ทำให้เธรดความคิดเห็นไม่สามารถเลือกได้ เนื่องจากข้อความทั้งหมดครอบคลุมโดยเธรดความคิดเห็นที่สั้นกว่า
ข้อความทั้งหมดภายใต้หัวข้อความคิดเห็น #1 ยังเป็นส่วนหนึ่งของชุดความคิดเห็นอื่นๆ ที่สั้นกว่า #1 (ตัวอย่างขนาดใหญ่)

ดังที่คุณเห็นในตัวอย่างข้างต้น หากเราปฏิบัติตามกฎที่เราเพิ่งอธิบายไว้ที่นี่ การคลิกที่อักขระใดๆ ระหว่าง #20 ถึง #80 จะเป็นการเลือกชุดข้อความ #2 หรือ #3 เสมอ เนื่องจากสั้นกว่า #1 และด้วยเหตุนี้ #1 จะไม่สามารถเลือกได้ สถานการณ์สมมติอื่นที่กฎนี้สามารถทำให้เราตัดสินใจว่าจะเลือกเธรดความคิดเห็นใดคือเมื่อมีเธรดความคิดเห็นที่มีความยาวสั้นที่สุดเท่ากันมากกว่าหนึ่งชุดบนโหนดข้อความ

สำหรับชุดค่าผสมของความคิดเห็นที่ทับซ้อนกันและชุดค่าผสมอื่น ๆ อีกมากมายที่อาจคิดว่าการปฏิบัติตามกฎนี้ทำให้ไม่สามารถเข้าถึงเธรดความคิดเห็นบางรายการได้โดยการคลิกที่ข้อความ เราจะสร้างแถบด้านข้างความคิดเห็นในบทความนี้ ซึ่งจะให้ผู้ใช้ดูชุดความคิดเห็นทั้งหมดได้ แสดงในเอกสารเพื่อให้พวกเขาสามารถคลิกที่เธรดเหล่านั้นในแถบด้านข้างและเปิดใช้งานในเครื่องมือแก้ไขเพื่อดูช่วงของความคิดเห็น เรายังคงต้องการใช้กฎนี้และนำไปใช้ เนื่องจากควรครอบคลุมสถานการณ์ที่ทับซ้อนกันจำนวนมาก ยกเว้นตัวอย่างที่มีแนวโน้มน้อยกว่าที่เรากล่าวถึงข้างต้น เราใช้ความพยายามทั้งหมดไปกับกฎนี้เป็นหลัก เพราะการเห็นข้อความที่ไฮไลต์ในตัวแก้ไขและคลิกเพื่อแสดงความคิดเห็นเป็นวิธีที่ง่ายกว่าในการเข้าถึงความคิดเห็นในข้อความ มากกว่าเพียงแค่ใช้รายการความคิดเห็นในแถบด้านข้าง

กฎการแทรก

กฎคือ:

“หากข้อความที่ผู้ใช้เลือกและพยายามแสดงความคิดเห็นมีเธรดความคิดเห็นอยู่ครบถ้วนแล้ว ก็อย่าอนุญาตการแทรกนั้น”

ที่เป็นเช่นนี้เพราะว่าหากเราอนุญาตให้มีการแทรกนี้ อักขระแต่ละตัวในช่วงนั้นจะมีเธรดความคิดเห็นอย่างน้อยสองชุด (อันหนึ่งมีอยู่และอีกอันหนึ่งอันใหม่ที่เราเพิ่งอนุญาต) ทำให้เรายากต่อการพิจารณาว่าจะเลือกอันใดเมื่อ ผู้ใช้คลิกที่ตัวละครนั้นในภายหลัง

เมื่อพิจารณาจากกฎนี้ หลายคนอาจสงสัยว่าทำไมเราต้องใช้กฎนี้ตั้งแต่แรก หากเรามีกฎช่วงความคิดเห็นที่สั้นที่สุดอยู่แล้ว ซึ่งช่วยให้เราเลือกช่วงข้อความที่เล็กที่สุดได้ ทำไมไม่อนุญาตการซ้อนทับกันทั้งหมดถ้าเราสามารถใช้กฎข้อแรกในการอนุมานเธรดความคิดเห็นที่ถูกต้องเพื่อแสดงได้ จากตัวอย่างบางส่วนที่เราได้พูดคุยกันก่อนหน้านี้ กฎข้อแรกใช้ได้กับหลายสถานการณ์ แต่ไม่ใช่ทั้งหมด ด้วยกฎการแทรก เราพยายามลดจำนวนสถานการณ์ที่กฎแรกไม่สามารถช่วยเราได้ และเราต้องทางเลือกในแถบด้านข้างเป็นวิธีเดียวที่ผู้ใช้จะสามารถเข้าถึงชุดความคิดเห็นนั้นได้ กฎการแทรกยังป้องกันการทับซ้อนของเธรดความคิดเห็น กฎนี้มักใช้โดยผู้แก้ไขยอดนิยมจำนวนมาก

ด้านล่างนี้คือตัวอย่างที่หากไม่มีกฎนี้ เราจะอนุญาตให้ใช้เธรดความคิดเห็น #3 จากนั้นกฎข้อแรก #3 จะไม่สามารถเข้าถึงได้เนื่องจากจะกลายเป็นความยาวที่ยาวที่สุด

กฎการแทรกไม่อนุญาตให้มีชุดความคิดเห็นที่สามซึ่งครอบคลุมช่วงข้อความทั้งหมดโดยชุดความคิดเห็นอื่นอีกสองชุด

หมายเหตุ : การมีกฎนี้ไม่ได้หมายความว่าเราจะไม่มีความคิดเห็นที่ทับซ้อนกันอย่างสมบูรณ์ สิ่งที่ยุ่งยากเกี่ยวกับความคิดเห็นที่ทับซ้อนกันก็คือ แม้จะมีกฎเกณฑ์ ลำดับที่ความคิดเห็นถูกแทรกเข้าไปยังสามารถทำให้เราอยู่ในสถานะที่เราไม่ต้องการให้มีการทับซ้อนกัน อ้างอิงกลับไปที่ตัวอย่างความคิดเห็นของเราเกี่ยวกับคำว่า 'นักออกแบบ ' ก่อนหน้านี้ เธรดความคิดเห็นที่ยาวที่สุดแทรกอยู่ มีชุดสุดท้ายที่จะเพิ่มเข้าไป ดังนั้นกฎการแทรกจะอนุญาต และเราลงเอยด้วยสถานการณ์ที่สมบูรณ์ — #1 และ #2 อยู่ภายใน #3 ไม่เป็นไรเพราะกฎช่วงความคิดเห็นที่สั้นที่สุดจะช่วยเราได้

เราจะนำกฎช่วงความคิดเห็นที่สั้นที่สุดไปใช้ในส่วนถัดไปที่เราเลือกใช้การเลือกชุดข้อความความคิดเห็น เนื่องจากตอนนี้เรามีปุ่มแถบเครื่องมือสำหรับใส่ความคิดเห็น เราจึงสามารถใช้กฎการแทรกได้ทันทีโดยตรวจสอบกฎเมื่อผู้ใช้เลือกข้อความบางส่วน หากกฎไม่เป็นที่พอใจ เราจะปิดใช้งานปุ่มความคิดเห็น ดังนั้นผู้ใช้จึงไม่สามารถแทรกเธรดความคิดเห็นใหม่บนข้อความที่เลือกได้ มาเริ่มกันเลย!

 # 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 ); }

ตรรกะในฟังก์ชันนี้ค่อนข้างตรงไปตรงมา

  • หากการเลือกของผู้ใช้เป็นเครื่องหมายรูปหมวกกะพริบ เราไม่อนุญาตให้ใส่ความคิดเห็นเนื่องจากไม่มีการเลือกข้อความ
  • หากการเลือกของผู้ใช้ไม่ใช่แบบยุบ เราจะพบโหนดข้อความทั้งหมดในส่วนที่เลือก สังเกตการใช้ mode: lowest ในการเรียกใช้ Editor.nodes (ฟังก์ชันตัวช่วยโดย SlateJS) ที่ช่วยให้เราเลือกโหนดข้อความทั้งหมดเนื่องจากโหนดข้อความเป็นใบไม้ของโครงสร้างเอกสารจริงๆ
  • หากมีโหนดข้อความอย่างน้อยหนึ่งโหนดที่ไม่มีเธรดความคิดเห็น เราอาจอนุญาตให้แทรกได้ เราใช้ util 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> );

มาทดสอบการใช้งานกฎโดยสร้างตัวอย่างด้านบนใหม่

ปุ่มแทรกในแถบเครื่องมือถูกปิดใช้งานเนื่องจากผู้ใช้พยายามแทรกความคิดเห็นในช่วงข้อความที่ความคิดเห็นอื่นๆ ครอบคลุมอยู่แล้ว

รายละเอียดประสบการณ์ผู้ใช้ที่ดีที่จะกล่าวถึงในที่นี้คือ แม้ว่าเราจะปิดใช้งานปุ่มแถบเครื่องมือ หากผู้ใช้เลือกข้อความทั้งบรรทัดที่นี่ แต่ประสบการณ์นั้นไม่ได้ทำให้ประสบการณ์ของผู้ใช้สมบูรณ์ ผู้ใช้อาจไม่เข้าใจอย่างถ่องแท้ว่าเหตุใดปุ่มจึงถูกปิดใช้งาน และมีแนวโน้มที่จะสับสนว่าเราไม่ได้ตอบสนองต่อความตั้งใจของพวกเขาที่จะแทรกเธรดความคิดเห็นที่นั่น เราแก้ไขปัญหานี้ในภายหลังเนื่องจากมีการสร้าง Popover ของความคิดเห็นซึ่งแม้ว่าปุ่มในแถบเครื่องมือจะถูกปิดใช้งาน ป๊อปโอเวอร์สำหรับหนึ่งในเธรดความคิดเห็นก็จะปรากฏขึ้น และผู้ใช้จะยังสามารถแสดงความคิดเห็นได้

เรามาทดสอบกรณีที่มีโหนดข้อความที่ไม่มีความคิดเห็น และกฎอนุญาตให้แทรกเธรดความคิดเห็นใหม่

กฎการแทรก อนุญาตให้แทรกเธรดความคิดเห็นเมื่อมีข้อความที่ไม่มีความคิดเห็นภายในการเลือกของผู้ใช้

การเลือกหัวข้อความคิดเห็น

ในส่วนนี้ เราเปิดใช้งานคุณลักษณะที่ผู้ใช้คลิกที่โหนดข้อความที่มีความคิดเห็น และเราใช้กฎของช่วงความคิดเห็นที่สั้นที่สุดเพื่อกำหนดว่าควรเลือกเธรดความคิดเห็นใด ขั้นตอนในกระบวนการคือ:

  1. ค้นหาเธรดความคิดเห็นที่สั้นที่สุดในโหนดข้อความแสดงความคิดเห็นที่ผู้ใช้คลิก
  2. ตั้งค่าเธรดความคิดเห็นนั้นเป็นเธรดความคิดเห็นที่ใช้งานอยู่ (เราสร้างอะตอมหดตัวใหม่ซึ่งจะเป็นแหล่งของความจริงสำหรับสิ่งนี้)
  3. โหนดข้อความที่มีความคิดเห็นจะรับฟังสถานะการหดตัว และหากโหนดเหล่านี้เป็นส่วนหนึ่งของเธรดความคิดเห็นที่ทำงานอยู่ โหนดเหล่านั้นจะเน้นตัวเองแตกต่างไปจากเดิม ด้วยวิธีนี้ เมื่อผู้ใช้คลิกที่เธรดความคิดเห็น ช่วงข้อความทั้งหมดจะโดดเด่น เนื่องจากโหนดข้อความทั้งหมดจะอัปเดตสีไฮไลต์

ขั้นตอนที่ 1: การใช้กฎช่วงความคิดเห็นที่สั้นที่สุด

มาเริ่มกันที่ขั้นตอนที่ #1 ซึ่งโดยทั่วไปแล้วจะใช้กฎช่วงความคิดเห็นที่สั้นที่สุด เป้าหมายที่นี่คือการค้นหาเธรดความคิดเห็นที่มีช่วงสั้นที่สุดที่โหนดข้อความที่ผู้ใช้คลิก ในการหาเธรดที่สั้นที่สุด เราต้องคำนวณความยาวของเธรดความคิดเห็นทั้งหมดที่โหนดข้อความนั้น ขั้นตอนในการทำเช่นนี้คือ:

  1. รับเธรดความคิดเห็นทั้งหมดที่โหนดข้อความที่เป็นปัญหา
  2. ข้ามไปในทิศทางใดทิศทางหนึ่งจากโหนดข้อความนั้นและอัปเดตความยาวของเธรดที่กำลังติดตามอยู่
  3. หยุดการข้ามไปในทิศทางที่เราไปถึงหนึ่งในขอบด้านล่าง:
    • โหนดข้อความที่ไม่มีความคิดเห็น (หมายความว่าเราไปถึงจุดเริ่มต้น/สิ้นสุดของเธรดความคิดเห็นทั้งหมดที่เรากำลังติดตามอยู่)
    • โหนดข้อความที่เธรดความคิดเห็นทั้งหมดที่เรากำลังติดตามได้มาถึงขอบแล้ว (เริ่มต้น/สิ้นสุด)
    • ไม่มีโหนดข้อความที่จะข้ามไปในทิศทางนั้นอีกต่อไป (หมายความว่าเรามาถึงจุดเริ่มต้นหรือจุดสิ้นสุดของเอกสารหรือโหนดที่ไม่ใช่ข้อความ)

เนื่องจากการเคลื่อนที่ไปข้างหน้าและย้อนกลับมีฟังก์ชันเหมือนกัน เราจะเขียนฟังก์ชันตัวช่วย updateCommentThreadLengthMap ที่โดยทั่วไปจะใช้ตัววนซ้ำโหนดข้อความ มันจะเรียกใช้ตัววนซ้ำและอัปเดตความยาวของเธรดการติดตามต่อไป เราจะเรียกฟังก์ชันนี้สองครั้ง — หนึ่งครั้งสำหรับไปข้างหน้าและอีกครั้งสำหรับทิศทางย้อนกลับ Let's write our main utility function that will use this helper function.

 # 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.

Example of multiple comment threads overlapping on a text node.
Two comment threads overlapping over the word 'text'. (ตัวอย่างขนาดใหญ่)

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.

Slideshow showing iterations in the implementation of Shortest Comment Thread Rule.

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> ); }

ส่วนประกอบนี้ใช้ useRecoilState ที่อนุญาตให้ส่วนประกอบสมัครสมาชิกและยังสามารถตั้งค่าของอะตอมหดตัว เราต้องการให้สมาชิกทราบว่าโหนดข้อความนี้เป็นส่วนหนึ่งของเธรดความคิดเห็นที่ใช้งานอยู่หรือไม่ เพื่อให้สามารถจัดรูปแบบตัวเองให้แตกต่างออกไป ดูภาพหน้าจอด้านล่างซึ่งมีเธรดความคิดเห็นอยู่ตรงกลางและเราสามารถมองเห็นช่วงของความคิดเห็นได้อย่างชัดเจน

ตัวอย่างที่แสดงว่าโหนดข้อความภายใต้เธรดความคิดเห็นที่เลือกกระโดดออกมาได้อย่างไร
โหนดข้อความภายใต้เธรดความคิดเห็นที่เลือกจะเปลี่ยนรูปแบบและกระโดดออก (ตัวอย่างขนาดใหญ่)

ตอนนี้เรามีโค้ดทั้งหมดในการเลือกเธรดความคิดเห็นแล้ว มาดูการทำงานกัน เพื่อทดสอบโค้ดการข้ามผ่านของเราให้ดี เราได้ทดสอบกรณีการทับซ้อนที่ไม่ซับซ้อนและกรณีขอบบางกรณี เช่น:

  • คลิกที่โหนดข้อความที่มีความคิดเห็นที่จุดเริ่มต้น/จุดสิ้นสุดของเอดิเตอร์
  • การคลิกที่โหนดข้อความแสดงความคิดเห็นที่มีเธรดความคิดเห็นที่ครอบคลุมหลายย่อหน้า
  • คลิกที่โหนดข้อความที่มีความคิดเห็นก่อนโหนดรูปภาพ
  • คลิกที่ลิงก์ที่ทับซ้อนกันของโหนดข้อความแสดงความคิดเห็น
การเลือกเธรดความคิดเห็นที่สั้นที่สุดสำหรับชุดค่าผสมที่ทับซ้อนกันต่างๆ

เนื่องจากตอนนี้เรามีอะตอมของการหดตัวเพื่อติดตาม ID เธรดความคิดเห็นที่ทำงานอยู่ รายละเอียดเล็ก ๆ น้อย ๆ ที่ต้องดูแลคือการตั้งค่าเธรดความคิดเห็นที่สร้างขึ้นใหม่ให้เป็นเธรดที่ใช้งานเมื่อผู้ใช้ใช้ปุ่มแถบเครื่องมือเพื่อแทรกเธรดความคิดเห็นใหม่ ซึ่งทำให้เราสามารถแสดงป๊อปโอเวอร์ของเธรดความคิดเห็นได้ทันทีที่แทรกในหัวข้อถัดไปในหัวข้อถัดไป เพื่อให้ผู้ใช้สามารถเริ่มเพิ่มความคิดเห็นได้ทันที

 # 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 ที่นี่ (ตะขอหดตัวที่แสดงตัวตั้งค่าสำหรับอะตอม แต่ไม่ได้สมัครส่วนประกอบตามค่าของมัน) คือสิ่งที่เราต้องการสำหรับแถบเครื่องมือในกรณีนี้

การเพิ่มความคิดเห็น กระทู้ Popovers

ในส่วนนี้ เราสร้างป๊อปโอเวอร์ความคิดเห็นที่ใช้แนวคิดของเธรดความคิดเห็นที่เลือก/ใช้งานอยู่ และแสดงป๊อปโอเวอร์ที่อนุญาตให้ผู้ใช้เพิ่มความคิดเห็นไปยังเธรดความคิดเห็นนั้น ก่อนที่เราจะสร้างมันขึ้นมา เรามาดูกันว่ามันทำงานอย่างไร

การแสดงตัวอย่างคุณลักษณะป๊อปโอเวอร์ความคิดเห็น

เมื่อพยายามแสดงป๊อปโอเวอร์ความคิดเห็นใกล้กับเธรดความคิดเห็นที่ทำงานอยู่ เราพบปัญหาบางอย่างที่เราทำในบทความแรกด้วยเมนูตัวแก้ไขลิงก์ ณ จุดนี้ ขอแนะนำให้อ่านส่วนในบทความแรกที่สร้างตัวแก้ไขลิงก์และปัญหาการเลือกที่เราพบเจอ

ขั้นแรก เรามาสร้างการแสดงผลองค์ประกอบป๊อปโอเวอร์ที่ว่างเปล่าในที่ที่ถูกต้องโดยพิจารณาจากเธรดความคิดเห็นที่ทำงานอยู่ วิธีการทำงานของป๊อปโอเวอร์คือ:

  • ความคิดเห็นเธรด Popover จะแสดงเฉพาะเมื่อมี ID เธรดความคิดเห็นที่ใช้งานอยู่ เพื่อให้ได้ข้อมูลนั้น เราฟังอะตอมการหดตัวที่เราสร้างขึ้นในส่วนก่อนหน้า
  • เมื่อทำการเรนเดอร์ เราจะพบโหนดข้อความที่การเลือกของเอดิเตอร์และเรนเดอร์ป๊อปโอเวอร์ที่อยู่ใกล้ๆ
  • เมื่อผู้ใช้คลิกที่ใดก็ได้นอกป๊อปโอเวอร์ เราตั้งค่าเธรดความคิดเห็นที่ใช้งานอยู่ให้เป็น 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 และการ selection จากองค์ประกอบ Editor ที่จะแสดงผล editorOffsets เป็นขอบเขตขององค์ประกอบ Editor เพื่อให้เราสามารถคำนวณตำแหน่งของป๊อปโอเวอร์และ selection อาจเป็นการเลือกปัจจุบันหรือก่อนหน้าในกรณีที่ผู้ใช้ใช้ปุ่มแถบเครื่องมือทำให้ selection กลายเป็น null ส่วนในเครื่องมือแก้ไขลิงก์จากบทความแรกที่ลิงก์ด้านบนจะมีรายละเอียดในส่วนเหล่านี้
  • เนื่องจาก LinkEditor จากบทความแรกและ CommentThreadPopover ที่นี่ ทั้งคู่แสดงป๊อปโอเวอร์รอบ ๆ โหนดข้อความ เราจึงย้ายตรรกะทั่วไปนั้นไปยังส่วนประกอบ NodePopover ที่จัดการการแสดงผลของส่วนประกอบที่จัดแนวกับโหนดข้อความที่เป็นปัญหา รายละเอียดการใช้งานเป็นสิ่งที่องค์ประกอบ LinkEditor มีอยู่ในบทความแรก
  • NodePopover ใช้เมธอด onClickOutside เป็น prop ที่เรียกว่าถ้าผู้ใช้คลิกที่อื่นนอกป๊อปโอเวอร์ เราใช้สิ่งนี้โดยแนบตัวฟังเหตุการณ์ mousedown เข้ากับ document - ตามที่อธิบายไว้ในรายละเอียดในบทความ Smashing เกี่ยวกับแนวคิดนี้
  • 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 ที่ควรล้างเธรดความคิดเห็นที่ใช้งานอยู่ อย่างไรก็ตาม เราต้องพิจารณาถึงสถานการณ์เมื่อเปิดป๊อปโอเวอร์ของเธรดความคิดเห็นและเธรดบางอันทำงานอยู่ และผู้ใช้บังเอิญคลิกที่เธรดความคิดเห็นอื่น ในกรณีนั้น เราไม่ต้องการให้ onClickOutside รีเซ็ตเธรดความคิดเห็นที่ใช้งานอยู่ เนื่องจากเหตุการณ์การคลิกบนองค์ประกอบ CommentedText อื่นควรตั้งค่าเธรดความคิดเห็นอื่นให้ทำงาน เราไม่ต้องการเข้าไปยุ่งเกี่ยวกับสิ่งนั้นในป๊อปโอเวอร์

วิธีที่เราทำคือเราพบ Slate Node ที่ใกล้กับโหนด DOM ที่สุดที่เกิดเหตุการณ์การคลิก หากโหนด Slate นั้นเป็นโหนดข้อความและมีความคิดเห็น เราจะข้ามการรีเซ็ตเธรดความคิดเห็นที่ใช้งานอยู่ Recoil atom มาดำเนินการกันเถอะ!

 # 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 มีวิธีตัวช่วย toSlateNode ที่ส่งคืนโหนด Slate ที่แมปกับโหนด DOM หรือบรรพบุรุษที่ใกล้ที่สุด หากตัวมันเองไม่ใช่ Slate Node การใช้งานตัวช่วยนี้ในปัจจุบันทำให้เกิดข้อผิดพลาดหากไม่พบโหนด Slate แทนที่จะส่งคืน null เราจัดการกับสิ่งนั้นข้างต้นโดยตรวจสอบกรณี null ด้วยตนเองซึ่งเป็นสถานการณ์ที่น่าจะเป็นไปได้มากหากผู้ใช้คลิกที่ใดที่หนึ่งนอกตัวแก้ไขที่ไม่มีโหนด Slate

ขณะนี้ เราสามารถอัปเดตองค์ประกอบ Editor เพื่อฟัง activeCommentThreadIDAtom และแสดงป๊อปโอเวอร์เฉพาะเมื่อเธรดความคิดเห็นทำงานอยู่เท่านั้น

 # 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> ... </> ); }

มาตรวจสอบว่าป๊อปโอเวอร์โหลดที่ตำแหน่งที่ถูกต้องสำหรับเธรดความคิดเห็นที่ถูกต้อง และล้างเธรดความคิดเห็นที่ใช้งานอยู่เมื่อเราคลิกภายนอก

ความคิดเห็นเธรด Popover โหลดอย่างถูกต้องสำหรับเธรดความคิดเห็นที่เลือก

ตอนนี้เราดำเนินการต่อไปเพื่อให้ผู้ใช้สามารถเพิ่มความคิดเห็นในเธรดความคิดเห็น และดูความคิดเห็นทั้งหมดของเธรดนั้นในป๊อปโอเวอร์ เราจะใช้ตระกูลอะตอมของ 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> ); }

หมายเหตุ : แม้ว่าเราจะแสดงผลอินพุตให้ผู้ใช้พิมพ์ในความคิดเห็น เราก็ไม่จำเป็นต้องให้มันโฟกัสเมื่อป๊อปโอเวอร์เมานต์ นี่เป็นการตัดสินใจเกี่ยวกับประสบการณ์ผู้ใช้ซึ่งอาจแตกต่างกันไปตามผู้แก้ไขรายหนึ่งไปยังอีกรายหนึ่ง บรรณาธิการบางคนไม่อนุญาตให้ผู้ใช้แก้ไขข้อความในขณะที่เปิดป๊อปโอเวอร์เธรดความคิดเห็น ในกรณีของเรา เราต้องการให้ผู้ใช้แก้ไขข้อความแสดงความคิดเห็นเมื่อคลิกได้

น่าเรียกว่าเราเข้าถึงข้อมูลเธรดความคิดเห็นเฉพาะจากตระกูลอะตอม Recoil ได้อย่างไร - โดยเรียกอะตอมว่า — commentThreadsState(threadID) สิ่งนี้ทำให้เรามีค่าของอะตอมและตัวตั้งค่าเพื่ออัปเดตอะตอมนั้นในครอบครัว หากความคิดเห็นถูกโหลดแบบขี้เกียจจากเซิร์ฟเวอร์ Recoil ยังมีเบ็ด 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> ); }

เราได้แยกส่วนนี้ออกเป็นส่วนประกอบในขณะที่เรานำมาใช้ใหม่ในภายหลังเมื่อเราติดตั้งแถบด้านข้างความคิดเห็น

ณ จุดนี้ความคิดเห็น Popover ของเรามีรหัสทั้งหมดที่จำเป็นสำหรับการแทรกความคิดเห็นใหม่และอัปเดตสถานะการหดตัวเหมือนเดิม มาตรวจสอบว่า บนคอนโซลของเบราว์เซอร์ โดยใช้ Recoil Debug Observer ที่เราเพิ่มไว้ก่อนหน้านี้ เราสามารถตรวจสอบว่าอะตอมของ Recoil สำหรับเธรดความคิดเห็นได้รับการอัปเดตอย่างถูกต้องเมื่อเราเพิ่มความคิดเห็นใหม่ไปยังเธรด

ความคิดเห็นเธรด Popover โหลดเมื่อเลือกเธรดความคิดเห็น

การเพิ่มความคิดเห็นแถบด้านข้าง

ก่อนหน้านี้ในบทความ เราได้กล่าวถึงสาเหตุในบางครั้ง กฎที่เรานำมาใช้ป้องกันไม่ให้เธรดความคิดเห็นบางรายการไม่สามารถเข้าถึงได้โดยการคลิกที่โหนดข้อความเพียงอย่างเดียว ทั้งนี้ขึ้นอยู่กับการรวมกันของการทับซ้อนกัน สำหรับกรณีดังกล่าว เราจำเป็นต้องมีแถบด้านข้างความคิดเห็นซึ่งให้ผู้ใช้เข้าถึงกระทู้ความคิดเห็นทั้งหมดในเอกสารได้

แถบด้านข้างความคิดเห็นเป็นส่วนเสริมที่ดีที่รวมเข้ากับเวิร์กโฟลว์ข้อเสนอแนะและการตรวจทาน โดยที่ผู้ตรวจทานสามารถนำทางผ่านเธรดความคิดเห็นทั้งหมดทีละรายการในการกวาดล้าง และสามารถแสดงความคิดเห็น/ตอบกลับได้ทุกที่ที่ต้องการ ก่อนที่เราจะเริ่มใช้งานแถบด้านข้าง มีงานหนึ่งที่ยังไม่เสร็จที่เราดูแลด้านล่าง

การเริ่มต้นสถานะการหดตัวของเธรดความคิดเห็น

เมื่อโหลดเอกสารในตัวแก้ไข เราจำเป็นต้องสแกนเอกสารเพื่อค้นหาเธรดความคิดเห็นทั้งหมดและเพิ่มลงในอะตอมของ Recoil ที่เราสร้างไว้ด้านบนซึ่งเป็นส่วนหนึ่งของกระบวนการเริ่มต้น มาเขียนฟังก์ชันยูทิลิตี้ใน EditorCommentUtils ที่สแกนโหนดข้อความ ค้นหาเธรดความคิดเห็นทั้งหมด และเพิ่มลงในอะตอมของหดตัว

 # 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 เพียงอย่างเดียว เราจึงเริ่มต้นใช้งานด้วยข้อมูลบางอย่างที่ช่วยให้เรายืนยันว่าโค้ดเริ่มต้นใช้งานได้

ในการใช้งานระบบแสดงความคิดเห็นในโลกแห่งความเป็นจริง เธรดความคิดเห็นมักจะถูกจัดเก็บแยกจากเนื้อหาในเอกสารด้วยตัวมันเอง ในกรณีดังกล่าว โค้ดข้างต้นจะต้องได้รับการอัปเดตเพื่อทำการเรียก API ที่ดึงข้อมูลเมตาและความคิดเห็นทั้งหมดบน ID เธรดความคิดเห็นทั้งหมดใน commentThreads เมื่อโหลดชุดความคิดเห็นแล้ว พวกเขามักจะได้รับการอัปเดตเมื่อมีผู้ใช้หลายคนเพิ่มความคิดเห็นในแบบเรียลไทม์ เปลี่ยนสถานะและอื่น ๆ เวอร์ชันที่ใช้งานจริงของระบบแสดงความคิดเห็นจะต้องจัดโครงสร้างที่เก็บข้อมูล 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 ( <> ... </> ); }

เราใช้ hook แบบกำหนดเอง — useAddCommentThreadToState ที่เราใช้กับการติดตั้งปุ่มความคิดเห็นของ Toolbar เพื่อเพิ่มเธรดความคิดเห็นใหม่ เนื่องจากเรามีป๊อปโอเวอร์ทำงาน เราจึงสามารถคลิกเธรดความคิดเห็นที่มีอยู่แล้วในเอกสารและตรวจสอบว่าแสดงข้อมูลที่เราใช้ในการเริ่มต้นเธรดด้านบน

การคลิกที่เธรดความคิดเห็นที่มีอยู่แล้วจะเป็นการโหลดป๊อปโอเวอร์พร้อมกับความคิดเห็นอย่างถูกต้อง
การคลิกที่เธรดความคิดเห็นที่มีอยู่แล้วจะเป็นการโหลดป๊อปโอเวอร์พร้อมกับความคิดเห็นอย่างถูกต้อง (ตัวอย่างขนาดใหญ่)

ตอนนี้สถานะของเราได้รับการเตรียมใช้งานอย่างถูกต้องแล้ว เราสามารถเริ่มใช้งานแถบด้านข้างได้ เธรดความคิดเห็นทั้งหมดของเราใน UI นั้นจัดเก็บไว้ในกลุ่มอะตอมของ Recoil — commentThreadsState ตามที่เน้นไว้ก่อนหน้านี้ วิธีที่เราทำซ้ำผ่านรายการทั้งหมดในตระกูลอะตอมหดตัวคือการติดตามคีย์/รหัสอะตอมในอะตอมอื่น เราทำอย่างนั้นด้วย commentThreadIDsState มาเพิ่มองค์ประกอบ CommentSidebar ที่วนซ้ำชุดของรหัสในอะตอมนี้และแสดงองค์ประกอบ CommentThread สำหรับแต่ละองค์ประกอบ

 # 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> ); }

ตอนนี้ เราใช้คอมโพเนนต์ CommentThread ที่รับฟังอะตอมของ Recoil ในตระกูลที่สอดคล้องกับเธรดความคิดเห็นที่แสดงผล ด้วยวิธีนี้ เมื่อผู้ใช้เพิ่มความคิดเห็นเพิ่มเติมในเธรดในตัวแก้ไขหรือเปลี่ยนแปลงข้อมูลเมตาอื่นๆ เราสามารถอัปเดตแถบด้านข้างเพื่อสะท้อนถึงสิ่งนั้น

เนื่องจากแถบด้านข้างอาจขยายใหญ่ขึ้นสำหรับเอกสารที่มีความคิดเห็นจำนวนมาก เราจึงซ่อนความคิดเห็นทั้งหมดยกเว้นความคิดเห็นแรกเมื่อเราแสดงแถบด้านข้าง ผู้ใช้สามารถใช้ปุ่ม 'แสดง/ซ่อนการตอบกลับ' เพื่อแสดง/ซ่อนชุดความคิดเห็นทั้งหมด

 # 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> ); }

เราได้นำองค์ประกอบ CommentRow กลับมาใช้ใหม่จากป๊อปโอเวอร์ แม้ว่าเราจะเพิ่มการออกแบบโดยใช้อุปกรณ์ประกอบฉาก showConnector ที่ทำให้ความคิดเห็นทั้งหมดดูเชื่อมโยงกับเธรดในแถบด้านข้าง

ตอนนี้ เราแสดงแถบด้าน CommentSidebar ใน Editor และตรวจสอบว่าแสดงชุดข้อความทั้งหมดที่เรามีในเอกสารและอัปเดตอย่างถูกต้องเมื่อเราเพิ่มชุดข้อความใหม่หรือความคิดเห็นใหม่ให้กับชุดข้อความที่มีอยู่

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
แถบด้านข้างความคิดเห็นพร้อมเธรดความคิดเห็นทั้งหมดในเอกสาร

ตอนนี้เราเริ่มใช้การโต้ตอบกับแถบความคิดเห็นซึ่งเป็นที่นิยมซึ่งพบได้ในเครื่องมือแก้ไข:

การคลิกที่ชุดความคิดเห็นในแถบด้านข้างควรเลือก/เปิดใช้งานชุดความคิดเห็นนั้น นอกจากนี้เรายังเพิ่มการออกแบบที่แตกต่างกันเพื่อเน้นเธรดความคิดเห็นในแถบด้านข้างหากเปิดใช้งานในตัวแก้ไข เพื่อให้สามารถทำได้ เราใช้ Recoil atom — 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 ยังคงไม่เปลี่ยนแปลง และด้วยเหตุนี้ Popover ของความคิดเห็นจึงไม่เคลื่อนไหว

วิธีแก้ไขปัญหานี้คือการอัปเดตการเลือกของตัวแก้ไขพร้อมกับอัปเดตอะตอมหดตัวเมื่อผู้ใช้คลิกที่เธรดความคิดเห็นในแถบด้านข้าง ขั้นตอนการทำคือ:

  1. ค้นหาโหนดข้อความทั้งหมดที่มีเธรดความคิดเห็นนี้ซึ่งเราจะตั้งค่าเป็นเธรดที่ใช้งานอยู่ใหม่
  2. จัดเรียงโหนดข้อความตามลำดับที่ปรากฏในเอกสาร (เราใช้ Path.compare API ของ Slate สำหรับสิ่งนี้)
  3. คำนวณช่วงการเลือกที่ครอบคลุมตั้งแต่จุดเริ่มต้นของโหนดข้อความแรกจนถึงจุดสิ้นสุดของโหนดข้อความสุดท้าย
  4. ตั้งค่าช่วงการเลือกให้เป็นการเลือกใหม่ของเอดิเตอร์ (โดยใช้ Transforms.select API ของ Slate)

หากเราเพียงต้องการแก้ไขจุดบกพร่อง เราก็สามารถค้นหาโหนดข้อความแรกในขั้นตอนที่ 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

มาตรวจสอบว่าการใช้งานนี้แก้ไขข้อผิดพลาดและความคิดเห็น Popover จะย้ายไปยังเธรดความคิดเห็นที่ใช้งานอยู่อย่างถูกต้อง ครั้งนี้ เรายังทดสอบกับกรณีของเธรดที่ทับซ้อนกันเพื่อให้แน่ใจว่าจะไม่แตกที่นั่น

การคลิกที่เธรดความคิดเห็นในแถบด้านข้างความคิดเห็นจะเป็นการเลือกและโหลดป๊อปโอเวอร์ของเธรดความคิดเห็น

ด้วยการแก้ไขข้อผิดพลาด เราได้เปิดใช้งานการโต้ตอบกับแถบด้านข้างอื่นที่เรายังไม่ได้พูดถึง หากเรามีเอกสารที่ยาวมาก และผู้ใช้คลิกที่เธรดความคิดเห็นในแถบด้านข้างที่อยู่นอกวิวพอร์ต เราต้องการเลื่อนไปยังส่วนนั้นของเอกสารเพื่อให้ผู้ใช้สามารถโฟกัสที่เธรดความคิดเห็นในตัวแก้ไขได้ ด้วยการตั้งค่าการเลือกด้านบนโดยใช้ API ของ Slate เราจะได้สิ่งนั้นฟรี เรามาดูการทำงานด้านล่างกัน

เอกสารจะเลื่อนไปที่เธรดความคิดเห็นอย่างถูกต้องเมื่อคลิกในแถบด้านข้างความคิดเห็น

ด้วยเหตุนี้ เราจึงสรุปการใช้งานแถบด้านข้าง ในช่วงท้ายของบทความ เราแสดงรายการคุณลักษณะและการปรับปรุงเพิ่มเติมที่เราสามารถทำได้ในแถบด้านข้างความคิดเห็น ซึ่งช่วยยกระดับประสบการณ์การแสดงความคิดเห็นและการตรวจสอบในตัวแก้ไข

การแก้ไขและเปิดความคิดเห็นใหม่

ในส่วนนี้ เราเน้นที่การทำให้ผู้ใช้สามารถทำเครื่องหมายชุดความคิดเห็นว่า 'แก้ไขแล้ว' หรือสามารถเปิดใหม่เพื่อการสนทนาได้หากจำเป็น จากมุมมองรายละเอียดการใช้งาน นี่คือข้อมูลเมตา status ในชุดข้อความความคิดเห็นที่เราเปลี่ยนแปลงเมื่อผู้ใช้ดำเนินการนี้ จากมุมมองของผู้ใช้ คุณลักษณะนี้เป็นคุณลักษณะที่มีประโยชน์มาก เนื่องจากช่วยให้สามารถยืนยันได้ว่าการอภิปรายเกี่ยวกับบางสิ่งบางอย่างในเอกสารได้สิ้นสุดลงแล้วหรือจำเป็นต้องเปิดใหม่ เนื่องจากมีการอัปเดต/มุมมองใหม่ๆ และอื่นๆ

ในการเปิดใช้งานการสลับสถานะ เราได้เพิ่มปุ่มใน CommentPopover ที่อนุญาตให้ผู้ใช้สลับระหว่างสองสถานะ: open และ resolved แล้ว

 # 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 หลักสำหรับระบบแสดงความคิดเห็นใน Rich Text Editor ชุดฟังก์ชันที่เราเพิ่มที่นี่ทำหน้าที่เป็นรากฐานในการสร้าง Collaboration Experience ที่สมบูรณ์ยิ่งขึ้นในเครื่องมือแก้ไข ซึ่งผู้ทำงานร่วมกันสามารถใส่คำอธิบายประกอบบางส่วนของเอกสารและสนทนาเกี่ยวกับสิ่งเหล่านี้ได้ การเพิ่มแถบด้านข้างความคิดเห็นทำให้เรามีพื้นที่สำหรับใช้งานฟังก์ชันการสนทนาหรือบทวิจารณ์เพิ่มเติมเพื่อเปิดใช้งานในผลิตภัณฑ์

ต่อไปนี้คือคุณลักษณะบางอย่างที่ Rich Text Editor อาจพิจารณาเพิ่มจากสิ่งที่เราสร้างขึ้นในบทความนี้:

  • รองรับการกล่าวถึง @ เพื่อให้ผู้ทำงานร่วมกันสามารถแท็กกันได้ในความคิดเห็น
  • รองรับประเภทสื่อเช่นรูปภาพและวิดีโอที่จะเพิ่มไปยังเธรดความคิดเห็น
  • โหมดข้อเสนอแนะที่ระดับเอกสารที่อนุญาตให้ผู้ตรวจทานแก้ไขเอกสารที่ปรากฏเป็นคำแนะนำสำหรับการเปลี่ยนแปลงได้ หนึ่งสามารถอ้างถึงคุณลักษณะนี้ใน Google เอกสารหรือการติดตามการเปลี่ยนแปลงใน Microsoft Word เป็นตัวอย่าง;
  • ปรับปรุงแถบด้านข้างเพื่อค้นหาการสนทนาตามคำสำคัญ กรองชุดข้อความตามสถานะหรือผู้แสดงความคิดเห็น และอื่นๆ