Membangun Editor Teks Kaya (WYSIWYG)

Diterbitkan: 2022-03-10
Ringkasan cepat Dalam artikel ini, kita akan mempelajari cara membuat WYSIWYG/Rich-Text Editor yang mendukung teks kaya, gambar, tautan, dan beberapa fitur bernuansa dari aplikasi pengolah kata. Kami akan menggunakan SlateJS untuk membangun shell editor dan kemudian menambahkan toolbar dan konfigurasi kustom. Kode untuk aplikasi tersedia di GitHub untuk referensi.

Dalam beberapa tahun terakhir, bidang Pembuatan dan Representasi Konten pada platform Digital telah mengalami gangguan besar-besaran. Keberhasilan produk yang tersebar luas seperti Quip, Google Docs, dan Dropbox Paper telah menunjukkan bagaimana perusahaan berlomba untuk membangun pengalaman terbaik bagi pembuat konten di domain perusahaan dan mencoba menemukan cara inovatif untuk memecahkan pola tradisional tentang bagaimana konten dibagikan dan dikonsumsi. Mengambil keuntungan dari jangkauan besar-besaran platform media sosial, ada gelombang baru pembuat konten independen yang menggunakan platform seperti Medium untuk membuat konten dan membagikannya kepada audiens mereka.

Karena begitu banyak orang dari berbagai profesi dan latar belakang mencoba membuat konten pada produk ini, penting bahwa produk ini memberikan pengalaman pembuatan konten yang berkinerja dan mulus dan memiliki tim desainer dan insinyur yang mengembangkan beberapa tingkat keahlian domain dari waktu ke waktu di ruang ini . Dengan artikel ini, kami mencoba untuk tidak hanya meletakkan dasar dalam membangun editor tetapi juga memberikan gambaran sekilas kepada pembaca tentang bagaimana nugget fungsi yang kecil ketika disatukan dapat menciptakan pengalaman pengguna yang luar biasa bagi pembuat konten.

Memahami Struktur Dokumen

Sebelum kita masuk ke dalam membangun editor, mari kita lihat bagaimana dokumen disusun untuk Editor Teks Kaya dan apa saja jenis struktur data yang terlibat.

Node Dokumen

Node dokumen digunakan untuk merepresentasikan isi dokumen. Jenis node umum yang dapat berisi dokumen teks kaya adalah paragraf, judul, gambar, video, blok kode, dan kutipan tarik. Beberapa di antaranya mungkin berisi node lain sebagai anak-anak di dalamnya (misalnya node Paragraf berisi node teks di dalamnya). Node juga menyimpan properti khusus untuk objek yang mereka wakili yang diperlukan untuk merender node tersebut di dalam editor. (Misalnya Node gambar berisi properti src gambar, Blok kode mungkin berisi properti language dan seterusnya).

Ada sebagian besar dua jenis node yang mewakili bagaimana mereka harus diberikan -

  • Blok Node (analog dengan konsep HTML elemen tingkat Blok) yang masing-masing dirender pada baris baru dan menempati lebar yang tersedia. Node blok dapat berisi node blok lain atau node sebaris di dalamnya. Pengamatan di sini adalah bahwa node tingkat atas dari dokumen akan selalu menjadi node blok.
  • Inline Nodes (analog dengan konsep HTML elemen Inline) yang mulai merender pada baris yang sama dengan node sebelumnya. Ada beberapa perbedaan dalam cara elemen sebaris direpresentasikan dalam pustaka pengeditan yang berbeda. SlateJS memungkinkan elemen sebaris menjadi simpul itu sendiri. DraftJS, perpustakaan Pengeditan Teks Kaya populer lainnya, memungkinkan Anda menggunakan konsep Entitas untuk merender elemen sebaris. Tautan dan Gambar Sebaris adalah contoh simpul Sebaris.
  • Void Nodes — SlateJS juga memungkinkan kategori ketiga dari node yang akan kita gunakan nanti dalam artikel ini untuk merender media.

Jika Anda ingin mempelajari lebih lanjut tentang kategori ini, dokumentasi SlateJS di Nodes adalah tempat yang baik untuk memulai.

Lebih banyak setelah melompat! Lanjutkan membaca di bawah ini

Atribut

Mirip dengan konsep atribut HTML, atribut dalam Dokumen Teks Kaya digunakan untuk mewakili properti non-konten dari sebuah simpul atau turunannya. Misalnya, sebuah simpul teks dapat memiliki atribut gaya karakter yang memberi tahu kita apakah teks itu tebal/miring/garis bawah dan seterusnya. Meskipun artikel ini merepresentasikan heading sebagai node itu sendiri, cara lain untuk merepresentasikannya adalah node memiliki gaya paragraf ( paragraph & h1-h6 ) sebagai atributnya.

Gambar di bawah ini memberikan contoh bagaimana struktur dokumen (dalam JSON) dijelaskan pada tingkat yang lebih terperinci menggunakan node dan atribut yang menyoroti beberapa elemen dalam struktur di sebelah kiri.

Gambar menunjukkan contoh dokumen di dalam editor dengan representasi strukturnya di sebelah kiri
Contoh Dokumen dan representasi strukturalnya. (Pratinjau besar)

Beberapa hal yang layak disebut di sini dengan strukturnya adalah:

  • Node teks direpresentasikan sebagai {text: 'text content'}
  • Properti dari node disimpan langsung pada node (misalnya url untuk link dan caption untuk gambar)
  • Representasi atribut teks khusus SlateJS memecah simpul teks menjadi simpulnya sendiri jika gaya karakter berubah. Oleh karena itu, teks ' Duis aute irure dolor ' adalah simpul teksnya sendiri dengan bold: true di atasnya. Sama halnya dengan teks miring, garis bawah dan gaya kode dalam dokumen ini.

Lokasi Dan Seleksi

Saat membangun editor teks kaya, sangat penting untuk memiliki pemahaman tentang bagaimana bagian paling granular dari sebuah dokumen (misalnya karakter) dapat direpresentasikan dengan semacam koordinat. Ini membantu kami menavigasi struktur dokumen saat runtime untuk memahami di mana dalam hierarki dokumen kami. Yang terpenting, objek lokasi memberi kita cara untuk mewakili pilihan pengguna yang cukup luas digunakan untuk menyesuaikan pengalaman pengguna editor secara real time. Kami akan menggunakan seleksi untuk membangun toolbar kami nanti di artikel ini. Contohnya bisa berupa:

  • Apakah kursor pengguna saat ini berada di dalam tautan, mungkin kita harus menunjukkan kepada mereka menu untuk mengedit/menghapus tautan?
  • Apakah pengguna telah memilih gambar? Mungkin kami memberi mereka menu untuk mengubah ukuran gambar.
  • Jika pengguna memilih teks tertentu dan menekan tombol DELETE, kami menentukan teks yang dipilih pengguna dan menghapusnya dari dokumen.

Dokumen SlateJS tentang Lokasi menjelaskan struktur data ini secara ekstensif tetapi kami membahasnya di sini dengan cepat karena kami menggunakan istilah ini pada contoh yang berbeda dalam artikel dan menunjukkan contoh dalam diagram berikut.

  • Jalur
    Diwakili oleh larik angka, jalur adalah cara untuk mencapai simpul dalam dokumen. Misalnya, jalur [2,3] mewakili simpul anak ke-3 dari simpul ke-2 dalam dokumen.
  • Titik
    Lokasi konten yang lebih terperinci diwakili oleh jalur + offset. Misalnya, titik {path: [2,3], offset: 14} mewakili karakter ke-14 dari simpul anak ke-3 di dalam simpul ke-2 dari dokumen.
  • Jarak
    Sepasang titik (disebut anchor dan focus ) yang mewakili rentang teks di dalam dokumen. Konsep ini berasal dari Web's Selection API di mana anchor adalah tempat pemilihan pengguna dimulai dan focus adalah tempat berakhirnya. Rentang/pilihan yang diciutkan menunjukkan tempat jangkar dan titik fokus sama (misalnya, bayangkan kursor yang berkedip dalam input teks).

Sebagai contoh katakanlah bahwa pilihan pengguna dalam contoh dokumen kita di atas adalah ipsum :

Gambar dengan teks `ipsum` dipilih di editor
Pengguna memilih kata ipsum . (Pratinjau besar)

Pilihan pengguna dapat direpresentasikan sebagai:

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

Menyiapkan Editor

Di bagian ini, kita akan menyiapkan aplikasi dan menjalankan editor teks kaya dasar dengan SlateJS. Aplikasi boilerplate akan menjadi create-react-app dengan dependensi SlateJS ditambahkan ke dalamnya. Kami sedang membangun UI aplikasi menggunakan komponen dari react-bootstrap . Mari kita mulai!

Buat folder bernama wysiwyg-editor dan jalankan perintah di bawah ini dari dalam direktori untuk mengatur aplikasi reaksi. Kami kemudian menjalankan perintah yarn start yang akan menjalankan server web lokal (port default ke 3000) dan menampilkan layar sambutan React kepada Anda.

 npx create-react-app . yarn start

Kami kemudian melanjutkan untuk menambahkan dependensi SlateJS ke aplikasi.

 yarn add slate slate-react

slate adalah paket inti SlateJS dan slate-react termasuk set komponen React yang akan kita gunakan untuk merender editor Slate. SlateJS memperlihatkan beberapa paket lagi yang diatur oleh fungsionalitas yang mungkin dipertimbangkan untuk ditambahkan ke editor mereka.

Kami pertama-tama membuat folder utils yang menampung modul utilitas apa pun yang kami buat di aplikasi ini. Kita mulai dengan membuat ExampleDocument.js yang mengembalikan struktur dokumen dasar yang berisi paragraf dengan beberapa teks. Modul ini terlihat seperti di bawah ini:

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

Kami sekarang menambahkan folder bernama components yang akan menampung semua komponen Bereaksi kami dan melakukan hal berikut:

  • Tambahkan komponen React pertama kami Editor.js ke dalamnya. Itu hanya mengembalikan div untuk saat ini.
  • Perbarui komponen App.js untuk menyimpan dokumen dalam statusnya yang diinisialisasi ke ExampleDocument kami di atas.
  • Render Editor di dalam aplikasi dan teruskan status dokumen dan handler onChange ke Editor sehingga status dokumen kami diperbarui saat pengguna memperbaruinya.
  • Kami menggunakan komponen Nav bootstrap React untuk menambahkan bilah navigasi ke aplikasi juga.

Komponen App.js sekarang terlihat seperti di bawah ini:

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

Di dalam komponen Editor, kita kemudian membuat instance editor SlateJS dan menahannya di dalam useMemo sehingga objek tidak berubah di antara rendering ulang.

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

createEditor memberi kita contoh editor SlateJS yang kita gunakan secara ekstensif melalui aplikasi untuk mengakses pilihan, menjalankan transformasi data, dan sebagainya. withReact adalah plugin SlateJS yang menambahkan perilaku React dan DOM ke objek editor. Plugin SlateJS adalah fungsi Javascript yang menerima objek editor dan melampirkan beberapa konfigurasi padanya. Ini memungkinkan pengembang web untuk menambahkan konfigurasi ke instance editor SlateJS mereka dengan cara yang dapat dikomposisi.

Kami sekarang mengimpor dan merender komponen <Slate /> dan <Editable /> dari SlateJS dengan prop dokumen yang kami dapatkan dari App.js. Slate memperlihatkan banyak konteks React yang kami gunakan untuk mengakses dalam kode aplikasi. Editable adalah komponen yang membuat hierarki dokumen untuk diedit. Secara keseluruhan, modul Editor.js pada tahap ini terlihat seperti di bawah ini:

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

Pada titik ini, kami telah menambahkan komponen React yang diperlukan dan editor diisi dengan dokumen contoh. Editor kami sekarang harus diatur memungkinkan kami untuk mengetik dan mengubah konten secara real time — seperti pada screencast di bawah ini.

Pengaturan Editor Dasar sedang beraksi

Sekarang, mari beralih ke bagian berikutnya di mana kita mengonfigurasi editor untuk merender gaya karakter dan simpul paragraf.

RENDER TEKS KUSTOM DAN TOOLBAR

Node Gaya Paragraf

Saat ini, editor kami menggunakan rendering default SlateJS untuk semua jenis node baru yang mungkin kami tambahkan ke dokumen. Di bagian ini, kami ingin dapat merender node heading. Untuk dapat melakukan itu, kami menyediakan prop fungsi renderElement ke komponen Slate. Fungsi ini dipanggil oleh Slate saat runtime ketika mencoba melintasi pohon dokumen dan merender setiap node. Fungsi renderElement mendapatkan tiga parameter —

  • attributes
    SlateJS khusus yang harus diterapkan ke elemen DOM tingkat atas yang dikembalikan dari fungsi ini.
  • element
    Objek simpul itu sendiri seperti yang ada dalam struktur dokumen
  • children
    Anak-anak dari simpul ini seperti yang didefinisikan dalam struktur dokumen.

Kami menambahkan implementasi renderElement kami ke kait yang disebut useEditorConfig di mana kami akan menambahkan lebih banyak konfigurasi editor saat kami pergi. Kami kemudian menggunakan pengait pada instance editor di dalam Editor.js .

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

Karena fungsi ini memberi kita akses ke element (yang merupakan simpul itu sendiri), kita dapat menyesuaikan renderElement untuk mengimplementasikan rendering yang lebih disesuaikan yang melakukan lebih dari sekadar memeriksa element.type . Misalnya, Anda dapat memiliki simpul gambar yang memiliki properti isInline yang dapat kami gunakan untuk mengembalikan struktur DOM berbeda yang membantu kami merender gambar sebaris dibandingkan dengan gambar blok.

Kami sekarang memperbarui komponen Editor untuk menggunakan kait ini seperti di bawah ini:

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

Dengan rendering kustom di tempat, kami memperbarui ExampleDocument untuk menyertakan jenis node baru kami dan memverifikasi bahwa mereka merender dengan benar di dalam editor.

 const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes 
Gambar yang menunjukkan judul berbeda dan simpul paragraf yang ditampilkan di editor
Judul dan Paragraf node di Editor. (Pratinjau besar)

Gaya Karakter

Mirip dengan renderElement , SlateJS memberikan fungsi prop yang disebut renderLeaf yang dapat digunakan untuk menyesuaikan rendering node teks ( Leaf mengacu pada node teks yang merupakan daun/node level terendah dari pohon dokumen). Mengikuti contoh renderElement , kita menulis implementasi untuk renderLeaf .

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

Pengamatan penting dari implementasi di atas adalah memungkinkan kita untuk menghormati semantik HTML untuk gaya karakter. Karena renderLeaf memberi kita akses ke leaf simpul teks itu sendiri, kita dapat menyesuaikan fungsi untuk mengimplementasikan rendering yang lebih disesuaikan. Misalnya, Anda mungkin memiliki cara untuk membiarkan pengguna memilih highlightColor untuk teks dan memeriksa properti daun di sini untuk melampirkan gaya masing-masing.

Kami sekarang memperbarui komponen Editor untuk menggunakan di atas, ExampleDocument untuk memiliki beberapa node teks dalam paragraf dengan kombinasi gaya ini dan memverifikasi bahwa mereka dirender seperti yang diharapkan di Editor dengan tag semantik yang kami gunakan.

 # src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
 # src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], }, 
Gaya karakter di UI dan bagaimana mereka dirender di pohon DOM
Gaya karakter di UI dan bagaimana mereka dirender di pohon DOM. (Pratinjau besar)

Menambahkan Bilah Alat

Mari kita mulai dengan menambahkan komponen baru Toolbar.js di mana kita menambahkan beberapa tombol untuk gaya karakter dan dropdown untuk gaya paragraf dan kita menghubungkannya nanti di bagian ini.

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

Kami mengabstraksikan tombol ke komponen ToolbarButton yang merupakan pembungkus di sekitar komponen React Bootstrap Button. Kami kemudian membuat bilah alat di atas komponen Editor yang Editable di dalam dan memverifikasi bahwa bilah alat muncul di aplikasi.

Gambar menunjukkan bilah alat dengan tombol yang ditampilkan di atas editor
Toolbar dengan tombol (Pratinjau besar)

Berikut adalah tiga fungsi utama yang perlu didukung oleh toolbar:

  1. Ketika kursor pengguna berada di tempat tertentu dalam dokumen dan mereka mengklik salah satu tombol gaya karakter, kita perlu mengubah gaya untuk teks yang mungkin mereka ketik berikutnya.
  2. Saat pengguna memilih rentang teks dan mengklik salah satu tombol gaya karakter, kita perlu mengaktifkan gaya untuk bagian tertentu.
  3. Saat pengguna memilih rentang teks, kami ingin memperbarui dropdown gaya paragraf untuk mencerminkan jenis paragraf pilihan. Jika mereka memilih nilai yang berbeda dari pilihan, kami ingin memperbarui gaya paragraf dari seluruh pilihan menjadi apa yang mereka pilih.

Mari kita lihat bagaimana fungsi-fungsi ini bekerja di Editor sebelum kita mulai mengimplementasikannya.

Perilaku beralih Gaya Karakter

Mendengarkan Seleksi

Hal terpenting yang dibutuhkan Toolbar untuk dapat menjalankan fungsi-fungsi di atas adalah status Seleksi dokumen. Saat menulis artikel ini, SlateJS tidak mengekspos metode onSelectionChange yang dapat memberi kita status pemilihan dokumen terbaru. Namun, saat pilihan berubah di editor, SlateJS memang memanggil metode onChange , meskipun konten dokumen tidak berubah. Kami menggunakan ini sebagai cara untuk diberitahu tentang perubahan pilihan dan menyimpannya dalam status komponen Editor . Kami mengabstraksikannya ke hook useSelection di mana kami bisa melakukan pembaruan status seleksi yang lebih optimal. Ini penting karena seleksi adalah properti yang cukup sering berubah untuk instans Editor WYSIWYG.

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

Kami menggunakan kait ini di dalam komponen Editor seperti di bawah ini dan meneruskan seleksi ke komponen Toolbar.

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

Pertimbangan Kinerja

Dalam aplikasi di mana kami memiliki basis kode Editor yang jauh lebih besar dengan lebih banyak fungsi, penting untuk menyimpan dan mendengarkan perubahan pilihan dengan cara yang berkinerja baik (seperti menggunakan beberapa perpustakaan manajemen keadaan) karena komponen yang mendengarkan perubahan pilihan kemungkinan besar akan dirender juga sering. Salah satu cara untuk melakukannya adalah dengan mengoptimalkan penyeleksi di atas status Seleksi yang menyimpan informasi pemilihan tertentu. Misalnya, editor mungkin ingin membuat menu pengubahan ukuran gambar saat Gambar dipilih. Dalam kasus seperti itu, mungkin berguna untuk memiliki pemilih isImageSelected dihitung dari status pemilihan editor dan menu Gambar akan dirender ulang hanya ketika nilai pemilih ini berubah. Redux's Reselect adalah salah satu perpustakaan yang memungkinkan pemilih bangunan.

Kami tidak menggunakan selection di dalam bilah alat sampai nanti, tetapi meneruskannya sebagai penyangga membuat bilah alat dirender ulang setiap kali pemilihan berubah pada Editor. Kami melakukan ini karena kami tidak dapat hanya mengandalkan perubahan konten dokumen untuk memicu rendering ulang pada hierarki ( App -> Editor -> Toolbar ) karena pengguna mungkin terus mengklik dokumen sehingga mengubah pilihan tetapi tidak pernah benar-benar mengubah konten dokumen diri.

Beralih Gaya Karakter

Kami sekarang pindah untuk mendapatkan apa gaya karakter aktif dari SlateJS dan menggunakannya di dalam Editor. Mari tambahkan modul JS baru EditorUtils yang akan menghosting semua fungsi util yang kita bangun kedepannya untuk mendapatkan/melakukan hal-hal dengan SlateJS. Fungsi pertama kami di modul adalah getActiveStyles yang memberikan Set gaya aktif di editor. Kami juga menambahkan fungsi untuk mengaktifkan gaya pada fungsi editor — toggleStyle :

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

Kedua fungsi tersebut mengambil objek editor yang merupakan instance Slate sebagai parameter seperti halnya banyak fungsi util yang kami tambahkan nanti di artikel. Dalam terminologi Slate, gaya pemformatan disebut Marks dan kami menggunakan metode pembantu pada antarmuka Editor untuk mendapatkan, menambahkan dan hapus tanda ini. Kami mengimpor fungsi util ini di dalam Toolbar dan menyambungkannya ke tombol yang kami tambahkan sebelumnya.

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

useEditor adalah pengait Slate yang memberi kita akses ke instance Slate dari konteks di mana ia dilampirkan oleh komponen &lt;Slate> yang lebih tinggi dalam hierarki render.

Orang mungkin bertanya-tanya mengapa kami menggunakan onMouseDown di sini alih-alih onClick ? Ada Masalah Github terbuka tentang bagaimana Slate mengubah selection menjadi null ketika editor kehilangan fokus dengan cara apa pun. Jadi, jika kami melampirkan penangan onClick ke tombol bilah alat kami, selection menjadi null dan pengguna kehilangan posisi kursor mereka mencoba beralih gaya yang bukan pengalaman hebat. Kami alih-alih mengganti gaya dengan melampirkan acara onMouseDown yang mencegah pemilihan disetel ulang. Cara lain untuk melakukan ini adalah dengan melacak seleksi sendiri sehingga kita tahu apa seleksi terakhir dan menggunakannya untuk mengganti gaya. Kami memang memperkenalkan konsep Seleksi previousSelection nanti di artikel tetapi untuk memecahkan masalah yang berbeda.

SlateJS memungkinkan kita untuk mengonfigurasi event handler di Editor. Kami menggunakannya untuk memasang pintasan keyboard untuk mengubah gaya karakter. Untuk melakukannya, kami menambahkan objek KeyBindings di dalam useEditorConfig tempat kami mengekspos event handler onKeyDown yang dilampirkan ke komponen Editable . Kami menggunakan util is-hotkey untuk menentukan kombinasi tombol dan mengganti gaya yang sesuai.

 # src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> 
Gaya karakter beralih menggunakan pintasan keyboard.

Membuat Dropdown Gaya Paragraf Bekerja

Mari kita beralih ke membuat dropdown Paragraph Styles berfungsi. Mirip dengan cara kerja dropdown gaya paragraf di aplikasi Pemrosesan Kata populer seperti MS Word atau Google Documents, kami ingin gaya blok tingkat atas dalam pilihan pengguna tercermin dalam dropdown. Jika ada satu gaya yang konsisten di seluruh pilihan, kami memperbarui nilai tarik-turun menjadi itu. Jika ada beberapa, kami menetapkan nilai dropdown menjadi 'Beberapa'. Perilaku ini harus berfungsi untuk keduanya — pilihan yang diciutkan dan diperluas.

Untuk menerapkan perilaku ini, kita harus dapat menemukan blok tingkat atas yang mencakup pilihan pengguna. Untuk melakukannya, kami menggunakan Slate's Editor.nodes — Fungsi pembantu yang biasa digunakan untuk mencari node di pohon yang difilter oleh opsi yang berbeda.

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

Fungsi helper mengambil instance Editor dan objek options yang merupakan cara untuk memfilter node di pohon saat melintasinya. Fungsi mengembalikan generator NodeEntry . NodeEntry dalam terminologi Slate adalah tuple dari sebuah node dan jalur ke sana — [node, pathToNode] . Opsi yang ditemukan di sini tersedia di sebagian besar fungsi pembantu Slate. Mari kita lihat apa artinya masing-masing:

  • at
    Ini bisa berupa Jalur/Titik/Rentang yang akan digunakan oleh fungsi pembantu untuk menjangkau traversal pohon. Ini default ke editor.selection jika tidak disediakan. Kami juga menggunakan default untuk kasus penggunaan kami di bawah ini karena kami tertarik pada node dalam pilihan pengguna.
  • match
    Ini adalah fungsi pencocokan yang dapat disediakan yang dipanggil pada setiap node dan disertakan jika cocok. Kami menggunakan parameter ini dalam implementasi kami di bawah ini untuk memfilter untuk memblokir elemen saja.
  • mode
    Beri tahu fungsi pembantu jika kita tertarik pada semua, node level tertinggi atau terendah at fungsi match lokasi yang diberikan. Parameter ini (diatur ke highest ) membantu kami menghindari mencoba melintasi pohon sendiri untuk menemukan node tingkat atas.
  • universal
    Tandai untuk memilih antara kecocokan penuh atau sebagian dari simpul. (Masalah GitHub dengan proposal untuk flag ini memiliki beberapa contoh yang menjelaskannya)
  • reverse
    Jika pencarian node harus dalam arah kebalikan dari titik awal dan akhir dari lokasi yang dilewati.
  • voids
    Jika pencarian harus memfilter ke elemen void saja.

SlateJS memperlihatkan banyak fungsi pembantu yang memungkinkan Anda meminta node dengan cara yang berbeda, melintasi pohon, memperbarui node atau pilihan dengan cara yang kompleks. Layak digali ke dalam beberapa antarmuka ini (tercantum di akhir artikel ini) saat membangun fungsi pengeditan yang kompleks di atas Slate.

Dengan latar belakang fungsi pembantu tersebut, di bawah ini adalah implementasi dari getTextBlockStyle .

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

Pertimbangan Kinerja

Implementasi Editor.nodes saat ini menemukan semua node di seluruh pohon di semua level yang berada dalam kisaran param at dan kemudian menjalankan filter kecocokan di atasnya (periksa nodeEntries dan pemfilteran nanti — sumber). Ini tidak masalah untuk dokumen yang lebih kecil. Namun, untuk kasus penggunaan kami, jika pengguna memilih, katakanlah 3 judul dan 2 paragraf (setiap paragraf berisi katakanlah 10 simpul teks), itu akan menggilir setidaknya 25 simpul (3 + 2 + 2*10) dan mencoba menjalankan filter pada mereka. Karena kami sudah tahu bahwa kami hanya tertarik pada node tingkat atas, kami dapat menemukan indeks awal dan akhir dari blok tingkat atas dari pilihan dan mengulanginya sendiri. Logika seperti itu hanya akan melewati 3 entri simpul (2 judul dan 1 paragraf). Kode untuk itu akan terlihat seperti di bawah ini:

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

Karena kami menambahkan lebih banyak fungsionalitas ke Editor WYSIWYG dan perlu sering melintasi pohon dokumen, penting untuk memikirkan cara yang paling berkinerja untuk melakukannya untuk kasus penggunaan yang ada karena API atau metode pembantu yang tersedia mungkin tidak selalu menjadi yang terbaik. cara yang efisien untuk melakukannya.

Setelah getTextBlockStyle diimplementasikan, beralih gaya blok relatif mudah. Jika gaya saat ini bukan yang dipilih pengguna di tarik-turun, kami beralih gaya ke itu. Jika sudah menjadi apa yang dipilih pengguna, kami mengubahnya menjadi paragraf. Karena kami mewakili gaya paragraf sebagai simpul dalam struktur dokumen kami, beralih gaya paragraf pada dasarnya berarti mengubah properti type pada simpul. Kami menggunakan Transforms.setNodes yang disediakan oleh Slate untuk memperbarui properti pada node.

toggleBlockType kami adalah seperti di bawah ini:

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

Terakhir, kami memperbarui dropdown Paragraph-Style kami untuk menggunakan fungsi utilitas ini.

 #src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... ); 
Memilih beberapa jenis blok dan mengubah jenis dengan dropdown.

LINK

Di bagian ini, kita akan menambahkan dukungan untuk menampilkan, menambah, menghapus, dan mengubah tautan. Kami juga akan menambahkan fungsi Detektor Tautan — sangat mirip dengan cara Google Documents atau MS Word memindai teks yang diketik oleh pengguna dan memeriksa apakah ada tautan di sana. Jika ada, mereka diubah menjadi objek tautan sehingga pengguna tidak perlu menggunakan tombol bilah alat untuk melakukannya sendiri.

Membuat Tautan

Di editor kami, kami akan mengimplementasikan tautan sebagai node sebaris dengan SlateJS. Kami memperbarui konfigurasi editor kami untuk menandai tautan sebagai simpul sebaris untuk SlateJS dan juga menyediakan komponen untuk dirender sehingga Slate tahu cara merender simpul tautan.

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

We then add a link node to our ExampleDocument and verify that it renders correctly (including a case for character styles inside a link) in the Editor.

 # src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... } 
Image showing Links rendered in the Editor and DOM tree of the editor
Links rendered in the Editor (Large preview)

Adding A Link Button To The Toolbar

Let's add a Link Button to the toolbar that enables the user to do the following:

  • Selecting some text and clicking on the button converts that text into a link
  • Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
  • If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.

To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above helper function from SlateJS.

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

Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.

 # src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> ); 
Link button in Toolbar becomes active if selection is inside a link.

To toggle links in the editor, we add a util function toggleLinkAtSelection . Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:

Before and After node structures after a link is inserted
Before and After node structures after a link is inserted. (Pratinjau besar)

If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection has the below implementation to insert a new link at an expanded selection.

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

If the selection is collapsed, we insert a new node there with Transform.insertNodes that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.

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

Link Editor Menu

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

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

Karena kita sedang merender LinkEditor di luar editor, kita memerlukan cara untuk memberi tahu LinkEditor di mana link tersebut berada di pohon DOM sehingga dapat merender dirinya sendiri di dekat editor. Cara kita melakukannya adalah menggunakan Slate's React API untuk menemukan simpul DOM yang sesuai dengan simpul tautan yang dipilih. Dan kami kemudian menggunakan getBoundingClientRect() untuk menemukan batas elemen DOM tautan dan batas komponen editor dan menghitung bagian top dan left untuk editor tautan. Pembaruan kode untuk Editor dan LinkEditor adalah seperti di bawah ini —

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

SlateJS secara internal memelihara peta node ke elemen DOM masing-masing. Kami mengakses peta itu dan menemukan elemen DOM tautan menggunakan ReactEditor.toDOMNode .

Pilihan di dalam tautan menunjukkan popover editor tautan.

Seperti yang terlihat pada video di atas, ketika tautan dimasukkan dan tidak memiliki URL, karena pilihan ada di dalam tautan, itu membuka editor tautan sehingga memberi pengguna cara untuk mengetikkan URL untuk tautan yang baru dimasukkan dan karenanya menutup loop pada pengalaman pengguna di sana.

Kami sekarang menambahkan elemen input dan tombol ke LinkEditor yang memungkinkan pengguna mengetikkan URL dan menerapkannya ke node tautan. Kami menggunakan paket isUrl untuk validasi URL.

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

Dengan elemen formulir terhubung, mari kita lihat apakah editor tautan berfungsi seperti yang diharapkan.

Editor kehilangan pilihan saat mengklik editor tautan di dalam

Seperti yang kita lihat di video ini, ketika pengguna mencoba mengeklik input, editor tautan menghilang. Ini karena saat kami merender editor tautan di luar komponen yang Dapat Editable , ketika pengguna mengklik elemen input, SlateJS menganggap editor telah kehilangan fokus dan menyetel ulang selection menjadi null yang menghapus LinkEditor karena isLinkActiveAtSelection tidak lagi true . Ada Masalah GitHub terbuka yang berbicara tentang perilaku Slate ini. Salah satu cara untuk mengatasi ini adalah dengan melacak pilihan pengguna sebelumnya saat berubah dan ketika editor kehilangan fokus, kita dapat melihat pilihan sebelumnya dan masih menampilkan menu editor tautan jika pilihan sebelumnya memiliki tautan di dalamnya. Mari perbarui kait useSelection untuk mengingat pilihan sebelumnya dan mengembalikannya ke komponen Editor.

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

Kami kemudian memperbarui logika di komponen Editor untuk menampilkan menu tautan meskipun pilihan sebelumnya memiliki tautan di dalamnya.

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

Kami kemudian memperbarui LinkEditor untuk menggunakan selectionForLink untuk mencari simpul tautan, merender di bawahnya dan memperbarui URL-nya.

 # src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ... 
Mengedit tautan menggunakan komponen LinkEditor.

Mendeteksi Tautan Dalam Teks

Sebagian besar aplikasi pengolah kata mengidentifikasi dan mengonversi tautan di dalam teks ke objek tautan. Mari kita lihat cara kerjanya di editor sebelum kita mulai membuatnya.

Tautan terdeteksi saat pengguna mengetiknya.

Langkah-langkah logika untuk mengaktifkan perilaku ini adalah:

  1. Saat dokumen berubah dengan pengetikan pengguna, temukan karakter terakhir yang disisipkan oleh pengguna. Jika karakter itu adalah spasi, kita tahu pasti ada kata yang mungkin muncul sebelumnya.
  2. Jika karakter terakhir adalah spasi, kami menandainya sebagai batas akhir kata yang datang sebelumnya. Kami kemudian menelusuri kembali karakter demi karakter di dalam simpul teks untuk menemukan di mana kata itu dimulai. Selama traversal ini, kita harus berhati-hati untuk tidak melewati tepi awal node ke node sebelumnya.
  3. Setelah kami menemukan batas awal dan akhir kata sebelumnya, kami memeriksa string kata dan melihat apakah itu URL. Jika ya, kami mengubahnya menjadi simpul tautan.

Logika kita hidup dalam fungsi util identifyLinksInTextIfAny yang hidup di EditorUtils dan dipanggil di dalam komponen onChange di Editor .

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

Berikut adalah identifyLinksInTextIfAny dengan logika untuk Langkah 1 yang diimplementasikan:

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

Ada dua fungsi pembantu SlateJS yang memudahkan segalanya di sini.

  • Editor.before — Memberi kami poin sebelum lokasi tertentu. Dibutuhkan unit sebagai parameter sehingga kami dapat meminta karakter/kata/blok dll sebelum location masuk.
  • Editor.string — Menempatkan string di dalam rentang.

Sebagai contoh, diagram di bawah ini menjelaskan nilai dari variabel-variabel ini ketika pengguna memasukkan karakter 'E' dan kursor mereka duduk setelahnya.

Diagram yang menjelaskan ke mana kursorPoint dan startPointOfLastCharacter menunjuk setelah langkah 1 dengan sebuah contoh
cursorPoint dan startPointOfLastCharacter setelah Langkah 1 dengan contoh teks. (Pratinjau besar)

Jika teks 'ABCDE' adalah simpul teks pertama dari paragraf pertama dalam dokumen, nilai poin kita adalah —

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

Jika karakter terakhir adalah spasi, kita tahu di mana itu dimulai — startPointOfLastCharacter. Mari kita pindah ke langkah-2 di mana kita bergerak mundur karakter demi karakter sampai kita menemukan ruang lain atau awal dari simpul teks itu sendiri.

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

Berikut adalah diagram yang menunjukkan ke mana titik-titik yang berbeda ini menunjuk begitu kita menemukan kata terakhir yang dimasukkan adalah ABCDE .

Diagram yang menjelaskan di mana titik-titik yang berbeda berada setelah langkah 2 pendeteksian tautan dengan sebuah contoh
Di mana titik yang berbeda berada setelah langkah 2 deteksi tautan dengan contoh. (Pratinjau besar)

Perhatikan bahwa start dan end adalah titik sebelum dan sesudah spasi di sana. Demikian pula, startPointOfLastCharacter dan cursorPoint adalah titik sebelum dan sesudah pengguna spasi yang baru saja dimasukkan. Oleh karena itu [end,startPointOfLastCharacter] memberi kita kata terakhir yang dimasukkan.

Kami mencatat nilai lastWord ke konsol dan memverifikasi nilai saat kami mengetik.

Log konsol memverifikasi kata terakhir yang dimasukkan oleh pengguna setelah logika di Langkah 2.

Sekarang kami telah menyimpulkan apa kata terakhir yang diketik pengguna, kami memverifikasi bahwa itu memang URL dan mengubah rentang itu menjadi objek tautan. Konversi ini terlihat mirip dengan bagaimana tombol tautan bilah alat mengonversi teks yang dipilih pengguna menjadi tautan.

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

identifyLinksInTextIfAny dipanggil di dalam onChange Slate sehingga kami tidak ingin memperbarui struktur dokumen di dalam onChange . Oleh karena itu, kami menempatkan pembaruan ini pada antrean tugas kami dengan panggilan Promise.resolve().then(..) .

Mari kita lihat logika bersatu dalam tindakan! Kami memverifikasi jika kami memasukkan tautan di akhir, di tengah, atau awal dari simpul teks.

Tautan terdeteksi saat pengguna mengetiknya.

Dengan itu, kami telah menyelesaikan fungsionalitas untuk tautan di editor dan beralih ke Gambar.

Menangani Gambar

Di bagian ini, kami fokus pada penambahan dukungan untuk merender node gambar, menambahkan gambar baru, dan memperbarui keterangan gambar. Gambar, dalam struktur dokumen kami, akan direpresentasikan sebagai node Void. Node kosong di SlateJS (analog dengan elemen Void dalam spesifikasi HTML) sedemikian rupa sehingga isinya bukan teks yang dapat diedit. Itu memungkinkan kita untuk membuat gambar sebagai kekosongan. Karena fleksibilitas Slate dengan rendering, kami masih dapat merender elemen kami sendiri yang dapat diedit di dalam elemen Void — yang akan kami lakukan untuk pengeditan teks gambar. SlateJS memiliki contoh yang menunjukkan bagaimana Anda dapat menyematkan seluruh Editor Teks Kaya di dalam elemen Void.

Untuk merender gambar, kami mengonfigurasi editor untuk memperlakukan gambar sebagai elemen Void dan memberikan implementasi render tentang bagaimana gambar harus dirender. Kami menambahkan gambar ke ExampleDocument kami dan memverifikasi bahwa itu ditampilkan dengan benar dengan keterangan.

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

Dua hal yang perlu diingat ketika mencoba membuat simpul kosong dengan SlateJS:

  • Elemen root DOM harus memiliki contentEditable={false} yang disetel sehingga SlateJS memperlakukan kontennya demikian. Tanpa ini, saat Anda berinteraksi dengan elemen void, SlateJS mungkin mencoba menghitung pilihan, dll., dan sebagai hasilnya rusak.
  • Bahkan jika node Void tidak memiliki node anak (seperti node gambar kita sebagai contoh), kita masih perlu merender children dan menyediakan node teks kosong sebagai anak (lihat ExampleDocument di bawah) yang diperlakukan sebagai titik pemilihan Void elemen oleh SlateJS

Kami sekarang memperbarui ExampleDocument untuk menambahkan gambar dan memverifikasi bahwa itu muncul dengan keterangan di editor.

 # src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ]; 
Gambar dirender di Editor
Gambar dirender di Editor. (Pratinjau besar)

Sekarang mari kita fokus pada pengeditan teks. Cara kami ingin ini menjadi pengalaman yang mulus bagi pengguna adalah ketika mereka mengklik teks, kami menampilkan input teks tempat mereka dapat mengedit teks. Jika mereka mengklik di luar input atau menekan tombol RETURN, kami menganggapnya sebagai konfirmasi untuk menerapkan teks. Kami kemudian memperbarui keterangan pada node gambar dan mengalihkan keterangan kembali ke mode baca. Mari kita lihat aksinya sehingga kita memiliki gambaran tentang apa yang sedang kita bangun.

Pengeditan Keterangan Gambar sedang beraksi.

Mari perbarui komponen Gambar agar memiliki status untuk mode baca-edit teks. Kami memperbarui status teks lokal saat pengguna memperbaruinya dan ketika mereka mengklik ( onBlur ) atau menekan RETURN ( onKeyDown ), kami menerapkan teks ke node dan beralih ke mode baca lagi.

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

Dengan itu, fungsi pengeditan teks selesai. Kami sekarang pindah ke menambahkan cara bagi pengguna untuk mengunggah gambar ke editor. Mari tambahkan tombol toolbar yang memungkinkan pengguna memilih dan mengunggah gambar.

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

Saat kami bekerja dengan unggahan gambar, kodenya bisa bertambah sedikit sehingga kami memindahkan penanganan unggahan gambar ke kait useImageUploadHandler yang memberikan panggilan balik yang dilampirkan ke elemen input file. Kami akan segera membahas tentang mengapa itu membutuhkan status previousSelection .

Sebelum kita mengimplementasikan useImageUploadHandler , kita akan menyiapkan server untuk dapat mengupload gambar. Kami menyiapkan server Express dan menginstal dua paket lainnya — cors dan multer yang menangani unggahan file untuk kami.

 yarn add express cors multer

Kami kemudian menambahkan skrip src/server.js yang mengonfigurasi server Express dengan cors dan multer dan memperlihatkan titik akhir /upload yang akan kami unggah gambarnya.

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

Sekarang setelah kita memiliki pengaturan server, kita dapat fokus menangani unggahan gambar. Saat pengguna mengunggah gambar, mungkin perlu beberapa detik sebelum gambar diunggah dan kami memiliki URL untuk itu. Namun, kami melakukan apa untuk memberikan umpan balik langsung kepada pengguna bahwa unggahan gambar sedang berlangsung sehingga mereka tahu bahwa gambar sedang dimasukkan ke dalam editor. Berikut adalah langkah-langkah yang kami terapkan untuk membuat perilaku ini berhasil -

  1. Setelah pengguna memilih gambar, kami menyisipkan node gambar di posisi kursor pengguna dengan flag isUploading yang disetel sehingga kami dapat menunjukkan status pemuatan kepada pengguna.
  2. Kami mengirim permintaan ke server untuk mengunggah gambar.
  3. Setelah permintaan selesai dan kami memiliki URL gambar, kami menetapkannya pada gambar dan menghapus status pemuatan.

Mari kita mulai dengan langkah pertama di mana kita memasukkan node gambar. Sekarang, bagian yang sulit di sini adalah kita mengalami masalah yang sama dengan pemilihan seperti tombol tautan di bilah alat. Segera setelah pengguna mengklik tombol Gambar di bilah alat, editor kehilangan fokus dan pilihan menjadi null . Jika kami mencoba menyisipkan gambar, kami tidak tahu di mana kursor pengguna berada. Pelacakan previousSelection memberi kita lokasi itu dan kita menggunakannya untuk menyisipkan node.

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

Saat kami menyisipkan node gambar baru, kami juga menetapkannya sebagai id pengenal menggunakan paket uuid. Kami akan membahas dalam implementasi Langkah (3) mengapa kami membutuhkannya. Kami sekarang memperbarui komponen gambar untuk menggunakan flag isUploading untuk menunjukkan status pemuatan.

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

Itu menyelesaikan implementasi langkah 1. Mari kita verifikasi bahwa kita dapat memilih gambar untuk diunggah, lihat simpul gambar dimasukkan dengan indikator pemuatan tempat gambar itu dimasukkan ke dalam dokumen.

Unggahan gambar membuat simpul gambar dengan status pemuatan.

Pindah ke Langkah (2), kita akan menggunakan library axois untuk mengirim permintaan ke server.

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

Kami memverifikasi bahwa unggahan gambar berfungsi dan gambar muncul di folder public/photos aplikasi. Sekarang setelah pengunggahan gambar selesai, kita pindah ke Langkah (3) di mana kita ingin mengatur URL pada gambar dalam fungsi resolve() dari janji axios. Kami dapat memperbarui gambar dengan Transforms.setNodes tetapi kami memiliki masalah — kami tidak memiliki jalur ke node gambar yang baru dimasukkan. Mari kita lihat apa pilihan kita untuk mendapatkan gambar itu —

  • Tidak bisakah kita menggunakan editor.selection karena seleksi harus pada node gambar yang baru dimasukkan? Kami tidak dapat menjamin ini karena saat gambar diunggah, pengguna mungkin telah mengklik di tempat lain dan pilihannya mungkin telah berubah.
  • Bagaimana dengan menggunakan previousSelection kita gunakan untuk menyisipkan node gambar di tempat pertama? Untuk alasan yang sama kami tidak dapat menggunakan editor.selection , kami tidak dapat menggunakan previousSelection karena mungkin telah berubah juga.
  • SlateJS memiliki modul History yang melacak semua perubahan yang terjadi pada dokumen. Kita bisa menggunakan modul ini untuk mencari sejarah dan menemukan node gambar yang terakhir disisipkan. Ini juga tidak sepenuhnya dapat diandalkan jika butuh waktu lebih lama untuk mengunggah gambar dan pengguna memasukkan lebih banyak gambar di berbagai bagian dokumen sebelum pengunggahan pertama selesai.
  • Saat ini, API Transform.insertNodes tidak mengembalikan informasi apa pun tentang node yang disisipkan. Jika itu bisa mengembalikan jalur ke node yang dimasukkan, kita bisa menggunakannya untuk menemukan node gambar yang tepat yang harus kita perbarui.

Karena tidak ada pendekatan di atas yang berfungsi, kami menerapkan id ke node gambar yang disisipkan (pada Langkah (1)) dan menggunakan id yang sama lagi untuk menemukannya saat unggahan gambar selesai. Dengan itu, kode kami untuk Langkah (3) terlihat seperti di bawah ini —

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

Dengan implementasi ketiga langkah selesai, kami siap untuk menguji pengunggahan gambar ujung ke ujung.

Unggah gambar berfungsi dari ujung ke ujung

Dengan itu, kami telah menyelesaikan Gambar untuk editor kami. Saat ini, kami menunjukkan status pemuatan dengan ukuran yang sama terlepas dari gambar. Ini bisa menjadi pengalaman yang mengejutkan bagi pengguna jika status pemuatan diganti dengan gambar yang lebih kecil atau lebih besar secara drastis saat pengunggahan selesai. Tindak lanjut yang baik untuk pengalaman unggahan adalah mendapatkan dimensi gambar sebelum unggahan dan menunjukkan placeholder dengan ukuran itu sehingga transisi menjadi mulus. Kait yang kami tambahkan di atas dapat diperluas untuk mendukung jenis media lain seperti video atau dokumen dan juga merender jenis simpul tersebut.

Kesimpulan

Dalam artikel ini, kami telah membangun Editor WYSIWYG yang memiliki serangkaian fungsi dasar dan beberapa pengalaman pengguna mikro seperti deteksi tautan, pengeditan tautan di tempat, dan pengeditan teks gambar yang membantu kami masuk lebih dalam dengan SlateJS dan konsep Pengeditan Teks Kaya di umum. Jika ruang masalah di sekitar Pengeditan Teks Kaya atau Pemrosesan Kata ini menarik minat Anda, beberapa masalah keren yang harus dicari adalah:

  • Kolaborasi
  • Pengalaman pengeditan teks yang lebih kaya yang mendukung perataan teks, gambar sebaris, salin-tempel, ubah font dan warna teks, dll.
  • Mengimpor dari format populer seperti dokumen Word dan Penurunan harga.

Jika Anda ingin mempelajari lebih lanjut SlateJS, berikut adalah beberapa tautan yang mungkin bisa membantu.

  • Contoh SlateJS
    Banyak contoh yang melampaui dasar-dasar dan membangun fungsionalitas yang biasanya ditemukan di Editor seperti Penelusuran & Sorotan, Pratinjau Penurunan Harga, dan Sebutan.
  • Dokumen API
    Referensi ke banyak fungsi pembantu yang diekspos oleh SlateJS yang mungkin ingin tetap berguna saat mencoba melakukan kueri/transformasi kompleks pada objek SlateJS.

Terakhir, Slack Channel SlateJS adalah komunitas pengembang web yang sangat aktif yang membangun aplikasi Pengeditan Teks Kaya menggunakan SlateJS dan tempat yang bagus untuk mempelajari lebih lanjut tentang perpustakaan dan mendapatkan bantuan jika diperlukan.