Jakýkoli prvek lze upravit přidáním contenteditable
atribut. Tento atribut se používá na celém webu, například v Tabulkách Google. Nebudu vám říkat, abyste používali nebo nepoužívali contenteditable
prvky ve vaší aplikaci. Pokud se rozhodnete použít contenteditable
, může se vám tento článek hodit.
Podělím se o spoustu věcí, které jsem zjistil při používání contenteditable
, aby to někdo jiný mohl najít na jednom místě.
Předpoklady
V tomto článku můžete najít něco užitečného, pokud provádíte jakoukoli práci v JavaScriptu s contenteditable
, ale budu používat své příklady s Reactem. Už byste měli znát JavaScript, vědět o Node, nastavení projektu React s create-react-app
, atd.
- Začínáme s Reactem – přehled a návod – pokud jste React nikdy nepoužívali.
Jako vždy se nestarám o uživatelské rozhraní/design, pokud jde o články o funkčnosti, takže budu používat prvky Semantic UI React k připojení jednoduchých výchozích stylů.
Cíle
Vytvořím jednoduchou tabulku CRUD v Reactu pomocí ContentEditable
komponent. Ukážu vám několik problémů, se kterými se můžete setkat, a řešení, která jsem použil.
Zde jsou problémy:
- Vkládání
- Mezery a speciální znaky
- Nové řádky
- Zvýraznění
- Zaměření
A pak něco o číslech/měnách a úpravách existujících řádků.
- Zobrazit dokončenou ukázku a zdroj
Nastavení
Zde je ukázka počátečního kódu CodeSandbox.
Chystám se nastavit projekt React v ce-app
.
npx create-react-app ce-app && cd ce-app
Přidejte react-contenteditable
a semantic-ui-react
jako závislosti. Reag-contenteditable je opravdu pěkná komponenta, která usnadňuje práci s contenteditable
snesitelnější.
yarn add react-contenteditable semantic-ui-react
Pro jednoduchost vše vložím do index.js
. Právě načítám všechny závislosti a vytvářím App
komponenta, uvedení některých falešných dat do stavu,
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'))
Tabulka obsahuje položky Item, Price a Action jako záhlaví a mapuje stav pro každý řádek. Každá buňka má ContentEditable
komponentu nebo akci pro odstranění řádku nebo přidání nového řádku.
<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>
Začneme třemi způsoby:jedním přidáte řádek, který aktualizuje úložiště novým řádkem, a vyprázdní stávající řádek; druhý k odstranění existujícího řádku.
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),
}))
}
Nakonec tu máme handleContentEditable
komponenta, která bude vyvolána při každé změně ContentEditable
, prostřednictvím onChange
. Aby bylo možné použít jednu funkci s mnoha možnými sloupci, přidal jsem data-column
atribut ke komponentě, takže dostanu klíč (sloupec) a hodnotu každého ContentEditable
a nastavte row
.
handleContentEditable = (event) => {
const { row } = this.state
const {
currentTarget: {
dataset: { column },
},
target: { value },
} = event
this.setState({ row: { ...row, [column]: value } })
}
A trochu CSS, aby to vypadalo sluš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;
}
Opět, v tomto bodě můžete vidět dokončené nastavení na tomto demu, pokud jste se někde ztratili.
Po dokončení nastavení máte tabulku, do které můžete přidat nový řádek pomocí contenteditable
, na rozdíl od input
nebo textarea
, a proto mají úplnou kontrolu nad stylem prvku.
Problém 1:Vkládání
Dobře, takže teď máte svou aplikaci. Pracovitý uživatel si myslí, že můžu jen zkopírovat a vložit z Tabulek Google nebo Excelu, místo abych vše zadával ručně!
Dovolte mi to zkopírovat...
Vložte jej do...
Vypadá dobře. Předložme toho zlého chlapce.
Co? contenteditable
prvky si zachovávají styl formátování textu. Ani vložení přímo z textového editoru nevloží prostý text. Nic není bezpečné.
Protože samozřejmě nechceme, aby se sem zasílal HTML, musíme vytvořit funkci, která pouze vloží text a ne formátování.
pasteAsPlainText = (event) => {
event.preventDefault()
const text = event.clipboardData.getData('text/plain')
document.execCommand('insertHTML', false, text)
}
Můžeme to dát na onPaste
z ContentEditable
.
<ContentEditable onPaste={this.pasteAsPlainText} />
Vydání 2:Mezery a speciální znaky
Můžete napsat něco s mezerami, odeslat to a bude to v pořádku.
Skvělé, takže mezery nejsou problém s contenteditable
, že?
Podívejme se, co se stane, když jej uživatel odněkud vloží a omylem zachová mezeru před a za frází.
Skvělý. &nsbp;
, nepřerušitelný prostor, který jste použili k formátování svého webu v roce 1998, zůstane zachován na začátku a na konci. Nejen to, ale také méně než, větší než a ampersand.
Takže jsem jen trochu našel a nahradil tyto znaky.
const trimSpaces = (string) => {
return string
.replace(/ /g, '')
.replace(/&/g, '&')
.replace(/>/g, '>')
.replace(/</g, '<')
}
Pokud to přidám do addRow
způsob, mohu je opravit, než budou odeslány.
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,
}
})
}
Vydání 3:Nové řádky
Není nad rámec možností předpokládat, že by se váš uživatel mohl pokusit stisknout klávesu Enter místo tabulátoru, aby se dostal k další položce.
Což vytvoří nový řádek.
Což bude bráno doslova jako contenteditable
.
Takže to můžeme zakázat. 13
je klíčový kód pro vstup.
disableNewlines = (event) => {
const keyCode = event.keyCode || event.which
if (keyCode === 13) {
event.returnValue = false
if (event.preventDefault) event.preventDefault()
}
}
To půjde na onKeyPress
atribut.
<ContentEditable onKeyPress={this.disableNewlines} />
Problém 4:Zvýraznění
Když procházíme tabulátorem contenteditable
prvek, který tam již je, se kurzor vrátí zpět na začátek div. To není moc užitečné. Místo toho udělám funkci, která při výběru zvýrazní celý prvek, buď tabulátorem nebo myší.
highlightAll = () => {
setTimeout(() => {
document.execCommand('selectAll', false, null)
}, 0)
}
To půjde na onFocus
atribut.
<ContentEditable onFocus={this.highlightAll} />
Problém 5:Zaměření po odeslání
V současné době se po odeslání řádku ztrácí fokus, což znemožňuje pěkný tok při vyplňování této tabulky. V ideálním případě by se zaměřil na první položku v novém řádku po odeslání řádku.
Nejprve vytvořte ref
pod stavem.
firstEditable = React.createRef()
Na konci addRow
funkci, zaměřte se na firstEditable
aktuální div
.
this.firstEditable.current.focus()
ContentEditable
pohodlně má innerRef
atribut, který k tomu můžeme použít.
<ContentEditable innerRef={this.firstEditable} />
Nyní po odeslání řádku se již soustředíme na další řádek.
Zacházení s čísly a měnou
Toto není zcela specifické pro contenteditable
, ale protože jako jednu z hodnot používám cenu, zde jsou některé funkce pro práci s měnou a čísly.
Můžete použít <input type="number">
povolit pouze čísla na frontendu v HTML, ale musíme vytvořit vlastní funkci pro ContentEditable
. U řetězce jsme museli zabránit novým řádkům na keyPress
, ale pro měnu povolíme pouze .
, ,
a 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()
}
}
Samozřejmě to stále umožní nesprávně formátovaná čísla, jako je 1,00,0.00.00
skrz, ale zde ověřujeme pouze vstup jediného stisknutí klávesy.
<ContentEditable onKeyPress={this.validateNumber} />
Úprava existujících řádků
Konečně, právě teď můžeme upravit pouze poslední řádek - jakmile byl přidán řádek, jediný způsob, jak jej změnit, je smazat jej a vytvořit nový. Bylo by hezké, kdybychom mohli upravovat každý jednotlivý řádek v reálném čase, ne?
Vytvořím novou metodu jen pro aktualizaci. Je to podobné jako u řádku s tím rozdílem, že místo změny stavu nového řádku se mapuje přes úložiště a aktualizuje se na základě indexu. Přidal jsem ještě jeden data
atribut – řádek.
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
}),
}
})
}
Místo pouhého zobrazení hodnot v řádcích budou všechny 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>
...
)
})}
Nakonec přidám disabled={!item || !price}
na Button
prvek, který zabrání průchodu prázdných položek. A máme hotovo!
Úplný kód
Zobrazit dokončené demo a zdroj
Tady je vše pro případ, že by něco nedávalo smysl. Kliknutím na ukázku výše zobrazíte zdroj CodeSandbox a rozhraní frontend.
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'))
Závěr
Doufám, že vám to pomůže!