Zengin Metin Düzenleyicisi Oluşturma (WYSIWYG)

Yayınlanan: 2022-03-10
Kısa özet ↬ Bu makalede, zengin metinleri, resimleri, bağlantıları ve kelime işlem uygulamalarından bazı nüanslı özellikleri destekleyen bir WYSIWYG/Zengin Metin Düzenleyicisinin nasıl oluşturulacağını öğreneceğiz. Editörün kabuğunu oluşturmak için SlateJS kullanacağız ve ardından bir araç çubuğu ve özel konfigürasyonlar ekleyeceğiz. Uygulamanın kodu referans için GitHub'da mevcuttur.

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

Atlamadan sonra daha fazlası! Aşağıdan okumaya devam edin ↓

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

Soldaki yapı gösterimi ile düzenleyici içinde örnek bir belgeyi gösteren resim
Örnek Belge ve yapısal gösterimi. (Büyük önizleme)

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çin caption )
  • 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 ve focus olarak adlandırılır). Bu kavram, Web'in Seçim API'sinden gelir; burada anchor , kullanıcının seçiminin başladığı ve focus 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:

Düzenleyicide "ipsum" metninin seçili olduğu resim
Kullanıcı ipsum kelimesini seçer. (Büyük önizleme)

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 create-react-app olacaktır. react-bootstrap bileşenlerini kullanarak uygulamanın kullanıcı arayüzünü oluşturuyoruz. Başlayalım!

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 bir div döndürür.
  • App.js bileşenini, belgeyi yukarıdaki ExampleDocument 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 Editor.js modülü aşağıdaki gibi görünür:

 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.

Temel Düzenleyici Kurulumu iş başında

Ş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 
Düzenleyicide oluşturulan farklı başlıkları ve paragraf düğümlerini gösteren resim
Editör'deki Başlıklar ve Paragraf düğümleri. (Büyük önizleme)

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 }, ], }, 
Kullanıcı arabirimindeki karakter stilleri ve bunların DOM ağacında nasıl oluşturuldukları
Kullanıcı arabirimindeki karakter stilleri ve bunların DOM ağacında nasıl oluşturuldukları. (Büyük önizleme)

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.

Düzenleyicinin üzerinde işlenen düğmelerle araç çubuğunu gösteren resim
Düğmeli araç çubuğu (Geniş önizleme)

Araç çubuğunun desteklemesi gereken üç temel işlev şunlardır:

  1. 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.
  2. 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.
  3. 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.

Karakter Stilleri arasında geçiş yapma davranışı

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 &lt;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} /> 
Klavye kısayolları kullanılarak değiştirilen karakter stilleri.

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 olarak editor.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ştirme match işlevindeki tüm, en üst düzey veya at 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> .... ); 
Birden fazla blok türü seçme ve açılır menüden türü değiştirme.

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 }, ], }, ... } 
Image showing Links rendered in the Editor and DOM tree of the editor
Links rendered in the Editor (Large preview)

Adding A Link Button To The Toolbar

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

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

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

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

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

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

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

Before and After node structures after a link is inserted
Before and After node structures after a link is inserted. (Büyük önizleme)

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

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

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

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

Link Editor Menu

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

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

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.

Bir bağlantı içindeki seçim, bağlantı düzenleyici açılır penceresini gösterir.

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.

Düzenleyici, bağlantı düzenleyicinin içine tıklandığında seçimi kaybediyor

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", }); ... 
LinkEditor bileşenini kullanarak bağlantıyı düzenleme.

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.

Kullanıcı bunları yazarken bağlantılar algılanıyor.

Bu davranışı etkinleştirmek için mantığın adımları şöyle olacaktır:

  1. 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.
  2. 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.
  3. 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 olarak unit alır, böylece location 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.

Bir örnekle imleçPoint ve startPointOfLastCharacter'ın 1. adımdan sonra nereye işaret ettiğini açıklayan diyagram
cursorPoint ve startPointOfLastCharacter Adım 1'den sonra örnek bir metinle. (Büyük önizleme)

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

Bir örnekle bağlantı algılamanın 2. adımından sonra farklı noktaların nerede olduğunu açıklayan diyagram
Bir örnekle bağlantı tespitinin 2. adımından sonra farklı noktaların olduğu yerler. (Büyük önizleme)

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.

Konsol, Adım 2'deki mantıktan sonra kullanıcı tarafından girilen son sözcüğü doğrulayan günlükleri.

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.

Kullanıcı bunları yazarken bağlantılar algılanıyor.

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ğıdaki ExampleDocument 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: "" }], }, ]; 
Editör'de işlenen resim
Editör'de işlenen resim. (Büyük önizleme)

Ş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 Yazısı Düzenleme iş başında.

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:

  1. 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.
  2. Görüntüyü yüklemek için isteği sunucuya göndeririz.
  3. İ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.

Yükleme durumuyla bir görüntü düğümü oluşturarak görüntü yükleme.

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ı nedenle previousSelection 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.

Uçtan uca çalışan resim yükleme

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.