Sử dụng Content Editable Elements trong JavaScript (React)
Bài đăng này đã không được cập nhật trong 4 năm
Link bài viết gốc: https://www.taniarascia.com/content-editable-elements-in-javascript-react/
.
Tất cả các thành phần đều có thể chỉnh sửa bằng cách thêm thuộc tính contenteditable
- nó giống như thẻ input
vậy, nhưng thay vào đó bạn có thể gõ văn bản trong một thẻ div, thẻ a, ....
Thuộc tính này đã được sử dụng trên rất nhiều trang web, tiêu biểu như là Google Sheets. Trong bài viết này, tôi sẽ không nói cho bạn biết bạn có nên sử dụng thành phần với thuộc tính contenteditable
trong app của bạn hay không. Nhưng nếu bạn muốn sử dụng contenteditable
, bài viết này có thể hữu ích cho bạn.
Tôi sẽ chia sẻ tất cả các kiến thức mà tôi tìm hiểu khi sử dụng contenteditable
, để mọi người có thể hiểu hết về nó chỉ với bài viết này.
Yêu cầu
Bạn có thể tìm ra một vài kiến thức hữu ích trong bài viết này nếu bạn đang làm việc một trong dự án có sử dụng contentediable
, tuy nhiên tôi sẽ sử dụng React trong bài viết này. Bạn cần biết một số kiến thức về Javascript, Node, sử dụng create-react-app, ...
- Getting Started with React - an Overview and Walkthrough - Nếu bạn chưa bao giờ sử dụng React.
Như mọi khi, tôi không quan tâm lắm về UI/design khi viết các bài viết về chức năng, vì vậy tôi sẽ sử dụng Semantic UI React, để có một số UI đơn giản.
Mục tiêu
Tôi sẽ tạo một bảng CRUD đơn giản với React và sử dụng ContentEditable component. Tôi cũng sẽ chỉ cho bạn một số vấn đề bạn có thể gặp phải, và cách tôi giải quyết chúng.
Đây là danh sách các vấn đề:
- Coppy & Paste
- Khoảng trắng và ký tự đặc biệt
- Xuống dòng
- Highlight đoạn văn bản
- Focusing
Và một số vấn đề về số/ tiền tệ và chỉnh sửa các hàng có sẵn.
Cài đặt
Bạn có thể dùng CodeSandbox demo này cho dự án mới.
Tôi sẽ tạo một dự án React với ce-app.
npx create-react-app ce-app && cd ce-app
cài đặt thêm react-contenteditable
và semantic-ui-react
. react-contenteditable
là một component rất tốt để sử dụng, nó giúp chúng ta làm việc với contenteditable
dễ dàng hơn.
yarn add react-contenteditable semantic-ui-react
npx
không phải là một lỗi cú pháp. Sử dụngnpm install -g npx
để cài đặtnpx
nếu bạn chưa sử dụng nó trước đây. Nếu bạn đang sử dụngnpm
, sử dụngnpm i react-contenteditable semantic-ui-react
thay cho câu lệnh trên.
Để cho đơn giản, tôi sẽ để tất cả code trong index.js. Đọan code bên dưới tôi sẽ load hết các thư viện, tạo một số fake data trong state.
index.js
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import ContentEditable from 'react-contenteditable'
import { Table, Button } from 'semantic-ui-react'
import './styles.css'
class App extends Component {
initialState = {
store: [
{ id: 1, item: 'silver', price: 15.41 },
{ id: 2, item: 'gold', price: 1284.3 },
{ id: 3, item: 'platinum', price: 834.9 },
],
row: {
item: '',
price: '',
},
}
state = this.initialState
// Methods will go here
render() {
const {
store,
row: { item, price },
} = this.state
return (
<div className="App">
<h1>React Contenteditable</h1>
{/* Table will go here */}
</div>
)
}
}
ReactDOM.render(<App />, document.getElementById('root'))
Bảng này sẽ có các trường Item, Price, Action và tương ứng với giữ liệu trong state trên mỗi hàng. Mỗi ô trong bảng sẽ có một ContentEditable component, hoặc một action xóa hàng hoặc thêm hàng mới.
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Item</Table.HeaderCell>
<Table.HeaderCell>Price</Table.HeaderCell>
<Table.HeaderCell>Action</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{store.map(row => {
return (
<Table.Row key={row.id}>
<Table.Cell>{row.item}</Table.Cell>
<Table.Cell>{row.price}</Table.Cell>
<Table.Cell className="narrow">
<Button
onClick={() => {
this.deleteRow(row.id)
}}
>
Delete
</Button>
</Table.Cell>
</Table.Row>
)
})}
<Table.Row>
<Table.Cell className="narrow">
<ContentEditable
html={item}
data-column="item"
className="content-editable"
onChange={this.handleContentEditable}
/>
</Table.Cell>
<Table.Cell className="narrow">
<ContentEditable
html={price}
data-column="price"
className="content-editable"
onChange={this.handleContentEditable}
/>
</Table.Cell>
<Table.Cell className="narrow">
<Button onClick={this.addRow}>Add</Button>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
Chúng ta bắt đầu với 2 methods: thêm một hàng mới và xóa hàng hiện tại.
addRow = () => {
this.setState(({ row, store }) => {
return {
store: [...store, { ...row, id: store.length + 1 }],
row: this.initialState.row,
}
})
}
deleteRow = id => {
this.setState(({ store }) => ({
store: store.filter(item => id !== item.id),
}))
}
Cuối cùng, chúng ta có hàm handleContentEditable
, hàm này sẽ được gọi mỗi khi có sự thay đổi trên ContentEditable
, với onChange
. Vì phải sử dùng hàm này trên nhiều cột, tôi thêm một thuộc tích là data-column
cho componet, sau đó có thể lấy ra key và giá trị của mỗi ContentEditable
component.
handleContentEditable = event => {
const { row } = this.state
const {
currentTarget: {
dataset: { column },
},
target: { value },
} = event
this.setState({ row: { ...row, [column]: value } })
}
Thêm một chút CSS để giao diện dễ nhìn hơn.
.App {
margin: 2rem auto;
max-width: 800px;
font-family: sans-serif;
}
.ui.table td {
padding: 1rem;
}
.ui.table td.narrow {
padding: 0;
}
.ui.button {
margin: 0 0.5rem;
}
.content-editable {
padding: 1rem;
}
.content-editable:hover {
background: #f7f7f7;
}
.content-editable:focus {
background: #fcf8e1;
outline: 0;
}
Tôi xin nhắc lại lần nữa, bạn có thể xem bản demo đã hoàn thành ở đây.
Kết thúc phần cài đặt, chúng ta có một bảng với các chức năng, thêm một hàng sử dụng contenteditable
, dễ dàng sửa đổi style cho phần thử.
Vấn đề 1: Coppy & Paste
Okay, giờ bạn đã có ứng dụng của mình. Một người dùng siêng năng có thể sẽ nghĩ, oh, tôi có thể chỉ cần coppy và paste từ Google Sheets hoặc Excel thay vì phải gõ phim!
Để tôi coppy một dòng...
Và dán nó vào..
Nhìn nó khả ổn. Tôi sẽ thử coppy một đoạn văn bản khác.
Uh, what? phần tử contentediable
giữ lại cả định dạng của đoạn văn bản. Ngay cả khi bạn coppy trực tiếp từ text editor của bạn nó cũng không coppy văn bản đơn thuần. Việc này là không an toàn.
Vì rõ ràng chúng ta không muốn HTML được gửi ở đây, chúng ta cần tạo một hàm để chỉ dán văn bản chứ không phải định dạng.
pasteAsPlainText = event => {
event.preventDefault()
const text = event.clipboardData.getData('text/plain')
document.execCommand('insertHTML', false, text)
}
Chúng ta có thể dùng hàm này với onPaste
trên ContentEditable
.
<ContentEditable onPaste={this.pasteAsPlainText} />
Vấn đề 2: Khoảng trắng và ký tự đặc biệt
Bạn có thể nhập một số khoảng trắng trong đó, gửi nó, và hóa ra mọi thứ đều ổn.
Cool, vậy là khoảng trắng không phải là vấn đề với contenteditable
.
Hãy xem điều gì sẽ xảy ra khi người dùng của bạn coppy vằn bản từ một nơi nào đó và vô tình giữ lại khoảng trống trước và sau đoặn văn bản.
Tuyệt. &nsbp;
, ký tự non-breaking space
bạn sử dụng để định dạng website của bạn năm 1998 vẫn được giữ lại ở đầu và cuối văn bản. Ngoài ra còn thêm một số ký tự đặc biệt khác.
Vì vậy tôi đã viết một hàm để tìm và thay thế các ký tự này.
const trimSpaces = string => {
return string
.replace(/ /g, '')
.replace(/&/g, '&')
.replace(/>/g, '>')
.replace(/</g, '<')
}
Nếu tôi đặt nó vào hàm addRow
, tôi có thể sửa nó trước khi người dùng xác nhận.
addRow = () => {
const trimSpaces = string => {
return string
.replace(/ /g, '')
.replace(/&/g, '&')
.replace(/>/g, '>')
.replace(/</g, '<')
}
this.setState(({ store, row }) => {
const trimmedRow = {
...row,
item: trimSpaces(row.item),
id: store.length + 1,
}
return {
store: [...store, trimmedRow],
row: this.initialState.row,
}
})
}
Vấn đề 3: Xuống dòng
Đôi khi người dùng có thể thử bấm enter thay vì tab để chuyển đến item tiếp theo.
Việc này sẽ tạo một dòng mới
Và nó được chuyển đổi với contenteditable
.
Vì vậy chúng ta nên vô hiệu hóa nó. 13
là key code của phím enter.
disableNewlines = event => {
const keyCode = event.keyCode || event.which
if (keyCode === 13) {
event.returnValue = false
if (event.preventDefault) event.preventDefault()
}
}
Hàm trên sẽ được đặt vào onKeyPress
.
<ContentEditable onKeyPress={this.disableNewlines} />
Vấn đề 4: Highlight đoạn văn bản
Khi chúng ta bấm tab để chuyển đổi qua lại giữa các phần tử, con trỏ sẽ quay trở lại điểm bắt đầu của thẻ div. Điều này không hữu ích lắm. Thay vào đó, tôi sẽ tạo một chức năng highlight toàn bộ thành phần khi được chọn, bằng tab hoặc chuột.
highlightAll = () => {
setTimeout(() => {
document.execCommand('selectAll', false, null)
}, 0)
}
Hàm trên sẽ được đặt vào onFocus
.
<ContentEditable onFocus={this.highlightAll} />
Vấn đề 5: Focus sau khi xác nhận
Hiện tại, sau khi tạo một hàng, con trỏ bị mất, điều này khiến cho việc có một trải nghiệm tốt khi điền vào bảng là không thể. Vì vậy, lý tưởng nhất, con trỏ sẽ được đặt trong hàng mới sau khi tạo một hàng.
Đầu tiên, tạo một ref
bên dưới state.
firstEditable = React.createRef()
Ở cưới hàm addRow
, focus vào firstEditable
div hiện tại.
this.firstEditable.current.focus()
ContentEditable đã có sẵn thuộc tính innerRef
, chúng ta có thể sử dụng nó.
<ContentEditable innerRef={this.firstEditable} />
Bây giờ sau khi tạo hàng mới, chúng ta đã được focus vào hàng tiếp theo.
Xử lý số và giá tiền
Đây là không phải là vấn đề thường gặp khi sử dụng contenteditable
, nhưng tôi đang sử dụng giá tiền, dưới đây là một số hàm xử lý số và giá tiền.
Bạn có thể sử dụng <input type="number">
để người dùng chỉ có thể nhập số trong HTML, nhưng ở đây chúng ta phải tạo hàm để tự xác thực cho ContentEditable
. Với chuỗi, chúng ta phải chặn việc xuống dòng với keyPress
,còn với giá tiền, chúng ta chỉ cho phép các ký tự .
, ,
, và 0-9
.
validateNumber = event => {
const keyCode = event.keyCode || event.which
const string = String.fromCharCode(keyCode)
const regex = /[0-9,]|\./
if (!regex.test(string)) {
event.returnValue = false
if (event.preventDefault) event.preventDefault()
}
}
Tất nhiên, sẽ vẫn có một số trường hợp không đúng định dạng như 1,00,0.00.00
, tuy nhiên chúng ta chỉ xác thực mỗi lần bấm phím ở đây.
<ContentEditable onKeyPress={this.validateNumber} />
Chỉnh sửa hàng có sẵn
Cuối cùng, bạn chỉ có thể chỉnh sử hàng cuối cùng bằng cách xóa nó đi và tạo một hàng mới. Nếu ó thể chỉnh sửa từng hàng thì sẽ tốt hơn, bạn nghĩ vậy chứ?
Tôi sẽ tạo một phương thức mới cho việc cập nhật. Nó tương tự như tạo hàng mới, ngoại trừ thay vì thay đổi trạng thái của hàng mới, nó ánh xạ qua store và cập nhật dựa trên chỉ mục. Tôi cũng đã thêm một thuộc tính data-row
.
handleContentEditableUpdate = event => {
const {
currentTarget: {
dataset: { row, column },
},
target: { value },
} = event
this.setState(({ store }) => {
return {
store: store.map(item => {
return item.id === parseInt(row, 10) ? { ...item, [column]: value } : item
}),
}
})
}
Thay vì chỉ hiển thị giá trị trên mỗi hàng, giờ thực sự chúng đã trở thành ContentEditable
.
{store.map((row, i) => {
return (
<Table.Row key={row.id}>
<Table.Cell className="narrow">
<ContentEditable
html={row.item}
data-column="item"
data-row={row.id}
className="content-editable"
onKeyPress={this.disableNewlines}
onPaste={this.pasteAsPlainText}
onFocus={this.highlightAll}
onChange={this.handleContentEditableUpdate}
/>
</Table.Cell>
<Table.Cell className="narrow">
<ContentEditable
html={row.price.toString()}
data-column="price"
data-row={row.id}
className="content-editable"
onKeyPress={this.validateNumber}
onPaste={this.pasteAsPlainText}
onFocus={this.highlightAll}
onChange={this.handleContentEditableUpdate}
/>
</Table.Cell>
...
)
})}
Cuối cùng, tôi sẽ thêm disabled={!item || !price}
trên Button để chặn các giá trị rỗng. Và chúng ta đã hoàn thành.
Complete Code
Dưới đây là toàn bộ code, trong trường hợp bạn không hiểu điều gì. Click vào link demo ở trên để xem CodeSandbox source và front end.
import React, { Component } from 'react'
import ReactDOM from 'react-dom'
import ContentEditable from 'react-contenteditable'
import { Table, Button } from 'semantic-ui-react'
import './styles.css'
class App extends Component {
initialState = {
store: [
{ id: 1, item: 'silver', price: 15.41 },
{ id: 2, item: 'gold', price: 1284.3 },
{ id: 3, item: 'platinum', price: 834.9 },
],
row: {
item: '',
price: '',
},
}
state = this.initialState
firstEditable = React.createRef()
addRow = () => {
const { store, row } = this.state
const trimSpaces = string => {
return string
.replace(/ /g, '')
.replace(/&/g, '&')
.replace(/>/g, '>')
.replace(/</g, '<')
}
const trimmedRow = {
...row,
item: trimSpaces(row.item),
}
row.id = store.length + 1
this.setState({
store: [...store, trimmedRow],
row: this.initialState.row,
})
this.firstEditable.current.focus()
}
deleteRow = id => {
const { store } = this.state
this.setState({
store: store.filter(item => id !== item.id),
})
}
disableNewlines = event => {
const keyCode = event.keyCode || event.which
if (keyCode === 13) {
event.returnValue = false
if (event.preventDefault) event.preventDefault()
}
}
validateNumber = event => {
const keyCode = event.keyCode || event.which
const string = String.fromCharCode(keyCode)
const regex = /[0-9,]|\./
if (!regex.test(string)) {
event.returnValue = false
if (event.preventDefault) event.preventDefault()
}
}
pasteAsPlainText = event => {
event.preventDefault()
const text = event.clipboardData.getData('text/plain')
document.execCommand('insertHTML', false, text)
}
highlightAll = () => {
setTimeout(() => {
document.execCommand('selectAll', false, null)
}, 0)
}
handleContentEditable = event => {
const { row } = this.state
const {
currentTarget: {
dataset: { column },
},
target: { value },
} = event
this.setState({ row: { ...row, [column]: value } })
}
handleContentEditableUpdate = event => {
const {
currentTarget: {
dataset: { row, column },
},
target: { value },
} = event
this.setState(({ store }) => {
return {
store: store.map(item => {
return item.id === parseInt(row, 10) ? { ...item, [column]: value } : item
}),
}
})
}
render() {
const {
store,
row: { item, price },
} = this.state
return (
<div className="App">
<h1>React Contenteditable</h1>
<Table celled>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Item</Table.HeaderCell>
<Table.HeaderCell>Price</Table.HeaderCell>
<Table.HeaderCell>Action</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{store.map((row, i) => {
return (
<Table.Row key={row.id}>
<Table.Cell className="narrow">
<ContentEditable
html={row.item}
data-column="item"
data-row={i}
className="content-editable"
onKeyPress={this.disableNewlines}
onPaste={this.pasteAsPlainText}
onFocus={this.highlightAll}
onChange={this.handleContentEditableUpdate}
/>
</Table.Cell>
<Table.Cell className="narrow">
<ContentEditable
html={row.price.toString()}
data-column="price"
data-row={i}
className="content-editable"
onKeyPress={this.validateNumber}
onPaste={this.pasteAsPlainText}
onFocus={this.highlightAll}
onChange={this.handleContentEditableUpdate}
/>
</Table.Cell>
<Table.Cell className="narrow">
<Button
onClick={() => {
this.deleteRow(row.id)
}}
>
Delete
</Button>
</Table.Cell>
</Table.Row>
)
})}
<Table.Row>
<Table.Cell className="narrow">
<ContentEditable
html={item}
data-column="item"
className="content-editable"
innerRef={this.firstEditable}
onKeyPress={this.disableNewlines}
onPaste={this.pasteAsPlainText}
onFocus={this.highlightAll}
onChange={this.handleContentEditable}
/>
</Table.Cell>
<Table.Cell className="narrow">
<ContentEditable
html={price}
data-column="price"
className="content-editable"
onKeyPress={this.validateNumber}
onPaste={this.pasteAsPlainText}
onFocus={this.highlightAll}
onChange={this.handleContentEditable}
/>
</Table.Cell>
<Table.Cell className="narrow">
<Button disabled={!item || !price} onClick={this.addRow}>
Add
</Button>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</div>
)
}
}
ReactDOM.render(<App />, document.getElementById('root'))
All rights reserved