diff --git a/meerkat/interactive/app/src/lib/component/core/number/Number.svelte b/meerkat/interactive/app/src/lib/component/core/number/Number.svelte index 5a059b13c..230046e42 100644 --- a/meerkat/interactive/app/src/lib/component/core/number/Number.svelte +++ b/meerkat/interactive/app/src/lib/component/core/number/Number.svelte @@ -6,12 +6,40 @@ export let precision: number = 3; export let percentage: boolean = false; export let editable: boolean = false; + export let focused: boolean = false; export let classes: string = ''; const cellEdit: CallableFunction = getContext('cellEdit'); + let editableCell: HTMLDivElement; + + $: setFocus(focused); + + function setFocus(focus: boolean) { + if (!editableCell) return; + + if (!focus) { + editableCell.blur(); + } else { + editableCell.focus(); + + // Set the cursor to the end of the div. From + // https://stackoverflow.com/a/3866442. Supported on Firefox, + // Chrome, Opera, Safari, IE 9+ + let range = document.createRange(); + range.selectNodeContents(editableCell); + range.collapse(false); + + let selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + } + let invalid = false; - let showInvalid = false; + let invalidCount = 0; if (dtype === 'auto') { // The + operator converts a string or number to a number if possible @@ -39,15 +67,17 @@ {#if editable} - (invalid = data !== '' && data !== 'NaN' && isNaN(data))} - on:change={() => { +
0} + contenteditable="true" + bind:innerHTML={data} + bind:this={editableCell} + on:input={() => { + invalid = data !== '' && data !== 'NaN' && isNaN(data); if (invalid) { - showInvalid = true; - setTimeout(() => (showInvalid = false), 1000); + invalidCount++; + setTimeout(() => invalidCount--, 400); } else { cellEdit(data === '' ? 0 : dtype === 'string' ? data : +data); } @@ -76,6 +106,6 @@ } .invalid { - animation: shake 0.2s ease-in-out 0s 2; + animation: shake 0.2s ease-in-out 0s infinite; } diff --git a/meerkat/interactive/app/src/lib/component/core/number/__init__.py b/meerkat/interactive/app/src/lib/component/core/number/__init__.py index 5e0b2eaa5..00e301559 100644 --- a/meerkat/interactive/app/src/lib/component/core/number/__init__.py +++ b/meerkat/interactive/app/src/lib/component/core/number/__init__.py @@ -10,3 +10,4 @@ class Number(Component): percentage: bool = False classes: str = "" editable: bool = False + focused: bool = False diff --git a/meerkat/interactive/app/src/lib/component/core/table/Table.svelte b/meerkat/interactive/app/src/lib/component/core/table/Table.svelte index 38787e04d..83b584218 100644 --- a/meerkat/interactive/app/src/lib/component/core/table/Table.svelte +++ b/meerkat/interactive/app/src/lib/component/core/table/Table.svelte @@ -3,8 +3,6 @@ import Pagination from '$lib/shared/pagination/Pagination.svelte'; import { fetchChunk, fetchSchema, dispatch } from '$lib/utils/api'; import { DataFrameChunk, type DataFrameRef, type DataFrameSchema } from '$lib/utils/dataframe'; - import { without } from 'underscore'; - import { onMount } from 'svelte'; import { openModal } from 'svelte-modals'; import type { Endpoint } from '$lib/utils/types'; import { zip } from 'underscore'; @@ -12,17 +10,37 @@ import Cell from '$lib/shared/cell/Cell.svelte'; import { Check, CheckAll, KeyFill } from 'svelte-bootstrap-icons'; + type Cell = { + column: string; + keyidx: number; // TODO: decide if this should be number or string + posidx: number; + value: any; + }; + export let df: DataFrameRef; export let page: number = 0; export let perPage: number = 50; - export let selected: Array = []; - export let singleSelect: boolean = false; + let editMode: boolean = false; + let editValue: string = ''; export let onEdit: Endpoint; - export let onSelect: Endpoint; + + let primarySelectedCell: Cell = { column: '', keyidx: -1, posidx: -1, value: '' }; + let secondarySelectedCell: Cell = { column: '', keyidx: -1, posidx: -1, value: '' }; + let activeCells: Array = []; // the cells currently being interacted with + let selectedCells: Array = []; + let selectedCols: Array = []; + let selectedRows: Array = []; + export let onSelectCells: Endpoint; + export let onSelectCols: Endpoint; + export let onSelectRows: Endpoint; export let classes: string = 'h-fit'; + let wrapping: string = 'clip'; + + let cutoutWidth: number = 0; + let cutoutHeight: number = 0; // Setup row modal const open_row_modal = (posidx: number) => { @@ -33,6 +51,29 @@ }); }; + let columnWidths: Array = []; + let columnUnit: string = 'px'; + let rowHeights: Array = []; + let rowUnit: string = 'px'; + + function setColumnWidths(clamp: number | null = null) { + const headerCells = document.getElementsByClassName('header-cell'); + // Use i + 1 because the first header-cell is the top-left cutout + columnWidths = columnWidths.map((_, i) => + clamp + ? Math.min((headerCells[i + 1] as HTMLElement).offsetWidth, clamp) + : (headerCells[i + 1] as HTMLElement).offsetWidth + ); + } + + function setRowHeights() { + const headerCells = document.getElementsByClassName('header-cell'); + // Use i + 1 because the first header-cell is the top-left cutout + rowHeights = rowHeights.map( + (_, i) => (headerCells[i + 1 + columnWidths.length] as HTMLElement).offsetHeight + ); + } + // Create placeholder variables for table data. let schema: Writable = writable({ columns: [], @@ -42,42 +83,75 @@ }); let chunk: Writable = writable(new DataFrameChunk([], [], [], 0, 'pkey')); + // Run this once to set columnWidths + fetchSchema({ + df: df, + formatter: 'icon' + }).then((newSchema) => { + columnWidths = Array(newSchema.columns.length).fill(-1); + // After giving 2 seconds for the table to load, clamp the column widths + setTimeout(() => setColumnWidths(300), 2000); + }); + $: fetchSchema({ df: df, formatter: 'icon' }).then((newSchema) => { schema.set(newSchema); + // After giving 2 seconds for the table to load, set the row heights + setTimeout(setRowHeights, 2000); }); - $: fetchChunk({ + // Run this once to set rowHeights (24 is same as text-sm + 4) + fetchChunk({ df: df, start: page * perPage, end: (page + 1) * perPage, formatter: 'tiny' - }).then((newChunk) => { - console.log('here'); - chunk.set(newChunk); - }); + }).then((newChunk) => (rowHeights = Array(newChunk.keyidxs.length).fill(-1))); - export let columnWidths = Array.apply(null, Array($schema.columns.length)).map((x, i) => 256); - let columnUnit: string = 'px'; + $: fetchChunk({ + df: df, + start: page * perPage, + end: (page + 1) * perPage, + formatter: 'tiny' + }).then((newChunk) => chunk.set(newChunk)); let resizeProps = { - colBeingResized: -1, - x: 0, - wLeft: 0, - wRight: 0, - dx: 0 + direction: 'x', + idxBeingResized: -1, + mouseStart: 0, + sizeStart: 0, + offset: 0 + }; + + /** + * Helper function to find the first ancestor of an element with a given + * class. + * + * @param et - The event target + * @param cls - The class to search for + */ + const findAncestor = (et: EventTarget | null, cls: string) => { + let el: HTMLElement | null = et as HTMLElement; + while (el && (el = el.parentElement) && !el.classList.contains(cls)); + return el; }; - let resizeMethods = { - mousedown(colIndex: number) { + const resizeMethods = { + mousedown(direction: string, idx: number) { return (e: MouseEvent) => { - // Update all the resize props - resizeProps.colBeingResized = colIndex; - resizeProps.x = e.clientX; - resizeProps.wLeft = columnWidths[colIndex]; - resizeProps.wRight = columnWidths[colIndex + 1]; + // Store the current state in the resize props + resizeProps.direction = direction; + resizeProps.idxBeingResized = idx; + resizeProps.mouseStart = direction === 'x' ? e.x : e.y; + + const el = findAncestor(e.target, 'header-cell'); + if (el) { + if (direction === 'x') columnWidths[idx] = el.offsetWidth; + else rowHeights[idx] = el.offsetHeight; + } + resizeProps.sizeStart = direction === 'x' ? columnWidths[idx] : rowHeights[idx]; // Attach listeners for events window.addEventListener('mousemove', resizeMethods.mousemove); @@ -86,15 +160,16 @@ }, mousemove(e: MouseEvent) { - if (resizeProps.colBeingResized === -1) return; + if (resizeProps.idxBeingResized === -1) return; // Determine how far the mouse has been moved - resizeProps.dx = e.clientX - resizeProps.x; + resizeProps.offset = (resizeProps.direction === 'x' ? e.x : e.y) - resizeProps.mouseStart; - // Update the width of column - if (resizeProps.wLeft + resizeProps.dx > 164 && resizeProps.wRight - resizeProps.dx > 164) { - columnWidths[resizeProps.colBeingResized] = resizeProps.wLeft + resizeProps.dx; - columnWidths[resizeProps.colBeingResized + 1] = resizeProps.wRight - resizeProps.dx; + // Update the size + const newSize = resizeProps.sizeStart + resizeProps.offset; + if (newSize > 5) { + if (resizeProps.direction === 'x') columnWidths[resizeProps.idxBeingResized] = newSize; + else rowHeights[resizeProps.idxBeingResized] = newSize; } }, @@ -104,193 +179,930 @@ } }; - let tableWidth: number; - onMount(async () => { - columnWidths = Array.apply(null, Array($schema.columns.length)).map( - (x, i) => tableWidth / $schema.columns.length + /** + * Helper function to convert a column name to the index of that column in + * the schema. + * @param col - The name of the column + */ + function col2idx(col: string) { + return $schema.columns.findIndex((c) => c.name === col); + } + + /** + * Helper function to convert a keyidx to the index of that keyidx in the + * chunk. This also corresponds to the posidx. + * @param keyidx - The keyidx of the row + */ + function keyidx2idx(keyidx: number) { + // TODO: figure out why we need to do k.toString(). It is a number when it should be a string + return $chunk.keyidxs.findIndex((k) => k.toString() === keyidx.toString()); + } + + /** + * Helper function to get the cell at a given column and posidx. + * @param col + * @param posidx + */ + function getCell(column: string, posidx: number) { + return { + column, + keyidx: parseInt($chunk.keyidxs[posidx]), + posidx, + value: $chunk.getCell(posidx % perPage, column).data + }; + } + + /** + * Helper function to determine if two cells are equal. + * @param cell1 + * @param cell2 + */ + function areEqual(cell1: Cell, cell2: Cell) { + return cell1.column === cell2.column && cell1.posidx === cell2.posidx; + } + + /** + * Selects all cells between cell1 and cell2, inclusive. + * @param cell1 + * @param cell2 + * @param select - If true, will select the cells. If false, will mark as active + */ + function selectRange(cell1: Cell, cell2: Cell, select = true) { + if (select) selectedCells = []; + else activeCells = []; + + const [col1, row1] = [col2idx(cell1.column), cell1.posidx]; + const [col2, row2] = [col2idx(cell2.column), cell2.posidx]; + + // If the user clicks on the same cell, none should be selected + if (col1 !== -1 && col1 === col2 && row1 !== -1 && row1 === row2) return; + + const [colStart, colEnd] = col1 < col2 ? [col1, col2] : [col2, col1]; + const [keyidxStart, keyidxEnd] = row1 < row2 ? [row1, row2] : [row2, row1]; + + for (let i = colStart; i <= colEnd; i++) { + for (let j = keyidxStart; j <= keyidxEnd; j++) { + const column = $schema.columns[i].name; + const keyidx = parseInt($chunk.keyidxs[j]); + const posidx = j; + const value = $chunk.getCell(posidx, column).data; + if (select) selectedCells.push({ column, keyidx, posidx, value }); + else activeCells.push({ column, keyidx, posidx, value }); + } + } + + // trigger update + if (select) selectedCells = selectedCells.slice(); + else activeCells = activeCells.slice(); + } + + const selectCellMethods = { + mousedown(cell: Cell) { + if (editMode) endEdit(); + return (e: MouseEvent) => { + if (e.shiftKey) { + secondarySelectedCell = cell; + selectRange(primarySelectedCell, secondarySelectedCell); + } else if (e.metaKey) { + if (getSelectedBitmap(primarySelectedCell.column, primarySelectedCell.keyidx) === 0) + selectedCells.push(primarySelectedCell); + primarySelectedCell = secondarySelectedCell = cell; + activeCells.push(cell); + } else { + primarySelectedCell = secondarySelectedCell = cell; + selectedCells = []; // don't add to selectedCells + selectedCols = []; + selectedRows = []; + } + selectedCells = selectedCells.slice(); // trigger update + + // Attach listeners for events + window.addEventListener('mousemove', selectCellMethods.mousemove); + window.addEventListener('mouseup', selectCellMethods.mouseup); + }; + }, + + mousemove(e: MouseEvent) { + // Loop through elements beneath the mouse x and y to find the first + // element with class 'cell' + for (const element of document.elementsFromPoint(e.x, e.y)) { + if (!element.classList.contains('cell')) { + continue; + } + const column = element.getAttribute('column') || ''; + const keyidx = parseInt(element.getAttribute('keyidx') || '-1'); + + if ( + selectedCells.length > 0 && + column === primarySelectedCell.column && + keyidx === primarySelectedCell.keyidx + ) { + return; + } + + const posidx = keyidx2idx(keyidx); + secondarySelectedCell = getCell(column, posidx); + selectRange(primarySelectedCell, secondarySelectedCell, false); + + break; + } + }, + + mouseup(e: MouseEvent) { + // select all activeCells if at least one of them is not already selected + let foundUnselected = false; + for (const cell of activeCells) { + if (getSelectedBitmap(cell.column, cell.keyidx, false) === 0) { + foundUnselected = true; + break; + } + } + + if (foundUnselected) { + // select all cells + selectedCells = selectedCells.concat(activeCells); + } else { + // unselect all cells + for (const cell of activeCells) { + const i = selectedCells.findIndex((c) => areEqual(c, cell)); + if (i !== -1) { + if (selectedCells.length === 2) { + selectedCells.splice(i, 1); + primarySelectedCell = secondarySelectedCell = selectedCells[0]; + selectedCells = []; + } else if ( + selectedCells.length > 1 && + (areEqual(selectedCells[i], primarySelectedCell) || + areEqual(selectedCells[i], secondarySelectedCell)) + ) { + selectedCells.splice(i, 1); + primarySelectedCell = secondarySelectedCell = selectedCells[0]; + } else { + selectedCells.splice(i, 1); + } + } else if (selectedCols.includes(cell.column)) { + selectedCols.splice(selectedCols.indexOf(cell.column), 1); + // add all the cells in the column to selectedCells except the one we clicked on + for (let i = 0; i < $chunk.keyidxs.length; i++) { + if (i !== cell.posidx) { + selectedCells.push(getCell(cell.column, i)); + } + } + if (selectedCells.length > 0) + primarySelectedCell = secondarySelectedCell = selectedCells[0]; + else primarySelectedCell = secondarySelectedCell = getCell(cell.column, cell.posidx); + } else if (selectedRows.includes(cell.keyidx)) { + selectedRows.splice(selectedRows.indexOf(cell.keyidx), 1); + // add all the cells in the row to selectedCells except the one we clicked on + for (let i = 0; i < $schema.columns.length; i++) { + if ($schema.columns[i].name !== cell.column) { + selectedCells.push(getCell($schema.columns[i].name, cell.posidx)); + } + } + if (selectedCells.length > 0) + primarySelectedCell = secondarySelectedCell = selectedCells[0]; + else primarySelectedCell = secondarySelectedCell = getCell(cell.column, cell.posidx); + } + } + } + + activeCells = []; + + let s = [primarySelectedCell]; + if (selectedCells.length > 0) { + // filter out duplicate cells + s = selectedCells.filter((cell, i, arr) => arr.findIndex((c) => areEqual(c, cell)) === i); + } + if (onSelectCells && onSelectCells.endpointId) { + dispatch(onSelectCells.endpointId, { detail: { selected: s } }); + } + + window.removeEventListener('mousemove', selectCellMethods.mousemove); + window.removeEventListener('mouseup', selectCellMethods.mouseup); + } + }; + + function onClickCol(e: MouseEvent, column: string) { + if (editMode) endEdit(); + if (e.shiftKey) { + selectedCols = []; + // loop through all cols between primarySelectedCol and this col + const col1 = col2idx(primarySelectedCell.column); + const col2 = col2idx(column); + const [colStart, colEnd] = col1 < col2 ? [col1, col2] : [col2, col1]; + for (let i = colStart; i <= colEnd; i++) { + selectedCols.push($schema.columns[i].name); + } + } else if (e.metaKey) { + const i = selectedCols.indexOf(column); + if (i !== -1) { + // remove cells from selectedCells in this col + selectedCells = selectedCells.filter((c) => c.column !== column); + selectedCols.splice(i, 1); + + // set new primarySelectedCell + if (selectedCells.length > 0) + primarySelectedCell = secondarySelectedCell = selectedCells[0]; + else if (selectedCols.length > 0) + primarySelectedCell = secondarySelectedCell = getCell(selectedCols[0], 0); + else if (selectedRows.length > 0) + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[0].name, + selectedRows[0] + ); + else primarySelectedCell = secondarySelectedCell = getCell($schema.columns[0].name, 0); + } else { + primarySelectedCell = secondarySelectedCell = getCell(column, 0); + selectedCols.push(column); + } + } else { + primarySelectedCell = secondarySelectedCell = getCell(column, 0); + selectedCells = []; + selectedCols = [column]; + selectedRows = []; + } + selectedCols = selectedCols.sort((a, b) => col2idx(a) - col2idx(b)); + + if (onSelectCols && onSelectCols.endpointId) { + dispatch(onSelectCols.endpointId, { detail: { selected: selectedCols } }); + } + } + + function onClickRow(e: MouseEvent, keyidx: number) { + if (editMode) endEdit(); + const posidx = keyidx2idx(keyidx); + if (e.shiftKey) { + selectedRows = []; + // loop through all rows between primarySelectedCol and this row + const row1 = primarySelectedCell.posidx; + const row2 = posidx; + const [rowStart, rowEnd] = row1 < row2 ? [row1, row2] : [row2, row1]; + for (let i = rowStart; i <= rowEnd; i++) { + selectedRows.push(parseInt($chunk.keyidxs[i])); + } + } else if (e.metaKey) { + const i = selectedRows.indexOf(keyidx); + if (i !== -1) { + // remove cells from selectedCells in this row + selectedCells = selectedCells.filter((c) => c.keyidx !== keyidx); + selectedRows.splice(i, 1); + + // set new primarySelectedCell + if (selectedCells.length > 0) + primarySelectedCell = secondarySelectedCell = selectedCells[0]; + else if (selectedCols.length > 0) + primarySelectedCell = secondarySelectedCell = getCell(selectedCols[0], 0); + else if (selectedRows.length > 0) + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[0].name, + selectedRows[0] + ); + else primarySelectedCell = secondarySelectedCell = getCell($schema.columns[0].name, 0); + } else { + const column = $schema.columns[0].name; + primarySelectedCell = secondarySelectedCell = getCell(column, posidx); + selectedRows.push(keyidx); + } + } else { + const column = $schema.columns[0].name; + primarySelectedCell = secondarySelectedCell = getCell(column, posidx); + selectedCells = []; + selectedCols = []; + selectedRows = [keyidx]; + } + selectedRows = selectedRows.sort((a, b) => keyidx2idx(a) - keyidx2idx(b)); + + if (onSelectRows && onSelectRows.endpointId) { + dispatch(onSelectRows.endpointId, { detail: { selected: selectedCols } }); + } + } + + function getColumnSelectClasses( + column: string, + primarySelectedCell: Cell, + activeCells: Array, + selectedCells: Array, + selectedCols: Array, + selectedRows: Array + ) { + if (selectedCols.includes(column)) return 'bg-violet-700 text-white font-bold '; + if ( + primarySelectedCell.column === column || + activeCells.some((c) => c.column === column) || + selectedCells.some((c) => c.column === column) || + selectedRows.length > 0 + ) + return 'bg-violet-200 font-bold '; + return ''; + } + + function getRowSelectClasses( + keyidx: number, + primarySelectedCell: Cell, + activeCells: Array, + selectedCells: Array, + selectedCols: Array, + selectedRows: Array + ) { + if (selectedRows.includes(keyidx)) return 'bg-violet-700 text-white font-bold '; + if ( + primarySelectedCell.keyidx === keyidx || + activeCells.some((c) => c.keyidx === keyidx) || + selectedCells.some((c) => c.keyidx === keyidx) || + selectedCols.length > 0 + ) + return 'bg-violet-200 font-bold '; + return ''; + } + + /** + * Helper function that returns a number representing the ways a cell has + * been selected or not. + * + * - ones's place: selectedCols (0 or 1) + * - ten's place: selectedRows (0 or 10) + * - hundred's place: activeCells (0 or 100) + * - thousand's place: number of occurences in selectedCells + * + * @param column + * @param keyidx + * @param countActive + */ + function getSelectedBitmap(column: string, keyidx: number, countActive = true) { + return ( + (selectedCols.includes(column) ? 1 : 0) + + (selectedRows.includes(keyidx) ? 10 : 0) + + (countActive && activeCells.some((c) => c.column === column && c.keyidx === keyidx) + ? 100 + : 0) + + selectedCells.filter((c) => c.column === column && c.keyidx === keyidx).length * 1000 ); - columnUnit = 'px'; + } + + /** + * Helper function that returns the number of times a cell is selected. + * @param column + * @param keyidx + * @param posidx + * @param primarySelectedCell + * @param activeCells + * @param selectedCells + * @param selectedCols + * @param selectedRows + */ + function getSelectedCount(column: string, keyidx: number) { + return ( + (selectedCols.includes(column) ? 1 : 0) + + (selectedRows.includes(keyidx) ? 1 : 0) + + (activeCells.some((c) => c.column === column && c.keyidx === keyidx) ? 1 : 0) + + selectedCells.filter((c) => c.column === column && c.keyidx === keyidx).length + ); + } + + /** + * Helper function that returns a string of classes for a cell based on + * how it has been selected. + * + * NOTE: The params activeCells, selectedCells, selectedCols, and + * selectedRows are included so that this funciton is called reactively + * whenever any of those (or primarySelectedCell) changes. + * + * @param column + * @param keyidx + * @param posidx + * @param primarySelectedCell + * @param activeCells + * @param selectedCells + * @param selectedCols + * @param selectedRows + */ + function getCellSelectClasses( + column: string, + keyidx: number, + posidx: number, + primarySelectedCell: Cell, + activeCells: Array, + selectedCells: Array, + selectedCols: Array, + selectedRows: Array, + editMode: boolean + ) { + let classes = ''; + const bitmap = getSelectedBitmap(column, keyidx); + + // Determine background color + if (bitmap > 0) { + // the max tailwind color is 900, so we cap the count at 9 + classes += `bg-violet-${Math.min(getSelectedCount(column, keyidx), 9)}00 `; + } + + // Determine borders + if (primarySelectedCell.column === column && primarySelectedCell.keyidx === keyidx) { + if (editMode) classes += 'overflow-visible -ml-px -mt-px '; + else classes += 'border-t-2 border-l-2 border-violet-600 '; + } else { + // border width of 1px, default color slate + classes += 'border-t border-l border-slate-300 '; + + if (posidx > 0) { + if (areEqual(getCell(column, posidx - 1), primarySelectedCell)) { + if (!editMode) classes += 'border-t-2 border-t-violet-600 -mt-px '; + } else { + const bitmapAbove = getSelectedBitmap(column, parseInt($chunk.keyidxs[posidx - 1])); + if (bitmap !== bitmapAbove) classes += 'border-t-violet-600 '; + } + } else if (bitmap > 0 && posidx === 0) { + classes += 'border-t-violet-600 '; + } + + if (bitmap > 0 && posidx === $chunk.keyidxs.length - 1) + classes += 'border-b border-b-violet-600 '; + + const colidx = col2idx(column); + if (colidx > 0) { + if (areEqual(getCell($chunk.columns[colidx - 1], posidx), primarySelectedCell)) { + if (!editMode) classes += 'border-l-2 border-l-violet-600 -ml-px '; + } else { + const bitmapLeft = getSelectedBitmap($chunk.columns[colidx - 1], keyidx); + if (bitmap !== bitmapLeft) classes += 'border-l-violet-600 '; + } + } else if (bitmap > 0 && colidx === 0) { + classes += 'border-l-violet-600 '; + } + + if (bitmap > 0 && colidx === $schema.columns.length - 1) + classes += 'border-r border-r-violet-600 '; + } + + return classes; + } + + /** + * Start editing the current cell. + */ + function startEdit() { + editMode = true; + editValue = primarySelectedCell.value; + } + + /** + * Finish editing. If callOnEdit is true, save the edit by calling the + * onEdit endpoint. + * @param callOnEdit Whether or not to call the onEdit endpoint. + */ + function endEdit(callOnEdit: boolean = true) { + if (callOnEdit && onEdit && onEdit.endpointId) { + const { column, keyidx, posidx } = primarySelectedCell; + primarySelectedCell.value = editValue; + dispatch(onEdit.endpointId, { + detail: { + column, + keyidx, + posidx, + value: editValue + } + }); + } + editMode = false; + } + + // Define keyboard shortcuts + window.addEventListener('keydown', (e) => { + if (primarySelectedCell.column === '') { + primarySelectedCell = secondarySelectedCell = getCell($schema.columns[0].name, 0); + } + + const colidx = col2idx(primarySelectedCell.column); + const posidx = primarySelectedCell.posidx; + + if (e.key === 'a' && e.metaKey) { + e.preventDefault(); + selectedCells = []; + selectedCols = $schema.columns.map((c) => c.name); + selectedRows = $chunk.keyidxs.map((k) => parseInt(k)); + } else if (e.key === 'ArrowDown') { + if (editMode) return; + e.preventDefault(); + if (e.metaKey) { + if (e.shiftKey) { + secondarySelectedCell = getCell(secondarySelectedCell.column, $chunk.keyidxs.length - 1); + selectRange(primarySelectedCell, secondarySelectedCell); + } else { + primarySelectedCell = secondarySelectedCell = getCell( + primarySelectedCell.column, + $chunk.keyidxs.length - 1 + ); + } + } else if (e.shiftKey) { + const posidx2 = secondarySelectedCell.posidx; + if (posidx2 + 1 < $chunk.keyidxs.length) { + secondarySelectedCell = getCell(secondarySelectedCell.column, posidx2 + 1); + selectRange(primarySelectedCell, secondarySelectedCell); + } else if (page * perPage + posidx2 < $schema.nrows - 1) { + // TODO: flesh out this case + page++; + secondarySelectedCell = getCell(secondarySelectedCell.column, 0); + } + } else { + if (posidx < $chunk.keyidxs.length - 1) { + primarySelectedCell = secondarySelectedCell = getCell( + primarySelectedCell.column, + posidx + 1 + ); + } else if (page * perPage + posidx < $schema.nrows - 1) { + page++; + primarySelectedCell = secondarySelectedCell = getCell(primarySelectedCell.column, 0); + } + selectedCells = []; + } + selectedCols = []; + selectedRows = []; + } else if (e.key === 'ArrowUp') { + if (editMode) return; + e.preventDefault(); + if (e.metaKey) { + if (e.shiftKey) { + secondarySelectedCell = getCell(secondarySelectedCell.column, 0); + selectRange(primarySelectedCell, secondarySelectedCell); + } else { + primarySelectedCell = secondarySelectedCell = getCell(primarySelectedCell.column, 0); + } + } else if (e.shiftKey) { + const posidx2 = secondarySelectedCell.posidx; + if (posidx2 > 0) { + secondarySelectedCell = getCell(secondarySelectedCell.column, posidx2 - 1); + selectRange(primarySelectedCell, secondarySelectedCell); + } else if (page > 0) { + // TODO: flesh out this case + page--; + secondarySelectedCell = getCell(secondarySelectedCell.column, $chunk.keyidxs.length - 1); + } + } else { + if (posidx > 0) { + primarySelectedCell = secondarySelectedCell = getCell( + primarySelectedCell.column, + posidx - 1 + ); + } else if (page > 0) { + page--; + primarySelectedCell = secondarySelectedCell = getCell( + primarySelectedCell.column, + $chunk.keyidxs.length - 1 + ); + } + selectedCells = []; + } + selectedCols = []; + selectedRows = []; + } else if (e.key === 'ArrowLeft') { + if (editMode) return; + e.preventDefault(); + if (e.metaKey) { + if (e.shiftKey) { + secondarySelectedCell = getCell($schema.columns[0].name, secondarySelectedCell.posidx); + selectRange(primarySelectedCell, secondarySelectedCell); + } else { + primarySelectedCell = secondarySelectedCell = getCell($schema.columns[0].name, posidx); + } + } else if (e.shiftKey) { + const colidx2 = col2idx(secondarySelectedCell.column); + const posidx2 = secondarySelectedCell.posidx; + if (colidx2 > 0) { + secondarySelectedCell = getCell($schema.columns[colidx2 - 1].name, posidx2); + selectRange(primarySelectedCell, secondarySelectedCell); + } + } else { + if (colidx > 0) { + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[colidx - 1].name, + posidx + ); + } + selectedCells = []; + } + selectedCols = []; + selectedRows = []; + } else if (e.key === 'ArrowRight') { + if (editMode) return; + e.preventDefault(); + if (e.metaKey) { + if (e.shiftKey) { + secondarySelectedCell = getCell( + $schema.columns[$schema.columns.length - 1].name, + secondarySelectedCell.posidx + ); + selectRange(primarySelectedCell, secondarySelectedCell); + } else { + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[$schema.columns.length - 1].name, + posidx + ); + } + } else if (e.shiftKey) { + const colidx2 = col2idx(secondarySelectedCell.column); + const posidx2 = secondarySelectedCell.posidx; + if (colidx2 < $schema.columns.length - 1) { + secondarySelectedCell = getCell($schema.columns[colidx2 + 1].name, posidx2); + selectRange(primarySelectedCell, secondarySelectedCell); + } + } else { + if (colidx < $schema.columns.length - 1) { + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[colidx + 1].name, + posidx + ); + } + selectedCells = []; + } + selectedCols = []; + selectedRows = []; + } else if (e.key === 'Tab') { + e.preventDefault(); + if (editMode) endEdit(); + // TODO: if there are selected cells, loop through them + if (e.shiftKey) { + if (colidx > 0) { + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[colidx - 1].name, + posidx + ); + } + } else { + if (colidx < $schema.columns.length - 1) { + primarySelectedCell = secondarySelectedCell = getCell( + $schema.columns[colidx + 1].name, + posidx + ); + } + } + selectedCells = []; + selectedCols = []; + selectedRows = []; + } else if (e.key === 'Enter') { + if (editMode) { + if (e.ctrlKey || e.altKey || e.metaKey) { + return; + } else { + e.preventDefault(); + endEdit(); + } + } else { + e.preventDefault(); + startEdit(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + if (editMode) endEdit(false); + } }); - +
-
-
-
- -
- {#each $schema.columns as column, col_index} -
- -
- {#if column.name === $schema.primaryKey} - - - {:else} - - {/if} - -
{column.name}
-
-
-
+ +
(h === -1 ? 'min-content' : `${h}${rowUnit}`)) + .join(' ')};` + + `grid-template-columns: min-content ${columnWidths + .map((w) => (w === -1 ? 'min-content' : `${w}${columnUnit}`)) + .join(' ')};` + + 'max-height:calc(100vh - 32px)'} + > + + + +
+ + {#each $schema.columns as column, col_index} +
+ + + + +
{ + columnWidths[col_index] = -1; // let it auto-size to min-content + setTimeout(() => { + const el = findAncestor(e.target, 'header-cell'); + if (el) columnWidths[col_index] = el.offsetWidth; + }); + }} + > +
+
- {/each} +
-
+ {/each} + + + + {#each zip($chunk.keyidxs, $chunk.posidxs) as [keyidx, posidx], rowi} + +
+ -
- {#each zip($chunk.keyidxs, $chunk.posidxs) as [keyidx, posidx], rowi} -
-
- + +
{ + rowHeights[posidx] = -1; // let it auto-size to min-content + setTimeout(() => { + const el = findAncestor(e.target, 'header-cell'); + if (el) rowHeights[posidx] = el.offsetHeight; + }); + }} + > +
+
+
+
+
+ + + {#each $chunk.columnInfos as col, col_index} +
{ + e.preventDefault(); + selectCellMethods.mousedown({ + column: col.name, + keyidx: keyidx, + posidx: posidx, + value: $chunk.getCell(rowi, col.name).data + })(e); + }} + on:dblclick={startEdit} + column={col.name} + {keyidx} + > +
+ (editValue = e.detail.value)} + />
- {#each $chunk.columnInfos as col} -
- { - console.log(keyidx); - dispatch(onEdit.endpointId, { - detail: { - column: col.name, - keyidx: keyidx, - posidx: posidx, - value: e.detail.value - } - }); - }} - /> -
- {/each}
{/each} -
+ {/each}
-
- -
-
- {#if selected.length > 0} - {#if selected.length === 1} - - {:else} - - {/if} -
{selected.length} Selected
+ + +
+ + +
+
+ {#if selectedRows.length > 0 && selectedCols.length === 0 && selectedCells.length === 0} + {#if selectedRows.length === 1} + +
1 row selected
+ {:else} + +
{selectedRows.length} rows selected
{/if} -
+ {:else if selectedRows.length === 0 && selectedCols.length > 0 && selectedCells.length === 0} + {#if selectedCols.length === 1} + +
1 column selected
+ {:else} + +
+ {selectedCols.length} columns selected +
+ {/if} + {:else if selectedRows.length === 0 && selectedCols.length === 0 && selectedCells.length > 0} + +
+ {selectedCells.length} cells selected +
+ {:else if selectedRows.length === 0 && selectedCols.length === 0 && selectedCells.length === 0 && primarySelectedCell.column !== ''} + +
1 cell selected
+ {/if}
- -
- +
+
Wrapping:
+ + +
- -
- -
+
- - diff --git a/meerkat/interactive/app/src/lib/component/core/table/__init__.py b/meerkat/interactive/app/src/lib/component/core/table/__init__.py index eee650179..854b445f4 100644 --- a/meerkat/interactive/app/src/lib/component/core/table/__init__.py +++ b/meerkat/interactive/app/src/lib/component/core/table/__init__.py @@ -1,5 +1,7 @@ from typing import Any, List +from pydantic import BaseModel + from meerkat.dataframe import DataFrame from meerkat.interactive.app.src.lib.component.abstract import Component from meerkat.interactive.endpoint import EndpointProperty @@ -25,8 +27,23 @@ class OnEditInterface(EventInterface): value: Any -class OnSelectTable(EventInterface): - selected: List[Any] +class Cell(BaseModel): + column: str + keyidx: int + posidx: int + value: Any + + +class OnSelectTableCells(EventInterface): + selected: List[Cell] + + +class OnSelectTableCols(EventInterface): + selected: List[str] # list of column names + + +class OnSelectTableRows(EventInterface): + selected: List[int] # list of keyidx class Table(Component): @@ -36,7 +53,9 @@ class Table(Component): classes: str = "h-fit" on_edit: EndpointProperty[OnEditInterface] = None - on_select: EndpointProperty[OnSelectTable] = None + on_select_cells: EndpointProperty[OnSelectTableCells] = None + on_select_cols: EndpointProperty[OnSelectTableCols] = None + on_select_rows: EndpointProperty[OnSelectTableRows] = None def __init__( self, @@ -46,7 +65,9 @@ def __init__( single_select: bool = False, classes: str = "h-fit", on_edit: EndpointProperty = None, - on_select: EndpointProperty = None + on_select_cells: EndpointProperty = None, + on_select_cols: EndpointProperty = None, + on_select_rows: EndpointProperty = None ): """Table view of a DataFrame. @@ -64,7 +85,9 @@ def __init__( single_select=single_select, classes=classes, on_edit=on_edit, - on_select=on_select, + on_select_cells=on_select_cells, + on_select_cols=on_select_cols, + on_select_rows=on_select_rows, ) def _get_ipython_height(self): diff --git a/meerkat/interactive/app/src/lib/component/core/text/Text.svelte b/meerkat/interactive/app/src/lib/component/core/text/Text.svelte index 87f1d3e8a..e89059972 100644 --- a/meerkat/interactive/app/src/lib/component/core/text/Text.svelte +++ b/meerkat/interactive/app/src/lib/component/core/text/Text.svelte @@ -3,18 +3,47 @@ export let data: any; export let editable: boolean = false; + export let focused: boolean = false; export let classes: string = ''; const cellEdit: CallableFunction = getContext('cellEdit'); + + let editableCell: HTMLDivElement; + + $: setFocus(focused); + + function setFocus(focus: boolean) { + if (!editableCell) return; + + if (!focus) { + editableCell.blur(); + } else { + editableCell.focus(); + + // Set the cursor to the end of the div. From + // https://stackoverflow.com/a/3866442. Supported on Firefox, + // Chrome, Opera, Safari, IE 9+ + let range = document.createRange(); + range.selectNodeContents(editableCell); + range.collapse(false); + + let selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + selection.addRange(range); + } + } + } {#if editable} - { - cellEdit(data); - }} - bind:value={data} + +
cellEdit(data)} /> {:else}
diff --git a/meerkat/interactive/app/src/lib/component/core/text/__init__.py b/meerkat/interactive/app/src/lib/component/core/text/__init__.py index e8f1225bb..8f9bd0684 100644 --- a/meerkat/interactive/app/src/lib/component/core/text/__init__.py +++ b/meerkat/interactive/app/src/lib/component/core/text/__init__.py @@ -5,6 +5,7 @@ class Text(Component): data: str classes: str = "" editable: bool = False + focused: bool = False def __init__( self, @@ -12,16 +13,19 @@ def __init__( *, classes: str = "", editable: bool = False, + focused: bool = False, ): """Display text. Args: data: The text to display. editable: Whether the text is editable. + focused: Whether the text is focused. """ # "whitespace-nowrap text-ellipsis overflow-hidden text-right " super().__init__( classes=classes, data=data, editable=editable, + focused=focused, ) diff --git a/meerkat/interactive/app/src/lib/shared/DynamicComponent.svelte b/meerkat/interactive/app/src/lib/shared/DynamicComponent.svelte index 892abef68..390be8986 100644 --- a/meerkat/interactive/app/src/lib/shared/DynamicComponent.svelte +++ b/meerkat/interactive/app/src/lib/shared/DynamicComponent.svelte @@ -7,8 +7,6 @@ export let props: any; export let slots: any = []; - console.log("props", props) - let component: ComponentType; onMount(async () => { // If the library is Meerkat, then we can load the component @@ -18,18 +16,16 @@ return; } }); - $:{ + $: { component = components[name]; } - - {#if component} {#each slots as slot} - + {/each} {/if} diff --git a/meerkat/interactive/app/src/lib/shared/cell/Cell.svelte b/meerkat/interactive/app/src/lib/shared/cell/Cell.svelte index 6cd3fee85..a1787c4f4 100644 --- a/meerkat/interactive/app/src/lib/shared/cell/Cell.svelte +++ b/meerkat/interactive/app/src/lib/shared/cell/Cell.svelte @@ -11,6 +11,7 @@ export let cellProps: object = {}; export let cellDataProp: string = 'data'; export let editable: boolean = false; + export let focused: boolean = false; const dispatch = createEventDispatcher(); setContext('cellEdit', (data: any) => { @@ -35,7 +36,9 @@ } } } - - +