Zengin Metin Düzenleyicisi Oluşturma (WYSIWYG)
Yayınlanan: 2022-03-10Son yıllarda, Dijital platformlarda İçerik Oluşturma ve Temsil alanı büyük bir bozulma gördü. Quip, Google Docs ve Dropbox Paper gibi ürünlerin yaygın başarısı, şirketlerin kurumsal alanda içerik oluşturucular için en iyi deneyimi oluşturmak için nasıl yarıştıklarını ve içeriğin paylaşılması ve tüketilmesiyle ilgili geleneksel kalıpları kırmanın yenilikçi yollarını bulmaya çalıştıklarını gösterdi. Sosyal medya platformlarının kitlesel erişiminden yararlanan yeni bir bağımsız içerik oluşturucu dalgası, içerik oluşturmak ve izleyicileriyle paylaşmak için Medium gibi platformları kullanıyor.
Farklı mesleklerden ve geçmişlerden pek çok insan bu ürünler üzerinde içerik oluşturmaya çalıştığından, bu ürünlerin performanslı ve kusursuz bir içerik oluşturma deneyimi sağlaması ve zaman içinde bu alanda belirli bir düzeyde alan uzmanlığı geliştiren tasarımcı ve mühendis ekiplerine sahip olması önemlidir. . Bu makaleyle, yalnızca bir editör oluşturmanın temellerini atmaya değil, aynı zamanda okuyuculara, bir araya getirildiğinde küçük işlevsellik parçalarının bir içerik oluşturucu için harika bir kullanıcı deneyimi yaratabileceğine dair bir fikir vermeye çalışıyoruz.
Belge Yapısını Anlamak
Düzenleyiciyi oluşturmaya başlamadan önce, bir Zengin Metin Düzenleyicisi için bir belgenin nasıl yapılandırıldığına ve ilgili farklı veri yapılarının neler olduğuna bakalım.
Belge Düğümleri
Belge düğümleri, belgenin içeriğini temsil etmek için kullanılır. Zengin metinli bir belgenin içerebileceği yaygın düğüm türleri paragraflar, başlıklar, resimler, videolar, kod blokları ve alıntılardır. Bunlardan bazıları, içlerinde alt öğe olarak başka düğümler içerebilir (örneğin, Paragraf düğümleri, içlerinde metin düğümleri içerir). Düğümler ayrıca, temsil ettikleri nesneye özgü ve bu düğümleri düzenleyicide işlemek için gereken tüm özellikleri de tutar. (örneğin, Görüntü düğümleri bir görüntü src
özelliği içerir, Kod blokları bir language
özelliği içerebilir vb.).
Nasıl oluşturulmaları gerektiğini temsil eden büyük ölçüde iki tür düğüm vardır -
- Her biri yeni bir satırda oluşturulan ve kullanılabilir genişliği işgal eden Blok Düğümleri (Blok düzeyinde öğelerin HTML kavramına benzer). Blok düğümleri, içlerinde başka blok düğümleri veya satır içi düğümler içerebilir. Buradaki bir gözlem, bir belgenin en üst düzey düğümlerinin her zaman blok düğümleri olacağıdır.
- Önceki düğümle aynı satırda işlemeye başlayan Satır İçi Düğümler (Satır içi öğelerin HTML kavramına benzer). Farklı düzenleme kitaplıklarında satır içi öğelerin nasıl temsil edildiği konusunda bazı farklılıklar vardır. SlateJS, satır içi öğelerin kendileri düğüm olmasına izin verir. Bir başka popüler Zengin Metin Düzenleme kitaplığı olan DraftJS, satır içi öğeleri oluşturmak için Varlıklar kavramını kullanmanıza olanak tanır. Bağlantılar ve Satır İçi Görüntüler, Satır içi düğümlere örnektir.
- Void Nodes — SlateJS, medyayı oluşturmak için bu makalenin ilerleyen kısımlarında kullanacağımız bu üçüncü düğüm kategorisine de izin verir.
Bu kategoriler hakkında daha fazla bilgi edinmek istiyorsanız, SlateJS'nin Düğümlerle ilgili belgeleri başlamak için iyi bir yerdir.
Öznitellikler
HTML'nin nitelik kavramına benzer şekilde, Zengin Metin Belgesindeki nitelikler, bir düğümün veya onun alt öğelerinin içerik olmayan özelliklerini temsil etmek için kullanılır. Örneğin, bir metin düğümü, bize metnin kalın/italik/altı çizili olup olmadığını söyleyen karakter stili özniteliklerine sahip olabilir. Bu makale, başlıkları düğümler olarak temsil etse de, onları temsil etmenin başka bir yolu, düğümlerin öznitelik olarak paragraf stillerine ( paragraph
& h1-h6
) sahip olması olabilir.
Aşağıdaki görüntü, bir belgenin yapısının (JSON'da) soldaki yapıdaki bazı öğeleri vurgulayan düğümler ve öznitelikler kullanılarak daha ayrıntılı bir düzeyde nasıl tanımlandığına dair bir örnek vermektedir.
Burada yapıyla birlikte seslenmeye değer şeylerden bazıları şunlardır:
- Metin düğümleri
{text: 'text content'}
olarak temsil edilir - Düğümlerin özellikleri doğrudan düğümde saklanır (örneğin, bağlantılar için
url
ve resimler içincaption
) - Metin niteliklerinin SlateJS'ye özgü temsili, karakter stili değişirse metin düğümlerini kendi düğümleri olacak şekilde keser. Bu nedenle, ' Duis aute irure dolor ' metni, üzerinde
bold: true
olan kendine ait bir metin düğümüdür. Bu belgedeki italik, altı çizili ve kod stili metin için de durum aynıdır.
Mekanlar ve Seçim
Zengin bir metin düzenleyicisi oluştururken, bir belgenin en ayrıntılı bölümünün (örneğin bir karakterin) bir tür koordinatla nasıl temsil edilebileceğini anlamak çok önemlidir. Bu, belge hiyerarşisinde nerede olduğumuzu anlamak için çalışma zamanında belge yapısında gezinmemize yardımcı olur. En önemlisi, konum nesneleri bize, editörün kullanıcı deneyimini gerçek zamanlı olarak uyarlamak için oldukça yaygın olarak kullanılan kullanıcı seçimini temsil etmenin bir yolunu sunar. Bu makalenin ilerleyen bölümlerinde araç çubuğumuzu oluşturmak için seçimi kullanacağız. Bunlara örnekler şunlar olabilir:
- Kullanıcının imleci şu anda bir bağlantının içinde mi, belki onlara bağlantıyı düzenlemek/kaldırmak için bir menü göstermeliyiz?
- Kullanıcı bir resim seçti mi? Belki onlara görüntüyü yeniden boyutlandırmaları için bir menü veririz.
- Kullanıcı belirli bir metni seçip SİL düğmesine basarsa, kullanıcının seçtiği metnin ne olduğunu belirleyip belgeden kaldırıyoruz.
SlateJS'nin Location hakkındaki belgesi bu veri yapılarını kapsamlı bir şekilde açıklıyor, ancak bu terimleri makalede farklı durumlarda kullandığımızdan ve aşağıdaki şemada bir örnek gösterdiğimiz için burada hızlı bir şekilde geçiyoruz.
- Yol
Bir dizi sayı ile temsil edilen yol, belgedeki bir düğüme ulaşmanın yoludur. Örneğin, bir yol[2,3]
, belgedeki 2. düğümün 3. alt düğümünü temsil eder. - Puan
Yol + ofset ile temsil edilen içeriğin daha ayrıntılı konumu. Örneğin, bir{path: [2,3], offset: 14}
noktası, belgenin 2. düğümündeki 3. alt düğümün 14. karakterini temsil eder. - Menzil
Belge içindeki bir dizi metni temsil eden bir çift nokta (anchor
vefocus
olarak adlandırılır). Bu kavram, Web'in Seçim API'sinden gelir; buradaanchor
, kullanıcının seçiminin başladığı vefocus
bittiği yerdir. Daraltılmış bir aralık/seçim, bağlantı ve odak noktalarının aynı olduğu yeri gösterir (örneğin, bir metin girişinde yanıp sönen bir imleç düşünün).
Örnek olarak, yukarıdaki belge örneğimizde kullanıcının seçiminin ipsum
olduğunu varsayalım:
Kullanıcının seçimi şu şekilde temsil edilebilir:
{ 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' }`
Editörün Kurulumu
Bu bölümde, uygulamayı kuracağız ve SlateJS ile çalışan temel bir zengin metin düzenleyicisi alacağız. Standart uygulama, SlateJS bağımlılıklarının eklendiği
olacaktır. create-react-app
bileşenlerini kullanarak uygulamanın kullanıcı arayüzünü oluşturuyoruz. Başlayalım!react-bootstrap
wysiwyg-editor
adlı bir klasör oluşturun ve tepki uygulamasını ayarlamak için dizinin içinden aşağıdaki komutu çalıştırın. Ardından yerel web sunucusunu çalıştıracak (port varsayılanı 3000'dir) ve size bir React karşılama ekranı gösterecek bir yarn start
komutu çalıştırıyoruz.
npx create-react-app . yarn start
Daha sonra uygulamaya SlateJS bağımlılıklarını eklemeye geçiyoruz.
yarn add slate slate-react
slate
, SlateJS'nin çekirdek paketidir ve slate-react
, Slate editörlerini oluşturmak için kullanacağımız React bileşenlerini içerir. SlateJS, birinin düzenleyicisine eklemeyi düşünebileceği işlevselliğe göre düzenlenmiş bazı paketleri daha ortaya çıkarır.
Öncelikle bu uygulamada oluşturduğumuz tüm yardımcı modüllerin bulunduğu bir utils
klasörü oluşturuyoruz. Biraz metin içeren bir paragraf içeren temel bir belge yapısı döndüren bir ExampleDocument.js
oluşturmaya başlıyoruz. Bu modül aşağıdaki gibi görünür:
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
Şimdi tüm React bileşenlerimizi tutacak ve aşağıdakileri yapacak olan components
adında bir klasör ekliyoruz:
- İlk React bileşenimiz
Editor.js
buna ekleyin. Şimdilik sadece birdiv
döndürür. -
App.js
bileşenini, belgeyi yukarıdakiExampleDocument
başlatılan durumunda tutmak için güncelleyin. - Düzenleyiciyi uygulamanın içinde işleyin ve belge durumunu ve bir
onChange
işleyicisini Düzenleyiciye iletin, böylece kullanıcı güncelledikçe belge durumumuz güncellenir. - Uygulamaya bir gezinme çubuğu eklemek için de React önyüklemesinin Nav bileşenlerini kullanıyoruz.
App.js
bileşeni şimdi aşağıdaki gibi görünüyor:
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> </> );
Editor bileşeninin içinde, daha sonra SlateJS düzenleyicisini başlatır ve nesnenin yeniden oluşturmalar arasında değişmemesi için onu bir useMemo
içinde tutarız.
// dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);
createEditor
bize, seçimlere erişmek, veri dönüşümlerini çalıştırmak vb. için uygulama aracılığıyla yaygın olarak kullandığımız SlateJS editor
örneğini verir. withReact, editör nesnesine React ve DOM davranışları ekleyen bir SlateJS eklentisidir. SlateJS Eklentileri, editor
nesnesini alan ve ona bazı yapılandırmalar ekleyen Javascript işlevleridir. Bu, web geliştiricilerinin SlateJS düzenleyici örneklerine birleştirilebilir bir şekilde yapılandırma eklemelerine olanak tanır.
Şimdi, App.js'den aldığımız belge prop ile <Slate />
ve <Editable />
bileşenlerini SlateJS'den içe aktarıyor ve oluşturuyoruz. Slate
, uygulama koduna erişmek için kullandığımız bir dizi React bağlamını ortaya çıkarır. Editable
, düzenleme için belge hiyerarşisini oluşturan bileşendir. Genel olarak, bu aşamadaki
modülü aşağıdaki gibi görünür: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> ); }
Bu noktada gerekli React bileşenlerini ekledik ve düzenleyiciyi örnek bir belge ile doldurduk. Editörümüz şimdi, aşağıdaki ekran görüntüsünde olduğu gibi, içeriği gerçek zamanlı olarak yazmamıza ve değiştirmemize izin verecek şekilde ayarlanmalıdır.
Şimdi, düzenleyiciyi karakter stillerini ve paragraf düğümlerini oluşturacak şekilde yapılandırdığımız sonraki bölüme geçelim.
ÖZEL METİN OLUŞTURMA VE ARAÇ ÇUBUĞU
Paragraf Stili Düğümleri
Şu anda düzenleyicimiz, belgeye ekleyebileceğimiz tüm yeni düğüm türleri için SlateJS'nin varsayılan oluşturmasını kullanıyor. Bu bölümde başlık düğümlerini işleyebilmek istiyoruz. Bunu yapabilmek için, Slate'in bileşenlerine bir renderElement
işlev desteği sağlıyoruz. Bu işlev, çalışma zamanında belge ağacında gezinmeye ve her bir düğümü işlemeye çalışırken Slate tarafından çağrılır. renderElement işlevi üç parametre alır —
-
attributes
Bu işlevden döndürülen üst düzey DOM öğesine uygulanması gereken SlateJS'ye özgü. -
element
Belge yapısında olduğu gibi düğüm nesnesinin kendisi -
children
Belge yapısında tanımlandığı gibi bu düğümün çocukları.
renderElement
uygulamamızı, ilerledikçe daha fazla düzenleyici yapılandırması ekleyeceğimiz useEditorConfig
adlı bir kancaya ekliyoruz. Ardından, Editor.js
içindeki düzenleyici örneğindeki kancayı kullanırız.
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} />; } }
Bu işlev bize element
(düğümün kendisidir) erişim sağladığından, renderElement
, yalnızca element.type
öğesini kontrol etmekten fazlasını yapan daha özelleştirilmiş bir işleme uygulamak için özelleştirebiliriz. Örneğin, satır içi görüntüleri blok görüntülere göre oluşturmamıza yardımcı olan farklı bir DOM yapısını döndürmek için kullanabileceğimiz isInline
özelliğine sahip bir görüntü düğümünüz olabilir.
Şimdi bu kancayı aşağıdaki gibi kullanmak için Editör bileşenini güncelliyoruz:
const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );
Özel işleme yerindeyken, yeni düğüm türlerimizi içerecek şekilde ExampleDocument'i güncelleriz ve düzenleyici içinde doğru şekilde oluşturulduklarını doğrularız.
const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes
Karakter Stilleri
renderElement öğesine benzer şekilde, renderElement
, metin düğümlerinin oluşturulmasını özelleştirmek için kullanılabilen renderLeaf adlı bir işlev desteği verir ( Leaf
, belge ağacının yaprakları/en düşük seviye düğümleri olan metin düğümlerine atıfta bulunur). renderElement örneğini takip ederek renderElement
için bir uygulama 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>; }
Yukarıdaki uygulamanın önemli bir gözlemi, karakter stilleri için HTML semantiğine saygı duymamıza izin vermesidir. renderLeaf bize metin düğüm leaf
kendisine erişim sağladığından, işlevi daha özelleştirilmiş bir işleme uygulamak için özelleştirebiliriz. Örneğin, kullanıcıların metin için bir highlightColor
rengi seçmesine ve ilgili stilleri eklemek için buradaki yaprak özelliğini kontrol etmesine izin vermenin bir yolunuz olabilir.
Şimdi Editör bileşenini yukarıdakileri kullanacak şekilde, ExampleDocument
paragrafta bu stillerin kombinasyonlarıyla birkaç metin düğümüne sahip olacak ve kullandığımız anlamsal etiketlerle Editör'de beklendiği gibi oluşturulduklarını doğrulayacak şekilde güncelliyoruz.
# 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 }, ], },
Araç Çubuğu Ekleme
Karakter stilleri için birkaç düğme ve paragraf stilleri için bir açılır menü eklediğimiz yeni bir Toolbar.js
bileşeni ekleyerek başlayalım ve bunları bölümün ilerleyen kısımlarında bağlarız.
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> ); }
Düğmeleri, React Bootstrap Button bileşeninin etrafını ToolbarButton
bileşenine soyutladık. Daha sonra araç çubuğunu Editable
İçinde Editor
bileşeninin üzerinde oluştururuz ve araç çubuğunun uygulamada göründüğünü doğrularız.
Araç çubuğunun desteklemesi gereken üç temel işlev şunlardır:
- Kullanıcının imleci belgede belirli bir noktada olduğunda ve karakter stili düğmelerinden birine tıkladığında, daha sonra yazabilecekleri metin için stili değiştirmemiz gerekir.
- Kullanıcı bir metin aralığı seçip karakter stili düğmelerinden birine tıkladığında, o belirli bölüm için stili değiştirmemiz gerekir.
- Kullanıcı bir metin aralığı seçtiğinde, seçimin paragraf türünü yansıtacak şekilde paragraf stili açılır menüsünü güncellemek istiyoruz. Seçimden farklı bir değer seçerlerse, tüm seçimin paragraf stilini onların seçtikleri şekilde güncellemek isteriz.
Bunları uygulamaya başlamadan önce bu işlevlerin Editör'de nasıl çalıştığına bakalım.
Seçimi Dinlemek
Araç Çubuğunun yukarıdaki işlevleri yerine getirebilmesi için ihtiyaç duyduğu en önemli şey, belgenin Seçim durumudur. Bu makaleyi yazarken, SlateJS bize belgenin en son seçim durumunu verebilecek bir onSelectionChange
yöntemini göstermiyor. Ancak düzenleyicide seçim değiştikçe, belge içeriği değişmemiş olsa bile SlateJS onChange
yöntemini çağırır. Bunu, seçim değişikliğinden haberdar olmanın ve Editor
bileşeninin durumunda saklamanın bir yolu olarak kullanıyoruz. Bunu, seçim durumunun daha optimal bir güncellemesini yapabileceğimiz bir kanca useSelection
. Seçim, bir WYSIWYG Editor örneği için oldukça sık değişen bir özellik olduğundan bu önemlidir.
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]; }
Bu kancayı Editor
bileşeninin içinde aşağıdaki gibi kullanıyoruz ve seçimi Araç Çubuğu bileşenine geçiriyoruz.
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} /> ...
Performans Değerlendirmesi
Çok daha fazla işlevselliğe sahip çok daha büyük bir Düzenleyici kod tabanına sahip olduğumuz bir uygulamada, seçim değişikliklerini dinleyen bileşenlerin de işleme olasılığı yüksek olduğundan (bazı durum yönetimi kitaplıklarını kullanmak gibi) seçim değişikliklerini performanslı bir şekilde depolamak ve dinlemek önemlidir. sıklıkla. Bunu yapmanın bir yolu, belirli seçim bilgilerini tutan Seçim durumunun üstünde optimize edilmiş seçicilere sahip olmaktır. Örneğin, bir düzenleyici, bir Görüntü seçildiğinde bir görüntüyü yeniden boyutlandırma menüsü oluşturmak isteyebilir. Böyle bir durumda, düzenleyicinin seçim durumundan hesaplanan bir isImageSelected
seçicisine sahip olmak yararlı olabilir ve Görüntü menüsü yalnızca bu seçicinin değeri değiştiğinde yeniden oluşturulur. Redux's Reselect, bina seçicileri sağlayan böyle bir kütüphanedir.
selection
araç çubuğu içinde daha sonra kullanmıyoruz, ancak onu bir destek olarak aktarmak, Editör'de seçim her değiştiğinde araç çubuğunun yeniden oluşturulmasını sağlıyor. Bunu, hiyerarşide ( App -> Editor -> Toolbar
) yeniden oluşturmayı tetiklemek için yalnızca belge içeriği değişikliğine güvenemeyeceğimiz için yapıyoruz, çünkü kullanıcılar belgeyi tıklamaya devam ederek seçimi değiştirebilir, ancak belge içeriğini asla değiştirmeyebilir. kendisi.
Karakter Stillerini Değiştirme
Şimdi aktif karakter stillerini SlateJS'den almaya ve Editörün içindekileri kullanmaya geçiyoruz. Şimdi, SlateJS ile bir şeyler almak/yapmak için oluşturduğumuz tüm kullanım işlevlerini barındıracak yeni bir JS modülü EditorUtils
ekleyelim. Modüldeki ilk Set
, düzenleyicide bir dizi etkin stil veren getActiveStyles
. Ayrıca düzenleyici işlevinde bir stil arasında geçiş yapmak için bir işlev ekliyoruz - 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); } }
Yazının ilerleyen kısımlarında ekleyeceğimiz birçok util fonksiyonu gibi her iki fonksiyon da parametre olarak Slate örneği olan editor
nesnesini alır. ve bu işaretleri kaldırın. Bu util fonksiyonlarını Araç Çubuğuna aktarıyoruz ve daha önce eklediğimiz düğmelere bağlıyoruz.
# 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
, oluşturma hiyerarşisinde daha üstteki <Slate>
bileşeni tarafından eklendiği bağlamdan Slate örneğine erişmemizi sağlayan bir Slate kancasıdır.
Burada neden onClick
yerine onMouseDown
kullandığımız merak edilebilir. Düzenleyici herhangi bir şekilde odağı kaybettiğinde null
selection
nasıl sıfıra çevirdiği hakkında açık bir Github Sorunu var. Bu nedenle, araç çubuğu düğmelerimize onClick
işleyicileri eklersek, selection
null
olur ve kullanıcılar bir stil arasında geçiş yapmaya çalışırken imleç konumlarını kaybederler ki bu harika bir deneyim değildir. Bunun yerine, seçimin sıfırlanmasını önleyen bir onMouseDown
olayı ekleyerek stili değiştiririz. Bunu yapmanın başka bir yolu da seçimi kendimiz takip etmek ve böylece son seçimin ne olduğunu bilmek ve bunu stilleri değiştirmek için kullanmaktır. previousSelection
Seçim kavramını makalenin ilerleyen bölümlerinde tanıtıyoruz, ancak farklı bir sorunu çözmek için.
SlateJS, Editör'de olay işleyicilerini yapılandırmamıza izin verir. Bunu, karakter stillerini değiştirmek için klavye kısayollarını bağlamak için kullanıyoruz. Bunu yapmak için, Editable
bileşene eklenmiş bir onKeyDown
olay işleyicisini açığa useEditorConfig
içine bir KeyBindings
nesnesi ekleriz. Tuş kombinasyonunu belirlemek ve ilgili stili değiştirmek için is-hotkey
util'i kullanırız.
# 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} />
Paragraf Stili Açılır Liste Çalışması Yapma
Paragraf Stilleri açılır listesinin çalışmasını sağlamaya devam edelim. Paragraf stili açılır listelerin MS Word veya Google Docs gibi popüler Kelime İşleme uygulamalarında nasıl çalıştığına benzer şekilde, kullanıcı seçimindeki en üst düzey blokların stillerinin açılır menüde yansıtılmasını istiyoruz. Seçim boyunca tek bir tutarlı stil varsa, açılır değeri bu olacak şekilde güncelleriz. Bunlardan birden fazla varsa, açılır değeri 'Multiple' olarak ayarlıyoruz. Bu davranış, hem daraltılmış hem de genişletilmiş seçimler için çalışmalıdır.
Bu davranışı uygulamak için, kullanıcının seçimini kapsayan üst düzey blokları bulabilmemiz gerekir. Bunu yapmak için, Editor.nodes
- Farklı seçeneklere göre filtrelenmiş bir ağaçtaki düğümleri aramak için yaygın olarak kullanılan bir yardımcı işlev kullanıyoruz.
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>
Helper işlevi, bir Editor örneğini ve ağaçta geçerken düğümleri filtrelemenin bir yolu olan bir options
nesnesini alır. İşlev, bir NodeEntry
üreteci döndürür. Slate terminolojisindeki bir NodeEntry
, bir düğüm demeti ve ona giden yoldur — [node, pathToNode]
. Burada bulunan seçenekler, Slate yardımcı işlevlerinin çoğunda bulunur. Bunların her birinin ne anlama geldiğini inceleyelim:
-
at
Bu, yardımcı işlevin ağaç geçişini kapsamak için kullanacağı bir Yol/Nokta/Aralık olabilir. Bu, sağlanmadığı takdirde varsayılan olarakeditor.selection
. Ayrıca, kullanıcının seçimi içindeki düğümlerle ilgilendiğimiz için aşağıdaki kullanım durumumuz için varsayılanı kullanıyoruz. -
match
Bu, her düğümde çağrılan ve bir eşleşme ise dahil edilen, birinin sağlayabileceği bir eşleştirme işlevidir. Bu parametreyi aşağıdaki uygulamamızda yalnızca öğeleri bloke edecek şekilde filtrelemek için kullanırız. -
mode
Yardımcı işlevlerin, verilen konum eşleştirmematch
işlevindeki tüm, en üst düzey veyaat
düşük düzey düğümlerle ilgilenip ilgilenmediğimizi bilmesini sağlayalım. Bu parametre (highest
olarak ayarlanmıştır), en üst düzey düğümleri bulmak için ağacı kendimiz aşmaya çalışmaktan kurtulmamıza yardımcı olur. -
universal
Düğümlerin tam veya kısmi eşleşmeleri arasında seçim yapmak için işaretleyin. (Bu bayrak önerisiyle ilgili GitHub Sorunu, bunu açıklayan bazı örneklere sahiptir) -
reverse
Düğüm araması, geçilen konumun başlangıç ve bitiş noktalarının tersi yönünde olmalıdır. -
voids
Aramanın yalnızca öğeleri geçersiz kılmak için filtrelemesi gerekiyorsa.
SlateJS, düğümleri farklı şekillerde sorgulamanıza, ağaçta gezinmenize, düğümleri veya seçimleri karmaşık yollarla güncellemenize izin veren birçok yardımcı işlev sunar. Slate'in üzerine karmaşık düzenleme işlevleri oluştururken bu arayüzlerden bazılarını (bu makalenin sonunda listelenmiştir) incelemeye değer.
Yardımcı işlevdeki bu arka planla, aşağıda getTextBlockStyle
uygulamasının bir uygulamasıdır.
# 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; }
Performans Değerlendirmesi
Editor.nodes
şu anki uygulaması, at
parametresinin aralığında olan tüm seviyelerde ağaç boyunca tüm düğümleri bulur ve ardından bunun üzerinde eşleşme filtreleri çalıştırır ( nodeEntries
ve filtrelemeyi daha sonra kontrol edin - kaynak). Bu, daha küçük belgeler için uygundur. Ancak, bizim kullanım durumumuz için, kullanıcı 3 başlık ve 2 paragraf (her paragraf 10 metin düğümü içerir) seçmişse, en az 25 düğüm (3 + 2 + 2*10) arasında geçiş yapacak ve filtreleri çalıştırmayı deneyecektir. onlar üzerinde. Yalnızca üst düzey düğümlerle ilgilendiğimizi zaten bildiğimiz için, seçimden üst düzey blokların başlangıç ve bitiş dizinlerini bulabilir ve kendimizi yineleyebiliriz. Böyle bir mantık, yalnızca 3 düğüm girişi (2 başlık ve 1 paragraf) arasında döngü yapar. Bunun için kod aşağıdaki gibi görünecektir:
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; }
Bir WYSIWYG Düzenleyicisine daha fazla işlev eklediğimizden ve belge ağacında sık sık geçiş yapmamız gerektiğinden, mevcut API veya yardımcı yöntemler her zaman en iyisi olmayabileceğinden, eldeki kullanım durumu için bunu yapmanın en performanslı yollarını düşünmek önemlidir. Bunu yapmanın verimli yolu.
getTextBlockStyle
uyguladığımızda, blok stilini değiştirmek nispeten basittir. Mevcut stil, açılır menüde kullanıcının seçtiği stil değilse, stili buna değiştiririz. Zaten kullanıcının seçtiği şeyse, onu bir paragraf olarak değiştiririz. Belge yapımızdaki paragraf stillerini düğümler olarak temsil ettiğimiz için, bir paragraf stilini değiştirmek, esasen düğümdeki type
özelliğini değiştirmek anlamına gelir. Düğümlerdeki özellikleri güncellemek için Slate tarafından sağlanan Transforms.setNodes
kullanıyoruz.
toggleBlockType
uygulamamız aşağıdaki gibidir:
# 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) } ); }
Son olarak, bu yardımcı işlevleri kullanmak için Paragraf Stili açılır listemizi güncelliyoruz.
#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> .... );
BAĞLANTILAR
Bu bölümde linkleri göstermek, eklemek, kaldırmak ve değiştirmek için destek ekleyeceğiz. Ayrıca, Google Dokümanlar veya MS Word'ün kullanıcı tarafından yazılan metni tarama ve orada bağlantı olup olmadığını kontrol etme yöntemine oldukça benzer bir Bağlantı Algılayıcı işlevi ekleyeceğiz. Varsa, bağlantı nesnelerine dönüştürülürler, böylece kullanıcının bunu yapmak için araç çubuğu düğmelerini kullanması gerekmez.
Bağlantıları Oluşturma
Editörümüzde, bağlantıları SlateJS ile satır içi düğümler olarak uygulayacağız. Bağlantıları SlateJS için satır içi düğümler olarak işaretlemek için düzenleyici yapılandırmamızı güncelleriz ve ayrıca Slate'in bağlantı düğümlerini nasıl oluşturacağını bilmesi için oluşturulacak bir bileşen sağlarız.
# 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>
LinkEditor
düzenleyicinin dışında oluşturduğumuz için, LinkEditor
bağlantının DOM ağacında nerede olduğunu söylemenin bir yoluna ihtiyacımız var, böylece kendisini düzenleyicinin yakınında gösterebilsin. Bunu yapmanın yolu, seçimdeki bağlantı düğümüne karşılık gelen DOM düğümünü bulmak için Slate'in React API'sini kullanmaktır. Ardından, bağlantının DOM öğesinin sınırlarını ve düzenleyici bileşeninin sınırlarını bulmak için getBoundingClientRect()
'i kullanırız ve bağlantı düzenleyici için top
ve left
değerleri hesaplarız. Editor
ve LinkEditor
kod güncellemeleri aşağıdaki gibidir:
# 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, düğüm haritalarını kendi ilgili DOM öğelerine dahili olarak tutar. Bu haritaya erişiriz ve ReactEditor.toDOMNode
kullanarak bağlantının DOM öğesini buluruz.
Yukarıdaki videoda görüldüğü gibi, bir bağlantı eklendiğinde ve URL'si olmadığında, seçim bağlantının içinde olduğu için bağlantı düzenleyiciyi açar ve böylece kullanıcıya yeni eklenen bağlantı için bir URL yazması için bir yol sunar ve dolayısıyla oradaki kullanıcı deneyimindeki döngüyü kapatır.
Şimdi LinkEditor
, kullanıcının bir URL yazıp bunu bağlantı düğümüne uygulamasına izin veren bir girdi öğesi ve bir düğme ekliyoruz. URL doğrulaması için isUrl
paketini kullanıyoruz.
# 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> ... );
Form öğeleri bağlıyken, bağlantı düzenleyicinin beklendiği gibi çalışıp çalışmadığını görelim.
Burada videoda gördüğümüz gibi, kullanıcı girişe tıklamaya çalıştığında bağlantı düzenleyici kayboluyor. Bunun nedeni, bağlantı düzenleyiciyi Editable
bileşenin dışında oluşturduğumuzda, kullanıcı giriş öğesine tıkladığında, SlateJS düzenleyicinin odağı kaybettiğini düşünmesi ve isLinkActiveAtSelection
artık true
olmadığı için LinkEditor
kaldıran selection
null
olarak sıfırlamasıdır. Bu Slate davranışından bahseden açık bir GitHub Sorunu var. Bunu çözmenin bir yolu, bir kullanıcının önceki seçimini değiştikçe izlemek ve düzenleyici odağı kaybettiğinde, önceki seçime bakabilir ve önceki seçimde bir bağlantı varsa yine bir bağlantı düzenleyici menüsü gösterebiliriz. Önceki seçimi hatırlamak ve bunu Editor bileşenine döndürmek için useSelection
kancasını güncelleyelim.
# 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]; }
Ardından, önceki seçimde bir bağlantı olsa bile bağlantı menüsünü göstermek için Editor
bileşenindeki mantığı güncelleriz.
# 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={..} ... );
Daha sonra selectionForLink
, link düğümünü aramak, onun altında işlemek ve URL'sini güncellemek için LinkEditor
kullanacak şekilde güncelleriz.
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
Metindeki Bağlantıları Algılama
Kelime işlem uygulamalarının çoğu, metin içindeki bağlantıları tanımlar ve bağlantı nesnelerine dönüştürür. Oluşturmaya başlamadan önce bunun editörde nasıl çalıştığını görelim.
Bu davranışı etkinleştirmek için mantığın adımları şöyle olacaktır:
- Kullanıcı yazdıkça belge değiştikçe, kullanıcı tarafından eklenen son karakteri bulun. Bu karakter bir boşluksa, ondan önce gelmiş olabilecek bir kelime olması gerektiğini biliyoruz.
- Son karakter boşluksa, bunu ondan önce gelen kelimenin bitiş sınırı olarak işaretleriz. Daha sonra, o kelimenin nerede başladığını bulmak için metin düğümü içinde karakter karakter geriye doğru gidiyoruz. Bu geçiş sırasında, düğümün başlangıcının kenarını bir önceki düğüme geçmemeye dikkat etmeliyiz.
- Daha önce kelimenin başlangıç ve bitiş sınırlarını bulduktan sonra, kelimenin dizisini kontrol eder ve bunun bir URL olup olmadığına bakarız. Eğer öyleyse, onu bir bağlantı düğümüne dönüştürürüz.
Mantığımız, EditorUtils'te yaşayan ve onChange
in Editor
bileşeni içinde çağrılan EditorUtils
identifyLinksInTextIfAny
bir util işlevinde yaşıyor.
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
Adım 1'in mantığı uygulanmış olan identifyLinksInTextIfAny
:
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; }
Burada işleri kolaylaştıran iki SlateJS yardımcı işlevi vardır.
-
Editor.before
— Bize belirli bir konumdan önceki noktayı verir. Parametre olarakunit
alır, böylecelocation
geçmeden önce karakter/kelime/blok vb. sorabiliriz. -
Editor.string
— Dizeyi bir aralık içinde alır.
Örnek olarak, aşağıdaki şema, kullanıcı bir 'E' karakteri eklediğinde ve imleci ondan sonra oturduğunda bu değişkenlerin hangi değerlerinin olduğunu açıklar.
'ABCDE' metni belgedeki ilk paragrafın ilk metin düğümü olsaydı, puan değerlerimiz şöyle olurdu:
cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}
Son karakter bir boşluksa, nerede başladığını biliyoruz - startPointOfLastCharacter.
Başka bir boşluk veya metin düğümünün kendisinin başlangıcını bulana kadar karakter karakter geriye doğru hareket ettiğimiz 2. adıma geçelim.
... 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);
Burada, ABCDE
olarak girilen son kelimeyi bulduğumuzda, bu farklı noktaların nereye işaret ettiğini gösteren bir diyagram var.
start
ve end
, oradaki boşluktan önceki ve sonraki noktalar olduğuna dikkat edin. Benzer şekilde, startPointOfLastCharacter
ve cursorPoint
, kullanıcının henüz girdiği boşluktan önceki ve sonraki noktalardır. Bu nedenle [end,startPointOfLastCharacter]
bize eklenen son kelimeyi verir.
lastWord
değerini konsola kaydediyoruz ve yazarken değerleri doğruluyoruz.
Artık kullanıcının yazdığı son kelimenin ne olduğunu çıkardığımıza göre, bunun gerçekten bir URL olduğunu doğrular ve bu aralığı bir bağlantı nesnesine dönüştürürüz. Bu dönüştürme, araç çubuğu bağlantı düğmesinin bir kullanıcının seçili metnini bir bağlantıya nasıl dönüştürdüğüne benzer.
if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }
identifyLinksInTextIfAny
, onChange
içinde çağrılır, bu yüzden onChange
içindeki belge yapısını güncellemek istemeyiz. Bu nedenle, bu güncellemeyi bir Promise.resolve().then(..)
çağrısı ile görev sıramıza koyduk.
Mantığın eylemde bir araya geldiğini görelim! Bir metin düğümünün sonuna, ortasına veya başlangıcına bağlantılar ekleyip eklemediğimizi doğrularız.
Bununla, düzenleyicideki bağlantılar için işlevleri tamamladık ve Görüntüler'e geçtik.
Görüntüleri İşleme
Bu bölümde, görüntü düğümlerini oluşturmak, yeni görüntüler eklemek ve görüntü başlıklarını güncellemek için destek eklemeye odaklanıyoruz. Belge yapımızdaki görüntüler Void düğümleri olarak temsil edilecektir. SlateJS'deki Void düğümleri (HTML özelliğindeki Void öğelerine benzer), içerikleri düzenlenebilir metin olmayacak şekildedir. Bu, görüntüleri boşluk olarak oluşturmamızı sağlar. Slate'in oluşturma konusundaki esnekliği nedeniyle, yine de kendi düzenlenebilir öğelerimizi Void öğelerinin içinde oluşturabiliriz - ki bunu resim yazısı düzenleme için yapacağız. SlateJS, bir Zengin Metin Düzenleyicisinin tamamını bir Void öğesinin içine nasıl gömebileceğinizi gösteren bir örneğe sahiptir.
Görüntüleri işlemek için, düzenleyiciyi, görüntüleri Void öğeleri olarak ele alacak ve görüntülerin nasıl oluşturulması gerektiğine ilişkin bir oluşturma uygulaması sağlayacak şekilde yapılandırıyoruz. ExampleDocument'imize bir resim ekliyoruz ve resim yazısı ile doğru şekilde oluşturulduğunu doğrulıyoruz.
# 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> ); }
SlateJS ile boş düğümler oluşturmaya çalışırken hatırlanması gereken iki şey:
- Kök DOM öğesinin üzerinde
contentEditable={false}
ayarlanmış olmalıdır, böylece SlateJS içeriğine bu şekilde davranabilir. Bu olmadan, siz void öğesiyle etkileşime girerken, SlateJS seçimleri vb. hesaplamayı deneyebilir ve sonuç olarak kesintiye uğrayabilir. - Void düğümlerinin herhangi bir alt düğümü olmasa bile (örnek olarak görüntü düğümümüz gibi), yine de
children
oluşturmamız ve alt öğe olarak boş bir metin düğümü sağlamamız gerekir (aşağıdakiExampleDocument
bakın), bu Void öğesinin seçim noktası olarak değerlendirilir. SlateJS tarafından eleman
Şimdi bir resim eklemek için ExampleDocument
güncelliyoruz ve düzenleyicide resim yazısıyla birlikte göründüğünü doğrulıyoruz.
# 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: "" }], }, ];
Şimdi altyazı düzenlemeye odaklanalım. Bunun kullanıcı için sorunsuz bir deneyim olmasını istememizin yolu, alt yazıya tıkladıklarında, başlığı düzenleyebilecekleri bir metin girişi göstermemizdir. Girişin dışına tıklarlarsa veya GERİ DÖN tuşuna basarlarsa, bunu altyazıyı uygulamak için bir onay olarak değerlendiririz. Ardından görüntü düğümündeki başlığı günceller ve başlığı tekrar okuma moduna geçiririz. Eylemde görelim, böylece ne inşa ettiğimiz hakkında bir fikrimiz olsun.
Resim bileşenimizi, altyazının okuma-düzenleme modları için bir duruma sahip olacak şekilde güncelleyelim. Kullanıcı güncelledikçe yerel altyazı durumunu güncelleriz ve tıkladıklarında ( onBlur
) veya RETURN'e ( onKeyDown
) bastıklarında, başlığı düğüme uygular ve tekrar okuma moduna geçeriz.
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> ...
Bununla, altyazı düzenleme işlevi tamamlanır. Şimdi, kullanıcıların düzenleyiciye resim yüklemesi için bir yol eklemeye geçiyoruz. Kullanıcıların bir resim seçip yüklemelerini sağlayan bir araç çubuğu düğmesi ekleyelim.
# 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>
Resim yüklemeleriyle çalışırken, kod biraz büyüyebilir, bu nedenle resim yükleme işlemesini dosya giriş öğesine eklenmiş bir geri arama veren useImageUploadHandler
kancasına taşırız. Neden previousSelection
durumuna ihtiyaç duyduğunu kısaca tartışacağız.
useImageUploadHandler
uygulamadan önce, sunucuyu bir resim yükleyebilecek şekilde ayarlayacağız. Bir Ekspres sunucu kuruyoruz ve bizim için dosya yüklemelerini yöneten cors
ve multer
olmak üzere iki paket daha kuruyoruz.
yarn add express cors multer
Ardından, Express sunucusunu cors ve multer ile yapılandıran ve görüntüyü yükleyeceğimiz bir uç nokta /upload
ortaya çıkaran bir src/server.js
betiği ekliyoruz.
# 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}`));
Artık sunucu kurulumuna sahip olduğumuza göre, resim yüklemeyi ele almaya odaklanabiliriz. Kullanıcı bir resim yüklediğinde, resmin yüklenmesi birkaç saniye sürebilir ve bunun için bir URL'miz vardır. Ancak, resmin düzenleyiciye eklendiğini bilmeleri için kullanıcıya resim yükleme işleminin devam ettiğine dair anında geri bildirim vermek için ne yaparız. Bu davranışın işe yaraması için uyguladığımız adımlar şunlardır:
- Kullanıcı bir görüntü seçtiğinde, kullanıcının imleç konumuna
isUploading
bayrağı ayarlanmış bir görüntü düğümü ekleriz, böylece kullanıcıya bir yükleme durumu gösterebiliriz. - Görüntüyü yüklemek için isteği sunucuya göndeririz.
- İstek tamamlandığında ve bir resim URL'miz olduğunda, bunu resim üzerinde ayarlıyoruz ve yükleme durumunu kaldırıyoruz.
İmaj düğümünü eklediğimiz ilk adımla başlayalım. Şimdi, buradaki zor kısım, araç çubuğundaki bağlantı düğmesinde olduğu gibi seçim ile aynı sorunla karşılaşmamızdır. Kullanıcı araç çubuğundaki Görüntü düğmesine tıkladığında, düzenleyici odağı kaybeder ve seçim null
olur. Bir resim eklemeye çalışırsak, kullanıcının imlecinin nerede olduğunu bilemeyiz. previousSelection
Seçimi izlemek bize bu konumu verir ve bunu düğümü eklemek için kullanırız.
# 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] ); }
Yeni görüntü düğümünü eklerken, uuid paketini kullanarak ona bir tanımlayıcı id
de atarız. Adım (3)'ün uygulanmasında buna neden ihtiyacımız olduğunu tartışacağız. Şimdi, yükleme durumunu göstermek için isUploading
bayrağını kullanmak için görüntü bileşenini güncelliyoruz.
{!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}
Bu, 1. adımın uygulamasını tamamlar. Yüklenecek bir görüntü seçebildiğimizi doğrulayalım, görüntü düğümünün belgeye eklendiği yere bir yükleme göstergesiyle eklendiğini görün.
Adım (2)'ye geçerek, sunucuya bir istek göndermek için axois kitaplığını kullanacağız.
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 }); }, [...]); }
Görsel yüklemenin çalıştığını ve görselin uygulamanın public/photos
klasöründe göründüğünü doğrularız. Artık resim yüklemesi tamamlandığına göre, axios sözününsolve resolve()
işlevinde resimdeki URL'yi ayarlamak istediğimiz Adım (3)'e geçiyoruz. Görüntüyü Transforms.setNodes
ile güncelleyebilirdik ancak bir sorunumuz var — yeni eklenen görüntü düğümüne giden yolumuz yok. Bakalım o görüntüye ulaşmak için seçeneklerimiz neler -
- Seçimin yeni eklenen görüntü düğümünde olması gerektiği için
editor.selection
kullanamaz mıyız? Bunu garanti edemeyiz çünkü resim yüklenirken kullanıcı başka bir yere tıklamış ve seçim değişmiş olabilir. - İlk etapta görüntü düğümünü eklemek için kullandığımız
previousSelection
kullanmaya ne dersiniz? Aynı nedenlepreviousSelection
kullanamıyoruz,editor.selection
da değiştirmiş olabileceğinden kullanamıyoruz. - SlateJS, belgede meydana gelen tüm değişiklikleri izleyen bir Geçmiş modülüne sahiptir. Bu modülü geçmişi araştırmak ve en son eklenen görüntü düğümünü bulmak için kullanabiliriz. Bu ayrıca, görüntünün yüklenmesi daha uzun sürdüyse ve kullanıcı, ilk yükleme tamamlanmadan önce belgenin farklı bölümlerine daha fazla görüntü eklediyse, tamamen güvenilir değildir.
- Şu anda,
Transform.insertNodes
API'si eklenen düğümler hakkında herhangi bir bilgi döndürmez. Eklenen düğümlere giden yolları döndürebilirse, güncellememiz gereken kesin görüntü düğümünü bulmak için bunu kullanabiliriz.
Yukarıdaki yaklaşımların hiçbiri işe yaramadığından, eklenen görüntü düğümüne bir id
uygularız (Adım (1)'de) ve resim yüklemesi tamamlandığında onu bulmak için aynı id
tekrar kullanırız. Bununla, Adım (3) için kodumuz aşağıdaki gibi görünür -
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. });
Tüm üç adımın uygulanması tamamlandığında, resim yüklemeyi uçtan uca test etmeye hazırız.
Bununla, editörümüz için Görselleri tamamladık. Şu anda, görüntüden bağımsız olarak aynı boyutta bir yükleme durumu gösteriyoruz. Yükleme tamamlandığında yükleme durumu çok daha küçük veya daha büyük bir resimle değiştirilirse, bu kullanıcı için sarsıcı bir deneyim olabilir. Yükleme deneyiminin iyi bir takibi, geçişin sorunsuz olması için görüntü boyutlarını yüklemeden önce almak ve bu boyutta bir yer tutucu göstermektir. Yukarıda eklediğimiz kanca, video veya belgeler gibi diğer medya türlerini desteklemek ve bu tür düğümleri de işlemek için genişletilebilir.
Çözüm
Bu makalede, temel bir dizi işlevselliğe ve bağlantı algılama, yerinde bağlantı düzenleme ve resim yazısı düzenleme gibi bazı mikro kullanıcı deneyimlerine sahip bir WYSIWYG Düzenleyici oluşturduk ve bu da SlateJS ile daha derine inmemize yardımcı oldu. Genel. Zengin Metin Düzenleme veya Kelime İşleme'yi çevreleyen bu sorunlu alan ilginizi çekiyorsa, peşinden gidilecek harika sorunlardan bazıları şunlar olabilir:
- İşbirliği
- Metin hizalamalarını, satır içi görüntüleri, kopyala-yapıştır, yazı tipini ve metin renklerini değiştirmeyi vb. destekleyen daha zengin bir metin düzenleme deneyimi.
- Word belgeleri ve Markdown gibi popüler formatlardan içe aktarma.
Daha fazla SlateJS öğrenmek istiyorsanız, işte size yardımcı olabilecek bazı bağlantılar.
- SlateJS Örnekleri
Temel bilgilerin ötesine geçen ve genellikle Editörlerde bulunan Search & Highlight, Markdown Preview ve Mentions gibi işlevler oluşturan birçok örnek. - API Belgeleri
SlateJS nesneleri üzerinde karmaşık sorgular/dönüşümler gerçekleştirmeye çalışırken elinizin altında bulundurmak isteyebileceği SlateJS tarafından sunulan birçok yardımcı işleve referans.
Son olarak, SlateJS'nin Slack Kanalı, SlateJS kullanarak Zengin Metin Düzenleme uygulamaları oluşturan web geliştiricilerinden oluşan çok aktif bir topluluktur ve kitaplık hakkında daha fazla bilgi edinmek ve gerekirse yardım almak için harika bir yerdir.