🙃
This commit is contained in:
@@ -6,6 +6,7 @@ use crate::{
|
|||||||
evaluator::{numerics::*, utils::*},
|
evaluator::{numerics::*, utils::*},
|
||||||
grid::Grid,
|
grid::Grid,
|
||||||
parser::*,
|
parser::*,
|
||||||
|
tokenizer::{Token, Tokenizer},
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::{collections::HashSet, f64, fmt};
|
use std::{collections::HashSet, f64, fmt};
|
||||||
@@ -49,6 +50,27 @@ pub fn evaluate(str: String, grid: Option<&Grid>) -> (Eval, HashSet<CellRef>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_literal(input: String) -> Eval {
|
||||||
|
let mut tokenizer = match Tokenizer::new(&input) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => {
|
||||||
|
return Eval::Literal(Literal::String(input.to_owned()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if tokenizer.len() != 1 {
|
||||||
|
return Eval::Literal(Literal::String(input.to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
match tokenizer.next() {
|
||||||
|
Token::Literal(lit) => match lit {
|
||||||
|
Literal::Number(_) | Literal::String(_) => Eval::Literal(lit),
|
||||||
|
Literal::Boolean(_) => Eval::Literal(Literal::String(input.to_owned())),
|
||||||
|
},
|
||||||
|
_ => Eval::Literal(Literal::String(input.to_owned())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn evaluate_expr(
|
fn evaluate_expr(
|
||||||
expr: &Expr,
|
expr: &Expr,
|
||||||
precs: &mut HashSet<CellRef>,
|
precs: &mut HashSet<CellRef>,
|
||||||
@@ -266,7 +288,6 @@ fn eval_range(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn eval_pos(val: &Eval) -> Result<Eval, LeadErr> {
|
fn eval_pos(val: &Eval) -> Result<Eval, LeadErr> {
|
||||||
match val {
|
match val {
|
||||||
Eval::Literal(Literal::Number(it)) => Ok(Eval::Literal(Literal::Number(*it))),
|
Eval::Literal(Literal::Number(it)) => Ok(Eval::Literal(Literal::Number(*it))),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use log::info;
|
|||||||
use crate::{
|
use crate::{
|
||||||
cell::{Cell, CellRef},
|
cell::{Cell, CellRef},
|
||||||
common::{LeadErr, LeadErrCode, Literal},
|
common::{LeadErr, LeadErrCode, Literal},
|
||||||
evaluator::{Eval, evaluate},
|
evaluator::{Eval, evaluate, evaluate_literal},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct Grid {
|
pub struct Grid {
|
||||||
@@ -33,7 +33,7 @@ impl Grid {
|
|||||||
let mut updated_cells = vec![cell_ref];
|
let mut updated_cells = vec![cell_ref];
|
||||||
|
|
||||||
if raw_val.chars().nth(0) != Some('=') {
|
if raw_val.chars().nth(0) != Some('=') {
|
||||||
eval = Eval::Literal(Literal::String(raw_val.to_owned()));
|
eval = evaluate_literal(raw_val.to_owned());
|
||||||
} else {
|
} else {
|
||||||
// Evaluate raw expr and get precedents
|
// Evaluate raw expr and get precedents
|
||||||
let (res_eval, res_precs) = evaluate(raw_val[1..].to_owned(), Some(&self));
|
let (res_eval, res_precs) = evaluate(raw_val[1..].to_owned(), Some(&self));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use log::info;
|
|||||||
use crate::{
|
use crate::{
|
||||||
cell::CellRef,
|
cell::CellRef,
|
||||||
common::{LeadErr, LeadErrCode, Literal},
|
common::{LeadErr, LeadErrCode, Literal},
|
||||||
tokenizer::*,
|
tokenizer::{self, *},
|
||||||
};
|
};
|
||||||
use std::{collections::HashSet, fmt};
|
use std::{collections::HashSet, fmt};
|
||||||
|
|
||||||
@@ -99,7 +99,6 @@ impl fmt::Display for Expr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
impl Expr {
|
impl Expr {
|
||||||
pub fn pretty(&self) -> String {
|
pub fn pretty(&self) -> String {
|
||||||
// entry point for users — root printed without └──
|
// entry point for users — root printed without └──
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ impl Tokenizer {
|
|||||||
pub fn peek(&mut self) -> Token {
|
pub fn peek(&mut self) -> Token {
|
||||||
self.tokens.last().cloned().unwrap_or(Token::Eof)
|
self.tokens.last().cloned().unwrap_or(Token::Eof)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.tokens.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
--primary: oklch(0.623 0.214 259.815);
|
--primary: oklch(0.5928 0.1225 136.37);
|
||||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
--primary-foreground: oklch(0.982 0.018 155.826);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--border: oklch(0.92 0.004 286.32);
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--input: oklch(0.92 0.004 286.32);
|
||||||
--ring: oklch(0.623 0.214 259.815);
|
--ring: oklch(0.723 0.219 149.579);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
@@ -31,12 +31,12 @@
|
|||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
--sidebar-primary: oklch(0.723 0.219 149.579);
|
||||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
--sidebar-primary-foreground: oklch(0.982 0.018 155.826);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
--sidebar-ring: oklch(0.723 0.219 149.579);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.21 0.006 285.885);
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.546 0.245 262.881);
|
--primary: oklch(0.5928 0.1225 136.37);
|
||||||
--primary-foreground: oklch(0.379 0.146 265.522);
|
--primary-foreground: oklch(0.393 0.095 152.535);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.488 0.243 264.376);
|
--ring: oklch(0.527 0.154 150.069);
|
||||||
--chart-1: 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-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
@@ -65,14 +65,13 @@
|
|||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.21 0.006 285.885);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
--sidebar-primary: oklch(0.696 0.17 162.48);
|
||||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
--sidebar-primary-foreground: oklch(0.393 0.095 152.535);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
--sidebar-ring: oklch(0.527 0.154 150.069);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
width = '80px',
|
width = 80,
|
||||||
height = '30px',
|
height = 30,
|
||||||
setColWidth = () => {},
|
setColWidth = () => {},
|
||||||
setRowHeight = () => {},
|
setRowHeight = () => {},
|
||||||
val,
|
val,
|
||||||
@@ -11,10 +11,10 @@
|
|||||||
direction = 'col', // New prop: 'col' for right-side drag, 'row' for bottom-side
|
direction = 'col', // New prop: 'col' for right-side drag, 'row' for bottom-side
|
||||||
resizeable = true
|
resizeable = true
|
||||||
}: {
|
}: {
|
||||||
width?: string;
|
width?: number;
|
||||||
height?: string;
|
height?: number;
|
||||||
setColWidth?: (width: string) => void;
|
setColWidth?: (width: number) => void;
|
||||||
setRowHeight?: (height: string) => void;
|
setRowHeight?: (height: number) => void;
|
||||||
val: string;
|
val: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
resizeable?: boolean;
|
resizeable?: boolean;
|
||||||
@@ -39,11 +39,11 @@
|
|||||||
if (direction === 'col') {
|
if (direction === 'col') {
|
||||||
const dx = moveEvent.clientX - startX;
|
const dx = moveEvent.clientX - startX;
|
||||||
// Enforce a minimum width of 40px
|
// Enforce a minimum width of 40px
|
||||||
setColWidth(`${Math.max(40, startWidth + dx)}px`);
|
setColWidth(Math.max(40, startWidth + dx));
|
||||||
} else {
|
} else {
|
||||||
const dy = moveEvent.clientY - startY;
|
const dy = moveEvent.clientY - startY;
|
||||||
// Enforce a minimum height of 20px
|
// Enforce a minimum height of 20px
|
||||||
setRowHeight(`${Math.max(30, startHeight + dy)}px`);
|
setRowHeight(Math.max(30, startHeight + dy));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,14 +60,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style:width
|
style:width={width + 'px'}
|
||||||
style:height
|
style:height={height + 'px'}
|
||||||
class={clsx('placeholder group relative bg-background p-1', {
|
class={clsx(
|
||||||
active,
|
'placeholder group relative bg-background p-1',
|
||||||
|
{
|
||||||
col: direction === 'col',
|
col: direction === 'col',
|
||||||
row: direction === 'row',
|
row: direction === 'row',
|
||||||
blank: direction === 'blank'
|
blank: direction === 'blank'
|
||||||
})}
|
},
|
||||||
|
{ 'bg-primary/70! font-bold': active }
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span class="pointer-events-none flex h-full w-full items-center justify-center select-none">
|
<span class="pointer-events-none flex h-full w-full items-center justify-center select-none">
|
||||||
{val}
|
{val}
|
||||||
@@ -88,7 +91,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.placeholder {
|
.placeholder {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
border: 1px solid var(--input);
|
border: 1px solid var(--input);
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
}
|
}
|
||||||
@@ -107,12 +110,6 @@
|
|||||||
/* border-top: none; */
|
/* border-top: none; */
|
||||||
/* } */
|
/* } */
|
||||||
|
|
||||||
.active {
|
|
||||||
background-color: color-mix(in oklab, var(--color-primary) 80%, var(--color-background) 80%);
|
|
||||||
font-weight: bold;
|
|
||||||
/* border: 1px solid var(--color-primary); */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Resizer Styles --- */
|
/* --- Resizer Styles --- */
|
||||||
.resizer {
|
.resizer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -12,18 +12,18 @@
|
|||||||
grid
|
grid
|
||||||
}: {
|
}: {
|
||||||
cla?: string;
|
cla?: string;
|
||||||
width?: string;
|
width?: number;
|
||||||
height?: string;
|
height?: number;
|
||||||
grid: Grid;
|
grid: Grid;
|
||||||
pos: Position;
|
pos: Position;
|
||||||
onmousedown?: (e: MouseEvent) => void;
|
onmousedown?: (e: MouseEvent) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let cell = $derived(grid.getCell(pos));
|
let cell = $derived(grid.getCell(pos));
|
||||||
let active = $derived(grid.isActive(pos));
|
// let active = $derived(grid.isActive(pos));
|
||||||
let primaryactive = $derived(grid.isPrimaryActive(pos));
|
// let primaryactive = $derived(grid.isPrimaryActive(pos));
|
||||||
let editing = $derived(grid.isEditing(pos));
|
// let editing = $derived(grid.isEditing(pos));
|
||||||
let externalediting = $derived(grid.isExternalEditing(pos));
|
// let externalediting = $derived(grid.isExternalEditing(pos));
|
||||||
let width = $derived(grid.getColWidth(pos.col));
|
let width = $derived(grid.getColWidth(pos.col));
|
||||||
let height = $derived(grid.getRowHeight(pos.row));
|
let height = $derived(grid.getRowHeight(pos.row));
|
||||||
let showPreview = $derived(getPreview() !== '');
|
let showPreview = $derived(getPreview() !== '');
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if editing}
|
{#if false}
|
||||||
<div class="relative inline-block">
|
<div class="relative inline-block">
|
||||||
{#if showPreview}
|
{#if showPreview}
|
||||||
<h3
|
<h3
|
||||||
@@ -98,24 +98,16 @@
|
|||||||
|
|
||||||
{#snippet InnerCell()}
|
{#snippet InnerCell()}
|
||||||
<div
|
<div
|
||||||
ondblclick={() => grid.startEditing(pos)}
|
ondblclick={() => {}}
|
||||||
{onmousedown}
|
{onmousedown}
|
||||||
data-row={pos.row}
|
data-row={pos.row}
|
||||||
data-col={pos.col}
|
data-col={pos.col}
|
||||||
ondragstart={(e) => e.preventDefault()}
|
ondragstart={(e) => e.preventDefault()}
|
||||||
style:width
|
style:width={width + 'px'}
|
||||||
style:height
|
style:height={height + 'px'}
|
||||||
class={clsx(
|
class={clsx(
|
||||||
'placeholder bg-background p-1',
|
'placeholder bg-background p-1',
|
||||||
{
|
|
||||||
primaryactive,
|
|
||||||
active,
|
|
||||||
'active-top': grid.isActiveTop(pos),
|
|
||||||
'active-bottom': grid.isActiveBottom(pos),
|
|
||||||
'active-right': grid.isActiveRight(pos),
|
|
||||||
'active-left': grid.isActiveLeft(pos),
|
|
||||||
'only-active': grid.isActive(pos) && grid.isSingleActive()
|
|
||||||
},
|
|
||||||
cla
|
cla
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -125,13 +117,13 @@
|
|||||||
err: isErr(cell.eval)
|
err: isErr(cell.eval)
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{#if externalediting}
|
<!-- {#if externalediting} -->
|
||||||
{cell.temp_raw}
|
<!-- {cell.temp_raw} -->
|
||||||
{:else if cell.eval}
|
<!-- {:else if cell.eval} -->
|
||||||
{getEvalLiteral(cell.eval)}
|
<!-- {getEvalLiteral(cell.eval)} -->
|
||||||
{:else}
|
<!-- {:else} -->
|
||||||
{cell.raw}
|
<!-- {cell.raw} -->
|
||||||
{/if}
|
<!-- {/if} -->
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
174
frontend/src/lib/components/grid/grid-mode.svelte.ts
Normal file
174
frontend/src/lib/components/grid/grid-mode.svelte.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import type { Grid } from './grid.svelte';
|
||||||
|
import type { Eval } from './messages';
|
||||||
|
import { Position } from './position.svelte';
|
||||||
|
import { GridSelection } from './grid-selection.svelte';
|
||||||
|
import {
|
||||||
|
getVimId,
|
||||||
|
registerVimListener,
|
||||||
|
unregisterVimListener,
|
||||||
|
vimKeyboardHandler,
|
||||||
|
type VimCommand,
|
||||||
|
type VimListener
|
||||||
|
} from './vim';
|
||||||
|
|
||||||
|
export type GridModeName = 'normal' | 'insert' | 'visual';
|
||||||
|
|
||||||
|
export class GridMode {
|
||||||
|
#grid: Grid;
|
||||||
|
|
||||||
|
constructor(grid: Grid) {
|
||||||
|
this.#grid = grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for initialization that requires DOM access
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public destroy() {}
|
||||||
|
|
||||||
|
public name(): GridModeName {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSelection(): GridSelection | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get grid(): Grid {
|
||||||
|
return this.#grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NormalMode extends GridMode implements VimListener {
|
||||||
|
#selection: GridSelection = $state(new GridSelection());
|
||||||
|
#vimId: string | null = null;
|
||||||
|
|
||||||
|
constructor(grid: Grid, cellPos: Position) {
|
||||||
|
super(grid);
|
||||||
|
|
||||||
|
this.#selection = new GridSelection(cellPos, cellPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
window.addEventListener('keydown', vimKeyboardHandler);
|
||||||
|
this.#selection.init();
|
||||||
|
|
||||||
|
this.#vimId = getVimId();
|
||||||
|
registerVimListener(this.#vimId, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getSelection(): GridSelection | null {
|
||||||
|
return this.#selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
window.removeEventListener('keydown', vimKeyboardHandler);
|
||||||
|
this.#selection.destroy();
|
||||||
|
|
||||||
|
if (this.#vimId) unregisterVimListener(this.#vimId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onVimCommand(command: VimCommand): void {
|
||||||
|
const shiftMap: Record<string, any> = {
|
||||||
|
left: () => (this.#selection = this.#selection.shiftLeft(command.modifier)),
|
||||||
|
down: () => (this.#selection = this.#selection.shiftDown(command.modifier)),
|
||||||
|
up: () => (this.#selection = this.#selection.shiftUp(command.modifier)),
|
||||||
|
right: () => (this.#selection = this.#selection.shiftRight(command.modifier))
|
||||||
|
};
|
||||||
|
|
||||||
|
const shift = (key: string) => {
|
||||||
|
const fn = shiftMap[key];
|
||||||
|
if (fn) {
|
||||||
|
fn();
|
||||||
|
this.#selection.init();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (command.action === 'visual-mode') {
|
||||||
|
// Switch to visual mode
|
||||||
|
this.destroy();
|
||||||
|
this.grid.mode = new VisualMode(this.grid, this.#selection.primary);
|
||||||
|
this.grid.mode.init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command.motion) return;
|
||||||
|
shift(command.motion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InsertMode extends GridMode {
|
||||||
|
#cellPos: Position;
|
||||||
|
#preview: [Eval, boolean] | null = $state(null); // [Eval, dirty]
|
||||||
|
|
||||||
|
constructor(grid: Grid, cellPos: Position) {
|
||||||
|
super(grid);
|
||||||
|
this.#cellPos = cellPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
override name(): GridModeName {
|
||||||
|
return 'insert';
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VisualMode extends GridMode implements VimListener {
|
||||||
|
#selection: GridSelection = $state(new GridSelection(new Position(0, 0), new Position(0, 0)));
|
||||||
|
#vimId: string | null = null;
|
||||||
|
|
||||||
|
constructor(grid: Grid, cellPos: Position) {
|
||||||
|
super(grid);
|
||||||
|
this.#selection = new GridSelection(cellPos, cellPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
window.addEventListener('keydown', vimKeyboardHandler);
|
||||||
|
this.#selection.init();
|
||||||
|
|
||||||
|
this.#vimId = getVimId();
|
||||||
|
registerVimListener(this.#vimId, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
override name(): GridModeName {
|
||||||
|
return 'visual';
|
||||||
|
}
|
||||||
|
|
||||||
|
override getSelection(): GridSelection | null {
|
||||||
|
return this.#selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
window.removeEventListener('keydown', vimKeyboardHandler);
|
||||||
|
this.#selection.destroy();
|
||||||
|
|
||||||
|
if (this.#vimId) unregisterVimListener(this.#vimId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onVimCommand(command: VimCommand): void {
|
||||||
|
const expandMap: Record<string, any> = {
|
||||||
|
left: () => (this.#selection = this.#selection.expandLeft(command.modifier)),
|
||||||
|
down: () => (this.#selection = this.#selection.expandDown(command.modifier)),
|
||||||
|
up: () => (this.#selection = this.#selection.expandUp(command.modifier)),
|
||||||
|
right: () => (this.#selection = this.#selection.expandRight(command.modifier))
|
||||||
|
};
|
||||||
|
|
||||||
|
const expand = (key: string) => {
|
||||||
|
const fn = expandMap[key];
|
||||||
|
if (fn) {
|
||||||
|
fn();
|
||||||
|
this.#selection.init();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (command.action === 'escape' || command.action === 'visual-mode') {
|
||||||
|
// Switch to visual mode
|
||||||
|
this.destroy();
|
||||||
|
this.grid.mode = new NormalMode(this.grid, this.#selection.secondary);
|
||||||
|
this.grid.mode.init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command.motion) return;
|
||||||
|
expand(command.motion);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
frontend/src/lib/components/grid/grid-selection.svelte.ts
Normal file
226
frontend/src/lib/components/grid/grid-selection.svelte.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Position } from './position.svelte';
|
||||||
|
import type { VimModifier } from './vim';
|
||||||
|
|
||||||
|
export class GridSelection {
|
||||||
|
#p1: Position; // This is the position that initated the selection
|
||||||
|
#p2: Position; // This is the position that ended the selection
|
||||||
|
|
||||||
|
#tl: Position; // Top-left of box
|
||||||
|
#br: Position; // You can guess
|
||||||
|
|
||||||
|
#pxTop: number = $state(0); // The pixel position of the top-left corner
|
||||||
|
#pxLeft: number = $state(0);
|
||||||
|
#pxWidth: number = $state(0); // The pixel dimensions of the selection
|
||||||
|
#pxHeight: number = $state(0);
|
||||||
|
|
||||||
|
#primaryPxTop: number = $state(0); // The pixel position of the primary selection cell
|
||||||
|
#primaryPxLeft: number = $state(0);
|
||||||
|
#primaryPxWidth: number = $state(0);
|
||||||
|
#primaryPxHeight: number = $state(0);
|
||||||
|
|
||||||
|
#secondaryPxTop: number = $state(0); // The pixel position of the secondary selection cell
|
||||||
|
#secondaryPxLeft: number = $state(0);
|
||||||
|
#secondaryPxWidth: number = $state(0);
|
||||||
|
#secondaryPxHeight: number = $state(0);
|
||||||
|
|
||||||
|
#observer: MutationObserver | null = null;
|
||||||
|
|
||||||
|
#updatePxDimensions() {
|
||||||
|
const tlEl = document.querySelector(
|
||||||
|
`[data-row="${this.#tl.row}"][data-col="${this.#tl.col}"]`
|
||||||
|
) as HTMLElement;
|
||||||
|
const brEl = document.querySelector(
|
||||||
|
`[data-row="${this.#br.row}"][data-col="${this.#br.col}"]`
|
||||||
|
) as HTMLElement;
|
||||||
|
const p1El = document.querySelector(
|
||||||
|
`[data-row="${this.#p1.row}"][data-col="${this.#p1.col}"]`
|
||||||
|
) as HTMLElement;
|
||||||
|
const p2El = document.querySelector(
|
||||||
|
`[data-row="${this.#p2.row}"][data-col="${this.#p2.col}"]`
|
||||||
|
) as HTMLElement;
|
||||||
|
|
||||||
|
if (!tlEl || !brEl || !p1El || !p2El) return;
|
||||||
|
|
||||||
|
// Use offsetLeft/offsetTop relative to the scrolling container
|
||||||
|
// This assumes the cells are positioned relative to the grid-wrapper
|
||||||
|
this.#pxLeft = tlEl.offsetLeft;
|
||||||
|
this.#pxTop = tlEl.offsetTop;
|
||||||
|
this.#pxWidth = brEl.offsetLeft + brEl.offsetWidth - tlEl.offsetLeft;
|
||||||
|
this.#pxHeight = brEl.offsetTop + brEl.offsetHeight - tlEl.offsetTop;
|
||||||
|
|
||||||
|
this.#primaryPxLeft = p1El.offsetLeft;
|
||||||
|
this.#primaryPxTop = p1El.offsetTop;
|
||||||
|
this.#primaryPxWidth = p1El.offsetWidth;
|
||||||
|
this.#primaryPxHeight = p1El.offsetHeight;
|
||||||
|
|
||||||
|
this.#secondaryPxLeft = p2El.offsetLeft;
|
||||||
|
this.#secondaryPxTop = p2El.offsetTop;
|
||||||
|
this.#secondaryPxWidth = p2El.offsetWidth;
|
||||||
|
this.#secondaryPxHeight = p2El.offsetHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(p1: Position = new Position(), p2: Position = new Position()) {
|
||||||
|
this.#p1 = p1;
|
||||||
|
this.#p2 = p2;
|
||||||
|
|
||||||
|
this.#tl = new Position(
|
||||||
|
Math.min(this.#p1.row, this.#p2.row),
|
||||||
|
Math.min(this.#p1.col, this.#p2.col)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#br = new Position(
|
||||||
|
Math.max(this.#p1.row, this.#p2.row),
|
||||||
|
Math.max(this.#p1.col, this.#p2.col)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dom-related initialization
|
||||||
|
public init() {
|
||||||
|
this.#updatePxDimensions();
|
||||||
|
|
||||||
|
this.#observer = new MutationObserver(() => {
|
||||||
|
this.#updatePxDimensions();
|
||||||
|
});
|
||||||
|
// On dom change, update pixel dimensions
|
||||||
|
// We use a mutation observer to detect changes in the DOM that might affect the selection dimensions
|
||||||
|
|
||||||
|
this.#observer.observe(document.body, {
|
||||||
|
attributes: true,
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this.#observer?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get tl(): Position {
|
||||||
|
return this.#tl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get br(): Position {
|
||||||
|
return this.#br;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get primary(): Position {
|
||||||
|
return this.#p1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get secondary(): Position {
|
||||||
|
return this.#p2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pxTop(): number {
|
||||||
|
return this.#pxTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pxLeft(): number {
|
||||||
|
return this.#pxLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pxWidth(): number {
|
||||||
|
return this.#pxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get pxHeight(): number {
|
||||||
|
return this.#pxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get primaryPxTop(): number {
|
||||||
|
return this.#primaryPxTop;
|
||||||
|
}
|
||||||
|
public get primaryPxLeft(): number {
|
||||||
|
return this.#primaryPxLeft;
|
||||||
|
}
|
||||||
|
public get primaryPxWidth(): number {
|
||||||
|
return this.#primaryPxWidth;
|
||||||
|
}
|
||||||
|
public get primaryPxHeight(): number {
|
||||||
|
return this.#primaryPxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get secondaryPxTop(): number {
|
||||||
|
return this.#secondaryPxTop;
|
||||||
|
}
|
||||||
|
public get secondaryPxLeft(): number {
|
||||||
|
return this.#secondaryPxLeft;
|
||||||
|
}
|
||||||
|
public get secondaryPxWidth(): number {
|
||||||
|
return this.#secondaryPxWidth;
|
||||||
|
}
|
||||||
|
public get secondaryPxHeight(): number {
|
||||||
|
return this.#secondaryPxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(pos: Position) {
|
||||||
|
return (
|
||||||
|
pos.row >= this.#tl.row &&
|
||||||
|
pos.row <= this.#br.row &&
|
||||||
|
pos.col >= this.#tl.col &&
|
||||||
|
pos.col <= this.#br.col
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public containsRow(row: number) {
|
||||||
|
return row >= this.#tl.row && row <= this.#br.row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public containsCol(col: number) {
|
||||||
|
return col >= this.#tl.col && col <= this.#br.col;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSingleCell(): boolean {
|
||||||
|
return this.#tl.equals(this.#br);
|
||||||
|
}
|
||||||
|
|
||||||
|
public expandUp(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(
|
||||||
|
this.#p1,
|
||||||
|
new Position(Math.max(0, this.#p2.row - amount), this.#p2.col)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public expandDown(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(this.#p1, new Position(this.#p2.row + amount, this.#p2.col));
|
||||||
|
}
|
||||||
|
|
||||||
|
public expandLeft(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(
|
||||||
|
this.#p1,
|
||||||
|
new Position(this.#p2.row, Math.max(0, this.#p2.col - amount))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public expandRight(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(this.#p1, new Position(this.#p2.row, this.#p2.col + amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
public shiftUp(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(
|
||||||
|
new Position(Math.max(0, this.#p1.row - amount), this.#p1.col),
|
||||||
|
new Position(Math.max(0, this.#p2.row - amount), this.#p2.col)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public shiftDown(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(
|
||||||
|
new Position(this.#p1.row + amount, this.#p1.col),
|
||||||
|
new Position(this.#p2.row + amount, this.#p2.col)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public shiftLeft(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(
|
||||||
|
new Position(this.#p1.row, Math.max(0, this.#p1.col - amount)),
|
||||||
|
new Position(this.#p2.row, Math.max(0, this.#p2.col - amount))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public shiftRight(amount: VimModifier = 1) {
|
||||||
|
return new GridSelection(
|
||||||
|
new Position(this.#p1.row, this.#p1.col + amount),
|
||||||
|
new Position(this.#p2.row, this.#p2.col + amount)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { EllipsisVertical, Omega } from '@lucide/svelte';
|
import { EllipsisVertical } from '@lucide/svelte';
|
||||||
import * as Alert from '$lib/components/ui/alert/index.js';
|
import * as Alert from '$lib/components/ui/alert/index.js';
|
||||||
import Cell from '$lib/components/grid/cell.svelte';
|
import Cell from '$lib/components/grid/cell.svelte';
|
||||||
import CellHeader from './cell-header.svelte';
|
import CellHeader from './cell-header.svelte';
|
||||||
import { colToStr, refToStr } from './utils';
|
import { colToStr, refToStr } from './utils';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Input } from '../ui/input';
|
|
||||||
import type { LeadMsg } from './messages';
|
import type { LeadMsg } from './messages';
|
||||||
import { Grid, Position } from './grid.svelte.ts';
|
import { Grid, Position } from './grid.svelte.ts';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
@@ -18,266 +17,135 @@
|
|||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
socket.onmessage = (msg: MessageEvent) => {
|
const grid = $state(new Grid(socket));
|
||||||
let res: LeadMsg;
|
|
||||||
|
|
||||||
|
socket.onmessage = (msg: MessageEvent) => {
|
||||||
try {
|
try {
|
||||||
res = JSON.parse(msg.data);
|
const res: LeadMsg = JSON.parse(msg.data);
|
||||||
console.log(res);
|
grid.handle_msg(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse LeadMsg:', err);
|
console.error('Failed to parse LeadMsg:', err);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.handle_msg(res);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const grid = $state(new Grid(socket));
|
onMount(() => {
|
||||||
|
grid.init();
|
||||||
|
});
|
||||||
|
|
||||||
let rows = 100;
|
let rows = 100;
|
||||||
let cols = 50;
|
let cols = 50;
|
||||||
// --- module-level state ------------------------------------------------------
|
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
|
|
||||||
// range picking while editing a formula:
|
const selectionBox = $derived(grid.mode.getSelection());
|
||||||
|
|
||||||
|
// Formula state
|
||||||
let selectingRangeForFormula = false;
|
let selectingRangeForFormula = false;
|
||||||
let anchorRow = -1;
|
let anchorRow = -1,
|
||||||
let anchorCol = -1;
|
anchorCol = -1,
|
||||||
let hoverRow = -1;
|
hoverRow = -1,
|
||||||
let hoverCol = -1;
|
hoverCol = -1;
|
||||||
let formulaCaretStart: number | null = null;
|
let formulaCaretStart: number | null = null,
|
||||||
let formulaCaretEnd: number | null = null;
|
formulaCaretEnd: number | null = null;
|
||||||
|
|
||||||
// helper: A1 or A1:B9 using your existing refToStr()
|
|
||||||
function rangeRef(r1: number, c1: number, r2: number, c2: number) {
|
function rangeRef(r1: number, c1: number, r2: number, c2: number) {
|
||||||
const rs = Math.min(r1, r2);
|
const rs = Math.min(r1, r2),
|
||||||
const re = Math.max(r1, r2);
|
re = Math.max(r1, r2);
|
||||||
const cs = Math.min(c1, c2);
|
const cs = Math.min(c1, c2),
|
||||||
const ce = Math.max(c1, c2);
|
ce = Math.max(c1, c2);
|
||||||
|
const a = refToStr(rs, cs),
|
||||||
const a = refToStr(rs, cs);
|
b = refToStr(re, ce);
|
||||||
const b = refToStr(re, ce);
|
|
||||||
return rs === re && cs === ce ? a : `${a}:${b}`;
|
return rs === re && cs === ce ? a : `${a}:${b}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- your existing handler, modified ----------------------------------------
|
|
||||||
function handleCellMouseDown(i: number, j: number, e: MouseEvent) {
|
function handleCellMouseDown(i: number, j: number, e: MouseEvent) {
|
||||||
const pos = new Position(i, j);
|
const input = document.querySelector<HTMLInputElement>('input:focus');
|
||||||
|
if (input?.value.trim().startsWith('=')) {
|
||||||
if (grid.anyIsEditing()) {
|
|
||||||
const el = document.querySelector<HTMLInputElement>('input:focus');
|
|
||||||
const currentInputValue = el?.value ?? '';
|
|
||||||
|
|
||||||
// Only treat as reference insert if we're editing a formula
|
|
||||||
if (currentInputValue.trim().startsWith('=')) {
|
|
||||||
// Keep focus in the input
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Enter "select range for formula" mode, but DO NOT insert yet.
|
|
||||||
selectingRangeForFormula = true;
|
selectingRangeForFormula = true;
|
||||||
dragging = true;
|
dragging = true;
|
||||||
|
anchorRow = hoverRow = i;
|
||||||
anchorRow = i;
|
anchorCol = hoverCol = j;
|
||||||
anchorCol = j;
|
formulaCaretStart = input.selectionStart ?? input.value.length;
|
||||||
hoverRow = i;
|
formulaCaretEnd = input.selectionEnd ?? input.value.length;
|
||||||
hoverCol = j;
|
|
||||||
|
|
||||||
// remember the caret where we'll insert the reference on mouseup
|
|
||||||
if (el) {
|
|
||||||
formulaCaretStart = el.selectionStart ?? el.value.length;
|
|
||||||
formulaCaretEnd = el.selectionEnd ?? el.value.length;
|
|
||||||
el.focus();
|
|
||||||
} else {
|
|
||||||
formulaCaretStart = formulaCaretEnd = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// visually highlight the starting cell
|
|
||||||
grid.setActive(new Position(anchorRow, anchorCol), new Position(anchorRow, anchorCol));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not a formula; exit editing before doing a normal selection
|
|
||||||
grid.stopAnyEditing();
|
grid.stopAnyEditing();
|
||||||
}
|
|
||||||
|
|
||||||
// Normal (non-formula) selection behavior
|
|
||||||
grid.setActive(pos, pos);
|
|
||||||
dragging = true;
|
dragging = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
// If click is outside the grid, cancel editing
|
|
||||||
if (!(e.target as HTMLElement).closest('.grid-wrapper')) {
|
|
||||||
grid.stopEditingActive();
|
|
||||||
|
|
||||||
// also reset any in-progress formula selection
|
|
||||||
selectingRangeForFormula = false;
|
|
||||||
dragging = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|
||||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
if (el && el instanceof HTMLElement && el.dataset.row && el.dataset.col) {
|
if (el instanceof HTMLElement && el.dataset.row && el.dataset.col) {
|
||||||
const row = parseInt(el.dataset.row, 10);
|
hoverRow = parseInt(el.dataset.row, 10);
|
||||||
const col = parseInt(el.dataset.col, 10);
|
hoverCol = parseInt(el.dataset.col, 10);
|
||||||
|
|
||||||
hoverRow = row;
|
|
||||||
hoverCol = col;
|
|
||||||
|
|
||||||
if (selectingRangeForFormula) {
|
|
||||||
// while dragging a formula range, keep the grid selection in sync
|
|
||||||
grid.setActive(new Position(anchorRow, anchorCol), new Position(row, col));
|
|
||||||
} else {
|
|
||||||
// normal drag-select
|
|
||||||
grid.setActive(grid.primary_active, new Position(row, col));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
// Commit the range to the formula input iff we were range-picking
|
|
||||||
if (selectingRangeForFormula) {
|
if (selectingRangeForFormula) {
|
||||||
const input = document.querySelector<HTMLInputElement>('input:focus');
|
const input = document.querySelector<HTMLInputElement>('input:focus');
|
||||||
|
|
||||||
// Fallbacks in case caret wasn't captured (shouldn't happen if input stayed focused)
|
|
||||||
const start = formulaCaretStart ?? input?.value.length ?? 0;
|
|
||||||
const end = formulaCaretEnd ?? start;
|
|
||||||
|
|
||||||
const ref = rangeRef(anchorRow, anchorCol, hoverRow, hoverCol);
|
|
||||||
|
|
||||||
if (input) {
|
if (input) {
|
||||||
const before = input.value.slice(0, start);
|
const ref = rangeRef(anchorRow, anchorCol, hoverRow, hoverCol);
|
||||||
const after = input.value.slice(end);
|
const start = formulaCaretStart ?? 0,
|
||||||
input.value = before + ref + after;
|
end = formulaCaretEnd ?? start;
|
||||||
|
input.value = input.value.slice(0, start) + ref + input.value.slice(end);
|
||||||
const newPos = start + ref.length;
|
|
||||||
input.setSelectionRange(newPos, newPos);
|
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
input.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset formula-range state but keep the user in edit mode
|
|
||||||
selectingRangeForFormula = false;
|
selectingRangeForFormula = false;
|
||||||
grid.clearActive();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dragging = false;
|
dragging = false;
|
||||||
|
|
||||||
// clear transient state
|
|
||||||
anchorRow = anchorCol = hoverRow = hoverCol = -1;
|
|
||||||
formulaCaretStart = formulaCaretEnd = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('click', handler);
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
return () => {
|
||||||
onDestroy(() => {
|
|
||||||
window.removeEventListener('click', handler);
|
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative mb-5 ml-5 flex items-center gap-[5px]">
|
<div class="relative mb-5 ml-5 flex items-center gap-[5px]">
|
||||||
<Alert.Root
|
<Alert.Root class="h-9 w-fit min-w-[80px] border bg-input/30 px-2 text-xs shadow-xs"></Alert.Root>
|
||||||
class={clsx(
|
|
||||||
'flex h-9 w-fit min-w-[80px] rounded-md border border-input bg-transparent px-2 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
|
|
||||||
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
||||||
'flex items-center justify-center aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{grid.getActiveRangeStr()}
|
|
||||||
</Alert.Root>
|
|
||||||
|
|
||||||
<EllipsisVertical class="text-muted-foreground" size="20px" />
|
<EllipsisVertical class="text-muted-foreground" size="20px" />
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
onkeydown={(e: KeyboardEvent) => {
|
|
||||||
const target = e.currentTarget as HTMLElement;
|
|
||||||
const input = target.querySelector('input') as HTMLInputElement | null;
|
|
||||||
|
|
||||||
if (e.key === 'Enter' || e.key === 'NumpadEnter') {
|
|
||||||
e.preventDefault(); // avoid form submit/line break
|
|
||||||
|
|
||||||
grid.stopExternalEdit(grid.getActivePos());
|
|
||||||
grid.setCell(grid.getActivePos());
|
|
||||||
input?.blur();
|
|
||||||
} else if (e.key == 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
grid.stopExternalEdit(grid.getActivePos());
|
|
||||||
grid.resetCellTemp(grid.getActivePos());
|
|
||||||
input?.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Omega
|
|
||||||
size="20px"
|
|
||||||
class="absolute top-1/2 left-2 z-10 -translate-y-1/2 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
disabled={grid.getActivePos() === null}
|
|
||||||
onmousedown={() => grid.startExternalEdit(grid.getActivePos())}
|
|
||||||
onblur={() => grid.stopExternalEdit(grid.getActivePos())}
|
|
||||||
bind:value={
|
|
||||||
() => grid.getActiveCell()?.temp_raw ?? '',
|
|
||||||
(v) => {
|
|
||||||
grid.setCellTemp(grid.getActivePos(), v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class="relative w-fit min-w-[300px] pl-9"
|
|
||||||
></Input>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class={clsx('grid-wrapper relative h-full overflow-auto text-xs outline-none', className)}>
|
||||||
class={clsx(' grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)}
|
<div class="relative w-fit min-w-full">
|
||||||
>
|
<div class="sticky top-0 flex w-fit" style="z-index: 100;">
|
||||||
<div class="sticky top-0 flex w-fit" style="z-index: {rows + 70}">
|
<div class="sticky left-0 z-[101]">
|
||||||
<div class="sticky top-0 left-0" style="z-index: {rows + 70}">
|
|
||||||
<CellHeader
|
<CellHeader
|
||||||
resizeable={false}
|
direction="blank"
|
||||||
height={grid.getDefaultRowHeight()}
|
height={grid.getDefaultRowHeight()}
|
||||||
width={grid.getDefaultColWidth()}
|
width={grid.getDefaultColWidth()}
|
||||||
val=""
|
val=""
|
||||||
active={false}
|
active={false}
|
||||||
direction="blank"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each Array(cols) as _, j}
|
{#each Array(cols) as _, j}
|
||||||
<CellHeader
|
<CellHeader
|
||||||
|
direction="col"
|
||||||
height={grid.getDefaultRowHeight()}
|
height={grid.getDefaultRowHeight()}
|
||||||
width={grid.getColWidth(j)}
|
width={grid.getColWidth(j)}
|
||||||
setColWidth={(width) => grid.setColWidth(j, width)}
|
setColWidth={(w) => grid.setColWidth(j, w)}
|
||||||
direction="col"
|
|
||||||
val={colToStr(j)}
|
val={colToStr(j)}
|
||||||
active={grid.primary_active !== null &&
|
active={grid.mode?.getSelection()?.containsCol(j) ?? false}
|
||||||
grid.secondary_active !== null &&
|
|
||||||
j >= Math.min(grid.primary_active.col, grid.secondary_active.col) &&
|
|
||||||
j <= Math.max(grid.primary_active.col, grid.secondary_active.col)}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each Array(rows) as _, i}
|
{#each Array(rows) as _, i}
|
||||||
<div class="relative flex w-fit">
|
<div class="flex w-fit">
|
||||||
<div class="sticky left-0 flex w-fit" style="z-index: {rows - i + 40}">
|
<div class="sticky left-0 z-50 flex w-fit">
|
||||||
<CellHeader
|
<CellHeader
|
||||||
direction="row"
|
direction="row"
|
||||||
height={grid.getRowHeight(i)}
|
height={grid.getRowHeight(i)}
|
||||||
width={grid.getDefaultColWidth()}
|
width={grid.getDefaultColWidth()}
|
||||||
setRowHeight={(height) => grid.setRowHeight(i, height)}
|
setRowHeight={(h) => grid.setRowHeight(i, h)}
|
||||||
val={(i + 1).toString()}
|
val={(i + 1).toString()}
|
||||||
active={grid.primary_active !== null &&
|
active={grid.mode?.getSelection()?.containsRow(i) ?? false}
|
||||||
grid.secondary_active !== null &&
|
|
||||||
i >= Math.min(grid.primary_active.row, grid.secondary_active.row) &&
|
|
||||||
i <= Math.max(grid.primary_active.row, grid.secondary_active.row)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#each Array(cols) as _, j}
|
{#each Array(cols) as _, j}
|
||||||
@@ -286,11 +154,40 @@
|
|||||||
pos={new Position(i, j)}
|
pos={new Position(i, j)}
|
||||||
height={grid.getRowHeight(i)}
|
height={grid.getRowHeight(i)}
|
||||||
width={grid.getColWidth(j)}
|
width={grid.getColWidth(j)}
|
||||||
onmousedown={(e) => {
|
onmousedown={(e) => handleCellMouseDown(i, j, e)}
|
||||||
handleCellMouseDown(i, j, e);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if selectionBox}
|
||||||
|
{@const isSingle = selectionBox.isSingleCell()}
|
||||||
|
<div class="pointer-events-none absolute inset-0 z-40">
|
||||||
|
<div
|
||||||
|
class="absolute border-2 border-primary"
|
||||||
|
style:top="{selectionBox.primaryPxTop}px"
|
||||||
|
style:left="{selectionBox.primaryPxLeft}px"
|
||||||
|
style:width="{selectionBox.primaryPxWidth}px"
|
||||||
|
style:height="{selectionBox.primaryPxHeight}px"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{#if !isSingle}
|
||||||
|
<div
|
||||||
|
class="absolute border-2 border-dashed border-primary"
|
||||||
|
style:top="{selectionBox.secondaryPxTop}px"
|
||||||
|
style:left="{selectionBox.secondaryPxLeft}px"
|
||||||
|
style:width="{selectionBox.secondaryPxWidth}px"
|
||||||
|
style:height="{selectionBox.secondaryPxHeight}px"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute bg-primary/20"
|
||||||
|
style:top="{selectionBox.pxTop}px"
|
||||||
|
style:left="{selectionBox.pxLeft}px"
|
||||||
|
style:width="{selectionBox.pxWidth}px"
|
||||||
|
style:height="{selectionBox.pxHeight}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,71 +1,38 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import type { CellRef, CellT, Eval, LeadMsg } from './messages';
|
import type { CellT, Eval, LeadMsg } from './messages';
|
||||||
import { refToStr } from './utils';
|
import type { GridData, GridDefaults } from './types';
|
||||||
|
import { NormalMode, type GridMode } from './grid-mode.svelte';
|
||||||
class Position {
|
import { Position } from './position.svelte';
|
||||||
public row: number;
|
|
||||||
public col: number;
|
|
||||||
|
|
||||||
constructor(row: number, col: number) {
|
|
||||||
this.row = row;
|
|
||||||
this.col = col;
|
|
||||||
}
|
|
||||||
|
|
||||||
public key() {
|
|
||||||
return `${this.row}:${this.col}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static key(row: number, col: number) {
|
|
||||||
return `${row}:${col}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ref(): CellRef {
|
|
||||||
return { row: this.row, col: this.col };
|
|
||||||
}
|
|
||||||
|
|
||||||
public str(): string {
|
|
||||||
return refToStr(this.row, this.col);
|
|
||||||
}
|
|
||||||
|
|
||||||
public equals(other: CellRef | null | undefined): boolean {
|
|
||||||
return !!other && this.row === other.row && this.col === other.col;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static equals(a: CellRef | null | undefined, b: CellRef | null | undefined): boolean {
|
|
||||||
return !!a && !!b && a.row === b.row && a.col === b.col;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Grid {
|
class Grid {
|
||||||
data: Record<string, CellT> = $state({});
|
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
row_heights: Record<number, string> = $state({});
|
|
||||||
col_widths: Record<number, string> = $state({});
|
|
||||||
default_row_height: string;
|
|
||||||
default_col_width: string;
|
|
||||||
primary_active: Position | null = $state(null);
|
|
||||||
secondary_active: Position | null = $state(null);
|
|
||||||
editing_cell: Position | null = $state(null);
|
|
||||||
external_editing_cell: Position | null = $state(null);
|
|
||||||
editing_preview: [Eval, boolean] | null = $state(null); // [Eval, dirty]
|
|
||||||
|
|
||||||
constructor(socket: WebSocket, default_col_width = '80px', default_row_height = '30px') {
|
mode: GridMode = $state(new NormalMode(this, new Position(0, 0)));
|
||||||
|
|
||||||
|
defaults: GridDefaults;
|
||||||
|
|
||||||
|
data: GridData = $state({ cells: {}, row_heights: {}, col_widths: {} });
|
||||||
|
|
||||||
|
constructor(socket: WebSocket, defaults: GridDefaults = { row_height: 20, col_width: 80 }) {
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
this.default_col_width = default_col_width;
|
this.defaults = defaults;
|
||||||
this.default_row_height = default_row_height;
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.mode.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCell(pos: Position): CellT | undefined {
|
public getCell(pos: Position): CellT | undefined {
|
||||||
return this.data[pos.key()];
|
return this.data.cells[pos.key()];
|
||||||
}
|
}
|
||||||
|
|
||||||
public setCell(pos: Position | null | undefined) {
|
public setCell(pos: Position | null | undefined) {
|
||||||
if (pos === null || pos === undefined) return;
|
if (pos === null || pos === undefined) return;
|
||||||
let cell = this.data[pos.key()];
|
let cell = this.data.cells[pos.key()];
|
||||||
if (cell === undefined) return;
|
if (cell === undefined) return;
|
||||||
|
|
||||||
if (cell.temp_raw === '') {
|
if (cell.temp_raw === '') {
|
||||||
delete this.data[pos.key()];
|
delete this.data.cells[pos.key()];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +51,9 @@ class Grid {
|
|||||||
public setCellTemp(pos: Position | null, raw: string | undefined) {
|
public setCellTemp(pos: Position | null, raw: string | undefined) {
|
||||||
if (pos === null || raw === undefined) return;
|
if (pos === null || raw === undefined) return;
|
||||||
|
|
||||||
let x = this.data[pos.key()];
|
let x = this.data.cells[pos.key()];
|
||||||
|
|
||||||
this.data[pos.key()] = {
|
this.data.cells[pos.key()] = {
|
||||||
raw: x?.raw ?? '',
|
raw: x?.raw ?? '',
|
||||||
temp_raw: raw,
|
temp_raw: raw,
|
||||||
pos: pos,
|
pos: pos,
|
||||||
@@ -100,9 +67,9 @@ class Grid {
|
|||||||
public resetCellTemp(pos: Position | null | undefined) {
|
public resetCellTemp(pos: Position | null | undefined) {
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
|
|
||||||
let x = this.data[pos.key()];
|
let x = this.data.cells[pos.key()];
|
||||||
|
|
||||||
this.data[pos.key()] = {
|
this.data.cells[pos.key()] = {
|
||||||
raw: x?.raw ?? '',
|
raw: x?.raw ?? '',
|
||||||
pos: pos,
|
pos: pos,
|
||||||
temp_raw: x?.raw ?? '',
|
temp_raw: x?.raw ?? '',
|
||||||
@@ -111,209 +78,225 @@ class Grid {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearActive(){
|
// public clearActive() {
|
||||||
this.setActive(null,null);
|
// this.setActive(null, null);
|
||||||
}
|
// }
|
||||||
|
|
||||||
public getRowHeight(row: number) {
|
public getRowHeight(row: number) {
|
||||||
return this.row_heights[row] ?? this.default_row_height;
|
return this.data.row_heights[row] ?? this.defaults.row_height;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getColWidth(col: number) {
|
public getColWidth(col: number) {
|
||||||
return this.col_widths[col] ?? this.default_col_width;
|
return this.data.col_widths[col] ?? this.defaults.col_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setColWidth(col: number, width: number) {
|
||||||
|
if (width === this.defaults.col_width) {
|
||||||
|
delete this.data.col_widths[col];
|
||||||
|
} else {
|
||||||
|
this.data.col_widths[col] = width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRowHeight(row: number, height: number) {
|
||||||
|
if (height === this.defaults.row_height) {
|
||||||
|
delete this.data.row_heights[row];
|
||||||
|
} else {
|
||||||
|
this.data.row_heights[row] = height;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDefaultColWidth() {
|
public getDefaultColWidth() {
|
||||||
return this.default_col_width;
|
return this.defaults.col_width;
|
||||||
}
|
}
|
||||||
public getDefaultRowHeight() {
|
public getDefaultRowHeight() {
|
||||||
return this.default_row_height;
|
return this.defaults.row_height;
|
||||||
}
|
}
|
||||||
|
|
||||||
public isActiveTop(pos: Position): boolean {
|
// public isActiveTop(pos: Position): boolean {
|
||||||
const tl = this.getActiveTopLeft();
|
// const tl = this.getActiveTopLeft();
|
||||||
if (!tl) return false;
|
// if (!tl) return false;
|
||||||
return this.isActive(pos) && pos.row === tl.row;
|
// return this.isActive(pos) && pos.row === tl.row;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public isActiveBottom(pos: Position): boolean {
|
// public isActiveBottom(pos: Position): boolean {
|
||||||
const br = this.getActiveBottomRight();
|
// const br = this.getActiveBottomRight();
|
||||||
if (!br) return false;
|
// if (!br) return false;
|
||||||
return this.isActive(pos) && pos.row === br.row;
|
// return this.isActive(pos) && pos.row === br.row;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public isActiveLeft(pos: Position): boolean {
|
// public isActiveLeft(pos: Position): boolean {
|
||||||
const tl = this.getActiveTopLeft();
|
// const tl = this.getActiveTopLeft();
|
||||||
if (!tl) return false;
|
// if (!tl) return false;
|
||||||
return this.isActive(pos) && pos.col === tl.col;
|
// return this.isActive(pos) && pos.col === tl.col;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public isActiveRight(pos: Position): boolean {
|
// public isActiveRight(pos: Position): boolean {
|
||||||
const br = this.getActiveBottomRight();
|
// const br = this.getActiveBottomRight();
|
||||||
if (!br) return false;
|
// if (!br) return false;
|
||||||
return this.isActive(pos) && pos.col === br.col;
|
// return this.isActive(pos) && pos.col === br.col;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public setRowHeight(row: number, height: string) {
|
// public setRowHeight(row: number, height: string) {
|
||||||
if (height === this.default_row_height) {
|
// if (height === this.defaults.row_height) {
|
||||||
delete this.row_heights[row];
|
// delete this.data.row_heights[row];
|
||||||
} else {
|
// } else {
|
||||||
this.row_heights[row] = height;
|
// this.data.row_heights[row] = height;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public setColWidth(col: number, width: string) {
|
// public setColWidth(col: number, width: string) {
|
||||||
if (width === this.default_col_width) {
|
// if (width === this.defaults.col_width) {
|
||||||
delete this.col_widths[col];
|
// delete this.data.col_widths[col];
|
||||||
} else {
|
// } else {
|
||||||
this.col_widths[col] = width;
|
// this.data.col_widths[col] = width;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public startEditing(pos: Position | undefined) {
|
// public startEditing(pos: Position | undefined) {
|
||||||
if (!pos) return;
|
// if (!pos) return;
|
||||||
|
//
|
||||||
this.setActive(pos, pos);
|
// this.setActive(pos, pos);
|
||||||
this.editing_cell = pos;
|
// // this.editing_cell = pos;
|
||||||
|
//
|
||||||
let cell = this.getCell(pos);
|
// let cell = this.getCell(pos);
|
||||||
if (!cell) return;
|
// if (!cell) return;
|
||||||
}
|
// }
|
||||||
|
|
||||||
public stopEditing(pos: Position | null | undefined) {
|
public stopEditing(pos: Position | null | undefined) {
|
||||||
if (!pos) return;
|
if (!pos) return;
|
||||||
this.editing_cell = null;
|
// this.editing_cell = null;
|
||||||
// this.setCell(pos);
|
// this.setCell(pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
public stopAnyEditing() {
|
public stopAnyEditing() {
|
||||||
this.editing_cell = null;
|
// this.editing_cell = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public stopEditingActive() {
|
// public stopEditingActive() {
|
||||||
if (!this.anyIsActive() || !this.primary_active?.equals(this.secondary_active)) return;
|
// if (!this.anyIsActive() || !this.primary_active?.equals(this.secondary_active)) return;
|
||||||
this.stopEditing(this.primary_active);
|
// this.stopEditing(this.primary_active);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
|
// public isEditing(pos: Position): boolean {
|
||||||
|
// if (this.editing_cell === null) return false;
|
||||||
|
// return this.editing_cell.equals(pos);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public anyIsEditing(): boolean {
|
||||||
|
// return this.editing_cell !== null;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public isExternalEditing(pos: Position): boolean {
|
||||||
|
// if (this.external_editing_cell === null) return false;
|
||||||
|
// return this.external_editing_cell.equals(pos);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public setActive(primary: Position | null, secondary: Position | null) {
|
||||||
|
// this.primary_active = primary;
|
||||||
|
// this.secondary_active = secondary;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public setInactive() {
|
||||||
|
// this.primary_active = null;
|
||||||
|
// this.secondary_active = null;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public startExternalEdit(pos: Position | null) {
|
||||||
|
// this.external_editing_cell = pos;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public stopExternalEdit(pos: Position | null) {
|
||||||
|
// this.external_editing_cell = null;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public getActiveCell(): CellT | undefined {
|
||||||
|
// if (this.primary_active === null || this.secondary_active === null) {
|
||||||
|
// return {
|
||||||
|
// raw: '',
|
||||||
|
// temp_raw: '',
|
||||||
|
// pos: new Position(-1, -1),
|
||||||
|
// eval: undefined
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (!this.primary_active.equals(this.secondary_active)) {
|
||||||
|
// return {
|
||||||
|
// raw: '',
|
||||||
|
// temp_raw: '',
|
||||||
|
// pos: new Position(-1, -1),
|
||||||
|
// eval: undefined
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return this.getCell(this.primary_active);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public getActiveRangeStr(): string {
|
||||||
|
// const tl = this.getActiveTopLeft();
|
||||||
|
// const br = this.getActiveBottomRight();
|
||||||
|
//
|
||||||
|
// if (tl === null || br === null) return '';
|
||||||
|
//
|
||||||
|
// // Single-cell selection
|
||||||
|
// if (tl.equals(br)) return tl.str();
|
||||||
|
//
|
||||||
|
// // Range selection
|
||||||
|
// return `${tl.str()}:${br.str()}`;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// public getActivePos(): Position | null {
|
||||||
|
// if (
|
||||||
|
// this.primary_active === null ||
|
||||||
|
// this.secondary_active === null ||
|
||||||
|
// !this.primary_active.equals(this.secondary_active)
|
||||||
|
// ) {
|
||||||
|
// return null;
|
||||||
|
// }
|
||||||
|
// return this.primary_active;
|
||||||
|
// }
|
||||||
|
|
||||||
public isEditing(pos: Position): boolean {
|
// public isActive(pos: Position): boolean {
|
||||||
if (this.editing_cell === null) return false;
|
// if (this.primary_active === null || this.secondary_active === null) return false;
|
||||||
return this.editing_cell.equals(pos);
|
//
|
||||||
}
|
// return (
|
||||||
|
// pos.row >= Math.min(this.primary_active.row, this.secondary_active.row) &&
|
||||||
|
// pos.row <= Math.max(this.primary_active.row, this.secondary_active.row) &&
|
||||||
|
// pos.col >= Math.min(this.primary_active.col, this.secondary_active.col) &&
|
||||||
|
// pos.col <= Math.max(this.primary_active.col, this.secondary_active.col)
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
public anyIsEditing(): boolean {
|
// public getActiveTopLeft(): Position | null {
|
||||||
return this.editing_cell !== null;
|
// if (this.primary_active === null || this.secondary_active === null) return null;
|
||||||
}
|
//
|
||||||
|
// return new Position(
|
||||||
|
// Math.min(this.primary_active.row, this.secondary_active.row),
|
||||||
|
// Math.min(this.primary_active.col, this.secondary_active.col)
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
public isExternalEditing(pos: Position): boolean {
|
// public getActiveBottomRight(): Position | null {
|
||||||
if (this.external_editing_cell === null) return false;
|
// if (this.primary_active === null || this.secondary_active === null) return null;
|
||||||
return this.external_editing_cell.equals(pos);
|
//
|
||||||
}
|
// return new Position(
|
||||||
|
// Math.max(this.primary_active.row, this.secondary_active.row),
|
||||||
|
// Math.max(this.primary_active.col, this.secondary_active.col)
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
public setActive(primary: Position | null, secondary: Position | null) {
|
// public isPrimaryActive(pos: Position): boolean {
|
||||||
this.primary_active = primary;
|
// if (this.primary_active === null) return false;
|
||||||
this.secondary_active = secondary;
|
// return this.primary_active.equals(pos);
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
public setInactive() {
|
// public isSingleActive(): boolean {
|
||||||
this.primary_active = null;
|
// return this.getActivePos() !== null;
|
||||||
this.secondary_active = null;
|
// }
|
||||||
}
|
//
|
||||||
|
// public anyIsActive(): boolean {
|
||||||
public startExternalEdit(pos: Position | null) {
|
// return this.primary_active !== null && this.secondary_active !== null;
|
||||||
this.external_editing_cell = pos;
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
public stopExternalEdit(pos: Position | null) {
|
|
||||||
this.external_editing_cell = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getActiveCell(): CellT | undefined {
|
|
||||||
if (this.primary_active === null || this.secondary_active === null) {
|
|
||||||
return {
|
|
||||||
raw: '',
|
|
||||||
temp_raw: '',
|
|
||||||
pos: new Position(-1, -1),
|
|
||||||
eval: undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.primary_active.equals(this.secondary_active)) {
|
|
||||||
return {
|
|
||||||
raw: '',
|
|
||||||
temp_raw: '',
|
|
||||||
pos: new Position(-1, -1),
|
|
||||||
eval: undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getCell(this.primary_active);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getActiveRangeStr(): string {
|
|
||||||
const tl = this.getActiveTopLeft();
|
|
||||||
const br = this.getActiveBottomRight();
|
|
||||||
|
|
||||||
if (tl === null || br === null) return '';
|
|
||||||
|
|
||||||
// Single-cell selection
|
|
||||||
if (tl.equals(br)) return tl.str();
|
|
||||||
|
|
||||||
// Range selection
|
|
||||||
return `${tl.str()}:${br.str()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getActivePos(): Position | null {
|
|
||||||
if (
|
|
||||||
this.primary_active === null ||
|
|
||||||
this.secondary_active === null ||
|
|
||||||
!this.primary_active.equals(this.secondary_active)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.primary_active;
|
|
||||||
}
|
|
||||||
|
|
||||||
public isActive(pos: Position): boolean {
|
|
||||||
if (this.primary_active === null || this.secondary_active === null) return false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
pos.row >= Math.min(this.primary_active.row, this.secondary_active.row) &&
|
|
||||||
pos.row <= Math.max(this.primary_active.row, this.secondary_active.row) &&
|
|
||||||
pos.col >= Math.min(this.primary_active.col, this.secondary_active.col) &&
|
|
||||||
pos.col <= Math.max(this.primary_active.col, this.secondary_active.col)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getActiveTopLeft(): Position | null {
|
|
||||||
if (this.primary_active === null || this.secondary_active === null) return null;
|
|
||||||
|
|
||||||
return new Position(
|
|
||||||
Math.min(this.primary_active.row, this.secondary_active.row),
|
|
||||||
Math.min(this.primary_active.col, this.secondary_active.col)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getActiveBottomRight(): Position | null {
|
|
||||||
if (this.primary_active === null || this.secondary_active === null) return null;
|
|
||||||
|
|
||||||
return new Position(
|
|
||||||
Math.max(this.primary_active.row, this.secondary_active.row),
|
|
||||||
Math.max(this.primary_active.col, this.secondary_active.col)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public isPrimaryActive(pos: Position): boolean {
|
|
||||||
if (this.primary_active === null) return false;
|
|
||||||
return this.primary_active.equals(pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
public isSingleActive(): boolean {
|
|
||||||
return this.getActivePos() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public anyIsActive(): boolean {
|
|
||||||
return this.primary_active !== null && this.secondary_active !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public quickEval(pos: Position | null, raw: string) {
|
public quickEval(pos: Position | null, raw: string) {
|
||||||
if (pos === null) return;
|
if (pos === null) return;
|
||||||
@@ -346,9 +329,9 @@ class Grid {
|
|||||||
|
|
||||||
let pos = new Position(msg.cell.row, msg.cell.col);
|
let pos = new Position(msg.cell.row, msg.cell.col);
|
||||||
|
|
||||||
let x = this.data[pos.key()];
|
let x = this.data.cells[pos.key()];
|
||||||
|
|
||||||
this.data[pos.key()] = {
|
this.data.cells[pos.key()] = {
|
||||||
raw: msg.raw ?? '',
|
raw: msg.raw ?? '',
|
||||||
eval: msg.eval,
|
eval: msg.eval,
|
||||||
pos: pos,
|
pos: pos,
|
||||||
@@ -377,9 +360,9 @@ class Grid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pos = new Position(msg.cell.row, msg.cell.col);
|
let pos = new Position(msg.cell.row, msg.cell.col);
|
||||||
if (this.data[pos.key()] === undefined) return;
|
if (this.data.cells[pos.key()] === undefined) return;
|
||||||
|
|
||||||
this.data[pos.key()].temp_eval = msg.eval;
|
this.data.cells[pos.key()].temp_eval = msg.eval;
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
36
frontend/src/lib/components/grid/position.svelte.ts
Normal file
36
frontend/src/lib/components/grid/position.svelte.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { CellRef } from './messages';
|
||||||
|
import { refToStr } from './utils';
|
||||||
|
|
||||||
|
export class Position {
|
||||||
|
public row: number = $state(0);
|
||||||
|
public col: number = $state(0);
|
||||||
|
|
||||||
|
constructor(row: number = 0, col: number = 0) {
|
||||||
|
this.row = row;
|
||||||
|
this.col = col;
|
||||||
|
}
|
||||||
|
|
||||||
|
public key() {
|
||||||
|
return `${this.row}:${this.col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static key(row: number, col: number) {
|
||||||
|
return `${row}:${col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ref(): CellRef {
|
||||||
|
return { row: this.row, col: this.col };
|
||||||
|
}
|
||||||
|
|
||||||
|
public str(): string {
|
||||||
|
return refToStr(this.row, this.col);
|
||||||
|
}
|
||||||
|
|
||||||
|
public equals(other: CellRef | null | undefined): boolean {
|
||||||
|
return !!other && this.row === other.row && this.col === other.col;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static equals(a: CellRef | null | undefined, b: CellRef | null | undefined): boolean {
|
||||||
|
return !!a && !!b && a.row === b.row && a.col === b.col;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
frontend/src/lib/components/grid/types.ts
Normal file
12
frontend/src/lib/components/grid/types.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { CellT } from './messages';
|
||||||
|
|
||||||
|
export interface GridDefaults {
|
||||||
|
row_height: number;
|
||||||
|
col_width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridData {
|
||||||
|
cells: Record<string, CellT>;
|
||||||
|
row_heights: Record<number, number>;
|
||||||
|
col_widths: Record<number, number>;
|
||||||
|
}
|
||||||
145
frontend/src/lib/components/grid/vim.ts
Normal file
145
frontend/src/lib/components/grid/vim.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
let vimHistory = new Array<VimCommand>();
|
||||||
|
|
||||||
|
let vimState: VimState = {
|
||||||
|
modifier: undefined,
|
||||||
|
action: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Id to listener map
|
||||||
|
let vimCommandListeners: Record<string, VimListener> = {};
|
||||||
|
|
||||||
|
export interface VimListener {
|
||||||
|
onVimCommand(command: VimCommand): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VimModifier = number;
|
||||||
|
|
||||||
|
export type VimMotion = 'up' | 'down' | 'left' | 'right';
|
||||||
|
|
||||||
|
export type VimAction = 'delete' | 'insert-mode' | 'visual-mode' | 'escape' | 'goto';
|
||||||
|
|
||||||
|
export interface VimState {
|
||||||
|
modifier?: VimModifier;
|
||||||
|
action?: VimAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VimCommand {
|
||||||
|
action?: VimAction;
|
||||||
|
modifier?: VimModifier;
|
||||||
|
motion?: VimMotion;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitVimCommand(command: VimCommand): void {
|
||||||
|
for (let key in vimCommandListeners) {
|
||||||
|
vimCommandListeners[key].onVimCommand(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVimState(): void {
|
||||||
|
vimState = {
|
||||||
|
modifier: undefined,
|
||||||
|
action: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerVimListener(id: string, listener: VimListener): void {
|
||||||
|
if (id in vimCommandListeners) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vimCommandListeners[id] = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterVimListener(id: string): void {
|
||||||
|
delete vimCommandListeners[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVimId(): string {
|
||||||
|
let id = 'vim-listener-' + Math.random().toString(36).slice(2, 11);
|
||||||
|
|
||||||
|
while (id in vimCommandListeners) {
|
||||||
|
id = 'vim-listener-' + Math.random().toString(36).slice(2, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vimKeyboardHandler(e: KeyboardEvent): void {
|
||||||
|
const motionMap: Record<string, VimMotion> = {
|
||||||
|
h: 'left',
|
||||||
|
j: 'down',
|
||||||
|
k: 'up',
|
||||||
|
l: 'right'
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'i':
|
||||||
|
emitVimCommand({ action: 'insert-mode' });
|
||||||
|
resetVimState();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'v':
|
||||||
|
emitVimCommand({ action: 'visual-mode' });
|
||||||
|
resetVimState();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'g':
|
||||||
|
vimState.action = 'goto';
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'h':
|
||||||
|
case 'j':
|
||||||
|
case 'k':
|
||||||
|
case 'l':
|
||||||
|
emitVimCommand({
|
||||||
|
modifier: vimState.modifier,
|
||||||
|
action: vimState.action,
|
||||||
|
motion: motionMap[e.key]
|
||||||
|
});
|
||||||
|
resetVimState();
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '.':
|
||||||
|
if (vimHistory.length > 0) {
|
||||||
|
const lastCommand = vimHistory[vimHistory.length - 1];
|
||||||
|
emitVimCommand(lastCommand);
|
||||||
|
resetVimState();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '0':
|
||||||
|
case '1':
|
||||||
|
case '2':
|
||||||
|
case '3':
|
||||||
|
case '4':
|
||||||
|
case '5':
|
||||||
|
case '6':
|
||||||
|
case '7':
|
||||||
|
case '8':
|
||||||
|
case '9':
|
||||||
|
if (vimState.modifier === undefined) {
|
||||||
|
if (e.key === '0') {
|
||||||
|
// Leading zero, ignore
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
vimState.modifier = parseInt(e.key) || 1;
|
||||||
|
} else {
|
||||||
|
vimState.modifier = vimState.modifier * 10 + (parseInt(e.key) || 0);
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
emitVimCommand({ action: 'escape' });
|
||||||
|
resetVimState();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,9 +31,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
.grid-wrapper {
|
|
||||||
/* border-top: 2px solid var(--color-input); */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user