chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions

View File

@@ -0,0 +1,10 @@
/** column default width */
export const DEFAULT_COLUMN_WIDTH = 180;
/** column min width */
export const DEFAULT_COLUMN_MIN_WIDTH = 100;
/** column title height */
export const DEFAULT_COLUMN_TITLE_HEIGHT = 34;
/** column title height */
export const DEFAULT_ADD_BUTTON_WIDTH = 40;
export const LEFT_TOOL_BAR_WIDTH = 24;
export const STATS_BAR_HEIGHT = 34;

View File

@@ -0,0 +1,51 @@
import type { GroupBy, GroupProperty } from '../../core/common/types.js';
import type { FilterGroup } from '../../core/filter/types.js';
import type { Sort } from '../../core/sort/types.js';
import { type BasicViewDataType, viewType } from '../../core/view/data-view.js';
import { TableSingleView } from './table-view-manager.js';
export const tableViewType = viewType('table');
export type TableViewColumn = {
id: string;
width: number;
statCalcType?: string;
hide?: boolean;
};
type DataType = {
columns: TableViewColumn[];
filter: FilterGroup;
groupBy?: GroupBy;
groupProperties?: GroupProperty[];
sort?: Sort;
header?: {
titleColumn?: string;
iconColumn?: string;
imageColumn?: string;
};
};
export type TableViewData = BasicViewDataType<
typeof tableViewType.type,
DataType
>;
export const tableViewModel = tableViewType.createModel<TableViewData>({
defaultName: 'Table View',
dataViewManager: TableSingleView,
defaultData: viewManager => {
return {
mode: 'table',
columns: [],
filter: {
type: 'group',
op: 'and',
conditions: [],
},
header: {
titleColumn: viewManager.dataSource.properties$.value.find(
id => viewManager.dataSource.propertyTypeGet(id) === 'title'
),
iconColumn: 'type',
},
};
},
});

View File

@@ -0,0 +1,4 @@
export * from './define.js';
export * from './pc/table-view.js';
export * from './renderer.js';
export * from './table-view-manager.js';

View File

@@ -0,0 +1,174 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { computed, effect } from '@preact/signals-core';
import { css } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createRef } from 'lit/directives/ref.js';
import {
type CellRenderProps,
type DataViewCellLifeCycle,
renderUniLit,
type SingleView,
} from '../../../core/index.js';
import type { TableColumn } from '../table-view-manager.js';
import { TableAreaSelection } from '../types.js';
export class MobileTableCell extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
mobile-table-cell {
display: flex;
align-items: start;
width: 100%;
height: 100%;
border: none;
outline: none;
}
mobile-table-cell * {
box-sizing: border-box;
}
mobile-table-cell uni-lit > *:first-child {
padding: 6px;
}
`;
private _cell = createRef<DataViewCellLifeCycle>();
@property({ attribute: false })
accessor column!: TableColumn;
@property({ attribute: false })
accessor rowId!: string;
cell$ = computed(() => {
return this.column.cellGet(this.rowId);
});
isEditing$ = computed(() => {
const selection = this.table?.props.selection$.value;
if (selection?.selectionType !== 'area') {
return false;
}
if (selection.groupKey !== this.groupKey) {
return false;
}
if (selection.focus.columnIndex !== this.columnIndex) {
return false;
}
if (selection.focus.rowIndex !== this.rowIndex) {
return false;
}
return selection.isEditing;
});
selectCurrentCell = (editing: boolean) => {
if (this.view.readonly$.value) {
return;
}
const setSelection = this.table?.props.setSelection;
const viewId = this.table?.props.view.id;
if (setSelection && viewId) {
if (editing && this.cell?.beforeEnterEditMode() === false) {
return;
}
setSelection({
viewId,
type: 'table',
...TableAreaSelection.create({
groupKey: this.groupKey,
focus: {
rowIndex: this.rowIndex,
columnIndex: this.columnIndex,
},
isEditing: editing,
}),
});
}
};
get cell(): DataViewCellLifeCycle | undefined {
return this._cell.value;
}
private get groupKey() {
return this.closest('mobile-table-group')?.group?.key;
}
private get readonly() {
return this.column.readonly$.value;
}
private get table() {
return this.closest('mobile-data-view-table');
}
override connectedCallback() {
super.connectedCallback();
if (this.column.readonly$.value) return;
this.disposables.add(
effect(() => {
const isEditing = this.isEditing$.value;
if (isEditing) {
this.isEditing = true;
this._cell.value?.onEnterEditMode();
} else {
this._cell.value?.onExitEditMode();
this.isEditing = false;
}
})
);
this.disposables.addFromEvent(this, 'click', () => {
if (!this.isEditing) {
this.selectCurrentCell(!this.column.readonly$.value);
}
});
}
override render() {
const renderer = this.column.renderer$.value;
if (!renderer) {
return;
}
const { edit, view } = renderer;
const uni = !this.readonly && this.isEditing && edit != null ? edit : view;
this.view.lockRows(this.isEditing);
this.dataset['editing'] = `${this.isEditing}`;
const props: CellRenderProps = {
cell: this.cell$.value,
isEditing: this.isEditing,
selectCurrentCell: this.selectCurrentCell,
};
return renderUniLit(uni, props, {
ref: this._cell,
style: {
display: 'contents',
},
});
}
@property({ attribute: false })
accessor columnId!: string;
@property({ attribute: false })
accessor columnIndex!: number;
@state()
accessor isEditing = false;
@property({ attribute: false })
accessor rowIndex!: number;
@property({ attribute: false })
accessor view!: SingleView;
}
declare global {
interface HTMLElementTagNameMap {
'mobile-table-cell': MobileTableCell;
}
}

View File

@@ -0,0 +1,286 @@
import {
menu,
type MenuConfig,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
DeleteIcon,
DuplicateIcon,
InsertLeftIcon,
InsertRightIcon,
MoveLeftIcon,
MoveRightIcon,
ViewIcon,
} from '@blocksuite/icons/lit';
import { css } from 'lit';
import { property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import { inputConfig, typeConfig } from '../../../core/common/property-menu.js';
import type { Property } from '../../../core/view-manager/property.js';
import type { NumberPropertyDataType } from '../../../property-presets/index.js';
import { numberFormats } from '../../../property-presets/number/utils/formats.js';
import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../consts.js';
import type { TableColumn, TableSingleView } from '../table-view-manager.js';
export class MobileTableColumnHeader extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.mobile-table-column-header {
display: flex;
padding: 6px;
gap: 6px;
align-items: center;
}
.mobile-table-column-header-icon {
font-size: 18px;
color: ${unsafeCSSVarV2('database/textSecondary')};
display: flex;
align-items: center;
}
.mobile-table-column-header-name {
font-weight: 500;
font-size: 14px;
color: ${unsafeCSSVarV2('database/textSecondary')};
}
`;
private _clickColumn = () => {
if (this.tableViewManager.readonly$.value) {
return;
}
this.popMenu();
};
editTitle = () => {
this._clickColumn();
};
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
title: {
text: 'Property settings',
},
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: {
title: {
text: 'Number Format',
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = (
this.column as Property<
number,
NumberPropertyDataType
>
).data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [
menu.action({
name: 'Hide In View',
prefix: ViewIcon(),
hide: () =>
this.column.hide$.value ||
this.column.type$.value === 'title',
select: () => {
this.column.hideSet(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 MobileTableColumnHeader) {
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 MobileTableColumnHeader) {
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.duplicate || this.column.type$.value === 'title',
select: () => {
this.column.duplicate?.();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
hide: () =>
!this.column.delete || this.column.type$.value === 'title',
select: () => {
this.column.delete?.();
},
class: {
'delete-item': true,
},
}),
],
}),
],
},
});
}
override render() {
const column = this.column;
const style = styleMap({
height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px',
});
return html`
<div
style=${style}
class="mobile-table-column-header"
@click="${this._clickColumn}"
>
<uni-lit
class="mobile-table-column-header-icon"
.uni="${column.icon}"
></uni-lit>
<div class="mobile-table-column-header-name">${column.name$.value}</div>
</div>
`;
}
@property({ attribute: false })
accessor column!: TableColumn;
@property({ attribute: false })
accessor tableViewManager!: TableSingleView;
}
function numberFormatConfig(column: Property): MenuConfig {
return () =>
html` <affine-database-number-format-bar
.column="${column}"
></affine-database-number-format-bar>`;
}
declare global {
interface HTMLElementTagNameMap {
'mobile-table-column-header': MobileTableColumnHeader;
}
}

View File

@@ -0,0 +1,200 @@
import {
menu,
popFilterableSimpleMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { DataViewRenderer } from '../../../core/data-view.js';
import { GroupTitle } from '../../../core/group-by/group-title.js';
import type { GroupData } from '../../../core/group-by/trait.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import type { DataViewTable } from '../pc/table-view.js';
import type { TableSingleView } from '../table-view-manager.js';
import { TableAreaSelection } from '../types.js';
const styles = css`
.data-view-table-group-add-row {
display: flex;
width: 100%;
height: 28px;
position: relative;
z-index: 0;
cursor: pointer;
transition: opacity 0.2s ease-in-out;
padding: 4px 8px;
border-bottom: 1px solid var(--affine-border-color);
}
.data-view-table-group-add-row-button {
position: sticky;
left: ${8 + LEFT_TOOL_BAR_WIDTH}px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
user-select: none;
font-size: 12px;
line-height: 20px;
color: var(--affine-text-secondary-color);
}
`;
export class MobileTableGroup extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
private clickAddRow = () => {
this.view.rowAdd('end', this.group?.key);
requestAnimationFrame(() => {
const selectionController = this.viewEle.selectionController;
const index = this.view.properties$.value.findIndex(
v => v.type$.value === 'title'
);
selectionController.selection = TableAreaSelection.create({
groupKey: this.group?.key,
focus: {
rowIndex: this.rows.length - 1,
columnIndex: index,
},
isEditing: true,
});
});
};
private clickAddRowInStart = () => {
this.view.rowAdd('start', this.group?.key);
requestAnimationFrame(() => {
const selectionController = this.viewEle.selectionController;
const index = this.view.properties$.value.findIndex(
v => v.type$.value === 'title'
);
selectionController.selection = TableAreaSelection.create({
groupKey: this.group?.key,
focus: {
rowIndex: 0,
columnIndex: index,
},
isEditing: true,
});
});
};
private clickGroupOptions = (e: MouseEvent) => {
const group = this.group;
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.view.rowDelete(group.rows);
},
}),
]);
};
private renderGroupHeader = () => {
if (!this.group) {
return null;
}
return html`
<div
style="position: sticky;left: 0;width: max-content;padding: 6px 0;margin-bottom: 4px;display:flex;align-items:center;gap: 12px;max-width: 400px"
>
${GroupTitle(this.group, {
readonly: this.view.readonly$.value,
clickAdd: this.clickAddRowInStart,
clickOps: this.clickGroupOptions,
})}
</div>
`;
};
get rows() {
return this.group?.rows ?? this.view.rows$.value;
}
private renderRows(ids: string[]) {
return html`
<mobile-table-header
.renderGroupHeader="${this.renderGroupHeader}"
.tableViewManager="${this.view}"
></mobile-table-header>
<div class="mobile-affine-table-body">
${repeat(
ids,
id => id,
(id, idx) => {
return html` <mobile-table-row
data-row-index="${idx}"
data-row-id="${id}"
.dataViewEle="${this.dataViewEle}"
.view="${this.view}"
.rowId="${id}"
.rowIndex="${idx}"
></mobile-table-row>`;
}
)}
</div>
${this.view.readonly$.value
? null
: html` <div
class="data-view-table-group-add-row dv-hover"
@click="${this.clickAddRow}"
>
<div
class="data-view-table-group-add-row-button dv-icon-16"
data-test-id="affine-database-add-row-button"
role="button"
>
${PlusIcon()}<span style="font-size: 12px">New Record</span>
</div>
</div>`}
<affine-database-column-stats .view="${this.view}" .group="${this.group}">
</affine-database-column-stats>
`;
}
override render() {
return this.renderRows(this.rows);
}
@property({ attribute: false })
accessor dataViewEle!: DataViewRenderer;
@property({ attribute: false })
accessor group: GroupData | undefined = undefined;
@query('.affine-database-block-rows')
accessor rowsContainer: HTMLElement | null = null;
@property({ attribute: false })
accessor view!: TableSingleView;
@property({ attribute: false })
accessor viewEle!: DataViewTable;
}
declare global {
interface HTMLElementTagNameMap {
'mobile-table-group': MobileTableGroup;
}
}

View File

@@ -0,0 +1,89 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import { css, type TemplateResult } 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.js';
export class MobileTableHeader extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.mobile-table-add-column {
font-size: 18px;
color: ${unsafeCSSVarV2('icon/primary')};
margin-left: 8px;
display: flex;
align-items: center;
}
`;
private _onAddColumn = () => {
if (this.readonly) return;
this.tableViewManager.propertyAdd('end');
this.editLastColumnTitle();
};
editLastColumnTitle = () => {
const columns = this.querySelectorAll('mobile-table-column-header');
const column = columns.item(columns.length - 1);
column.editTitle();
};
private get readonly() {
return this.tableViewManager.readonly$.value;
}
override render() {
return html`
${this.renderGroupHeader?.()}
<div class="mobile-table-header mobile-table-row">
${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`
<mobile-table-column-header
style="${style}"
data-column-id="${column.id}"
data-column-index="${index}"
class="mobile-table-cell"
.column="${column}"
.tableViewManager="${this.tableViewManager}"
></mobile-table-column-header>
<div class="cell-divider" style="height: auto;"></div>
`;
}
)}
<div @click="${this._onAddColumn}" class="mobile-table-add-column">
${PlusIcon()}
</div>
<div class="scale-div" style="width: 1px;height: 1px;"></div>
</div>
`;
}
@property({ attribute: false })
accessor renderGroupHeader: (() => TemplateResult) | undefined;
@query('.scale-div')
accessor scaleDiv!: HTMLDivElement;
@property({ attribute: false })
accessor tableViewManager!: TableSingleView;
}
declare global {
interface HTMLElementTagNameMap {
'mobile-table-header': MobileTableHeader;
}
}

View File

@@ -0,0 +1,46 @@
import {
menu,
popFilterableSimpleMenu,
type PopupTarget,
} from '@blocksuite/affine-components/context-menu';
import { DeleteIcon, ExpandFullIcon } from '@blocksuite/icons/lit';
import type { DataViewRenderer } from '../../../core/data-view.js';
import type { SingleView } from '../../../core/index.js';
export const popMobileRowMenu = (
target: PopupTarget,
rowId: string,
dataViewEle: DataViewRenderer,
view: SingleView
) => {
popFilterableSimpleMenu(target, [
menu.group({
items: [
menu.action({
name: 'Expand Row',
prefix: ExpandFullIcon(),
select: () => {
dataViewEle.openDetailPanel({
view: view,
rowId: rowId,
});
},
}),
],
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Delete Row',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
view.rowDelete([rowId]);
},
}),
],
}),
]);
};

View File

@@ -0,0 +1,148 @@
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { css, nothing } 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 { DataViewRenderer } from '../../../core/data-view.js';
import type { TableSingleView } from '../table-view-manager.js';
import { popMobileRowMenu } from './menu.js';
export class MobileTableRow extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.mobile-table-row {
width: 100%;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--affine-border-color);
position: relative;
min-height: 34px;
}
.mobile-row-ops {
position: relative;
width: 0;
margin-top: 5px;
height: max-content;
display: flex;
gap: 4px;
cursor: pointer;
justify-content: end;
right: 8px;
}
.affine-database-block-row:has([data-editing='true']) .mobile-row-ops {
visibility: hidden;
opacity: 0;
}
.mobile-row-op {
display: flex;
padding: 4px;
border-radius: 4px;
box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14);
background-color: var(--affine-background-primary-color);
position: relative;
font-size: 16px;
color: ${unsafeCSSVarV2('icon/primary')};
}
`;
get groupKey() {
return this.closest('affine-data-view-table-group')?.group?.key;
}
override connectedCallback() {
super.connectedCallback();
this.classList.add('mobile-table-row');
}
protected override render(): unknown {
const view = this.view;
return html`
${repeat(
view.properties$.value,
v => v.id,
(column, i) => {
const clickDetail = () => {
this.dataViewEle.openDetailPanel({
view: this.view,
rowId: this.rowId,
});
};
const openMenu = (e: MouseEvent) => {
const ele = e.currentTarget as HTMLElement;
popMobileRowMenu(
popupTargetFromElement(ele),
this.rowId,
this.dataViewEle,
this.view
);
};
return html`
<div style="display: flex;">
<mobile-table-cell
class="mobile-table-cell"
style=${styleMap({
width: `${column.width$.value}px`,
border: i === 0 ? 'none' : undefined,
})}
.view="${view}"
.column="${column}"
.rowId="${this.rowId}"
data-row-id="${this.rowId}"
.rowIndex="${this.rowIndex}"
data-row-index="${this.rowIndex}"
.columnId="${column.id}"
data-column-id="${column.id}"
.columnIndex="${i}"
data-column-index="${i}"
>
</mobile-table-cell>
<div class="cell-divider"></div>
</div>
${!column.readonly$.value &&
column.view.mainProperties$.value.titleColumn === column.id
? html` <div class="mobile-row-ops">
<div class="mobile-row-op" @click="${clickDetail}">
${CenterPeekIcon()}
</div>
${!view.readonly$.value
? html` <div class="mobile-row-op" @click="${openMenu}">
${MoreHorizontalIcon()}
</div>`
: nothing}
</div>`
: nothing}
`;
}
)}
<div class="database-cell add-column-button"></div>
`;
}
@property({ attribute: false })
accessor dataViewEle!: DataViewRenderer;
@property({ attribute: false })
accessor rowId!: string;
@property({ attribute: false })
accessor rowIndex!: number;
@property({ attribute: false })
accessor view!: TableSingleView;
}
declare global {
interface HTMLElementTagNameMap {
'mobile-table-row': MobileTableRow;
}
}

View File

@@ -0,0 +1,209 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { AddCursorIcon } from '@blocksuite/icons/lit';
import { css } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { GroupTrait } from '../../../core/group-by/trait.js';
import type { DataViewInstance } from '../../../core/index.js';
import { renderUniLit } from '../../../core/utils/uni-component/uni-component.js';
import { DataViewBase } from '../../../core/view/data-view-base.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import type { TableSingleView } from '../table-view-manager.js';
import type { TableViewSelectionWithType } from '../types.js';
export class MobileDataViewTable extends DataViewBase<
TableSingleView,
TableViewSelectionWithType
> {
static override styles = css`
.mobile-affine-database-table-wrapper {
position: relative;
width: 100%;
padding-bottom: 4px;
overflow-x: scroll;
overflow-y: hidden;
}
.mobile-affine-database-table-container {
position: relative;
width: fit-content;
min-width: 100%;
}
.cell-divider {
width: 1px;
height: 100%;
background-color: var(--affine-border-color);
}
`;
private _addRow = (
tableViewManager: TableSingleView,
position: InsertToPosition | number
) => {
if (this.readonly) return;
tableViewManager.rowAdd(position);
};
onWheel = (event: WheelEvent) => {
if (event.metaKey || event.ctrlKey) {
return;
}
const ele = event.currentTarget;
if (ele instanceof HTMLElement) {
if (ele.scrollWidth === ele.clientWidth) {
return;
}
event.stopPropagation();
}
};
renderAddGroup = (groupHelper: GroupTrait) => {
const addGroup = groupHelper.addGroup;
if (!addGroup) {
return;
}
const add = (e: MouseEvent) => {
const ele = e.currentTarget as HTMLElement;
popMenu(popupTargetFromElement(ele), {
options: {
items: [
menu.input({
onComplete: text => {
const column = groupHelper.property$.value;
if (column) {
column.dataUpdate(
() =>
addGroup({
text,
oldData: column.data$.value,
dataSource: this.props.view.manager.dataSource,
}) as never
);
}
},
}),
],
},
});
};
return html` <div style="display:flex;">
<div
class="dv-hover dv-round-8"
style="display:flex;align-items:center;gap: 10px;padding: 6px 12px 6px 8px;color: var(--affine-text-secondary-color);font-size: 12px;line-height: 20px;position: sticky;left: ${LEFT_TOOL_BAR_WIDTH}px;"
@click="${add}"
>
<div class="dv-icon-16" style="display:flex;">${AddCursorIcon()}</div>
<div>New Group</div>
</div>
</div>`;
};
get expose(): DataViewInstance {
return {
clearSelection: () => {},
addRow: position => {
this._addRow(this.props.view, position);
},
focusFirstCell: () => {},
showIndicator: _evt => {
return false;
},
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: () => {
throw new BlockSuiteError(
ErrorCode.DatabaseBlockError,
'Not implemented'
);
},
view: this.props.view,
eventTrace: this.props.eventTrace,
};
}
private get readonly() {
return this.props.view.readonly$.value;
}
private renderTable() {
const groups = this.props.view.groupTrait.groupsDataList$.value;
if (groups) {
return html`
<div style="display:flex;flex-direction: column;gap: 16px;">
${repeat(
groups,
v => v.key,
group => {
return html` <mobile-table-group
data-group-key="${group.key}"
.dataViewEle="${this.props.dataViewEle}"
.view="${this.props.view}"
.viewEle="${this}"
.group="${group}"
></mobile-table-group>`;
}
)}
${this.renderAddGroup(this.props.view.groupTrait)}
</div>
`;
}
return html` <mobile-table-group
.dataViewEle="${this.props.dataViewEle}"
.view="${this.props.view}"
.viewEle="${this}"
></mobile-table-group>`;
}
override render() {
const vPadding = this.props.virtualPadding$.value;
const wrapperStyle = styleMap({
marginLeft: `-${vPadding}px`,
marginRight: `-${vPadding}px`,
});
const containerStyle = styleMap({
paddingLeft: `${vPadding}px`,
paddingRight: `${vPadding}px`,
});
return html`
${renderUniLit(this.props.headerWidget, {
dataViewInstance: this.expose,
})}
<div class="mobile-affine-database-table-wrapper" style="${wrapperStyle}">
<div
class="mobile-affine-database-table-container"
style="${containerStyle}"
@wheel="${this.onWheel}"
>
${this.renderTable()}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'mobile-data-view-table': MobileDataViewTable;
}
}

View File

@@ -0,0 +1,174 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import {
assertExists,
SignalWatcher,
WithDisposable,
} from '@blocksuite/global/utils';
import { computed } from '@preact/signals-core';
import { css } from 'lit';
import { property, state } from 'lit/decorators.js';
import { createRef } from 'lit/directives/ref.js';
import { renderUniLit } from '../../../core/index.js';
import type {
CellRenderProps,
DataViewCellLifeCycle,
} from '../../../core/property/index.js';
import type { SingleView } from '../../../core/view-manager/single-view.js';
import type { TableColumn } from '../table-view-manager.js';
import {
TableAreaSelection,
type TableViewSelectionWithType,
} from '../types.js';
export class DatabaseCellContainer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-database-cell-container {
display: flex;
align-items: start;
width: 100%;
height: 100%;
border: none;
outline: none;
}
affine-database-cell-container * {
box-sizing: border-box;
}
affine-database-cell-container uni-lit > *:first-child {
padding: 6px;
}
`;
private _cell = createRef<DataViewCellLifeCycle>();
@property({ attribute: false })
accessor column!: TableColumn;
@property({ attribute: false })
accessor rowId!: string;
cell$ = computed(() => {
return this.column.cellGet(this.rowId);
});
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 = TableAreaSelection.create({
groupKey: this.groupKey,
focus: {
rowIndex: this.rowIndex,
columnIndex: this.columnIndex,
},
isEditing: true,
});
} else {
selectionView.selection = TableAreaSelection.create({
groupKey: this.groupKey,
focus: {
rowIndex: this.rowIndex,
columnIndex: this.columnIndex,
},
isEditing: false,
});
}
}
};
get cell(): DataViewCellLifeCycle | undefined {
return this._cell.value;
}
private get groupKey() {
return this.closest('affine-data-view-table-group')?.group?.key;
}
private get readonly() {
return this.column.readonly$.value;
}
private get selectionView() {
return this.closest('affine-database-table')?.selectionController;
}
get table() {
const table = this.closest('affine-database-table');
assertExists(table);
return table;
}
override connectedCallback() {
super.connectedCallback();
this._disposables.addFromEvent(this, 'click', () => {
if (!this.isEditing) {
this.selectCurrentCell(!this.column.readonly$.value);
}
});
}
isSelected(selection: TableViewSelectionWithType) {
if (selection.selectionType !== 'area') {
return false;
}
if (selection.groupKey !== this.groupKey) {
return;
}
if (selection.focus.columnIndex !== this.columnIndex) {
return;
}
return selection.focus.rowIndex === this.rowIndex;
}
override render() {
const renderer = this.column.renderer$.value;
if (!renderer) {
return;
}
const { edit, view } = renderer;
const uni = !this.readonly && this.isEditing && edit != null ? edit : view;
this.view.lockRows(this.isEditing);
this.dataset['editing'] = `${this.isEditing}`;
const props: CellRenderProps = {
cell: this.cell$.value,
isEditing: this.isEditing,
selectCurrentCell: this.selectCurrentCell,
};
return renderUniLit(uni, props, {
ref: this._cell,
style: {
display: 'contents',
},
});
}
@property({ attribute: false })
accessor columnId!: string;
@property({ attribute: false })
accessor columnIndex!: number;
@state()
accessor isEditing = false;
@property({ attribute: false })
accessor rowIndex!: number;
@property({ attribute: false })
accessor view!: SingleView;
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-cell-container': DatabaseCellContainer;
}
}

View File

@@ -0,0 +1,285 @@
import type { UIEventStateContext } from '@blocksuite/block-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 {
TableAreaSelection,
TableRowSelection,
type TableViewSelection,
type TableViewSelectionWithType,
} from '../../types.js';
import type { DataViewTable } from '../table-view.js';
const BLOCKSUITE_DATABASE_TABLE = 'blocksuite/database/table';
type JsonAreaData = string[][];
const TEXT = 'text/plain';
export class TableClipboardController implements ReactiveController {
private _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 _onCut = (tableSelection: TableViewSelectionWithType) => {
this._onCopy(tableSelection, true);
};
private _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 (TableRowSelection.is(tableSelection)) {
return;
}
if (tableSelection) {
const json = await this.clipboard.readFromClipboard(clipboardData);
const dataString = json[BLOCKSUITE_DATABASE_TABLE];
if (!dataString) return;
const jsonAreaData = JSON.parse(dataString) as JsonAreaData;
pasteToCells(view, jsonAreaData, 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: DataViewTable) {
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: DataViewTable
): SelectedArea | undefined {
const view = table.props.view;
if (TableRowSelection.is(selection)) {
const rows = TableRowSelection.rows(selection)
.map(row => {
const y =
table.selectionController
.getRow(row.groupKey, row.id)
?.getBoundingClientRect().y ?? 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];
for (let j = columnsSelection.start; j <= columnsSelection.end; j++) {
const columnId = columns[j];
const cell = view.cellGet(rowId, columnId);
row.cells.push(cell);
}
data.push(row);
}
return data;
}
type SelectedArea = {
row?: Row;
cells: Cell[];
}[];
function getTargetRangeFromSelection(
selection: TableAreaSelection,
data: JsonAreaData
) {
const { rowsSelection, columnsSelection, focus } = selection;
return TableAreaSelection.isFocus(selection)
? {
row: {
start: focus.rowIndex,
length: data.length,
},
column: {
start: focus.columnIndex,
length: data[0].length,
},
}
: {
row: {
start: rowsSelection.start,
length: rowsSelection.end - rowsSelection.start + 1,
},
column: {
start: columnsSelection.start,
length: columnsSelection.end - columnsSelection.start + 1,
},
};
}
function pasteToCells(
table: DataViewTable,
rows: JsonAreaData,
selection: TableAreaSelection
) {
const srcRowLength = rows.length;
const srcColumnLength = rows[0].length;
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?.dataset.rowId;
const columnId = targetContainer?.dataset.columnId;
if (rowId && columnId) {
targetContainer?.column.valueSetFromString(rowId, dataString);
}
}
}
}

View File

@@ -0,0 +1,110 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { assertEquals } from '@blocksuite/global/utils';
import { DocCollection, 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 { t } from '../../../../core/index.js';
import type { TableAreaSelection } from '../../types.js';
import type { DataViewTable } from '../table-view.js';
export class DragToFillElement extends ShadowlessElement {
static override styles = css`
.drag-to-fill {
border-radius: 50%;
box-sizing: border-box;
background-color: var(--affine-background-primary-color);
border: 2px solid var(--affine-primary-color);
display: none;
position: absolute;
cursor: ns-resize;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
pointer-events: auto;
user-select: none;
transition: scale 0.2s ease;
z-index: 2;
}
.drag-to-fill.dragging {
scale: 1.1;
}
`;
dragToFillRef = createRef<HTMLDivElement>();
override render() {
// TODO add tooltip
return html`<div
${ref(this.dragToFillRef)}
data-drag-to-fill="true"
class="drag-to-fill ${this.dragging ? 'dragging' : ''}"
></div>`;
}
@state()
accessor dragging = false;
}
export function fillSelectionWithFocusCellData(
host: DataViewTable,
selection: TableAreaSelection
) {
const { groupKey, rowsSelection, columnsSelection, focus } = selection;
const focusCell = host.selectionController.getCellContainer(
groupKey,
focus.rowIndex,
focus.columnIndex
);
if (!focusCell) return;
if (rowsSelection && columnsSelection) {
assertEquals(
columnsSelection.start,
columnsSelection.end,
'expected selections on a single column'
);
const curCol = focusCell.column; // we are sure that we are always in the same column while iterating through rows
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 DocCollection.Y.Text();
newText.applyDelta(delta);
curCell.valueSet(newText);
}
} else {
curCell.valueSet(focusData);
}
}
}
}

View File

@@ -0,0 +1,212 @@
// related component
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
import type { ReactiveController } from 'lit';
import { startDrag } from '../../../../core/utils/drag.js';
import { TableRow } from '../row/row.js';
import type { DataViewTable } from '../table-view.js';
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 host: DataViewTable) {
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();
},
};
};

View File

@@ -0,0 +1,383 @@
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
import type { ReactiveController } from 'lit';
import { TableAreaSelection, TableRowSelection } from '../../types.js';
import { popRowMenu } from '../menu.js';
import type { DataViewTable } from '../table-view.js';
export class TableHotkeysController implements ReactiveController {
get selectionController() {
return this.host.selectionController;
}
constructor(private host: DataViewTable) {
this.host.addController(this);
}
hostConnected() {
this.host.disposables.add(
this.host.props.bindHotkey({
Backspace: () => {
const selection = this.selectionController.selection;
if (!selection) {
return;
}
if (TableRowSelection.is(selection)) {
const rows = TableRowSelection.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.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.valueSetFromString(rowId, '');
}
}
}
},
Escape: () => {
const selection = this.selectionController.selection;
if (!selection) {
return false;
}
if (TableRowSelection.is(selection)) {
const result = this.selectionController.rowsToArea(
selection.rows.map(v => v.id)
);
if (result) {
this.selectionController.selection = TableAreaSelection.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 (TableRowSelection.is(selection)) {
const result = this.selectionController.rowsToArea(
selection.rows.map(v => v.id)
);
if (result) {
this.selectionController.selection = TableAreaSelection.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 ||
TableRowSelection.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 ||
TableRowSelection.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 ||
TableRowSelection.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 ||
TableRowSelection.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 ||
TableRowSelection.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 (TableRowSelection.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 (TableRowSelection.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 (TableRowSelection.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 (TableRowSelection.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 ||
TableRowSelection.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 ||
TableRowSelection.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 (TableRowSelection.is(selection)) {
return false;
}
if (selection?.isEditing) {
return true;
}
if (selection) {
context.get('keyboardState').raw.preventDefault();
this.selectionController.selection = TableRowSelection.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 (TableRowSelection.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 = TableRowSelection.create({
rows: [row],
});
popRowMenu(
this.host.props.dataViewEle,
popupTargetFromElement(cell),
this.selectionController
);
}
},
})
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,305 @@
import {
menu,
popFilterableSimpleMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import { effect } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import type { DataViewRenderer } from '../../../core/data-view.js';
import { GroupTitle } from '../../../core/group-by/group-title.js';
import type { GroupData } from '../../../core/group-by/trait.js';
import { createDndContext } from '../../../core/utils/wc-dnd/dnd-context.js';
import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js';
import { linearMove } from '../../../core/utils/wc-dnd/utils/linear-move.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import type { TableSingleView } from '../table-view-manager.js';
import { TableAreaSelection } from '../types.js';
import { DataViewColumnPreview } from './header/column-renderer.js';
import { getVerticalIndicator } from './header/vertical-indicator.js';
import type { DataViewTable } from './table-view.js';
const styles = css`
affine-data-view-table-group:hover .group-header-op {
visibility: visible;
opacity: 1;
}
.data-view-table-group-add-row {
display: flex;
width: 100%;
height: 28px;
position: relative;
z-index: 0;
cursor: pointer;
transition: opacity 0.2s ease-in-out;
padding: 4px 8px;
border-bottom: 1px solid var(--affine-border-color);
}
@media print {
.data-view-table-group-add-row {
display: none;
}
}
.data-view-table-group-add-row-button {
position: sticky;
left: ${8 + LEFT_TOOL_BAR_WIDTH}px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
user-select: none;
font-size: 12px;
line-height: 20px;
color: var(--affine-text-secondary-color);
}
`;
export class TableGroup extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
private clickAddRow = () => {
this.view.rowAdd('end', this.group?.key);
requestAnimationFrame(() => {
const selectionController = this.viewEle.selectionController;
const index = this.view.properties$.value.findIndex(
v => v.type$.value === 'title'
);
selectionController.selection = TableAreaSelection.create({
groupKey: this.group?.key,
focus: {
rowIndex: this.rows.length - 1,
columnIndex: index,
},
isEditing: true,
});
});
};
private clickAddRowInStart = () => {
this.view.rowAdd('start', this.group?.key);
requestAnimationFrame(() => {
const selectionController = this.viewEle.selectionController;
const index = this.view.properties$.value.findIndex(
v => v.type$.value === 'title'
);
selectionController.selection = TableAreaSelection.create({
groupKey: this.group?.key,
focus: {
rowIndex: 0,
columnIndex: index,
},
isEditing: true,
});
});
};
private clickGroupOptions = (e: MouseEvent) => {
const group = this.group;
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.view.rowDelete(group.rows);
},
}),
]);
};
private renderGroupHeader = () => {
if (!this.group) {
return null;
}
return html`
<div
style="position: sticky;left: 0;width: max-content;padding: 6px 0;margin-bottom: 4px;display:flex;align-items:center;gap: 12px;max-width: 400px"
>
${GroupTitle(this.group, {
readonly: this.view.readonly$.value,
clickAdd: this.clickAddRowInStart,
clickOps: this.clickGroupOptions,
})}
</div>
`;
};
@property({ attribute: false })
accessor group: GroupData | undefined = undefined;
@property({ attribute: false })
accessor view!: TableSingleView;
dndContext = createDndContext({
activators: defaultActivators,
container: this,
modifiers: [
({ transform }) => {
return {
...transform,
y: 0,
};
},
],
onDragEnd: ({ over, active }) => {
if (over && over.id !== active.id) {
const activeIndex = this.view.properties$.value.findIndex(
data => data.id === active.id
);
const overIndex = this.view.properties$.value.findIndex(
data => data.id === over.id
);
this.view.propertyMove(active.id, {
before: activeIndex > overIndex,
id: over.id,
});
}
},
collisionDetection: linearMove(true),
createOverlay: active => {
const column = this.view.propertyGet(active.id);
const preview = new DataViewColumnPreview();
preview.column = column;
preview.group = this.group;
preview.container = this;
preview.style.position = 'absolute';
preview.style.zIndex = '999';
const scale = this.dndContext.scale$.value;
const offsetParentRect = this.offsetParent?.getBoundingClientRect();
if (!offsetParentRect) {
return;
}
preview.style.width = `${column.width$.value}px`;
preview.style.top = `${(active.rect.top - offsetParentRect.top - 1) / scale.y}px`;
preview.style.left = `${(active.rect.left - offsetParentRect.left) / scale.x}px`;
const cells = Array.from(
this.querySelectorAll(`[data-column-id="${active.id}"]`)
) as HTMLElement[];
cells.forEach(ele => {
ele.style.opacity = '0.1';
});
this.append(preview);
return {
overlay: preview,
cleanup: () => {
preview.remove();
cells.forEach(ele => {
ele.style.opacity = '1';
});
},
};
},
});
showIndicator = () => {
const columnMoveIndicator = getVerticalIndicator();
this.disposables.add(
effect(() => {
const active = this.dndContext.active$.value;
const over = this.dndContext.over$.value;
if (!active || !over) {
columnMoveIndicator.remove();
return;
}
const scrollX = this.dndContext.scrollOffset$.value.x;
const bottom =
this.rowsContainer?.getBoundingClientRect().bottom ??
this.getBoundingClientRect().bottom;
const left =
over.rect.left < active.rect.left ? over.rect.left : over.rect.right;
const height = bottom - over.rect.top;
columnMoveIndicator.display(left - scrollX, over.rect.top, height);
})
);
};
get rows() {
return this.group?.rows ?? this.view.rows$.value;
}
private renderRows(ids: string[]) {
return html`
<affine-database-column-header
.renderGroupHeader="${this.renderGroupHeader}"
.tableViewManager="${this.view}"
></affine-database-column-header>
<div class="affine-database-block-rows">
${repeat(
ids,
id => id,
(id, idx) => {
return html` <data-view-table-row
data-row-index="${idx}"
data-row-id="${id}"
.dataViewEle="${this.dataViewEle}"
.view="${this.view}"
.rowId="${id}"
.rowIndex="${idx}"
></data-view-table-row>`;
}
)}
</div>
${this.view.readonly$.value
? null
: html` <div
class="data-view-table-group-add-row dv-hover"
@click="${this.clickAddRow}"
>
<div
class="data-view-table-group-add-row-button dv-icon-16"
data-test-id="affine-database-add-row-button"
role="button"
>
${PlusIcon()}<span style="font-size: 12px">New Record</span>
</div>
</div>`}
<affine-database-column-stats .view="${this.view}" .group="${this.group}">
</affine-database-column-stats>
`;
}
override connectedCallback(): void {
super.connectedCallback();
this.showIndicator();
}
override render() {
return this.renderRows(this.rows);
}
@property({ attribute: false })
accessor dataViewEle!: DataViewRenderer;
@query('.affine-database-block-rows')
accessor rowsContainer: HTMLElement | null = null;
@property({ attribute: false })
accessor viewEle!: DataViewTable;
}
declare global {
interface HTMLElementTagNameMap {
'affine-data-view-table-group': TableGroup;
}
}

View File

@@ -0,0 +1,139 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { getScrollContainer } from '@blocksuite/affine-shared/utils';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons/lit';
import { autoUpdate } from '@floating-ui/dom';
import { nothing, type TemplateResult } 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.js';
import type { TableGroup } from '../group.js';
import { styles } from './styles.js';
export class DatabaseColumnHeader extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
private _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;
}
private autoSetHeaderPosition(
group: TableGroup,
scrollContainer: HTMLElement
) {
const referenceRect = group.getBoundingClientRect();
const floatingRect = this.getBoundingClientRect();
const rootRect = scrollContainer.getBoundingClientRect();
let moveX = 0;
if (rootRect.top > referenceRect.top) {
moveX =
Math.min(referenceRect.bottom - floatingRect.height, rootRect.top) -
referenceRect.top;
}
if (moveX === 0 && this.preMove === 0) {
return;
}
this.preMove = moveX;
this.style.transform = `translate3d(0,${moveX / this.getScale()}px,0)`;
}
override connectedCallback() {
super.connectedCallback();
const scrollContainer = getScrollContainer(
this.closest('affine-data-view-renderer')!
);
const group = this.closest('affine-data-view-table-group');
if (group) {
const cancel = autoUpdate(group, this, () => {
if (!scrollContainer) {
return;
}
this.autoSetHeaderPosition(group, scrollContainer);
});
this.disposables.add(cancel);
}
}
getScale() {
return this.scaleDiv?.getBoundingClientRect().width ?? 1;
}
override render() {
return html`
${this.renderGroupHeader?.()}
<div class="affine-database-column-header database-row">
${this.readonly
? nothing
: html`<div class="data-view-table-left-bar"></div>`}
${repeat(
this.tableViewManager.properties$.value,
column => column.id,
(column, index) => {
const style = styleMap({
width: `${column.width$.value}px`,
border: index === 0 ? 'none' : undefined,
});
return html`
<affine-database-header-column
style="${style}"
data-column-id="${column.id}"
data-column-index="${index}"
class="affine-database-column database-cell"
.column="${column}"
.tableViewManager="${this.tableViewManager}"
></affine-database-header-column>
<div class="cell-divider" style="height: auto;"></div>
`;
}
)}
<div
@click="${this._onAddColumn}"
class="header-add-column-button dv-hover"
>
${PlusIcon()}
</div>
<div class="scale-div" style="width: 1px;height: 1px;"></div>
</div>
`;
}
@property({ attribute: false })
accessor renderGroupHeader: (() => TemplateResult) | undefined;
@query('.scale-div')
accessor scaleDiv!: HTMLDivElement;
@property({ attribute: false })
accessor tableViewManager!: TableSingleView;
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-column-header': DatabaseColumnHeader;
}
}

View File

@@ -0,0 +1,85 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { css } 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.js';
import type { TableColumn, TableSingleView } from '../../table-view-manager.js';
export class DataViewColumnPreview extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
affine-data-view-column-preview {
pointer-events: none;
display: block;
position: fixed;
font-family: var(--affine-font-family);
}
`;
get tableViewManager(): TableSingleView {
return this.column.view as TableSingleView;
}
private renderGroup(rows: string[]) {
const columnIndex = this.tableViewManager.propertyIndexGet(this.column.id);
return html`
<div
style="background-color: var(--affine-background-primary-color);border-top: 1px solid var(--affine-border-color);box-shadow: var(--affine-shadow-2);"
>
<affine-database-header-column
.tableViewManager="${this.tableViewManager}"
.column="${this.column}"
></affine-database-header-column>
${repeat(rows, (id, index) => {
const height = this.container.querySelector(
`affine-database-cell-container[data-row-id="${id}"]`
)?.clientHeight;
const style = styleMap({
height: height + 'px',
});
return html`<div
style="border-top: 1px solid var(--affine-border-color)"
>
<div style="${style}">
<affine-database-cell-container
.column="${this.column}"
.view="${this.tableViewManager}"
.rowId="${id}"
.columnId="${this.column.id}"
.rowIndex="${index}"
.columnIndex="${columnIndex}"
></affine-database-cell-container>
</div>
</div>`;
})}
</div>
<div style="height: 45px;"></div>
`;
}
override render() {
return this.renderGroup(
this.group?.rows ?? this.tableViewManager.rows$.value
);
}
@property({ attribute: false })
accessor column!: TableColumn;
@property({ attribute: false })
accessor container!: HTMLElement;
@property({ attribute: false })
accessor group: GroupData | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'affine-data-view-column-preview': DataViewColumnPreview;
}
}

View File

@@ -0,0 +1,506 @@
import {
menu,
type MenuConfig,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
DeleteIcon,
DuplicateIcon,
FilterIcon,
InsertLeftIcon,
InsertRightIcon,
MoveLeftIcon,
MoveRightIcon,
SortIcon,
ViewIcon,
} from '@blocksuite/icons/lit';
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 {
inputConfig,
typeConfig,
} from '../../../../core/common/property-menu.js';
import { filterTraitKey } from '../../../../core/filter/trait.js';
import { firstFilterByRef } from '../../../../core/filter/utils.js';
import { renderUniLit } from '../../../../core/index.js';
import { sortTraitKey } from '../../../../core/sort/manager.js';
import { createSortUtils } from '../../../../core/sort/utils.js';
import {
draggable,
dragHandler,
droppable,
} from '../../../../core/utils/wc-dnd/dnd-context.js';
import type { Property } from '../../../../core/view-manager/property.js';
import type { NumberPropertyDataType } from '../../../../property-presets/index.js';
import { numberFormats } from '../../../../property-presets/number/utils/formats.js';
import { ShowQuickSettingBarContextKey } from '../../../../widget-presets/quick-setting-bar/context.js';
import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../../consts.js';
import type { TableColumn, TableSingleView } from '../../table-view-manager.js';
import {
getTableGroupRect,
getVerticalIndicator,
startDragWidthAdjustmentBar,
} from './vertical-indicator.js';
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 _clickColumn = () => {
if (this.tableViewManager.readonly$.value) {
return;
}
this.popMenu();
};
private _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.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 _contextMenu = (e: MouseEvent) => {
if (this.tableViewManager.readonly$.value) {
return;
}
e.preventDefault();
this.popMenu(e.currentTarget as HTMLElement);
};
private _enterWidthDragBar = () => {
if (this.tableViewManager.readonly$.value) {
return;
}
if (this.drawWidthDragBarTask) {
cancelAnimationFrame(this.drawWidthDragBarTask);
this.drawWidthDragBarTask = 0;
}
this.drawWidthDragBar();
};
private _leaveWidthDragBar = () => {
cancelAnimationFrame(this.drawWidthDragBarTask);
this.drawWidthDragBarTask = 0;
getVerticalIndicator().remove();
};
private 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 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?.expose.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 as Property<
number,
NumberPropertyDataType
>
).data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
}),
]
: []),
// Number format end
menu.group({
items: [
menu.action({
name: 'Hide In View',
prefix: ViewIcon(),
hide: () =>
this.column.hide$.value ||
this.column.type$.value === 'title',
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.duplicate || this.column.type$.value === 'title',
select: () => {
this.column.duplicate?.();
},
}),
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
hide: () =>
!this.column.delete || this.column.type$.value === 'title',
select: () => {
this.column.delete?.();
},
class: {
'delete-item': true,
},
}),
],
}),
],
},
});
}
private widthDragStart(event: PointerEvent) {
startDragWidthAdjustmentBar(
event,
this,
this.getBoundingClientRect().width,
this.column
);
}
override connectedCallback() {
super.connectedCallback();
const table = this.closest('affine-database-table');
if (table) {
this.disposables.add(
table.props.handleEvent('dragStart', context => {
if (this.tableViewManager.readonly$.value) {
return;
}
const event = context.get('pointerState').raw;
const target = event.target;
if (
target instanceof Element &&
this.widthDragBar.value?.contains(target)
) {
event.preventDefault();
event.stopPropagation();
this.widthDragStart(event);
return true;
}
return false;
})
);
}
}
override render() {
const column = this.column;
const style = styleMap({
height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px',
});
const classes = classMap({
'affine-database-column-move': true,
[this.grabStatus]: true,
});
return html`
<div
style=${style}
class="affine-database-column-content"
@click="${this._clickColumn}"
@contextmenu="${this._contextMenu}"
${dragHandler(column.id)}
${draggable(column.id)}
${droppable(column.id)}
>
${this.readonly
? null
: html` <button class="${classes}">
<div class="hover-trigger"></div>
<div class="control-h"></div>
<div class="control-l"></div>
<div class="control-r"></div>
</button>`}
<div class="affine-database-column-text ${column.type$.value}">
<div
class="affine-database-column-type-icon dv-hover"
@click="${this._clickTypeIcon}"
>
<uni-lit .uni="${column.icon}"></uni-lit>
</div>
<div class="affine-database-column-text-content">
<div class="affine-database-column-text-input">
${column.name$.value}
</div>
</div>
</div>
</div>
<div
${ref(this.widthDragBar)}
@mouseenter="${this._enterWidthDragBar}"
@mouseleave="${this._leaveWidthDragBar}"
style="width: 0;position: relative;height: 100%;z-index: 1;cursor: col-resize"
>
<div style="width: 8px;height: 100%;margin-left: -4px;"></div>
</div>
`;
}
@property({ attribute: false })
accessor column!: TableColumn;
@property({ attribute: false })
accessor grabStatus: 'grabStart' | 'grabEnd' | 'grabbing' = 'grabEnd';
@property({ attribute: false })
accessor tableViewManager!: TableSingleView;
}
function numberFormatConfig(column: Property): MenuConfig {
return () =>
html` <affine-database-number-format-bar
.column="${column}"
></affine-database-number-format-bar>`;
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-header-column': DatabaseHeaderColumn;
}
}

View File

@@ -0,0 +1,144 @@
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { Property } from '../../../../core/view-manager/property.js';
import { formatNumber } from '../../../../property-presets/number/utils/formatter.js';
const IncreaseDecimalPlacesIcon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 10.5H2.66176V13.5H0V10.5ZM13.3333 18H20V21H13.3333V18ZM22.6259 17.9356L24 19.5282L22.6312 21.0585L20 24V15L22.6259 17.9356ZM3.99019 4.4953C3.99019 2.01262 5.78279 0 7.98405 0C10.1898 0 11.9779 2.02109 11.9779 4.4953V9.0047C11.9779 11.4874 10.1853 13.5 7.98405 13.5C5.7783 13.5 3.99019 11.4789 3.99019 9.0047V4.4953ZM6 4.49786V9.00214C6 10.2525 6.89543 11.25 8 11.25C9.11227 11.25 10 10.2436 10 9.00214V4.49786C10 3.24754 9.10457 2.25 8 2.25C6.88773 2.25 6 3.2564 6 4.49786ZM13.3235 4.4953C13.3235 2.01262 15.1161 0 17.3174 0C19.5231 0 21.3113 2.02109 21.3113 4.4953V9.0047C21.3113 11.4874 19.5187 13.5 17.3174 13.5C15.1116 13.5 13.3235 11.4789 13.3235 9.0047V4.4953ZM15.3333 4.49786V9.00214C15.3333 10.2525 16.2288 11.25 17.3333 11.25C18.4456 11.25 19.3333 10.2436 19.3333 9.00214V4.49786C19.3333 3.24754 18.4379 2.25 17.3333 2.25C16.2211 2.25 15.3333 3.2564 15.3333 4.49786Z"
fill="currentColor"
/>
</svg>
`;
const DecreaseDecimalPlacesIcon = html`
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 10.5H3V13.5H0V10.5ZM18.09 22.41L16.305 20.625H24V18.375H16.305L18.09 16.59L16.5 15L12 19.5L16.5 24L18.09 22.41ZM13.5 9.375V4.125C13.5 1.845 11.655 0 9.375 0C7.095 0 5.25 1.845 5.25 4.125V9.375C5.25 11.655 7.095 13.5 9.375 13.5C11.655 13.5 13.5 11.655 13.5 9.375ZM11.25 9.375C11.25 10.41 10.41 11.25 9.375 11.25C8.34 11.25 7.5 10.41 7.5 9.375V4.125C7.5 3.09 8.34 2.25 9.375 2.25C10.41 2.25 11.25 3.09 11.25 4.125V9.375Z"
fill="currentColor"
/>
</svg>
`;
export class DatabaseNumberFormatBar extends WithDisposable(LitElement) {
static override styles = css`
.number-format-toolbar-container {
padding: 4px 12px;
display: flex;
gap: 7px;
flex-direction: column;
}
.number-format-decimal-places {
display: flex;
gap: 4px;
align-items: center;
justify-content: flex-start;
}
.number-format-toolbar-button {
box-sizing: border-box;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--affine-icon-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
position: relative;
user-select: none;
}
.number-format-toolbar-button svg {
width: 16px;
height: 16px;
}
.number-formatting-sample {
font-size: var(--affine-font-xs);
color: var(--affine-icon-color);
margin-left: auto;
}
.number-format-toolbar-button:hover {
background-color: var(--affine-hover-color);
}
.divider {
width: 100%;
height: 1px;
background-color: var(--affine-border-color);
}
`;
private _decrementDecimalPlaces = () => {
this.column.dataUpdate(data => ({
decimal: Math.max(((data.decimal as number) ?? 0) - 1, 0),
}));
this.requestUpdate();
};
private _incrementDecimalPlaces = () => {
this.column.dataUpdate(data => ({
decimal: Math.min(((data.decimal as number) ?? 0) + 1, 8),
}));
this.requestUpdate();
};
override render() {
return html`
<div class="number-format-toolbar-container">
<div class="number-format-decimal-places">
<button
class="number-format-toolbar-button"
aria-label="decrease decimal places"
@click=${this._decrementDecimalPlaces}
>
${DecreaseDecimalPlacesIcon}
</button>
<button
class="number-format-toolbar-button"
aria-label="increase decimal places"
@click=${this._incrementDecimalPlaces}
>
${IncreaseDecimalPlacesIcon}
</button>
<span class="number-formatting-sample">
&lpar;&nbsp;${formatNumber(
1,
'number',
(this.column.data$.value.decimal as number) ?? 0
)}&nbsp;&rpar;
</span>
</div>
<div class="divider"></div>
</div>
`;
}
@property({ attribute: false })
accessor column!: Property;
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-number-format-bar': DatabaseNumberFormatBar;
}
}

View File

@@ -0,0 +1,348 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
import {
DEFAULT_ADD_BUTTON_WIDTH,
DEFAULT_COLUMN_TITLE_HEIGHT,
} from '../../consts.js';
export const styles = css`
affine-database-column-header {
display: block;
background-color: var(--affine-background-primary-color);
position: relative;
z-index: 2;
}
.affine-database-column-header {
position: relative;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--affine-border-color);
border-top: 1px solid var(--affine-border-color);
box-sizing: border-box;
user-select: none;
background-color: var(--affine-background-primary-color);
}
.affine-database-column {
cursor: pointer;
}
.database-cell {
user-select: none;
}
.database-cell.add-column-button {
flex: 1;
min-width: ${DEFAULT_ADD_BUTTON_WIDTH}px;
min-height: 100%;
display: flex;
align-items: center;
}
.affine-database-column-content {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
height: 100%;
padding: 6px;
box-sizing: border-box;
position: relative;
}
.affine-database-column-content:hover,
.affine-database-column-content.edit {
background: var(--affine-hover-color);
}
.affine-database-column-content.edit .affine-database-column-text-icon {
opacity: 1;
}
.affine-database-column-text {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
/* https://stackoverflow.com/a/36247448/15443637 */
overflow: hidden;
color: var(--affine-text-secondary-color);
font-size: 14px;
position: relative;
}
.affine-database-column-type-icon {
display: flex;
align-items: center;
border-radius: 4px;
padding: 2px;
font-size: 18px;
color: ${unsafeCSSVarV2('database/textSecondary')};
}
.affine-database-column-text-content {
flex: 1;
display: flex;
align-items: center;
overflow: hidden;
}
.affine-database-column-content:hover .affine-database-column-text-icon {
opacity: 1;
}
.affine-database-column-text-input {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.affine-database-column-text-icon {
display: flex;
align-items: center;
width: 16px;
height: 16px;
background: var(--affine-white);
border: 1px solid var(--affine-border-color);
border-radius: 4px;
opacity: 0;
}
.affine-database-column-text-save-icon {
display: flex;
align-items: center;
width: 16px;
height: 16px;
border: 1px solid transparent;
border-radius: 4px;
fill: var(--affine-icon-color);
}
.affine-database-column-text-save-icon:hover {
background: var(--affine-white);
border-color: var(--affine-border-color);
}
.affine-database-column-text-icon svg {
fill: var(--affine-icon-color);
}
.affine-database-column-input {
width: 100%;
height: 24px;
padding: 0;
border: none;
color: inherit;
font-weight: 600;
font-size: 14px;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
background: transparent;
}
.affine-database-column-input:focus {
outline: none;
}
.affine-database-column-move {
display: flex;
align-items: center;
}
.affine-database-column-move svg {
width: 10px;
height: 14px;
color: var(--affine-black-10);
cursor: grab;
opacity: 0;
}
.affine-database-column-content:hover svg {
opacity: 1;
}
.affine-database-add-column-button {
position: sticky;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 38px;
cursor: pointer;
}
.header-add-column-button {
height: ${DEFAULT_COLUMN_TITLE_HEIGHT}px;
background-color: var(--affine-background-primary-color);
display: flex;
align-items: center;
justify-content: center;
width: 40px;
cursor: pointer;
font-size: 18px;
color: ${unsafeCSSVarV2('icon/primary')};
}
@media print {
.header-add-column-button {
display: none;
}
}
.affine-database-column-type-menu-icon {
border: 1px solid var(--affine-border-color);
border-radius: 4px;
padding: 5px;
background-color: var(--affine-background-secondary-color);
}
.affine-database-column-type-menu-icon svg {
color: var(--affine-text-secondary-color);
width: 20px;
height: 20px;
}
.affine-database-column-move-preview {
position: fixed;
z-index: 100;
width: 100px;
height: 100px;
background: var(--affine-text-emphasis-color);
}
.affine-database-column-move {
--color: var(--affine-placeholder-color);
--active: var(--affine-black-10);
--bw: 1px;
--bw2: -1px;
cursor: grab;
background: none;
border: none;
border-radius: 0;
position: absolute;
inset: 0;
}
.affine-database-column-move .control-l::before,
.affine-database-column-move .control-h::before,
.affine-database-column-move .control-l::after,
.affine-database-column-move .control-h::after,
.affine-database-column-move .control-r,
.affine-database-column-move .hover-trigger {
--delay: 0s;
--delay-opacity: 0s;
content: '';
position: absolute;
transition: all 0.2s ease var(--delay),
opacity 0.2s ease var(--delay-opacity);
}
.affine-database-column-move .control-r {
--delay: 0s;
--delay-opacity: 0.6s;
width: 4px;
border-radius: 1px;
height: 32%;
background: var(--color);
right: 6px;
top: 50%;
transform: translateY(-50%);
opacity: 0;
pointer-events: none;
}
.affine-database-column-move .hover-trigger {
width: 12px;
height: 80%;
right: 3px;
top: 10%;
background: transparent
z-index: 1;
opacity: 1;
}
.affine-database-column-move:hover .control-r {
opacity: 1;
}
.affine-database-column-move .control-h::before,
.affine-database-column-move .control-h::after {
--delay: 0.2s;
width: calc(100% - var(--bw2) * 2);
opacity: 0;
height: var(--bw);
right: var(--bw2);
background: var(--active);
}
.affine-database-column-move .control-h::before {
top: var(--bw2);
}
.affine-database-column-move .control-h::after {
bottom: var(--bw2);
}
.affine-database-column-move .control-l::before {
--delay: 0s;
width: var(--bw);
height: 100%;
opacity: 0;
background: var(--active);
left: var(--bw2);
}
.affine-database-column-move .control-l::before {
top: 0;
}
.affine-database-column-move .control-l::after {
bottom: 0;
}
/* handle--active style */
.affine-database-column-move:hover .control-r {
--delay-opacity: 0s;
opacity: 1;
}
.affine-database-column-move:active .control-r,
.hover-trigger:hover ~ .control-r,
.grabbing.affine-database-column-move .control-r {
opacity: 1;
--delay: 0s;
--delay-opacity: 0s;
right: var(--bw2);
width: var(--bw);
height: 100%;
background: var(--active);
}
.affine-database-column-move:active .control-h::before,
.affine-database-column-move:active .control-h::after,
.hover-trigger:hover ~ .control-h::before,
.hover-trigger:hover ~ .control-h::after,
.grabbing.affine-database-column-move .control-h::before,
.grabbing.affine-database-column-move .control-h::after {
--delay: 0.2s;
width: calc(100% - var(--bw2) * 2);
opacity: 1;
}
.affine-database-column-move:active .control-l::before,
.affine-database-column-move:active .control-l::after,
.hover-trigger:hover ~ .control-l::before,
.hover-trigger:hover ~ .control-l::after,
.grabbing.affine-database-column-move .control-l::before,
.grabbing.affine-database-column-move .control-l::after {
--delay: 0.4s;
opacity: 1;
}
`;

View File

@@ -0,0 +1,163 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
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.js';
import { getResultInRange } from '../../../../core/utils/utils.js';
import type { TableColumn } from '../../table-view-manager.js';
export class TableVerticalIndicator extends WithDisposable(ShadowlessElement) {
static override styles = css`
data-view-table-vertical-indicator {
position: fixed;
left: 0;
top: 0;
z-index: 1;
pointer-events: none;
}
.vertical-indicator {
position: absolute;
pointer-events: none;
width: 1px;
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
}
.vertical-indicator::after {
position: absolute;
z-index: 1;
width: 2px;
height: 100%;
content: '';
right: 0;
background-color: var(--affine-primary-color);
border-radius: 1px;
}
.with-shadow.vertical-indicator::after {
box-shadow: 0px 0px 8px 0px rgba(30, 150, 235, 0.35);
}
`;
protected override render(): unknown {
const style = styleMap({
top: `${this.top}px`,
left: `${this.left}px`,
height: `${this.height}px`,
width: `${this.width}px`,
});
const className = classMap({
'with-shadow': this.shadow,
'vertical-indicator': true,
});
return html` <div class="${className}" style=${style}></div> `;
}
@property({ attribute: false })
accessor height!: number;
@property({ attribute: false })
accessor left!: number;
@property({ attribute: false })
accessor shadow = false;
@property({ attribute: false })
accessor top!: number;
@property({ attribute: false })
accessor width!: number;
}
export const getTableGroupRect = (ele: HTMLElement) => {
const group = ele.closest('affine-data-view-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;
};

View File

@@ -0,0 +1,130 @@
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.js';
import { TableRowSelection } from '../types.js';
import type { TableSelectionController } from './controller/selection.js';
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 (!TableRowSelection.is(selection)) {
return;
}
if (selection.rows.length > 1) {
const rows = TableRowSelection.rowsIds(selection);
popFilterableSimpleMenu(ele, [
menu.group({
name: '',
items: [
menu.action({
name: 'Copy',
prefix: html` <div
style="transform: rotate(90deg);display:flex;align-items:center;"
>
${CopyIcon()}
</div>`,
select: () => {
selectionController.host.clipboardController.copy();
},
}),
],
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Delete Rows',
class: {
'delete-item': true,
},
prefix: DeleteIcon(),
select: () => {
selectionController.view.rowDelete(rows);
},
}),
],
}),
]);
return;
}
const row = selection.rows[0];
popFilterableSimpleMenu(ele, [
menu.action({
name: 'Expand Row',
prefix: ExpandFullIcon(),
select: () => {
openDetail(dataViewEle, row.id, selectionController);
},
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Insert Before',
prefix: html` <div
style="transform: rotate(90deg);display:flex;align-items:center;"
>
${MoveLeftIcon()}
</div>`,
select: () => {
selectionController.insertRowBefore(row.groupKey, row.id);
},
}),
menu.action({
name: 'Insert After',
prefix: html` <div
style="transform: rotate(90deg);display:flex;align-items:center;"
>
${MoveRightIcon()}
</div>`,
select: () => {
selectionController.insertRowAfter(row.groupKey, row.id);
},
}),
],
}),
menu.group({
items: [
menu.action({
name: 'Delete Row',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
selectionController.deleteRow(row.id);
},
}),
],
}),
]);
};

View File

@@ -0,0 +1,82 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import {
TableRowSelection,
type TableViewSelectionWithType,
} from '../../types.js';
export class RowSelectCheckbox extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
row-select-checkbox {
display: contents;
}
.row-select-checkbox {
display: flex;
align-items: center;
background-color: var(--affine-background-primary-color);
opacity: 0;
cursor: pointer;
font-size: 20px;
color: var(--affine-icon-color);
}
.row-select-checkbox:hover {
opacity: 1;
}
.row-select-checkbox.selected {
opacity: 1;
}
`;
@property({ attribute: false })
accessor groupKey: string | undefined;
@property({ attribute: false })
accessor rowId!: string;
@property({ attribute: false })
accessor selection!: ReadonlySignal<TableViewSelectionWithType | undefined>;
isSelected$ = computed(() => {
const selection = this.selection.value;
if (!selection || selection.selectionType !== 'row') {
return false;
}
return TableRowSelection.includes(selection, {
id: this.rowId,
groupKey: this.groupKey,
});
});
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', () => {
this.closest('affine-database-table')?.selectionController.toggleRow(
this.rowId,
this.groupKey
);
});
}
override render() {
const classString = classMap({
'row-selected-bg': true,
'row-select-checkbox': true,
selected: this.isSelected$.value,
});
return html`
<div class="${classString}">
${this.isSelected$.value
? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` })
: CheckBoxUnIcon()}
</div>
`;
}
}

View File

@@ -0,0 +1,294 @@
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { css, nothing } 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 { DataViewRenderer } from '../../../../core/data-view.js';
import type { TableSingleView } from '../../table-view-manager.js';
import { TableRowSelection, type TableViewSelection } from '../../types.js';
import { openDetail, popRowMenu } from '../menu.js';
export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
static override styles = css`
.affine-database-block-row:has(.row-select-checkbox.selected) {
background: var(--affine-primary-color-04);
}
.affine-database-block-row:has(.row-select-checkbox.selected)
.row-selected-bg {
position: relative;
}
.affine-database-block-row:has(.row-select-checkbox.selected)
.row-selected-bg:before {
content: '';
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: var(--affine-primary-color-04);
}
.affine-database-block-row {
width: 100%;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--affine-border-color);
position: relative;
}
.affine-database-block-row.selected > .database-cell {
background: transparent;
}
.row-ops {
position: relative;
width: 0;
margin-top: 4px;
height: max-content;
visibility: hidden;
display: flex;
gap: 4px;
cursor: pointer;
justify-content: end;
}
.row-op:last-child {
margin-right: 8px;
}
.affine-database-block-row .show-on-hover-row {
visibility: hidden;
opacity: 0;
}
.affine-database-block-row:hover .show-on-hover-row {
visibility: visible;
opacity: 1;
}
.affine-database-block-row:has(.active) .show-on-hover-row {
visibility: visible;
opacity: 1;
}
.affine-database-block-row:has([data-editing='true']) .show-on-hover-row {
visibility: hidden;
opacity: 0;
}
.row-op {
display: flex;
padding: 4px;
border-radius: 4px;
box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14);
background-color: var(--affine-background-primary-color);
position: relative;
}
.row-op:hover:before {
content: '';
border-radius: 4px;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: var(--affine-hover-color);
}
.row-op svg {
fill: var(--affine-icon-color);
color: var(--affine-icon-color);
width: 16px;
height: 16px;
}
.data-view-table-view-drag-handler {
width: 4px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
background-color: var(--affine-background-primary-color);
}
`;
private _clickDragHandler = () => {
if (this.view.readonly$.value) {
return;
}
this.selectionController?.toggleRow(this.rowId, this.groupKey);
};
contextMenu = (e: MouseEvent) => {
if (this.view.readonly$.value) {
return;
}
const selection = this.selectionController;
if (!selection) {
return;
}
e.preventDefault();
const ele = e.target as HTMLElement;
const cell = ele.closest('affine-database-cell-container');
const row = { id: this.rowId, groupKey: this.groupKey };
if (!TableRowSelection.includes(selection.selection, row)) {
selection.selection = TableRowSelection.create({
rows: [row],
});
}
const target =
cell ??
(e.target as HTMLElement).closest('.database-cell') ?? // for last add btn cell
(e.target as HTMLElement);
popRowMenu(this.dataViewEle, popupTargetFromElement(target), selection);
};
setSelection = (selection?: TableViewSelection) => {
if (this.selectionController) {
this.selectionController.selection = selection;
}
};
get groupKey() {
return this.closest('affine-data-view-table-group')?.group?.key;
}
get selectionController() {
return this.closest('affine-database-table')?.selectionController;
}
override connectedCallback() {
super.connectedCallback();
this.disposables.addFromEvent(this, 'contextmenu', this.contextMenu);
this.classList.add('affine-database-block-row', 'database-row');
}
protected override render(): unknown {
const view = this.view;
return html`
${view.readonly$.value
? nothing
: html`<div class="data-view-table-left-bar" style="height: 34px">
<div style="display: flex;">
<div
class="data-view-table-view-drag-handler show-on-hover-row row-selected-bg"
@click=${this._clickDragHandler}
>
<div
style="width: 4px;
border-radius: 2px;
height: 12px;
background-color: var(--affine-placeholder-color);"
></div>
</div>
<row-select-checkbox
.selection="${this.dataViewEle.config.selection$}"
.rowId="${this.rowId}"
.groupKey="${this.groupKey}"
></row-select-checkbox>
</div>
</div>`}
${repeat(
view.properties$.value,
v => v.id,
(column, i) => {
const clickDetail = () => {
if (!this.selectionController) {
return;
}
this.setSelection(
TableRowSelection.create({
rows: [{ id: this.rowId, groupKey: this.groupKey }],
})
);
openDetail(this.dataViewEle, this.rowId, this.selectionController);
};
const openMenu = (e: MouseEvent) => {
if (!this.selectionController) {
return;
}
const ele = e.currentTarget as HTMLElement;
const selection = this.selectionController.selection;
if (
!TableRowSelection.is(selection) ||
!selection.rows.some(
row => row.id === this.rowId && row.groupKey === this.groupKey
)
) {
const row = { id: this.rowId, groupKey: this.groupKey };
this.setSelection(
TableRowSelection.create({
rows: [row],
})
);
}
popRowMenu(
this.dataViewEle,
popupTargetFromElement(ele),
this.selectionController
);
};
return html`
<div style="display: flex;">
<affine-database-cell-container
class="database-cell"
style=${styleMap({
width: `${column.width$.value}px`,
border: i === 0 ? 'none' : undefined,
})}
.view="${view}"
.column="${column}"
.rowId="${this.rowId}"
data-row-id="${this.rowId}"
.rowIndex="${this.rowIndex}"
data-row-index="${this.rowIndex}"
.columnId="${column.id}"
data-column-id="${column.id}"
.columnIndex="${i}"
data-column-index="${i}"
>
</affine-database-cell-container>
<div class="cell-divider"></div>
</div>
${!column.readonly$.value &&
column.view.mainProperties$.value.titleColumn === column.id
? html`<div class="row-ops show-on-hover-row">
<div class="row-op" @click="${clickDetail}">
${CenterPeekIcon()}
</div>
${!view.readonly$.value
? html`<div class="row-op" @click="${openMenu}">
${MoreHorizontalIcon()}
</div>`
: nothing}
</div>`
: nothing}
`;
}
)}
<div class="database-cell add-column-button"></div>
`;
}
@property({ attribute: false })
accessor dataViewEle!: DataViewRenderer;
@property({ attribute: false })
accessor rowId!: string;
@property({ attribute: false })
accessor rowIndex!: number;
@property({ attribute: false })
accessor view!: TableSingleView;
}
declare global {
interface HTMLElementTagNameMap {
'data-view-table-row': TableRow;
}
}

View File

@@ -0,0 +1,320 @@
import {
menu,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { AddCursorIcon } from '@blocksuite/icons/lit';
import { css } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { html } from 'lit/static-html.js';
import type { GroupTrait } from '../../../core/group-by/trait.js';
import type { DataViewInstance } from '../../../core/index.js';
import { renderUniLit } from '../../../core/utils/uni-component/uni-component.js';
import { DataViewBase } from '../../../core/view/data-view-base.js';
import { LEFT_TOOL_BAR_WIDTH } from '../consts.js';
import type { TableSingleView } from '../table-view-manager.js';
import type { TableViewSelectionWithType } from '../types.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';
const styles = css`
affine-database-table {
position: relative;
display: flex;
flex-direction: column;
}
affine-database-table * {
box-sizing: border-box;
}
.affine-database-table {
overflow-y: auto;
}
.affine-database-block-title-container {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 2px 0 2px;
}
.affine-database-block-table {
position: relative;
width: 100%;
padding-bottom: 4px;
z-index: 1;
overflow-x: scroll;
overflow-y: hidden;
}
/* Disable horizontal scrolling to prevent crashes on iOS Safari */
affine-edgeless-root .affine-database-block-table {
@media (pointer: coarse) {
overflow: hidden;
}
@media (pointer: fine) {
overflow-x: scroll;
overflow-y: hidden;
}
}
.affine-database-block-table:hover {
padding-bottom: 0px;
}
.affine-database-block-table::-webkit-scrollbar {
-webkit-appearance: none;
display: block;
}
.affine-database-block-table::-webkit-scrollbar:horizontal {
height: 4px;
}
.affine-database-block-table::-webkit-scrollbar-thumb {
border-radius: 2px;
background-color: transparent;
}
.affine-database-block-table:hover::-webkit-scrollbar:horizontal {
height: 8px;
}
.affine-database-block-table:hover::-webkit-scrollbar-thumb {
border-radius: 16px;
background-color: var(--affine-black-30);
}
.affine-database-block-table:hover::-webkit-scrollbar-track {
background-color: var(--affine-hover-color);
}
.affine-database-table-container {
position: relative;
width: fit-content;
min-width: 100%;
}
.affine-database-block-tag-circle {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.affine-database-block-tag {
display: inline-flex;
border-radius: 11px;
align-items: center;
padding: 0 8px;
cursor: pointer;
}
.cell-divider {
width: 1px;
height: 100%;
background-color: var(--affine-border-color);
}
.data-view-table-left-bar {
display: flex;
align-items: center;
position: sticky;
z-index: 1;
left: 0;
width: ${LEFT_TOOL_BAR_WIDTH}px;
flex-shrink: 0;
}
.affine-database-block-rows {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
}
`;
export class DataViewTable extends DataViewBase<
TableSingleView,
TableViewSelectionWithType
> {
static override styles = styles;
clipboardController = new TableClipboardController(this);
dragController = new TableDragController(this);
hotkeysController = new TableHotkeysController(this);
onWheel = (event: WheelEvent) => {
if (event.metaKey || event.ctrlKey) {
return;
}
const ele = event.currentTarget;
if (ele instanceof HTMLElement) {
if (ele.scrollWidth === ele.clientWidth) {
return;
}
event.stopPropagation();
}
};
renderAddGroup = (groupHelper: GroupTrait) => {
const addGroup = groupHelper.addGroup;
if (!addGroup) {
return;
}
const add = (e: MouseEvent) => {
const ele = e.currentTarget as HTMLElement;
popMenu(popupTargetFromElement(ele), {
options: {
items: [
menu.input({
onComplete: text => {
const column = groupHelper.property$.value;
if (column) {
column.dataUpdate(
() =>
addGroup({
text,
oldData: column.data$.value,
dataSource: this.props.view.manager.dataSource,
}) as never
);
}
},
}),
],
},
});
};
return html` <div style="display:flex;">
<div
class="dv-hover dv-round-8"
style="display:flex;align-items:center;gap: 10px;padding: 6px 12px 6px 8px;color: var(--affine-text-secondary-color);font-size: 12px;line-height: 20px;position: sticky;left: ${LEFT_TOOL_BAR_WIDTH}px;"
@click="${add}"
>
<div class="dv-icon-16" style="display:flex;">${AddCursorIcon()}</div>
<div>New Group</div>
</div>
</div>`;
};
selectionController = new TableSelectionController(this);
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;
}
private renderTable() {
const groups = this.props.view.groupTrait.groupsDataList$.value;
if (groups) {
return html`
<div style="display:flex;flex-direction: column;gap: 16px;">
${repeat(
groups,
v => v.key,
group => {
return html` <affine-data-view-table-group
data-group-key="${group.key}"
.dataViewEle="${this.props.dataViewEle}"
.view="${this.props.view}"
.viewEle="${this}"
.group="${group}"
></affine-data-view-table-group>`;
}
)}
${this.renderAddGroup(this.props.view.groupTrait)}
</div>
`;
}
return html` <affine-data-view-table-group
.dataViewEle="${this.props.dataViewEle}"
.view="${this.props.view}"
.viewEle="${this}"
></affine-data-view-table-group>`;
}
override render() {
const vPadding = this.props.virtualPadding$.value;
const wrapperStyle = styleMap({
marginLeft: `-${vPadding}px`,
marginRight: `-${vPadding}px`,
});
const containerStyle = styleMap({
paddingLeft: `${vPadding}px`,
paddingRight: `${vPadding}px`,
});
return html`
${renderUniLit(this.props.headerWidget, {
dataViewInstance: this.expose,
})}
<div class="affine-database-table" style="${wrapperStyle}">
<div class="affine-database-block-table" @wheel="${this.onWheel}">
<div
class="affine-database-table-container"
style="${containerStyle}"
>
${this.renderTable()}
</div>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-table': DataViewTable;
}
}

View File

@@ -0,0 +1,11 @@
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';
export const tableViewMeta = tableViewModel.createMeta({
view: createUniComponentFromWebComponent(DataViewTable),
mobileView: createUniComponentFromWebComponent(MobileDataViewTable),
icon: createIcon('DatabaseTableViewIcon'),
});

View File

@@ -0,0 +1,55 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
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.js';
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%;
margin-left: ${LEFT_TOOL_BAR_WIDTH}px;
height: ${STATS_BAR_HEIGHT}px;
display: flex;
}
`;
export class DataBaseColumnStats extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
protected override render() {
const cols = this.view.properties$.value;
return html`
<div class="affine-database-column-stats">
${repeat(
cols,
col => col.id,
col => {
return html`<affine-database-column-stats-cell
.column=${col}
.group=${this.group}
></affine-database-column-stats-cell>`;
}
)}
</div>
`;
}
@property({ attribute: false })
accessor group: GroupData | undefined = undefined;
@property({ attribute: false })
accessor view!: TableSingleView;
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-column-stats': DataBaseColumnStats;
}
}

View File

@@ -0,0 +1,242 @@
import {
menu,
type MenuConfig,
popMenu,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { ArrowDownSmallIcon } from '@blocksuite/icons/lit';
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 type { GroupData } from '../../../core/group-by/trait.js';
import { typeSystem } from '../../../core/index.js';
import { statsFunctions } from '../../../core/statistics/index.js';
import type { StatisticsConfig } from '../../../core/statistics/types.js';
import type { TableColumn } from '../table-view-manager.js';
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-column-stats:hover .stats-cell {
opacity: 1;
}
.stats-cell:hover,
affine-database-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 DatabaseColumnStatsCell 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.value$.value);
});
groups$ = computed(() => {
const groups: Record<string, Record<string, StatisticsConfig>> = {};
statsFunctions.forEach(func => {
if (!typeSystem.unify(this.column.dataType$.value, func.dataType)) {
return;
}
if (!groups[func.group]) {
groups[func.group] = {};
}
const oldFunc = groups[func.group][func.type];
if (!oldFunc || typeSystem.unify(func.dataType, oldFunc.dataType)) {
if (!func.impl) {
delete groups[func.group][func.type];
} else {
groups[func.group][func.type] = func;
}
}
});
return groups;
});
openMenu = (ev: MouseEvent) => {
const menus: MenuConfig[] = Object.entries(this.groups$.value).map(
([group, funcs]) => {
return menu.subMenu({
name: group,
options: {
items: Object.values(funcs).map(func => {
return menu.action({
isSelected: func.type === this.column.statCalcOp$.value,
name: func.menuName ?? func.type,
select: () => {
this.column.updateStatCalcOp(func.type);
},
});
}),
},
});
}
);
popMenu(popupTargetFromElement(ev.currentTarget as HTMLElement), {
options: {
items: [
menu.action({
isSelected: !this.column.statCalcOp$.value,
name: 'None',
select: () => {
this.column.updateStatCalcOp();
},
}),
...menus,
],
},
middleware: [
autoPlacement({ allowedPlacements: ['top', 'bottom'] }),
offset(10),
],
});
};
statsFunc$ = computed(() => {
return Object.values(this.groups$.value)
.flatMap(group => Object.values(group))
.find(func => func.type === this.column.statCalcOp$.value);
});
values$ = signal<unknown[]>([]);
statsResult$ = computed(() => {
const meta = this.column.view.propertyMetaGet(this.column.type$.value);
if (!meta) {
return null;
}
const func = this.statsFunc$.value;
if (!func) {
return null;
}
return {
name: func.displayName,
value:
func.impl?.(this.values$.value, {
meta,
dataSource: this.column.view.manager.dataSource,
}) ?? '',
};
});
subscriptionMap = new Map<unknown, () => void>();
override connectedCallback(): void {
super.connectedCallback();
this.disposables.addFromEvent(this, 'click', this.openMenu);
this.disposables.add(
this.cellValues$.subscribe(values => {
const map = new Map<unknown, () => void>();
values.forEach(value => {
if (value instanceof Text) {
const unsub = this.subscriptionMap.get(value);
if (unsub) {
map.set(value, unsub);
this.subscriptionMap.delete(value);
} else {
const f = () => {
this.values$.value = [...this.cellValues$.value];
};
value.yText.observe(f);
map.set(value, () => {
value.yText.unobserve(f);
});
}
}
});
this.subscriptionMap.forEach(unsub => {
unsub();
});
this.subscriptionMap = map;
this.values$.value = this.cellValues$.value;
})
);
this.disposables.add(() => {
this.subscriptionMap.forEach(unsub => {
unsub();
});
});
}
protected override render() {
const style = {
width: `${this.column.width$.value}px`,
};
return html` <div
calculated="${!!this.column.statCalcOp$.value}"
style="${styleMap(style)}"
class="stats-cell"
>
<div class="content">
${!this.statsResult$.value
? html`Calculate ${ArrowDownSmallIcon()}`
: html`
<span class="label">${this.statsResult$.value.name}</span>
<span class="value">${this.statsResult$.value.value} </span>
`}
</div>
</div>`;
}
@property({ attribute: false })
accessor group: GroupData | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'affine-database-column-stats-cell': DatabaseColumnStatsCell;
}
}

View File

@@ -0,0 +1,401 @@
import {
insertPositionToIndex,
type InsertToPosition,
} from '@blocksuite/affine-shared/utils';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { evalFilter } from '../../core/filter/eval.js';
import { generateDefaultValues } from '../../core/filter/generate-default-values.js';
import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js';
import type { FilterGroup } from '../../core/filter/types.js';
import { emptyFilterGroup } from '../../core/filter/utils.js';
import {
GroupTrait,
groupTraitKey,
sortByManually,
} from '../../core/group-by/trait.js';
import { SortManager, sortTraitKey } from '../../core/sort/manager.js';
import { PropertyBase } from '../../core/view-manager/property.js';
import {
type SingleView,
SingleViewBase,
} from '../../core/view-manager/single-view.js';
import type { ViewManager } from '../../core/view-manager/view-manager.js';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js';
import type { TableViewData } from './define.js';
import type { StatCalcOpType } from './types.js';
export class TableSingleView extends SingleViewBase<TableViewData> {
propertiesWithoutFilter$ = computed(() => {
const needShow = new Set(this.dataSource.properties$.value);
const result: string[] = [];
this.data$.value?.columns.forEach(v => {
if (needShow.has(v.id)) {
result.push(v.id);
needShow.delete(v.id);
}
});
result.push(...needShow);
return result;
});
private computedColumns$ = computed(() => {
return this.propertiesWithoutFilter$.value.map(id => {
const column = this.propertyGet(id);
return {
id: column.id,
hide: column.hide$.value,
width: column.width$.value,
statCalcType: column.statCalcOp$.value,
};
});
});
private filter$ = computed(() => {
return this.data$.value?.filter ?? emptyFilterGroup;
});
private groupBy$ = computed(() => {
return this.data$.value?.groupBy;
});
private sortList$ = computed(() => {
return this.data$.value?.sort;
});
private sortManager = this.traitSet(
sortTraitKey,
new SortManager(this.sortList$, this, {
setSortList: sortList => {
this.dataUpdate(data => {
return {
sort: {
...data.sort,
...sortList,
},
};
});
},
})
);
detailProperties$ = computed(() => {
return this.propertiesWithoutFilter$.value.filter(
id => this.propertyTypeGet(id) !== 'title'
);
});
filterTrait = this.traitSet(
filterTraitKey,
new FilterTrait(this.filter$, this, {
filterSet: (filter: FilterGroup) => {
this.dataUpdate(() => {
return {
filter,
};
});
},
})
);
groupTrait = this.traitSet(
groupTraitKey,
new GroupTrait(this.groupBy$, this, {
groupBySet: groupBy => {
this.dataUpdate(() => {
return {
groupBy,
};
});
},
sortGroup: ids =>
sortByManually(
ids,
v => v,
this.groupProperties.map(v => v.key)
),
sortRow: (key, ids) => {
const property = this.groupProperties.find(v => v.key === key);
return sortByManually(ids, v => v, property?.manuallyCardSort ?? []);
},
changeGroupSort: keys => {
const map = new Map(this.groupProperties.map(v => [v.key, v]));
this.dataUpdate(() => {
return {
groupProperties: keys.map(key => {
const property = map.get(key);
if (property) {
return property;
}
return {
key,
hide: false,
manuallyCardSort: [],
};
}),
};
});
},
changeRowSort: (groupKeys, groupKey, keys) => {
const map = new Map(this.groupProperties.map(v => [v.key, v]));
this.dataUpdate(() => {
return {
groupProperties: groupKeys.map(key => {
if (key === groupKey) {
const group = map.get(key);
return group
? {
...group,
manuallyCardSort: keys,
}
: {
key,
hide: false,
manuallyCardSort: keys,
};
} else {
return (
map.get(key) ?? {
key,
hide: false,
manuallyCardSort: [],
}
);
}
}),
};
});
},
})
);
mainProperties$ = computed(() => {
return (
this.data$.value?.header ?? {
titleColumn: this.propertiesWithoutFilter$.value.find(
id => this.propertyTypeGet(id) === 'title'
),
iconColumn: 'type',
}
);
});
propertyIds$ = computed(() => {
return this.propertiesWithoutFilter$.value.filter(
id => !this.propertyHideGet(id)
);
});
readonly$ = computed(() => {
return this.manager.readonly$.value;
});
get groupProperties() {
return this.data$.value?.groupProperties ?? [];
}
get name(): string {
return this.data$.value?.name ?? '';
}
override get type(): string {
return this.data$.value?.mode ?? 'table';
}
constructor(viewManager: ViewManager, viewId: string) {
super(viewManager, viewId);
}
columnGetStatCalcOp(columnId: string): StatCalcOpType {
return this.data$.value?.columns.find(v => v.id === columnId)?.statCalcType;
}
columnGetWidth(columnId: string): number {
const column = this.data$.value?.columns.find(v => v.id === columnId);
if (column?.width != null) {
return column.width;
}
const type = this.propertyTypeGet(columnId);
if (type === 'title') {
return 260;
}
return DEFAULT_COLUMN_WIDTH;
}
columnUpdateStatCalcOp(columnId: string, op?: string): void {
this.dataUpdate(() => {
return {
columns: this.computedColumns$.value.map(v =>
v.id === columnId
? {
...v,
statCalcType: op,
}
: v
),
};
});
}
columnUpdateWidth(columnId: string, width: number): void {
this.dataUpdate(() => {
return {
columns: this.computedColumns$.value.map(v =>
v.id === columnId
? {
...v,
width: width,
}
: v
),
};
});
}
isShow(rowId: string): boolean {
if (this.filter$.value?.conditions.length) {
const rowMap = Object.fromEntries(
this.properties$.value.map(column => [
column.id,
column.cellGet(rowId).jsonValue$.value,
])
);
return evalFilter(this.filter$.value, rowMap);
}
return true;
}
minWidthGet(type: string): number {
return (
this.propertyMetaGet(type)?.config.minWidth ?? DEFAULT_COLUMN_MIN_WIDTH
);
}
propertyGet(columnId: string): TableColumn {
return new TableColumn(this, columnId);
}
propertyHideGet(columnId: string): boolean {
return (
this.data$.value?.columns.find(v => v.id === columnId)?.hide ?? false
);
}
propertyHideSet(columnId: string, hide: boolean): void {
this.dataUpdate(() => {
return {
columns: this.computedColumns$.value.map(v =>
v.id === columnId
? {
...v,
hide,
}
: v
),
};
});
}
propertyMove(columnId: string, toAfterOfColumn: InsertToPosition): void {
this.dataUpdate(() => {
const columnIndex = this.computedColumns$.value.findIndex(
v => v.id === columnId
);
if (columnIndex < 0) {
return {};
}
const columns = [...this.computedColumns$.value];
const [column] = columns.splice(columnIndex, 1);
const index = insertPositionToIndex(toAfterOfColumn, columns);
columns.splice(index, 0, column);
return {
columns,
};
});
}
override rowAdd(
insertPosition: InsertToPosition | number,
groupKey?: string
): string {
const id = super.rowAdd(insertPosition);
const filter = this.filter$.value;
if (filter.conditions.length > 0) {
const defaultValues = generateDefaultValues(filter, this.vars$.value);
Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => {
const property = this.propertyGet(propertyId);
const propertyMeta = this.propertyMetaGet(property.type$.value);
if (propertyMeta?.config.cellFromJson) {
const value = propertyMeta.config.cellFromJson({
value: jsonValue,
data: property.data$.value,
dataSource: this.dataSource,
});
this.cellValueSet(id, propertyId, value);
}
});
}
if (groupKey) {
this.groupTrait.addToGroup(id, groupKey);
}
return id;
}
override rowMove(
rowId: string,
position: InsertToPosition,
fromGroup?: string,
toGroup?: string
) {
if (toGroup == null) {
super.rowMove(rowId, position);
return;
}
this.groupTrait.moveCardTo(rowId, fromGroup, toGroup, position);
}
override rowNextGet(rowId: string): string {
const index = this.rows$.value.indexOf(rowId);
return this.rows$.value[index + 1];
}
override rowPrevGet(rowId: string): string {
const index = this.rows$.value.indexOf(rowId);
return this.rows$.value[index - 1];
}
override rowsMapping(rows: string[]) {
return this.sortManager.sort(super.rowsMapping(rows));
}
}
export class TableColumn extends PropertyBase {
statCalcOp$ = computed(() => {
return this.tableView.columnGetStatCalcOp(this.id);
});
width$: ReadonlySignal<number> = computed(() => {
return this.tableView.columnGetWidth(this.id);
});
get minWidth() {
return this.tableView.minWidthGet(this.type$.value);
}
constructor(
private tableView: TableSingleView,
columnId: string
) {
super(tableView as SingleView, columnId);
}
updateStatCalcOp(type?: string): void {
return this.tableView.columnUpdateStatCalcOp(this.id, type);
}
updateWidth(width: number): void {
this.tableView.columnUpdateWidth(this.id, width);
}
}

View File

@@ -0,0 +1,117 @@
export type ColumnType = string;
export interface Column<
Data extends Record<string, unknown> = Record<string, unknown>,
> {
id: string;
type: ColumnType;
name: string;
data: Data;
}
export type StatCalcOpType = string | undefined;
type WithTableViewType<T> = T extends unknown
? {
viewId: string;
type: 'table';
} & T
: never;
export type RowWithGroup = {
id: string;
groupKey?: string;
};
export const RowWithGroup = {
equal(a?: RowWithGroup, b?: RowWithGroup) {
if (a == null || b == null) {
return false;
}
return a.id === b.id && a.groupKey === b.groupKey;
},
};
export type TableRowSelection = {
selectionType: 'row';
rows: RowWithGroup[];
};
export const TableRowSelection = {
rows: (selection?: TableViewSelection): RowWithGroup[] => {
if (selection?.selectionType === 'row') {
return selection.rows;
}
return [];
},
rowsIds: (selection?: TableViewSelection): string[] => {
return TableRowSelection.rows(selection).map(v => v.id);
},
includes(
selection: TableViewSelection | undefined,
row: RowWithGroup
): boolean {
if (!selection) {
return false;
}
return TableRowSelection.rows(selection).some(v =>
RowWithGroup.equal(v, row)
);
},
create(options: { rows: RowWithGroup[] }): TableRowSelection {
return {
selectionType: 'row',
rows: options.rows,
};
},
is(selection?: TableViewSelection): selection is TableRowSelection {
return selection?.selectionType === 'row';
},
};
export type TableAreaSelection = {
selectionType: 'area';
groupKey?: string;
rowsSelection: MultiSelection;
columnsSelection: MultiSelection;
focus: CellFocus;
isEditing: boolean;
};
export const TableAreaSelection = {
create: (options: {
groupKey?: string;
focus: CellFocus;
rowsSelection?: MultiSelection;
columnsSelection?: MultiSelection;
isEditing: boolean;
}): TableAreaSelection => {
return {
...options,
selectionType: 'area',
rowsSelection: options.rowsSelection ?? {
start: options.focus.rowIndex,
end: options.focus.rowIndex,
},
columnsSelection: options.columnsSelection ?? {
start: options.focus.columnIndex,
end: options.focus.columnIndex,
},
};
},
isFocus(selection: TableAreaSelection) {
return (
selection.focus.rowIndex === selection.rowsSelection.start &&
selection.focus.rowIndex === selection.rowsSelection.end &&
selection.focus.columnIndex === selection.columnsSelection.start &&
selection.focus.columnIndex === selection.columnsSelection.end
);
},
};
export type CellFocus = {
rowIndex: number;
columnIndex: number;
};
export type MultiSelection = {
start: number;
end: number;
};
export type TableViewSelection = TableAreaSelection | TableRowSelection;
export type TableViewSelectionWithType = WithTableViewType<
TableAreaSelection | TableRowSelection
>;