Menambahkan Sistem Komentar ke Editor WYSIWYG
Diterbitkan: 2022-03-10Dalam beberapa tahun terakhir, kami telah melihat Kolaborasi menembus banyak alur kerja digital dan kasus penggunaan di banyak profesi. Hanya dalam komunitas Desain dan Rekayasa Perangkat Lunak, kami melihat desainer berkolaborasi dalam artefak desain menggunakan alat seperti Figma, tim melakukan Sprint dan Perencanaan Proyek menggunakan alat seperti Mural dan wawancara dilakukan menggunakan CoderPad. Semua alat ini terus-menerus bertujuan untuk menjembatani kesenjangan antara pengalaman dunia online dan fisik dalam menjalankan alur kerja ini dan menjadikan pengalaman kolaborasi sekaya dan semulus mungkin.
Untuk sebagian besar Alat Kolaborasi seperti ini, kemampuan untuk berbagi pendapat satu sama lain dan berdiskusi tentang konten yang sama adalah hal yang harus dimiliki. Sebuah Sistem Komentar yang memungkinkan kolaborator untuk membubuhi keterangan bagian dari dokumen dan memiliki percakapan tentang mereka adalah inti dari konsep ini. Bersamaan dengan membangun satu untuk teks di Editor WYSIWYG, artikel ini mencoba melibatkan pembaca tentang bagaimana kami mencoba menimbang pro dan kontra dan mencoba menemukan keseimbangan antara kompleksitas aplikasi dan pengalaman pengguna ketika datang untuk membangun fitur untuk Editor WYSIWYG atau Pengolah kata pada umumnya.
Mewakili Komentar Dalam Struktur Dokumen
Untuk menemukan cara untuk merepresentasikan komentar dalam struktur data dokumen teks kaya, mari kita lihat beberapa skenario di mana komentar dapat dibuat di dalam editor.
- Komentar dibuat di atas teks yang tidak memiliki gaya (skenario dasar);
- Komentar yang dibuat di atas teks yang bisa dicetak tebal/miring/garis bawah, dan sebagainya;
- Komentar yang tumpang tindih satu sama lain dalam beberapa cara (tumpang tindih sebagian di mana dua komentar hanya berbagi beberapa kata atau lengkap di mana teks satu komentar sepenuhnya terkandung dalam teks komentar lain);
- Komentar dibuat di atas teks di dalam tautan (khusus karena tautan adalah simpul itu sendiri dalam struktur dokumen kami);
- Komentar yang mencakup beberapa paragraf (khusus karena paragraf adalah simpul dalam struktur dokumen kita dan komentar diterapkan ke simpul teks yang merupakan turunan paragraf).
Melihat kasus penggunaan di atas, sepertinya komentar dalam cara mereka muncul dalam dokumen teks kaya sangat mirip dengan gaya karakter (tebal, miring, dll). Mereka dapat tumpang tindih satu sama lain, membahas teks dalam jenis simpul lain seperti tautan dan bahkan menjangkau beberapa simpul induk seperti paragraf.
Untuk alasan ini, kami menggunakan metode yang sama untuk merepresentasikan komentar seperti yang kami lakukan untuk gaya karakter, yaitu "Tanda" (seperti yang disebut dalam terminologi SlateJS). Tanda hanyalah properti biasa pada node — khusus karena API Slate di sekitar tanda ( Editor.addMark
dan Editor.removeMark
) menangani perubahan hierarki node karena beberapa tanda diterapkan ke rentang teks yang sama. Ini sangat berguna bagi kami karena kami menangani banyak kombinasi berbeda dari komentar yang tumpang tindih.
Utas Komentar Sebagai Tanda
Setiap kali pengguna memilih rentang teks dan mencoba memasukkan komentar, secara teknis, mereka memulai utas komentar baru untuk rentang teks tersebut. Karena kami mengizinkan mereka untuk menyisipkan komentar dan kemudian membalas komentar itu, kami memperlakukan acara ini sebagai penyisipan utas komentar baru dalam dokumen.
Cara kami merepresentasikan utas komentar sebagai tanda adalah bahwa setiap utas komentar diwakili oleh tanda bernama commentThread_threadID
di mana threadID
adalah ID unik yang kami tetapkan untuk setiap utas komentar. Jadi, jika rentang teks yang sama memiliki dua utas komentar di atasnya, itu akan memiliki dua properti yang disetel ke true
— commentThread_thread1
dan commentThread_thread2
. Di sinilah utas komentar sangat mirip dengan gaya karakter karena jika teks yang sama dicetak tebal dan miring, keduanya akan memiliki properti yang disetel ke true
— bold
dan italic
.
Sebelum kita masuk ke dalam pengaturan struktur ini, ada baiknya melihat bagaimana node teks berubah saat utas komentar diterapkan padanya. Cara kerjanya (seperti halnya dengan tanda apa pun) adalah ketika properti tanda disetel pada teks yang dipilih, Editor.addMark API Slate akan membagi simpul teks jika diperlukan sedemikian rupa sehingga dalam struktur yang dihasilkan, simpul teks diatur sedemikian rupa sehingga setiap simpul teks memiliki nilai tanda yang sama persis.
Untuk memahami ini dengan lebih baik, lihat tiga contoh berikut yang menunjukkan keadaan sebelum dan sesudah node teks setelah utas komentar disisipkan pada teks yang dipilih:
Menyorot Teks yang Dikomentari
Sekarang kita tahu bagaimana kita akan merepresentasikan komentar dalam struktur dokumen, mari kita lanjutkan dan tambahkan beberapa ke dokumen contoh dari artikel pertama dan konfigurasikan editor untuk benar-benar menampilkannya sebagai disorot. Karena kita akan memiliki banyak fungsi utilitas untuk menangani komentar dalam artikel ini, kita membuat modul EditorCommentUtils
yang akan menampung semua utilitas ini. Untuk memulainya, kami membuat fungsi yang membuat tanda untuk ID utas komentar yang diberikan. Kami kemudian menggunakannya untuk memasukkan beberapa utas komentar di ExampleDocument
kami.
# src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }
Gambar di bawah ini menggarisbawahi dengan warna merah rentang teks yang kami miliki sebagai contoh utas komentar yang ditambahkan dalam cuplikan kode berikutnya. Perhatikan bahwa teks 'Richard McClintock' memiliki dua utas komentar yang saling tumpang tindih. Secara khusus, ini adalah kasus dari satu utas komentar yang sepenuhnya terkandung di dalam yang lain.
# 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, }, ... ];
Kami fokus pada sisi UI dari Sistem Komentar dalam artikel ini sehingga kami menetapkan ID mereka dalam dokumen contoh secara langsung menggunakan paket npm uuid. Sangat mungkin bahwa dalam versi produksi editor, ID ini dibuat oleh layanan backend.
Kami sekarang fokus pada tweaker editor untuk menampilkan node teks ini sebagai disorot. Untuk melakukan itu, saat merender node teks, kita memerlukan cara untuk mengetahui apakah node tersebut memiliki utas komentar. Kami menambahkan util getCommentThreadsOnTextNode
untuk itu. Kami membangun komponen StyledText
yang kami buat di artikel pertama untuk menangani kasus di mana ia mungkin mencoba merender simpul teks dengan komentar aktif. Karena kami memiliki beberapa fungsionalitas lagi yang akan ditambahkan ke node teks yang dikomentari nanti, kami membuat komponen CommentedText
yang merender teks yang dikomentari. StyledText
akan memeriksa apakah simpul teks yang coba dirender memiliki komentar. Jika ya, itu membuat CommentedText
. Ini menggunakan util getCommentThreadsOnTextNode
untuk menyimpulkan itu.
# 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; }
Artikel pertama membangun komponen StyledText
yang merender node teks (menangani gaya karakter dan sebagainya). Kami memperluas komponen itu untuk menggunakan utilitas di atas dan merender komponen CommentedText
jika node memiliki komentar di dalamnya.
# 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>; }
Di bawah ini adalah implementasi dari CommentedText
yang merender node teks dan melampirkan CSS yang menunjukkannya sebagai disorot.
# 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; }
Dengan semua kode di atas bersatu, sekarang kita melihat node teks dengan utas komentar disorot di editor.
Catatan : Pengguna saat ini tidak dapat mengetahui apakah teks tertentu memiliki komentar yang tumpang tindih. Seluruh rentang teks yang disorot tampak seperti utas komentar tunggal. Kami membahasnya nanti di artikel di mana kami memperkenalkan konsep utas komentar aktif yang memungkinkan pengguna memilih utas komentar tertentu dan dapat melihat jangkauannya di editor.
Penyimpanan UI Untuk Komentar
Sebelum kami menambahkan fungsionalitas yang memungkinkan pengguna untuk memasukkan komentar baru, pertama-tama kami menyiapkan status UI untuk menahan utas komentar kami. Dalam artikel ini, kami menggunakan RecoilJS sebagai perpustakaan manajemen negara kami untuk menyimpan utas komentar, komentar yang terkandung di dalam utas dan metadata lainnya seperti waktu pembuatan, status, penulis komentar, dll. Mari tambahkan Recoil ke aplikasi kami:
> yarn add recoil
Kami menggunakan atom Recoil untuk menyimpan dua struktur data ini. Jika Anda tidak terbiasa dengan Recoil, atom adalah yang memegang status aplikasi. Untuk bagian yang berbeda dari status aplikasi, Anda biasanya ingin mengatur atom yang berbeda. Keluarga Atom adalah kumpulan atom — dapat dianggap sebagai Map
dari kunci unik yang mengidentifikasi atom hingga atom itu sendiri. Ada baiknya melalui konsep inti Recoil pada saat ini dan membiasakan diri dengan mereka.
Untuk kasus penggunaan kami, kami menyimpan utas komentar sebagai keluarga Atom dan kemudian membungkus aplikasi kami dalam komponen RecoilRoot
. RecoilRoot
diterapkan untuk menyediakan konteks di mana nilai atom akan digunakan. Kami membuat modul CommentState
terpisah yang menyimpan definisi atom Recoil kami saat kami menambahkan lebih banyak definisi atom nanti di artikel.
# 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([]), });
Layak disebut beberapa hal tentang definisi atom ini:
- Setiap keluarga atom/atom diidentifikasi secara unik oleh sebuah
key
dan dapat diatur dengan nilai default. - Saat kami membangun lebih lanjut dalam artikel ini, kami akan membutuhkan cara untuk mengulangi semua utas komentar yang pada dasarnya berarti membutuhkan cara untuk mengulangi keluarga atom
commentThreadsState
. Pada saat penulisan artikel ini, cara melakukannya dengan Recoil adalah dengan menyiapkan atom lain yang menampung semua ID keluarga atom. Kami melakukannya dengancommentThreadIDsState
di atas. Kedua atom ini harus tetap sinkron setiap kali kita menambah/menghapus utas komentar.
Kami menambahkan pembungkus RecoilRoot
di komponen App
root kami sehingga kami dapat menggunakan atom ini nanti. Dokumentasi Recoil juga menyediakan komponen Debugger yang berguna yang kami ambil apa adanya dan masukkan ke editor kami. Komponen ini akan meninggalkan log console.debug
ke konsol Dev kami saat atom Recoil diperbarui secara real-time.
# 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. }
Kita juga perlu menambahkan kode yang menginisialisasi atom kita dengan utas komentar yang sudah ada di dokumen (yang kita tambahkan ke dokumen contoh kita di bagian sebelumnya, misalnya). Kami melakukannya nanti saat kami membangun Bilah Sisi Komentar yang perlu membaca semua utas komentar dalam dokumen.
Pada titik ini, kami memuat aplikasi kami, pastikan tidak ada kesalahan yang mengarah ke pengaturan Recoil kami dan lanjutkan.
Menambahkan Komentar Baru
Di bagian ini, kami menambahkan tombol ke bilah alat yang memungkinkan pengguna menambahkan komentar (yaitu membuat utas komentar baru) untuk rentang teks yang dipilih. Ketika pengguna memilih rentang teks dan mengklik tombol ini, kita perlu melakukan hal di bawah ini:
- Tetapkan ID unik ke utas komentar baru yang dimasukkan.
- Tambahkan tanda baru ke struktur dokumen Slate dengan ID sehingga pengguna melihat teks yang disorot.
- Tambahkan utas komentar baru ke atom Recoil yang kami buat di bagian sebelumnya.
Mari tambahkan fungsi util ke EditorCommentUtils
yang melakukan #1 dan #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; }
Dengan menggunakan konsep tanda untuk menyimpan setiap utas komentar sebagai tandanya sendiri, kami cukup menggunakan API Editor.addMark
untuk menambahkan utas komentar baru pada rentang teks yang dipilih. Panggilan ini sendiri menangani semua kasus berbeda dalam menambahkan komentar — beberapa di antaranya telah kami jelaskan di bagian sebelumnya — komentar yang tumpang tindih sebagian, komentar di dalam/tautan yang tumpang tindih, komentar di atas teks tebal/miring, komentar yang mencakup paragraf, dan seterusnya. Panggilan API ini menyesuaikan hierarki node untuk membuat node teks baru sebanyak yang diperlukan untuk menangani kasus ini.
addCommentThreadToState
adalah fungsi panggilan balik yang menangani langkah #3 — menambahkan utas komentar baru ke Recoil atom . Kami mengimplementasikannya selanjutnya sebagai kait panggilan balik khusus sehingga dapat digunakan kembali. Panggilan balik ini perlu menambahkan utas komentar baru ke kedua atom — commentThreadsState
dan commentThreadIDsState
. Untuk dapat melakukan ini, kami menggunakan kait useRecoilCallback
. Kait ini dapat digunakan untuk membuat panggilan balik yang mendapatkan beberapa hal yang dapat digunakan untuk membaca/mengatur data atom. Yang kami minati saat ini adalah fungsi set
yang dapat digunakan untuk memperbarui nilai atom sebagai 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); }, [] ); }
Panggilan pertama ke set
menambahkan ID baru ke set ID utas komentar yang ada dan mengembalikan Set
baru (yang menjadi nilai atom baru).
Pada panggilan kedua, kita mendapatkan atom untuk ID dari keluarga atom — commentThreadsState
sebagai commentThreadsState(id)
dan kemudian mengatur threadData
menjadi nilainya. atomFamilyName(atomID)
adalah bagaimana Recoil memungkinkan kita mengakses atom dari keluarga atomnya menggunakan kunci unik. Secara longgar, kita dapat mengatakan bahwa jika commentThreadsState
adalah Peta javascript, panggilan ini pada dasarnya adalah — commentThreadsState.set(id, threadData)
.
Sekarang kita memiliki semua pengaturan kode ini untuk menangani penyisipan utas komentar baru ke dokumen dan atom Recoil, mari tambahkan tombol ke bilah alat kita dan sambungkan dengan panggilan ke fungsi-fungsi ini.
# 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> ); }
Catatan : Kami menggunakan onMouseDown
dan bukan onClick
yang akan membuat editor kehilangan fokus dan pilihan menjadi null
. Kami telah membahasnya lebih detail di bagian penyisipan tautan di artikel pertama.
Dalam contoh di bawah ini, kita melihat penyisipan beraksi untuk utas komentar sederhana dan utas komentar yang tumpang tindih dengan tautan. Perhatikan bagaimana kami mendapatkan pembaruan dari Recoil Debugger yang mengonfirmasi status kami diperbarui dengan benar. Kami juga memverifikasi bahwa node teks baru dibuat saat utas ditambahkan ke dokumen.
Komentar yang Tumpang Tindih
Sebelum kita melanjutkan dengan menambahkan lebih banyak fitur ke sistem komentar kita, kita perlu membuat beberapa keputusan tentang bagaimana kita akan menangani komentar yang tumpang tindih dan kombinasi yang berbeda di editor. Untuk melihat mengapa kita membutuhkannya, mari kita intip cara kerja Popover Komentar — fungsionalitas yang akan kita bangun nanti di artikel. Ketika pengguna mengklik teks tertentu dengan utas komentar di atasnya, kami 'memilih' utas komentar dan menampilkan popover tempat pengguna dapat menambahkan komentar ke utas itu.
Seperti yang Anda ketahui dari video di atas, kata 'desainer' sekarang menjadi bagian dari tiga utas komentar. Jadi kami memiliki dua utas komentar yang tumpang tindih satu sama lain dalam satu kata. Dan kedua utas komentar ini (#1 dan #2) sepenuhnya terkandung di dalam rentang teks utas komentar yang lebih panjang (#3). Hal ini menimbulkan beberapa pertanyaan:
- Utas komentar mana yang harus kita pilih dan tampilkan saat pengguna mengklik kata 'desainer'?
- Berdasarkan bagaimana kami memutuskan untuk menjawab pertanyaan di atas, apakah kami pernah memiliki kasus tumpang tindih di mana mengklik kata apa pun tidak akan pernah mengaktifkan utas komentar tertentu dan utas tidak dapat diakses sama sekali?
Ini menyiratkan dalam kasus komentar yang tumpang tindih, hal terpenting untuk dipertimbangkan adalah — setelah pengguna memasukkan utas komentar, apakah akan ada cara bagi mereka untuk dapat memilih utas komentar itu di masa mendatang dengan mengklik beberapa teks di dalamnya dia? Jika tidak, kami mungkin tidak ingin mereka memasukkannya sejak awal. Untuk memastikan prinsip ini dihormati hampir sepanjang waktu di editor kami, kami memperkenalkan dua aturan mengenai komentar yang tumpang tindih dan menerapkannya di editor kami.
Sebelum kita mendefinisikan aturan tersebut, ada baiknya menyebutkan bahwa editor dan pengolah kata yang berbeda memiliki pendekatan yang berbeda dalam hal komentar yang tumpang tindih. Untuk menjaga hal-hal sederhana, beberapa editor tidak mengizinkan komentar yang tumpang tindih sama sekali. Dalam kasus kami, kami mencoba menemukan jalan tengah dengan tidak mengizinkan kasus tumpang tindih yang terlalu rumit tetapi tetap mengizinkan komentar yang tumpang tindih sehingga pengguna dapat memiliki pengalaman Kolaborasi dan Tinjauan yang lebih kaya.
Aturan Rentang Komentar Terpendek
Aturan ini membantu kami menjawab pertanyaan #1 dari atas tentang utas komentar mana yang harus dipilih jika pengguna mengklik simpul teks yang memiliki banyak utas komentar di dalamnya. Aturannya adalah:
“Jika pengguna mengklik teks yang memiliki beberapa utas komentar di dalamnya, kami menemukan utas komentar dengan rentang teks terpendek dan memilih itu.”
Secara intuitif, masuk akal untuk melakukan ini sehingga pengguna selalu memiliki cara untuk mencapai utas komentar terdalam yang sepenuhnya terkandung di dalam utas komentar lain. Untuk kondisi lain (tumpang tindih sebagian atau tidak tumpang tindih), harus ada beberapa teks yang hanya memiliki satu utas komentar sehingga teks tersebut harus mudah digunakan untuk memilih utas komentar tersebut. Ini adalah kasus tumpang tindih penuh (atau padat ) utas dan mengapa kami membutuhkan aturan ini.
Mari kita lihat kasus tumpang tindih yang agak rumit yang memungkinkan kita menggunakan aturan ini dan 'melakukan hal yang benar' saat memilih utas komentar.
Dalam contoh di atas, pengguna menyisipkan utas komentar berikut dalam urutan itu:
- Komentar Utas #1 di atas karakter 'B' (panjang = 1).
- Komentar Utas #2 di atas 'AB' (panjang = 2).
- Komentar Utas #3 di atas 'BC' (panjang = 2).
Di akhir penyisipan ini, karena cara Slate membagi simpul teks dengan tanda, kita akan memiliki tiga simpul teks — satu untuk setiap karakter. Sekarang, jika pengguna mengklik 'B', mengikuti aturan panjang terpendek, kami memilih utas #1 karena ini adalah yang terpendek dari ketiganya. Jika kami tidak melakukannya, kami tidak akan memiliki cara untuk memilih Thread Komentar #1 karena panjangnya hanya satu karakter dan juga merupakan bagian dari dua utas lainnya.
Meskipun aturan ini memudahkan untuk memunculkan utas komentar yang lebih pendek, kita dapat mengalami situasi di mana utas komentar yang lebih panjang menjadi tidak dapat diakses karena semua karakter yang terkandung di dalamnya adalah bagian dari beberapa utas komentar yang lebih pendek lainnya. Mari kita lihat contoh untuk itu.
Mari kita asumsikan kita memiliki 100 karakter (misalnya, karakter 'A' diketik 100 kali) dan pengguna memasukkan utas komentar dalam urutan berikut:
- Komentar Utas # 1 dari kisaran 20,80
- Komentar Thread #2 dari range 0,50
- Komentar Utas # 3 dari kisaran 51.100
Seperti yang Anda lihat pada contoh di atas, jika kita mengikuti aturan yang baru saja kita jelaskan di sini, mengklik karakter apa pun antara #20 dan #80, akan selalu memilih utas #2 atau #3 karena mereka lebih pendek dari #1 dan karenanya #1 tidak akan dapat dipilih. Skenario lain di mana aturan ini dapat membuat kita ragu-ragu tentang utas komentar mana yang harus dipilih adalah ketika ada lebih dari satu utas komentar dengan panjang terpendek yang sama pada simpul teks.
Untuk kombinasi komentar yang tumpang tindih dan banyak kombinasi lainnya yang dapat dipikirkan di mana mengikuti aturan ini membuat utas komentar tertentu tidak dapat diakses dengan mengklik teks, kami membangun Bilah Sisi Komentar nanti di artikel ini yang memberi pengguna pandangan tentang semua utas komentar hadir dalam dokumen sehingga mereka dapat mengklik utas tersebut di bilah sisi dan mengaktifkannya di editor untuk melihat rentang komentar. Kami masih ingin memiliki aturan ini dan menerapkannya karena harus mencakup banyak skenario tumpang tindih kecuali untuk contoh yang kurang mungkin seperti yang kami kutip di atas. Kami melakukan semua upaya ini di sekitar aturan ini terutama karena melihat teks yang disorot di editor dan mengkliknya untuk berkomentar adalah cara yang lebih intuitif untuk mengakses komentar pada teks daripada hanya menggunakan daftar komentar di bilah sisi.
Aturan Penyisipan
Aturannya adalah:
“Jika teks yang telah dipilih pengguna dan mencoba untuk dikomentari sudah sepenuhnya dicakup oleh utas komentar, jangan izinkan penyisipan itu.”
Ini karena jika kami mengizinkan penyisipan ini, setiap karakter dalam rentang itu akan memiliki setidaknya dua utas komentar (satu sudah ada dan satu lagi yang baru kami izinkan) sehingga sulit bagi kami untuk menentukan mana yang harus dipilih ketika pengguna mengklik karakter itu nanti.
Melihat aturan ini, orang mungkin bertanya-tanya mengapa kita membutuhkannya jika kita sudah memiliki Aturan Rentang Komentar Terpendek yang memungkinkan kita untuk memilih rentang teks terkecil. Mengapa tidak mengizinkan semua kombinasi tumpang tindih jika kita dapat menggunakan aturan pertama untuk menyimpulkan utas komentar yang tepat untuk ditampilkan? Seperti beberapa contoh yang telah kita bahas sebelumnya, aturan pertama berfungsi untuk banyak skenario tetapi tidak semuanya. Dengan Aturan Penyisipan, kami mencoba meminimalkan jumlah skenario di mana aturan pertama tidak dapat membantu kami dan kami harus mundur di Bilah Sisi sebagai satu-satunya cara bagi pengguna untuk mengakses utas komentar itu. Aturan Penyisipan juga mencegah tumpang tindih yang tepat dari utas komentar. Aturan ini biasanya diterapkan oleh banyak editor populer.
Di bawah ini adalah contoh di mana jika aturan ini tidak ada, kami akan mengizinkan Utas Komentar #3 dan sebagai akibat dari aturan pertama, #3 tidak akan dapat diakses karena akan menjadi yang terpanjang.
Catatan : Memiliki aturan ini tidak berarti kami tidak akan pernah sepenuhnya memuat komentar yang tumpang tindih. Hal yang rumit tentang komentar yang tumpang tindih adalah bahwa terlepas dari aturan, urutan komentar yang dimasukkan masih dapat membuat kita dalam keadaan yang tidak kita inginkan untuk tumpang tindih. Merujuk kembali ke contoh komentar pada kata 'desainer'. ' sebelumnya, utas komentar terpanjang yang dimasukkan ada yang terakhir ditambahkan sehingga Aturan Penyisipan akan mengizinkannya dan kita berakhir dengan situasi yang sepenuhnya terkendali — #1 dan #2 terkandung di dalam #3. Tidak apa-apa karena Aturan Rentang Komentar Terpendek akan membantu kami di luar sana.
Kami akan menerapkan Aturan Rentang Komentar Terpendek di bagian berikutnya di mana kami menerapkan pemilihan utas komentar. Karena sekarang kita memiliki tombol toolbar untuk menyisipkan komentar, kita dapat langsung mengimplementasikan Aturan Penyisipan dengan memeriksa aturan saat pengguna memilih beberapa teks. Jika aturan tidak terpenuhi, kami akan menonaktifkan tombol Komentar sehingga pengguna tidak dapat memasukkan utas komentar baru pada teks yang dipilih. Mari kita mulai!
# 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 ); }
Logika dalam fungsi ini relatif mudah.
- Jika pilihan pengguna adalah tanda sisipan yang berkedip, kami tidak mengizinkan memasukkan komentar di sana karena tidak ada teks yang dipilih.
- Jika pilihan pengguna bukan yang diciutkan, kami menemukan semua simpul teks dalam pilihan. Perhatikan penggunaan
mode: lowest
dalam panggilan keEditor.nodes
(fungsi pembantu oleh SlateJS) yang membantu kita memilih semua simpul teks karena simpul teks benar-benar merupakan daun dari pohon dokumen. - Jika ada setidaknya satu simpul teks yang tidak memiliki utas komentar, kami dapat mengizinkan penyisipan. Kami menggunakan util
getCommentThreadsOnTextNode
yang kami tulis sebelumnya di sini.
Kami sekarang menggunakan fungsi util ini di dalam bilah alat untuk mengontrol status tombol yang dinonaktifkan.
# 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> );
Mari kita uji implementasi aturan dengan membuat ulang contoh kita di atas.
Detail pengalaman pengguna yang baik untuk disebutkan di sini adalah bahwa sementara kami menonaktifkan tombol bilah alat jika pengguna telah memilih seluruh baris teks di sini, itu tidak melengkapi pengalaman bagi pengguna. Pengguna mungkin tidak sepenuhnya memahami mengapa tombol dinonaktifkan dan kemungkinan akan bingung karena kami tidak menanggapi niat mereka untuk menyisipkan utas komentar di sana. Kami membahas ini nanti karena Popover Komentar dibuat sedemikian rupa sehingga meskipun tombol bilah alat dinonaktifkan, popover untuk salah satu utas komentar akan muncul dan pengguna masih dapat meninggalkan komentar.
Mari kita juga menguji kasus di mana ada beberapa simpul teks yang tidak dikomentari dan aturan mengizinkan penyisipan utas komentar baru.
Memilih Utas Komentar
Di bagian ini, kami mengaktifkan fitur di mana pengguna mengklik pada simpul teks yang dikomentari dan kami menggunakan Aturan Rentang Komentar Terpendek untuk menentukan utas komentar mana yang harus dipilih. Langkah-langkah dalam prosesnya adalah:
- Temukan utas komentar terpendek pada simpul teks komentar yang diklik pengguna.
- Setel utas komentar itu menjadi utas komentar aktif. (Kami membuat atom Recoil baru yang akan menjadi sumber kebenaran untuk ini.)
- Node teks yang dikomentari akan mendengarkan status Recoil dan jika mereka adalah bagian dari utas komentar aktif, mereka akan menyorot diri mereka sendiri secara berbeda. Dengan begitu, ketika pengguna mengklik utas komentar, seluruh rentang teks akan menonjol karena semua simpul teks akan memperbarui warna sorotannya.
Langkah 1: Menerapkan Aturan Rentang Komentar Terpendek
Mari kita mulai dengan Langkah #1 yang pada dasarnya menerapkan Aturan Rentang Komentar Terpendek. Tujuannya di sini adalah untuk menemukan utas komentar dengan rentang terpendek di simpul teks tempat pengguna mengklik. Untuk menemukan utas panjang terpendek, kita perlu menghitung panjang semua utas komentar pada simpul teks itu. Langkah-langkah untuk melakukannya adalah:
- Dapatkan semua utas komentar di simpul teks yang dimaksud.
- Lintasi kedua arah dari simpul teks itu dan terus perbarui panjang utas yang dilacak.
- Hentikan traversal ke arah ketika kita telah mencapai salah satu tepi di bawah ini:
- Node teks tanpa komentar (menyiratkan bahwa kami telah mencapai tepi awal/akhir terjauh dari semua utas komentar yang kami lacak).
- Node teks tempat semua utas komentar yang kami lacak telah mencapai tepi (mulai/akhir).
- Tidak ada lagi simpul teks untuk dilintasi ke arah itu (menyiratkan bahwa kita telah mencapai awal atau akhir dokumen atau simpul non-teks).
Karena traversal dalam arah maju dan mundur secara fungsional sama, kita akan menulis fungsi pembantu updateCommentThreadLengthMap
yang pada dasarnya menggunakan iterator simpul teks. Itu akan terus memanggil iterator dan terus memperbarui panjang utas pelacakan. Kami akan memanggil fungsi ini dua kali — sekali untuk maju dan sekali untuk arah mundur. 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.
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> ); }
Komponen ini menggunakan useRecoilState
yang memungkinkan komponen untuk berlangganan dan juga dapat mengatur nilai atom Recoil. Kami membutuhkan pelanggan untuk mengetahui apakah simpul teks ini adalah bagian dari utas komentar aktif sehingga ia dapat menata dirinya sendiri secara berbeda. Lihat tangkapan layar di bawah ini di mana utas komentar di tengah aktif dan kita dapat melihat jangkauannya dengan jelas.
Sekarang setelah kita memiliki semua kode untuk membuat pilihan utas komentar berfungsi, mari kita lihat tindakannya. Untuk menguji kode traversal kami dengan baik, kami menguji beberapa kasus tumpang tindih dan beberapa kasus tepi seperti:
- Mengklik pada simpul teks yang dikomentari di awal/akhir editor.
- Mengklik pada simpul teks yang dikomentari dengan utas komentar yang mencakup beberapa paragraf.
- Mengklik pada simpul teks yang dikomentari tepat sebelum simpul gambar.
- Mengklik tautan tumpang tindih simpul teks yang dikomentari.
Karena kita sekarang memiliki atom Recoil untuk melacak ID utas komentar yang aktif, satu detail kecil yang harus diperhatikan adalah mengatur utas komentar yang baru dibuat menjadi yang aktif ketika pengguna menggunakan tombol bilah alat untuk menyisipkan utas komentar baru. Ini memungkinkan kami, di bagian berikutnya, untuk menampilkan popover utas komentar segera saat penyisipan sehingga pengguna dapat mulai menambahkan komentar segera.
# 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>; };
Catatan: Penggunaan useSetRecoilState
di sini (pengait Recoil yang memperlihatkan setter untuk atom tetapi tidak berlangganan komponen ke nilainya) adalah apa yang kita butuhkan untuk bilah alat dalam kasus ini.
Menambahkan Komentar Thread Popovers
Di bagian ini, kami membuat Popover Komentar yang menggunakan konsep utas komentar yang dipilih/aktif dan menampilkan sembulan yang memungkinkan pengguna menambahkan komentar ke utas komentar tersebut. Sebelum kita membangunnya, mari kita lihat dulu bagaimana fungsinya.
Saat mencoba membuat Popover Komentar dekat dengan utas komentar yang aktif, kami mengalami beberapa masalah yang kami lakukan di artikel pertama dengan Menu Editor Tautan. Pada titik ini, dianjurkan untuk membaca bagian dalam artikel pertama yang membangun Editor Tautan dan masalah pemilihan yang kami hadapi dengan itu.
Pertama-tama mari kita bekerja untuk merender komponen popover kosong di tempat yang tepat berdasarkan utas komentar yang aktif. Cara kerja popover adalah:
- Komentar Thread Popover diberikan hanya ketika ada ID thread komentar aktif. Untuk mendapatkan informasi tersebut, kita mendengarkan atom Recoil yang telah kita buat di bagian sebelumnya.
- Ketika itu merender, kami menemukan simpul teks pada pilihan editor dan membuat popover dekat dengannya.
- Ketika pengguna mengklik di mana saja di luar popover, kami menyetel utas komentar aktif menjadi
null
sehingga menonaktifkan utas komentar dan juga membuat sembulan menghilang.
# 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> ); }
Beberapa hal yang harus diperhatikan untuk implementasi komponen popover ini:
- Dibutuhkan
editorOffsets
danselection
dari komponenEditor
di mana ia akan dirender.editorOffsets
adalah batas dari komponen Editor sehingga kita dapat menghitung posisi popover danselection
bisa menjadi pilihan saat ini atau sebelumnya jika pengguna menggunakan tombol toolbar yang menyebabkanselection
menjadinull
. Bagian pada Editor Tautan dari artikel pertama yang ditautkan di atas membahas ini secara rinci. - Karena
LinkEditor
dari artikel pertama danCommentThreadPopover
di sini, keduanya merender popover di sekitar simpul teks, kami telah memindahkan logika umum itu ke dalam komponenNodePopover
yang menangani rendering komponen yang disejajarkan dengan simpul teks yang dimaksud. Detail implementasinya adalah yang dimiliki komponenLinkEditor
di artikel pertama. -
NodePopover
mengambil metodeonClickOutside
sebagai prop yang dipanggil jika pengguna mengklik di suatu tempat di luar popover. Kami menerapkan ini dengan melampirkan pendengar acaramousedown
kedocument
— seperti yang dijelaskan secara rinci dalam artikel Smashing tentang ide ini. -
getFirstTextNodeAtSelection
mendapatkan simpul teks pertama di dalam pilihan pengguna yang kami gunakan untuk merender popover. Implementasi fungsi ini menggunakan pembantu Slate untuk menemukan simpul teks.
# 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; }
Mari kita terapkan callback onClickOutside
yang akan menghapus utas komentar aktif. Namun, kita harus memperhitungkan skenario ketika popover utas komentar terbuka dan utas tertentu aktif dan pengguna kebetulan mengklik utas komentar lain. Dalam hal ini, kami tidak ingin onClickOutside
menyetel ulang utas komentar aktif karena peristiwa klik pada komponen CommentedText
lainnya harus menyetel utas komentar lainnya menjadi aktif. Kami tidak ingin mengganggu itu di popover.
Cara kita melakukannya adalah kita menemukan Slate Node yang paling dekat dengan node DOM tempat event click terjadi. Jika simpul Slate itu adalah simpul teks dan memiliki komentar di atasnya, kita lewati pengaturan ulang utas komentar aktif Recoil atom. Mari kita terapkan!
# 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 memiliki metode pembantu toSlateNode
yang mengembalikan node Slate yang memetakan ke node DOM atau leluhur terdekatnya jika itu sendiri bukan Slate Node. Implementasi saat ini dari helper ini menimbulkan kesalahan jika tidak dapat menemukan node Slate alih-alih mengembalikan null
. Kami menanganinya di atas dengan memeriksa sendiri kasus null
yang merupakan skenario yang sangat mungkin terjadi jika pengguna mengklik di suatu tempat di luar editor di mana node Slate tidak ada.
Kami sekarang dapat memperbarui komponen Editor
untuk mendengarkan activeCommentThreadIDAtom
dan membuat popover hanya ketika utas komentar aktif.
# 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> ... </> ); }
Mari kita verifikasi bahwa popover dimuat di tempat yang tepat untuk utas komentar yang tepat dan menghapus utas komentar yang aktif saat kita mengklik di luar.
Kami sekarang beralih ke memungkinkan pengguna untuk menambahkan komentar ke utas komentar dan melihat semua komentar dari utas itu di popover. Kami akan menggunakan keluarga atom Recoil — commentThreadsState
yang kami buat sebelumnya di artikel untuk ini.
Komentar di utas komentar disimpan di larik comments
. Untuk mengaktifkan penambahan komentar baru, kami merender input Formulir yang memungkinkan pengguna memasukkan komentar baru. Saat pengguna mengetik komentar, kami mempertahankannya dalam variabel status lokal — commentText
. Pada klik tombol, kami menambahkan teks komentar sebagai komentar baru ke array 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> ); }
Catatan : Meskipun kami memberikan masukan bagi pengguna untuk mengetik komentar, kami tidak selalu membiarkannya mengambil fokus saat popover dipasang. Ini adalah keputusan Pengalaman Pengguna yang dapat bervariasi dari satu editor ke editor lainnya. Beberapa editor tidak mengizinkan pengguna mengedit teks saat popover utas komentar terbuka. Dalam kasus kami, kami ingin memungkinkan pengguna mengedit teks yang dikomentari ketika mereka mengkliknya.
Layak disebut bagaimana kami mengakses data utas komentar tertentu dari keluarga atom Recoil — dengan memanggil atom sebagai — commentThreadsState(threadID)
. Ini memberi kita nilai atom dan setter untuk memperbarui hanya atom itu dalam keluarga. Jika komentar sedang malas dimuat dari server, Recoil juga menyediakan kait useRecoilStateLoadable
yang mengembalikan objek Loadable yang memberi tahu kita tentang status pemuatan data atom. Jika masih memuat, kita dapat memilih untuk menampilkan status pemuatan di popover.
Sekarang, kita mengakses threadData
dan membuat daftar komentar. Setiap komentar diberikan oleh komponen CommentRow
.
# src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );
Di bawah ini adalah implementasi dari CommentRow
yang merender teks komentar dan metadata lainnya seperti nama penulis dan waktu pembuatan. Kami menggunakan modul date-fns
untuk menunjukkan waktu pembuatan yang diformat.
# 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> ); }
Kami telah mengekstrak ini menjadi komponennya sendiri saat kami menggunakannya kembali nanti saat kami mengimplementasikan Bilah Sisi Komentar.
Pada titik ini, Popover Komentar kami memiliki semua kode yang diperlukan untuk memasukkan komentar baru dan memperbarui status Recoil untuk hal yang sama. Mari kita verifikasi itu. Di konsol browser, menggunakan Recoil Debug Observer yang kami tambahkan sebelumnya, kami dapat memverifikasi bahwa atom Recoil untuk utas komentar diperbarui dengan benar saat kami menambahkan komentar baru ke utas.
Menambahkan Bilah Sisi Komentar
Sebelumnya di artikel, kami telah menyebutkan mengapa kadang-kadang, mungkin terjadi bahwa aturan yang kami terapkan mencegah utas komentar tertentu tidak dapat diakses dengan mengklik simpul teksnya saja — tergantung pada kombinasi tumpang tindih. Untuk kasus seperti itu, kami memerlukan Bilah Sisi Komentar yang memungkinkan pengguna mengakses setiap dan semua utas komentar dalam dokumen.
Bilah Sisi Komentar juga merupakan tambahan yang bagus yang menjalin alur kerja Saran & Tinjauan di mana peninjau dapat menavigasi semua utas komentar satu demi satu dalam sapuan dan dapat meninggalkan komentar/balasan di mana pun mereka merasa perlu. Sebelum kita mulai menerapkan bilah sisi, ada satu tugas yang belum selesai yang kita tangani di bawah ini.
Menginisialisasi Recoil State Of Comment Threads
Saat dokumen dimuat di editor, kita perlu memindai dokumen untuk menemukan semua utas komentar dan menambahkannya ke atom Recoil yang kita buat di atas sebagai bagian dari proses inisialisasi. Mari kita tulis fungsi utilitas di EditorCommentUtils
yang memindai node teks, menemukan semua utas komentar dan menambahkannya ke atom 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", }) ); }
Menyinkronkan dengan Penyimpanan Backend dan Pertimbangan Kinerja
Untuk konteks artikel, karena kami murni berfokus pada implementasi UI, kami hanya menginisialisasinya dengan beberapa data yang memungkinkan kami mengonfirmasi bahwa kode inisialisasi berfungsi.
Dalam penggunaan Sistem Komentar di dunia nyata, utas komentar cenderung disimpan secara terpisah dari konten dokumen itu sendiri. Dalam kasus seperti itu, kode di atas perlu diperbarui untuk membuat panggilan API yang mengambil semua metadata dan komentar di semua ID utas komentar di commentThreads
. Setelah utas komentar dimuat, mereka kemungkinan akan diperbarui karena banyak pengguna menambahkan lebih banyak komentar ke mereka secara real time, mengubah status mereka, dan seterusnya. Versi produksi Sistem Komentar perlu menyusun penyimpanan Recoil sedemikian rupa sehingga kami dapat terus menyinkronkannya dengan server. Jika Anda memilih untuk menggunakan Recoil untuk manajemen keadaan, ada beberapa contoh di Atom Effects API (eksperimental saat menulis artikel ini) yang melakukan hal serupa.
Jika dokumen sangat panjang dan memiliki banyak pengguna yang berkolaborasi di banyak utas komentar, kami mungkin harus mengoptimalkan kode inisialisasi untuk hanya memuat utas komentar untuk beberapa halaman pertama dokumen. Sebagai alternatif, kami dapat memilih untuk hanya memuat metadata ringan dari semua utas komentar alih-alih seluruh daftar komentar yang kemungkinan merupakan bagian muatan yang lebih berat.
Sekarang, mari beralih ke pemanggilan fungsi ini ketika komponen Editor
dipasang dengan dokumen sehingga status Recoil diinisialisasi dengan benar.
# 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 ( <> ... </> ); }
Kami menggunakan kait khusus yang sama — useAddCommentThreadToState
yang kami gunakan dengan implementasi Tombol Komentar Toolbar untuk menambahkan utas komentar baru. Karena popover kami berfungsi, kami dapat mengklik salah satu utas komentar yang sudah ada sebelumnya di dokumen dan memverifikasi bahwa itu menunjukkan data yang kami gunakan untuk menginisialisasi utas di atas.
Sekarang setelah status kita diinisialisasi dengan benar, kita dapat mulai mengimplementasikan sidebar. Semua utas komentar kami di UI disimpan dalam keluarga atom Recoil — commentThreadsState
. Seperti yang disorot sebelumnya, cara kita mengulangi semua item dalam keluarga atom Recoil adalah dengan melacak kunci/id atom di atom lain. Kami telah melakukannya dengan commentThreadIDsState
. Mari tambahkan komponen CommentSidebar
yang berulang melalui set id dalam atom ini dan membuat komponen CommentThread
untuk masing-masing.
# 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> ); }
Sekarang, kami mengimplementasikan komponen CommentThread
yang mendengarkan atom Recoil dalam keluarga yang sesuai dengan utas komentar yang dirender. Dengan cara ini, saat pengguna menambahkan lebih banyak komentar pada utas di editor atau mengubah metadata lainnya, kami dapat memperbarui bilah sisi untuk mencerminkan hal itu.
Karena bilah sisi bisa menjadi sangat besar untuk dokumen dengan banyak komentar, kami menyembunyikan semua komentar kecuali yang pertama saat kami merender bilah sisi. Pengguna dapat menggunakan tombol 'Tampilkan/Sembunyikan Balasan' untuk menampilkan/menyembunyikan seluruh rangkaian komentar.
# 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> ); }
Kami telah menggunakan kembali komponen CommentRow
dari popover meskipun kami menambahkan perawatan desain menggunakan prop showConnector
yang pada dasarnya membuat semua komentar terlihat terhubung dengan utas di bilah sisi.
Sekarang, kami membuat CommentSidebar
di Editor
dan memverifikasi bahwa itu menunjukkan semua utas yang kami miliki di dokumen dan memperbarui dengan benar saat kami menambahkan utas baru atau komentar baru ke utas yang ada.
# src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
Kami sekarang beralih ke penerapan interaksi Bilah Sisi Komentar populer yang ditemukan di editor:
Mengklik utas komentar di bilah sisi harus memilih/mengaktifkan utas komentar itu. Kami juga menambahkan perlakuan desain diferensial untuk menyorot utas komentar di bilah sisi jika aktif di editor. Untuk dapat melakukannya, kami menggunakan atom Recoil — activeCommentThreadIDAtom
. Mari perbarui komponen CommentThread
untuk mendukung ini.
# 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> );
Jika kami perhatikan lebih dekat, kami memiliki bug dalam implementasi kami menyinkronkan utas komentar aktif dengan bilah sisi. Saat kami mengklik utas komentar yang berbeda di bilah sisi, utas komentar yang benar memang disorot di editor. Namun, Popover Komentar tidak benar-benar pindah ke utas komentar aktif yang diubah. Itu tetap di tempat pertama kali diberikan. Jika kita melihat implementasi dari Popover Komentar, itu membuat dirinya sendiri melawan simpul teks pertama dalam pilihan editor. Pada saat implementasi, satu-satunya cara untuk memilih utas komentar adalah dengan mengklik simpul teks sehingga kami dapat dengan mudah mengandalkan pilihan editor karena diperbarui oleh Slate sebagai hasil dari peristiwa klik. Dalam acara onClick
di atas, kami tidak memperbarui pilihan tetapi hanya memperbarui nilai atom Recoil yang menyebabkan pilihan Slate tetap tidak berubah dan karenanya Komentar Popover tidak bergerak.
Solusi untuk masalah ini adalah memperbarui pilihan editor bersama dengan memperbarui atom Recoil ketika pengguna mengklik utas komentar di bilah sisi. Langkah-langkah melakukan ini adalah:
- Temukan semua node teks yang memiliki utas komentar ini yang akan kita tetapkan sebagai utas aktif baru.
- Urutkan simpul teks ini dalam urutan kemunculannya di dokumen (Kami menggunakan API
Path.compare
Slate untuk ini). - Hitung rentang pilihan yang terbentang dari awal simpul teks pertama hingga akhir simpul teks terakhir.
- Setel rentang pilihan menjadi pilihan baru editor (menggunakan API
Transforms.select
Slate).
Jika kami hanya ingin memperbaiki bug, kami dapat menemukan simpul teks pertama di Langkah #1 yang memiliki utas komentar dan menetapkannya sebagai pilihan editor. Namun, rasanya seperti pendekatan yang lebih bersih untuk memilih seluruh rentang komentar karena kami benar-benar memilih utas komentar.
Mari perbarui penerapan callback onClick
untuk menyertakan langkah-langkah di atas.
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]);
Catatan : allTextNodePaths
berisi path ke semua node teks. Kami menggunakan API Editor.point
untuk mendapatkan titik awal dan akhir di jalur itu. Artikel pertama membahas konsep Lokasi Slate. Mereka juga didokumentasikan dengan baik di dokumentasi Slate.
Mari kita verifikasi bahwa implementasi ini memperbaiki bug dan Popover Komentar pindah ke utas komentar aktif dengan benar. Kali ini, kami juga menguji dengan kasus utas yang tumpang tindih untuk memastikan tidak putus di sana.
Dengan perbaikan bug, kami telah mengaktifkan interaksi bilah sisi lain yang belum kami diskusikan. Jika kami memiliki dokumen yang sangat panjang dan pengguna mengklik utas komentar di bilah sisi yang berada di luar area pandang, kami ingin menggulir ke bagian dokumen itu sehingga pengguna dapat fokus pada utas komentar di editor. Dengan menyetel pilihan di atas menggunakan API Slate, kita mendapatkannya secara gratis. Mari kita lihat aksinya di bawah ini.
Dengan itu, kami membungkus implementasi sidebar kami. Menjelang akhir artikel, kami mencantumkan beberapa penambahan dan peningkatan fitur bagus yang dapat kami lakukan pada Bilah Sisi Komentar yang membantu meningkatkan pengalaman Berkomentar dan Meninjau pada editor.
Menyelesaikan Dan Membuka Kembali Komentar
Di bagian ini, kami fokus untuk memungkinkan pengguna menandai utas komentar sebagai 'Terselesaikan' atau dapat membukanya kembali untuk diskusi jika diperlukan. Dari perspektif detail implementasi, ini adalah metadata status
pada utas komentar yang kami ubah saat pengguna melakukan tindakan ini. Dari sudut pandang pengguna, ini adalah fitur yang sangat berguna karena memberi mereka cara untuk menegaskan bahwa diskusi tentang sesuatu pada dokumen telah selesai atau perlu dibuka kembali karena ada beberapa pembaruan/perspektif baru, dan sebagainya.
Untuk mengaktifkan beralih status, kami menambahkan tombol ke CommentPopover
yang memungkinkan pengguna untuk beralih di antara dua status: open
dan 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> ); }
Sebelum kita menguji ini, mari berikan juga Comments Sidebar perlakuan desain diferensial untuk komentar yang diselesaikan sehingga pengguna dapat dengan mudah mendeteksi utas komentar mana yang belum terselesaikan atau terbuka dan fokus pada komentar tersebut jika mereka mau.
# 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> ); }
Kesimpulan
Dalam artikel ini, kami membangun infrastruktur UI inti untuk Sistem Komentar pada Editor Teks Kaya. Kumpulan fungsi yang kami tambahkan di sini bertindak sebagai dasar untuk membangun Pengalaman Kolaborasi yang lebih kaya pada editor tempat kolaborator dapat membubuhi keterangan bagian-bagian dokumen dan melakukan percakapan tentangnya. Menambahkan Bilah Sisi Komentar memberi kami ruang untuk mengaktifkan lebih banyak fungsi percakapan atau berbasis ulasan pada produk.
Sejalan dengan itu, berikut adalah beberapa fitur yang dapat dipertimbangkan oleh Editor Teks Kaya untuk ditambahkan di atas apa yang kami buat di artikel ini:
- Dukungan untuk
@
sebutan sehingga kolaborator dapat saling menandai di komentar; - Dukungan untuk jenis media seperti gambar dan video untuk ditambahkan ke utas komentar;
- Mode Saran di tingkat dokumen yang memungkinkan pengulas melakukan pengeditan pada dokumen yang muncul sebagai saran perubahan. Seseorang dapat merujuk ke fitur ini di Google Documents atau Ubah Pelacakan di Microsoft Word sebagai contoh;
- Penyempurnaan pada bilah sisi untuk mencari percakapan berdasarkan kata kunci, memfilter utas berdasarkan status atau penulis komentar, dan sebagainya.