mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
refactor(editor): support virtual scroll for table view of database block (#11642)
close: BS-3378 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a modular virtualized table view with grouping, selection, drag-and-drop, clipboard support, and batch task management for optimized rendering. - Added comprehensive keyboard shortcuts, drag-to-fill functionality, and clipboard operations for efficient table editing. - Enabled dynamic column statistics, number formatting controls, and flexible switching between virtual and standard table views via a feature flag. - Provided detailed row and group header/footer components with context menus, row management actions, and column reordering/resizing. - Added a table view selector component to toggle between virtual and standard table views based on feature flags. - **Style** - Added extensive styling modules for virtual table elements including headers, footers, rows, cells, and interactive controls. - **Chores** - Registered numerous custom elements via modular effect functions to streamline component initialization. - Updated feature flag system to include virtual table scrolling toggle. - Added new dependencies to support styling and component functionality. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
85
blocksuite/affine/data-view/src/core/common/dv.css.ts
Normal file
85
blocksuite/affine/data-view/src/core/common/dv.css.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { createVar, style } from '@vanilla-extract/css';
|
||||
|
||||
export const dataViewVars = {
|
||||
cellTextSize: createVar(),
|
||||
cellTextLineHeight: createVar(),
|
||||
};
|
||||
|
||||
export const dataViewRoot = style({
|
||||
vars: {
|
||||
[dataViewVars.cellTextSize]: '14px',
|
||||
[dataViewVars.cellTextLineHeight]: '22px',
|
||||
},
|
||||
});
|
||||
|
||||
export const withDataViewCssVariable = style({
|
||||
vars: {
|
||||
[dataViewVars.cellTextSize]: '14px',
|
||||
[dataViewVars.cellTextLineHeight]: '22px',
|
||||
},
|
||||
fontFamily: 'var(--affine-font-family)',
|
||||
});
|
||||
|
||||
export const p2 = style({
|
||||
padding: '2px',
|
||||
});
|
||||
|
||||
export const p4 = style({
|
||||
padding: '4px',
|
||||
});
|
||||
|
||||
export const p8 = style({
|
||||
padding: '8px',
|
||||
});
|
||||
|
||||
export const hover = style({
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const icon16 = style({
|
||||
fontSize: '16px',
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
|
||||
export const icon20 = style({
|
||||
fontSize: '20px',
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
|
||||
export const border = style({
|
||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
});
|
||||
|
||||
export const round4 = style({
|
||||
borderRadius: '4px',
|
||||
});
|
||||
|
||||
export const round8 = style({
|
||||
borderRadius: '8px',
|
||||
});
|
||||
|
||||
export const color2 = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const shadow2 = style({
|
||||
boxShadow: 'var(--affine-shadow-2)',
|
||||
});
|
||||
|
||||
export const dividerH = style({
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--affine-divider-color)',
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const dividerV = style({
|
||||
width: '1px',
|
||||
backgroundColor: 'var(--affine-divider-color)',
|
||||
margin: '0 8px',
|
||||
});
|
||||
39
blocksuite/affine/data-view/src/core/effect.ts
Normal file
39
blocksuite/affine/data-view/src/core/effect.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DataViewPropertiesSettingView } from './common/properties.js';
|
||||
import { Button } from './component/button/button.js';
|
||||
import { Overflow } from './component/overflow/overflow.js';
|
||||
import { MultiTagSelect, MultiTagView } from './component/tags/index.js';
|
||||
import { DataViewRenderer } from './data-view.js';
|
||||
import { RecordDetail } from './detail/detail.js';
|
||||
import { RecordField } from './detail/field.js';
|
||||
import { VariableRefView } from './expression/ref/ref-view.js';
|
||||
import { BooleanGroupView } from './group-by/renderer/boolean-group.js';
|
||||
import { NumberGroupView } from './group-by/renderer/number-group.js';
|
||||
import { SelectGroupView } from './group-by/renderer/select-group.js';
|
||||
import { StringGroupView } from './group-by/renderer/string-group.js';
|
||||
import { GroupSetting } from './group-by/setting.js';
|
||||
import { AffineLitIcon, UniAnyRender, UniLit } from './index.js';
|
||||
import { AnyRender } from './utils/uni-component/render-template.js';
|
||||
|
||||
export function coreEffects() {
|
||||
customElements.define('affine-data-view-renderer', DataViewRenderer);
|
||||
customElements.define('any-render', AnyRender);
|
||||
customElements.define(
|
||||
'data-view-properties-setting',
|
||||
DataViewPropertiesSettingView
|
||||
);
|
||||
customElements.define('affine-data-view-record-field', RecordField);
|
||||
customElements.define('data-view-component-button', Button);
|
||||
customElements.define('component-overflow', Overflow);
|
||||
customElements.define('data-view-group-title-select-view', SelectGroupView);
|
||||
customElements.define('data-view-group-title-string-view', StringGroupView);
|
||||
customElements.define('data-view-group-title-number-view', NumberGroupView);
|
||||
customElements.define('affine-lit-icon', AffineLitIcon);
|
||||
customElements.define('data-view-group-setting', GroupSetting);
|
||||
customElements.define('affine-multi-tag-select', MultiTagSelect);
|
||||
customElements.define('data-view-group-title-boolean-view', BooleanGroupView);
|
||||
customElements.define('affine-multi-tag-view', MultiTagView);
|
||||
customElements.define('uni-lit', UniLit);
|
||||
customElements.define('uni-any-render', UniAnyRender);
|
||||
customElements.define('variable-ref-view', VariableRefView);
|
||||
customElements.define('affine-data-view-record-detail', RecordDetail);
|
||||
}
|
||||
@@ -13,4 +13,5 @@ export type PropertyDataUpdater<
|
||||
|
||||
export interface DatabaseFlags {
|
||||
enable_number_formatting: boolean;
|
||||
enable_table_virtual_scroll: boolean;
|
||||
}
|
||||
|
||||
@@ -1,165 +1,11 @@
|
||||
import { DataViewPropertiesSettingView } from './core/common/properties.js';
|
||||
import { Button } from './core/component/button/button.js';
|
||||
import { Overflow } from './core/component/overflow/overflow.js';
|
||||
import { MultiTagSelect, MultiTagView } from './core/component/tags/index.js';
|
||||
import { DataViewRenderer } from './core/data-view.js';
|
||||
import { RecordDetail } from './core/detail/detail.js';
|
||||
import { RecordField } from './core/detail/field.js';
|
||||
import { VariableRefView } from './core/expression/ref/ref-view.js';
|
||||
import { BooleanGroupView } from './core/group-by/renderer/boolean-group.js';
|
||||
import { NumberGroupView } from './core/group-by/renderer/number-group.js';
|
||||
import { SelectGroupView } from './core/group-by/renderer/select-group.js';
|
||||
import { StringGroupView } from './core/group-by/renderer/string-group.js';
|
||||
import { GroupSetting } from './core/group-by/setting.js';
|
||||
import { AffineLitIcon, UniAnyRender, UniLit } from './core/index.js';
|
||||
import { AnyRender } from './core/utils/uni-component/render-template.js';
|
||||
import { CheckboxCell } from './property-presets/checkbox/cell-renderer.js';
|
||||
import { DateCell } from './property-presets/date/cell-renderer.js';
|
||||
import { ImageCell } from './property-presets/image/cell-renderer.js';
|
||||
import { MultiSelectCell } from './property-presets/multi-select/cell-renderer.js';
|
||||
import { NumberCell } from './property-presets/number/cell-renderer.js';
|
||||
import { ProgressCell } from './property-presets/progress/cell-renderer.js';
|
||||
import { SelectCell } from './property-presets/select/cell-renderer.js';
|
||||
import { TextCell } from './property-presets/text/cell-renderer.js';
|
||||
import { DataViewKanban, DataViewTable } from './view-presets/index.js';
|
||||
import { MobileKanbanCard } from './view-presets/kanban/mobile/card.js';
|
||||
import { MobileKanbanCell } from './view-presets/kanban/mobile/cell.js';
|
||||
import { MobileKanbanGroup } from './view-presets/kanban/mobile/group.js';
|
||||
import { MobileDataViewKanban } from './view-presets/kanban/mobile/kanban-view.js';
|
||||
import { KanbanCard } from './view-presets/kanban/pc/card.js';
|
||||
import { KanbanCell } from './view-presets/kanban/pc/cell.js';
|
||||
import { KanbanGroup } from './view-presets/kanban/pc/group.js';
|
||||
import { KanbanHeader } from './view-presets/kanban/pc/header.js';
|
||||
import { MobileTableCell } from './view-presets/table/mobile/cell.js';
|
||||
import { MobileTableColumnHeader } from './view-presets/table/mobile/column-header.js';
|
||||
import { MobileTableGroup } from './view-presets/table/mobile/group.js';
|
||||
import { MobileTableHeader } from './view-presets/table/mobile/header.js';
|
||||
import { MobileTableRow } from './view-presets/table/mobile/row.js';
|
||||
import { MobileDataViewTable } from './view-presets/table/mobile/table-view.js';
|
||||
import { DatabaseCellContainer } from './view-presets/table/pc/cell.js';
|
||||
import { DragToFillElement } from './view-presets/table/pc/controller/drag-to-fill.js';
|
||||
import { SelectionElement } from './view-presets/table/pc/controller/selection.js';
|
||||
import { TableGroup } from './view-presets/table/pc/group.js';
|
||||
import { DatabaseColumnHeader } from './view-presets/table/pc/header/column-header.js';
|
||||
import { DataViewColumnPreview } from './view-presets/table/pc/header/column-renderer.js';
|
||||
import { DatabaseHeaderColumn } from './view-presets/table/pc/header/database-header-column.js';
|
||||
import { DatabaseNumberFormatBar } from './view-presets/table/pc/header/number-format-bar.js';
|
||||
import { TableVerticalIndicator } from './view-presets/table/pc/header/vertical-indicator.js';
|
||||
import { TableRow } from './view-presets/table/pc/row/row.js';
|
||||
import { RowSelectCheckbox } from './view-presets/table/pc/row/row-select-checkbox.js';
|
||||
import { DataBaseColumnStats } from './view-presets/table/stats/column-stats-bar.js';
|
||||
import { DatabaseColumnStatsCell } from './view-presets/table/stats/column-stats-column.js';
|
||||
import { FilterConditionView } from './widget-presets/quick-setting-bar/filter/condition-view.js';
|
||||
import { FilterGroupView } from './widget-presets/quick-setting-bar/filter/group-panel-view.js';
|
||||
import { FilterBar } from './widget-presets/quick-setting-bar/filter/list-view.js';
|
||||
import { FilterRootView } from './widget-presets/quick-setting-bar/filter/root-panel-view.js';
|
||||
import { SortRootView } from './widget-presets/quick-setting-bar/sort/root-panel.js';
|
||||
import { DataViewHeaderToolsFilter } from './widget-presets/tools/presets/filter/filter.js';
|
||||
import { DataViewHeaderToolsSearch } from './widget-presets/tools/presets/search/search.js';
|
||||
import { DataViewHeaderToolsSort } from './widget-presets/tools/presets/sort/sort.js';
|
||||
import { DataViewHeaderToolsAddRow } from './widget-presets/tools/presets/table-add-row/add-row.js';
|
||||
import { NewRecordPreview } from './widget-presets/tools/presets/table-add-row/new-record-preview.js';
|
||||
import { DataViewHeaderToolsViewOptions } from './widget-presets/tools/presets/view-options/view-options.js';
|
||||
import { DataViewHeaderTools } from './widget-presets/tools/tools-view.js';
|
||||
import { DataViewHeaderViews } from './widget-presets/views-bar/views-view.js';
|
||||
import { coreEffects } from './core/effect.js';
|
||||
import { propertyPresetsEffects } from './property-presets/effect.js';
|
||||
import { viewPresetsEffects } from './view-presets/effect.js';
|
||||
import { widgetPresetsEffects } from './widget-presets/effect.js';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-database-progress-cell', ProgressCell);
|
||||
customElements.define('data-view-header-tools', DataViewHeaderTools);
|
||||
customElements.define('affine-database-number-cell', NumberCell);
|
||||
customElements.define(
|
||||
'affine-database-cell-container',
|
||||
DatabaseCellContainer
|
||||
);
|
||||
customElements.define('mobile-table-cell', MobileTableCell);
|
||||
customElements.define('affine-data-view-renderer', DataViewRenderer);
|
||||
customElements.define('any-render', AnyRender);
|
||||
customElements.define('affine-database-image-cell', ImageCell);
|
||||
customElements.define('affine-database-date-cell', DateCell);
|
||||
customElements.define(
|
||||
'data-view-properties-setting',
|
||||
DataViewPropertiesSettingView
|
||||
);
|
||||
customElements.define('affine-database-checkbox-cell', CheckboxCell);
|
||||
customElements.define('affine-database-text-cell', TextCell);
|
||||
customElements.define('affine-database-select-cell', SelectCell);
|
||||
customElements.define('affine-database-multi-select-cell', MultiSelectCell);
|
||||
customElements.define('affine-data-view-record-field', RecordField);
|
||||
customElements.define('data-view-drag-to-fill', DragToFillElement);
|
||||
customElements.define('affine-data-view-table-group', TableGroup);
|
||||
customElements.define('mobile-table-group', MobileTableGroup);
|
||||
customElements.define(
|
||||
'affine-data-view-column-preview',
|
||||
DataViewColumnPreview
|
||||
);
|
||||
customElements.define('data-view-component-button', Button);
|
||||
customElements.define('component-overflow', Overflow);
|
||||
customElements.define('data-view-group-title-select-view', SelectGroupView);
|
||||
customElements.define('data-view-group-title-string-view', StringGroupView);
|
||||
customElements.define('affine-data-view-kanban-card', KanbanCard);
|
||||
customElements.define('mobile-kanban-card', MobileKanbanCard);
|
||||
customElements.define('filter-bar', FilterBar);
|
||||
customElements.define('data-view-group-title-number-view', NumberGroupView);
|
||||
customElements.define('affine-data-view-kanban-cell', KanbanCell);
|
||||
customElements.define('mobile-kanban-cell', MobileKanbanCell);
|
||||
customElements.define('affine-lit-icon', AffineLitIcon);
|
||||
customElements.define('filter-condition-view', FilterConditionView);
|
||||
customElements.define('data-view-group-setting', GroupSetting);
|
||||
customElements.define('affine-multi-tag-select', MultiTagSelect);
|
||||
customElements.define('data-view-group-title-boolean-view', BooleanGroupView);
|
||||
customElements.define('affine-database-table', DataViewTable);
|
||||
customElements.define('mobile-data-view-table', MobileDataViewTable);
|
||||
customElements.define('affine-multi-tag-view', MultiTagView);
|
||||
customElements.define(
|
||||
'data-view-header-tools-search',
|
||||
DataViewHeaderToolsSearch
|
||||
);
|
||||
customElements.define('uni-lit', UniLit);
|
||||
customElements.define('uni-any-render', UniAnyRender);
|
||||
customElements.define('filter-group-view', FilterGroupView);
|
||||
customElements.define(
|
||||
'data-view-header-tools-add-row',
|
||||
DataViewHeaderToolsAddRow
|
||||
);
|
||||
customElements.define('data-view-table-selection', SelectionElement);
|
||||
customElements.define('affine-database-new-record-preview', NewRecordPreview);
|
||||
customElements.define('affine-data-view-kanban-group', KanbanGroup);
|
||||
customElements.define('mobile-kanban-group', MobileKanbanGroup);
|
||||
customElements.define(
|
||||
'data-view-header-tools-filter',
|
||||
DataViewHeaderToolsFilter
|
||||
);
|
||||
customElements.define('data-view-header-tools-sort', DataViewHeaderToolsSort);
|
||||
customElements.define(
|
||||
'data-view-header-tools-view-options',
|
||||
DataViewHeaderToolsViewOptions
|
||||
);
|
||||
customElements.define('affine-data-view-kanban', DataViewKanban);
|
||||
customElements.define('mobile-data-view-kanban', MobileDataViewKanban);
|
||||
customElements.define('affine-data-view-kanban-header', KanbanHeader);
|
||||
customElements.define('variable-ref-view', VariableRefView);
|
||||
customElements.define('affine-data-view-record-detail', RecordDetail);
|
||||
customElements.define('filter-root-view', FilterRootView);
|
||||
customElements.define('sort-root-view', SortRootView);
|
||||
customElements.define('affine-database-column-header', DatabaseColumnHeader);
|
||||
customElements.define('mobile-table-header', MobileTableHeader);
|
||||
customElements.define('data-view-header-views', DataViewHeaderViews);
|
||||
customElements.define(
|
||||
'affine-database-number-format-bar',
|
||||
DatabaseNumberFormatBar
|
||||
);
|
||||
customElements.define('affine-database-header-column', DatabaseHeaderColumn);
|
||||
customElements.define('mobile-table-column-header', MobileTableColumnHeader);
|
||||
customElements.define('row-select-checkbox', RowSelectCheckbox);
|
||||
customElements.define(
|
||||
'data-view-table-vertical-indicator',
|
||||
TableVerticalIndicator
|
||||
);
|
||||
customElements.define('data-view-table-row', TableRow);
|
||||
customElements.define('mobile-table-row', MobileTableRow);
|
||||
customElements.define('affine-database-column-stats', DataBaseColumnStats);
|
||||
customElements.define(
|
||||
'affine-database-column-stats-cell',
|
||||
DatabaseColumnStatsCell
|
||||
);
|
||||
coreEffects();
|
||||
propertyPresetsEffects();
|
||||
viewPresetsEffects();
|
||||
widgetPresetsEffects();
|
||||
}
|
||||
|
||||
19
blocksuite/affine/data-view/src/property-presets/effect.ts
Normal file
19
blocksuite/affine/data-view/src/property-presets/effect.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CheckboxCell } from './checkbox/cell-renderer.js';
|
||||
import { DateCell } from './date/cell-renderer.js';
|
||||
import { ImageCell } from './image/cell-renderer.js';
|
||||
import { MultiSelectCell } from './multi-select/cell-renderer.js';
|
||||
import { NumberCell } from './number/cell-renderer.js';
|
||||
import { ProgressCell } from './progress/cell-renderer.js';
|
||||
import { SelectCell } from './select/cell-renderer.js';
|
||||
import { TextCell } from './text/cell-renderer.js';
|
||||
|
||||
export function propertyPresetsEffects() {
|
||||
customElements.define('affine-database-checkbox-cell', CheckboxCell);
|
||||
customElements.define('affine-database-date-cell', DateCell);
|
||||
customElements.define('affine-database-image-cell', ImageCell);
|
||||
customElements.define('affine-database-multi-select-cell', MultiSelectCell);
|
||||
customElements.define('affine-database-number-cell', NumberCell);
|
||||
customElements.define('affine-database-progress-cell', ProgressCell);
|
||||
customElements.define('affine-database-select-cell', SelectCell);
|
||||
customElements.define('affine-database-text-cell', TextCell);
|
||||
}
|
||||
48
blocksuite/affine/data-view/src/view-presets/effect.ts
Normal file
48
blocksuite/affine/data-view/src/view-presets/effect.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { DataViewKanban, TableViewSelector } from './index.js';
|
||||
import { MobileKanbanCard } from './kanban/mobile/card.js';
|
||||
import { MobileKanbanCell } from './kanban/mobile/cell.js';
|
||||
import { MobileKanbanGroup } from './kanban/mobile/group.js';
|
||||
import { MobileDataViewKanban } from './kanban/mobile/kanban-view.js';
|
||||
import { KanbanCard } from './kanban/pc/card.js';
|
||||
import { KanbanCell } from './kanban/pc/cell.js';
|
||||
import { KanbanGroup } from './kanban/pc/group.js';
|
||||
import { KanbanHeader } from './kanban/pc/header.js';
|
||||
import { MobileTableCell } from './table/mobile/cell.js';
|
||||
import { MobileTableColumnHeader } from './table/mobile/column-header.js';
|
||||
import { MobileTableGroup } from './table/mobile/group.js';
|
||||
import { MobileTableHeader } from './table/mobile/header.js';
|
||||
import { MobileTableRow } from './table/mobile/row.js';
|
||||
import { MobileDataViewTable } from './table/mobile/table-view.js';
|
||||
import { pcEffects } from './table/pc/effect.js';
|
||||
import { pcVirtualEffects } from './table/pc-virtual/effect.js';
|
||||
import { DataBaseColumnStats } from './table/stats/column-stats-bar.js';
|
||||
import { DatabaseColumnStatsCell } from './table/stats/column-stats-column.js';
|
||||
|
||||
export function viewPresetsEffects() {
|
||||
customElements.define('affine-data-view-kanban-card', KanbanCard);
|
||||
customElements.define('mobile-kanban-card', MobileKanbanCard);
|
||||
customElements.define('affine-data-view-kanban-cell', KanbanCell);
|
||||
customElements.define('mobile-kanban-cell', MobileKanbanCell);
|
||||
customElements.define('affine-data-view-kanban-group', KanbanGroup);
|
||||
customElements.define('mobile-kanban-group', MobileKanbanGroup);
|
||||
customElements.define('affine-data-view-kanban', DataViewKanban);
|
||||
customElements.define('mobile-data-view-kanban', MobileDataViewKanban);
|
||||
customElements.define('affine-data-view-kanban-header', KanbanHeader);
|
||||
|
||||
customElements.define('mobile-table-cell', MobileTableCell);
|
||||
customElements.define('mobile-table-group', MobileTableGroup);
|
||||
customElements.define('mobile-data-view-table', MobileDataViewTable);
|
||||
customElements.define('mobile-table-header', MobileTableHeader);
|
||||
customElements.define('mobile-table-column-header', MobileTableColumnHeader);
|
||||
customElements.define('mobile-table-row', MobileTableRow);
|
||||
|
||||
customElements.define('affine-database-column-stats', DataBaseColumnStats);
|
||||
customElements.define(
|
||||
'affine-database-column-stats-cell',
|
||||
DatabaseColumnStatsCell
|
||||
);
|
||||
customElements.define('affine-database-table-selector', TableViewSelector);
|
||||
|
||||
pcEffects();
|
||||
pcVirtualEffects();
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from './define.js';
|
||||
export * from './pc/effect.js';
|
||||
export * from './pc/table-view.js';
|
||||
export * from './pc-virtual/effect.js';
|
||||
export * from './renderer.js';
|
||||
export * from './selection.js';
|
||||
export * from './table-view-manager.js';
|
||||
export * from './table-view-selector.js';
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { UIEventStateContext } from '@blocksuite/std';
|
||||
import type { ReactiveController } from 'lit';
|
||||
|
||||
import type { Cell } from '../../../../core/view-manager/cell.js';
|
||||
import type { Row } from '../../../../core/view-manager/row.js';
|
||||
import {
|
||||
TableViewAreaSelection,
|
||||
TableViewRowSelection,
|
||||
type TableViewSelection,
|
||||
type TableViewSelectionWithType,
|
||||
} from '../../selection';
|
||||
import type { VirtualTableView } from '../table-view.js';
|
||||
|
||||
const BLOCKSUITE_DATABASE_TABLE = 'blocksuite/database/table';
|
||||
type JsonAreaData = string[][];
|
||||
const TEXT = 'text/plain';
|
||||
|
||||
export class TableClipboardController implements ReactiveController {
|
||||
private readonly _onCopy = (
|
||||
tableSelection: TableViewSelectionWithType,
|
||||
isCut = false
|
||||
) => {
|
||||
const table = this.host;
|
||||
|
||||
const area = getSelectedArea(tableSelection, table);
|
||||
if (!area) {
|
||||
return;
|
||||
}
|
||||
const stringResult = area
|
||||
.map(row => row.cells.map(cell => cell.stringValue$.value).join('\t'))
|
||||
.join('\n');
|
||||
const jsonResult: JsonAreaData = area.map(row =>
|
||||
row.cells.map(cell => cell.stringValue$.value)
|
||||
);
|
||||
if (isCut) {
|
||||
const deleteRows: string[] = [];
|
||||
for (const row of area) {
|
||||
if (row.row) {
|
||||
deleteRows.push(row.row.rowId);
|
||||
} else {
|
||||
for (const cell of row.cells) {
|
||||
cell.valueSet(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deleteRows.length) {
|
||||
this.props.view.rowDelete(deleteRows);
|
||||
}
|
||||
}
|
||||
this.clipboard
|
||||
.writeToClipboard(items => {
|
||||
return {
|
||||
...items,
|
||||
[TEXT]: stringResult,
|
||||
[BLOCKSUITE_DATABASE_TABLE]: JSON.stringify(jsonResult),
|
||||
};
|
||||
})
|
||||
.then(() => {
|
||||
if (area[0]?.row) {
|
||||
this.notification.toast(
|
||||
`${area.length} row${area.length > 1 ? 's' : ''} copied to clipboard`
|
||||
);
|
||||
} else {
|
||||
const count = area.flatMap(row => row.cells).length;
|
||||
this.notification.toast(
|
||||
`${count} cell${count > 1 ? 's' : ''} copied to clipboard`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private readonly _onCut = (tableSelection: TableViewSelectionWithType) => {
|
||||
this._onCopy(tableSelection, true);
|
||||
};
|
||||
|
||||
private readonly _onPaste = async (_context: UIEventStateContext) => {
|
||||
const event = _context.get('clipboardState').raw;
|
||||
event.stopPropagation();
|
||||
const view = this.host;
|
||||
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
const tableSelection = this.host.selectionController.selection;
|
||||
if (TableViewRowSelection.is(tableSelection)) {
|
||||
return;
|
||||
}
|
||||
if (tableSelection) {
|
||||
try {
|
||||
// First try to read internal format data
|
||||
const json = await this.clipboard.readFromClipboard(clipboardData);
|
||||
const dataString = json[BLOCKSUITE_DATABASE_TABLE];
|
||||
|
||||
if (dataString) {
|
||||
// If internal format data exists, use it
|
||||
const jsonAreaData = JSON.parse(dataString) as JsonAreaData;
|
||||
pasteToCells(view, jsonAreaData, tableSelection);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore error when reading internal format, will fallback to plain text
|
||||
console.debug('No internal format data found, trying plain text');
|
||||
}
|
||||
|
||||
// Try reading plain text (possibly copied from Excel)
|
||||
const plainText = clipboardData.getData('text/plain');
|
||||
if (plainText) {
|
||||
// Split text by newlines and then by tabs for each line
|
||||
const rows = plainText
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.split('\t').map(cell => cell.trim()))
|
||||
.filter(row => row.some(cell => cell !== '')); // Filter out empty rows
|
||||
|
||||
if (rows.length > 0) {
|
||||
pasteToCells(view, rows, tableSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private get clipboard() {
|
||||
return this.props.clipboard;
|
||||
}
|
||||
|
||||
private get notification() {
|
||||
return this.props.notification;
|
||||
}
|
||||
|
||||
get props() {
|
||||
return this.host.props;
|
||||
}
|
||||
|
||||
private get readonly() {
|
||||
return this.props.view.readonly$.value;
|
||||
}
|
||||
|
||||
constructor(public host: VirtualTableView) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
copy() {
|
||||
const tableSelection = this.host.selectionController.selection;
|
||||
if (!tableSelection) {
|
||||
return;
|
||||
}
|
||||
this._onCopy(tableSelection);
|
||||
}
|
||||
|
||||
cut() {
|
||||
const tableSelection = this.host.selectionController.selection;
|
||||
if (!tableSelection) {
|
||||
return;
|
||||
}
|
||||
this._onCopy(tableSelection, true);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this.host.disposables.add(
|
||||
this.props.handleEvent('copy', _ctx => {
|
||||
const tableSelection = this.host.selectionController.selection;
|
||||
if (!tableSelection) return false;
|
||||
|
||||
this._onCopy(tableSelection);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
this.host.disposables.add(
|
||||
this.props.handleEvent('cut', _ctx => {
|
||||
const tableSelection = this.host.selectionController.selection;
|
||||
if (!tableSelection) return false;
|
||||
|
||||
this._onCut(tableSelection);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
this.host.disposables.add(
|
||||
this.props.handleEvent('paste', ctx => {
|
||||
if (this.readonly) return false;
|
||||
|
||||
this._onPaste(ctx).catch(console.error);
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedArea(
|
||||
selection: TableViewSelection,
|
||||
table: VirtualTableView
|
||||
): SelectedArea | undefined {
|
||||
const view = table.props.view;
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
const rows = TableViewRowSelection.rows(selection)
|
||||
.map(row => {
|
||||
const y =
|
||||
table.selectionController.getRow(row.groupKey, row.id)?.top$?.value ??
|
||||
0;
|
||||
return {
|
||||
y,
|
||||
row,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map(v => v.row);
|
||||
return rows.map(r => {
|
||||
const row = view.rowGet(r.id);
|
||||
return {
|
||||
row,
|
||||
cells: row.cells$.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
const { rowsSelection, columnsSelection, groupKey } = selection;
|
||||
const data: SelectedArea = [];
|
||||
const rows = groupKey
|
||||
? view.groupTrait.groupDataMap$.value?.[groupKey]?.rows
|
||||
: view.rows$.value;
|
||||
const columns = view.propertyIds$.value;
|
||||
if (!rows) {
|
||||
return;
|
||||
}
|
||||
for (let i = rowsSelection.start; i <= rowsSelection.end; i++) {
|
||||
const row: SelectedArea[number] = {
|
||||
cells: [],
|
||||
};
|
||||
const rowId = rows[i];
|
||||
if (rowId == null) {
|
||||
continue;
|
||||
}
|
||||
for (let j = columnsSelection.start; j <= columnsSelection.end; j++) {
|
||||
const columnId = columns[j];
|
||||
if (columnId == null) {
|
||||
continue;
|
||||
}
|
||||
const cell = view.cellGet(rowId, columnId);
|
||||
row.cells.push(cell);
|
||||
}
|
||||
data.push(row);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
type SelectedArea = {
|
||||
row?: Row;
|
||||
cells: Cell[];
|
||||
}[];
|
||||
|
||||
function getTargetRangeFromSelection(
|
||||
selection: TableViewAreaSelection,
|
||||
data: JsonAreaData
|
||||
) {
|
||||
const { rowsSelection, columnsSelection, focus } = selection;
|
||||
return TableViewAreaSelection.isFocus(selection)
|
||||
? {
|
||||
row: {
|
||||
start: focus.rowIndex,
|
||||
length: data.length,
|
||||
},
|
||||
column: {
|
||||
start: focus.columnIndex,
|
||||
length: data[0]?.length ?? 0,
|
||||
},
|
||||
}
|
||||
: {
|
||||
row: {
|
||||
start: rowsSelection.start,
|
||||
length: rowsSelection.end - rowsSelection.start + 1,
|
||||
},
|
||||
column: {
|
||||
start: columnsSelection.start,
|
||||
length: columnsSelection.end - columnsSelection.start + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pasteToCells(
|
||||
table: VirtualTableView,
|
||||
rows: JsonAreaData,
|
||||
selection: TableViewAreaSelection
|
||||
) {
|
||||
const srcRowLength = rows.length;
|
||||
const srcColumnLength = rows[0]?.length ?? 0;
|
||||
const targetRange = getTargetRangeFromSelection(selection, rows);
|
||||
for (let i = 0; i < targetRange.row.length; i++) {
|
||||
for (let j = 0; j < targetRange.column.length; j++) {
|
||||
const rowIndex = targetRange.row.start + i;
|
||||
const columnIndex = targetRange.column.start + j;
|
||||
|
||||
const srcRowIndex = i % srcRowLength;
|
||||
const srcColumnIndex = j % srcColumnLength;
|
||||
const dataString = rows[srcRowIndex]?.[srcColumnIndex];
|
||||
|
||||
const targetContainer = table.selectionController.getCellContainer(
|
||||
selection.groupKey,
|
||||
rowIndex,
|
||||
columnIndex
|
||||
);
|
||||
const rowId = targetContainer?.rowId;
|
||||
const columnId = targetContainer?.columnId;
|
||||
if (rowId && columnId) {
|
||||
targetContainer?.column$.value?.valueSetFromString(
|
||||
rowId,
|
||||
dataString ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { type Text } from '@blocksuite/store';
|
||||
import { css, html } from 'lit';
|
||||
import { state } from 'lit/decorators.js';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { t } from '../../../../core/index.js';
|
||||
import type { TableViewAreaSelection } from '../../selection';
|
||||
import type { VirtualTableView } from '../table-view';
|
||||
|
||||
export class DragToFillElement extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
.drag-to-fill {
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
border: 2px solid var(--affine-primary-color);
|
||||
display: none;
|
||||
position: absolute;
|
||||
cursor: ns-resize;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: auto;
|
||||
user-select: none;
|
||||
transition: scale 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
.drag-to-fill.dragging {
|
||||
scale: 1.1;
|
||||
}
|
||||
`;
|
||||
|
||||
dragToFillRef = createRef<HTMLDivElement>();
|
||||
|
||||
override render() {
|
||||
// TODO add tooltip
|
||||
return html`<div
|
||||
${ref(this.dragToFillRef)}
|
||||
data-drag-to-fill="true"
|
||||
class="drag-to-fill ${this.dragging ? 'dragging' : ''}"
|
||||
></div>`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor dragging = false;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-virtual-drag-to-fill': DragToFillElement;
|
||||
}
|
||||
}
|
||||
|
||||
export function fillSelectionWithFocusCellData(
|
||||
host: VirtualTableView,
|
||||
selection: TableViewAreaSelection
|
||||
) {
|
||||
const { groupKey, rowsSelection, columnsSelection, focus } = selection;
|
||||
|
||||
const focusCell = host.selectionController.getCellContainer(
|
||||
groupKey,
|
||||
focus.rowIndex,
|
||||
focus.columnIndex
|
||||
);
|
||||
|
||||
if (!focusCell) return;
|
||||
|
||||
if (rowsSelection && columnsSelection) {
|
||||
if (!isEqual(columnsSelection.start, columnsSelection.end)) {
|
||||
console.error('expected selections on a single column');
|
||||
return;
|
||||
}
|
||||
|
||||
const curCol = focusCell.column$.value; // we are sure that we are always in the same column while iterating through rows
|
||||
if (!curCol) return;
|
||||
const cell = focusCell.cell$.value;
|
||||
const focusData = cell.value$.value;
|
||||
|
||||
const draggingColIdx = columnsSelection.start;
|
||||
const { start, end } = rowsSelection;
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i === focus.rowIndex) continue;
|
||||
|
||||
const cellContainer = host.selectionController.getCellContainer(
|
||||
groupKey,
|
||||
i,
|
||||
draggingColIdx
|
||||
);
|
||||
|
||||
if (!cellContainer) continue;
|
||||
|
||||
const curCell = cellContainer.cell$.value;
|
||||
|
||||
if (t.richText.is(curCol.dataType$.value)) {
|
||||
const focusCellText = focusData as Text | undefined;
|
||||
|
||||
const delta = focusCellText?.toDelta() ?? [{ insert: '' }];
|
||||
const curCellText = curCell.value$.value as Text | undefined;
|
||||
|
||||
if (curCellText) {
|
||||
curCellText.clear();
|
||||
curCellText.applyDelta(delta);
|
||||
} else {
|
||||
const newText = new Y.Text();
|
||||
newText.applyDelta(delta);
|
||||
curCell.valueSet(newText);
|
||||
}
|
||||
} else {
|
||||
curCell.valueSet(focusData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// related component
|
||||
|
||||
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
|
||||
import type { ReactiveController } from 'lit';
|
||||
|
||||
// import { startDrag } from '../../../../core/utils/drag.js';
|
||||
import type { VirtualTableView } from '../table-view';
|
||||
|
||||
export class TableDragController implements ReactiveController {
|
||||
// dragStart = (row: TableRow, evt: PointerEvent) => {
|
||||
// const eleRect = row.getBoundingClientRect();
|
||||
// const offsetLeft = evt.x - eleRect.left;
|
||||
// const offsetTop = evt.y - eleRect.top;
|
||||
// const preview = createDragPreview(
|
||||
// row,
|
||||
// evt.x - offsetLeft,
|
||||
// evt.y - offsetTop
|
||||
// );
|
||||
// const fromGroup = row.groupKey;
|
||||
//
|
||||
// startDrag<
|
||||
// | undefined
|
||||
// | {
|
||||
// type: 'self';
|
||||
// groupKey?: string;
|
||||
// position: InsertToPosition;
|
||||
// }
|
||||
// | { type: 'out'; callback: () => void },
|
||||
// PointerEvent
|
||||
// >(evt, {
|
||||
// onDrag: () => undefined,
|
||||
// onMove: evt => {
|
||||
// preview.display(evt.x - offsetLeft, evt.y - offsetTop);
|
||||
// if (!this.host.contains(evt.target as Node)) {
|
||||
// const callback = this.host.props.onDrag;
|
||||
// if (callback) {
|
||||
// this.dropPreview.remove();
|
||||
// return {
|
||||
// type: 'out',
|
||||
// callback: callback(evt, row.rowId),
|
||||
// };
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// const result = this.showIndicator(evt);
|
||||
// if (result) {
|
||||
// return {
|
||||
// type: 'self',
|
||||
// groupKey: result.groupKey,
|
||||
// position: result.position,
|
||||
// };
|
||||
// }
|
||||
// return;
|
||||
// },
|
||||
// onClear: () => {
|
||||
// preview.remove();
|
||||
// this.dropPreview.remove();
|
||||
// },
|
||||
// onDrop: result => {
|
||||
// if (!result) {
|
||||
// return;
|
||||
// }
|
||||
// if (result.type === 'out') {
|
||||
// result.callback();
|
||||
// return;
|
||||
// }
|
||||
// if (result.type === 'self') {
|
||||
// this.host.props.view.rowMove(
|
||||
// row.rowId,
|
||||
// result.position,
|
||||
// fromGroup,
|
||||
// result.groupKey
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
dropPreview = createDropPreview();
|
||||
|
||||
getInsertPosition = (
|
||||
evt: MouseEvent
|
||||
):
|
||||
| {
|
||||
groupKey: string | undefined;
|
||||
position: InsertToPosition;
|
||||
y: number;
|
||||
width: number;
|
||||
x: number;
|
||||
}
|
||||
| undefined => {
|
||||
const y = evt.y;
|
||||
const tableRect = this.host
|
||||
.querySelector('affine-data-view-table-group')
|
||||
?.getBoundingClientRect();
|
||||
const rows = this.host.querySelectorAll('data-view-table-row');
|
||||
if (!rows || !tableRect || y < tableRect.top) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows.item(i);
|
||||
const rect = row.getBoundingClientRect();
|
||||
const mid = (rect.top + rect.bottom) / 2;
|
||||
if (y < rect.bottom) {
|
||||
return {
|
||||
groupKey: row.groupKey,
|
||||
position: {
|
||||
id: row.dataset.rowId as string,
|
||||
before: y < mid,
|
||||
},
|
||||
y: y < mid ? rect.top : rect.bottom,
|
||||
width: tableRect.width,
|
||||
x: tableRect.left,
|
||||
};
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
showIndicator = (evt: MouseEvent) => {
|
||||
const position = this.getInsertPosition(evt);
|
||||
if (position) {
|
||||
this.dropPreview.display(position.x, position.y, position.width);
|
||||
} else {
|
||||
this.dropPreview.remove();
|
||||
}
|
||||
return position;
|
||||
};
|
||||
|
||||
constructor(private readonly host: VirtualTableView) {
|
||||
this.host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
if (this.host.props.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
// this.host.disposables.add(
|
||||
// this.host.props.handleEvent('dragStart', context => {
|
||||
// // const event = context.get('pointerState').raw;
|
||||
// // const target = event.target;
|
||||
// // if (
|
||||
// // target instanceof Element &&
|
||||
// // this.host.contains(target) &&
|
||||
// // target.closest('.data-view-table-view-drag-handler')
|
||||
// // ) {
|
||||
// // event.preventDefault();
|
||||
// // const row = target.closest('data-view-table-row');
|
||||
// // if (row) {
|
||||
// // getSelection()?.removeAllRanges();
|
||||
// // this.dragStart(row, event);
|
||||
// // }
|
||||
// // return true;
|
||||
// // }
|
||||
// return false;
|
||||
// })
|
||||
// );
|
||||
}
|
||||
}
|
||||
|
||||
// const createDragPreview = (row: TableRow, x: number, y: number) => {
|
||||
// const div = document.createElement('div');
|
||||
// const cloneRow = new TableRow();
|
||||
// cloneRow.view = row.view;
|
||||
// cloneRow.rowIndex = row.rowIndex;
|
||||
// cloneRow.rowId = row.rowId;
|
||||
// cloneRow.dataViewEle = row.dataViewEle;
|
||||
// div.append(cloneRow);
|
||||
// div.className = 'with-data-view-css-variable';
|
||||
// div.style.width = `${row.getBoundingClientRect().width}px`;
|
||||
// div.style.position = 'fixed';
|
||||
// div.style.pointerEvents = 'none';
|
||||
// div.style.opacity = '0.5';
|
||||
// div.style.backgroundColor = 'var(--affine-background-primary-color)';
|
||||
// div.style.boxShadow = 'var(--affine-shadow-2)';
|
||||
// div.style.left = `${x}px`;
|
||||
// div.style.top = `${y}px`;
|
||||
// div.style.zIndex = '9999';
|
||||
// document.body.append(div);
|
||||
// return {
|
||||
// display(x: number, y: number) {
|
||||
// div.style.left = `${Math.round(x)}px`;
|
||||
// div.style.top = `${Math.round(y)}px`;
|
||||
// },
|
||||
// remove() {
|
||||
// div.remove();
|
||||
// },
|
||||
// };
|
||||
// };
|
||||
const createDropPreview = () => {
|
||||
const div = document.createElement('div');
|
||||
div.dataset.isDropPreview = 'true';
|
||||
div.style.pointerEvents = 'none';
|
||||
div.style.position = 'fixed';
|
||||
div.style.zIndex = '9999';
|
||||
div.style.height = '2px';
|
||||
div.style.borderRadius = '1px';
|
||||
div.style.backgroundColor = 'var(--affine-primary-color)';
|
||||
div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)';
|
||||
return {
|
||||
display(x: number, y: number, width: number) {
|
||||
document.body.append(div);
|
||||
div.style.left = `${x}px`;
|
||||
div.style.top = `${y - 2}px`;
|
||||
div.style.width = `${width}px`;
|
||||
},
|
||||
remove() {
|
||||
div.remove();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,386 @@
|
||||
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
|
||||
import type { ReactiveController } from 'lit';
|
||||
|
||||
import { TableViewAreaSelection, TableViewRowSelection } from '../../selection';
|
||||
import { popRowMenu } from '../row/menu';
|
||||
import type { VirtualTableView } from '../table-view.js';
|
||||
|
||||
export class TableHotkeysController implements ReactiveController {
|
||||
get selectionController() {
|
||||
return this.host.selectionController;
|
||||
}
|
||||
|
||||
constructor(private readonly host: VirtualTableView) {
|
||||
this.host.addController(this);
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
this.host.disposables.add(
|
||||
this.host.props.bindHotkey({
|
||||
Backspace: () => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
const rows = TableViewRowSelection.rowsIds(selection);
|
||||
this.selectionController.selection = undefined;
|
||||
this.host.props.view.rowDelete(rows);
|
||||
return;
|
||||
}
|
||||
const {
|
||||
focus,
|
||||
rowsSelection,
|
||||
columnsSelection,
|
||||
isEditing,
|
||||
groupKey,
|
||||
} = selection;
|
||||
if (focus && !isEditing) {
|
||||
if (rowsSelection && columnsSelection) {
|
||||
// multi cell
|
||||
for (let i = rowsSelection.start; i <= rowsSelection.end; i++) {
|
||||
const { start, end } = columnsSelection;
|
||||
for (let j = start; j <= end; j++) {
|
||||
const container = this.selectionController.getCellContainer(
|
||||
groupKey,
|
||||
i,
|
||||
j
|
||||
);
|
||||
const rowId = container?.dataset.rowId;
|
||||
const columnId = container?.dataset.columnId;
|
||||
if (rowId && columnId) {
|
||||
container?.column$.value?.valueSetFromString(rowId, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// single cell
|
||||
const container = this.selectionController.getCellContainer(
|
||||
groupKey,
|
||||
focus.rowIndex,
|
||||
focus.columnIndex
|
||||
);
|
||||
const rowId = container?.dataset.rowId;
|
||||
const columnId = container?.dataset.columnId;
|
||||
if (rowId && columnId) {
|
||||
container?.column$.value?.valueSetFromString(rowId, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Escape: () => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
const result = this.selectionController.rowsToArea(
|
||||
selection.rows.map(v => v.id)
|
||||
);
|
||||
if (result) {
|
||||
this.selectionController.selection =
|
||||
TableViewAreaSelection.create({
|
||||
groupKey: result.groupKey,
|
||||
focus: {
|
||||
rowIndex: result.start,
|
||||
columnIndex: 0,
|
||||
},
|
||||
rowsSelection: {
|
||||
start: result.start,
|
||||
end: result.end,
|
||||
},
|
||||
isEditing: false,
|
||||
});
|
||||
} else {
|
||||
this.selectionController.selection = undefined;
|
||||
}
|
||||
} else if (selection.isEditing) {
|
||||
this.selectionController.selection = {
|
||||
...selection,
|
||||
isEditing: false,
|
||||
};
|
||||
} else {
|
||||
const rows = this.selectionController.areaToRows(selection);
|
||||
this.selectionController.rowSelectionChange({
|
||||
add: rows,
|
||||
remove: [],
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Enter: context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
const result = this.selectionController.rowsToArea(
|
||||
selection.rows.map(v => v.id)
|
||||
);
|
||||
if (result) {
|
||||
this.selectionController.selection =
|
||||
TableViewAreaSelection.create({
|
||||
groupKey: result.groupKey,
|
||||
focus: {
|
||||
rowIndex: result.start,
|
||||
columnIndex: 0,
|
||||
},
|
||||
rowsSelection: {
|
||||
start: result.start,
|
||||
end: result.end,
|
||||
},
|
||||
isEditing: false,
|
||||
});
|
||||
}
|
||||
} else if (selection.isEditing) {
|
||||
return false;
|
||||
} else {
|
||||
this.selectionController.selection = {
|
||||
...selection,
|
||||
isEditing: true,
|
||||
};
|
||||
}
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
'Shift-Enter': () => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const cell = this.selectionController.getCellContainer(
|
||||
selection.groupKey,
|
||||
selection.focus.rowIndex,
|
||||
selection.focus.columnIndex
|
||||
);
|
||||
if (cell) {
|
||||
this.selectionController.insertRowAfter(
|
||||
selection.groupKey,
|
||||
cell.rowId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Tab: ctx => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
this.selectionController.focusToCell('right');
|
||||
return true;
|
||||
},
|
||||
'Shift-Tab': ctx => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
this.selectionController.focusToCell('left');
|
||||
return true;
|
||||
},
|
||||
ArrowLeft: context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.selectionController.focusToCell('left');
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
ArrowRight: context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.selectionController.focusToCell('right');
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
ArrowUp: context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
this.selectionController.navigateRowSelection('up', false);
|
||||
} else if (selection.isEditing) {
|
||||
return false;
|
||||
} else {
|
||||
this.selectionController.focusToCell('up');
|
||||
}
|
||||
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
ArrowDown: context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
this.selectionController.navigateRowSelection('down', false);
|
||||
} else if (selection.isEditing) {
|
||||
return false;
|
||||
} else {
|
||||
this.selectionController.focusToCell('down');
|
||||
}
|
||||
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
|
||||
'Shift-ArrowUp': context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
this.selectionController.navigateRowSelection('up', true);
|
||||
} else if (selection.isEditing) {
|
||||
return false;
|
||||
} else {
|
||||
this.selectionController.selectionAreaUp();
|
||||
}
|
||||
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
|
||||
'Shift-ArrowDown': context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
this.selectionController.navigateRowSelection('down', true);
|
||||
} else if (selection.isEditing) {
|
||||
return false;
|
||||
} else {
|
||||
this.selectionController.selectionAreaDown();
|
||||
}
|
||||
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
|
||||
'Shift-ArrowLeft': context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing ||
|
||||
this.selectionController.isRowSelection()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.selectionController.selectionAreaLeft();
|
||||
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
|
||||
'Shift-ArrowRight': context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (
|
||||
!selection ||
|
||||
TableViewRowSelection.is(selection) ||
|
||||
selection.isEditing ||
|
||||
this.selectionController.isRowSelection()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.selectionController.selectionAreaRight();
|
||||
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
},
|
||||
|
||||
'Mod-a': context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
return false;
|
||||
}
|
||||
if (selection?.isEditing) {
|
||||
return true;
|
||||
}
|
||||
if (selection) {
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
this.selectionController.selection = TableViewRowSelection.create({
|
||||
rows:
|
||||
this.host.props.view.groupTrait.groupsDataList$.value?.flatMap(
|
||||
group =>
|
||||
group?.rows.map(id => ({ groupKey: group.key, id })) ?? []
|
||||
) ??
|
||||
this.host.props.view.rows$.value.map(id => ({
|
||||
groupKey: undefined,
|
||||
id,
|
||||
})),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
},
|
||||
'/': context => {
|
||||
const selection = this.selectionController.selection;
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
if (TableViewRowSelection.is(selection)) {
|
||||
// open multi-rows context-menu
|
||||
return;
|
||||
}
|
||||
if (selection.isEditing) {
|
||||
return;
|
||||
}
|
||||
const cell = this.selectionController.getCellContainer(
|
||||
selection.groupKey,
|
||||
selection.focus.rowIndex,
|
||||
selection.focus.columnIndex
|
||||
);
|
||||
if (cell) {
|
||||
context.get('keyboardState').raw.preventDefault();
|
||||
const row = {
|
||||
id: cell.rowId,
|
||||
groupKey: selection.groupKey,
|
||||
};
|
||||
this.selectionController.selection = TableViewRowSelection.create({
|
||||
rows: [row],
|
||||
});
|
||||
popRowMenu(
|
||||
this.host.props.dataViewEle,
|
||||
popupTargetFromElement(cell),
|
||||
this.selectionController
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { DragToFillElement } from './controller/drag-to-fill';
|
||||
import { SelectionElement } from './controller/selection';
|
||||
import { TableGroupFooter } from './group/bottom/group-footer';
|
||||
import { VirtualDataBaseColumnStats } from './group/bottom/stats/column-stats-bar';
|
||||
import { VirtualDatabaseColumnStatsCell } from './group/bottom/stats/column-stats-column';
|
||||
import { TableGroupHeader } from './group/top/group-header';
|
||||
import { VirtualTableHeader } from './group/top/header/column-header';
|
||||
import { DataViewColumnPreview } from './group/top/header/column-move-preview';
|
||||
import { DatabaseNumberFormatBar } from './group/top/header/number-format-bar';
|
||||
import { DatabaseHeaderColumn } from './group/top/header/single-column-header';
|
||||
import { TableVerticalIndicator } from './group/top/header/vertical-indicator';
|
||||
import { DatabaseCellContainer } from './row/cell';
|
||||
import { TableRowHeader } from './row/row-header';
|
||||
import { TableRowLast } from './row/row-last';
|
||||
import { VirtualTableView } from './table-view';
|
||||
import { VirtualElementWrapper } from './virtual/virtual-cell';
|
||||
|
||||
export function pcVirtualEffects() {
|
||||
customElements.define('affine-virtual-table', VirtualTableView);
|
||||
customElements.define(
|
||||
'affine-database-virtual-cell-container',
|
||||
DatabaseCellContainer
|
||||
);
|
||||
customElements.define('virtual-table-header', VirtualTableHeader);
|
||||
customElements.define(
|
||||
'virtual-data-view-column-preview',
|
||||
DataViewColumnPreview
|
||||
);
|
||||
customElements.define('virtual-database-header-column', DatabaseHeaderColumn);
|
||||
customElements.define(
|
||||
'virtual-database-number-format-bar',
|
||||
DatabaseNumberFormatBar
|
||||
);
|
||||
customElements.define('data-view-table-row-header', TableRowHeader);
|
||||
customElements.define('data-view-table-row-last', TableRowLast);
|
||||
customElements.define('data-view-virtual-table-selection', SelectionElement);
|
||||
customElements.define('data-view-virtual-drag-to-fill', DragToFillElement);
|
||||
customElements.define(
|
||||
'data-view-virtual-table-vertical-indicator',
|
||||
TableVerticalIndicator
|
||||
);
|
||||
customElements.define('virtual-element-wrapper', VirtualElementWrapper);
|
||||
customElements.define(
|
||||
'affine-database-virtual-column-stats',
|
||||
VirtualDataBaseColumnStats
|
||||
);
|
||||
customElements.define(
|
||||
'affine-database-virtual-column-stats-cell',
|
||||
VirtualDatabaseColumnStatsCell
|
||||
);
|
||||
customElements.define('virtual-table-group-header', TableGroupHeader);
|
||||
customElements.define('virtual-table-group-footer', TableGroupFooter);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const groupFooter = style({
|
||||
display: 'block',
|
||||
});
|
||||
export const addRowWrapper = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
height: '28px',
|
||||
position: 'relative',
|
||||
zIndex: 0,
|
||||
cursor: 'pointer',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
padding: '4px 8px',
|
||||
borderBottom: `1px solid ${cssVarV2.database.border}`,
|
||||
});
|
||||
|
||||
export const addRowButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
position: 'sticky',
|
||||
left: '8px',
|
||||
});
|
||||
|
||||
export const addRowText = style({
|
||||
userSelect: 'none',
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2.text.secondary,
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { TableViewAreaSelection } from '../../../selection';
|
||||
import type { VirtualTableView } from '../../table-view';
|
||||
import type { TableGridGroup } from '../../types';
|
||||
import * as styles from './group-footer.css';
|
||||
|
||||
export class TableGroupFooter extends WithDisposable(ShadowlessElement) {
|
||||
@property({ attribute: false })
|
||||
accessor tableView!: VirtualTableView;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor gridGroup!: TableGridGroup;
|
||||
|
||||
group$ = computed(() => {
|
||||
return this.tableView.groupTrait$.value?.groupsDataList$.value?.find(
|
||||
g => g.key === this.gridGroup.groupId
|
||||
);
|
||||
});
|
||||
|
||||
get selectionController() {
|
||||
return this.tableView.selectionController;
|
||||
}
|
||||
|
||||
get tableViewManager() {
|
||||
return this.tableView.props.view;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(styles.groupFooter);
|
||||
this.disposables.addFromEvent(this, 'mouseenter', () => {
|
||||
this.gridGroup.data.footerHover$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'mouseleave', () => {
|
||||
this.gridGroup.data.footerHover$.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
private readonly clickAddRow = () => {
|
||||
const group = this.group$.value;
|
||||
const rowId = this.tableViewManager.rowAdd('end', group?.key);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const rowIndex = this.selectionController.getRow(group?.key, rowId)
|
||||
?.rowIndex$.value;
|
||||
if (rowIndex == null) return;
|
||||
const index = this.tableViewManager.properties$.value.findIndex(
|
||||
v => v.type$.value === 'title'
|
||||
);
|
||||
|
||||
this.selectionController.selection = undefined;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.selectionController.selection = TableViewAreaSelection.create({
|
||||
groupKey: group?.key,
|
||||
focus: {
|
||||
rowIndex: rowIndex,
|
||||
columnIndex: index,
|
||||
},
|
||||
isEditing: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${this.tableViewManager.readonly$.value
|
||||
? null
|
||||
: html`
|
||||
<div
|
||||
class="${styles.addRowWrapper} dv-hover"
|
||||
@click="${this.clickAddRow}"
|
||||
>
|
||||
<div
|
||||
class="${styles.addRowButton} dv-icon-16"
|
||||
data-test-id="affine-database-add-row-button"
|
||||
role="button"
|
||||
>
|
||||
${PlusIcon()}<span class="${styles.addRowText}"
|
||||
>New Record</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
<affine-database-virtual-column-stats
|
||||
.view="${this.tableViewManager}"
|
||||
.group="${this.group$.value}"
|
||||
></affine-database-virtual-column-stats>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-table-group-footer': TableGroupFooter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import type { GroupData } from '../../../../../../core/group-by/trait';
|
||||
import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../../../../consts';
|
||||
import type { TableSingleView } from '../../../../table-view-manager';
|
||||
|
||||
const styles = css`
|
||||
affine-database-virtual-column-stats {
|
||||
margin-left: ${LEFT_TOOL_BAR_WIDTH}px;
|
||||
height: ${STATS_BAR_HEIGHT}px;
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
export class VirtualDataBaseColumnStats extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
protected override render() {
|
||||
const cols = this.view.properties$.value;
|
||||
return html`
|
||||
${repeat(
|
||||
cols,
|
||||
col => col.id,
|
||||
col => {
|
||||
return html`<affine-database-virtual-column-stats-cell
|
||||
.column=${col}
|
||||
.group=${this.group}
|
||||
></affine-database-virtual-column-stats-cell>`;
|
||||
}
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor group: GroupData | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: TableSingleView;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-virtual-column-stats': VirtualDataBaseColumnStats;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
menu,
|
||||
type MenuConfig,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { Text } from '@blocksuite/store';
|
||||
import { autoPlacement, offset } from '@floating-ui/dom';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { typeSystem } from '../../../../../../core';
|
||||
import type { GroupData } from '../../../../../../core/group-by/trait';
|
||||
import { statsFunctions } from '../../../../../../core/statistics';
|
||||
import type { StatisticsConfig } from '../../../../../../core/statistics/types';
|
||||
import type { TableColumn } from '../../../../table-view-manager';
|
||||
|
||||
const styles = css`
|
||||
.stats-cell {
|
||||
cursor: pointer;
|
||||
transition: opacity 230ms ease;
|
||||
font-size: 12px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
display: flex;
|
||||
opacity: 0;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
affine-database-virtual-column-stats:hover .stats-cell {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stats-cell:hover,
|
||||
affine-database-virtual-column-stats-cell.active .stats-cell {
|
||||
opacity: 1;
|
||||
background-color: var(--affine-hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stats-cell[calculated='true'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stats-cell .content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
margin-inline: 5px;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
export class VirtualDatabaseColumnStatsCell extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column!: TableColumn;
|
||||
|
||||
cellValues$ = computed(() => {
|
||||
if (this.group) {
|
||||
return this.group.rows.map(id => {
|
||||
return this.column.valueGet(id);
|
||||
});
|
||||
}
|
||||
return this.column.cells$.value.map(cell => cell.jsonValue$.value);
|
||||
});
|
||||
|
||||
groups$ = computed(() => {
|
||||
const groups: Record<string, Record<string, StatisticsConfig>> = {};
|
||||
|
||||
statsFunctions.forEach(func => {
|
||||
if (!typeSystem.unify(this.column.dataType$.value, func.dataType)) {
|
||||
return;
|
||||
}
|
||||
if (!groups[func.group]) {
|
||||
groups[func.group] = {};
|
||||
}
|
||||
const oldFunc = groups[func.group]?.[func.type];
|
||||
if (!oldFunc || typeSystem.unify(func.dataType, oldFunc.dataType)) {
|
||||
if (!func.impl) {
|
||||
delete groups[func.group]?.[func.type];
|
||||
} else {
|
||||
const group = groups[func.group];
|
||||
if (group) {
|
||||
group[func.type] = func;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
openMenu = (ev: MouseEvent) => {
|
||||
const menus: MenuConfig[] = Object.entries(this.groups$.value).map(
|
||||
([group, funcs]) => {
|
||||
return menu.subMenu({
|
||||
name: group,
|
||||
options: {
|
||||
items: Object.values(funcs).map(func => {
|
||||
return menu.action({
|
||||
isSelected: func.type === this.column.statCalcOp$.value,
|
||||
name: func.menuName ?? func.type,
|
||||
select: () => {
|
||||
this.column.updateStatCalcOp(func.type);
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
popMenu(popupTargetFromElement(ev.currentTarget as HTMLElement), {
|
||||
options: {
|
||||
items: [
|
||||
menu.action({
|
||||
isSelected: !this.column.statCalcOp$.value,
|
||||
name: 'None',
|
||||
select: () => {
|
||||
this.column.updateStatCalcOp();
|
||||
},
|
||||
}),
|
||||
...menus,
|
||||
],
|
||||
},
|
||||
middleware: [
|
||||
autoPlacement({ allowedPlacements: ['top', 'bottom'] }),
|
||||
offset(10),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
statsFunc$ = computed(() => {
|
||||
return Object.values(this.groups$.value)
|
||||
.flatMap(group => Object.values(group))
|
||||
.find(func => func.type === this.column.statCalcOp$.value);
|
||||
});
|
||||
|
||||
values$ = signal<unknown[]>([]);
|
||||
|
||||
statsResult$ = computed(() => {
|
||||
const meta = this.column.view.propertyMetaGet(this.column.type$.value);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
const func = this.statsFunc$.value;
|
||||
if (!func) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: func.displayName,
|
||||
value:
|
||||
func.impl?.(this.values$.value, {
|
||||
meta,
|
||||
dataSource: this.column.view.manager.dataSource,
|
||||
}) ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
subscriptionMap = new Map<unknown, () => void>();
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.disposables.addFromEvent(this, 'click', this.openMenu);
|
||||
this.disposables.add(
|
||||
this.cellValues$.subscribe(values => {
|
||||
const map = new Map<unknown, () => void>();
|
||||
values.forEach(value => {
|
||||
if (value instanceof Text) {
|
||||
const unsub = this.subscriptionMap.get(value);
|
||||
if (unsub) {
|
||||
map.set(value, unsub);
|
||||
this.subscriptionMap.delete(value);
|
||||
} else {
|
||||
const f = () => {
|
||||
this.values$.value = [...this.cellValues$.value];
|
||||
};
|
||||
value.yText.observe(f);
|
||||
map.set(value, () => {
|
||||
value.yText.unobserve(f);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
this.subscriptionMap.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
this.subscriptionMap = map;
|
||||
this.values$.value = this.cellValues$.value;
|
||||
})
|
||||
);
|
||||
this.disposables.add(() => {
|
||||
this.subscriptionMap.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
this.subscriptionMap.clear();
|
||||
});
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const style = {
|
||||
width: `${this.column.width$.value}px`,
|
||||
};
|
||||
return html` <div
|
||||
calculated="${!!this.column.statCalcOp$.value}"
|
||||
style="${styleMap(style)}"
|
||||
class="stats-cell"
|
||||
>
|
||||
<div class="content">
|
||||
${!this.statsResult$.value
|
||||
? html`Calculate ${ArrowDownSmallIcon()}`
|
||||
: html`
|
||||
<span class="label">${this.statsResult$.value.name}</span>
|
||||
<span class="value">${this.statsResult$.value.value} </span>
|
||||
`}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor group: GroupData | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-virtual-column-stats-cell': VirtualDatabaseColumnStatsCell;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const groupHeader = style({
|
||||
display: 'block',
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
menu,
|
||||
popFilterableSimpleMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { TableViewAreaSelection } from '../../../selection';
|
||||
import type { VirtualTableView } from '../../table-view';
|
||||
import type { TableGridGroup } from '../../types';
|
||||
import * as styles from './group-header.css';
|
||||
import { GroupTitle } from './group-title';
|
||||
export class TableGroupHeader extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ attribute: false })
|
||||
accessor tableView!: VirtualTableView;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor gridGroup!: TableGridGroup;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.classList.add(styles.groupHeader);
|
||||
this.disposables.addFromEvent(this, 'mouseenter', () => {
|
||||
this.gridGroup.data.headerHover$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'mouseleave', () => {
|
||||
this.gridGroup.data.headerHover$.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
group$ = computed(() => {
|
||||
return this.tableView.groupTrait$.value?.groupsDataList$.value?.find(
|
||||
g => g.key === this.gridGroup.groupId
|
||||
);
|
||||
});
|
||||
|
||||
groupKey$ = computed(() => {
|
||||
return this.group$.value?.key;
|
||||
});
|
||||
|
||||
get tableViewManager() {
|
||||
return this.tableView.props.view;
|
||||
}
|
||||
|
||||
get selectionController() {
|
||||
return this.tableView.selectionController;
|
||||
}
|
||||
|
||||
private readonly clickAddRowInStart = () => {
|
||||
const group = this.group$.value;
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
this.tableViewManager.rowAdd('start', group.key);
|
||||
const selectionController = this.selectionController;
|
||||
selectionController.selection = undefined;
|
||||
requestAnimationFrame(() => {
|
||||
const index = this.tableViewManager.properties$.value.findIndex(
|
||||
v => v.type$.value === 'title'
|
||||
);
|
||||
selectionController.selection = TableViewAreaSelection.create({
|
||||
groupKey: group.key,
|
||||
focus: {
|
||||
rowIndex: 0,
|
||||
columnIndex: index,
|
||||
},
|
||||
isEditing: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private readonly clickGroupOptions = (e: MouseEvent) => {
|
||||
const group = this.group$.value;
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
const ele = e.currentTarget as HTMLElement;
|
||||
popFilterableSimpleMenu(popupTargetFromElement(ele), [
|
||||
menu.action({
|
||||
name: 'Ungroup',
|
||||
hide: () => group.value == null,
|
||||
select: () => {
|
||||
group.rows.forEach(id => {
|
||||
group.manager.removeFromGroup(id, group.key);
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete Cards',
|
||||
select: () => {
|
||||
this.tableViewManager.rowDelete(group.rows);
|
||||
},
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
private readonly renderGroupHeader = () => {
|
||||
const group = this.group$.value;
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
style="position: sticky;left: 0;width: max-content;padding: 6px 0;margin-bottom: 4px;display:flex;align-items:center;gap: 12px;max-width: 400px"
|
||||
>
|
||||
${GroupTitle(group, {
|
||||
groupHover: this.gridGroup.data.headerHover$.value,
|
||||
readonly: this.tableViewManager.readonly$.value,
|
||||
clickAdd: this.clickAddRowInStart,
|
||||
clickOps: this.clickGroupOptions,
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${this.renderGroupHeader()}
|
||||
<virtual-table-header
|
||||
.tableViewManager="${this.tableViewManager}"
|
||||
></virtual-table-header>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-table-group-header': TableGroupHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const groupHeaderCount = style({
|
||||
flexShrink: 0,
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: cssVarV2.text.secondary,
|
||||
fontSize: 'var(--data-view-cell-text-size)',
|
||||
});
|
||||
|
||||
export const groupHeaderName = style({
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const groupHeaderOps = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
'&:has(.active)': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const show = style({
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
export const groupHeaderOp = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
borderRadius: '4px',
|
||||
transition: 'all 150ms cubic-bezier(0.42, 0, 1, 1)',
|
||||
color: cssVarV2.icon.primary,
|
||||
selectors: {
|
||||
'&:hover, &.active': {
|
||||
backgroundColor: cssVarV2.layer.background.hoverOverlay,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const groupHeaderIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginRight: '-4px',
|
||||
color: cssVarV2.icon.primary,
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const groupHeaderTitle = style({
|
||||
color: cssVarV2.text.primary,
|
||||
fontSize: 'var(--data-view-cell-text-size)',
|
||||
marginLeft: '4px',
|
||||
});
|
||||
|
||||
export const groupTitleRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
overflow: 'hidden',
|
||||
height: '22px',
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { MoreHorizontalIcon, PlusIcon } from '@blocksuite/icons/lit';
|
||||
import clsx from 'clsx';
|
||||
import { nothing } from 'lit';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import {
|
||||
type GroupData,
|
||||
type GroupRenderProps,
|
||||
renderUniLit,
|
||||
} from '../../../../../core';
|
||||
import {
|
||||
groupHeaderCount,
|
||||
groupHeaderIcon,
|
||||
groupHeaderOp,
|
||||
groupHeaderOps,
|
||||
groupHeaderTitle,
|
||||
groupTitleRow,
|
||||
show,
|
||||
} from './group-title.css';
|
||||
|
||||
function GroupHeaderCount(group: GroupData) {
|
||||
const cards = group.rows;
|
||||
if (!cards.length) {
|
||||
return;
|
||||
}
|
||||
return html` <div class="${groupHeaderCount}">${cards.length}</div>`;
|
||||
}
|
||||
|
||||
export const GroupTitle = (
|
||||
groupData: GroupData,
|
||||
ops: {
|
||||
groupHover: boolean;
|
||||
readonly: boolean;
|
||||
clickAdd: (evt: MouseEvent) => void;
|
||||
clickOps: (evt: MouseEvent) => void;
|
||||
}
|
||||
) => {
|
||||
const data = groupData.manager.config$.value;
|
||||
if (!data) return nothing;
|
||||
const icon =
|
||||
groupData.value == null
|
||||
? ''
|
||||
: html` <uni-lit
|
||||
class="${groupHeaderIcon}"
|
||||
.uni="${groupData.manager.property$.value?.icon}"
|
||||
></uni-lit>`;
|
||||
const props: GroupRenderProps = {
|
||||
value: groupData.value,
|
||||
data: groupData.property.data$.value,
|
||||
updateData: groupData.manager.updateData,
|
||||
updateValue: value => groupData.manager.updateValue(groupData.rows, value),
|
||||
readonly: ops.readonly,
|
||||
};
|
||||
|
||||
const showColumnName = groupData.property.type$.value === 'checkbox';
|
||||
const columnName = showColumnName
|
||||
? html`<span class="${groupHeaderTitle}"
|
||||
>${groupData.property.name$.value}</span
|
||||
>`
|
||||
: nothing;
|
||||
const opsClass = clsx(ops.groupHover && show, groupHeaderOps);
|
||||
return html`
|
||||
<div class="${groupTitleRow}">
|
||||
${icon} ${renderUniLit(data.view, props)} ${columnName}
|
||||
${GroupHeaderCount(groupData)}
|
||||
</div>
|
||||
${!ops.readonly
|
||||
? html` <div class="${opsClass}">
|
||||
<div @click="${ops.clickAdd}" class="${groupHeaderOp}">
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
<div @click="${ops.clickOps}" class="${groupHeaderOp}">
|
||||
${MoreHorizontalIcon()}
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
import {
|
||||
DEFAULT_ADD_BUTTON_WIDTH,
|
||||
DEFAULT_COLUMN_TITLE_HEIGHT,
|
||||
} from '../../../../consts';
|
||||
|
||||
export const columnHeaderContainer = style({
|
||||
display: 'block',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
});
|
||||
|
||||
export const columnHeader = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderBottom: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
borderTop: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
boxSizing: 'border-box',
|
||||
userSelect: 'none',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const column = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const cell = style({
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const addColumnButton = style({
|
||||
flex: 1,
|
||||
minWidth: `${DEFAULT_ADD_BUTTON_WIDTH}px`,
|
||||
minHeight: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const columnContent = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '6px',
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const columnText = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
overflow: 'hidden',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: '14px',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const columnTypeIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
fontSize: '18px',
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
|
||||
export const columnTextContent = style({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const columnTextInput = style({
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
export const columnTextIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
background: 'var(--affine-white)',
|
||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
borderRadius: '4px',
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
export const columnTextSaveIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '1px solid transparent',
|
||||
borderRadius: '4px',
|
||||
fill: 'var(--affine-icon-color)',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-white)',
|
||||
borderColor: cssVarV2.layer.insideBorder.border,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const columnInput = style({
|
||||
width: '100%',
|
||||
height: '24px',
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
fontFamily: baseTheme.fontSansFamily,
|
||||
background: 'transparent',
|
||||
selectors: {
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const columnMove = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
vars: {
|
||||
'--color': 'var(--affine-placeholder-color)',
|
||||
'--active': 'var(--affine-black-10)',
|
||||
'--bw': '1px',
|
||||
'--bw2': '-1px',
|
||||
},
|
||||
cursor: 'grab',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
});
|
||||
|
||||
globalStyle(`${columnMove} svg`, {
|
||||
width: '10px',
|
||||
height: '14px',
|
||||
color: 'var(--affine-black-10)',
|
||||
cursor: 'grab',
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
export const databaseAddColumnButton = style({
|
||||
position: 'sticky',
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
height: '38px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const headerAddColumnButton = style({
|
||||
height: `${DEFAULT_COLUMN_TITLE_HEIGHT}px`,
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '40px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '18px',
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
|
||||
export const columnTypeMenuIcon = style({
|
||||
border: `1px solid ${cssVarV2.layer.insideBorder.border}`,
|
||||
borderRadius: '4px',
|
||||
padding: '5px',
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
});
|
||||
|
||||
export const columnMovePreview = style({
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
width: '100px',
|
||||
height: '100px',
|
||||
background: 'var(--affine-text-emphasis-color)',
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import type { TableSingleView } from '../../../../table-view-manager';
|
||||
import * as styles from './column-header.css';
|
||||
|
||||
export class VirtualTableHeader extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private readonly _onAddColumn = (e: MouseEvent) => {
|
||||
if (this.readonly) return;
|
||||
this.tableViewManager.propertyAdd('end');
|
||||
const ele = e.currentTarget as HTMLElement;
|
||||
requestAnimationFrame(() => {
|
||||
this.editLastColumnTitle();
|
||||
ele.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
};
|
||||
|
||||
editLastColumnTitle = () => {
|
||||
const columns = this.querySelectorAll('affine-database-header-column');
|
||||
const column = columns.item(columns.length - 1);
|
||||
column.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
column.editTitle();
|
||||
};
|
||||
|
||||
preMove = 0;
|
||||
|
||||
private get readonly() {
|
||||
return this.tableViewManager.readonly$.value;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(styles.columnHeaderContainer);
|
||||
}
|
||||
|
||||
getScale() {
|
||||
return this.scaleDiv?.getBoundingClientRect().width ?? 1;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="${styles.columnHeader} database-row">
|
||||
${this.readonly
|
||||
? nothing
|
||||
: html`<div class="data-view-table-left-bar"></div>`}
|
||||
${repeat(
|
||||
this.tableViewManager.properties$.value,
|
||||
column => column.id,
|
||||
(column, index) => {
|
||||
const style = styleMap({
|
||||
width: `${column.width$.value}px`,
|
||||
border: index === 0 ? 'none' : undefined,
|
||||
});
|
||||
return html`
|
||||
<affine-database-header-column
|
||||
style="${style}"
|
||||
data-column-id="${column.id}"
|
||||
data-column-index="${index}"
|
||||
class="${styles.column} ${styles.cell}"
|
||||
.column="${column}"
|
||||
.tableViewManager="${this.tableViewManager}"
|
||||
></affine-database-header-column>
|
||||
<div class="cell-divider" style="height: auto;"></div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
<div
|
||||
@click="${this._onAddColumn}"
|
||||
class="${styles.headerAddColumnButton}"
|
||||
>
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
<div class="scale-div" style="width: 1px;height: 1px;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.scale-div')
|
||||
accessor scaleDiv!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tableViewManager!: TableSingleView;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-table-header': VirtualTableHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import type { GroupData } from '../../../../../../core/group-by/trait';
|
||||
import type {
|
||||
TableColumn,
|
||||
TableSingleView,
|
||||
} from '../../../../table-view-manager';
|
||||
|
||||
export class DataViewColumnPreview extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
affine-data-view-column-preview {
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: fixed;
|
||||
font-family: var(--affine-font-family);
|
||||
}
|
||||
`;
|
||||
|
||||
get tableViewManager(): TableSingleView {
|
||||
return this.column.view as TableSingleView;
|
||||
}
|
||||
|
||||
private renderGroup(rows: string[]) {
|
||||
const columnIndex = this.tableViewManager.propertyIndexGet(this.column.id);
|
||||
return html`
|
||||
<div
|
||||
style="background-color: var(--affine-background-primary-color);border-top: 1px solid ${unsafeCSS(
|
||||
cssVarV2.layer.insideBorder.border
|
||||
)};box-shadow: var(--affine-shadow-2);"
|
||||
>
|
||||
<affine-database-header-column
|
||||
.tableViewManager="${this.tableViewManager}"
|
||||
.column="${this.column}"
|
||||
></affine-database-header-column>
|
||||
${repeat(rows, (id, index) => {
|
||||
const height = this.container.querySelector(
|
||||
`affine-database-cell-container[data-row-id="${id}"]`
|
||||
)?.clientHeight;
|
||||
const style = styleMap({
|
||||
height: height + 'px',
|
||||
});
|
||||
return html`<div
|
||||
style="border-top: 1px solid ${unsafeCSS(
|
||||
cssVarV2.layer.insideBorder.border
|
||||
)}"
|
||||
>
|
||||
<div style="${style}">
|
||||
<affine-database-cell-container
|
||||
.column="${this.column}"
|
||||
.view="${this.tableViewManager}"
|
||||
.rowId="${id}"
|
||||
.columnId="${this.column.id}"
|
||||
.rowIndex="${index}"
|
||||
.columnIndex="${columnIndex}"
|
||||
></affine-database-cell-container>
|
||||
</div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>
|
||||
<div style="height: 45px;"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.renderGroup(
|
||||
this.group?.rows ?? this.tableViewManager.rows$.value
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column!: TableColumn;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor container!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor group: GroupData | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-data-view-column-preview': DataViewColumnPreview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { css, html, LitElement, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { Property } from '../../../../../../core/view-manager/property';
|
||||
import { formatNumber } from '../../../../../../property-presets/number/utils/formatter';
|
||||
|
||||
const IncreaseDecimalPlacesIcon = html`
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 10.5H2.66176V13.5H0V10.5ZM13.3333 18H20V21H13.3333V18ZM22.6259 17.9356L24 19.5282L22.6312 21.0585L20 24V15L22.6259 17.9356ZM3.99019 4.4953C3.99019 2.01262 5.78279 0 7.98405 0C10.1898 0 11.9779 2.02109 11.9779 4.4953V9.0047C11.9779 11.4874 10.1853 13.5 7.98405 13.5C5.7783 13.5 3.99019 11.4789 3.99019 9.0047V4.4953ZM6 4.49786V9.00214C6 10.2525 6.89543 11.25 8 11.25C9.11227 11.25 10 10.2436 10 9.00214V4.49786C10 3.24754 9.10457 2.25 8 2.25C6.88773 2.25 6 3.2564 6 4.49786ZM13.3235 4.4953C13.3235 2.01262 15.1161 0 17.3174 0C19.5231 0 21.3113 2.02109 21.3113 4.4953V9.0047C21.3113 11.4874 19.5187 13.5 17.3174 13.5C15.1116 13.5 13.3235 11.4789 13.3235 9.0047V4.4953ZM15.3333 4.49786V9.00214C15.3333 10.2525 16.2288 11.25 17.3333 11.25C18.4456 11.25 19.3333 10.2436 19.3333 9.00214V4.49786C19.3333 3.24754 18.4379 2.25 17.3333 2.25C16.2211 2.25 15.3333 3.2564 15.3333 4.49786Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const DecreaseDecimalPlacesIcon = html`
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0 10.5H3V13.5H0V10.5ZM18.09 22.41L16.305 20.625H24V18.375H16.305L18.09 16.59L16.5 15L12 19.5L16.5 24L18.09 22.41ZM13.5 9.375V4.125C13.5 1.845 11.655 0 9.375 0C7.095 0 5.25 1.845 5.25 4.125V9.375C5.25 11.655 7.095 13.5 9.375 13.5C11.655 13.5 13.5 11.655 13.5 9.375ZM11.25 9.375C11.25 10.41 10.41 11.25 9.375 11.25C8.34 11.25 7.5 10.41 7.5 9.375V4.125C7.5 3.09 8.34 2.25 9.375 2.25C10.41 2.25 11.25 3.09 11.25 4.125V9.375Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
export class DatabaseNumberFormatBar extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
.number-format-toolbar-container {
|
||||
padding: 4px 12px;
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.number-format-decimal-places {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.number-format-toolbar-button {
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--affine-icon-color);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
position: relative;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.number-format-toolbar-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.number-formatting-sample {
|
||||
font-size: var(--affine-font-xs);
|
||||
color: var(--affine-icon-color);
|
||||
margin-left: auto;
|
||||
}
|
||||
.number-format-toolbar-button:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: ${unsafeCSS(cssVarV2.layer.insideBorder.border)};
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _decrementDecimalPlaces = () => {
|
||||
this.column.dataUpdate(data => ({
|
||||
decimal: Math.max(((data.decimal as number) ?? 0) - 1, 0),
|
||||
}));
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private readonly _incrementDecimalPlaces = () => {
|
||||
this.column.dataUpdate(data => ({
|
||||
decimal: Math.min(((data.decimal as number) ?? 0) + 1, 8),
|
||||
}));
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="number-format-toolbar-container">
|
||||
<div class="number-format-decimal-places">
|
||||
<button
|
||||
class="number-format-toolbar-button"
|
||||
aria-label="decrease decimal places"
|
||||
@click=${this._decrementDecimalPlaces}
|
||||
>
|
||||
${DecreaseDecimalPlacesIcon}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="number-format-toolbar-button"
|
||||
aria-label="increase decimal places"
|
||||
@click=${this._incrementDecimalPlaces}
|
||||
>
|
||||
${IncreaseDecimalPlacesIcon}
|
||||
</button>
|
||||
<span class="number-formatting-sample">
|
||||
<span class="number-formatting-sample">
|
||||
( ${formatNumber(
|
||||
1,
|
||||
'number',
|
||||
(this.column.data$.value?.decimal as number) ?? 0
|
||||
)} )
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column!: Property;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-database-number-format-bar': DatabaseNumberFormatBar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
menu,
|
||||
type MenuConfig,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
FilterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoveLeftIcon,
|
||||
MoveRightIcon,
|
||||
SortIcon,
|
||||
ViewIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { css } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { renderUniLit } from '../../../../../../core';
|
||||
import {
|
||||
inputConfig,
|
||||
typeConfig,
|
||||
} from '../../../../../../core/common/property-menu';
|
||||
import { filterTraitKey } from '../../../../../../core/filter/trait';
|
||||
import { firstFilterByRef } from '../../../../../../core/filter/utils';
|
||||
import { sortTraitKey } from '../../../../../../core/sort/manager';
|
||||
import { createSortUtils } from '../../../../../../core/sort/utils';
|
||||
import {
|
||||
draggable,
|
||||
dragHandler,
|
||||
droppable,
|
||||
} from '../../../../../../core/utils/wc-dnd/dnd-context';
|
||||
import type { Property } from '../../../../../../core/view-manager/property';
|
||||
import { numberFormats } from '../../../../../../property-presets/number/utils/formats';
|
||||
import { ShowQuickSettingBarContextKey } from '../../../../../../widget-presets/quick-setting-bar/context';
|
||||
import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../../../../consts';
|
||||
import type {
|
||||
TableColumn,
|
||||
TableSingleView,
|
||||
} from '../../../../table-view-manager';
|
||||
import {
|
||||
getTableGroupRect,
|
||||
getVerticalIndicator,
|
||||
startDragWidthAdjustmentBar,
|
||||
} from './vertical-indicator';
|
||||
|
||||
export class DatabaseHeaderColumn extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
affine-database-header-column {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.affine-database-header-column-grabbing * {
|
||||
cursor: grabbing;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _clickColumn = () => {
|
||||
if (this.tableViewManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
this.popMenu();
|
||||
};
|
||||
|
||||
private readonly _clickTypeIcon = (event: MouseEvent) => {
|
||||
if (this.tableViewManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
if (this.column.type$.value === 'title') {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
popMenu(popupTargetFromElement(this), {
|
||||
options: {
|
||||
items: this.tableViewManager.propertyMetas$.value.map(config => {
|
||||
return menu.action({
|
||||
name: config.config.name,
|
||||
isSelected: config.type === this.column.type$.value,
|
||||
prefix: renderUniLit(
|
||||
this.tableViewManager.propertyIconGet(config.type)
|
||||
),
|
||||
select: () => {
|
||||
this.column.typeSet?.(config.type);
|
||||
},
|
||||
});
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _contextMenu = (e: MouseEvent) => {
|
||||
if (this.tableViewManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.popMenu(e.currentTarget as HTMLElement);
|
||||
};
|
||||
|
||||
private readonly _enterWidthDragBar = () => {
|
||||
if (this.tableViewManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
if (this.drawWidthDragBarTask) {
|
||||
cancelAnimationFrame(this.drawWidthDragBarTask);
|
||||
this.drawWidthDragBarTask = 0;
|
||||
}
|
||||
this.drawWidthDragBar();
|
||||
};
|
||||
|
||||
private readonly _leaveWidthDragBar = () => {
|
||||
cancelAnimationFrame(this.drawWidthDragBarTask);
|
||||
this.drawWidthDragBarTask = 0;
|
||||
getVerticalIndicator().remove();
|
||||
};
|
||||
|
||||
private readonly drawWidthDragBar = () => {
|
||||
const rect = getTableGroupRect(this);
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
getVerticalIndicator().display(
|
||||
this.getBoundingClientRect().right,
|
||||
rect.top,
|
||||
rect.bottom - rect.top
|
||||
);
|
||||
this.drawWidthDragBarTask = requestAnimationFrame(this.drawWidthDragBar);
|
||||
};
|
||||
|
||||
private drawWidthDragBarTask = 0;
|
||||
|
||||
private readonly widthDragBar = createRef();
|
||||
|
||||
editTitle = () => {
|
||||
this._clickColumn();
|
||||
};
|
||||
|
||||
private get readonly() {
|
||||
return this.tableViewManager.readonly$.value;
|
||||
}
|
||||
|
||||
private _addFilter() {
|
||||
const filterTrait = this.tableViewManager.traitGet(filterTraitKey);
|
||||
if (!filterTrait) return;
|
||||
|
||||
const filter = firstFilterByRef(this.tableViewManager.vars$.value, {
|
||||
type: 'ref',
|
||||
name: this.column.id,
|
||||
});
|
||||
|
||||
filterTrait.filterSet({
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [filter, ...filterTrait.filter$.value.conditions],
|
||||
});
|
||||
|
||||
this._toggleQuickSettingBar();
|
||||
}
|
||||
|
||||
private _addSort(desc: boolean) {
|
||||
const sortTrait = this.tableViewManager.traitGet(sortTraitKey);
|
||||
if (!sortTrait) return;
|
||||
|
||||
const sortUtils = createSortUtils(
|
||||
sortTrait,
|
||||
this.closest('affine-data-view-renderer')?.view?.eventTrace ?? (() => {})
|
||||
);
|
||||
const sortList = sortUtils.sortList$.value;
|
||||
const existingIndex = sortList.findIndex(
|
||||
sort => sort.ref.name === this.column.id
|
||||
);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
sortUtils.change(existingIndex, {
|
||||
ref: { type: 'ref', name: this.column.id },
|
||||
desc,
|
||||
});
|
||||
} else {
|
||||
sortUtils.add({
|
||||
ref: { type: 'ref', name: this.column.id },
|
||||
desc,
|
||||
});
|
||||
}
|
||||
|
||||
this._toggleQuickSettingBar();
|
||||
}
|
||||
|
||||
private _toggleQuickSettingBar(show = true) {
|
||||
const map = this.tableViewManager.contextGet(ShowQuickSettingBarContextKey);
|
||||
map.value = {
|
||||
...map.value,
|
||||
[this.tableViewManager.id]: show,
|
||||
};
|
||||
}
|
||||
|
||||
private popMenu(ele?: HTMLElement) {
|
||||
const enableNumberFormatting =
|
||||
this.tableViewManager.featureFlags$.value.enable_number_formatting;
|
||||
|
||||
popMenu(popupTargetFromElement(ele ?? this), {
|
||||
options: {
|
||||
items: [
|
||||
inputConfig(this.column),
|
||||
typeConfig(this.column),
|
||||
// Number format begin
|
||||
...(enableNumberFormatting
|
||||
? [
|
||||
menu.subMenu({
|
||||
name: 'Number Format',
|
||||
hide: () =>
|
||||
!this.column.dataUpdate ||
|
||||
this.column.type$.value !== 'number',
|
||||
options: {
|
||||
items: [
|
||||
numberFormatConfig(this.column),
|
||||
...numberFormats.map(format => {
|
||||
const data = this.column.data$.value;
|
||||
return menu.action({
|
||||
isSelected: data.format === format.type,
|
||||
prefix: html`<span
|
||||
style="font-size: var(--affine-font-base); scale: 1.2;"
|
||||
>${format.symbol}</span
|
||||
>`,
|
||||
name: format.label,
|
||||
select: () => {
|
||||
if (data.format === format.type) return;
|
||||
this.column.dataUpdate(() => ({
|
||||
format: format.type,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// Number format end
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Hide In View',
|
||||
prefix: ViewIcon(),
|
||||
hide: () => !this.column.hideCanSet,
|
||||
select: () => {
|
||||
this.column.hideSet(true);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Filter',
|
||||
prefix: FilterIcon(),
|
||||
select: () => this._addFilter(),
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Sort Ascending',
|
||||
prefix: SortIcon(),
|
||||
select: () => this._addSort(false),
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Sort Descending',
|
||||
prefix: SortIcon(),
|
||||
select: () => this._addSort(true),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Insert Left Column',
|
||||
prefix: InsertLeftIcon(),
|
||||
select: () => {
|
||||
this.tableViewManager.propertyAdd({
|
||||
id: this.column.id,
|
||||
before: true,
|
||||
});
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
const pre =
|
||||
this.previousElementSibling?.previousElementSibling;
|
||||
if (pre instanceof DatabaseHeaderColumn) {
|
||||
pre.editTitle();
|
||||
pre.scrollIntoView({
|
||||
inline: 'nearest',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Insert Right Column',
|
||||
prefix: InsertRightIcon(),
|
||||
select: () => {
|
||||
this.tableViewManager.propertyAdd({
|
||||
id: this.column.id,
|
||||
before: false,
|
||||
});
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
const next = this.nextElementSibling?.nextElementSibling;
|
||||
if (next instanceof DatabaseHeaderColumn) {
|
||||
next.editTitle();
|
||||
next.scrollIntoView({
|
||||
inline: 'nearest',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Left',
|
||||
prefix: MoveLeftIcon(),
|
||||
hide: () => this.column.isFirst,
|
||||
select: () => {
|
||||
const preId = this.tableViewManager.propertyPreGet(
|
||||
this.column.id
|
||||
)?.id;
|
||||
if (!preId) {
|
||||
return;
|
||||
}
|
||||
this.tableViewManager.propertyMove(this.column.id, {
|
||||
id: preId,
|
||||
before: true,
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Right',
|
||||
prefix: MoveRightIcon(),
|
||||
hide: () => this.column.isLast,
|
||||
select: () => {
|
||||
const nextId = this.tableViewManager.propertyNextGet(
|
||||
this.column.id
|
||||
)?.id;
|
||||
if (!nextId) {
|
||||
return;
|
||||
}
|
||||
this.tableViewManager.propertyMove(this.column.id, {
|
||||
id: nextId,
|
||||
before: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Duplicate',
|
||||
prefix: DuplicateIcon(),
|
||||
hide: () => !this.column.canDuplicate,
|
||||
select: () => {
|
||||
this.column.duplicate?.();
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
hide: () => !this.column.canDelete,
|
||||
select: () => {
|
||||
this.column.delete?.();
|
||||
},
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private widthDragStart(event: PointerEvent) {
|
||||
startDragWidthAdjustmentBar(
|
||||
event,
|
||||
this,
|
||||
this.getBoundingClientRect().width,
|
||||
this.column
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const table = this.closest('affine-database-table');
|
||||
if (table) {
|
||||
this.disposables.add(
|
||||
table.props.handleEvent('dragStart', context => {
|
||||
if (this.tableViewManager.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const event = context.get('pointerState').raw;
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof Element &&
|
||||
this.widthDragBar.value?.contains(target)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.widthDragStart(event);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const column = this.column;
|
||||
const style = styleMap({
|
||||
height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px',
|
||||
});
|
||||
const classes = classMap({
|
||||
'affine-database-column-move': true,
|
||||
[this.grabStatus]: true,
|
||||
});
|
||||
return html`
|
||||
<div
|
||||
style=${style}
|
||||
class="affine-database-column-content"
|
||||
@click="${this._clickColumn}"
|
||||
@contextmenu="${this._contextMenu}"
|
||||
${dragHandler(column.id)}
|
||||
${draggable(column.id)}
|
||||
${droppable(column.id)}
|
||||
>
|
||||
${this.readonly
|
||||
? null
|
||||
: html` <button class="${classes}">
|
||||
<div class="hover-trigger"></div>
|
||||
<div class="control-h"></div>
|
||||
<div class="control-l"></div>
|
||||
<div class="control-r"></div>
|
||||
</button>`}
|
||||
<div class="affine-database-column-text ${column.type$.value}">
|
||||
<div
|
||||
class="affine-database-column-type-icon dv-hover"
|
||||
@click="${this._clickTypeIcon}"
|
||||
>
|
||||
<uni-lit .uni="${column.icon}"></uni-lit>
|
||||
</div>
|
||||
<div class="affine-database-column-text-content">
|
||||
<div class="affine-database-column-text-input">
|
||||
${column.name$.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
${ref(this.widthDragBar)}
|
||||
@mouseenter="${this._enterWidthDragBar}"
|
||||
@mouseleave="${this._leaveWidthDragBar}"
|
||||
style="width: 0;position: relative;height: 100%;z-index: 1;cursor: col-resize"
|
||||
>
|
||||
<div style="width: 8px;height: 100%;margin-left: -4px;"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column!: TableColumn;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor grabStatus: 'grabStart' | 'grabEnd' | 'grabbing' = 'grabEnd';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tableViewManager!: TableSingleView;
|
||||
}
|
||||
|
||||
function numberFormatConfig(column: Property): MenuConfig {
|
||||
return () =>
|
||||
html` <virtual-database-number-format-bar
|
||||
.column="${column}"
|
||||
></virtual-database-number-format-bar>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-database-header-column': DatabaseHeaderColumn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { startDrag } from '../../../../../../core/utils/drag';
|
||||
import { getResultInRange } from '../../../../../../core/utils/utils';
|
||||
import type { TableColumn } from '../../../../table-view-manager';
|
||||
|
||||
export class TableVerticalIndicator extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
data-view-virtual-table-vertical-indicator {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vertical-indicator {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
width: 1px;
|
||||
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.vertical-indicator::after {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
content: '';
|
||||
right: 0;
|
||||
background-color: var(--affine-primary-color);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.with-shadow.vertical-indicator::after {
|
||||
box-shadow: 0px 0px 8px 0px rgba(30, 150, 235, 0.35);
|
||||
}
|
||||
`;
|
||||
|
||||
protected override render(): unknown {
|
||||
const style = styleMap({
|
||||
top: `${this.top}px`,
|
||||
left: `${this.left}px`,
|
||||
height: `${this.height}px`,
|
||||
width: `${this.width}px`,
|
||||
});
|
||||
const className = classMap({
|
||||
'with-shadow': this.shadow,
|
||||
'vertical-indicator': true,
|
||||
});
|
||||
return html` <div class="${className}" style=${style}></div> `;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor height!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor left!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor shadow = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor top!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor width!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-virtual-table-vertical-indicator': TableVerticalIndicator;
|
||||
}
|
||||
}
|
||||
|
||||
export const getTableGroupRect = (ele: HTMLElement) => {
|
||||
const group = ele.closest('affine-data-view-virtual-table-group');
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
const groupRect = group?.getBoundingClientRect();
|
||||
const top =
|
||||
group
|
||||
.querySelector('.affine-database-column-header')
|
||||
?.getBoundingClientRect().top ?? groupRect.top;
|
||||
const bottom =
|
||||
group.querySelector('.affine-database-block-rows')?.getBoundingClientRect()
|
||||
.bottom ?? groupRect.bottom;
|
||||
return {
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
};
|
||||
};
|
||||
export const startDragWidthAdjustmentBar = (
|
||||
evt: PointerEvent,
|
||||
ele: HTMLElement,
|
||||
width: number,
|
||||
column: TableColumn
|
||||
) => {
|
||||
const scale = width / column.width$.value;
|
||||
const left = ele.getBoundingClientRect().left;
|
||||
const rect = getTableGroupRect(ele);
|
||||
if (!rect) {
|
||||
return;
|
||||
}
|
||||
const preview = getVerticalIndicator();
|
||||
preview.display(left, rect.top, rect.bottom - rect.top, width * scale);
|
||||
startDrag<{ width: number }>(evt, {
|
||||
onDrag: () => ({ width: column.width$.value }),
|
||||
onMove: ({ x }) => {
|
||||
const width = Math.round(
|
||||
getResultInRange((x - left) / scale, column.minWidth, Infinity)
|
||||
);
|
||||
preview.display(left, rect.top, rect.bottom - rect.top, width * scale);
|
||||
return {
|
||||
width,
|
||||
};
|
||||
},
|
||||
onDrop: ({ width }) => {
|
||||
column.updateWidth(width);
|
||||
},
|
||||
onClear: () => {
|
||||
preview.remove();
|
||||
},
|
||||
});
|
||||
};
|
||||
let preview: VerticalIndicator | null = null;
|
||||
type VerticalIndicator = {
|
||||
display: (
|
||||
left: number,
|
||||
top: number,
|
||||
height: number,
|
||||
width?: number,
|
||||
shadow?: boolean
|
||||
) => void;
|
||||
remove: () => void;
|
||||
};
|
||||
export const getVerticalIndicator = (): VerticalIndicator => {
|
||||
if (!preview) {
|
||||
const dragBar = new TableVerticalIndicator();
|
||||
preview = {
|
||||
display(
|
||||
left: number,
|
||||
top: number,
|
||||
height: number,
|
||||
width = 1,
|
||||
shadow = false
|
||||
) {
|
||||
document.body.append(dragBar);
|
||||
dragBar.left = left;
|
||||
dragBar.height = height;
|
||||
dragBar.top = top;
|
||||
dragBar.width = width;
|
||||
dragBar.shadow = shadow;
|
||||
},
|
||||
remove() {
|
||||
dragBar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return preview;
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { computed, effect, signal } from '@preact/signals-core';
|
||||
import { css } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { renderUniLit } from '../../../../core';
|
||||
import type {
|
||||
CellRenderProps,
|
||||
DataViewCellLifeCycle,
|
||||
} from '../../../../core/property';
|
||||
import type { SingleView } from '../../../../core/view-manager/single-view';
|
||||
import {
|
||||
TableViewAreaSelection,
|
||||
TableViewRowSelection,
|
||||
type TableViewSelectionWithType,
|
||||
} from '../../selection';
|
||||
import type { VirtualTableView } from '../table-view';
|
||||
import type { TableGridCell } from '../types';
|
||||
import { popRowMenu } from './menu';
|
||||
import { rowSelectedBg } from './row-header.css';
|
||||
export class DatabaseCellContainer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
affine-database-virtual-cell-container {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
affine-database-virtual-cell-container * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
affine-database-virtual-cell-container uni-lit > *:first-child {
|
||||
padding: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _cell = signal<DataViewCellLifeCycle>();
|
||||
|
||||
cell$ = computed(() => {
|
||||
return this.view.cellGet(this.rowId, this.columnId);
|
||||
});
|
||||
|
||||
selectCurrentCell = (editing: boolean) => {
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const selectionView = this.selectionView;
|
||||
if (selectionView) {
|
||||
const selection = selectionView.selection;
|
||||
if (selection && this.isSelected(selection) && editing) {
|
||||
selectionView.selection = TableViewAreaSelection.create({
|
||||
groupKey: this.groupKey,
|
||||
focus: {
|
||||
rowIndex: this.rowIndex$.value,
|
||||
columnIndex: this.columnIndex$.value,
|
||||
},
|
||||
isEditing: true,
|
||||
});
|
||||
} else {
|
||||
selectionView.selection = TableViewAreaSelection.create({
|
||||
groupKey: this.groupKey,
|
||||
focus: {
|
||||
rowIndex: this.rowIndex$.value,
|
||||
columnIndex: this.columnIndex$.value,
|
||||
},
|
||||
isEditing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
get cell(): DataViewCellLifeCycle | undefined {
|
||||
return this._cell.value;
|
||||
}
|
||||
|
||||
private get selectionView() {
|
||||
return this.tableView?.selectionController;
|
||||
}
|
||||
|
||||
get rowSelected$() {
|
||||
return this.gridCell.row.data.selected$;
|
||||
}
|
||||
|
||||
contextMenu = (e: MouseEvent) => {
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
const selection = this.selectionView;
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const row = { id: this.rowId, groupKey: this.groupKey };
|
||||
if (!TableViewRowSelection.includes(selection.selection, row)) {
|
||||
selection.selection = TableViewRowSelection.create({
|
||||
rows: [row],
|
||||
});
|
||||
}
|
||||
popRowMenu(
|
||||
this.tableView.props.dataViewEle,
|
||||
popupTargetFromElement(this),
|
||||
selection
|
||||
);
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.disposables.addFromEvent(this, 'contextmenu', this.contextMenu);
|
||||
this.disposables.addFromEvent(this.parentElement, 'click', () => {
|
||||
if (!this.isEditing$.value) {
|
||||
this.selectCurrentCell(!this.column$.value?.readonly$.value);
|
||||
}
|
||||
});
|
||||
this.disposables.addFromEvent(this.parentElement, 'mouseenter', () => {
|
||||
this.gridCell.data.hover$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(this.parentElement, 'mouseleave', () => {
|
||||
this.gridCell.data.hover$.value = false;
|
||||
});
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const rowSelected = this.rowSelected$.value;
|
||||
if (rowSelected) {
|
||||
this.parentElement?.classList.add(rowSelectedBg);
|
||||
} else {
|
||||
this.parentElement?.classList.remove(rowSelectedBg);
|
||||
}
|
||||
})
|
||||
);
|
||||
const style = this.parentElement?.style;
|
||||
if (style) {
|
||||
style.borderBottom = '1px solid var(--affine-border-color)';
|
||||
style.borderRight = '1px solid var(--affine-border-color)';
|
||||
}
|
||||
}
|
||||
|
||||
isRowSelected$ = computed(() => {
|
||||
const selection = this.selectionView?.selection;
|
||||
if (selection?.selectionType !== 'row') {
|
||||
return false;
|
||||
}
|
||||
return selection.rows.some(row => row.id === this.rowId);
|
||||
});
|
||||
|
||||
isSelected(selection: TableViewSelectionWithType) {
|
||||
if (selection.selectionType !== 'area') {
|
||||
return false;
|
||||
}
|
||||
if (selection.groupKey !== this.groupKey) {
|
||||
return false;
|
||||
}
|
||||
if (selection.focus.columnIndex !== this.columnIndex$.value) {
|
||||
return false;
|
||||
}
|
||||
return selection.focus.rowIndex === this.rowIndex$.value;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const renderer = this.column$.value?.renderer$.value;
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
const { view } = renderer;
|
||||
this.view.lockRows(this.isEditing$.value);
|
||||
this.dataset['editing'] = `${this.isEditing$.value}`;
|
||||
const props: CellRenderProps = {
|
||||
cell: this.cell$.value,
|
||||
isEditing$: this.isEditing$,
|
||||
selectCurrentCell: this.selectCurrentCell,
|
||||
};
|
||||
|
||||
return renderUniLit(view, props, {
|
||||
ref: this._cell,
|
||||
style: {
|
||||
display: 'contents',
|
||||
},
|
||||
});
|
||||
}
|
||||
isEditing$ = signal(false);
|
||||
|
||||
rowIndex$ = computed(() => {
|
||||
return this.gridCell.rowIndex$.value;
|
||||
});
|
||||
|
||||
columnIndex$ = computed(() => {
|
||||
return this.gridCell.columnIndex$.value - 1;
|
||||
});
|
||||
|
||||
column$ = computed(() => {
|
||||
return this.view.properties$.value.find(
|
||||
property => property.id === this.columnId
|
||||
);
|
||||
});
|
||||
|
||||
get rowId() {
|
||||
return this.gridCell.row.rowId;
|
||||
}
|
||||
|
||||
get columnId() {
|
||||
return this.gridCell.columnId;
|
||||
}
|
||||
|
||||
get groupKey() {
|
||||
return this.gridCell.row.group.groupId;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor gridCell!: TableGridCell;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: SingleView;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tableView!: VirtualTableView;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-virtual-cell-container': DatabaseCellContainer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
menu,
|
||||
popFilterableSimpleMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import {
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
ExpandFullIcon,
|
||||
MoveLeftIcon,
|
||||
MoveRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { DataViewRenderer } from '../../../../core/data-view';
|
||||
import { TableViewRowSelection } from '../../selection';
|
||||
import type { TableSelectionController } from '../controller/selection';
|
||||
|
||||
export const openDetail = (
|
||||
dataViewEle: DataViewRenderer,
|
||||
rowId: string,
|
||||
selection: TableSelectionController
|
||||
) => {
|
||||
const old = selection.selection;
|
||||
selection.selection = undefined;
|
||||
dataViewEle.openDetailPanel({
|
||||
view: selection.host.props.view,
|
||||
rowId: rowId,
|
||||
onClose: () => {
|
||||
selection.selection = old;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const popRowMenu = (
|
||||
dataViewEle: DataViewRenderer,
|
||||
ele: PopupTarget,
|
||||
selectionController: TableSelectionController
|
||||
) => {
|
||||
const selection = selectionController.selection;
|
||||
if (!TableViewRowSelection.is(selection)) {
|
||||
return;
|
||||
}
|
||||
if (selection.rows.length > 1) {
|
||||
const rows = TableViewRowSelection.rowsIds(selection);
|
||||
popFilterableSimpleMenu(ele, [
|
||||
menu.group({
|
||||
name: '',
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Copy',
|
||||
prefix: html` <div
|
||||
style="transform: rotate(90deg);display:flex;align-items:center;"
|
||||
>
|
||||
${CopyIcon()}
|
||||
</div>`,
|
||||
select: () => {
|
||||
selectionController.host.clipboardController.copy();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
name: '',
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Delete Rows',
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
selectionController.view.rowDelete(rows);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
const row = selection.rows[0];
|
||||
if (!row) return;
|
||||
popFilterableSimpleMenu(ele, [
|
||||
menu.action({
|
||||
name: 'Expand Row',
|
||||
prefix: ExpandFullIcon(),
|
||||
select: () => {
|
||||
openDetail(dataViewEle, row.id, selectionController);
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
name: '',
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Insert Before',
|
||||
prefix: html` <div
|
||||
style="transform: rotate(90deg);display:flex;align-items:center;"
|
||||
>
|
||||
${MoveLeftIcon()}
|
||||
</div>`,
|
||||
select: () => {
|
||||
selectionController.insertRowBefore(row.groupKey, row.id);
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Insert After',
|
||||
prefix: html` <div
|
||||
style="transform: rotate(90deg);display:flex;align-items:center;"
|
||||
>
|
||||
${MoveRightIcon()}
|
||||
</div>`,
|
||||
select: () => {
|
||||
selectionController.insertRowAfter(row.groupKey, row.id);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Delete Row',
|
||||
class: { 'delete-item': true },
|
||||
prefix: DeleteIcon(),
|
||||
select: () => {
|
||||
selectionController.deleteRow(row.id);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const leftBar = style({
|
||||
display: 'flex',
|
||||
height: '34px',
|
||||
});
|
||||
|
||||
export const dragHandlerWrapper = style({
|
||||
backgroundColor: cssVarV2.layer.background.primary,
|
||||
marginBottom: '1px',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const dragHandler = style({
|
||||
width: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'grab',
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
export const checkboxWrapper = style({
|
||||
backgroundColor: cssVarV2.layer.background.primary,
|
||||
marginBottom: '1px',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const rowSelectedBg = style({
|
||||
backgroundColor: 'var(--affine-primary-color-04)',
|
||||
});
|
||||
|
||||
export const dragHandlerIndicator = style({
|
||||
width: '4px',
|
||||
borderRadius: '2px',
|
||||
height: '12px',
|
||||
backgroundColor: 'var(--affine-placeholder-color)',
|
||||
});
|
||||
|
||||
export const rowSelectCheckbox = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
opacity: 0,
|
||||
cursor: 'pointer',
|
||||
fontSize: '20px',
|
||||
color: cssVarV2.icon.primary,
|
||||
});
|
||||
|
||||
export const show = style({
|
||||
opacity: 1,
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { CheckBoxCheckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { computed, effect } from '@preact/signals-core';
|
||||
import clsx from 'clsx';
|
||||
import { nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import type { TableSingleView } from '../../table-view-manager.js';
|
||||
import type { VirtualTableView } from '../table-view.js';
|
||||
import type { TableGridCell } from '../types.js';
|
||||
import * as styles from './row-header.css.js';
|
||||
|
||||
export class TableRowHeader extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const rowSelected = this.rowSelected$.value;
|
||||
if (rowSelected) {
|
||||
this.parentElement?.classList.add(styles.rowSelectedBg);
|
||||
} else {
|
||||
this.parentElement?.classList.remove(styles.rowSelectedBg);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.disposables.addFromEvent(this.parentElement, 'mouseenter', () => {
|
||||
this.gridCell.data.hover$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(this.parentElement, 'mouseleave', () => {
|
||||
this.gridCell.data.hover$.value = false;
|
||||
});
|
||||
}
|
||||
private readonly selectRow = () => {
|
||||
if (this.view.readonly$.value) {
|
||||
return;
|
||||
}
|
||||
this.selectionController?.toggleRow(this.rowId, this.groupKey);
|
||||
};
|
||||
|
||||
get selectionController() {
|
||||
return this.tableView.selectionController;
|
||||
}
|
||||
|
||||
get rowSelected$() {
|
||||
return this.gridCell.row.data.selected$;
|
||||
}
|
||||
|
||||
renderDragHandle = () => {
|
||||
const dragHandlerClass = clsx(
|
||||
styles.dragHandler,
|
||||
this.rowSelected$.value && styles.rowSelectedBg,
|
||||
this.rowHover$.value && styles.show
|
||||
);
|
||||
return html`
|
||||
<div class="${styles.dragHandlerWrapper}">
|
||||
<div class="${dragHandlerClass}" @click=${this.selectRow}>
|
||||
<div class="${styles.dragHandlerIndicator}"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
get rowHover$() {
|
||||
return this.gridCell.row.data.hover$;
|
||||
}
|
||||
|
||||
showCheckbox$ = computed(() => {
|
||||
return this.rowSelected$.value || this.rowHover$.value;
|
||||
});
|
||||
|
||||
renderCheckbox = () => {
|
||||
const classString = clsx(
|
||||
this.rowSelected$.value && styles.rowSelectedBg,
|
||||
styles.rowSelectCheckbox,
|
||||
this.showCheckbox$.value && styles.show
|
||||
);
|
||||
return html`
|
||||
<div @click=${this.selectRow} class="${styles.checkboxWrapper}">
|
||||
<div class="${classString}">
|
||||
${this.rowSelected$.value
|
||||
? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` })
|
||||
: CheckBoxUnIcon()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
protected override render() {
|
||||
const view = this.view;
|
||||
|
||||
if (view.readonly$.value) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="${styles.leftBar}">
|
||||
${this.renderDragHandle()} ${this.renderCheckbox()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get rowId() {
|
||||
return this.gridCell.row.rowId;
|
||||
}
|
||||
|
||||
get groupKey() {
|
||||
return this.gridCell.row.group.groupId;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor gridCell!: TableGridCell;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: TableSingleView;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tableView!: VirtualTableView;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-table-row-header': TableRowHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cssVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import type { TableSingleView } from '../../table-view-manager.js';
|
||||
import type { VirtualTableView } from '../table-view.js';
|
||||
import type { TableGridCell } from '../types.js';
|
||||
import * as styles from './row-header.css.js';
|
||||
|
||||
export class TableRowLast extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
get rowSelected$() {
|
||||
return this.gridCell.row.data.selected$;
|
||||
}
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
const style = this.parentElement?.style;
|
||||
if (style) {
|
||||
style.borderBottom = `1px solid ${cssVarV2.database.border}`;
|
||||
}
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const rowSelected = this.rowSelected$.value;
|
||||
if (rowSelected) {
|
||||
this.parentElement?.classList.add(styles.rowSelectedBg);
|
||||
} else {
|
||||
this.parentElement?.classList.remove(styles.rowSelectedBg);
|
||||
}
|
||||
})
|
||||
);
|
||||
this.disposables.addFromEvent(this.parentElement, 'mouseenter', () => {
|
||||
this.gridCell.data.hover$.value = true;
|
||||
});
|
||||
this.disposables.addFromEvent(this.parentElement, 'mouseleave', () => {
|
||||
this.gridCell.data.hover$.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return html``;
|
||||
}
|
||||
|
||||
get rowId() {
|
||||
return this.gridCell.row.rowId;
|
||||
}
|
||||
|
||||
get groupKey() {
|
||||
return this.gridCell.row.group.groupId;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor gridCell!: TableGridCell;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: TableSingleView;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tableView!: VirtualTableView;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-table-row-last': TableRowLast;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
|
||||
|
||||
export const tableView = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const tableViewAllElements = style({
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const tableContainer = style({
|
||||
overflowY: 'auto',
|
||||
});
|
||||
|
||||
export const tableTitleContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
height: '44px',
|
||||
margin: '2px 0 2px',
|
||||
});
|
||||
|
||||
export const tableBlockTable = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: '4px',
|
||||
zIndex: 1,
|
||||
overflowX: 'scroll',
|
||||
overflowY: 'hidden',
|
||||
selectors: {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:horizontal': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-track:horizontal': {
|
||||
backgroundColor: 'transparent',
|
||||
height: '8px',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-thumb:horizontal': {
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--affine-black-30)',
|
||||
},
|
||||
'&:hover::-webkit-scrollbar-track:horizontal': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
height: '8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tableContainer2 = style({
|
||||
position: 'relative',
|
||||
width: 'fit-content',
|
||||
minWidth: '100%',
|
||||
});
|
||||
|
||||
export const tableBlockTagCircle = style({
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
});
|
||||
|
||||
export const tableBlockTag = style({
|
||||
display: 'inline-flex',
|
||||
borderRadius: '11px',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const cellDivider = style({
|
||||
width: '1px',
|
||||
height: '100%',
|
||||
backgroundColor: cssVarV2.layer.insideBorder.border,
|
||||
});
|
||||
|
||||
export const tableLeftBar = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'sticky',
|
||||
zIndex: 1,
|
||||
left: 0,
|
||||
width: `${LEFT_TOOL_BAR_WIDTH}px`,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tableBlockRows = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
});
|
||||
export const addGroup = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '6px 12px 6px 8px',
|
||||
color: cssVarV2.text.secondary,
|
||||
fontSize: '12px',
|
||||
lineHeight: '20px',
|
||||
position: 'sticky',
|
||||
left: `${LEFT_TOOL_BAR_WIDTH}px`,
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { AddCursorIcon } from '@blocksuite/icons/lit';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import * as dv from '../../../core/common/dv.css.js';
|
||||
import {
|
||||
type GroupTrait,
|
||||
groupTraitKey,
|
||||
} from '../../../core/group-by/trait.js';
|
||||
import { type DataViewInstance, renderUniLit } from '../../../core/index.js';
|
||||
import { DataViewBase } from '../../../core/view/data-view-base.js';
|
||||
import {
|
||||
type TableSingleView,
|
||||
TableViewRowSelection,
|
||||
type TableViewSelectionWithType,
|
||||
} from '../../index.js';
|
||||
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
|
||||
import { TableClipboardController } from './controller/clipboard.js';
|
||||
import { TableDragController } from './controller/drag.js';
|
||||
import { TableHotkeysController } from './controller/hotkeys.js';
|
||||
import { TableSelectionController } from './controller/selection.js';
|
||||
import { TableGroupFooter } from './group/bottom/group-footer';
|
||||
import { TableGroupHeader } from './group/top/group-header';
|
||||
import { DatabaseCellContainer } from './row/cell';
|
||||
import { TableRowHeader } from './row/row-header.js';
|
||||
import { TableRowLast } from './row/row-last.js';
|
||||
import * as styles from './table-view.css.js';
|
||||
import type {
|
||||
TableCellData,
|
||||
TableGrid,
|
||||
TableGroupData,
|
||||
TableRowData,
|
||||
} from './types.js';
|
||||
import {
|
||||
getScrollContainer,
|
||||
GridVirtualScroll,
|
||||
} from './virtual/virtual-scroll.js';
|
||||
|
||||
export class VirtualTableView extends DataViewBase<
|
||||
TableSingleView,
|
||||
TableViewSelectionWithType
|
||||
> {
|
||||
clipboardController = new TableClipboardController(this);
|
||||
|
||||
dragController = new TableDragController(this);
|
||||
|
||||
hotkeysController = new TableHotkeysController(this);
|
||||
|
||||
onWheel = (event: WheelEvent) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
const ele = event.currentTarget;
|
||||
if (ele instanceof HTMLElement) {
|
||||
if (ele.scrollWidth === ele.clientWidth) {
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
renderAddGroup = (groupHelper: GroupTrait) => {
|
||||
const addGroup = groupHelper.addGroup;
|
||||
if (!addGroup) {
|
||||
return;
|
||||
}
|
||||
const add = (e: MouseEvent) => {
|
||||
const ele = e.currentTarget as HTMLElement;
|
||||
popMenu(popupTargetFromElement(ele), {
|
||||
options: {
|
||||
items: [
|
||||
menu.input({
|
||||
onComplete: text => {
|
||||
const column = groupHelper.property$.value;
|
||||
if (column) {
|
||||
column.dataUpdate(
|
||||
() =>
|
||||
addGroup({
|
||||
text,
|
||||
oldData: column.data$.value,
|
||||
dataSource: this.props.view.manager.dataSource,
|
||||
}) as never
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
return html` <div style="display:flex;">
|
||||
<div class="${dv.hover} ${dv.round8} ${styles.addGroup}" @click="${add}">
|
||||
<div class="${dv.icon16}" style="display:flex;">${AddCursorIcon()}</div>
|
||||
<div>New Group</div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
selectionController = new TableSelectionController(this);
|
||||
yScrollContainer: HTMLElement | undefined;
|
||||
|
||||
get expose(): DataViewInstance {
|
||||
return {
|
||||
clearSelection: () => {
|
||||
this.selectionController.clear();
|
||||
},
|
||||
addRow: position => {
|
||||
if (this.readonly) return;
|
||||
const rowId = this.props.view.rowAdd(position);
|
||||
if (rowId) {
|
||||
this.props.dataViewEle.openDetailPanel({
|
||||
view: this.props.view,
|
||||
rowId,
|
||||
});
|
||||
}
|
||||
return rowId;
|
||||
},
|
||||
focusFirstCell: () => {
|
||||
this.selectionController.focusFirstCell();
|
||||
},
|
||||
showIndicator: evt => {
|
||||
return this.dragController.showIndicator(evt) != null;
|
||||
},
|
||||
hideIndicator: () => {
|
||||
this.dragController.dropPreview.remove();
|
||||
},
|
||||
moveTo: (id, evt) => {
|
||||
const result = this.dragController.getInsertPosition(evt);
|
||||
if (result) {
|
||||
this.props.view.rowMove(
|
||||
id,
|
||||
result.position,
|
||||
undefined,
|
||||
result.groupKey
|
||||
);
|
||||
}
|
||||
},
|
||||
getSelection: () => {
|
||||
return this.selectionController.selection;
|
||||
},
|
||||
view: this.props.view,
|
||||
eventTrace: this.props.eventTrace,
|
||||
};
|
||||
}
|
||||
|
||||
private get readonly() {
|
||||
return this.props.view.readonly$.value;
|
||||
}
|
||||
|
||||
columns$ = computed(() => {
|
||||
return [
|
||||
{
|
||||
id: 'row-header',
|
||||
width: LEFT_TOOL_BAR_WIDTH,
|
||||
},
|
||||
...this.props.view.properties$.value.map(property => ({
|
||||
id: property.id,
|
||||
width: property.width$.value + 1,
|
||||
})),
|
||||
{
|
||||
id: 'row-last',
|
||||
width: 40,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
groupTrait$ = computed(() => {
|
||||
return this.props.view.traitGet(groupTraitKey);
|
||||
});
|
||||
|
||||
groups$ = computed(() => {
|
||||
const groupTrait = this.groupTrait$.value;
|
||||
if (!groupTrait?.groupsDataList$.value) {
|
||||
return [
|
||||
{
|
||||
id: '',
|
||||
rows: this.props.view.rows$.value,
|
||||
},
|
||||
];
|
||||
}
|
||||
return groupTrait.groupsDataList$.value.map(group => ({
|
||||
id: group.key,
|
||||
rows: group.rows,
|
||||
}));
|
||||
});
|
||||
virtualScroll$ = signal<TableGrid>();
|
||||
private initVirtualScroll(yScrollContainer: HTMLElement) {
|
||||
this.virtualScroll$.value = new GridVirtualScroll<
|
||||
TableGroupData,
|
||||
TableRowData,
|
||||
TableCellData
|
||||
>({
|
||||
initGroupData: group => ({
|
||||
hover$: computed(() => {
|
||||
const headerHover = group.data.headerHover$.value;
|
||||
if (headerHover) {
|
||||
return true;
|
||||
}
|
||||
const footerHover = group.data.footerHover$.value;
|
||||
if (footerHover) {
|
||||
return true;
|
||||
}
|
||||
return group.rows$.value.some(row => row.data.hover$.value);
|
||||
}),
|
||||
headerHover$: signal(false),
|
||||
footerHover$: signal(false),
|
||||
}),
|
||||
initRowData: row => ({
|
||||
hover$: computed(() => {
|
||||
return row.cells$.value.some(cell => cell.data.hover$.value);
|
||||
}),
|
||||
selected$: computed(() => {
|
||||
const selection = this.props.selection$.value;
|
||||
if (!selection || selection.selectionType !== 'row') {
|
||||
return false;
|
||||
}
|
||||
return TableViewRowSelection.includes(selection, {
|
||||
id: row.rowId,
|
||||
groupKey: row.group.groupId,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
initCellData: () => ({
|
||||
hover$: signal(false),
|
||||
selected$: signal(false),
|
||||
}),
|
||||
columns$: this.columns$,
|
||||
groups$: this.groups$,
|
||||
createCell: (cell, wrapper) => {
|
||||
if (cell.columnId === 'row-header') {
|
||||
wrapper.style.borderBottom = `1px solid ${cssVarV2.database.border}`;
|
||||
const rowHeader = new TableRowHeader();
|
||||
rowHeader.view = this.props.view;
|
||||
rowHeader.gridCell = cell;
|
||||
rowHeader.tableView = this;
|
||||
return rowHeader;
|
||||
}
|
||||
if (cell.columnId === 'row-last') {
|
||||
const rowLast = new TableRowLast();
|
||||
rowLast.view = this.props.view;
|
||||
rowLast.gridCell = cell;
|
||||
rowLast.tableView = this;
|
||||
return rowLast;
|
||||
}
|
||||
const cellContainer = new DatabaseCellContainer();
|
||||
cellContainer.view = this.props.view;
|
||||
cellContainer.gridCell = cell;
|
||||
cellContainer.tableView = this;
|
||||
return cellContainer;
|
||||
},
|
||||
createGroup: {
|
||||
top: gridGroup => {
|
||||
const groupHeader = new TableGroupHeader();
|
||||
groupHeader.tableView = this;
|
||||
groupHeader.gridGroup = gridGroup;
|
||||
return groupHeader;
|
||||
},
|
||||
bottom: gridGroup => {
|
||||
const groupFooter = new TableGroupFooter();
|
||||
groupFooter.tableView = this;
|
||||
groupFooter.gridGroup = gridGroup;
|
||||
return groupFooter;
|
||||
},
|
||||
},
|
||||
fixedRowHeight$: signal(undefined),
|
||||
yScrollContainer,
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
const virtualScroll = this.virtualScroll$.value;
|
||||
if (virtualScroll) {
|
||||
virtualScroll.init();
|
||||
this.disposables.add(() => virtualScroll.dispose());
|
||||
}
|
||||
});
|
||||
}
|
||||
private renderTable() {
|
||||
return this.virtualScroll$.value?.content;
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.initVirtualScroll(getScrollContainer(this, 'y') ?? document.body);
|
||||
this.classList.add(styles.tableView);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const vPadding = this.props.virtualPadding$.value;
|
||||
const wrapperStyle = styleMap({
|
||||
marginLeft: `-${vPadding}px`,
|
||||
marginRight: `-${vPadding}px`,
|
||||
});
|
||||
const containerStyle = styleMap({
|
||||
paddingLeft: `${vPadding}px`,
|
||||
paddingRight: `${vPadding}px`,
|
||||
});
|
||||
return html`
|
||||
${renderUniLit(this.props.headerWidget, {
|
||||
dataViewInstance: this.expose,
|
||||
})}
|
||||
<div class="${styles.tableContainer}" style="${wrapperStyle}">
|
||||
<div class="${styles.tableBlockTable}" @wheel="${this.onWheel}">
|
||||
<div class="${styles.tableContainer2}" style="${containerStyle}">
|
||||
${this.renderTable()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-virtual-table': VirtualTableView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Signal } from '@blocksuite/affine-shared/utils';
|
||||
import type { ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import type {
|
||||
GridCell,
|
||||
GridGroup,
|
||||
GridRow,
|
||||
GridVirtualScroll,
|
||||
} from './virtual/virtual-scroll';
|
||||
|
||||
export type TableGroupData = {
|
||||
hover$: ReadonlySignal<boolean>;
|
||||
headerHover$: Signal<boolean>;
|
||||
footerHover$: Signal<boolean>;
|
||||
};
|
||||
|
||||
export type TableRowData = {
|
||||
hover$: ReadonlySignal<boolean>;
|
||||
selected$: ReadonlySignal<boolean>;
|
||||
};
|
||||
|
||||
export type TableCellData = {
|
||||
hover$: Signal<boolean>;
|
||||
};
|
||||
|
||||
export type TableGrid = GridVirtualScroll<
|
||||
TableGroupData,
|
||||
TableRowData,
|
||||
TableCellData
|
||||
>;
|
||||
export type TableGridGroup = GridGroup<
|
||||
TableGroupData,
|
||||
TableRowData,
|
||||
TableCellData
|
||||
>;
|
||||
export type TableGridRow = GridRow<TableGroupData, TableRowData, TableCellData>;
|
||||
export type TableGridCell = GridCell<
|
||||
TableGroupData,
|
||||
TableRowData,
|
||||
TableCellData
|
||||
>;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { LinkedList, type LinkedListNode } from './linked-list';
|
||||
|
||||
type Task = () => false | void;
|
||||
|
||||
export class TaskNode {
|
||||
private _priority?: number;
|
||||
private _linkedListNode?: LinkedListNode<Task>;
|
||||
constructor(private readonly manager: BatchTaskManager) {}
|
||||
|
||||
get priority() {
|
||||
return this._priority;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this._linkedListNode?.active) {
|
||||
this._linkedListNode.remove();
|
||||
}
|
||||
}
|
||||
|
||||
updateTask(priority: number, task: Task, toFront = false) {
|
||||
this.cancel();
|
||||
this._linkedListNode = this.manager.addTask(priority, task, toFront);
|
||||
this._priority = priority;
|
||||
}
|
||||
}
|
||||
|
||||
export class BatchTaskManager {
|
||||
private readonly queues: LinkedList<Task>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly batchSizes: number[],
|
||||
private readonly totalBatchSize: number
|
||||
) {
|
||||
this.queues = batchSizes.map(() => new LinkedList<Task>());
|
||||
}
|
||||
|
||||
private isRunning = false;
|
||||
|
||||
newTask() {
|
||||
return new TaskNode(this);
|
||||
}
|
||||
|
||||
addTask(priority: number, task: Task, toFront = false) {
|
||||
const linkedList = this.queues[priority];
|
||||
if (!linkedList) {
|
||||
throw new Error('Priority index out of bounds');
|
||||
}
|
||||
const linkedListNode = linkedList[toFront ? 'prepend' : 'append'](task);
|
||||
if (!this.isRunning) {
|
||||
this.isRunning = true;
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
this.run();
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
return linkedListNode;
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
let totalBatchCount = this.totalBatchSize;
|
||||
// let skipCount = 0;
|
||||
// let tasksExecuted = false;
|
||||
const runTaskArr = this.queues.map(() => 0);
|
||||
for (let i = this.queues.length - 1; i >= 0; i--) {
|
||||
const queue = this.queues[i];
|
||||
let priorityBatchCount = this.batchSizes[i];
|
||||
if (!queue || !priorityBatchCount) continue;
|
||||
while (
|
||||
!queue.isEmpty() &&
|
||||
totalBatchCount > 0 &&
|
||||
priorityBatchCount > 0
|
||||
) {
|
||||
const node = queue.pop();
|
||||
if (!node) break;
|
||||
|
||||
const task = node.value;
|
||||
const result = task();
|
||||
|
||||
if (result !== false) {
|
||||
totalBatchCount--;
|
||||
priorityBatchCount--;
|
||||
// tasksExecuted = true;
|
||||
runTaskArr[i] = (runTaskArr[i] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if (tasksExecuted) {
|
||||
// console.log(
|
||||
// 'run task count',
|
||||
// ...runTaskArr,
|
||||
// 'skip count',
|
||||
// skipCount,
|
||||
// 'total task count',
|
||||
// ...this.queues.map(arr => arr.size)
|
||||
// );
|
||||
// }
|
||||
|
||||
const hasRemainingTasks = this.queues.some(queue => !queue.isEmpty());
|
||||
|
||||
if (hasRemainingTasks) {
|
||||
requestAnimationFrame(() => {
|
||||
this.run();
|
||||
});
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
clean(): void {
|
||||
for (const queue of this.queues) {
|
||||
queue.clear();
|
||||
}
|
||||
this.isRunning = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export interface LinkedListNode<T = unknown> {
|
||||
value: T;
|
||||
prev: LinkedListNode<T> | null;
|
||||
next: LinkedListNode<T> | null;
|
||||
active: boolean;
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
export class LinkedList<T = unknown> {
|
||||
head: LinkedListNode<T> | null = null;
|
||||
tail: LinkedListNode<T> | null = null;
|
||||
size = 0;
|
||||
|
||||
append(value: T): LinkedListNode<T> {
|
||||
const node: LinkedListNode<T> = {
|
||||
value,
|
||||
prev: null,
|
||||
next: null,
|
||||
active: true,
|
||||
remove: () => this.remove(node),
|
||||
};
|
||||
|
||||
if (!this.head) {
|
||||
this.head = node;
|
||||
this.tail = node;
|
||||
} else {
|
||||
node.prev = this.tail;
|
||||
this.tail!.next = node;
|
||||
this.tail = node;
|
||||
}
|
||||
|
||||
this.size++;
|
||||
return node;
|
||||
}
|
||||
|
||||
prepend(value: T): LinkedListNode<T> {
|
||||
const node: LinkedListNode<T> = {
|
||||
value,
|
||||
prev: null,
|
||||
next: null,
|
||||
active: true,
|
||||
remove: () => this.remove(node),
|
||||
};
|
||||
|
||||
if (!this.head) {
|
||||
this.head = node;
|
||||
this.tail = node;
|
||||
} else {
|
||||
node.next = this.head;
|
||||
this.head.prev = node;
|
||||
this.head = node;
|
||||
}
|
||||
|
||||
this.size++;
|
||||
return node;
|
||||
}
|
||||
|
||||
remove(node: LinkedListNode<T>): void {
|
||||
if (node === this.head && node === this.tail) {
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
} else if (node === this.head) {
|
||||
this.head = node.next;
|
||||
if (this.head) {
|
||||
this.head.prev = null;
|
||||
}
|
||||
} else if (node === this.tail) {
|
||||
this.tail = node.prev;
|
||||
if (this.tail) {
|
||||
this.tail.next = null;
|
||||
}
|
||||
} else {
|
||||
if (node.prev) {
|
||||
node.prev.next = node.next;
|
||||
}
|
||||
if (node.next) {
|
||||
node.next.prev = node.prev;
|
||||
}
|
||||
}
|
||||
|
||||
node.prev = null;
|
||||
node.next = null;
|
||||
node.active = false;
|
||||
this.size--;
|
||||
}
|
||||
|
||||
shift(): LinkedListNode<T> | null {
|
||||
if (!this.head) return null;
|
||||
|
||||
const removedNode = this.head;
|
||||
this.remove(removedNode);
|
||||
return removedNode;
|
||||
}
|
||||
|
||||
pop(): LinkedListNode<T> | null {
|
||||
if (!this.tail) return null;
|
||||
|
||||
const removedNode = this.tail;
|
||||
this.remove(removedNode);
|
||||
return removedNode;
|
||||
}
|
||||
|
||||
forEach(
|
||||
callback: (value: T, node: LinkedListNode<T>, index: number) => void
|
||||
): void {
|
||||
let current = this.head;
|
||||
let index = 0;
|
||||
|
||||
while (current) {
|
||||
callback(current.value, current, index);
|
||||
current = current.next;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
forEachReverse(
|
||||
callback: (value: T, node: LinkedListNode<T>, index: number) => void
|
||||
): void {
|
||||
let current = this.tail;
|
||||
let index = this.size - 1;
|
||||
|
||||
while (current) {
|
||||
callback(current.value, current, index);
|
||||
current = current.prev;
|
||||
index--;
|
||||
}
|
||||
}
|
||||
|
||||
find(
|
||||
predicate: (value: T, node: LinkedListNode<T>, index: number) => boolean
|
||||
): LinkedListNode<T> | null {
|
||||
let current = this.head;
|
||||
let index = 0;
|
||||
|
||||
while (current) {
|
||||
if (predicate(current.value, current, index)) {
|
||||
return current;
|
||||
}
|
||||
current = current.next;
|
||||
index++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
let current = this.head;
|
||||
while (current) {
|
||||
const next = current.next;
|
||||
current.active = false;
|
||||
current.next = null;
|
||||
current.prev = null;
|
||||
current = next;
|
||||
}
|
||||
this.head = null;
|
||||
this.tail = null;
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.size === 0;
|
||||
}
|
||||
|
||||
toArray(): T[] {
|
||||
const result: T[] = [];
|
||||
this.forEach(value => result.push(value));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const cell = style({
|
||||
position: 'absolute',
|
||||
padding: '6px',
|
||||
});
|
||||
|
||||
export const cellContent = style({
|
||||
padding: '6px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class VirtualElementWrapper extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
@property({ attribute: false })
|
||||
accessor rect!: {
|
||||
left$: ReadonlySignal<number | undefined>;
|
||||
top$: ReadonlySignal<number | undefined>;
|
||||
width$: ReadonlySignal<number | undefined>;
|
||||
height$: ReadonlySignal<number | undefined>;
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateHeight!: (height: number) => void;
|
||||
@property({ attribute: false })
|
||||
accessor element!: HTMLElement;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.style.position = 'absolute';
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this.style.left = `${this.rect.left$.value ?? -1000}px`;
|
||||
this.style.top = `${this.rect.top$.value ?? -1000}px`;
|
||||
if (this.rect.width$.value != null) {
|
||||
this.style.width = `${this.rect.width$.value}px`;
|
||||
}
|
||||
this.style.height = `${this.rect.height$.value ?? 0}px`;
|
||||
})
|
||||
);
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (this.element.isConnected) {
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
this.updateHeight(this.element.clientHeight);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(this.element);
|
||||
this.disposables.add(() => {
|
||||
resizeObserver.disconnect();
|
||||
});
|
||||
}
|
||||
override render() {
|
||||
return this.element;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'virtual-element-wrapper': VirtualElementWrapper;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
type ReadonlySignal,
|
||||
signal,
|
||||
} from '@preact/signals-core';
|
||||
|
||||
import { BatchTaskManager } from './batch-task-manager';
|
||||
import { VirtualElementWrapper } from './virtual-cell';
|
||||
|
||||
export interface Disposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export class NodeLifeCycle implements Disposable {
|
||||
disposables: (() => void)[] = [];
|
||||
init() {}
|
||||
isDisposed = false;
|
||||
dispose() {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this.isDisposed = true;
|
||||
this.disposables.forEach(disposable => disposable());
|
||||
}
|
||||
}
|
||||
|
||||
export class GridNode<Data> extends NodeLifeCycle {
|
||||
private _data?: Data;
|
||||
get data(): Data {
|
||||
if (!this._data) {
|
||||
this._data = this.initData();
|
||||
}
|
||||
return this._data;
|
||||
}
|
||||
constructor(private readonly initData: () => Data) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class CacheManager<K, V extends Disposable> {
|
||||
constructor(readonly keyToString: (key: K) => string) {}
|
||||
protected readonly cache = new Map<string, V>();
|
||||
|
||||
getOrCreate(key: K, create: () => V): V {
|
||||
const stringKey = this.keyToString(key);
|
||||
let value = this.cache.get(stringKey);
|
||||
if (!value) {
|
||||
value = create();
|
||||
this.cache.set(stringKey, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.cache.has(this.keyToString(key));
|
||||
}
|
||||
|
||||
delete(key: K): void {
|
||||
const value = this.cache.get(this.keyToString(key));
|
||||
if (value) {
|
||||
value.dispose();
|
||||
this.cache.delete(this.keyToString(key));
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const value of this.cache.values()) {
|
||||
value.dispose();
|
||||
}
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
cleanup(activeKeys: Set<string>): void {
|
||||
const toDelete: string[] = [];
|
||||
for (const key of this.cache.keys()) {
|
||||
if (!activeKeys.has(key)) {
|
||||
toDelete.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of toDelete) {
|
||||
this.cache.get(key)?.dispose();
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class VirtualScroll extends NodeLifeCycle {
|
||||
readonly container: VirtualScrollContainer;
|
||||
|
||||
constructor(containerOptions: VirtualScrollOptions) {
|
||||
super();
|
||||
this.container = new VirtualScrollContainer(containerOptions);
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
this.container.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export class GridCell<GroupData, RowData, CellData> extends GridNode<CellData> {
|
||||
readonly renderTask;
|
||||
readonly element;
|
||||
readonly columnIndex$ = computed(() => {
|
||||
return this.row.grid.columns$.value.findIndex(
|
||||
column => column.id === this.columnId
|
||||
);
|
||||
});
|
||||
|
||||
private readonly realHeight$ = signal<number>();
|
||||
readonly contentHeight$ = computed(() => {
|
||||
return this.realHeight$.value;
|
||||
});
|
||||
private readonly columnPosition$ = computed(() => {
|
||||
return this.row.grid.columnPosition$.value[this.columnIndex$.value];
|
||||
});
|
||||
readonly height$ = computed(
|
||||
() => this.grid.fixedRowHeight$.value ?? this.contentHeight$.value
|
||||
);
|
||||
readonly width$ = computed(() => this.columnPosition$.value?.width);
|
||||
readonly left$ = computed(() => this.columnPosition$.value?.left);
|
||||
readonly top$ = computed(() => this.row.top$.value);
|
||||
readonly right$ = computed(() => {
|
||||
return this.columnPosition$.value?.right;
|
||||
});
|
||||
readonly bottom$ = computed(() => {
|
||||
const top = this.top$.value;
|
||||
if (top == null) {
|
||||
return;
|
||||
}
|
||||
const height = this.height$.value;
|
||||
if (height == null) {
|
||||
return;
|
||||
}
|
||||
return top + height;
|
||||
});
|
||||
|
||||
get rowIndex$() {
|
||||
return this.row.rowIndex$;
|
||||
}
|
||||
|
||||
get grid() {
|
||||
return this.row.grid;
|
||||
}
|
||||
|
||||
constructor(
|
||||
readonly row: GridRow<GroupData, RowData, CellData>,
|
||||
readonly columnId: string,
|
||||
createElement: (
|
||||
cell: GridCell<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement,
|
||||
initCellData: (cell: GridCell<GroupData, RowData, CellData>) => CellData
|
||||
) {
|
||||
super(() => initCellData(this));
|
||||
this.element = new VirtualElementWrapper();
|
||||
this.element.rect = {
|
||||
left$: this.left$,
|
||||
top$: this.top$,
|
||||
width$: this.width$,
|
||||
height$: this.row.height$,
|
||||
};
|
||||
this.element.updateHeight = height => this.updateHeight(height);
|
||||
this.element.element = createElement(this, this.element);
|
||||
const isInit = computed(() => {
|
||||
return this.height$.value != null;
|
||||
});
|
||||
this.renderTask = this.grid.container.initElement(this.element, isInit);
|
||||
const cancel = effect(() => {
|
||||
if (isInit.value && !this.isVisible$.peek()) {
|
||||
this.renderTask.hide();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
this.disposables.push(cancel);
|
||||
this.disposables.push(
|
||||
effect(() => {
|
||||
this.checkRender();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
isVisible$ = computed(() => {
|
||||
const height = this.realHeight$.value;
|
||||
if (height == null) {
|
||||
return false;
|
||||
}
|
||||
const offsetTop = this.top$.value;
|
||||
if (offsetTop == null) {
|
||||
return false;
|
||||
}
|
||||
const offsetBottom = this.bottom$.value;
|
||||
if (offsetBottom == null) {
|
||||
return false;
|
||||
}
|
||||
const offsetLeft = this.left$.value ?? 0;
|
||||
const offsetRight = this.right$.value ?? 0;
|
||||
const viewport = this.grid.container.viewport$.value;
|
||||
const xInView =
|
||||
offsetRight >= viewport.left && offsetLeft <= viewport.right;
|
||||
const yInView =
|
||||
offsetBottom >= viewport.top && offsetTop <= viewport.bottom;
|
||||
const isVisible = xInView && yInView;
|
||||
return isVisible;
|
||||
});
|
||||
|
||||
checkRender() {
|
||||
const isVisible = this.isVisible$.value;
|
||||
if (isVisible && !this.element.isConnected) {
|
||||
this.renderTask.show();
|
||||
} else if (!isVisible && this.element.isConnected) {
|
||||
this.renderTask.hide();
|
||||
}
|
||||
}
|
||||
|
||||
updateHeight(height: number) {
|
||||
this.realHeight$.value = height;
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
this.renderTask.cancel();
|
||||
this.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class GridRow<GroupData, RowData, CellData> extends GridNode<RowData> {
|
||||
cells$ = computed(() => {
|
||||
return this.grid.columns$.value.map(column => {
|
||||
return this.grid.getOrCreateCell(this, column.id);
|
||||
});
|
||||
});
|
||||
|
||||
rowIndex$ = computed(() => {
|
||||
return this.group.rows$.value.findIndex(row => row.rowId === this.rowId);
|
||||
});
|
||||
|
||||
prevRow$ = computed(() => {
|
||||
return this.group.rows$.value[this.rowIndex$.value - 1];
|
||||
});
|
||||
|
||||
get grid() {
|
||||
return this.group.grid;
|
||||
}
|
||||
|
||||
top$: ReadonlySignal<number | undefined> = computed(() => {
|
||||
const prevRow = this.prevRow$.value;
|
||||
if (!prevRow) {
|
||||
return this.group.rowsTop$.value;
|
||||
}
|
||||
return prevRow.bottom$.value;
|
||||
});
|
||||
|
||||
bottom$ = computed(() => {
|
||||
const top = this.top$.value;
|
||||
if (top == null) {
|
||||
return;
|
||||
}
|
||||
const height = this.height$.value;
|
||||
if (height == null) {
|
||||
return;
|
||||
}
|
||||
return top + height;
|
||||
});
|
||||
|
||||
height$ = computed(() => {
|
||||
const fixedRowHeight = this.grid.fixedRowHeight$.value;
|
||||
if (fixedRowHeight != null) {
|
||||
return fixedRowHeight;
|
||||
}
|
||||
const cells = this.cells$.value
|
||||
.map(cell => cell.height$.value)
|
||||
.filter(v => v != null);
|
||||
if (cells.length > 0) {
|
||||
return Math.max(...cells);
|
||||
}
|
||||
return;
|
||||
});
|
||||
|
||||
constructor(
|
||||
readonly group: GridGroup<GroupData, RowData, CellData>,
|
||||
readonly rowId: string,
|
||||
initRowData: (row: GridRow<GroupData, RowData, CellData>) => RowData
|
||||
) {
|
||||
super(() => initRowData(this));
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
export class GroupNode<GroupData, RowData, CellData> extends NodeLifeCycle {
|
||||
readonly renderTask;
|
||||
readonly height$ = signal<number | undefined>();
|
||||
readonly bottom$ = computed(() => {
|
||||
const top = this.top$.value;
|
||||
const height = this.height$.value;
|
||||
if (top == null) {
|
||||
return;
|
||||
}
|
||||
if (height == null) {
|
||||
return;
|
||||
}
|
||||
return top + height;
|
||||
});
|
||||
constructor(
|
||||
public readonly group: GridGroup<GroupData, RowData, CellData>,
|
||||
public readonly top$: ReadonlySignal<number | undefined>,
|
||||
content: (
|
||||
group: GridGroup<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement,
|
||||
readonly visibleCheck: (
|
||||
node: GroupNode<GroupData, RowData, CellData>
|
||||
) => boolean
|
||||
) {
|
||||
super();
|
||||
const element = new VirtualElementWrapper();
|
||||
element.rect = {
|
||||
left$: signal(0),
|
||||
top$,
|
||||
width$: signal(),
|
||||
height$: this.height$,
|
||||
};
|
||||
element.element = content(this.group, element);
|
||||
element.updateHeight = height => {
|
||||
this.height$.value = height;
|
||||
};
|
||||
const isInit = computed(() => {
|
||||
return this.height$.value != null;
|
||||
});
|
||||
this.renderTask = this.container.initElement(element, isInit);
|
||||
const cancel = effect(() => {
|
||||
if (isInit.value && !this.isVisible$.peek()) {
|
||||
this.renderTask.hide();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
this.disposables.push(
|
||||
effect(() => {
|
||||
this.checkRender();
|
||||
})
|
||||
);
|
||||
}
|
||||
get container() {
|
||||
return this.group.grid.container;
|
||||
}
|
||||
|
||||
isVisible$ = computed(() => {
|
||||
return this.visibleCheck(this);
|
||||
});
|
||||
|
||||
checkRender() {
|
||||
const isVisible = this.isVisible$.value;
|
||||
if (isVisible) {
|
||||
this.renderTask.show();
|
||||
} else {
|
||||
this.renderTask.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GridGroup<
|
||||
GroupData,
|
||||
RowData,
|
||||
CellData,
|
||||
> extends GridNode<GroupData> {
|
||||
top$: ReadonlySignal<number | undefined> = computed(() => {
|
||||
const prevGroup = this.prevGroup$.value;
|
||||
if (!prevGroup) {
|
||||
return 0;
|
||||
}
|
||||
return prevGroup.bottom$.value;
|
||||
});
|
||||
topNode = new GroupNode(this, this.top$, this.topElement, node => {
|
||||
const height = node.height$.value;
|
||||
if (height == null) {
|
||||
return false;
|
||||
}
|
||||
const top = this.top$.value;
|
||||
if (top == null) {
|
||||
return false;
|
||||
}
|
||||
const bottom = this.lastRowBottom$.value ?? top + height;
|
||||
const groupInView =
|
||||
top < this.grid.container.viewport$.value.bottom &&
|
||||
bottom > this.grid.container.viewport$.value.top;
|
||||
return groupInView;
|
||||
});
|
||||
lastRowBottom$: ReadonlySignal<number | undefined> = computed(() => {
|
||||
if (this.rows$.value.length === 0) {
|
||||
return this.rowsTop$.value;
|
||||
}
|
||||
const lastRow = this.rows$.value.findLast(row => row.bottom$.value != null);
|
||||
if (lastRow == null) {
|
||||
return;
|
||||
}
|
||||
return lastRow.bottom$.value;
|
||||
});
|
||||
bottomNode = new GroupNode(
|
||||
this,
|
||||
this.lastRowBottom$,
|
||||
this.bottomElement,
|
||||
node => {
|
||||
const height = node.height$.value;
|
||||
if (height == null) {
|
||||
return false;
|
||||
}
|
||||
const top = this.lastRowBottom$.value;
|
||||
if (top == null) {
|
||||
return false;
|
||||
}
|
||||
const bottom = top + height;
|
||||
const groupInView =
|
||||
top < this.grid.container.viewport$.value.bottom &&
|
||||
bottom > this.grid.container.viewport$.value.top;
|
||||
return groupInView;
|
||||
}
|
||||
);
|
||||
rows$ = computed(() => {
|
||||
const group = this.grid.options.groups$.value.find(
|
||||
g => g.id === this.groupId
|
||||
);
|
||||
if (!group) {
|
||||
return [];
|
||||
}
|
||||
return group.rows.map(rowId => {
|
||||
return this.grid.getOrCreateRow(this, rowId);
|
||||
});
|
||||
});
|
||||
|
||||
groupIndex$ = computed(() => {
|
||||
return this.grid.groups$.value.findIndex(
|
||||
group => group.groupId === this.groupId
|
||||
);
|
||||
});
|
||||
|
||||
prevGroup$ = computed(() => {
|
||||
return this.grid.groups$.value[this.groupIndex$.value - 1];
|
||||
});
|
||||
|
||||
get rowsTop$() {
|
||||
return this.topNode.bottom$;
|
||||
}
|
||||
|
||||
get bottomNodeTop$() {
|
||||
return this.lastRowBottom$;
|
||||
}
|
||||
|
||||
height$ = computed(() => {
|
||||
const bottom = this.bottom$.value;
|
||||
if (bottom == null) {
|
||||
return;
|
||||
}
|
||||
const top = this.top$.value;
|
||||
if (top == null) {
|
||||
return;
|
||||
}
|
||||
return bottom - top;
|
||||
});
|
||||
|
||||
bottom$ = computed(() => {
|
||||
return this.bottomNode.bottom$.value;
|
||||
});
|
||||
|
||||
constructor(
|
||||
readonly grid: GridVirtualScroll<GroupData, RowData, CellData>,
|
||||
readonly groupId: string,
|
||||
readonly topElement: (
|
||||
group: GridGroup<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement,
|
||||
readonly bottomElement: (
|
||||
group: GridGroup<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement,
|
||||
initGroupData: (group: GridGroup<GroupData, RowData, CellData>) => GroupData
|
||||
) {
|
||||
super(() => initGroupData(this));
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export interface GridGroupData {
|
||||
id: string;
|
||||
rows: string[];
|
||||
}
|
||||
|
||||
export interface GridVirtualScrollOptions<GroupData, RowData, CellData>
|
||||
extends VirtualScrollOptions {
|
||||
initGroupData: (group: GridGroup<GroupData, RowData, CellData>) => GroupData;
|
||||
initRowData: (row: GridRow<GroupData, RowData, CellData>) => RowData;
|
||||
initCellData: (cell: GridCell<GroupData, RowData, CellData>) => CellData;
|
||||
columns$: ReadonlySignal<
|
||||
{
|
||||
id: string;
|
||||
width: number;
|
||||
}[]
|
||||
>;
|
||||
fixedRowHeight$: ReadonlySignal<number | undefined>;
|
||||
createGroup: {
|
||||
top: (
|
||||
group: GridGroup<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement;
|
||||
bottom: (
|
||||
group: GridGroup<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement;
|
||||
};
|
||||
createCell: (
|
||||
cell: GridCell<GroupData, RowData, CellData>,
|
||||
wrapper: VirtualElementWrapper
|
||||
) => HTMLElement;
|
||||
groups$: ReadonlySignal<GridGroupData[]>;
|
||||
}
|
||||
|
||||
export class GridVirtualScroll<
|
||||
GroupData,
|
||||
RowData,
|
||||
CellData,
|
||||
> extends VirtualScroll {
|
||||
readonly cellsCache = new CacheManager<
|
||||
{ groupId: string; columnId: string; rowId: string },
|
||||
GridCell<GroupData, RowData, CellData>
|
||||
>(cell => `${cell.groupId}-${cell.rowId}-${cell.columnId}`);
|
||||
readonly rowsCache = new CacheManager<
|
||||
{ groupId: string; rowId: string },
|
||||
GridRow<GroupData, RowData, CellData>
|
||||
>(row => `${row.groupId}-${row.rowId}`);
|
||||
readonly groupsCache = new CacheManager<
|
||||
string,
|
||||
GridGroup<GroupData, RowData, CellData>
|
||||
>(groupId => groupId);
|
||||
|
||||
readonly groups$ = computed(() => {
|
||||
return this.options.groups$.value.map(group => {
|
||||
return this.getOrCreateGroup(group.id);
|
||||
});
|
||||
});
|
||||
|
||||
constructor(
|
||||
readonly options: GridVirtualScrollOptions<GroupData, RowData, CellData>
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
getOrCreateRow(
|
||||
group: GridGroup<GroupData, RowData, CellData>,
|
||||
rowId: string
|
||||
): GridRow<GroupData, RowData, CellData> {
|
||||
return this.rowsCache.getOrCreate({ groupId: group.groupId, rowId }, () => {
|
||||
return new GridRow(group, rowId, this.options.initRowData);
|
||||
});
|
||||
}
|
||||
|
||||
getGroup(groupId: string) {
|
||||
return this.getOrCreateGroup(groupId);
|
||||
}
|
||||
|
||||
getRow(groupId: string, rowId: string) {
|
||||
const group = this.getOrCreateGroup(groupId);
|
||||
return this.getOrCreateRow(group, rowId);
|
||||
}
|
||||
|
||||
getCell(groupId: string, rowId: string, columnId: string) {
|
||||
const row = this.getRow(groupId, rowId);
|
||||
return this.getOrCreateCell(row, columnId);
|
||||
}
|
||||
|
||||
getOrCreateCell(
|
||||
row: GridRow<GroupData, RowData, CellData>,
|
||||
columnId: string
|
||||
): GridCell<GroupData, RowData, CellData> {
|
||||
return this.cellsCache.getOrCreate(
|
||||
{ groupId: row.group.groupId, rowId: row.rowId, columnId },
|
||||
() => {
|
||||
return new GridCell(
|
||||
row,
|
||||
columnId,
|
||||
this.options.createCell,
|
||||
this.options.initCellData
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getOrCreateGroup(groupId: string): GridGroup<GroupData, RowData, CellData> {
|
||||
return this.groupsCache.getOrCreate(groupId, () => {
|
||||
return new GridGroup(
|
||||
this,
|
||||
groupId,
|
||||
this.options.createGroup.top,
|
||||
this.options.createGroup.bottom,
|
||||
this.options.initGroupData
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private listenDataChange() {
|
||||
this.disposables.push(
|
||||
effect(() => {
|
||||
const activeGroupIds = new Set<string>();
|
||||
const activeRowIds = new Set<string>();
|
||||
const activeCellIds = new Set<string>();
|
||||
|
||||
for (const group of this.groups$.value) {
|
||||
activeGroupIds.add(group.groupId);
|
||||
for (const row of group.rows$.value) {
|
||||
const rowKey = this.rowsCache.keyToString({
|
||||
groupId: group.groupId,
|
||||
rowId: row.rowId,
|
||||
});
|
||||
activeRowIds.add(rowKey);
|
||||
for (const cell of row.cells$.value) {
|
||||
const cellKey = this.cellsCache.keyToString({
|
||||
groupId: group.groupId,
|
||||
rowId: row.rowId,
|
||||
columnId: cell.columnId,
|
||||
});
|
||||
activeCellIds.add(cellKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.cellsCache.cleanup(activeCellIds);
|
||||
this.rowsCache.cleanup(activeRowIds);
|
||||
this.groupsCache.cleanup(activeGroupIds);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
lastGroupBottom$ = computed(() => {
|
||||
const lastGroup = this.groups$.value.findLast(
|
||||
group => group.bottom$.value != null
|
||||
);
|
||||
if (lastGroup == null) {
|
||||
return;
|
||||
}
|
||||
return lastGroup.bottom$.value;
|
||||
});
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
this.cellsCache.clear();
|
||||
this.rowsCache.clear();
|
||||
this.groupsCache.clear();
|
||||
}
|
||||
|
||||
get columns$() {
|
||||
return this.options.columns$;
|
||||
}
|
||||
|
||||
get fixedRowHeight$() {
|
||||
return this.options.fixedRowHeight$;
|
||||
}
|
||||
|
||||
columnPosition$ = computed(() => {
|
||||
const columns = this.options.columns$.value;
|
||||
const positions: { left: number; right: number; width: number }[] = [];
|
||||
let left = 0;
|
||||
for (const column of columns) {
|
||||
positions.push({
|
||||
left,
|
||||
right: left + column.width,
|
||||
width: column.width,
|
||||
});
|
||||
left += column.width ?? 0;
|
||||
}
|
||||
return positions;
|
||||
});
|
||||
|
||||
totalWidth$ = computed(() => {
|
||||
const lastPosition =
|
||||
this.columnPosition$.value[this.columnPosition$.value.length - 1];
|
||||
if (lastPosition == null) {
|
||||
return 0;
|
||||
}
|
||||
return lastPosition.right;
|
||||
});
|
||||
|
||||
get content() {
|
||||
return this.container.content;
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init();
|
||||
this.container.init();
|
||||
this.listenSizeChange();
|
||||
this.listenDataChange();
|
||||
}
|
||||
|
||||
private listenSizeChange() {
|
||||
this.disposables.push(
|
||||
effect(() => {
|
||||
const width = this.totalWidth$.value ?? 0;
|
||||
const height = this.lastGroupBottom$.value ?? 0;
|
||||
this.container.updateContentSize(width, height);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
xScrollContainer?: HTMLElement;
|
||||
yScrollContainer?: HTMLElement;
|
||||
}
|
||||
|
||||
export const getScrollContainer = (
|
||||
element: HTMLElement,
|
||||
direction: 'x' | 'y'
|
||||
) => {
|
||||
let current: HTMLElement | null = element;
|
||||
while (current) {
|
||||
const overflow = current
|
||||
.computedStyleMap()
|
||||
.get(`overflow-${direction}`)
|
||||
?.toString();
|
||||
if (overflow === 'auto' || overflow === 'scroll') {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
export class VirtualScrollContainer {
|
||||
private readonly options: VirtualScrollOptions;
|
||||
private xScrollContainer?: HTMLElement;
|
||||
private readonly xScrollContainerWidth$ = signal(0);
|
||||
private yScrollContainer?: HTMLElement;
|
||||
private readonly yScrollContainerHeight$ = signal(0);
|
||||
readonly content: HTMLElement = document.createElement('div');
|
||||
readonly scrollTop$ = signal(0);
|
||||
readonly scrollLeft$ = signal(0);
|
||||
private readonly disposables: (() => void)[] = [];
|
||||
private readonly preloadSize = signal({
|
||||
left: 100,
|
||||
right: 100,
|
||||
top: 100,
|
||||
bottom: 100,
|
||||
});
|
||||
private readonly offsetTop$ = signal(0);
|
||||
private readonly offsetLeft$ = signal(0);
|
||||
readonly viewport$ = computed(() => {
|
||||
const preloadSize = this.preloadSize.value;
|
||||
const offsetTop = this.offsetTop$.value;
|
||||
const offsetLeft = this.offsetLeft$.value;
|
||||
const scrollTop = this.scrollTop$.value;
|
||||
const scrollLeft = this.scrollLeft$.value;
|
||||
const xScrollContainerWidth = this.xScrollContainerWidth$.value;
|
||||
const yScrollContainerHeight = this.yScrollContainerHeight$.value;
|
||||
const top = scrollTop - offsetTop - preloadSize.top;
|
||||
const height =
|
||||
yScrollContainerHeight + preloadSize.top + preloadSize.bottom;
|
||||
const bottom = top + height;
|
||||
const left = scrollLeft - offsetLeft - preloadSize.left;
|
||||
const width = xScrollContainerWidth + preloadSize.left + preloadSize.right;
|
||||
const right = left + width;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
right,
|
||||
};
|
||||
});
|
||||
|
||||
constructor(options: VirtualScrollOptions) {
|
||||
this.options = {
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
init() {
|
||||
this.content.style.position = 'relative';
|
||||
this.content.style.overflow = 'hidden';
|
||||
this.xScrollContainer =
|
||||
this.options.xScrollContainer ??
|
||||
getScrollContainer(this.content, 'x') ??
|
||||
document.body;
|
||||
this.yScrollContainer =
|
||||
this.options.yScrollContainer ??
|
||||
getScrollContainer(this.content, 'y') ??
|
||||
document.body;
|
||||
this.listenScroll();
|
||||
this.listenResize();
|
||||
this.updateOffset();
|
||||
}
|
||||
|
||||
private getOffset(
|
||||
container: HTMLElement,
|
||||
content: HTMLElement,
|
||||
direction: 'Top' | 'Left'
|
||||
) {
|
||||
let current: HTMLElement | null = content;
|
||||
let offset = 0;
|
||||
while (current) {
|
||||
offset += current[`offset${direction}`];
|
||||
current =
|
||||
current.offsetParent instanceof HTMLElement
|
||||
? current.offsetParent
|
||||
: null;
|
||||
if (current === container) {
|
||||
return offset;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
private updateOffsetTask?: ReturnType<typeof setTimeout>;
|
||||
private updateOffset() {
|
||||
if (this.updateOffsetTask) {
|
||||
clearTimeout(this.updateOffsetTask);
|
||||
this.updateOffsetTask = undefined;
|
||||
}
|
||||
if (this.yScrollContainer) {
|
||||
this.offsetTop$.value =
|
||||
this.getOffset(this.yScrollContainer, this.content, 'Top') ?? 0;
|
||||
}
|
||||
if (this.xScrollContainer) {
|
||||
this.offsetLeft$.value =
|
||||
this.getOffset(this.xScrollContainer, this.content, 'Left') ?? 0;
|
||||
}
|
||||
this.updateOffsetTask = setTimeout(() => {
|
||||
this.updateOffsetTask = undefined;
|
||||
this.updateOffset();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private listenScroll() {
|
||||
const handlerX = () => {
|
||||
this.scrollLeft$.value = this.xScrollContainer?.scrollLeft ?? 0;
|
||||
};
|
||||
const handlerY = () => {
|
||||
this.scrollTop$.value = this.yScrollContainer?.scrollTop ?? 0;
|
||||
};
|
||||
this.yScrollContainer?.addEventListener('scroll', handlerY);
|
||||
this.xScrollContainer?.addEventListener('scroll', handlerX);
|
||||
this.disposables.push(() => {
|
||||
this.yScrollContainer?.removeEventListener('scroll', handlerY);
|
||||
this.xScrollContainer?.removeEventListener('scroll', handlerX);
|
||||
});
|
||||
}
|
||||
|
||||
private listenResize() {
|
||||
if (this.xScrollContainer) {
|
||||
const handlerX = () => {
|
||||
this.xScrollContainerWidth$.value =
|
||||
this.xScrollContainer?.clientWidth ?? 0;
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(handlerX);
|
||||
resizeObserver.observe(this.xScrollContainer);
|
||||
this.disposables.push(() => {
|
||||
resizeObserver.disconnect();
|
||||
});
|
||||
}
|
||||
if (this.yScrollContainer) {
|
||||
const handlerY = () => {
|
||||
this.yScrollContainerHeight$.value =
|
||||
this.yScrollContainer?.clientHeight ?? 0;
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(handlerY);
|
||||
resizeObserver.observe(this.yScrollContainer);
|
||||
this.disposables.push(() => {
|
||||
resizeObserver.disconnect();
|
||||
});
|
||||
}
|
||||
}
|
||||
readonly batchTaskManager = new BatchTaskManager([5, 50], 50);
|
||||
|
||||
initElement(element: HTMLElement, isInit: ReadonlySignal<boolean>) {
|
||||
const initTask = this.batchTaskManager.newTask();
|
||||
initTask.updateTask(
|
||||
0,
|
||||
() => {
|
||||
if (element.isConnected || isInit.value) {
|
||||
return false;
|
||||
}
|
||||
this.content.append(element);
|
||||
return;
|
||||
},
|
||||
true
|
||||
);
|
||||
const task = this.batchTaskManager.newTask();
|
||||
return {
|
||||
cancel: () => {
|
||||
initTask.cancel();
|
||||
task.cancel();
|
||||
},
|
||||
show: () => {
|
||||
task.updateTask(1, () => {
|
||||
if (element.isConnected) {
|
||||
return false;
|
||||
}
|
||||
this.content.append(element);
|
||||
return;
|
||||
});
|
||||
},
|
||||
hide: () => {
|
||||
task.updateTask(1, () => {
|
||||
if (!element.isConnected) {
|
||||
return false;
|
||||
}
|
||||
element.remove();
|
||||
return;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.batchTaskManager.clean();
|
||||
this.disposables.forEach(disposable => disposable());
|
||||
}
|
||||
|
||||
public updateContentSize(width: number, height: number) {
|
||||
this.content.style.width = `${width}px`;
|
||||
this.content.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
public scrollToPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
behavior: ScrollBehavior = 'auto'
|
||||
) {
|
||||
this.xScrollContainer?.scrollTo({
|
||||
left: x,
|
||||
behavior,
|
||||
});
|
||||
this.yScrollContainer?.scrollTo({
|
||||
top: y,
|
||||
behavior,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListVirtualScrollOptions extends VirtualScrollOptions {
|
||||
itemCount: number;
|
||||
itemHeight: number | ((index: number) => number);
|
||||
}
|
||||
|
||||
export class ListVirtualScroll extends VirtualScroll {
|
||||
protected itemCount: number;
|
||||
protected itemHeight: number | ((index: number) => number);
|
||||
|
||||
constructor(options: ListVirtualScrollOptions) {
|
||||
super(options);
|
||||
this.itemCount = options.itemCount;
|
||||
this.itemHeight = options.itemHeight;
|
||||
this.updateTotalSize();
|
||||
}
|
||||
|
||||
private updateTotalSize() {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DatabaseCellContainer } from './cell.js';
|
||||
import { DragToFillElement } from './controller/drag-to-fill.js';
|
||||
import { SelectionElement } from './controller/selection.js';
|
||||
import { TableGroup } from './group.js';
|
||||
import { DatabaseColumnHeader } from './header/column-header.js';
|
||||
import { DataViewColumnPreview } from './header/column-renderer.js';
|
||||
import { DatabaseHeaderColumn } from './header/database-header-column.js';
|
||||
import { DatabaseNumberFormatBar } from './header/number-format-bar.js';
|
||||
import { TableVerticalIndicator } from './header/vertical-indicator.js';
|
||||
import { TableRow } from './row/row.js';
|
||||
import { RowSelectCheckbox } from './row/row-select-checkbox.js';
|
||||
import { DataViewTable } from './table-view.js';
|
||||
|
||||
export function pcEffects() {
|
||||
customElements.define('affine-database-table', DataViewTable);
|
||||
customElements.define('affine-data-view-table-group', TableGroup);
|
||||
customElements.define(
|
||||
'affine-database-cell-container',
|
||||
DatabaseCellContainer
|
||||
);
|
||||
customElements.define('affine-database-column-header', DatabaseColumnHeader);
|
||||
customElements.define(
|
||||
'affine-data-view-column-preview',
|
||||
DataViewColumnPreview
|
||||
);
|
||||
customElements.define('affine-database-header-column', DatabaseHeaderColumn);
|
||||
customElements.define(
|
||||
'affine-database-number-format-bar',
|
||||
DatabaseNumberFormatBar
|
||||
);
|
||||
customElements.define('data-view-table-row', TableRow);
|
||||
customElements.define('row-select-checkbox', RowSelectCheckbox);
|
||||
customElements.define('data-view-table-selection', SelectionElement);
|
||||
customElements.define('data-view-drag-to-fill', DragToFillElement);
|
||||
customElements.define(
|
||||
'data-view-table-vertical-indicator',
|
||||
TableVerticalIndicator
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import './pc/effect.js';
|
||||
import './pc-virtual/effect.js';
|
||||
|
||||
import { createUniComponentFromWebComponent } from '../../core/utils/uni-component/uni-component.js';
|
||||
import { createIcon } from '../../core/utils/uni-icon.js';
|
||||
import { tableViewModel } from './define.js';
|
||||
import { MobileDataViewTable } from './mobile/table-view.js';
|
||||
import { DataViewTable } from './pc/table-view.js';
|
||||
import { TableViewSelector } from './table-view-selector.js';
|
||||
|
||||
export const tableViewMeta = tableViewModel.createMeta({
|
||||
view: createUniComponentFromWebComponent(DataViewTable),
|
||||
view: createUniComponentFromWebComponent(TableViewSelector),
|
||||
mobileView: createUniComponentFromWebComponent(MobileDataViewTable),
|
||||
icon: createIcon('DatabaseTableViewIcon'),
|
||||
});
|
||||
|
||||
@@ -9,8 +9,7 @@ import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../consts.js';
|
||||
import type { TableSingleView } from '../table-view-manager.js';
|
||||
|
||||
const styles = css`
|
||||
.affine-database-column-stats {
|
||||
width: 100%;
|
||||
affine-database-column-stats {
|
||||
margin-left: ${LEFT_TOOL_BAR_WIDTH}px;
|
||||
height: ${STATS_BAR_HEIGHT}px;
|
||||
display: flex;
|
||||
@@ -24,20 +23,17 @@ export class DataBaseColumnStats extends SignalWatcher(
|
||||
|
||||
protected override render() {
|
||||
const cols = this.view.properties$.value;
|
||||
|
||||
return html`
|
||||
<div class="affine-database-column-stats">
|
||||
${repeat(
|
||||
cols,
|
||||
col => col.id,
|
||||
col => {
|
||||
return html`<affine-database-column-stats-cell
|
||||
.column=${col}
|
||||
.group=${this.group}
|
||||
></affine-database-column-stats-cell>`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
${repeat(
|
||||
cols,
|
||||
col => col.id,
|
||||
col => {
|
||||
return html`<affine-database-column-stats-cell
|
||||
.column=${col}
|
||||
.group=${this.group}
|
||||
></affine-database-column-stats-cell>`;
|
||||
}
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const styles = css`
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-database-column-stats:hover .stats-cell {
|
||||
affine-database-column-stats:hover .stats-cell {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { DataViewBase } from '../../core/view/data-view-base.js';
|
||||
import type { DataViewTable } from './pc/table-view.js';
|
||||
import type { VirtualTableView } from './pc-virtual/table-view.js';
|
||||
import type { TableViewSelectionWithType } from './selection.js';
|
||||
import type { TableSingleView } from './table-view-manager.js';
|
||||
|
||||
export class TableViewSelector extends DataViewBase<
|
||||
TableSingleView,
|
||||
TableViewSelectionWithType
|
||||
> {
|
||||
tableRef$ = signal<VirtualTableView | DataViewTable>();
|
||||
|
||||
// @ts-expect-error TODO: fix this
|
||||
get expose() {
|
||||
if (this.tableRef$.value) {
|
||||
return this.tableRef$.value.expose;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const flags = this.props.view.manager.dataSource.featureFlags$.value;
|
||||
|
||||
if (flags.enable_table_virtual_scroll) {
|
||||
return html`<affine-virtual-table
|
||||
${ref(this.tableRef$)}
|
||||
.props=${this.props}
|
||||
></affine-virtual-table>`;
|
||||
}
|
||||
|
||||
return html`<affine-database-table
|
||||
${ref(this.tableRef$)}
|
||||
.props=${this.props}
|
||||
></affine-database-table>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-database-table-selector': TableViewSelector;
|
||||
}
|
||||
}
|
||||
41
blocksuite/affine/data-view/src/widget-presets/effect.ts
Normal file
41
blocksuite/affine/data-view/src/widget-presets/effect.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { FilterConditionView } from './quick-setting-bar/filter/condition-view.js';
|
||||
import { FilterGroupView } from './quick-setting-bar/filter/group-panel-view.js';
|
||||
import { FilterBar } from './quick-setting-bar/filter/list-view.js';
|
||||
import { FilterRootView } from './quick-setting-bar/filter/root-panel-view.js';
|
||||
import { SortRootView } from './quick-setting-bar/sort/root-panel.js';
|
||||
import { DataViewHeaderToolsFilter } from './tools/presets/filter/filter.js';
|
||||
import { DataViewHeaderToolsSearch } from './tools/presets/search/search.js';
|
||||
import { DataViewHeaderToolsSort } from './tools/presets/sort/sort.js';
|
||||
import { DataViewHeaderToolsAddRow } from './tools/presets/table-add-row/add-row.js';
|
||||
import { NewRecordPreview } from './tools/presets/table-add-row/new-record-preview.js';
|
||||
import { DataViewHeaderToolsViewOptions } from './tools/presets/view-options/view-options.js';
|
||||
import { DataViewHeaderTools } from './tools/tools-view.js';
|
||||
import { DataViewHeaderViews } from './views-bar/views-view.js';
|
||||
|
||||
export function widgetPresetsEffects() {
|
||||
customElements.define('data-view-header-tools', DataViewHeaderTools);
|
||||
customElements.define('filter-bar', FilterBar);
|
||||
customElements.define('filter-condition-view', FilterConditionView);
|
||||
customElements.define(
|
||||
'data-view-header-tools-search',
|
||||
DataViewHeaderToolsSearch
|
||||
);
|
||||
customElements.define('filter-group-view', FilterGroupView);
|
||||
customElements.define(
|
||||
'data-view-header-tools-add-row',
|
||||
DataViewHeaderToolsAddRow
|
||||
);
|
||||
customElements.define('affine-database-new-record-preview', NewRecordPreview);
|
||||
customElements.define(
|
||||
'data-view-header-tools-filter',
|
||||
DataViewHeaderToolsFilter
|
||||
);
|
||||
customElements.define('data-view-header-tools-sort', DataViewHeaderToolsSort);
|
||||
customElements.define(
|
||||
'data-view-header-tools-view-options',
|
||||
DataViewHeaderToolsViewOptions
|
||||
);
|
||||
customElements.define('filter-root-view', FilterRootView);
|
||||
customElements.define('sort-root-view', SortRootView);
|
||||
customElements.define('data-view-header-views', DataViewHeaderViews);
|
||||
}
|
||||
Reference in New Issue
Block a user