WIP
commit
e8471b60c6
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
yarn-error.log
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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%;
|
||||
}
|
@ -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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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 />);
|
@ -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;
|
@ -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"
|
||||
}]
|
||||
]
|
||||
}
|
||||
});
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
@ -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>
|
Loading…
Reference in New Issue