การสร้างตัวแก้ไข Rich Text (WYSIWYG)
เผยแพร่แล้ว: 2022-03-10ในช่วงไม่กี่ปีที่ผ่านมา วงการการสร้างและการนำเสนอเนื้อหาบนแพลตฟอร์มดิจิทัลประสบปัญหาการหยุดชะงักครั้งใหญ่ ความสำเร็จอย่างกว้างขวางของผลิตภัณฑ์ เช่น Quip, Google Docs และ Dropbox Paper ได้แสดงให้เห็นว่าบริษัทต่างๆ ต่างแข่งขันกันเพื่อสร้างประสบการณ์ที่ดีที่สุดสำหรับผู้สร้างเนื้อหาในโดเมนองค์กร และพยายามหาวิธีใหม่ๆ ในการทำลายรูปแบบเดิมๆ ของการแชร์และบริโภคเนื้อหา การใช้ประโยชน์จากการเข้าถึงแพลตฟอร์มโซเชียลมีเดียจำนวนมาก ทำให้มีผู้สร้างเนื้อหาอิสระคลื่นลูกใหม่ที่ใช้แพลตฟอร์มอย่าง Medium เพื่อสร้างเนื้อหาและแชร์กับผู้ชมของพวกเขา
เนื่องจากผู้คนจำนวนมากจากหลากหลายอาชีพและภูมิหลังพยายามสร้างเนื้อหาในผลิตภัณฑ์เหล่านี้ จึงเป็นสิ่งสำคัญที่ผลิตภัณฑ์เหล่านี้จะต้องมอบประสบการณ์ที่มีประสิทธิภาพและราบรื่นในการสร้างเนื้อหา และมีทีมนักออกแบบและวิศวกรที่พัฒนาความเชี่ยวชาญด้านโดเมนในระดับหนึ่งตลอดเวลาในพื้นที่นี้ . ในบทความนี้ เราพยายามที่จะไม่เพียงแค่วางรากฐานของการสร้างตัวแก้ไขเท่านั้น แต่ยังให้ผู้อ่านได้ทราบว่าฟังก์ชันการทำงานเพียงเล็กน้อยเมื่อนำมารวมกันสามารถสร้างประสบการณ์การใช้งานที่ยอดเยี่ยมให้กับผู้สร้างเนื้อหาได้อย่างไร
ทำความเข้าใจโครงสร้างเอกสาร
ก่อนที่เราจะลงลึกในการสร้างตัวแก้ไข มาดูว่าเอกสารมีโครงสร้างสำหรับ Rich Text Editor อย่างไร และโครงสร้างข้อมูลประเภทต่างๆ ที่เกี่ยวข้องมีอะไรบ้าง
โหนดเอกสาร
โหนดเอกสารใช้เพื่อแสดงเนื้อหาของเอกสาร ประเภททั่วไปของโหนดที่เอกสาร Rich-Text อาจมี ได้แก่ ย่อหน้า หัวเรื่อง รูปภาพ วิดีโอ โค้ดบล็อก และพูลอัญประกาศ บางส่วนอาจมีโหนดอื่นเป็นโหนดย่อย (เช่น โหนดย่อหน้ามีโหนดข้อความอยู่ภายใน) โหนดยังมีคุณสมบัติใดๆ เฉพาะสำหรับอ็อบเจ็กต์ที่พวกเขาเป็นตัวแทนซึ่งจำเป็นต่อการแสดงผลโหนดเหล่านั้นภายในเอดิเตอร์ (เช่น Image nodes มีคุณสมบัติ src
ของรูปภาพ, Code-blocks อาจมีคุณสมบัติของ language
เป็นต้น)
โหนดส่วนใหญ่มีสองประเภทที่แสดงถึงวิธีการแสดงผล -
- Block Nodes (คล้ายกับแนวคิด HTML ขององค์ประกอบระดับบล็อก) ที่แสดงแต่ละรายการในบรรทัดใหม่และใช้ความกว้างที่มีอยู่ โหนดที่ถูกบล็อกอาจมีโหนดบล็อกอื่นหรือโหนดแบบอินไลน์อยู่ภายใน ข้อสังเกตที่นี่คือโหนดระดับบนสุดของเอกสารจะเป็นโหนดบล็อกเสมอ
- โหนดอินไลน์ (คล้ายกับแนวคิด HTML ขององค์ประกอบอินไลน์) ที่เริ่มแสดงผลบนบรรทัดเดียวกันกับโหนดก่อนหน้า มีความแตกต่างบางประการในการแสดงองค์ประกอบแบบอินไลน์ในไลบรารีการแก้ไขต่างๆ SlateJS อนุญาตให้องค์ประกอบอินไลน์เป็นโหนดเอง DraftJS ซึ่งเป็นไลบรารีแก้ไข Rich Text ยอดนิยมอีกรายการหนึ่ง ให้คุณใช้แนวคิดของเอนทิตีเพื่อแสดงองค์ประกอบแบบอินไลน์ ลิงก์และรูปภาพแบบอินไลน์เป็นตัวอย่างของโหนดแบบอินไลน์
- โหนดเป็นโมฆะ — SlateJS ยังอนุญาตโหนดประเภทที่สามนี้ที่เราจะใช้ในภายหลังในบทความนี้เพื่อแสดงผลสื่อ
หากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับหมวดหมู่เหล่านี้ เอกสารประกอบของ SlateJS เกี่ยวกับโหนดเป็นจุดเริ่มต้นที่ดี
คุณลักษณะ
คล้ายกับแนวคิดเกี่ยวกับแอตทริบิวต์ของ HTML แอตทริบิวต์ในเอกสาร Rich Text จะใช้เพื่อแสดงคุณสมบัติที่ไม่ใช่เนื้อหาของโหนดหรือโหนดย่อย ตัวอย่างเช่น โหนดข้อความสามารถมีคุณลักษณะลักษณะอักขระที่บอกเราว่าข้อความเป็นตัวหนา/ตัวเอียง/ขีดเส้นใต้และอื่นๆ แม้ว่าบทความนี้จะแสดงส่วนหัวเป็นโหนดเอง แต่อีกวิธีหนึ่งในการแสดงส่วนหัวเหล่านี้อาจเป็นได้ว่าโหนดมีลักษณะย่อหน้า ( paragraph
& h1-h6
) เป็นแอตทริบิวต์
รูปภาพด้านล่างแสดงตัวอย่างการอธิบายโครงสร้างของเอกสาร (ใน JSON) ในระดับที่ละเอียดยิ่งขึ้นโดยใช้โหนดและแอตทริบิวต์ที่เน้นองค์ประกอบบางอย่างในโครงสร้างทางด้านซ้าย
บางสิ่งที่ควรค่าแก่การเรียกที่นี่ด้วยโครงสร้างคือ:
- โหนดข้อความแสดงเป็น
{text: 'text content'}
- คุณสมบัติของโหนดถูกเก็บไว้โดยตรงบนโหนด (เช่น
url
สำหรับลิงค์และcaption
ภาพ) - การแสดงแอตทริบิวต์ข้อความเฉพาะของ SlateJS จะแบ่งโหนดข้อความให้เป็นโหนดของตนเอง หากลักษณะอักขระเปลี่ยนไป ดังนั้น ข้อความ ' Duis aute irure dolor ' จึงเป็นโหนดข้อความที่มี
bold: true
ไว้ เช่นเดียวกับข้อความลักษณะตัวเอียง ขีดเส้นใต้ และโค้ดในเอกสารนี้
สถานที่และการคัดเลือก
เมื่อสร้าง Rich Text Editor จำเป็นต้องมีความเข้าใจว่าส่วนที่ละเอียดที่สุดของเอกสาร (เช่น อักขระ) สามารถแสดงด้วยพิกัดบางประเภทได้อย่างไร ซึ่งช่วยให้เรานำทางโครงสร้างเอกสารที่รันไทม์เพื่อให้เข้าใจว่าเราอยู่ที่ใดในลำดับชั้นของเอกสาร สิ่งสำคัญที่สุดคือ ออบเจ็กต์ตำแหน่งช่วยให้เราสามารถแสดงการเลือกผู้ใช้ ซึ่งค่อนข้างจะใช้อย่างกว้างขวางเพื่อปรับแต่งประสบการณ์ผู้ใช้ของตัวแก้ไขแบบเรียลไทม์ เราจะใช้การเลือกเพื่อสร้างแถบเครื่องมือของเราในบทความนี้ ตัวอย่างของสิ่งเหล่านี้อาจเป็น:
- ขณะนี้เคอร์เซอร์ของผู้ใช้อยู่ในลิงก์หรือไม่ บางทีเราควรแสดงเมนูเพื่อแก้ไข/ลบลิงก์ให้ผู้ใช้เห็น
- ผู้ใช้เลือกรูปภาพหรือไม่? บางทีเราอาจให้เมนูเพื่อปรับขนาดภาพให้พวกเขา
- หากผู้ใช้เลือกข้อความบางข้อความและกดปุ่ม DELETE เราจะกำหนดว่าข้อความที่ผู้ใช้เลือกคือข้อความใดและลบข้อความนั้นออกจากเอกสาร
เอกสารของ SlateJS เกี่ยวกับ Location อธิบายโครงสร้างข้อมูลเหล่านี้อย่างละเอียด แต่เราจะอธิบายที่นี่อย่างรวดเร็ว เนื่องจากเราใช้คำศัพท์เหล่านี้กับอินสแตนซ์ต่างๆ ในบทความและแสดงตัวอย่างในไดอะแกรมที่ตามมา
- เส้นทาง
แสดงโดยอาร์เรย์ของตัวเลข เส้นทางคือวิธีไปยังโหนดในเอกสาร ตัวอย่างเช่น เส้นทาง[2,3]
แทนโหนดลูกที่ 3 ของโหนดที่ 2 ในเอกสาร - จุด
ตำแหน่งที่ละเอียดยิ่งขึ้นของเนื้อหาที่แสดงโดยเส้นทาง + ออฟเซ็ต ตัวอย่างเช่น จุดของ{path: [2,3], offset: 14}
แทนอักขระตัวที่ 14 ของโหนดย่อยที่ 3 ภายในโหนดที่ 2 ของเอกสาร - พิสัย
จุดคู่ (เรียกว่าanchor
andfocus
) ที่แสดงช่วงของข้อความภายในเอกสาร แนวคิดนี้มาจาก API การเลือกของเว็บ โดยที่anchor
คือจุดเริ่มต้นของการเลือกของผู้ใช้และfocus
อยู่ที่จุดสิ้นสุด ช่วง/การเลือกที่ยุบแสดงว่าจุดยึดและจุดโฟกัสเหมือนกัน (เช่น ให้นึกถึงเคอร์เซอร์กะพริบในการป้อนข้อความ เป็นต้น)
ตัวอย่างเช่น สมมติว่าการเลือกของผู้ใช้ในตัวอย่างเอกสารด้านบนของเราคือ ipsum
:
การเลือกของผู้ใช้สามารถแสดงเป็น:
{ 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' }`
การตั้งค่าตัวแก้ไข
ในส่วนนี้ เราจะตั้งค่าแอปพลิเคชันและรับตัวแก้ไขข้อความแบบพื้นฐานที่ใช้ SlateJS แอปพลิเคชันสำเร็จรูปจะเป็น
โดยเพิ่มการพึ่งพา SlateJS เรากำลังสร้าง UI ของแอปพลิเคชันโดยใช้ส่วนประกอบจาก create-react-app
มาเริ่มกันเลย!react-bootstrap
สร้างโฟลเดอร์ชื่อ wysiwyg-editor
และเรียกใช้คำสั่งด้านล่างจากภายในไดเร็กทอรีเพื่อตั้งค่าแอปตอบโต้ จากนั้นเราเรียกใช้คำสั่ง yarn start
ที่ควรหมุนเว็บเซิร์ฟเวอร์ในพื้นที่ (พอร์ตเริ่มต้นที่ 3000) และแสดงหน้าจอต้อนรับของ React ให้คุณเห็น
npx create-react-app . yarn start
จากนั้นเราไปต่อเพื่อเพิ่มการพึ่งพา SlateJS ให้กับแอปพลิเคชัน
yarn add slate slate-react
slate
เป็นแพ็คเกจหลักของ SlateJS และ slate-react
รวมชุดของส่วนประกอบ React ที่เราจะใช้เพื่อแสดงตัวแก้ไข Slate SlateJS เปิดเผยแพ็คเกจเพิ่มเติมบางส่วนที่จัดตามฟังก์ชันที่อาจพิจารณาเพิ่มลงในตัวแก้ไข
ก่อนอื่นเราสร้างโฟลเดอร์ utils
ที่มีโมดูลยูทิลิตี้ที่เราสร้างขึ้นในแอปพลิเคชันนี้ เราเริ่มต้นด้วยการสร้าง ExampleDocument.js
ที่ส่งคืนโครงสร้างเอกสารพื้นฐานที่มีย่อหน้าพร้อมข้อความบางส่วน โมดูลนี้มีลักษณะดังนี้:
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
ตอนนี้เราเพิ่มโฟลเดอร์ที่เรียกว่า components
ที่จะเก็บส่วนประกอบ React ทั้งหมดของเราและทำดังต่อไปนี้:
- เพิ่ม React component
Editor.js
แรกของเราเข้าไป ตอนนี้มันคืนค่าdiv
เท่านั้น - อัปเดตองค์ประกอบ
App.js
เพื่อให้เอกสารอยู่ในสถานะซึ่งเริ่มต้นจากExampleDocument
ของเราด้านบน - แสดงตัวแก้ไขภายในแอพและส่งสถานะเอกสารและตัวจัดการ
onChange
ไปยังตัวแก้ไข เพื่อให้สถานะเอกสารของเราได้รับการอัปเดตเมื่อผู้ใช้อัปเดต - เราใช้ส่วนประกอบ Nav ของ React bootstrap เพื่อเพิ่มแถบนำทางให้กับแอปพลิเคชันเช่นกัน
ตอนนี้องค์ประกอบ App.js
มีลักษณะดังนี้:
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> </> );
ภายในองค์ประกอบตัวแก้ไข จากนั้นเราจะสร้างอินสแตนซ์ตัวแก้ไข SlateJS และเก็บไว้ใน useMemo
เพื่อไม่ให้วัตถุเปลี่ยนแปลงระหว่างการเรนเดอร์ซ้ำ
// dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);
createEditor
ให้อินสแตนซ์ตัว editor
SlateJS แก่เรา ซึ่งเราใช้อย่างกว้างขวางผ่านแอปพลิเคชันเพื่อเข้าถึงการเลือก เรียกใช้การแปลงข้อมูล และอื่นๆ withReact เป็นปลั๊กอิน SlateJS ที่เพิ่มพฤติกรรม React และ DOM ให้กับวัตถุตัวแก้ไข ปลั๊กอิน SlateJS เป็นฟังก์ชัน Javascript ที่ได้รับวัตถุ editor
และแนบการกำหนดค่าบางอย่างไป ซึ่งช่วยให้นักพัฒนาเว็บเพิ่มการกำหนดค่าให้กับอินสแตนซ์ตัวแก้ไข SlateJS ของตนได้ในรูปแบบที่คอมไพล์ได้
ตอนนี้เรานำเข้าและแสดงส่วนประกอบ <Slate />
และ <Editable />
จาก SlateJS ด้วยเอกสารประกอบที่เราได้รับจาก App.js Slate
เปิดเผยบริบท React จำนวนมากที่เราใช้เพื่อเข้าถึงในรหัสแอปพลิเคชัน Editable
เป็นองค์ประกอบที่แสดงลำดับชั้นของเอกสารสำหรับการแก้ไข โดยรวมแล้ว โมดูล
ในขั้นตอนนี้มีลักษณะดังนี้: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> ); }
ณ จุดนี้ เราได้เพิ่มส่วนประกอบ React ที่จำเป็นและตัวแก้ไขจะเติมด้วยเอกสารตัวอย่าง ตัวแก้ไขของเราควรได้รับการตั้งค่าเพื่อให้เราสามารถพิมพ์และเปลี่ยนแปลงเนื้อหาได้แบบเรียลไทม์ เช่นเดียวกับใน screencast ด้านล่าง
ตอนนี้ ไปที่ส่วนถัดไปที่เรากำหนดค่าตัวแก้ไขเพื่อแสดงลักษณะอักขระและโหนดย่อหน้า
การแสดงข้อความที่กำหนดเองและแถบเครื่องมือ
โหนดลักษณะย่อหน้า
ในปัจจุบัน โปรแกรมแก้ไขของเราใช้การเรนเดอร์เริ่มต้นของ SlateJS สำหรับประเภทโหนดใหม่ที่เราอาจเพิ่มลงในเอกสาร ในส่วนนี้ เราต้องการแสดงโหนดส่วนหัว เพื่อให้สามารถทำเช่นนั้นได้ เราได้จัดเตรียมฟังก์ชัน renderElement
ให้กับส่วนประกอบของ Slate ฟังก์ชันนี้ถูกเรียกใช้โดย Slate เมื่อรันไทม์เมื่อพยายามสำรวจโครงสร้างเอกสารและแสดงผลแต่ละโหนด ฟังก์ชัน renderElement ได้รับสามพารามิเตอร์ —
-
attributes
SlateJS เฉพาะที่ต้องใช้กับองค์ประกอบ DOM ระดับบนสุดที่ส่งคืนจากฟังก์ชันนี้ -
element
โหนดวัตถุตามที่มีอยู่ในโครงสร้างเอกสาร -
children
ลูกของโหนดนี้ตามที่กำหนดไว้ในโครงสร้างเอกสาร
เราเพิ่มการนำ renderElement
ไปใช้กับ hook ที่เรียกว่า useEditorConfig
ซึ่งเราจะเพิ่มการกำหนดค่าตัวแก้ไขเพิ่มเติมเมื่อเราไป จากนั้นเราใช้ hook บนอินสแตนซ์ตัวแก้ไขภายใน Editor.js
import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }
เนื่องจากฟังก์ชันนี้ทำให้เราเข้าถึง element
(ซึ่งเป็นตัวโหนดเอง) เราจึงสามารถปรับแต่ง renderElement
เพื่อใช้งานการเรนเดอร์ที่กำหนดเองมากขึ้นซึ่งทำมากกว่าการตรวจสอบ element.type
ตัวอย่างเช่น คุณอาจมีโหนดรูปภาพที่มีคุณสมบัติ isInline
ที่เราสามารถใช้เพื่อส่งคืนโครงสร้าง DOM อื่นที่ช่วยให้เราแสดงรูปภาพแบบอินไลน์เทียบกับรูปภาพที่ถูกบล็อก
ตอนนี้เราอัปเดตองค์ประกอบตัวแก้ไขเพื่อใช้ hook นี้ดังนี้:
const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );
ด้วยการแสดงผลแบบกำหนดเอง เราได้อัปเดต ExampleDocument เพื่อรวมประเภทโหนดใหม่ของเราและตรวจสอบว่าแสดงผลอย่างถูกต้องภายในตัวแก้ไข
const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes
ลักษณะตัวละคร
คล้ายกับ renderElement
นั้น SlateJS ให้ฟังก์ชั่น prop ที่เรียกว่า renderLeaf ที่สามารถใช้เพื่อปรับแต่งการแสดงผลของโหนดข้อความ ( Leaf
หมายถึงโหนดข้อความซึ่งเป็นโหนดระดับใบไม้ / ต่ำสุดของแผนผังเอกสาร) ตามตัวอย่างของ renderElement
เราเขียนการใช้งานสำหรับ 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>; }
ข้อสังเกตที่สำคัญของการใช้งานข้างต้นคือช่วยให้เราเคารพความหมาย HTML สำหรับรูปแบบอักขระ เนื่องจาก renderLeaf ทำให้เราสามารถเข้าถึง text node leaf
ได้ เราสามารถปรับแต่งฟังก์ชันเพื่อใช้การเรนเดอร์ที่ปรับแต่งเองได้มากขึ้น ตัวอย่างเช่น คุณอาจมีวิธีให้ผู้ใช้เลือก highlightColor
สำหรับข้อความ และตรวจสอบคุณสมบัติของ leaf ที่นี่เพื่อแนบสไตล์ที่เกี่ยวข้อง
ตอนนี้เราอัปเดตองค์ประกอบ Editor เพื่อใช้ด้านบน ExampleDocument
เพื่อให้มีโหนดข้อความสองสามโหนดในย่อหน้าที่มีการผสมผสานของสไตล์เหล่านี้ และตรวจสอบว่ามีการแสดงผลตามที่คาดไว้ใน Editor ด้วยแท็กความหมายที่เราใช้
# 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 }, ], },
การเพิ่มแถบเครื่องมือ
เริ่มต้นด้วยการเพิ่ม Toolbar.js
คอมโพเนนต์ใหม่ ซึ่งเราเพิ่มปุ่มสองสามปุ่มสำหรับลักษณะอักขระและดรอปดาวน์สำหรับลักษณะย่อหน้า และเราจะเชื่อมโยงสิ่งเหล่านี้ขึ้นในภายหลังในหัวข้อ
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> ); }
เราแยกปุ่มออกเป็นองค์ประกอบ ToolbarButton
ที่เป็นเสื้อคลุมรอบส่วนประกอบปุ่ม React Bootstrap จากนั้น เราแสดงแถบเครื่องมือเหนือองค์ประกอบที่ Editable
ภายใน Editor
และตรวจสอบว่าแถบเครื่องมือปรากฏขึ้นในแอปพลิเคชัน
ต่อไปนี้คือฟังก์ชันหลักสามประการที่เราต้องการให้แถบเครื่องมือรองรับ:
- เมื่อเคอร์เซอร์ของผู้ใช้อยู่ในจุดใดจุดหนึ่งในเอกสาร และพวกเขาคลิกปุ่มลักษณะอักขระปุ่มใดปุ่มหนึ่ง เราจำเป็นต้องสลับรูปแบบสำหรับข้อความที่ผู้ใช้อาจพิมพ์ต่อไป
- เมื่อผู้ใช้เลือกช่วงของข้อความและคลิกปุ่มลักษณะอักขระปุ่มใดปุ่มหนึ่ง เราจำเป็นต้องสลับรูปแบบสำหรับส่วนนั้นๆ
- เมื่อผู้ใช้เลือกช่วงของข้อความ เราต้องการอัปเดตดรอปดาวน์ลักษณะย่อหน้าเพื่อแสดงประเภทย่อหน้าของการเลือก หากพวกเขาเลือกค่าอื่นจากการเลือก เราต้องการอัปเดตลักษณะย่อหน้าของการเลือกทั้งหมดให้เป็นสิ่งที่พวกเขาเลือก
มาดูกันว่าฟังก์ชันเหล่านี้ทำงานอย่างไรใน Editor ก่อนที่เราจะเริ่มใช้งาน
ฟังการคัดเลือก
สิ่งที่สำคัญที่สุดที่ Toolbar จำเป็นต้องสามารถทำหน้าที่ด้านบนได้คือสถานะการเลือกของเอกสาร ในการเขียนบทความนี้ SlateJS จะไม่เปิดเผยเมธอด onSelectionChange
ที่อาจให้สถานะการเลือกล่าสุดของเอกสารแก่เรา อย่างไรก็ตาม เมื่อการเลือกเปลี่ยนแปลงในตัวแก้ไข SlateJS จะเรียกใช้เมธอด onChange
แม้ว่าเนื้อหาในเอกสารจะไม่เปลี่ยนแปลงก็ตาม เราใช้วิธีนี้เป็นวิธีรับการแจ้งเตือนการเปลี่ยนแปลงการเลือกและเก็บไว้ในสถานะขององค์ประกอบ Editor
เราสรุปสิ่งนี้เป็น hook useSelection
ซึ่งเราสามารถอัปเดตสถานะการเลือกได้อย่างเหมาะสมที่สุด นี่เป็นสิ่งสำคัญเนื่องจากการเลือกเป็นคุณสมบัติที่เปลี่ยนแปลงค่อนข้างบ่อยสำหรับอินสแตนซ์ WYSIWYG Editor
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]; }
เราใช้ hook นี้ภายในองค์ประกอบ Editor
ดังด้านล่าง และส่งการเลือกไปยังองค์ประกอบ Toolbar
const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...
การพิจารณาประสิทธิภาพ
ในแอปพลิเคชันที่เรามีฐานโค้ดตัวแก้ไขที่ใหญ่กว่ามากพร้อมฟังก์ชันการทำงานที่มากขึ้น การจัดเก็บและรับฟังการเปลี่ยนแปลงการเลือกอย่างมีประสิทธิภาพเป็นสิ่งสำคัญ (เช่น การใช้ไลบรารีการจัดการสถานะบางส่วน) เนื่องจากส่วนประกอบที่รับฟังการเปลี่ยนแปลงการเลือกก็มีแนวโน้มที่จะแสดงผลเช่นกัน บ่อยครั้ง. วิธีหนึ่งในการทำเช่นนี้คือการมีตัวเลือกที่ปรับให้เหมาะสมที่ด้านบนของสถานะการเลือกที่มีข้อมูลการเลือกเฉพาะ ตัวอย่างเช่น ผู้แก้ไขอาจต้องการแสดงเมนูการปรับขนาดรูปภาพเมื่อเลือกรูปภาพ ในกรณีเช่นนี้ อาจเป็นประโยชน์ที่จะให้ตัวเลือก isImageSelected
คำนวณจากสถานะการเลือกของเอดิเตอร์ และเมนูอิมเมจจะแสดงผลอีกครั้งเมื่อค่าของตัวเลือกนี้เปลี่ยนแปลงเท่านั้น Reselect ของ Redux เป็นหนึ่งในห้องสมุดที่เปิดใช้งานตัวเลือกอาคาร
เราไม่ใช้ส่วนที่ selection
ภายในแถบเครื่องมือจนกว่าจะถึงภายหลัง แต่ส่งผ่านลงไปในขณะที่อุปกรณ์ประกอบฉากทำให้แถบเครื่องมือแสดงผลใหม่ทุกครั้งที่การเลือกเปลี่ยนแปลงในตัวแก้ไข เราทำสิ่งนี้เพราะเราไม่สามารถพึ่งพาการเปลี่ยนแปลงเนื้อหาเอกสารเพียงอย่างเดียวเพื่อทริกเกอร์การแสดงลำดับชั้นอีกครั้ง ( App -> Editor -> Toolbar
) เนื่องจากผู้ใช้อาจคลิกไปรอบๆ เอกสารต่อไป ดังนั้นจึงเปลี่ยนการเลือกแต่ไม่เคยเปลี่ยนเนื้อหาเอกสารจริงๆ ตัวเอง.
สลับลักษณะตัวละคร
ตอนนี้เราย้ายไปรับลักษณะตัวละครที่ใช้งานอยู่จาก SlateJS และใช้รูปแบบดังกล่าวใน Editor มาเพิ่มโมดูล JS ใหม่ EditorUtils
ที่จะโฮสต์ฟังก์ชั่น util ทั้งหมดที่เราสร้างขึ้นในอนาคตเพื่อรับ/ทำสิ่งต่าง ๆ ด้วย SlateJS ฟังก์ชันแรกของเราในโมดูลคือ getActiveStyles
ที่ให้ Set
ของสไตล์ที่ใช้งานในตัวแก้ไข นอกจากนี้เรายังเพิ่มฟังก์ชันเพื่อสลับรูปแบบในฟังก์ชันตัวแก้ไข — 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); } }
ฟังก์ชันทั้งสองใช้อ็อบเจกต์ตัว editor
ซึ่งเป็นอินสแตนซ์ Slate เป็นพารามิเตอร์ เช่นเดียวกับฟังก์ชัน util ที่เราเพิ่มในภายหลังในบทความ ในคำศัพท์ Slate รูปแบบการจัดรูปแบบเรียกว่า Marks และเราใช้วิธีตัวช่วยบนอินเทอร์เฟซ Editor เพื่อรับ เพิ่ม และลบเครื่องหมายเหล่านี้ เรานำเข้าฟังก์ชัน util เหล่านี้ภายใน Toolbar และเชื่อมต่อกับปุ่มที่เราเพิ่มไว้ก่อนหน้านี้
# 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
คือ Slate hook ที่ช่วยให้เราเข้าถึงอินสแตนซ์ Slate จากบริบทที่คอมโพเนนต์ <Slate>
แนบอยู่สูงขึ้นไปในลำดับชั้นการแสดงผล
บางคนอาจสงสัยว่าทำไมเราใช้ onMouseDown
ที่นี่แทน onClick
? มีปัญหา Github แบบเปิดเกี่ยวกับวิธีที่ Slate เปลี่ยนการ selection
ให้เป็น null
เมื่อตัวแก้ไขสูญเสียโฟกัส แต่อย่างใด ดังนั้น หากเราแนบตัวจัดการ onClick
กับปุ่มบนแถบเครื่องมือ การ selection
จะกลายเป็น null
และผู้ใช้สูญเสียตำแหน่งเคอร์เซอร์เมื่อพยายามสลับรูปแบบซึ่งไม่ใช่ประสบการณ์ที่ยอดเยี่ยม เราสลับรูปแบบโดยแนบเหตุการณ์ onMouseDown
ซึ่งป้องกันไม่ให้การเลือกถูกรีเซ็ต อีกวิธีในการทำเช่นนี้คือการติดตามการเลือกด้วยตนเอง เพื่อให้เรารู้ว่าการเลือกล่าสุดคืออะไร และใช้เพื่อสลับสไตล์ เราจะแนะนำแนวคิดของ previousSelection
ในภายหลังในบทความ แต่จะแก้ปัญหาอื่น
SlateJS ช่วยให้เราสามารถกำหนดค่าตัวจัดการเหตุการณ์บนตัวแก้ไข เราใช้สิ่งนี้เพื่อเชื่อมต่อแป้นพิมพ์ลัดเพื่อสลับรูปแบบอักขระ ในการทำเช่นนั้น เราเพิ่มวัตถุ KeyBindings
ภายใน useEditorConfig
ซึ่งเราเปิดเผยตัวจัดการเหตุการณ์ onKeyDown
ที่แนบมากับองค์ประกอบที่ Editable
เราใช้ยูทิลิตี้ is-hotkey
เพื่อกำหนดคีย์ผสมและสลับรูปแบบที่สอดคล้องกัน
# 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} />
การทำให้รูปแบบย่อหน้าใช้งานได้
ไปที่การทำให้ดรอปดาวน์สไตล์ย่อหน้าใช้งานได้ เช่นเดียวกับการทำงานของดรอปดาวน์สไตล์ย่อหน้าในแอปพลิเคชันการประมวลผลคำยอดนิยม เช่น MS Word หรือ Google Docs เราต้องการให้สไตล์ของบล็อกระดับบนสุดในการเลือกของผู้ใช้ปรากฏในเมนูดรอปดาวน์ หากการเลือกมีลักษณะที่สอดคล้องกัน เราจะอัปเดตค่าดรอปดาวน์ให้เป็นแบบนั้น หากมีหลายรายการ เราจะตั้งค่าดรอปดาวน์เป็น 'หลายรายการ' ลักษณะการทำงานนี้ต้องใช้ได้กับทั้งการเลือกแบบยุบและขยาย
เพื่อนำพฤติกรรมนี้ไปใช้ เราจำเป็นต้องสามารถค้นหาบล็อกระดับบนสุดซึ่งครอบคลุมการเลือกของผู้ใช้ ในการดำเนินการดังกล่าว เราใช้ Editor.nodes
ของ Slate — ฟังก์ชันตัวช่วยที่ใช้กันทั่วไปในการค้นหาโหนดในทรีที่กรองด้วยตัวเลือกต่างๆ
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>
ฟังก์ชันตัวช่วยใช้อินสแตนซ์ตัวแก้ไขและอ็อบเจ็กต์ options
ที่เป็นวิธีกรองโหนดในทรีขณะที่ข้ามผ่าน ฟังก์ชันส่งคืนตัวสร้าง NodeEntry
NodeEntry
ในคำศัพท์ Slate เป็นทูเพิลของโหนดและพาธไปยังโหนดนั้น — [node, pathToNode]
ตัวเลือกที่พบในนี้จะมีอยู่ในฟังก์ชันตัวช่วย Slate ส่วนใหญ่ มาดูกันว่าแต่ละอันหมายถึงอะไร:
-
at
นี่อาจเป็นเส้นทาง/จุด/ช่วงที่ฟังก์ชันตัวช่วยใช้ในการกำหนดขอบเขตการข้ามต้นไม้ไป ค่าดีฟอลต์นี้เป็นeditor.selection
หากไม่ได้ระบุไว้ เรายังใช้ค่าเริ่มต้นสำหรับกรณีการใช้งานด้านล่าง เนื่องจากเราสนใจโหนดภายในการเลือกของผู้ใช้ -
match
นี่คือฟังก์ชันการจับคู่ที่เรียกใช้ได้ในแต่ละโหนดและรวมไว้ด้วยหากตรงกัน เราใช้พารามิเตอร์นี้ในการใช้งานด้านล่างเพื่อกรองเพื่อบล็อกองค์ประกอบเท่านั้น -
mode
ให้ฟังก์ชันตัวช่วยรู้ว่าเราสนใจโหนดระดับสูงสุดหรือต่ำสุดทั้งหมดat
ฟังก์ชันmatch
คู่ตำแหน่งที่กำหนดหรือไม่ พารามิเตอร์นี้ (ตั้งค่าเป็นค่าhighest
) ช่วยให้เราหลีกหนีจากการพยายามสำรวจต้นไม้ ขึ้น เองเพื่อค้นหาโหนดระดับบนสุด -
universal
ตั้งค่าสถานะเพื่อเลือกระหว่างการจับคู่ทั้งหมดหรือบางส่วนของโหนด (ปัญหา GitHub กับข้อเสนอสำหรับการตั้งค่าสถานะนี้มีตัวอย่างอธิบายไว้) -
reverse
หากการค้นหาโหนดควรอยู่ในทิศทางย้อนกลับของจุดเริ่มต้นและจุดสิ้นสุดของตำแหน่งที่ส่งผ่านเข้ามา -
voids
หากการค้นหาควรกรององค์ประกอบที่เป็นโมฆะเท่านั้น
SlateJS แสดงฟังก์ชันตัวช่วยมากมายที่ให้คุณค้นหาโหนดด้วยวิธีต่างๆ กัน สำรวจต้นไม้ อัปเดตโหนดหรือการเลือกด้วยวิธีที่ซับซ้อน ควรค่าแก่การเจาะลึกเข้าไปในอินเทอร์เฟซบางส่วนเหล่านี้ (แสดงอยู่ท้ายบทความนี้) เมื่อสร้างฟังก์ชันการแก้ไขที่ซับซ้อนบน Slate
ด้วยพื้นหลังนั้นในฟังก์ชันตัวช่วย ด้านล่างนี้คือการใช้งาน getTextBlockStyle
# src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }
การพิจารณาประสิทธิภาพ
การใช้งาน Editor.nodes
ในปัจจุบันจะค้นหาโหนดทั้งหมดทั่วทั้งทรีในทุกระดับที่อยู่ภายในช่วงของ at
param แล้วรันตัวกรองที่ตรงกันบนโหนดนั้น (ตรวจสอบ nodeEntries
และการกรองภายหลัง — แหล่งที่มา) นี่เป็นเรื่องปกติสำหรับเอกสารที่มีขนาดเล็กลง อย่างไรก็ตาม สำหรับกรณีการใช้งานของเรา หากผู้ใช้เลือก ให้พูด 3 หัวข้อและ 2 ย่อหน้า (แต่ละย่อหน้ามี 10 โหนดข้อความ) จะวนผ่านอย่างน้อย 25 โหนด (3 + 2 + 2*10) และพยายามเรียกใช้ตัวกรอง กับพวกเขา เนื่องจากเรารู้อยู่แล้วว่าเราสนใจโหนดระดับบนสุดเท่านั้น เราจึงสามารถค้นหาดัชนีเริ่มต้นและสิ้นสุดของบล็อกระดับบนสุดจากการเลือกและทำซ้ำตัวเอง ตรรกะดังกล่าวจะวนซ้ำเพียง 3 รายการโหนด (2 หัวเรื่องและ 1 ย่อหน้า) รหัสสำหรับสิ่งนั้นจะมีลักษณะดังนี้:
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; }
เมื่อเราเพิ่มฟังก์ชันการทำงานให้กับ WYSIWYG Editor และจำเป็นต้องสำรวจโครงสร้างเอกสารบ่อยๆ สิ่งสำคัญคือต้องคิดถึงวิธีที่มีประสิทธิภาพมากที่สุดสำหรับกรณีการใช้งานที่มีอยู่ เนื่องจาก API หรือวิธีตัวช่วยที่มีอยู่อาจไม่ใช่วิธีสูงสุดเสมอไป วิธีที่มีประสิทธิภาพในการทำเช่นนั้น
เมื่อเราใช้งาน getTextBlockStyle
แล้ว การสลับสไตล์บล็อกจะค่อนข้างตรงไปตรงมา หากสไตล์ปัจจุบันไม่ใช่สไตล์ที่ผู้ใช้เลือกในเมนูแบบเลื่อนลง เราจะสลับสไตล์เป็นแบบนั้น หากเป็นสิ่งที่ผู้ใช้เลือกอยู่แล้ว เราจะสลับให้เป็นย่อหน้า เนื่องจากเรากำลังแสดงลักษณะย่อหน้าเป็นโหนดในโครงสร้างเอกสารของเรา การสลับรูปแบบย่อหน้าจึงหมายถึงการเปลี่ยนคุณสมบัติ type
บนโหนด เราใช้ Transforms.setNodes
ที่จัดเตรียมโดย Slate เพื่ออัปเดตคุณสมบัติบนโหนด
การใช้งาน toggleBlockType
ของเรามีดังต่อไปนี้:
# 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) } ); }
สุดท้าย เราอัปเดตดรอปดาวน์สไตล์ย่อหน้าเพื่อใช้ฟังก์ชันยูทิลิตี้เหล่านี้
#src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... );
ลิงค์
ในส่วนนี้ เราจะเพิ่มการสนับสนุนเพื่อแสดง เพิ่ม ลบ และเปลี่ยนลิงก์ นอกจากนี้เรายังจะเพิ่มฟังก์ชัน Link-Detector ซึ่งคล้ายกับที่ Google Docs หรือ MS Word สแกนข้อความที่ผู้ใช้พิมพ์และตรวจสอบว่ามีลิงก์อยู่ในนั้นหรือไม่ หากมี สิ่งเหล่านี้จะถูกแปลงเป็นออบเจ็กต์ลิงก์เพื่อให้ผู้ใช้ไม่จำเป็นต้องใช้ปุ่มแถบเครื่องมือเพื่อดำเนินการเอง
ลิงค์แสดงผล
ในโปรแกรมแก้ไขของเรา เราจะนำลิงก์ไปใช้ในโหนดแบบอินไลน์ด้วย SlateJS เราอัปเดตการกำหนดค่าตัวแก้ไขของเราเพื่อตั้งค่าสถานะลิงก์เป็นโหนดอินไลน์สำหรับ SlateJS และยังมีองค์ประกอบในการแสดงผลเพื่อให้ Slate รู้วิธีแสดงโหนดลิงก์
# 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
นอกตัวแก้ไข เราจึงต้องมีวิธีบอก LinkEditor
ว่าลิงก์นั้นอยู่ที่ใดในแผนผัง DOM เพื่อให้สามารถแสดงผลได้เองใกล้กับตัวแก้ไข วิธีที่เราทำคือใช้ React API ของ Slate เพื่อค้นหาโหนด DOM ที่สอดคล้องกับโหนดลิงก์ในการเลือก จากนั้นเราใช้ getBoundingClientRect()
เพื่อค้นหาขอบเขตขององค์ประกอบ DOM ของลิงก์และขอบเขตขององค์ประกอบตัวแก้ไข และคำนวณ top
และ left
สำหรับตัวแก้ไขลิงก์ โค้ดอัพเดท Editor
และ LinkEditor
มีดังนี้ —
# 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 รักษาแผนที่ของโหนดภายในองค์ประกอบ DOM ตามลำดับ เราเข้าถึงแผนที่นั้นและค้นหาองค์ประกอบ DOM ของลิงก์โดยใช้ ReactEditor.toDOMNode
ดังที่เห็นในวิดีโอด้านบน เมื่อมีการแทรกลิงก์และไม่มี URL เนื่องจากการเลือกอยู่ภายในลิงก์ โปรแกรมแก้ไขลิงก์จะเปิดขึ้นจึงทำให้ผู้ใช้สามารถพิมพ์ URL สำหรับลิงก์ที่แทรกใหม่ได้และ ดังนั้นจึงปิดการวนซ้ำของประสบการณ์ผู้ใช้ที่นั่น
ตอนนี้เราเพิ่มองค์ประกอบอินพุตและปุ่มลงใน LinkEditor
เพื่อให้ผู้ใช้พิมพ์ URL และนำไปใช้กับโหนดลิงก์ เราใช้แพ็คเกจ isUrl
สำหรับการตรวจสอบความถูกต้องของ URL
# src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );
เมื่อรวมองค์ประกอบแบบฟอร์มแล้ว มาดูกันว่าตัวแก้ไขลิงก์ทำงานตามที่คาดไว้หรือไม่
ดังที่เราเห็นในวิดีโอนี้ เมื่อผู้ใช้พยายามคลิกเข้าไปในอินพุต ตัวแก้ไขลิงก์จะหายไป นี่เป็นเพราะเมื่อเราแสดงตัวแก้ไขลิงก์ภายนอกองค์ประกอบที่ Editable
เมื่อผู้ใช้คลิกที่องค์ประกอบอินพุต SlateJS คิดว่าตัวแก้ไขสูญเสียโฟกัสและรีเซ็ตการ selection
ให้เป็น null
ซึ่งจะลบ LinkEditor
เนื่องจาก isLinkActiveAtSelection
ไม่ true
อีกต่อไป มีปัญหา GitHub แบบเปิดที่พูดถึงพฤติกรรมชนวนนี้ วิธีหนึ่งในการแก้ปัญหานี้คือการติดตามการเลือกก่อนหน้าของผู้ใช้เมื่อมีการเปลี่ยนแปลง และเมื่อตัวแก้ไขไม่โฟกัส เราสามารถดูที่การเลือกก่อนหน้าและยังคงแสดงเมนูตัวแก้ไขลิงก์ หากการเลือกก่อนหน้ามีลิงก์อยู่ในนั้น มาอัปเดต useSelection
hook เพื่อจำการเลือกก่อนหน้าและกลับไปที่องค์ประกอบ Editor
# src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }
จากนั้นเราจะอัปเดตตรรกะในองค์ประกอบ Editor
เพื่อแสดงเมนูลิงก์ แม้ว่าการเลือกก่อนหน้าจะมีลิงก์อยู่ก็ตาม
# 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={..} ... );
จากนั้นเราจะอัปเดต LinkEditor
เพื่อใช้ selectionForLink
เพื่อค้นหาโหนดลิงก์ แสดงผลด้านล่าง และอัปเดตเป็น URL
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
การตรวจจับลิงก์ในข้อความ
แอปพลิเคชันประมวลผลคำส่วนใหญ่ระบุและแปลงลิงก์ภายในข้อความเป็นลิงก์ออบเจ็กต์ เรามาดูกันว่ามันจะทำงานอย่างไรในตัวแก้ไขก่อนที่เราจะเริ่มสร้างมัน
ขั้นตอนของตรรกะในการเปิดใช้งานลักษณะการทำงานนี้คือ:
- เมื่อเอกสารเปลี่ยนไปตามการพิมพ์ของผู้ใช้ ให้ค้นหาอักขระตัวสุดท้ายที่ผู้ใช้ใส่เข้าไป ถ้าตัวละครนั้นเป็นช่องว่าง เรารู้ว่าต้องมีคำที่อาจมาก่อนมัน
- หากอักขระตัวสุดท้ายเป็นช่องว่าง เราจะทำเครื่องหมายว่าเป็นขอบเขตสิ้นสุดของคำที่อยู่ข้างหน้า จากนั้นเราย้อนกลับทีละอักขระภายในโหนดข้อความเพื่อค้นหาว่าคำนั้นเริ่มต้นที่ใด ในระหว่างการข้ามผ่านนี้ เราต้องระวังไม่ให้ผ่านขอบของจุดเริ่มต้นของโหนดไปยังโหนดก่อนหน้า
- เมื่อเราพบขอบเขตเริ่มต้นและสิ้นสุดของคำแล้ว เราจะตรวจสอบสตริงของคำนั้นและดูว่าเป็น URL หรือไม่ ถ้าเป็นเราจะแปลงเป็นโหนดลิงก์
ตรรกะของเราอยู่ในฟังก์ชัน util identifyLinksInTextIfAny
LinksInTextIfAny ที่อยู่ใน EditorUtils
และถูกเรียกภายในคอมโพเนนต์ onChange
in Editor
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
นี่คือ identifyLinksInTextIfAny
ด้วยตรรกะสำหรับขั้นตอนที่ 1 ที่นำไปใช้:
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; }
มีฟังก์ชันตัวช่วย SlateJS สองฟังก์ชันซึ่งทำให้สิ่งต่างๆ ง่ายขึ้นที่นี่
-
Editor.before
— ให้จุดก่อนตำแหน่งที่แน่นอน ใช้unit
เป็นพารามิเตอร์เพื่อให้เราสามารถขออักขระ / คำ / บล็อก ฯลฯ ก่อนที่location
จะผ่านไป -
Editor.string
— รับสตริงภายในช่วง
ตัวอย่างเช่น ไดอะแกรมด้านล่างอธิบายว่าค่าของตัวแปรเหล่านี้คืออะไรเมื่อผู้ใช้แทรกอักขระ 'E' และเคอร์เซอร์นั่งอยู่หลังจากนั้น
หากข้อความ 'ABCDE' เป็นโหนดข้อความแรกของย่อหน้าแรกในเอกสาร ค่าจุดของเราจะเป็น —
cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}
หากอักขระตัวสุดท้ายเป็นช่องว่าง เรารู้ว่ามันเริ่มต้นที่ใด — startPointOfLastCharacter.
ไปที่ขั้นตอนที่ 2 กัน โดยเราจะย้อนกลับทีละอักขระ จนกว่าเราจะพบช่องว่างอื่นหรือจุดเริ่มต้นของโหนดข้อความเอง
... 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);
นี่คือไดอะแกรมที่แสดงว่าจุดต่างๆ เหล่านี้ชี้ไปที่จุดใดเมื่อเราพบคำสุดท้ายที่ป้อนเป็น ABCDE
โปรดทราบว่า start
และ end
คือจุดก่อนและหลังช่องว่างที่นั่น ในทำนองเดียวกัน startPointOfLastCharacter
และ cursorPoint
เป็นจุดก่อนและหลังผู้ใช้ Space เพิ่งแทรก ดังนั้น [end,startPointOfLastCharacter]
จึงให้คำสุดท้ายที่ใส่เข้าไป
เราบันทึกค่าของ lastWord
ไปยังคอนโซลและตรวจสอบค่าในขณะที่เราพิมพ์
ตอนนี้เราได้อนุมานว่าคำสุดท้ายที่ผู้ใช้พิมพ์คืออะไร เรายืนยันว่าเป็น URL จริงและแปลงช่วงนั้นเป็นออบเจ็กต์ลิงก์ การแปลงนี้ดูเหมือนกับวิธีที่ปุ่มลิงก์ของแถบเครื่องมือแปลงข้อความที่ผู้ใช้เลือกให้เป็นลิงก์
if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }
identifyLinksInTextIfAny
ถูกเรียกใน onChange
ของ Slate ดังนั้นเราจึงไม่ต้องการอัปเดตโครงสร้างเอกสารภายใน onChange
ดังนั้นเราจึงใส่การอัปเดตนี้ในคิวงานของเราด้วยการเรียก Promise.resolve().then(..)
มาดูตรรกะที่นำมาปฏิบัติกัน! เราตรวจสอบว่าเราแทรกลิงก์ที่ส่วนท้าย ตรงกลาง หรือจุดเริ่มต้นของโหนดข้อความ
ด้วยเหตุนี้ เราจึงได้รวบรวมฟังก์ชันสำหรับลิงก์ในตัวแก้ไขและไปยังรูปภาพ
การจัดการรูปภาพ
ในส่วนนี้ เรามุ่งเน้นที่การเพิ่มการรองรับการแสดงผลโหนดรูปภาพ เพิ่มรูปภาพใหม่ และอัปเดตคำบรรยายภาพ รูปภาพในโครงสร้างเอกสารของเราจะแสดงเป็นโหนดที่เป็นโมฆะ โหนดเป็นโมฆะใน SlateJS (คล้ายกับองค์ประกอบที่เป็นโมฆะในข้อกำหนด HTML) เป็นเนื้อหาที่เนื้อหาไม่ใช่ข้อความที่แก้ไขได้ ที่ช่วยให้เราสามารถแสดงภาพเป็นช่องว่าง เนื่องจากความยืดหยุ่นในการเรนเดอร์ของ Slate เราจึงยังสามารถแสดงองค์ประกอบที่แก้ไขได้ของเราเองภายในองค์ประกอบ Void ซึ่งเราจะใช้สำหรับการแก้ไขคำบรรยายภาพ SlateJS มีตัวอย่างที่แสดงให้เห็นว่าคุณสามารถฝัง Rich Text Editor ทั้งหมดภายในองค์ประกอบ Void ได้อย่างไร
ในการเรนเดอร์รูปภาพ เรากำหนดค่าตัวแก้ไขให้ถือว่ารูปภาพเป็นองค์ประกอบที่เป็นโมฆะ และจัดเตรียมการใช้งานการเรนเดอร์ว่าควรเรนเดอร์รูปภาพอย่างไร เราเพิ่มรูปภาพใน ExampleDocument ของเราและตรวจสอบว่าแสดงอย่างถูกต้องพร้อมคำอธิบายภาพ
# 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:
- องค์ประกอบ DOM รูทควรมีการตั้งค่า contentEditable
contentEditable={false}
เพื่อให้ SlateJS ปฏิบัติต่อเนื้อหาดังกล่าว หากไม่มีสิ่งนี้ เมื่อคุณโต้ตอบกับองค์ประกอบ void SlateJS อาจพยายามคำนวณการเลือก ฯลฯ และแตกเป็นผล - แม้ว่าโหนด Void ไม่มีโหนดย่อย (เช่นโหนดรูปภาพของเราเป็นตัวอย่าง) เรายังคงต้องแสดงโหนดย่อยและจัดเตรียมโหนดข้อความว่างเป็นโหนด
ExampleDocument
children
ล่าง) ซึ่งถือเป็นจุดเลือกของ Void องค์ประกอบโดย SlateJS
ตอนนี้เราอัปเดต ExampleDocument
เพื่อเพิ่มรูปภาพและตรวจสอบว่ารูปภาพปรากฏขึ้นพร้อมคำอธิบายภาพในตัวแก้ไขหรือไม่
# 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: "" }], }, ];
ตอนนี้เรามาเน้นที่การแก้ไขคำบรรยาย วิธีที่เราต้องการให้เป็นประสบการณ์ที่ราบรื่นสำหรับผู้ใช้ก็คือ เมื่อพวกเขาคลิกที่คำอธิบายภาพ เราจะแสดงข้อความที่ผู้ใช้สามารถแก้ไขคำอธิบายภาพได้ หากพวกเขาคลิกนอกอินพุตหรือกดปุ่ม RETURN เราจะถือว่านั่นเป็นการยืนยันเพื่อใช้คำอธิบายภาพ จากนั้นเราอัปเดตคำบรรยายบนโหนดรูปภาพและเปลี่ยนคำอธิบายภาพกลับเป็นโหมดอ่าน มาดูกันจริง ๆ ว่าเรากำลังจะสร้างอะไร
มาอัปเดตองค์ประกอบรูปภาพของเราเพื่อให้มีสถานะสำหรับโหมดอ่านแก้ไขคำอธิบายภาพ เราอัปเดตสถานะคำบรรยายในเครื่องเมื่อผู้ใช้อัปเดต และเมื่อพวกเขาคลิกออก ( onBlur
) หรือกด RETURN ( onKeyDown
) เราจะใช้คำบรรยายใต้ภาพกับโหนดและเปลี่ยนเป็นโหมดอ่านอีกครั้ง
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> ...
ด้วยเหตุนี้ ฟังก์ชันการแก้ไขคำอธิบายภาพจึงสมบูรณ์ ตอนนี้เราย้ายไปเพิ่มวิธีการให้ผู้ใช้อัปโหลดภาพไปยังโปรแกรมแก้ไข มาเพิ่มปุ่มแถบเครื่องมือที่ให้ผู้ใช้เลือกและอัปโหลดรูปภาพกันเถอะ
# 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>
ในขณะที่เราทำงานกับการอัปโหลดรูปภาพ โค้ดอาจเพิ่มขึ้นเล็กน้อย ดังนั้นเราจึงย้ายการจัดการการอัปโหลดรูปภาพไปยัง hook useImageUploadHandler
ที่ให้การโทรกลับที่แนบมากับองค์ประกอบอินพุตไฟล์ เราจะพูดคุยกันสั้นๆ ว่าทำไมจึงต้องมีสถานะ previousSelection
ก่อนที่เราจะใช้งาน useImageUploadHandler
เราจะตั้งค่าเซิร์ฟเวอร์เพื่อให้สามารถอัปโหลดรูปภาพได้ เราตั้งค่าเซิร์ฟเวอร์ Express และติดตั้งแพ็คเกจอื่นๆ อีกสองแพ็คเกจ — cors
และ multer
ที่จัดการการอัปโหลดไฟล์สำหรับเรา
yarn add express cors multer
จากนั้นเราเพิ่มสคริปต์ src/server.js
ที่กำหนดค่าเซิร์ฟเวอร์ Express ด้วย cors และ multer และแสดงจุดสิ้นสุด /upload
ซึ่งเราจะอัปโหลดรูปภาพไป
# 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}`));
ตอนนี้เรามีการตั้งค่าเซิร์ฟเวอร์แล้ว เราสามารถมุ่งเน้นไปที่การจัดการการอัปโหลดรูปภาพ เมื่อผู้ใช้อัปโหลดรูปภาพ อาจใช้เวลาไม่กี่วินาทีก่อนที่จะอัปโหลดรูปภาพ และเรามี URL สำหรับรูปภาพนั้น อย่างไรก็ตาม เราทำสิ่งใดเพื่อให้ผู้ใช้ตอบกลับทันทีว่ากำลังอัปโหลดรูปภาพอยู่ เพื่อให้พวกเขารู้ว่ากำลังแทรกรูปภาพในโปรแกรมแก้ไข นี่คือขั้นตอนที่เราดำเนินการเพื่อให้พฤติกรรมนี้ใช้งานได้ -
- เมื่อผู้ใช้เลือกรูปภาพ เราจะแทรกโหนดรูปภาพที่ตำแหน่งเคอร์เซอร์ของผู้ใช้ด้วยการตั้งค่าสถานะ
isUploading
เพื่อให้เราสามารถแสดงสถานะการโหลดแก่ผู้ใช้ - เราส่งคำขอไปยังเซิร์ฟเวอร์เพื่ออัปโหลดภาพ
- เมื่อคำขอเสร็จสมบูรณ์และเรามี URL รูปภาพแล้ว เราจะตั้งค่านั้นบนรูปภาพและลบสถานะการโหลดออก
เริ่มต้นด้วยขั้นตอนแรกที่เราแทรกโหนดรูปภาพ ตอนนี้ ส่วนที่ยากคือเราพบปัญหาเดียวกันกับการเลือกเช่นเดียวกับปุ่มลิงก์ในแถบเครื่องมือ ทันทีที่ผู้ใช้คลิกปุ่มรูปภาพในแถบเครื่องมือ ตัวแก้ไขจะสูญเสียโฟกัสและการเลือกจะกลายเป็น null
หากเราพยายามแทรกรูปภาพ เราไม่รู้ว่าเคอร์เซอร์ของผู้ใช้อยู่ที่ไหน การติดตาม previousSelection
การเลือกทำให้เรามีตำแหน่งนั้นและเราใช้เพื่อแทรกโหนด
# 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] ); }
เมื่อเราแทรกโหนดรูปภาพใหม่ เรายังกำหนด id
ตัวระบุโดยใช้แพ็คเกจ uuid เราจะหารือในการดำเนินการของขั้นตอนที่ (3) ว่าทำไมเราถึงต้องการสิ่งนั้น ตอนนี้เราอัปเดตองค์ประกอบรูปภาพเพื่อใช้การตั้งค่าสถานะ isUploading
เพื่อแสดงสถานะการโหลด
{!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}
การดำเนินการตามขั้นตอนที่ 1 เสร็จสมบูรณ์ มาลองดูว่าเราสามารถเลือกรูปภาพที่จะอัปโหลดได้ ดูโหนดรูปภาพที่แทรกด้วยตัวบ่งชี้การโหลดที่แทรกอยู่ในเอกสาร
ย้ายไปยังขั้นตอนที่ (2) เราจะใช้ไลบรารี axois เพื่อส่งคำขอไปยังเซิร์ฟเวอร์
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 }); }, [...]); }
เราตรวจสอบว่าการอัปโหลดรูปภาพใช้งานได้และรูปภาพนั้นปรากฏในโฟลเดอร์ public/photos
ของแอป เมื่อการอัปโหลดรูปภาพเสร็จสมบูรณ์ เราย้ายไปที่ขั้นตอนที่ (3) ซึ่งเราต้องการตั้งค่า URL บนรูปภาพในฟังก์ชัน resolve()
ของสัญญา axios เราสามารถอัปเดตรูปภาพด้วย Transforms.setNodes
แต่เรามีปัญหา — เราไม่มีเส้นทางไปยังโหนดภาพที่แทรกใหม่ มาดูกันว่าเรามีตัวเลือกอะไรบ้างในการไปที่ภาพนั้น —
- เราไม่สามารถใช้
editor.selection
เนื่องจากการเลือกต้องอยู่บนโหนดภาพที่แทรกใหม่หรือไม่ เราไม่สามารถรับประกันได้ เนื่องจากในขณะที่กำลังอัปโหลดรูปภาพ ผู้ใช้อาจคลิกที่อื่นและอาจมีการเปลี่ยนแปลงการเลือก - แล้วการใช้
previousSelection
ที่เราใช้ในการแทรกโหนดรูปภาพตั้งแต่แรกล่ะ? ด้วยเหตุผลเดียวกัน เราจึงใช้editor.selection
ไม่ได้ เราจึงใช้ PreviousSelection ไม่previousSelection
เนื่องจากอาจมีการเปลี่ยนแปลงเช่นกัน - SlateJS มีโมดูลประวัติที่ติดตามการเปลี่ยนแปลงทั้งหมดที่เกิดขึ้นกับเอกสาร เราสามารถใช้โมดูลนี้เพื่อค้นหาประวัติและค้นหาโหนดภาพที่แทรกล่าสุด นอกจากนี้ยังไม่น่าเชื่อถืออย่างสมบูรณ์หากใช้เวลานานกว่าในการอัปโหลดรูปภาพ และผู้ใช้แทรกรูปภาพเพิ่มเติมในส่วนต่างๆ ของเอกสารก่อนที่การอัปโหลดครั้งแรกจะเสร็จสมบูรณ์
- ปัจจุบัน API ของ
Transform.insertNodes
จะไม่ส่งคืนข้อมูลใดๆ เกี่ยวกับโหนดที่แทรก หากมันสามารถส่งคืนพาธไปยังโหนดที่แทรก เราสามารถใช้สิ่งนั้นเพื่อค้นหาโหนดรูปภาพที่แม่นยำที่เราควรอัปเดต
เนื่องจากวิธีการข้างต้นไม่ได้ผล เราจึงใช้ id
กับโหนดภาพที่แทรก (ในขั้นตอนที่ (1)) และใช้ id
เดิมอีกครั้งเพื่อค้นหาเมื่อการอัปโหลดภาพเสร็จสิ้น ด้วยเหตุนี้ รหัสของเราสำหรับขั้นตอนที่ (3) ดูเหมือนด้านล่าง —
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. });
เมื่อดำเนินการทั้งสามขั้นตอนเสร็จเรียบร้อยแล้ว เราก็พร้อมที่จะทดสอบการอัปโหลดภาพตั้งแต่ต้นจนจบ
ด้วยเหตุนี้ เราจึงสรุปรูปภาพสำหรับโปรแกรมแก้ไขของเรา ขณะนี้ เราแสดงสถานะการโหลดที่มีขนาดเท่ากันโดยไม่คำนึงถึงรูปภาพ นี่อาจเป็นประสบการณ์ที่น่าปวดหัวสำหรับผู้ใช้ หากสถานะการโหลดถูกแทนที่ด้วยรูปภาพที่เล็กกว่าหรือใหญ่กว่าอย่างมากเมื่อการอัปโหลดเสร็จสิ้น การติดตามประสบการณ์การอัปโหลดที่ดีคือการได้รับขนาดภาพก่อนการอัปโหลด และแสดงตัวยึดตำแหน่งขนาดนั้นเพื่อให้การเปลี่ยนแปลงเป็นไปอย่างราบรื่น เบ็ดที่เราเพิ่มด้านบนสามารถขยายเพื่อรองรับสื่อประเภทอื่น ๆ เช่นวิดีโอหรือเอกสารและแสดงโหนดประเภทเหล่านั้นได้เช่นกัน
บทสรุป
ในบทความนี้ เราได้สร้าง WYSIWYG Editor ที่มีชุดฟังก์ชันพื้นฐานและประสบการณ์ผู้ใช้ขนาดเล็ก เช่น การตรวจหาลิงก์ การแก้ไขลิงก์ในตำแหน่ง และการแก้ไขคำอธิบายภาพ ซึ่งช่วยให้เราเจาะลึกยิ่งขึ้นด้วย SlateJS และแนวคิดของการแก้ไข Rich Text ใน ทั่วไป. หากพื้นที่ปัญหารอบๆ Rich Text Editing หรือ Word Processing ทำให้คุณสนใจ ปัญหาเจ๋งๆ ที่ต้องทำคือ:
- การทำงานร่วมกัน
- ประสบการณ์การแก้ไขข้อความที่สมบูรณ์ยิ่งขึ้นซึ่งสนับสนุนการจัดแนวข้อความ รูปภาพในบรรทัด คัดลอกและวาง การเปลี่ยนแบบอักษรและสีข้อความ ฯลฯ
- นำเข้าจากรูปแบบที่นิยมเช่นเอกสาร Word และ Markdown
หากคุณต้องการเรียนรู้เพิ่มเติมเกี่ยวกับ SlateJS นี่คือลิงก์บางส่วนที่อาจเป็นประโยชน์
- ตัวอย่าง SlateJS
ตัวอย่างมากมายที่นอกเหนือไปจากพื้นฐานและสร้างฟังก์ชันการทำงานที่มักพบใน Editors เช่น Search & Highlight, Markdown Preview และ Mentions - เอกสาร API
อ้างอิงถึงฟังก์ชันตัวช่วยจำนวนมากที่ SlateJS เปิดเผยซึ่งอาจต้องการทำให้สะดวกเมื่อพยายามดำเนินการสืบค้น/การแปลงที่ซับซ้อนบนวัตถุ SlateJS
สุดท้ายนี้ Slack Channel ของ SlateJS เป็นชุมชนที่ใช้งานมากของนักพัฒนาเว็บที่สร้างแอปพลิเคชันการแก้ไข Rich Text โดยใช้ SlateJS และเป็นที่ที่ดีในการเรียนรู้เพิ่มเติมเกี่ยวกับไลบรารีและรับความช่วยเหลือหากจำเป็น