mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
45
blocksuite/affine/data-view/package.json
Normal file
45
blocksuite/affine/data-view/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@blocksuite/data-view",
|
||||
"description": "Views of database in affine",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.1.75",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.1",
|
||||
"date-fns": "^4.0.0",
|
||||
"lit": "^3.2.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./property-presets": "./src/property-presets/index.ts",
|
||||
"./property-pure-presets": "./src/property-presets/pure-index.ts",
|
||||
"./view-presets": "./src/view-presets/index.ts",
|
||||
"./widget-presets": "./src/widget-presets/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
]
|
||||
}
|
||||
63
blocksuite/affine/data-view/src/core/common/css-variable.ts
Normal file
63
blocksuite/affine/data-view/src/core/common/css-variable.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const dataViewCssVariable = () => {
|
||||
return `
|
||||
--data-view-cell-text-size:14px;
|
||||
--data-view-cell-text-line-height:22px;
|
||||
`;
|
||||
};
|
||||
export const dataViewCommonStyle = (selector: string) => `
|
||||
${selector}{
|
||||
${dataViewCssVariable()}
|
||||
}
|
||||
.with-data-view-css-variable{
|
||||
${dataViewCssVariable()}
|
||||
font-family: var(--affine-font-family)
|
||||
}
|
||||
.dv-pd-2{
|
||||
padding:2px;
|
||||
}
|
||||
.dv-pd-4{
|
||||
padding:4px;
|
||||
}
|
||||
.dv-pd-8{
|
||||
padding:8px;
|
||||
}
|
||||
.dv-hover:hover, .dv-hover.active{
|
||||
background-color: var(--affine-hover-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dv-icon-16{
|
||||
font-size: 16px;
|
||||
}
|
||||
.dv-icon-16 svg{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-color);
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
.dv-icon-20 svg{
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--affine-icon-color);
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
.dv-border{
|
||||
border: 1px solid var(--affine-border-color);
|
||||
}
|
||||
.dv-round-4{
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dv-round-8{
|
||||
border-radius: 8px;
|
||||
}
|
||||
.dv-color-2{
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
.dv-shadow-2{
|
||||
box-shadow: var(--affine-shadow-2)
|
||||
}
|
||||
.dv-divider-h{
|
||||
height: 1px;
|
||||
background-color: var(--affine-divider-color);
|
||||
margin: 8px 0;
|
||||
}
|
||||
`;
|
||||
9
blocksuite/affine/data-view/src/core/common/index.ts
Normal file
9
blocksuite/affine/data-view/src/core/common/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from '../data-source/index.js';
|
||||
export * from '../detail/detail.js';
|
||||
export * from '../group-by/default.js';
|
||||
export * from '../group-by/matcher.js';
|
||||
export type { GroupByConfig } from '../group-by/types.js';
|
||||
export type { GroupRenderProps } from '../group-by/types.js';
|
||||
export * from './css-variable.js';
|
||||
export * from './selection-schema.js';
|
||||
export * from './types.js';
|
||||
287
blocksuite/affine/data-view/src/core/common/properties.ts
Normal file
287
blocksuite/affine/data-view/src/core/common/properties.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { InvisibleIcon, ViewIcon } from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { dragHandler } from '../utils/wc-dnd/dnd-context.js';
|
||||
import { defaultActivators } from '../utils/wc-dnd/sensors/index.js';
|
||||
import {
|
||||
createSortContext,
|
||||
sortable,
|
||||
} from '../utils/wc-dnd/sort/sort-context.js';
|
||||
import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js';
|
||||
import type { Property } from '../view-manager/property.js';
|
||||
import type { SingleView } from '../view-manager/single-view.js';
|
||||
|
||||
export class DataViewPropertiesSettingView extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.properties-group-header {
|
||||
user-select: none;
|
||||
padding: 4px 12px 12px 12px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--affine-divider-color);
|
||||
}
|
||||
|
||||
.properties-group-title {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--affine-text-secondary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.properties-group-op {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.properties-group-op:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.properties-group {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.property-item {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.property-item-drag-bar {
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
border-radius: 1px;
|
||||
background-color: #efeff0;
|
||||
}
|
||||
|
||||
.property-item:hover .property-item-drag-bar {
|
||||
background-color: #c0bfc1;
|
||||
}
|
||||
|
||||
.property-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.property-item-icon svg {
|
||||
color: var(--affine-icon-color);
|
||||
fill: var(--affine-icon-color);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.property-item-op-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.property-item-op-icon:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.property-item-op-icon.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.property-item-op-icon svg {
|
||||
fill: var(--affine-icon-color);
|
||||
color: var(--affine-icon-color);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.property-item-op-icon.disabled svg {
|
||||
fill: var(--affine-text-disable-color);
|
||||
color: var(--affine-text-disable-color);
|
||||
}
|
||||
|
||||
.property-item-name {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
flex: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: SingleView;
|
||||
|
||||
items$ = computed(() => {
|
||||
return this.view.propertiesWithoutFilter$.value;
|
||||
});
|
||||
|
||||
renderProperty = (property: Property) => {
|
||||
const isTitle = property.type$.value === 'title';
|
||||
const icon = property.hide$.value ? InvisibleIcon() : ViewIcon();
|
||||
const changeVisible = () => {
|
||||
if (property.type$.value !== 'title') {
|
||||
property.hideSet(!property.hide$.value);
|
||||
}
|
||||
};
|
||||
const classList = classMap({
|
||||
'property-item-op-icon': true,
|
||||
disabled: isTitle,
|
||||
});
|
||||
return html` <div
|
||||
${dragHandler(property.id)}
|
||||
${sortable(property.id)}
|
||||
class="property-item"
|
||||
>
|
||||
<div class="property-item-drag-bar"></div>
|
||||
<uni-lit class="property-item-icon" .uni="${property.icon}"></uni-lit>
|
||||
<div class="property-item-name">${property.name$.value}</div>
|
||||
<div class="${classList}" @click="${changeVisible}">${icon}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
sortContext = createSortContext({
|
||||
activators: defaultActivators,
|
||||
container: this,
|
||||
onDragEnd: evt => {
|
||||
const over = evt.over;
|
||||
const activeId = evt.active.id;
|
||||
if (over && over.id !== activeId) {
|
||||
const properties = this.items$.value;
|
||||
const activeIndex = properties.findIndex(id => id === activeId);
|
||||
const overIndex = properties.findIndex(id => id === over.id);
|
||||
|
||||
this.view.propertyMove(
|
||||
activeId,
|
||||
activeIndex > overIndex
|
||||
? {
|
||||
before: true,
|
||||
id: over.id,
|
||||
}
|
||||
: {
|
||||
before: false,
|
||||
id: over.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
modifiers: [
|
||||
({ transform }) => {
|
||||
return {
|
||||
...transform,
|
||||
x: 0,
|
||||
};
|
||||
},
|
||||
],
|
||||
items: this.items$,
|
||||
strategy: verticalListSortingStrategy,
|
||||
});
|
||||
|
||||
private itemsGroup() {
|
||||
return this.view.propertiesWithoutFilter$.value.map(id =>
|
||||
this.view.propertyGet(id)
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const items = this.itemsGroup();
|
||||
return html`
|
||||
<div class="properties-group">
|
||||
${repeat(items, v => v.id, this.renderProperty)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.properties-group')
|
||||
accessor groupContainer!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onBack: (() => void) | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-properties-setting': DataViewPropertiesSettingView;
|
||||
}
|
||||
}
|
||||
|
||||
export const popPropertiesSetting = (
|
||||
target: PopupTarget,
|
||||
props: {
|
||||
view: SingleView;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
) => {
|
||||
popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Properties',
|
||||
onBack: props.onBack,
|
||||
postfix: () => {
|
||||
const items = props.view.propertiesWithoutFilter$.value.map(id =>
|
||||
props.view.propertyGet(id)
|
||||
);
|
||||
const isAllShowed = items.every(v => !v.hide$.value);
|
||||
const clickChangeAll = () => {
|
||||
props.view.propertiesWithoutFilter$.value.forEach(id => {
|
||||
if (props.view.propertyTypeGet(id) !== 'title') {
|
||||
props.view.propertyHideSet(id, isAllShowed);
|
||||
}
|
||||
});
|
||||
};
|
||||
return html` <div
|
||||
class="properties-group-op"
|
||||
@click="${clickChangeAll}"
|
||||
>
|
||||
${isAllShowed ? 'Hide All' : 'Show All'}
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
() =>
|
||||
html` <data-view-properties-setting
|
||||
.view="${props.view}"
|
||||
></data-view-properties-setting>`,
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// const view = new DataViewPropertiesSettingView();
|
||||
// view.view = props.view;
|
||||
// view.onBack = () => {
|
||||
// close();
|
||||
// props.onBack?.();
|
||||
// };
|
||||
// const close = createPopup(target, view, { onClose: props.onClose });
|
||||
};
|
||||
76
blocksuite/affine/data-view/src/core/common/property-menu.ts
Normal file
76
blocksuite/affine/data-view/src/core/common/property-menu.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { renderUniLit } from '../utils/uni-component/index.js';
|
||||
import type { Property } from '../view-manager/property.js';
|
||||
|
||||
export const inputConfig = (property: Property) => {
|
||||
if (IS_MOBILE) {
|
||||
return menu.input({
|
||||
prefix: html`
|
||||
<div class="affine-database-column-type-menu-icon">
|
||||
${renderUniLit(property.icon)}
|
||||
</div>
|
||||
`,
|
||||
initialValue: property.name$.value,
|
||||
onChange: text => {
|
||||
property.nameSet(text);
|
||||
},
|
||||
});
|
||||
}
|
||||
return menu.input({
|
||||
prefix: html`
|
||||
<div class="affine-database-column-type-menu-icon">
|
||||
${renderUniLit(property.icon)}
|
||||
</div>
|
||||
`,
|
||||
initialValue: property.name$.value,
|
||||
onComplete: text => {
|
||||
property.nameSet(text);
|
||||
},
|
||||
});
|
||||
};
|
||||
export const typeConfig = (property: Property) => {
|
||||
return menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
name: 'Type',
|
||||
hide: () => !property.typeSet || property.type$.value === 'title',
|
||||
postfix: html` <div
|
||||
class="affine-database-column-type-icon"
|
||||
style="color: var(--affine-text-secondary-color);gap:4px;font-size: 14px;"
|
||||
>
|
||||
${renderUniLit(property.icon)}
|
||||
${property.view.propertyMetas.find(
|
||||
v => v.type === property.type$.value
|
||||
)?.config.name}
|
||||
</div>`,
|
||||
options: {
|
||||
title: {
|
||||
text: 'Property type',
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: property.view.propertyMetas.map(config => {
|
||||
return menu.action({
|
||||
isSelected: config.type === property.type$.value,
|
||||
name: config.config.name,
|
||||
prefix: renderUniLit(
|
||||
property.view.propertyIconGet(config.type)
|
||||
),
|
||||
select: () => {
|
||||
if (property.type$.value === config.type) {
|
||||
return;
|
||||
}
|
||||
property.typeSet?.(config.type);
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
133
blocksuite/affine/data-view/src/core/common/selection-schema.ts
Normal file
133
blocksuite/affine/data-view/src/core/common/selection-schema.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { BaseSelection, SelectionExtension } from '@blocksuite/block-std';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { DataViewSelection, GetDataViewSelection } from '../types.js';
|
||||
|
||||
const TableViewSelectionSchema = z.union([
|
||||
z.object({
|
||||
viewId: z.string(),
|
||||
type: z.literal('table'),
|
||||
selectionType: z.literal('area'),
|
||||
rowsSelection: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
columnsSelection: z.object({
|
||||
start: z.number(),
|
||||
end: z.number(),
|
||||
}),
|
||||
focus: z.object({
|
||||
rowIndex: z.number(),
|
||||
columnIndex: z.number(),
|
||||
}),
|
||||
isEditing: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
viewId: z.string(),
|
||||
type: z.literal('table'),
|
||||
selectionType: z.literal('row'),
|
||||
rows: z.array(
|
||||
z.object({ id: z.string(), groupKey: z.string().optional() })
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
const KanbanCellSelectionSchema = z.object({
|
||||
selectionType: z.literal('cell'),
|
||||
groupKey: z.string(),
|
||||
cardId: z.string(),
|
||||
columnId: z.string(),
|
||||
isEditing: z.boolean(),
|
||||
});
|
||||
|
||||
const KanbanCardSelectionSchema = z.object({
|
||||
selectionType: z.literal('card'),
|
||||
cards: z.array(
|
||||
z.object({
|
||||
groupKey: z.string(),
|
||||
cardId: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
const KanbanGroupSelectionSchema = z.object({
|
||||
selectionType: z.literal('group'),
|
||||
groupKeys: z.array(z.string()),
|
||||
});
|
||||
|
||||
const DatabaseSelectionSchema = z.object({
|
||||
blockId: z.string(),
|
||||
viewSelection: z.union([
|
||||
TableViewSelectionSchema,
|
||||
KanbanCellSelectionSchema,
|
||||
KanbanCardSelectionSchema,
|
||||
KanbanGroupSelectionSchema,
|
||||
]),
|
||||
});
|
||||
|
||||
export class DatabaseSelection extends BaseSelection {
|
||||
static override group = 'note';
|
||||
|
||||
static override type = 'database';
|
||||
|
||||
readonly viewSelection: DataViewSelection;
|
||||
|
||||
get viewId() {
|
||||
return this.viewSelection.viewId;
|
||||
}
|
||||
|
||||
constructor({
|
||||
blockId,
|
||||
viewSelection,
|
||||
}: {
|
||||
blockId: string;
|
||||
viewSelection: DataViewSelection;
|
||||
}) {
|
||||
super({
|
||||
blockId,
|
||||
});
|
||||
|
||||
this.viewSelection = viewSelection;
|
||||
}
|
||||
|
||||
static override fromJSON(json: Record<string, unknown>): DatabaseSelection {
|
||||
DatabaseSelectionSchema.parse(json);
|
||||
return new DatabaseSelection({
|
||||
blockId: json.blockId as string,
|
||||
viewSelection: json.viewSelection as DataViewSelection,
|
||||
});
|
||||
}
|
||||
|
||||
override equals(other: BaseSelection): boolean {
|
||||
if (!(other instanceof DatabaseSelection)) {
|
||||
return false;
|
||||
}
|
||||
return this.blockId === other.blockId;
|
||||
}
|
||||
|
||||
getSelection<T extends DataViewSelection['type']>(
|
||||
type: T
|
||||
): GetDataViewSelection<T> | undefined {
|
||||
return this.viewSelection.type === type
|
||||
? (this.viewSelection as GetDataViewSelection<T>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
override toJSON(): Record<string, unknown> {
|
||||
return {
|
||||
type: 'database',
|
||||
blockId: this.blockId,
|
||||
viewSelection: this.viewSelection,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface Selection {
|
||||
database: typeof DatabaseSelection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DatabaseSelectionExtension = SelectionExtension(DatabaseSelection);
|
||||
13
blocksuite/affine/data-view/src/core/common/types.ts
Normal file
13
blocksuite/affine/data-view/src/core/common/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type GroupBy = {
|
||||
type: 'groupBy';
|
||||
columnId: string;
|
||||
name: string;
|
||||
sort?: {
|
||||
desc: boolean;
|
||||
};
|
||||
};
|
||||
export type GroupProperty = {
|
||||
key: string;
|
||||
hide?: boolean;
|
||||
manuallyCardSort: string[];
|
||||
};
|
||||
@@ -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 { css, html, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class Button extends SignalWatcher(WithDisposable(ShadowlessElement)) {
|
||||
static override styles = css`
|
||||
data-view-component-button {
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background-color 0.2s,
|
||||
border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
data-view-component-button.border:hover,
|
||||
data-view-component-button.border.active {
|
||||
color: ${unsafeCSSVarV2('text/emphasis')};
|
||||
border-color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
|
||||
data-view-component-button.background:hover,
|
||||
data-view-component-button.background.active {
|
||||
background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.button-icon {
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color 0.2s;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
|
||||
data-view-component-button.border:hover .button-icon,
|
||||
data-view-component-button.border.active .button-icon {
|
||||
color: ${unsafeCSSVarV2('icon/activated')};
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(this.hoverType);
|
||||
if (this.onClick) {
|
||||
this.disposables.addFromEvent(this, 'click', this.onClick);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="button-icon">${this.icon}</div>
|
||||
${this.text}
|
||||
<div class="button-icon">${this.postfix}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property()
|
||||
accessor hoverType: 'background' | 'border' = 'background';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor icon: TemplateResult | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onClick: ((event: MouseEvent) => void) | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor postfix: TemplateResult | string | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor text: TemplateResult | string | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'data-view-component-button': Button;
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/data-view/src/core/component/index.ts
Normal file
3
blocksuite/affine/data-view/src/core/component/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './button/button.js';
|
||||
export * from './overflow/overflow.js';
|
||||
export * from './tags/index.js';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html, type PropertyValues, type TemplateResult } from 'lit';
|
||||
import { property, query, queryAll, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
export class Overflow extends SignalWatcher(WithDisposable(ShadowlessElement)) {
|
||||
static override styles = css`
|
||||
component-overflow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.component-overflow-item {
|
||||
}
|
||||
.component-overflow-item.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
`;
|
||||
|
||||
protected frameId: number | undefined = undefined;
|
||||
|
||||
protected widthList: number[] = [];
|
||||
|
||||
adjustStyle() {
|
||||
if (this.frameId) {
|
||||
cancelAnimationFrame(this.frameId);
|
||||
}
|
||||
|
||||
this.frameId = requestAnimationFrame(() => {
|
||||
this.doAdjustStyle();
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
const resize = new ResizeObserver(() => {
|
||||
this.adjustStyle();
|
||||
});
|
||||
resize.observe(this);
|
||||
this.disposables.add(() => {
|
||||
resize.unobserve(this);
|
||||
});
|
||||
}
|
||||
|
||||
protected doAdjustStyle() {
|
||||
const moreWidth = this.more.getBoundingClientRect().width;
|
||||
this.widthList[this.renderCount] = moreWidth;
|
||||
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
|
||||
let width = 0;
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const itemWidth = this.items[i].getBoundingClientRect().width;
|
||||
// Try to calculate the width occupied by rendering n+1 items;
|
||||
// if it exceeds the limit, render n items(in i++ round).
|
||||
const totalWidth =
|
||||
width + itemWidth + (this.widthList[i + 1] ?? moreWidth);
|
||||
if (totalWidth > containerWidth) {
|
||||
this.renderCount = i;
|
||||
return;
|
||||
}
|
||||
width += itemWidth;
|
||||
}
|
||||
this.renderCount = this.items.length;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${repeat(this.renderItem, (render, index) => {
|
||||
const className = classMap({
|
||||
'component-overflow-item': true,
|
||||
hidden: index >= this.renderCount,
|
||||
});
|
||||
return html`<div class="${className}">${render()}</div>`;
|
||||
})}
|
||||
<div class="component-overflow-more">
|
||||
${this.renderMore(this.renderCount)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override updated(_changedProperties: PropertyValues) {
|
||||
super.updated(_changedProperties);
|
||||
this.adjustStyle();
|
||||
}
|
||||
|
||||
@queryAll(':scope > .component-overflow-item')
|
||||
accessor items!: HTMLDivElement[] & NodeList;
|
||||
|
||||
@query(':scope > .component-overflow-more')
|
||||
accessor more!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
accessor renderCount = 0;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor renderItem!: Array<() => TemplateResult>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor renderMore!: (count: number) => TemplateResult;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
|
||||
export type SelectOptionColor = {
|
||||
oldColor: string;
|
||||
color: string;
|
||||
name: string;
|
||||
};
|
||||
export const selectOptionColors: SelectOptionColor[] = [
|
||||
{
|
||||
oldColor: 'var(--affine-tag-red)',
|
||||
color: cssVarV2('chip/label/red'),
|
||||
name: 'Red',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-pink)',
|
||||
color: cssVarV2('chip/label/magenta'),
|
||||
name: 'Magenta',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-orange)',
|
||||
color: cssVarV2('chip/label/orange'),
|
||||
name: 'Orange',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-yellow)',
|
||||
color: cssVarV2('chip/label/yellow'),
|
||||
name: 'Yellow',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-green)',
|
||||
color: cssVarV2('chip/label/green'),
|
||||
name: 'Green',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-teal)',
|
||||
color: cssVarV2('chip/label/teal'),
|
||||
name: 'Teal',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-blue)',
|
||||
color: cssVarV2('chip/label/blue'),
|
||||
name: 'Blue',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-purple)',
|
||||
color: cssVarV2('chip/label/purple'),
|
||||
name: 'Purple',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-gray)',
|
||||
color: cssVarV2('chip/label/grey'),
|
||||
name: 'Grey',
|
||||
},
|
||||
{
|
||||
oldColor: 'var(--affine-tag-white)',
|
||||
color: cssVarV2('chip/label/white'),
|
||||
name: 'White',
|
||||
},
|
||||
];
|
||||
|
||||
const oldColorMap = Object.fromEntries(
|
||||
selectOptionColors.map(tag => [tag.oldColor, tag.color])
|
||||
);
|
||||
|
||||
export const getColorByColor = (color: string) => {
|
||||
if (color.startsWith('--affine-tag')) {
|
||||
return oldColorMap[color] ?? color;
|
||||
}
|
||||
return color;
|
||||
};
|
||||
|
||||
/** select tag color poll */
|
||||
const selectTagColorPoll = selectOptionColors.map(color => color.color);
|
||||
|
||||
function tagColorHelper() {
|
||||
let colors = [...selectTagColorPoll];
|
||||
return () => {
|
||||
if (colors.length === 0) {
|
||||
colors = [...selectTagColorPoll];
|
||||
}
|
||||
const index = Math.floor(Math.random() * colors.length);
|
||||
const color = colors.splice(index, 1)[0];
|
||||
return color;
|
||||
};
|
||||
}
|
||||
|
||||
export const getTagColor = tagColorHelper();
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './colors.js';
|
||||
export * from './multi-tag-select.js';
|
||||
export * from './multi-tag-view.js';
|
||||
@@ -0,0 +1,574 @@
|
||||
import {
|
||||
createPopup,
|
||||
menu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { rangeWrap } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import {
|
||||
CloseIcon,
|
||||
DeleteIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.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 { SelectTag } from '../../logical/index.js';
|
||||
import { stopPropagation } from '../../utils/event.js';
|
||||
import { dragHandler } from '../../utils/wc-dnd/dnd-context.js';
|
||||
import { defaultActivators } from '../../utils/wc-dnd/sensors/index.js';
|
||||
import {
|
||||
createSortContext,
|
||||
sortable,
|
||||
} from '../../utils/wc-dnd/sort/sort-context.js';
|
||||
import { verticalListSortingStrategy } from '../../utils/wc-dnd/sort/strategies/index.js';
|
||||
import { arrayMove } from '../../utils/wc-dnd/utils/array-move.js';
|
||||
import { getTagColor, selectOptionColors } from './colors.js';
|
||||
import { styles } from './styles.js';
|
||||
|
||||
type RenderOption = {
|
||||
value: string;
|
||||
id: string;
|
||||
color: string;
|
||||
isCreate: boolean;
|
||||
select: () => void;
|
||||
};
|
||||
export type TagManagerOptions = {
|
||||
mode?: 'single' | 'multi';
|
||||
value: ReadonlySignal<string[]>;
|
||||
onChange: (value: string[]) => void;
|
||||
options: ReadonlySignal<SelectTag[]>;
|
||||
onOptionsChange: (options: SelectTag[]) => void;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
class TagManager {
|
||||
changeTag = (option: SelectTag) => {
|
||||
this.ops.onOptionsChange(
|
||||
this.ops.options.value.map(item => {
|
||||
if (item.id === option.id) {
|
||||
return {
|
||||
...item,
|
||||
...option,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
color = signal(getTagColor());
|
||||
|
||||
createOption = () => {
|
||||
const value = this.text.value.trim();
|
||||
if (value === '') return;
|
||||
const id = nanoid();
|
||||
this.ops.onOptionsChange([
|
||||
{
|
||||
id: id,
|
||||
value: value,
|
||||
color: this.color.value,
|
||||
},
|
||||
...this.ops.options.value,
|
||||
]);
|
||||
this.selectTag(id);
|
||||
this.text.value = '';
|
||||
this.color.value = getTagColor();
|
||||
if (this.isSingleMode) {
|
||||
this.ops.onComplete?.();
|
||||
}
|
||||
};
|
||||
|
||||
deleteOption = (id: string) => {
|
||||
this.ops.onOptionsChange(
|
||||
this.ops.options.value.filter(item => item.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
filteredOptions$ = computed(() => {
|
||||
let matched = false;
|
||||
const options: RenderOption[] = [];
|
||||
for (const option of this.options.value) {
|
||||
if (
|
||||
!this.text.value ||
|
||||
option.value
|
||||
.toLocaleLowerCase()
|
||||
.includes(this.text.value.toLocaleLowerCase())
|
||||
) {
|
||||
options.push({
|
||||
...option,
|
||||
isCreate: false,
|
||||
select: () => this.selectTag(option.id),
|
||||
});
|
||||
}
|
||||
if (option.value === this.text.value) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
if (this.text.value && !matched) {
|
||||
options.push({
|
||||
id: 'create',
|
||||
color: this.color.value,
|
||||
value: this.text.value,
|
||||
isCreate: true,
|
||||
select: this.createOption,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
optionsMap$ = computed(() => {
|
||||
return new Map<string, SelectTag>(
|
||||
this.ops.options.value.map(v => [v.id, v])
|
||||
);
|
||||
});
|
||||
|
||||
text = signal('');
|
||||
|
||||
get isSingleMode() {
|
||||
return this.ops.mode === 'single';
|
||||
}
|
||||
|
||||
get options() {
|
||||
return this.ops.options;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.ops.value;
|
||||
}
|
||||
|
||||
constructor(private ops: TagManagerOptions) {}
|
||||
|
||||
deleteTag(id: string) {
|
||||
this.ops.onChange(this.value.value.filter(item => item !== id));
|
||||
}
|
||||
|
||||
isSelected(id: string) {
|
||||
return this.value.value.includes(id);
|
||||
}
|
||||
|
||||
selectTag(id: string) {
|
||||
if (this.isSelected(id)) {
|
||||
return;
|
||||
}
|
||||
const newValue = this.isSingleMode ? [id] : [...this.value.value, id];
|
||||
this.ops.onChange(newValue);
|
||||
this.text.value = '';
|
||||
if (this.isSingleMode) {
|
||||
requestAnimationFrame(() => {
|
||||
this.ops.onComplete?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiTagSelect extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
private _clickItemOption = (e: MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
const option = this.options.value.find(v => v.id === id);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), {
|
||||
options: {
|
||||
items: [
|
||||
menu.input({
|
||||
initialValue: option.value,
|
||||
onChange: text => {
|
||||
this.tagManager.changeTag({
|
||||
...option,
|
||||
value: text,
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Delete',
|
||||
prefix: DeleteIcon(),
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
select: () => {
|
||||
this.tagManager.deleteOption(id);
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
name: 'color',
|
||||
items: selectOptionColors.map(item => {
|
||||
const styles = styleMap({
|
||||
backgroundColor: item.color,
|
||||
borderRadius: '50%',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
});
|
||||
return menu.action({
|
||||
name: item.name,
|
||||
prefix: html` <div style=${styles}></div>`,
|
||||
isSelected: option.color === item.color,
|
||||
select: () => {
|
||||
this.tagManager.changeTag({
|
||||
...option,
|
||||
color: item.color,
|
||||
});
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private _onInput = (event: KeyboardEvent) => {
|
||||
this.tagManager.text.value = (event.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
private _onInputKeydown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
const inputValue = this.text.value.trim();
|
||||
if (event.key === 'Backspace' && inputValue === '') {
|
||||
this.tagManager.deleteTag(this.value.value[this.value.value.length - 1]);
|
||||
} else if (event.key === 'Enter' && !event.isComposing) {
|
||||
this.selectedTag$.value?.select();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.setSelectedOption(this.selectedIndex - 1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.setSelectedOption(this.selectedIndex + 1);
|
||||
} else if (event.key === 'Escape') {
|
||||
this.onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
private tagManager = new TagManager(this);
|
||||
|
||||
private selectedTag$ = computed(() => {
|
||||
return this.tagManager.filteredOptions$.value[this.selectedIndex];
|
||||
});
|
||||
|
||||
sortContext = createSortContext({
|
||||
activators: defaultActivators,
|
||||
container: this,
|
||||
onDragEnd: evt => {
|
||||
const over = evt.over;
|
||||
const activeId = evt.active.id;
|
||||
if (over && over.id !== activeId) {
|
||||
this.onOptionsChange(
|
||||
arrayMove(
|
||||
this.options.value,
|
||||
this.options.value.findIndex(v => v.id === activeId),
|
||||
this.options.value.findIndex(v => v.id === over.id)
|
||||
)
|
||||
);
|
||||
this.requestUpdate();
|
||||
// const properties = this.filteredOptions$.value.map(v=>v.id);
|
||||
// const activeIndex = properties.findIndex(id => id === activeId);
|
||||
// const overIndex = properties.findIndex(id => id === over.id);
|
||||
}
|
||||
},
|
||||
modifiers: [
|
||||
({ transform }) => {
|
||||
return {
|
||||
...transform,
|
||||
x: 0,
|
||||
};
|
||||
},
|
||||
],
|
||||
items: computed(() => {
|
||||
return this.tagManager.filteredOptions$.value.map(v => v.id);
|
||||
}),
|
||||
strategy: verticalListSortingStrategy,
|
||||
});
|
||||
|
||||
private get text() {
|
||||
return this.tagManager.text;
|
||||
}
|
||||
|
||||
private renderInput() {
|
||||
return html`
|
||||
<div class="tag-select-input-container">
|
||||
${this.value.value.map(id => {
|
||||
const option = this.tagManager.optionsMap$.value.get(id);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
return this.renderTag(option.value, option.color, () =>
|
||||
this.tagManager.deleteTag(id)
|
||||
);
|
||||
})}
|
||||
<input
|
||||
class="tag-select-input"
|
||||
placeholder="Type here..."
|
||||
.value="${this.text.value}"
|
||||
@input="${this._onInput}"
|
||||
@keydown="${this._onInputKeydown}"
|
||||
@pointerdown="${stopPropagation}"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTag(name: string, color: string, onDelete?: () => void) {
|
||||
const style = styleMap({
|
||||
backgroundColor: color,
|
||||
});
|
||||
return html` <div class="tag-container" style=${style}>
|
||||
<div class="tag-text">${name}</div>
|
||||
${onDelete
|
||||
? html` <div class="tag-delete-icon" @click="${onDelete}">
|
||||
${CloseIcon()}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderTags() {
|
||||
return html`
|
||||
<div
|
||||
style="height: 0.5px;background-color: ${cssVarV2(
|
||||
'layer/insideBorder/border'
|
||||
)};margin: 4px 0;"
|
||||
></div>
|
||||
<div class="select-options-tips">Select tag or create one</div>
|
||||
<div class="select-options-container">
|
||||
${repeat(
|
||||
this.tagManager.filteredOptions$.value,
|
||||
select => select.id,
|
||||
(select, index) => {
|
||||
const isSelected = index === this.selectedIndex;
|
||||
const mouseenter = () => {
|
||||
this.setSelectedOption(index);
|
||||
};
|
||||
const classes = classMap({
|
||||
'select-option': true,
|
||||
selected: isSelected,
|
||||
});
|
||||
const clickOption = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
this._clickItemOption(e, select.id);
|
||||
};
|
||||
return html`
|
||||
<div
|
||||
${!select.isCreate ? sortable(select.id) : nothing}
|
||||
class="${classes}"
|
||||
@mouseenter="${mouseenter}"
|
||||
@click="${select.select}"
|
||||
>
|
||||
<div class="select-option-content">
|
||||
${select.isCreate
|
||||
? html` <div class="select-option-new-icon">Create</div>`
|
||||
: html`
|
||||
<div
|
||||
${dragHandler(select.id)}
|
||||
class="select-option-drag-handler"
|
||||
></div>
|
||||
`}
|
||||
${this.renderTag(select.value, select.color)}
|
||||
</div>
|
||||
${!select.isCreate
|
||||
? html` <div
|
||||
class="select-option-icon"
|
||||
@click="${clickOption}"
|
||||
>
|
||||
${MoreHorizontalIcon()}
|
||||
</div>`
|
||||
: null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private setSelectedOption(index: number) {
|
||||
this.selectedIndex = rangeWrap(
|
||||
index,
|
||||
0,
|
||||
this.tagManager.filteredOptions$.value.length
|
||||
);
|
||||
}
|
||||
|
||||
protected override firstUpdated() {
|
||||
requestAnimationFrame(() => {
|
||||
this._selectInput.focus();
|
||||
});
|
||||
this._disposables.addFromEvent(this, 'click', () => {
|
||||
this._selectInput.focus();
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(this._selectInput, 'copy', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
this._disposables.addFromEvent(this._selectInput, 'cut', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
this.setSelectedOption(this.selectedIndex);
|
||||
return html` ${this.renderInput()} ${this.renderTags()} `;
|
||||
}
|
||||
|
||||
@query('.tag-select-input')
|
||||
private accessor _selectInput!: HTMLInputElement;
|
||||
|
||||
@property()
|
||||
accessor mode: 'multi' | 'single' = 'multi';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onChange!: (value: string[]) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onComplete!: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onOptionsChange!: (options: SelectTag[]) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options!: ReadonlySignal<SelectTag[]>;
|
||||
|
||||
@state()
|
||||
private accessor selectedIndex = 0;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value!: ReadonlySignal<string[]>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-multi-tag-select': MultiTagSelect;
|
||||
}
|
||||
}
|
||||
|
||||
const popMobileTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
|
||||
const tagManager = new TagManager(ops);
|
||||
const onInput = (e: InputEvent) => {
|
||||
tagManager.text.value = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
return popMenu(target, {
|
||||
options: {
|
||||
onClose: () => {
|
||||
ops.onComplete?.();
|
||||
},
|
||||
title: {
|
||||
text: ops.name,
|
||||
},
|
||||
items: [
|
||||
() => {
|
||||
return html`
|
||||
<div
|
||||
style="padding: 12px;border-radius: 12px;background-color: ${unsafeCSSVarV2(
|
||||
'layer/background/primary'
|
||||
)};display: flex;gap:8px 12px;"
|
||||
>
|
||||
${ops.value.value.map(id => {
|
||||
const option = ops.options.value.find(v => v.id === id);
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const style = styleMap({
|
||||
backgroundColor: option.color,
|
||||
width: 'max-content',
|
||||
});
|
||||
return html` <div class="tag-container" style=${style}>
|
||||
<div class="tag-text">${option.value}</div>
|
||||
</div>`;
|
||||
})}
|
||||
<input
|
||||
.value="${tagManager.text.value}"
|
||||
@input="${onInput}"
|
||||
placeholder="Type here..."
|
||||
type="text"
|
||||
style="outline: none;border: none;flex:1;min-width: 10px"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
menu.group({
|
||||
items: [
|
||||
menu.dynamic(() => {
|
||||
const options = tagManager.filteredOptions$.value;
|
||||
return options.map(option =>
|
||||
menu.action({
|
||||
name: option.value,
|
||||
label: () => {
|
||||
const style = styleMap({
|
||||
backgroundColor: option.color,
|
||||
width: 'max-content',
|
||||
});
|
||||
return html`
|
||||
<div style="display: flex; align-items:center;">
|
||||
${option.isCreate
|
||||
? html` <div style="margin-right: 8px;">Create</div>`
|
||||
: ''}
|
||||
<div class="tag-container" style=${style}>
|
||||
<div class="tag-text">${option.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
select: () => {
|
||||
option.select();
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export type TagSelectOptions = {
|
||||
name: string;
|
||||
minWidth?: number;
|
||||
container?: HTMLElement;
|
||||
} & TagManagerOptions;
|
||||
export const popTagSelect = (target: PopupTarget, ops: TagSelectOptions) => {
|
||||
if (IS_MOBILE) {
|
||||
const handler = popMobileTagSelect(target, ops);
|
||||
return () => {
|
||||
handler.close();
|
||||
};
|
||||
}
|
||||
const component = new MultiTagSelect();
|
||||
if (ops.mode) {
|
||||
component.mode = ops.mode;
|
||||
}
|
||||
const width = target.targetRect.getBoundingClientRect().width;
|
||||
component.style.width = `${Math.max(ops.minWidth ?? width, width)}px`;
|
||||
component.value = ops.value;
|
||||
component.onChange = ops.onChange;
|
||||
component.options = ops.options;
|
||||
component.onOptionsChange = ops.onOptionsChange;
|
||||
component.onComplete = () => {
|
||||
ops.onComplete?.();
|
||||
remove();
|
||||
};
|
||||
const remove = createPopup(target, component, {
|
||||
onClose: ops.onComplete,
|
||||
middleware: [flip(), offset({ mainAxis: -28, crossAxis: 112 })],
|
||||
container: ops.container,
|
||||
});
|
||||
return remove;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css } 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 { SelectTag } from '../../logical/index.js';
|
||||
import { getColorByColor } from './colors.js';
|
||||
|
||||
export class MultiTagView extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
affine-multi-tag-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.affine-select-cell-container * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.affine-select-cell-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
font-size: var(--affine-font-sm);
|
||||
}
|
||||
|
||||
.affine-select-cell-container .select-selected {
|
||||
height: 22px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: var(--affine-tag-white);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border: 1px solid ${unsafeCSSVarV2('database/border')};
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
const values = this.value;
|
||||
const map = new Map<string, SelectTag>(this.options?.map(v => [v.id, v]));
|
||||
return html`
|
||||
<div contenteditable="false" class="affine-select-cell-container">
|
||||
${repeat(values, id => {
|
||||
const option = map.get(id);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
const style = styleMap({
|
||||
backgroundColor: getColorByColor(option.color),
|
||||
});
|
||||
return html`<span class="select-selected" style=${style}
|
||||
>${option.value}</span
|
||||
>`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor options: SelectTag[] = [];
|
||||
|
||||
@query('.affine-select-cell-container')
|
||||
accessor selectContainer!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value: string[] = [];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-multi-tag-view': MultiTagView;
|
||||
}
|
||||
}
|
||||
251
blocksuite/affine/data-view/src/core/component/tags/styles.ts
Normal file
251
blocksuite/affine/data-view/src/core/component/tags/styles.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
affine-multi-tag-select {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/blackBorder')};
|
||||
border-radius: 8px;
|
||||
background: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
font-family: var(--affine-font-family);
|
||||
max-width: 400px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
affine-multi-tag-select {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-select-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tag-select-input {
|
||||
flex: 1 1 0;
|
||||
border: none;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
background-color: transparent;
|
||||
line-height: 22px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tag-select-input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
|
||||
.select-options-tips {
|
||||
padding: 4px;
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.select-options-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 4px 4px 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
gap: 4px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
border: 1px solid ${unsafeCSSVarV2('database/border')};
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tag-text {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag-delete-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${unsafeCSSVarV2('chip/label/text')};
|
||||
}
|
||||
|
||||
.select-option.selected {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
.select-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.select-option-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.select-option.selected .select-option-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.select-option-icon:hover {
|
||||
background: ${unsafeCSSVarV2('layer/background/hoverOverlay')};
|
||||
}
|
||||
|
||||
.select-option-drag-handler {
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
border-radius: 1px;
|
||||
background-color: ${unsafeCSSVarV2('button/grabber/default')};
|
||||
margin-right: 4px;
|
||||
cursor: -webkit-grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.select-option-new-icon {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
margin-right: 8px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
// .select-selected-text {
|
||||
// width: calc(100% - 16px);
|
||||
// white-space: nowrap;
|
||||
// text-overflow: ellipsis;
|
||||
// overflow: hidden;
|
||||
// }
|
||||
//
|
||||
// .select-selected > .close-icon {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// }
|
||||
//
|
||||
// .select-selected > .close-icon:hover {
|
||||
// cursor: pointer;
|
||||
// }
|
||||
//
|
||||
// .select-selected > .close-icon > svg {
|
||||
// fill: var(--affine-black-90);
|
||||
// }
|
||||
//
|
||||
// .select-option-new {
|
||||
// display: flex;
|
||||
// flex-direction: row;
|
||||
// align-items: center;
|
||||
// height: 36px;
|
||||
// padding: 4px;
|
||||
// gap: 5px;
|
||||
// border-radius: 4px;
|
||||
// background: var(--affine-selected-color);
|
||||
// }
|
||||
//
|
||||
// .select-option-new-text {
|
||||
// overflow: hidden;
|
||||
// white-space: nowrap;
|
||||
// text-overflow: ellipsis;
|
||||
// height: 28px;
|
||||
// padding: 2px 10px;
|
||||
// border-radius: 4px;
|
||||
// background: var(--affine-tag-red);
|
||||
// }
|
||||
//
|
||||
// .select-option-new-icon {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// gap: 6px;
|
||||
// height: 28px;
|
||||
// color: var(--affine-text-primary-color);
|
||||
// margin-right: 8px;
|
||||
// }
|
||||
//
|
||||
// .select-option-new-icon svg {
|
||||
// width: 16px;
|
||||
// height: 16px;
|
||||
// }
|
||||
//
|
||||
// .select-option {
|
||||
// position: relative;
|
||||
// display: flex;
|
||||
// justify-content: space-between;
|
||||
// align-items: center;
|
||||
// padding: 4px;
|
||||
// border-radius: 4px;
|
||||
// margin-bottom: 4px;
|
||||
// cursor: pointer;
|
||||
// }
|
||||
//
|
||||
// .select-option.selected {
|
||||
// background: var(--affine-hover-color);
|
||||
// }
|
||||
//
|
||||
// .select-option-text-container {
|
||||
// width: 100%;
|
||||
// overflow: hidden;
|
||||
// display: flex;
|
||||
// }
|
||||
//
|
||||
// .select-option-group-name {
|
||||
// font-size: 9px;
|
||||
// padding: 0 2px;
|
||||
// border-radius: 2px;
|
||||
// }
|
||||
//
|
||||
// .select-option-name {
|
||||
// padding: 4px 8px;
|
||||
// border-radius: 4px;
|
||||
// white-space: nowrap;
|
||||
// text-overflow: ellipsis;
|
||||
// overflow: hidden;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// .select-option-icon:hover {
|
||||
// background: var(--affine-hover-color);
|
||||
// }
|
||||
//
|
||||
// .select-option-icon svg {
|
||||
// width: 16px;
|
||||
// height: 16px;
|
||||
// pointer-events: none;
|
||||
// }
|
||||
`;
|
||||
226
blocksuite/affine/data-view/src/core/data-source/base.ts
Normal file
226
blocksuite/affine/data-view/src/core/data-source/base.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type { InsertToPosition } from '@blocksuite/affine-shared/utils';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
import type { PropertyMetaConfig } from '../property/property-config.js';
|
||||
import type { DatabaseFlags } from '../types.js';
|
||||
import type { ViewConvertConfig } from '../view/convert.js';
|
||||
import type { DataViewDataType, ViewMeta } from '../view/data-view.js';
|
||||
import type { ViewManager } from '../view-manager/view-manager.js';
|
||||
import type { DataViewContextKey } from './context.js';
|
||||
|
||||
export interface DataSource {
|
||||
readonly$: ReadonlySignal<boolean>;
|
||||
properties$: ReadonlySignal<string[]>;
|
||||
featureFlags$: ReadonlySignal<DatabaseFlags>;
|
||||
|
||||
cellValueGet(rowId: string, propertyId: string): unknown;
|
||||
cellValueGet$(
|
||||
rowId: string,
|
||||
propertyId: string
|
||||
): ReadonlySignal<unknown | undefined>;
|
||||
cellValueChange(rowId: string, propertyId: string, value: unknown): void;
|
||||
|
||||
rows$: ReadonlySignal<string[]>;
|
||||
rowAdd(InsertToPosition: InsertToPosition | number): string;
|
||||
rowDelete(ids: string[]): void;
|
||||
rowMove(rowId: string, position: InsertToPosition): void;
|
||||
|
||||
propertyMetas: PropertyMetaConfig[];
|
||||
|
||||
propertyNameGet$(propertyId: string): ReadonlySignal<string | undefined>;
|
||||
propertyNameGet(propertyId: string): string;
|
||||
propertyNameSet(propertyId: string, name: string): void;
|
||||
|
||||
propertyTypeGet(propertyId: string): string | undefined;
|
||||
propertyTypeGet$(propertyId: string): ReadonlySignal<string | undefined>;
|
||||
propertyTypeSet(propertyId: string, type: string): void;
|
||||
|
||||
propertyDataGet(propertyId: string): Record<string, unknown>;
|
||||
propertyDataGet$(
|
||||
propertyId: string
|
||||
): ReadonlySignal<Record<string, unknown> | undefined>;
|
||||
propertyDataSet(propertyId: string, data: Record<string, unknown>): void;
|
||||
|
||||
propertyDataTypeGet(propertyId: string): TypeInstance | undefined;
|
||||
propertyDataTypeGet$(
|
||||
propertyId: string
|
||||
): ReadonlySignal<TypeInstance | undefined>;
|
||||
|
||||
propertyReadonlyGet(propertyId: string): boolean;
|
||||
propertyReadonlyGet$(propertyId: string): ReadonlySignal<boolean>;
|
||||
|
||||
propertyMetaGet(type: string): PropertyMetaConfig;
|
||||
propertyAdd(insertToPosition: InsertToPosition, type?: string): string;
|
||||
propertyDuplicate(propertyId: string): string;
|
||||
propertyDelete(id: string): void;
|
||||
|
||||
contextGet<T>(key: DataViewContextKey<T>): T;
|
||||
|
||||
viewConverts: ViewConvertConfig[];
|
||||
viewManager: ViewManager;
|
||||
viewMetas: ViewMeta[];
|
||||
viewDataList$: ReadonlySignal<DataViewDataType[]>;
|
||||
|
||||
viewDataGet(viewId: string): DataViewDataType | undefined;
|
||||
viewDataGet$(viewId: string): ReadonlySignal<DataViewDataType | undefined>;
|
||||
|
||||
viewDataAdd(viewData: DataViewDataType): string;
|
||||
viewDataDuplicate(id: string): string;
|
||||
viewDataDelete(viewId: string): void;
|
||||
viewDataMoveTo(id: string, position: InsertToPosition): void;
|
||||
viewDataUpdate<ViewData extends DataViewDataType>(
|
||||
id: string,
|
||||
updater: (data: ViewData) => Partial<ViewData>
|
||||
): void;
|
||||
|
||||
viewMetaGet(type: string): ViewMeta;
|
||||
viewMetaGet$(type: string): ReadonlySignal<ViewMeta | undefined>;
|
||||
|
||||
viewMetaGetById(viewId: string): ViewMeta;
|
||||
viewMetaGetById$(viewId: string): ReadonlySignal<ViewMeta | undefined>;
|
||||
}
|
||||
|
||||
export abstract class DataSourceBase implements DataSource {
|
||||
context = new Map<symbol, unknown>();
|
||||
|
||||
abstract featureFlags$: ReadonlySignal<DatabaseFlags>;
|
||||
|
||||
abstract properties$: ReadonlySignal<string[]>;
|
||||
|
||||
abstract propertyMetas: PropertyMetaConfig[];
|
||||
|
||||
abstract readonly$: ReadonlySignal<boolean>;
|
||||
|
||||
abstract rows$: ReadonlySignal<string[]>;
|
||||
|
||||
abstract viewConverts: ViewConvertConfig[];
|
||||
|
||||
abstract viewDataList$: ReadonlySignal<DataViewDataType[]>;
|
||||
|
||||
abstract viewManager: ViewManager;
|
||||
|
||||
abstract viewMetas: ViewMeta[];
|
||||
|
||||
abstract cellValueChange(
|
||||
rowId: string,
|
||||
propertyId: string,
|
||||
value: unknown
|
||||
): void;
|
||||
|
||||
abstract cellValueChange(
|
||||
rowId: string,
|
||||
propertyId: string,
|
||||
value: unknown
|
||||
): void;
|
||||
|
||||
abstract cellValueGet(rowId: string, propertyId: string): unknown;
|
||||
|
||||
cellValueGet$(
|
||||
rowId: string,
|
||||
propertyId: string
|
||||
): ReadonlySignal<unknown | undefined> {
|
||||
return computed(() => this.cellValueGet(rowId, propertyId));
|
||||
}
|
||||
|
||||
contextGet<T>(key: DataViewContextKey<T>): T {
|
||||
return (this.context.get(key.key) as T) ?? key.defaultValue;
|
||||
}
|
||||
|
||||
contextSet<T>(key: DataViewContextKey<T>, value: T): void {
|
||||
this.context.set(key.key, value);
|
||||
}
|
||||
|
||||
abstract propertyAdd(
|
||||
insertToPosition: InsertToPosition,
|
||||
type?: string
|
||||
): string;
|
||||
|
||||
abstract propertyDataGet(propertyId: string): Record<string, unknown>;
|
||||
|
||||
propertyDataGet$(
|
||||
propertyId: string
|
||||
): ReadonlySignal<Record<string, unknown> | undefined> {
|
||||
return computed(() => this.propertyDataGet(propertyId));
|
||||
}
|
||||
|
||||
abstract propertyDataSet(
|
||||
propertyId: string,
|
||||
data: Record<string, unknown>
|
||||
): void;
|
||||
|
||||
abstract propertyDataTypeGet(propertyId: string): TypeInstance | undefined;
|
||||
|
||||
propertyDataTypeGet$(
|
||||
propertyId: string
|
||||
): ReadonlySignal<TypeInstance | undefined> {
|
||||
return computed(() => this.propertyDataTypeGet(propertyId));
|
||||
}
|
||||
|
||||
abstract propertyDelete(id: string): void;
|
||||
|
||||
abstract propertyDuplicate(propertyId: string): string;
|
||||
|
||||
abstract propertyMetaGet(type: string): PropertyMetaConfig;
|
||||
|
||||
abstract propertyNameGet(propertyId: string): string;
|
||||
|
||||
propertyNameGet$(propertyId: string): ReadonlySignal<string | undefined> {
|
||||
return computed(() => this.propertyNameGet(propertyId));
|
||||
}
|
||||
|
||||
abstract propertyNameSet(propertyId: string, name: string): void;
|
||||
|
||||
propertyReadonlyGet(_propertyId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
propertyReadonlyGet$(propertyId: string): ReadonlySignal<boolean> {
|
||||
return computed(() => this.propertyReadonlyGet(propertyId));
|
||||
}
|
||||
|
||||
abstract propertyTypeGet(propertyId: string): string;
|
||||
|
||||
propertyTypeGet$(propertyId: string): ReadonlySignal<string | undefined> {
|
||||
return computed(() => this.propertyTypeGet(propertyId));
|
||||
}
|
||||
|
||||
abstract propertyTypeSet(propertyId: string, type: string): void;
|
||||
|
||||
abstract rowAdd(InsertToPosition: InsertToPosition | number): string;
|
||||
|
||||
abstract rowDelete(ids: string[]): void;
|
||||
|
||||
abstract rowMove(rowId: string, position: InsertToPosition): void;
|
||||
|
||||
abstract viewDataAdd(viewData: DataViewDataType): string;
|
||||
|
||||
abstract viewDataDelete(viewId: string): void;
|
||||
|
||||
abstract viewDataDuplicate(id: string): string;
|
||||
|
||||
abstract viewDataGet(viewId: string): DataViewDataType;
|
||||
|
||||
viewDataGet$(viewId: string): ReadonlySignal<DataViewDataType | undefined> {
|
||||
return computed(() => this.viewDataGet(viewId));
|
||||
}
|
||||
|
||||
abstract viewDataMoveTo(id: string, position: InsertToPosition): void;
|
||||
|
||||
abstract viewDataUpdate<ViewData extends DataViewDataType>(
|
||||
id: string,
|
||||
updater: (data: ViewData) => Partial<ViewData>
|
||||
): void;
|
||||
|
||||
abstract viewMetaGet(type: string): ViewMeta;
|
||||
|
||||
viewMetaGet$(type: string): ReadonlySignal<ViewMeta | undefined> {
|
||||
return computed(() => this.viewMetaGet(type));
|
||||
}
|
||||
|
||||
abstract viewMetaGetById(viewId: string): ViewMeta;
|
||||
|
||||
viewMetaGetById$(viewId: string): ReadonlySignal<ViewMeta | undefined> {
|
||||
return computed(() => this.viewMetaGetById(viewId));
|
||||
}
|
||||
}
|
||||
12
blocksuite/affine/data-view/src/core/data-source/context.ts
Normal file
12
blocksuite/affine/data-view/src/core/data-source/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface DataViewContextKey<T> {
|
||||
key: symbol;
|
||||
defaultValue: T;
|
||||
}
|
||||
|
||||
export const createContextKey = <T>(
|
||||
name: string,
|
||||
defaultValue: T
|
||||
): DataViewContextKey<T> => ({
|
||||
key: Symbol(name),
|
||||
defaultValue,
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './base.js';
|
||||
export * from './context.js';
|
||||
233
blocksuite/affine/data-view/src/core/data-view.ts
Normal file
233
blocksuite/affine/data-view/src/core/data-view.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
DatabaseAllEvents,
|
||||
EventTraceFn,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { dataViewCommonStyle } from './common/css-variable.js';
|
||||
import type { DataViewSelection, DataViewSelectionState } from './types.js';
|
||||
import { renderUniLit } from './utils/uni-component/index.js';
|
||||
import type { DataViewInstance, DataViewProps } from './view/types.js';
|
||||
import type { SingleView } from './view-manager/single-view.js';
|
||||
|
||||
type ViewProps = {
|
||||
view: SingleView;
|
||||
selection$: ReadonlySignal<DataViewSelectionState>;
|
||||
setSelection: (selection?: DataViewSelectionState) => void;
|
||||
bindHotkey: DataViewProps['bindHotkey'];
|
||||
handleEvent: DataViewProps['handleEvent'];
|
||||
};
|
||||
|
||||
export type DataViewRendererConfig = Pick<
|
||||
DataViewProps,
|
||||
| 'bindHotkey'
|
||||
| 'handleEvent'
|
||||
| 'virtualPadding$'
|
||||
| 'clipboard'
|
||||
| 'dataSource'
|
||||
| 'headerWidget'
|
||||
| 'onDrag'
|
||||
| 'notification'
|
||||
> & {
|
||||
selection$: ReadonlySignal<DataViewSelection | undefined>;
|
||||
setSelection: (selection: DataViewSelection | undefined) => void;
|
||||
eventTrace: EventTraceFn<DatabaseAllEvents>;
|
||||
detailPanelConfig: {
|
||||
openDetailPanel: (
|
||||
target: HTMLElement,
|
||||
data: {
|
||||
view: SingleView;
|
||||
rowId: string;
|
||||
}
|
||||
) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
export class DataViewRenderer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
${unsafeCSS(dataViewCommonStyle('affine-data-view-renderer'))}
|
||||
affine-data-view-renderer {
|
||||
background-color: var(--affine-background-primary-color);
|
||||
display: contents;
|
||||
}
|
||||
`;
|
||||
|
||||
private _view = createRef<{
|
||||
expose: DataViewInstance;
|
||||
}>();
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: DataViewRendererConfig;
|
||||
|
||||
private currentViewId$ = computed(() => {
|
||||
return this.config.dataSource.viewManager.currentViewId$.value;
|
||||
});
|
||||
|
||||
viewMap$ = computed(() => {
|
||||
const manager = this.config.dataSource.viewManager;
|
||||
return Object.fromEntries(
|
||||
manager.views$.value.map(view => [view, manager.viewGet(view)])
|
||||
);
|
||||
});
|
||||
|
||||
currentViewConfig$ = computed<ViewProps | undefined>(() => {
|
||||
const currentViewId = this.currentViewId$.value;
|
||||
if (!currentViewId) {
|
||||
return;
|
||||
}
|
||||
const view = this.viewMap$.value[currentViewId];
|
||||
return {
|
||||
view: view,
|
||||
selection$: computed(() => {
|
||||
const selection$ = this.config.selection$;
|
||||
if (selection$.value?.viewId === currentViewId) {
|
||||
return selection$.value;
|
||||
}
|
||||
return;
|
||||
}),
|
||||
setSelection: selection => {
|
||||
this.config.setSelection(selection);
|
||||
},
|
||||
handleEvent: (name, handler) =>
|
||||
this.config.handleEvent(name, context => {
|
||||
return handler(context);
|
||||
}),
|
||||
bindHotkey: hotkeys =>
|
||||
this.config.bindHotkey(
|
||||
Object.fromEntries(
|
||||
Object.entries(hotkeys).map(([key, fn]) => [
|
||||
key,
|
||||
ctx => {
|
||||
return fn(ctx);
|
||||
},
|
||||
])
|
||||
)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
focusFirstCell = () => {
|
||||
this.view?.expose.focusFirstCell();
|
||||
};
|
||||
|
||||
openDetailPanel = (ops: {
|
||||
view: SingleView;
|
||||
rowId: string;
|
||||
onClose?: () => void;
|
||||
}) => {
|
||||
const openDetailPanel = this.config.detailPanelConfig.openDetailPanel;
|
||||
if (openDetailPanel) {
|
||||
openDetailPanel(this, {
|
||||
view: ops.view,
|
||||
rowId: ops.rowId,
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(ops.onClose);
|
||||
}
|
||||
};
|
||||
|
||||
get view() {
|
||||
return this._view.value;
|
||||
}
|
||||
|
||||
private renderView(viewData?: ViewProps) {
|
||||
if (!viewData) {
|
||||
return;
|
||||
}
|
||||
const props: DataViewProps = {
|
||||
dataViewEle: this,
|
||||
headerWidget: this.config.headerWidget,
|
||||
onDrag: this.config.onDrag,
|
||||
dataSource: this.config.dataSource,
|
||||
virtualPadding$: this.config.virtualPadding$,
|
||||
clipboard: this.config.clipboard,
|
||||
notification: this.config.notification,
|
||||
view: viewData.view,
|
||||
selection$: viewData.selection$,
|
||||
setSelection: viewData.setSelection,
|
||||
bindHotkey: viewData.bindHotkey,
|
||||
handleEvent: viewData.handleEvent,
|
||||
eventTrace: (key, params) => {
|
||||
this.config.eventTrace(key, {
|
||||
...(params as DatabaseAllEvents[typeof key]),
|
||||
viewId: viewData.view.id,
|
||||
viewType: viewData.view.type,
|
||||
});
|
||||
},
|
||||
};
|
||||
const renderer = viewData.view.meta.renderer;
|
||||
const view =
|
||||
(IS_MOBILE ? renderer.mobileView : renderer.view) ?? renderer.view;
|
||||
return keyed(
|
||||
viewData.view.id,
|
||||
renderUniLit(
|
||||
view,
|
||||
{ props },
|
||||
{
|
||||
ref: this._view,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
let preId: string | undefined = undefined;
|
||||
this.disposables.add(
|
||||
this.currentViewId$.subscribe(current => {
|
||||
if (current !== preId) {
|
||||
this.config.setSelection(undefined);
|
||||
}
|
||||
preId = current;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const containerClass = classMap({
|
||||
'toolbar-hover-container': true,
|
||||
'data-view-root': true,
|
||||
'prevent-reference-popup': true,
|
||||
});
|
||||
return html`
|
||||
<div style="display: contents" class="${containerClass}">
|
||||
${this.renderView(this.currentViewConfig$.value)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor currentView: string | undefined = undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-data-view-renderer': DataViewRenderer;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataView {
|
||||
private _ref = createRef<DataViewRenderer>();
|
||||
|
||||
get expose() {
|
||||
return this._ref.value?.view?.expose;
|
||||
}
|
||||
|
||||
render(props: DataViewRendererConfig) {
|
||||
return html` <affine-data-view-renderer
|
||||
${ref(this._ref)}
|
||||
.config="${props}"
|
||||
></affine-data-view-renderer>`;
|
||||
}
|
||||
}
|
||||
297
blocksuite/affine/data-view/src/core/detail/detail.ts
Normal file
297
blocksuite/affine/data-view/src/core/detail/detail.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import {
|
||||
ArrowDownBigIcon,
|
||||
ArrowUpBigIcon,
|
||||
PlusIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, nothing, unsafeCSS } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { dataViewCommonStyle } from '../common/css-variable.js';
|
||||
import {
|
||||
renderUniLit,
|
||||
type UniComponent,
|
||||
} from '../utils/uni-component/uni-component.js';
|
||||
import type { SingleView } from '../view-manager/single-view.js';
|
||||
import { DetailSelection } from './selection.js';
|
||||
|
||||
export type DetailSlotProps = {
|
||||
view: SingleView;
|
||||
rowId: string;
|
||||
openDoc: (docId: string) => void;
|
||||
};
|
||||
|
||||
export interface DetailSlots {
|
||||
header?: UniComponent<DetailSlotProps>;
|
||||
note?: UniComponent<DetailSlotProps>;
|
||||
}
|
||||
|
||||
const styles = css`
|
||||
${unsafeCSS(dataViewCommonStyle('affine-data-view-record-detail'))}
|
||||
affine-data-view-record-detail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 12px;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.add-property {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
color: var(--affine-text-disable-color);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px 6px 4px;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.add-property:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.add-property .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-property .icon svg {
|
||||
fill: var(--affine-icon-color);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.switch-row:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.switch-row.disable {
|
||||
cursor: default;
|
||||
background: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
export class RecordDetail extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = styles;
|
||||
|
||||
_clickAddProperty = () => {
|
||||
popMenu(popupTargetFromElement(this.addPropertyButton), {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Add property',
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: this.view.propertyMetas.map(meta => {
|
||||
return menu.action({
|
||||
name: meta.config.name,
|
||||
prefix: renderUniLit(this.view.propertyIconGet(meta.type)),
|
||||
select: () => {
|
||||
this.view.propertyAdd('end', meta.type);
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: SingleView;
|
||||
|
||||
properties$ = computed(() => {
|
||||
return this.view.detailProperties$.value.map(id =>
|
||||
this.view.propertyGet(id)
|
||||
);
|
||||
});
|
||||
|
||||
selection = new DetailSelection(this);
|
||||
|
||||
private get readonly() {
|
||||
return this.view.readonly$.value;
|
||||
}
|
||||
|
||||
private renderHeader() {
|
||||
const header = this.detailSlots?.header;
|
||||
if (header) {
|
||||
const props: DetailSlotProps = {
|
||||
view: this.view,
|
||||
rowId: this.rowId,
|
||||
openDoc: this.openDoc,
|
||||
};
|
||||
return renderUniLit(header, props);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private renderNote() {
|
||||
const note = this.detailSlots?.note;
|
||||
if (note) {
|
||||
const props: DetailSlotProps = {
|
||||
view: this.view,
|
||||
rowId: this.rowId,
|
||||
openDoc: this.openDoc,
|
||||
};
|
||||
return renderUniLit(note, props);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.addFromEvent(this, 'click', e => {
|
||||
e.stopPropagation();
|
||||
this.selection.selection = undefined;
|
||||
});
|
||||
//FIXME: simulate as a widget
|
||||
this.dataset.widgetId = 'affine-detail-widget';
|
||||
}
|
||||
|
||||
hasNext() {
|
||||
return this.view.rowNextGet(this.rowId) != null;
|
||||
}
|
||||
|
||||
hasPrev() {
|
||||
return this.view.rowPrevGet(this.rowId) != null;
|
||||
}
|
||||
|
||||
nextRow() {
|
||||
const rowId = this.view.rowNextGet(this.rowId);
|
||||
if (rowId == null) {
|
||||
return;
|
||||
}
|
||||
this.rowId = rowId;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
prevRow() {
|
||||
const rowId = this.view.rowPrevGet(this.rowId);
|
||||
if (rowId == null) {
|
||||
return;
|
||||
}
|
||||
this.rowId = rowId;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const properties = this.properties$.value;
|
||||
const upClass = classMap({
|
||||
'switch-row': true,
|
||||
disable: !this.hasPrev(),
|
||||
});
|
||||
const downClass = classMap({
|
||||
'switch-row': true,
|
||||
disable: !this.hasNext(),
|
||||
});
|
||||
return html`
|
||||
<div
|
||||
style="position: absolute;left: 20px;top:20px;display: flex;align-items:center;gap:4px;"
|
||||
>
|
||||
<div @click="${this.prevRow}" class="${upClass}">
|
||||
${ArrowUpBigIcon()}
|
||||
</div>
|
||||
<div @click="${this.nextRow}" class="${downClass}">
|
||||
${ArrowDownBigIcon()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="width: 100%;max-width: var(--affine-editor-width);display: flex;flex-direction: column;margin: 0 auto;box-sizing: border-box;"
|
||||
>
|
||||
${keyed(this.rowId, this.renderHeader())}
|
||||
${repeat(
|
||||
properties,
|
||||
v => v.id,
|
||||
property => {
|
||||
return keyed(
|
||||
this.rowId,
|
||||
html` <affine-data-view-record-field
|
||||
.view="${this.view}"
|
||||
.column="${property}"
|
||||
.rowId="${this.rowId}"
|
||||
data-column-id="${property.id}"
|
||||
></affine-data-view-record-field>`
|
||||
);
|
||||
}
|
||||
)}
|
||||
${!this.readonly
|
||||
? html` <div class="add-property" @click="${this._clickAddProperty}">
|
||||
<div class="icon">${PlusIcon()}</div>
|
||||
Add Property
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
${keyed(this.rowId, this.renderNote())}
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.add-property')
|
||||
accessor addPropertyButton!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor detailSlots: DetailSlots | undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor openDoc!: (docId: string) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rowId!: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-data-view-record-detail': RecordDetail;
|
||||
}
|
||||
}
|
||||
export const createRecordDetail = (ops: {
|
||||
view: SingleView;
|
||||
rowId: string;
|
||||
detail: DetailSlots;
|
||||
openDoc: (docId: string) => void;
|
||||
}) => {
|
||||
return html` <affine-data-view-record-detail
|
||||
.view=${ops.view}
|
||||
.rowId=${ops.rowId}
|
||||
.detailSlots=${ops.detail}
|
||||
.openDoc=${ops.openDoc}
|
||||
class="data-view-popup-container"
|
||||
></affine-data-view-record-detail>`;
|
||||
};
|
||||
289
blocksuite/affine/data-view/src/core/detail/field.ts
Normal file
289
blocksuite/affine/data-view/src/core/detail/field.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import {
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
MoveLeftIcon,
|
||||
MoveRightIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { createRef } from 'lit/directives/ref.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { inputConfig, typeConfig } from '../common/property-menu.js';
|
||||
import type {
|
||||
CellRenderProps,
|
||||
DataViewCellLifeCycle,
|
||||
} from '../property/index.js';
|
||||
import { renderUniLit } from '../utils/uni-component/uni-component.js';
|
||||
import type { Property } from '../view-manager/property.js';
|
||||
import type { SingleView } from '../view-manager/single-view.js';
|
||||
|
||||
export class RecordField extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
affine-data-view-record-field {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-left {
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
height: max-content;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
color: var(--affine-text-secondary-color);
|
||||
width: 160px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.field-left:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
affine-data-view-record-field .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
affine-data-view-record-field .icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.filed-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field-content {
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.field-content .affine-database-number {
|
||||
text-align: left;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.field-content:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.field-content.is-editing {
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
}
|
||||
|
||||
.field-content.is-focus {
|
||||
border: 1px solid var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.field-content.empty::before {
|
||||
content: 'Empty';
|
||||
color: var(--affine-text-disable-color);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _cell = createRef<DataViewCellLifeCycle>();
|
||||
|
||||
_click = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (this.readonly) return;
|
||||
|
||||
this.changeEditing(true);
|
||||
};
|
||||
|
||||
_clickLeft = (e: MouseEvent) => {
|
||||
if (this.readonly) return;
|
||||
const ele = e.currentTarget as HTMLElement;
|
||||
const properties = this.view.detailProperties$.value;
|
||||
popMenu(popupTargetFromElement(ele), {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Property settings',
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [inputConfig(this.column), typeConfig(this.column)],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Move Up',
|
||||
prefix: html` <div
|
||||
style="transform: rotate(90deg);display:flex;align-items:center;"
|
||||
>
|
||||
${MoveLeftIcon()}
|
||||
</div>`,
|
||||
hide: () =>
|
||||
properties.findIndex(v => v === this.column.id) === 0,
|
||||
select: () => {
|
||||
const index = properties.findIndex(v => v === this.column.id);
|
||||
const targetId = properties[index - 1];
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
this.view.propertyMove(this.column.id, {
|
||||
id: targetId,
|
||||
before: true,
|
||||
});
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
name: 'Move Down',
|
||||
prefix: html` <div
|
||||
style="transform: rotate(90deg);display:flex;align-items:center;"
|
||||
>
|
||||
${MoveRightIcon()}
|
||||
</div>`,
|
||||
hide: () =>
|
||||
properties.findIndex(v => v === this.column.id) ===
|
||||
properties.length - 1,
|
||||
select: () => {
|
||||
const index = properties.findIndex(v => v === this.column.id);
|
||||
const targetId = properties[index + 1];
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
this.view.propertyMove(this.column.id, {
|
||||
id: targetId,
|
||||
before: false,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
name: 'operation',
|
||||
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 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor column!: Property;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor rowId!: string;
|
||||
|
||||
cell$ = computed(() => {
|
||||
return this.column.cellGet(this.rowId);
|
||||
});
|
||||
|
||||
changeEditing = (editing: boolean) => {
|
||||
const selection = this.closest('affine-data-view-record-detail')?.selection;
|
||||
if (selection) {
|
||||
selection.selection = {
|
||||
propertyId: this.column.id,
|
||||
isEditing: editing,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
get cell(): DataViewCellLifeCycle | undefined {
|
||||
return this._cell.value;
|
||||
}
|
||||
|
||||
private get readonly() {
|
||||
return this.view.readonly$.value;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const column = this.column;
|
||||
|
||||
const props: CellRenderProps = {
|
||||
cell: this.cell$.value,
|
||||
isEditing: this.editing,
|
||||
selectCurrentCell: this.changeEditing,
|
||||
};
|
||||
const renderer = this.column.renderer$.value;
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
const { view, edit } = renderer;
|
||||
const contentClass = classMap({
|
||||
'field-content': true,
|
||||
empty: !this.editing && this.cell$.value.isEmpty$.value,
|
||||
'is-editing': this.editing,
|
||||
'is-focus': this.isFocus,
|
||||
});
|
||||
return html`
|
||||
<div>
|
||||
<div class="field-left" @click="${this._clickLeft}">
|
||||
<div class="icon">
|
||||
<uni-lit .uni="${this.column.icon}"></uni-lit>
|
||||
</div>
|
||||
<div class="filed-name">${column.name$.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="${this._click}" class="${contentClass}">
|
||||
${renderUniLit(this.editing && edit ? edit : view, props, {
|
||||
ref: this._cell,
|
||||
class: 'kanban-cell',
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
accessor editing = false;
|
||||
|
||||
@state()
|
||||
accessor isFocus = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor view!: SingleView;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-data-view-record-field': RecordField;
|
||||
}
|
||||
}
|
||||
154
blocksuite/affine/data-view/src/core/detail/selection.ts
Normal file
154
blocksuite/affine/data-view/src/core/detail/selection.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
|
||||
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
|
||||
import type { KanbanCardSelection } from '../../view-presets/kanban/types.js';
|
||||
import type { RecordDetail } from './detail.js';
|
||||
import { RecordField } from './field.js';
|
||||
|
||||
type DetailViewSelection = {
|
||||
propertyId: string;
|
||||
isEditing: boolean;
|
||||
};
|
||||
|
||||
export class DetailSelection {
|
||||
_selection?: DetailViewSelection;
|
||||
|
||||
onSelect = (selection?: DetailViewSelection) => {
|
||||
const old = this._selection;
|
||||
if (old) {
|
||||
this.blur(old);
|
||||
}
|
||||
this._selection = selection;
|
||||
if (selection) {
|
||||
this.focus(selection);
|
||||
}
|
||||
};
|
||||
|
||||
get selection(): DetailViewSelection | undefined {
|
||||
return this._selection;
|
||||
}
|
||||
|
||||
set selection(selection: DetailViewSelection | undefined) {
|
||||
if (!selection) {
|
||||
this.onSelect();
|
||||
return;
|
||||
}
|
||||
if (selection.isEditing) {
|
||||
const container = this.getFocusCellContainer(selection);
|
||||
const cell = container?.cell;
|
||||
const isEditing = cell
|
||||
? cell.beforeEnterEditMode()
|
||||
? selection.isEditing
|
||||
: false
|
||||
: false;
|
||||
this.onSelect({
|
||||
propertyId: selection.propertyId,
|
||||
isEditing,
|
||||
});
|
||||
} else {
|
||||
this.onSelect(selection);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(private viewEle: RecordDetail) {}
|
||||
|
||||
blur(selection: DetailViewSelection) {
|
||||
const container = this.getFocusCellContainer(selection);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.isFocus = false;
|
||||
const cell = container.cell;
|
||||
|
||||
if (selection.isEditing) {
|
||||
requestAnimationFrame(() => {
|
||||
cell?.onExitEditMode();
|
||||
});
|
||||
if (cell?.blurCell()) {
|
||||
container.blur();
|
||||
}
|
||||
container.editing = false;
|
||||
} else {
|
||||
container.blur();
|
||||
}
|
||||
}
|
||||
|
||||
deleteProperty() {
|
||||
//
|
||||
}
|
||||
|
||||
focus(selection: DetailViewSelection) {
|
||||
const container = this.getFocusCellContainer(selection);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.isFocus = true;
|
||||
const cell = container.cell;
|
||||
if (selection.isEditing) {
|
||||
cell?.onEnterEditMode();
|
||||
if (cell?.focusCell()) {
|
||||
container.focus();
|
||||
}
|
||||
container.editing = true;
|
||||
} else {
|
||||
container.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusDown() {
|
||||
const selection = this.selection;
|
||||
if (!selection || selection?.isEditing) {
|
||||
return;
|
||||
}
|
||||
const nextContainer =
|
||||
this.getFocusCellContainer(selection)?.nextElementSibling;
|
||||
if (nextContainer instanceof KanbanCell) {
|
||||
this.selection = {
|
||||
propertyId: nextContainer.column.id,
|
||||
isEditing: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
focusFirstCell() {
|
||||
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
|
||||
?.column.id;
|
||||
if (firstId) {
|
||||
this.selection = {
|
||||
propertyId: firstId,
|
||||
isEditing: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
focusUp() {
|
||||
const selection = this.selection;
|
||||
if (!selection || selection?.isEditing) {
|
||||
return;
|
||||
}
|
||||
const preContainer =
|
||||
this.getFocusCellContainer(selection)?.previousElementSibling;
|
||||
if (preContainer instanceof RecordField) {
|
||||
this.selection = {
|
||||
propertyId: preContainer.column.id,
|
||||
isEditing: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getFocusCellContainer(selection: DetailViewSelection) {
|
||||
return this.viewEle.querySelector(
|
||||
`affine-data-view-record-field[data-column-id="${selection.propertyId}"]`
|
||||
) as RecordField | undefined;
|
||||
}
|
||||
|
||||
getSelectCard(selection: KanbanCardSelection) {
|
||||
const { groupKey, cardId } = selection.cards[0];
|
||||
|
||||
return this.viewEle
|
||||
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
|
||||
?.querySelector(
|
||||
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
|
||||
) as KanbanCard | undefined;
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/data-view/src/core/expression/index.ts
Normal file
3
blocksuite/affine/data-view/src/core/expression/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from '../filter/literal/index.js';
|
||||
export * from './ref/index.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ref.js';
|
||||
export * from './ref-view.js';
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
menu,
|
||||
popFilterableSimpleMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { renderUniLit } from '../../utils/uni-component/uni-component.js';
|
||||
import type { Variable, VariableRef } from '../types.js';
|
||||
|
||||
export class VariableRefView extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
variable-ref-view {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
variable-ref-view:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
variable-ref-view svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: var(--affine-icon-color);
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
get field() {
|
||||
if (!this.data) {
|
||||
return;
|
||||
}
|
||||
return this.data.name;
|
||||
}
|
||||
|
||||
get fieldData() {
|
||||
const id = this.field;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
return this.vars.find(v => v.id === id);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.disposables.addFromEvent(this, 'click', e => {
|
||||
popFilterableSimpleMenu(
|
||||
popupTargetFromElement(e.target as HTMLElement),
|
||||
this.vars.map(v =>
|
||||
menu.action({
|
||||
name: v.name,
|
||||
prefix: renderUniLit(v.icon, {}),
|
||||
select: () => {
|
||||
this.setData({
|
||||
type: 'ref',
|
||||
name: v.id,
|
||||
});
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const data = this.fieldData;
|
||||
return html` ${renderUniLit(data?.icon, {})} ${data?.name} `;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor data: VariableRef | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor setData!: (filter: VariableRef) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor vars!: Variable[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'variable-ref-view': VariableRefView;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Variable, VariableRef } from '../types.js';
|
||||
|
||||
export const getRefType = (vars: Variable[], ref: VariableRef) => {
|
||||
return vars.find(v => v.id === ref.name)?.type;
|
||||
};
|
||||
21
blocksuite/affine/data-view/src/core/expression/types.ts
Normal file
21
blocksuite/affine/data-view/src/core/expression/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
import type { UniComponent } from '../utils/index.js';
|
||||
|
||||
export type VariableRef = {
|
||||
type: 'ref';
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Variable = {
|
||||
name: string;
|
||||
type: TypeInstance;
|
||||
propertyType: string;
|
||||
id: string;
|
||||
icon?: UniComponent;
|
||||
};
|
||||
export type Literal = {
|
||||
type: 'literal';
|
||||
value: unknown;
|
||||
};
|
||||
// TODO support VariableRef
|
||||
export type Value = Literal;
|
||||
67
blocksuite/affine/data-view/src/core/filter/add-filter.ts
Normal file
67
blocksuite/affine/data-view/src/core/filter/add-filter.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { AddCursorIcon } from '@blocksuite/icons/lit';
|
||||
import type { Middleware } from '@floating-ui/dom';
|
||||
import type { ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import type { Variable } from '../expression/index.js';
|
||||
import { renderUniLit } from '../utils/index.js';
|
||||
import type { Filter } from './types.js';
|
||||
import { firstFilterByRef, firstFilterInGroup } from './utils.js';
|
||||
|
||||
export const popCreateFilter = (
|
||||
target: PopupTarget,
|
||||
props: {
|
||||
vars: ReadonlySignal<Variable[]>;
|
||||
onSelect: (filter: Filter) => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
},
|
||||
ops?: {
|
||||
middleware?: Middleware[];
|
||||
}
|
||||
) => {
|
||||
popMenu(target, {
|
||||
middleware: ops?.middleware,
|
||||
options: {
|
||||
onClose: props.onClose,
|
||||
title: {
|
||||
onBack: props.onBack,
|
||||
text: 'New filter',
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: props.vars.value.map(v =>
|
||||
menu.action({
|
||||
name: v.name,
|
||||
prefix: renderUniLit(v.icon, {}),
|
||||
select: () => {
|
||||
props.onSelect(
|
||||
firstFilterByRef(props.vars.value, {
|
||||
type: 'ref',
|
||||
name: v.id,
|
||||
})
|
||||
);
|
||||
},
|
||||
})
|
||||
),
|
||||
}),
|
||||
menu.group({
|
||||
name: '',
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Add filter group',
|
||||
prefix: AddCursorIcon(),
|
||||
select: () => {
|
||||
props.onSelect(firstFilterInGroup(props.vars.value));
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
53
blocksuite/affine/data-view/src/core/filter/eval.ts
Normal file
53
blocksuite/affine/data-view/src/core/filter/eval.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Value, VariableRef } from '../expression/types.js';
|
||||
import { filterMatcher } from './filter-fn/matcher.js';
|
||||
import type { Filter } from './types.js';
|
||||
|
||||
const evalRef = (ref: VariableRef, row: Record<string, unknown>): unknown => {
|
||||
return row[ref.name];
|
||||
};
|
||||
|
||||
const evalValue = (value?: Value): unknown => {
|
||||
return value?.value;
|
||||
};
|
||||
export const evalFilter = (
|
||||
filterGroup: Filter,
|
||||
row: Record<string, unknown>
|
||||
): boolean => {
|
||||
const evalF = (filter: Filter): boolean => {
|
||||
if (filter.type === 'filter') {
|
||||
const value = evalRef(filter.left, row);
|
||||
const func = filterMatcher.getFilterByName(filter.function);
|
||||
if (!func) {
|
||||
return true;
|
||||
}
|
||||
const expectArgLen = func.args.length;
|
||||
const args: unknown[] = [];
|
||||
for (let i = 0; i < expectArgLen; i++) {
|
||||
const argValue = evalValue(filter.args[i]);
|
||||
const argType = func.args[i];
|
||||
if (argValue == null) {
|
||||
return true;
|
||||
}
|
||||
if (!argType.valueValidate(argValue)) {
|
||||
return true;
|
||||
}
|
||||
args.push(argValue);
|
||||
}
|
||||
const impl = func.impl;
|
||||
try {
|
||||
return impl(value ?? undefined, ...args);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return true;
|
||||
}
|
||||
} else if (filter.type === 'group') {
|
||||
if (filter.op === 'and') {
|
||||
return filter.conditions.every(f => evalF(f));
|
||||
} else if (filter.op === 'or') {
|
||||
return filter.conditions.some(f => evalF(f));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
return evalF(filterGroup);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { createFilter } from './create.js';
|
||||
|
||||
export const booleanFilter = [
|
||||
createFilter({
|
||||
name: 'isChecked',
|
||||
self: t.boolean.instance(),
|
||||
args: [],
|
||||
label: 'Is checked',
|
||||
shortString: () => ': Checked',
|
||||
impl: value => {
|
||||
return !!value;
|
||||
},
|
||||
defaultValue: () => true,
|
||||
}),
|
||||
createFilter({
|
||||
name: 'isUnchecked',
|
||||
self: t.boolean.instance(),
|
||||
args: [],
|
||||
label: 'Is unchecked',
|
||||
shortString: () => ': Unchecked',
|
||||
impl: value => {
|
||||
return !value;
|
||||
},
|
||||
defaultValue: () => false,
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { ArrayTypeInstance } from '../../logical/composite-type.js';
|
||||
import type { DTInstance } from '../../logical/data-type.js';
|
||||
import type { TypeInstance, ValueTypeOf } from '../../logical/type.js';
|
||||
import type {
|
||||
TypeVarDefinitionInstance,
|
||||
TypeVarReferenceInstance,
|
||||
} from '../../logical/type-variable.js';
|
||||
|
||||
export type FilterConfig<
|
||||
Self extends TypeInstance = TypeInstance,
|
||||
Args extends TypeInstance[] = TypeInstance[],
|
||||
Vars extends TypeVarDefinitionInstance[] = TypeVarDefinitionInstance[],
|
||||
> = {
|
||||
name: string;
|
||||
label: string;
|
||||
shortString: (
|
||||
...args: {
|
||||
[K in keyof Args]:
|
||||
| {
|
||||
value: ValueTypeOf<ReplaceVar<Args[K], Vars>>;
|
||||
type: ReplaceVar<Args[K], Vars>;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
) => string | undefined;
|
||||
self: Self;
|
||||
vars?: Vars;
|
||||
args: Args;
|
||||
impl: (
|
||||
self: ValueTypeOf<ReplaceVar<Self, Vars>> | undefined,
|
||||
...args: { [K in keyof Args]: ValueTypeOf<ReplaceVar<Args[K], Vars>> }
|
||||
) => boolean;
|
||||
defaultValue?: (args: {
|
||||
[K in keyof Args]: ValueTypeOf<ReplaceVar<Args[K], Vars>>;
|
||||
}) => ValueTypeOf<ReplaceVar<Self, Vars>>;
|
||||
};
|
||||
type FindVar<
|
||||
Vars extends TypeVarDefinitionInstance[],
|
||||
Name extends string,
|
||||
> = Vars[number] extends infer Var
|
||||
? Var extends TypeVarDefinitionInstance<Name, infer R>
|
||||
? R
|
||||
: never
|
||||
: never;
|
||||
type ReplaceVar<
|
||||
Arg,
|
||||
Vars extends TypeVarDefinitionInstance[],
|
||||
> = Arg extends TypeVarReferenceInstance
|
||||
? FindVar<Vars, Arg['varName']>
|
||||
: Arg extends ArrayTypeInstance<infer Ele>
|
||||
? ArrayTypeInstance<ReplaceVar<Ele, Vars>>
|
||||
: Arg extends DTInstance
|
||||
? Arg
|
||||
: Arg;
|
||||
|
||||
export const createFilter = <
|
||||
Self extends TypeInstance,
|
||||
Args extends TypeInstance[],
|
||||
Vars extends TypeVarDefinitionInstance[],
|
||||
>(
|
||||
config: FilterConfig<Self, Args, Vars>
|
||||
) => {
|
||||
return config;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { addDays } from 'date-fns/addDays';
|
||||
import { format } from 'date-fns/format';
|
||||
import { subDays } from 'date-fns/subDays';
|
||||
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { createFilter } from './create.js';
|
||||
|
||||
export const dateFilter = [
|
||||
createFilter({
|
||||
name: 'before',
|
||||
self: t.date.instance(),
|
||||
args: [t.date.instance()] as const,
|
||||
label: 'Before',
|
||||
shortString: v => (v ? ` < ${format(v.value, 'yyyy/MM/dd')}` : undefined),
|
||||
impl: (self, value) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self < value;
|
||||
},
|
||||
defaultValue: args => subDays(args[0], 1).getTime(),
|
||||
}),
|
||||
createFilter({
|
||||
name: 'after',
|
||||
self: t.date.instance(),
|
||||
args: [t.date.instance()] as const,
|
||||
label: 'After',
|
||||
shortString: v => (v ? ` > ${format(v.value, 'yyyy/MM/dd')}` : undefined),
|
||||
impl: (self, value) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self > value;
|
||||
},
|
||||
defaultValue: args => addDays(args[0], 1).getTime(),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ct } from '../../logical/composite-type.js';
|
||||
import { t } from '../../logical/index.js';
|
||||
import type { TypeInstance } from '../../logical/type.js';
|
||||
import { typeSystem } from '../../logical/type-system.js';
|
||||
import { booleanFilter } from './boolean.js';
|
||||
import type { FilterConfig } from './create.js';
|
||||
import { dateFilter } from './date.js';
|
||||
import { multiTagFilter } from './multi-tag.js';
|
||||
import { numberFilter } from './number.js';
|
||||
import { stringFilter } from './string.js';
|
||||
import { tagFilter } from './tag.js';
|
||||
import { unknownFilter } from './unknown.js';
|
||||
|
||||
const allFilter = [
|
||||
...dateFilter,
|
||||
...multiTagFilter,
|
||||
...numberFilter,
|
||||
...stringFilter,
|
||||
...tagFilter,
|
||||
...booleanFilter,
|
||||
...unknownFilter,
|
||||
] as FilterConfig[];
|
||||
|
||||
const getPredicate = (selfType: TypeInstance) => (filter: FilterConfig) => {
|
||||
const fn = ct.fn.instance(
|
||||
[filter.self, ...filter.args],
|
||||
t.boolean.instance(),
|
||||
filter.vars
|
||||
);
|
||||
const staticType = fn.subst(
|
||||
Object.fromEntries(
|
||||
filter.vars?.map(v => [
|
||||
v.varName,
|
||||
{
|
||||
define: v,
|
||||
type: v.typeConstraint,
|
||||
},
|
||||
]) ?? []
|
||||
)
|
||||
);
|
||||
if (!staticType) {
|
||||
return false;
|
||||
}
|
||||
const firstArg = staticType.args[0];
|
||||
return firstArg && typeSystem.unify(selfType, firstArg);
|
||||
};
|
||||
|
||||
export const filterMatcher = {
|
||||
filterListBySelfType: (selfType: TypeInstance) => {
|
||||
return allFilter.filter(getPredicate(selfType));
|
||||
},
|
||||
firstMatchedBySelfType: (selfType: TypeInstance) => {
|
||||
return allFilter.find(getPredicate(selfType));
|
||||
},
|
||||
getFilterByName: (name?: string) => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
return allFilter.find(v => v.name === name);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ct } from '../../logical/composite-type.js';
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { tRef, tVar } from '../../logical/type-variable.js';
|
||||
import { createFilter } from './create.js';
|
||||
import { tagToString } from './utils.js';
|
||||
|
||||
const optionName = 'option' as const;
|
||||
export const multiTagFilter = [
|
||||
createFilter({
|
||||
name: 'containsOneOf',
|
||||
vars: [tVar(optionName, t.tag.instance())] as const,
|
||||
self: ct.array.instance(tRef(optionName)),
|
||||
args: [ct.array.instance(tRef(optionName))] as const,
|
||||
label: 'Contains one of',
|
||||
shortString: v =>
|
||||
v ? `: ${tagToString(v.value, v.type.element)}` : undefined,
|
||||
impl: (self, value) => {
|
||||
if (!value.length) {
|
||||
return true;
|
||||
}
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return value.some(v => self.includes(v));
|
||||
},
|
||||
defaultValue: args => [args[0][0]],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'doesNotContainOneOf',
|
||||
vars: [tVar(optionName, t.tag.instance())] as const,
|
||||
self: ct.array.instance(tRef(optionName)),
|
||||
args: [ct.array.instance(tRef(optionName))] as const,
|
||||
label: 'Does not contains one of',
|
||||
shortString: v =>
|
||||
v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined,
|
||||
impl: (self, value) => {
|
||||
if (!value.length) {
|
||||
return true;
|
||||
}
|
||||
if (self == null) {
|
||||
return true;
|
||||
}
|
||||
return value.every(v => !self.includes(v));
|
||||
},
|
||||
}),
|
||||
createFilter({
|
||||
name: 'containsAll',
|
||||
vars: [tVar(optionName, t.tag.instance())] as const,
|
||||
self: ct.array.instance(tRef(optionName)),
|
||||
args: [ct.array.instance(tRef(optionName))] as const,
|
||||
label: 'Contains all',
|
||||
shortString: v =>
|
||||
v ? `: ${tagToString(v.value, v.type.element)}` : undefined,
|
||||
impl: (self, value) => {
|
||||
if (!value.length) {
|
||||
return true;
|
||||
}
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return value.every(v => self.includes(v));
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'doesNotContainAll',
|
||||
vars: [tVar(optionName, t.tag.instance())] as const,
|
||||
self: ct.array.instance(tRef(optionName)),
|
||||
args: [ct.array.instance(tRef(optionName))] as const,
|
||||
label: 'Does not contains all',
|
||||
shortString: v =>
|
||||
v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined,
|
||||
impl: (self, value) => {
|
||||
if (!value.length) {
|
||||
return true;
|
||||
}
|
||||
if (self == null) {
|
||||
return true;
|
||||
}
|
||||
return !value.every(v => self.includes(v));
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,88 @@
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { createFilter } from './create.js';
|
||||
|
||||
export const numberFilter = [
|
||||
createFilter({
|
||||
name: 'equal',
|
||||
self: t.number.instance(),
|
||||
args: [t.number.instance()] as const,
|
||||
label: '=',
|
||||
shortString: v => (v ? ` = ${v.value}` : undefined),
|
||||
impl: (self, target) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self == target;
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'notEqual',
|
||||
self: t.number.instance(),
|
||||
args: [t.number.instance()] as const,
|
||||
label: '≠',
|
||||
shortString: v => (v ? ` ≠ ${v.value}` : undefined),
|
||||
impl: (self, target) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self != target;
|
||||
},
|
||||
}),
|
||||
createFilter({
|
||||
name: 'greatThan',
|
||||
self: t.number.instance(),
|
||||
args: [t.number.instance()] as const,
|
||||
label: '>',
|
||||
shortString: v => (v ? ` > ${v.value}` : undefined),
|
||||
impl: (self, target) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self > target;
|
||||
},
|
||||
defaultValue: args => args[0] + 1,
|
||||
}),
|
||||
createFilter({
|
||||
name: 'lessThan',
|
||||
self: t.number.instance(),
|
||||
args: [t.number.instance()] as const,
|
||||
label: '<',
|
||||
shortString: v => (v ? ` < ${v.value}` : undefined),
|
||||
impl: (self, target) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self < target;
|
||||
},
|
||||
defaultValue: args => args[0] - 1,
|
||||
}),
|
||||
createFilter({
|
||||
name: 'greatThanOrEqual',
|
||||
self: t.number.instance(),
|
||||
args: [t.number.instance()] as const,
|
||||
label: '≥',
|
||||
shortString: v => (v ? ` ≥ ${v.value}` : undefined),
|
||||
impl: (self, target) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self >= target;
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'lessThanOrEqual',
|
||||
self: t.number.instance(),
|
||||
args: [t.number.instance()] as const,
|
||||
label: '≤',
|
||||
shortString: v => (v ? ` ≤ ${v.value}` : undefined),
|
||||
impl: (self, target) => {
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return self <= target;
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,69 @@
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { createFilter } from './create.js';
|
||||
|
||||
export const stringFilter = [
|
||||
createFilter({
|
||||
name: 'contains',
|
||||
self: t.string.instance(),
|
||||
args: [t.string.instance()] as const,
|
||||
label: 'Contains',
|
||||
shortString: v => (v ? `: ${v.value}` : undefined),
|
||||
impl: (self = '', value) => {
|
||||
return self.toLowerCase().includes(value.toLowerCase());
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'doesNoContains',
|
||||
self: t.string.instance(),
|
||||
args: [t.string.instance()] as const,
|
||||
label: 'Does no contains',
|
||||
shortString: v => (v ? `: Not ${v.value}` : undefined),
|
||||
impl: (self = '', value) => {
|
||||
return !self.toLowerCase().includes(value.toLowerCase());
|
||||
},
|
||||
}),
|
||||
createFilter({
|
||||
name: 'startsWith',
|
||||
self: t.string.instance(),
|
||||
args: [t.string.instance()] as const,
|
||||
label: 'Starts with',
|
||||
shortString: v => (v ? `: Starts with ${v.value}` : undefined),
|
||||
impl: (self = '', value) => {
|
||||
return self.toLowerCase().startsWith(value.toLowerCase());
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'endsWith',
|
||||
self: t.string.instance(),
|
||||
args: [t.string.instance()] as const,
|
||||
label: 'Ends with',
|
||||
shortString: v => (v ? `: Ends with ${v.value}` : undefined),
|
||||
impl: (self = '', value) => {
|
||||
return self.toLowerCase().endsWith(value.toLowerCase());
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'is',
|
||||
self: t.string.instance(),
|
||||
args: [t.string.instance()] as const,
|
||||
label: 'Is',
|
||||
shortString: v => (v ? `: ${v.value}` : undefined),
|
||||
impl: (self = '', value) => {
|
||||
return self.toLowerCase() == value.toLowerCase();
|
||||
},
|
||||
defaultValue: args => args[0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'isNot',
|
||||
self: t.string.instance(),
|
||||
args: [t.string.instance()] as const,
|
||||
label: 'Is not',
|
||||
shortString: v => (v ? `: Not ${v.value}` : undefined),
|
||||
impl: (self = '', value) => {
|
||||
return self.toLowerCase() != value.toLowerCase();
|
||||
},
|
||||
}),
|
||||
];
|
||||
46
blocksuite/affine/data-view/src/core/filter/filter-fn/tag.ts
Normal file
46
blocksuite/affine/data-view/src/core/filter/filter-fn/tag.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ct } from '../../logical/composite-type.js';
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { tRef, tVar } from '../../logical/type-variable.js';
|
||||
import { createFilter } from './create.js';
|
||||
import { tagToString } from './utils.js';
|
||||
|
||||
const optionName = 'options' as const;
|
||||
export const tagFilter = [
|
||||
createFilter({
|
||||
name: 'isOneOf',
|
||||
vars: [tVar(optionName, t.tag.instance())] as const,
|
||||
self: tRef(optionName),
|
||||
args: [ct.array.instance(tRef(optionName))] as const,
|
||||
label: 'Is one of',
|
||||
shortString: v =>
|
||||
v ? `: ${tagToString(v.value, v.type.element)}` : undefined,
|
||||
impl: (self, value) => {
|
||||
if (!value.length) {
|
||||
return true;
|
||||
}
|
||||
if (self == null) {
|
||||
return false;
|
||||
}
|
||||
return value.includes(self);
|
||||
},
|
||||
defaultValue: args => args[0][0],
|
||||
}),
|
||||
createFilter({
|
||||
name: 'isNotOneOf',
|
||||
vars: [tVar(optionName, t.tag.instance())] as const,
|
||||
self: tRef(optionName),
|
||||
args: [ct.array.instance(tRef(optionName))] as const,
|
||||
label: 'Is not one of',
|
||||
shortString: v =>
|
||||
v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined,
|
||||
impl: (self, value) => {
|
||||
if (!value.length) {
|
||||
return true;
|
||||
}
|
||||
if (self == null) {
|
||||
return true;
|
||||
}
|
||||
return !value.includes(self);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { createFilter } from './create.js';
|
||||
|
||||
export const unknownFilter = [
|
||||
createFilter({
|
||||
name: 'isNotEmpty',
|
||||
self: t.unknown.instance(),
|
||||
args: [] as const,
|
||||
label: 'Is not empty',
|
||||
shortString: () => ': Is not empty',
|
||||
impl: self => {
|
||||
if (Array.isArray(self)) {
|
||||
return self.length > 0;
|
||||
}
|
||||
if (typeof self === 'string') {
|
||||
return !!self;
|
||||
}
|
||||
return self != null;
|
||||
},
|
||||
}),
|
||||
createFilter({
|
||||
name: 'isEmpty',
|
||||
self: t.unknown.instance(),
|
||||
args: [] as const,
|
||||
label: 'Is empty',
|
||||
shortString: () => ': Is empty',
|
||||
impl: self => {
|
||||
if (Array.isArray(self)) {
|
||||
return self.length === 0;
|
||||
}
|
||||
if (typeof self === 'string') {
|
||||
return !self;
|
||||
}
|
||||
return self == null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DataTypeOf } from '../../logical/data-type.js';
|
||||
import type { t } from '../../logical/index.js';
|
||||
|
||||
export const tagToString = (
|
||||
value: (string | undefined)[],
|
||||
type: DataTypeOf<typeof t.tag>
|
||||
) => {
|
||||
if (!type.data) {
|
||||
return;
|
||||
}
|
||||
const map = new Map(type.data.map(v => [v.id, v.value]));
|
||||
return value
|
||||
.flatMap(id => {
|
||||
if (id) {
|
||||
return map.get(id);
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.join(', ');
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Variable } from '../expression/index.js';
|
||||
import type { DVJSON } from '../property/types.js';
|
||||
import { filterMatcher } from './filter-fn/matcher.js';
|
||||
import type { FilterGroup, SingleFilter } from './types.js';
|
||||
|
||||
/**
|
||||
* Generate default values for a new row based on current filter conditions.
|
||||
* If a property has multiple conditions, no value will be set to avoid conflicts.
|
||||
*/
|
||||
export function generateDefaultValues(
|
||||
filter: FilterGroup,
|
||||
_vars: Variable[]
|
||||
): Record<string, DVJSON> {
|
||||
const defaultValues: Record<string, DVJSON> = {};
|
||||
const propertyConditions = new Map<string, SingleFilter[]>();
|
||||
|
||||
// Only collect top-level filters
|
||||
for (const condition of filter.conditions) {
|
||||
if (condition.type === 'filter') {
|
||||
const propertyId = condition.left.name;
|
||||
if (!propertyConditions.has(propertyId)) {
|
||||
propertyConditions.set(propertyId, []);
|
||||
}
|
||||
propertyConditions.get(propertyId)?.push(condition);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [propertyId, conditions] of propertyConditions) {
|
||||
if (conditions.length === 1) {
|
||||
const condition = conditions[0];
|
||||
const filterConfig = filterMatcher.getFilterByName(condition.function);
|
||||
if (filterConfig?.defaultValue) {
|
||||
const argValues = condition.args.map(arg => arg.value);
|
||||
const defaultValue = filterConfig.defaultValue(argValues);
|
||||
if (defaultValue != null) {
|
||||
defaultValues[propertyId] = defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValues;
|
||||
}
|
||||
5
blocksuite/affine/data-view/src/core/filter/index.ts
Normal file
5
blocksuite/affine/data-view/src/core/filter/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './add-filter.js';
|
||||
export * from './eval.js';
|
||||
export * from './generate-default-values.js';
|
||||
export * from './types.js';
|
||||
export * from './utils.js';
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { CreateLiteralItemsConfig } from './types.js';
|
||||
|
||||
export const createLiteral: CreateLiteralItemsConfig = config => {
|
||||
return config;
|
||||
};
|
||||
154
blocksuite/affine/data-view/src/core/filter/literal/define.ts
Normal file
154
blocksuite/affine/data-view/src/core/filter/literal/define.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { menu } from '@blocksuite/affine-components/context-menu';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { t } from '../../logical/type-presets.js';
|
||||
import { createLiteral } from './create.js';
|
||||
import type { LiteralItemsConfig } from './types.js';
|
||||
|
||||
export const allLiteralConfig: LiteralItemsConfig[] = [
|
||||
createLiteral({
|
||||
type: t.date.instance(),
|
||||
getItems: (_type, value, onChange) => {
|
||||
return [
|
||||
() => {
|
||||
return html` <date-picker
|
||||
.padding="${8}"
|
||||
.value="${value.value}"
|
||||
.onChange="${(date: Date) => {
|
||||
onChange(date.getTime());
|
||||
}}"
|
||||
></date-picker>`;
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
createLiteral({
|
||||
type: t.boolean.instance(),
|
||||
getItems: (_type, _value, _onChange) => {
|
||||
return [
|
||||
// menu.action({
|
||||
// name: 'Unchecked',
|
||||
// isSelected: !value.value,
|
||||
// select: () => {
|
||||
// onChange(false);
|
||||
// return false;
|
||||
// },
|
||||
// }),
|
||||
// menu.action({
|
||||
// name: 'Checked',
|
||||
// isSelected: !!value.value,
|
||||
// select: () => {
|
||||
// onChange(true);
|
||||
// return false;
|
||||
// },
|
||||
// }),
|
||||
];
|
||||
},
|
||||
}),
|
||||
createLiteral({
|
||||
type: t.string.instance(),
|
||||
getItems: (_type, value, onChange) => {
|
||||
return [
|
||||
menu.input({
|
||||
initialValue: value.value ?? '',
|
||||
onChange: onChange,
|
||||
placeholder: 'Type a value...',
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
createLiteral({
|
||||
type: t.number.instance(),
|
||||
getItems: (_type, value, onChange) => {
|
||||
return [
|
||||
menu.input({
|
||||
initialValue: value.value?.toString(10) ?? '',
|
||||
placeholder: 'Type a value...',
|
||||
onChange: text => {
|
||||
const number = Number.parseFloat(text);
|
||||
if (Number.isNaN(number)) {
|
||||
return;
|
||||
}
|
||||
onChange(number);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
createLiteral({
|
||||
type: t.array.instance(t.tag.instance()),
|
||||
getItems: (type, value, onChange) => {
|
||||
const set = new Set(value.value);
|
||||
return [
|
||||
menu.group({
|
||||
items:
|
||||
type.element.data?.map(tag => {
|
||||
const selected = set.has(tag.id);
|
||||
const prefix = selected
|
||||
? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` })
|
||||
: CheckBoxUnIcon();
|
||||
return menu.action({
|
||||
name: tag.value,
|
||||
prefix,
|
||||
label: () =>
|
||||
html`<span
|
||||
style="
|
||||
background-color: ${tag.color};
|
||||
padding:0 8px;
|
||||
border-radius:4px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
border:1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
"
|
||||
>${tag.value}</span
|
||||
>`,
|
||||
select: () => {
|
||||
if (selected) {
|
||||
set.delete(tag.id);
|
||||
} else {
|
||||
set.add(tag.id);
|
||||
}
|
||||
onChange([...set]);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}) ?? [],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
createLiteral({
|
||||
type: t.tag.instance(),
|
||||
getItems: (type, value, onChange) => {
|
||||
return [
|
||||
menu.group({
|
||||
items:
|
||||
type.data?.map(tag => {
|
||||
return menu.action({
|
||||
name: tag.value,
|
||||
label: () =>
|
||||
html`<span
|
||||
style="
|
||||
background-color: ${tag.color};
|
||||
padding:0 8px;
|
||||
border-radius:4px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
border:1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
"
|
||||
>${tag.value}</span
|
||||
>`,
|
||||
isSelected: value.value === tag.id,
|
||||
select: () => {
|
||||
onChange(tag.id);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}) ?? [],
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './define.js';
|
||||
export * from './matcher.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import { typeSystem } from '../../logical/index.js';
|
||||
import type { TypeInstance } from '../../logical/type.js';
|
||||
import { allLiteralConfig } from './define.js';
|
||||
|
||||
export const literalItemsMatcher = {
|
||||
getItems: (
|
||||
type: TypeInstance,
|
||||
value: ReadonlySignal<unknown>,
|
||||
onChange: (value: unknown) => void
|
||||
) => {
|
||||
for (const config of allLiteralConfig) {
|
||||
if (typeSystem.unify(type, config.type)) {
|
||||
return config.getItems(type, value, onChange);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
19
blocksuite/affine/data-view/src/core/filter/literal/types.ts
Normal file
19
blocksuite/affine/data-view/src/core/filter/literal/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { MenuConfig } from '@blocksuite/affine-components/context-menu';
|
||||
import type { ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import type { TypeInstance, ValueTypeOf } from '../../logical/type.js';
|
||||
|
||||
export type CreateLiteralItemsConfig = <
|
||||
Type extends TypeInstance = TypeInstance,
|
||||
>(
|
||||
config: LiteralItemsConfig<Type>
|
||||
) => LiteralItemsConfig<Type>;
|
||||
|
||||
export type LiteralItemsConfig<Type extends TypeInstance = any> = {
|
||||
type: Type;
|
||||
getItems: (
|
||||
type: Type,
|
||||
value: ReadonlySignal<ValueTypeOf<Type> | undefined>,
|
||||
onChange: (value: ValueTypeOf<Type>) => void
|
||||
) => MenuConfig[];
|
||||
};
|
||||
25
blocksuite/affine/data-view/src/core/filter/trait.ts
Normal file
25
blocksuite/affine/data-view/src/core/filter/trait.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import { createTraitKey } from '../traits/key.js';
|
||||
import type { SingleView } from '../view-manager/index.js';
|
||||
import type { FilterGroup } from './types.js';
|
||||
|
||||
export class FilterTrait {
|
||||
filterSet = (filter: FilterGroup) => {
|
||||
this.config.filterSet(filter);
|
||||
};
|
||||
|
||||
hasFilter$ = computed(() => {
|
||||
return this.filter$.value.conditions.length > 0;
|
||||
});
|
||||
|
||||
constructor(
|
||||
readonly filter$: ReadonlySignal<FilterGroup>,
|
||||
readonly view: SingleView,
|
||||
readonly config: {
|
||||
filterSet: (filter: FilterGroup) => void;
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
export const filterTraitKey = createTraitKey<FilterTrait>('filter');
|
||||
14
blocksuite/affine/data-view/src/core/filter/types.ts
Normal file
14
blocksuite/affine/data-view/src/core/filter/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Value, VariableRef } from '../expression/types.js';
|
||||
|
||||
export type SingleFilter = {
|
||||
type: 'filter';
|
||||
left: VariableRef;
|
||||
function?: string;
|
||||
args: Value[];
|
||||
};
|
||||
export type FilterGroup = {
|
||||
type: 'group';
|
||||
op: 'and' | 'or';
|
||||
conditions: Filter[];
|
||||
};
|
||||
export type Filter = SingleFilter | FilterGroup;
|
||||
59
blocksuite/affine/data-view/src/core/filter/utils.ts
Normal file
59
blocksuite/affine/data-view/src/core/filter/utils.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
import { getRefType } from '../expression/ref/ref.js';
|
||||
import type { Variable, VariableRef } from '../expression/types.js';
|
||||
import { filterMatcher } from './filter-fn/matcher.js';
|
||||
import type { FilterGroup, SingleFilter } from './types.js';
|
||||
|
||||
export const firstFilterName = (vars: Variable[], ref: VariableRef) => {
|
||||
const type = getRefType(vars, ref);
|
||||
if (!type) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DatabaseBlockError,
|
||||
`can't resolve ref type`
|
||||
);
|
||||
}
|
||||
return filterMatcher.firstMatchedBySelfType(type)?.name;
|
||||
};
|
||||
export const firstFilterByRef = (
|
||||
vars: Variable[],
|
||||
ref: VariableRef
|
||||
): SingleFilter => {
|
||||
return {
|
||||
type: 'filter',
|
||||
left: ref,
|
||||
function: firstFilterName(vars, ref),
|
||||
args: [],
|
||||
};
|
||||
};
|
||||
export const firstFilter = (vars: Variable[]): SingleFilter => {
|
||||
const ref: VariableRef = {
|
||||
type: 'ref',
|
||||
name: vars[0].id,
|
||||
};
|
||||
const filter = firstFilterName(vars, ref);
|
||||
if (!filter) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DatabaseBlockError,
|
||||
`can't match any filter`
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'filter',
|
||||
left: ref,
|
||||
function: filter,
|
||||
args: [],
|
||||
};
|
||||
};
|
||||
export const firstFilterInGroup = (vars: Variable[]): FilterGroup => {
|
||||
return {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [firstFilter(vars)],
|
||||
};
|
||||
};
|
||||
export const emptyFilterGroup: FilterGroup = {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
};
|
||||
22
blocksuite/affine/data-view/src/core/group-by/default.ts
Normal file
22
blocksuite/affine/data-view/src/core/group-by/default.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { GroupBy } from '../common/types.js';
|
||||
import type { DataSource } from '../data-source/index.js';
|
||||
import type { PropertyMetaConfig } from '../property/property-config.js';
|
||||
import { groupByMatcher } from './matcher.js';
|
||||
|
||||
export const defaultGroupBy = (
|
||||
dataSource: DataSource,
|
||||
propertyMeta: PropertyMetaConfig,
|
||||
propertyId: string,
|
||||
data: NonNullable<unknown>
|
||||
): GroupBy | undefined => {
|
||||
const name = groupByMatcher.match(
|
||||
propertyMeta.config.type({ data, dataSource })
|
||||
)?.name;
|
||||
return name != null
|
||||
? {
|
||||
type: 'groupBy',
|
||||
columnId: propertyId,
|
||||
name: name,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
169
blocksuite/affine/data-view/src/core/group-by/define.ts
Normal file
169
blocksuite/affine/data-view/src/core/group-by/define.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import hash from '@emotion/hash';
|
||||
|
||||
import { MatcherCreator } from '../logical/matcher.js';
|
||||
import { t } from '../logical/type-presets.js';
|
||||
import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js';
|
||||
import { BooleanGroupView } from './renderer/boolean-group.js';
|
||||
import { NumberGroupView } from './renderer/number-group.js';
|
||||
import { SelectGroupView } from './renderer/select-group.js';
|
||||
import { StringGroupView } from './renderer/string-group.js';
|
||||
import type { GroupByConfig } from './types.js';
|
||||
|
||||
const groupByMatcherCreator = new MatcherCreator<GroupByConfig>();
|
||||
const ungroups = {
|
||||
key: 'Ungroups',
|
||||
value: null,
|
||||
};
|
||||
export const groupByMatchers = [
|
||||
groupByMatcherCreator.createMatcher(t.tag.instance(), {
|
||||
name: 'select',
|
||||
groupName: (type, value) => {
|
||||
if (t.tag.is(type) && type.data) {
|
||||
return type.data.find(v => v.id === value)?.value ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
defaultKeys: type => {
|
||||
if (t.tag.is(type) && type.data) {
|
||||
return [
|
||||
ungroups,
|
||||
...type.data.map(v => ({
|
||||
key: v.id,
|
||||
value: v.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (value == null) {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: `${value}`,
|
||||
value: value.toString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
view: createUniComponentFromWebComponent(SelectGroupView),
|
||||
}),
|
||||
groupByMatcherCreator.createMatcher(t.array.instance(t.tag.instance()), {
|
||||
name: 'multi-select',
|
||||
groupName: (type, value) => {
|
||||
if (t.tag.is(type) && type.data) {
|
||||
return type.data.find(v => v.id === value)?.value ?? '';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
defaultKeys: type => {
|
||||
if (t.array.is(type) && t.tag.is(type.element) && type.element.data) {
|
||||
return [
|
||||
ungroups,
|
||||
...type.element.data.map(v => ({
|
||||
key: v.id,
|
||||
value: v.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (value == null) {
|
||||
return [ungroups];
|
||||
}
|
||||
if (Array.isArray(value) && value.length) {
|
||||
return value.map(id => ({
|
||||
key: `${id}`,
|
||||
value: id,
|
||||
}));
|
||||
}
|
||||
return [ungroups];
|
||||
},
|
||||
addToGroup: (value, old) => {
|
||||
if (value == null) {
|
||||
return old;
|
||||
}
|
||||
return Array.isArray(old) ? [...old, value] : [value];
|
||||
},
|
||||
removeFromGroup: (value, old) => {
|
||||
if (Array.isArray(old)) {
|
||||
return old.filter(v => v !== value);
|
||||
}
|
||||
return old;
|
||||
},
|
||||
view: createUniComponentFromWebComponent(SelectGroupView),
|
||||
}),
|
||||
groupByMatcherCreator.createMatcher(t.string.instance(), {
|
||||
name: 'text',
|
||||
groupName: (_type, value) => {
|
||||
return `${value ?? ''}`;
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: hash(value),
|
||||
value,
|
||||
},
|
||||
];
|
||||
},
|
||||
view: createUniComponentFromWebComponent(StringGroupView),
|
||||
}),
|
||||
groupByMatcherCreator.createMatcher(t.number.instance(), {
|
||||
name: 'number',
|
||||
groupName: (_type, value) => {
|
||||
return `${value ?? ''}`;
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [ungroups];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (typeof value !== 'number') {
|
||||
return [ungroups];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: `g:${Math.floor(value / 10)}`,
|
||||
value: Math.floor(value / 10),
|
||||
},
|
||||
];
|
||||
},
|
||||
addToGroup: value => (typeof value === 'number' ? value * 10 : undefined),
|
||||
view: createUniComponentFromWebComponent(NumberGroupView),
|
||||
}),
|
||||
groupByMatcherCreator.createMatcher(t.boolean.instance(), {
|
||||
name: 'boolean',
|
||||
groupName: (_type, value) => {
|
||||
return `${value?.toString() ?? ''}`;
|
||||
},
|
||||
defaultKeys: _type => {
|
||||
return [
|
||||
{ key: 'true', value: true },
|
||||
{ key: 'false', value: false },
|
||||
];
|
||||
},
|
||||
valuesGroup: (value, _type) => {
|
||||
if (typeof value !== 'boolean') {
|
||||
return [
|
||||
{
|
||||
key: 'false',
|
||||
value: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: value.toString(),
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
},
|
||||
view: createUniComponentFromWebComponent(BooleanGroupView),
|
||||
}),
|
||||
];
|
||||
210
blocksuite/affine/data-view/src/core/group-by/group-title.ts
Normal file
210
blocksuite/affine/data-view/src/core/group-by/group-title.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { MoreHorizontalIcon, PlusIcon } from '@blocksuite/icons/lit';
|
||||
import { nothing } from 'lit';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { renderUniLit } from '../utils/uni-component/uni-component.js';
|
||||
import type { GroupData } from './trait.js';
|
||||
import type { GroupRenderProps } from './types.js';
|
||||
|
||||
function GroupHeaderCount(group: GroupData) {
|
||||
const cards = group.rows;
|
||||
if (!cards.length) {
|
||||
return;
|
||||
}
|
||||
return html` <div class="group-header-count">${cards.length}</div>`;
|
||||
}
|
||||
const GroupTitleMobile = (
|
||||
groupData: GroupData,
|
||||
ops: {
|
||||
readonly: boolean;
|
||||
clickAdd: (evt: MouseEvent) => void;
|
||||
clickOps: (evt: MouseEvent) => void;
|
||||
}
|
||||
) => {
|
||||
const data = groupData.manager.config$.value;
|
||||
if (!data) return nothing;
|
||||
|
||||
const icon =
|
||||
groupData.value == null
|
||||
? ''
|
||||
: html` <uni-lit
|
||||
class="group-header-icon"
|
||||
.uni="${groupData.manager.property$.value?.icon}"
|
||||
></uni-lit>`;
|
||||
const props: GroupRenderProps = {
|
||||
value: groupData.value,
|
||||
data: groupData.property.data$.value,
|
||||
updateData: groupData.manager.updateData,
|
||||
updateValue: value => groupData.manager.updateValue(groupData.rows, value),
|
||||
readonly: ops.readonly,
|
||||
};
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.group-header-count {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--affine-background-secondary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
}
|
||||
|
||||
.group-header-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header-ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.group-header-op {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
|
||||
.group-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: -4px;
|
||||
font-size: 16px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
style="display:flex;align-items:center;gap: 8px;overflow: hidden;height: 22px;"
|
||||
>
|
||||
${icon} ${renderUniLit(data.view, props)} ${GroupHeaderCount(groupData)}
|
||||
</div>
|
||||
${ops.readonly
|
||||
? nothing
|
||||
: html` <div class="group-header-ops">
|
||||
<div @click="${ops.clickAdd}" class="group-header-op add-card">
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
<div @click="${ops.clickOps}" class="group-header-op">
|
||||
${MoreHorizontalIcon()}
|
||||
</div>
|
||||
</div>`}
|
||||
`;
|
||||
};
|
||||
|
||||
export const GroupTitle = (
|
||||
groupData: GroupData,
|
||||
ops: {
|
||||
readonly: boolean;
|
||||
clickAdd: (evt: MouseEvent) => void;
|
||||
clickOps: (evt: MouseEvent) => void;
|
||||
}
|
||||
) => {
|
||||
if (IS_MOBILE) {
|
||||
return GroupTitleMobile(groupData, ops);
|
||||
}
|
||||
const data = groupData.manager.config$.value;
|
||||
if (!data) return nothing;
|
||||
|
||||
const icon =
|
||||
groupData.value == null
|
||||
? ''
|
||||
: html` <uni-lit
|
||||
class="group-header-icon"
|
||||
.uni="${groupData.manager.property$.value?.icon}"
|
||||
></uni-lit>`;
|
||||
const props: GroupRenderProps = {
|
||||
value: groupData.value,
|
||||
data: groupData.property.data$.value,
|
||||
updateData: groupData.manager.updateData,
|
||||
updateValue: value => groupData.manager.updateValue(groupData.rows, value),
|
||||
readonly: ops.readonly,
|
||||
};
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.group-header-count {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--affine-background-secondary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--affine-text-secondary-color);
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
}
|
||||
|
||||
.group-header-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header-ops {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.group-header-op {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: all 150ms cubic-bezier(0.42, 0, 1, 1);
|
||||
}
|
||||
|
||||
.group-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.group-header-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-color);
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.group-header-op:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.group-header-op svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: var(--affine-icon-color);
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
style="display:flex;align-items:center;gap: 8px;overflow: hidden;height: 22px;"
|
||||
>
|
||||
${icon} ${renderUniLit(data.view, props)} ${GroupHeaderCount(groupData)}
|
||||
</div>
|
||||
${ops.readonly
|
||||
? nothing
|
||||
: html` <div class="group-header-ops">
|
||||
<div @click="${ops.clickAdd}" class="group-header-op add-card">
|
||||
${PlusIcon()}
|
||||
</div>
|
||||
<div @click="${ops.clickOps}" class="group-header-op">
|
||||
${MoreHorizontalIcon()}
|
||||
</div>
|
||||
</div>`}
|
||||
`;
|
||||
};
|
||||
5
blocksuite/affine/data-view/src/core/group-by/matcher.ts
Normal file
5
blocksuite/affine/data-view/src/core/group-by/matcher.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Matcher } from '../logical/matcher.js';
|
||||
import { groupByMatchers } from './define.js';
|
||||
import type { GroupByConfig } from './types.js';
|
||||
|
||||
export const groupByMatcher = new Matcher<GroupByConfig>(groupByMatchers);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { GroupRenderProps } from '../types.js';
|
||||
|
||||
export class BaseGroup<Data extends NonNullable<unknown>, Value>
|
||||
extends SignalWatcher(WithDisposable(ShadowlessElement))
|
||||
implements GroupRenderProps<Data, Value>
|
||||
{
|
||||
@property({ attribute: false })
|
||||
accessor data!: Data;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor readonly!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateData: ((data: Data) => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateValue: ((value: Value) => void) | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor value!: Value;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import { BaseGroup } from './base.js';
|
||||
|
||||
export class BooleanGroupView extends BaseGroup<NonNullable<unknown>, boolean> {
|
||||
static override styles = css`
|
||||
.data-view-group-title-boolean-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.data-view-group-title-boolean-view svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
protected override render(): unknown {
|
||||
return html` <div class="data-view-group-title-boolean-view">
|
||||
${this.value
|
||||
? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` })
|
||||
: CheckBoxUnIcon()}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import { BaseGroup } from './base.js';
|
||||
|
||||
export class NumberGroupView extends BaseGroup<NonNullable<unknown>, number> {
|
||||
static override styles = css`
|
||||
.data-view-group-title-number-view {
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
width: max-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-view-group-title-number-view:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _click = () => {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
popMenu(popupTargetFromElement(this), {
|
||||
options: {
|
||||
items: [
|
||||
menu.input({
|
||||
initialValue: this.value ? `${this.value * 10}` : '',
|
||||
onChange: text => {
|
||||
const num = Number.parseFloat(text);
|
||||
if (Number.isNaN(num)) {
|
||||
return;
|
||||
}
|
||||
this.updateValue?.(num);
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
protected override render(): unknown {
|
||||
if (this.value == null) {
|
||||
return html` <div>Ungroups</div>`;
|
||||
}
|
||||
if (this.value >= 10) {
|
||||
return html` <div
|
||||
@click="${this._click}"
|
||||
class="data-view-group-title-number-view"
|
||||
>
|
||||
>= 100
|
||||
</div>`;
|
||||
}
|
||||
return html` <div
|
||||
@click="${this._click}"
|
||||
class="data-view-group-title-number-view"
|
||||
>
|
||||
${this.value * 10} - ${this.value * 10 + 9}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { css, html } from 'lit';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { selectOptionColors } from '../../component/tags/colors.js';
|
||||
import type { SelectTag } from '../../logical/index.js';
|
||||
import { BaseGroup } from './base.js';
|
||||
|
||||
export class SelectGroupView extends BaseGroup<
|
||||
{
|
||||
options: SelectTag[];
|
||||
},
|
||||
string
|
||||
> {
|
||||
static override styles = css`
|
||||
data-view-group-title-select-view {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-view-group-title-select-view {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-view-group-title-select-view.readonly {
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--data-view-cell-text-size);
|
||||
line-height: var(--data-view-cell-text-line-height);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
private _click = (e: MouseEvent) => {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
popMenu(popupTargetFromElement(this), {
|
||||
options: {
|
||||
items: [
|
||||
menu.input({
|
||||
initialValue: this.tag?.value ?? '',
|
||||
onChange: text => {
|
||||
this.updateTag({ value: text });
|
||||
},
|
||||
}),
|
||||
...selectOptionColors.map(({ color, name }) => {
|
||||
const styles = styleMap({
|
||||
backgroundColor: color,
|
||||
borderRadius: '50%',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
});
|
||||
return menu.action({
|
||||
name: name,
|
||||
isSelected: this.tag?.color === color,
|
||||
prefix: html` <div style=${styles}></div>`,
|
||||
select: () => {
|
||||
this.updateTag({ color });
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
get tag() {
|
||||
return this.data.options.find(v => v.id === this.value);
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
const tag = this.tag;
|
||||
if (!tag) {
|
||||
return html` <div
|
||||
style="font-size: 14px;color: var(--affine-text-primary-color);line-height: 22px;"
|
||||
>
|
||||
Ungroups
|
||||
</div>`;
|
||||
}
|
||||
const style = styleMap({
|
||||
backgroundColor: tag.color,
|
||||
});
|
||||
const classList = classMap({
|
||||
'data-view-group-title-select-view': true,
|
||||
readonly: this.readonly,
|
||||
});
|
||||
return html` <div @click="${this._click}" class="${classList}">
|
||||
<div class="tag" style="${style}">${tag.value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
updateTag(tag: Partial<SelectTag>) {
|
||||
this.updateData?.({
|
||||
...this.data,
|
||||
options: this.data.options.map(v => {
|
||||
if (v.id === this.value) {
|
||||
return {
|
||||
...v,
|
||||
...tag,
|
||||
};
|
||||
}
|
||||
return v;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { css, html } from 'lit';
|
||||
|
||||
import { BaseGroup } from './base.js';
|
||||
|
||||
export class StringGroupView extends BaseGroup<NonNullable<unknown>, string> {
|
||||
static override styles = css`
|
||||
.data-view-group-title-string-view {
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
width: max-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-view-group-title-string-view:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _click = () => {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
popMenu(popupTargetFromElement(this), {
|
||||
options: {
|
||||
items: [
|
||||
menu.input({
|
||||
initialValue: this.value ?? '',
|
||||
onComplete: text => {
|
||||
this.updateValue?.(text);
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
protected override render(): unknown {
|
||||
if (!this.value) {
|
||||
return html` <div>Ungroups</div>`;
|
||||
}
|
||||
return html` <div
|
||||
@click="${this._click}"
|
||||
class="data-view-group-title-string-view"
|
||||
>
|
||||
${this.value}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
312
blocksuite/affine/data-view/src/core/group-by/setting.ts
Normal file
312
blocksuite/affine/data-view/src/core/group-by/setting.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
menu,
|
||||
type MenuConfig,
|
||||
type MenuOptions,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { DeleteIcon } from '@blocksuite/icons/lit';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, unsafeCSS } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js';
|
||||
import { TableSingleView } from '../../view-presets/table/table-view-manager.js';
|
||||
import { dataViewCssVariable } from '../common/css-variable.js';
|
||||
import { renderUniLit } from '../utils/uni-component/uni-component.js';
|
||||
import { dragHandler } from '../utils/wc-dnd/dnd-context.js';
|
||||
import { defaultActivators } from '../utils/wc-dnd/sensors/index.js';
|
||||
import {
|
||||
createSortContext,
|
||||
sortable,
|
||||
} from '../utils/wc-dnd/sort/sort-context.js';
|
||||
import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js';
|
||||
import { groupByMatcher } from './matcher.js';
|
||||
import type { GroupTrait } from './trait.js';
|
||||
import type { GroupRenderProps } from './types.js';
|
||||
|
||||
export class GroupSetting extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
data-view-group-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
${unsafeCSS(dataViewCssVariable())};
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
padding: 4px 12px;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.group-item-drag-bar {
|
||||
width: 4px;
|
||||
height: 12px;
|
||||
border-radius: 1px;
|
||||
background-color: #efeff0;
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.group-item:hover .group-item-drag-bar {
|
||||
background-color: #c0bfc1;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor groupTrait!: GroupTrait;
|
||||
|
||||
groups$ = computed(() => {
|
||||
return this.groupTrait.groupsDataList$.value;
|
||||
});
|
||||
|
||||
sortContext = createSortContext({
|
||||
activators: defaultActivators,
|
||||
container: this,
|
||||
onDragEnd: evt => {
|
||||
const over = evt.over;
|
||||
const activeId = evt.active.id;
|
||||
const groups = this.groups$.value;
|
||||
if (over && over.id !== activeId && groups) {
|
||||
const activeIndex = groups.findIndex(data => data.key === activeId);
|
||||
const overIndex = groups.findIndex(data => data.key === over.id);
|
||||
|
||||
this.groupTrait.moveGroupTo(
|
||||
activeId,
|
||||
activeIndex > overIndex
|
||||
? {
|
||||
before: true,
|
||||
id: over.id,
|
||||
}
|
||||
: {
|
||||
before: false,
|
||||
id: over.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
modifiers: [
|
||||
({ transform }) => {
|
||||
return {
|
||||
...transform,
|
||||
x: 0,
|
||||
};
|
||||
},
|
||||
],
|
||||
items: computed(() => {
|
||||
return this.groupTrait.groupsDataList$.value?.map(v => v.key) ?? [];
|
||||
}),
|
||||
strategy: verticalListSortingStrategy,
|
||||
});
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
const groups = this.groupTrait.groupsDataList$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
return html`
|
||||
<div style="padding: 7px 0;">
|
||||
<div
|
||||
style="padding: 0 4px; font-size: 12px;color: var(--affine-text-secondary-color);line-height: 20px;"
|
||||
>
|
||||
Groups
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div
|
||||
style="display:flex;flex-direction: column;gap: 4px;"
|
||||
class="group-sort-setting"
|
||||
>
|
||||
${repeat(
|
||||
groups,
|
||||
group => group.key,
|
||||
group => {
|
||||
const props: GroupRenderProps = {
|
||||
value: group.value,
|
||||
data: group.property.data$.value,
|
||||
readonly: true,
|
||||
};
|
||||
const config = group.manager.config$.value;
|
||||
return html` <div
|
||||
${sortable(group.key)}
|
||||
${dragHandler(group.key)}
|
||||
class="dv-hover dv-round-4 group-item"
|
||||
>
|
||||
<div class="group-item-drag-bar"></div>
|
||||
<div style="padding: 0 4px;position:relative;">
|
||||
${renderUniLit(config?.view, props)}
|
||||
<div
|
||||
style="position:absolute;left: 0;top: 0;right: 0;bottom: 0;"
|
||||
></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.group-sort-setting')
|
||||
accessor groupContainer!: HTMLElement;
|
||||
}
|
||||
|
||||
export const selectGroupByProperty = (
|
||||
group: GroupTrait,
|
||||
ops?: {
|
||||
onSelect?: (id?: string) => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
): MenuOptions => {
|
||||
const view = group.view;
|
||||
return {
|
||||
onClose: ops?.onClose,
|
||||
title: {
|
||||
text: 'Group by',
|
||||
onBack: ops?.onBack,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: view.propertiesWithoutFilter$.value
|
||||
.filter(id => {
|
||||
if (view.propertyGet(id).type$.value === 'title') {
|
||||
return false;
|
||||
}
|
||||
return !!groupByMatcher.match(view.propertyGet(id).dataType$.value);
|
||||
})
|
||||
.map<MenuConfig>(id => {
|
||||
const property = view.propertyGet(id);
|
||||
return menu.action({
|
||||
name: property.name$.value,
|
||||
isSelected: group.property$.value?.id === id,
|
||||
prefix: html` <uni-lit .uni="${property.icon}"></uni-lit>`,
|
||||
select: () => {
|
||||
group.changeGroup(id);
|
||||
ops?.onSelect?.(id);
|
||||
},
|
||||
});
|
||||
}),
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: DeleteIcon(),
|
||||
hide: () =>
|
||||
view instanceof KanbanSingleView || group.property$.value == null,
|
||||
class: { 'delete-item': true },
|
||||
name: 'Remove Grouping',
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
ops?.onSelect?.();
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
export const popSelectGroupByProperty = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
ops?: {
|
||||
onSelect?: () => void;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
) => {
|
||||
popMenu(target, {
|
||||
options: selectGroupByProperty(group, ops),
|
||||
});
|
||||
};
|
||||
export const popGroupSetting = (
|
||||
target: PopupTarget,
|
||||
group: GroupTrait,
|
||||
onBack: () => void
|
||||
) => {
|
||||
const view = group.view;
|
||||
const groupProperty = group.property$.value;
|
||||
if (groupProperty == null) {
|
||||
return;
|
||||
}
|
||||
const type = groupProperty.type$.value;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
const icon = view.propertyIconGet(type);
|
||||
const menuHandler = popMenu(target, {
|
||||
options: {
|
||||
title: {
|
||||
text: 'Group',
|
||||
onBack: onBack,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: [
|
||||
menu.subMenu({
|
||||
name: 'Group By',
|
||||
postfix: html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap: 4px;font-size: 12px;line-height: 20px;color: var(--affine-text-secondary-color);margin-right: 4px;margin-left: 8px;"
|
||||
class="dv-icon-16"
|
||||
>
|
||||
${renderUniLit(icon, {})} ${groupProperty.name$.value}
|
||||
</div>
|
||||
`,
|
||||
label: () => html`
|
||||
<div style="color: var(--affine-text-secondary-color);">
|
||||
Group By
|
||||
</div>
|
||||
`,
|
||||
options: selectGroupByProperty(group, {
|
||||
onSelect: () => {
|
||||
menuHandler.close();
|
||||
popGroupSetting(target, group, onBack);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu =>
|
||||
html` <data-view-group-setting
|
||||
@mouseenter="${() => menu.closeSubMenu()}"
|
||||
.groupTrait="${group}"
|
||||
.columnId="${groupProperty.id}"
|
||||
></data-view-group-setting>`,
|
||||
],
|
||||
}),
|
||||
menu.group({
|
||||
items: [
|
||||
menu.action({
|
||||
name: 'Remove grouping',
|
||||
prefix: DeleteIcon(),
|
||||
class: { 'delete-item': true },
|
||||
hide: () => !(view instanceof TableSingleView),
|
||||
select: () => {
|
||||
group.changeGroup(undefined);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
313
blocksuite/affine/data-view/src/core/group-by/trait.ts
Normal file
313
blocksuite/affine/data-view/src/core/group-by/trait.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import type { GroupBy, GroupProperty } from '../common/types.js';
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
import type { DVJSON } from '../property/types.js';
|
||||
import { createTraitKey } from '../traits/key.js';
|
||||
import type { Property } from '../view-manager/property.js';
|
||||
import type { SingleView } from '../view-manager/single-view.js';
|
||||
import { defaultGroupBy } from './default.js';
|
||||
import { groupByMatcher } from './matcher.js';
|
||||
|
||||
export type GroupData = {
|
||||
manager: GroupTrait;
|
||||
property: Property;
|
||||
key: string;
|
||||
name: string;
|
||||
type: TypeInstance;
|
||||
value: DVJSON;
|
||||
rows: string[];
|
||||
};
|
||||
|
||||
export class GroupTrait {
|
||||
private preDataList: GroupData[] | undefined;
|
||||
|
||||
config$ = computed(() => {
|
||||
const groupBy = this.groupBy$.value;
|
||||
if (!groupBy) {
|
||||
return;
|
||||
}
|
||||
const result = groupByMatcher.find(v => v.data.name === groupBy.name);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
return result.data;
|
||||
});
|
||||
|
||||
property$ = computed(() => {
|
||||
const groupBy = this.groupBy$.value;
|
||||
if (!groupBy) {
|
||||
return;
|
||||
}
|
||||
return this.view.propertyGet(groupBy.columnId);
|
||||
});
|
||||
|
||||
staticGroupDataMap$ = computed<
|
||||
Record<string, Omit<GroupData, 'rows'>> | undefined
|
||||
>(() => {
|
||||
const config = this.config$.value;
|
||||
const property = this.property$.value;
|
||||
const tType = property?.dataType$.value;
|
||||
if (!config || !tType || !property) {
|
||||
return;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
config.defaultKeys(tType).map(({ key, value }) => [
|
||||
key,
|
||||
{
|
||||
key,
|
||||
property,
|
||||
name: config.groupName(tType, value),
|
||||
manager: this,
|
||||
type: tType,
|
||||
value,
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
groupDataMap$ = computed<Record<string, GroupData> | undefined>(() => {
|
||||
const staticGroupMap = this.staticGroupDataMap$.value;
|
||||
const config = this.config$.value;
|
||||
const groupBy = this.groupBy$.value;
|
||||
const property = this.property$.value;
|
||||
const tType = property?.dataType$.value;
|
||||
if (!staticGroupMap || !config || !groupBy || !tType || !property) {
|
||||
return;
|
||||
}
|
||||
const groupMap: Record<string, GroupData> = Object.fromEntries(
|
||||
Object.entries(staticGroupMap).map(([k, v]) => [k, { ...v, rows: [] }])
|
||||
);
|
||||
this.view.rows$.value.forEach(id => {
|
||||
const value = this.view.cellJsonValueGet(id, groupBy.columnId);
|
||||
const keys = config.valuesGroup(value, tType);
|
||||
keys.forEach(({ key, value }) => {
|
||||
if (!groupMap[key]) {
|
||||
groupMap[key] = {
|
||||
key,
|
||||
property: property,
|
||||
name: config.groupName(tType, value),
|
||||
manager: this,
|
||||
value,
|
||||
rows: [],
|
||||
type: tType,
|
||||
};
|
||||
}
|
||||
groupMap[key].rows.push(id);
|
||||
});
|
||||
});
|
||||
return groupMap;
|
||||
});
|
||||
|
||||
private _groupsDataList$ = computed(() => {
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
if (!groupMap) {
|
||||
return;
|
||||
}
|
||||
const sortedGroup = this.ops.sortGroup(Object.keys(groupMap));
|
||||
sortedGroup.forEach(key => {
|
||||
groupMap[key].rows = this.ops.sortRow(key, groupMap[key].rows);
|
||||
});
|
||||
return (this.preDataList = sortedGroup.map(key => groupMap[key]));
|
||||
});
|
||||
|
||||
groupsDataList$ = computed(() => {
|
||||
if (this.view.isLocked$.value) {
|
||||
return this.preDataList;
|
||||
}
|
||||
return (this.preDataList = this._groupsDataList$.value);
|
||||
});
|
||||
|
||||
updateData = (data: NonNullable<unknown>) => {
|
||||
const propertyId = this.propertyId;
|
||||
if (!propertyId) {
|
||||
return;
|
||||
}
|
||||
this.view.propertyDataSet(propertyId, data);
|
||||
};
|
||||
|
||||
get addGroup() {
|
||||
const type = this.property$.value?.type$.value;
|
||||
if (!type) {
|
||||
return;
|
||||
}
|
||||
return this.view.propertyMetaGet(type)?.config.addGroup;
|
||||
}
|
||||
|
||||
get propertyId() {
|
||||
return this.groupBy$.value?.columnId;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private groupBy$: ReadonlySignal<GroupBy | undefined>,
|
||||
public view: SingleView,
|
||||
private ops: {
|
||||
groupBySet: (groupBy: GroupBy | undefined) => void;
|
||||
sortGroup: (keys: string[]) => string[];
|
||||
sortRow: (groupKey: string, rowIds: string[]) => string[];
|
||||
changeGroupSort: (keys: string[]) => void;
|
||||
changeRowSort: (
|
||||
groupKeys: string[],
|
||||
groupKey: string,
|
||||
keys: string[]
|
||||
) => void;
|
||||
}
|
||||
) {}
|
||||
|
||||
addToGroup(rowId: string, key: string) {
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
const propertyId = this.propertyId;
|
||||
if (!groupMap || !propertyId) {
|
||||
return;
|
||||
}
|
||||
const addTo = this.config$.value?.addToGroup ?? (value => value);
|
||||
const newValue = addTo(
|
||||
groupMap[key].value,
|
||||
this.view.cellJsonValueGet(rowId, propertyId)
|
||||
);
|
||||
this.view.cellValueSet(rowId, propertyId, newValue);
|
||||
}
|
||||
|
||||
changeCardSort(groupKey: string, cardIds: string[]) {
|
||||
const groups = this.groupsDataList$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
this.ops.changeRowSort(
|
||||
groups.map(v => v.key),
|
||||
groupKey,
|
||||
cardIds
|
||||
);
|
||||
}
|
||||
|
||||
changeGroup(columnId: string | undefined) {
|
||||
if (columnId == null) {
|
||||
this.ops.groupBySet(undefined);
|
||||
return;
|
||||
}
|
||||
const column = this.view.propertyGet(columnId);
|
||||
const propertyMeta = this.view.propertyMetaGet(column.type$.value);
|
||||
if (propertyMeta) {
|
||||
this.ops.groupBySet(
|
||||
defaultGroupBy(
|
||||
this.view.manager.dataSource,
|
||||
propertyMeta,
|
||||
column.id,
|
||||
column.data$.value
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
changeGroupSort(keys: string[]) {
|
||||
this.ops.changeGroupSort(keys);
|
||||
}
|
||||
|
||||
defaultGroupProperty(key: string): GroupProperty {
|
||||
return {
|
||||
key,
|
||||
hide: false,
|
||||
manuallyCardSort: [],
|
||||
};
|
||||
}
|
||||
|
||||
moveCardTo(
|
||||
rowId: string,
|
||||
fromGroupKey: string | undefined,
|
||||
toGroupKey: string,
|
||||
position: InsertToPosition
|
||||
) {
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
if (!groupMap) {
|
||||
return;
|
||||
}
|
||||
if (fromGroupKey !== toGroupKey) {
|
||||
const propertyId = this.propertyId;
|
||||
if (!propertyId) {
|
||||
return;
|
||||
}
|
||||
const remove = this.config$.value?.removeFromGroup ?? (() => undefined);
|
||||
const group = fromGroupKey != null ? groupMap[fromGroupKey] : undefined;
|
||||
let newValue: unknown = undefined;
|
||||
if (group) {
|
||||
newValue = remove(
|
||||
group.value,
|
||||
this.view.cellJsonValueGet(rowId, propertyId)
|
||||
);
|
||||
}
|
||||
const addTo = this.config$.value?.addToGroup ?? (value => value);
|
||||
newValue = addTo(groupMap[toGroupKey].value, newValue);
|
||||
this.view.cellValueSet(rowId, propertyId, newValue);
|
||||
}
|
||||
const rows = groupMap[toGroupKey].rows.filter(id => id !== rowId);
|
||||
const index = insertPositionToIndex(position, rows, id => id);
|
||||
rows.splice(index, 0, rowId);
|
||||
this.changeCardSort(toGroupKey, rows);
|
||||
}
|
||||
|
||||
moveGroupTo(groupKey: string, position: InsertToPosition) {
|
||||
const groups = this.groupsDataList$.value;
|
||||
if (!groups) {
|
||||
return;
|
||||
}
|
||||
const keys = groups.map(v => v.key);
|
||||
keys.splice(
|
||||
keys.findIndex(key => key === groupKey),
|
||||
1
|
||||
);
|
||||
const index = insertPositionToIndex(position, keys, key => key);
|
||||
keys.splice(index, 0, groupKey);
|
||||
this.changeGroupSort(keys);
|
||||
}
|
||||
|
||||
removeFromGroup(rowId: string, key: string) {
|
||||
const groupMap = this.groupDataMap$.value;
|
||||
if (!groupMap) {
|
||||
return;
|
||||
}
|
||||
const propertyId = this.propertyId;
|
||||
if (!propertyId) {
|
||||
return;
|
||||
}
|
||||
const remove = this.config$.value?.removeFromGroup ?? (() => undefined);
|
||||
const newValue = remove(
|
||||
groupMap[key].value,
|
||||
this.view.cellJsonValueGet(rowId, propertyId)
|
||||
);
|
||||
this.view.cellValueSet(rowId, propertyId, newValue);
|
||||
}
|
||||
|
||||
updateValue(rows: string[], value: DVJSON) {
|
||||
const propertyId = this.propertyId;
|
||||
if (!propertyId) {
|
||||
return;
|
||||
}
|
||||
rows.forEach(id => {
|
||||
this.view.cellJsonValueSet(id, propertyId, value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const groupTraitKey = createTraitKey<GroupTrait>('group');
|
||||
|
||||
export const sortByManually = <T>(
|
||||
arr: T[],
|
||||
getId: (v: T) => string,
|
||||
ids: string[]
|
||||
) => {
|
||||
const map = new Map(arr.map(v => [getId(v), v]));
|
||||
const result: T[] = [];
|
||||
for (const id of ids) {
|
||||
const value = map.get(id);
|
||||
if (value) {
|
||||
map.delete(id);
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
result.push(...map.values());
|
||||
return result;
|
||||
};
|
||||
33
blocksuite/affine/data-view/src/core/group-by/types.ts
Normal file
33
blocksuite/affine/data-view/src/core/group-by/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
import type { DVJSON } from '../property/types.js';
|
||||
import type { UniComponent } from '../utils/index.js';
|
||||
|
||||
export interface GroupRenderProps<
|
||||
Data extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
Value = DVJSON,
|
||||
> {
|
||||
data: Data;
|
||||
updateData?: (data: Data) => void;
|
||||
value: Value;
|
||||
updateValue?: (value: Value) => void;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
export type GroupByConfig = {
|
||||
name: string;
|
||||
groupName: (type: TypeInstance, value: unknown) => string;
|
||||
defaultKeys: (type: TypeInstance) => {
|
||||
key: string;
|
||||
value: DVJSON;
|
||||
}[];
|
||||
valuesGroup: (
|
||||
value: unknown,
|
||||
type: TypeInstance
|
||||
) => {
|
||||
key: string;
|
||||
value: DVJSON;
|
||||
}[];
|
||||
addToGroup?: (value: unknown, oldValue: unknown) => unknown;
|
||||
removeFromGroup?: (value: unknown, oldValue: unknown) => unknown;
|
||||
view: UniComponent<GroupRenderProps>;
|
||||
};
|
||||
13
blocksuite/affine/data-view/src/core/index.ts
Normal file
13
blocksuite/affine/data-view/src/core/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export * from './common/index.js';
|
||||
export * from './component/index.js';
|
||||
export { DataSourceBase } from './data-source/base.js';
|
||||
export { DataView } from './data-view.js';
|
||||
export * from './filter/index.js';
|
||||
export * from './logical/index.js';
|
||||
export * from './property/index.js';
|
||||
export type { DataViewSelection } from './types.js';
|
||||
export * from './types.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './view/index.js';
|
||||
export * from './view-manager/index.js';
|
||||
export * from './widget/index.js';
|
||||
138
blocksuite/affine/data-view/src/core/logical/composite-type.ts
Normal file
138
blocksuite/affine/data-view/src/core/logical/composite-type.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import Zod from 'zod';
|
||||
|
||||
import type {
|
||||
AnyTypeInstance,
|
||||
TypeInstance,
|
||||
Unify,
|
||||
ValueTypeOf,
|
||||
} from './type.js';
|
||||
import type {
|
||||
TypeVarContext,
|
||||
TypeVarDefinitionInstance,
|
||||
} from './type-variable.js';
|
||||
|
||||
type FnValueType<
|
||||
Args extends readonly TypeInstance[],
|
||||
Return extends TypeInstance,
|
||||
> = (
|
||||
...args: { [K in keyof Args]: ValueTypeOf<Args[K]> }
|
||||
) => ValueTypeOf<Return>;
|
||||
|
||||
export class FnTypeInstance<
|
||||
Args extends readonly TypeInstance[] = readonly TypeInstance[],
|
||||
Return extends TypeInstance = TypeInstance,
|
||||
> implements TypeInstance
|
||||
{
|
||||
_validate = fnSchema;
|
||||
|
||||
readonly _valueType = undefined as never as FnValueType<Args, Return>;
|
||||
|
||||
name = 'function';
|
||||
|
||||
constructor(
|
||||
readonly args: Args,
|
||||
readonly rt: Return,
|
||||
readonly vars: TypeVarDefinitionInstance[]
|
||||
) {}
|
||||
|
||||
subst(ctx: TypeVarContext) {
|
||||
const newCtx = { ...ctx };
|
||||
const args: TypeInstance[] = [];
|
||||
for (const arg of this.args) {
|
||||
const newArg = arg.subst(newCtx);
|
||||
if (!newArg) {
|
||||
return;
|
||||
}
|
||||
args.push(newArg);
|
||||
}
|
||||
const rt = this.rt.subst(newCtx);
|
||||
if (!rt) {
|
||||
return;
|
||||
}
|
||||
return ct.fn.instance(args, rt);
|
||||
}
|
||||
|
||||
unify(ctx: TypeVarContext, template: FnTypeInstance, unify: Unify): boolean {
|
||||
const newCtx = { ...ctx };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-for-of
|
||||
for (let i = 0; i < template.args.length; i++) {
|
||||
const arg = template.args[i];
|
||||
const realArg = this.args[i];
|
||||
if (arg == null) {
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if (realArg != null) {
|
||||
if (!unify(newCtx, realArg, arg)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return unify(newCtx, template.rt, this.rt);
|
||||
}
|
||||
|
||||
valueValidate(value: unknown): value is FnValueType<Args, Return> {
|
||||
return fnSchema.safeParse(value).success;
|
||||
}
|
||||
}
|
||||
|
||||
const fnSchema = Zod.function();
|
||||
|
||||
export class ArrayTypeInstance<Element extends TypeInstance = TypeInstance>
|
||||
implements TypeInstance
|
||||
{
|
||||
readonly _validate;
|
||||
|
||||
readonly _valueType = undefined as never as ValueTypeOf<Element>[];
|
||||
|
||||
readonly name = 'array';
|
||||
|
||||
constructor(readonly element: Element) {
|
||||
this._validate = Zod.array(element._validate);
|
||||
}
|
||||
|
||||
subst(ctx: TypeVarContext) {
|
||||
const ele = this.element.subst(ctx);
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
return ct.array.instance(ele);
|
||||
}
|
||||
|
||||
unify(ctx: TypeVarContext, type: ArrayTypeInstance, unify: Unify): boolean {
|
||||
return unify(ctx, this.element, type.element);
|
||||
}
|
||||
|
||||
valueValidate(value: unknown): value is ValueTypeOf<Element>[] {
|
||||
return this._validate.safeParse(value).success;
|
||||
}
|
||||
}
|
||||
|
||||
export const ct = {
|
||||
fn: {
|
||||
is: (type: AnyTypeInstance): type is FnTypeInstance => {
|
||||
return type.name === 'function';
|
||||
},
|
||||
instance: <
|
||||
Args extends readonly TypeInstance[],
|
||||
Return extends TypeInstance,
|
||||
>(
|
||||
args: Args,
|
||||
rt: Return,
|
||||
vars?: TypeVarDefinitionInstance[]
|
||||
) => {
|
||||
return new FnTypeInstance(args, rt, vars ?? []);
|
||||
},
|
||||
},
|
||||
array: {
|
||||
is: (type: AnyTypeInstance): type is ArrayTypeInstance => {
|
||||
return type.name === 'array';
|
||||
},
|
||||
instance: <Element extends TypeInstance>(
|
||||
element: Element
|
||||
): ArrayTypeInstance<Element> => {
|
||||
return new ArrayTypeInstance(element);
|
||||
},
|
||||
},
|
||||
};
|
||||
79
blocksuite/affine/data-view/src/core/logical/data-type.ts
Normal file
79
blocksuite/affine/data-view/src/core/logical/data-type.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type Zod from 'zod';
|
||||
|
||||
import type {
|
||||
AnyTypeInstance,
|
||||
TypeDefinition,
|
||||
TypeInstance,
|
||||
Unify,
|
||||
} from './type.js';
|
||||
import type { TypeVarContext } from './type-variable.js';
|
||||
|
||||
export type DataTypeOf<T extends DataType> = ReturnType<T['instance']>;
|
||||
|
||||
export class DTInstance<
|
||||
Name extends string = string,
|
||||
Data = unknown,
|
||||
ValueSchema extends Zod.ZodType = Zod.ZodType,
|
||||
> implements TypeInstance
|
||||
{
|
||||
readonly _valueType = undefined as never as Zod.TypeOf<ValueSchema>;
|
||||
|
||||
constructor(
|
||||
readonly name: Name,
|
||||
readonly _validate: ValueSchema,
|
||||
readonly data?: Data
|
||||
) {}
|
||||
|
||||
subst(_ctx: TypeVarContext): void | TypeInstance {
|
||||
return this;
|
||||
}
|
||||
|
||||
unify(_ctx: TypeVarContext, type: DTInstance, _unify: Unify): boolean {
|
||||
if (this.name !== type.name) {
|
||||
return false;
|
||||
}
|
||||
if (type.data == null) {
|
||||
return true;
|
||||
}
|
||||
return this.data != null;
|
||||
}
|
||||
|
||||
valueValidate(value: unknown): value is this['_valueType'] {
|
||||
return this._validate.safeParse(value).success;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataType<
|
||||
Name extends string = string,
|
||||
DataSchema extends Zod.ZodType = Zod.ZodType,
|
||||
ValueSchema extends Zod.ZodType = Zod.ZodType,
|
||||
> implements TypeDefinition
|
||||
{
|
||||
constructor(
|
||||
private name: Name,
|
||||
_dataSchema: DataSchema,
|
||||
private valueSchema: ValueSchema
|
||||
) {}
|
||||
|
||||
instance(literal?: Zod.TypeOf<DataSchema>) {
|
||||
return new DTInstance(this.name, this.valueSchema, literal);
|
||||
}
|
||||
|
||||
is(
|
||||
type: AnyTypeInstance
|
||||
): type is DTInstance<Name, Zod.TypeOf<DataSchema>, ValueSchema> {
|
||||
return type.name === this.name;
|
||||
}
|
||||
}
|
||||
|
||||
export const defineDataType = <
|
||||
Name extends string,
|
||||
Data extends Zod.ZodType,
|
||||
Value extends Zod.ZodType,
|
||||
>(
|
||||
name: Name,
|
||||
validateData: Data,
|
||||
validateValue: Value
|
||||
) => {
|
||||
return new DataType(name, validateData, validateValue);
|
||||
};
|
||||
3
blocksuite/affine/data-view/src/core/logical/index.ts
Normal file
3
blocksuite/affine/data-view/src/core/logical/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './type.js';
|
||||
export * from './type-presets.js';
|
||||
export * from './type-system.js';
|
||||
70
blocksuite/affine/data-view/src/core/logical/matcher.ts
Normal file
70
blocksuite/affine/data-view/src/core/logical/matcher.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { TypeInstance } from './type.js';
|
||||
import { typeSystem } from './type-system.js';
|
||||
|
||||
type MatcherData<Data, Type extends TypeInstance = TypeInstance> = {
|
||||
type: Type;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export class MatcherCreator<Data, Type extends TypeInstance = TypeInstance> {
|
||||
createMatcher(type: Type, data: Data) {
|
||||
return { type, data };
|
||||
}
|
||||
}
|
||||
|
||||
export class Matcher<Data, Type extends TypeInstance = TypeInstance> {
|
||||
constructor(
|
||||
private list: MatcherData<Data, Type>[],
|
||||
private _match: (type: Type, target: TypeInstance) => boolean = (
|
||||
type,
|
||||
target
|
||||
) => typeSystem.unify(target, type)
|
||||
) {}
|
||||
|
||||
all(): MatcherData<Data, Type>[] {
|
||||
return this.list;
|
||||
}
|
||||
|
||||
allMatched(type: TypeInstance): MatcherData<Data>[] {
|
||||
const result: MatcherData<Data>[] = [];
|
||||
for (const t of this.list) {
|
||||
if (this._match(t.type, type)) {
|
||||
result.push(t);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
allMatchedData(type: TypeInstance): Data[] {
|
||||
const result: Data[] = [];
|
||||
for (const t of this.list) {
|
||||
if (this._match(t.type, type)) {
|
||||
result.push(t.data);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
find(
|
||||
f: (data: MatcherData<Data, Type>) => boolean
|
||||
): MatcherData<Data, Type> | undefined {
|
||||
return this.list.find(f);
|
||||
}
|
||||
|
||||
findData(f: (data: Data) => boolean): Data | undefined {
|
||||
return this.list.find(data => f(data.data))?.data;
|
||||
}
|
||||
|
||||
isMatched(type: Type, target: TypeInstance) {
|
||||
return this._match(type, target);
|
||||
}
|
||||
|
||||
match(type: TypeInstance) {
|
||||
for (const t of this.list) {
|
||||
if (this._match(t.type, type)) {
|
||||
return t.data;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
57
blocksuite/affine/data-view/src/core/logical/type-presets.ts
Normal file
57
blocksuite/affine/data-view/src/core/logical/type-presets.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as zod from 'zod';
|
||||
import Zod from 'zod';
|
||||
|
||||
import { ct } from './composite-type.js';
|
||||
import { defineDataType } from './data-type.js';
|
||||
import type { TypeConvertConfig, TypeInstance, ValueTypeOf } from './type.js';
|
||||
import { tv } from './type-variable.js';
|
||||
|
||||
export type SelectTag = Zod.TypeOf<typeof SelectTagSchema>;
|
||||
export const SelectTagSchema = Zod.object({
|
||||
id: Zod.string(),
|
||||
color: Zod.string(),
|
||||
value: Zod.string(),
|
||||
parentId: Zod.string().optional(),
|
||||
});
|
||||
export const unknown = defineDataType('Unknown', zod.never(), zod.unknown());
|
||||
export const dt = {
|
||||
number: defineDataType('Number', zod.number(), zod.number()),
|
||||
string: defineDataType('String', zod.string(), zod.string()),
|
||||
boolean: defineDataType('Boolean', zod.boolean(), zod.boolean()),
|
||||
richText: defineDataType('RichText', zod.string(), zod.string()),
|
||||
date: defineDataType('Date', zod.number(), zod.number()),
|
||||
url: defineDataType('URL', zod.string(), zod.string()),
|
||||
image: defineDataType('Image', zod.string(), zod.string()),
|
||||
tag: defineDataType('Tag', zod.array(SelectTagSchema), zod.string()),
|
||||
};
|
||||
export const t = {
|
||||
unknown,
|
||||
...dt,
|
||||
...tv,
|
||||
...ct,
|
||||
};
|
||||
const createTypeConvert = <From extends TypeInstance, To extends TypeInstance>(
|
||||
from: From,
|
||||
to: To,
|
||||
convert: (value: ValueTypeOf<From>) => ValueTypeOf<To>
|
||||
): TypeConvertConfig<From, To> => {
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
convert,
|
||||
};
|
||||
};
|
||||
export const converts: TypeConvertConfig[] = [
|
||||
...Object.values(dt).map(from => ({
|
||||
from: from.instance(),
|
||||
to: t.unknown.instance(),
|
||||
convert: (value: unknown) => value,
|
||||
})),
|
||||
createTypeConvert(
|
||||
t.array.instance(unknown.instance()),
|
||||
unknown.instance(),
|
||||
value => value
|
||||
),
|
||||
createTypeConvert(t.richText.instance(), t.string.instance(), value => value),
|
||||
createTypeConvert(t.url.instance(), t.string.instance(), value => value),
|
||||
];
|
||||
196
blocksuite/affine/data-view/src/core/logical/type-system.ts
Normal file
196
blocksuite/affine/data-view/src/core/logical/type-system.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { FnTypeInstance } from './composite-type.js';
|
||||
import type { TypeConvertConfig, TypeInstance, Unify } from './type.js';
|
||||
import { converts } from './type-presets.js';
|
||||
import {
|
||||
tv,
|
||||
type TypeVarContext,
|
||||
type TypeVarReferenceInstance,
|
||||
} from './type-variable.js';
|
||||
|
||||
type From = string;
|
||||
type To = string;
|
||||
const setMap2 = <T>(
|
||||
map2: Map<string, Map<string, T>>,
|
||||
key1: string,
|
||||
key2: string,
|
||||
value: T
|
||||
) => {
|
||||
let map = map2.get(key1);
|
||||
if (!map) {
|
||||
map2.set(key1, (map = new Map()));
|
||||
}
|
||||
map.set(key2, value);
|
||||
return map;
|
||||
};
|
||||
|
||||
const getMap2 = <T>(
|
||||
map2: Map<string, Map<string, T>>,
|
||||
key1: string,
|
||||
key2: string
|
||||
) => {
|
||||
return map2.get(key1)?.get(key2);
|
||||
};
|
||||
|
||||
export class TypeSystem {
|
||||
private _unify: Unify = (
|
||||
ctx: TypeVarContext,
|
||||
left: TypeInstance | undefined,
|
||||
right: TypeInstance | undefined
|
||||
): boolean => {
|
||||
if (left == null) return true;
|
||||
if (right == null) return false;
|
||||
if (tv.typeVarReference.is(left)) {
|
||||
return this.unifyReference(ctx, left, right);
|
||||
}
|
||||
if (tv.typeVarReference.is(right)) {
|
||||
return this.unifyReference(ctx, right, left, false);
|
||||
}
|
||||
return this.unifyNormalType(ctx, left, right);
|
||||
};
|
||||
|
||||
convertMapFromTo = new Map<
|
||||
From,
|
||||
Map<
|
||||
To,
|
||||
{
|
||||
level: number;
|
||||
from: TypeInstance;
|
||||
to: TypeInstance;
|
||||
convert: (value: unknown) => unknown;
|
||||
}
|
||||
>
|
||||
>();
|
||||
|
||||
convertMapToFrom = new Map<
|
||||
From,
|
||||
Map<
|
||||
To,
|
||||
{
|
||||
level: number;
|
||||
from: TypeInstance;
|
||||
to: TypeInstance;
|
||||
convert: (value: unknown) => unknown;
|
||||
}
|
||||
>
|
||||
>();
|
||||
|
||||
unify = <T extends TypeInstance>(
|
||||
left: TypeInstance | undefined,
|
||||
right: T | undefined
|
||||
): left is T => {
|
||||
return this._unify({}, left, right);
|
||||
};
|
||||
|
||||
constructor(converts: TypeConvertConfig[]) {
|
||||
converts.forEach(config => {
|
||||
this.registerConvert(config.from, config.to, config.convert);
|
||||
});
|
||||
}
|
||||
|
||||
private registerConvert(
|
||||
from: TypeInstance,
|
||||
to: TypeInstance,
|
||||
convert: (value: unknown) => unknown,
|
||||
level = 0
|
||||
) {
|
||||
const currentConfig = getMap2(this.convertMapFromTo, from.name, to.name);
|
||||
if (currentConfig && currentConfig.level <= level) {
|
||||
return;
|
||||
}
|
||||
const config = {
|
||||
level,
|
||||
from,
|
||||
to,
|
||||
convert,
|
||||
};
|
||||
setMap2(this.convertMapFromTo, from.name, to.name, config);
|
||||
setMap2(this.convertMapToFrom, to.name, from.name, config);
|
||||
this.convertMapToFrom.get(from.name)?.forEach(config => {
|
||||
this.registerConvert(config.from, to, value =>
|
||||
convert(config.convert(value))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private unifyNormalType(
|
||||
ctx: TypeVarContext,
|
||||
left: TypeInstance | undefined,
|
||||
right: TypeInstance | undefined,
|
||||
covariance: boolean = true
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return false;
|
||||
}
|
||||
if (left.name !== right.name) {
|
||||
[left, right] = covariance ? [left, right] : [right, left];
|
||||
const convertConfig = this.convertMapFromTo
|
||||
.get(left.name)
|
||||
?.get(right.name);
|
||||
if (convertConfig == null) {
|
||||
return false;
|
||||
}
|
||||
left = convertConfig.to;
|
||||
}
|
||||
return left.unify(ctx, right, this._unify);
|
||||
}
|
||||
|
||||
private unifyReference(
|
||||
ctx: TypeVarContext,
|
||||
left: TypeVarReferenceInstance,
|
||||
right: TypeInstance | undefined,
|
||||
covariance: boolean = true
|
||||
): boolean {
|
||||
if (!right) {
|
||||
return false;
|
||||
}
|
||||
let leftDefine = ctx[left.varName];
|
||||
if (!leftDefine) {
|
||||
ctx[left.varName] = leftDefine = {
|
||||
define: tv.typeVarDefine.create(left.varName),
|
||||
};
|
||||
}
|
||||
const leftType = leftDefine.type;
|
||||
if (tv.typeVarReference.is(right)) {
|
||||
return this.unifyReference(ctx, right, leftType, !covariance);
|
||||
}
|
||||
if (!leftType) {
|
||||
leftDefine.type = right;
|
||||
return true;
|
||||
}
|
||||
return this.unifyNormalType(ctx, leftType, right, covariance);
|
||||
}
|
||||
|
||||
instanceFn(
|
||||
template: FnTypeInstance,
|
||||
realArgs: TypeInstance[],
|
||||
realRt: TypeInstance,
|
||||
ctx: TypeVarContext
|
||||
): FnTypeInstance | void {
|
||||
const newCtx = {
|
||||
...ctx,
|
||||
};
|
||||
template.vars.forEach(v => {
|
||||
newCtx[v.varName] = {
|
||||
define: v,
|
||||
};
|
||||
});
|
||||
for (let i = 0; i < template.args.length; i++) {
|
||||
const arg = template.args[i];
|
||||
const realArg = realArgs[i];
|
||||
if (arg == null) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-collapsible-if
|
||||
if (realArg != null) {
|
||||
if (!this._unify(newCtx, realArg, arg)) {
|
||||
console.log('arg', realArg, arg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._unify(newCtx, template.rt, realRt);
|
||||
return template.subst(newCtx);
|
||||
}
|
||||
}
|
||||
|
||||
export const typeSystem = new TypeSystem(converts);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import Zod from 'zod';
|
||||
|
||||
import type { TypeInstance, Unify } from './type.js';
|
||||
|
||||
const unknownSchema = Zod.unknown();
|
||||
|
||||
export class TypeVarDefinitionInstance<
|
||||
Name extends string = string,
|
||||
Type extends TypeInstance = TypeInstance,
|
||||
> {
|
||||
readonly name = '__TypeVarDefine';
|
||||
|
||||
constructor(
|
||||
readonly varName: Name,
|
||||
readonly typeConstraint?: Type
|
||||
) {}
|
||||
}
|
||||
|
||||
export class TypeVarReferenceInstance<Name extends string = string>
|
||||
implements TypeInstance
|
||||
{
|
||||
readonly _validate = unknownSchema;
|
||||
|
||||
readonly _valueType = undefined as unknown;
|
||||
|
||||
readonly name = '__TypeVarReference';
|
||||
|
||||
constructor(readonly varName: Name) {}
|
||||
|
||||
subst(ctx: TypeVarContext): void | TypeInstance {
|
||||
return ctx[this.varName].type;
|
||||
}
|
||||
|
||||
unify(_ctx: TypeVarContext, _type: TypeInstance, _unify: Unify): boolean {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DatabaseBlockError,
|
||||
'unexpected type unify, type var reference'
|
||||
);
|
||||
}
|
||||
|
||||
valueValidate(_value: unknown): _value is unknown {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export const tv = {
|
||||
typeVarDefine: {
|
||||
create: <
|
||||
Name extends string = string,
|
||||
Type extends TypeInstance = TypeInstance,
|
||||
>(
|
||||
name: Name,
|
||||
typeConstraint?: Type
|
||||
) => {
|
||||
return new TypeVarDefinitionInstance(name, typeConstraint);
|
||||
},
|
||||
},
|
||||
typeVarReference: {
|
||||
create: <Name extends string>(name: Name) => {
|
||||
return new TypeVarReferenceInstance(name);
|
||||
},
|
||||
is: (type: TypeInstance): type is TypeVarReferenceInstance => {
|
||||
return type.name === '__TypeVarReference';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type TypeVarDefine = {
|
||||
define: TypeVarDefinitionInstance;
|
||||
type?: TypeInstance;
|
||||
};
|
||||
|
||||
export type TypeVarContext = Record<string, TypeVarDefine>;
|
||||
export const tRef = tv.typeVarReference.create;
|
||||
export const tVar = tv.typeVarDefine.create;
|
||||
38
blocksuite/affine/data-view/src/core/logical/type.ts
Normal file
38
blocksuite/affine/data-view/src/core/logical/type.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type Zod from 'zod';
|
||||
|
||||
import type { TypeVarContext } from './type-variable.js';
|
||||
|
||||
export type AnyTypeInstance = {
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
export interface TypeDefinition {
|
||||
is(typeInstance: AnyTypeInstance): boolean;
|
||||
}
|
||||
|
||||
export interface TypeInstance extends AnyTypeInstance {
|
||||
readonly _valueType: any;
|
||||
readonly _validate: Zod.ZodSchema;
|
||||
|
||||
valueValidate(value: unknown): value is this['_valueType'];
|
||||
|
||||
subst(ctx: TypeVarContext): TypeInstance | void;
|
||||
|
||||
unify(ctx: TypeVarContext, type: TypeInstance, unify: Unify): boolean;
|
||||
}
|
||||
|
||||
export type ValueTypeOf<T> = T extends TypeInstance ? T['_valueType'] : never;
|
||||
|
||||
export type Unify = (
|
||||
ctx: TypeVarContext,
|
||||
type: TypeInstance | undefined,
|
||||
expect: TypeInstance | undefined
|
||||
) => boolean;
|
||||
export type TypeConvertConfig<
|
||||
From extends TypeInstance = TypeInstance,
|
||||
To extends TypeInstance = TypeInstance,
|
||||
> = {
|
||||
from: From;
|
||||
to: To;
|
||||
convert: (value: ValueTypeOf<From>) => ValueTypeOf<To>;
|
||||
};
|
||||
114
blocksuite/affine/data-view/src/core/property/base-cell.ts
Normal file
114
blocksuite/affine/data-view/src/core/property/base-cell.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { Cell } from '../view-manager/cell.js';
|
||||
import type { CellRenderProps, DataViewCellLifeCycle } from './manager.js';
|
||||
|
||||
export abstract class BaseCellRenderer<
|
||||
Value,
|
||||
Data extends Record<string, unknown> = Record<string, unknown>,
|
||||
>
|
||||
extends SignalWatcher(WithDisposable(ShadowlessElement))
|
||||
implements DataViewCellLifeCycle, CellRenderProps<Data, Value>
|
||||
{
|
||||
@property({ attribute: false })
|
||||
accessor cell!: Cell<Value, Data>;
|
||||
|
||||
readonly$ = computed(() => {
|
||||
return this.cell.property.readonly$.value;
|
||||
});
|
||||
|
||||
value$ = computed(() => {
|
||||
return this.cell.value$.value;
|
||||
});
|
||||
|
||||
get property() {
|
||||
return this.cell.property;
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return this.readonly$.value;
|
||||
}
|
||||
|
||||
get row() {
|
||||
return this.cell.row;
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.value$.value;
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this.cell.view;
|
||||
}
|
||||
|
||||
beforeEnterEditMode(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
blurCell() {
|
||||
return true;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.style.width = '100%';
|
||||
this._disposables.addFromEvent(this, 'click', e => {
|
||||
if (this.isEditing) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(this, 'copy', e => {
|
||||
if (!this.isEditing) return;
|
||||
e.stopPropagation();
|
||||
this.onCopy(e);
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(this, 'cut', e => {
|
||||
if (!this.isEditing) return;
|
||||
e.stopPropagation();
|
||||
this.onCut(e);
|
||||
});
|
||||
|
||||
this._disposables.addFromEvent(this, 'paste', e => {
|
||||
if (!this.isEditing) return;
|
||||
e.stopPropagation();
|
||||
this.onPaste(e);
|
||||
});
|
||||
}
|
||||
|
||||
focusCell() {
|
||||
return true;
|
||||
}
|
||||
|
||||
forceUpdate(): void {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
onChange(value: Value | undefined): void {
|
||||
this.cell.valueSet(value);
|
||||
}
|
||||
|
||||
onCopy(_e: ClipboardEvent) {}
|
||||
|
||||
onCut(_e: ClipboardEvent) {}
|
||||
|
||||
onEnterEditMode(): void {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
onExitEditMode() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
onPaste(_e: ClipboardEvent) {}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isEditing!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selectCurrentCell!: (editing: boolean) => void;
|
||||
}
|
||||
30
blocksuite/affine/data-view/src/core/property/convert.ts
Normal file
30
blocksuite/affine/data-view/src/core/property/convert.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { PropertyModel } from './property-config.js';
|
||||
import type {
|
||||
GetCellDataFromConfig,
|
||||
GetPropertyDataFromConfig,
|
||||
} from './types.js';
|
||||
|
||||
export type ConvertFunction<
|
||||
From extends PropertyModel = PropertyModel,
|
||||
To extends PropertyModel = PropertyModel,
|
||||
> = (
|
||||
property: GetPropertyDataFromConfig<From['config']>,
|
||||
cells: (GetCellDataFromConfig<From['config']> | undefined)[]
|
||||
) => {
|
||||
property: GetPropertyDataFromConfig<To['config']>;
|
||||
cells: (GetCellDataFromConfig<To['config']> | undefined)[];
|
||||
};
|
||||
export const createPropertyConvert = <
|
||||
From extends PropertyModel<any, any, any>,
|
||||
To extends PropertyModel<any, any, any>,
|
||||
>(
|
||||
from: From,
|
||||
to: To,
|
||||
convert: ConvertFunction<From, To>
|
||||
) => {
|
||||
return {
|
||||
from: from.type,
|
||||
to: to.type,
|
||||
convert,
|
||||
};
|
||||
};
|
||||
6
blocksuite/affine/data-view/src/core/property/index.ts
Normal file
6
blocksuite/affine/data-view/src/core/property/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './base-cell.js';
|
||||
export * from './convert.js';
|
||||
export * from './manager.js';
|
||||
export * from './property-config.js';
|
||||
export * from './renderer.js';
|
||||
export * from './types.js';
|
||||
38
blocksuite/affine/data-view/src/core/property/manager.ts
Normal file
38
blocksuite/affine/data-view/src/core/property/manager.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { UniComponent } from '../utils/uni-component/index.js';
|
||||
import type { Cell } from '../view-manager/cell.js';
|
||||
|
||||
export interface CellRenderProps<
|
||||
Data extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
Value = unknown,
|
||||
> {
|
||||
cell: Cell<Value, Data>;
|
||||
isEditing: boolean;
|
||||
selectCurrentCell: (editing: boolean) => void;
|
||||
}
|
||||
|
||||
export interface DataViewCellLifeCycle {
|
||||
beforeEnterEditMode(): boolean;
|
||||
|
||||
onEnterEditMode(): void;
|
||||
|
||||
onExitEditMode(): void;
|
||||
|
||||
focusCell(): boolean;
|
||||
|
||||
blurCell(): boolean;
|
||||
|
||||
forceUpdate(): void;
|
||||
}
|
||||
|
||||
export type DataViewCellComponent<
|
||||
Data extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
Value = unknown,
|
||||
> = UniComponent<CellRenderProps<Data, Value>, DataViewCellLifeCycle>;
|
||||
|
||||
export type CellRenderer<
|
||||
Data extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
Value = unknown,
|
||||
> = {
|
||||
view: DataViewCellComponent<Data, Value>;
|
||||
edit?: DataViewCellComponent<Data, Value>;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Renderer } from './renderer.js';
|
||||
import type { PropertyConfig } from './types.js';
|
||||
|
||||
export type PropertyMetaConfig<
|
||||
Type extends string = string,
|
||||
PropertyData extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
CellData = unknown,
|
||||
> = {
|
||||
type: Type;
|
||||
config: PropertyConfig<PropertyData, CellData>;
|
||||
create: Create<PropertyData>;
|
||||
renderer: Renderer<PropertyData, CellData>;
|
||||
};
|
||||
type CreatePropertyMeta<
|
||||
Type extends string = string,
|
||||
PropertyData extends Record<string, unknown> = Record<string, never>,
|
||||
CellData = unknown,
|
||||
> = (
|
||||
renderer: Omit<Renderer<PropertyData, CellData>, 'type'>
|
||||
) => PropertyMetaConfig<Type, PropertyData, CellData>;
|
||||
type Create<
|
||||
PropertyData extends Record<string, unknown> = Record<string, never>,
|
||||
> = (
|
||||
name: string,
|
||||
data?: PropertyData
|
||||
) => {
|
||||
type: string;
|
||||
name: string;
|
||||
statCalcOp?: string;
|
||||
data: PropertyData;
|
||||
};
|
||||
export type PropertyModel<
|
||||
Type extends string = string,
|
||||
PropertyData extends Record<string, unknown> = Record<string, never>,
|
||||
CellData = unknown,
|
||||
> = {
|
||||
type: Type;
|
||||
config: PropertyConfig<PropertyData, CellData>;
|
||||
create: Create<PropertyData>;
|
||||
createPropertyMeta: CreatePropertyMeta<Type, PropertyData, CellData>;
|
||||
};
|
||||
export const propertyType = <Type extends string>(type: Type) => ({
|
||||
type: type,
|
||||
modelConfig: <
|
||||
CellData,
|
||||
PropertyData extends Record<string, unknown> = Record<string, never>,
|
||||
>(
|
||||
ops: PropertyConfig<PropertyData, CellData>
|
||||
): PropertyModel<Type, PropertyData, CellData> => {
|
||||
const create: Create<PropertyData> = (name, data) => {
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
data: data ?? ops.defaultData(),
|
||||
};
|
||||
};
|
||||
return {
|
||||
type,
|
||||
config: ops,
|
||||
create,
|
||||
createPropertyMeta: renderer => ({
|
||||
type,
|
||||
config: ops,
|
||||
create,
|
||||
renderer: {
|
||||
type,
|
||||
...renderer,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
24
blocksuite/affine/data-view/src/core/property/renderer.ts
Normal file
24
blocksuite/affine/data-view/src/core/property/renderer.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
createUniComponentFromWebComponent,
|
||||
type UniComponent,
|
||||
} from '../utils/uni-component/index.js';
|
||||
import type { BaseCellRenderer } from './base-cell.js';
|
||||
import type { CellRenderer, DataViewCellComponent } from './manager.js';
|
||||
|
||||
export interface Renderer<
|
||||
Data extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
Value = unknown,
|
||||
> {
|
||||
type: string;
|
||||
icon?: UniComponent;
|
||||
cellRenderer: CellRenderer<Data, Value>;
|
||||
}
|
||||
|
||||
export const createFromBaseCellRenderer = <
|
||||
Value,
|
||||
Data extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(
|
||||
renderer: new () => BaseCellRenderer<Value, Data>
|
||||
): DataViewCellComponent => {
|
||||
return createUniComponentFromWebComponent(renderer as never) as never;
|
||||
};
|
||||
98
blocksuite/affine/data-view/src/core/property/types.ts
Normal file
98
blocksuite/affine/data-view/src/core/property/types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Disposable } from '@blocksuite/global/utils';
|
||||
|
||||
import type { DataSource } from '../data-source/base.js';
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
|
||||
export type WithCommonPropertyConfig<T = {}> = T & {
|
||||
dataSource: DataSource;
|
||||
};
|
||||
export type GetPropertyDataFromConfig<T> =
|
||||
T extends PropertyConfig<infer R, any> ? R : never;
|
||||
export type GetCellDataFromConfig<T> =
|
||||
T extends PropertyConfig<any, infer R> ? R : never;
|
||||
export type PropertyConfig<
|
||||
Data extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
Value = unknown,
|
||||
> = {
|
||||
name: string;
|
||||
defaultData: () => Data;
|
||||
type: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
data: Data;
|
||||
}>
|
||||
) => TypeInstance;
|
||||
formatValue?: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: Value;
|
||||
data: Data;
|
||||
}>
|
||||
) => Value;
|
||||
isEmpty: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value?: Value;
|
||||
}>
|
||||
) => boolean;
|
||||
minWidth?: number;
|
||||
values?: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value?: Value;
|
||||
}>
|
||||
) => unknown[];
|
||||
cellToString: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: Value;
|
||||
data: Data;
|
||||
}>
|
||||
) => string;
|
||||
cellFromString: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: string;
|
||||
data: Data;
|
||||
}>
|
||||
) => {
|
||||
value: unknown;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
cellToJson: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: Value;
|
||||
data: Data;
|
||||
}>
|
||||
) => DVJSON;
|
||||
cellFromJson: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: DVJSON;
|
||||
data: Data;
|
||||
}>
|
||||
) => Value | undefined;
|
||||
addGroup?: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
text: string;
|
||||
oldData: Data;
|
||||
}>
|
||||
) => Data;
|
||||
onUpdate?: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: Value;
|
||||
data: Data;
|
||||
callback: () => void;
|
||||
}>
|
||||
) => Disposable;
|
||||
valueUpdate?: (
|
||||
config: WithCommonPropertyConfig<{
|
||||
value: Value;
|
||||
data: Data;
|
||||
newValue: Value;
|
||||
}>
|
||||
) => Value;
|
||||
};
|
||||
|
||||
export type DVJSON =
|
||||
| null
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| DVJSON[]
|
||||
| {
|
||||
[k: string]: DVJSON;
|
||||
};
|
||||
53
blocksuite/affine/data-view/src/core/sort/add-sort.ts
Normal file
53
blocksuite/affine/data-view/src/core/sort/add-sort.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
type PopupTarget,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
|
||||
import { renderUniLit } from '../utils/index.js';
|
||||
import type { SortUtils } from './utils.js';
|
||||
|
||||
export const popCreateSort = (
|
||||
target: PopupTarget,
|
||||
props: {
|
||||
sortUtils: SortUtils;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
) => {
|
||||
popMenu(target, {
|
||||
options: {
|
||||
onClose: props.onClose,
|
||||
title: {
|
||||
text: 'New sort',
|
||||
onBack: props.onBack,
|
||||
},
|
||||
items: [
|
||||
menu.group({
|
||||
items: props.sortUtils.vars$.value
|
||||
.filter(
|
||||
v =>
|
||||
!props.sortUtils.sortList$.value.some(
|
||||
sort => sort.ref.name === v.id
|
||||
)
|
||||
)
|
||||
.map(v =>
|
||||
menu.action({
|
||||
name: v.name,
|
||||
prefix: renderUniLit(v.icon, {}),
|
||||
select: () => {
|
||||
props.sortUtils.add({
|
||||
ref: {
|
||||
type: 'ref',
|
||||
name: v.id,
|
||||
},
|
||||
desc: false,
|
||||
});
|
||||
},
|
||||
})
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
184
blocksuite/affine/data-view/src/core/sort/eval.ts
Normal file
184
blocksuite/affine/data-view/src/core/sort/eval.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { VariableRef } from '../expression/types.js';
|
||||
import type { ArrayTypeInstance } from '../logical/composite-type.js';
|
||||
import type { DataTypeOf } from '../logical/data-type.js';
|
||||
import { t } from '../logical/index.js';
|
||||
import type { TypeInstance } from '../logical/type.js';
|
||||
import { typeSystem } from '../logical/type-system.js';
|
||||
import type { SingleView } from '../view-manager/index.js';
|
||||
import type { Sort } from './types.js';
|
||||
|
||||
export const Compare = {
|
||||
GT: 'GT',
|
||||
LT: 'LT',
|
||||
} as const;
|
||||
export type CompareType = keyof typeof Compare | number;
|
||||
const evalRef = (
|
||||
view: SingleView,
|
||||
ref: VariableRef
|
||||
):
|
||||
| ((row: string) => {
|
||||
value: unknown;
|
||||
ttype?: TypeInstance;
|
||||
})
|
||||
| undefined => {
|
||||
const ttype = view.propertyDataTypeGet(ref.name);
|
||||
return row => ({
|
||||
value: view.cellJsonValueGet(row, ref.name),
|
||||
ttype,
|
||||
});
|
||||
};
|
||||
const compareList = <T>(
|
||||
listA: T[],
|
||||
listB: T[],
|
||||
compare: (a: T, b: T) => CompareType
|
||||
) => {
|
||||
let i = 0;
|
||||
while (i < listA.length && i < listB.length) {
|
||||
const result = compare(listA[i], listB[i]);
|
||||
if (result !== 0) {
|
||||
return result;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const compareString = (a: unknown, b: unknown): CompareType => {
|
||||
if (typeof a != 'string' || a === '') {
|
||||
return Compare.GT;
|
||||
}
|
||||
if (typeof b != 'string' || b === '') {
|
||||
return Compare.LT;
|
||||
}
|
||||
const listA = a.split('.');
|
||||
const listB = b.split('.');
|
||||
return compareList(listA, listB, (a, b) => {
|
||||
const lowA = a.toLowerCase();
|
||||
const lowB = b.toLowerCase();
|
||||
const numberA = Number.parseInt(lowA);
|
||||
const numberB = Number.parseInt(lowB);
|
||||
const aIsNaN = Number.isNaN(numberA);
|
||||
const bIsNaN = Number.isNaN(numberB);
|
||||
if (aIsNaN && !bIsNaN) {
|
||||
return 1;
|
||||
}
|
||||
if (!aIsNaN && bIsNaN) {
|
||||
return -1;
|
||||
}
|
||||
if (!aIsNaN && !bIsNaN && numberA !== numberB) {
|
||||
return numberA - numberB;
|
||||
}
|
||||
|
||||
return lowA.localeCompare(lowB);
|
||||
});
|
||||
};
|
||||
const compareNumber = (a: unknown, b: unknown) => {
|
||||
if (a == null) {
|
||||
return Compare.GT;
|
||||
}
|
||||
if (b == null) {
|
||||
return Compare.LT;
|
||||
}
|
||||
return Number(a) - Number(b);
|
||||
};
|
||||
const compareBoolean = (a: unknown, b: unknown) => {
|
||||
a = Boolean(a);
|
||||
b = Boolean(b);
|
||||
const bA = a ? 1 : 0;
|
||||
const bB = b ? 1 : 0;
|
||||
return bA - bB;
|
||||
};
|
||||
const compareArray = (type: ArrayTypeInstance, a: unknown, b: unknown) => {
|
||||
if (!Array.isArray(a)) {
|
||||
return Compare.GT;
|
||||
}
|
||||
if (!Array.isArray(b)) {
|
||||
return Compare.LT;
|
||||
}
|
||||
return compareList(a, b, (a, b) => {
|
||||
return compare(type.element, a, b);
|
||||
});
|
||||
};
|
||||
const compareAny = (a: unknown, b: unknown) => {
|
||||
if (!a) {
|
||||
return Compare.GT;
|
||||
}
|
||||
if (!b) {
|
||||
return Compare.LT;
|
||||
}
|
||||
// @ts-expect-error FIXME: ts error
|
||||
return a - b;
|
||||
};
|
||||
|
||||
const compareTag = (type: DataTypeOf<typeof t.tag>, a: unknown, b: unknown) => {
|
||||
if (a == null) {
|
||||
return Compare.GT;
|
||||
}
|
||||
if (b == null) {
|
||||
return Compare.LT;
|
||||
}
|
||||
const indexA = type.data?.findIndex(tag => tag.id === a);
|
||||
const indexB = type.data?.findIndex(tag => tag.id === b);
|
||||
return compareNumber(indexA, indexB);
|
||||
};
|
||||
|
||||
const compare = (type: TypeInstance, a: unknown, b: unknown): CompareType => {
|
||||
if (typeSystem.unify(type, t.richText.instance())) {
|
||||
return compareString(a?.toString(), b?.toString());
|
||||
}
|
||||
if (typeSystem.unify(type, t.string.instance())) {
|
||||
return compareString(a, b);
|
||||
}
|
||||
if (typeSystem.unify(type, t.number.instance())) {
|
||||
return compareNumber(a, b);
|
||||
}
|
||||
if (typeSystem.unify(type, t.date.instance())) {
|
||||
return compareNumber(a, b);
|
||||
}
|
||||
if (typeSystem.unify(type, t.boolean.instance())) {
|
||||
return compareBoolean(a, b);
|
||||
}
|
||||
if (typeSystem.unify(type, t.tag.instance())) {
|
||||
return compareTag(type, a, b);
|
||||
}
|
||||
if (t.array.is(type)) {
|
||||
return compareArray(type, a, b);
|
||||
}
|
||||
return compareAny(a, b);
|
||||
};
|
||||
|
||||
export const evalSort = (
|
||||
sort: Sort,
|
||||
view: SingleView
|
||||
): ((rowA: string, rowB: string) => number) | undefined => {
|
||||
if (sort.sortBy.length) {
|
||||
const sortBy = sort.sortBy.map(sort => {
|
||||
return {
|
||||
ref: evalRef(view, sort.ref),
|
||||
desc: sort.desc,
|
||||
};
|
||||
});
|
||||
return (rowA, rowB) => {
|
||||
for (const sort of sortBy) {
|
||||
const refA = sort.ref?.(rowA);
|
||||
const refB = sort.ref?.(rowB);
|
||||
const result = compare(
|
||||
refA?.ttype ?? t.unknown.instance(),
|
||||
refA?.value,
|
||||
refB?.value
|
||||
);
|
||||
if (typeof result === 'number' && result !== 0) {
|
||||
return sort.desc ? -result : result;
|
||||
}
|
||||
if (result === Compare.GT) {
|
||||
return 1;
|
||||
}
|
||||
if (result === Compare.LT) {
|
||||
return -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
return;
|
||||
};
|
||||
43
blocksuite/affine/data-view/src/core/sort/manager.ts
Normal file
43
blocksuite/affine/data-view/src/core/sort/manager.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import { createTraitKey } from '../traits/key.js';
|
||||
import type { SingleView } from '../view-manager/index.js';
|
||||
import { evalSort } from './eval.js';
|
||||
import type { Sort, SortBy } from './types.js';
|
||||
|
||||
export class SortManager {
|
||||
hasSort$ = computed(() => (this.sort$.value?.sortBy?.length ?? 0) > 0);
|
||||
|
||||
setSortList = (sortList: SortBy[]) => {
|
||||
this.ops.setSortList({
|
||||
manuallySort: [],
|
||||
...this.sort$.value,
|
||||
sortBy: sortList,
|
||||
});
|
||||
};
|
||||
|
||||
sort = (rows: string[]) => {
|
||||
if (!this.sort$.value) {
|
||||
return rows;
|
||||
}
|
||||
const compare = evalSort(this.sort$.value, this.view);
|
||||
if (!compare) {
|
||||
return rows;
|
||||
}
|
||||
const newRows = rows.slice();
|
||||
newRows.sort(compare);
|
||||
return newRows;
|
||||
};
|
||||
|
||||
sortList$ = computed(() => this.sort$.value?.sortBy ?? []);
|
||||
|
||||
constructor(
|
||||
readonly sort$: ReadonlySignal<Sort | undefined>,
|
||||
readonly view: SingleView,
|
||||
private ops: {
|
||||
setSortList: (sortList: Sort) => void;
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
export const sortTraitKey = createTraitKey<SortManager>('sort');
|
||||
10
blocksuite/affine/data-view/src/core/sort/types.ts
Normal file
10
blocksuite/affine/data-view/src/core/sort/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { VariableRef } from '../expression/types.js';
|
||||
|
||||
export type SortBy = {
|
||||
ref: VariableRef;
|
||||
desc: boolean;
|
||||
};
|
||||
export type Sort = {
|
||||
sortBy: SortBy[];
|
||||
manuallySort: string[];
|
||||
};
|
||||
108
blocksuite/affine/data-view/src/core/sort/utils.ts
Normal file
108
blocksuite/affine/data-view/src/core/sort/utils.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
DatabaseAllViewEvents,
|
||||
EventTraceFn,
|
||||
SortParams,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
import type { Variable } from '../expression/index.js';
|
||||
import { arrayMove } from '../utils/wc-dnd/utils/array-move.js';
|
||||
import type { SortManager } from './manager.js';
|
||||
import type { SortBy } from './types.js';
|
||||
|
||||
export interface SortUtils {
|
||||
sortList$: ReadonlySignal<SortBy[]>;
|
||||
vars$: ReadonlySignal<Variable[]>;
|
||||
add: (sort: SortBy) => void;
|
||||
move: (from: number, to: number) => void;
|
||||
change: (index: number, sort: SortBy) => void;
|
||||
remove: (index: number) => void;
|
||||
removeAll: () => void;
|
||||
}
|
||||
|
||||
export const createSortUtils = (
|
||||
sortTrait: SortManager,
|
||||
eventTrace: EventTraceFn<DatabaseAllViewEvents>
|
||||
): SortUtils => {
|
||||
const view = sortTrait.view;
|
||||
const varsMap$ = computed(() => {
|
||||
return new Map(view.vars$.value.map(v => [v.id, v]));
|
||||
});
|
||||
const sortList$ = sortTrait.sortList$;
|
||||
const sortParams = (
|
||||
sort?: SortBy,
|
||||
index?: number
|
||||
): SortParams | undefined => {
|
||||
if (!sort) {
|
||||
return;
|
||||
}
|
||||
const v = varsMap$.value.get(sort.ref.name);
|
||||
return {
|
||||
fieldId: sort.ref.name,
|
||||
fieldType: v?.propertyType ?? '',
|
||||
orderType: sort.desc ? 'desc' : 'asc',
|
||||
orderIndex:
|
||||
index ?? sortList$.value.findIndex(v => v.ref.name === sort.ref.name),
|
||||
};
|
||||
};
|
||||
return {
|
||||
vars$: view.vars$,
|
||||
sortList$: sortList$,
|
||||
add: sort => {
|
||||
const list = sortTrait.sortList$.value;
|
||||
sortTrait.setSortList([...list, sort]);
|
||||
const params = sortParams(sort, list.length);
|
||||
if (params) {
|
||||
eventTrace('DatabaseSortAdd', params);
|
||||
}
|
||||
},
|
||||
move: (fromIndex, toIndex) => {
|
||||
const list = sortTrait.sortList$.value;
|
||||
const from = sortParams(list[fromIndex], fromIndex);
|
||||
const newList = arrayMove(list, fromIndex, toIndex);
|
||||
sortTrait.setSortList(newList);
|
||||
const prev = sortParams(newList[toIndex - 1], toIndex - 1);
|
||||
const next = sortParams(newList[toIndex + 1], toIndex + 1);
|
||||
if (from) {
|
||||
eventTrace('DatabaseSortReorder', {
|
||||
...from,
|
||||
prevFieldType: prev?.fieldType ?? '',
|
||||
nextFieldType: next?.fieldType ?? '',
|
||||
newOrderIndex: toIndex,
|
||||
});
|
||||
}
|
||||
},
|
||||
change: (index, sort) => {
|
||||
const list = sortTrait.sortList$.value.slice();
|
||||
const old = sortParams(list[index], index);
|
||||
list[index] = sort;
|
||||
sortTrait.setSortList(list);
|
||||
|
||||
const params = sortParams(sort, index);
|
||||
if (params && old) {
|
||||
eventTrace('DatabaseSortModify', {
|
||||
...params,
|
||||
oldOrderType: old.orderType,
|
||||
oldFieldType: old.fieldType,
|
||||
oldFieldId: old.fieldId,
|
||||
});
|
||||
}
|
||||
},
|
||||
remove: index => {
|
||||
const list = sortTrait.sortList$.value.slice();
|
||||
const old = sortParams(list[index], index);
|
||||
list.splice(index, 1);
|
||||
sortTrait.setSortList([...list]);
|
||||
if (old) {
|
||||
eventTrace('DatabaseSortRemove', old);
|
||||
}
|
||||
},
|
||||
removeAll: () => {
|
||||
const count = sortTrait.sortList$.value.length;
|
||||
sortTrait.setSortList([]);
|
||||
eventTrace('DatabaseSortClear', {
|
||||
rulesCount: count,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
106
blocksuite/affine/data-view/src/core/statistics/any.ts
Normal file
106
blocksuite/affine/data-view/src/core/statistics/any.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { t } from '../logical/index.js';
|
||||
import { createStatisticConfig } from './create.js';
|
||||
import type { StatisticsConfig } from './types.js';
|
||||
|
||||
export const anyTypeStatsFunctions: StatisticsConfig[] = [
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
menuName: 'Count All',
|
||||
displayName: 'All',
|
||||
type: 'count-all',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: data => {
|
||||
return data.length.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
menuName: 'Count Values',
|
||||
displayName: 'Values',
|
||||
type: 'count-values',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: (data, { meta, dataSource }) => {
|
||||
const values = data
|
||||
.flatMap(v => {
|
||||
if (meta.config.values) {
|
||||
return meta.config.values({ value: v, dataSource });
|
||||
}
|
||||
return v;
|
||||
})
|
||||
.filter(v => v != null);
|
||||
return values.length.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
menuName: 'Count Unique Values',
|
||||
displayName: 'Unique Values',
|
||||
type: 'count-unique-values',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: (data, { meta, dataSource }) => {
|
||||
const values = data
|
||||
.flatMap(v => {
|
||||
if (meta.config.values) {
|
||||
return meta.config.values({ value: v, dataSource });
|
||||
}
|
||||
return v;
|
||||
})
|
||||
.filter(v => v != null);
|
||||
return new Set(values).size.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
menuName: 'Count Empty',
|
||||
displayName: 'Empty',
|
||||
type: 'count-empty',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: (data, { meta, dataSource }) => {
|
||||
const emptyList = data.filter(value =>
|
||||
meta.config.isEmpty({ value, dataSource })
|
||||
);
|
||||
return emptyList.length.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
menuName: 'Count Not Empty',
|
||||
displayName: 'Not Empty',
|
||||
type: 'count-not-empty',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: (data, { meta, dataSource }) => {
|
||||
const notEmptyList = data.filter(
|
||||
value => !meta.config.isEmpty({ value, dataSource })
|
||||
);
|
||||
return notEmptyList.length.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Percent',
|
||||
menuName: 'Percent Empty',
|
||||
displayName: 'Empty',
|
||||
type: 'percent-empty',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: (data, { meta, dataSource }) => {
|
||||
if (data.length === 0) return '';
|
||||
const emptyList = data.filter(value =>
|
||||
meta.config.isEmpty({ value, dataSource })
|
||||
);
|
||||
return ((emptyList.length / data.length) * 100).toFixed(2) + '%';
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Percent',
|
||||
menuName: 'Percent Not Empty',
|
||||
displayName: 'Not Empty',
|
||||
type: 'percent-not-empty',
|
||||
dataType: t.unknown.instance(),
|
||||
impl: (data, { meta, dataSource }) => {
|
||||
if (data.length === 0) return '';
|
||||
const notEmptyList = data.filter(
|
||||
value => !meta.config.isEmpty({ value, dataSource })
|
||||
);
|
||||
return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%';
|
||||
},
|
||||
}),
|
||||
];
|
||||
62
blocksuite/affine/data-view/src/core/statistics/checkbox.ts
Normal file
62
blocksuite/affine/data-view/src/core/statistics/checkbox.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { t } from '../logical/index.js';
|
||||
import { createStatisticConfig } from './create.js';
|
||||
import type { StatisticsConfig } from './types.js';
|
||||
|
||||
export const checkboxTypeStatsFunctions: StatisticsConfig[] = [
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
type: 'count-values',
|
||||
dataType: t.boolean.instance(),
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
type: 'count-unique-values',
|
||||
dataType: t.boolean.instance(),
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
type: 'count-empty',
|
||||
dataType: t.boolean.instance(),
|
||||
menuName: 'Count Unchecked',
|
||||
displayName: 'Unchecked',
|
||||
impl: data => {
|
||||
const emptyList = data.filter(value => !value);
|
||||
return emptyList.length.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Count',
|
||||
type: 'count-not-empty',
|
||||
dataType: t.boolean.instance(),
|
||||
menuName: 'Count Checked',
|
||||
displayName: 'Checked',
|
||||
impl: data => {
|
||||
const notEmptyList = data.filter(value => !!value);
|
||||
return notEmptyList.length.toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Percent',
|
||||
type: 'percent-empty',
|
||||
dataType: t.boolean.instance(),
|
||||
menuName: 'Percent Unchecked',
|
||||
displayName: 'Unchecked',
|
||||
impl: data => {
|
||||
if (data.length === 0) return '';
|
||||
const emptyList = data.filter(value => !value);
|
||||
return ((emptyList.length / data.length) * 100).toFixed(2) + '%';
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'Percent',
|
||||
type: 'percent-not-empty',
|
||||
dataType: t.boolean.instance(),
|
||||
menuName: 'Percent Checked',
|
||||
displayName: 'Checked',
|
||||
impl: data => {
|
||||
if (data.length === 0) return '';
|
||||
const notEmptyList = data.filter(value => !!value);
|
||||
return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%';
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { TypeInstance } from '../logical/index.js';
|
||||
import type { StatisticsConfig } from './types.js';
|
||||
|
||||
export const createStatisticConfig = <T extends TypeInstance>(
|
||||
config: StatisticsConfig<T>
|
||||
) => {
|
||||
return config;
|
||||
};
|
||||
10
blocksuite/affine/data-view/src/core/statistics/index.ts
Normal file
10
blocksuite/affine/data-view/src/core/statistics/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { anyTypeStatsFunctions } from './any.js';
|
||||
import { checkboxTypeStatsFunctions } from './checkbox.js';
|
||||
import { numberStatsFunctions } from './number.js';
|
||||
import type { StatisticsConfig } from './types.js';
|
||||
|
||||
export const statsFunctions: StatisticsConfig[] = [
|
||||
...anyTypeStatsFunctions,
|
||||
...numberStatsFunctions,
|
||||
...checkboxTypeStatsFunctions,
|
||||
];
|
||||
125
blocksuite/affine/data-view/src/core/statistics/number.ts
Normal file
125
blocksuite/affine/data-view/src/core/statistics/number.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { t } from '../logical/index.js';
|
||||
import { createStatisticConfig } from './create.js';
|
||||
import type { StatisticsConfig } from './types.js';
|
||||
|
||||
export const numberStatsFunctions: StatisticsConfig[] = [
|
||||
createStatisticConfig({
|
||||
group: 'More options',
|
||||
menuName: 'Sum',
|
||||
type: 'sum',
|
||||
displayName: 'Sum',
|
||||
dataType: t.number.instance(),
|
||||
impl: data => {
|
||||
const numbers = withoutNull(data);
|
||||
if (numbers.length === 0) {
|
||||
return 'None';
|
||||
}
|
||||
return parseFloat(
|
||||
numbers.reduce((a, b) => a + b, 0).toFixed(2)
|
||||
).toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'More options',
|
||||
menuName: 'Average',
|
||||
displayName: 'Average',
|
||||
type: 'average',
|
||||
dataType: t.number.instance(),
|
||||
impl: data => {
|
||||
const numbers = withoutNull(data);
|
||||
if (numbers.length === 0) {
|
||||
return 'None';
|
||||
}
|
||||
return (numbers.reduce((a, b) => a + b, 0) / numbers.length).toString();
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'More options',
|
||||
menuName: 'Median',
|
||||
displayName: 'Median',
|
||||
type: 'median',
|
||||
dataType: t.number.instance(),
|
||||
impl: data => {
|
||||
const arr = withoutNull(data).sort((a, b) => a - b);
|
||||
let result = 0;
|
||||
if (arr.length % 2 === 1) {
|
||||
result = arr[(arr.length - 1) / 2];
|
||||
} else {
|
||||
const index = arr.length / 2;
|
||||
result = (arr[index] + arr[index - 1]) / 2;
|
||||
}
|
||||
return result?.toString() ?? 'None';
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'More options',
|
||||
menuName: 'Min',
|
||||
displayName: 'Min',
|
||||
type: 'min',
|
||||
dataType: t.number.instance(),
|
||||
impl: data => {
|
||||
let min: number | null = null;
|
||||
for (const num of data) {
|
||||
if (num != null) {
|
||||
if (min == null) {
|
||||
min = num;
|
||||
} else {
|
||||
min = Math.min(min, num);
|
||||
}
|
||||
}
|
||||
}
|
||||
return min?.toString() ?? 'None';
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'More options',
|
||||
menuName: 'Max',
|
||||
displayName: 'Max',
|
||||
type: 'max',
|
||||
dataType: t.number.instance(),
|
||||
impl: data => {
|
||||
let max: number | null = null;
|
||||
for (const num of data) {
|
||||
if (num != null) {
|
||||
if (max == null) {
|
||||
max = num;
|
||||
} else {
|
||||
max = Math.max(max, num);
|
||||
}
|
||||
}
|
||||
}
|
||||
return max?.toString() ?? 'None';
|
||||
},
|
||||
}),
|
||||
createStatisticConfig({
|
||||
group: 'More options',
|
||||
menuName: 'Range',
|
||||
displayName: 'Range',
|
||||
type: 'range',
|
||||
dataType: t.number.instance(),
|
||||
impl: data => {
|
||||
let min: number | null = null;
|
||||
let max: number | null = null;
|
||||
for (const num of data) {
|
||||
if (num != null) {
|
||||
if (max == null) {
|
||||
max = num;
|
||||
} else {
|
||||
max = Math.max(max, num);
|
||||
}
|
||||
if (min == null) {
|
||||
min = num;
|
||||
} else {
|
||||
min = Math.min(min, num);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (min == null || max == null) {
|
||||
return 'None';
|
||||
}
|
||||
return (max - min).toString();
|
||||
},
|
||||
}),
|
||||
];
|
||||
const withoutNull = (arr: readonly (number | null | undefined)[]): number[] =>
|
||||
arr.filter(v => v != null);
|
||||
18
blocksuite/affine/data-view/src/core/statistics/types.ts
Normal file
18
blocksuite/affine/data-view/src/core/statistics/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { DataSource } from '../data-source/index.js';
|
||||
import type { TypeInstance, ValueTypeOf } from '../logical/type.js';
|
||||
import type { PropertyMetaConfig } from '../property/property-config.js';
|
||||
|
||||
export type StatisticsConfig<T extends TypeInstance = TypeInstance> = {
|
||||
group: string;
|
||||
type: string;
|
||||
dataType: T;
|
||||
menuName?: string;
|
||||
displayName?: string;
|
||||
impl?: (
|
||||
data: ReadonlyArray<ValueTypeOf<T> | undefined>,
|
||||
info: {
|
||||
meta: PropertyMetaConfig;
|
||||
dataSource: DataSource;
|
||||
}
|
||||
) => string;
|
||||
};
|
||||
10
blocksuite/affine/data-view/src/core/traits/key.ts
Normal file
10
blocksuite/affine/data-view/src/core/traits/key.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface TraitKey<T> {
|
||||
key: symbol;
|
||||
__type?: T;
|
||||
}
|
||||
|
||||
export function createTraitKey<T>(name: string): TraitKey<T> {
|
||||
return {
|
||||
key: Symbol(name),
|
||||
};
|
||||
}
|
||||
26
blocksuite/affine/data-view/src/core/types.ts
Normal file
26
blocksuite/affine/data-view/src/core/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { KanbanViewSelectionWithType } from '../view-presets/kanban/types.js';
|
||||
import type { TableViewSelectionWithType } from '../view-presets/table/types.js';
|
||||
|
||||
export type DataViewSelection =
|
||||
| TableViewSelectionWithType
|
||||
| KanbanViewSelectionWithType;
|
||||
export type GetDataViewSelection<
|
||||
K extends DataViewSelection['type'],
|
||||
T = DataViewSelection,
|
||||
> = T extends {
|
||||
type: K;
|
||||
}
|
||||
? T
|
||||
: never;
|
||||
export type DataViewSelectionState = DataViewSelection | undefined;
|
||||
export type PropertyDataUpdater<
|
||||
Data extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = (data: Data) => Partial<Data>;
|
||||
|
||||
export interface DatabaseFlags {
|
||||
enable_number_formatting: boolean;
|
||||
}
|
||||
|
||||
export const defaultDatabaseFlags: Readonly<DatabaseFlags> = {
|
||||
enable_number_formatting: false,
|
||||
};
|
||||
78
blocksuite/affine/data-view/src/core/utils/auto-scroll.ts
Normal file
78
blocksuite/affine/data-view/src/core/utils/auto-scroll.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { effect, type ReadonlySignal } from '@preact/signals-core';
|
||||
|
||||
const timeWeight = 1 / 16;
|
||||
const distanceWeight = 1 / 8;
|
||||
|
||||
export const autoScrollOnBoundary = (
|
||||
container: HTMLElement,
|
||||
box: ReadonlySignal<{
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}>,
|
||||
ops?: {
|
||||
onScroll?: () => void;
|
||||
}
|
||||
) => {
|
||||
let updateTask = 0;
|
||||
const startUpdate = () => {
|
||||
if (updateTask) {
|
||||
return;
|
||||
}
|
||||
const update = (preTime: number) => {
|
||||
const now = Date.now();
|
||||
const delta = now - preTime;
|
||||
updateTask = 0;
|
||||
const { left, right, top, bottom } = box.value;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const getResult = (diff: number) =>
|
||||
(diff * distanceWeight + 1) * delta * timeWeight;
|
||||
let move = false;
|
||||
if (left < rect.left) {
|
||||
const diff = getResult(rect.left - left);
|
||||
container.scrollLeft -= diff;
|
||||
if (diff !== 0) {
|
||||
move = true;
|
||||
}
|
||||
}
|
||||
if (right > rect.right) {
|
||||
const diff = getResult(right - rect.right);
|
||||
container.scrollLeft += diff;
|
||||
if (diff !== 0) {
|
||||
move = true;
|
||||
}
|
||||
}
|
||||
if (top < rect.top) {
|
||||
const diff = getResult(rect.top - top);
|
||||
container.scrollTop -= diff;
|
||||
if (diff !== 0) {
|
||||
move = true;
|
||||
}
|
||||
}
|
||||
if (bottom > rect.bottom) {
|
||||
const diff = getResult(bottom - rect.bottom);
|
||||
container.scrollTop += diff;
|
||||
if (diff !== 0) {
|
||||
move = true;
|
||||
}
|
||||
}
|
||||
if (move) {
|
||||
ops?.onScroll?.();
|
||||
updateTask = requestAnimationFrame(() => update(now));
|
||||
}
|
||||
};
|
||||
const now = Date.now();
|
||||
updateTask = requestAnimationFrame(() => update(now));
|
||||
};
|
||||
|
||||
const cancelBoxListen = effect(() => {
|
||||
box.value;
|
||||
startUpdate();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelBoxListen();
|
||||
cancelAnimationFrame(updateTask);
|
||||
};
|
||||
};
|
||||
69
blocksuite/affine/data-view/src/core/utils/drag.ts
Normal file
69
blocksuite/affine/data-view/src/core/utils/drag.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
export const startDrag = <
|
||||
T extends Record<string, unknown> | void,
|
||||
P = {
|
||||
x: number;
|
||||
},
|
||||
>(
|
||||
evt: MouseEvent,
|
||||
ops: {
|
||||
transform?: (evt: MouseEvent) => P;
|
||||
onDrag: (p: P) => T;
|
||||
onMove: (p: P) => T;
|
||||
onDrop: (result: T) => void;
|
||||
onClear: () => void;
|
||||
cursor?: string;
|
||||
}
|
||||
) => {
|
||||
const oldCursor = document.body.style.cursor;
|
||||
document.body.style.cursor = ops.cursor ?? 'grab';
|
||||
const mousePosition = signal<{ x: number; y: number }>({
|
||||
x: evt.clientX,
|
||||
y: evt.clientY,
|
||||
});
|
||||
const transform = ops?.transform ?? (e => e as P);
|
||||
const param = transform(evt);
|
||||
const result = {
|
||||
data: ops.onDrag(param),
|
||||
last: param,
|
||||
mousePosition,
|
||||
move: (p: P) => {
|
||||
result.data = ops.onMove(p);
|
||||
},
|
||||
};
|
||||
const clear = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
window.removeEventListener('keydown', keydown);
|
||||
document.body.style.cursor = oldCursor;
|
||||
ops.onClear();
|
||||
};
|
||||
const keydown = (evt: KeyboardEvent) => {
|
||||
if (evt.key === 'Escape') {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
const move = (evt: PointerEvent) => {
|
||||
evt.preventDefault();
|
||||
mousePosition.value = {
|
||||
x: evt.clientX,
|
||||
y: evt.clientY,
|
||||
};
|
||||
const p = transform(evt);
|
||||
result.last = p;
|
||||
result.data = ops.onMove(p);
|
||||
};
|
||||
const up = () => {
|
||||
try {
|
||||
ops.onDrop(result.data);
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
window.addEventListener('keydown', keydown);
|
||||
|
||||
return result;
|
||||
};
|
||||
3
blocksuite/affine/data-view/src/core/utils/event.ts
Normal file
3
blocksuite/affine/data-view/src/core/utils/event.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function stopPropagation(event: Event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
2
blocksuite/affine/data-view/src/core/utils/index.ts
Normal file
2
blocksuite/affine/data-view/src/core/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './uni-component/index.js';
|
||||
export * from './uni-icon.js';
|
||||
23
blocksuite/affine/data-view/src/core/utils/menu-title.ts
Normal file
23
blocksuite/affine/data-view/src/core/utils/menu-title.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
export const menuTitle = (name: string, onBack: () => void) => {
|
||||
return html`
|
||||
<div
|
||||
style="display:flex;align-items:center;gap: 4px;padding: 3px 3px 3px 2px"
|
||||
>
|
||||
<div
|
||||
@click=${onBack}
|
||||
class="dv-icon-20 dv-hover dv-pd-2 dv-round-4"
|
||||
style="display:flex;"
|
||||
>
|
||||
${ArrowLeftBigIcon()}
|
||||
</div>
|
||||
<div
|
||||
style="font-weight:500;font-size: 14px;line-height: 22px;color: var(--affine-text-primary-color)"
|
||||
>
|
||||
${name}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './operation.js';
|
||||
export * from './uni-component.js';
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { UniComponent } from './uni-component.js';
|
||||
|
||||
export const uniMap = <T, R, P extends NonNullable<unknown>>(
|
||||
component: UniComponent<T, P>,
|
||||
map: (r: R) => T
|
||||
): UniComponent<R, P> => {
|
||||
return (ele, props) => {
|
||||
const result = component(ele, map(props));
|
||||
return {
|
||||
unmount: result.unmount,
|
||||
update: props => {
|
||||
result.update(map(props));
|
||||
},
|
||||
expose: result.expose,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class AnyRender<T> extends SignalWatcher(ShadowlessElement) {
|
||||
override render() {
|
||||
return this.renderTemplate(this.props);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor props!: T;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor renderTemplate!: (props: T) => TemplateResult | symbol;
|
||||
}
|
||||
|
||||
export const renderTemplate = <T>(
|
||||
renderTemplate: (props: T) => TemplateResult | symbol
|
||||
) => {
|
||||
const ins = new AnyRender<T>();
|
||||
ins.renderTemplate = renderTemplate;
|
||||
return ins;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher } from '@blocksuite/global/utils';
|
||||
import type { LitElement, PropertyValues, TemplateResult } from 'lit';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import type { Ref } from 'lit/directives/ref.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export type UniComponentReturn<
|
||||
Props = NonNullable<unknown>,
|
||||
Expose extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
> = {
|
||||
update: (props: Props) => void;
|
||||
unmount: () => void;
|
||||
expose: Expose;
|
||||
};
|
||||
export type UniComponent<
|
||||
Props = NonNullable<unknown>,
|
||||
Expose extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
> = (ele: HTMLElement, props: Props) => UniComponentReturn<Props, Expose>;
|
||||
export const renderUniLit = <Props, Expose extends NonNullable<unknown>>(
|
||||
uni: UniComponent<Props, Expose> | undefined,
|
||||
props?: Props,
|
||||
options?: {
|
||||
ref?: Ref<Expose>;
|
||||
style?: Readonly<StyleInfo>;
|
||||
class?: string;
|
||||
}
|
||||
): TemplateResult => {
|
||||
return html` <uni-lit
|
||||
.uni="${uni}"
|
||||
.props="${props}"
|
||||
.ref="${options?.ref}"
|
||||
style=${options?.style ? styleMap(options?.style) : ''}
|
||||
></uni-lit>`;
|
||||
};
|
||||
|
||||
export class UniLit<
|
||||
Props,
|
||||
Expose extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
> extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
uni-lit {
|
||||
display: contents;
|
||||
}
|
||||
`;
|
||||
|
||||
uniReturn?: UniComponentReturn<Props, Expose>;
|
||||
|
||||
get expose(): Expose | undefined {
|
||||
return this.uniReturn?.expose;
|
||||
}
|
||||
|
||||
private mount() {
|
||||
this.uniReturn = this.uni?.(this, this.props);
|
||||
if (this.ref) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
this.ref.value = this.uniReturn?.expose;
|
||||
}
|
||||
}
|
||||
|
||||
private unmount() {
|
||||
this.uniReturn?.unmount();
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.mount();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.unmount();
|
||||
}
|
||||
|
||||
protected override render(): unknown {
|
||||
return html``;
|
||||
}
|
||||
|
||||
protected override updated(_changedProperties: PropertyValues) {
|
||||
super.updated(_changedProperties);
|
||||
if (_changedProperties.has('uni')) {
|
||||
this.unmount();
|
||||
this.mount();
|
||||
} else if (_changedProperties.has('props')) {
|
||||
this.uniReturn?.update(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor props!: Props;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor ref: Ref<Expose> | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor uni: UniComponent<Props, Expose> | undefined = undefined;
|
||||
}
|
||||
|
||||
export const createUniComponentFromWebComponent = <
|
||||
T,
|
||||
Expose extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
>(
|
||||
component: typeof LitElement
|
||||
): UniComponent<T, Expose> => {
|
||||
return (ele, props) => {
|
||||
const ins = new component();
|
||||
Object.assign(ins, props);
|
||||
ele.append(ins);
|
||||
return {
|
||||
update: props => {
|
||||
Object.assign(ins, props);
|
||||
ins.requestUpdate();
|
||||
},
|
||||
unmount: () => {
|
||||
ins.remove();
|
||||
},
|
||||
expose: ins as never as Expose,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export class UniAnyRender<
|
||||
T,
|
||||
Expose extends NonNullable<unknown>,
|
||||
> extends SignalWatcher(ShadowlessElement) {
|
||||
override render() {
|
||||
return this.renderTemplate(this.props, this.expose);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor expose!: Expose;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor props!: T;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor renderTemplate!: (props: T, expose: Expose) => TemplateResult;
|
||||
}
|
||||
export const defineUniComponent = <T, Expose extends NonNullable<unknown>>(
|
||||
renderTemplate: (props: T, expose: Expose) => TemplateResult
|
||||
): UniComponent<T, Expose> => {
|
||||
return (ele, props) => {
|
||||
const ins = new UniAnyRender<T, Expose>();
|
||||
ins.props = props;
|
||||
ins.expose = {} as Expose;
|
||||
ins.renderTemplate = renderTemplate;
|
||||
ele.append(ins);
|
||||
return {
|
||||
update: props => {
|
||||
ins.props = props;
|
||||
ins.requestUpdate();
|
||||
},
|
||||
unmount: () => {
|
||||
ins.remove();
|
||||
},
|
||||
expose: ins.expose,
|
||||
};
|
||||
};
|
||||
};
|
||||
36
blocksuite/affine/data-view/src/core/utils/uni-icon.ts
Normal file
36
blocksuite/affine/data-view/src/core/utils/uni-icon.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import * as icons from '@blocksuite/icons/lit';
|
||||
import { css, html, type TemplateResult } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { uniMap } from './uni-component/operation.js';
|
||||
import { createUniComponentFromWebComponent } from './uni-component/uni-component.js';
|
||||
|
||||
export class AffineLitIcon extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
affine-lit-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
affine-lit-icon svg {
|
||||
fill: var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
protected override render(): unknown {
|
||||
const createIcon = icons[this.name] as () => TemplateResult;
|
||||
return html`${createIcon?.()}`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor name!: keyof typeof icons;
|
||||
}
|
||||
|
||||
const litIcon = createUniComponentFromWebComponent<{ name: string }>(
|
||||
AffineLitIcon
|
||||
);
|
||||
export const createIcon = (name: keyof typeof icons) => {
|
||||
return uniMap(litIcon, () => ({ name }));
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user