Compare commits
1 Commits
feature-ni
...
feature/ni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71eaf1dd7 |
12
Makefile
@@ -1,6 +1,13 @@
|
||||
all:
|
||||
cargo build --release
|
||||
cd bindings/wasm/ && make
|
||||
cd webapp && npm install && npm run build
|
||||
|
||||
lint:
|
||||
cargo fmt -- --check
|
||||
cargo clippy --all-targets --all-features
|
||||
# TODO: See issue #33
|
||||
# cargo clippy --all-targets --all-features -- -D warnings -D clippy::expect_used -D clippy::unwrap_used -D clippy::panic
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
format:
|
||||
cargo fmt
|
||||
@@ -10,7 +17,7 @@ tests: lint
|
||||
./target/debug/documentation
|
||||
cmp functions.md wiki/functions.md || exit 1
|
||||
make remove-artifacts
|
||||
cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs
|
||||
cd bindings/wasm/ && make tests
|
||||
|
||||
remove-artifacts:
|
||||
rm -f xlsx/hello-calc.xlsx
|
||||
@@ -25,6 +32,7 @@ clean: remove-artifacts
|
||||
rm -f cargo-test-*
|
||||
rm -f base/cargo-test-*
|
||||
rm -f xlsx/cargo-test-*
|
||||
rm -r -f webapp/node_modules
|
||||
|
||||
|
||||
coverage:
|
||||
|
||||
@@ -308,9 +308,9 @@ impl Lexer {
|
||||
return self.consume_range(None);
|
||||
}
|
||||
let name_upper = name.to_ascii_uppercase();
|
||||
if name_upper == self.language.booleans.true_value {
|
||||
if name_upper == self.language.booleans.r#true {
|
||||
return TokenType::Boolean(true);
|
||||
} else if name_upper == self.language.booleans.false_value {
|
||||
} else if name_upper == self.language.booleans.r#false {
|
||||
return TokenType::Boolean(false);
|
||||
}
|
||||
if self.mode == LexerMode::A1 {
|
||||
@@ -660,8 +660,8 @@ impl Lexer {
|
||||
fn consume_error(&mut self) -> TokenType {
|
||||
let errors = &self.language.errors;
|
||||
let rest_of_formula: String = self.chars[self.position - 1..self.len].iter().collect();
|
||||
if rest_of_formula.starts_with(&errors.ref_value) {
|
||||
self.position += errors.ref_value.chars().count() - 1;
|
||||
if rest_of_formula.starts_with(&errors.r#ref) {
|
||||
self.position += errors.r#ref.chars().count() - 1;
|
||||
return TokenType::Error(Error::REF);
|
||||
} else if rest_of_formula.starts_with(&errors.name) {
|
||||
self.position += errors.name.chars().count() - 1;
|
||||
|
||||
@@ -6,11 +6,11 @@ use crate::{
|
||||
token::TokenType,
|
||||
},
|
||||
language::get_language,
|
||||
locale::get_locale_fix,
|
||||
locale::get_locale,
|
||||
};
|
||||
|
||||
fn new_language_lexer(formula: &str, locale: &str, language: &str) -> Lexer {
|
||||
let locale = get_locale_fix(locale).unwrap();
|
||||
let locale = get_locale(locale).unwrap();
|
||||
let language = get_language(language).unwrap();
|
||||
Lexer::new(formula, LexerMode::A1, locale, language)
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ impl Error {
|
||||
pub fn to_localized_error_string(&self, language: &Language) -> String {
|
||||
match self {
|
||||
Error::NULL => language.errors.null.to_string(),
|
||||
Error::REF => language.errors.ref_value.to_string(),
|
||||
Error::REF => language.errors.r#ref.to_string(),
|
||||
Error::NAME => language.errors.name.to_string(),
|
||||
Error::VALUE => language.errors.value.to_string(),
|
||||
Error::DIV => language.errors.div.to_string(),
|
||||
@@ -137,7 +137,7 @@ impl Error {
|
||||
|
||||
pub fn get_error_by_name(name: &str, language: &Language) -> Option<Error> {
|
||||
let errors = &language.errors;
|
||||
if name == errors.ref_value {
|
||||
if name == errors.r#ref {
|
||||
return Some(Error::REF);
|
||||
} else if name == errors.name {
|
||||
return Some(Error::NAME);
|
||||
|
||||
@@ -5,16 +5,13 @@ use std::collections::HashMap;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Booleans {
|
||||
#[serde(rename = "true")]
|
||||
pub true_value: String,
|
||||
#[serde(rename = "false")]
|
||||
pub false_value: String,
|
||||
pub r#true: String,
|
||||
pub r#false: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Errors {
|
||||
#[serde(rename = "ref")]
|
||||
pub ref_value: String,
|
||||
pub r#ref: String,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
pub div: String,
|
||||
|
||||
@@ -80,14 +80,8 @@ static LOCALES: Lazy<HashMap<String, Locale>> = Lazy::new(|| {
|
||||
serde_json::from_str(include_str!("locales.json")).expect("Failed parsing locale")
|
||||
});
|
||||
|
||||
pub fn get_locale(_id: &str) -> Result<&Locale, String> {
|
||||
pub fn get_locale(id: &str) -> Result<&Locale, String> {
|
||||
// TODO: pass the locale once we implement locales in Rust
|
||||
let locale = LOCALES.get("en").ok_or("Invalid locale")?;
|
||||
Ok(locale)
|
||||
}
|
||||
|
||||
// TODO: Remove this function one we implement locales properly
|
||||
pub fn get_locale_fix(id: &str) -> Result<&Locale, String> {
|
||||
let locale = LOCALES.get(id).ok_or("Invalid locale")?;
|
||||
Ok(locale)
|
||||
}
|
||||
|
||||
@@ -798,7 +798,7 @@ impl Model {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns a model from a String representation of a workbook
|
||||
/// Returns a model from an internal binary representation of a workbook
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
@@ -816,9 +816,12 @@ impl Model {
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// See also:
|
||||
/// * [Model::to_bytes]
|
||||
pub fn from_bytes(s: &[u8]) -> Result<Model, String> {
|
||||
let workbook: Workbook =
|
||||
bitcode::decode(s).map_err(|_| "Error parsing workbook".to_string())?;
|
||||
bitcode::decode(s).map_err(|e| format!("Error parsing workbook: {e}"))?;
|
||||
Model::from_workbook(workbook)
|
||||
}
|
||||
|
||||
@@ -1760,7 +1763,10 @@ impl Model {
|
||||
.get_style(self.get_cell_style_index(sheet, row, column))
|
||||
}
|
||||
|
||||
/// Returns a JSON string of the workbook
|
||||
/// Returns an internal binary representation of the workbook
|
||||
///
|
||||
/// See also:
|
||||
/// * [Model::from_bytes]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
bitcode::encode(&self.workbook)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn send_queue() {
|
||||
#[test]
|
||||
fn apply_external_diffs_wrong_str() {
|
||||
let mut model1 = UserModel::from_model(new_empty_model());
|
||||
assert!(model1.apply_external_diffs("invalid").is_err());
|
||||
assert!(model1.apply_external_diffs("invalid".as_bytes()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -155,5 +155,7 @@ fn new_sheet() {
|
||||
#[test]
|
||||
fn wrong_diffs_handled() {
|
||||
let mut model = UserModel::from_model(new_empty_model());
|
||||
assert!(model.apply_external_diffs("Hello world").is_err());
|
||||
assert!(model
|
||||
.apply_external_diffs("Hello world".as_bytes())
|
||||
.is_err());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@ fn errors() {
|
||||
let model_bytes = "Early in the morning, late in the century, Cricklewood Broadway.".as_bytes();
|
||||
assert_eq!(
|
||||
&UserModel::from_bytes(model_bytes).unwrap_err(),
|
||||
"Error parsing workbook"
|
||||
"Error parsing workbook: invalid packing"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::{collections::HashMap, fmt::Debug};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use bitcode::{Decode, Encode};
|
||||
|
||||
use crate::{
|
||||
constants,
|
||||
@@ -18,19 +18,19 @@ use crate::{
|
||||
utils::is_valid_hex_color,
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Encode, Decode)]
|
||||
struct RowData {
|
||||
row: Option<Row>,
|
||||
data: HashMap<i32, Cell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Encode, Decode)]
|
||||
struct ColumnData {
|
||||
column: Option<Col>,
|
||||
data: HashMap<i32, Cell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Encode, Decode)]
|
||||
enum Diff {
|
||||
// Cell diffs
|
||||
SetCellValue {
|
||||
@@ -160,13 +160,13 @@ impl History {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Encode, Decode)]
|
||||
enum DiffType {
|
||||
Undo,
|
||||
Redo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[derive(Clone, Encode, Decode)]
|
||||
struct QueueDiffs {
|
||||
r#type: DiffType,
|
||||
list: DiffList,
|
||||
@@ -408,9 +408,9 @@ impl UserModel {
|
||||
///
|
||||
/// See also:
|
||||
/// * [UserModel::apply_external_diffs]
|
||||
pub fn flush_send_queue(&mut self) -> String {
|
||||
pub fn flush_send_queue(&mut self) -> Vec<u8> {
|
||||
// This can never fail :O:
|
||||
let q = serde_json::to_string(&self.send_queue).unwrap();
|
||||
let q = bitcode::encode(&self.send_queue);
|
||||
self.send_queue = vec![];
|
||||
q
|
||||
}
|
||||
@@ -421,8 +421,8 @@ impl UserModel {
|
||||
///
|
||||
/// See also:
|
||||
/// * [UserModel::flush_send_queue]
|
||||
pub fn apply_external_diffs(&mut self, diff_list_str: &str) -> Result<(), String> {
|
||||
if let Ok(queue_diffs_list) = serde_json::from_str::<Vec<QueueDiffs>>(diff_list_str) {
|
||||
pub fn apply_external_diffs(&mut self, diff_list_str: &[u8]) -> Result<(), String> {
|
||||
if let Ok(queue_diffs_list) = bitcode::decode::<Vec<QueueDiffs>>(diff_list_str) {
|
||||
for queue_diff in queue_diffs_list {
|
||||
if matches!(queue_diff.r#type, DiffType::Redo) {
|
||||
self.apply_diff_list(&queue_diff.list)?;
|
||||
@@ -845,6 +845,9 @@ impl UserModel {
|
||||
"fill.bg_color" => {
|
||||
style.fill.bg_color = color(value)?;
|
||||
}
|
||||
"fill.fg_color" => {
|
||||
style.fill.fg_color = color(value)?;
|
||||
}
|
||||
"num_fmt" => {
|
||||
style.num_fmt = value.to_owned();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
all:
|
||||
wasm-pack build --target web --scope ironcalc
|
||||
wasm-pack build --target web --scope ironcalc --release
|
||||
cp README.pkg.md pkg/README.md
|
||||
tsc types.ts --target esnext --module esnext
|
||||
python fix_types.py
|
||||
|
||||
tests:
|
||||
wasm-pack build --target nodejs && node tests/test.mjs
|
||||
|
||||
lint:
|
||||
cargo check
|
||||
cargo fmt -- --check
|
||||
|
||||
@@ -14,9 +14,9 @@ export function getTokens(formula: string): any;
|
||||
""".strip()
|
||||
|
||||
get_tokens_str_types = r"""
|
||||
* @returns {TokenType[]}
|
||||
* @returns {MarkedToken[]}
|
||||
*/
|
||||
export function getTokens(formula: string): TokenType[];
|
||||
export function getTokens(formula: string): MarkedToken[];
|
||||
""".strip()
|
||||
|
||||
update_style_str = r"""
|
||||
|
||||
@@ -71,12 +71,12 @@ impl Model {
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "flushSendQueue")]
|
||||
pub fn flush_send_queue(&mut self) -> String {
|
||||
pub fn flush_send_queue(&mut self) -> Vec<u8> {
|
||||
self.model.flush_send_queue()
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = "applyExternalDiffs")]
|
||||
pub fn apply_external_diffs(&mut self, diffs: &str) -> Result<(), JsError> {
|
||||
pub fn apply_external_diffs(&mut self, diffs: &[u8]) -> Result<(), JsError> {
|
||||
self.model.apply_external_diffs(diffs).map_err(to_js_error)
|
||||
}
|
||||
|
||||
|
||||
3
webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/*
|
||||
dist/*
|
||||
example.json
|
||||
19
webapp/.storybook/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-onboarding',
|
||||
'@storybook/addon-interactions',
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
29
webapp/.storybook/preview.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Preview } from '@storybook/react';
|
||||
import i18n from '../src/i18n';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
const withI18next = (Story: any) => {
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Story />
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export const decorators = [withI18next];
|
||||
export default preview;
|
||||
21
webapp/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# IronCalc Web App
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
# Deploy
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
BIN
webapp/example.xlsx
Normal file
16
webapp/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- <meta name="theme-color" content="#1bb566"> -->
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2994A" />
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="black" />
|
||||
<title>Spreadsheet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
webapp/jest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Config } from "jest";
|
||||
// import {defaults} from 'jest-config';
|
||||
|
||||
const config: Config = {
|
||||
// testMatch:["**.jest.mjs"],
|
||||
moduleFileExtensions: ["js", "ts", "mts", "mjs"],
|
||||
transform: {
|
||||
"^.+\\.[jt]s?$": "ts-jest",
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"^@ironcalc/wasm$": "<rootDir>/node_modules/@ironcalc/nodejs/"
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
17153
webapp/package-lock.json
generated
Normal file
55
webapp/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"restore": "cp node_modules/@ironcalc/wasm/wasm_bg.wasm node_modules/.vite/deps/",
|
||||
"dev": "vite",
|
||||
"test": "jest",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@ironcalc/wasm": "file:../bindings/wasm/pkg",
|
||||
"@mui/material": "^5.15.15",
|
||||
"i18next": "^23.11.1",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^13.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "^7.6.17",
|
||||
"@storybook/addon-interactions": "^7.6.17",
|
||||
"@storybook/addon-links": "^7.6.17",
|
||||
"@storybook/addon-onboarding": "^1.0.11",
|
||||
"@storybook/blocks": "^7.5.3",
|
||||
"@storybook/react": "^7.5.3",
|
||||
"@storybook/react-vite": "^7.6.17",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "^18.2.75",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"eslint-plugin-storybook": "^0.6.15",
|
||||
"jest": "^29.7.0",
|
||||
"storybook": "^7.6.17",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
}
|
||||
6
webapp/src/App.css
Normal file
@@ -0,0 +1,6 @@
|
||||
#root {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid #AAA;
|
||||
border-radius: 4px;
|
||||
}
|
||||
40
webapp/src/App.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import "./App.css";
|
||||
import Workbook from "./components/workbook";
|
||||
import "./i18n";
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import init, { Model } from "@ironcalc/wasm";
|
||||
import { WorkbookState } from "./components/workbookState";
|
||||
import WorkbookContext from "./components/workbookContext";
|
||||
|
||||
function App() {
|
||||
const [model, setModel] = useState<Model | null>(null);
|
||||
const [workbookState, setWorkbookState] = useState<WorkbookState | null>(
|
||||
null
|
||||
);
|
||||
useEffect(() => {
|
||||
async function start() {
|
||||
await init();
|
||||
const model_bytes = new Uint8Array(await (await fetch("./example.ic")).arrayBuffer());
|
||||
const _model = Model.from_bytes(model_bytes);
|
||||
// const _model = new Model("en", "UTC");
|
||||
if (!model) setModel(_model);
|
||||
if (!workbookState) setWorkbookState(new WorkbookState());
|
||||
}
|
||||
start();
|
||||
}, []);
|
||||
|
||||
if (!model || !workbookState) {
|
||||
return <div>Loading</div>;
|
||||
}
|
||||
|
||||
// We could use context for model, but the problem is that it should initialized to null.
|
||||
// Passing the property down makes sure it is always defined.
|
||||
return (
|
||||
// <WorkbookContext.Provider value={{}}>
|
||||
<Workbook model={model} workbookState={workbookState} />
|
||||
// </WorkbookContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
12
webapp/src/components/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Keyboard and mouse events architecture
|
||||
|
||||
This document describes the architecture of the keyboard navigation and mouse events in IronCalc Web
|
||||
|
||||
There are two modes for mouse events:
|
||||
|
||||
* Normal mode: clicking a cell selects it, clicking on a sheet opens it
|
||||
* Browse mode: clicking on a cell updates the formula, etc
|
||||
|
||||
While in browse mode some mouse events might end the browse mode
|
||||
|
||||
We follow Excel's way of navigating a spreadsheet
|
||||
25
webapp/src/components/WorksheetCanvas/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export const headerCornerBackground = '#FFF';
|
||||
export const headerTextColor = '#333';
|
||||
export const headerBackground = '#FFF';
|
||||
export const headerGlobalSelectorColor = '#EAECF4';
|
||||
export const headerSelectedBackground = '#EEEEEE';
|
||||
export const headerFullSelectedBackground = '#D3D6E9';
|
||||
export const headerSelectedColor = '#333';
|
||||
export const headerBorderColor = '#DEE0EF';
|
||||
|
||||
export const gridColor = '#D3D6E9';
|
||||
export const gridSeparatorColor = '#D3D6E9';
|
||||
export const defaultTextColor = '#2E414D';
|
||||
|
||||
export const outlineColor = '#F2994A';
|
||||
export const outlineBackgroundColor = '#F2994A1A';
|
||||
|
||||
export const LAST_COLUMN = 16_384;
|
||||
export const LAST_ROW = 1_048_576;
|
||||
|
||||
// FIXME: Browsers cannot have a height that big
|
||||
// For now we will go A-IZ and 10_000 rows
|
||||
export const lastColumn = 260; // TODO: Excel supports up to 16_384
|
||||
// I know of a world with one million moons.
|
||||
// Carl Sagan in The cosmic connection Chapter 7 "Space Exploration as a Human Enterprise"
|
||||
export const lastRow = 10_000; // TODO: Excel supports up to 1_048_576
|
||||
23
webapp/src/components/WorksheetCanvas/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface Cell {
|
||||
row: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
}
|
||||
|
||||
export interface SheetArea extends Area {
|
||||
sheet: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface AreaWithBorderInterface extends Area {
|
||||
border: "left" | "top" | "right" | "bottom";
|
||||
}
|
||||
|
||||
export type AreaWithBorder = AreaWithBorderInterface | null;
|
||||
|
||||
396
webapp/src/components/WorksheetCanvas/util.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
const letters = [
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
];
|
||||
interface Reference {
|
||||
row: number;
|
||||
column: number;
|
||||
absoluteRow: boolean;
|
||||
absoluteColumn: boolean;
|
||||
}
|
||||
|
||||
export function referenceToString(rf: Reference): string {
|
||||
const absC = rf.absoluteColumn ? '$' : '';
|
||||
const absR = rf.absoluteRow ? '$' : '';
|
||||
return absC + columnNameFromNumber(rf.column) + absR + rf.row;
|
||||
}
|
||||
|
||||
export function columnNameFromNumber(column: number): string {
|
||||
let columnName = '';
|
||||
let index = column;
|
||||
while (index > 0) {
|
||||
columnName = `${letters[(index - 1) % 26]}${columnName}`;
|
||||
index = Math.floor((index - 1) / 26);
|
||||
}
|
||||
return columnName;
|
||||
}
|
||||
|
||||
export function columnNumberFromName(columnName: string): number {
|
||||
let column = 0;
|
||||
for (const character of columnName) {
|
||||
const index = (character.codePointAt(0) ?? 0) - 64;
|
||||
column = column * 26 + index;
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
// EqualTo Color Palette
|
||||
export function getColor(index: number, alpha = 1): string {
|
||||
const colors = [
|
||||
{
|
||||
name: 'Cyan',
|
||||
rgba: [89, 185, 188, 1],
|
||||
hex: '#59B9BC',
|
||||
},
|
||||
{
|
||||
name: 'Flamingo',
|
||||
rgba: [236, 87, 83, 1],
|
||||
hex: '#EC5753',
|
||||
},
|
||||
{
|
||||
hex: '#3358B7',
|
||||
rgba: [51, 88, 183, 1],
|
||||
name: 'Blue',
|
||||
},
|
||||
{
|
||||
hex: '#F8CD3C',
|
||||
rgba: [248, 205, 60, 1],
|
||||
name: 'Yellow',
|
||||
},
|
||||
{
|
||||
hex: '#3BB68A',
|
||||
rgba: [59, 182, 138, 1],
|
||||
name: 'Emerald',
|
||||
},
|
||||
{
|
||||
hex: '#523E93',
|
||||
rgba: [82, 62, 147, 1],
|
||||
name: 'Violet',
|
||||
},
|
||||
{
|
||||
hex: '#A23C52',
|
||||
rgba: [162, 60, 82, 1],
|
||||
name: 'Burgundy',
|
||||
},
|
||||
{
|
||||
hex: '#8CB354',
|
||||
rgba: [162, 60, 82, 1],
|
||||
name: 'Wasabi',
|
||||
},
|
||||
{
|
||||
hex: '#D03627',
|
||||
rgba: [208, 54, 39, 1],
|
||||
name: 'Red',
|
||||
},
|
||||
{
|
||||
hex: '#1B717E',
|
||||
rgba: [27, 113, 126, 1],
|
||||
name: 'Teal',
|
||||
},
|
||||
];
|
||||
if (alpha === 1) {
|
||||
return colors[index % 10].hex;
|
||||
}
|
||||
const { rgba } = colors[index % 10];
|
||||
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`;
|
||||
}
|
||||
|
||||
export function mergedAreas(area1: Area, area2: Area): Area {
|
||||
return {
|
||||
rowStart: Math.min(area1.rowStart, area2.rowStart, area1.rowEnd, area2.rowEnd),
|
||||
rowEnd: Math.max(area1.rowStart, area2.rowStart, area1.rowEnd, area2.rowEnd),
|
||||
columnStart: Math.min(area1.columnStart, area2.columnStart, area1.columnEnd, area2.columnEnd),
|
||||
columnEnd: Math.max(area1.columnStart, area2.columnStart, area1.columnEnd, area2.columnEnd),
|
||||
};
|
||||
}
|
||||
|
||||
export function getExpandToArea(area: Area, cell: Cell): AreaWithBorder {
|
||||
let { rowStart, rowEnd, columnStart, columnEnd } = area;
|
||||
if (rowStart > rowEnd) {
|
||||
[rowStart, rowEnd] = [rowEnd, rowStart];
|
||||
}
|
||||
if (columnStart > columnEnd) {
|
||||
[columnStart, columnEnd] = [columnEnd, columnStart];
|
||||
}
|
||||
const { row, column } = cell;
|
||||
if (row <= rowEnd && row >= rowStart && column >= columnStart && column <= columnEnd) {
|
||||
return null;
|
||||
}
|
||||
// Two rules:
|
||||
// * The extendTo area must be larger than the selected area
|
||||
// * The extendTo area must be of the same width or the same height as the selected area
|
||||
if (row >= rowEnd && column >= columnStart) {
|
||||
// Normal case: we are expanding down and right
|
||||
if (row - rowEnd > column - columnEnd) {
|
||||
// Expanding by rows (down)
|
||||
return {
|
||||
rowStart: rowEnd + 1,
|
||||
rowEnd: row,
|
||||
columnStart,
|
||||
columnEnd,
|
||||
border: 'top',
|
||||
};
|
||||
}
|
||||
// expanding by columns (right)
|
||||
return {
|
||||
rowStart,
|
||||
rowEnd,
|
||||
columnStart: columnEnd + 1,
|
||||
columnEnd: column,
|
||||
border: 'left',
|
||||
};
|
||||
}
|
||||
if (row >= rowEnd && column <= columnStart) {
|
||||
// We are expanding down and left
|
||||
if (row - rowEnd > columnStart - column) {
|
||||
// Expanding by rows (down)
|
||||
return {
|
||||
rowStart: rowEnd + 1,
|
||||
rowEnd: row,
|
||||
columnStart,
|
||||
columnEnd,
|
||||
border: 'top',
|
||||
};
|
||||
}
|
||||
// Expanding by columns (left)
|
||||
return {
|
||||
rowStart,
|
||||
rowEnd,
|
||||
columnStart: column,
|
||||
columnEnd: columnStart - 1,
|
||||
border: 'right',
|
||||
};
|
||||
}
|
||||
if (row <= rowEnd && column >= columnEnd) {
|
||||
// We are expanding up and right
|
||||
if (rowStart - row > column - columnEnd) {
|
||||
// Expanding by rows (up)
|
||||
return {
|
||||
rowStart: row,
|
||||
rowEnd: rowStart - 1,
|
||||
columnStart,
|
||||
columnEnd,
|
||||
border: 'bottom',
|
||||
};
|
||||
}
|
||||
// Expanding by columns (right)
|
||||
return {
|
||||
rowStart,
|
||||
rowEnd,
|
||||
columnStart: columnEnd + 1,
|
||||
columnEnd: column,
|
||||
border: 'left',
|
||||
};
|
||||
}
|
||||
if (row <= rowEnd && column <= columnStart) {
|
||||
// We are expanding up and left
|
||||
if (rowStart - row > columnStart - column) {
|
||||
// Expanding by rows (up)
|
||||
return {
|
||||
rowStart: row,
|
||||
rowEnd: rowStart - 1,
|
||||
columnStart,
|
||||
columnEnd,
|
||||
border: 'bottom',
|
||||
};
|
||||
}
|
||||
// Expanding by columns (left)
|
||||
return {
|
||||
rowStart,
|
||||
rowEnd,
|
||||
columnStart: column,
|
||||
columnEnd: columnStart - 1,
|
||||
border: 'right',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the keypress should start editing
|
||||
*/
|
||||
export function isEditingKey(key: string): boolean {
|
||||
if (key.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const code = key.codePointAt(0) ?? 0;
|
||||
if (code > 0 && code < 255) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// / Common types
|
||||
|
||||
export interface Area {
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
}
|
||||
|
||||
interface AreaWithBorderInterface extends Area {
|
||||
border: 'left' | 'top' | 'right' | 'bottom';
|
||||
}
|
||||
|
||||
export type AreaWithBorder = AreaWithBorderInterface | null;
|
||||
|
||||
export interface Cell {
|
||||
row: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface ScrollPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface StateSettings {
|
||||
selectedCell: Cell;
|
||||
selectedArea: Area;
|
||||
scrollPosition: ScrollPosition;
|
||||
extendToArea: AreaWithBorder;
|
||||
}
|
||||
|
||||
export type Dispatch<A> = (value: A) => void;
|
||||
export type SetStateAction<S> = S | ((prevState: S) => S);
|
||||
|
||||
export enum FocusType {
|
||||
Cell = 'cell',
|
||||
FormulaBar = 'formula-bar',
|
||||
}
|
||||
|
||||
/**
|
||||
* In Excel there are two "modes" of editing
|
||||
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
|
||||
* * `edit`: If you double click on a cell or click in the cell while editing.
|
||||
* In this mode arrow keys will move within the cell.
|
||||
*
|
||||
* In a formula bar mode is always `edit`.
|
||||
*/
|
||||
export type CellEditMode = 'init' | 'edit';
|
||||
export interface CellEditingType {
|
||||
/**
|
||||
* ID of cell editing. Useful when one edit transforms into another and some code needs to run
|
||||
* when target changes.
|
||||
*
|
||||
* Due to problems with focus management (see #339) it's possible to start a new cell editing
|
||||
* without properly cleaning up previous one (lose focus in workbook, regain focus NOT in
|
||||
* the input and then use the keyboard.
|
||||
*/
|
||||
id: number;
|
||||
sheet: number;
|
||||
row: number;
|
||||
column: number;
|
||||
text: string;
|
||||
base: string;
|
||||
mode: CellEditMode;
|
||||
focus: FocusType;
|
||||
}
|
||||
|
||||
export type NavigationKey = 'ArrowRight' | 'ArrowLeft' | 'ArrowDown' | 'ArrowUp' | 'Home' | 'End';
|
||||
|
||||
export const isNavigationKey = (key: string): key is NavigationKey =>
|
||||
['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'].includes(key);
|
||||
|
||||
function nameNeedsQuoting(name: string): boolean {
|
||||
const chars = [' ', '(', ')', "'", '$', ',', ';', '-', '+', '{', '}'];
|
||||
const l = chars.length;
|
||||
for (let index = 0; index < l; index += 1) {
|
||||
if (name.includes(chars[index])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// FIXME: We should use the function of a similar name in the rust code.
|
||||
export const quoteSheetName = (name: string): string => {
|
||||
if (nameNeedsQuoting(name)) {
|
||||
return `'${name.replace("'", "''")}'`;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
export function cellReprToRowColumn(cellRepr: string): { row: number; column: number } {
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
for (const character of cellRepr) {
|
||||
if (Number.isNaN(Number.parseInt(character, 10))) {
|
||||
column *= 26;
|
||||
const characterCode = character.codePointAt(0);
|
||||
const ACharacterCode = 'A'.codePointAt(0);
|
||||
if (typeof characterCode === 'undefined' || typeof ACharacterCode === 'undefined') {
|
||||
throw new TypeError('Failed to find character code');
|
||||
}
|
||||
const deltaCodes = characterCode - ACharacterCode;
|
||||
if (deltaCodes < 0) {
|
||||
throw new Error('Incorrect character');
|
||||
}
|
||||
column += deltaCodes + 1;
|
||||
} else {
|
||||
row *= 10;
|
||||
row += Number.parseInt(character, 10);
|
||||
}
|
||||
}
|
||||
return { row, column };
|
||||
}
|
||||
|
||||
export const getMessageCellText = (
|
||||
cell: string,
|
||||
getMessageSheetNumber: (sheet: string) => number | undefined,
|
||||
getCellText?: (sheet: number, row: number, column: number) => string | undefined,
|
||||
) => {
|
||||
const messageMatch = /^=?(?<sheet>\w+)!(?<cell>\w+)/.exec(cell);
|
||||
if (messageMatch && messageMatch.groups) {
|
||||
const messageSheet = getMessageSheetNumber(messageMatch.groups.sheet);
|
||||
const dynamicIconCell = cellReprToRowColumn(messageMatch.groups.cell);
|
||||
if (messageSheet !== undefined && getCellText) {
|
||||
return getCellText(messageSheet, dynamicIconCell.row, dynamicIconCell.column) || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const getCellAddress = (selectedArea: Area, selectedCell?: Cell) => {
|
||||
const isSingleCell =
|
||||
selectedArea.rowStart === selectedArea.rowEnd &&
|
||||
selectedArea.columnEnd === selectedArea.columnStart;
|
||||
|
||||
return isSingleCell && selectedCell
|
||||
? `${columnNameFromNumber(selectedCell.column)}${selectedCell.row}`
|
||||
: `${columnNameFromNumber(selectedArea.columnStart)}${
|
||||
selectedArea.rowStart
|
||||
}:${columnNameFromNumber(selectedArea.columnEnd)}${selectedArea.rowEnd}`;
|
||||
};
|
||||
|
||||
export enum Border {
|
||||
Top = 'top',
|
||||
Bottom = 'bottom',
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
1328
webapp/src/components/WorksheetCanvas/worksheetCanvas.ts
Normal file
566
webapp/src/components/borderPicker.tsx
Normal file
@@ -0,0 +1,566 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BorderBottomIcon,
|
||||
BorderCenterHIcon,
|
||||
BorderCenterVIcon,
|
||||
BorderInnerIcon,
|
||||
BorderLeftIcon,
|
||||
BorderOuterIcon,
|
||||
BorderRightIcon,
|
||||
BorderTopIcon,
|
||||
BorderNoneIcon,
|
||||
BorderStyleIcon,
|
||||
} from "../icons";
|
||||
import ColorPicker from "./colorPicker";
|
||||
import Popover, { PopoverOrigin } from "@mui/material/Popover";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Grid2X2 as BorderAllIcon,
|
||||
PencilLine,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { theme } from "../theme";
|
||||
import { BorderOptions, BorderStyle, BorderType } from "@ironcalc/wasm";
|
||||
|
||||
type BorderPickerProps = {
|
||||
className?: string;
|
||||
onChange: (border: BorderOptions) => void;
|
||||
anchorEl: React.RefObject<HTMLElement>;
|
||||
anchorOrigin?: PopoverOrigin;
|
||||
transformOrigin?: PopoverOrigin;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
const BorderPicker = (properties: BorderPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [borderSelected, setBorderSelected] = useState(BorderType.None);
|
||||
const [borderColor, setBorderColor] = useState("#000000");
|
||||
const [borderStyle, setBorderStyle] = useState(BorderStyle.Thin);
|
||||
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
const closePicker = (): void => {
|
||||
properties.onChange({
|
||||
color: borderColor,
|
||||
style: borderStyle,
|
||||
border: borderSelected,
|
||||
});
|
||||
};
|
||||
const borderColorButton = useRef(null);
|
||||
const borderStyleButton = useRef(null);
|
||||
return (
|
||||
<>
|
||||
<StyledPopover
|
||||
open={properties.open}
|
||||
onClose={(): void => closePicker()}
|
||||
anchorEl={properties.anchorEl.current}
|
||||
anchorOrigin={
|
||||
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
|
||||
}
|
||||
transformOrigin={
|
||||
properties.transformOrigin || { vertical: "top", horizontal: "left" }
|
||||
}
|
||||
>
|
||||
<BorderPickerDialog>
|
||||
<Borders>
|
||||
<Line>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderAll}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderAll) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderAll);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderAllIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderInner}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderInner) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderInner);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderInnerIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderCenterH}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderCenterH) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderCenterH);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderCenterHIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderCenterV}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderCenterV) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderCenterV);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderCenterVIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderOuter}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderOuter) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderOuter);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderOuterIcon />
|
||||
</Button>
|
||||
</Line>
|
||||
<Line>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderNone}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderNone) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderNone);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderNoneIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderTop}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderTop) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderTop);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderTopIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderRight}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderRight) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderRight);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderRightIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderBottom}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderBottom) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderBottom);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderBottomIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={borderSelected === BorderType.BorderLeft}
|
||||
onClick={() => {
|
||||
if (borderSelected === BorderType.BorderLeft) {
|
||||
setBorderSelected(BorderType.None);
|
||||
} else {
|
||||
setBorderSelected(BorderType.BorderLeft);
|
||||
}
|
||||
}}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderLeftIcon />
|
||||
</Button>
|
||||
</Line>
|
||||
</Borders>
|
||||
<Divider />
|
||||
<Styles>
|
||||
<ButtonWrapper onClick={() => setColorPickerOpen(true)}>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={false}
|
||||
disabled={false}
|
||||
ref={borderColorButton}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<PencilLine />
|
||||
</Button>
|
||||
<div style={{flexGrow:2}}>Border color</div>
|
||||
<ChevronRightStyled />
|
||||
</ButtonWrapper>
|
||||
<ButtonWrapper onClick={() => setStylePickerOpen(true)} ref={borderStyleButton}>
|
||||
<Button
|
||||
type="button"
|
||||
$pressed={false}
|
||||
disabled={false}
|
||||
title={t("workbook.toolbar.borders_button_title")}
|
||||
>
|
||||
<BorderStyleIcon />
|
||||
</Button>
|
||||
<div style={{flexGrow:2}}>Border style</div>
|
||||
<ChevronRightStyled />
|
||||
</ButtonWrapper>
|
||||
</Styles>
|
||||
</BorderPickerDialog>
|
||||
<ColorPicker
|
||||
color={borderColor}
|
||||
onChange={(color): void => {
|
||||
setBorderColor(color);
|
||||
setColorPickerOpen(false);
|
||||
}}
|
||||
anchorEl={borderColorButton}
|
||||
open={colorPickerOpen}
|
||||
/>
|
||||
<StyledPopover
|
||||
open={stylePickerOpen}
|
||||
onClose={(): void => {
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
anchorEl={borderStyleButton.current}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
transformOrigin={{ vertical: 38, horizontal: -6 }}
|
||||
>
|
||||
<BorderStyleDialog>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Dashed);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.None}
|
||||
>
|
||||
<BorderDescription>None</BorderDescription>
|
||||
<NoneLine />
|
||||
</LineWrapper>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Thin);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.Thin}
|
||||
>
|
||||
<BorderDescription>Thin</BorderDescription>
|
||||
<SolidLine />
|
||||
</LineWrapper>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Medium);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.Medium}
|
||||
>
|
||||
<BorderDescription>Medium</BorderDescription>
|
||||
<MediumLine />
|
||||
</LineWrapper>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Thick);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.Thick}
|
||||
>
|
||||
<BorderDescription>Thick</BorderDescription>
|
||||
<ThickLine />
|
||||
</LineWrapper>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Dotted);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.Dotted}
|
||||
>
|
||||
<BorderDescription>Dotted</BorderDescription>
|
||||
<DottedLine />
|
||||
</LineWrapper>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Dashed);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.Dashed}
|
||||
>
|
||||
<BorderDescription>Dashed</BorderDescription>
|
||||
<DashedLine />
|
||||
</LineWrapper>
|
||||
<LineWrapper
|
||||
onClick={() => {
|
||||
setBorderStyle(BorderStyle.Dashed);
|
||||
setStylePickerOpen(false);
|
||||
}}
|
||||
$checked={borderStyle === BorderStyle.Double}
|
||||
>
|
||||
<BorderDescription>Double</BorderDescription>
|
||||
<DoubleLine />
|
||||
</LineWrapper>
|
||||
</BorderStyleDialog>
|
||||
</StyledPopover>
|
||||
</StyledPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type LineWrapperProperties = { $checked: boolean };
|
||||
const LineWrapper = styled("div")<LineWrapperProperties>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: ${({ $checked }): string => {
|
||||
if ($checked) {
|
||||
return '#EEEEEE;';
|
||||
} else {
|
||||
return 'inherit;';
|
||||
}
|
||||
}};
|
||||
&:hover {
|
||||
border: 1px solid #EEEEEE;
|
||||
}
|
||||
padding:8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border: 1px solid white;
|
||||
`;
|
||||
|
||||
const CheckIconWrapper = styled("div")`
|
||||
width: 12px;
|
||||
`;
|
||||
|
||||
type CheckIconProperties = { $checked: boolean };
|
||||
const CheckIcon = styled("div")<CheckIconProperties>`
|
||||
width: 2px;
|
||||
background-color: #EEE;
|
||||
height: 28px;
|
||||
visibility: ${({ $checked }): string => {
|
||||
if ($checked) {
|
||||
return "visible";
|
||||
}
|
||||
return "hidden";
|
||||
}};
|
||||
`;
|
||||
const NoneLine = styled("div")`
|
||||
width: 68px;
|
||||
border-top: 1px solid #E0E0E0;
|
||||
`;
|
||||
const SolidLine = styled("div")`
|
||||
width: 68px;
|
||||
border-top: 1px solid #333333;
|
||||
`;
|
||||
const MediumLine = styled("div")`
|
||||
width: 68px;
|
||||
border-top: 2px solid #333333;
|
||||
`;
|
||||
const ThickLine = styled("div")`
|
||||
width: 68px;
|
||||
border-top: 3px solid #333333;
|
||||
`;
|
||||
const DashedLine = styled("div")`
|
||||
width: 68px;
|
||||
border-top: 1px dashed #333333;
|
||||
`;
|
||||
const DottedLine = styled("div")`
|
||||
width: 68px;
|
||||
border-top: 1px dotted #333333;
|
||||
`;
|
||||
const DoubleLine = styled('div')`
|
||||
width: 68px;
|
||||
border-top: 3px double #333333;
|
||||
`;
|
||||
|
||||
const Divider = styled("div")`
|
||||
display: inline-flex;
|
||||
heigh: 1px;
|
||||
border-bottom: 1px solid #EEE;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
`;
|
||||
|
||||
const Borders = styled("div")`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 4px;
|
||||
`;
|
||||
|
||||
const Styles = styled("div")`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Line = styled("div")`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const ButtonWrapper = styled("div")`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
background-color: #EEE;
|
||||
border-top-color: ${(): string => theme.palette.grey["400"]};
|
||||
}
|
||||
cursor: pointer;
|
||||
padding: 8px
|
||||
`;
|
||||
|
||||
const BorderStyleDialog = styled("div")`
|
||||
background: ${({ theme }): string => theme.palette.background.default};
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledPopover = styled(Popover)`
|
||||
.MuiPopover-paper {
|
||||
border-radius: 10px;
|
||||
border: 0px solid ${({ theme }): string => theme.palette.background.default};
|
||||
box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
|
||||
}
|
||||
.MuiPopover-padding {
|
||||
padding: 0px;
|
||||
}
|
||||
.MuiList-padding {
|
||||
padding: 0px;
|
||||
}
|
||||
font-family: ${({ theme }) => theme.typography.fontFamily};
|
||||
font-size: 13px;
|
||||
`;
|
||||
|
||||
const BorderPickerDialog = styled("div")`
|
||||
background: ${({ theme }): string => theme.palette.background.default};
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const BorderDescription = styled("div")`
|
||||
width: 70px;
|
||||
`;
|
||||
|
||||
// type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
|
||||
// const Button = styled.button<TypeButtonProperties>`
|
||||
// width: 23px;
|
||||
// height: 23px;
|
||||
// display: inline-flex;
|
||||
// align-items: center;
|
||||
// justify-content: center;
|
||||
// font-size: 14px;
|
||||
// border-radius: 2px;
|
||||
// margin-right: 5px;
|
||||
// transition: all 0.2s;
|
||||
|
||||
// ${({ theme, disabled, $pressed, $underlinedColor }): string => {
|
||||
// if (disabled) {
|
||||
// return `
|
||||
// color: ${theme.palette.grey['600']};
|
||||
// cursor: default;
|
||||
// `;
|
||||
// }
|
||||
// return `
|
||||
// border-top: ${$underlinedColor ? '3px solid #FFF' : 'none'};
|
||||
// border-bottom: ${$underlinedColor ? `3px solid ${$underlinedColor}` : 'none'};
|
||||
// color: ${theme.palette.text.primary};
|
||||
// background-color: ${$pressed ? theme.palette.grey['600'] : '#FFF'};
|
||||
// &:hover {
|
||||
// background-color: ${theme.palette.grey['400']};
|
||||
// border-top-color: ${theme.palette.grey['400']};
|
||||
// }
|
||||
// `;
|
||||
// }}
|
||||
// `;
|
||||
|
||||
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
|
||||
const Button = styled("button")<TypeButtonProperties>(
|
||||
({ disabled, $pressed, $underlinedColor }) => {
|
||||
let result: Record<string, any> = {
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
// fontSize: "26px",
|
||||
border: "0px solid #fff",
|
||||
borderRadius: "2px",
|
||||
marginRight: "5px",
|
||||
transition: "all 0.2s",
|
||||
cursor: "pointer",
|
||||
padding: "0px",
|
||||
};
|
||||
if (disabled) {
|
||||
result.color = theme.palette.grey["600"];
|
||||
result.cursor = "default";
|
||||
} else {
|
||||
result.borderTop = $underlinedColor ? "3px solid #FFF" : "none";
|
||||
result.borderBottom = $underlinedColor
|
||||
? `3px solid ${$underlinedColor}`
|
||||
: "none";
|
||||
(result.color = "#21243A"),
|
||||
(result.backgroundColor = $pressed
|
||||
? theme.palette.grey["600"]
|
||||
: "inherit");
|
||||
result["&:hover"] = {
|
||||
backgroundColor: "#F1F2F8",
|
||||
borderTopColor: "#F1F2F8",
|
||||
};
|
||||
result["svg"] = {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
const ChevronRightStyled = styled(ChevronRight)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default BorderPicker;
|
||||
261
webapp/src/components/colorPicker.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import styled from "@emotion/styled";
|
||||
import Popover, { PopoverOrigin } from "@mui/material/Popover";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { HexColorInput, HexColorPicker } from "react-colorful";
|
||||
import { theme } from "../theme";
|
||||
|
||||
type ColorPickerProps = {
|
||||
className?: string;
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
anchorEl: React.RefObject<HTMLElement>;
|
||||
anchorOrigin?: PopoverOrigin;
|
||||
transformOrigin?: PopoverOrigin;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
const colorPickerWidth = 240;
|
||||
const colorPickerPadding = 15;
|
||||
const colorfulHeight = 185; // 150 + 15 + 20
|
||||
|
||||
const ColorPicker = (properties: ColorPickerProps) => {
|
||||
const [color, setColor] = useState<string>(properties.color);
|
||||
const recentColors = useRef<string[]>([]);
|
||||
|
||||
const closePicker = (newColor: string): void => {
|
||||
const maxRecentColors = 14;
|
||||
properties.onChange(newColor);
|
||||
const colors = recentColors.current.filter((c) => c !== newColor);
|
||||
recentColors.current = [newColor, ...colors].slice(0, maxRecentColors);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setColor(properties.color);
|
||||
}, [properties.color]);
|
||||
|
||||
const presetColors = [
|
||||
"#FFFFFF",
|
||||
"#1B717E",
|
||||
"#59B9BC",
|
||||
"#3BB68A",
|
||||
"#8CB354",
|
||||
"#F8CD3C",
|
||||
"#EC5753",
|
||||
"#A23C52",
|
||||
"#D03627",
|
||||
"#523E93",
|
||||
"#3358B7",
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={properties.open}
|
||||
onClose={(): void => closePicker(color)}
|
||||
anchorEl={properties.anchorEl.current}
|
||||
anchorOrigin={
|
||||
properties.anchorOrigin || { vertical: "bottom", horizontal: "left" }
|
||||
}
|
||||
transformOrigin={
|
||||
properties.transformOrigin || { vertical: "top", horizontal: "left" }
|
||||
}
|
||||
>
|
||||
<ColorPickerDialog>
|
||||
<HexColorPicker
|
||||
color={color}
|
||||
onChange={(newColor): void => {
|
||||
setColor(newColor);
|
||||
}}
|
||||
/>
|
||||
<ColorPickerInput>
|
||||
<HexWrapper>
|
||||
<HexLabel>{"Hex"}</HexLabel>
|
||||
<HexColorInputBox>
|
||||
<HashLabel>{"#"}</HashLabel>
|
||||
<HexColorInput
|
||||
color={color}
|
||||
onChange={(newColor): void => {
|
||||
setColor(newColor);
|
||||
}}
|
||||
/>
|
||||
</HexColorInputBox>
|
||||
</HexWrapper>
|
||||
<Swatch $color={color} />
|
||||
</ColorPickerInput>
|
||||
<HorizontalDivider />
|
||||
<ColorList>
|
||||
{presetColors.map((presetColor) => (
|
||||
<Button
|
||||
key={presetColor}
|
||||
$color={presetColor}
|
||||
onClick={(): void => {
|
||||
closePicker(presetColor);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ColorList>
|
||||
<HorizontalDivider />
|
||||
<RecentLabel>{"Recent"}</RecentLabel>
|
||||
<ColorList>
|
||||
{recentColors.current.map((recentColor) => (
|
||||
<Button
|
||||
key={recentColor}
|
||||
$color={recentColor}
|
||||
onClick={(): void => {
|
||||
closePicker(recentColor);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ColorList>
|
||||
</ColorPickerDialog>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const RecentLabel = styled.div`
|
||||
font-size: 12px;
|
||||
color: ${theme.palette.text.secondary};
|
||||
`;
|
||||
|
||||
const ColorList = styled.div`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const Button = styled.button<{ $color: string }>`
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
${({ $color }): string => {
|
||||
if ($color.toUpperCase() === "#FFFFFF") {
|
||||
return `border: 1px solid ${theme.palette.grey['600']};`;
|
||||
}
|
||||
return `border: 1px solid ${$color};`;
|
||||
}}
|
||||
background-color: ${({ $color }): string => {
|
||||
return $color;
|
||||
}};
|
||||
box-sizing: border-box;
|
||||
margin-top: 10px;
|
||||
margin-right: 10px;
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const HorizontalDivider = styled.div`
|
||||
height: 0px;
|
||||
width: 100%;
|
||||
border-top: 1px solid ${theme.palette.grey['400']};
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
|
||||
// const StyledPopover = styled(Popover)`
|
||||
// .MuiPopover-paper {
|
||||
// border-radius: 10px;
|
||||
// border: 0px solid ${theme.palette.background.default};
|
||||
// box-shadow: 1px 2px 8px rgba(139, 143, 173, 0.5);
|
||||
// }
|
||||
// .MuiPopover-padding {
|
||||
// padding: 0px;
|
||||
// }
|
||||
// .MuiList-padding {
|
||||
// padding: 0px;
|
||||
// }
|
||||
// `;
|
||||
|
||||
const ColorPickerDialog = styled.div`
|
||||
background: ${theme.palette.background.default};
|
||||
width: ${colorPickerWidth}px;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& .react-colorful {
|
||||
height: ${colorfulHeight}px;
|
||||
width: ${colorPickerWidth - colorPickerPadding * 2}px;
|
||||
}
|
||||
& .react-colorful__saturation {
|
||||
border-bottom: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
& .react-colorful__hue {
|
||||
height: 20px;
|
||||
margin-top: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
& .react-colorful__saturation-pointer {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
& .react-colorful__hue-pointer {
|
||||
width: 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
`;
|
||||
|
||||
const HashLabel = styled.div`
|
||||
margin: auto 0px auto 10px;
|
||||
font-size: 13px;
|
||||
color: #7d8ec2;
|
||||
font-family: ${theme.typography.button.fontFamily};
|
||||
`;
|
||||
|
||||
const HexLabel = styled.div`
|
||||
margin: auto 10px auto 0px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
font-family: ${theme.typography.button.fontFamily};
|
||||
`;
|
||||
|
||||
const HexColorInputBox = styled.div`
|
||||
display: inline-flex;
|
||||
flex-grow: 1;
|
||||
margin-right: 10px;
|
||||
width: 140px;
|
||||
height: 28px;
|
||||
border: 1px solid ${theme.palette.grey['600']};
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
const HexWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
& input {
|
||||
min-width: 0px;
|
||||
border: 0px;
|
||||
background: ${theme.palette.background.default};
|
||||
outline: none;
|
||||
font-family: ${theme.typography.button.fontFamily};
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
& input:focus {
|
||||
border-color: #4298ef;
|
||||
}
|
||||
`;
|
||||
|
||||
const Swatch = styled.div<{ $color: string }>`
|
||||
display: inline-flex;
|
||||
${({ $color }): string => {
|
||||
if ($color.toUpperCase() === "#FFFFFF") {
|
||||
return `border: 1px solid ${theme.palette.grey['600']};`;
|
||||
}
|
||||
return `border: 1px solid ${$color};`;
|
||||
}}
|
||||
background-color: ${({ $color }): string => $color};
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 5px;
|
||||
`;
|
||||
|
||||
const ColorPickerInput = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
`;
|
||||
|
||||
export default ColorPicker;
|
||||
420
webapp/src/components/editor/editor.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
KeyboardEvent,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { useRef } from "react";
|
||||
import EditorContext, { Area } from "./editorContext";
|
||||
import { getStringRange } from "./util";
|
||||
|
||||
/**
|
||||
* This is the Cell Editor for IronCalc
|
||||
* I uses a transparent textarea and a styled mask. What you see is the HTML styled content of the mask
|
||||
* and the caret from the textarea. The alternative would be to have a 'contenteditable' div.
|
||||
* That turns out to be a much more difficult implementation.
|
||||
*
|
||||
* The editor grows horizontally with text if it fits in the screen.
|
||||
* If it doesn't fit, it wraps and grows vertically. If it doesn't fit vertically it scrolls.
|
||||
*
|
||||
* Many keyboard and mouse events are handled gracefully by the textarea in full or in part.
|
||||
* For example letter key strokes like 'q' or '1' are handled full by the textarea.
|
||||
* Some keyboard events like "RightArrow" might need to be handled separately and let them bubble up,
|
||||
* or might be handled by the textarea, depending on the "editor mode".
|
||||
* Some other like "Enter" we need to intercept and change the normal behaviour.
|
||||
*/
|
||||
|
||||
const commonCSS: CSSProperties = {
|
||||
fontWeight: "inherit",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
whiteSpace: "pre",
|
||||
width: "100%",
|
||||
padding: 0,
|
||||
};
|
||||
|
||||
interface Cell {
|
||||
sheet: number;
|
||||
row: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
interface EditorOptions {
|
||||
minimalWidth: number;
|
||||
minimalHeight: number;
|
||||
textColor: string;
|
||||
originalText: string;
|
||||
getStyledText: (
|
||||
text: string,
|
||||
insertRangeText: string
|
||||
) => {
|
||||
html: JSX.Element[];
|
||||
isInReferenceMode: boolean;
|
||||
};
|
||||
onEditEnd: (text: string) => void;
|
||||
display: boolean;
|
||||
cell: Cell;
|
||||
sheetNames: string[];
|
||||
}
|
||||
|
||||
// You can either be editing a formula or content.
|
||||
// When editing content (behaviour is common to Excel and Google Sheets):
|
||||
// * If you start editing by typing you are in *accept* mode
|
||||
// * If you start editing by F2 you are in *cruise* mode
|
||||
// * If you start editing by double click you are in *cruise* mode
|
||||
// In Google Sheets "Enter" starts editing and puts you in *cruise* mode. We do not do that
|
||||
// Once you are in cruise mode it is not possible to switch to accept mode
|
||||
// The only way to go from accept mode to cruise mode is clicking in the content somewhere
|
||||
|
||||
// When editing a formula.
|
||||
// In Google Sheets you are either in insert mode or cruise mode.
|
||||
// You can get back to accept mode if you delete the whole formula
|
||||
// In Excel you can be either in insert or accept but if you click in the formula body
|
||||
// you switch to cruise mode. Once in cruise mode you can go to insert mode by selecting a range.
|
||||
// Then you are back in accept/insert modes
|
||||
|
||||
const Editor = (options: EditorOptions) => {
|
||||
const {
|
||||
minimalWidth,
|
||||
minimalHeight,
|
||||
textColor,
|
||||
onEditEnd,
|
||||
originalText,
|
||||
display,
|
||||
cell,
|
||||
sheetNames,
|
||||
} = options;
|
||||
|
||||
const [width, setWidth] = useState(minimalWidth);
|
||||
const [height, setHeight] = useState(minimalHeight);
|
||||
|
||||
const { editorContext, setEditorContext } = useContext(EditorContext);
|
||||
|
||||
const setBaseText = (newText: string) => {
|
||||
console.log('Calling setBaseText');
|
||||
setEditorContext((c) => {
|
||||
return {
|
||||
...c,
|
||||
baseText: newText,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const insertRangeText = editorContext.insertRange
|
||||
? getStringRange(editorContext.insertRange, sheetNames)
|
||||
: "";
|
||||
|
||||
const baseText = editorContext.baseText;
|
||||
const text = baseText + insertRangeText;
|
||||
console.log('baseText', baseText, 'insertRange:', insertRangeText);
|
||||
|
||||
const formulaRef = useRef<HTMLDivElement>(null);
|
||||
const maskRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// useEffect(() => {
|
||||
// setBaseText(originalText);
|
||||
// }, [cell]);
|
||||
|
||||
const { html: styledFormula, isInReferenceMode } = options.getStyledText(
|
||||
baseText,
|
||||
insertRangeText
|
||||
);
|
||||
|
||||
if (display && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (formulaRef.current) {
|
||||
const scrollWidth = formulaRef.current.scrollWidth;
|
||||
if (scrollWidth > width) {
|
||||
setWidth(scrollWidth);
|
||||
} else if (scrollWidth <= minimalWidth) {
|
||||
setWidth(minimalWidth);
|
||||
}
|
||||
const scrollHeight = formulaRef.current.scrollHeight;
|
||||
if (scrollHeight > height) {
|
||||
setHeight(scrollHeight);
|
||||
}
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInReferenceMode) {
|
||||
setEditorContext((c) => {
|
||||
return {
|
||||
...c,
|
||||
mode: "insert",
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setEditorContext((c) => {
|
||||
return {
|
||||
...c,
|
||||
mode: "cruise",
|
||||
insertRange: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [isInReferenceMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (display && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [display]);
|
||||
|
||||
console.log("Ok, this is running", text, editorContext.id);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const { key, shiftKey, altKey } = event;
|
||||
const textarea = textareaRef.current;
|
||||
const mode = editorContext.mode;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
switch (key) {
|
||||
case "Enter": {
|
||||
if (altKey) {
|
||||
// new line
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newText = text.slice(0, start) + "\n" + text.slice(end);
|
||||
setBaseText(newText);
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(start + 1, start + 1);
|
||||
}, 1);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
} else {
|
||||
// end edit
|
||||
onEditEnd(text);
|
||||
textarea.blur();
|
||||
// event bubbles up
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Escape": {
|
||||
setBaseText(originalText);
|
||||
textarea.blur();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
break;
|
||||
}
|
||||
case "ArrowLeft": {
|
||||
if (mode === "accept") {
|
||||
onEditEnd(text);
|
||||
textarea.blur();
|
||||
// event bubbles up
|
||||
return;
|
||||
} else if (mode == "insert") {
|
||||
if (shiftKey) {
|
||||
// increase the inserted range to the left
|
||||
if (!editorContext.insertRange) {
|
||||
setEditorContext((c) => {
|
||||
return {
|
||||
...c,
|
||||
insertRange: {
|
||||
absoluteColumnEnd: false,
|
||||
absoluteColumnStart: false,
|
||||
absoluteRowEnd: false,
|
||||
absoluteRowStart: false,
|
||||
sheet: cell.sheet,
|
||||
rowStart: cell.row,
|
||||
rowEnd: cell.row,
|
||||
columnStart: cell.column,
|
||||
columnEnd: cell.column,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// const r = insertRage;
|
||||
// r.columnStart = Math.max(r.columnStart - 1, 1);
|
||||
// setInsertRange(r);
|
||||
}
|
||||
} else {
|
||||
// move inserted cell to the left
|
||||
if (!editorContext.insertRange) {
|
||||
setEditorContext((c) => {
|
||||
return {
|
||||
...c,
|
||||
insertRange: {
|
||||
absoluteColumnEnd: false,
|
||||
absoluteColumnStart: false,
|
||||
absoluteRowEnd: false,
|
||||
absoluteRowStart: false,
|
||||
sheet: cell.sheet,
|
||||
rowStart: cell.row,
|
||||
rowEnd: cell.row,
|
||||
columnStart: cell.column,
|
||||
columnEnd: cell.column,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setEditorContext((c) => {
|
||||
const range = c.insertRange as Area;
|
||||
const row = range.rowStart;
|
||||
let column = range.columnStart - 1;
|
||||
if (column < 1) {
|
||||
column = 1;
|
||||
}
|
||||
return {
|
||||
...c,
|
||||
insertRange: {
|
||||
absoluteColumnEnd: false,
|
||||
absoluteColumnStart: false,
|
||||
absoluteRowEnd: false,
|
||||
absoluteRowStart: false,
|
||||
sheet: range.sheet,
|
||||
rowStart: row,
|
||||
rowEnd: row,
|
||||
columnStart: column,
|
||||
columnEnd: column,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// We don't do anything in "cruise mode" and rely on the textarea default behaviour
|
||||
break;
|
||||
}
|
||||
case "ArrowDown": {
|
||||
if (mode === "accept") {
|
||||
onEditEnd(text);
|
||||
textarea.blur();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowRight": {
|
||||
if (mode === "accept") {
|
||||
onEditEnd(text);
|
||||
textarea.blur();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "ArrowUp": {
|
||||
if (mode === "accept") {
|
||||
onEditEnd(text);
|
||||
textarea.blur();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Tab": {
|
||||
onEditEnd(text);
|
||||
textarea.blur();
|
||||
// event bubbles up
|
||||
}
|
||||
}
|
||||
if (editorContext.mode === "insert") {
|
||||
setBaseText(text);
|
||||
setEditorContext((context) => {
|
||||
return {
|
||||
...context,
|
||||
mode: "cruise",
|
||||
insertRange: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
[text, editorContext]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width,
|
||||
height,
|
||||
overflow: "hidden",
|
||||
background: "#FFF",
|
||||
display: display ? "block" : "none",
|
||||
}}
|
||||
onClick={(_event) => {
|
||||
console.log("Click on wrapper");
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
console.log("On pointer down wrapper");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={maskRef}
|
||||
style={{
|
||||
...commonCSS,
|
||||
textAlign: "left",
|
||||
pointerEvents: "none",
|
||||
height,
|
||||
}}
|
||||
onClick={(_event) => {
|
||||
console.log("Click on mask");
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
console.log("On pointer down mask");
|
||||
}}
|
||||
>
|
||||
<div ref={formulaRef}>{styledFormula}</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
style={{
|
||||
...commonCSS,
|
||||
color: "transparent",
|
||||
backgroundColor: "transparent",
|
||||
caretColor: textColor,
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
border: "none",
|
||||
height,
|
||||
}}
|
||||
spellCheck="false"
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
console.log("onChange", event.target.value);
|
||||
setBaseText(event.target.value);
|
||||
}}
|
||||
onScroll={() => {
|
||||
if (maskRef.current && textareaRef.current) {
|
||||
maskRef.current.style.left = `-${textareaRef.current.scrollLeft}px`;
|
||||
maskRef.current.style.top = `-${textareaRef.current.scrollTop}px`;
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={(event) => {
|
||||
console.log("Setting mode");
|
||||
setEditorContext((c) => {
|
||||
return {
|
||||
...c,
|
||||
mode: "cruise",
|
||||
};
|
||||
});
|
||||
console.log("here");
|
||||
// if (display) {
|
||||
event.stopPropagation();
|
||||
// }
|
||||
}}
|
||||
onBlur={() => {
|
||||
// on blur
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
></textarea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
45
webapp/src/components/editor/editorContext.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Dispatch, SetStateAction, createContext } from "react";
|
||||
|
||||
export interface Area {
|
||||
sheet: number | null;
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
absoluteRowStart: boolean;
|
||||
absoluteRowEnd: boolean;
|
||||
absoluteColumnStart: boolean;
|
||||
absoluteColumnEnd: boolean;
|
||||
}
|
||||
|
||||
// Arrow keys behave in different ways depending on the "edit mode":
|
||||
// * In _cruise_ mode arrowy keys navigate within the editor
|
||||
// * In _accept_ mode pressing an arrow key will end editing
|
||||
// * In _insert_ mode arrow keys will change the selected range
|
||||
export type EditorMode = "cruise" | "accept" | "insert";
|
||||
|
||||
export interface EditorState {
|
||||
mode: EditorMode;
|
||||
insertRange: null | Area;
|
||||
baseText: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface EditorContextType {
|
||||
editorContext: EditorState;
|
||||
setEditorContext: Dispatch<
|
||||
SetStateAction<{ mode: EditorMode; insertRange: null | Area }>
|
||||
>;
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextType>({
|
||||
editorContext: {
|
||||
mode: "accept",
|
||||
insertRange: null,
|
||||
baseText: '',
|
||||
id: Math.floor(Math.random()*1000),
|
||||
},
|
||||
setEditorContext: () => {},
|
||||
});
|
||||
|
||||
export default EditorContext;
|
||||
3
webapp/src/components/editor/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './editor';
|
||||
|
||||
|
||||
92
webapp/src/components/editor/tokenTypes.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
type ErrorType =
|
||||
| 'REF'
|
||||
| 'NAME'
|
||||
| 'VALUE'
|
||||
| 'DIV'
|
||||
| 'NA'
|
||||
| 'NUM'
|
||||
| 'ERROR'
|
||||
| 'NIMPL'
|
||||
| 'SPILL'
|
||||
| 'CALC'
|
||||
| 'CIRC';
|
||||
|
||||
type OpCompareType =
|
||||
| 'LessThan'
|
||||
| 'GreaterThan'
|
||||
| 'Equal'
|
||||
| 'LessOrEqualThan'
|
||||
| 'GreaterOrEqualThan'
|
||||
| 'NonEqual';
|
||||
|
||||
type OpSumType = 'Add' | 'Minus';
|
||||
|
||||
type OpProductType = 'Times' | 'Divide';
|
||||
|
||||
interface ReferenceType {
|
||||
sheet: string | null;
|
||||
row: number;
|
||||
column: number;
|
||||
absolute_column: boolean;
|
||||
absolute_row: boolean;
|
||||
}
|
||||
|
||||
interface ParsedReferenceType {
|
||||
column: number;
|
||||
row: number;
|
||||
absolute_column: boolean;
|
||||
absolute_row: boolean;
|
||||
}
|
||||
|
||||
interface Reference {
|
||||
Reference: ReferenceType;
|
||||
}
|
||||
|
||||
interface Range {
|
||||
Range: {
|
||||
sheet: string | null;
|
||||
left: ParsedReferenceType;
|
||||
right: ParsedReferenceType;
|
||||
};
|
||||
}
|
||||
|
||||
export type TokenType =
|
||||
| 'Illegal'
|
||||
| 'Eof'
|
||||
| { Ident: string }
|
||||
| { String: string }
|
||||
| { Boolean: boolean }
|
||||
| { Number: number }
|
||||
| { ERROR: ErrorType }
|
||||
| { COMPARE: OpCompareType }
|
||||
| { SUM: OpSumType }
|
||||
| { PRODUCT: OpProductType }
|
||||
| 'POWER'
|
||||
| 'LPAREN'
|
||||
| 'RPAREN'
|
||||
| 'COLON'
|
||||
| 'SEMICOLON'
|
||||
| 'LBRACKET'
|
||||
| 'RBRACKET'
|
||||
| 'LBRACE'
|
||||
| 'RBRACE'
|
||||
| 'COMMA'
|
||||
| 'BANG'
|
||||
| 'PERCENT'
|
||||
| 'AND'
|
||||
| Reference
|
||||
| Range;
|
||||
|
||||
export interface MarkedToken {
|
||||
token: TokenType;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function tokenIsReferenceType(token: TokenType): token is Reference {
|
||||
return typeof token === 'object' && 'Reference' in token;
|
||||
}
|
||||
|
||||
export function tokenIsRangeType(token: TokenType): token is Range {
|
||||
return typeof token === 'object' && 'Range' in token;
|
||||
}
|
||||
108
webapp/src/components/editor/useEditorKeyDown.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback, KeyboardEvent } from "react";
|
||||
import { WorkbookState } from "../workbookState";
|
||||
import { Model } from "@ironcalc/wasm";
|
||||
|
||||
interface Options {
|
||||
// onMoveCaretToStart: () => void;
|
||||
// onMoveCaretToEnd: () => void;
|
||||
// onEditEnd: (delta: { deltaRow: number; deltaColumn: number }) => void;
|
||||
// onEditEscape: () => void;
|
||||
// onReferenceCycle: () => void;
|
||||
// text: string;
|
||||
// setText: (text: string) => void;
|
||||
model: Model;
|
||||
state: WorkbookState;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const useEditorKeydown = (
|
||||
options: Options
|
||||
): {
|
||||
onKeyDown: (event: KeyboardEvent) => void;
|
||||
} => {
|
||||
const { state, model } = options;
|
||||
const onKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
const { key, shiftKey } = event;
|
||||
const { mode, text } = state.getEditor() ?? { mode: "init", text: "" };
|
||||
switch (key) {
|
||||
case "Enter":
|
||||
// options.onEditEnd({ deltaRow: 1, deltaColumn: 0 });
|
||||
const { row, column } = state.getSelectedCell();
|
||||
const sheet = state.getSelectedSheet();
|
||||
model.setUserInput(sheet, row, column, text);
|
||||
state.selectCell({ row: row + 1, column });
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
options.refresh();
|
||||
break;
|
||||
// case 'ArrowUp': {
|
||||
// if (mode === 'init') {
|
||||
// options.onEditEnd({ deltaRow: -1, deltaColumn: 0 });
|
||||
// } else {
|
||||
// options.onMoveCaretToStart();
|
||||
// }
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// break;
|
||||
// }
|
||||
// case 'ArrowDown': {
|
||||
// if (mode === 'init') {
|
||||
// options.onEditEnd({ deltaRow: 1, deltaColumn: 0 });
|
||||
// } else {
|
||||
// options.onMoveCaretToEnd();
|
||||
// }
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// break;
|
||||
// }
|
||||
// case 'Tab': {
|
||||
// if (event.shiftKey) {
|
||||
// options.onEditEnd({ deltaRow: 0, deltaColumn: -1 });
|
||||
// } else {
|
||||
// options.onEditEnd({ deltaRow: 0, deltaColumn: 1 });
|
||||
// }
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
|
||||
// break;
|
||||
// }
|
||||
// case 'Escape': {
|
||||
// options.onEditEscape();
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
|
||||
// break;
|
||||
// }
|
||||
// case 'ArrowLeft': {
|
||||
// if (mode === 'init') {
|
||||
// options.onEditEnd({ deltaRow: 0, deltaColumn: -1 });
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// }
|
||||
|
||||
// break;
|
||||
// }
|
||||
// case 'ArrowRight': {
|
||||
// if (mode === 'init') {
|
||||
// options.onEditEnd({ deltaRow: 0, deltaColumn: 1 });
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// }
|
||||
|
||||
// break;
|
||||
// }
|
||||
// case 'F4': {
|
||||
// options.onReferenceCycle();
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
|
||||
// break;
|
||||
// }
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [model, state]);
|
||||
return { onKeyDown };
|
||||
};
|
||||
|
||||
export default useEditorKeydown;
|
||||
334
webapp/src/components/editor/util.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { getTokens } from "@ironcalc/wasm";
|
||||
import { tokenIsRangeType, tokenIsReferenceType } from "./tokenTypes";
|
||||
import { Area } from "./editorContext";
|
||||
|
||||
const letters = [
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
"G",
|
||||
"H",
|
||||
"I",
|
||||
"J",
|
||||
"K",
|
||||
"L",
|
||||
"M",
|
||||
"N",
|
||||
"O",
|
||||
"P",
|
||||
"Q",
|
||||
"R",
|
||||
"S",
|
||||
"T",
|
||||
"U",
|
||||
"V",
|
||||
"W",
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
];
|
||||
interface Reference {
|
||||
row: number;
|
||||
column: number;
|
||||
absoluteRow: boolean;
|
||||
absoluteColumn: boolean;
|
||||
}
|
||||
|
||||
export function referenceToString(rf: Reference): string {
|
||||
const absC = rf.absoluteColumn ? "$" : "";
|
||||
const absR = rf.absoluteRow ? "$" : "";
|
||||
return absC + columnNameFromNumber(rf.column) + absR + rf.row;
|
||||
}
|
||||
|
||||
export function columnNameFromNumber(column: number): string {
|
||||
let columnName = "";
|
||||
let index = column;
|
||||
while (index > 0) {
|
||||
columnName = `${letters[(index - 1) % 26]}${columnName}`;
|
||||
index = Math.floor((index - 1) / 26);
|
||||
}
|
||||
return columnName;
|
||||
}
|
||||
|
||||
export function columnNumberFromName(columnName: string): number {
|
||||
let column = 0;
|
||||
for (const character of columnName) {
|
||||
const index = (character.codePointAt(0) ?? 0) - 64;
|
||||
column = column * 26 + index;
|
||||
}
|
||||
return column;
|
||||
}
|
||||
interface Range {
|
||||
sheet: number | null;
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
absoluteRowStart: boolean;
|
||||
absoluteRowEnd: boolean;
|
||||
absoluteColumnStart: boolean;
|
||||
absoluteColumnEnd: boolean;
|
||||
}
|
||||
|
||||
export function getStringRange(range: Range, sheetNames: string[]) {
|
||||
const name = range.sheet ? `${sheetNames[range.sheet]}!` : "";
|
||||
const left = referenceToString({
|
||||
row: range.rowStart,
|
||||
column: range.columnStart,
|
||||
absoluteRow: range.absoluteRowStart,
|
||||
absoluteColumn: range.absoluteColumnStart,
|
||||
});
|
||||
if (
|
||||
range.rowStart === range.rowEnd &&
|
||||
range.columnStart === range.columnEnd
|
||||
) {
|
||||
return `${name}${left}`;
|
||||
}
|
||||
const right = referenceToString({
|
||||
row: range.rowEnd,
|
||||
column: range.columnEnd,
|
||||
absoluteRow: range.absoluteRowEnd,
|
||||
absoluteColumn: range.absoluteColumnEnd,
|
||||
});
|
||||
return `${name}${left}:${right}`;
|
||||
}
|
||||
|
||||
interface ActiveRange {
|
||||
sheet: number;
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// IronCalc Color Palette
|
||||
export function getColor(index: number, alpha = 1): string {
|
||||
const colors = [
|
||||
{
|
||||
name: "Cyan",
|
||||
rgba: [89, 185, 188, 1],
|
||||
hex: "#59B9BC",
|
||||
},
|
||||
{
|
||||
name: "Flamingo",
|
||||
rgba: [236, 87, 83, 1],
|
||||
hex: "#EC5753",
|
||||
},
|
||||
{
|
||||
hex: "#3358B7",
|
||||
rgba: [51, 88, 183, 1],
|
||||
name: "Blue",
|
||||
},
|
||||
{
|
||||
hex: "#F8CD3C",
|
||||
rgba: [248, 205, 60, 1],
|
||||
name: "Yellow",
|
||||
},
|
||||
{
|
||||
hex: "#3BB68A",
|
||||
rgba: [59, 182, 138, 1],
|
||||
name: "Emerald",
|
||||
},
|
||||
{
|
||||
hex: "#523E93",
|
||||
rgba: [82, 62, 147, 1],
|
||||
name: "Violet",
|
||||
},
|
||||
{
|
||||
hex: "#A23C52",
|
||||
rgba: [162, 60, 82, 1],
|
||||
name: "Burgundy",
|
||||
},
|
||||
{
|
||||
hex: "#8CB354",
|
||||
rgba: [162, 60, 82, 1],
|
||||
name: "Wasabi",
|
||||
},
|
||||
{
|
||||
hex: "#D03627",
|
||||
rgba: [208, 54, 39, 1],
|
||||
name: "Red",
|
||||
},
|
||||
{
|
||||
hex: "#1B717E",
|
||||
rgba: [27, 113, 126, 1],
|
||||
name: "Teal",
|
||||
},
|
||||
];
|
||||
if (alpha === 1) {
|
||||
return colors[index % 10].hex;
|
||||
}
|
||||
const { rgba } = colors[index % 10];
|
||||
return `rgba(${rgba[0]}, ${rgba[1]}, ${rgba[2]}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* This function get a formula like `=A1*SUM(B5:C6)` and transforms it to:
|
||||
*
|
||||
* `<span>=</span><span>A1</span><span>SUM</span><span>(</span><span>B5:C6</span><span>)</span>`
|
||||
*
|
||||
* While also returning the set of ranges [A1, B5:C6] with specific color assignments for each range
|
||||
*/
|
||||
export function getFormulaHTML(
|
||||
text: string,
|
||||
sheet: number,
|
||||
sheetList: string[],
|
||||
insertRage: Area | null,
|
||||
insertRangeText: string
|
||||
): {
|
||||
html: JSX.Element[];
|
||||
activeRanges: ActiveRange[];
|
||||
isInReferenceMode: boolean;
|
||||
} {
|
||||
let html = [];
|
||||
const activeRanges: ActiveRange[] = [];
|
||||
let colorCount = 0;
|
||||
if (text.startsWith("=")) {
|
||||
const formula = text.slice(1);
|
||||
|
||||
const tokens = getTokens(formula);
|
||||
const tokenCount = tokens.length;
|
||||
const usedColors: Record<string, string> = {};
|
||||
for (let index = 0; index < tokenCount; index += 1) {
|
||||
const { token, start, end } = tokens[index];
|
||||
if (tokenIsReferenceType(token)) {
|
||||
const { sheet: refSheet, row, column } = token.Reference;
|
||||
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
|
||||
const key = `${sheetIndex}-${row}-${column}`;
|
||||
let color = usedColors[key];
|
||||
if (!color) {
|
||||
color = getColor(colorCount);
|
||||
usedColors[key] = color;
|
||||
colorCount += 1;
|
||||
}
|
||||
html.push(
|
||||
<span key={index} style={{ color }}>
|
||||
{formula.slice(start, end)}
|
||||
</span>
|
||||
);
|
||||
activeRanges.push({
|
||||
sheet: sheetIndex,
|
||||
rowStart: row,
|
||||
columnStart: column,
|
||||
rowEnd: row,
|
||||
columnEnd: column,
|
||||
color,
|
||||
});
|
||||
} else if (tokenIsRangeType(token)) {
|
||||
let {
|
||||
sheet: refSheet,
|
||||
left: { row: rowStart, column: columnStart },
|
||||
right: { row: rowEnd, column: columnEnd },
|
||||
} = token.Range;
|
||||
const sheetIndex = refSheet ? sheetList.indexOf(refSheet) : sheet;
|
||||
|
||||
const key = `${sheetIndex}-${rowStart}-${columnStart}:${rowEnd}-${columnEnd}`;
|
||||
let color = usedColors[key];
|
||||
if (!color) {
|
||||
color = getColor(colorCount);
|
||||
usedColors[key] = color;
|
||||
colorCount += 1;
|
||||
}
|
||||
|
||||
if (rowStart > rowEnd) {
|
||||
[rowStart, rowEnd] = [rowEnd, rowStart];
|
||||
}
|
||||
if (columnStart > columnEnd) {
|
||||
[columnStart, columnEnd] = [columnEnd, columnStart];
|
||||
}
|
||||
html.push(
|
||||
<span key={index} style={{ color }}>
|
||||
{formula.slice(start, end)}
|
||||
</span>
|
||||
);
|
||||
colorCount += 1;
|
||||
|
||||
activeRanges.push({
|
||||
sheet: sheetIndex,
|
||||
rowStart,
|
||||
columnStart,
|
||||
rowEnd,
|
||||
columnEnd,
|
||||
color,
|
||||
});
|
||||
} else {
|
||||
html.push(<span key={index}>{formula.slice(start, end)}</span>);
|
||||
}
|
||||
}
|
||||
if (tokenCount > 0) {
|
||||
const lastToken = tokens[tokens.length - 1];
|
||||
if (lastToken.end < text.length - 1) {
|
||||
html.push(
|
||||
<span key="rest">{text.slice(lastToken.end + 1, text.length)}</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
html = [<span key="equals">=</span>].concat(html);
|
||||
} else {
|
||||
html = [<span key="single">{text}</span>];
|
||||
}
|
||||
const isRefMode = isInReferenceMode(text, text.length);
|
||||
if (isRefMode) {
|
||||
if (insertRage) {
|
||||
const color = getColor(colorCount);
|
||||
activeRanges.push({
|
||||
sheet: insertRage.sheet || sheet,
|
||||
rowStart: insertRage.rowStart,
|
||||
rowEnd: insertRage.rowEnd,
|
||||
columnStart: insertRage.columnStart,
|
||||
columnEnd: insertRage.columnEnd,
|
||||
color,
|
||||
});
|
||||
colorCount += 1;
|
||||
html.push(
|
||||
<span key="insert-range" style={{ color, textDecoration: "underline" }}>
|
||||
{insertRangeText}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
html.push(
|
||||
<span
|
||||
key="insert-cue"
|
||||
style={{
|
||||
border: "1px solid #d5d5d5",
|
||||
height: "2px",
|
||||
width: "7px",
|
||||
borderTop: 0,
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
// We add a clickable element that spans the rest of the available space
|
||||
html.push(<span key="spacer" style={{ flexGrow: 1 }}></span>);
|
||||
return { html, activeRanges, isInReferenceMode: isRefMode };
|
||||
}
|
||||
|
||||
export function isInReferenceMode(text: string, cursor: number): boolean {
|
||||
// FIXME
|
||||
// This is a gross oversimplification
|
||||
// Returns true if both are true:
|
||||
// 1. Cursor is at the end
|
||||
// 2. Last char is one of [',', '(', '+', '*', '-', '/', '<', '>', '=', '&']
|
||||
// This has many false positives like '="1+' and also likely some false negatives
|
||||
// The right way of doing this is to have a partial parse of the formula tree
|
||||
// and check if the next token could be a reference
|
||||
if (!text.startsWith("=")) {
|
||||
return false;
|
||||
}
|
||||
if (text === "=") {
|
||||
return true;
|
||||
}
|
||||
const l = text.length;
|
||||
const chars = [",", "(", "+", "*", "-", "/", "<", ">", "=", "&"];
|
||||
if (cursor === l && chars.includes(text[l - 1])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
109
webapp/src/components/formatMenu.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useRef, ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberFormats } from './formatUtil';
|
||||
import { Menu, MenuItem, styled } from '@mui/material';
|
||||
import FormatPicker from './formatPicker';
|
||||
|
||||
type FormatMenuProps = {
|
||||
children: any; //ReactI18NextChild | Iterable<ReactI18NextChild>;
|
||||
numFmt: string;
|
||||
onChange: (numberFmt: string) => void;
|
||||
onExited?: () => void;
|
||||
anchorOrigin?: ComponentProps<typeof Menu>['anchorOrigin'];
|
||||
};
|
||||
|
||||
const FormatMenu = (properties: FormatMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { onChange } = properties;
|
||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||
const [isPickerOpen, setPickerOpen] = useState(false);
|
||||
const anchorElement = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChildrenWrapper onClick={(): void => setMenuOpen(true)} ref={anchorElement}>
|
||||
{properties.children}
|
||||
</ChildrenWrapper>
|
||||
<Menu
|
||||
open={isMenuOpen}
|
||||
onClose={(): void => setMenuOpen(false)}
|
||||
// onExited={properties.onExited}
|
||||
anchorEl={anchorElement.current}
|
||||
anchorOrigin={properties.anchorOrigin}
|
||||
>
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.AUTO)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.auto')}</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
{/** TODO: Text option that transforms into plain text */}
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.NUMBER)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.number')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.number_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.PERCENTAGE)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.percentage')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.percentage_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_EUR)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.currency_eur')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.currency_eur_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_USD)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.currency_usd')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.currency_usd_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.CURRENCY_GBP)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.currency_gbp')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.currency_gbp_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.DATE_SHORT)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.date_short')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.date_short_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
<MenuItemWrapper onClick={(): void => onChange(NumberFormats.DATE_LONG)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.date_long')}</MenuItemText>
|
||||
<MenuItemExample>{t('toolbar.format_menu.date_long_example')}</MenuItemExample>
|
||||
</MenuItemWrapper>
|
||||
|
||||
<MenuDivider />
|
||||
<MenuItemWrapper onClick={(): void => setPickerOpen(true)}>
|
||||
<MenuItemText>{t('toolbar.format_menu.custom')}</MenuItemText>
|
||||
</MenuItemWrapper>
|
||||
</Menu>
|
||||
<FormatPicker
|
||||
numFmt={properties.numFmt}
|
||||
onChange={properties.onChange}
|
||||
open={isPickerOpen}
|
||||
onClose={(): void => setPickerOpen(false)}
|
||||
onExited={properties.onExited}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuItemWrapper = styled(MenuItem)`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const ChildrenWrapper = styled('div')`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const MenuDivider = styled('div')``;
|
||||
|
||||
const MenuItemText = styled('div')`
|
||||
color: #000;
|
||||
`;
|
||||
|
||||
const MenuItemExample = styled('div')`
|
||||
margin-left: 20px;
|
||||
`;
|
||||
|
||||
export default FormatMenu;
|
||||
46
webapp/src/components/formatPicker.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type FormatPickerProps = {
|
||||
className?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onExited?: () => void;
|
||||
numFmt: string;
|
||||
onChange: (numberFmt: string) => void;
|
||||
};
|
||||
|
||||
const FormatPicker = (properties: FormatPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [formatCode, setFormatCode] = useState(properties.numFmt);
|
||||
|
||||
const onSubmit = (format_code: string): void => {
|
||||
properties.onChange(format_code);
|
||||
properties.onClose();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={properties.open}
|
||||
onClose={properties.onClose}
|
||||
>
|
||||
<DialogTitle>{t('num_fmt.title')}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TextField
|
||||
defaultValue={properties.numFmt}
|
||||
label={t('num_fmt.label')}
|
||||
name="format_code"
|
||||
onChange={(event) => setFormatCode(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => onSubmit(formatCode)}>
|
||||
{t('num_fmt.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default FormatPicker;
|
||||
36
webapp/src/components/formatUtil.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function increaseDecimalPlaces(numberFormat: string): string {
|
||||
// FIXME: Should it be done in the Rust? How should it work?
|
||||
// Increase decimal places for existing numbers with decimals
|
||||
const newNumberFormat = numberFormat.replace(/\.0/g, '.00');
|
||||
// If no decimal places declared, add 0.0
|
||||
if (!newNumberFormat.includes('.')) {
|
||||
if (newNumberFormat.includes('0')) {
|
||||
return newNumberFormat.replace(/0/g, '0.0');
|
||||
}
|
||||
if (newNumberFormat.includes('#')) {
|
||||
return newNumberFormat.replace(/#([^#,]|$)/g, '0.0$1');
|
||||
}
|
||||
return '0.0';
|
||||
}
|
||||
return newNumberFormat;
|
||||
}
|
||||
|
||||
export function decreaseDecimalPlaces(numberFormat: string): string {
|
||||
// FIXME: Should it be done in the Rust? How should it work?
|
||||
// Decrease decimal places for existing numbers with decimals
|
||||
let newNumberFormat = numberFormat.replace(/\.0/g, '.');
|
||||
// Fix leftover dots
|
||||
newNumberFormat = newNumberFormat.replace(/0\.([^0]|$)/, '0$1');
|
||||
return newNumberFormat;
|
||||
}
|
||||
|
||||
export enum NumberFormats {
|
||||
AUTO = 'general',
|
||||
CURRENCY_EUR = '"€"#,##0.00',
|
||||
CURRENCY_USD = '"$"#,##0.00',
|
||||
CURRENCY_GBP = '"£"#,##0.00',
|
||||
DATE_SHORT = 'dd"/"mm"/"yyyy',
|
||||
DATE_LONG = 'dddd"," mmmm dd"," yyyy',
|
||||
PERCENTAGE = '0.00%',
|
||||
NUMBER = '#,##0.00',
|
||||
}
|
||||
108
webapp/src/components/formulabar.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Button, styled } from "@mui/material";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Fx } from "../icons";
|
||||
|
||||
type FormulaBarProps = {
|
||||
cellAddress: string;
|
||||
};
|
||||
|
||||
const formulaBarHeight = 30;
|
||||
const headerColumnWidth = 30;
|
||||
|
||||
function FormulaBar(properties: FormulaBarProps) {
|
||||
return (
|
||||
<Container>
|
||||
<AddressContainer>
|
||||
<CellBarAddress>{properties.cellAddress}</CellBarAddress>
|
||||
<StyledButton>
|
||||
<ChevronDown />
|
||||
</StyledButton>
|
||||
</AddressContainer>
|
||||
<Divider />
|
||||
<FormulaContainer>
|
||||
<FormulaSymbolButton><Fx /></FormulaSymbolButton>
|
||||
<Editor contentEditable="true" spellCheck="false" />
|
||||
</FormulaContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: 15px;
|
||||
min-width: 0px;
|
||||
padding: 0px;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
`;
|
||||
|
||||
const FormulaSymbolButton = styled(StyledButton)`
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const Divider = styled("div")`
|
||||
background-color: #e0e0e0;
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
`;
|
||||
|
||||
const FormulaContainer = styled("div")`
|
||||
margin-left: 10px;
|
||||
line-height: 22px;
|
||||
font-weight: normal;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const Container = styled("div")`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: ${(properties): string =>
|
||||
properties.theme.palette.background.default};
|
||||
height: ${formulaBarHeight}px;
|
||||
`;
|
||||
|
||||
const AddressContainer = styled("div")`
|
||||
padding-left: 16px;
|
||||
color: #333;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
font-weight: 600;
|
||||
flex-grow: row;
|
||||
min-width: ${headerColumnWidth}px;
|
||||
`;
|
||||
|
||||
const CellBarAddress = styled("div")`
|
||||
width: 100%;
|
||||
text-align: "center";
|
||||
`;
|
||||
|
||||
const Editor = styled("div")`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding: 0px;
|
||||
border-width: 0px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
white-space: pre-wrap;
|
||||
vertical-align: bottom;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
span {
|
||||
min-width: 1px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default FormulaBar;
|
||||
2
webapp/src/components/navigation/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './navigation';
|
||||
export type { NavigationProps } from './navigation';
|
||||
72
webapp/src/components/navigation/menus.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { styled } from "@mui/material";
|
||||
import { SheetOptions } from "./types";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
interface SheetListMenuProps {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
anchorEl: HTMLButtonElement | null;
|
||||
onSheetSelected: (index: number) => void;
|
||||
sheetOptionsList: SheetOptions[];
|
||||
}
|
||||
|
||||
const SheetListMenu = (properties: SheetListMenuProps) => {
|
||||
const { isOpen, close, anchorEl, onSheetSelected, sheetOptionsList } =
|
||||
properties;
|
||||
|
||||
return (
|
||||
<StyledMenu
|
||||
open={isOpen}
|
||||
onClose={close}
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: 6,
|
||||
}}
|
||||
>
|
||||
{sheetOptionsList.map((tab, index) => (
|
||||
<StyledMenuItem
|
||||
key={tab.sheetId}
|
||||
onClick={(): void => onSheetSelected(index)}
|
||||
>
|
||||
<ItemColor style={{ backgroundColor: tab.color }} />
|
||||
<ItemName>{tab.name}</ItemName>
|
||||
</StyledMenuItem>
|
||||
))}
|
||||
</StyledMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledMenu = styled(Menu)({
|
||||
"& .MuiPaper-root": {
|
||||
borderRadius: 8,
|
||||
padding: 4
|
||||
},
|
||||
"& .MuiList-padding": {
|
||||
padding: 0,
|
||||
}
|
||||
});
|
||||
|
||||
const StyledMenuItem = styled(MenuItem)({
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
})
|
||||
|
||||
const ItemColor = styled("div")`
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const ItemName = styled("div")`
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
export default SheetListMenu;
|
||||
139
webapp/src/components/navigation/navigation.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { styled } from "@mui/material";
|
||||
import { ChevronLeft, ChevronRight, Menu, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SheetOptions } from "./types";
|
||||
import SheetListMenu from "./menus";
|
||||
import Sheet from "./sheet";
|
||||
import { StyledButton } from "../toolbar";
|
||||
|
||||
export interface NavigationProps {
|
||||
sheets: SheetOptions[];
|
||||
selectedIndex: number;
|
||||
onSheetSelected: (index: number) => void;
|
||||
onAddBlankSheet: () => void;
|
||||
onSheetColorChanged: (hex: string) => void;
|
||||
onSheetRenamed: (name: string) => void;
|
||||
onSheetDeleted: () => void;
|
||||
}
|
||||
|
||||
function Navigation(props: NavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
const { onSheetSelected, sheets, selectedIndex } = props;
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
<StyledButton title={t("navigation.add_sheet")} $pressed={false}>
|
||||
<Plus />
|
||||
</StyledButton>
|
||||
<StyledButton onClick={handleClick} title={t("navigation.sheet_list")} $pressed={false}>
|
||||
<Menu />
|
||||
</StyledButton>
|
||||
<Sheets>
|
||||
<SheetInner>
|
||||
{sheets.map((tab, index) => (
|
||||
<Sheet
|
||||
key={tab.sheetId}
|
||||
name={tab.name}
|
||||
color={tab.color}
|
||||
selected={index === selectedIndex}
|
||||
onSelected={() => onSheetSelected(index)}
|
||||
onColorChanged={function (hex: string): void {
|
||||
console.log("Picked:", hex);
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
onRenamed={function (name: string): void {
|
||||
console.log("Renamed:", name);
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
onDeleted={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SheetInner>
|
||||
</Sheets>
|
||||
<LeftDivider />
|
||||
<ChevronLeftStyled />
|
||||
<ChevronRightStyled />
|
||||
<RightDivider />
|
||||
<Advert>ironcalc.com</Advert>
|
||||
<SheetListMenu
|
||||
anchorEl={anchorEl}
|
||||
isOpen={open}
|
||||
close={handleClose}
|
||||
sheetOptionsList={sheets}
|
||||
onSheetSelected={onSheetSelected}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const ChevronLeftStyled = styled(ChevronLeft)`
|
||||
color: #333333;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const ChevronRightStyled = styled(ChevronRight)`
|
||||
color: #333333;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
// Note I have to specify the font-family in every component that can be considered stand-alone
|
||||
const Container = styled("div")`
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
align-items: center;
|
||||
padding-left: 12px;
|
||||
font-family: Inter;
|
||||
background-color: #fff;
|
||||
`;
|
||||
|
||||
const Sheets = styled("div")`
|
||||
flex-grow: 2;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const SheetInner = styled("div")`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const LeftDivider = styled("div")`
|
||||
height: 10px;
|
||||
width: 1px;
|
||||
background-color: #eee;
|
||||
margin: 0px 10px 0px 0px;
|
||||
`;
|
||||
|
||||
const RightDivider = styled("div")`
|
||||
height: 10px;
|
||||
width: 1px;
|
||||
background-color: #eee;
|
||||
margin: 0px 20px 0px 10px;
|
||||
`;
|
||||
|
||||
const Advert = styled("div")`
|
||||
color: #f2994a;
|
||||
margin-right: 12px;
|
||||
font-size: 12px;
|
||||
`;
|
||||
|
||||
export default Navigation;
|
||||
83
webapp/src/components/navigation/sheet.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Button, Menu, MenuItem, styled } from "@mui/material";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
interface SheetProps {
|
||||
name: string;
|
||||
color: string;
|
||||
selected: boolean;
|
||||
onSelected: () => void;
|
||||
onColorChanged: (hex: string) => void;
|
||||
onRenamed: (name: string) => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
function Sheet(props: SheetProps) {
|
||||
const { name, color, selected, onSelected } = props;
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLButtonElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
return (
|
||||
<Wrapper
|
||||
style={{ borderBottomColor: color, fontWeight: selected ? 600 : 400 }}
|
||||
onClick={onSelected}
|
||||
>
|
||||
<Name>{name}</Name>
|
||||
<StyledButton onClick={handleOpen}>
|
||||
<ChevronDown />
|
||||
</StyledButton>
|
||||
<StyledMenu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: 6,
|
||||
}}
|
||||
>
|
||||
<MenuItem>Rename</MenuItem>
|
||||
<MenuItem>Change Color</MenuItem>
|
||||
<MenuItem>Delete</MenuItem>
|
||||
</StyledMenu>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledMenu = styled(Menu)``;
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: 15px;
|
||||
height: 24px;
|
||||
min-width: 0px;
|
||||
padding: 0px;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")`
|
||||
display: flex;
|
||||
margin-left: 20px;
|
||||
border-bottom: 3px solid;
|
||||
border-top: 3px solid white;
|
||||
line-height: 34px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Name = styled("div")`
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
export default Sheet;
|
||||
5
webapp/src/components/navigation/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface SheetOptions {
|
||||
name: string;
|
||||
color: string;
|
||||
sheetId: number;
|
||||
}
|
||||
430
webapp/src/components/toolbar.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
ChevronDown,
|
||||
Euro,
|
||||
Italic,
|
||||
PaintBucket,
|
||||
Paintbrush2,
|
||||
Percent,
|
||||
Redo2,
|
||||
Strikethrough,
|
||||
Underline,
|
||||
Undo2,
|
||||
Grid2X2,
|
||||
Type,
|
||||
ArrowDownToLine,
|
||||
ArrowUpToLine,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRef, useState } from "react";
|
||||
import ColorPicker from "./colorPicker";
|
||||
import BorderPicker from "./borderPicker";
|
||||
import {
|
||||
ArrowMiddleFromLine,
|
||||
DecimalPlacesDecreaseIcon,
|
||||
DecimalPlacesIncreaseIcon,
|
||||
} from "../icons";
|
||||
import {
|
||||
NumberFormats,
|
||||
decreaseDecimalPlaces,
|
||||
increaseDecimalPlaces,
|
||||
} from "./formatUtil";
|
||||
import FormatMenu from "./formatMenu";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { theme } from "../theme";
|
||||
import {
|
||||
BorderOptions,
|
||||
HorizontalAlignment,
|
||||
VerticalAlignment,
|
||||
} from "@ironcalc/wasm";
|
||||
|
||||
type ToolbarProperties = {
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onRedo: () => void;
|
||||
onUndo: () => void;
|
||||
onToggleUnderline: (u: boolean) => void;
|
||||
onToggleBold: (v: boolean) => void;
|
||||
onToggleItalic: (v: boolean) => void;
|
||||
onToggleStrike: (v: boolean) => void;
|
||||
onToggleHorizontalAlign: (v: string) => void;
|
||||
onToggleVerticalAlign: (v: string) => void;
|
||||
onCopyStyles: () => void;
|
||||
onTextColorPicked: (hex: string) => void;
|
||||
onFillColorPicked: (hex: string) => void;
|
||||
onNumberFormatPicked: (numberFmt: string) => void;
|
||||
onBorderChanged: (border: BorderOptions) => void;
|
||||
fillColor: string;
|
||||
fontColor: string;
|
||||
bold: boolean;
|
||||
underline: boolean;
|
||||
italic: boolean;
|
||||
strike: boolean;
|
||||
horizontalAlign: HorizontalAlignment;
|
||||
verticalAlign: VerticalAlignment;
|
||||
canEdit: boolean;
|
||||
numFmt: string;
|
||||
};
|
||||
|
||||
function Toolbar(properties: ToolbarProperties) {
|
||||
const [fontColorPickerOpen, setFontColorPickerOpen] = useState(false);
|
||||
const [fillColorPickerOpen, setFillColorPickerOpen] = useState(false);
|
||||
const [borderPickerOpen, setBorderPickerOpen] = useState(false);
|
||||
|
||||
const fontColorButton = useRef(null);
|
||||
const fillColorButton = useRef(null);
|
||||
const borderButton = useRef(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { canEdit } = properties;
|
||||
|
||||
return (
|
||||
<ToolbarContainer>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={properties.onUndo}
|
||||
disabled={!properties.canUndo}
|
||||
title={t("toolbar.undo")}
|
||||
>
|
||||
<Undo2 />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={properties.onRedo}
|
||||
disabled={!properties.canRedo}
|
||||
title={t("toolbar.redo")}
|
||||
>
|
||||
<Redo2 />
|
||||
</StyledButton>
|
||||
<Divider />
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={properties.onCopyStyles}
|
||||
title={t("toolbar.copy_styles")}
|
||||
>
|
||||
<Paintbrush2 />
|
||||
</StyledButton>
|
||||
<Divider />
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={(): void => {
|
||||
properties.onNumberFormatPicked(NumberFormats.CURRENCY_EUR);
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.euro")}
|
||||
>
|
||||
<Euro />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={(): void => {
|
||||
properties.onNumberFormatPicked(NumberFormats.PERCENTAGE);
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.percentage")}
|
||||
>
|
||||
<Percent />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={(): void => {
|
||||
properties.onNumberFormatPicked(
|
||||
decreaseDecimalPlaces(properties.numFmt)
|
||||
);
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.decimal_places_decrease")}
|
||||
>
|
||||
<DecimalPlacesDecreaseIcon />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={(): void => {
|
||||
properties.onNumberFormatPicked(
|
||||
increaseDecimalPlaces(properties.numFmt)
|
||||
);
|
||||
}}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.decimal_places_increase")}
|
||||
>
|
||||
<DecimalPlacesIncreaseIcon />
|
||||
</StyledButton>
|
||||
<FormatMenu
|
||||
numFmt={properties.numFmt}
|
||||
onChange={(numberFmt): void => {
|
||||
properties.onNumberFormatPicked(numberFmt);
|
||||
}}
|
||||
onExited={(): void => {}}
|
||||
anchorOrigin={{
|
||||
horizontal: 20, // Aligning the menu to the middle of FormatButton
|
||||
vertical: "bottom",
|
||||
}}
|
||||
>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.format_number")}
|
||||
sx={{
|
||||
width: "40px", // Keep in sync with anchorOrigin in FormatMenu above
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{"123"}
|
||||
<ChevronDown />
|
||||
</StyledButton>
|
||||
</FormatMenu>
|
||||
<Divider />
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.bold}
|
||||
onClick={() => properties.onToggleBold(!properties.bold)}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.bold")}
|
||||
>
|
||||
<Bold />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.italic}
|
||||
onClick={() => properties.onToggleItalic(!properties.italic)}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.italic")}
|
||||
>
|
||||
<Italic />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.underline}
|
||||
onClick={() => properties.onToggleUnderline(!properties.underline)}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.underline")}
|
||||
>
|
||||
<Underline />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.strike}
|
||||
onClick={() => properties.onToggleStrike(!properties.strike)}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.strike_trough")}
|
||||
>
|
||||
<Strikethrough />
|
||||
</StyledButton>
|
||||
<Divider />
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.font_color")}
|
||||
ref={fontColorButton}
|
||||
$underlinedColor={properties.fontColor}
|
||||
onClick={() => setFontColorPickerOpen(true)}
|
||||
>
|
||||
<Type />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.fill_color")}
|
||||
ref={fillColorButton}
|
||||
$underlinedColor={properties.fillColor}
|
||||
onClick={() => setFillColorPickerOpen(true)}
|
||||
>
|
||||
<PaintBucket />
|
||||
</StyledButton>
|
||||
<Divider />
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.horizontalAlign === "left"}
|
||||
onClick={() =>
|
||||
properties.onToggleHorizontalAlign(
|
||||
properties.horizontalAlign === "left" ? "general" : "left"
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.align_left")}
|
||||
>
|
||||
<AlignLeft />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.horizontalAlign === "center"}
|
||||
onClick={() =>
|
||||
properties.onToggleHorizontalAlign(
|
||||
properties.horizontalAlign === "center" ? "general" : "center"
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.align_center")}
|
||||
>
|
||||
<AlignCenter />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.horizontalAlign === "right"}
|
||||
onClick={() =>
|
||||
properties.onToggleHorizontalAlign(
|
||||
properties.horizontalAlign === "right" ? "general" : "right"
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.align_right")}
|
||||
>
|
||||
<AlignRight />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.verticalAlign === "top"}
|
||||
onClick={() =>
|
||||
properties.onToggleVerticalAlign(
|
||||
properties.verticalAlign === "top" ? "bottom" : "top"
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.vertical_align_top")}
|
||||
>
|
||||
<ArrowUpToLine />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.verticalAlign === "center"}
|
||||
onClick={() =>
|
||||
properties.onToggleVerticalAlign(
|
||||
properties.verticalAlign === "center" ? "bottom" : "center"
|
||||
)
|
||||
}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.vertical_align_center")}
|
||||
>
|
||||
<ArrowMiddleFromLine />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={properties.verticalAlign === "bottom"}
|
||||
onClick={() => properties.onToggleVerticalAlign("bottom")}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.vertical_align_bottom")}
|
||||
>
|
||||
<ArrowDownToLine />
|
||||
</StyledButton>
|
||||
<Divider />
|
||||
<StyledButton
|
||||
type="button"
|
||||
$pressed={false}
|
||||
onClick={() => setBorderPickerOpen(true)}
|
||||
ref={borderButton}
|
||||
disabled={!canEdit}
|
||||
title={t("toolbar.borders")}
|
||||
>
|
||||
<Grid2X2 />
|
||||
</StyledButton>
|
||||
<ColorPicker
|
||||
color={properties.fontColor}
|
||||
onChange={(color): void => {
|
||||
properties.onTextColorPicked(color);
|
||||
setFontColorPickerOpen(false);
|
||||
}}
|
||||
anchorEl={fontColorButton}
|
||||
open={fontColorPickerOpen}
|
||||
/>
|
||||
<ColorPicker
|
||||
color={properties.fillColor}
|
||||
onChange={(color): void => {
|
||||
properties.onFillColorPicked(color);
|
||||
setFillColorPickerOpen(false);
|
||||
}}
|
||||
anchorEl={fillColorButton}
|
||||
open={fillColorPickerOpen}
|
||||
/>
|
||||
<BorderPicker
|
||||
onChange={(border): void => {
|
||||
properties.onBorderChanged(border);
|
||||
setBorderPickerOpen(false);
|
||||
}}
|
||||
anchorEl={borderButton}
|
||||
open={borderPickerOpen}
|
||||
/>
|
||||
</ToolbarContainer>
|
||||
);
|
||||
}
|
||||
const toolbarHeight = 40;
|
||||
|
||||
const ToolbarContainer = styled("div")`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.palette.background.paper};
|
||||
height: ${toolbarHeight}px;
|
||||
line-height: ${toolbarHeight}px;
|
||||
border-bottom: 1px solid ${({}) => theme.palette.grey["600"]};
|
||||
font-family: Inter;
|
||||
border-radius: 4px 4px 0px 0px;
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
type TypeButtonProperties = { $pressed: boolean; $underlinedColor?: string };
|
||||
export const StyledButton = styled("button")<TypeButtonProperties>(
|
||||
({ disabled, $pressed, $underlinedColor }) => {
|
||||
let result: Record<string, any> = {
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "26px",
|
||||
border: "0px solid #fff",
|
||||
borderRadius: "2px",
|
||||
marginRight: "5px",
|
||||
transition: "all 0.2s",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "white",
|
||||
padding: "0px",
|
||||
};
|
||||
if (disabled) {
|
||||
result.color = theme.palette.grey["600"];
|
||||
result.cursor = "default";
|
||||
} else {
|
||||
result.borderTop = $underlinedColor ? "3px solid #FFF" : "none";
|
||||
result.borderBottom = $underlinedColor
|
||||
? `3px solid ${$underlinedColor}`
|
||||
: "none";
|
||||
(result.color = "#21243A"), //theme.palette.text.primary;
|
||||
(result.backgroundColor = $pressed
|
||||
? theme.palette.grey["600"]
|
||||
: "#FFF");
|
||||
result["&:hover"] = {
|
||||
backgroundColor: "#F1F2F8",
|
||||
borderTopColor: "#F1F2F8",
|
||||
};
|
||||
}
|
||||
result["svg"] = {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
};
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
const Divider = styled("div")({
|
||||
width: "0px",
|
||||
height: "10px",
|
||||
borderLeft: "1px solid #D3D6E9",
|
||||
marginLeft: "5px",
|
||||
marginRight: "10px",
|
||||
});
|
||||
|
||||
export default Toolbar;
|
||||
178
webapp/src/components/useKeyboardNavigation.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useCallback, KeyboardEvent, RefObject } from 'react';
|
||||
import { isEditingKey, isNavigationKey, NavigationKey } from './WorksheetCanvas/util';
|
||||
|
||||
export enum Border {
|
||||
Top = 'top',
|
||||
Bottom = 'bottom',
|
||||
Right = 'right',
|
||||
Left = 'left',
|
||||
}
|
||||
|
||||
interface Options {
|
||||
onCellsDeleted: () => void;
|
||||
onExpandAreaSelectedKeyboard: (key: 'ArrowRight' | 'ArrowLeft' | 'ArrowUp' | 'ArrowDown') => void;
|
||||
onEditKeyPressStart: (initText: string) => void;
|
||||
onCellEditStart: () => void;
|
||||
onBold: () => void;
|
||||
onItalic: () => void;
|
||||
onUnderline: () => void;
|
||||
onNavigationToEdge: (direction: NavigationKey) => void;
|
||||
onPageDown: () => void;
|
||||
onPageUp: () => void;
|
||||
onArrowDown: () => void;
|
||||
onArrowUp: () => void;
|
||||
onArrowLeft: () => void;
|
||||
onArrowRight: () => void;
|
||||
onKeyHome: () => void;
|
||||
onKeyEnd: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
root: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const useKeyboardNavigation = (options: Options): { onKeyDown: (event: KeyboardEvent) => void } => {
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const { key } = event;
|
||||
const { root } = options;
|
||||
// Silence the linter
|
||||
if (!root.current) {
|
||||
return;
|
||||
}
|
||||
if (event.target !== root.current) {
|
||||
return;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
switch (key) {
|
||||
case 'z': {
|
||||
options.onUndo();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'y': {
|
||||
options.onRedo();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'b': {
|
||||
options.onBold();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'i': {
|
||||
options.onItalic();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'u': {
|
||||
options.onUnderline();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
if (isNavigationKey(key)) {
|
||||
options.onNavigationToEdge(key);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key === 'F2') {
|
||||
options.onCellEditStart();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isEditingKey(key) || key === 'Backspace') {
|
||||
const initText = key === 'Backspace' ? '' : key;
|
||||
options.onEditKeyPressStart(initText);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Worksheet Navigation
|
||||
if (event.shiftKey) {
|
||||
if (
|
||||
key === 'ArrowRight' ||
|
||||
key === 'ArrowLeft' ||
|
||||
key === 'ArrowUp' ||
|
||||
key === 'ArrowDown'
|
||||
) {
|
||||
options.onExpandAreaSelectedKeyboard(key);
|
||||
} else if (key === 'Tab') {
|
||||
options.onArrowLeft();
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (key) {
|
||||
case 'ArrowRight':
|
||||
case 'Tab': {
|
||||
options.onArrowRight();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
options.onArrowLeft();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown':
|
||||
case 'Enter': {
|
||||
options.onArrowDown();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
options.onArrowUp();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'End': {
|
||||
options.onKeyEnd();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'Home': {
|
||||
options.onKeyHome();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'Delete': {
|
||||
options.onCellsDeleted();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'PageDown': {
|
||||
options.onPageDown();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'PageUp': {
|
||||
options.onPageUp();
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
[options],
|
||||
);
|
||||
return { onKeyDown };
|
||||
};
|
||||
|
||||
export default useKeyboardNavigation;
|
||||
223
webapp/src/components/usePointer.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useCallback, RefObject, PointerEvent, useRef } from 'react';
|
||||
import WorksheetCanvas, { headerColumnWidth, headerRowHeight } from './WorksheetCanvas/worksheetCanvas';
|
||||
import { Cell } from './WorksheetCanvas/util';
|
||||
|
||||
interface PointerSettings {
|
||||
canvasElement: RefObject<HTMLCanvasElement>;
|
||||
worksheetCanvas: RefObject<WorksheetCanvas | null>;
|
||||
worksheetElement: RefObject<HTMLDivElement>;
|
||||
// rowContextMenuAnchorElement: RefObject<HTMLDivElement>;
|
||||
// columnContextMenuAnchorElement: RefObject<HTMLDivElement>;
|
||||
onCellSelected: (cell: Cell, event: React.MouseEvent) => void;
|
||||
onAreaSelecting: (cell: Cell) => void;
|
||||
onExtendToCell: (cell: Cell) => void;
|
||||
onExtendToEnd: () => void;
|
||||
// onRowContextMenu: (row: number) => void;
|
||||
// onColumnContextMenu: (column: number) => void;
|
||||
}
|
||||
|
||||
interface PointerEvents {
|
||||
onPointerDown: (event: PointerEvent) => void;
|
||||
onPointerMove: (event: PointerEvent) => void;
|
||||
onPointerUp: (event: PointerEvent) => void;
|
||||
onPointerHandleDown: (event: PointerEvent) => void;
|
||||
// onContextMenu: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const usePointer = (options: PointerSettings): PointerEvents => {
|
||||
const isSelecting = useRef(false);
|
||||
const isExtending = useRef(false);
|
||||
|
||||
// const onContextMenu = useCallback(
|
||||
// (event: React.MouseEvent): void => {
|
||||
// let x = event.clientX;
|
||||
// let y = event.clientY;
|
||||
// const {
|
||||
// canvasElement,
|
||||
// worksheetElement,
|
||||
// worksheetCanvas,
|
||||
// onRowContextMenu,
|
||||
// rowContextMenuAnchorElement,
|
||||
// onColumnContextMenu,
|
||||
// columnContextMenuAnchorElement,
|
||||
// } = options;
|
||||
// const worksheet = worksheetCanvas.current;
|
||||
// const canvas = canvasElement.current;
|
||||
// const worksheetWrapper = worksheetElement.current;
|
||||
// // Silence the linter
|
||||
// if (!canvas || !worksheet || !worksheetWrapper) {
|
||||
// return;
|
||||
// }
|
||||
// const canvasRect = canvas.getBoundingClientRect();
|
||||
// x -= canvasRect.x;
|
||||
// y -= canvasRect.y;
|
||||
// const menuAnchorOffsetY = 10;
|
||||
// if (x > 0 && x < headerColumnWidth && y > headerRowHeight && y < canvasRect.height) {
|
||||
// // Click on a row number
|
||||
// const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
|
||||
// if (cell) {
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// if (rowContextMenuAnchorElement.current) {
|
||||
// const scrollPosition = worksheet.getScrollPosition();
|
||||
// rowContextMenuAnchorElement.current.style.left = `${x + scrollPosition.left}px`;
|
||||
// rowContextMenuAnchorElement.current.style.top = `${
|
||||
// y + scrollPosition.top + menuAnchorOffsetY
|
||||
// }px`;
|
||||
// }
|
||||
// options.onPointerDownAtCell(cell, event);
|
||||
// onRowContextMenu(cell.row);
|
||||
// }
|
||||
// }
|
||||
// if (x > headerColumnWidth && x < canvas.width && y > 0 && y < headerRowHeight) {
|
||||
// // Click on a column number
|
||||
// const cell = worksheet.getCellByCoordinates(x, headerRowHeight);
|
||||
// if (cell) {
|
||||
// event.preventDefault();
|
||||
// event.stopPropagation();
|
||||
// if (columnContextMenuAnchorElement.current) {
|
||||
// const scrollPosition = worksheet.getScrollPosition();
|
||||
// columnContextMenuAnchorElement.current.style.left = `${x + scrollPosition.left}px`;
|
||||
// columnContextMenuAnchorElement.current.style.top = `${
|
||||
// y + scrollPosition.top + menuAnchorOffsetY
|
||||
// }px`;
|
||||
// }
|
||||
// options.onPointerDownAtCell(cell, event);
|
||||
// onColumnContextMenu(cell.column);
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// [options],
|
||||
// );
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(event: PointerEvent): void => {
|
||||
// Range selections are disabled on non-mouse devices. Use touch move only
|
||||
// to scroll for now.
|
||||
if (event.pointerType !== 'mouse') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSelecting.current) {
|
||||
const { canvasElement, worksheetCanvas } = options;
|
||||
const canvas = canvasElement.current;
|
||||
const worksheet = worksheetCanvas.current;
|
||||
// Silence the linter
|
||||
if (!worksheet || !canvas) {
|
||||
return;
|
||||
}
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
x -= canvasRect.x;
|
||||
y -= canvasRect.y;
|
||||
const cell = worksheet.getCellByCoordinates(x, y);
|
||||
if (cell) {
|
||||
options.onAreaSelecting(cell);
|
||||
}
|
||||
} else if (isExtending.current) {
|
||||
const { canvasElement, worksheetCanvas } = options;
|
||||
const canvas = canvasElement.current;
|
||||
const worksheet = worksheetCanvas.current;
|
||||
// Silence the linter
|
||||
if (!worksheet || !canvas) {
|
||||
return;
|
||||
}
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
x -= canvasRect.x;
|
||||
y -= canvasRect.y;
|
||||
const cell = worksheet.getCellByCoordinates(x, y);
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
options.onExtendToCell(cell);
|
||||
}
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(event: PointerEvent): void => {
|
||||
if (isSelecting.current) {
|
||||
const { worksheetElement } = options;
|
||||
isSelecting.current = false;
|
||||
worksheetElement.current?.releasePointerCapture(event.pointerId);
|
||||
} else if (isExtending.current) {
|
||||
const { worksheetElement } = options;
|
||||
isExtending.current = false;
|
||||
worksheetElement.current?.releasePointerCapture(event.pointerId);
|
||||
options.onExtendToEnd();
|
||||
}
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
let x = event.clientX;
|
||||
let y = event.clientY;
|
||||
const { canvasElement, worksheetElement, worksheetCanvas } = options;
|
||||
const worksheet = worksheetCanvas.current;
|
||||
const canvas = canvasElement.current;
|
||||
const worksheetWrapper = worksheetElement.current;
|
||||
// Silence the linter
|
||||
if (!canvas || !worksheet || !worksheetWrapper) {
|
||||
return;
|
||||
}
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
x -= canvasRect.x;
|
||||
y -= canvasRect.y;
|
||||
// Makes sure is in the sheet area
|
||||
if (
|
||||
x > canvasRect.width ||
|
||||
x < headerColumnWidth ||
|
||||
y < headerRowHeight ||
|
||||
y > canvasRect.height
|
||||
) {
|
||||
if (x > 0 && x < headerColumnWidth && y > headerRowHeight && y < canvasRect.height) {
|
||||
// Click on a row number
|
||||
const cell = worksheet.getCellByCoordinates(headerColumnWidth, y);
|
||||
if (cell) {
|
||||
// TODO
|
||||
// Row selected
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cell = worksheet.getCellByCoordinates(x, y);
|
||||
if (cell) {
|
||||
options.onCellSelected(cell, event);
|
||||
isSelecting.current = true;
|
||||
worksheetWrapper.setPointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const onPointerHandleDown = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
const worksheetWrapper = options.worksheetElement.current;
|
||||
// Silence the linter
|
||||
if (!worksheetWrapper) {
|
||||
return;
|
||||
}
|
||||
isExtending.current = true;
|
||||
worksheetWrapper.setPointerCapture(event.pointerId);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
return {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerHandleDown,
|
||||
// onContextMenu,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePointer;
|
||||
294
webapp/src/components/workbook.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import Toolbar from "./toolbar";
|
||||
import FormulaBar from "./formulabar";
|
||||
import Navigation from "./navigation/navigation";
|
||||
import Worksheet from "./worksheet";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useKeyboardNavigation from "./useKeyboardNavigation";
|
||||
import { NavigationKey, getCellAddress } from "./WorksheetCanvas/util";
|
||||
import { LAST_COLUMN, LAST_ROW } from "./WorksheetCanvas/constants";
|
||||
import { WorkbookState } from "./workbookState";
|
||||
import { BorderOptions, Model, WorksheetProperties } from "@ironcalc/wasm";
|
||||
|
||||
const Workbook = (props: { model: Model; workbookState: WorkbookState }) => {
|
||||
const { model, workbookState } = props;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [_redrawId, setRedrawId] = useState(0);
|
||||
const info = model
|
||||
.getWorksheetsProperties()
|
||||
.map(({ name, color, sheet_id }: WorksheetProperties) => {
|
||||
return { name, color: color ? color : "#FFF", sheetId: sheet_id };
|
||||
});
|
||||
|
||||
const onRedo = () => {
|
||||
model.redo();
|
||||
setRedrawId((id) => id + 1);
|
||||
};
|
||||
|
||||
const onUndo = () => {
|
||||
model.undo();
|
||||
setRedrawId((id) => id + 1);
|
||||
};
|
||||
|
||||
const updateRangeStyle = (stylePath: string, value: string) => {
|
||||
const area = {
|
||||
sheet: workbookState.getSelectedSheet(),
|
||||
...workbookState.getSelectedArea(),
|
||||
};
|
||||
const range = {
|
||||
sheet: area.sheet,
|
||||
row: area.rowStart,
|
||||
column: area.columnStart,
|
||||
width: area.columnEnd - area.columnStart + 1,
|
||||
height: area.rowEnd - area.rowStart + 1,
|
||||
};
|
||||
model.updateRangeStyle(range, stylePath, value);
|
||||
setRedrawId((id) => id + 1);
|
||||
};
|
||||
|
||||
const onToggleUnderline = (value: boolean) => {
|
||||
updateRangeStyle("font.u", `${value}`);
|
||||
};
|
||||
|
||||
const onToggleItalic = (value: boolean) => {
|
||||
updateRangeStyle("font.i", `${value}`);
|
||||
};
|
||||
|
||||
const onToggleBold = (value: boolean) => {
|
||||
updateRangeStyle("font.b", `${value}`);
|
||||
};
|
||||
|
||||
const onToggleStrike = (value: boolean) => {
|
||||
updateRangeStyle("font.strike", `${value}`);
|
||||
};
|
||||
|
||||
const onToggleHorizontalAlign = (value: string) => {
|
||||
updateRangeStyle("alignment.horizontal", value);
|
||||
};
|
||||
|
||||
const onToggleVerticalAlign = (value: string) => {
|
||||
updateRangeStyle("alignment.vertical", value);
|
||||
};
|
||||
|
||||
const onTextColorPicked = (hex: string) => {
|
||||
updateRangeStyle("font.color", hex);
|
||||
};
|
||||
|
||||
const onFillColorPicked = (hex: string) => {
|
||||
updateRangeStyle("fill.fg_color", hex);
|
||||
};
|
||||
|
||||
const onNumberFormatPicked = (numberFmt: string) => {
|
||||
updateRangeStyle("num_fmt", numberFmt);
|
||||
};
|
||||
|
||||
const onCopyStyles = () => {
|
||||
const area = {
|
||||
sheet: workbookState.getSelectedSheet(),
|
||||
...workbookState.getSelectedArea(),
|
||||
};
|
||||
const styles = [];
|
||||
for (let row = area.rowStart; row < area.rowEnd; row++) {
|
||||
const styleRow = [];
|
||||
for (let column = area.columnStart; column < area.columnEnd; column++) {
|
||||
styleRow.push(model.getCellStyle(area.sheet, row, column));
|
||||
}
|
||||
styles.push(styleRow);
|
||||
}
|
||||
};
|
||||
|
||||
const { onKeyDown } = useKeyboardNavigation({
|
||||
onCellsDeleted: function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onExpandAreaSelectedKeyboard: function (
|
||||
key: "ArrowRight" | "ArrowLeft" | "ArrowUp" | "ArrowDown"
|
||||
): void {
|
||||
console.log(key);
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onEditKeyPressStart: function (initText: string): void {
|
||||
console.log(initText);
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onCellEditStart: function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onBold: () => {
|
||||
let sheet = workbookState.getSelectedSheet();
|
||||
let { row, column } = workbookState.getSelectedCell();
|
||||
let value = !model.getCellStyle(sheet, row, column).font.b;
|
||||
onToggleBold(!value);
|
||||
},
|
||||
onItalic: () => {
|
||||
let sheet = workbookState.getSelectedSheet();
|
||||
let { row, column } = workbookState.getSelectedCell();
|
||||
let value = !model.getCellStyle(sheet, row, column).font.i;
|
||||
onToggleItalic(!value);
|
||||
},
|
||||
onUnderline: () => {
|
||||
let sheet = workbookState.getSelectedSheet();
|
||||
let { row, column } = workbookState.getSelectedCell();
|
||||
let value = !model.getCellStyle(sheet, row, column).font.u;
|
||||
onToggleUnderline(!value);
|
||||
},
|
||||
onNavigationToEdge: function (direction: NavigationKey): void {
|
||||
console.log(direction);
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onPageDown: function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onPageUp: function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onArrowDown: function (): void {
|
||||
const cell = workbookState.getSelectedCell();
|
||||
const row = cell.row + 1;
|
||||
if (row > LAST_ROW) {
|
||||
return;
|
||||
}
|
||||
workbookState.selectCell({ row, column: cell.column });
|
||||
setRedrawId((id) => id + 1);
|
||||
},
|
||||
onArrowUp: function (): void {
|
||||
const cell = workbookState.getSelectedCell();
|
||||
const row = cell.row - 1;
|
||||
if (row < 1) {
|
||||
return;
|
||||
}
|
||||
workbookState.selectCell({ row, column: cell.column });
|
||||
setRedrawId((id) => id + 1);
|
||||
},
|
||||
onArrowLeft: function (): void {
|
||||
const cell = workbookState.getSelectedCell();
|
||||
const column = cell.column - 1;
|
||||
if (column < 1) {
|
||||
return;
|
||||
}
|
||||
workbookState.selectCell({ row: cell.row, column });
|
||||
setRedrawId((id) => id + 1);
|
||||
},
|
||||
onArrowRight: function (): void {
|
||||
const cell = workbookState.getSelectedCell();
|
||||
const column = cell.column + 1;
|
||||
if (column > LAST_COLUMN) {
|
||||
return;
|
||||
}
|
||||
workbookState.selectCell({ row: cell.row, column });
|
||||
setRedrawId((id) => id + 1);
|
||||
},
|
||||
onKeyHome: function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onKeyEnd: function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
},
|
||||
onUndo: function (): void {
|
||||
model.undo();
|
||||
setRedrawId((id) => id + 1);
|
||||
},
|
||||
onRedo: function (): void {
|
||||
model.redo();
|
||||
setRedrawId((id) => id + 1);
|
||||
},
|
||||
root: rootRef,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootRef.current) {
|
||||
return;
|
||||
}
|
||||
rootRef.current.focus();
|
||||
});
|
||||
|
||||
const cellAddress = getCellAddress(
|
||||
workbookState.getSelectedArea(),
|
||||
workbookState.getSelectedCell()
|
||||
);
|
||||
|
||||
const sheet = workbookState.getSelectedSheet();
|
||||
const { row, column } = workbookState.getSelectedCell();
|
||||
|
||||
const style = model.getCellStyle(sheet, row, column);
|
||||
console.log("data", sheet, row, column, style);
|
||||
|
||||
return (
|
||||
<Container ref={rootRef} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<Toolbar
|
||||
canUndo={model.canUndo()}
|
||||
canRedo={model.canRedo()}
|
||||
onRedo={onRedo}
|
||||
onUndo={onUndo}
|
||||
onToggleUnderline={onToggleUnderline}
|
||||
onToggleBold={onToggleBold}
|
||||
onToggleItalic={onToggleItalic}
|
||||
onToggleStrike={onToggleStrike}
|
||||
onToggleHorizontalAlign={onToggleHorizontalAlign}
|
||||
onToggleVerticalAlign={onToggleVerticalAlign}
|
||||
onCopyStyles={onCopyStyles}
|
||||
onTextColorPicked={onTextColorPicked}
|
||||
onFillColorPicked={onFillColorPicked}
|
||||
onNumberFormatPicked={onNumberFormatPicked}
|
||||
onBorderChanged={function (_border: BorderOptions): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
fillColor={style.fill.fg_color || "#FFF"}
|
||||
fontColor={style.font.color}
|
||||
bold={style.font.b}
|
||||
underline={style.font.u}
|
||||
italic={style.font.i}
|
||||
strike={style.font.strike}
|
||||
horizontalAlign={
|
||||
style.alignment ? style.alignment.horizontal : "general"
|
||||
}
|
||||
verticalAlign={style.alignment ? style.alignment.vertical : "center"}
|
||||
canEdit={true}
|
||||
numFmt={""}
|
||||
/>
|
||||
<FormulaBar cellAddress={cellAddress} />
|
||||
<Worksheet
|
||||
model={model}
|
||||
workbookState={workbookState}
|
||||
refresh={(): void => {
|
||||
setRedrawId((id) => id + 1);
|
||||
}}
|
||||
/>
|
||||
<Navigation
|
||||
sheets={info}
|
||||
selectedIndex={workbookState.getSelectedSheet()}
|
||||
onSheetSelected={function (sheet: number): void {
|
||||
workbookState.setSelectedSheet(sheet);
|
||||
setRedrawId((value) => value + 1);
|
||||
}}
|
||||
onAddBlankSheet={function (): void {
|
||||
model.newSheet();
|
||||
}}
|
||||
onSheetColorChanged={function (hex: string): void {
|
||||
console.log(hex);
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
onSheetRenamed={function (name: string): void {
|
||||
console.log(name);
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
onSheetDeleted={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled("div")`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-family: ${({ theme }) => theme.typography.fontFamily};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Workbook;
|
||||
62
webapp/src/components/workbookContext.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface Cell {
|
||||
row: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
}
|
||||
|
||||
interface Scroll {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
type FocusType = "cell" | "formula-bar";
|
||||
|
||||
/**
|
||||
* In Excel there are two "modes" of editing
|
||||
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
|
||||
* * `edit`: If you double click on a cell or click in the cell while editing.
|
||||
* In this mode arrow keys will move within the cell.
|
||||
*
|
||||
* In a formula bar mode is always `edit`.
|
||||
*/
|
||||
type CellEditMode = "init" | "edit";
|
||||
|
||||
const WorkbookContext = createContext<{
|
||||
selectedSheet: number;
|
||||
selectedCell: Cell;
|
||||
selectedArea: Area;
|
||||
scroll: Scroll;
|
||||
extendToArea: Area | null;
|
||||
editor: Editor | null;
|
||||
}>({
|
||||
selectedSheet: 0,
|
||||
selectedCell: {row: 1, column: 1},
|
||||
selectedArea: {rowStart:1, rowEnd: 1, columnStart:1, columnEnd: 1},
|
||||
scroll: {top: 0, left: 0},
|
||||
extendToArea: null,
|
||||
editor: null
|
||||
});
|
||||
|
||||
|
||||
|
||||
interface Editor {
|
||||
id: number;
|
||||
sheet: number;
|
||||
row: number;
|
||||
column: number;
|
||||
text: string;
|
||||
base: string;
|
||||
mode: CellEditMode;
|
||||
focus: FocusType;
|
||||
}
|
||||
|
||||
|
||||
export default WorkbookContext;
|
||||
158
webapp/src/components/workbookState.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
export interface Cell {
|
||||
row: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
columnStart: number;
|
||||
columnEnd: number;
|
||||
}
|
||||
|
||||
interface Scroll {
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
type FocusType = 'cell' | 'formula-bar';
|
||||
|
||||
|
||||
/**
|
||||
* In Excel there are two "modes" of editing
|
||||
* * `init`: When you start typing in a cell. In this mode arrow keys will move away from the cell
|
||||
* * `edit`: If you double click on a cell or click in the cell while editing.
|
||||
* In this mode arrow keys will move within the cell.
|
||||
*
|
||||
* In a formula bar mode is always `edit`.
|
||||
*/
|
||||
type CellEditMode = 'init' | 'edit';
|
||||
|
||||
interface Editor {
|
||||
id: number;
|
||||
sheet: number;
|
||||
row: number;
|
||||
column: number;
|
||||
text: string;
|
||||
base: string;
|
||||
mode: CellEditMode;
|
||||
focus: FocusType;
|
||||
}
|
||||
|
||||
export class WorkbookState {
|
||||
private selectedSheet: number;
|
||||
private selectedCell: Cell;
|
||||
private selectedArea: Area;
|
||||
private scroll: Scroll;
|
||||
private extendToArea: Area | null;
|
||||
private editor: Editor | null;
|
||||
private id;
|
||||
|
||||
constructor() {
|
||||
const row = 1;
|
||||
const column = 1;
|
||||
const sheet = 0;
|
||||
this.selectedSheet = sheet;
|
||||
this.selectedCell = { row, column };
|
||||
this.selectedArea = {
|
||||
rowStart: row,
|
||||
rowEnd: row,
|
||||
columnStart: column,
|
||||
columnEnd: column,
|
||||
};
|
||||
this.extendToArea = null;
|
||||
this.scroll = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
};
|
||||
this.editor = null;
|
||||
this.id = Math.floor(Math.random()*1000);
|
||||
}
|
||||
|
||||
startEditing(focus: FocusType, text: string) {
|
||||
const {row, column} = this.selectedCell;
|
||||
this.editor = {
|
||||
id: 0,
|
||||
sheet: this.selectedSheet,
|
||||
row,
|
||||
column,
|
||||
base: '',
|
||||
text,
|
||||
mode: 'init',
|
||||
focus
|
||||
}
|
||||
}
|
||||
|
||||
setEditorText(text: string) {
|
||||
if (!this.editor) {
|
||||
return;
|
||||
}
|
||||
this.editor.text = text;
|
||||
}
|
||||
|
||||
endEditing() {
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
getEditor(): Editor | null {
|
||||
console.log('getEditor', this.id);
|
||||
return this.editor;
|
||||
}
|
||||
|
||||
getSelectedSheet(): number {
|
||||
return this.selectedSheet;
|
||||
}
|
||||
|
||||
setSelectedSheet(sheet: number): void {
|
||||
this.selectedSheet = sheet;
|
||||
}
|
||||
|
||||
getSelectedCell(): Cell {
|
||||
return this.selectedCell;
|
||||
}
|
||||
|
||||
setSelectedCell(cell: Cell): void {
|
||||
this.selectedCell = cell;
|
||||
}
|
||||
|
||||
getSelectedArea(): Area {
|
||||
return this.selectedArea;
|
||||
}
|
||||
|
||||
setSelectedArea(area: Area): void {
|
||||
this.selectedArea = area;
|
||||
}
|
||||
|
||||
selectCell(cell: { row: number; column: number }): void {
|
||||
console.log('selectCell: ', this.id)
|
||||
const { row, column } = cell;
|
||||
this.selectedArea = {
|
||||
rowStart: row,
|
||||
rowEnd: row,
|
||||
columnStart: column,
|
||||
columnEnd: column,
|
||||
};
|
||||
this.selectedCell = { row, column };
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
getScroll(): Scroll {
|
||||
return this.scroll;
|
||||
}
|
||||
|
||||
setScroll(scroll: Scroll): void {
|
||||
this.scroll = scroll;
|
||||
}
|
||||
|
||||
getExtendToArea(): Area | null {
|
||||
return this.extendToArea;
|
||||
}
|
||||
|
||||
clearExtendToArea(): void {
|
||||
this.extendToArea = null;
|
||||
}
|
||||
|
||||
setExtendToArea(area: Area): void {
|
||||
this.extendToArea = area;
|
||||
}
|
||||
}
|
||||
436
webapp/src/components/worksheet.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { styled } from "@mui/material/styles";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import WorksheetCanvas from "./WorksheetCanvas/worksheetCanvas";
|
||||
import {
|
||||
outlineBackgroundColor,
|
||||
outlineColor,
|
||||
} from "./WorksheetCanvas/constants";
|
||||
import usePointer from "./usePointer";
|
||||
import { WorkbookState } from "./workbookState";
|
||||
import { Cell } from "./WorksheetCanvas/types";
|
||||
import Editor from "./editor";
|
||||
import EditorContext, { EditorState } from "./editor/editorContext";
|
||||
import { getFormulaHTML } from "./editor/util";
|
||||
import { Model } from "@ironcalc/wasm";
|
||||
|
||||
function Worksheet(props: {
|
||||
model: Model;
|
||||
workbookState: WorkbookState;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const worksheetElement = useRef<HTMLDivElement>(null);
|
||||
const scrollElement = useRef<HTMLDivElement>(null);
|
||||
// const rootElement = useRef<HTMLDivElement>(null);
|
||||
const spacerElement = useRef<HTMLDivElement>(null);
|
||||
const cellOutline = useRef<HTMLDivElement>(null);
|
||||
const areaOutline = useRef<HTMLDivElement>(null);
|
||||
const cellOutlineHandle = useRef<HTMLDivElement>(null);
|
||||
const extendToOutline = useRef<HTMLDivElement>(null);
|
||||
const columnResizeGuide = useRef<HTMLDivElement>(null);
|
||||
const rowResizeGuide = useRef<HTMLDivElement>(null);
|
||||
// const contextMenuAnchorElement = useRef<HTMLDivElement>(null);
|
||||
const columnHeaders = useRef<HTMLDivElement>(null);
|
||||
const worksheetCanvas = useRef<WorksheetCanvas | null>(null);
|
||||
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
|
||||
const [editorContext, setEditorContext] = useState<EditorState>({
|
||||
mode: "accept",
|
||||
insertRange: null,
|
||||
baseText: '',
|
||||
id: Math.floor(Math.random()*1000),
|
||||
});
|
||||
|
||||
console.log('worksheet', editorContext.id);
|
||||
|
||||
const { model, workbookState, refresh } = props;
|
||||
useEffect(() => {
|
||||
const canvasRef = canvasElement.current;
|
||||
const columnGuideRef = columnResizeGuide.current;
|
||||
const rowGuideRef = rowResizeGuide.current;
|
||||
const columnHeadersRef = columnHeaders.current;
|
||||
const worksheetRef = worksheetElement.current;
|
||||
|
||||
const outline = cellOutline.current;
|
||||
const handle = cellOutlineHandle.current;
|
||||
const area = areaOutline.current;
|
||||
const extendTo = extendToOutline.current;
|
||||
|
||||
if (
|
||||
!canvasRef ||
|
||||
!columnGuideRef ||
|
||||
!rowGuideRef ||
|
||||
!columnHeadersRef ||
|
||||
!worksheetRef ||
|
||||
!outline ||
|
||||
!handle ||
|
||||
!area ||
|
||||
!extendTo
|
||||
)
|
||||
return;
|
||||
const canvas = new WorksheetCanvas({
|
||||
width: worksheetRef.clientWidth,
|
||||
height: worksheetRef.clientHeight,
|
||||
model,
|
||||
workbookState,
|
||||
elements: {
|
||||
canvas: canvasRef,
|
||||
columnGuide: columnGuideRef,
|
||||
rowGuide: rowGuideRef,
|
||||
columnHeaders: columnHeadersRef,
|
||||
cellOutline: outline,
|
||||
cellOutlineHandle: handle,
|
||||
areaOutline: area,
|
||||
extendToOutline: extendTo,
|
||||
},
|
||||
onColumnWidthChanges(sheet, column, width) {
|
||||
model.setColumnWidth(sheet, column, width);
|
||||
worksheetCanvas.current?.renderSheet();
|
||||
},
|
||||
onRowHeightChanges(sheet, row, height) {
|
||||
model.setRowHeight(sheet, row, height);
|
||||
worksheetCanvas.current?.renderSheet();
|
||||
},
|
||||
});
|
||||
const [sheetWidth, sheetHeight] = canvas.getSheetDimensions();
|
||||
if (spacerElement.current) {
|
||||
spacerElement.current.style.height = `${sheetHeight}px`;
|
||||
spacerElement.current.style.width = `${sheetWidth}px`;
|
||||
}
|
||||
canvas.renderSheet();
|
||||
worksheetCanvas.current = canvas;
|
||||
});
|
||||
|
||||
const sheetNames = model.getWorksheetsProperties().map((s: { name: string; }) => s.name);
|
||||
|
||||
const {
|
||||
onPointerMove,
|
||||
onPointerDown,
|
||||
onPointerHandleDown,
|
||||
onPointerUp,
|
||||
// onContextMenu,
|
||||
} = usePointer({
|
||||
onCellSelected: (cell: Cell, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
workbookState.selectCell(cell);
|
||||
// worksheetCanvas.current?.renderSheet();
|
||||
refresh();
|
||||
},
|
||||
onAreaSelecting: (cell: Cell) => {
|
||||
const canvas = worksheetCanvas.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const { row, column } = cell;
|
||||
// const { width, height } = worksheet.getBoundingClientRect();
|
||||
// const [x, y] = canvas.getCoordinatesByCell(row, column);
|
||||
// const [x1, y1] = canvas.getCoordinatesByCell(row + 1, column + 1);
|
||||
// const { left: canvasLeft, top: canvasTop } = canvas.getScrollPosition();
|
||||
// // let border = Border.Right;
|
||||
// // let { left, top } = state.scrollPosition;
|
||||
// // if (x < headerColumnWidth) {
|
||||
// // border = Border.Left;
|
||||
// // left = canvasLeft - headerColumnWidth + x;
|
||||
// // } else if (x1 > width - 20) {
|
||||
// // border = Border.Right;
|
||||
// // }
|
||||
// // if (y < headerRowHeight) {
|
||||
// // border = Border.Top;
|
||||
// // top = canvasTop - headerRowHeight + y;
|
||||
// // } else if (y1 > height - 20) {
|
||||
// // border = Border.Bottom;
|
||||
// // }
|
||||
const selectedCell = workbookState.getSelectedCell();
|
||||
const area = {
|
||||
rowStart: Math.min(selectedCell.row, row),
|
||||
rowEnd: Math.max(selectedCell.row, row),
|
||||
columnStart: Math.min(selectedCell.column, column),
|
||||
columnEnd: Math.max(selectedCell.column, column),
|
||||
};
|
||||
workbookState.setSelectedArea(area);
|
||||
canvas.renderSheet();
|
||||
// // If there are frozen rows or columns snap to origin if we cross boundaries
|
||||
// const frozenRows = canvas.workbook.getFrozenRowsCount();
|
||||
// const frozenColumns = canvas.workbook.getFrozenColumnsCount();
|
||||
// if (area.rowStart <= frozenRows && area.rowEnd > frozenRows) {
|
||||
// top = 0;
|
||||
// }
|
||||
// if (area.columnStart <= frozenColumns && area.columnEnd > frozenColumns) {
|
||||
// left = 0;
|
||||
// }
|
||||
}, // editorActions.onPointerMoveToCell,
|
||||
onExtendToCell: (cell) => {
|
||||
const canvas = worksheetCanvas.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const { row, column } = cell;
|
||||
const selectedCell = workbookState.getSelectedCell();
|
||||
const area = {
|
||||
rowStart: Math.min(selectedCell.row, row),
|
||||
rowEnd: Math.max(selectedCell.row, row),
|
||||
columnStart: Math.min(selectedCell.column, column),
|
||||
columnEnd: Math.max(selectedCell.column, column),
|
||||
};
|
||||
workbookState.setExtendToArea(area);
|
||||
canvas.renderSheet();
|
||||
}, // editorActions.onExtendToCell,
|
||||
onExtendToEnd: () => {
|
||||
const canvas = worksheetCanvas.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
const sheet = workbookState.getSelectedSheet();
|
||||
const initialArea = workbookState.getSelectedArea();
|
||||
const extendedArea = workbookState.getExtendToArea();
|
||||
if (!extendedArea) {
|
||||
return;
|
||||
}
|
||||
// model.extendTo(sheet, initialArea, extendedArea);
|
||||
workbookState.clearExtendToArea();
|
||||
canvas.renderSheet();
|
||||
}, // editorActions.onExtendToEnd,
|
||||
canvasElement,
|
||||
worksheetElement,
|
||||
worksheetCanvas,
|
||||
// rowContextMenuAnchorElement,
|
||||
// columnContextMenuAnchorElement,
|
||||
// onRowContextMenu,
|
||||
// onColumnContextMenu,
|
||||
});
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (!scrollElement.current || !worksheetCanvas.current) {
|
||||
return;
|
||||
}
|
||||
const left = scrollElement.current.scrollLeft;
|
||||
const top = scrollElement.current.scrollTop;
|
||||
|
||||
worksheetCanvas.current.setScrollPosition({ left, top });
|
||||
worksheetCanvas.current.renderSheet();
|
||||
};
|
||||
|
||||
const {row, column} = workbookState.getSelectedCell();
|
||||
const selectedSheet = workbookState.getSelectedSheet();
|
||||
|
||||
return (
|
||||
// <EditorContext.Provider value={{editorContext}}>
|
||||
<Wrapper ref={scrollElement} onScroll={onScroll}>
|
||||
<Spacer ref={spacerElement} />
|
||||
<SheetContainer
|
||||
ref={worksheetElement}
|
||||
onPointerDown={(event) => {
|
||||
if (isEditing === true && editorContext.mode !== 'insert') {
|
||||
setEditing(false);
|
||||
model.setUserInput(selectedSheet, row, column, editorContext.baseText);
|
||||
}
|
||||
onPointerDown(event);
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onDoubleClick={(event) => {
|
||||
const sheet = workbookState.getSelectedSheet();
|
||||
const {row, column} = workbookState.getSelectedCell();
|
||||
const text = model.getCellContent(sheet, row, column) || '';
|
||||
console.log('dbclick', text);
|
||||
|
||||
workbookState.startEditing("cell", `${text}`);
|
||||
setEditorContext ((c: EditorState) => {
|
||||
console.log('text', text, c.id);
|
||||
return {
|
||||
mode: c.mode,
|
||||
insertRange: c.insertRange,
|
||||
baseText: text,
|
||||
dontChange: true,
|
||||
id: c.id,
|
||||
};
|
||||
});
|
||||
|
||||
setEditing(true);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
// refresh();
|
||||
}}
|
||||
>
|
||||
<SheetCanvas ref={canvasElement} />
|
||||
<CellOutline ref={cellOutline}>
|
||||
{
|
||||
<Editor
|
||||
minimalWidth={200}
|
||||
minimalHeight={90}
|
||||
textColor="#333"
|
||||
getStyledText={(text: string, insertRangeText: string) => {
|
||||
return getFormulaHTML(
|
||||
text,
|
||||
0,
|
||||
sheetNames,
|
||||
editorContext.insertRange,
|
||||
insertRangeText
|
||||
);
|
||||
} }
|
||||
onEditEnd={(text: string) => {
|
||||
console.log(text);
|
||||
setEditing(false);
|
||||
model.setUserInput(selectedSheet, row, column, text);
|
||||
} }
|
||||
originalText={model.getCellContent(selectedSheet, row, column) || ''}
|
||||
display={isEditing}
|
||||
cell={{ sheet: selectedSheet, row, column }}
|
||||
sheetNames={sheetNames}
|
||||
/>
|
||||
/* <Editor
|
||||
data-testid={WorkbookTestId.WorkbookCellEditor}
|
||||
onEditChange={onEditChange}
|
||||
onEditEnd={onEditEnd}
|
||||
onEditEscape={onEditEscape}
|
||||
onReferenceCycle={onReferenceCycle}
|
||||
display={!!cellEditing}
|
||||
focus={cellEditing?.focus === FocusType.Cell}
|
||||
html={cellEditing?.html ?? ''}
|
||||
cursorStart={cellEditing?.cursorStart ?? 0}
|
||||
cursorEnd={cellEditing?.cursorEnd ?? 0}
|
||||
mode={cellEditing?.mode ?? 'init'}
|
||||
/> */
|
||||
}
|
||||
</CellOutline>
|
||||
<AreaOutline ref={areaOutline} />
|
||||
<ExtendToOutline ref={extendToOutline} />
|
||||
<CellOutlineHandle
|
||||
ref={cellOutlineHandle}
|
||||
onPointerDown={onPointerHandleDown}
|
||||
/>
|
||||
<ColumnResizeGuide ref={columnResizeGuide} />
|
||||
<RowResizeGuide ref={rowResizeGuide} />
|
||||
<ColumnHeaders ref={columnHeaders} />
|
||||
</SheetContainer>
|
||||
</Wrapper>
|
||||
// </EditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const Spacer = styled("div")`
|
||||
position: absolute;
|
||||
height: 5000px;
|
||||
width: 5000px;
|
||||
`;
|
||||
|
||||
const SheetContainer = styled("div")`
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
height: 100%;
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
width: 3px;
|
||||
opacity: 0;
|
||||
background: ${outlineColor};
|
||||
border-radius: 5px;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.column-resize-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.row-resize-handle {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
background: ${outlineColor};
|
||||
border-radius: 5px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.row-resize-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const Wrapper = styled("div")({
|
||||
position: "absolute",
|
||||
overflow: "scroll",
|
||||
top: 71,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 41,
|
||||
});
|
||||
|
||||
const SheetCanvas = styled("canvas")`
|
||||
position: relative;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
bottom: 40px;
|
||||
`;
|
||||
|
||||
const ColumnResizeGuide = styled("div")`
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
display: none;
|
||||
height: 100%;
|
||||
width: 0px;
|
||||
border-left: 1px dashed ${outlineColor};
|
||||
`;
|
||||
|
||||
const ColumnHeaders = styled("div")`
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
& .column-header {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const RowResizeGuide = styled("div")`
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 0px;
|
||||
height: 0px;
|
||||
width: 100%;
|
||||
border-top: 1px dashed ${outlineColor};
|
||||
`;
|
||||
|
||||
const AreaOutline = styled("div")`
|
||||
position: absolute;
|
||||
border: 1px solid ${outlineColor};
|
||||
border-radius: 3px;
|
||||
background-color: ${outlineBackgroundColor};
|
||||
`;
|
||||
|
||||
const CellOutline = styled("div")`
|
||||
position: absolute;
|
||||
border: 2px solid ${outlineColor};
|
||||
border-radius: 3px;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const CellOutlineHandle = styled("div")`
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: ${outlineColor};
|
||||
cursor: crosshair;
|
||||
// border: 1px solid white;
|
||||
border-radius: 1px;
|
||||
`;
|
||||
|
||||
const ExtendToOutline = styled("div")`
|
||||
position: absolute;
|
||||
border: 1px dashed ${outlineColor};
|
||||
border-radius: 3px;
|
||||
`;
|
||||
|
||||
export default Worksheet;
|
||||
16
webapp/src/fonts.css
Normal file
@@ -0,0 +1,16 @@
|
||||
/* inter-regular - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('fonts/inter-v13-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
/* inter-600 - latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('fonts/inter-v13-latin-600.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||
}
|
||||
BIN
webapp/src/fonts/inter-v13-latin-600.woff2
Normal file
BIN
webapp/src/fonts/inter-v13-latin-regular.woff2
Normal file
18
webapp/src/i18n.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import translationEN from './locale/en_us.json';
|
||||
|
||||
const resources = {
|
||||
'en-US': { translation: translationEN },
|
||||
};
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: 'en-US',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
14
webapp/src/icons/arrow-middle-from-line.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="arrow-middle-from-line" clip-path="url(#clip0_107_4135)">
|
||||
<path id="Vector" d="M8 14.6667V10.6667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_2" d="M8 5.33333V1.33333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_3" d="M14.6667 8H1.33334" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_4" d="M10 12.6667L8 10.6667L6 12.6667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path id="Vector_5" d="M10 3.33333L8 5.33333L6 3.33333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_107_4135">
|
||||
<rect width="16" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 869 B |
6
webapp/src/icons/border-bottom.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 14H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 11.3333V3.33333C14 2.59695 13.403 2 12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
4
webapp/src/icons/border-center-h.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 5.33333V3.33333C14 2.59695 13.403 2 12.6667 2H8M14 10.6667V12.6667C14 13.403 13.403 14 12.6667 14H8M2 10.6667V12.6667C2 13.403 2.59695 14 3.33333 14H8M2 5.33333V3.33333C2 2.59695 2.59695 2 3.33333 2H8M8 14V10.6667M8 2V5.33333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
4
webapp/src/icons/border-center-v.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6667 2H12.6667C13.403 2 14 2.59695 14 3.33333V8M5.33333 2H3.33333C2.59695 2 2 2.59695 2 3.33333V8M5.33333 14H3.33333C2.59695 14 2 13.403 2 12.6667V8M10.6667 14H12.6667C13.403 14 14 13.403 14 12.6667V8M2 8H5.33333M14 8H10.6667" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 498 B |
5
webapp/src/icons/border-inner.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 5.33333V3.33333C14 2.59695 13.403 2 12.6667 2H10.6667M14 10.6667V12.6667C14 13.403 13.403 14 12.6667 14H10.6667M2 10.6667V12.6667C2 13.403 2.59695 14 3.33333 14H5.33333M2 5.33333V3.33333C2 2.59695 2.59695 2 3.33333 2H5.33333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 586 B |
6
webapp/src/icons/border-left.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.66667 2H12.6667C13.403 2 14 2.59695 14 3.33333V12.6667C14 13.403 13.403 14 12.6667 14H4.66667" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.66667 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 539 B |
5
webapp/src/icons/border-none.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 513 B |
5
webapp/src/icons/border-outer.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.66667 8H11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 4.66667L8 11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
6
webapp/src/icons/border-right.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.3333 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8H11.3333" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 2V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
15
webapp/src/icons/border-style.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- <path d="M14 4H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 8H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 2"/>
|
||||
<path d="M14 12H2" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="0.01 2"/> -->
|
||||
<style>
|
||||
line {
|
||||
stroke: black;
|
||||
}
|
||||
</style>
|
||||
<line x1="0" y1="2" x2="16" y2="2" />
|
||||
<!-- Dashes and gaps of the same size -->
|
||||
<line x1="0" y1="8" x2="16" y2="8" stroke-dasharray="2.28 2.28" />
|
||||
<!-- Dashes and gaps of different sizes -->
|
||||
<line x1="0" y1="14" x2="16" y2="14" stroke-dasharray="1 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 744 B |
6
webapp/src/icons/border-top.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 4.66667V12.6667C14 13.403 13.403 14 12.6667 14H3.33333C2.59695 14 2 13.403 2 12.6667V4.66667" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 2H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 8H14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 4.66667V14" stroke="#B2B2B2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 539 B |
6
webapp/src/icons/decrease-decimal.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 11.3333H5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 9.33333L5 11.3333L7 13.3333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.66667 4.33333C7.66667 3.59695 7.06971 3 6.33333 3C5.59695 3 5 3.59695 5 4.33333V5.66667C5 6.40305 5.59695 7 6.33333 7C7.06971 7 7.66667 6.40305 7.66667 5.66667V4.33333Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 7H3.00667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 659 B |
10
webapp/src/icons/delete-column.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 3.33333L2 12.6667C2 13.403 2.59695 14 3.33333 14L3.66667 14C4.40305 14 5 13.403 5 12.6667L5 3.33333C5 2.59695 4.40305 2 3.66667 2L3.33333 2C2.59695 2 2 2.59695 2 3.33333Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 6L2 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 6L11 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5 10L2 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 10L11 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M11 3.33333L11 12.6667C11 13.403 11.597 14 12.3333 14L12.6667 14C13.403 14 14 13.403 14 12.6667L14 3.33333C14 2.59695 13.403 2 12.6667 2L12.3333 2C11.597 2 11 2.59695 11 3.33333Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.58578 9.41422L9.41421 6.58579" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.58578 6.58578L9.41421 9.41421" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
8
webapp/src/icons/delete-row.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V3.66667C2 4.40305 2.59695 5 3.33333 5H12.6667C13.403 5 14 4.40305 14 3.66667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.6667 11H3.33333C2.59695 11 2 11.597 2 12.3333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V12.3333C14 11.597 13.403 11 12.6667 11Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 11V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.58578 6.58578L9.41421 9.41421" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.41422 6.58578L6.58579 9.41421" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1004 B |
3
webapp/src/icons/fx.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.182 13.305C0.303333 13.3917 0.468 13.435 0.676 13.435C0.962 13.435 1.24367 13.3483 1.521 13.175C1.79833 13.0103 2.05833 12.7937 2.301 12.525C2.55233 12.2563 2.77333 11.9703 2.964 11.667C3.16333 11.3637 3.32367 11.069 3.445 10.783C3.575 10.5057 3.653 10.276 3.679 10.094L4.459 5.011H5.954V4.439H4.537L4.706 3.36C4.80133 2.75334 4.96167 2.281 5.187 1.943C5.421 1.59634 5.73733 1.423 6.136 1.423C6.422 1.423 6.67767 1.488 6.903 1.618L7.189 1.787H7.293L7.566 1.28C7.592 1.23667 7.61367 1.189 7.631 1.137C7.657 1.085 7.67 1.04167 7.67 1.007C7.67 0.93767 7.64833 0.881336 7.605 0.838003C7.57033 0.786003 7.49233 0.72967 7.371 0.669003C7.30167 0.643003 7.22367 0.621336 7.137 0.604003C7.05033 0.578003 6.95933 0.565002 6.864 0.565002C6.53467 0.565002 6.20967 0.651669 5.889 0.825003C5.56833 0.98967 5.265 1.21067 4.979 1.488C4.693 1.75667 4.43733 2.047 4.212 2.359C3.98667 2.66234 3.80033 2.957 3.653 3.243C3.51433 3.52034 3.432 3.75434 3.406 3.945L3.328 4.439H2.249V5.011H3.25L2.405 10.692C2.31833 11.2813 2.17533 11.745 1.976 12.083C1.77667 12.4297 1.508 12.603 1.17 12.603C0.953333 12.603 0.788667 12.564 0.676 12.486L0.39 12.278H0.312L0.0779999 12.746C0.026 12.85 0 12.9367 0 13.006C0 13.1187 0.0606667 13.2183 0.182 13.305ZM5.90545 9.98999C5.82745 10.1027 5.78845 10.211 5.78845 10.315H6.65945C6.70279 10.211 6.75045 10.1113 6.80245 10.016C6.85445 9.91199 6.93245 9.78199 7.03645 9.62599C7.14045 9.46132 7.30079 9.23166 7.51745 8.93699C7.73412 8.64232 8.03312 8.25232 8.41445 7.76699L9.45445 10.341H9.49345L11.2745 9.92499V9.82099L10.3385 9.44399L9.37645 6.98699C9.80112 6.50166 10.1521 6.11166 10.4295 5.81699C10.7068 5.52232 10.9235 5.29266 11.0795 5.12799C11.2441 4.96332 11.3568 4.83332 11.4175 4.73799C11.4868 4.63399 11.5215 4.53432 11.5215 4.43899H10.7025C10.6678 4.52566 10.6288 4.61666 10.5855 4.71199C10.5421 4.79866 10.4728 4.91566 10.3775 5.06299C10.2908 5.20166 10.1565 5.39666 9.97445 5.64799C9.79245 5.89932 9.54545 6.22866 9.23345 6.63599L8.31045 4.30899H8.27145L6.43845 4.72499V4.82899L7.38745 5.21899L8.27145 7.40299C7.78612 7.95766 7.38312 8.40399 7.06245 8.74199C6.74179 9.07999 6.48612 9.34432 6.29545 9.53499C6.11345 9.72566 5.98345 9.87732 5.90545 9.98999Z" fill="#828282"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
7
webapp/src/icons/increase-decimal.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 11.3333H5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.5 9.33333L12.5 11.3333L10.5 13.3333" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7.66667 4.33333C7.66667 3.59695 7.06971 3 6.33333 3C5.59695 3 5 3.59695 5 4.33333V5.66667C5 6.40305 5.59695 7 6.33333 7C7.06971 7 7.66667 6.40305 7.66667 5.66667V4.33333Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.3333 4.33333C12.3333 3.59695 11.7364 3 11 3C10.2636 3 9.66667 3.59695 9.66667 4.33333V5.66667C9.66667 6.40305 10.2636 7 11 7C11.7364 7 12.3333 6.40305 12.3333 5.66667V4.33333Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 7H3.00667" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 929 B |
46
webapp/src/icons/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import DecimalPlacesDecreaseIcon from "./decrease-decimal.svg?react";
|
||||
import DecimalPlacesIncreaseIcon from "./increase-decimal.svg?react";
|
||||
|
||||
import BorderBottomIcon from "./border-bottom.svg?react";
|
||||
import BorderCenterHIcon from "./border-center-h.svg?react";
|
||||
import BorderCenterVIcon from "./border-center-v.svg?react";
|
||||
import BorderInnerIcon from "./border-inner.svg?react";
|
||||
import BorderLeftIcon from "./border-left.svg?react";
|
||||
import BorderOuterIcon from "./border-outer.svg?react";
|
||||
import BorderRightIcon from "./border-right.svg?react";
|
||||
import BorderTopIcon from "./border-top.svg?react";
|
||||
import BorderNoneIcon from "./border-none.svg?react";
|
||||
import BorderStyleIcon from "./border-style.svg?react";
|
||||
|
||||
import DeleteColumnIcon from "./delete-column.svg?react";
|
||||
import DeleteRowIcon from "./delete-row.svg?react";
|
||||
import InsertColumnLeftIcon from "./insert-column-left.svg?react";
|
||||
import InsertColumnRightIcon from "./insert-column-right.svg?react";
|
||||
import InsertRowAboveIcon from "./insert-row-above.svg?react";
|
||||
import InsertRowBelow from "./insert-row-below.svg?react";
|
||||
import ArrowMiddleFromLine from "./arrow-middle-from-line.svg?react";
|
||||
|
||||
import Fx from "./fx.svg?react";
|
||||
|
||||
export {
|
||||
ArrowMiddleFromLine,
|
||||
DecimalPlacesDecreaseIcon,
|
||||
DecimalPlacesIncreaseIcon,
|
||||
BorderBottomIcon,
|
||||
BorderCenterHIcon,
|
||||
BorderCenterVIcon,
|
||||
BorderInnerIcon,
|
||||
BorderLeftIcon,
|
||||
BorderOuterIcon,
|
||||
BorderRightIcon,
|
||||
BorderTopIcon,
|
||||
BorderNoneIcon,
|
||||
BorderStyleIcon,
|
||||
DeleteColumnIcon,
|
||||
DeleteRowIcon,
|
||||
InsertColumnLeftIcon,
|
||||
InsertColumnRightIcon,
|
||||
InsertRowAboveIcon,
|
||||
InsertRowBelow,
|
||||
Fx,
|
||||
};
|
||||
7
webapp/src/icons/insert-column-left.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 12.6667L14 3.33333C14 2.59695 13.403 2 12.6667 2L9.33333 2C8.59695 2 8 2.59695 8 3.33333L8 12.6667C8 13.403 8.59695 14 9.33333 14L12.6667 14C13.403 14 14 13.403 14 12.6667Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 6L8 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 10L8 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4 6L4 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 8L2 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 726 B |
7
webapp/src/icons/insert-column-right.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 3.33333L2 12.6667C2 13.403 2.59695 14 3.33333 14L6.66667 14C7.40305 14 8 13.403 8 12.6667L8 3.33333C8 2.59695 7.40305 2 6.66667 2L3.33333 2C2.59695 2 2 2.59695 2 3.33333Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 10L8 10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 6L8 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12 10L12 6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 8L14 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
7
webapp/src/icons/insert-row-above.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6667 8H3.33333C2.59695 8 2 8.59695 2 9.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V9.33333C14 8.59695 13.403 8 12.6667 8Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 11H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 8V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 4H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V6" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
7
webapp/src/icons/insert-row-below.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V6.66667C2 7.40305 2.59695 8 3.33333 8H12.6667C13.403 8 14 7.40305 14 6.66667V3.33333C14 2.59695 13.403 2 12.6667 2Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 2V8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 12H10" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 10V14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 706 B |
8
webapp/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh; */
|
||||
}
|
||||
45
webapp/src/locale/en_us.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"toolbar": {
|
||||
"redo": "Redo",
|
||||
"undo": "Undo",
|
||||
"copy_styles": "Copy styles",
|
||||
"euro": "Format as Euro",
|
||||
"percentage": "Format as Percentage",
|
||||
"bold": "Bold",
|
||||
"italic": "Italic",
|
||||
"underline": "Underline",
|
||||
"strike_through": "Strikethrough",
|
||||
"align_left": "Align left",
|
||||
"align_right": "Align right",
|
||||
"align_center": "Align center",
|
||||
"format_number": "Format number",
|
||||
"font_color": "Font color",
|
||||
"fill_color": "Fill color",
|
||||
"borders": "Borders",
|
||||
"decimal_places_increase": "Increase decimal places",
|
||||
"decimal_places_decrease": "Decrease decimal places",
|
||||
"format_menu": {
|
||||
"auto": "Auto",
|
||||
"number": "Number",
|
||||
"percentage": "Percentage",
|
||||
"currency_eur": "Euro (EUR)",
|
||||
"currency_usd": "Dollar (USD",
|
||||
"currency_gbp": "British Pound (GBD)",
|
||||
"date_short": "Short date",
|
||||
"date_long": "Long date",
|
||||
"custom": "Custom",
|
||||
"number_example": "1,000.00",
|
||||
"percentage_example": "10%",
|
||||
"currency_eur_example": "€",
|
||||
"currency_usd_example": "$",
|
||||
"currency_gbp_example": "£",
|
||||
"date_short_example": "09/24/2024",
|
||||
"date_long_example": "Tuesday, September 24, 2024"
|
||||
}
|
||||
},
|
||||
"num_fmt" :{
|
||||
"title": "Custom number format",
|
||||
"label": "Number format",
|
||||
"save": "Save"
|
||||
}
|
||||
}
|
||||
14
webapp/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { theme } from './theme.ts';
|
||||
import ThemeProvider from '@mui/material/styles/ThemeProvider';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
50
webapp/src/stories/Button.stories.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
||||
const meta = {
|
||||
title: 'Example/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/react/configure/story-layout
|
||||
layout: 'centered',
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
},
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
primary: true,
|
||||
label: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
label: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'large',
|
||||
label: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'small',
|
||||
label: 'Button',
|
||||
},
|
||||
};
|
||||
47
webapp/src/stories/Button.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import './button.css';
|
||||
|
||||
interface ButtonProps {
|
||||
/**
|
||||
* Is this the principal call to action on the page?
|
||||
*/
|
||||
primary?: boolean;
|
||||
/**
|
||||
* What background color to use
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* How large should the button be?
|
||||
*/
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
/**
|
||||
* Button contents
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Optional click handler
|
||||
*/
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary UI component for user interaction
|
||||
*/
|
||||
export const Button = ({
|
||||
primary = false,
|
||||
size = 'medium',
|
||||
backgroundColor,
|
||||
label,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
|
||||
style={{ backgroundColor }}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
364
webapp/src/stories/Configure.mdx
Normal file
@@ -0,0 +1,364 @@
|
||||
import { Meta } from "@storybook/blocks";
|
||||
|
||||
import Github from "./assets/github.svg";
|
||||
import Discord from "./assets/discord.svg";
|
||||
import Youtube from "./assets/youtube.svg";
|
||||
import Tutorials from "./assets/tutorials.svg";
|
||||
import Styling from "./assets/styling.png";
|
||||
import Context from "./assets/context.png";
|
||||
import Assets from "./assets/assets.png";
|
||||
import Docs from "./assets/docs.png";
|
||||
import Share from "./assets/share.png";
|
||||
import FigmaPlugin from "./assets/figma-plugin.png";
|
||||
import Testing from "./assets/testing.png";
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import Theming from "./assets/theming.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
|
||||
export const RightArrow = () => <svg
|
||||
viewBox="0 0 14 14"
|
||||
width="8px"
|
||||
height="14px"
|
||||
style={{
|
||||
marginLeft: '4px',
|
||||
display: 'inline-block',
|
||||
shapeRendering: 'inherit',
|
||||
verticalAlign: 'middle',
|
||||
fill: 'currentColor',
|
||||
'path fill': 'currentColor'
|
||||
}}
|
||||
>
|
||||
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
|
||||
</svg>
|
||||
|
||||
<Meta title="Configure your project" />
|
||||
|
||||
<div className="sb-container">
|
||||
<div className='sb-section-title'>
|
||||
# Configure your project
|
||||
|
||||
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
|
||||
</div>
|
||||
<div className="sb-section">
|
||||
<div className="sb-section-item">
|
||||
<img
|
||||
src={Styling}
|
||||
alt="A wall of logos representing different styling technologies"
|
||||
/>
|
||||
<h4 className="sb-section-item-heading">Add styling and CSS</h4>
|
||||
<p className="sb-section-item-paragraph">Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/configure/styling-and-css"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img
|
||||
src={Context}
|
||||
alt="An abstraction representing the composition of data for a component"
|
||||
/>
|
||||
<h4 className="sb-section-item-heading">Provide context and mocking</h4>
|
||||
<p className="sb-section-item-paragraph">Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/writing-stories/decorators#context-for-mocking"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Assets} alt="A representation of typography and image assets" />
|
||||
<div>
|
||||
<h4 className="sb-section-item-heading">Load assets and resources</h4>
|
||||
<p className="sb-section-item-paragraph">To link static files (like fonts) to your projects and stories, use the
|
||||
`staticDirs` configuration option to specify folders to load when
|
||||
starting Storybook.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/configure/images-and-assets"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-container">
|
||||
<div className='sb-section-title'>
|
||||
# Do more with Storybook
|
||||
|
||||
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
|
||||
</div>
|
||||
|
||||
<div className="sb-section">
|
||||
<div className="sb-features-grid">
|
||||
<div className="sb-grid-item">
|
||||
<img src={Docs} alt="A screenshot showing the autodocs tag being set, pointing a docs page being generated" />
|
||||
<h4 className="sb-section-item-heading">Autodocs</h4>
|
||||
<p className="sb-section-item-paragraph">Auto-generate living,
|
||||
interactive reference documentation from your components and stories.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/writing-docs/autodocs"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Share} alt="A browser window showing a Storybook being published to a chromatic.com URL" />
|
||||
<h4 className="sb-section-item-heading">Publish to Chromatic</h4>
|
||||
<p className="sb-section-item-paragraph">Publish your Storybook to review and collaborate with your entire team.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/sharing/publish-storybook#publish-storybook-with-chromatic"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={FigmaPlugin} alt="Windows showing the Storybook plugin in Figma" />
|
||||
<h4 className="sb-section-item-heading">Figma Plugin</h4>
|
||||
<p className="sb-section-item-paragraph">Embed your stories into Figma to cross-reference the design and live
|
||||
implementation in one place.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/sharing/design-integrations#embed-storybook-in-figma-with-the-plugin"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Testing} alt="Screenshot of tests passing and failing" />
|
||||
<h4 className="sb-section-item-heading">Testing</h4>
|
||||
<p className="sb-section-item-paragraph">Use stories to test a component in all its variations, no matter how
|
||||
complex.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/writing-tests/introduction"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Accessibility} alt="Screenshot of accessibility tests passing and failing" />
|
||||
<h4 className="sb-section-item-heading">Accessibility</h4>
|
||||
<p className="sb-section-item-paragraph">Automatically test your components for a11y issues as you develop.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/writing-tests/accessibility-testing"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-grid-item">
|
||||
<img src={Theming} alt="Screenshot of Storybook in light and dark mode" />
|
||||
<h4 className="sb-section-item-heading">Theming</h4>
|
||||
<p className="sb-section-item-paragraph">Theme Storybook's UI to personalize it to your project.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/docs/react/configure/theming"
|
||||
target="_blank"
|
||||
>Learn more<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='sb-addon'>
|
||||
<div className='sb-addon-text'>
|
||||
<h4>Addons</h4>
|
||||
<p className="sb-section-item-paragraph">Integrate your tools with Storybook to connect workflows.</p>
|
||||
<a
|
||||
href="https://storybook.js.org/integrations/"
|
||||
target="_blank"
|
||||
>Discover all addons<RightArrow /></a>
|
||||
</div>
|
||||
<div className='sb-addon-img'>
|
||||
<img src={AddonLibrary} alt="Integrate your tools with Storybook to connect workflows." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sb-section sb-socials">
|
||||
<div className="sb-section-item">
|
||||
<img src={Github} alt="Github logo" className="sb-explore-image"/>
|
||||
Join our contributors building the future of UI development.
|
||||
|
||||
<a
|
||||
href="https://github.com/storybookjs/storybook"
|
||||
target="_blank"
|
||||
>Star on GitHub<RightArrow /></a>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Discord} alt="Discord logo" className="sb-explore-image"/>
|
||||
<div>
|
||||
Get support and chat with frontend developers.
|
||||
|
||||
<a
|
||||
href="https://discord.gg/storybook"
|
||||
target="_blank"
|
||||
>Join Discord server<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Youtube} alt="Youtube logo" className="sb-explore-image"/>
|
||||
<div>
|
||||
Watch tutorials, feature previews and interviews.
|
||||
|
||||
<a
|
||||
href="https://www.youtube.com/@chromaticui"
|
||||
target="_blank"
|
||||
>Watch on YouTube<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sb-section-item">
|
||||
<img src={Tutorials} alt="A book" className="sb-explore-image"/>
|
||||
<p>Follow guided walkthroughs on for key workflows.</p>
|
||||
|
||||
<a
|
||||
href="https://storybook.js.org/tutorials/"
|
||||
target="_blank"
|
||||
>Discover tutorials<RightArrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
.sb-container {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.sb-section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sb-section-title {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.sb-section a:not(h1 a, h2 a, h3 a) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sb-section-item, .sb-grid-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-section-item-heading {
|
||||
padding-top: 20px !important;
|
||||
padding-bottom: 5px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.sb-section-item-paragraph {
|
||||
margin: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.sb-chevron {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.sb-features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 32px 20px;
|
||||
}
|
||||
|
||||
.sb-socials {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.sb-socials p {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sb-explore-image {
|
||||
max-height: 32px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sb-addon {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
background-color: #EEF3F8;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: #EEF3F8;
|
||||
height: 180px;
|
||||
margin-bottom: 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-addon-text {
|
||||
padding-left: 48px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.sb-addon-text h4 {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.sb-addon-img {
|
||||
position: absolute;
|
||||
left: 345px;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 200%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-addon-img img {
|
||||
width: 650px;
|
||||
transform: rotate(-15deg);
|
||||
margin-left: 40px;
|
||||
margin-top: -72px;
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, 0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px) {
|
||||
.sb-addon-img {
|
||||
left: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.sb-section {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-features-grid {
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
.sb-socials {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.sb-addon {
|
||||
height: 280px;
|
||||
align-items: flex-start;
|
||||
padding-top: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sb-addon-text {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.sb-addon-img {
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 130px;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
width: 124%;
|
||||
}
|
||||
|
||||
.sb-addon-img img {
|
||||
width: 1200px;
|
||||
transform: rotate(-12deg);
|
||||
margin-left: 0;
|
||||
margin-top: 48px;
|
||||
margin-bottom: -40px;
|
||||
margin-left: -24px;
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
27
webapp/src/stories/Header.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { Header } from './Header';
|
||||
|
||||
const meta = {
|
||||
title: 'Example/Header',
|
||||
component: Header,
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
} satisfies Meta<typeof Header>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const LoggedIn: Story = {
|
||||
args: {
|
||||
user: {
|
||||
name: 'Jane Doe',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LoggedOut: Story = {};
|
||||
54
webapp/src/stories/Header.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button } from './Button';
|
||||
import './header.css';
|
||||
|
||||
type User = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface HeaderProps {
|
||||
user?: User;
|
||||
onLogin: () => void;
|
||||
onLogout: () => void;
|
||||
onCreateAccount: () => void;
|
||||
}
|
||||
|
||||
export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
|
||||
<header>
|
||||
<div className="storybook-header">
|
||||
<div>
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
|
||||
fill="#FFF"
|
||||
/>
|
||||
<path
|
||||
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
|
||||
fill="#555AB9"
|
||||
/>
|
||||
<path
|
||||
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
|
||||
fill="#91BAF8"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1>Acme</h1>
|
||||
</div>
|
||||
<div>
|
||||
{user ? (
|
||||
<>
|
||||
<span className="welcome">
|
||||
Welcome, <b>{user.name}</b>!
|
||||
</span>
|
||||
<Button size="small" onClick={onLogout} label="Log out" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="small" onClick={onLogin} label="Log in" />
|
||||
<Button primary size="small" onClick={onCreateAccount} label="Sign up" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
29
webapp/src/stories/Page.stories.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { within, userEvent } from '@storybook/testing-library';
|
||||
|
||||
import { Page } from './Page';
|
||||
|
||||
const meta = {
|
||||
title: 'Example/Page',
|
||||
component: Page,
|
||||
parameters: {
|
||||
// More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
} satisfies Meta<typeof Page>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const LoggedOut: Story = {};
|
||||
|
||||
// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing
|
||||
export const LoggedIn: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const loginButton = await canvas.getByRole('button', {
|
||||
name: /Log in/i,
|
||||
});
|
||||
await userEvent.click(loginButton);
|
||||
},
|
||||
};
|
||||
73
webapp/src/stories/Page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Header } from './Header';
|
||||
import './page.css';
|
||||
|
||||
type User = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const Page: React.FC = () => {
|
||||
const [user, setUser] = React.useState<User>();
|
||||
|
||||
return (
|
||||
<article>
|
||||
<Header
|
||||
user={user}
|
||||
onLogin={() => setUser({ name: 'Jane Doe' })}
|
||||
onLogout={() => setUser(undefined)}
|
||||
onCreateAccount={() => setUser({ name: 'Jane Doe' })}
|
||||
/>
|
||||
|
||||
<section className="storybook-page">
|
||||
<h2>Pages in Storybook</h2>
|
||||
<p>
|
||||
We recommend building UIs with a{' '}
|
||||
<a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
|
||||
<strong>component-driven</strong>
|
||||
</a>{' '}
|
||||
process starting with atomic components and ending with pages.
|
||||
</p>
|
||||
<p>
|
||||
Render pages with mock data. This makes it easy to build and review page states without
|
||||
needing to navigate to them in your app. Here are some handy patterns for managing page
|
||||
data in Storybook:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Use a higher-level connected component. Storybook helps you compose such data from the
|
||||
"args" of child component stories
|
||||
</li>
|
||||
<li>
|
||||
Assemble data in the page component from your services. You can mock these services out
|
||||
using Storybook.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Get a guided tutorial on component-driven development at{' '}
|
||||
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
|
||||
Storybook tutorials
|
||||
</a>
|
||||
. Read more in the{' '}
|
||||
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">
|
||||
docs
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="tip-wrapper">
|
||||
<span className="tip">Tip</span> Adjust the width of the canvas with the{' '}
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
|
||||
id="a"
|
||||
fill="#999"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
Viewports addon in the toolbar
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
BIN
webapp/src/stories/assets/accessibility.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
5
webapp/src/stories/assets/accessibility.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Accessibility</title>
|
||||
<circle cx="24.334" cy="24" r="24" fill="#A849FF" fill-opacity="0.3"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.8609 11.585C27.8609 9.59506 26.2497 7.99023 24.2519 7.99023C22.254 7.99023 20.6429 9.65925 20.6429 11.585C20.6429 13.575 22.254 15.1799 24.2519 15.1799C26.2497 15.1799 27.8609 13.575 27.8609 11.585ZM21.8922 22.6473C21.8467 23.9096 21.7901 25.4788 21.5897 26.2771C20.9853 29.0462 17.7348 36.3314 17.3325 37.2275C17.1891 37.4923 17.1077 37.7955 17.1077 38.1178C17.1077 39.1519 17.946 39.9902 18.9802 39.9902C19.6587 39.9902 20.253 39.6293 20.5814 39.0889L20.6429 38.9874L24.2841 31.22C24.2841 31.22 27.5529 37.9214 27.9238 38.6591C28.2948 39.3967 28.8709 39.9902 29.7168 39.9902C30.751 39.9902 31.5893 39.1519 31.5893 38.1178C31.5893 37.7951 31.3639 37.2265 31.3639 37.2265C30.9581 36.3258 27.698 29.0452 27.0938 26.2771C26.8975 25.4948 26.847 23.9722 26.8056 22.7236C26.7927 22.333 26.7806 21.9693 26.7653 21.6634C26.7008 21.214 27.0231 20.8289 27.4097 20.7005L35.3366 18.3253C36.3033 18.0685 36.8834 16.9773 36.6256 16.0144C36.3678 15.0515 35.2722 14.4737 34.3055 14.7305C34.3055 14.7305 26.8619 17.1057 24.2841 17.1057C21.7062 17.1057 14.456 14.7947 14.456 14.7947C13.4893 14.5379 12.3937 14.9873 12.0715 15.9502C11.7493 16.9131 12.3293 18.0044 13.3604 18.3253L21.2873 20.7005C21.674 20.8289 21.9318 21.214 21.9318 21.6634C21.9174 21.9493 21.9053 22.2857 21.8922 22.6473Z" fill="#A470D5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
webapp/src/stories/assets/addon-library.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
webapp/src/stories/assets/assets.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
webapp/src/stories/assets/context.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
15
webapp/src/stories/assets/discord.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="33" height="32" viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_10031_177575)">
|
||||
<mask id="mask0_10031_177575" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="4" width="33" height="25">
|
||||
<path d="M32.5034 4.00195H0.503906V28.7758H32.5034V4.00195Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_10031_177575)">
|
||||
<path d="M27.5928 6.20817C25.5533 5.27289 23.3662 4.58382 21.0794 4.18916C21.0378 4.18154 20.9962 4.20057 20.9747 4.23864C20.6935 4.73863 20.3819 5.3909 20.1637 5.90358C17.7042 5.53558 15.2573 5.53558 12.8481 5.90358C12.6299 5.37951 12.307 4.73863 12.0245 4.23864C12.003 4.20184 11.9614 4.18281 11.9198 4.18916C9.63431 4.58255 7.44721 5.27163 5.40641 6.20817C5.38874 6.21578 5.3736 6.22848 5.36355 6.24497C1.21508 12.439 0.078646 18.4809 0.636144 24.4478C0.638667 24.477 0.655064 24.5049 0.677768 24.5227C3.41481 26.5315 6.06609 27.7511 8.66815 28.5594C8.70979 28.5721 8.75392 28.5569 8.78042 28.5226C9.39594 27.6826 9.94461 26.7968 10.4151 25.8653C10.4428 25.8107 10.4163 25.746 10.3596 25.7244C9.48927 25.3945 8.66058 24.9922 7.86343 24.5354C7.80038 24.4986 7.79533 24.4084 7.85333 24.3653C8.02108 24.2397 8.18888 24.109 8.34906 23.977C8.37804 23.9529 8.41842 23.9478 8.45249 23.963C13.6894 26.3526 19.359 26.3526 24.5341 23.963C24.5682 23.9465 24.6086 23.9516 24.6388 23.9757C24.799 24.1077 24.9668 24.2397 25.1358 24.3653C25.1938 24.4084 25.19 24.4986 25.127 24.5354C24.3298 25.0011 23.5011 25.3945 22.6296 25.7232C22.5728 25.7447 22.5476 25.8107 22.5754 25.8653C23.0559 26.7955 23.6046 27.6812 24.2087 28.5213C24.234 28.5569 24.2794 28.5721 24.321 28.5594C26.9357 27.7511 29.5869 26.5315 32.324 24.5227C32.348 24.5049 32.3631 24.4783 32.3656 24.4491C33.0328 17.5506 31.2481 11.5584 27.6344 6.24623C27.6256 6.22848 27.6105 6.21578 27.5928 6.20817ZM11.1971 20.8146C9.62043 20.8146 8.32129 19.3679 8.32129 17.5913C8.32129 15.8146 9.59523 14.368 11.1971 14.368C12.8115 14.368 14.0981 15.8273 14.0729 17.5913C14.0729 19.3679 12.7989 20.8146 11.1971 20.8146ZM21.8299 20.8146C20.2533 20.8146 18.9541 19.3679 18.9541 17.5913C18.9541 15.8146 20.228 14.368 21.8299 14.368C23.4444 14.368 24.7309 15.8273 24.7057 17.5913C24.7057 19.3679 23.4444 20.8146 21.8299 20.8146Z" fill="#5865F2"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_10031_177575">
|
||||
<rect width="31.9995" height="32" fill="white" transform="translate(0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
webapp/src/stories/assets/docs.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
webapp/src/stories/assets/figma-plugin.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
3
webapp/src/stories/assets/github.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16.0001 0C7.16466 0 0 7.17472 0 16.0256C0 23.1061 4.58452 29.1131 10.9419 31.2322C11.7415 31.3805 12.0351 30.8845 12.0351 30.4613C12.0351 30.0791 12.0202 28.8167 12.0133 27.4776C7.56209 28.447 6.62283 25.5868 6.62283 25.5868C5.89499 23.7345 4.8463 23.2419 4.8463 23.2419C3.39461 22.2473 4.95573 22.2678 4.95573 22.2678C6.56242 22.3808 7.40842 23.9192 7.40842 23.9192C8.83547 26.3691 11.1514 25.6609 12.0645 25.2514C12.2081 24.2156 12.6227 23.5087 13.0803 23.1085C9.52648 22.7032 5.7906 21.3291 5.7906 15.1886C5.7906 13.4389 6.41563 12.0094 7.43916 10.8871C7.27303 10.4834 6.72537 8.85349 7.59415 6.64609C7.59415 6.64609 8.93774 6.21539 11.9953 8.28877C13.2716 7.9337 14.6404 7.75563 16.0001 7.74953C17.3599 7.75563 18.7297 7.9337 20.0084 8.28877C23.0623 6.21539 24.404 6.64609 24.404 6.64609C25.2749 8.85349 24.727 10.4834 24.5608 10.8871C25.5868 12.0094 26.2075 13.4389 26.2075 15.1886C26.2075 21.3437 22.4645 22.699 18.9017 23.0957C19.4756 23.593 19.9869 24.5683 19.9869 26.0634C19.9869 28.2077 19.9684 29.9334 19.9684 30.4613C19.9684 30.8877 20.2564 31.3874 21.0674 31.2301C27.4213 29.1086 32 23.1037 32 16.0256C32 7.17472 24.8364 0 16.0001 0ZM5.99257 22.8288C5.95733 22.9084 5.83227 22.9322 5.71834 22.8776C5.60229 22.8253 5.53711 22.7168 5.57474 22.6369C5.60918 22.5549 5.7345 22.5321 5.85029 22.587C5.9666 22.6393 6.03284 22.7489 5.99257 22.8288ZM6.7796 23.5321C6.70329 23.603 6.55412 23.5701 6.45291 23.4581C6.34825 23.3464 6.32864 23.197 6.40601 23.125C6.4847 23.0542 6.62937 23.0874 6.73429 23.1991C6.83895 23.3121 6.85935 23.4605 6.7796 23.5321ZM7.31953 24.4321C7.2215 24.5003 7.0612 24.4363 6.96211 24.2938C6.86407 24.1513 6.86407 23.9804 6.96422 23.9119C7.06358 23.8435 7.2215 23.905 7.32191 24.0465C7.41968 24.1914 7.41968 24.3623 7.31953 24.4321ZM8.23267 25.4743C8.14497 25.5712 7.95818 25.5452 7.82146 25.413C7.68156 25.2838 7.64261 25.1004 7.73058 25.0035C7.81934 24.9064 8.00719 24.9337 8.14497 25.0648C8.28381 25.1938 8.3262 25.3785 8.23267 25.4743ZM9.41281 25.8262C9.37413 25.9517 9.19423 26.0088 9.013 25.9554C8.83203 25.9005 8.7136 25.7535 8.75016 25.6266C8.78778 25.5003 8.96848 25.4408 9.15104 25.4979C9.33174 25.5526 9.45044 25.6985 9.41281 25.8262ZM10.7559 25.9754C10.7604 26.1076 10.6067 26.2172 10.4165 26.2196C10.2252 26.2238 10.0704 26.1169 10.0683 25.9868C10.0683 25.8534 10.2185 25.7448 10.4098 25.7416C10.6001 25.7379 10.7559 25.8441 10.7559 25.9754ZM12.0753 25.9248C12.0981 26.0537 11.9658 26.1862 11.7769 26.2215C11.5912 26.2554 11.4192 26.1758 11.3957 26.0479C11.3726 25.9157 11.5072 25.7833 11.6927 25.7491C11.8819 25.7162 12.0512 25.7937 12.0753 25.9248Z" fill="#161614"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
webapp/src/stories/assets/share.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
webapp/src/stories/assets/styling.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
webapp/src/stories/assets/testing.png
Normal file
|
After Width: | Height: | Size: 48 KiB |