import { classNamesToSelector, cellToText, columnToVisibleIndex, createElement, debounce, escapeText, visibleToColumnIndex } from "../helpers" import { cellType, dataRowType, rowRenderType, elementNodeType } from "../types" import {DataTable} from "../datatable" import {parseDate} from "../date" import { defaultConfig } from "./config" import {menuItemType, dataType, EditorOptions} from "./types" /** * Main lib * @param {Object} dataTable Target dataTable * @param {Object} options User config */ export class Editor { menuOpen!: boolean containerDOM!: HTMLElement data!: dataType disabled!: boolean dt: DataTable editing!: boolean editingCell!: boolean editingRow!: boolean event!: Event events!: { keydown: (event: KeyboardEvent) => void click: (event: MouseEvent) => void context?: (event: MouseEvent) => void updateMenu?: () => void dismissMenu?: (event: Event) => void reset?: () => void } initialized!: boolean limits!: {x: number, y: number} menuDOM!: HTMLElement modalDOM!: HTMLElement | false options: EditorOptions originalRowRender!: rowRenderType | false rect!: {width: number, height: number} wrapperDOM!: HTMLElement constructor(dataTable: DataTable, options: EditorOptions = {}) { this.dt = dataTable this.options = { ...defaultConfig, ...options } as EditorOptions } /** * Init instance * @return {Void} */ init() { if (this.initialized) { return } this.options.classes!.editable!.split(" ").forEach(className => this.dt.wrapperDOM.classList.add(className)) if (this.options.inline) { this.originalRowRender = this.dt.options.rowRender this.dt.options.rowRender = (row, tr, index) => { let newTr: elementNodeType | void = this.rowRender(row, tr, index) if (this.originalRowRender) { newTr = this.originalRowRender(row, newTr, index) } return newTr } } if (this.options.contextMenu) { this.containerDOM = createElement("div", { id: this.options.classes!.container! }) this.wrapperDOM = createElement("div", { class: this.options.classes!.wrapper! }) this.menuDOM = createElement("ul", { class: this.options.classes!.menu! }) if (this.options.menuItems && this.options.menuItems.length) { this.options.menuItems!.forEach((item: menuItemType) => { const li = createElement("li", { class: item.separator ? this.options.classes!.separator! : this.options.classes!.item! }) if (!item.separator) { const a = createElement("a", { class: this.options.classes!.action!, href: item.url || "#", html: (typeof item.text === "function" ? item.text(this) : item.text) || "" }) li.appendChild(a) if (item.action && typeof item.action === "function") { a.addEventListener("click", (event: Event) => { event.preventDefault() item.action!(this, event) }) } } this.menuDOM.appendChild(li) }) } this.wrapperDOM.appendChild(this.menuDOM) this.containerDOM.appendChild(this.wrapperDOM) this.updateMenu() } this.data = {} this.menuOpen = false this.editing = false this.editingRow = false this.editingCell = false this.disabled = false this.modalDOM = false this.limits = {x: 0, y: 0} this.rect = {width: 0, height: 0} this.bindEvents() setTimeout(() => { this.initialized = true this.dt.emit("editable.init") }, 10) } /** * Bind events to DOM * @return {Void} */ bindEvents() { this.events = { keydown: this.keydown.bind(this), click: this.click.bind(this) } // listen for click / double-click this.dt.dom.addEventListener(this.options.clickEvent!, this.events.click as EventListener) // listen for right-click document.addEventListener("keydown", this.events.keydown as EventListener) if (this.options.contextMenu) { this.events.context = this.context.bind(this) this.events.updateMenu = this.updateMenu.bind(this) this.events.dismissMenu = this.dismissMenu.bind(this) this.events.reset = debounce(() => this.events.updateMenu!(), 50) // listen for right-click this.dt.dom.addEventListener("contextmenu", this.events.context as EventListener) // listen for click everywhere except the menu document.addEventListener("click", this.events.dismissMenu as EventListener) // Reset contextmenu on browser window changes window.addEventListener("resize", this.events.reset) window.addEventListener("scroll", this.events.reset) } } /** * contextmenu listener * @param {Object} event Event * @return {Void} */ context(event: MouseEvent) { const target = event.target if (!(target instanceof Element)) { return } this.event = event const cell = target.closest("tbody td") if (!this.disabled && cell) { event.preventDefault() // get the mouse position let x = event.pageX let y = event.pageY // check if we're near the right edge of window if (x > this.limits.x) { x -= this.rect.width } // check if we're near the bottom edge of window if (y > this.limits.y) { y -= this.rect.height } this.wrapperDOM.style.top = `${y}px` this.wrapperDOM.style.left = `${x}px` this.openMenu() this.updateMenu() } } /** * dblclick listener * @param {Object} event Event * @return {Void} */ click(event: MouseEvent) { const target = event.target if (!(target instanceof Element)) { return } if (this.editing && this.data && this.editingCell) { const inputSelector = classNamesToSelector(this.options.classes!.input!) const input = this.modalDOM ? (this.modalDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) : (this.dt.wrapperDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) this.saveCell(input.value) } else if (!this.editing) { const cell = target.closest("tbody td") as HTMLTableCellElement if (cell) { this.editCell(cell) event.preventDefault() } } } /** * keydown listener * @param {Object} event Event * @return {Void} */ keydown(event: KeyboardEvent) { const inputSelector = classNamesToSelector(this.options.classes!.input!) if (this.modalDOM) { if (event.key === "Escape") { // close button if (this.options.cancelModal!(this)) { this.closeModal() } } else if (event.key === "Enter") { // save button // Save if (this.editingCell) { const input = (this.modalDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) this.saveCell(input.value) } else { const values = (Array.from(this.modalDOM.querySelectorAll(`input${inputSelector}[type=text]`)) as HTMLInputElement[]).map(input => input.value.trim()) this.saveRow(values, this.data.row!) } } } else if (this.editing && this.data) { if (event.key === "Enter") { // Enter key saves if (this.editingCell) { const input = (this.dt.wrapperDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) this.saveCell(input.value) } else if (this.editingRow) { const values = (Array.from(this.dt.wrapperDOM.querySelectorAll(`input${inputSelector}[type=text]`)) as HTMLInputElement[]).map(input => input.value.trim()) this.saveRow(values, this.data.row!) } } else if (event.key === "Escape") { // Escape key reverts if (this.editingCell) { this.saveCell(this.data.content!) } else if (this.editingRow) { this.saveRow(null, this.data.row!) } } } } /** * Edit cell * @param {Object} td The HTMLTableCellElement * @return {Void} */ editCell(td: HTMLTableCellElement) { const columnIndex = visibleToColumnIndex(td.cellIndex, this.dt.columns.settings) if (this.options.excludeColumns!.includes(columnIndex)) { this.closeMenu() return } const rowIndex = parseInt(td.parentElement!.dataset.index!, 10) const row = this.dt.data.data[rowIndex] const cell = row.cells[columnIndex] this.data = { cell, rowIndex, columnIndex, content: cellToText(cell) } this.editing = true this.editingCell = true if (this.options.inline) { this.dt.update() } else { this.editCellModal() } this.closeMenu() } editCellModal() { const cell = this.data.cell! const columnIndex = this.data.columnIndex! const label = (this.dt.data.headings[columnIndex].text || String(this.dt.data.headings[columnIndex].data)) as string const template = [ `
`, `
`, `

${this.options.labels!.editCell!}

`, ``, "
", `
`, `
`, `
`, ``, ``, "
", `
`, ``, ``, "
", "
", "
", "
" ].join("") const modalDOM = createElement("div", { class: this.options.classes!.modal!, html: template }) this.modalDOM = modalDOM this.openModal() const inputSelector = classNamesToSelector(this.options.classes!.input!) const input = (modalDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) input.focus() input.selectionStart = input.selectionEnd = input.value.length // Close / save modalDOM.addEventListener("click", (event: Event) => { const target = event.target if (!(target instanceof Element)) { return } if (target.hasAttribute("data-editor-cancel")) { // cancel button event.preventDefault() if (this.options.cancelModal!(this)) { this.closeModal() } } else if (target.hasAttribute("data-editor-save")) { // save button event.preventDefault() // Save this.saveCell(input.value) } }) } /** * Save edited cell * @param {Object} row The HTMLTableCellElement * @param {String} value Cell content * @return {Void} */ saveCell(value: string) { const oldData = this.data.content! // Get the type of that column const type = this.dt.columns.settings[this.data.columnIndex!].type || this.dt.options.type const stringValue = value.trim() let cell if (type === "number") { cell = {data: parseFloat(stringValue)} } else if (type === "boolean") { if (["", "false", "0"].includes(stringValue)) { cell = {data: false, text: "false", order: 0} } else { cell = {data: true, text: "true", order: 1} } } else if (type === "html") { cell = {data: [ {nodeName: "#text", data: value} ], text: value, order: value} } else if (type === "string") { cell = {data: value} } else if (type === "date") { const format = this.dt.columns.settings[this.data.columnIndex!].format || this.dt.options.format cell = {data: value, order: parseDate(String(value), format)} } else { cell = {data: value} } // Set the cell content const row = this.dt.data.data[this.data.rowIndex!] row.cells[this.data.columnIndex!] = cell this.closeModal() const rowIndex = this.data.rowIndex! const columnIndex = this.data.columnIndex! this.data = {} this.dt.update(true) this.editing = false this.editingCell = false this.dt.emit("editable.save.cell", value, oldData, rowIndex, columnIndex) } /** * Edit row * @param {Object} row The HTMLTableRowElement * @return {Void} */ editRow(tr: HTMLElement) { if (!tr || tr.nodeName !== "TR" || this.editing) return const rowIndex = parseInt(tr.dataset.index!, 10) const row = this.dt.data.data[rowIndex] this.data = { row: row.cells, rowIndex } this.editing = true this.editingRow = true if (this.options.inline) { this.dt.update() } else { this.editRowModal() } this.closeMenu() } editRowModal() { const row = this.data.row! const template = [ `
`, `
`, `

${this.options.labels!.editRow!}

`, ``, "
", `
`, `
`, `
`, ``, ``, "
", "
", "
", "
" ].join("") const modalDOM = createElement("div", { class: this.options.classes!.modal!, html: template }) const inner = modalDOM.firstElementChild if (!inner) { return } const form = inner.lastElementChild?.firstElementChild if (!form) { return } // Add the inputs for each cell row.forEach((cell: cellType, i: number) => { const columnSettings = this.dt.columns.settings[i] if ((!columnSettings.hidden || (columnSettings.hidden && this.options.hiddenColumns)) && !this.options.excludeColumns!.includes(i)) { const label = (this.dt.data.headings[i].text || String(this.dt.data.headings[i].data)) as string form.insertBefore(createElement("div", { class: this.options.classes!.row!, html: [ `
`, ``, ``, "
" ].join("") }), form.lastElementChild) } }) this.modalDOM = modalDOM this.openModal() // Grab the inputs const inputSelector = classNamesToSelector(this.options.classes!.input!) const inputs = Array.from(form.querySelectorAll(`input${inputSelector}[type=text]`)) as HTMLInputElement[] // Close / save modalDOM.addEventListener("click", (event: MouseEvent) => { const target = event.target if (!(target instanceof Element)) { return } if (target.hasAttribute("data-editor-cancel")) { // cancel button if (this.options.cancelModal!(this)) { this.closeModal() } } else if (target.hasAttribute("data-editor-save")) { // save button // Save const values = inputs.map((input: HTMLInputElement) => input.value.trim()) this.saveRow(values, this.data.row!) } }) } /** * Save edited row * @param {Object} row The HTMLTableRowElement * @param {Array} data Cell data * @return {Void} */ saveRow(data: string[] | null, row: cellType[]) { // Store the old data for the emitter const oldData = row.map((cell: cellType) => cellToText(cell)) const updatedRow = this.dt.data.data[this.data.rowIndex!] if (data) { let valueCounter = 0 updatedRow.cells = row.map((oldItem, colIndex) => { if (this.options.excludeColumns!.includes(colIndex) || this.dt.columns.settings[colIndex].hidden) { return oldItem } const type = this.dt.columns.settings[colIndex].type || this.dt.options.type const value = data[valueCounter++] let cell if (type === "number") { cell = {data: parseFloat(value)} } else if (type === "boolean") { if (["", "false", "0"].includes(value)) { cell = {data: false, text: "false", order: 0} } else { cell = {data: true, text: "true", order: 1} } } else if (type === "html") { cell = { data: [ {nodeName: "#text", data: value} ], text: value, order: value } } else if (type === "string") { cell = {data: value} } else if (type === "date") { const format = this.dt.columns.settings[colIndex].format || this.dt.options.format cell = {data: value, order: parseDate(String(value), format)} } else { cell = {data: value} } return cell }) } const newData = updatedRow.cells.map(cell => cellToText(cell)) this.data = {} this.dt.update(true) this.closeModal() this.editing = false this.dt.emit("editable.save.row", newData, oldData, row) } /** * Open the row editor modal * @return {Void} */ openModal() { if (this.modalDOM) { document.body.appendChild(this.modalDOM) } } /** * Close the row editor modal * @return {Void} */ closeModal() { if (this.editing && this.modalDOM) { document.body.removeChild(this.modalDOM) this.modalDOM = this.editing = this.editingRow = this.editingCell = false } } /** * Remove a row * @param {Object} tr The HTMLTableRowElement * @return {Void} */ removeRow(tr: HTMLElement) { if (!tr || tr.nodeName !== "TR" || this.editing) return const index = parseInt(tr.dataset.index!, 10) this.dt.rows.remove(index) this.closeMenu() } /** * Update context menu position * @return {Void} */ updateMenu() { const scrollX = window.scrollX || window.pageXOffset const scrollY = window.scrollY || window.pageYOffset this.rect = this.wrapperDOM.getBoundingClientRect() this.limits = { x: window.innerWidth + scrollX - this.rect.width, y: window.innerHeight + scrollY - this.rect.height } } /** * Dismiss the context menu * @param {Object} event Event * @return {Void} */ dismissMenu(event: Event) { const target = event.target if (!(target instanceof Element) || this.wrapperDOM.contains(target)) { return } let valid = true if (this.editing) { const inputSelector = classNamesToSelector(this.options.classes!.input!) valid = !(target.matches(`input${inputSelector}[type=text]`)) } if (valid) { this.closeMenu() } } /** * Open the context menu * @return {Void} */ openMenu() { if (this.editing && this.data && this.editingCell) { const inputSelector = classNamesToSelector(this.options.classes!.input!) const input = this.modalDOM ? (this.modalDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) : (this.dt.wrapperDOM.querySelector(`input${inputSelector}[type=text]`) as HTMLInputElement) this.saveCell(input.value) } document.body.appendChild(this.containerDOM) this.menuOpen = true this.dt.emit("editable.context.open") } /** * Close the context menu * @return {Void} */ closeMenu() { if (this.menuOpen) { this.menuOpen = false document.body.removeChild(this.containerDOM) this.dt.emit("editable.context.close") } } /** * Destroy the instance * @return {Void} */ destroy() { this.dt.dom.removeEventListener(this.options.clickEvent!, this.events.click as EventListener) this.dt.dom.removeEventListener("contextmenu", this.events.context as EventListener) document.removeEventListener("click", this.events.dismissMenu as EventListener) document.removeEventListener("keydown", this.events.keydown as EventListener) window.removeEventListener("resize", this.events.reset as EventListener) window.removeEventListener("scroll", this.events.reset as EventListener) if (document.body.contains(this.containerDOM)) { document.body.removeChild(this.containerDOM) } if (this.options.inline) { this.dt.options.rowRender = this.originalRowRender } this.initialized = false } rowRender(row: dataRowType, tr: elementNodeType, index: number): elementNodeType { if (!this.data || this.data.rowIndex !== index) { return tr } if (this.editingCell) { // cell editing const cell = tr.childNodes![columnToVisibleIndex(this.data.columnIndex!, this.dt.columns.settings)] as elementNodeType cell.childNodes = [ { nodeName: "INPUT", attributes: { type: "text", value: this.data.content!, class: this.options.classes!.input! } } ] } else { // row editing // Add the inputs for each cell tr.childNodes!.forEach((cell, i) => { const colIndex = visibleToColumnIndex(i, this.dt.columns.settings) const dataCell = row.cells[colIndex] if (!this.options.excludeColumns!.includes(colIndex)) { const tableCell = tr.childNodes![i] as elementNodeType tableCell.childNodes = [ { nodeName: "INPUT", attributes: { type: "text", value: escapeText(dataCell.text || String(dataCell.data) || ""), class: this.options.classes!.input! } } ] } }) } return tr } } export const makeEditable = function(dataTable: DataTable, options: EditorOptions = {}) { const editor = new Editor(dataTable, options) if (dataTable.initialized) { editor.init() } else { dataTable.on("datatable.init", () => editor.init()) } return editor }