Quill
是一個開源且極具彈性的富文本編輯器,提供多樣的文字格式化選項與易於操作的 API,並且支援跨瀏覽器使用和自訂化設定。它擁有即時協作功能,能讓多名使用者同時在文件上編輯和合作。
更多Quill
的介紹請見官方
富文本編輯器(Rich Text Editor)是一個使用者介面元件,它允許使用者以可視化的方式編輯格式化的文本。富文本通常指的是包含了一些格式的文本,例如不同種類的字體、顏色、大小以及包含連接、圖片和其他多媒體元素的文本。
在網頁或應用程式中,當使用者使用富文本編輯器時,他們能夠利用編輯工具欄上的按鈕來設置文本格式,例如將文字加粗、斜體或者下底線,插入連結等,而無需手動撰寫HTML或Markdown。
在IT邦發文的時候我們也是使用Rich Text Editor
先建立發布文章的檔案,在pages
資料夾底下新增EditPost.js
(到時候會跟編輯文章共用)
這次主要功能重點
TagsInput
元件ReactQuill
//EditPost.js
import React, { useState, useMemo } from "react";
import { TagsInput } from "react-tag-input-component";
import api from '../api/api';
import useInput from "../hooks/useInput";
const EditPost = (props) => {
const validateRequired = (value) => {
if (value.trim() === '') {
return { isValid: false };
}
return { isValid: true };
};
//設定title相關屬性
const {
value: title,
isValid: titleIsValid,
hasError: titleInputHasError,
valueChangeHandler: titleChangeHandler,
inputBlurHandler: titleBlurHandler,
} = useInput(validateRequired);
//設定tag相關屬性
const [tags, setTags] = useState([]);
const [tagsTouched, setTagsTouched] = useState(false);
//設定tag驗證
const tagsIsValid = tags.length > 0 ;
const tagsInputIsInValid = !tagsIsValid && tagsTouched;
//tagInput blur事件
const handleTagsInputBlur = () =>{
setTagsTouched(true);
}
//從localstorage取得authorId
const authorId = JSON.parse(localStorage.getItem("user")).data.id;
const titleInputClasses = titleInputHasError ? 'border-red-500 focus:ring-red-500' : 'border-slate-300 focus:ring-sky-500' ;
const tagsInputClasses = tagsInputIsInValid ? 'border-red' : 'border-slate';
return (
<div className="w-screen py-16">
<form className="w-[720px] mx-auto pb-20 pt-10" onSubmit={onSubmit}>
<h1 className=" mb-4 text-4xl text-black custom-font text-center">Create Post</h1>
<div className="mb-8">
<h3 className="text-xl mb-1">Title</h3>
<input
type="text"
value={title}
placeholder="請輸入文章標題"
onChange={titleChangeHandler}
onBlur={titleBlurHandler}
className={`mt-1 block w-full px-3 py-2 bg-white border
rounded-md text-sm shadow-sm placeholder-slate-400
focus:outline-none focus:ring-1 ${titleInputClasses}`}
/>
{titleInputHasError && <p className="text-red-500 text-sm">Title is required</p>}
</div>
<div className="mb-8">
<h3 className="text-xl mb-1">Tags</h3>
<TagsInput
value={tags}
classNames={{ input: tagsInputClasses}}
onChange={setTags}
onBlur={handleTagsInputBlur}
name="tags"
placeHolder="輸入文章分類"
/>
{ tagsInputIsInValid && <p className="text-red-500 text-sm">Please enter at least one tag</p>}
</div>
<div className="mb-8">
<h3 className="text-2xl mb-1">Cover Image</h3>
<input
type="file"
accept="image/jpeg, image/png"
onChange={onFileChange}
/>
</div>
<div className="mb-8">
<h3 className="text-2xl mb-1">Content</h3>
{/* 到時候放置react-quill的地方 */}
</div>
<div className="text-right">
<button className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50" type="submit" disabled={loading || !formIsValid}>
{loading ? 'Uploading...' : 'Submit'}
</button>
</div>
</form>
</div>
);
};
export default EditPost;
上面我們把標題欄位和標籤欄位的驗證和值設定完成。
🔺另外提醒的是,在TagsInput
裡不使用handleChange function
是因為它回傳的是string[]
而不是event
,所以才會直接呼叫setTags
<TagsInput
value={tags}
classNames={{ input: tagsInputClasses}}
onChange={setTags}
onBlur={handleTagsInputBlur}
name="tags"
placeHolder="輸入文章分類"
/>
詳細文件請見react-tag-input-component
先前已經安裝過react-quill,
所以直接在檔案上引用。
import React, { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { TagsInput } from "react-tag-input-component";
//引用ReactQuill
import ReactQuill from "react-quill";
//引用ReactQuill的編輯器樣式
import 'react-quill/dist/quill.snow.css';
import api from '../api/api';
import useInput from "../hooks/useInput";
const EditPost = (props) => {
const navigate = useNavigate();
const validateRequired = (value) => {
if (value.trim() === '') {
return { isValid: false };
}
return { isValid: true };
};
//檢驗編輯器內容是否合法
const validateContent = (content) =>{
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/html');
const textContent = doc.body.textContent || "";
// 檢查是否有非空白字符
return textContent.trim().length > 0;
}
const {
value: title,
isValid: titleIsValid,
hasError: titleInputHasError,
valueChangeHandler: titleChangeHandler,
inputBlurHandler: titleBlurHandler,
} = useInput(validateRequired);
const [tags, setTags] = useState([]);
const [tagsTouched, setTagsTouched] = useState(false);
//編輯器的內容
const [content, setContent] = useState("");
const [contentTouched, setContentTouched] = useState(false);
const tagsIsValid = tags.length > 0 ;
const tagsInputIsInValid = !tagsIsValid && tagsTouched;
const contentIsValid = validateContent(content) ;
const contenttIsInValid = !contentIsValid && contentTouched;
let formIsValid = false;
if (titleIsValid && tagsIsValid && contentIsValid ) {
formIsValid = true;
}
const handleTagsInputBlur = () =>{
setTagsTouched(true);
}
//Content處理,onChange事件可以拿到 (content, delta, source, editor)
const handleContentChange = (value, delta) => {
setContent(value);
};
//Content blur事件
const handleContentBlur = (previousRange, source, editor) => {
setContentTouched(true);
};
const authorId = JSON.parse(localStorage.getItem("user")).data.id;
//編輯器模組設定
const modules = useMemo(() => ({
toolbar: [
[{ header: [1, 2, 3, false] }],
[{ font: [] }],
[
"bold",
"italic",
"underline",
"strike",
"blockquote",
"code-block",
"link",
{ align: [] },
],
]
}), []);
//欄位錯誤樣式設定
const titleInputClasses = titleInputHasError ? 'border-red-500 focus:ring-red-500' : 'border-slate-300 focus:ring-sky-500' ;
const tagsInputClasses = tagsInputIsInValid ? 'border-red' : 'border-slate';
const contentClasses = contenttIsInValid ? 'border border-red-500 focus:border-red-500' : '';
return (
<div className="w-screen py-16">
<form className="w-[720px] mx-auto pb-20 pt-10" onSubmit={onSubmit}>
<h1 className=" mb-4 text-4xl text-black custom-font text-center">Create Post</h1>
<div className="mb-8">
<h3 className="text-xl mb-1">Title</h3>
<input
type="text"
value={title}
placeholder="請輸入文章標題"
onChange={titleChangeHandler}
onBlur={titleBlurHandler}
className={`mt-1 block w-full px-3 py-2 bg-white border
rounded-md text-sm shadow-sm placeholder-slate-400
focus:outline-none focus:ring-1 ${titleInputClasses}`}
/>
{titleInputHasError && <p className="text-red-500 text-sm">Title is required</p>}
</div>
<div className="mb-8">
<h3 className="text-xl mb-1">Tags</h3>
<TagsInput
value={tags}
classNames={{ input: tagsInputClasses}}
onChange={setTags}
onBlur={handleTagsInputBlur}
name="tags"
placeHolder="輸入文章分類"
/>
{ tagsInputIsInValid && <p className="text-red-500 text-sm">Please enter at least one tag</p>}
</div>
<div className="mb-8">
<h3 className="text-2xl mb-1">Cover Image</h3>
<input
type="file"
accept="image/jpeg, image/png"
onChange={onFileChange}
/>
</div>
<div className="mb-8">
<h3 className="text-2xl mb-1">Content</h3>
<ReactQuill
theme="snow"
placeholder="Enter your rich text edtior"
modules={modules}
value={content}
className={contentClasses}
onBlur={handleContentBlur}
onChange={handleContentChange}
/>
{contenttIsInValid && <p className="text-red-500 text-sm">Please enter valid content</p>}
</div>
<div className="text-right">
<button className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50" type="submit" disabled={loading || !formIsValid}>
{loading ? 'Uploading...' : 'Submit'}
</button>
</div>
</form>
</div>
);
};
export default EditPost;
這樣設置完成後應該就能看到react-quill了
import React, { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { TagsInput } from "react-tag-input-component";
import ReactQuill from "react-quill";
import 'react-quill/dist/quill.snow.css';
import api from '../api/api';
import useInput from "../hooks/useInput";
const EditPost = (props) => {
const navigate = useNavigate();
const validateRequired = (value) => {
if (value.trim() === '') {
return { isValid: false };
}
return { isValid: true };
};
const validateContent = (content) =>{
const parser = new DOMParser();
const doc = parser.parseFromString(content, 'text/html');
const textContent = doc.body.textContent || "";
// 檢查是否有非空白字符
return textContent.trim().length > 0;
}
const {
value: title,
isValid: titleIsValid,
hasError: titleInputHasError,
valueChangeHandler: titleChangeHandler,
inputBlurHandler: titleBlurHandler,
} = useInput(validateRequired);
//圖片檔案存取
const [file, setFile] = useState(null);
//loading狀態,當api還在處理時為true,完成則設回為false
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState([]);
const [tagsTouched, setTagsTouched] = useState(false);
const [content, setContent] = useState("");
const [contentTouched, setContentTouched] = useState(false);
const tagsIsValid = tags.length > 0 ;
const tagsInputIsInValid = !tagsIsValid && tagsTouched;
const contentIsValid = validateContent(content) ;
const contenttIsInValid = !contentIsValid && contentTouched;
let formIsValid = false;
if (titleIsValid && tagsIsValid && contentIsValid ) {
formIsValid = true;
}
const handleTagsInputBlur = () =>{
setTagsTouched(true);
}
const handleContentChange = (value, delta) => {
setContent(value);
};
const handleContentBlur = (previousRange, source, editor) => {
setContentTouched(true);
};
const authorId = JSON.parse(localStorage.getItem("user")).data.id;
const modules = useMemo(() => ({
toolbar: [
[{ header: [1, 2, 3, false] }],
[{ font: [] }],
[
"bold",
"italic",
"underline",
"strike",
"blockquote",
"code-block",
"link",
{ align: [] },
],
]
}), []);
//選取檔案事件
const onFileChange = (e) =>{
setFile(e.target.files[0]);
}
//圖片上傳API
const uploadImage = async () => {
const formData = new FormData();
formData.append('image', file);
try {
const response = await api.post('/images/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
//若圖片成功上傳會回傳圖片的url
return response.data.data.url;
} catch (error) {
throw error;
}
};
//發布文章API
const createPost = async (imageUrl) => {
try {
const postData = {
title,
content,
coverImage: imageUrl,
authorId: authorId,
tags: tags,
};
await api.post('/posts', postData);
} catch (error) {
throw error;
}
};
//發布文章
const onSubmit = async (e) =>{
e.preventDefault();
//若沒有選取檔案,則顯示錯誤訊息
if(file === null){
alert('Please select cover image!');
return;
}
//設定狀態為載入中
setLoading(true);
try {
//先呼叫圖片上傳api取得圖片url
const imageUrl = await uploadImage();
//接著呼叫發布文章API
await createPost(imageUrl);
//成功的話跳出成功訊息,並導回首頁
alert('Post created successfully!');
navigate("/");
} catch (error) {
console.error("An error occurred:", error);
alert('An error occurred:',error.message);
} finally {
setLoading(false);
}
}
const titleInputClasses = titleInputHasError ? 'border-red-500 focus:ring-red-500' : 'border-slate-300 focus:ring-sky-500' ;
const tagsInputClasses = tagsInputIsInValid ? 'border-red' : 'border-slate';
const contentClasses = contenttIsInValid ? 'border border-red-500 focus:border-red-500' : '';
return (
<div className="w-screen py-16">
<form className="w-[720px] mx-auto pb-20 pt-10" onSubmit={onSubmit}>
<h1 className=" mb-4 text-4xl text-black custom-font text-center">Create Post</h1>
<div className="mb-8">
<h3 className="text-xl mb-1">Title</h3>
<input
type="text"
value={title}
placeholder="請輸入文章標題"
onChange={titleChangeHandler}
onBlur={titleBlurHandler}
className={`mt-1 block w-full px-3 py-2 bg-white border
rounded-md text-sm shadow-sm placeholder-slate-400
focus:outline-none focus:ring-1 ${titleInputClasses}`}
/>
{titleInputHasError && <p className="text-red-500 text-sm">Title is required</p>}
</div>
<div className="mb-8">
<h3 className="text-xl mb-1">Tags</h3>
<TagsInput
value={tags}
classNames={{ input: tagsInputClasses}}
onChange={setTags}
onBlur={handleTagsInputBlur}
name="tags"
placeHolder="輸入文章分類"
/>
{ tagsInputIsInValid && <p className="text-red-500 text-sm">Please enter at least one tag</p>}
</div>
<div className="mb-8">
<h3 className="text-2xl mb-1">Cover Image</h3>
<input
type="file"
accept="image/jpeg, image/png"
onChange={onFileChange}
/>
</div>
<div className="mb-8">
<h3 className="text-2xl mb-1">Content</h3>
<ReactQuill
theme="snow"
placeholder="Enter your rich text edtior"
modules={modules}
value={content}
className={contentClasses}
onBlur={handleContentBlur}
onChange={handleContentChange}
/>
{contenttIsInValid && <p className="text-red-500 text-sm">Please enter valid content</p>}
</div>
<div className="text-right">
<button className="bg-violet-600 text-white px-6 py-2 rounded disabled:opacity-50" type="submit" disabled={loading || !formIsValid}>
{loading ? 'Uploading...' : 'Submit'}
</button>
</div>
</form>
</div>
);
};
export default EditPost;
今日程式碼在此