diff --git a/mathesar_ui/.eslintrc.cjs b/mathesar_ui/.eslintrc.cjs index 2ec3f77086..6b30ababfa 100644 --- a/mathesar_ui/.eslintrc.cjs +++ b/mathesar_ui/.eslintrc.cjs @@ -30,6 +30,7 @@ module.exports = { rules: { 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 'no-console': ['warn', { allow: ['error'] }], + 'generator-star-spacing': 'off', 'no-continue': 'off', '@typescript-eslint/explicit-member-accessibility': 'off', '@typescript-eslint/ban-ts-comment': [ diff --git a/mathesar_ui/package-lock.json b/mathesar_ui/package-lock.json index c475073dd1..cd79da17a9 100644 --- a/mathesar_ui/package-lock.json +++ b/mathesar_ui/package-lock.json @@ -13,7 +13,7 @@ "dayjs": "^1.11.5", "fast-diff": "^1.2.0", "flatpickr": "^4.6.13", - "iter-tools": "^7.4.0", + "iter-tools": "^7.5.3", "js-cookie": "^3.0.1", "papaparse": "^5.4.1", "perfect-scrollbar": "^1.5.5", @@ -5728,9 +5728,9 @@ "dev": true }, "node_modules/iter-tools": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/iter-tools/-/iter-tools-7.4.0.tgz", - "integrity": "sha512-twcHD87GOpbnfFmOtEQMcZuJzBCViNaYx4//70KkkjFcQTkKcUfcuni5G5dfO4Uyb80KIsWnVGZ4vvkpmYCNQQ==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/iter-tools/-/iter-tools-7.5.3.tgz", + "integrity": "sha512-iEcHpgM9cn6tsI5MewqxyEega9KPbIDytQTEnu6c0MtlQQhQFofssYuRqxCarZgUdzliepRZPwwwflE4wAIjaA==", "dependencies": { "@babel/runtime": "^7.12.1" } diff --git a/mathesar_ui/package.json b/mathesar_ui/package.json index e2dae02a4c..e6ab0e021c 100644 --- a/mathesar_ui/package.json +++ b/mathesar_ui/package.json @@ -56,7 +56,7 @@ "dayjs": "^1.11.5", "fast-diff": "^1.2.0", "flatpickr": "^4.6.13", - "iter-tools": "^7.4.0", + "iter-tools": "^7.5.3", "js-cookie": "^3.0.1", "papaparse": "^5.4.1", "perfect-scrollbar": "^1.5.5", diff --git a/mathesar_ui/src/component-library/common/actions/slider.ts b/mathesar_ui/src/component-library/common/actions/slider.ts index 695a591db0..8a929d3b01 100644 --- a/mathesar_ui/src/component-library/common/actions/slider.ts +++ b/mathesar_ui/src/component-library/common/actions/slider.ts @@ -128,6 +128,8 @@ export default function slider( } function start(e: MouseEvent | TouchEvent) { + e.stopPropagation(); + e.preventDefault(); opts.onStart(); startingValue = opts.getStartingValue(); startingPosition = getPosition(e, opts.axis); diff --git a/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts b/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts index 609c156ecd..d442984ad0 100644 --- a/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts +++ b/mathesar_ui/src/component-library/common/utils/ImmutableSet.ts @@ -32,6 +32,15 @@ export default class ImmutableSet { return this.getNewInstance(set); } + /** + * @returns a new ImmutableSet that contains only the items that are present + * in both this set and the other set. The order of the items in the returned + * set is taken from this set (not the supplied set). + */ + intersect(other: { has: (v: T) => boolean }): this { + return this.getNewInstance([...this].filter((v) => other.has(v))); + } + without(itemOrItems: T | T[]): this { const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; const set = new Set(this.set); diff --git a/mathesar_ui/src/component-library/common/utils/__tests__/ImmutableSet.test.ts b/mathesar_ui/src/component-library/common/utils/__tests__/ImmutableSet.test.ts index 24938eb957..3d20add0d6 100644 --- a/mathesar_ui/src/component-library/common/utils/__tests__/ImmutableSet.test.ts +++ b/mathesar_ui/src/component-library/common/utils/__tests__/ImmutableSet.test.ts @@ -20,3 +20,15 @@ test('union', () => { expect(a.union(empty).valuesArray()).toEqual([2, 7, 13, 19, 5]); expect(empty.union(a).valuesArray()).toEqual([2, 7, 13, 19, 5]); }); + +test('intersect', () => { + const a = new ImmutableSet([2, 7, 13, 19, 5]); + const b = new ImmutableSet([23, 13, 3, 2]); + const empty = new ImmutableSet(); + expect(a.intersect(a).valuesArray()).toEqual([2, 7, 13, 19, 5]); + expect(a.intersect(b).valuesArray()).toEqual([2, 13]); + expect(b.intersect(a).valuesArray()).toEqual([13, 2]); + expect(a.intersect(empty).valuesArray()).toEqual([]); + expect(empty.intersect(a).valuesArray()).toEqual([]); + expect(empty.intersect(empty).valuesArray()).toEqual([]); +}); diff --git a/mathesar_ui/src/components/cell-fabric/CellFabric.svelte b/mathesar_ui/src/components/cell-fabric/CellFabric.svelte index 564ca257ee..41d9390317 100644 --- a/mathesar_ui/src/components/cell-fabric/CellFabric.svelte +++ b/mathesar_ui/src/components/cell-fabric/CellFabric.svelte @@ -4,17 +4,9 @@ This component is meant to be common for tables, queries, and for import preview -->
@@ -118,4 +98,7 @@ .cell-fabric:not(.show-as-skeleton) .loader { display: none; } + .light-text { + color: var(--cell-text-color-processing); + } diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/CellWrapper.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/CellWrapper.svelte index 775f060aff..f8b4b71c4b 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/CellWrapper.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/CellWrapper.svelte @@ -1,14 +1,15 @@
{#if mode !== 'edit'} - ; export let isActive: Props['isActive']; - export let isSelectedInRange: Props['isSelectedInRange']; export let value: Props['value']; export let disabled: Props['disabled']; export let multiLineTruncate = false; @@ -144,23 +143,15 @@ resetEditMode(); } - function handleMouseDown() { - if (!isActive) { - dispatch('activate'); - } - } - onMount(initLastSavedValue); {#if isDefinedNonNullable(value)} diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/checkbox/CheckboxCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/checkbox/CheckboxCell.svelte index fa7fb382f1..3a4b8c79fb 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/checkbox/CheckboxCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/checkbox/CheckboxCell.svelte @@ -12,7 +12,6 @@ const dispatch = createEventDispatcher(); export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value'] = undefined; export let disabled: $$Props['disabled']; export let searchValue: $$Props['searchValue'] = undefined; @@ -20,7 +19,7 @@ export let isIndependentOfSheet: $$Props['isIndependentOfSheet']; let cellRef: HTMLElement; - let isFirstActivated = false; + let shouldToggleOnMouseUp = false; $: valueComparisonOutcome = compareWholeValues(searchValue, value); @@ -51,19 +50,18 @@ } } - function checkAndToggle(e: Event) { - if (!disabled && isActive && e.target === cellRef && !isFirstActivated) { - value = !value; - dispatchUpdate(); - } - isFirstActivated = false; - cellRef?.focus(); + function handleMouseDown() { + shouldToggleOnMouseUp = isActive; } - function handleMouseDown() { - if (!isActive) { - isFirstActivated = true; - dispatch('activate'); + function handleMouseLeave() { + shouldToggleOnMouseUp = false; + } + + function handleMouseUp() { + if (!disabled && isActive && shouldToggleOnMouseUp) { + value = !value; + dispatchUpdate(); } } @@ -71,14 +69,14 @@ {#if value === undefined} diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/date-time/DateTimeCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/date-time/DateTimeCell.svelte index 68cd21e284..100e0567cb 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/date-time/DateTimeCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/date-time/DateTimeCell.svelte @@ -7,7 +7,6 @@ type $$Props = DateTimeCellProps; export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value']; export let disabled: $$Props['disabled']; export let isIndependentOfSheet: $$Props['isIndependentOfSheet']; @@ -23,7 +22,6 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/formatted-input/FormattedInputCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/formatted-input/FormattedInputCell.svelte index aba9dc1f70..d1e86ee950 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/formatted-input/FormattedInputCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/formatted-input/FormattedInputCell.svelte @@ -7,7 +7,6 @@ type $$Props = FormattedInputCellProps; export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value']; export let disabled: $$Props['disabled']; export let isIndependentOfSheet: $$Props['isIndependentOfSheet']; @@ -24,7 +23,6 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/linked-record/LinkedRecordCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/linked-record/LinkedRecordCell.svelte index 91d013a31e..499be0c43a 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/linked-record/LinkedRecordCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/linked-record/LinkedRecordCell.svelte @@ -23,7 +23,6 @@ export let isActive: $$Props['isActive']; export let columnFabric: $$Props['columnFabric']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value'] = undefined; export let searchValue: $$Props['searchValue'] = undefined; export let recordSummary: $$Props['recordSummary'] = undefined; @@ -96,7 +95,6 @@ { class?: string; id?: string; diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte index a6c63969fa..b4ec04d224 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/money/MoneyCell.svelte @@ -7,7 +7,6 @@ type $$Props = MoneyCellProps; export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value']; export let disabled: $$Props['disabled']; export let searchValue: $$Props['searchValue'] = undefined; @@ -20,7 +19,6 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/primary-key/PrimaryKeyCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/primary-key/PrimaryKeyCell.svelte index 66992bef9d..b9423580f2 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/primary-key/PrimaryKeyCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/primary-key/PrimaryKeyCell.svelte @@ -14,7 +14,6 @@ const dispatch = createEventDispatcher(); export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value'] = undefined; export let disabled: $$Props['disabled']; export let tableId: $$Props['tableId']; @@ -29,10 +28,6 @@ e.stopPropagation(); } - function handleValueMouseDown() { - dispatch('activate'); - } - function handleKeyDown(e: KeyboardEvent) { switch (e.key) { case 'Tab': @@ -53,7 +48,6 @@ - + {#if value === undefined} {:else} diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/select/SingleSelectCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/select/SingleSelectCell.svelte index 951dd24e45..9d003c4ad7 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/select/SingleSelectCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/select/SingleSelectCell.svelte @@ -26,7 +26,6 @@ const id = getGloballyUniqueId(); export let isActive: DefinedProps['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: DefinedProps['value'] = undefined; export let disabled: DefinedProps['disabled']; export let isIndependentOfSheet: $$Props['isIndependentOfSheet']; @@ -115,7 +114,6 @@ aria-controls={id} aria-haspopup="listbox" {isActive} - {isSelectedInRange} {disabled} {isIndependentOfSheet} on:mousedown={handleMouseDown} diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte index 0bfc11f7bf..031035bdd5 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/textarea/TextAreaCell.svelte @@ -8,7 +8,6 @@ type $$Props = TextAreaCellProps; export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value'] = undefined; export let disabled: $$Props['disabled']; export let searchValue: $$Props['searchValue'] = undefined; @@ -34,7 +33,6 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/textbox/TextBoxCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/textbox/TextBoxCell.svelte index a5ef92f5c5..317a8b1b62 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/textbox/TextBoxCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/textbox/TextBoxCell.svelte @@ -7,7 +7,6 @@ type $$Props = TextBoxCellProps; export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value'] = undefined; export let disabled: $$Props['disabled']; export let searchValue: $$Props['searchValue'] = undefined; @@ -21,7 +20,6 @@ diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/typeDefinitions.ts b/mathesar_ui/src/components/cell-fabric/data-types/components/typeDefinitions.ts index b91fa59169..9897637c48 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/typeDefinitions.ts +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/typeDefinitions.ts @@ -33,7 +33,6 @@ export type CellValueFormatter = ( export interface CellTypeProps { value: Value | null | undefined; isActive: boolean; - isSelectedInRange: boolean; disabled: boolean; searchValue?: unknown; isProcessing: boolean; diff --git a/mathesar_ui/src/components/cell-fabric/data-types/components/uri/UriCell.svelte b/mathesar_ui/src/components/cell-fabric/data-types/components/uri/UriCell.svelte index 006dc48e2e..a51b54278d 100644 --- a/mathesar_ui/src/components/cell-fabric/data-types/components/uri/UriCell.svelte +++ b/mathesar_ui/src/components/cell-fabric/data-types/components/uri/UriCell.svelte @@ -13,7 +13,6 @@ type $$Props = CellTypeProps; export let isActive: $$Props['isActive']; - export let isSelectedInRange: $$Props['isSelectedInRange']; export let value: $$Props['value'] = undefined; export let disabled: $$Props['disabled']; export let searchValue: $$Props['searchValue'] = undefined; @@ -24,7 +23,6 @@ diff --git a/mathesar_ui/src/components/inspector/cell/CellInspector.svelte b/mathesar_ui/src/components/inspector/cell/CellInspector.svelte new file mode 100644 index 0000000000..659a4859d9 --- /dev/null +++ b/mathesar_ui/src/components/inspector/cell/CellInspector.svelte @@ -0,0 +1,62 @@ + + +
+ {#if activeCellData} + {@const { column, value, recordSummary } = activeCellData} +
+
{$_('content')}
+
+ {#if column} + + {/if} +
+
+ {:else} + {$_('select_cell_view_properties')} + {/if} +
+ + diff --git a/mathesar_ui/src/components/inspector/cell/cellInspectorUtils.ts b/mathesar_ui/src/components/inspector/cell/cellInspectorUtils.ts new file mode 100644 index 0000000000..b493f92069 --- /dev/null +++ b/mathesar_ui/src/components/inspector/cell/cellInspectorUtils.ts @@ -0,0 +1,16 @@ +import type { CellColumnFabric } from '@mathesar/components/cell-fabric/types'; + +interface ActiveCellData { + column: CellColumnFabric; + value: unknown; + recordSummary?: string; +} + +interface SelectionData { + cellCount: number; +} + +export interface SelectedCellData { + activeCellData?: ActiveCellData; + selectionData: SelectionData; +} diff --git a/mathesar_ui/src/components/sheet/README.md b/mathesar_ui/src/components/sheet/README.md index 711552011a..ec8340cbe1 100644 --- a/mathesar_ui/src/components/sheet/README.md +++ b/mathesar_ui/src/components/sheet/README.md @@ -1,9 +1,16 @@ -This is a placeholder directory for the Sheet component which is meant to be implemented for use with Tables, Views and Data Explorer. - -- This would be a lower-order component. -- It would encapsulate: - - Rows - - Cells -- It will _not_ include column headers. -- It would be a pure component and will _not_ hardcode requests from within the component. - - They would be made on the parent components utilizing the Sheet component using callbacks, events and/or by exposing a Sheet API through slot. +# Sheet + +The Sheet components help us display things in a spreadsheet-like format for the table page and the data explorer. + +## `data-sheet-element` values + +We use the `data-sheet-element` HTML attribute for CSS styling and JS functionality. The values are: + +- `header-row`: The top most row of the sheet. It contains the column header cells. +- `data-row`: Used for the remaining rows, including (for now) non-standard ones like grouping headers which don't contain data. +- `positionable-cell`: Cells that span multiple columns or are taken out of regular flow e.g. "New records" message, grouping headers, etc. +- `origin-cell`: The cell in the top-left corner of the sheet. +- `column-header-cell`: Contains the column names. +- `new-column-cell`: Contains the `+` button for adding a new column. +- `row-header-cell`: Contains the row numbers. +- `data-cell`: Regular data cells. diff --git a/mathesar_ui/src/components/sheet/Sheet.svelte b/mathesar_ui/src/components/sheet/Sheet.svelte index 14e24a6954..29cb469587 100644 --- a/mathesar_ui/src/components/sheet/Sheet.svelte +++ b/mathesar_ui/src/components/sheet/Sheet.svelte @@ -1,13 +1,22 @@
{#if columns.length} @@ -140,10 +207,11 @@ --z-index__sheet__column-resizer: 2; --z-index__sheet__active-cell: 3; --z-index__sheet__row-header-cell: 4; - --z-index__sheet__group-header: 5; - --z-index__sheet__new-record-message: 6; - --z-index__sheet__horizontal-scrollbar: 7; - --z-index__sheet__vertical-scrollbar: 8; + --z-index__sheet__positionable-cell: 5; + --z-index__sheet__column-header-cell: 6; + --z-index__sheet__origin-cell: 7; + --z-index__sheet__horizontal-scrollbar: 8; + --z-index__sheet__vertical-scrollbar: 9; --virtual-list-horizontal-scrollbar-z-index: var( --z-index__sheet__horizontal-scrollbar @@ -169,34 +237,12 @@ min-width: 100%; } - :global([data-sheet-element='cell']) { - position: absolute; - display: flex; - align-items: center; - border-bottom: var(--cell-border-horizontal); - border-right: var(--cell-border-vertical); - left: 0; - top: 0; - height: 100%; - } - - :global([data-sheet-element='cell'][data-cell-static='true']) { - position: sticky; - z-index: var(--z-index__sheet__row-header-cell); - } - - :global([data-sheet-element='cell'][data-cell-control='true']) { - font-size: var(--text-size-x-small); - padding: 0 1.5rem; - justify-content: center; - color: var(--color-text-muted); - display: inline-flex; - align-items: center; - height: 100%; + :global([data-sheet-element='data-row']) { + transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); } - :global([data-sheet-element='row']) { - transition: all 0.2s cubic-bezier(0, 0, 0.2, 1); + &.selection-in-progress :global(*) { + cursor: default; } } diff --git a/mathesar_ui/src/components/sheet/SheetCell.svelte b/mathesar_ui/src/components/sheet/SheetCell.svelte deleted file mode 100644 index fb70966943..0000000000 --- a/mathesar_ui/src/components/sheet/SheetCell.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/mathesar_ui/src/components/sheet/SheetCellResizer.svelte b/mathesar_ui/src/components/sheet/SheetCellResizer.svelte index 09e9c9dc50..e53d19f1ea 100644 --- a/mathesar_ui/src/components/sheet/SheetCellResizer.svelte +++ b/mathesar_ui/src/components/sheet/SheetCellResizer.svelte @@ -5,16 +5,19 @@ type SheetColumnIdentifierKey = $$Generic; - const { api } = getSheetContext(); + const { api, stores } = getSheetContext(); export let minColumnWidth = 50; export let columnIdentifierKey: SheetColumnIdentifierKey; let isResizing = false; + + $: ({ selectionInProgress } = stores);
api.getColumnWidth(columnIdentifierKey), @@ -54,4 +57,7 @@ .column-resizer:not(:hover):not(.is-resizing) .indicator { display: none; } + .column-resizer.selection-in-progress { + display: none; + } diff --git a/mathesar_ui/src/components/sheet/SheetClipboardHandler.ts b/mathesar_ui/src/components/sheet/SheetClipboardHandler.ts index 26bcd2a853..c499ed0d47 100644 --- a/mathesar_ui/src/components/sheet/SheetClipboardHandler.ts +++ b/mathesar_ui/src/components/sheet/SheetClipboardHandler.ts @@ -1,45 +1,44 @@ import * as Papa from 'papaparse'; -import { get } from 'svelte/store'; -import SheetSelection, { - isCellSelected, -} from '@mathesar/components/sheet/SheetSelection'; import type { AbstractTypeCategoryIdentifier } from '@mathesar/stores/abstract-types/types'; import type { ClipboardHandler } from '@mathesar/stores/clipboard'; -import type { - ProcessedColumn, - RecordRow, - RecordSummariesForSheet, -} from '@mathesar/stores/table-data'; -import type { QueryRow } from '@mathesar/systems/data-explorer/QueryRunner'; -import type { ProcessedQueryOutputColumn } from '@mathesar/systems/data-explorer/utils'; +import type { RecordSummariesForSheet } from '@mathesar/stores/table-data'; import type { ReadableMapLike } from '@mathesar/typeUtils'; import { labeledCount } from '@mathesar/utils/languageUtils'; -import { ImmutableSet, type MakeToast } from '@mathesar-component-library'; +import type { ImmutableSet } from '@mathesar-component-library'; const MIME_PLAIN_TEXT = 'text/plain'; const MIME_MATHESAR_SHEET_CLIPBOARD = 'application/x-vnd.mathesar-sheet-clipboard'; -/** Keys are row ids, values are records */ -type IndexedRecords = Map>; +/** + * A column which allows the cells in it to be copied. + */ +interface CopyableColumn { + abstractType: { identifier: AbstractTypeCategoryIdentifier }; + formatCellValue: ( + cellValue: unknown, + recordSummaries?: RecordSummariesForSheet, + ) => string | null | undefined; +} -function getRawCellValue< - Column extends ProcessedQueryOutputColumn | ProcessedColumn, ->( - indexedRecords: IndexedRecords, - rowId: number, - columnId: Column['id'], -): unknown { - return indexedRecords.get(rowId)?.[String(columnId)]; +/** + * This is the stuff we need to know from the sheet in order to copy the content + * of cells to the clipboard. + */ +interface CopyingContext { + /** Keys are row ids, values are records */ + rowsMap: Map>; + columnsMap: ReadableMapLike; + recordSummaries: RecordSummariesForSheet; + selectedRowIds: ImmutableSet; + selectedColumnIds: ImmutableSet; } -function getFormattedCellValue< - Column extends ProcessedQueryOutputColumn | ProcessedColumn, ->( +function getFormattedCellValue( rawCellValue: unknown, - columnsMap: ReadableMapLike, - columnId: Column['id'], + columnsMap: CopyingContext['columnsMap'], + columnId: string, recordSummaries: RecordSummariesForSheet, ): string { if (rawCellValue === undefined || rawCellValue === null) { @@ -88,70 +87,37 @@ export interface StructuredCell { formatted: string; } -interface SheetClipboardHandlerDeps< - Row extends QueryRow | RecordRow, - Column extends ProcessedQueryOutputColumn | ProcessedColumn, -> { - selection: SheetSelection; - toast: MakeToast; - getRows(): Row[]; - getColumnsMap(): ReadableMapLike; - getRecordSummaries(): RecordSummariesForSheet; +interface Dependencies { + getCopyingContext(): CopyingContext; + showToastInfo(msg: string): void; } -export class SheetClipboardHandler< - Row extends QueryRow | RecordRow, - Column extends ProcessedQueryOutputColumn | ProcessedColumn, -> implements ClipboardHandler -{ - private readonly deps: SheetClipboardHandlerDeps; +export class SheetClipboardHandler implements ClipboardHandler { + private readonly deps: Dependencies; - constructor(deps: SheetClipboardHandlerDeps) { + constructor(deps: Dependencies) { this.deps = deps; } - private getColumnIds(cells: ImmutableSet) { - return this.deps.selection.getSelectedUniqueColumnsId( - cells, - // We don't care about the columns selected when the table is empty, - // because we only care about cells selected that have content. - new ImmutableSet(), - ); - } - - private getRowIds(cells: ImmutableSet) { - return this.deps.selection.getSelectedUniqueRowsId(cells); - } - private getCopyContent(): { structured: string; tsv: string } { - const cells = get(this.deps.selection.selectedCells); - const indexedRecords = new Map( - this.deps.getRows().map((r) => [r.rowIndex, r.record]), - ); - const columns = this.deps.getColumnsMap(); - const recordSummaries = this.deps.getRecordSummaries(); - + const context = this.deps.getCopyingContext(); const tsvRows: string[][] = []; const structuredRows: StructuredCell[][] = []; - for (const rowId of this.getRowIds(cells)) { + for (const rowId of context.selectedRowIds) { const tsvRow: string[] = []; const structuredRow: StructuredCell[] = []; - for (const columnId of this.getColumnIds(cells)) { - const column = columns.get(columnId); - if (!isCellSelected(cells, { rowIndex: rowId }, { id: columnId })) { - // Ignore cells that are not selected. - continue; - } + for (const columnId of context.selectedColumnIds) { + const column = context.columnsMap.get(columnId); if (!column) { // Ignore cells with no associated column. This should never happen. continue; } - const rawCellValue = getRawCellValue(indexedRecords, rowId, columnId); + const rawCellValue = context.rowsMap.get(rowId)?.[columnId]; const formattedCellValue = getFormattedCellValue( rawCellValue, - columns, + context.columnsMap, columnId, - recordSummaries, + context.recordSummaries, ); const type = column.abstractType.identifier; structuredRow.push({ @@ -164,7 +130,9 @@ export class SheetClipboardHandler< tsvRows.push(tsvRow); structuredRows.push(structuredRow); } - this.deps.toast.info(`Copied ${labeledCount(cells.size, 'cells')}.`); + const cellCount = + context.selectedRowIds.size * context.selectedColumnIds.size; + this.deps.showToastInfo(`Copied ${labeledCount(cellCount, 'cells')}.`); return { structured: JSON.stringify(structuredRows), tsv: serializeTsv(tsvRows), diff --git a/mathesar_ui/src/components/sheet/SheetHeader.svelte b/mathesar_ui/src/components/sheet/SheetHeader.svelte index c2b98dff0e..7922e277e8 100644 --- a/mathesar_ui/src/components/sheet/SheetHeader.svelte +++ b/mathesar_ui/src/components/sheet/SheetHeader.svelte @@ -42,7 +42,7 @@
@@ -51,7 +51,7 @@
diff --git a/mathesar_ui/src/components/sheet/cells/SheetColumnHeaderCell.svelte b/mathesar_ui/src/components/sheet/cells/SheetColumnHeaderCell.svelte new file mode 100644 index 0000000000..0feba78d76 --- /dev/null +++ b/mathesar_ui/src/components/sheet/cells/SheetColumnHeaderCell.svelte @@ -0,0 +1,35 @@ + + +
+ +
+ + diff --git a/mathesar_ui/src/components/sheet/cells/SheetDataCell.svelte b/mathesar_ui/src/components/sheet/cells/SheetDataCell.svelte new file mode 100644 index 0000000000..bdfb54f38f --- /dev/null +++ b/mathesar_ui/src/components/sheet/cells/SheetDataCell.svelte @@ -0,0 +1,66 @@ + + +
+ {#if hasSelectionBackground} + + {/if} + +
+ + diff --git a/mathesar_ui/src/components/sheet/cells/SheetOriginCell.svelte b/mathesar_ui/src/components/sheet/cells/SheetOriginCell.svelte new file mode 100644 index 0000000000..7af9d9748b --- /dev/null +++ b/mathesar_ui/src/components/sheet/cells/SheetOriginCell.svelte @@ -0,0 +1,26 @@ + + +
+ +
+ + diff --git a/mathesar_ui/src/components/sheet/SheetPositionableCell.svelte b/mathesar_ui/src/components/sheet/cells/SheetPositionableCell.svelte similarity index 74% rename from mathesar_ui/src/components/sheet/SheetPositionableCell.svelte rename to mathesar_ui/src/components/sheet/cells/SheetPositionableCell.svelte index 4cc8fe18ee..eda77ca02e 100644 --- a/mathesar_ui/src/components/sheet/SheetPositionableCell.svelte +++ b/mathesar_ui/src/components/sheet/cells/SheetPositionableCell.svelte @@ -1,6 +1,6 @@ - +
+ +
+ + diff --git a/mathesar_ui/src/components/sheet/cells/SheetRowHeaderCell.svelte b/mathesar_ui/src/components/sheet/cells/SheetRowHeaderCell.svelte new file mode 100644 index 0000000000..2409c6a1e3 --- /dev/null +++ b/mathesar_ui/src/components/sheet/cells/SheetRowHeaderCell.svelte @@ -0,0 +1,37 @@ + + +
+ +
+ + diff --git a/mathesar_ui/src/components/sheet/cells/index.ts b/mathesar_ui/src/components/sheet/cells/index.ts new file mode 100644 index 0000000000..2fd6716ac2 --- /dev/null +++ b/mathesar_ui/src/components/sheet/cells/index.ts @@ -0,0 +1,7 @@ +export { default as SheetColumnHeaderCell } from './SheetColumnHeaderCell.svelte'; +export { default as SheetDataCell } from './SheetDataCell.svelte'; +export { default as SheetColumnCreationCell } from './SheetColumnCreationCell.svelte'; +export { default as SheetOriginCell } from './SheetOriginCell.svelte'; +export { default as SheetPositionableCell } from './SheetPositionableCell.svelte'; +export { default as SheetRowHeaderCell } from './SheetRowHeaderCell.svelte'; +export * from './sheetCellUtils'; diff --git a/mathesar_ui/src/components/sheet/cells/sheetCellUtils.ts b/mathesar_ui/src/components/sheet/cells/sheetCellUtils.ts new file mode 100644 index 0000000000..0049923d5d --- /dev/null +++ b/mathesar_ui/src/components/sheet/cells/sheetCellUtils.ts @@ -0,0 +1,14 @@ +import { type Readable, derived } from 'svelte/store'; + +import { getSheetContext } from '../utils'; + +export function getSheetCellStyle( + columnIdentifierKey: ColumnIdentifierKey, +): Readable { + const { stores } = getSheetContext(); + const { columnStyleMap } = stores; + return derived(columnStyleMap, (map) => { + const columnPosition = map.get(columnIdentifierKey); + return columnPosition?.styleString; + }); +} diff --git a/mathesar_ui/src/components/sheet/index.ts b/mathesar_ui/src/components/sheet/index.ts index cc67f1275b..e541c3f139 100644 --- a/mathesar_ui/src/components/sheet/index.ts +++ b/mathesar_ui/src/components/sheet/index.ts @@ -1,17 +1,11 @@ export { default as Sheet } from './Sheet.svelte'; export { default as SheetHeader } from './SheetHeader.svelte'; -export { default as SheetCell } from './SheetCell.svelte'; -export { default as SheetPositionableCell } from './SheetPositionableCell.svelte'; export { default as SheetCellResizer } from './SheetCellResizer.svelte'; export { default as SheetVirtualRows } from './SheetVirtualRows.svelte'; export { default as SheetRow } from './SheetRow.svelte'; -export { default as SheetSelection } from './SheetSelection'; export { - isColumnSelected, - isRowSelected, - isCellSelected, - getSelectedRowIndex, - isCellActive, scrollBasedOnActiveCell, scrollBasedOnSelection, -} from './SheetSelection'; +} from './sheetScrollingUtils'; + +export * from './cells'; diff --git a/mathesar_ui/src/components/sheet/selection/Direction.ts b/mathesar_ui/src/components/sheet/selection/Direction.ts new file mode 100644 index 0000000000..b53704f378 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/Direction.ts @@ -0,0 +1,28 @@ +export enum Direction { + Up = 'up', + Down = 'down', + Left = 'left', + Right = 'right', +} + +export function getColumnOffset(direction: Direction): number { + switch (direction) { + case Direction.Left: + return -1; + case Direction.Right: + return 1; + default: + return 0; + } +} + +export function getRowOffset(direction: Direction): number { + switch (direction) { + case Direction.Up: + return -1; + case Direction.Down: + return 1; + default: + return 0; + } +} diff --git a/mathesar_ui/src/components/sheet/selection/Plane.ts b/mathesar_ui/src/components/sheet/selection/Plane.ts new file mode 100644 index 0000000000..5a39907614 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/Plane.ts @@ -0,0 +1,227 @@ +import { makeCellId, makeCells, parseCellId } from '../cellIds'; + +import { Direction, getColumnOffset, getRowOffset } from './Direction'; +import Series from './Series'; + +/** + * This describes the different kinds of cells that can be adjacent to a given + * cell in a particular direction. + */ +export type AdjacentCell = + | { + type: 'dataCell'; + cellId: string; + } + | { + type: 'placeholderCell'; + cellId: string; + } + | { + type: 'none'; + }; + +function noAdjacentCell(): AdjacentCell { + return { type: 'none' }; +} + +function adjacentDataCell(cellId: string): AdjacentCell { + return { type: 'dataCell', cellId }; +} + +function adjacentPlaceholderCell(cellId: string): AdjacentCell { + return { type: 'placeholderCell', cellId }; +} + +/** + * A Plane is like a coordinate system for a sheet. We can query it to find the + * ids of cells within certain bounding rectangles. + * + * The Plane can also have a "placeholder row", which is a row at the bottom of + * the sheet that provides a visual cue to the user that they can add more rows. + * It never contains any data, but we allow the user to move the active cell + * into the placeholder row in order to easily add more rows. + * + * The term "Flexible" is used in methods to indicate that it will gracefully + * handle ids of cells within the placeholder row. + */ +export default class Plane { + readonly rowIds: Series; + + readonly columnIds: Series; + + readonly placeholderRowId: string | undefined; + + constructor( + rowIds: Series = new Series(), + columnIds: Series = new Series(), + placeholderRowId?: string, + ) { + this.rowIds = rowIds; + this.columnIds = columnIds; + this.placeholderRowId = placeholderRowId; + } + + /** + * @returns the row id that should be used to represent the given row id in + * the plane. If the given row id is the placeholder row id, then the last + * row id in the plane will be returned. Otherwise, the given row id will be + * returned. + */ + private normalizeFlexibleRowId(rowId: string): string | undefined { + if (rowId === this.placeholderRowId) { + return this.rowIds.last; + } + return rowId; + } + + /** + * @returns an iterable of all the data cells in the plane. This does not + * include header cells or placeholder cells. + */ + allDataCells(): Iterable { + return makeCells(this.rowIds, this.columnIds); + } + + /** + * @returns an iterable of all the data cells in the plane that are in or + * between the two given rows. This does not include header cells. + * + * If either of the provided rows are placeholder rows, then the last data row + * will be used in their place. This ensures that the selection is made only + * of data rows, and will never include the placeholder row, even if a user + * drags to select it. + */ + dataCellsInFlexibleRowRange( + rowIdA: string, + rowIdB: string, + ): Iterable { + const a = this.normalizeFlexibleRowId(rowIdA); + if (a === undefined) { + return []; + } + const b = this.normalizeFlexibleRowId(rowIdB); + if (b === undefined) { + return []; + } + return makeCells(this.rowIds.range(a, b), this.columnIds); + } + + /** + * @returns an iterable of all the data cells in the plane that are in or + * between the two given columns. This does not include header cells or + * placeholder cells. + */ + dataCellsInColumnRange( + columnIdA: string, + columnIdB: string, + ): Iterable { + return makeCells(this.rowIds, this.columnIds.range(columnIdA, columnIdB)); + } + + /** + * @returns an iterable of all the data cells in the plane that are in or + * between the two given cell. This does not include header cells. + * + * If either of the provided cells are placeholder cells, then cells in the + * last row and last column will be used in their place. This ensures that the + * selection is made only of data cells, and will never include the + * placeholder cell, even if a user drags to select it. + */ + dataCellsInFlexibleCellRange( + cellIdA: string, + cellIdB: string, + ): Iterable { + const cellA = parseCellId(cellIdA); + const cellB = parseCellId(cellIdB); + return this.dataCellsInFlexibleRowColumnRange( + cellA.rowId, + cellB.rowId, + cellA.columnId, + cellB.columnId, + ); + } + + /** + * @returns an iterable of all the data cells in the plane that are within the + * rectangle bounded by the given rows and columns. This does not include + * header cells. + * + * If either of the provided rowIds are placeholder cells, then cells in the + * last row and last column will be used in their place. This ensures that the + * selection is made only of data cells, and will never include the + * placeholder cell, even if a user drags to select it. + */ + dataCellsInFlexibleRowColumnRange( + flexibleRowIdA: string, + flexibleRowIdB: string, + columnIdA: string, + columnIdB: string, + ): Iterable { + const rowIdA = this.normalizeFlexibleRowId(flexibleRowIdA); + if (rowIdA === undefined) { + return []; + } + const rowIdB = this.normalizeFlexibleRowId(flexibleRowIdB); + if (rowIdB === undefined) { + return []; + } + const rowIds = this.rowIds.range(rowIdA, rowIdB); + const columnIds = this.columnIds.range(columnIdA, columnIdB); + return makeCells(rowIds, columnIds); + } + + getAdjacentCell(cellId: string, direction: Direction): AdjacentCell { + const cell = parseCellId(cellId); + + const columnOffset = getColumnOffset(direction); + const newColumnId = this.columnIds.offset(cell.columnId, columnOffset); + if (newColumnId === undefined) { + return noAdjacentCell(); + } + + if (cell.rowId === this.placeholderRowId) { + if (direction === Direction.Up) { + const lastRowId = this.rowIds.last; + if (lastRowId === undefined) { + // Can't go up from the placeholder row if there are no data rows + return noAdjacentCell(); + } + // Move up from the placeholder row into the last data row + return adjacentDataCell(makeCellId(lastRowId, newColumnId)); + } + if (direction === Direction.Down) { + // Can't go down from the placeholder row + return noAdjacentCell(); + } + // Move laterally within the placeholder row + return adjacentPlaceholderCell(makeCellId(cell.rowId, newColumnId)); + } + + const rowOffset = getRowOffset(direction); + const newRowId = this.rowIds.offset(cell.rowId, rowOffset); + if (newRowId === undefined) { + if (direction === Direction.Down) { + if (this.placeholderRowId === undefined) { + // Can't go down from the last data row if there is no placeholder row + return noAdjacentCell(); + } + const newCellId = makeCellId(this.placeholderRowId, newColumnId); + // Move down from the last data row into the placeholder row + return adjacentPlaceholderCell(newCellId); + } + // Can't move up from the first data row + return noAdjacentCell(); + } + + // Normal movement from one data cell to another data cell + return adjacentDataCell(makeCellId(newRowId, newColumnId)); + } + + get hasResultRows(): boolean { + return this.rowIds.length > 0; + } + + get hasPlaceholder(): boolean { + return this.placeholderRowId !== undefined; + } +} diff --git a/mathesar_ui/src/components/sheet/selection/Series.ts b/mathesar_ui/src/components/sheet/selection/Series.ts new file mode 100644 index 0000000000..1f4c070280 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/Series.ts @@ -0,0 +1,150 @@ +import { filter, findBest, firstHighest, firstLowest } from 'iter-tools'; + +import { ImmutableMap } from '@mathesar-component-library'; + +/** + * A Series is an immutable ordered collection of unique values with methods + * that provide efficient access to _ranges_ of those values. + */ +export default class Series { + private readonly values: Value[]; + + /** Maps the id value to its index */ + private readonly indexLookup: ImmutableMap; + + /** + * @throws Error if duplicate values are provided + */ + constructor(values: Value[] = []) { + this.values = values; + this.indexLookup = new ImmutableMap( + values.map((value, index) => [value, index]), + ); + if (new Set(values).size !== values.length) { + throw new Error('Duplicate values are not allowed within a Series.'); + } + } + + getIndex(value: Value): number | undefined { + return this.indexLookup.get(value); + } + + get length(): number { + return this.values.length; + } + + /** + * Return an iterator of all values between the two provided values, + * inclusive. Iteration occurs in the order stored. The two provided values + * may be present in any order. + * + * @throws an Error if either value is not present in the sequence + */ + range(a: Value, b: Value): Iterable { + const aIndex = this.getIndex(a); + const bIndex = this.getIndex(b); + + if (aIndex === undefined || bIndex === undefined) { + throw new Error('Id value not found within sequence.'); + } + + const startIndex = Math.min(aIndex, bIndex); + const endIndex = Math.max(aIndex, bIndex); + + return this.values.slice(startIndex, endIndex + 1); + } + + /** + * Get the value at a specific index. + */ + at(index: number): Value | undefined { + return this.values[index]; + } + + get first(): Value | undefined { + return this.at(0); + } + + get last(): Value | undefined { + return this.at(this.values.length - 1); + } + + has(value: Value): boolean { + return this.getIndex(value) !== undefined; + } + + /** + * This method is used for `min` and `max`, but could potentially be used for + * other things too. + * + * @param comparator Corresponds to the [iter-tools comparators][1] + * + * [1]: + * https://github.com/iter-tools/iter-tools/blob/d7.5/API.md#compare-values-and-return-true-or-false + */ + best( + values: Iterable, + comparator: (best: number, v: number) => boolean, + ): Value | undefined { + const validValues = filter((v) => this.has(v), values); + return findBest(comparator, (v) => this.getIndex(v) ?? 0, validValues); + } + + /** + * Of all the supplied values, return the value that is the lowest as ordered + * within the series. If no such value is present, then `undefined` will + * be returned. + */ + min(values: Iterable): Value | undefined { + return this.best(values, firstLowest); + } + + /** + * Of all the supplied values, return the value that is the highest as ordered + * within the series. If no such value is present, then `undefined` will be + * returned. + */ + max(values: Iterable): Value | undefined { + return this.best(values, firstHighest); + } + + /** + * @returns the value positioned relative to the given value by the given + * offset. If the offset is 0, the given value will be returned. If the offset + * is positive, the returned value will be that many positions _after_ the + * given value. If no such value is present, then `undefined` will be + * returned. + */ + offset(value: Value, offset: number): Value | undefined { + if (offset === 0) { + return this.has(value) ? value : undefined; + } + const index = this.getIndex(value); + if (index === undefined) { + return undefined; + } + return this.values[index + offset]; + } + + /** + * This is similar to `offset`, but accepts an iterable of values and treats + * them as a unified block to be collapsed into one value. When `offset` is + * positive, the returned value will be that many positions _after_ the last + * value in the block. If no such value is present, then `undefined` will be + * returned. If offset is zero, then `undefined` will be returned. + */ + collapsedOffset(values: Iterable, offset: number): Value | undefined { + if (offset === 0) { + return undefined; + } + const outerValue = offset > 0 ? this.max(values) : this.min(values); + if (outerValue === undefined) { + return undefined; + } + return this.offset(outerValue, offset); + } + + [Symbol.iterator](): Iterator { + return this.values[Symbol.iterator](); + } +} diff --git a/mathesar_ui/src/components/sheet/selection/SheetSelection.ts b/mathesar_ui/src/components/sheet/selection/SheetSelection.ts new file mode 100644 index 0000000000..de1039b2bb --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/SheetSelection.ts @@ -0,0 +1,484 @@ +import { execPipe, filter, first, map } from 'iter-tools'; + +import { match } from '@mathesar/utils/patternMatching'; +import { ImmutableSet, assertExhaustive } from '@mathesar-component-library'; + +import { makeCellId, makeCells, parseCellId } from '../cellIds'; + +import { + type Basis, + basisFromDataCells, + basisFromEmptyColumns, + basisFromOneDataCell, + basisFromPlaceholderCell, + basisFromZeroEmptyColumns, + emptyBasis, +} from './basis'; +import { Direction, getColumnOffset } from './Direction'; +import Plane from './Plane'; +import { + type SheetCellDetails, + fitSelectedValuesToSeriesTransformation, +} from './selectionUtils'; + +function getFullySelectedColumnIds( + plane: Plane, + basis: Basis, +): ImmutableSet { + if (basis.type === 'dataCells') { + // The logic within this branch is somewhat complex because: + // - We might want to support non-rectangular selections someday. + // - For performance, we want to avoid iterating over all the selected + // cells. + + const selectedRowCount = basis.rowIds.size; + const availableRowCount = plane.rowIds.length; + if (selectedRowCount < availableRowCount) { + // Performance heuristic. If the number of selected rows is less than the + // total number of rows, we can assume that no column exist in which all + // rows are selected. + return new ImmutableSet(); + } + + const selectedColumnCount = basis.columnIds.size; + const selectedCellCount = basis.cellIds.size; + const avgCellsSelectedPerColumn = selectedCellCount / selectedColumnCount; + if (avgCellsSelectedPerColumn === availableRowCount) { + // Performance heuristic. We know that no column can have more cells + // selected than the number of rows. Thus, if the average number of cells + // selected per column is equal to the number of rows, then we know that + // all selected columns are fully selected. + return basis.columnIds; + } + + // This is the worst-case scenario, performance-wise, which is why we try to + // return early before hitting this branch. This case will only happen when + // we have a mix of fully selected columns and partially selected columns. + // This case should be rare because most (maybe all?) selections are + // rectangular. + const countSelectedCellsPerColumn = new Map(); + for (const cellId of basis.cellIds) { + const { columnId } = parseCellId(cellId); + const count = countSelectedCellsPerColumn.get(columnId) ?? 0; + countSelectedCellsPerColumn.set(columnId, count + 1); + } + const fullySelectedColumnIds = execPipe( + countSelectedCellsPerColumn, + filter(([, count]) => count === availableRowCount), + map(([id]) => id), + ); + return new ImmutableSet(fullySelectedColumnIds); + } + + if (basis.type === 'emptyColumns') { + return basis.columnIds; + } + + if (basis.type === 'placeholderCell') { + return new ImmutableSet(); + } + + if (basis.type === 'empty') { + return new ImmutableSet(); + } + + return assertExhaustive(basis.type); +} + +/** + * This is an immutable data structure which fully represents the state of a + * selection selection of cells along with the Plane in which they were + * selected. + * + * We store the Plane here so to make it possible to provide methods on + * `SheetSelection` instances which return new mutations of the selection that + * are still valid within the Plane. + */ +export default class SheetSelection { + private readonly plane: Plane; + + private readonly basis: Basis; + + /** Ids of columns in which _all_ data cells are selected */ + fullySelectedColumnIds: ImmutableSet; + + constructor(plane: Plane = new Plane(), basis: Basis = emptyBasis()) { + this.plane = plane; + this.basis = basis; + // TODO validate that basis is valid within plane. For example, remove + // selected cells from the basis that do not occur within the plane. + + this.fullySelectedColumnIds = getFullySelectedColumnIds( + this.plane, + this.basis, + ); + } + + get activeCellId(): string | undefined { + return this.basis.activeCellId; + } + + get cellIds(): ImmutableSet { + return this.basis.cellIds; + } + + /** Ids of rows which are at least _partially_ selected */ + get rowIds(): ImmutableSet { + return this.basis.rowIds; + } + + /** Ids of columns which are at least _partially_ selected */ + get columnIds(): ImmutableSet { + return this.basis.columnIds; + } + + private withBasis(basis: Basis): SheetSelection { + return new SheetSelection(this.plane, basis); + } + + /** + * @returns a new selection with all cells selected. The active cell will be + * the cell in the first row and first column. + */ + ofAllDataCells(): SheetSelection { + if (!this.plane.hasResultRows) { + return this.withBasis(basisFromZeroEmptyColumns()); + } + return this.withBasis(basisFromDataCells(this.plane.allDataCells())); + } + + /** + * @returns a new selection with the cell in the first row and first column + * selected. + */ + ofFirstDataCell(): SheetSelection { + const firstCellId = first(this.plane.allDataCells()); + if (firstCellId === undefined) { + return this.withBasis(basisFromZeroEmptyColumns()); + } + return this.withBasis(basisFromDataCells([firstCellId])); + } + + /** + * @returns a new selection formed by selecting the one cell that we think + * users are most likely to want selected after choosing to add a new record. + * + * We use the last row because that's where we add new records. If there is + * only one column, then we select the first cell in that column. Otherwise, + * we select the cell in the second column (because we assume the first column + * is probably a PK column which can't accept data entry.) + */ + ofNewRecordDataEntryCell(): SheetSelection { + const rowId = this.plane.rowIds.last; + if (!rowId) return this; + const columnId = this.plane.columnIds.at(1) ?? this.plane.columnIds.first; + if (!columnId) return this; + return this.ofOneCell(makeCellId(rowId, columnId)); + } + + /** + * @returns a new selection with all rows selected between (and including) the + * provided rows. + * + * If either of the provided rows are placeholder rows, then the last data row + * will be used in their place. This ensures that the selection is made only + * of data rows, and will never include the placeholder row, even if a user + * drags to select it. + */ + ofRowRange(rowIdA: string, rowIdB: string): SheetSelection { + return this.withBasis( + basisFromDataCells( + this.plane.dataCellsInFlexibleRowRange(rowIdA, rowIdB), + makeCellId(rowIdA, this.plane.columnIds.first ?? ''), + ), + ); + } + + /** + * @returns a new selection of all data cells in all columns between the + * provided columnIds, inclusive. + */ + ofColumnRange(columnIdA: string, columnIdB: string): SheetSelection { + const newBasis = this.plane.rowIds.first + ? basisFromDataCells( + this.plane.dataCellsInColumnRange(columnIdA, columnIdB), + makeCellId(this.plane.rowIds.first, columnIdA), + ) + : basisFromEmptyColumns(this.plane.columnIds.range(columnIdA, columnIdB)); + return this.withBasis(newBasis); + } + + /** + * @returns a new selection of all data cells in the intersection of the + * provided rows and columns. + */ + ofRowColumnIntersection( + rowIds: Iterable, + columnIds: Iterable, + ): SheetSelection { + return this.withBasis(basisFromDataCells(makeCells(rowIds, columnIds))); + } + + /** + * @returns a new selection formed by the rectangle between the provided + * cells, inclusive. + * + * If either of the provided cells are in the placeholder row, then the cell + * in the last data row will be used in its place. This ensures that the + * selection is made only of data cells, and will never include cells in the + * placeholder row, even if a user drags to select a cell in it. + */ + ofDataCellRange(cellIdA: string, cellIdB: string): SheetSelection { + return this.withBasis( + basisFromDataCells( + this.plane.dataCellsInFlexibleCellRange(cellIdA, cellIdB), + cellIdA, + ), + ); + } + + ofSheetCellRange( + startingCell: SheetCellDetails, + endingCell: SheetCellDetails, + ): SheetSelection { + // Nullish coalescing is safe here since we know we'll have a first row and + // first column in the cases where we're selecting things. + const firstRow = () => this.plane.rowIds.first ?? ''; + const firstColumn = () => this.plane.columnIds.first ?? ''; + + return match(startingCell, 'type', { + 'data-cell': ({ cellId: startingCellId }) => { + const endingCellId = match(endingCell, 'type', { + 'data-cell': (b) => b.cellId, + 'column-header-cell': (b) => makeCellId(firstRow(), b.columnId), + 'row-header-cell': (b) => makeCellId(b.rowId, firstColumn()), + }); + return this.ofDataCellRange(startingCellId, endingCellId); + }, + + 'column-header-cell': ({ columnId: startingColumnId }) => { + const endingColumnId = match(endingCell, 'type', { + 'data-cell': (b) => parseCellId(b.cellId).columnId, + 'column-header-cell': (b) => b.columnId, + 'row-header-cell': () => firstColumn(), + }); + return this.ofColumnRange(startingColumnId, endingColumnId); + }, + + 'row-header-cell': ({ rowId: startingRowId }) => { + const endingRowId = match(endingCell, 'type', { + 'data-cell': (b) => parseCellId(b.cellId).rowId, + 'column-header-cell': () => firstRow(), + 'row-header-cell': (b) => b.rowId, + }); + return this.ofRowRange(startingRowId, endingRowId); + }, + }); + } + + /** + * @returns a new selection formed from one cell within the data rows or the + * placeholder row. + */ + ofOneCell(cellId: string): SheetSelection { + const { rowId } = parseCellId(cellId); + const { placeholderRowId } = this.plane; + const makeBasis = + rowId === placeholderRowId + ? basisFromPlaceholderCell + : basisFromOneDataCell; + return this.withBasis(makeBasis(cellId)); + } + + ofOneRow(rowId: string): SheetSelection { + return this.ofRowRange(rowId, rowId); + } + + ofOneColumn(columnId: string): SheetSelection { + return this.ofColumnRange(columnId, columnId); + } + + /** + * @returns a new selection that fits within the provided plane. This is + * relevant when paginating or when rows/columns are deleted/reordered/inserted. + */ + forNewPlane(newPlane: Plane): SheetSelection { + if (this.basis.type === 'dataCells') { + if (!newPlane.hasResultRows) { + return new SheetSelection(newPlane, basisFromZeroEmptyColumns()); + } + + const [minRowId, maxRowId] = fitSelectedValuesToSeriesTransformation( + this.basis.rowIds, + this.plane.rowIds, + newPlane.rowIds, + ); + const [minColumnId, maxColumnId] = + fitSelectedValuesToSeriesTransformation( + this.basis.columnIds, + this.plane.columnIds, + newPlane.columnIds, + ); + if ( + minRowId === undefined || + maxRowId === undefined || + minColumnId === undefined || + maxColumnId === undefined + ) { + return new SheetSelection(newPlane); + } + + const cellIds = newPlane.dataCellsInFlexibleRowColumnRange( + minRowId, + maxRowId, + minColumnId, + maxColumnId, + ); + + return new SheetSelection( + newPlane, + basisFromDataCells(cellIds, this.activeCellId), + ); + } + + if (this.basis.type === 'emptyColumns') { + if (newPlane.hasResultRows) { + return new SheetSelection(newPlane); + } + const minColumnId = newPlane.columnIds.min(this.basis.columnIds); + const maxColumnId = newPlane.columnIds.max(this.basis.columnIds); + if (minColumnId === undefined || maxColumnId === undefined) { + return new SheetSelection(newPlane, basisFromZeroEmptyColumns()); + } + const columnIds = newPlane.columnIds.range(minColumnId, maxColumnId); + return new SheetSelection(newPlane, basisFromEmptyColumns(columnIds)); + } + + if (this.basis.type === 'placeholderCell') { + const columnId = first(this.basis.columnIds); + if (columnId === undefined) { + return new SheetSelection(newPlane); + } + const newPlaneHasSelectedCell = + newPlane.columnIds.has(columnId) && + newPlane.placeholderRowId === this.plane.placeholderRowId; + if (newPlaneHasSelectedCell) { + // If we can retain the selected placeholder cell, then do so. + return new SheetSelection(newPlane, basisFromPlaceholderCell(columnId)); + } + // Otherwise, return an empty selection + return new SheetSelection(newPlane); + } + + if (this.basis.type === 'empty') { + // If the selection is empty, we keep it empty. + return new SheetSelection(newPlane); + } + + return assertExhaustive(this.basis.type); + } + + /** + * @returns a new selection formed by the rectangle between the currently + * active cell and provided cell, inclusive. + * + * This operation is designed to mimic the behavior of Google Sheets when + * shift-clicking a specific cell, or when dragging to create a new selection. + * A new selection is created that contains all cells in a rectangle bounded + * by the active cell (also the first cell selected when dragging) and the + * provided cell. + */ + drawnToDataCell(cellId: string): SheetSelection { + return this.ofDataCellRange(this.activeCellId ?? cellId, cellId); + } + + /** + * @returns a new selection formed by the cells in all the rows between the + * active cell and the provided row, inclusive. + */ + drawnToRow(rowId: string): SheetSelection { + const activeRowId = this.activeCellId + ? parseCellId(this.activeCellId).rowId + : rowId; + return this.ofRowRange(activeRowId, rowId); + } + + drawnToColumn(columnId: string): SheetSelection { + // TODO improve handling for empty columns + + const activeColumnId = this.activeCellId + ? parseCellId(this.activeCellId).columnId + : columnId; + return this.ofColumnRange(activeColumnId, columnId); + } + + /** + * @returns a new selection that mimics the behavior of arrow keys in + * spreadsheets. If the active cell can be moved in the provided direction, + * then a new selection is created with only that one cell selected. + */ + collapsedAndMoved(direction: Direction): SheetSelection { + if (this.basis.type === 'emptyColumns') { + const offset = getColumnOffset(direction); + const newActiveColumnId = this.plane.columnIds.collapsedOffset( + this.basis.columnIds, + offset, + ); + if (newActiveColumnId === undefined) { + // If we couldn't shift in the direction, then do nothing + return this; + } + return this.withBasis(basisFromEmptyColumns([newActiveColumnId])); + } + + if (this.activeCellId === undefined) { + // If no cells are selected, then select the first data cell + return this.ofFirstDataCell(); + } + + const adjacent = this.plane.getAdjacentCell(this.activeCellId, direction); + + if (adjacent.type === 'none') { + // If we can't move anywhere, then do nothing + return this; + } + if (adjacent.type === 'dataCell' || adjacent.type === 'placeholderCell') { + // Move to an adjacent data cell or adjacent placeholder cell + return this.ofOneCell(adjacent.cellId); + } + return assertExhaustive(adjacent); + } + + /** + * @returns a new selection with the active cell moved within the selection, + * left to right, top to bottom. + * + * This is to handle the `Tab` and `Shift+Tab` keys. + */ + withActiveCellAdvanced( + direction: 'forward' | 'back' = 'forward', + ): SheetSelection { + throw new Error('Not implemented'); + } + + /** + * @returns a new selection that is grown or shrunk to mimic the behavior of + * Google Sheets when manipulating selections via keyboard shortcuts like + * `Shift+Down`. The selection is deformed in the direction of the provided + * argument. The selection is _shrunk_ if doing so will keep the active cell + * within the selection. Otherwise, the selection is _grown_. + * + * Note that other spreadsheet applications have slightly different behavior + * for Shift + arrow keys. For example, LibreOffice Calc maintains state for + * the origin of the selection separate from the active cell. The two cells + * may be different if the user presses `Tab` after making a selection. In + * this case, the selection will be resized with respect to the origin, not + * the active cell. We chose to mimic Google Sheets behavior here because it + * is simpler. + */ + resized(direction: Direction): SheetSelection { + // TODO + console.log('Sheet selection resizing is not yet implemented'); + return this; + } +} diff --git a/mathesar_ui/src/components/sheet/selection/SheetSelectionStore.ts b/mathesar_ui/src/components/sheet/selection/SheetSelectionStore.ts new file mode 100644 index 0000000000..91261083e7 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/SheetSelectionStore.ts @@ -0,0 +1,67 @@ +import type { Readable, Writable } from 'svelte/store'; + +import { EventHandler } from '@mathesar/component-library'; +import PreventableEffectsStore from '@mathesar/stores/PreventableEffectsStore'; + +import type Plane from './Plane'; +import SheetSelection from './SheetSelection'; + +export default class SheetSelectionStore + extends EventHandler<{ + focus: void; + }> + implements Writable +{ + private selection: PreventableEffectsStore; + + private cleanupFunctions: (() => void)[] = []; + + constructor(plane: Readable) { + super(); + this.selection = new PreventableEffectsStore(new SheetSelection(), { + focus: () => this.focus(), + }); + this.cleanupFunctions.push( + plane.subscribe((p) => this.selection.update((s) => s.forNewPlane(p))), + ); + } + + private focus(): void { + void this.dispatch('focus'); + } + + subscribe(run: (value: SheetSelection) => void): () => void { + return this.selection.subscribe(run); + } + + update(getNewValue: (oldValue: SheetSelection) => SheetSelection): void { + this.selection.update(getNewValue); + } + + /** + * Updates the selection while skipping the side effect of focusing the active + * cell. + */ + updateWithoutFocus( + getNewValue: (oldValue: SheetSelection) => SheetSelection, + ): void { + this.selection.update(getNewValue, { prevent: ['focus'] }); + } + + set(value: SheetSelection): void { + this.selection.set(value); + } + + /** + * Sets the selection while skipping the side effect of focusing the active + * cell. + */ + setWithoutFocus(value: SheetSelection): void { + this.selection.update(() => value, { prevent: ['focus'] }); + } + + destroy(): void { + super.destroy(); + this.cleanupFunctions.forEach((f) => f()); + } +} diff --git a/mathesar_ui/src/components/sheet/selection/__tests__/Plane.test.ts b/mathesar_ui/src/components/sheet/selection/__tests__/Plane.test.ts new file mode 100644 index 0000000000..ac1351a4b9 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/__tests__/Plane.test.ts @@ -0,0 +1,60 @@ +import { Direction } from '../Direction'; +import Plane from '../Plane'; +import Series from '../Series'; + +test('Plane with placeholder row', () => { + const p = new Plane( + new Series(['r1', 'r2', 'r3', 'r4']), + new Series(['c1', 'c2', 'c3', 'c4']), + 'PH', + ); + + expect([...p.allDataCells()]).toEqual([ + '["r1","c1"]', + '["r1","c2"]', + '["r1","c3"]', + '["r1","c4"]', + '["r2","c1"]', + '["r2","c2"]', + '["r2","c3"]', + '["r2","c4"]', + '["r3","c1"]', + '["r3","c2"]', + '["r3","c3"]', + '["r3","c4"]', + '["r4","c1"]', + '["r4","c2"]', + '["r4","c3"]', + '["r4","c4"]', + ]); + + expect([...p.dataCellsInFlexibleRowRange('r1', 'r2')].length).toBe(8); + expect([...p.dataCellsInFlexibleRowRange('r1', 'r3')].length).toBe(12); + expect([...p.dataCellsInFlexibleRowRange('r3', 'r4')].length).toBe(8); + expect([...p.dataCellsInFlexibleRowRange('r3', 'PH')].length).toBe(8); + expect([...p.dataCellsInFlexibleRowRange('PH', 'PH')]).toEqual([ + '["r4","c1"]', + '["r4","c2"]', + '["r4","c3"]', + '["r4","c4"]', + ]); + + expect([...p.dataCellsInColumnRange('c1', 'c2')].length).toBe(8); + expect([...p.dataCellsInColumnRange('c1', 'c3')].length).toBe(12); + + expect(p.getAdjacentCell('["r4","c4"]', Direction.Up)).toEqual({ + type: 'dataCell', + cellId: '["r3","c4"]', + }); + expect(p.getAdjacentCell('["r4","c4"]', Direction.Right)).toEqual({ + type: 'none', + }); + expect(p.getAdjacentCell('["r4","c4"]', Direction.Down)).toEqual({ + type: 'placeholderCell', + cellId: '["PH","c4"]', + }); + expect(p.getAdjacentCell('["r4","c4"]', Direction.Left)).toEqual({ + type: 'dataCell', + cellId: '["r4","c3"]', + }); +}); diff --git a/mathesar_ui/src/components/sheet/selection/__tests__/Series.test.ts b/mathesar_ui/src/components/sheet/selection/__tests__/Series.test.ts new file mode 100644 index 0000000000..0d485b64a7 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/__tests__/Series.test.ts @@ -0,0 +1,78 @@ +import Series from '../Series'; + +describe('Series', () => { + const h = 'h'; + const i = 'i'; + const j = 'j'; + const k = 'k'; + const l = 'l'; + const all = [h, i, j, k, l]; + const s = new Series(all); + + test('basics', () => { + expect(s.length).toBe(5); + expect(s.first).toBe(h); + expect(s.last).toBe(l); + expect([...s]).toEqual(all); + }); + + test.each([ + [[i, j, k], i], + [[k, j, i], i], + [[i, 'NOPE'], i], + [['NOPE'], undefined], + [[], undefined], + ])('min %#', (input, expected) => { + expect(s.min(input)).toBe(expected); + }); + + test.each([ + [[i, j, k], k], + [[k, j, i], k], + [[i, 'NOPE'], i], + [['NOPE'], undefined], + [[], undefined], + ])('max %#', (input, expected) => { + expect(s.max(input)).toBe(expected); + }); + + test.each([ + [i, k, [i, j, k]], + [k, i, [i, j, k]], + [i, i, [i]], + ])('range %#', (a, b, expected) => { + expect([...s.range(a, b)]).toEqual(expected); + }); + + test.each([ + [i, 'NOPE'], + ['NOPE', i], + ])('range failures %#', (a, b) => { + expect(() => s.range(a, b)).toThrow(); + }); + + test.each([ + [i, 0, i], + [i, 1, j], + [i, 2, k], + [i, -1, h], + [i, -2, undefined], + ['NOPE', 0, undefined], + ['NOPE', 1, undefined], + ])('offset %#', (value, offset, expected) => { + expect(s.offset(value, offset)).toBe(expected); + }); + + test.each([ + [[i, k], 0, undefined], + [[i, k], 1, l], + [[i, k], 2, undefined], + [[i, k], -1, h], + [[i, k], -2, undefined], + [[i, 'NOPE'], 0, undefined], + [[i, 'NOPE'], 1, j], + [[], 0, undefined], + ])('collapsedOffset %#', (values, offset, expected) => { + expect(s.collapsedOffset(values, offset)).toBe(expected); + }); +}); diff --git a/mathesar_ui/src/components/sheet/selection/basis.ts b/mathesar_ui/src/components/sheet/selection/basis.ts new file mode 100644 index 0000000000..6d31861d05 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/basis.ts @@ -0,0 +1,106 @@ +import { first } from 'iter-tools'; + +import { ImmutableSet } from '@mathesar/component-library'; + +import { parseCellId } from '../cellIds'; + +/** + * - `'dataCells'` means that the selection contains data cells. This is by far + * the most common type of selection basis. + * + * - `'emptyColumns'` is used when the sheet has no rows. In this case we still + * want to allow the user to select columns, so we use this basis. + * + * - `'placeholderCell'` is used when the user is selecting a cell in the + * placeholder row. This is a special case because we don't want to allow the + * user to select multiple cells in the placeholder row, and we also don't + * want to allow selections that include cells in data rows _and_ the + * placeholder row. + * + * - `'empty'` is used when no cells are selected. We try to avoid this state, + * but we also allow for it because it makes it easier to construct selection + * instances if we don't already have the full plane data. + */ +export type BasisType = + | 'dataCells' + | 'emptyColumns' + | 'placeholderCell' + | 'empty'; + +/** + * This type stores data about "which cells are selected", with some redundancy + * for efficient and consistent lookup across different kinds of selections. + * + * Due to the redundant nature of some properties on this type, you should be + * sure to only instantiate Basis using the utility functions below. This will + * ensure that the data is always valid. + */ +export interface Basis { + readonly type: BasisType; + readonly activeCellId: string | undefined; + readonly cellIds: ImmutableSet; + readonly rowIds: ImmutableSet; + readonly columnIds: ImmutableSet; +} + +export function basisFromDataCells( + _cellIds: Iterable, + _activeCellId?: string, +): Basis { + const parsedCells = [..._cellIds].map(parseCellId); + const cellIds = new ImmutableSet(_cellIds); + const activeCellId = (() => { + if (_activeCellId === undefined) { + return first(_cellIds); + } + if (cellIds.has(_activeCellId)) { + return _activeCellId; + } + return first(_cellIds); + })(); + return { + type: 'dataCells', + activeCellId, + cellIds, + columnIds: new ImmutableSet(parsedCells.map((cellId) => cellId.columnId)), + rowIds: new ImmutableSet(parsedCells.map((cellId) => cellId.rowId)), + }; +} + +export function basisFromOneDataCell(cellId: string): Basis { + return basisFromDataCells([cellId], cellId); +} + +export function basisFromEmptyColumns(columnIds: Iterable): Basis { + return { + type: 'emptyColumns', + activeCellId: undefined, + cellIds: new ImmutableSet(), + columnIds: new ImmutableSet(columnIds), + rowIds: new ImmutableSet(), + }; +} + +export function basisFromZeroEmptyColumns(): Basis { + return basisFromEmptyColumns([]); +} + +export function basisFromPlaceholderCell(activeCellId: string): Basis { + return { + type: 'placeholderCell', + activeCellId, + cellIds: new ImmutableSet([activeCellId]), + columnIds: new ImmutableSet([parseCellId(activeCellId).columnId]), + rowIds: new ImmutableSet(), + }; +} + +export function emptyBasis(): Basis { + return { + type: 'empty', + activeCellId: undefined, + cellIds: new ImmutableSet(), + columnIds: new ImmutableSet(), + rowIds: new ImmutableSet(), + }; +} diff --git a/mathesar_ui/src/components/sheet/selection/index.ts b/mathesar_ui/src/components/sheet/selection/index.ts new file mode 100644 index 0000000000..ec81a55c52 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/index.ts @@ -0,0 +1 @@ +export * from './selectionUtils'; diff --git a/mathesar_ui/src/components/sheet/selection/selectionUtils.ts b/mathesar_ui/src/components/sheet/selection/selectionUtils.ts new file mode 100644 index 0000000000..ee9c120b90 --- /dev/null +++ b/mathesar_ui/src/components/sheet/selection/selectionUtils.ts @@ -0,0 +1,116 @@ +import type { Writable } from 'svelte/store'; + +import { type ImmutableSet, defined } from '@mathesar-component-library'; + +import type Series from './Series'; +import type SheetSelection from './SheetSelection'; + +export type SheetCellDetails = + | { type: 'data-cell'; cellId: string } + | { type: 'column-header-cell'; columnId: string } + | { type: 'row-header-cell'; rowId: string }; + +export function findContainingSheetCell( + element: HTMLElement, +): SheetCellDetails | undefined { + const containingElement = element.closest('[data-sheet-element]'); + if (!containingElement) return undefined; + + const elementType = containingElement.getAttribute('data-sheet-element'); + if (!elementType) return undefined; + + if (elementType === 'data-cell') { + const cellId = containingElement.getAttribute('data-cell-selection-id'); + if (!cellId) return undefined; + return { type: 'data-cell', cellId }; + } + + if (elementType === 'column-header-cell') { + const columnId = containingElement.getAttribute('data-column-identifier'); + if (!columnId) return undefined; + return { type: 'column-header-cell', columnId }; + } + + if (elementType === 'row-header-cell') { + const rowId = containingElement.getAttribute('data-row-selection-id'); + if (!rowId) return undefined; + return { type: 'row-header-cell', rowId }; + } + + return undefined; +} + +export function beginSelection({ + selection, + sheetElement, + startingCell, + targetCell, + selectionInProgress, +}: { + selection: Writable; + sheetElement: HTMLElement; + startingCell: SheetCellDetails; + targetCell: SheetCellDetails; + selectionInProgress: Writable; +}) { + let previousTarget: HTMLElement | undefined; + + function drawToCell(endingCell: SheetCellDetails) { + selection.update((s) => s.ofSheetCellRange(startingCell, endingCell)); + } + + function drawToPoint(e: MouseEvent) { + const target = e.target as HTMLElement; + if (target === previousTarget) return; // For performance + const cell = findContainingSheetCell(target); + if (!cell) return; + drawToCell(cell); + } + + function finish() { + sheetElement.removeEventListener('mousemove', drawToPoint); + window.removeEventListener('mouseup', finish); + selectionInProgress.set(false); + } + + selectionInProgress.set(true); + drawToCell(targetCell); + sheetElement.addEventListener('mousemove', drawToPoint); + window.addEventListener('mouseup', finish); +} + +function positive(n: number): number { + return Math.max(0, n); +} + +/** + * Given a set of (contiguous) selected values within a series, this function + * will formulate a new set of selected values to fit within a new series such + * that the starting index and width of the selection are preserved to the best + * extent possible. + * + * @return A tuple containing the starting and ending values of the new + * selection. + */ +export function fitSelectedValuesToSeriesTransformation( + selectedValues: ImmutableSet, + oldSeries: Series, + newSeries: Series, +): [T, T] { + const matchingMin = newSeries.min(selectedValues); + const matchingMax = newSeries.max(selectedValues); + if (matchingMin !== undefined && matchingMax !== undefined) { + return [matchingMin, matchingMax]; + } + + const width = Math.min(selectedValues.size, newSeries.length); + const oldStartingIndex = + defined(oldSeries.min(selectedValues), (v) => oldSeries.getIndex(v)) ?? 0; + const indexReduction = positive( + oldStartingIndex + width - 1 - newSeries.length, + ); + const newStartingIndex = positive(oldStartingIndex - indexReduction); + const newStartingValue = newSeries.at(newStartingIndex) as T; + const newEndingValue = newSeries.at(newStartingIndex + width - 1) as T; + return [newStartingValue, newEndingValue]; +} diff --git a/mathesar_ui/src/components/sheet/sheetKeyboardUtils.ts b/mathesar_ui/src/components/sheet/sheetKeyboardUtils.ts new file mode 100644 index 0000000000..187dc00da6 --- /dev/null +++ b/mathesar_ui/src/components/sheet/sheetKeyboardUtils.ts @@ -0,0 +1,48 @@ +import type { Writable } from 'svelte/store'; + +import { KeyboardShortcut } from '@mathesar/utils/KeyboardShortcut'; + +import { Direction } from './selection/Direction'; +import type SheetSelection from './selection/SheetSelection'; +import { autoScroll } from './sheetScrollingUtils'; + +function move(selection: Writable, direction: Direction) { + selection.update((s) => s.collapsedAndMoved(direction)); + void autoScroll(); +} + +function resize(selection: Writable, direction: Direction) { + selection.update((s) => s.resized(direction)); + void autoScroll(); +} + +function key(...args: Parameters) { + return KeyboardShortcut.fromKey(...args).toString(); +} + +const shortcutMapData: [string, (s: Writable) => void][] = [ + [key('ArrowUp'), (s) => move(s, Direction.Up)], + [key('ArrowDown'), (s) => move(s, Direction.Down)], + [key('ArrowLeft'), (s) => move(s, Direction.Left)], + [key('ArrowRight'), (s) => move(s, Direction.Right)], + [key('Tab'), (s) => move(s, Direction.Right)], + [key('Tab', ['Shift']), (s) => move(s, Direction.Left)], + [key('ArrowUp', ['Shift']), (s) => resize(s, Direction.Up)], + [key('ArrowDown', ['Shift']), (s) => resize(s, Direction.Down)], + [key('ArrowLeft', ['Shift']), (s) => resize(s, Direction.Left)], + [key('ArrowRight', ['Shift']), (s) => resize(s, Direction.Right)], +]; + +const shortcutMap = new Map(shortcutMapData); + +export function handleKeyboardEventOnCell( + event: KeyboardEvent, + selection: Writable, +): void { + const shortcut = KeyboardShortcut.fromKeyboardEvent(event); + const action = shortcutMap.get(shortcut.toString()); + if (action) { + event.preventDefault(); + action(selection); + } +} diff --git a/mathesar_ui/src/components/sheet/sheetScrollingUtils.ts b/mathesar_ui/src/components/sheet/sheetScrollingUtils.ts new file mode 100644 index 0000000000..b59cbc2bdf --- /dev/null +++ b/mathesar_ui/src/components/sheet/sheetScrollingUtils.ts @@ -0,0 +1,52 @@ +import { tick } from 'svelte'; + +// TODO: Create a common utility action to handle active element based scroll +function scrollToElement(htmlElement: HTMLElement | null): void { + const activeRow = htmlElement?.parentElement; + const container = document.querySelector('[data-sheet-body-element="list"]'); + if (!container || !activeRow) { + return; + } + // Vertical scroll + if ( + activeRow.offsetTop + activeRow.clientHeight + 40 > + container.scrollTop + container.clientHeight + ) { + const offsetValue: number = + container.getBoundingClientRect().bottom - + activeRow.getBoundingClientRect().bottom - + 40; + container.scrollTop -= offsetValue; + } else if (activeRow.offsetTop - 30 < container.scrollTop) { + container.scrollTop = activeRow.offsetTop - 30; + } + + // Horizontal scroll + if ( + htmlElement.offsetLeft + activeRow.clientWidth + 30 > + container.scrollLeft + container.clientWidth + ) { + const offsetValue: number = + container.getBoundingClientRect().right - + htmlElement.getBoundingClientRect().right - + 30; + container.scrollLeft -= offsetValue; + } else if (htmlElement.offsetLeft - 30 < container.scrollLeft) { + container.scrollLeft = htmlElement.offsetLeft - 30; + } +} + +export function scrollBasedOnActiveCell(): void { + const cell = document.querySelector('[data-cell-active]'); + scrollToElement(cell); +} + +export function scrollBasedOnSelection(): void { + const cell = document.querySelector('[data-cell-selected]'); + scrollToElement(cell); +} + +export async function autoScroll() { + await tick(); + scrollBasedOnActiveCell(); +} diff --git a/mathesar_ui/src/components/sheet/utils.ts b/mathesar_ui/src/components/sheet/utils.ts index 4f3915d8d0..93b5074599 100644 --- a/mathesar_ui/src/components/sheet/utils.ts +++ b/mathesar_ui/src/components/sheet/utils.ts @@ -15,6 +15,7 @@ export interface SheetContextStores { horizontalScrollOffset: Readable; scrollOffset: Readable; paddingRight: Readable; + selectionInProgress: Readable; } export interface SheetContext { @@ -73,3 +74,7 @@ export function getSheetContext< >(): SheetContext { return getContext(SHEET_CONTEXT_KEY); } + +export function focusActiveCell(sheetElement: HTMLElement): void { + sheetElement?.querySelector('[data-active-cell]')?.focus(); +} diff --git a/mathesar_ui/src/pages/import/preview/ImportPreviewContent.svelte b/mathesar_ui/src/pages/import/preview/ImportPreviewContent.svelte index 2b59a9e911..b6359e8e2c 100644 --- a/mathesar_ui/src/pages/import/preview/ImportPreviewContent.svelte +++ b/mathesar_ui/src/pages/import/preview/ImportPreviewContent.svelte @@ -269,7 +269,8 @@ } .sheet-holder { max-width: fit-content; - overflow: auto; + overflow-x: auto; + overflow-y: hidden; margin: 0 auto; border: 1px solid var(--slate-200); } diff --git a/mathesar_ui/src/pages/import/preview/ImportPreviewSheet.svelte b/mathesar_ui/src/pages/import/preview/ImportPreviewSheet.svelte index bc443f6a92..e2689764f4 100644 --- a/mathesar_ui/src/pages/import/preview/ImportPreviewSheet.svelte +++ b/mathesar_ui/src/pages/import/preview/ImportPreviewSheet.svelte @@ -3,8 +3,9 @@ import CellFabric from '@mathesar/components/cell-fabric/CellFabric.svelte'; import { Sheet, - SheetCell, SheetCellResizer, + SheetColumnHeaderCell, + SheetDataCell, SheetHeader, SheetRow, } from '@mathesar/components/sheet'; @@ -26,21 +27,19 @@ c.id}> {#each columns as column (column.id)} - -
- - -
-
+ + + + {/each}
{#each records as record (record)} @@ -51,20 +50,14 @@ >
{#each columns as column (column)} - -
- -
-
+ + + {/each}
@@ -74,13 +67,17 @@ diff --git a/mathesar_ui/src/systems/data-explorer/exploration-inspector/ExplorationInspector.svelte b/mathesar_ui/src/systems/data-explorer/exploration-inspector/ExplorationInspector.svelte index cb2daab392..cc8fb8dc09 100644 --- a/mathesar_ui/src/systems/data-explorer/exploration-inspector/ExplorationInspector.svelte +++ b/mathesar_ui/src/systems/data-explorer/exploration-inspector/ExplorationInspector.svelte @@ -1,10 +1,12 @@