+5

Sử dụng Content Editable Elements trong JavaScript (React)

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

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-contenteditablesemantic-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ụng npm install -g npx để cài đặt npx nếu bạn chưa sử dụng nó trước đây. Nếu bạn đang sử dụng npm, sử dụng npm 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(/&nbsp;/g, '')
    .replace(/&amp;/g, '&')
    .replace(/&gt;/g, '>')
    .replace(/&lt;/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(/&nbsp;/g, '')
      .replace(/&amp;/g, '&')
      .replace(/&gt;/g, '>')
      .replace(/&lt;/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

Xem bản demo và mã nguồn

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(/&nbsp;/g, '')
        .replace(/&amp;/g, '&')
        .replace(/&gt;/g, '>')
        .replace(/&lt;/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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí