From 1ea73456ca8a566f4afbcf7f6aece28ff4281d4d Mon Sep 17 00:00:00 2001 From: zzj3720 <17165520+zzj3720@users.noreply.github.com> Date: Tue, 29 Apr 2025 09:52:07 +0000 Subject: [PATCH] refactor(editor): support virtual scroll for table view of database block (#11642) close: BS-3378 ## 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. --- .../affine/blocks/database/src/block-icons.ts | 35 +- .../affine/blocks/database/src/data-source.ts | 8 +- .../database/src/properties/title/text.ts | 42 +- blocksuite/affine/data-view/package.json | 1 + .../data-view/src/core/common/dv.css.ts | 85 ++ .../affine/data-view/src/core/effect.ts | 39 + blocksuite/affine/data-view/src/core/types.ts | 1 + blocksuite/affine/data-view/src/effects.ts | 170 +-- .../data-view/src/property-presets/effect.ts | 19 + .../data-view/src/view-presets/effect.ts | 48 + .../data-view/src/view-presets/table/index.ts | 3 + .../table/pc-virtual/controller/clipboard.ts | 316 +++++ .../pc-virtual/controller/drag-to-fill.ts | 117 ++ .../table/pc-virtual/controller/drag.ts | 211 ++++ .../table/pc-virtual/controller/hotkeys.ts | 386 ++++++ .../table/pc-virtual/controller/selection.ts | 1115 +++++++++++++++++ .../view-presets/table/pc-virtual/effect.ts | 53 + .../group/bottom/group-footer.css.ts | 33 + .../pc-virtual/group/bottom/group-footer.ts | 104 ++ .../group/bottom/stats/column-stats-bar.ts | 51 + .../group/bottom/stats/column-stats-column.ts | 246 ++++ .../pc-virtual/group/top/group-header.css.ts | 5 + .../pc-virtual/group/top/group-header.ts | 136 ++ .../pc-virtual/group/top/group-title.css.ts | 72 ++ .../table/pc-virtual/group/top/group-title.ts | 78 ++ .../group/top/header/column-header.css.ts | 195 +++ .../group/top/header/column-header.ts | 97 ++ .../group/top/header/column-move-preview.ts | 93 ++ .../group/top/header/number-format-bar.ts | 147 +++ .../group/top/header/single-column-header.ts | 498 ++++++++ .../group/top/header/vertical-indicator.ts | 169 +++ .../view-presets/table/pc-virtual/row/cell.ts | 229 ++++ .../view-presets/table/pc-virtual/row/menu.ts | 131 ++ .../table/pc-virtual/row/row-header.css.ts | 53 + .../table/pc-virtual/row/row-header.ts | 128 ++ .../table/pc-virtual/row/row-last.ts | 69 + .../table/pc-virtual/table-view.css.ts | 111 ++ .../table/pc-virtual/table-view.ts | 322 +++++ .../view-presets/table/pc-virtual/types.ts | 41 + .../pc-virtual/virtual/batch-task-manager.ts | 119 ++ .../table/pc-virtual/virtual/linked-list.ts | 169 +++ .../pc-virtual/virtual/virtual-cell.css.ts | 12 + .../table/pc-virtual/virtual/virtual-cell.ts | 60 + .../pc-virtual/virtual/virtual-scroll.ts | 959 ++++++++++++++ .../src/view-presets/table/pc/effect.ts | 39 + .../src/view-presets/table/renderer.ts | 7 +- .../table/stats/column-stats-bar.ts | 26 +- .../table/stats/column-stats-column.ts | 2 +- .../view-presets/table/table-view-selector.ts | 45 + .../data-view/src/widget-presets/effect.ts | 41 + .../src/services/feature-flag-service.ts | 2 + .../core/src/modules/feature-flag/constant.ts | 19 +- packages/frontend/i18n/src/i18n.gen.ts | 8 + packages/frontend/i18n/src/resources/en.json | 2 + yarn.lock | 1 + 55 files changed, 6941 insertions(+), 227 deletions(-) create mode 100644 blocksuite/affine/data-view/src/core/common/dv.css.ts create mode 100644 blocksuite/affine/data-view/src/core/effect.ts create mode 100644 blocksuite/affine/data-view/src/property-presets/effect.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/effect.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag-to-fill.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/effect.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/number-format-bar.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/vertical-indicator.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/cell.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-last.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/types.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/batch-task-manager.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/linked-list.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.css.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-scroll.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/pc/effect.ts create mode 100644 blocksuite/affine/data-view/src/view-presets/table/table-view-selector.ts create mode 100644 blocksuite/affine/data-view/src/widget-presets/effect.ts diff --git a/blocksuite/affine/blocks/database/src/block-icons.ts b/blocksuite/affine/blocks/database/src/block-icons.ts index b485f1b16..271d54cdd 100644 --- a/blocksuite/affine/blocks/database/src/block-icons.ts +++ b/blocksuite/affine/blocks/database/src/block-icons.ts @@ -14,7 +14,19 @@ import { } from '@blocksuite/icons/lit'; import type { BlockModel } from '@blocksuite/store'; import type { TemplateResult } from 'lit'; - +const icons: Record = { + text: TextIcon(), + quote: QuoteIcon(), + h1: Heading1Icon(), + h2: Heading2Icon(), + h3: Heading3Icon(), + h4: Heading4Icon(), + h5: Heading5Icon(), + h6: Heading6Icon(), + bulleted: BulletedListIcon(), + numbered: NumberedListIcon(), + todo: CheckBoxCheckLinearIcon(), +}; export const getIcon = ( model: BlockModel & { props: { @@ -24,27 +36,10 @@ export const getIcon = ( ): TemplateResult => { if (model.flavour === 'affine:paragraph') { const type = model.props.type as ParagraphType; - return ( - { - text: TextIcon(), - quote: QuoteIcon(), - h1: Heading1Icon(), - h2: Heading2Icon(), - h3: Heading3Icon(), - h4: Heading4Icon(), - h5: Heading5Icon(), - h6: Heading6Icon(), - } as Record - )[type]; + return icons[type] ?? TextIcon(); } if (model.flavour === 'affine:list') { - return ( - { - bulleted: BulletedListIcon(), - numbered: NumberedListIcon(), - todo: CheckBoxCheckLinearIcon(), - }[model.props.type ?? 'bulleted'] ?? BulletedListIcon() - ); + return icons[model.props.type ?? 'bulleted'] ?? BulletedListIcon(); } return TextIcon(); }; diff --git a/blocksuite/affine/blocks/database/src/data-source.ts b/blocksuite/affine/blocks/database/src/data-source.ts index 7d3650b14..66f7d81b3 100644 --- a/blocksuite/affine/blocks/database/src/data-source.ts +++ b/blocksuite/affine/blocks/database/src/data-source.ts @@ -71,11 +71,15 @@ export class DatabaseBlockDataSource extends DataSourceBase { override featureFlags$: ReadonlySignal = computed(() => { const featureFlagService = this.doc.get(FeatureFlagService); - const flag = featureFlagService.getFlag( + const enableNumberFormat = featureFlagService.getFlag( 'enable_database_number_formatting' ); + const enableTableVirtualScroll = featureFlagService.getFlag( + 'enable_table_virtual_scroll' + ); return { - enable_number_formatting: flag ?? false, + enable_number_formatting: enableNumberFormat ?? false, + enable_table_virtual_scroll: enableTableVirtualScroll ?? false, }; }); diff --git a/blocksuite/affine/blocks/database/src/properties/title/text.ts b/blocksuite/affine/blocks/database/src/properties/title/text.ts index 9be370bf1..8c582b1b2 100644 --- a/blocksuite/affine/blocks/database/src/properties/title/text.ts +++ b/blocksuite/affine/blocks/database/src/properties/title/text.ts @@ -12,7 +12,7 @@ import { BaseCellRenderer } from '@blocksuite/data-view'; import { IS_MAC } from '@blocksuite/global/env'; import { LinkedPageIcon } from '@blocksuite/icons/lit'; import type { BlockSnapshot, DeltaInsert, Text } from '@blocksuite/store'; -import { signal } from '@preact/signals-core'; +import { computed, signal } from '@preact/signals-core'; import { property } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { html } from 'lit/static-html.js'; @@ -217,17 +217,23 @@ export class HeaderAreaTextCell extends BaseCellRenderer { super.firstUpdated(props); this.richText.value?.updateComplete .then(() => { - this.disposables.addFromEvent( - this.richText.value, - 'copy', - this._onCopy - ); - this.disposables.addFromEvent(this.richText.value, 'cut', this._onCut); - this.disposables.addFromEvent( - this.richText.value, - 'paste', - this._onPaste - ); + if (this.richText.value) { + this.disposables.addFromEvent( + this.richText.value, + 'copy', + this._onCopy + ); + this.disposables.addFromEvent( + this.richText.value, + 'cut', + this._onCut + ); + this.disposables.addFromEvent( + this.richText.value, + 'paste', + this._onPaste + ); + } }) .catch(console.error); } @@ -261,7 +267,14 @@ export class HeaderAreaTextCell extends BaseCellRenderer { class="${titleRichTextStyle}" >`; } + icon$ = computed(() => { + const iconColumn = this.view.mainProperties$.value.iconColumn; + if (!iconColumn) return; + const icon = this.view.cellValueGet(this.cell.rowId, iconColumn) as string; + if (!icon) return; + return icon; + }); renderIcon() { if (!this.showIcon) { return; @@ -271,10 +284,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer { ${LinkedPageIcon({})} `; } - const iconColumn = this.view.mainProperties$.value.iconColumn; - if (!iconColumn) return; - - const icon = this.view.cellValueGet(this.cell.rowId, iconColumn) as string; + const icon = this.icon$.value; if (!icon) return; return html`
${icon}
`; diff --git a/blocksuite/affine/data-view/package.json b/blocksuite/affine/data-view/package.json index bcf8b438d..581b645ae 100644 --- a/blocksuite/affine/data-view/package.json +++ b/blocksuite/affine/data-view/package.json @@ -23,6 +23,7 @@ "@toeverything/theme": "^1.1.12", "@types/lodash-es": "^4.17.12", "@vanilla-extract/css": "^1.17.0", + "clsx": "^2.1.1", "date-fns": "^4.0.0", "lit": "^3.2.0", "lodash-es": "^4.17.21", diff --git a/blocksuite/affine/data-view/src/core/common/dv.css.ts b/blocksuite/affine/data-view/src/core/common/dv.css.ts new file mode 100644 index 000000000..64053d2ed --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/dv.css.ts @@ -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', +}); diff --git a/blocksuite/affine/data-view/src/core/effect.ts b/blocksuite/affine/data-view/src/core/effect.ts new file mode 100644 index 000000000..427c1c4bb --- /dev/null +++ b/blocksuite/affine/data-view/src/core/effect.ts @@ -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); +} diff --git a/blocksuite/affine/data-view/src/core/types.ts b/blocksuite/affine/data-view/src/core/types.ts index 42d15b233..c3c60ba40 100644 --- a/blocksuite/affine/data-view/src/core/types.ts +++ b/blocksuite/affine/data-view/src/core/types.ts @@ -13,4 +13,5 @@ export type PropertyDataUpdater< export interface DatabaseFlags { enable_number_formatting: boolean; + enable_table_virtual_scroll: boolean; } diff --git a/blocksuite/affine/data-view/src/effects.ts b/blocksuite/affine/data-view/src/effects.ts index 9cdefcad6..c7e032834 100644 --- a/blocksuite/affine/data-view/src/effects.ts +++ b/blocksuite/affine/data-view/src/effects.ts @@ -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(); } diff --git a/blocksuite/affine/data-view/src/property-presets/effect.ts b/blocksuite/affine/data-view/src/property-presets/effect.ts new file mode 100644 index 000000000..65dddc102 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/effect.ts @@ -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); +} diff --git a/blocksuite/affine/data-view/src/view-presets/effect.ts b/blocksuite/affine/data-view/src/view-presets/effect.ts new file mode 100644 index 000000000..e5bb1b1af --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/effect.ts @@ -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(); +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/index.ts b/blocksuite/affine/data-view/src/view-presets/table/index.ts index 58fe1f7df..ec2b51e49 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/index.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/index.ts @@ -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'; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts new file mode 100644 index 000000000..4c449223f --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/clipboard.ts @@ -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 ?? '' + ); + } + } + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag-to-fill.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag-to-fill.ts new file mode 100644 index 000000000..da11b735b --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag-to-fill.ts @@ -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(); + + override render() { + // TODO add tooltip + return html`
`; + } + + @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); + } + } + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag.ts new file mode 100644 index 000000000..528390a57 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/drag.ts @@ -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(); + }, + }; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts new file mode 100644 index 000000000..002d364a3 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/hotkeys.ts @@ -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 + ); + } + }, + }) + ); + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts new file mode 100644 index 000000000..73693c1c8 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/controller/selection.ts @@ -0,0 +1,1115 @@ +import { getRangeByPositions } from '@blocksuite/affine-shared/utils'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; +import { ShadowlessElement } from '@blocksuite/std'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import type { ReactiveController } from 'lit'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { autoScrollOnBoundary } from '../../../../core/utils/auto-scroll.js'; +import { startDrag } from '../../../../core/utils/drag.js'; +import { + type MultiSelection, + RowWithGroup, + TableViewAreaSelection, + TableViewRowSelection, + type TableViewSelection, + type TableViewSelectionWithType, +} from '../../selection'; +import type { DatabaseCellContainer } from '../row/cell'; +import type { VirtualTableView } from '../table-view.js'; +import type { TableGridCell } from '../types.js'; +import { + DragToFillElement, + fillSelectionWithFocusCellData, +} from './drag-to-fill.js'; + +export class TableSelectionController implements ReactiveController { + private _tableViewSelection?: TableViewSelectionWithType; + + private readonly getFocusCellContainer = () => { + if ( + !this._tableViewSelection || + this._tableViewSelection.selectionType !== 'area' + ) + return null; + const { groupKey, focus } = this._tableViewSelection; + + const dragStartCell = this.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + return dragStartCell ?? null; + }; + + __dragToFillElement = new DragToFillElement(); + + __selectionElement; + + selectionStyleUpdateTask = 0; + + // private get areaSelectionElement() { + // return this.__selectionElement; + // } + + get dragToFillDraggable() { + return this.__dragToFillElement.dragToFillRef.value; + } + + // private get focusSelectionElement() { + // return this.__selectionElement; + // } + + get selection(): TableViewSelectionWithType | undefined { + return this._tableViewSelection; + } + + set selection(data: TableViewSelection | undefined) { + if (!data) { + this.clearSelection(); + return; + } + const selection: TableViewSelectionWithType = { + ...data, + viewId: this.view.id, + type: 'table', + }; + if (selection.selectionType === 'area' && selection.isEditing) { + const focus = selection.focus; + const container = this.getCellContainer( + selection.groupKey, + focus.rowIndex, + focus.columnIndex + ); + const cell = container?.cell; + const isEditing = cell ? cell.beforeEnterEditMode() : true; + this.host.props.setSelection({ + ...selection, + isEditing, + }); + } else { + this.host.props.setSelection(selection); + } + } + + get tableContainer() { + return this.virtualScroll?.content.parentElement; + } + + get view() { + return this.host.props.view; + } + + get viewData() { + return this.view; + } + + constructor(public host: VirtualTableView) { + host.addController(this); + this.__selectionElement = new SelectionElement(); + this.__selectionElement.controller = this; + } + + private clearSelection() { + this.host.props.setSelection(); + } + + private handleDragEvent() { + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + if (this.host.props.view.readonly$.value) { + return; + } + const event = context.get('pointerState').raw; + const target = event.target; + if (target instanceof HTMLElement) { + const [cell, fillValues] = this.resolveDragStartTarget(target); + + if (cell) { + const selection = this.selection; + if ( + selection && + selection.selectionType === 'area' && + selection.isEditing && + selection.focus.rowIndex === cell.rowIndex$.value && + selection.focus.columnIndex === cell.columnIndex$.value + ) { + return false; + } + this.startDrag(event, cell, fillValues); + event.preventDefault(); + return true; + } + return false; + } + return false; + }) + ); + } + + private handleSelectionChange() { + this.host.disposables.add( + this.host.props.selection$.subscribe(tableSelection => { + if (!this.isValidSelection(tableSelection)) { + this.selection = undefined; + return; + } + const old = + this._tableViewSelection?.selectionType === 'area' + ? this._tableViewSelection + : undefined; + const newSelection = + tableSelection?.selectionType === 'area' ? tableSelection : undefined; + if ( + old?.focus.rowIndex !== newSelection?.focus.rowIndex || + old?.focus.columnIndex !== newSelection?.focus.columnIndex + ) { + requestAnimationFrame(() => { + this.scrollToFocus(); + }); + } + + if ( + this.isRowSelection() && + (old?.rowsSelection?.start !== newSelection?.rowsSelection?.start || + old?.rowsSelection?.end !== newSelection?.rowsSelection?.end) + ) { + requestAnimationFrame(() => { + this.scrollToAreaSelection(); + }); + } + + if (old) { + const container = this.getCellContainer( + old.groupKey, + old.focus.rowIndex, + old.focus.columnIndex + ); + if (container) { + const cell = container.cell; + if (old.isEditing) { + cell?.beforeExitEditingMode(); + cell?.blurCell(); + container.isEditing$.value = false; + } + } + } + this._tableViewSelection = tableSelection; + + if (newSelection) { + const container = this.getCellContainer( + newSelection.groupKey, + newSelection.focus.rowIndex, + newSelection.focus.columnIndex + ); + if (container) { + const cell = container.cell; + if (newSelection.isEditing) { + container.isEditing$.value = true; + requestAnimationFrame(() => { + cell?.afterEnterEditingMode(); + cell?.focusCell(); + }); + } + } + } + }) + ); + } + + private insertTo( + groupKey: string | undefined, + rowId: string, + before: boolean + ) { + const id = this.view.rowAdd({ before, id: rowId }); + if (groupKey != null) { + this.view.groupTrait.moveCardTo(id, undefined, groupKey, { + before, + id: rowId, + }); + } + const rows = + groupKey != null + ? this.view.groupTrait.groupDataMap$.value?.[groupKey]?.rows + : this.view.rows$.value; + requestAnimationFrame(() => { + const index = this.host.props.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + this.selection = TableViewAreaSelection.create({ + groupKey: groupKey, + focus: { + rowIndex: rows?.findIndex(v => v === id) ?? 0, + columnIndex: index, + }, + isEditing: true, + }); + }); + } + + private resolveDragStartTarget( + target: HTMLElement + ): [cell: DatabaseCellContainer | null, fillValues: boolean] { + let cell: DatabaseCellContainer | null; + const fillValues = !!target.dataset.dragToFill; + if (fillValues) { + const focusCellContainer = this.getFocusCellContainer(); + cell = focusCellContainer ?? null; + } else { + cell = target.closest('affine-database-virtual-cell-container'); + } + return [cell, fillValues]; + } + + private scrollToAreaSelection() { + // this.areaSelectionElement?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // }); + } + + private scrollToFocus() { + // this.focusSelectionElement?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // }); + } + + areaToRows(selection: TableViewAreaSelection) { + const rows = this.rows(selection.groupKey ?? '') ?? []; + const ids = Array.from({ + length: selection.rowsSelection.end - selection.rowsSelection.start + 1, + }) + .map((_, index) => index + selection.rowsSelection.start) + .map(row => rows[row]?.rowId) + .filter((id): id is string => id != null); + return ids.map(id => ({ id, groupKey: selection.groupKey })); + } + + readonly columnPositions$ = computed(() => { + const columnPositions = this.virtualScroll?.columnPosition$.value?.slice(1); + if (columnPositions == null) { + return []; + } + return columnPositions; + }); + + readonly columnOffsets$ = computed(() => { + const columnPositions = this.columnPositions$.value; + if (columnPositions == null) { + return []; + } + const firstLeft = columnPositions[0]?.left; + if (firstLeft == null) { + return []; + } + const rights = columnPositions.map(v => v.right); + if (rights == null) { + return []; + } + return [firstLeft, ...rights]; + }); + + readonly groupRowOffsets$ = computed< + Record> + >(() => { + return Object.fromEntries( + this.virtualScroll?.groups$.value.map(group => { + return [ + group.groupId, + computed(() => { + const firstTop = group.rowsTop$.value; + if (firstTop == null) { + return []; + } + const offsets: number[] = [firstTop]; + for (const row of group.rows$.value) { + if (row.bottom$.value == null) { + break; + } + offsets.push(row.bottom$.value); + } + return offsets; + }), + ]; + }) ?? [] + ); + }); + + cellPosition(groupKey: string | undefined) { + const containerRect = this.virtualScroll?.content.getBoundingClientRect(); + if (containerRect == null) { + return; + } + return (x1: number, x2: number, y1: number, y2: number) => { + x1 = x1 - containerRect.left; + x2 = x2 - containerRect.left; + y1 = y1 - containerRect.top; + y2 = y2 - containerRect.top; + const rowOffsets = this.groupRowOffsets$.value[groupKey ?? '']; + if (rowOffsets == null) { + return; + } + const [startX, endX] = x1 < x2 ? [x1, x2] : [x2, x1]; + const [startY, endY] = y1 < y2 ? [y1, y2] : [y2, y1]; + const column = getRangeByPositions( + this.columnOffsets$.value, + startX, + endX + ); + const row = getRangeByPositions(rowOffsets.value, startY, endY); + return { + row, + column, + }; + }; + } + + clear() { + this.selection = undefined; + } + + deleteRow(rowId: string) { + this.view.rowDelete([rowId]); + this.focusToCell('up'); + } + + focusFirstCell() { + this.selection = TableViewAreaSelection.create({ + focus: { + rowIndex: 0, + columnIndex: 0, + }, + isEditing: false, + }); + } + + focusToArea(selection: TableViewAreaSelection) { + return { + ...selection, + rowsSelection: selection.rowsSelection ?? { + start: selection.focus.rowIndex, + end: selection.focus.rowIndex, + }, + columnsSelection: selection.columnsSelection ?? { + start: selection.focus.columnIndex, + end: selection.focus.columnIndex, + }, + isEditing: false, + } satisfies TableViewAreaSelection; + } + + focusToCell(position: 'left' | 'right' | 'up' | 'down') { + if (!this.selection || this.selection.selectionType !== 'area') { + return; + } + const cell = this.getCellContainer( + this.selection.groupKey, + this.selection.focus.rowIndex, + this.selection.focus.columnIndex + ); + if (!cell) { + return; + } + const row = cell.gridCell.row; + const rows = row.group.rows$.value; + let rowIndex = row.rowIndex$.value; + let columnIndex = cell.columnIndex$.value; + const columns = this.columnPositions$.value; + if (position === 'left') { + if (columnIndex === 0) { + columnIndex = columns.length - 1; + rowIndex--; + } else { + columnIndex--; + } + } + if (position === 'right') { + if (columnIndex === columns.length - 1) { + columnIndex = 0; + rowIndex++; + } else { + columnIndex++; + } + } + if (position === 'up') { + if (rowIndex === 0) { + // + } else { + rowIndex--; + } + } + if (position === 'down') { + if (rowIndex === rows.length - 1) { + // + } else { + rowIndex++; + } + } + const cellContainer = this.getCellContainer( + this.selection.groupKey, + rowIndex, + columnIndex + ); + if (!cellContainer) { + return; + } + cellContainer.selectCurrentCell(false); + } + + getCellElement(cell: TableGridCell): DatabaseCellContainer | undefined { + return ( + cell.element.querySelector('affine-database-virtual-cell-container') ?? + undefined + ); + } + + getCellByIndex( + groupKey: string | undefined, + rowIndex: number, + columnIndex: number + ): TableGridCell | undefined { + const row = this.rows(groupKey)?.[rowIndex]; + if (!row) { + return; + } + const cell = row.cells$.value[columnIndex + 1]; + return cell; + } + + getCellContainer( + groupKey: string | undefined, + rowIndex: number, + columnIndex: number + ): DatabaseCellContainer | undefined { + const cell = this.getCellByIndex(groupKey, rowIndex, columnIndex); + if (!cell) { + return; + } + return this.getCellElement(cell); + } + + getRect( + groupKey: string | undefined, + topIndex: number, + bottomIndex: number, + leftIndex: number, + rightIndex: number + ): + | undefined + | { + top: number; + left: number; + width: number; + height: number; + scale: number; + } { + const rows = this.rows(groupKey); + const top = rows?.[topIndex]?.top$.value; + const bottom = rows?.[bottomIndex]?.bottom$.value; + if (top == null || bottom == null) { + return; + } + const left = this.columnPositions$.value[leftIndex]?.left; + const right = this.columnPositions$.value[rightIndex]?.right; + if (left == null || right == null) { + return; + } + return { + top, + left, + width: right - left, + height: bottom - top, + scale: 1, + }; + } + + getSelectionAreaBorder(position: 'left' | 'right' | 'top' | 'bottom') { + return this.__selectionElement?.querySelector( + `.area-border.area-${position}` + ); + } + + hostConnected() { + requestAnimationFrame(() => { + this.tableContainer?.append(this.__selectionElement); + this.tableContainer?.append(this.__dragToFillElement); + }); + this.handleDragEvent(); + this.handleSelectionChange(); + } + + insertRowAfter(groupKey: string | undefined, rowId: string) { + this.insertTo(groupKey, rowId, false); + } + + insertRowBefore(groupKey: string | undefined, rowId: string) { + this.insertTo(groupKey, rowId, true); + } + + isRowSelection() { + return this.selection?.selectionType === 'row'; + } + + isValidSelection(selection?: TableViewSelectionWithType): boolean { + if (!selection || selection.selectionType === 'row') { + return true; + } + if (selection.focus.rowIndex > this.view.rows$.value.length - 1) { + this.selection = undefined; + return false; + } + if (selection.focus.columnIndex > this.view.propertyIds$.value.length - 1) { + this.selection = undefined; + return false; + } + return true; + } + + navigateRowSelection(direction: 'up' | 'down', append = false) { + if (!TableViewRowSelection.is(this.selection)) return; + const rows = this.selection.rows; + const lastRow = rows[rows.length - 1]; + if (!lastRow) return; + const lastRowIndex = + this.getGroup(lastRow?.groupKey)?.rows$.value.find( + r => r.rowId === lastRow?.id + )?.rowIndex$.value ?? 0; + const getRowByIndex = (index: number) => { + const tableRow = this.rows(lastRow?.groupKey)?.[index]; + if (!tableRow) { + return; + } + return { + id: tableRow.rowId, + groupKey: lastRow?.groupKey, + }; + }; + const prevRow = getRowByIndex(lastRowIndex - 1); + const nextRow = getRowByIndex(lastRowIndex + 1); + const includes = (row: RowWithGroup) => { + if (!row) { + return false; + } + return rows.some(r => RowWithGroup.equal(r, row)); + }; + if (append) { + const addList: RowWithGroup[] = []; + const removeList: RowWithGroup[] = []; + if (direction === 'up' && prevRow != null) { + if (includes(prevRow)) { + removeList.push(lastRow); + } else { + addList.push(prevRow); + } + } + if (direction === 'down' && nextRow != null) { + if (includes(nextRow)) { + removeList.push(lastRow); + } else { + addList.push(nextRow); + } + } + this.rowSelectionChange({ add: addList, remove: removeList }); + } else { + const target = direction === 'up' ? prevRow : nextRow; + if (target != null) { + this.selection = TableViewRowSelection.create({ + rows: [target], + }); + } + } + } + + get virtualScroll() { + return this.host.virtualScroll$.value; + } + + getGroup(groupKey: string | undefined) { + return this.virtualScroll?.getGroup(groupKey ?? ''); + } + + getRow(groupKey: string | undefined, rowId: string) { + return this.virtualScroll?.getRow(groupKey ?? '', rowId); + } + + getCell(groupKey: string | undefined, rowId: string, columnId: string) { + return this.virtualScroll?.getCell(groupKey ?? '', rowId, columnId); + } + + rows(groupKey: string | undefined) { + return this.virtualScroll?.getGroup(groupKey ?? '').rows$.value; + } + + rowSelectionChange({ + add, + remove, + }: { + add: RowWithGroup[]; + remove: RowWithGroup[]; + }) { + const key = (r: RowWithGroup) => `${r.id}.${r.groupKey ? r.groupKey : ''}`; + const rows = new Set( + TableViewRowSelection.rows(this.selection).map(r => key(r)) + ); + remove.forEach(row => rows.delete(key(row))); + add.forEach(row => rows.add(key(row))); + const result = [...rows] + .map(r => r.split('.')) + .flatMap(([id, groupKey]) => { + if (id == null) return []; + return [ + { + id, + groupKey: groupKey ? groupKey : undefined, + }, + ]; + }); + this.selection = TableViewRowSelection.create({ + rows: result, + }); + } + + rowsToArea(rows: string[]): + | { + start: number; + end: number; + groupKey?: string; + } + | undefined { + let groupKey: string | undefined = undefined; + let minIndex: number | undefined = undefined; + let maxIndex: number | undefined = undefined; + const set = new Set(rows); + if (!this.tableContainer) return; + for (const row of this.tableContainer + ?.querySelectorAll('data-view-table-row') + .values() ?? []) { + if (!set.has(row.rowId)) { + continue; + } + minIndex = + minIndex != null ? Math.min(minIndex, row.rowIndex) : row.rowIndex; + maxIndex = + maxIndex != null ? Math.max(maxIndex, row.rowIndex) : row.rowIndex; + if (groupKey == null) { + groupKey = row.groupKey; + } else if (groupKey !== row.groupKey) { + return; + } + } + if (minIndex == null || maxIndex == null) { + return; + } + return { + groupKey, + start: minIndex, + end: maxIndex, + }; + } + + selectionAreaDown() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.rowsSelection.start === newSelection.focus.rowIndex) { + newSelection.rowsSelection.end = Math.min( + (this.rows(newSelection.groupKey)?.length ?? 0) - 1, + newSelection.rowsSelection.end + 1 + ); + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('bottom')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } else { + newSelection.rowsSelection.start += 1; + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('top')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } + this.selection = newSelection; + } + + selectionAreaLeft() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.columnsSelection.end === newSelection.focus.columnIndex) { + newSelection.columnsSelection.start = Math.max( + 0, + newSelection.columnsSelection.start - 1 + ); + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('left')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } else { + newSelection.columnsSelection.end -= 1; + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('right')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } + this.selection = newSelection; + } + + selectionAreaRight() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if ( + newSelection.columnsSelection.start === newSelection.focus.columnIndex + ) { + const max = (this.virtualScroll?.columns$.value.length ?? 0) - 1; + newSelection.columnsSelection.end = Math.min( + max, + newSelection.columnsSelection.end + 1 + ); + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('right')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } else { + newSelection.columnsSelection.start += 1; + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('left')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } + this.selection = newSelection; + } + + selectionAreaUp() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.rowsSelection.end === newSelection.focus.rowIndex) { + newSelection.rowsSelection.start = Math.max( + 0, + newSelection.rowsSelection.start - 1 + ); + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('top')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } else { + newSelection.rowsSelection.end -= 1; + // requestAnimationFrame(() => { + // this.getSelectionAreaBorder('bottom')?.scrollIntoView({ + // block: 'nearest', + // inline: 'nearest', + // behavior: 'smooth', + // }); + // }); + } + this.selection = newSelection; + } + + startDrag( + evt: PointerEvent, + cell: DatabaseCellContainer, + fillValues?: boolean + ) { + const groupKey = cell.groupKey; + const table = this.tableContainer; + const scrollContainer = table?.parentElement; + if (!table || !scrollContainer) { + return; + } + const tableRect = table.getBoundingClientRect(); + const startOffsetX = evt.x - tableRect.left; + const startOffsetY = evt.y - tableRect.top; + const offsetToSelection = this.cellPosition(groupKey); + const select = (selection: { + row: MultiSelection; + column: MultiSelection; + }) => { + this.selection = TableViewAreaSelection.create({ + groupKey: groupKey, + rowsSelection: selection.row, + columnsSelection: selection.column, + focus: { + rowIndex: cell.rowIndex$.value, + columnIndex: cell.columnIndex$.value, + }, + isEditing: false, + }); + }; + const drag = startDrag< + | { + row: MultiSelection; + column: MultiSelection; + } + | undefined, + { + x: number; + y: number; + } + >(evt, { + transform: evt => ({ + x: evt.x, + y: evt.y, + }), + onDrag: () => { + if (fillValues) this.__dragToFillElement.dragging = true; + return undefined; + }, + onMove: ({ x, y }) => { + if (!table) return; + const tableRect = table.getBoundingClientRect(); + const startX = tableRect.left + startOffsetX; + const startY = tableRect.top + startOffsetY; + const selection = offsetToSelection?.(startX, x, startY, y); + if (!selection) { + return; + } + if (fillValues) + selection.column = { + start: cell.columnIndex$.value, + end: cell.columnIndex$.value, + }; + + select(selection); + return selection; + }, + onDrop: selection => { + if (!selection) { + return; + } + select(selection); + if (fillValues && this.selection) { + this.__dragToFillElement.dragging = false; + fillSelectionWithFocusCellData( + this.host, + TableViewAreaSelection.create({ + groupKey: groupKey, + rowsSelection: selection.row, + columnsSelection: selection.column, + focus: { + rowIndex: cell.rowIndex$.value, + columnIndex: cell.columnIndex$.value, + }, + isEditing: false, + }) + ); + } + }, + onClear: () => { + cancelScroll(); + }, + }); + const cancelScroll = autoScrollOnBoundary( + scrollContainer, + computed(() => { + return { + left: drag.mousePosition.value.x, + right: drag.mousePosition.value.x, + top: drag.mousePosition.value.y, + bottom: drag.mousePosition.value.y, + }; + }), + { + onScroll() { + drag.move({ x: drag.last.x, y: drag.last.y }); + }, + } + ); + } + + toggleRow(rowId: string, groupKey?: string) { + const row = { + id: rowId, + groupKey, + }; + const isSelected = TableViewRowSelection.includes(this.selection, row); + this.rowSelectionChange({ + add: isSelected ? [] : [row], + remove: isSelected ? [row] : [], + }); + } +} + +export class SelectionElement extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + data-view-virtual-table-selection { + display: block; + position: absolute; + left: 0; + top: 0; + } + .database-selection { + position: absolute; + z-index: 2; + box-sizing: border-box; + background-color: var(--affine-primary-color-04); + pointer-events: none; + display: none; + } + + .database-focus { + position: absolute; + z-index: 2; + box-sizing: border-box; + border: 1px solid var(--affine-primary-color); + border-radius: 2px; + pointer-events: none; + outline: none; + display: none; + } + `; + + get virtualScroll() { + return this.controller.virtualScroll; + } + + focusPosition$ = computed(() => { + const selection = this.selection$.value; + if (selection?.selectionType !== 'area') { + return; + } + const focus = selection.focus; + const groupKey = selection.groupKey; + const group = this.controller.getGroup(groupKey); + if (!group) { + return; + } + const row = group.rows$.value[focus.rowIndex]; + if (!row) { + return; + } + const columnPosition = + this.controller.columnPositions$.value[focus.columnIndex]; + if (!columnPosition) { + return; + } + const left = columnPosition.left; + const top = row.top$.value; + const width = columnPosition.width; + const height = row.height$.value; + if (left == null || top == null || width == null || height == null) { + return; + } + const paddingLeft = this.controller.host.props.virtualPadding$.value; + return { + left: left + paddingLeft, + top, + width, + height, + editing: selection.isEditing, + }; + }); + + areaPosition$ = computed(() => { + const selection = this.selection$.value; + if (selection?.selectionType !== 'area') { + return; + } + const groupKey = selection.groupKey; + const group = this.controller.getGroup(groupKey); + if (!group) { + return; + } + const rect = this.controller.getRect( + groupKey, + selection.rowsSelection.start, + selection.rowsSelection.end, + selection.columnsSelection.start, + selection.columnsSelection.end + ); + if (!rect) { + return; + } + const paddingLeft = this.controller.host.props.virtualPadding$.value; + return { + left: rect.left + paddingLeft, + top: rect.top, + width: rect.width, + height: rect.height, + }; + }); + + rowsPosition$ = computed(() => { + const selection = this.selection$.value; + if (selection?.selectionType !== 'area') { + return; + } + }); + + get selection$() { + return this.controller.host.props.selection$; + } + + override render() { + const focus = this.focusPosition$.value; + const focusStyle = focus + ? styleMap({ + left: `${focus.left}px`, + top: `${focus.top}px`, + width: `${focus.width}px`, + height: `${focus.height}px`, + display: 'block', + boxShadow: focus.editing + ? '0px 0px 0px 2px rgba(30, 150, 235, 0.30)' + : 'unset', + }) + : undefined; + const area = this.areaPosition$.value; + const areaStyle = area + ? styleMap({ + left: `${area.left}px`, + top: `${area.top}px`, + width: `${area.width}px`, + height: `${area.height}px`, + display: 'block', + }) + : undefined; + return html` +
+
+ `; + } + + @property({ attribute: false }) + accessor controller!: TableSelectionController; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-virtual-table-selection': SelectionElement; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/effect.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/effect.ts new file mode 100644 index 000000000..d144fd3cb --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/effect.ts @@ -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); +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.css.ts new file mode 100644 index 000000000..c47e9a39b --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.css.ts @@ -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, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts new file mode 100644 index 000000000..2f4263d1a --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/group-footer.ts @@ -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` +
+
+ ${PlusIcon()}New Record +
+
+ `} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'virtual-table-group-footer': TableGroupFooter; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts new file mode 100644 index 000000000..bfe156218 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts @@ -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``; + } + )} + `; + } + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; + + @property({ attribute: false }) + accessor view!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-virtual-column-stats': VirtualDataBaseColumnStats; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts new file mode 100644 index 000000000..7c20456d8 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts @@ -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> = {}; + + 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([]); + + 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 void>(); + + override connectedCallback(): void { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', this.openMenu); + this.disposables.add( + this.cellValues$.subscribe(values => { + const map = new Map 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`
+
+ ${!this.statsResult$.value + ? html`Calculate ${ArrowDownSmallIcon()}` + : html` + ${this.statsResult$.value.name} + ${this.statsResult$.value.value} + `} +
+
`; + } + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-virtual-column-stats-cell': VirtualDatabaseColumnStatsCell; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.css.ts new file mode 100644 index 000000000..4168c0c40 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css'; + +export const groupHeader = style({ + display: 'block', +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts new file mode 100644 index 000000000..cd87d89ed --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-header.ts @@ -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` +
+ ${GroupTitle(group, { + groupHover: this.gridGroup.data.headerHover$.value, + readonly: this.tableViewManager.readonly$.value, + clickAdd: this.clickAddRowInStart, + clickOps: this.clickGroupOptions, + })} +
+ `; + }; + + override render() { + return html` + ${this.renderGroupHeader()} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'virtual-table-group-header': TableGroupHeader; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.css.ts new file mode 100644 index 000000000..32513f601 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.css.ts @@ -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', +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts new file mode 100644 index 000000000..e86cb4188 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts @@ -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`
${cards.length}
`; +} + +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` `; + 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`${groupData.property.name$.value}` + : nothing; + const opsClass = clsx(ops.groupHover && show, groupHeaderOps); + return html` +
+ ${icon} ${renderUniLit(data.view, props)} ${columnName} + ${GroupHeaderCount(groupData)} +
+ ${!ops.readonly + ? html`
+
+ ${PlusIcon()} +
+
+ ${MoreHorizontalIcon()} +
+
` + : nothing} + `; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.css.ts new file mode 100644 index 000000000..c8b452afc --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.css.ts @@ -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)', +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.ts new file mode 100644 index 000000000..2bbc1ae35 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-header.ts @@ -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` +
+ ${this.readonly + ? nothing + : html`
`} + ${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` + +
+ `; + } + )} +
+ ${PlusIcon()} +
+
+
+ `; + } + + @query('.scale-div') + accessor scaleDiv!: HTMLDivElement; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'virtual-table-header': VirtualTableHeader; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts new file mode 100644 index 000000000..918fbca03 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts @@ -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` +
+ + ${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`
+
+ +
+
`; + })} +
+
+ `; + } + + 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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/number-format-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/number-format-bar.ts new file mode 100644 index 000000000..d6e1b0dbd --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/number-format-bar.ts @@ -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` + + + +`; + +const DecreaseDecimalPlacesIcon = html` + + + +`; + +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` +
+
+ + + + + + ( ${formatNumber( + 1, + 'number', + (this.column.data$.value?.decimal as number) ?? 0 + )} ) + + +
+
+
+ `; + } + + @property({ attribute: false }) + accessor column!: Property; +} + +declare global { + interface HTMLElementTagNameMap { + 'virtual-database-number-format-bar': DatabaseNumberFormatBar; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts new file mode 100644 index 000000000..beb1957dd --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts @@ -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`${format.symbol}`, + 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` +
+ ${this.readonly + ? null + : html` `} +
+
+ +
+
+
+ ${column.name$.value} +
+
+
+
+
+
+
+ `; + } + + @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` `; +} + +declare global { + interface HTMLElementTagNameMap { + 'virtual-database-header-column': DatabaseHeaderColumn; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/vertical-indicator.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/vertical-indicator.ts new file mode 100644 index 000000000..b7ec06cdf --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/vertical-indicator.ts @@ -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`
`; + } + + @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; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/cell.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/cell.ts new file mode 100644 index 000000000..43944be14 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/cell.ts @@ -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(); + + 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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts new file mode 100644 index 000000000..7c49ff011 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/menu.ts @@ -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`
+ ${CopyIcon()} +
`, + 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`
+ ${MoveLeftIcon()} +
`, + select: () => { + selectionController.insertRowBefore(row.groupKey, row.id); + }, + }), + menu.action({ + name: 'Insert After', + prefix: html`
+ ${MoveRightIcon()} +
`, + 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); + }, + }), + ], + }), + ]); +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.css.ts new file mode 100644 index 000000000..d55916584 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.css.ts @@ -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, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.ts new file mode 100644 index 000000000..09d085dfc --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-header.ts @@ -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` +
+
+
+
+
+ `; + }; + + 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` +
+
+ ${this.rowSelected$.value + ? CheckBoxCheckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon()} +
+
+ `; + }; + + protected override render() { + const view = this.view; + + if (view.readonly$.value) { + return nothing; + } + + return html` +
+ ${this.renderDragHandle()} ${this.renderCheckbox()} +
+ `; + } + + 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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-last.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-last.ts new file mode 100644 index 000000000..a021cfc26 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/row/row-last.ts @@ -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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.css.ts new file mode 100644 index 000000000..912d50adf --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.css.ts @@ -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`, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.ts new file mode 100644 index 000000000..7099f59e6 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/table-view.ts @@ -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`
+
+
${AddCursorIcon()}
+
New Group
+
+
`; + }; + + 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(); + 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, + })} +
+
+
+ ${this.renderTable()} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-virtual-table': VirtualTableView; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/types.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/types.ts new file mode 100644 index 000000000..7411d4bf8 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/types.ts @@ -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; + headerHover$: Signal; + footerHover$: Signal; +}; + +export type TableRowData = { + hover$: ReadonlySignal; + selected$: ReadonlySignal; +}; + +export type TableCellData = { + hover$: Signal; +}; + +export type TableGrid = GridVirtualScroll< + TableGroupData, + TableRowData, + TableCellData +>; +export type TableGridGroup = GridGroup< + TableGroupData, + TableRowData, + TableCellData +>; +export type TableGridRow = GridRow; +export type TableGridCell = GridCell< + TableGroupData, + TableRowData, + TableCellData +>; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/batch-task-manager.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/batch-task-manager.ts new file mode 100644 index 000000000..c3d8f4d04 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/batch-task-manager.ts @@ -0,0 +1,119 @@ +import { LinkedList, type LinkedListNode } from './linked-list'; + +type Task = () => false | void; + +export class TaskNode { + private _priority?: number; + private _linkedListNode?: LinkedListNode; + 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[] = []; + + constructor( + private readonly batchSizes: number[], + private readonly totalBatchSize: number + ) { + this.queues = batchSizes.map(() => new LinkedList()); + } + + 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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/linked-list.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/linked-list.ts new file mode 100644 index 000000000..1d57e8623 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/linked-list.ts @@ -0,0 +1,169 @@ +export interface LinkedListNode { + value: T; + prev: LinkedListNode | null; + next: LinkedListNode | null; + active: boolean; + remove: () => void; +} + +export class LinkedList { + head: LinkedListNode | null = null; + tail: LinkedListNode | null = null; + size = 0; + + append(value: T): LinkedListNode { + const node: LinkedListNode = { + 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 { + const node: LinkedListNode = { + 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): 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 | null { + if (!this.head) return null; + + const removedNode = this.head; + this.remove(removedNode); + return removedNode; + } + + pop(): LinkedListNode | null { + if (!this.tail) return null; + + const removedNode = this.tail; + this.remove(removedNode); + return removedNode; + } + + forEach( + callback: (value: T, node: LinkedListNode, 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, 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, index: number) => boolean + ): LinkedListNode | 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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.css.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.css.ts new file mode 100644 index 000000000..b2290198c --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.css.ts @@ -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%', +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.ts new file mode 100644 index 000000000..655cdd890 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-cell.ts @@ -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; + top$: ReadonlySignal; + width$: ReadonlySignal; + height$: ReadonlySignal; + }; + + @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; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-scroll.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-scroll.ts new file mode 100644 index 000000000..559c79af0 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/virtual/virtual-scroll.ts @@ -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 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 { + constructor(readonly keyToString: (key: K) => string) {} + protected readonly cache = new Map(); + + 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): 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 extends GridNode { + readonly renderTask; + readonly element; + readonly columnIndex$ = computed(() => { + return this.row.grid.columns$.value.findIndex( + column => column.id === this.columnId + ); + }); + + private readonly realHeight$ = signal(); + 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, + readonly columnId: string, + createElement: ( + cell: GridCell, + wrapper: VirtualElementWrapper + ) => HTMLElement, + initCellData: (cell: GridCell) => 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 extends GridNode { + 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 = 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, + readonly rowId: string, + initRowData: (row: GridRow) => RowData + ) { + super(() => initRowData(this)); + } + + override dispose() { + super.dispose(); + } +} +export class GroupNode extends NodeLifeCycle { + readonly renderTask; + readonly height$ = signal(); + 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, + public readonly top$: ReadonlySignal, + content: ( + group: GridGroup, + wrapper: VirtualElementWrapper + ) => HTMLElement, + readonly visibleCheck: ( + node: GroupNode + ) => 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 { + top$: ReadonlySignal = 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 = 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, + readonly groupId: string, + readonly topElement: ( + group: GridGroup, + wrapper: VirtualElementWrapper + ) => HTMLElement, + readonly bottomElement: ( + group: GridGroup, + wrapper: VirtualElementWrapper + ) => HTMLElement, + initGroupData: (group: GridGroup) => GroupData + ) { + super(() => initGroupData(this)); + } + + override dispose() { + super.dispose(); + } +} + +export interface GridGroupData { + id: string; + rows: string[]; +} + +export interface GridVirtualScrollOptions + extends VirtualScrollOptions { + initGroupData: (group: GridGroup) => GroupData; + initRowData: (row: GridRow) => RowData; + initCellData: (cell: GridCell) => CellData; + columns$: ReadonlySignal< + { + id: string; + width: number; + }[] + >; + fixedRowHeight$: ReadonlySignal; + createGroup: { + top: ( + group: GridGroup, + wrapper: VirtualElementWrapper + ) => HTMLElement; + bottom: ( + group: GridGroup, + wrapper: VirtualElementWrapper + ) => HTMLElement; + }; + createCell: ( + cell: GridCell, + wrapper: VirtualElementWrapper + ) => HTMLElement; + groups$: ReadonlySignal; +} + +export class GridVirtualScroll< + GroupData, + RowData, + CellData, +> extends VirtualScroll { + readonly cellsCache = new CacheManager< + { groupId: string; columnId: string; rowId: string }, + GridCell + >(cell => `${cell.groupId}-${cell.rowId}-${cell.columnId}`); + readonly rowsCache = new CacheManager< + { groupId: string; rowId: string }, + GridRow + >(row => `${row.groupId}-${row.rowId}`); + readonly groupsCache = new CacheManager< + string, + GridGroup + >(groupId => groupId); + + readonly groups$ = computed(() => { + return this.options.groups$.value.map(group => { + return this.getOrCreateGroup(group.id); + }); + }); + + constructor( + readonly options: GridVirtualScrollOptions + ) { + super(options); + } + + getOrCreateRow( + group: GridGroup, + rowId: string + ): GridRow { + 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, + columnId: string + ): GridCell { + 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 { + 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(); + const activeRowIds = new Set(); + const activeCellIds = new Set(); + + 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; + 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) { + 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() {} +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/effect.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/effect.ts new file mode 100644 index 000000000..f6142858f --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/effect.ts @@ -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 + ); +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/renderer.ts b/blocksuite/affine/data-view/src/view-presets/table/renderer.ts index 01fcd9d09..68b845a16 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/renderer.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/renderer.ts @@ -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'), }); diff --git a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts index 36e5c92a1..8fe1f356f 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts @@ -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` -
- ${repeat( - cols, - col => col.id, - col => { - return html``; - } - )} -
+ ${repeat( + cols, + col => col.id, + col => { + return html``; + } + )} `; } diff --git a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts index 9052ba463..9214f80b3 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts @@ -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; } diff --git a/blocksuite/affine/data-view/src/view-presets/table/table-view-selector.ts b/blocksuite/affine/data-view/src/view-presets/table/table-view-selector.ts new file mode 100644 index 000000000..04050c08f --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/table-view-selector.ts @@ -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(); + + // @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``; + } + + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-table-selector': TableViewSelector; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/effect.ts b/blocksuite/affine/data-view/src/widget-presets/effect.ts new file mode 100644 index 000000000..a210fb1c9 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/effect.ts @@ -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); +} diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index ab03fe721..b7b0a8f23 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -18,6 +18,7 @@ export interface BlockSuiteFlags { enable_block_meta: boolean; enable_callout: boolean; enable_edgeless_scribbled_style: boolean; + enable_table_virtual_scroll: boolean; enable_embed_doc_with_alias: boolean; enable_turbo_renderer: boolean; enable_citation: boolean; @@ -43,6 +44,7 @@ export class FeatureFlagService extends StoreExtension { enable_block_meta: true, enable_callout: false, enable_edgeless_scribbled_style: false, + enable_table_virtual_scroll: false, enable_embed_doc_with_alias: false, enable_turbo_renderer: false, enable_citation: false, diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts index ce0516b9a..9bdfca573 100644 --- a/packages/frontend/core/src/modules/feature-flag/constant.ts +++ b/packages/frontend/core/src/modules/feature-flag/constant.ts @@ -26,15 +26,6 @@ export const AFFINE_FLAGS = { configurable: false, defaultState: true, }, - enable_block_query: { - category: 'blocksuite', - bsFlag: 'enable_block_query', - displayName: - 'com.affine.settings.workspace.experimental-features.enable-block-query.name', - description: - 'com.affine.settings.workspace.experimental-features.enable-block-query.description', - configurable: isCanaryBuild, - }, enable_edgeless_text: { category: 'blocksuite', bsFlag: 'enable_edgeless_text', @@ -259,6 +250,16 @@ export const AFFINE_FLAGS = { configurable: isCanaryBuild, defaultState: false, }, + enable_table_virtual_scroll: { + category: 'blocksuite', + bsFlag: 'enable_table_virtual_scroll', + displayName: + 'com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.name', + description: + 'com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.description', + configurable: isCanaryBuild, + defaultState: false, + }, enable_meetings: { category: 'affine', displayName: diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 6c275a1f8..3e79c576a 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -5761,6 +5761,14 @@ export function useAFFiNEI18N(): { * `Once enabled, you can use scribbled style in edgeless mode.` */ ["com.affine.settings.workspace.experimental-features.enable-edgeless-scribbled-style.description"](): string; + /** + * `Database block table view virtual scroll` + */ + ["com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.name"](): string; + /** + * `Once enabled, switch table view to virtual scroll mode in Database Block.` + */ + ["com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.description"](): string; /** * `Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 19e115cdf..1a8859061 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1438,6 +1438,8 @@ "com.affine.settings.workspace.experimental-features.enable-editor-rtl.description": "Once enabled, the editor will be displayed in RTL mode.", "com.affine.settings.workspace.experimental-features.enable-edgeless-scribbled-style.name": "Edgeless scribbled style", "com.affine.settings.workspace.experimental-features.enable-edgeless-scribbled-style.description": "Once enabled, you can use scribbled style in edgeless mode.", + "com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.name": "Database block table view virtual scroll", + "com.affine.settings.workspace.experimental-features.enable-table-virtual-scroll.description": "Once enabled, switch table view to virtual scroll mode in Database Block.", "com.affine.settings.workspace.not-owner": "Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.", "com.affine.settings.workspace.preferences": "Preference", "com.affine.settings.workspace.billing": "Team's Billing", diff --git a/yarn.lock b/yarn.lock index d17a20d4c..3e5aa460c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4113,6 +4113,7 @@ __metadata: "@toeverything/theme": "npm:^1.1.12" "@types/lodash-es": "npm:^4.17.12" "@vanilla-extract/css": "npm:^1.17.0" + clsx: "npm:^2.1.1" date-fns: "npm:^4.0.0" lit: "npm:^3.2.0" lodash-es: "npm:^4.17.21"