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
@@ -0,0 +1,7 @@
import { type Signal, signal } from '@preact/signals-core';
import { createContextKey } from '../../core/index.js';
export const ShowQuickSettingBarContextKey = createContextKey<
Signal<Record<string, boolean>>
>('show-quick-setting-bar', signal({}));
@@ -0,0 +1,308 @@
import {
menu,
popFilterableSimpleMenu,
popMenu,
type PopupTarget,
popupTargetFromElement,
subMenuMiddleware,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import {
ArrowDownSmallIcon,
ArrowRightSmallIcon,
DeleteIcon,
} from '@blocksuite/icons/lit';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { getRefType } from '../../../core/expression/ref/ref.js';
import type { Variable } from '../../../core/expression/types.js';
import { filterMatcher } from '../../../core/filter/filter-fn/matcher.js';
import { literalItemsMatcher } from '../../../core/filter/literal/index.js';
import type { Filter, SingleFilter } from '../../../core/filter/types.js';
import {
renderUniLit,
t,
type TypeInstance,
typeSystem,
} from '../../../core/index.js';
export class FilterConditionView extends SignalWatcher(ShadowlessElement) {
static override styles = css`
filter-condition-view {
}
.filter-condition-expression {
display: flex;
align-items: center;
gap: 4px;
}
.filter-condition-delete {
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
height: max-content;
cursor: pointer;
}
.filter-condition-delete:hover {
background-color: var(--affine-hover-color);
}
.filter-condition-delete svg {
width: 16px;
height: 16px;
}
.filter-condition-function-name {
font-size: 12px;
line-height: 20px;
color: var(--affine-text-secondary-color);
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
}
.filter-condition-function-name:hover {
background-color: var(--affine-hover-color);
}
.filter-condition-arg {
font-size: 12px;
font-style: normal;
font-weight: 600;
padding: 0 4px;
height: 100%;
display: flex;
align-items: center;
}
`;
private onClickButton = (evt: Event) => {
this.popConditionEdit(
popupTargetFromElement(evt.currentTarget as HTMLElement)
);
};
private popConditionEdit = (target: PopupTarget) => {
const type = this.leftVar$.value?.type;
if (!type) {
return;
}
const fn = this.fnConfig$.value;
if (!fn) {
popFilterableSimpleMenu(target, this.getFunctionItems(target));
return;
}
const handler = popMenu(target, {
options: {
items: [
menu.group({
items: [
menu.action({
name: fn.label,
postfix: ArrowRightSmallIcon(),
select: ele => {
popMenu(popupTargetFromElement(ele), {
options: {
items: [
menu.group({
items: this.getFunctionItems(target, () => {
handler.close();
}),
}),
],
},
middleware: subMenuMiddleware,
});
return false;
},
}),
],
}),
menu.dynamic(() => this.getArgsItems()),
menu.group({
items: [
menu.action({
name: 'Delete',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
const list = this.value.value.slice();
list.splice(this.index, 1);
this.onChange(list);
},
}),
],
}),
],
},
});
};
@property({ attribute: false })
accessor value!: ReadonlySignal<Filter[]>;
filter$ = computed(() => {
const filter = this.value.value[this.index];
if (!filter || filter.type !== 'filter') {
return;
}
return filter;
});
args$ = computed(() => {
return this.filter$.value?.args.map(v => v.value);
});
fnConfig$ = computed(() => {
return filterMatcher.getFilterByName(this.filter$.value?.function);
});
@property({ attribute: false })
accessor vars!: ReadonlySignal<Variable[]>;
fnType$ = computed(() => {
const fnConfig = this.fnConfig$.value;
const filter = this.filter$.value;
if (!fnConfig || !filter) {
return;
}
const refType = getRefType(this.vars.value, filter.left);
if (!refType) {
return;
}
const fnTemplate = t.fn.instance(
[fnConfig.self, ...fnConfig.args],
t.boolean.instance(),
fnConfig.vars
);
return typeSystem.instanceFn(
fnTemplate,
[refType],
t.boolean.instance(),
{}
);
});
getFunctionItems = (target: PopupTarget, onSelect?: () => void) => {
const filter = this.filter$.value;
if (!filter) {
return [];
}
const type = getRefType(this.vars.value, filter?.left);
if (!type) {
return [];
}
return filterMatcher.filterListBySelfType(type).map(v => {
const selected = v.name === filter.function;
return menu.action({
name: v.label,
isSelected: selected,
select: () => {
this.setFilter({
...filter,
function: v.name,
});
onSelect?.();
this.popConditionEdit(target);
},
});
});
};
leftVar$ = computed(() => {
return this.vars.value.find(v => v.id === this.filter$.value?.left.name);
});
setFilter = (filter: SingleFilter) => {
const list = this.value.value.slice();
list[this.index] = filter;
this.onChange(list);
};
text$ = computed(() => {
const name = this.leftVar$.value?.name ?? '';
const data = this.fnConfig$.value;
const type = this.fnType$.value;
const argValues = this.args$.value;
if (!type || !argValues || !data) {
return;
}
const argDataList = argValues.map((v, i) =>
v ? { value: v, type: type.args[i + 1] } : undefined
);
const valueString = data.shortString?.(...argDataList) ?? '';
if (valueString) {
return `${name}${valueString}`;
}
return name;
});
private getArgItems(argType: TypeInstance, index: number) {
return literalItemsMatcher.getItems(
argType,
computed(() => {
return this.filter$.value?.args[index]?.value;
}),
value => {
const filter = this.filter$.value;
if (!filter) {
return;
}
const args = filter.args.slice();
args[index] = { type: 'literal', value };
this.setFilter({
...filter,
args: args,
});
}
);
}
private getArgsItems() {
return (
this.fnType$.value?.args
.slice(1)
.flatMap((arg, i) => this.getArgItems(arg, i)) ?? []
);
}
override render() {
const leftVar = this.leftVar$.value;
if (!leftVar) {
return html` <data-view-component-button
hoverType="border"
.text="${html`Invalid filter rule`}"
></data-view-component-button>`;
}
return html`
<data-view-component-button
hoverType="border"
.icon="${renderUniLit(leftVar.icon)}"
@click="${this.onClickButton}"
.text="${html`<span
style="overflow: hidden;max-width: 230px;text-overflow: ellipsis"
>${this.text$.value}</span
>`}"
.postfix="${ArrowDownSmallIcon()}"
></data-view-component-button>
`;
}
@property({ attribute: false })
accessor index!: number;
@property({ attribute: false })
accessor onChange!: (filters: Filter[]) => void;
}
declare global {
interface HTMLElementTagNameMap {
'filter-condition-view': FilterConditionView;
}
}
@@ -0,0 +1,476 @@
import {
menu,
popFilterableSimpleMenu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import {
ArrowDownSmallIcon,
ConvertIcon,
DeleteIcon,
DuplicateIcon,
MoreHorizontalIcon,
PlusIcon,
} from '@blocksuite/icons/lit';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html, nothing, type TemplateResult } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import type { Variable } from '../../../core/expression/types.js';
import type { Filter, FilterGroup } from '../../../core/filter/types.js';
import { firstFilter, firstFilterInGroup } from '../../../core/filter/utils.js';
export const popAddNewFilter = (
target: PopupTarget,
props: {
value: FilterGroup;
onChange: (value: FilterGroup) => void;
vars: Variable[];
}
) => {
popFilterableSimpleMenu(target, [
menu.action({
name: 'Add filter',
select: () => {
props.onChange({
...props.value,
conditions: [...props.value.conditions, firstFilter(props.vars)],
});
},
}),
menu.action({
name: 'Add filter group',
select: () => {
props.onChange({
...props.value,
conditions: [
...props.value.conditions,
firstFilterInGroup(props.vars),
],
});
},
}),
]);
};
export class FilterGroupView extends SignalWatcher(ShadowlessElement) {
static override styles = css`
filter-group-view {
border-radius: 4px;
display: flex;
flex-direction: column;
user-select: none;
}
.filter-group-op {
width: 60px;
display: flex;
justify-content: end;
padding: 4px;
height: 34px;
align-items: center;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
color: var(--affine-text-primary-color);
}
.filter-group-op-clickable {
border-radius: 4px;
cursor: pointer;
}
.filter-group-op-clickable:hover {
background-color: var(--affine-hover-color);
}
.filter-group-container {
display: flex;
flex-direction: column;
gap: 2px;
}
.filter-group-button {
padding: 8px 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 22px;
border-radius: 4px;
cursor: pointer;
color: var(--affine-text-secondary-color);
}
.filter-group-button svg {
fill: var(--affine-text-secondary-color);
color: var(--affine-text-secondary-color);
width: 20px;
height: 20px;
}
.filter-group-button:hover {
background-color: var(--affine-hover-color);
color: var(--affine-text-primary-color);
}
.filter-group-button:hover svg {
fill: var(--affine-text-primary-color);
color: var(--affine-text-primary-color);
}
.filter-group-item {
padding: 4px 0;
display: flex;
align-items: start;
gap: 8px;
}
.filter-group-item-ops {
margin-top: 4px;
padding: 4px;
border-radius: 4px;
height: max-content;
display: flex;
cursor: pointer;
}
.filter-group-item-ops:hover {
background-color: var(--affine-hover-color);
}
.filter-group-item-ops svg {
fill: var(--affine-text-secondary-color);
color: var(--affine-text-secondary-color);
width: 18px;
height: 18px;
}
.filter-group-item-ops:hover svg {
fill: var(--affine-text-primary-color);
color: var(--affine-text-primary-color);
}
.delete-style {
background-color: var(--affine-background-error-color);
}
.filter-group-border {
border: 1px dashed var(--affine-border-color);
}
.filter-group-bg-1 {
background-color: var(--affine-background-secondary-color);
border: 1px solid var(--affine-border-color);
}
.filter-group-bg-2 {
background-color: var(--affine-background-tertiary-color);
border: 1px solid var(--affine-border-color);
}
.hover-style {
background-color: var(--affine-hover-color);
}
.delete-style {
background-color: var(--affine-background-error-color);
}
`;
private _addNew = (e: MouseEvent) => {
if (this.isMaxDepth) {
this.onChange({
...this.filterGroup.value,
conditions: [
...this.filterGroup.value.conditions,
firstFilter(this.vars.value),
],
});
return;
}
popAddNewFilter(popupTargetFromElement(e.currentTarget as HTMLElement), {
value: this.filterGroup.value,
onChange: this.onChange,
vars: this.vars.value,
});
};
private _selectOp = (event: MouseEvent) => {
popFilterableSimpleMenu(
popupTargetFromElement(event.currentTarget as HTMLElement),
[
menu.action({
name: 'And',
select: () => {
this.onChange({
...this.filterGroup.value,
op: 'and',
});
},
}),
menu.action({
name: 'Or',
select: () => {
this.onChange({
...this.filterGroup.value,
op: 'or',
});
},
}),
]
);
};
private _setFilter = (index: number, filter: Filter) => {
this.onChange({
...this.filterGroup.value,
conditions: this.filterGroup.value.conditions.map((v, i) =>
index === i ? filter : v
),
});
};
private opMap = {
and: 'And',
or: 'Or',
};
@property({ attribute: false })
accessor filterGroup!: ReadonlySignal<FilterGroup>;
conditions$ = computed(() => {
return this.filterGroup.value.conditions;
});
setConditions = (conditions: Filter[]) => {
this.onChange({
...this.filterGroup.value,
conditions: conditions,
});
};
private get isMaxDepth() {
return this.depth === 3;
}
private _clickConditionOps(target: HTMLElement, i: number) {
const filter = this.filterGroup.value.conditions[i];
popFilterableSimpleMenu(popupTargetFromElement(target), [
menu.group({
items: [
menu.action({
name:
filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
prefix: ConvertIcon(),
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'hover-style' }
: undefined;
},
hide: () => this.depth + getDepth(filter) > 3,
select: () => {
this.onChange({
type: 'group',
op: 'and',
conditions: [this.filterGroup.value],
});
},
}),
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'hover-style' }
: undefined;
},
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(
i + 1,
0,
JSON.parse(JSON.stringify(conditions[i]))
);
this.onChange({
...this.filterGroup.value,
conditions: conditions,
});
},
}),
],
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
class: { 'delete-item': true },
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'delete-style' }
: undefined;
},
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(i, 1);
this.onChange({
...this.filterGroup.value,
conditions,
});
},
}),
],
}),
]);
}
override render() {
const data = this.filterGroup.value;
return html`
<div class="filter-group-container">
${repeat(data.conditions, (filter, i) => {
const clickOps = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
this._clickConditionOps(e.target as HTMLElement, i);
};
let op: TemplateResult;
if (i === 0) {
op = html` <div class="filter-group-op">Where</div>`;
} else {
op = html`
<div
class="filter-group-op filter-group-op-clickable"
@click="${this._selectOp}"
>
${this.opMap[data.op]}
</div>
`;
}
const classList = classMap({
'filter-root-item': true,
'filter-exactly-hover-container': true,
'dv-pd-4 dv-round-4': true,
[this.containerClass?.class ?? '']:
this.containerClass?.index === i,
});
const groupClassList = classMap({
[`filter-group-bg-${this.depth}`]: filter.type !== 'filter',
});
return html` <div class="${classList}" @contextmenu="${clickOps}">
${op}
<div
style="flex:1;display:flex;align-items:start;justify-content: space-between;gap: 8px;"
>
${filter.type === 'filter'
? html`
<filter-condition-view
.vars="${this.vars}"
.index="${i}"
.value="${this.conditions$}"
.onChange="${this.setConditions}"
></filter-condition-view>
`
: html`
<filter-group-view
class="${groupClassList}"
style="width: 100%;"
.depth="${this.depth + 1}"
.onChange="${(v: Filter) => this._setFilter(i, v)}"
.vars="${this.vars}"
.filterGroup="${computed(() => filter)}"
></filter-group-view>
`}
<div class="filter-group-item-ops" @click="${clickOps}">
${MoreHorizontalIcon()}
</div>
</div>
</div>`;
})}
</div>
<div class="filter-group-button" @click="${this._addNew}">
${PlusIcon()} Add ${this.isMaxDepth ? nothing : ArrowDownSmallIcon()}
</div>
`;
}
@state()
accessor containerClass:
| {
index: number;
class: string;
}
| undefined = undefined;
@property({ attribute: false })
accessor depth = 1;
@property({ attribute: false })
accessor onChange!: (filter: FilterGroup) => void;
@property({ attribute: false })
accessor vars!: ReadonlySignal<Variable[]>;
}
declare global {
interface HTMLElementTagNameMap {
'filter-group-view': FilterGroupView;
}
}
export const getDepth = (filter: Filter): number => {
if (filter.type === 'filter') {
return 1;
}
return Math.max(...filter.conditions.map(getDepth)) + 1;
};
export const popFilterGroup = (
target: PopupTarget,
props: {
vars: ReadonlySignal<Variable[]>;
value$: ReadonlySignal<FilterGroup>;
onChange: (value?: FilterGroup) => void;
onBack?: () => void;
}
) => {
popMenu(target, {
options: {
title: {
text: 'Filter group',
onBack: props.onBack,
},
items: [
menu.group({
items: [
() => {
return html` <filter-group-view
.vars="${props.vars}"
.filterGroup="${props.value$}"
.onChange="${props.onChange}"
></filter-group-view>`;
},
],
}),
menu.group({
items: [
menu.action({
name: 'Delete',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
props.onChange();
},
}),
],
}),
],
},
});
};
@@ -0,0 +1,20 @@
import { IS_MOBILE } from '@blocksuite/global/env';
import { html } from 'lit';
import { filterTraitKey } from '../../../core/filter/trait.js';
import type { DataViewWidgetProps } from '../../../core/widget/types.js';
export const renderFilterBar = (props: DataViewWidgetProps) => {
const filterTrait = props.dataViewInstance.view.traitGet(filterTraitKey);
if (!filterTrait) {
return;
}
if (!IS_MOBILE && !filterTrait.hasFilter$.value) {
return;
}
return html` <filter-bar
.vars="${filterTrait.view.vars$}"
.filterGroup="${filterTrait.filter$}"
.onChange="${filterTrait.filterSet}"
></filter-bar>`;
};
@@ -0,0 +1,215 @@
import {
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import {
ArrowDownSmallIcon,
FilterIcon,
PlusIcon,
} from '@blocksuite/icons/lit';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import type { Variable } from '../../../core/expression/types.js';
import type { Filter, FilterGroup } from '../../../core/filter/types.js';
import { popCreateFilter } from '../../../core/index.js';
import { popFilterGroup } from './group-panel-view.js';
export class FilterBar extends SignalWatcher(ShadowlessElement) {
static override styles = css`
filter-bar {
display: flex;
gap: 8px;
overflow-x: scroll;
margin-bottom: -10px;
padding-bottom: 2px;
align-items: center;
}
.filter-group-tag {
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 20px;
display: flex;
align-items: center;
padding: 4px;
background-color: var(--affine-white);
}
.filter-bar-add-filter {
white-space: nowrap;
color: var(--affine-text-secondary-color);
padding: 4px 8px;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
filter-bar::-webkit-scrollbar {
-webkit-appearance: none;
display: block;
}
filter-bar::-webkit-scrollbar-thumb {
border-radius: 2px;
background-color: transparent;
}
filter-bar::-webkit-scrollbar:horizontal {
height: 8px;
}
filter-bar:hover::-webkit-scrollbar-thumb {
border-radius: 16px;
background-color: var(--affine-black-30);
}
filter-bar:hover::-webkit-scrollbar-track {
//background-color: var(--affine-hover-color);
}
`;
private _setFilter = (index: number, filter: Filter) => {
this.onChange({
...this.filterGroup.value,
conditions: this.filterGroup.value.conditions.map((v, i) =>
index === i ? filter : v
),
});
};
private addFilter = (e: MouseEvent) => {
const element = popupTargetFromElement(e.target as HTMLElement);
popCreateFilter(element, {
vars: this.vars,
onSelect: filter => {
const index = this.filterGroup.value.conditions.length;
this.onChange({
...this.filterGroup.value,
conditions: [...this.filterGroup.value.conditions, filter],
});
requestAnimationFrame(() => {
this.expandGroup(element, index);
});
},
});
};
private expandGroup = (position: PopupTarget, i: number) => {
if (this.filterGroup.value.conditions[i]?.type !== 'group') {
return;
}
popFilterGroup(position, {
vars: this.vars,
value$: computed(() => {
return this.filterGroup.value.conditions[i] as FilterGroup;
}),
onChange: filter => {
if (filter) {
this._setFilter(i, filter);
} else {
this.deleteFilter(i);
}
},
});
};
@property({ attribute: false })
accessor filterGroup!: ReadonlySignal<FilterGroup>;
conditions$ = computed(() => {
return this.filterGroup.value.conditions;
});
renderAddFilter = () => {
return html` <div
style="height: 100%;"
class="filter-bar-add-filter dv-icon-16 dv-round-4 dv-hover"
@click="${this.addFilter}"
>
${PlusIcon()} Add filter
</div>`;
};
setConditions = (conditions: Filter[]) => {
this.onChange({
...this.filterGroup.value,
conditions: conditions,
});
};
updateMoreFilterPanel?: () => void;
private deleteFilter(i: number) {
this.onChange({
...this.filterGroup.value,
conditions: this.filterGroup.value.conditions.filter(
(_, index) => index !== i
),
});
}
override render() {
return html` ${this.renderFilters()} ${this.renderAddFilter()} `;
}
renderCondition(i: number) {
const condition = this.conditions$.value[i];
if (!condition) {
return;
}
if (condition.type === 'filter') {
return html` <filter-condition-view
.vars="${this.vars}"
.index="${i}"
.value="${this.conditions$}"
.onChange="${this.setConditions}"
></filter-condition-view>`;
}
const expandGroup = (e: MouseEvent) => {
this.expandGroup(
popupTargetFromElement(e.currentTarget as HTMLElement),
i
);
};
const length = condition.conditions.length;
const text = length > 1 ? `${length} rules` : `${length} rule`;
return html` <data-view-component-button
hoverType="border"
.icon="${FilterIcon()}"
@click="${expandGroup}"
.text="${html`${text}`}"
.postfix="${ArrowDownSmallIcon()}"
></data-view-component-button>`;
}
renderFilters() {
return this.filterGroup.value.conditions.map((_, i) =>
this.renderCondition(i)
);
}
override updated() {
this.updateMoreFilterPanel?.();
}
@property({ attribute: false })
accessor onChange!: (filter: FilterGroup) => void;
@property({ attribute: false })
accessor vars!: ReadonlySignal<Variable[]>;
}
declare global {
interface HTMLElementTagNameMap {
'filter-bar': FilterBar;
}
}
@@ -0,0 +1,426 @@
import {
menu,
popFilterableSimpleMenu,
popMenu,
type PopupTarget,
popupTargetFromElement,
subMenuMiddleware,
} from '@blocksuite/affine-components/context-menu';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher } from '@blocksuite/global/utils';
import {
ArrowDownSmallIcon,
ConvertIcon,
DeleteIcon,
DuplicateIcon,
FilterIcon,
MoreHorizontalIcon,
PlusIcon,
} from '@blocksuite/icons/lit';
import { computed, type ReadonlySignal } from '@preact/signals-core';
import { css, html } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import type { Variable } from '../../../core/expression/types.js';
import type { FilterTrait } from '../../../core/filter/trait.js';
import type { Filter, FilterGroup } from '../../../core/filter/types.js';
import { popCreateFilter } from '../../../core/index.js';
import {
type FilterGroupView,
getDepth,
popFilterGroup,
} from './group-panel-view.js';
export class FilterRootView extends SignalWatcher(ShadowlessElement) {
static override styles = css`
.filter-root-title {
padding: 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
color: var(--affine-text-primary-color);
}
.filter-root-op {
width: 60px;
display: flex;
justify-content: end;
padding: 4px;
height: 34px;
align-items: center;
}
.filter-root-op-clickable {
border-radius: 4px;
cursor: pointer;
}
.filter-root-op-clickable:hover {
background-color: var(--affine-hover-color);
}
.filter-root-container {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 400px;
overflow: auto;
}
.filter-root-button {
margin: 4px 8px 8px;
padding: 8px 12px;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 22px;
border-radius: 4px;
cursor: pointer;
color: var(--affine-text-secondary-color);
}
.filter-root-button svg {
fill: var(--affine-text-secondary-color);
color: var(--affine-text-secondary-color);
width: 20px;
height: 20px;
}
.filter-root-button:hover {
background-color: var(--affine-hover-color);
color: var(--affine-text-primary-color);
}
.filter-root-button:hover svg {
fill: var(--affine-text-primary-color);
color: var(--affine-text-primary-color);
}
.filter-root-item {
padding: 4px 0;
display: flex;
align-items: start;
gap: 8px;
}
.filter-group-title {
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 22px;
display: flex;
align-items: center;
color: var(--affine-text-primary-color);
gap: 6px;
}
.filter-root-item-ops {
margin-top: 2px;
padding: 4px;
border-radius: 4px;
height: max-content;
display: flex;
cursor: pointer;
}
.filter-root-item-ops:hover {
background-color: var(--affine-hover-color);
}
.filter-root-item-ops svg {
fill: var(--affine-text-secondary-color);
color: var(--affine-text-secondary-color);
width: 18px;
height: 18px;
}
.filter-root-item-ops:hover svg {
fill: var(--affine-text-primary-color);
color: var(--affine-text-primary-color);
}
.filter-root-grabber {
cursor: grab;
width: 4px;
height: 12px;
background-color: var(--affine-placeholder-color);
border-radius: 1px;
}
.divider {
height: 1px;
background-color: var(--affine-divider-color);
flex-shrink: 0;
margin: 8px 0;
}
`;
private _setFilter = (index: number, filter: Filter) => {
this.onChange({
...this.filterGroup.value,
conditions: this.filterGroup.value.conditions.map((v, i) =>
index === i ? filter : v
),
});
};
private expandGroup = (position: PopupTarget, i: number) => {
if (this.filterGroup.value.conditions[i]?.type !== 'group') {
return;
}
popFilterGroup(position, {
vars: this.vars,
value$: computed(() => {
return this.filterGroup.value.conditions[i] as FilterGroup;
}),
onChange: filter => {
if (filter) {
this._setFilter(i, filter);
} else {
this.deleteFilter(i);
}
},
});
};
@property({ attribute: false })
accessor filterGroup!: ReadonlySignal<FilterGroup>;
conditions$ = computed(() => {
return this.filterGroup.value.conditions;
});
setConditions = (conditions: Filter[]) => {
this.onChange({
...this.filterGroup.value,
conditions: conditions,
});
};
private _clickConditionOps(target: HTMLElement, i: number) {
const filter = this.filterGroup.value.conditions[i];
popFilterableSimpleMenu(popupTargetFromElement(target), [
menu.action({
name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group',
prefix: ConvertIcon(),
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'hover-style' }
: undefined;
},
hide: () => getDepth(filter) > 3,
select: () => {
this.onChange({
type: 'group',
op: 'and',
conditions: [this.filterGroup.value],
});
},
}),
menu.action({
name: 'Duplicate',
prefix: DuplicateIcon(),
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'hover-style' }
: undefined;
},
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(
i + 1,
0,
JSON.parse(JSON.stringify(conditions[i]))
);
this.onChange({ ...this.filterGroup.value, conditions: conditions });
},
}),
menu.group({
name: '',
items: [
menu.action({
name: 'Delete',
prefix: DeleteIcon(),
class: { 'delete-item': true },
onHover: hover => {
this.containerClass = hover
? { index: i, class: 'delete-style' }
: undefined;
},
select: () => {
const conditions = [...this.filterGroup.value.conditions];
conditions.splice(i, 1);
this.onChange({
...this.filterGroup.value,
conditions,
});
},
}),
],
}),
]);
}
private deleteFilter(i: number) {
this.onChange({
...this.filterGroup.value,
conditions: this.filterGroup.value.conditions.filter(
(_, index) => index !== i
),
});
}
override render() {
const data = this.filterGroup.value;
return html`
<div class="filter-root-container">
${repeat(data.conditions, (_, i) => {
const clickOps = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
this._clickConditionOps(e.target as HTMLElement, i);
};
const ops = html`
<div class="filter-root-item-ops" @click="${clickOps}">
${MoreHorizontalIcon()}
</div>
`;
const content = html`
<div
style="display:flex;align-items:center;justify-content: space-between;width: 100%;gap:8px;"
>
<div style="display:flex;align-items:center;gap:6px;">
<div class="filter-root-grabber"></div>
${this.renderCondition(i)}
</div>
${ops}
</div>
`;
const classList = classMap({
'filter-root-item': true,
'filter-exactly-hover-container': true,
'dv-pd-4 dv-round-4': true,
[this.containerClass?.class ?? '']:
this.containerClass?.index === i,
});
return html` <div @contextmenu="${clickOps}" class="${classList}">
${content}
</div>`;
})}
</div>
`;
}
renderCondition(i: number) {
const condition = this.conditions$.value[i];
if (!condition) {
return;
}
if (condition.type === 'filter') {
return html` <filter-condition-view
.vars="${this.vars}"
.index="${i}"
.value="${this.conditions$}"
.onChange="${this.setConditions}"
></filter-condition-view>`;
}
const expandGroup = (e: MouseEvent) => {
this.expandGroup(
popupTargetFromElement(e.currentTarget as HTMLElement),
i
);
};
const length = condition.conditions.length;
const text = length > 1 ? `${length} rules` : `${length} rule`;
return html` <data-view-component-button
hoverType="border"
.icon="${FilterIcon()}"
@click="${expandGroup}"
.text="${html`${text}`}"
.postfix="${ArrowDownSmallIcon()}"
></data-view-component-button>`;
}
@state()
accessor containerClass:
| {
index: number;
class: string;
}
| undefined = undefined;
@property({ attribute: false })
accessor onBack!: () => void;
@property({ attribute: false })
accessor onChange!: (filter: FilterGroup) => void;
@property({ attribute: false })
accessor vars!: ReadonlySignal<Variable[]>;
}
declare global {
interface HTMLElementTagNameMap {
'filter-root-view': FilterGroupView;
}
}
export const popFilterRoot = (
target: PopupTarget,
props: {
filterTrait: FilterTrait;
onBack: () => void;
}
) => {
const filterTrait = props.filterTrait;
const view = filterTrait.view;
popMenu(target, {
options: {
title: {
text: 'Filters',
onBack: props.onBack,
},
items: [
menu.group({
items: [
() => {
return html` <filter-root-view
.onBack="${props.onBack}"
.vars="${view.vars$}"
.filterGroup="${filterTrait.filter$}"
.onChange="${filterTrait.filterSet}"
></filter-root-view>`;
},
],
}),
menu.group({
items: [
menu.action({
name: 'Add',
prefix: PlusIcon(),
select: ele => {
const value = filterTrait.filter$.value;
popCreateFilter(
popupTargetFromElement(ele),
{
vars: view.vars$,
onSelect: filter => {
filterTrait.filterSet({
...value,
conditions: [...value.conditions, filter],
});
},
},
{ middleware: subMenuMiddleware }
);
return false;
},
}),
],
}),
],
},
});
};
@@ -0,0 +1,44 @@
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { IS_MOBILE } from '@blocksuite/global/env';
import { html, nothing } from 'lit';
import {
type DataViewWidgetProps,
defineUniComponent,
} from '../../core/index.js';
import { ShowQuickSettingBarContextKey } from './context.js';
import { renderFilterBar } from './filter/index.js';
import { renderSortBar } from './sort/index.js';
export const widgetQuickSettingBar = defineUniComponent(
(props: DataViewWidgetProps) => {
const view = props.dataViewInstance.view;
const barList = [renderSortBar(props), renderFilterBar(props)].filter(
Boolean
);
if (!IS_MOBILE) {
if (!view.contextGet(ShowQuickSettingBarContextKey).value[view.id]) {
return html``;
}
if (!barList.length) {
return html``;
}
}
return html` <div
style="display: flex;margin-top: 8px;align-items: center;width: 100%;gap:8px"
>
${barList.map((bar, index) => {
return html`
${index !== 0
? html` <div
style="width: 1px;height:27px;background-color: ${unsafeCSSVarV2(
'layer/insideBorder/border'
)}"
></div>`
: nothing}
${bar}
`;
})}
</div>`;
}
);
@@ -0,0 +1,32 @@
import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu';
import { SortIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { sortTraitKey } from '../../../core/sort/manager.js';
import { createSortUtils } from '../../../core/sort/utils.js';
import type { DataViewWidgetProps } from '../../../core/widget/types.js';
import { popSortRoot } from './root-panel.js';
export const renderSortBar = (props: DataViewWidgetProps) => {
const sortTrait = props.dataViewInstance.view.traitGet(sortTraitKey);
if (!sortTrait) {
return;
}
const count = sortTrait.sortList$.value.length;
if (count === 0) {
return;
}
const text = count === 1 ? html`1 Sort` : html`${count} Sorts`;
const click = (event: MouseEvent) => {
popSortRoot(popupTargetFromElement(event.currentTarget as HTMLElement), {
sortUtils: createSortUtils(sortTrait, props.dataViewInstance.eventTrace),
});
};
return html` <data-view-component-button
class="data-view-sort-button"
.onClick="${click}"
hoverType="border"
.icon="${SortIcon()}"
.text="${text}"
></data-view-component-button>`;
};
@@ -0,0 +1,240 @@
import {
menu,
popMenu,
type PopupTarget,
popupTargetFromElement,
} from '@blocksuite/affine-components/context-menu';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import {
ArrowDownSmallIcon,
CloseIcon,
DeleteIcon,
PlusIcon,
} from '@blocksuite/icons/lit';
import { computed } from '@preact/signals-core';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import { renderUniLit } from '../../../core/index.js';
import { popCreateSort } from '../../../core/sort/add-sort.js';
import type { SortBy } from '../../../core/sort/types.js';
import type { SortUtils } from '../../../core/sort/utils.js';
import { dragHandler } from '../../../core/utils/wc-dnd/dnd-context.js';
import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js';
import {
createSortContext,
sortable,
} from '../../../core/utils/wc-dnd/sort/sort-context.js';
import { verticalListSortingStrategy } from '../../../core/utils/wc-dnd/sort/strategies/index.js';
export class SortRootView extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
.sort-root-container {
margin-bottom: 8px;
gap: 8px;
display: flex;
flex-direction: column;
}
.sort-item {
display: flex;
align-items: center;
}
`;
@property({ attribute: false })
accessor sortUtils!: SortUtils;
items$ = computed(() => {
return this.sortUtils.sortList$.value.map(v => v.ref.name);
});
sortContext = createSortContext({
activators: defaultActivators,
container: this,
onDragEnd: evt => {
const over = evt.over;
if (over) {
const list = this.sortUtils.sortList$.value;
this.sortUtils.move(
list.findIndex(v => v.ref.name === evt.active.id),
list.findIndex(v => v.ref.name === over.id)
);
}
},
modifiers: [
({ transform }) => {
return {
...transform,
x: 0,
};
},
],
items: this.items$,
strategy: verticalListSortingStrategy,
});
override render() {
const list = this.sortUtils.sortList$.value;
return html`
<div class="sort-root-container">
${repeat(list, (sort, index) => {
const id = sort.ref.name;
const variable = this.sortUtils.vars$.value.find(v => v.id === id);
let content;
const deleteRule = () => {
this.sortUtils.remove(index);
};
const changeRule = (rule: SortBy) => {
this.sortUtils.change(index, rule);
};
if (!variable) {
content = html`
<data-view-component-button
style="color: var(--affine-error-color);border-color: color: var(--affine-error-color)"
@click="${deleteRule}"
.text="${html`This rule is invalid, click to delete`}"
></data-view-component-button>
`;
} else {
const descName = sort.desc ? 'Descending' : 'Ascending';
const clickField = (event: MouseEvent) => {
popMenu(
popupTargetFromElement(event.currentTarget as HTMLElement),
{
options: {
items: this.sortUtils.vars$.value.map(v => {
return menu.action({
name: v.name,
prefix: renderUniLit(v.icon),
isSelected: v.id === id,
select: () => {
changeRule({
...sort,
ref: { type: 'ref', name: v.id },
});
},
});
}),
},
}
);
};
const clickOrder = (event: MouseEvent) => {
popMenu(
popupTargetFromElement(event.currentTarget as HTMLElement),
{
options: {
items: [false, true].map(desc => {
return menu.action({
name: desc ? 'Descending' : 'Ascending',
isSelected: desc === sort.desc,
select: () => {
changeRule({ ...sort, desc });
},
});
}),
},
}
);
};
content = html`
<data-view-component-button
style="margin-right: 6px;margin-left: 4px"
@click="${clickField}"
.icon="${renderUniLit(variable.icon)}"
.text="${variable.name}"
.postfix="${ArrowDownSmallIcon()}"
></data-view-component-button>
<data-view-component-button
@click="${clickOrder}"
.text="${html` <div style="padding: 0 4px">${descName}</div>`}"
.postfix="${ArrowDownSmallIcon()}"
></data-view-component-button>
`;
}
return keyed(
id,
html`
<div
${sortable(id)}
class='sort-item'
>
<div style='display: flex;align-items: center;flex:1;margin-right: 16px;'>
<div
${dragHandler(id)}
style='border-radius:2px;cursor:pointer;width: 4px;height: 12px;background-color: ${unsafeCSSVarV2(
'button/grabber/default'
)}'
></div>
${content}
</div>
<div
@click='${deleteRule}'
style='padding: 2px;display: flex;align-items: center;border-radius: 2px;color:${unsafeCSSVarV2('icon/primary')}'
class='dv-hover dv-rounded'>${CloseIcon({ width: '16px', height: '16px' })}
</div>
</div>
</div>
`
);
})}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'sort-root-view': SortRootView;
}
}
export const popSortRoot = (
target: PopupTarget,
props: {
sortUtils: SortUtils;
title?: {
text: string;
onBack?: () => void;
};
}
) => {
const sortUtils = props.sortUtils;
popMenu(target, {
options: {
title: props.title,
items: [
() => {
return html` <sort-root-view
.sortUtils="${sortUtils}"
></sort-root-view>`;
},
menu.action({
name: 'Add sort',
prefix: PlusIcon(),
select: ele => {
popCreateSort(popupTargetFromElement(ele), {
sortUtils: props.sortUtils,
});
return false;
},
}),
menu.action({
name: 'Delete',
class: { 'delete-item': true },
prefix: DeleteIcon(),
select: () => {
props.sortUtils.removeAll();
},
}),
],
},
});
};