Použití prvků upravitelných obsahem v JavaScriptu (React)

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

Pokud to přidám do addRow způsob, mohu je opravit, než budou odeslány.

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,
    }
  })
}

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(/&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'))

Závěr

Doufám, že vám to pomůže!