chore: merge blocksuite source code (#9213)

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

View File

@@ -0,0 +1,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__"
]
}

View 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;
}
`;

View 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';

View 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 });
};

View 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);
},
});
}),
}),
],
},
}),
],
});
};

View 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);

View 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[];
};

View File

@@ -0,0 +1,89 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { 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;
}
}

View File

@@ -0,0 +1,3 @@
export * from './button/button.js';
export * from './overflow/overflow.js';
export * from './tags/index.js';

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -0,0 +1,3 @@
export * from './colors.js';
export * from './multi-tag-select.js';
export * from './multi-tag-view.js';

View File

@@ -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;
};

View File

@@ -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;
}
}

View 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;
// }
`;

View 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));
}
}

View 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,
});

View File

@@ -0,0 +1,2 @@
export * from './base.js';
export * from './context.js';

View 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>`;
}
}

View 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>`;
};

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,3 @@
export * from '../filter/literal/index.js';
export * from './ref/index.js';
export * from './types.js';

View File

@@ -0,0 +1,2 @@
export * from './ref.js';
export * from './ref-view.js';

View File

@@ -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;
}
}

View File

@@ -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;
};

View 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;

View 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));
},
}),
],
}),
],
},
});
};

View 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);
};

View File

@@ -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,
}),
];

View File

@@ -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;
};

View File

@@ -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(),
}),
];

View File

@@ -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);
},
};

View File

@@ -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));
},
}),
];

View File

@@ -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],
}),
];

View File

@@ -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();
},
}),
];

View 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);
},
}),
];

View File

@@ -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;
},
}),
];

View File

@@ -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(', ');
};

View File

@@ -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;
}

View 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';

View File

@@ -0,0 +1,5 @@
import type { CreateLiteralItemsConfig } from './types.js';
export const createLiteral: CreateLiteralItemsConfig = config => {
return config;
};

View 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;
},
});
}) ?? [],
}),
];
},
}),
];

View File

@@ -0,0 +1,3 @@
export * from './define.js';
export * from './matcher.js';
export * from './types.js';

View File

@@ -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 [];
},
};

View 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[];
};

View 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');

View 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;

View 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: [],
};

View 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;
};

View 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),
}),
];

View 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>`}
`;
};

View 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);

View File

@@ -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;
}

View File

@@ -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>`;
}
}

View File

@@ -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>`;
}
}

View File

@@ -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;
}),
});
}
}

View File

@@ -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>`;
}
}

View 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);
},
}),
],
}),
],
},
});
};

View 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;
};

View 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>;
};

View 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';

View 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);
},
},
};

View 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);
};

View File

@@ -0,0 +1,3 @@
export * from './type.js';
export * from './type-presets.js';
export * from './type-system.js';

View 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;
}
}

View 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),
];

View 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);

View File

@@ -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;

View 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>;
};

View 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;
}

View 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,
};
};

View 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';

View 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>;
};

View File

@@ -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,
},
}),
};
},
});

View 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;
};

View 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;
};

View 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,
});
},
})
),
}),
],
},
});
};

View 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;
};

View 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');

View 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[];
};

View 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,
});
},
};
};

View 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) + '%';
},
}),
];

View 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) + '%';
},
}),
];

View File

@@ -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;
};

View 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,
];

View 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);

View 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;
};

View 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),
};
}

View 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,
};

View 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);
};
};

View 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;
};

View File

@@ -0,0 +1,3 @@
export function stopPropagation(event: Event) {
event.stopPropagation();
}

View File

@@ -0,0 +1,2 @@
export * from './uni-component/index.js';
export * from './uni-icon.js';

View 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>
`;
};

View File

@@ -0,0 +1,2 @@
export * from './operation.js';
export * from './uni-component.js';

View File

@@ -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,
};
};
};

View File

@@ -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;
};

View File

@@ -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,
};
};
};

View 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