From 536ab439aa58c6a8b52b0b7cb7e0df4e082e632d Mon Sep 17 00:00:00 2001 From: blankie Date: Thu, 19 Oct 2023 15:46:45 +1100 Subject: [PATCH] Initial commit --- .gitlab-ci.yml | 9 ++ LICENSE | 19 +++ deserialize_table.mjs | 126 +++++++++++++++ index.html | 73 +++++++++ main.mjs | 362 ++++++++++++++++++++++++++++++++++++++++++ serialize_table.mjs | 119 ++++++++++++++ style.css | 68 ++++++++ tables.mjs | 4 + 8 files changed, 780 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 LICENSE create mode 100644 deserialize_table.mjs create mode 100644 index.html create mode 100644 main.mjs create mode 100644 serialize_table.mjs create mode 100644 style.css create mode 100644 tables.mjs diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a04e9bd --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,9 @@ +image: scratch + +pages: + stage: deploy + artifacts: + paths: + - . + only: + - master diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4314df --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 blankie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/deserialize_table.mjs b/deserialize_table.mjs new file mode 100644 index 0000000..b3b1478 --- /dev/null +++ b/deserialize_table.mjs @@ -0,0 +1,126 @@ +/* + * line: string, must be trimmed of whitespace beforehand + * tableItems: int | null, result output will be padded or trimmed to it if specified + * + * Returns an array of strings + */ +function splitByPipe(line, tableItems = null) { + let res = []; + let item = ""; + let consecutiveBackslashes = 0; + + if (line.startsWith("|")) { + line = line.substring(1); + } + + for (let char of line) { + if (char === "\\") { + item += "\\"; + consecutiveBackslashes++; + } else if (char === "|" && consecutiveBackslashes % 2 === 1) { + item = item.substring(0, item.length - 1) + "|"; + consecutiveBackslashes = 0; + } else if (char === "|" && consecutiveBackslashes % 2 === 0) { + item = item.trim(); + res.push(item); + + item = ""; + consecutiveBackslashes = 0; + } else { + item += char; + consecutiveBackslashes = 0; + } + } + item = item.trim(); + if (item.length !== 0) { + res.push(item); + } + + while (tableItems !== null && res.length < tableItems) { + res.push(""); + } + while (tableItems !== null && res.length > tableItems) { + res.pop(); + } + return res; +} + +/* + * line: string, must be trimmed of whitespace beforehand + * headerRow: array of strings + * + * Returns an array of objects, which has the following keys: + * - text: string + * - leftAligned: boolean + * - rightAligned: boolean + */ +function parseDelimiterRow(line, headerRow) { + let res = []; + let delimiterRow = splitByPipe(line); + + if (delimiterRow.length !== headerRow.length) { + throw new Error(`delimiter row is not as long as the header row (${delimiterRow.length} !== ${headerRow.length})`); + } + + for (let i = 0; i < headerRow.length; i++) { + let header = headerRow[i]; + let delimiter = delimiterRow[i]; + let match = delimiter.match(/^(:)?-+(:)?$/); + + if (match === null) { + throw new Error(`invalid delimiter row (index: ${i}): ${delimiter}`); + } + res.push({ + text: header, + leftAligned: match[1] === ":", + rightAligned: match[2] === ":" + }); + } + + return res; +} + +/* + * lines: array of strings, each item must be trimmed of whitespace beforehand + * + * See parseDelimiterRow() for the return type + */ +function parseHeader(lines) { + if (lines.length < 2) { + throw new Error(`input must be at least 2 lines long (${lines.length} < 2)`); + } + + let headerRow = splitByPipe(lines[0]); + if (headerRow.length === 0) { + throw new Error("header row must have at least one item"); + } + + return parseDelimiterRow(lines[1], headerRow); +} + +/* + * input: string + * + * Returns an object with the following keys: + * - columns: See parseHeader() for the first item + * - rows: array of an array of strings + */ +function deserializeTable(input) { + let lines = []; + let rows = []; + + for (let line of input.trim().split("\n")) { + lines.push(line.trim()); + } + + let columns = parseHeader(lines); + for (let i = 2; i < lines.length; i++) { + rows.push(splitByPipe(lines[i], columns.length)); + } + + return {columns, rows}; +} + + + +export {deserializeTable}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..4479617 --- /dev/null +++ b/index.html @@ -0,0 +1,73 @@ + + + +Markdown Table Editor + + + + + + + + +

Load table

+ + + +
+
+ +
+ +
+
+
+ + +
+ +
+
+ Column alignment: + + + + +
+
+ +
+ + +
+ + + + +
+ +Hold Shift to suppress table controls + +
+
+
+ +
+ + + +
+ + diff --git a/main.mjs b/main.mjs new file mode 100644 index 0000000..e1fa91e --- /dev/null +++ b/main.mjs @@ -0,0 +1,362 @@ +import {deserializeTable, serializeTable, calculateColumnLength} from "./tables.mjs"; + +let loadDialog = document.querySelector("#load-dialog"); + +let itemContextMenu = document.querySelector("#item-context-menu"); +let deleteRow = itemContextMenu.querySelector("#delete-row"); +let deleteColumn = itemContextMenu.querySelector("#delete-column"); +let insertRowAbove = itemContextMenu.querySelector("#insert-row-above"); +let insertRowBelow = itemContextMenu.querySelector("#insert-row-below"); +let insertColumnLeft = itemContextMenu.querySelector("#insert-column-left"); +let insertColumnRight = itemContextMenu.querySelector("#insert-column-right"); + +let output = document.querySelector("#output"); +let tableElement = document.querySelector("table"); +let table = { + columns: [ + {text: "ABC", leftAligned: false, rightAligned: false}, + {text: "DEF", leftAligned: false, rightAligned: false}, + {text: "GHI", leftAligned: false, rightAligned: false} + ], + rows: [ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"] + ] +}; + + + +Array.prototype.insert = function(index, item) { + this.splice(index, 0, item); +}; + +Array.prototype.remove = function(index) { + this.splice(index, 1); +}; + +Node.prototype.insertAfter = function(newNode, referenceNode) { + this.insertBefore(newNode, referenceNode?.nextSibling); +}; + +NodeList.prototype.indexOf = function(searchElement) { + for (let [index, element] of this.entries()) { + if (element === searchElement) { + return index; + } + } + return -1; +}; + +function findTableIndices(textarea) { + let index = tableElement.querySelectorAll("textarea").indexOf(textarea); + let columnIndex = index % table.columns.length; + let rowIndex = -1; + + if (index >= table.columns.length) { + rowIndex = Math.floor(index / table.columns.length - 1); + } + + return {columnIndex, rowIndex}; +} + +function updateItemAlignment(textarea, column) { + textarea.classList.remove("left"); + textarea.classList.remove("right"); + if (column.leftAligned) { + textarea.classList.add("left"); + } + if (column.rightAligned) { + textarea.classList.add("right"); + } +} + + + +function createItemElement(item, column) { + let textarea = document.createElement("textarea"); + textarea.rows = 1; + textarea.cols = column.maxLength; + textarea.value = item; + if (column.leftAligned) { + textarea.classList.add("left"); + } + if (column.rightAligned) { + textarea.classList.add("right"); + } + return textarea; +} + +function createRowElement(row, isHeader) { + let tr = document.createElement(isHeader ? "th" : "tr"); + + for (let i = 0; i < row.length; i++) { + let td = document.createElement("td"); + td.appendChild(createItemElement(row[i], table.columns[i])); + tr.appendChild(td); + } + + return tr; +} + +function reloadTable() { + for (let i = 0; i < table.columns.length; i++) { + table.columns[i].maxLength = calculateColumnLength(table.columns[i], table.rows, i); + } + + while (tableElement.lastChild) { + tableElement.removeChild(tableElement.lastChild); + } + + let columnRow = []; + for (let column of table.columns) { + columnRow.push(column.text); + } + tableElement.appendChild(createRowElement(columnRow, true)); + + for (let row of table.rows) { + tableElement.appendChild(createRowElement(row, false)); + } + + output.value = serializeTable(table); +} + + + +loadDialog.querySelector("#load-button").addEventListener("click", function() { + try { + table = deserializeTable(loadDialog.querySelector("textarea").value); + reloadTable(); + loadDialog.close(); + } catch (e) { + loadDialog.querySelector("#error").innerText = e.message; + } +}, {passive: true}); + +document.querySelector("#open-load-button").addEventListener("click", function() { + loadDialog.querySelector("textarea").value = ""; + loadDialog.querySelector("#error").innerText = ""; + loadDialog.showModal(); +}, {passive: true}); + + + +itemContextMenu.querySelector("form").addEventListener("change", function(event) { + let leftAligned = ["left", "center"].includes(event.target.id); + let rightAligned = ["center", "right"].includes(event.target.id); + let columnIndex = parseInt(itemContextMenu.dataset.columnIndex, 10); + let column = table.columns[columnIndex]; + + column.leftAligned = leftAligned; + column.rightAligned = rightAligned; + + updateItemAlignment(tableElement.querySelectorAll("th textarea")[columnIndex], column); + for (let tr of tableElement.querySelectorAll("tr")) { + updateItemAlignment(tr.querySelectorAll("textarea")[columnIndex], column); + } + + output.value = serializeTable(table); +}); + +deleteRow.addEventListener("click", function() { + let rowIndex = parseInt(itemContextMenu.dataset.rowIndex, 10); + + table.rows.remove(rowIndex); + tableElement.querySelectorAll("tr")[rowIndex].remove(); + + output.value = serializeTable(table); + itemContextMenu.close(); +}, {passive: true}); + +deleteColumn.addEventListener("click", function() { + let columnIndex = parseInt(itemContextMenu.dataset.columnIndex, 10); + + table.columns.remove(columnIndex); + for (let row of table.rows) { + row.remove(columnIndex); + } + + for (let tr of tableElement.querySelectorAll("th, tr")) { + tr.querySelectorAll("td")[columnIndex].remove(); + } + + output.value = serializeTable(table); + itemContextMenu.close(); +}, {passive: true}); + +insertRowAbove.addEventListener("click", function() { + let rowIndex = parseInt(itemContextMenu.dataset.rowIndex, 10); + + let newRow = new Array(table.columns.length); + newRow.fill(""); + table.rows.insert(rowIndex, newRow); + + tableElement.insertBefore(createRowElement(newRow, false), tableElement.querySelectorAll("tr")[rowIndex]); + + output.value = serializeTable(table); + itemContextMenu.close(); +}, {passive: true}); + +insertRowBelow.addEventListener("click", function() { + let rowIndex = parseInt(itemContextMenu.dataset.rowIndex, 10); + + let newRow = new Array(table.columns.length); + newRow.fill(""); + table.rows.insert(rowIndex + 1, newRow); + + let rowElement = createRowElement(newRow, false); + let referenceNode = rowIndex >= 0 + ? tableElement.querySelectorAll("tr")[rowIndex] + : tableElement.querySelector("th"); + tableElement.insertAfter(rowElement, referenceNode); + + output.value = serializeTable(table); + itemContextMenu.close(); +}, {passive: true}); + +insertColumnLeft.addEventListener("click", function() { + let columnIndex = parseInt(itemContextMenu.dataset.columnIndex, 10); + + let column = { + text: "", + leftAligned: false, + rightAligned: false, + maxLength: 3 + }; + table.columns.insert(columnIndex, column); + for (let row of table.rows) { + row.insert(columnIndex, ""); + } + + let td = document.createElement("td"); + td.appendChild(createItemElement("", column)); + + let th = tableElement.querySelector("th"); + for (let tr of tableElement.querySelectorAll("th, tr")) { + tr.insertBefore(td.cloneNode(true), tr.querySelectorAll("td")[columnIndex]); + } + + output.value = serializeTable(table); + itemContextMenu.close(); +}, {passive: true}); + +insertColumnRight.addEventListener("click", function() { + let columnIndex = parseInt(itemContextMenu.dataset.columnIndex, 10); + + let column = { + text: "", + leftAligned: false, + rightAligned: false, + maxLength: 3 + }; + table.columns.insert(columnIndex + 1, column); + for (let row of table.rows) { + row.insert(columnIndex + 1, ""); + } + + let td = document.createElement("td"); + td.appendChild(createItemElement("", column)); + + let th = tableElement.querySelector("th"); + for (let tr of tableElement.querySelectorAll("th, tr")) { + tr.insertAfter(td.cloneNode(true), tr.querySelectorAll("td")[columnIndex]); + } + + output.value = serializeTable(table); + itemContextMenu.close(); +}, {passive: true}); + + + +tableElement.addEventListener("input", function(event) { + let {columnIndex, rowIndex} = findTableIndices(event.target); + if (rowIndex < 0) { + // is modifying a header + table.columns[columnIndex].text = event.target.value; + } else { + // is modifying a row + table.rows[rowIndex][columnIndex] = event.target.value; + } + + output.value = serializeTable(table); + + table.columns[columnIndex].maxLength = calculateColumnLength(table.columns[columnIndex], table.rows, columnIndex); + for (let rowElement of tableElement.querySelectorAll("th, tr")) { + rowElement.children[columnIndex].querySelector("textarea").cols = table.columns[columnIndex].maxLength; + } +}); + +tableElement.addEventListener("contextmenu", function(event) { + if (event.shiftKey || event.target.localName !== "textarea") { + return; + } + + let {columnIndex, rowIndex} = findTableIndices(event.target); + itemContextMenu.dataset.columnIndex = columnIndex; + itemContextMenu.dataset.rowIndex = rowIndex; + itemContextMenu.style.margin = 0; + itemContextMenu.style.top = itemContextMenu.style.left = "0px"; + itemContextMenu.showModal(); + + let rect = itemContextMenu.getBoundingClientRect(); + let top = event.clientY; + let left = event.clientX; + + if (top + rect.height > window.innerHeight) { + top = window.innerHeight - rect.height; + } + if (left + rect.width > window.innerWidth) { + left = window.innerWidth - rect.width; + } + itemContextMenu.style.margin = 0; + itemContextMenu.style.top = `${top}px`; + itemContextMenu.style.left = `${left}px`; + + let column = table.columns[columnIndex]; + if (column.leftAligned && column.rightAligned) { + itemContextMenu.querySelector("#center").checked = true; + } else if (column.leftAligned) { + itemContextMenu.querySelector("#left").checked = true; + } else if (column.rightAligned) { + itemContextMenu.querySelector("#right").checked = true; + } else { + itemContextMenu.querySelector("#none").checked = true; + } + + deleteRow.disabled = event.target.closest("th") !== null; + deleteColumn.disabled = table.columns.length < 2; + insertRowAbove.disabled = event.target.closest("th") !== null; + + event.preventDefault(); +}); + + + +document.querySelector("#copy-to-clipboard").addEventListener("click", function() { + output.select(); + navigator.clipboard.writeText(output.value).then( + function() { + // success + }, + function(reason) { + // TODO: make visible to user + console.error(`Failed to copy to clipboard: ${reason}`); + } + ); +}, {passive: true}); + + + +window.addEventListener("click", function(event) { + if (event.target.localName !== "dialog") { + return; + } + + let rect = event.target.getBoundingClientRect(); + if (rect.top <= event.clientY && rect.left <= event.clientX && rect.bottom >= event.clientY && rect.right >= event.clientX) { + return; + } + event.target.close(); +}, {passive: true}); + +reloadTable(); diff --git a/serialize_table.mjs b/serialize_table.mjs new file mode 100644 index 0000000..7eb689d --- /dev/null +++ b/serialize_table.mjs @@ -0,0 +1,119 @@ +/* + * str: string + * + * Returns a string + */ +function escapeItem(str) { + return str.replaceAll(/(\\*)\|/g, function(match, backslashes) { + if (backslashes.length % 2 === 0) { + backslashes += "\\"; + } + return backslashes + "|"; + }); +} + +/* + * escapedColumns: array of objects + * escapedRow: array of string + * + * Returns a string + */ +function writeRow(escapedColumns, escapedRow) { + let res = ""; + for (let i = 0; i < escapedColumns.length; i++) { + let escapedColumn = escapedColumns[i]; + let escapedItem = escapedRow[i]; + res += `| ${escapedItem}${" ".repeat(escapedColumn.maxLength - escapedItem.length)} `; + } + res += "|"; + + return res; +} + +/* + * escapedColumns: array of objects + * + * Returns a string + */ +function writeHeader(escapedColumns) { + let headerRow = []; + let delimiterRow = []; + + for (let escapedColumn of escapedColumns) { + headerRow.push(escapedColumn.text); + + let delimiter = ""; + if (escapedColumn.leftAligned) { + delimiter += ":"; + } + delimiter += "-".repeat(escapedColumn.maxLength - escapedColumn.leftAligned - escapedColumn.rightAligned); + if (escapedColumn.rightAligned) { + delimiter += ":"; + } + delimiterRow.push(delimiter); + } + + return writeRow(escapedColumns, headerRow) + "\n" + writeRow(escapedColumns, delimiterRow); +} + +/* + * column: object + * rows: array of array of strings + * headerIndex: integer + * + * Returns an integer + */ +function calculateColumnLength(column, rows, columnIndex) { + let length = Math.max(column.text.length, 3); + + for (let row of rows) { + let item = row[columnIndex]; + if (item.length > length) { + length = item.length; + } + } + + return length; +} + +/* + * table: object + * + * Returns a string + */ +function serializeTable(table) { + let escapedColumns = []; + let escapedRows = []; + + for (let column of table.columns) { + escapedColumns.push({ + text: escapeItem(column.text), + leftAligned: column.leftAligned, + rightAligned: column.rightAligned + }); + } + + for (let row of table.rows) { + let escapedRow = []; + for (let item of row) { + escapedRow.push(escapeItem(item)); + } + + escapedRows.push(escapedRow); + } + + for (let i = 0; i < escapedColumns.length; i++) { + escapedColumns[i].maxLength = calculateColumnLength(escapedColumns[i], escapedRows, i); + } + + let res = writeHeader(escapedColumns); + for (let escapedRow of escapedRows) { + res += "\n" + writeRow(escapedColumns, escapedRow); + } + return res; +} + + + +// XXX: perhaps calculateColumnLength could be placed somewhere else? +export {serializeTable, calculateColumnLength}; diff --git a/style.css b/style.css new file mode 100644 index 0000000..36aa4ce --- /dev/null +++ b/style.css @@ -0,0 +1,68 @@ +html:has(dialog[open]) { + overflow: hidden; +} +/* prevent table from wrapping when contents are too long */ +table { + min-width: max-content; +} +/* prevent textboxes from soft-wrapping */ +textarea { + white-space: pre; +} + +th { + padding: 0; +} +td { + display: inline; +} +textarea { + resize: none; +} +.left { + text-align: left; +} +.right { + text-align: right; +} +.left.right { + text-align: center; +} + +#load-dialog h3 { + margin-top: 0; + margin-bottom: 0.5em; +} +#load-dialog #error { + display: block; +} +#load-dialog textarea { + width: 80vw; + height: 75vh; +} +#load-dialog .buttons { + text-align: right; +} +#load-dialog form { + display: inline; +} + +#item-context-menu br { + display: block; + margin-top: 8px; +} +#item-context-menu button { + display: block; +} + +.table-container { + overflow-x: scroll; + scrollbar-width: thin; +} +th textarea { + font-weight: bold; +} + +#output { + width: 100%; +} diff --git a/tables.mjs b/tables.mjs new file mode 100644 index 0000000..3b08147 --- /dev/null +++ b/tables.mjs @@ -0,0 +1,4 @@ +import {deserializeTable} from "./deserialize_table.mjs"; +import {serializeTable, calculateColumnLength} from "./serialize_table.mjs"; + +export {deserializeTable, serializeTable, calculateColumnLength};