🙃
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import clsx from 'clsx';
|
||||
import { getErrDesc, getErrTitle, getEvalLiteral, isErr, type CellT } from './utils';
|
||||
import { getErrDesc, getErrTitle, getEvalLiteral, isErr } from './utils';
|
||||
import * as HoverCard from '$lib/components/ui/hover-card/index.js';
|
||||
import type { CellT } from './messages';
|
||||
|
||||
let {
|
||||
cla = '',
|
||||
@@ -33,7 +34,7 @@
|
||||
queueMicrotask(() => {
|
||||
const el = node.querySelector('input') as HTMLInputElement | null;
|
||||
if (el !== null) {
|
||||
el.value = cell?.raw_val ?? '';
|
||||
el.value = cell?.temp_raw ?? '';
|
||||
el.focus();
|
||||
}
|
||||
});
|
||||
@@ -49,44 +50,48 @@
|
||||
stopediting();
|
||||
}
|
||||
}
|
||||
|
||||
function getPreview() {
|
||||
return !isErr(cell?.temp_eval) ? getEvalLiteral(cell?.temp_eval) : '';
|
||||
}
|
||||
|
||||
let showPreview = $derived(getPreview() !== '');
|
||||
</script>
|
||||
|
||||
{#if editing}
|
||||
<div class="relative inline-block">
|
||||
{#if showPreview}
|
||||
<h3
|
||||
class="bubble pointer-events-none absolute -top-[6px] -left-1 z-[500] -translate-y-full text-sm font-semibold tracking-tight text-foreground select-none"
|
||||
role="tooltip"
|
||||
>
|
||||
{getPreview()}
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<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-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
|
||||
bind:value={
|
||||
() => {
|
||||
return cell?.raw_val ?? '';
|
||||
},
|
||||
(v) => {
|
||||
cell = {
|
||||
val: cell?.val,
|
||||
raw_val: v
|
||||
};
|
||||
() => cell?.temp_raw ?? '',
|
||||
(v) => (cell = { eval: cell?.eval, raw: cell?.raw ?? '', temp_raw: v })
|
||||
}
|
||||
}
|
||||
onblur={(e) => {
|
||||
// cell = {
|
||||
// val: cell?.val,
|
||||
// raw_val: (e.target as HTMLInputElement).value
|
||||
// };
|
||||
stopediting();
|
||||
}}
|
||||
onblur={stopediting}
|
||||
/>
|
||||
</div>
|
||||
{:else if cell && isErr(cell.val)}
|
||||
</div>
|
||||
{:else if cell && isErr(cell.eval)}
|
||||
<HoverCard.Root openDelay={500} closeDelay={500}>
|
||||
<HoverCard.Trigger>
|
||||
{@render InnerCell()}
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content side="right">
|
||||
<h2 class="text-md font-semibold tracking-tight transition-colors">
|
||||
{getErrTitle(cell.val)}
|
||||
{getErrTitle(cell.eval)}
|
||||
</h2>
|
||||
{getErrDesc(cell.val)}
|
||||
{getErrDesc(cell.eval)}
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
{:else}
|
||||
@@ -101,12 +106,12 @@
|
||||
style:height
|
||||
class={clsx('placeholder bg-background p-1', { active }, cla)}
|
||||
>
|
||||
{#if cell && (cell.raw_val !== '' || getEvalLiteral(cell.val) !== '')}
|
||||
<span class={clsx('pointer-events-none select-none', { err: isErr(cell.val) })}>
|
||||
{#if cell.val && !externalediting}
|
||||
{getEvalLiteral(cell.val)}
|
||||
{#if cell && (cell.raw !== '' || getEvalLiteral(cell.eval) !== '')}
|
||||
<span class={clsx('pointer-events-none select-none', { err: isErr(cell.eval) })}>
|
||||
{#if cell.eval && !externalediting}
|
||||
{getEvalLiteral(cell.eval)}
|
||||
{:else}
|
||||
{cell.raw_val}
|
||||
{cell.raw}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -144,4 +149,37 @@
|
||||
border-top: 12px solid red; /* size & color of the triangle */
|
||||
border-left: 12px solid transparent;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
z-index: 500;
|
||||
background: var(--color-popover);
|
||||
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
|
||||
border-radius: 10px;
|
||||
color: var(--color-popover-foreground);
|
||||
padding: 0.35rem 0.6rem;
|
||||
box-shadow: 0 2px 18px rgba(0, 0, 0, 0.08);
|
||||
max-width: min(15rem, 20vw);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* (optional) subtle appear animation */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.bubble {
|
||||
transform-origin: bottom left;
|
||||
animation: bubble-in 120ms ease-out both;
|
||||
}
|
||||
@keyframes bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
};
|
||||
|
||||
const grid = new Grid(socket);
|
||||
let rows = 100;
|
||||
let cols = 40;
|
||||
let rows = 10;
|
||||
let cols = 10;
|
||||
|
||||
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
|
||||
let pos = new Position(i, j);
|
||||
@@ -104,9 +104,12 @@
|
||||
/>
|
||||
<Input
|
||||
onmousedown={() => grid.setExternalEdit(grid.getActivePos())}
|
||||
onblur={() => grid.setExternalEdit(null)}
|
||||
onblur={() => grid.setCell(grid.getActivePos())}
|
||||
bind:value={
|
||||
() => grid.getActiveCell()?.raw_val ?? '', (raw) => grid.quickEval(grid.getActivePos(), raw)
|
||||
() => grid.getActiveCell()?.temp_raw ?? '',
|
||||
(v) => {
|
||||
grid.setCellTemp(grid.getActivePos(), v);
|
||||
}
|
||||
}
|
||||
class="relative w-[200px] pl-9"
|
||||
></Input>
|
||||
@@ -114,7 +117,10 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={clsx('grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)}
|
||||
class={clsx(
|
||||
' grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto overflow-visible',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div class="sticky top-0 flex w-fit" style="z-index: {rows + 70}">
|
||||
<div class="sticky top-0 left-0" style="z-index: {rows + 70}">
|
||||
@@ -163,7 +169,8 @@
|
||||
handleCellInteraction(i, j, e);
|
||||
}}
|
||||
bind:cell={
|
||||
() => grid.getCell(new Position(i, j)), (v) => grid.setCell(new Position(i, j), v)
|
||||
() => grid.getCell(new Position(i, j)),
|
||||
(v) => grid.setCellTemp(new Position(i, j), v?.temp_raw)
|
||||
}
|
||||
active={grid.isActive(new Position(i, j))}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { CellRef, CellT, LeadMsg } from './messages';
|
||||
import type { CellRef, CellT, Eval, LeadMsg } from './messages';
|
||||
|
||||
class Position {
|
||||
public row: number;
|
||||
@@ -41,7 +41,7 @@ class Grid {
|
||||
active_cell: Position | null = $state(null);
|
||||
editing_cell: Position | null = $state(null);
|
||||
external_editing_cell: Position | null = $state(null);
|
||||
editing_preview = $state(null);
|
||||
editing_preview: [Eval, boolean] | null = $state(null); // [Eval, dirty]
|
||||
|
||||
constructor(socket: WebSocket, default_col_width = '80px', default_row_height = '30px') {
|
||||
this.socket = socket;
|
||||
@@ -49,30 +49,47 @@ class Grid {
|
||||
this.default_row_height = default_row_height;
|
||||
}
|
||||
|
||||
public getCell(pos: Position): CellT {
|
||||
public getCell(pos: Position): CellT | undefined {
|
||||
return this.data[pos.key()];
|
||||
}
|
||||
|
||||
public setCell(pos: Position, v: CellT) {
|
||||
if (v?.raw_val == null || v.raw_val === '') {
|
||||
public setCell(pos: Position | null) {
|
||||
if (pos === null) return;
|
||||
let data = this.data[pos.key()];
|
||||
if (data === undefined) return;
|
||||
|
||||
if (data.temp_raw === '') {
|
||||
delete this.data[pos.key()];
|
||||
return;
|
||||
}
|
||||
|
||||
this.data[pos.key()] = {
|
||||
raw_val: v?.raw_val,
|
||||
val: v.val
|
||||
};
|
||||
data.raw = data.temp_raw;
|
||||
data.eval = data.temp_eval;
|
||||
|
||||
let msg: LeadMsg = {
|
||||
msg_type: 'set',
|
||||
cell: pos.ref(),
|
||||
raw: v.raw_val
|
||||
raw: data.temp_raw
|
||||
};
|
||||
|
||||
this.socket.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
public setCellTemp(pos: Position | null, raw: string | undefined) {
|
||||
if (pos === null || raw === undefined) return;
|
||||
|
||||
let x = this.data[pos.key()];
|
||||
|
||||
this.data[pos.key()] = {
|
||||
raw: x?.raw ?? '',
|
||||
temp_raw: raw,
|
||||
eval: x?.eval ?? undefined,
|
||||
temp_eval: x?.temp_eval ?? undefined
|
||||
};
|
||||
|
||||
this.quickEval(pos, raw);
|
||||
}
|
||||
|
||||
public getRowHeight(row: number) {
|
||||
return this.row_heights[row] ?? this.default_row_height;
|
||||
}
|
||||
@@ -107,16 +124,19 @@ class Grid {
|
||||
public startEditing(pos: Position) {
|
||||
this.active_cell = pos;
|
||||
this.editing_cell = pos;
|
||||
|
||||
let cell = this.getCell(pos);
|
||||
if (!cell) return;
|
||||
cell.temp_eval = undefined;
|
||||
}
|
||||
|
||||
public stopEditing(pos: Position) {
|
||||
this.editing_cell = null;
|
||||
this.setCell(pos, this.getCell(pos));
|
||||
this.setCell(pos);
|
||||
}
|
||||
|
||||
public stopEditingActive() {
|
||||
if (this.active_cell == null) return;
|
||||
|
||||
this.stopEditing(this.active_cell);
|
||||
}
|
||||
|
||||
@@ -138,11 +158,12 @@ class Grid {
|
||||
this.external_editing_cell = pos;
|
||||
}
|
||||
|
||||
public getActiveCell(): CellT {
|
||||
public getActiveCell(): CellT | undefined {
|
||||
if (this.active_cell === null)
|
||||
return {
|
||||
raw_val: '',
|
||||
val: undefined
|
||||
raw: '',
|
||||
temp_raw: '',
|
||||
eval: undefined
|
||||
};
|
||||
|
||||
return this.getCell(this.active_cell);
|
||||
@@ -179,16 +200,22 @@ class Grid {
|
||||
}
|
||||
case 'set': {
|
||||
if (msg.cell === undefined) {
|
||||
console.error('Expected cell ref for SET msgponse from server.');
|
||||
console.error('Expected cell ref for SET msg from server.');
|
||||
return;
|
||||
} else if (msg.eval === undefined) {
|
||||
console.error('Expected cell value for SET msgponse from server.');
|
||||
console.error('Expected cell value for SET msg from server.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.data[Position.key(msg.cell.row, msg.cell.col)] = {
|
||||
raw_val: msg.raw ?? '',
|
||||
val: msg.eval
|
||||
let pos = new Position(msg.cell.row, msg.cell.col);
|
||||
|
||||
let x = this.data[pos.key()];
|
||||
|
||||
this.data[pos.key()] = {
|
||||
raw: msg.raw ?? '',
|
||||
eval: msg.eval,
|
||||
temp_raw: x?.temp_raw ?? '',
|
||||
temp_eval: x?.temp_eval ?? undefined
|
||||
};
|
||||
|
||||
break;
|
||||
@@ -200,9 +227,23 @@ class Grid {
|
||||
}
|
||||
|
||||
for (const m of msg.bulk_msgs) this.handle_msg(m);
|
||||
break;
|
||||
}
|
||||
case 'eval': {
|
||||
// TODO
|
||||
if (msg.cell === undefined) {
|
||||
console.error('Expected cell ref for EVAL msg from server.');
|
||||
return;
|
||||
} else if (msg.eval === undefined) {
|
||||
console.error('Expected cell value for EVAL msg from server.');
|
||||
return;
|
||||
}
|
||||
|
||||
let pos = new Position(msg.cell.row, msg.cell.col);
|
||||
if (this.data[pos.key()] === undefined) return;
|
||||
|
||||
this.data[pos.key()].temp_eval = msg.eval;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,11 @@ type Eval =
|
||||
| { err: LeadErr }
|
||||
| 'unset';
|
||||
|
||||
interface CellT {
|
||||
raw_val: string;
|
||||
val?: Eval;
|
||||
interface CellT {
|
||||
raw: string;
|
||||
temp_raw: string;
|
||||
temp_eval?: Eval;
|
||||
eval?: Eval;
|
||||
}
|
||||
|
||||
export type { Eval, LeadMsg, LeadErr, Literal, CellRef, LiteralValue, CellT };
|
||||
|
||||
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
17
frontend/src/lib/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Content from "./popover-content.svelte";
|
||||
import Trigger from "./popover-trigger.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn("", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 0,
|
||||
side = "top",
|
||||
side = 'top',
|
||||
children,
|
||||
arrowClasses,
|
||||
...restProps
|
||||
@@ -22,7 +22,7 @@
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
|
||||
'z-[400] w-fit origin-(--bits-tooltip-content-transform-origin) animate-in rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
@@ -32,11 +32,11 @@
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
class={cn(
|
||||
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
|
||||
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
|
||||
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
|
||||
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
|
||||
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
|
||||
'z-50 size-2.5 rotate-45 rounded-[2px] bg-primary',
|
||||
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
|
||||
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
|
||||
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
|
||||
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
|
||||
arrowClasses
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user