spreadsheet

This commit is contained in:
“chrisshank” 2024-10-11 14:27:58 -07:00
parent 6781eb19ba
commit 47a336333d
3 changed files with 340 additions and 0 deletions

View File

@ -26,6 +26,7 @@
<li><a href="/ink">Ink</a></li>
<li><a href="/arrow">Arrow</a></li>
<li><a href="/canvasify">Canvasify</a></li>
<li><a href="/spreadsheet">Spreadsheet</a></li>
</ul>
</body>
</html>

41
demo/spreadsheet.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spreadsheet</title>
<style>
html {
height: 100%;
}
body {
min-height: 100%;
position: relative;
margin: 0;
}
s-table {
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<spatial-geometry x="50" y="50" height="800" width="700">
<s-table></s-table>
</spatial-geometry>
<script type="module">
import { SpatialGeometry } from '../src/canvas/spatial-geometry.ts';
import { SpreadsheetTable, SpreadsheetCell } from '../src/spreadsheet/spreadsheet.ts';
SpatialGeometry.register();
SpreadsheetTable.register();
SpreadsheetCell.register();
document.querySelector(`s-cell[column="A"][row="1"]`).expression = '1';
document.querySelector(`s-cell[column="A"][row="2"]`).expression = '$A1 * 2';
</script>
</body>
</html>

View File

@ -0,0 +1,298 @@
const styles = new CSSStyleSheet();
styles.replaceSync(`
:host {
--column-number: 26;
--row-number: 100;
--cell-height: 1.75rem;
--cell-width: 100px;
display: grid;
font-family: monospace;
grid-template-columns: 50px repeat(var(--column-number), var(--cell-width));
grid-template-rows: repeat(calc(var(--row-number) + 1), var(--cell-height));
position: relative;
overflow: scroll;
scroll-snap-type: both mandatory;
scroll-padding-top: var(--cell-height);
scroll-padding-left: var(--cell-width);
}
s-columns {
box-shadow: 0px 3px 5px 0px rgba(173, 168, 168, 0.4);
display: grid;
grid-column: 2 / -1;
grid-row: 1;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
position: sticky;
top: 0;
}
s-rows {
box-shadow: 3px 0px 5px 0px rgba(173, 168, 168, 0.4);
display: grid;
grid-column: 1;
grid-row: 2 / -1;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
position: sticky;
left: 0;
s-header {
font-size: 0.75rem;
}
}
s-header {
background-color: #f8f9fa;
border: 1px solid #e1e1e1;
display: flex;
padding: 0.125rem 0.5rem;
align-items: center;
justify-content: center;
&[empty] {
background-color: #f8f9fa;
box-shadow: 3px 3px 3px 0px rgba(173, 168, 168, 0.4);
grid-area: 1;
position: sticky;
top: 0;
left: 0;
z-index: 2;
}
}
s-body {
display: grid;
grid-column: 2 / -1;
grid-row: 2 / -1;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
}
::slotted(s-cell) {
align-items: center;
border: 0.5px solid #e1e1e1;
display: flex;
padding: 0.25rem;
justify-content: start;
scroll-snap-align: start;
}
::slotted(s-cell[type="number"]) {
justify-content: end;
}
::slotted(s-cell:focus) {
border: 2px solid #1b73e8;
}
`);
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
function getColumnName(index: number) {
return alphabet[index % alphabet.length];
}
function relativeColumnName(name: string, num: number) {
const index = alphabet.indexOf(name);
return alphabet[index + num];
}
export class SpreadsheetTable extends HTMLElement {
static tagName = 's-table';
static register() {
customElements.define(this.tagName, this);
}
constructor() {
super();
this.addEventListener('click', this);
this.addEventListener('keydown', this);
const shadow = this.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets.push(styles);
const columnHeaders = Array.from({ length: 26 })
.map((_, i) => `<s-header type="column" data-index="${i}">${getColumnName(i)}</s-header>`)
.join('');
const columnRows = Array.from({ length: 100 })
.map((_, i) => `<s-header type="row" data-index="${i + 1}">${i + 1}</s-header>`)
.join('');
shadow.innerHTML = `
<s-header empty></s-header>
<s-columns>${columnHeaders}</s-columns>
<s-rows>${columnRows}</s-rows>
<s-body><slot></slot></s-body>
`;
}
connectedCallback() {
let html = '';
for (let i = 0; i < 100; i += 1) {
for (let j = 0; j < 26; j += 1) {
html += `<s-cell column="${getColumnName(j)}" row="${i + 1}" tabindex="0"></s-cell>`;
}
}
this.innerHTML = html;
}
handleEvent(event: Event) {
switch (event.type) {
case 'keydown': {
if (event.target instanceof SpreadsheetCell && event instanceof KeyboardEvent) {
event.preventDefault(); // dont scroll as we change focus
switch (event.code) {
case 'ArrowUp': {
event.target.cellAbove?.focus();
return;
}
case 'ArrowDown': {
event.target.cellBelow?.focus();
return;
}
case 'ArrowLeft': {
event.target.cellToTheLeft?.focus();
return;
}
case 'ArrowRight': {
event.target.cellToTheRight?.focus();
return;
}
}
}
return;
}
}
}
}
export class SpreadsheetCell extends HTMLElement {
static tagName = 's-cell';
static register() {
customElements.define(this.tagName, this);
}
connectedCallback() {
this.expression = this.getAttribute('expression') || '';
}
get type() {
return this.getAttribute('type') || '';
}
get name() {
return `${this.#column}${this.#row}`;
}
#column = this.getAttribute('column') || '';
get column() {
return this.#column;
}
set column(column) {
this.#column = column;
}
#row = Number(this.getAttribute('row')) || -1;
get row() {
return this.#row;
}
set row(row) {
this.#row = row;
}
#expression = '';
#dependencies: SpreadsheetCell[] = [];
#function = new Function();
get expression() {
return this.#expression;
}
set expression(expression) {
expression = expression.trim();
this.#dependencies.forEach((dep) => dep.removeEventListener('propagate', this));
if (expression === '') {
this.#expression = expression;
return;
}
if (!expression.includes('return ')) {
expression = `return ${expression}`;
}
this.#expression = expression;
const argNames: string[] = expression.match(/\$[A-Z]+\d+/g) ?? [];
this.#dependencies = argNames
.map((dep) => {
const [, column, row] = dep.split(/([A-Z]+)(\d+)/s);
return this.#getCell(column, row);
})
.filter((cell) => cell !== null);
this.#dependencies.forEach((dep) => dep.addEventListener('propagate', this));
this.#function = new Function(...argNames, expression);
this.#evaluate();
}
// More generic parsing?
#value = NaN;
get value(): number {
return this.#value;
}
#getCell(column: string, row: number | string): SpreadsheetCell | null {
return document.querySelector(`s-cell[column="${column}"][row="${row}"]`);
}
get cellAbove() {
return this.#getCell(this.#column, this.#row - 1);
}
get cellBelow() {
return this.#getCell(this.#column, this.#row + 1);
}
get cellToTheLeft() {
return this.#getCell(relativeColumnName(this.column, -1), this.#row);
}
get cellToTheRight() {
return this.#getCell(relativeColumnName(this.column, 1), this.#row);
}
#evaluate() {
try {
const args = this.#dependencies.map((dep) => dep.value);
const value = this.#function.apply(null, args);
if (typeof value === 'number' && !Number.isNaN(value)) {
this.#value = value;
this.textContent = value.toString();
this.dispatchEvent(new Event('propagate'));
this.setAttribute('type', 'number');
}
} catch (error) {
console.log(error);
}
}
handleEvent(event: Event) {
switch (event.type) {
case 'propagate': {
this.#evaluate();
return;
}
}
}
}