Sven Slootweg 8 months ago
commit
e8471b60c6
  1. 2
      .gitignore
  2. 3
      notes.txt
  3. 32
      package.json
  4. 35
      src/client/components/app/index.jsx
  5. 39
      src/client/components/autoresize-textarea/autoresize-textarea.css
  6. 39
      src/client/components/autoresize-textarea/index.jsx
  7. 242
      src/client/components/table/index.jsx
  8. 45
      src/client/components/table/table.css
  9. 24
      src/client/global.css
  10. 11
      src/client/index.jsx
  11. 10
      src/server/app.js
  12. 37
      src/server/index.js
  13. 127
      src/static/css/bundle.css
  14. 13
      src/static/index.html
  15. 5472
      yarn.lock

2
.gitignore

@ -0,0 +1,2 @@
node_modules
yarn-error.log

3
notes.txt

@ -0,0 +1,3 @@
TODO:
- Keyboard navigation of the active table; arrow keys and enter-to-edit, as well as escape cancellation
- Reworking event handling to work with nested tables, where necessary

32
package.json

@ -0,0 +1,32 @@
{
"name": "websheets",
"version": "1.0.0",
"main": "index.js",
"repository": "git@git.cryto.net:joepie91/websheets.git",
"author": "Sven Slootweg <admin@cryto.net>",
"license": "WTFPL OR CC0-1.0",
"dependencies": {
"as-expression": "^1.0.0",
"classnames": "^2.3.1",
"css-extract": "^2.0.0",
"express": "^4.17.3",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@babel/core": "^7.17.9",
"@babel/preset-env": "^7.16.11",
"@babel/preset-react": "^7.16.7",
"autoprefixer": "^10.4.4",
"babelify": "^10.0.0",
"browserify": "^17.0.0",
"budo-express": "^1.0.8",
"icssify": "^1.2.1",
"nodemon": "^2.0.15",
"postcss-nested": "^5.0.6",
"postcss-nested-props": "^2.0.0"
},
"scripts": {
"dev": "NODE_ENV=development nodemon ./src/server/index.js"
}
}

35
src/client/components/app/index.jsx

@ -0,0 +1,35 @@
"use strict";
const React = require("react");
const Table = require("../table");
let sheet = {
columnCount: 15,
rowCount: 15,
cells: [
[ "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" ],
[ "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz" ],
[ "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo" ],
[ "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" ],
[ "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz" ],
[ "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo" ],
[ "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" ],
[ "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz" ],
[ "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo" ],
[ "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" ],
[ "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz" ],
[ "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo" ],
[ "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" ],
[ "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz", "asdf", "Qux", "Quz" ],
[ "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo", "FooBar", "BazQux", "QuzFoo" ]
]
};
module.exports = function App() {
return (
<div className="app">
<Table sheet={sheet} />
</div>
);
};

39
src/client/components/autoresize-textarea/autoresize-textarea.css

@ -0,0 +1,39 @@
/* HACK: https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */
.wrapper {
display: grid;
width: 100%;
height: 100%;
}
/* No nesting here, because we want to keep precedence as low as possible */
.editable, .dummy {
grid-area: 1 / 1 / 2 / 2;
display: inline-block;
box-sizing: border-box;
white-space: normal;
overflow: hidden;
overflow-wrap: normal;
font: {
family: inherit;
size: inherit;
weight: inherit;
style: inherit;
};
}
.dummy {
visibility: hidden;
width: max-content; /* NOTE: This prevents the pseudo-element from line-wrapping when the overall page content is bigger than the viewport, which is necessary to get consistent behaviour with regular elements. */
}
.editable {
border: none;
resize: none;
margin: 0;
width: 100%;
height: 100%;
}

39
src/client/components/autoresize-textarea/index.jsx

@ -0,0 +1,39 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const style = require("./autoresize-textarea.css");
const NBSP = String.fromCharCode(160);
module.exports = React.forwardRef(function AutosizeTextarea({ value, className, onChange, ... args }, ref) {
// TODO: Handle this outside of the React state cycle using raw JS events, for improved performance
let [ currentValue, setCurrentValue ] = React.useState(value);
// This is mainly to ensure that an empty newline is taken into account for the size measurement, otherwise we get a scrollbar in the textarea
let displayValue = currentValue + (currentValue.endsWith("\n") ? NBSP : "");
return (
<div className={classnames(style.wrapper)}>
<div className={classnames(style.dummy, className)}>{displayValue}</div>
<textarea
ref={ref}
rows="1" cols="1"
name="value"
className={classnames(style.editable, className)}
value={currentValue}
onChange={(event) => {
let newValue = event.target.value;
setCurrentValue(newValue);
if (onChange != null) {
onChange(newValue);
}
}}
{... args}
/>
</div>
);
});

242
src/client/components/table/index.jsx

@ -0,0 +1,242 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const asExpression = require("as-expression");
const AutosizeTextarea = require("../autoresize-textarea");
const style = require("./table.css");
const MOUSE_PRIMARY = 0;
function generateColumnTemplate(columnCount) {
return (new Array(columnCount))
.fill("auto")
.join(" ");
}
function StaticCellContents({ dummy, children }) {
return (
<div className={classnames(style.cellContents, { [style.dummy]: dummy })}>
{children}
</div>
);
}
function EditableCellContents({ onChange, children }) {
let ref = React.useRef();
React.useEffect(() => {
ref.current.focus();
ref.current.select();
}, [ ref ]);
return (
<AutosizeTextarea
ref={ref}
value={children}
className={style.editableCell}
onChange={onChange}
onMouseDown={(event) => {
// Workaround for Chromium, which will try to select the underlying cell when attempting to click the editable text
event.stopPropagation();
}}
/>
);
}
function Cell({ rowIndex, columnIndex, children }) {
let table = React.useContext(TableContext);
let isSingleSelection = (
(table.selection.rowStart != null || table.selection.columnStart != null)
&& rowIndex === table.selection.rowStart
&& rowIndex === table.selection.rowEnd
&& columnIndex === table.selection.columnStart
&& columnIndex === table.selection.columnEnd
);
let isPartOfSelection = (
(table.selection.rowStart != null || table.selection.columnStart != null)
&& rowIndex >= table.selection.rowStart
&& rowIndex <= table.selection.rowEnd
&& columnIndex >= table.selection.columnStart
&& columnIndex <= table.selection.columnEnd
);
let isLeftEdge = (columnIndex === table.selection.columnStart);
let isRightEdge = (columnIndex === table.selection.columnEnd);
let isTopEdge = (rowIndex === table.selection.rowStart);
let isBottomEdge = (rowIndex === table.selection.rowEnd);
let isEditable = (table.isEditing && isPartOfSelection);
let contents = (isEditable)
? <EditableCellContents
children={children}
onChange={(contents) => table.onCellContentsChange(rowIndex, columnIndex, contents)}
/>
: <StaticCellContents children={children} />;
return <div
className={classnames(style.cell, {
[style.selectionLeftEdge]: isPartOfSelection && isLeftEdge,
[style.selectionRightEdge]: isPartOfSelection && isRightEdge,
[style.selectionTopEdge]: isPartOfSelection && isTopEdge,
[style.selectionBottomEdge]: isPartOfSelection && isBottomEdge,
[style.selectionPart]: isPartOfSelection
})}
children={contents}
onMouseDown={(event) => table.onCellDown(rowIndex, columnIndex, event)}
onMouseUp={(event) => table.onCellUp(rowIndex, columnIndex, event)}
onMouseEnter={(event) => table.onCellEnter(rowIndex, columnIndex, event)}
onMouseLeave={(event) => table.onCellLeave(rowIndex, columnIndex, event)}
onDoubleClick={(event) => table.onCellDoubleClick(rowIndex, columnIndex, event)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
table.onCellCompleteEditing(rowIndex, columnIndex);
}
}}
/>;
}
function Row({ columns, index }) {
return columns.map((cell, columnIndex) => {
return <Cell rowIndex={index} columnIndex={columnIndex}>{cell}</Cell>
});
}
let TableContext = React.createContext();
module.exports = function Table({ sheet }) {
let [ selectedRowStart, setSelectedRowStart ] = React.useState();
let [ selectedRowEnd, setSelectedRowEnd ] = React.useState();
let [ selectedColumnStart, setSelectedColumnStart ] = React.useState();
let [ selectedColumnEnd, setSelectedColumnEnd ] = React.useState();
let [ isSelecting, setIsSelecting ] = React.useState(false);
let [ isEditing, setIsEditing ] = React.useState(false);
// FIXME: Ensure that in editing mode, there's always just *one* cell selected, including when editing mode is activated by eg. pressing Enter (at least until we have multi-cell editing implemented!)
React.useEffect(() => {
function mouseupListener(event) {
if (event.button === MOUSE_PRIMARY) {
setIsSelecting(false);
}
};
function mousedownListener(event) {
if (event.button === MOUSE_PRIMARY) {
stopEditing();
deselect();
}
}
document.addEventListener("mouseup", mouseupListener);
document.addEventListener("mousedown", mousedownListener);
return function () {
document.removeEventListener("mouseup", mouseupListener);
document.removeEventListener("mousedown", mousedownListener);
}
}, []);
function deselect() {
setSelectedRowStart(null);
setSelectedRowEnd(null);
setSelectedColumnStart(null);
setSelectedColumnEnd(null);
}
function select(rowIndex, columnIndex) {
setSelectedRowStart(rowIndex);
setSelectedRowEnd(rowIndex);
setSelectedColumnStart(columnIndex);
setSelectedColumnEnd(columnIndex);
}
function selectCellStart(rowIndex, columnIndex) {
setSelectedRowStart(rowIndex);
setSelectedColumnStart(columnIndex);
}
// FIXME: Swap when end < start
function selectCellEnd(rowIndex, columnIndex) {
setSelectedRowEnd(rowIndex);
setSelectedColumnEnd(columnIndex);
}
function startEditing() {
setIsEditing(true);
}
function stopEditing() {
setIsEditing(false);
}
let flippedSelection = asExpression(() => {
let [ rowStart, rowEnd ] = (selectedRowStart < selectedRowEnd)
? [ selectedRowStart, selectedRowEnd ]
: [ selectedRowEnd, selectedRowStart ];
let [ columnStart, columnEnd ] = (selectedColumnStart < selectedColumnEnd)
? [ selectedColumnStart, selectedColumnEnd ]
: [ selectedColumnEnd, selectedColumnStart ];
return { rowStart, rowEnd, columnStart, columnEnd };
});
// TODO: Also stop selecting on focus blur and document mouseleave? Or is this not necessary?
let tableAPI = {
selection: flippedSelection,
isEditing: isEditing,
onCellDown: function (rowIndex, columnIndex, event) {
if (event.button === MOUSE_PRIMARY) {
stopEditing();
setIsSelecting(true);
selectCellStart(rowIndex, columnIndex);
selectCellEnd(rowIndex, columnIndex);
event.stopPropagation();
}
},
onCellUp: function (rowIndex, columnIndex, event) {
if (event.button === MOUSE_PRIMARY) {
setIsSelecting(false);
event.stopPropagation();
}
},
onCellEnter: function (rowIndex, columnIndex, event) {
if (isSelecting) {
selectCellEnd(rowIndex, columnIndex);
}
},
onCellLeave: function (rowIndex, columnIndex, event) {
},
onCellDoubleClick: function (rowIndex, columnIndex, event) {
if (event.button === MOUSE_PRIMARY) {
startEditing();
select(rowIndex, columnIndex);
event.stopPropagation();
}
},
onCellContentsChange: function (rowIndex, columnIndex, contents) {
// FIXME: Properly run this through a state management cycle?
sheet.cells[rowIndex][columnIndex] = contents;
},
onCellCompleteEditing: function (rowIndex, columnIndex) {
stopEditing();
}
};
return (
<div className={style.table} style={{ gridTemplateColumns: generateColumnTemplate(sheet.columnCount) }}>
<TableContext.Provider value={tableAPI}>
{sheet.cells.map((columns, rowIndex) => {
return <Row index={rowIndex} columns={columns} />;
})}
</TableContext.Provider>
</div>
);
}

45
src/client/components/table/table.css

@ -0,0 +1,45 @@
.table {
display: grid;
grid-template-columns: auto auto auto;
width: fit-content;
}
.cell {
/* width: max(fit-content, 100%); */
border: 1px solid silver;
user-select: none;
/* Border-collapsing hack */
margin-left: -1px;
margin-top: -1px;
&.selectionPart {
background-color: #EEEEEE;
z-index: 2;
}
&.selectionLeftEdge {
border-left: 2px solid rgb(36, 36, 36);
margin-left: -2px; /* Includes border-collapsing compensation */
}
&.selectionRightEdge {
border-right: 2px solid rgb(36, 36, 36);
margin-right: -1px;
}
&.selectionTopEdge {
border-top: 2px solid rgb(36, 36, 36);
margin-top: -2px; /* Includes border-collapsing compensation */
}
&.selectionBottomEdge {
border-bottom: 2px solid rgb(36, 36, 36);
margin-bottom: -1px;
}
}
.cellContents, .editableCell {
padding: 3px 5px;
white-space: pre-wrap;
}

24
src/client/global.css

@ -0,0 +1,24 @@
body {
font-family: "sans-serif";
}
textarea {
margin-bottom: 4px;
}
:global .preview {
background-color: chartreuse;
/* overflow: visible; */
width: 80px;
height: 40px;
}
:global .previewContents {
/* NOTE: *Must* be a block element for ResizeObserver to work */
display: inline-block;
background-color: beige;
width: max-content;
white-space: pre-line;
}

11
src/client/index.jsx

@ -0,0 +1,11 @@
"use strict";
require("./global.css");
const React = require("react");
const { createRoot } = require("react-dom/client");
const App = require("./components/app");
let root = createRoot(document.querySelector("#app"));
root.render(<App />);

10
src/server/app.js

@ -0,0 +1,10 @@
"use strict";
const express = require("express");
const path = require("path");
let app = express();
app.use(express.static(path.join(__dirname, "../static")));
module.exports = app;

37
src/server/index.js

@ -0,0 +1,37 @@
#!/usr/bin/env node
"use strict";
const budoExpress = require("budo-express");
const path = require("path");
budoExpress({
port: 3500,
allowUnsafeHost: true,
expressApp: require("./app"),
basePath: path.join(__dirname, "../.."),
entryFiles: "src/client/index.jsx",
staticPath: "src/static",
bundlePath: "js/bundle.js",
livereloadPattern: "**/*.{css,html,js,svg}",
browserify: {
extensions: [ ".jsx" ],
transform: [
[ "babelify", {
presets: [ "@babel/preset-env", "@babel/preset-react" ]
}]
],
plugin: [
[ "icssify", {
before: [
require("postcss-nested"),
require("postcss-nested-props"),
require("autoprefixer"),
]
}],
[ "css-extract", {
out: "src/static/css/bundle.css"
}]
]
}
});

127
src/static/css/bundle.css

@ -0,0 +1,127 @@
/* from src/client/components/autoresize-textarea/autoresize-textarea.css */
/* HACK: https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ */
.autoresize-textarea__wrapper---2VZA1 {
display: grid;
width: 100%;
height: 100%;
}
/* No nesting here, because we want to keep precedence as low as possible */
.autoresize-textarea__editable---LrrqY, .autoresize-textarea__dummy---mFLYc {
grid-area: 1 / 1 / 2 / 2;
display: inline-block;
box-sizing: border-box;
white-space: normal;
overflow: hidden;
overflow-wrap: normal;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
font-style: inherit
}
.autoresize-textarea__dummy---mFLYc {
visibility: hidden;
width: -webkit-max-content;
width: -moz-max-content;
width: max-content; /* NOTE: This prevents the pseudo-element from line-wrapping when the overall page content is bigger than the viewport, which is necessary to get consistent behaviour with regular elements. */
}
.autoresize-textarea__editable---LrrqY {
border: none;
resize: none;
margin: 0;
width: 100%;
height: 100%;
}
/* from src/client/components/table/table.css */
.table__table---22_r7 {
display: grid;
grid-template-columns: auto auto auto;
width: -webkit-fit-content;
width: -moz-fit-content;
width: fit-content;
}
.table__cell---3PaNC {
/* width: max(fit-content, 100%); */
border: 1px solid silver;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
/* Border-collapsing hack */
margin-left: -1px;
margin-top: -1px;
}
.table__cell---3PaNC.table__selectionPart---x6YGc {
background-color: #EEEEEE;
z-index: 2;
}
.table__cell---3PaNC.table__selectionLeftEdge---1N5B_ {
border-left: 2px solid rgb(36, 36, 36);
margin-left: -2px; /* Includes border-collapsing compensation */
}
.table__cell---3PaNC.table__selectionRightEdge---3HQMN {
border-right: 2px solid rgb(36, 36, 36);
margin-right: -1px;
}
.table__cell---3PaNC.table__selectionTopEdge---3rpyb {
border-top: 2px solid rgb(36, 36, 36);
margin-top: -2px; /* Includes border-collapsing compensation */
}
.table__cell---3PaNC.table__selectionBottomEdge---1YFE2 {
border-bottom: 2px solid rgb(36, 36, 36);
margin-bottom: -1px;
}
.table__cellContents---Tzzg7, .table__editableCell---35lZy {
padding: 3px 5px;
white-space: pre-wrap;
}
/* from src/client/global.css */
body {
font-family: "sans-serif";
}
textarea {
margin-bottom: 4px;
}
.preview {
background-color: chartreuse;
/* overflow: visible; */
width: 80px;
height: 40px;
}
.previewContents {
/* NOTE: *Must* be a block element for ResizeObserver to work */
display: inline-block;
background-color: beige;
width: -webkit-max-content;
width: -moz-max-content;
width: max-content;
white-space: pre-line;
}

13
src/static/index.html

@ -0,0 +1,13 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Websheets</title>
<link rel="stylesheet" href="/css/bundle.css">
</head>
<body>
<div id="app">Loading Websheets...</div>
<script src="/js/bundle.js"></script>
</body>
</html>

5472
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save