This commit is contained in:
2025-09-05 01:32:06 +10:00
parent 65c52be4a1
commit bba8986c88
13 changed files with 773 additions and 133 deletions

View File

@@ -28,9 +28,9 @@ impl Evaluator {
};
}
pub fn set_cell(&mut self, cell_ref: CellRef, raw_val: String) -> Result<(), String> {
pub fn set_cell(&mut self, cell_ref: CellRef, raw_val: String) -> Result<Eval, String> {
if self.cells.contains_key(&cell_ref) && self.cells[&cell_ref].raw() == raw_val {
return Ok(());
return self.get_cell(cell_ref);
}
let eval: Eval;
@@ -50,19 +50,23 @@ impl Evaluator {
}
}
self.cells.insert(cell_ref, Cell::new(eval, raw_val));
Ok(())
self.cells
.insert(cell_ref, Cell::new(eval.clone(), raw_val));
Ok(eval)
}
pub fn get_cell(&mut self, cell_ref: CellRef) -> Result<(String, Eval), String> {
// pub fn get_cell(&mut self, cell_ref: CellRef) -> Result<(String, Eval), String> {
pub fn get_cell(&mut self, cell_ref: CellRef) -> Result<Eval, String> {
if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref));
}
let cell = &self.cells[&cell_ref];
Ok((cell.raw(), cell.eval()))
// Ok((cell.raw(), cell.eval()))
Ok(cell.eval())
}
pub fn add_cell_dep(&mut self, cell_ref: CellRef, dep_ref: CellRef) -> Result<(), String> {
if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref));
@@ -87,7 +91,7 @@ impl Evaluator {
fn evaluate_expr(&mut self, expr: &Expr) -> Result<Eval, String> {
let res = match expr {
Expr::Literal(lit) => Eval::Literal(lit.clone()),
Expr::CellRef(re) => self.get_cell(re.to_owned())?.1,
Expr::CellRef(re) => self.get_cell(re.to_owned())?,
Expr::Infix { op, lhs, rhs } => {
let lval = self.evaluate_expr(lhs)?;
let rval = self.evaluate_expr(rhs)?;
@@ -151,6 +155,14 @@ fn eval_mul(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
fn eval_div(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => {
if let (Literal::Integer(_), Literal::Integer(y)) = (a, b) {
if *y == 0 {
return Err(
"Evaluation error: integers attempted to divide by zero.".to_string()
);
}
}
if let Some(res) = eval_numeric_infix(a, b, |x, y| x / y, |x, y| x / y) {
return Ok(Eval::Literal(res));
}

View File

@@ -80,6 +80,36 @@ async fn main() -> Result<(), Error> {
// }
}
// async fn accept_connection(stream: TcpStream) {
// let addr = stream
// .peer_addr()
// .expect("connected streams should have a peer address");
// info!("Peer address: {}", addr);
//
// let ws_stream = tokio_tungstenite::accept_async(stream)
// .await
// .expect("Error during the websocket handshake occurred");
//
// info!("New WebSocket connection: {}", addr);
//
// let (mut write, mut read) = ws_stream.split();
//
// // We should not forward messages other than text or binary.
// while let Some(msg) = read.try_next().await.unwrap_or(None) {
// if msg.is_text() || msg.is_binary() {
// if let Err(e) = write
// .send(format!("This is a message {}!", msg.to_text().unwrap_or("")).into())
// .await
// {
// eprintln!("send error: {}", e);
// break;
// }
// }
// }
//
// info!("Disconnected from {}", addr);
// }
async fn accept_connection(stream: TcpStream) {
let addr = stream
.peer_addr()
@@ -94,15 +124,57 @@ async fn accept_connection(stream: TcpStream) {
let (mut write, mut read) = ws_stream.split();
// We should not forward messages other than text or binary.
// Each connection gets its own evaluator
let mut evaluator = Evaluator::new();
while let Some(msg) = read.try_next().await.unwrap_or(None) {
if msg.is_text() || msg.is_binary() {
if let Err(e) = write
.send(format!("This is a message {}!", msg.to_text().unwrap_or("")).into())
.await
{
eprintln!("send error: {}", e);
break;
if msg.is_text() {
let input = msg.to_text().unwrap_or("").trim().to_string();
let cmds = ["set", "get"];
let cmd = &input[0..3.min(input.len())]; // avoid panic on short strings
if !cmds.iter().any(|c| c == &cmd) {
let _ = write
.send(format!("ERR invalid command: {}", input).into())
.await;
continue;
}
let rest = input[4..].trim();
let mut parts = rest.splitn(2, char::is_whitespace);
let raw_ref = parts.next().unwrap_or("").trim(); // cell reference
let raw_str = parts.next().unwrap_or("").trim(); // rest (value)
if let Ok(cell_ref) = CellRef::new(raw_ref.to_owned()) {
match cmd {
"set" => match evaluator.set_cell(cell_ref.clone(), raw_str.to_owned()) {
Ok(eval) => {
let _ = write.send(format!("{} {}", raw_ref, eval).into()).await;
}
Err(e) => {
let _ = write.send(format!("ERR {}", e).into()).await;
}
},
"get" => match evaluator.get_cell(cell_ref.clone()) {
Ok(res) => {
let _ = write
.send(format!("{} {}", raw_ref, res.to_string()).into())
.await;
}
Err(e) => {
let _ = write.send(format!("ERR {}", e).into()).await;
}
},
_ => {
let _ = write.send("ERR impossible".into()).await;
}
}
} else {
let _ = write
.send(format!("ERR invalid cell reference: {}", raw_ref).into())
.await;
}
}
}

View File

@@ -29,6 +29,7 @@
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.0.0",

View File

@@ -57,6 +57,9 @@ importers:
svelte-check:
specifier: ^4.0.0
version: 4.3.1(picomatch@4.0.3)(svelte@5.38.6)(typescript@5.9.2)
svelte-sonner:
specifier: ^1.0.5
version: 1.0.5(svelte@5.38.6)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -859,6 +862,11 @@ packages:
peerDependencies:
svelte: ^5.7.0
runed@0.28.0:
resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==}
peerDependencies:
svelte: ^5.7.0
runed@0.29.2:
resolution: {integrity: sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==}
peerDependencies:
@@ -890,6 +898,11 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: '>=5.0.0'
svelte-sonner@1.0.5:
resolution: {integrity: sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg==}
peerDependencies:
svelte: ^5.0.0
svelte-toolbelt@0.7.1:
resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
@@ -1584,6 +1597,11 @@ snapshots:
esm-env: 1.2.2
svelte: 5.38.6
runed@0.28.0(svelte@5.38.6):
dependencies:
esm-env: 1.2.2
svelte: 5.38.6
runed@0.29.2(svelte@5.38.6):
dependencies:
esm-env: 1.2.2
@@ -1619,6 +1637,11 @@ snapshots:
transitivePeerDependencies:
- picomatch
svelte-sonner@1.0.5(svelte@5.38.6):
dependencies:
runed: 0.28.0(svelte@5.38.6)
svelte: 5.38.6
svelte-toolbelt@0.7.1(svelte@5.38.6):
dependencies:
clsx: 2.1.1

View File

@@ -1,121 +1,121 @@
@import "tailwindcss";
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.97 0.014 254.604);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.379 0.146 265.522);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import clsx from 'clsx';
let {
width = '80px',
height = '30px',
setColWidth = () => {},
setRowHeight = () => {},
val,
active,
direction = 'col' // New prop: 'col' for right-side drag, 'row' for bottom-side
}: {
width?: string;
height?: string;
setColWidth?: (width: string) => void;
setRowHeight?: (height: string) => void;
val: string;
active: boolean;
direction?: 'col' | 'row';
} = $props();
// --- Drag Logic ---
const handleMouseDown = (startEvent: MouseEvent) => {
// Prevent text selection during drag
startEvent.preventDefault();
const target = startEvent.currentTarget as HTMLElement;
const parent = target.parentElement!;
// Store the initial position and size
const startX = startEvent.clientX;
const startY = startEvent.clientY;
const startWidth = parent.offsetWidth;
const startHeight = parent.offsetHeight;
const handleMouseMove = (moveEvent: MouseEvent) => {
if (direction === 'col') {
const dx = moveEvent.clientX - startX;
// Enforce a minimum width of 40px
setColWidth(`${Math.max(40, startWidth + dx)}px`);
} else {
const dy = moveEvent.clientY - startY;
// Enforce a minimum height of 20px
setRowHeight(`${Math.max(30, startHeight + dy)}px`);
}
};
const handleMouseUp = () => {
// Cleanup: remove the global listeners
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
// Add global listeners to track mouse movement anywhere on the page
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
</script>
<div
style:width
style:height
class={clsx('placeholder group relative bg-background p-1 dark:bg-input/30', { active })}
>
<span class="pointer-events-none flex h-full w-full items-center justify-center select-none">
{val}
</span>
<div
role="separator"
aria-label="Resize handle"
onmousedown={handleMouseDown}
class={clsx('resizer', {
'resizer-col': direction === 'col',
'resizer-row': direction === 'row'
})}
/>
</div>
<style>
.placeholder {
font-size: 14px;
border: 1px solid var(--input);
overflow: hidden;
}
.active {
border: 1px solid var(--color-primary);
background-color: color-mix(in oklab, var(--color-primary) 40%, transparent);
}
/* --- Resizer Styles --- */
.resizer {
position: absolute;
/* Make it easier to grab */
z-index: 10;
/* Subtle visual cue, becomes more visible on hover */
background-color: transparent;
transition: background-color 0.2s ease-in-out;
}
/* Style for vertical (column) resizing */
.resizer-col {
cursor: col-resize;
top: 0;
right: 0;
width: 8px; /* Larger grab area */
height: 100%;
}
/* Style for horizontal (row) resizing */
.resizer-row {
cursor: row-resize;
bottom: 0;
left: 0;
height: 8px; /* Larger grab area */
width: 100%;
}
/* Make the handle visible when hovering over the component */
.group:hover > .resizer {
background-color: var(--color-primary);
opacity: 0.5;
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input/index.js';
import clsx from 'clsx';
let {
width = '80px',
height = '30px',
raw_val = $bindable(''),
val = undefined,
onmousedown = () => {},
startediting = () => {},
stopediting = () => {},
active = false,
editing = false
}: {
width?: string;
height?: string;
raw_val?: string;
val?: number | string | undefined;
onmousedown?: (e: MouseEvent) => void;
startediting?: () => void;
stopediting?: () => void;
active?: boolean;
editing?: boolean;
} = $props();
// focus the first focusable descendant (the inner <input>)
function autofocusWithin(node: HTMLElement) {
queueMicrotask(() => {
const el = node.querySelector('input') as HTMLInputElement | null;
if (el !== null) {
el.value = raw_val;
el.focus();
}
});
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === 'NumpadEnter' || e.key == 'Escape') {
e.preventDefault(); // avoid form submit/line break
const el = (e.currentTarget as HTMLElement).querySelector('input') as HTMLInputElement | null;
el?.blur(); // triggers on:blur below
}
}
</script>
{#if editing}
<div use:autofocusWithin onkeydown={handleKeydown}>
<Input
style="width: {width}; height: {height}"
class="relative rounded-none p-1
!transition-none delay-0 duration-0
focus:z-50"
onblur={(e) => {
raw_val = (e.target as HTMLInputElement).value;
stopediting();
}}
/>
</div>
{:else}
<div
ondblclick={startediting}
{onmousedown}
style:width
style:height
class={clsx('placeholder bg-background p-1 dark:bg-input/30', { active, 'z-50': active })}
>
{#if raw_val !== '' || val !== ''}
<span class="pointer-events-none select-none">
{#if val !== undefined}
{val}
{:else}
{raw_val}
{/if}
</span>
{/if}
</div>
{/if}
<style>
.placeholder {
border: 1px solid var(--input);
overflow: hidden;
}
.active {
border: 1px solid var(--color-primary);
outline: 1px solid var(--color-primary);
}
</style>

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import Cell from '$lib/components/grid/cell.svelte';
import { onDestroy, onMount } from 'svelte';
import CellHeader from './cell-header.svelte';
import { fromGridRef, toColLetter, toGridRef, type CellData, type CellValue } from './utils';
let {
socket
}: {
socket: WebSocket;
} = $props();
function splitErrorString(errorString: string) {
// Remove the "ERR " prefix.
const content = errorString.substring(4);
// Find the index of the first colon.
const colonIndex = content.indexOf(':');
// If no colon is found, return the whole content as the first element.
if (colonIndex === -1) {
return [content.trim(), ''];
}
// Extract the part before the colon (the error type).
const errorType = content.substring(0, colonIndex).trim();
// Extract the part after the colon (the error message).
const errorMessage = content.substring(colonIndex + 1).trim();
return [errorType, errorMessage];
}
socket.onmessage = (msg: MessageEvent) => {
const input = msg.data.toString().trim();
if (input.startsWith('ERR')) {
let split = splitErrorString(input);
toast.error(split[0], {
description: split[1]
});
return;
}
let cellRef: string | undefined;
let evalStr: string | undefined;
// Case 1: "Cell D4 = Integer(4)"
let match = input.match(/^Cell\s+([A-Z]+\d+)\s*=\s*(.+)$/);
if (match) {
[, cellRef, evalStr] = match;
}
// Case 2: "D6 String("hello")" or "E9 Double(4.0)"
if (!match) {
match = input.match(/^([A-Z]+\d+)\s+(.+)$/);
if (match) {
[, cellRef, evalStr] = match;
}
}
if (!cellRef || !evalStr) {
console.warn('Unrecognized message:', input);
return;
}
console.log(`Cell: ${cellRef}`);
console.log(`Eval: ${evalStr}`);
let [i, j] = fromGridRef(cellRef);
// Parse eval types
if (evalStr.startsWith('Integer(')) {
const num = parseInt(evalStr.match(/^Integer\(([-\d]+)\)$/)?.[1] ?? 'NaN', 10);
console.log(`Parsed integer:`, num);
setCellVal(i, j, num);
} else if (evalStr.startsWith('Double(')) {
const num = parseFloat(evalStr.match(/^Double\(([-\d.]+)\)$/)?.[1] ?? 'NaN');
console.log(`Parsed double:`, num);
setCellVal(i, j, num);
} else if (evalStr.startsWith('String(')) {
const str = evalStr.match(/^String\("(.+)"\)$/)?.[1];
console.log(`Parsed string:`, str);
setCellVal(i, j, str);
}
};
let rows = 50;
let cols = 50;
let default_row_height = '30px';
let default_col_width = '60px';
// Only store touched cells
let grid_vals: Record<string, CellData> = $state({});
let row_heights: Record<number, string> = $state({});
let col_widths: Record<number, string> = $state({});
function getRowHeight(row: number) {
return row_heights[row] ?? default_row_height;
}
function getColWidth(col: number) {
return col_widths[col] ?? default_col_width;
}
function setRowHeight(row: number, height: string) {
if (height === default_row_height) {
delete row_heights[row];
} else {
row_heights[row] = height;
}
}
function setColWidth(col: number, width: string) {
if (width === default_col_width) {
delete col_widths[col];
} else {
col_widths[col] = width;
}
}
let active_cell: [number, number] | null = $state(null);
let editing_cell: [number, number] | null = $state(null);
function startEditing(i: number, j: number) {
active_cell = [i, j];
editing_cell = [i, j];
}
function stopEditing() {
editing_cell = null;
}
const key = (i: number, j: number) => `${i}:${j}`;
const getCell = (i: number, j: number) => grid_vals[key(i, j)] ?? undefined;
const getCellRaw = (i: number, j: number) => getCell(i, j)?.raw_val ?? '';
const setCellRaw = (i: number, j: number, val: string) => {
if (grid_vals[key(i, j)] === undefined) {
grid_vals[key(i, j)] = {
raw_val: val,
val: undefined
};
} else {
grid_vals[key(i, j)].raw_val = val;
}
};
const getCellVal = (i: number, j: number) => getCell(i, j)?.val ?? undefined;
const setCellVal = (i: number, j: number, val: CellValue) => {
if (grid_vals[key(i, j)] === undefined) {
console.warn('Cell raw value was undefined but recieved eval.');
} else {
grid_vals[key(i, j)].val = val;
}
};
const setCell = (i: number, j: number, v: string | undefined) => {
// ignore “no value” so we dont create keys on mount
if (v == null || v === '') delete grid_vals[key(i, j)];
else {
setCellRaw(i, j, v);
console.log(i, j);
socket.send(`set ${toGridRef(i, j)} ${v}`);
}
};
// $effect(() => {
// $inspect(grid_vals);
// });
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
if (editing_cell) {
// Get the actual input element that's being edited
const el = document.querySelector<HTMLInputElement>('input:focus');
const currentInputValue = el?.value ?? '';
// ONLY treat this as a reference insert if it's a formula
if (currentInputValue.trim().startsWith('=')) {
// Prevent the input from losing focus
e.preventDefault();
// --- This is the same reference-inserting logic as before ---
const ref = toGridRef(i, j);
if (el) {
const { selectionStart, selectionEnd } = el;
const before = el.value.slice(0, selectionStart ?? 0);
const after = el.value.slice(selectionEnd ?? 0);
el.value = before + ref + after;
const newPos = (selectionStart ?? 0) + ref.length;
el.setSelectionRange(newPos, newPos);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.focus();
}
return;
}
}
// We are not editing, so this is a normal cell selection OR this is not a formula
active_cell = [i, j];
}
onMount(() => {
const handler = (e: MouseEvent) => {
// optional: check if click target is outside grid container
if (!(e.target as HTMLElement).closest('.grid-wrapper')) {
active_cell = null;
}
};
window.addEventListener('click', handler);
onDestroy(() => window.removeEventListener('click', handler));
});
</script>
<div class="grid-wrapper">
<div class="flex w-fit">
<CellHeader height={default_row_height} width={default_col_width} val="" active={false} />
{#each Array(cols) as _, j}
<CellHeader
height={default_row_height}
width={getColWidth(j)}
setColWidth={(width) => setColWidth(j, width)}
direction="col"
val={toColLetter(j + 1)}
active={active_cell !== null && active_cell[1] === j}
/>
{/each}
</div>
{#each Array(rows) as _, i}
<div class="flex w-fit">
<CellHeader
direction="row"
width={default_col_width}
height={getRowHeight(i)}
setRowHeight={(height) => setRowHeight(i, height)}
val={(i + 1).toString()}
active={active_cell !== null && active_cell[0] === i}
/>
{#each Array(cols) as _, j}
<Cell
height={getRowHeight(i)}
width={getColWidth(j)}
editing={editing_cell?.[0] === i && editing_cell?.[1] === j}
startediting={() => startEditing(i, j)}
stopediting={stopEditing}
onmousedown={(e) => {
handleCellInteraction(i, j, e);
}}
bind:raw_val={() => getCellRaw(i, j), (v) => setCell(i, j, v)}
val={getCellVal(i, j)}
active={active_cell !== null && active_cell[0] === i && active_cell[1] === j}
/>
{/each}
</div>
{/each}
</div>

View File

@@ -0,0 +1,39 @@
export function fromGridRef(ref: string): [number, number] {
const match = ref.match(/^([A-Z]+)([0-9]+)$/i);
if (!match) throw new Error('Invalid reference');
const [, letters, rowStr] = match;
let col = 0;
for (let i = 0; i < letters.length; i++) {
col = col * 26 + (letters.charCodeAt(i) - 64); // 'A' = 65 → 1
}
const row = parseInt(rowStr, 10);
return [row - 1, col - 1];
}
export function toColLetter(col: number): string {
let result = '';
let n = col;
while (n > 0) {
const rem = (n - 1) % 26;
result = String.fromCharCode(65 + rem) + result; // 65 = 'A'
n = Math.floor((n - 1) / 26);
}
return result;
}
export function toGridRef(row: number, col: number): string {
row++;
col++;
return toColLetter(col) + row.toString();
}
export type CellValue = number | string | undefined;
export interface CellData {
raw_val: string;
val: CellValue;
}

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let { ...restProps }: SonnerProps = $props();
</script>
<Sonner
theme={mode.current}
class="toaster group"
style="--normal-bg: var(--color-popover); --normal-text: var(--color-popover-foreground); --normal-border: var(--color-border);"
{...restProps}
/>

View File

@@ -1,12 +1,14 @@
<script lang="ts">
import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<ModeWatcher defaultMode="light" />
<Toaster richColors position="bottom-right" />
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>

View File

@@ -5,18 +5,20 @@
import { Button } from '$lib/components/ui/button/index.js';
import { toggleMode } from 'mode-watcher';
import Grid from '$lib/components/grid/grid.svelte';
let socket = new WebSocket('ws://localhost:7050');
</script>
<Button onclick={toggleMode} variant="outline" size="icon">
<SunIcon
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>
<!-- <Button onclick={toggleMode} variant="outline" size="icon"> -->
<!-- <SunIcon -->
<!-- class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90" -->
<!-- /> -->
<!-- <MoonIcon -->
<!-- class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0" -->
<!-- /> -->
<!-- <span class="sr-only">Toggle theme</span> -->
<!-- </Button> -->
<div class="h-[80vh] overflow-hidden rounded-lg border">
<Grid />
<div class="m-0">
<Grid {socket} />
</div>