Membangun Editor Teks Kaya (WYSIWYG)
Diterbitkan: 2022-03-10Dalam 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.
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.
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 dancaption
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 (disebutanchor
danfocus
) yang mewakili rentang teks di dalam dokumen. Konsep ini berasal dari Web's Selection API di manaanchor
adalah tempat pemilihan pengguna dimulai danfocus
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
:
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
dengan dependensi SlateJS ditambahkan ke dalamnya. Kami sedang membangun UI aplikasi menggunakan komponen dari create-react-app
. Mari kita mulai!react-bootstrap
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 mengembalikandiv
untuk saat ini. - Perbarui komponen
App.js
untuk menyimpan dokumen dalam statusnya yang diinisialisasi keExampleDocument
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
pada tahap ini terlihat seperti di bawah ini:Editor.js
import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }
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.
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
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 }, ], },
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.
Berikut adalah tiga fungsi utama yang perlu didukung oleh toolbar:
- 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.
- Saat pengguna memilih rentang teks dan mengklik salah satu tombol gaya karakter, kita perlu mengaktifkan gaya untuk bagian tertentu.
- 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.
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 <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} />
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 keeditor.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 terendahat
fungsimatch
lokasi yang diberikan. Parameter ini (diatur kehighest
) 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> .... );
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 }, ], }, ... }
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> );
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:
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
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.Transform.insertNodes
# 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
.
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.
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", }); ...
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.
Langkah-langkah logika untuk mengaktifkan perilaku ini adalah:
- 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.
- 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.
- 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. Dibutuhkanunit
sebagai parameter sehingga kami dapat meminta karakter/kata/blok dll sebelumlocation
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.
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
.
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.
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.
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 (lihatExampleDocument
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: "" }], }, ];
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.
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 -
- 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. - Kami mengirim permintaan ke server untuk mengunggah gambar.
- 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.
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 menggunakaneditor.selection
, kami tidak dapat menggunakanpreviousSelection
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.
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.